Files
rustfs/rustfs/src/app/object/extract.rs
T
cxymds df30dff1a7 feat(storage): complete Snowball and decommission follow-ups (#7039)
* feat(storage): complete Snowball and capacity follow-ups

* fix(ecstore): clarify V3 capacity gate guidance

* fix(ecstore): keep target contention retryable

* fix(ecstore): preserve typed target lock errors

* fix(ecstore): harden decommission recovery

* fix(ecstore): close decommission recovery races

* fix(ecstore): fail closed on multipart cleanup gaps

* fix(ecstore): model capacity mutation parameters

* fix(ecstore): settle checkpoint capacity retries
2026-09-03 03:58:59 +00:00

4793 lines
195 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Snowball auto-extract (PutObject x-amz-meta-snowball-auto-extract) path.
use super::*;
use crate::app::storage_api::object_usecase::bucket::replication::ReplicateDecision;
#[cfg(test)]
use crate::app::storage_api::object_usecase::concurrency::SNOWBALL_MEMBER_COMMIT_LIMIT;
use crate::app::storage_api::object_usecase::concurrency::SNOWBALL_STAGING_BYTES_LIMIT;
use futures::stream::FuturesUnordered;
use std::collections::HashSet;
// One logical member can be preceded by local PAX, GNU long-name, and GNU
// long-link records. Count all four physical headers without rejecting that
// compatible extension combination.
const EXTRACT_ARCHIVE_PHYSICAL_ENTRY_MULTIPLIER: u64 = 4;
// Sparse maps are metadata, so bound them independently of object byte quotas.
const EXTRACT_ARCHIVE_MAX_SPARSE_ENTRIES: u64 = 4_096;
const EXTRACT_ARCHIVE_MAX_SPARSE_CONTINUATION_BLOCKS: u64 = 256;
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<R> {
#[pin]
inner: R,
md5: Md5,
expected_length: u64,
bytes_read: u64,
pending_final_byte: Option<u8>,
validating_eof: bool,
finished: bool,
state: Arc<Mutex<ExtractArchiveUploadState>>,
}
}
pin_project! {
struct ExtractArchiveDecoderReader<R> {
#[pin]
inner: R,
_permit: OwnedSemaphorePermit,
}
}
#[derive(Debug, Default)]
struct ExtractArchiveUploadState {
etag: Option<String>,
body_complete: bool,
}
fn resolve_extract_archive_format(key: &str, detected: CompressionFormat) -> CompressionFormat {
// Zlib has no unambiguous magic. Preserve the legacy zlib/zz suffix
// contract without letting other misleading suffixes override content
// detection.
if detected == CompressionFormat::Tar
&& Path::new(key)
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| CompressionFormat::from_extension(extension) == CompressionFormat::Zlib)
{
CompressionFormat::Zlib
} else {
detected
}
}
impl<R> ExtractArchiveEtagReader<R> {
fn new(inner: R, expected_length: u64, state: Arc<Mutex<ExtractArchiveUploadState>>) -> Self {
Self {
inner,
md5: Md5::new(),
expected_length,
bytes_read: 0,
pending_final_byte: None,
validating_eof: expected_length == 0,
finished: false,
state,
}
}
}
impl<R> ExtractArchiveDecoderReader<R> {
fn new(inner: R, permit: OwnedSemaphorePermit) -> Self {
Self { inner, _permit: permit }
}
}
impl<R> AsyncRead for ExtractArchiveDecoderReader<R>
where
R: AsyncRead,
{
fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
self.project().inner.poll_read(cx, buf)
}
}
fn extract_archive_incomplete_body(remaining: u64) -> std::io::Error {
let Ok(remaining) = i64::try_from(remaining) else {
return std::io::Error::new(std::io::ErrorKind::InvalidData, "archive remaining body length exceeds i64");
};
std::io::Error::new(std::io::ErrorKind::UnexpectedEof, rustfs_rio::IncompleteBody { remaining })
}
impl<R: AsyncRead> AsyncRead for ExtractArchiveEtagReader<R> {
fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
let mut this = self.project();
if buf.remaining() == 0 || *this.finished {
return Poll::Ready(Ok(()));
}
loop {
if *this.validating_eof {
let mut probe = [0u8; 1];
let mut probe_buf = ReadBuf::new(&mut probe);
match this.inner.as_mut().poll_read(cx, &mut probe_buf) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Err(err)) => return Poll::Ready(Err(err)),
Poll::Ready(Ok(())) if !probe_buf.filled().is_empty() => {
return Poll::Ready(Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"archive body exceeded expected Content-Length",
)));
}
Poll::Ready(Ok(())) => {
if let Ok(mut state) = this.state.lock()
&& !state.body_complete
{
state.etag =
Some(hex_simd::encode_to_string(this.md5.clone().finalize(), hex_simd::AsciiCase::Lower));
state.body_complete = true;
}
*this.validating_eof = false;
*this.finished = true;
if let Some(final_byte) = this.pending_final_byte.take() {
buf.put_slice(&[final_byte]);
}
return Poll::Ready(Ok(()));
}
}
}
let remaining = *this.expected_length - *this.bytes_read;
if remaining == 1 {
let mut final_byte = [0u8; 1];
let mut final_buf = ReadBuf::new(&mut final_byte);
match this.inner.as_mut().poll_read(cx, &mut final_buf) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Err(err)) => return Poll::Ready(Err(err)),
Poll::Ready(Ok(())) if final_buf.filled().is_empty() => {
return Poll::Ready(Err(extract_archive_incomplete_body(*this.expected_length - *this.bytes_read)));
}
Poll::Ready(Ok(())) => {
this.md5.update(final_buf.filled());
*this.bytes_read = match this.bytes_read.checked_add(1) {
Some(bytes_read) => bytes_read,
None => return Poll::Ready(Err(std::io::Error::other("archive read length overflow"))),
};
*this.pending_final_byte = Some(final_buf.filled()[0]);
*this.validating_eof = true;
continue;
}
}
}
let max_read = usize::try_from(remaining - 1).unwrap_or(usize::MAX).min(buf.remaining());
let read_len = {
let target = buf.initialize_unfilled_to(max_read);
let mut limited_buf = ReadBuf::new(target);
match this.inner.as_mut().poll_read(cx, &mut limited_buf) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Err(err)) => return Poll::Ready(Err(err)),
Poll::Ready(Ok(())) if limited_buf.filled().is_empty() => {
return Poll::Ready(Err(extract_archive_incomplete_body(*this.expected_length - *this.bytes_read)));
}
Poll::Ready(Ok(())) => {
this.md5.update(limited_buf.filled());
limited_buf.filled().len()
}
}
};
let read = match u64::try_from(read_len) {
Ok(read) => read,
Err(_) => return Poll::Ready(Err(std::io::Error::other("archive read length exceeds u64"))),
};
*this.bytes_read = match this.bytes_read.checked_add(read) {
Some(bytes_read) => bytes_read,
None => return Poll::Ready(Err(std::io::Error::other("archive read length overflow"))),
};
buf.advance(read_len);
return Poll::Ready(Ok(()));
}
}
}
pin_project! {
struct ExtractMemberReadTracker {
#[pin]
inner: HashReader,
failed: Arc<AtomicBool>,
}
}
impl AsyncRead for ExtractMemberReadTracker {
fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
let this = self.project();
match this.inner.poll_read(cx, buf) {
Poll::Ready(Err(err)) => {
this.failed.store(true, Ordering::Release);
Poll::Ready(Err(err))
}
other => other,
}
}
}
impl rustfs_rio::EtagResolvable for ExtractMemberReadTracker {
fn try_resolve_etag(&mut self) -> Option<String> {
rustfs_rio::EtagResolvable::try_resolve_etag(&mut self.inner)
}
}
impl rustfs_rio::HashReaderDetector for ExtractMemberReadTracker {}
impl rustfs_rio::TryGetIndex for ExtractMemberReadTracker {
fn try_get_index(&self) -> Option<&rustfs_rio::Index> {
rustfs_rio::TryGetIndex::try_get_index(&self.inner)
}
}
fn track_extract_member_read_errors(reader: HashReader) -> std::io::Result<(HashReader, Arc<AtomicBool>)> {
let size = reader.size();
let actual_size = reader.actual_size();
let failed = Arc::new(AtomicBool::new(false));
let tracker = ExtractMemberReadTracker {
inner: reader,
failed: failed.clone(),
};
let mut tracked = HashReader::from_reader(tracker, HashReader::SIZE_PRESERVE_LAYER, actual_size, None, None, false)?;
tracked.update_params(size, actual_size, None);
Ok((tracked, failed))
}
fn classify_extract_member_write_error(error: S3Error, member_read_failed: &AtomicBool) -> ExtractCommitError {
if member_read_failed.load(Ordering::Acquire) {
ExtractCommitError::Fatal(error)
} else {
ExtractCommitError::StorageWrite(error)
}
}
pin_project! {
struct ExtractDecodedLimitReader<R> {
#[pin]
inner: R,
remaining: u64,
}
}
impl<R> ExtractDecodedLimitReader<R> {
fn new(inner: R, limit: u64) -> Self {
Self { inner, remaining: limit }
}
}
impl<R: AsyncRead> AsyncRead for ExtractDecodedLimitReader<R> {
fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
if buf.remaining() == 0 {
return Poll::Ready(Ok(()));
}
let mut this = self.project();
let allowed = this.remaining.saturating_add(1);
let max_read = usize::try_from(allowed).unwrap_or(usize::MAX).min(buf.remaining());
let read_len = {
let unfilled = buf.initialize_unfilled_to(max_read);
let mut limited = ReadBuf::new(unfilled);
match this.inner.as_mut().poll_read(cx, &mut limited) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Err(err)) => return Poll::Ready(Err(err)),
Poll::Ready(Ok(())) => limited.filled().len(),
}
};
let read = u64::try_from(read_len).unwrap_or(u64::MAX);
if read > *this.remaining {
return Poll::Ready(Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"archive decoded size exceeds limit",
)));
}
*this.remaining -= read;
buf.advance(read_len);
Poll::Ready(Ok(()))
}
}
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_STORED_TRANSPORT_KEYS_LOWER: &[&str] = &[
"snowball-auto-extract",
"snowball-prefix",
"snowball-ignore-dirs",
"snowball-ignore-errors",
"minio-snowball-prefix",
"minio-snowball-ignore-dirs",
"minio-snowball-ignore-errors",
"rustfs-snowball-prefix",
"rustfs-snowball-ignore-dirs",
"rustfs-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,
];
const EXTRACT_MAX_EFFECTIVE_PAX_HEADER_BYTES: usize = 8 * 1024;
const EXTRACT_MAX_EFFECTIVE_PAX_USER_METADATA_BYTES: usize = 2 * 1024;
const EXTRACT_MAX_EFFECTIVE_PAX_FIELDS: usize = 4096;
const EXTRACT_MAX_EXPANDED_PAX_METADATA_BYTES: u64 = 128 * 1024 * 1024;
const EXTRACT_SMALL_MEMBER_MAX_BYTES: usize = 128 * 1024;
const EXTRACT_DEFAULT_MAX_INFLIGHT: usize = 16;
const EXTRACT_BATCH_MAX_MEMBERS: usize = 16;
const EXTRACT_BATCH_MAX_STAGING_BYTES: usize = 3 * 1024 * 1024;
const EXTRACT_MEMBER_CONTEXT_OVERHEAD_BYTES: usize = 512;
const EXTRACT_METADATA_ENTRY_OVERHEAD_BYTES: usize = 64;
const ENV_RUSTFS_SNOWBALL_EXTRACT_MAX_INFLIGHT: &str = "RUSTFS_SNOWBALL_EXTRACT_MAX_INFLIGHT";
const TAR_TYPEFLAG_OFFSET: usize = 156;
fn put_object_extract_max_inflight() -> usize {
static MAX_INFLIGHT: OnceLock<usize> = OnceLock::new();
*MAX_INFLIGHT.get_or_init(|| {
normalize_put_object_extract_max_inflight(rustfs_utils::get_env_usize(
ENV_RUSTFS_SNOWBALL_EXTRACT_MAX_INFLIGHT,
EXTRACT_DEFAULT_MAX_INFLIGHT,
))
})
}
fn normalize_put_object_extract_max_inflight(value: usize) -> usize {
value.clamp(1, EXTRACT_BATCH_MAX_MEMBERS)
}
fn select_put_object_extract_max_inflight(configured: usize, ignore_errors: bool, quota_enabled: bool) -> usize {
if ignore_errors && !quota_enabled { configured } else { 1 }
}
struct ExtractPutRequestGuard {
inner: PutObjectGuard,
succeeded: bool,
}
impl ExtractPutRequestGuard {
fn new() -> Self {
Self {
inner: PutObjectGuard::new(),
succeeded: false,
}
}
fn finish_ok(&mut self) {
self.succeeded = true;
}
}
impl Drop for ExtractPutRequestGuard {
fn drop(&mut self) {
if self.succeeded {
self.inner.finish_ok();
} else {
self.inner.finish_err();
}
}
}
struct ExtractStagedBody {
reader: Option<super::put::PooledBufferReader>,
}
impl ExtractStagedBody {
fn empty() -> Self {
Self { reader: None }
}
}
impl AsyncRead for ExtractStagedBody {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, target: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
let Some(reader) = self.reader.as_mut() else {
return Poll::Ready(Ok(()));
};
Pin::new(reader).poll_read(cx, target)
}
}
async fn stage_extract_member_body<R>(body: &mut R, size: usize) -> S3Result<ExtractStagedBody>
where
R: AsyncRead + Unpin,
{
if size == 0 {
return Ok(ExtractStagedBody::empty());
}
let pool = get_concurrency_manager().bytes_pool();
let mut buffer = pool.acquire_buffer(size).await;
super::put::read_small_put_body_into(body, &mut *buffer, size).await?;
Ok(ExtractStagedBody {
reader: Some(super::put::PooledBufferReader::new(buffer, size)),
})
}
fn checked_extract_hash_map_allocation<K, V>(map: &HashMap<K, V>) -> Option<usize> {
// HashMap does not expose its raw bucket allocation. Twice the advertised
// element capacity conservatively covers buckets plus control bytes while
// keeping the accounting independent of the current hashbrown layout.
map.capacity()
.checked_mul(2)?
.checked_mul(std::mem::size_of::<(K, V)>().checked_add(1)?)
}
fn checked_extract_member_staging_weight(
path: &str,
size: usize,
opts: &ObjectOptions,
replication: &ReplicateDecision,
) -> S3Result<usize> {
let body_reservation = if size == 0 { 0 } else { EXTRACT_SMALL_MEMBER_MAX_BYTES };
// The authorization request is dropped before staging. Account the two
// retained key copies plus all dynamic maps frozen into ObjectOptions;
// the fixed allowance covers the remaining options/write-plan handles.
let mut total = body_reservation
.checked_add(
path.len()
.checked_mul(2)
.ok_or_else(|| object_s3_error(S3ErrorCode::InvalidArgument, "Snowball prepared member path size overflowed"))?,
)
.and_then(|bytes| bytes.checked_add(EXTRACT_MEMBER_CONTEXT_OVERHEAD_BYTES))
.ok_or_else(|| object_s3_error(S3ErrorCode::InvalidArgument, "Snowball prepared member size overflowed"))?;
for metadata in std::iter::once(&opts.user_defined).chain(opts.eval_metadata.iter()) {
for (name, value) in metadata {
total = total
.checked_add(name.len())
.and_then(|bytes| bytes.checked_add(value.len()))
.and_then(|bytes| bytes.checked_add(EXTRACT_METADATA_ENTRY_OVERHEAD_BYTES))
.ok_or_else(|| object_s3_error(S3ErrorCode::InvalidArgument, "Snowball prepared member size overflowed"))?;
}
}
total = total
.checked_add(
checked_extract_hash_map_allocation(&replication.targets_map)
.ok_or_else(|| object_s3_error(S3ErrorCode::InvalidArgument, "Snowball replication decision size overflowed"))?,
)
.ok_or_else(|| object_s3_error(S3ErrorCode::InvalidArgument, "Snowball prepared member size overflowed"))?;
for (target_name, target) in &replication.targets_map {
total = total
.checked_add(target_name.capacity())
.and_then(|bytes| bytes.checked_add(target.arn.capacity()))
.and_then(|bytes| bytes.checked_add(target.id.capacity()))
.ok_or_else(|| object_s3_error(S3ErrorCode::InvalidArgument, "Snowball replication decision size overflowed"))?;
}
Ok(total)
}
fn try_acquire_extract_staging_permit(manager: &ConcurrencyManager, staging_weight: usize) -> S3Result<OwnedSemaphorePermit> {
if staging_weight > SNOWBALL_STAGING_BYTES_LIMIT {
return Err(object_s3_error(
S3ErrorCode::SlowDown,
"Snowball member retained state exceeds the global staging budget",
));
}
let permits = u32::try_from(staging_weight).map_err(|_| {
object_s3_error(S3ErrorCode::SlowDown, "Snowball member retained state exceeds the global staging budget")
})?;
manager.try_acquire_snowball_staging_bytes(permits).ok_or_else(|| {
object_s3_error(
S3ErrorCode::SlowDown,
"Snowball staging budget is exhausted, please reduce your request rate",
)
})
}
async fn build_admitted_extract_archive_decoder<R>(
manager: &ConcurrencyManager,
key: &str,
tracked_archive: R,
) -> S3Result<ExtractArchiveDecoderReader<Box<dyn AsyncRead + Send + Unpin>>>
where
R: AsyncRead + Send + Unpin + 'static,
{
// Admission precedes stream inspection so saturation cannot allocate or
// drive another codec. The returned reader owns the permit through archive
// finalization and transport-length validation.
let permit = manager.try_acquire_snowball_archive_decoder().ok_or_else(|| {
object_s3_error(
S3ErrorCode::SlowDown,
"Snowball archive decoder limit reached, please reduce your request rate",
)
})?;
let (detected_archive_format, sniffed_archive) =
CompressionFormat::sniff(tracked_archive).await.map_err(|err| match err {
ZipError::InspectStream(source) => map_extract_archive_error(source),
_ => s3_error!(InvalidArgument, "Failed to detect archive compression"),
})?;
let archive_format = resolve_extract_archive_format(key, detected_archive_format);
let decoder = archive_format.get_decoder(sniffed_archive).map_err(|e| {
error!(error = ?e, "Archive decoder creation failed");
s3_error!(InvalidArgument, "get_decoder err")
})?;
Ok(ExtractArchiveDecoderReader::new(decoder, permit))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ExtractBatchAction {
Stage,
FlushThenStage,
SerialBarrier,
}
#[derive(Debug, Default)]
struct ExtractBatchState {
keys: HashSet<String>,
staging_bytes: usize,
}
impl ExtractBatchState {
fn action(
&self,
key: &str,
member_size: usize,
staging_weight: usize,
max_inflight: usize,
durable_quota: bool,
) -> ExtractBatchAction {
if durable_quota
|| max_inflight <= 1
|| member_size > EXTRACT_SMALL_MEMBER_MAX_BYTES
|| staging_weight > EXTRACT_BATCH_MAX_STAGING_BYTES
{
return ExtractBatchAction::SerialBarrier;
}
if self.keys.contains(key)
|| self.keys.len() >= max_inflight
|| self
.staging_bytes
.checked_add(staging_weight)
.is_none_or(|bytes| bytes > EXTRACT_BATCH_MAX_STAGING_BYTES)
{
ExtractBatchAction::FlushThenStage
} else {
ExtractBatchAction::Stage
}
}
fn record(&mut self, key: &str, staging_weight: usize) {
debug_assert!(!self.keys.contains(key));
self.keys.insert(key.to_string());
self.staging_bytes = self.staging_bytes.saturating_add(staging_weight);
}
fn clear(&mut self) {
self.keys.clear();
self.staging_bytes = 0;
}
}
struct ExtractPreparedMember<R = ExtractStagedBody> {
archive_seq: usize,
key: String,
size: i64,
actual_size: i64,
body: R,
write_plan: WritePlan,
opts: ObjectOptions,
replication: ReplicateDecision,
staging_permit: OwnedSemaphorePermit,
member_permit: OwnedSemaphorePermit,
}
struct ExtractCommitContext {
store: Arc<ECStore>,
cache_adapter: Arc<ObjectDataCacheAdapter>,
notify: Arc<dyn crate::runtime_sources::NotifyInterface>,
bucket: String,
quota_enabled: bool,
request_context: request_context::RequestContext,
req_params: hashbrown::HashMap<String, String>,
host: String,
port: u16,
user_agent: String,
wrote_any_entry: AtomicBool,
}
struct ExtractCommitSuccess {
event: rustfs_notify::EventArgs,
post_commit_error: Option<S3Error>,
}
enum ExtractCommitError {
StorageWrite(S3Error),
Fatal(S3Error),
}
impl From<ApiError> for ExtractCommitError {
fn from(error: ApiError) -> Self {
Self::Fatal(error.into())
}
}
impl ExtractCommitError {
fn into_unignored(self, ignore_errors: bool) -> Option<S3Error> {
match self {
Self::StorageWrite(error) if ignore_errors => {
warn!(error = %error, "Archive object write skipped due to ignore-errors");
None
}
Self::StorageWrite(error) | Self::Fatal(error) => Some(error),
}
}
}
struct ExtractCommitOutcome {
archive_seq: usize,
event: Option<rustfs_notify::EventArgs>,
error: Option<ExtractCommitError>,
}
async fn run_extract_owned_task<T, F>(task: F) -> S3Result<T>
where
T: Send + 'static,
F: std::future::Future<Output = T> + Send + 'static,
{
spawn_traced_join(task)
.await
.map_err(|error| S3Error::with_message(S3ErrorCode::InternalError, format!("Snowball commit owner task failed: {error}")))
}
async fn complete_extract_member_post_commit(
context: Arc<ExtractCommitContext>,
key: String,
opts: ObjectOptions,
replication: ReplicateDecision,
obj_info: ObjectInfo,
backfilled_old_current_size: Option<OldCurrentSize>,
) -> ExtractCommitSuccess {
let extract_versioned = opts.versioned;
let post_commit_error = match quota_accounting_object_size(&obj_info, context.quota_enabled) {
Ok(committed_size) => {
match previous_current_size_from_backfill(backfilled_old_current_size) {
Some(previous_current_size) => {
if extract_versioned {
record_bucket_object_version_write_memory(&context.bucket, previous_current_size, committed_size).await;
} else {
record_bucket_object_write_memory(&context.bucket, previous_current_size, committed_size).await;
}
}
None => {
record_bucket_object_write_unknown_previous_memory(&context.bucket, committed_size, extract_versioned).await;
}
}
None
}
Err(err) => Some(err),
};
let _ = invalidate_object_data_cache_after_put_success(&context.cache_adapter, &context.bucket, &key).await;
if replication.replicate_any() {
schedule_object_replication(obj_info.clone(), context.store.clone(), replication).await;
}
let e_tag = obj_info.etag.clone().map(|etag| to_s3s_etag(&etag));
let output = PutObjectOutput {
e_tag,
..Default::default()
};
let actual_version_id = extract_notification_version_id(obj_info.version_id, opts.versioned, opts.version_suspended);
let mut event_object = convert_ecstore_object_info(obj_info);
event_object.version_id = (!actual_version_id.is_empty()).then_some(actual_version_id.clone());
let event = rustfs_notify::EventArgs {
event_name: put_event_name_for_post_object(false),
bucket_name: context.bucket.clone(),
object: event_object,
req_params: context.req_params.clone(),
resp_elements: build_event_resp_elements(&S3Response::new(output), &context.request_context.request_id),
version_id: actual_version_id,
host: context.host.clone(),
port: context.port,
user_agent: context.user_agent.clone(),
};
ExtractCommitSuccess {
event,
post_commit_error,
}
}
async fn commit_extract_member_inner<R>(
context: Arc<ExtractCommitContext>,
member: ExtractPreparedMember<R>,
foreground_permit: Option<OwnedSemaphorePermit>,
) -> Result<ExtractCommitSuccess, ExtractCommitError>
where
R: AsyncRead + Send + Sync + Unpin + 'static,
{
let ExtractPreparedMember {
archive_seq: _,
key,
size,
actual_size,
body,
write_plan,
opts,
replication,
staging_permit,
member_permit,
} = member;
let hrd = HashReader::from_stream(body, size, actual_size, None, None, false).map_err(ApiError::from)?;
let hrd = write_plan.apply(hrd, actual_size).map_err(ApiError::from)?;
let (hrd, member_read_failed) = track_extract_member_read_errors(hrd).map_err(ApiError::from)?;
let mut reader = PutObjReader::new(hrd);
let _ = invalidate_object_data_cache_before_mutation(&context.cache_adapter, &context.bucket, &key).await;
let (obj_info, backfilled_old_current_size) = match context
.store
.put_object_with_old_current_size(&context.bucket, &key, &mut reader, &opts)
.await
{
Ok(result) => result,
Err(error) => {
let error: S3Error = ApiError::from(error).into();
return Err(classify_extract_member_write_error(error, &member_read_failed));
}
};
drop(reader);
drop(staging_permit);
drop(foreground_permit);
// The independently owned commit publishes the authoritative mutation to
// the scanner before its post-store awaits, then retains the lifecycle slot
// through quota, cache, replication, and event construction.
if !context.wrote_any_entry.swap(true, Ordering::AcqRel) {
rustfs_scanner::record_dirty_usage_bucket(&context.bucket);
}
let success =
complete_extract_member_post_commit(context, key, opts, replication, obj_info, backfilled_old_current_size).await;
drop(member_permit);
Ok(success)
}
async fn commit_extract_member<R>(context: Arc<ExtractCommitContext>, member: ExtractPreparedMember<R>) -> ExtractCommitOutcome
where
R: AsyncRead + Send + Sync + Unpin + 'static,
{
let archive_seq = member.archive_seq;
let manager = get_concurrency_manager();
let foreground_permit = match manager.admit_snowball_foreground_write(member.actual_size).await {
Ok(ForegroundWriteAdmission::Disabled) => None,
Ok(ForegroundWriteAdmission::Admitted(permit)) => Some(permit),
Ok(ForegroundWriteAdmission::Rejected) => {
return ExtractCommitOutcome {
archive_seq,
event: None,
error: Some(ExtractCommitError::StorageWrite(object_s3_error(
S3ErrorCode::SlowDown,
"foreground write concurrency limit reached, please reduce your request rate",
))),
};
}
Err(_) => {
return ExtractCommitOutcome {
archive_seq,
event: None,
error: Some(ExtractCommitError::Fatal(object_s3_error(
S3ErrorCode::InternalError,
"Snowball foreground write admission closed",
))),
};
}
};
match commit_extract_member_inner(context, member, foreground_permit).await {
Ok(success) => ExtractCommitOutcome {
archive_seq,
event: Some(success.event),
error: success.post_commit_error.map(ExtractCommitError::Fatal),
},
Err(error) => ExtractCommitOutcome {
archive_seq,
event: None,
error: Some(error),
},
}
}
fn ordered_extract_outcomes(
mut outcomes: Vec<ExtractCommitOutcome>,
ignore_errors: bool,
) -> (Vec<rustfs_notify::EventArgs>, Option<S3Error>) {
outcomes.sort_by_key(|outcome| outcome.archive_seq);
let mut events = Vec::with_capacity(outcomes.len());
let mut earliest_error = None;
for outcome in outcomes {
if let Some(event) = outcome.event {
events.push(event);
}
let error = outcome.error.and_then(|error| error.into_unignored(ignore_errors));
if earliest_error.is_none() {
earliest_error = error;
}
}
(events, earliest_error)
}
fn spawn_extract_notification_batch<F, Fut>(
request_context: Option<request_context::RequestContext>,
events: Vec<rustfs_notify::EventArgs>,
notify: F,
) where
F: Fn(rustfs_notify::EventArgs) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = ()> + Send + 'static,
{
spawn_background_with_context(request_context, async move {
for event in events {
notify(event).await;
}
});
}
fn finish_extract_outcomes(
outcomes: Vec<ExtractCommitOutcome>,
context: &Arc<ExtractCommitContext>,
ignore_errors: bool,
) -> S3Result<()> {
let (events, earliest_error) = ordered_extract_outcomes(outcomes, ignore_errors);
if !events.is_empty() {
let notify = context.notify.clone();
spawn_extract_notification_batch(Some(context.request_context.clone()), events, move |event| {
let notify = notify.clone();
async move {
notify.notify(event).await;
}
});
}
match earliest_error {
Some(error) => Err(error),
None => Ok(()),
}
}
async fn run_extract_outcomes_owner<F>(context: Arc<ExtractCommitContext>, ignore_errors: bool, commits: F) -> S3Result<()>
where
F: std::future::Future<Output = Vec<ExtractCommitOutcome>> + Send + 'static,
{
run_extract_owned_task(async move {
let outcomes = commits.await;
finish_extract_outcomes(outcomes, &context, ignore_errors)
})
.await?
}
async fn drain_extract_commits<F>(mut commits: FuturesUnordered<F>) -> Vec<ExtractCommitOutcome>
where
F: std::future::Future<Output = ExtractCommitOutcome>,
{
let mut outcomes = Vec::with_capacity(commits.len());
while let Some(outcome) = commits.next().await {
outcomes.push(outcome);
}
outcomes
}
async fn run_extract_commits<T, F, Fut>(members: impl IntoIterator<Item = T>, mut commit: F) -> Vec<ExtractCommitOutcome>
where
F: FnMut(T) -> Fut,
Fut: std::future::Future<Output = ExtractCommitOutcome>,
{
let commits = FuturesUnordered::new();
for member in members {
commits.push(commit(member));
}
drain_extract_commits(commits).await
}
async fn flush_extract_batch(
batch: &mut Vec<ExtractPreparedMember>,
batch_state: &mut ExtractBatchState,
context: &Arc<ExtractCommitContext>,
ignore_errors: bool,
) -> S3Result<()> {
if batch.is_empty() {
batch_state.clear();
return Ok(());
}
let members = batch.split_off(0);
batch_state.clear();
let commit_context = context.clone();
run_extract_outcomes_owner(context.clone(), ignore_errors, async move {
run_extract_commits(members, |member| commit_extract_member(commit_context.clone(), member)).await
})
.await
}
async fn acquire_extract_member_lifecycle_permit(
batch: &mut Vec<ExtractPreparedMember>,
batch_state: &mut ExtractBatchState,
context: &Arc<ExtractCommitContext>,
ignore_errors: bool,
) -> S3Result<OwnedSemaphorePermit> {
let manager = get_concurrency_manager();
if let Some(permit) = manager.try_acquire_snowball_member_commit() {
return Ok(permit);
}
if !batch.is_empty() {
flush_extract_batch(batch, batch_state, context, ignore_errors).await?;
if let Some(permit) = manager.try_acquire_snowball_member_commit() {
return Ok(permit);
}
}
manager
.acquire_snowball_member_commit()
.await
.map_err(|_| object_s3_error(S3ErrorCode::InternalError, "Snowball member lifecycle admission closed"))
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct PutObjectExtractOptions {
prefix: Option<String>,
ignore_dirs: bool,
ignore_errors: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ExtractEntryKind {
Directory,
Object,
Skip,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ExtractNormalizedVersion {
storage_id: String,
authorization_id: String,
requires_versioning: bool,
}
#[derive(Debug, Clone, Default)]
struct ExtractPaxOverrides {
headers: HeaderMap,
header_bytes: usize,
user_metadata_bytes: usize,
version_id: Option<String>,
}
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 classify_extract_entry_type(entry_type: tokio_tar::EntryType) -> ExtractEntryKind {
if entry_type.is_dir() {
ExtractEntryKind::Directory
} else if entry_type.is_file()
|| entry_type.is_character_special()
|| entry_type.is_block_special()
|| entry_type.is_fifo()
|| entry_type.is_gnu_sparse()
{
// `EntryType::Regular` covers both POSIX TypeReg and the legacy NUL
// TypeRegA marker. MinIO materializes zero-sized device and FIFO
// members as empty objects instead of recreating filesystem nodes.
ExtractEntryKind::Object
} else {
// Links, contiguous files, PAX extension carrier records, and unknown
// typeflags are archive control/filesystem semantics, not S3 objects.
// In particular, MinIO does not inherit global PAX metadata into later
// Snowball members.
ExtractEntryKind::Skip
}
}
fn is_header_only_special_entry(entry_type: tokio_tar::EntryType) -> bool {
entry_type.is_character_special() || entry_type.is_block_special() || entry_type.is_fifo()
}
fn validate_extract_special_entry_size(entry_type: tokio_tar::EntryType, size: u64) -> S3Result<()> {
if is_header_only_special_entry(entry_type) && size != 0 {
return Err(object_s3_error(
S3ErrorCode::InvalidArgument,
"Snowball special archive member declares a non-zero body",
));
}
Ok(())
}
fn is_legacy_null_directory(header: &tokio_tar::Header, path: &str) -> bool {
header.as_bytes()[TAR_TYPEFLAG_OFFSET] == b'\0' && path.as_bytes().ends_with(b"/")
}
fn is_snowball_transport_header(key: &str) -> bool {
if key.eq_ignore_ascii_case(AMZ_SNOWBALL_EXTRACT) || key.eq_ignore_ascii_case(AMZ_SNOWBALL_EXTRACT_COMPAT) {
return true;
}
if is_exact_snowball_meta_key(key, SNOWBALL_PREFIX_HEADER_KEYS)
|| is_exact_snowball_meta_key(key, SNOWBALL_IGNORE_DIRS_HEADER_KEYS)
|| is_exact_snowball_meta_key(key, SNOWBALL_IGNORE_ERRORS_HEADER_KEYS)
{
return true;
}
let key = key.to_ascii_lowercase();
if SNOWBALL_STORED_TRANSPORT_KEYS_LOWER.contains(&key.as_str()) {
return true;
}
key.starts_with(AMZ_META_PREFIX_LOWER)
&& (key.ends_with(SNOWBALL_PREFIX_SUFFIX_LOWER)
|| key.ends_with(SNOWBALL_IGNORE_DIRS_SUFFIX_LOWER)
|| key.ends_with(SNOWBALL_IGNORE_ERRORS_SUFFIX_LOWER))
}
fn snowball_member_headers(headers: &HeaderMap) -> HeaderMap {
let mut member_headers = headers.clone();
let transport_headers: Vec<_> = member_headers
.keys()
.filter(|name| is_snowball_transport_header(name.as_str()))
.cloned()
.collect();
for name in transport_headers {
member_headers.remove(name);
}
member_headers
}
fn normalize_extract_version_id(value: &str) -> S3Result<ExtractNormalizedVersion> {
let value = value.trim();
if value == "null" {
return Ok(ExtractNormalizedVersion {
storage_id: Uuid::nil().to_string(),
authorization_id: "null".to_string(),
requires_versioning: false,
});
}
let version_id = Uuid::parse_str(value).map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball PAX version ID"))?;
let version_id = version_id.to_string();
Ok(ExtractNormalizedVersion {
storage_id: version_id.clone(),
authorization_id: version_id,
requires_versioning: true,
})
}
fn apply_extract_version_id(value: &str, opts: &mut ObjectOptions) -> S3Result<String> {
let normalized = normalize_extract_version_id(value)?;
if normalized.requires_versioning && !opts.versioned {
return Err(object_s3_error(
S3ErrorCode::InvalidArgument,
"Snowball version ID requires bucket versioning to be enabled",
));
}
opts.version_id = Some(normalized.storage_id);
Ok(normalized.authorization_id)
}
fn extract_notification_version_id(version_id: Option<Uuid>, versioned: bool, version_suspended: bool) -> String {
match version_id {
Some(version_id) if !version_id.is_nil() => version_id.to_string(),
Some(_) if versioned || version_suspended => "null".to_string(),
_ => String::new(),
}
}
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<String> {
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<String> {
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<String> {
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<Option<String>> {
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<String> {
rustfs_utils::path::normalize_extract_entry_key(path, prefix, is_dir).map_err(|msg| s3_error!(InvalidArgument, "{msg}"))
}
fn map_extract_archive_error(err: std::io::Error) -> S3Error {
let message = err.to_string();
let api_error = ApiError::from(err);
if matches!(api_error.code, S3ErrorCode::BadDigest | S3ErrorCode::IncompleteBody) {
return api_error.into();
}
let mut archive_error = s3_error!(InvalidArgument, "Failed to process archive entry: {}", message);
archive_error.set_source(Box::new(api_error));
archive_error
}
fn map_extract_pax_text_error(err: impl std::fmt::Display) -> S3Error {
object_s3_error(S3ErrorCode::InvalidArgument, format!("Failed to decode archive PAX metadata: {}", err))
}
#[derive(Debug)]
enum ExtractEntryError {
Fatal(S3Error),
Recoverable(S3Error),
}
impl ExtractEntryError {
fn into_s3_error(self) -> S3Error {
match self {
Self::Fatal(err) | Self::Recoverable(err) => err,
}
}
fn ignore_or_return(self, ignore_errors: bool) -> S3Result<()> {
match self {
Self::Recoverable(_) if ignore_errors => Ok(()),
Self::Fatal(err) | Self::Recoverable(err) => Err(err),
}
}
#[cfg(test)]
fn is_recoverable(&self) -> bool {
matches!(self, Self::Recoverable(_))
}
}
fn extract_entry_quota_growth(kind: ExtractEntryKind, entry_size: u64) -> u64 {
match kind {
ExtractEntryKind::Object => entry_size,
ExtractEntryKind::Directory | ExtractEntryKind::Skip => 0,
}
}
fn extract_archive_entry_mod_time(header: &tokio_tar::Header) -> S3Result<Option<OffsetDateTime>> {
let modified_at_secs = header.mtime().map_err(map_extract_archive_error)?;
// GNU base-256 represents negative values with an all-sign-extended first
// byte. MinIO treats non-positive mtimes as unset, while the tar parser's
// unsigned API exposes `-1` as `u64::MAX`.
if modified_at_secs == 0 || header.as_old().mtime[0] == 0xff {
return Ok(None);
}
let modified_at_secs = i64::try_from(modified_at_secs)
.map_err(|_| object_s3_error(S3ErrorCode::InvalidArgument, "Archive entry modification time is out of range"))?;
OffsetDateTime::from_unix_timestamp(modified_at_secs)
.map(Some)
.map_err(|_| object_s3_error(S3ErrorCode::InvalidArgument, "Archive entry modification time is out of range"))
}
fn strict_extract_entry_path(path: &[u8]) -> Result<&str, ExtractEntryError> {
std::str::from_utf8(path)
.map_err(|_| ExtractEntryError::Recoverable(s3_error!(InvalidArgument, "Archive entry path must be valid UTF-8")))
}
fn is_empty_extract_entry_path(path: &str) -> bool {
path.is_empty() || path == "." || path == "./"
}
fn validate_extract_member_key(path: &str, limits: ArchiveLimits) -> Result<(), ExtractEntryError> {
validate_put_object_extract_entry_path(path, limits).map_err(ExtractEntryError::Recoverable)?;
validate_object_key(path, "PUT").map_err(ExtractEntryError::Recoverable)
}
fn record_extract_pax_metadata_bytes(
entry_size: &mut u64,
total_size: &mut u64,
key_size: usize,
value_size: usize,
limits: ArchiveLimits,
) -> Result<(), ExtractEntryError> {
let record_size = key_size
.checked_add(value_size)
.and_then(|size| u64::try_from(size).ok())
.ok_or_else(|| {
ExtractEntryError::Fatal(object_s3_error(
S3ErrorCode::InvalidArgument,
"Archive PAX metadata size overflowed while processing entry",
))
})?;
*entry_size = (*entry_size).checked_add(record_size).ok_or_else(|| {
ExtractEntryError::Fatal(object_s3_error(
S3ErrorCode::InvalidArgument,
"Archive PAX metadata size overflowed while processing entry",
))
})?;
*total_size = (*total_size).checked_add(record_size).ok_or_else(|| {
ExtractEntryError::Fatal(object_s3_error(
S3ErrorCode::InvalidArgument,
"Archive total PAX metadata size overflowed",
))
})?;
if *entry_size > limits.max_pax_metadata_size {
return Err(ExtractEntryError::Fatal(object_s3_error(
S3ErrorCode::InvalidArgument,
"Archive PAX metadata exceeds per-entry limit",
)));
}
if *total_size > limits.max_total_pax_metadata_size {
return Err(ExtractEntryError::Fatal(object_s3_error(
S3ErrorCode::InvalidArgument,
"Archive total PAX metadata exceeds limit",
)));
}
Ok(())
}
fn record_extract_pax_metadata_record(
entry_records: &mut usize,
total_records: &mut usize,
limits: ArchiveLimits,
) -> Result<(), ExtractEntryError> {
*entry_records = entry_records.checked_add(1).ok_or_else(|| {
ExtractEntryError::Fatal(object_s3_error(
S3ErrorCode::InvalidArgument,
"Archive PAX metadata record count overflowed",
))
})?;
*total_records = total_records.checked_add(1).ok_or_else(|| {
ExtractEntryError::Fatal(object_s3_error(
S3ErrorCode::InvalidArgument,
"Archive total PAX metadata record count overflowed",
))
})?;
if *entry_records > limits.max_pax_metadata_records {
return Err(ExtractEntryError::Fatal(object_s3_error(
S3ErrorCode::InvalidArgument,
"Archive PAX metadata record count exceeds per-entry limit",
)));
}
if *total_records > limits.max_total_pax_metadata_records {
return Err(ExtractEntryError::Fatal(object_s3_error(
S3ErrorCode::InvalidArgument,
"Archive total PAX metadata record count exceeds limit",
)));
}
Ok(())
}
fn is_extract_user_metadata_header(name: &http::HeaderName) -> bool {
["x-amz-meta-", "x-minio-meta-", "x-rustfs-meta-"]
.iter()
.any(|prefix| name.as_str().starts_with(prefix))
}
fn extract_pax_header_bytes(name: &http::HeaderName, value: &HeaderValue) -> usize {
name.as_str().len().saturating_add(value.as_bytes().len())
}
fn validate_extract_pax_header_budget(headers: &HeaderMap) -> S3Result<()> {
if headers.len() > EXTRACT_MAX_EFFECTIVE_PAX_FIELDS {
return Err(object_s3_error(
S3ErrorCode::InvalidArgument,
"Snowball PAX metadata field count exceeds limit",
));
}
let mut header_bytes = 0usize;
let mut user_metadata_bytes = 0usize;
for (name, value) in headers {
let field_bytes = extract_pax_header_bytes(name, value);
header_bytes = header_bytes
.checked_add(field_bytes)
.ok_or_else(|| object_s3_error(S3ErrorCode::InvalidArgument, "Snowball PAX metadata size overflowed"))?;
if is_extract_user_metadata_header(name) {
user_metadata_bytes = user_metadata_bytes
.checked_add(field_bytes)
.ok_or_else(|| object_s3_error(S3ErrorCode::InvalidArgument, "Snowball PAX user metadata size overflowed"))?;
}
}
if header_bytes > EXTRACT_MAX_EFFECTIVE_PAX_HEADER_BYTES {
return Err(object_s3_error(
S3ErrorCode::InvalidArgument,
"Snowball PAX metadata exceeds effective size limit",
));
}
if user_metadata_bytes > EXTRACT_MAX_EFFECTIVE_PAX_USER_METADATA_BYTES {
return Err(object_s3_error(
S3ErrorCode::InvalidArgument,
"Snowball PAX user metadata exceeds effective size limit",
));
}
Ok(())
}
fn try_insert_extract_header(headers: &mut HeaderMap, name: http::HeaderName, value: HeaderValue) -> S3Result<()> {
headers
.try_insert(name, value)
.map(|_| ())
.map_err(|_| object_s3_error(S3ErrorCode::InvalidArgument, "Snowball PAX metadata field count exceeds header capacity"))
}
fn replace_extract_header(headers: &mut HeaderMap, name: &'static str, value: &str) -> S3Result<()> {
let value = HeaderValue::from_str(value)
.map_err(|_| object_s3_error(S3ErrorCode::InvalidArgument, "Invalid canonical Snowball PAX metadata value"))?;
let name = http::HeaderName::from_bytes(name.as_bytes())
.map_err(|_| object_s3_error(S3ErrorCode::InvalidArgument, "Invalid canonical Snowball PAX metadata header"))?;
try_insert_extract_header(headers, name, value)
}
fn extract_pax_metadata_delta_bytes(baseline: &HashMap<String, String>, metadata: &HashMap<String, String>) -> S3Result<u64> {
metadata
.iter()
.filter(|(key, value)| baseline.get(*key) != Some(*value))
.try_fold(0u64, |total, (key, value)| {
let field_bytes = key
.len()
.checked_add(value.len())
.and_then(|size| u64::try_from(size).ok())
.ok_or_else(|| object_s3_error(S3ErrorCode::InvalidArgument, "Snowball expanded PAX metadata size overflowed"))?;
total
.checked_add(field_bytes)
.ok_or_else(|| object_s3_error(S3ErrorCode::InvalidArgument, "Snowball expanded PAX metadata size overflowed"))
})
}
fn validate_extract_expanded_pax_metadata_total(total: u64) -> S3Result<()> {
if total > EXTRACT_MAX_EXPANDED_PAX_METADATA_BYTES {
return Err(object_s3_error(
S3ErrorCode::InvalidArgument,
"Snowball expanded PAX metadata exceeds archive limit",
));
}
Ok(())
}
impl ExtractPaxOverrides {
fn overlay_record(&mut self, key: &str, value: &str) -> S3Result<()> {
if key == "minio.versionId" {
if value.is_empty() {
self.version_id = None;
} else {
self.version_id = Some(normalize_extract_version_id(value)?.authorization_id);
}
return Ok(());
}
let Some(meta_key) = key.strip_prefix("minio.metadata.") else {
return Ok(());
};
if meta_key.is_empty() {
return Ok(());
}
let name = http::HeaderName::from_bytes(meta_key.as_bytes())
.map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball PAX metadata header"))?;
if is_snowball_transport_header(name.as_str()) {
return Ok(());
}
if value.is_empty() {
if let Some(previous) = self.headers.remove(&name) {
let previous_bytes = extract_pax_header_bytes(&name, &previous);
self.header_bytes = self.header_bytes.saturating_sub(previous_bytes);
if is_extract_user_metadata_header(&name) {
self.user_metadata_bytes = self.user_metadata_bytes.saturating_sub(previous_bytes);
}
}
return Ok(());
}
let header_value = HeaderValue::from_str(value)
.map_err(|_| object_s3_error(S3ErrorCode::InvalidArgument, "Invalid Snowball PAX metadata value"))?;
let previous_bytes = self
.headers
.get(&name)
.map(|previous| extract_pax_header_bytes(&name, previous))
.unwrap_or_default();
let next_bytes = extract_pax_header_bytes(&name, &header_value);
let next_header_bytes = self
.header_bytes
.checked_sub(previous_bytes)
.and_then(|bytes| bytes.checked_add(next_bytes))
.ok_or_else(|| object_s3_error(S3ErrorCode::InvalidArgument, "Snowball PAX metadata size overflowed"))?;
if next_header_bytes > EXTRACT_MAX_EFFECTIVE_PAX_HEADER_BYTES {
return Err(object_s3_error(
S3ErrorCode::InvalidArgument,
"Snowball PAX metadata exceeds effective size limit",
));
}
let next_user_metadata_bytes = if is_extract_user_metadata_header(&name) {
self.user_metadata_bytes
.checked_sub(previous_bytes)
.and_then(|bytes| bytes.checked_add(next_bytes))
.ok_or_else(|| object_s3_error(S3ErrorCode::InvalidArgument, "Snowball PAX user metadata size overflowed"))?
} else {
self.user_metadata_bytes
};
if next_user_metadata_bytes > EXTRACT_MAX_EFFECTIVE_PAX_USER_METADATA_BYTES {
return Err(object_s3_error(
S3ErrorCode::InvalidArgument,
"Snowball PAX user metadata exceeds effective size limit",
));
}
if !self.headers.contains_key(&name) && self.headers.len() >= EXTRACT_MAX_EFFECTIVE_PAX_FIELDS {
return Err(object_s3_error(
S3ErrorCode::InvalidArgument,
"Snowball PAX metadata field count exceeds limit",
));
}
try_insert_extract_header(&mut self.headers, name, header_value)?;
self.header_bytes = next_header_bytes;
self.user_metadata_bytes = next_user_metadata_bytes;
Ok(())
}
}
async fn overlay_extract_pax_extensions<R>(
entry: &mut tokio_tar::Entry<Archive<R>>,
overrides: &mut ExtractPaxOverrides,
) -> S3Result<()>
where
R: AsyncRead + Send + Unpin + 'static,
{
let Some(extensions) = entry.pax_extensions().await.map_err(map_extract_archive_error)? else {
return Ok(());
};
for ext in extensions {
let ext = ext.map_err(map_extract_archive_error)?;
let key = ext.key().map_err(map_extract_pax_text_error)?;
let value = ext.value().map_err(map_extract_pax_text_error)?;
overrides.overlay_record(key, value)?;
}
Ok(())
}
#[derive(Debug, Default)]
struct ExtractEntryPaxAuthorization {
headers: HeaderMap,
version_id: Option<String>,
object_lock_legal_hold_status: Option<ObjectLockLegalHoldStatus>,
object_lock_mode: Option<ObjectLockMode>,
object_lock_retain_until_date: Option<Timestamp>,
expanded_metadata_bytes: u64,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct ExtractMemberIamRequirements {
tagging: bool,
retention: bool,
legal_hold: bool,
replication: bool,
}
fn extract_member_iam_requirements(
metadata: &HashMap<String, String>,
object_lock_legal_hold_status: Option<&ObjectLockLegalHoldStatus>,
object_lock_mode: Option<&ObjectLockMode>,
object_lock_retain_until_date: Option<&Timestamp>,
explicit_version_id: Option<&str>,
replica: bool,
) -> ExtractMemberIamRequirements {
ExtractMemberIamRequirements {
tagging: metadata.contains_key(AMZ_OBJECT_TAGGING),
retention: object_lock_mode.is_some() || object_lock_retain_until_date.is_some(),
legal_hold: object_lock_legal_hold_status.is_some(),
replication: explicit_version_id.is_some() || replica,
}
}
fn apply_extract_pax_overrides(
overrides: &ExtractPaxOverrides,
bucket: &str,
object_name: &str,
object_lock_config_state: &metadata_sys::ObjectLockConfigState,
metadata: &mut HashMap<String, String>,
opts: &mut ObjectOptions,
) -> S3Result<ExtractEntryPaxAuthorization> {
let baseline_metadata = metadata.clone();
let mut canonical_headers = overrides.headers.clone();
let normalized_version = overrides
.version_id
.as_deref()
.map(normalize_extract_version_id)
.transpose()?;
if let Some(version) = normalized_version.as_ref() {
opts.version_id = Some(version.storage_id.clone());
}
let storage_class = canonical_headers
.get(AMZ_STORAGE_CLASS)
.map(|value| {
value
.to_str()
.map(str::trim)
.map(str::to_owned)
.map_err(|_| object_s3_error_default(S3ErrorCode::InvalidStorageClass))
})
.transpose()?;
if let Some(storage_class) = storage_class.as_deref() {
if !is_valid_storage_class(storage_class) {
return Err(object_s3_error_default(S3ErrorCode::InvalidStorageClass));
}
replace_extract_header(&mut canonical_headers, AMZ_STORAGE_CLASS, storage_class)?;
}
let tagging = canonical_headers
.get("x-amz-tagging")
.map(|value| {
let value = value
.to_str()
.map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball object tagging value"))?;
crate::app::storage_api::object_usecase::s3_api::tagging::parse_copy_object_tags(value)
})
.transpose()?;
if let Some(tagging) = tagging.as_deref() {
replace_extract_header(&mut canonical_headers, "x-amz-tagging", tagging)?;
}
let object_lock_mode = canonical_headers
.get(AMZ_OBJECT_LOCK_MODE_LOWER)
.map(|value| {
let value = value
.to_str()
.map(str::trim)
.map(str::to_ascii_uppercase)
.map_err(|_| object_s3_error(S3ErrorCode::InvalidArgument, "Invalid Snowball Object Lock mode"))?;
match value.as_str() {
ObjectLockMode::GOVERNANCE => Ok(ObjectLockMode::from_static(ObjectLockMode::GOVERNANCE)),
ObjectLockMode::COMPLIANCE => Ok(ObjectLockMode::from_static(ObjectLockMode::COMPLIANCE)),
_ => Err(s3_error!(InvalidArgument, "Invalid Snowball Object Lock mode")),
}
})
.transpose()?;
if let Some(mode) = object_lock_mode.as_ref() {
replace_extract_header(&mut canonical_headers, AMZ_OBJECT_LOCK_MODE_LOWER, mode.as_str())?;
}
let object_lock_retain_until_date = canonical_headers
.get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER)
.map(|value| {
let value = value
.to_str()
.map(str::trim)
.map_err(|_| object_s3_error(S3ErrorCode::InvalidArgument, "Invalid Snowball Object Lock retain-until date"))?;
Timestamp::parse(TimestampFormat::DateTime, value)
.map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball Object Lock retain-until date"))
})
.transpose()?;
if let Some(retain_until_date) = object_lock_retain_until_date.as_ref() {
let formatted = OffsetDateTime::from(retain_until_date.clone())
.to_offset(time::UtcOffset::UTC)
.format(&Rfc3339)
.map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball Object Lock retain-until date"))?;
replace_extract_header(&mut canonical_headers, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, &formatted)?;
}
let object_lock_legal_hold_status = canonical_headers
.get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER)
.map(|value| {
let value =
value.to_str().map(str::trim).map(str::to_ascii_uppercase).map_err(|_| {
object_s3_error(S3ErrorCode::InvalidArgument, "Invalid Snowball Object Lock legal-hold status")
})?;
match value.as_str() {
ObjectLockLegalHoldStatus::ON => Ok(ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::ON)),
ObjectLockLegalHoldStatus::OFF => Ok(ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::OFF)),
_ => Err(s3_error!(InvalidArgument, "Invalid Snowball Object Lock legal-hold status")),
}
})
.transpose()?;
if let Some(status) = object_lock_legal_hold_status.as_ref() {
replace_extract_header(&mut canonical_headers, AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, status.as_str())?;
}
let replica = canonical_headers
.get(AMZ_BUCKET_REPLICATION_STATUS)
.map(|value| {
let value = value
.to_str()
.map(str::trim)
.map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball replication status"))?;
if value.eq_ignore_ascii_case(ReplicationStatusType::Replica.as_str()) {
Ok(true)
} else {
Err(s3_error!(InvalidArgument, "Invalid Snowball replication status"))
}
})
.transpose()?
.unwrap_or(false);
if replica {
replace_extract_header(
&mut canonical_headers,
AMZ_BUCKET_REPLICATION_STATUS,
ReplicationStatusType::Replica.as_str(),
)?;
}
validate_extract_pax_header_budget(&canonical_headers)?;
for (name, value) in &canonical_headers {
let value = value
.to_str()
.map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball PAX metadata value"))?;
preserve_unclassified_user_metadata(metadata, name.as_str(), value);
}
let mut authorization_headers = HeaderMap::new();
for name in [
AMZ_STORAGE_CLASS,
"x-amz-tagging",
AMZ_OBJECT_LOCK_MODE_LOWER,
AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER,
AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER,
] {
if let Some(value) = canonical_headers.get(name) {
try_insert_extract_header(&mut authorization_headers, http::HeaderName::from_static(name), value.clone())?;
}
}
let mut metadata_headers = canonical_headers;
metadata_headers.remove("x-amz-tagging");
metadata_headers.remove(AMZ_OBJECT_LOCK_MODE_LOWER);
metadata_headers.remove(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER);
metadata_headers.remove(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER);
metadata_headers.remove(AMZ_BUCKET_REPLICATION_STATUS);
if let Some(tagging) = tagging {
metadata.insert(AMZ_OBJECT_TAGGING.to_owned(), tagging);
}
extract_metadata_from_mime_with_object_name(&metadata_headers, metadata, false, Some(object_name));
if replica {
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(),
);
opts.set_replica_status(ReplicationStatusType::Replica);
}
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);
}
let expanded_metadata_bytes = extract_pax_metadata_delta_bytes(&baseline_metadata, metadata)?;
Ok(ExtractEntryPaxAuthorization {
headers: authorization_headers,
version_id: normalized_version.map(|version| version.authorization_id),
object_lock_legal_hold_status,
object_lock_mode,
object_lock_retain_until_date,
expanded_metadata_bytes,
})
}
async fn count_extract_entry_pax_metadata<R>(
entry: &mut tokio_tar::Entry<Archive<R>>,
total_pax_metadata_size: &mut u64,
total_pax_metadata_records: &mut usize,
limits: ArchiveLimits,
) -> Result<(), ExtractEntryError>
where
R: AsyncRead + Send + Unpin + 'static,
{
let Some(extensions) = entry
.pax_extensions()
.await
.map_err(|err| ExtractEntryError::Fatal(map_extract_archive_error(err)))?
else {
return Ok(());
};
let mut entry_pax_metadata_size = 0u64;
let mut entry_pax_metadata_records = 0usize;
for ext in extensions {
let ext = ext.map_err(|err| ExtractEntryError::Fatal(map_extract_archive_error(err)))?;
record_extract_pax_metadata_record(&mut entry_pax_metadata_records, total_pax_metadata_records, limits)?;
record_extract_pax_metadata_bytes(
&mut entry_pax_metadata_size,
total_pax_metadata_size,
ext.key_bytes().len(),
ext.value_bytes().len(),
limits,
)?;
}
Ok(())
}
async fn apply_extract_entry_pax_extensions<R>(
entry: &mut tokio_tar::Entry<Archive<R>>,
bucket: &str,
object_name: &str,
object_lock_config_state: &metadata_sys::ObjectLockConfigState,
metadata: &mut HashMap<String, String>,
opts: &mut ObjectOptions,
) -> Result<ExtractEntryPaxAuthorization, ExtractEntryError>
where
R: AsyncRead + Send + Unpin + 'static,
{
let mut overrides = ExtractPaxOverrides::default();
overlay_extract_pax_extensions(entry, &mut overrides)
.await
.map_err(ExtractEntryError::Recoverable)?;
apply_extract_pax_overrides(&overrides, bucket, object_name, object_lock_config_state, metadata, opts)
.map_err(ExtractEntryError::Recoverable)
}
#[cfg(test)]
async fn apply_extract_entry_pax_extensions_for_test<R>(
entry: &mut tokio_tar::Entry<Archive<R>>,
bucket: &str,
object_name: &str,
object_lock_config_state: &metadata_sys::ObjectLockConfigState,
metadata: &mut HashMap<String, String>,
opts: &mut ObjectOptions,
) -> Result<ExtractEntryPaxAuthorization, ExtractEntryError>
where
R: AsyncRead + Send + Unpin + 'static,
{
let mut total_pax_metadata_size = 0;
let mut total_pax_metadata_records = 0;
count_extract_entry_pax_metadata(
entry,
&mut total_pax_metadata_size,
&mut total_pax_metadata_records,
ArchiveLimits::default(),
)
.await?;
apply_extract_entry_pax_extensions(entry, bucket, object_name, object_lock_config_state, metadata, opts).await
}
fn resolve_put_object_extract_options(headers: &HeaderMap) -> S3Result<PutObjectExtractOptions> {
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 {
static LIMITS: OnceLock<ArchiveLimits> = OnceLock::new();
*LIMITS.get_or_init(|| {
normalize_put_object_extract_limits(
rustfs_utils::get_env_u64(
rustfs_config::ENV_SNOWBALL_MAX_ENTRY_BYTES,
rustfs_config::DEFAULT_SNOWBALL_MAX_ENTRY_BYTES,
),
rustfs_utils::get_env_u64(
rustfs_config::ENV_SNOWBALL_MAX_UNPACKED_BYTES,
rustfs_config::DEFAULT_SNOWBALL_MAX_UNPACKED_BYTES,
),
)
})
}
fn normalize_put_object_extract_limits(max_entry_bytes: u64, max_unpacked_bytes: u64) -> ArchiveLimits {
let defaults = ArchiveLimits::default();
let max_total_unpacked_size = max_unpacked_bytes.clamp(1, rustfs_config::MAX_SNOWBALL_UNPACKED_BYTES);
let max_entry_size = max_entry_bytes
.clamp(1, rustfs_config::MAX_SNOWBALL_ENTRY_BYTES)
.min(max_total_unpacked_size);
ArchiveLimits {
max_entry_size,
max_total_unpacked_size,
max_decoded_size: max_total_unpacked_size.saturating_add(max_entry_size),
..defaults
}
}
fn build_put_object_extract_archive<R>(decoder: R, limits: ArchiveLimits) -> Archive<R>
where
R: AsyncRead + Unpin,
{
let max_physical_entries = u64::try_from(limits.max_entries)
.unwrap_or(u64::MAX)
.saturating_mul(EXTRACT_ARCHIVE_PHYSICAL_ENTRY_MULTIPLIER);
tokio_tar::ArchiveBuilder::new(decoder)
.set_max_extension_entry_size(limits.max_pax_metadata_size)
.set_max_total_extension_size(limits.max_total_pax_metadata_size)
.set_max_physical_entries(max_physical_entries)
.set_max_sparse_entries(EXTRACT_ARCHIVE_MAX_SPARSE_ENTRIES)
.set_max_sparse_continuation_blocks(EXTRACT_ARCHIVE_MAX_SPARSE_CONTINUATION_BLOCKS)
.build()
}
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<PutObjectInput>) -> S3Result<S3Response<PutObjectOutput>> {
self.execute_put_object_extract_boxed(req).await
}
fn execute_put_object_extract_boxed(
&self,
req: S3Request<PutObjectInput>,
) -> impl std::future::Future<Output = S3Result<S3Response<PutObjectOutput>>> + Send + '_ {
Box::pin(self.execute_put_object_extract_inner(req))
}
async fn execute_put_object_extract_inner(&self, req: S3Request<PutObjectInput>) -> S3Result<S3Response<PutObjectOutput>> {
let helper = OperationHelper::new(&req, EventName::ObjectCreatedPut, S3Operation::PutObject).suppress_event();
let request_context = helper.request_context_or_from_request(&req);
let extract_options = resolve_put_object_extract_options(&req.headers)?;
let member_request_headers = snowball_member_headers(&req.headers);
let auth_method = req.method.clone();
let auth_uri = req.uri.clone();
// Authorization retains the complete signed request context. Snowball
// transport controls are filtered only at member metadata/storage boundaries.
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 outer_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 body = guard_put_object_body_read_timeout(
body,
&bucket,
&key,
&request_context.request_id,
content_length,
put_object_body_read_timeout(),
);
let size = match content_length {
Some(c) => c,
None => {
if let Some(val) = req.headers.get(AMZ_DECODED_CONTENT_LENGTH) {
match atoi::atoi::<i64>(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?;
match get_concurrency_manager()
.admit_put_object(size)
.await
.map_err(|_| object_s3_error(S3ErrorCode::InternalError, "foreground write admission closed"))?
{
ForegroundWriteAdmission::Disabled => {}
ForegroundWriteAdmission::Admitted(permit) => {
counter!("rustfs.put_object.foreground_admission.total", "result" => "admitted").increment(1);
// The archive admission is a preflight. Each member later uses
// its own logical size against the same foreground PUT policy.
drop(permit);
}
ForegroundWriteAdmission::Rejected => {
counter!("rustfs.put_object.foreground_admission.total", "result" => "rejected").increment(1);
return Err(object_s3_error(
S3ErrorCode::SlowDown,
"foreground write concurrency limit reached, please reduce your request rate",
));
}
}
let mut put_request_guard = ExtractPutRequestGuard::new();
// 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 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 expected_archive_length = u64::try_from(size).map_err(|_| S3Error::new(S3ErrorCode::UnexpectedContent))?;
let archive_upload_state = Arc::new(Mutex::new(ExtractArchiveUploadState::default()));
let extract_limits = put_object_extract_limits();
let tracked_archive =
ExtractArchiveEtagReader::new(archive_reader, expected_archive_length, archive_upload_state.clone());
let decoder = build_admitted_extract_archive_decoder(get_concurrency_manager(), &key, tracked_archive).await?;
let decoder = ExtractDecodedLimitReader::new(decoder, extract_limits.max_decoded_size);
let mut ar = build_put_object_extract_archive(decoder, extract_limits);
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_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 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 extracted_entry_count = 0usize;
let mut resource_total_size = 0u64;
let mut legacy_quota_growth = 0u64;
let mut total_pax_metadata_size = 0u64;
let mut total_pax_metadata_records = 0usize;
let mut total_expanded_pax_metadata = 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();
let commit_context = Arc::new(ExtractCommitContext {
store,
cache_adapter: self.object_data_cache(),
notify,
bucket: bucket.clone(),
quota_enabled: extract_quota_enabled,
request_context,
req_params,
host,
port,
user_agent,
wrote_any_entry: AtomicBool::new(false),
});
let durable_quota = extract_quota_check
.as_ref()
.is_some_and(|result| result.uses_durable_reservations);
// Without ignore-errors, the legacy contract stops before attempting a
// later member after the first storage failure. Parallel commits cannot
// preserve that boundary, so only ignore-errors requests use the
// configured micro-batch. Quota accounting can fail after storage
// commit, so quota-enabled imports also remain serial. Setting
// RUSTFS_SNOWBALL_EXTRACT_MAX_INFLIGHT=1 restores serial behavior. A
// micro-batch is always drained; a fatal outcome stops later batches
// but cannot roll back peers that already committed in the current
// batch.
let max_inflight = select_put_object_extract_max_inflight(
put_object_extract_max_inflight(),
extract_options.ignore_errors,
extract_quota_enabled,
);
let mut batch = Vec::with_capacity(max_inflight);
let mut batch_state = ExtractBatchState::default();
macro_rules! extract_try {
($expression:expr) => {
match $expression {
Ok(value) => value,
Err(error) => {
let error: S3Error = error.into();
flush_extract_batch(&mut batch, &mut batch_state, &commit_context, extract_options.ignore_errors).await?;
return Err(error);
}
}
};
}
while let Some(entry) = entries.next().await {
let mut f = match entry {
Ok(f) => f,
Err(error) => {
error!(error = %error, "Archive entry read failed");
flush_extract_batch(&mut batch, &mut batch_state, &commit_context, extract_options.ignore_errors).await?;
return Err(s3_error!(InvalidArgument, "Failed to read archive entry: {:?}", error));
}
};
extracted_entry_count = extracted_entry_count.saturating_add(1);
extract_try!(validate_put_object_extract_entry_count(extracted_entry_count, extract_limits,));
let entry_size = f.effective_size();
extract_try!(validate_put_object_extract_entry_size("archive member", entry_size, extract_limits,));
resource_total_size = extract_try!(resource_total_size.checked_add(entry_size).ok_or_else(|| {
s3_error!(InvalidArgument, "Archive total unpacked size overflowed while processing entries")
}));
extract_try!(validate_put_object_extract_total_size(resource_total_size, extract_limits,));
extract_try!(
count_extract_entry_pax_metadata(
&mut f,
&mut total_pax_metadata_size,
&mut total_pax_metadata_records,
extract_limits,
)
.await
.map_err(ExtractEntryError::into_s3_error)
);
let archive_entry_type = f.header().entry_type();
let entry_kind = classify_extract_entry_type(archive_entry_type);
match entry_kind {
ExtractEntryKind::Skip => continue,
ExtractEntryKind::Directory | ExtractEntryKind::Object => {}
}
extract_try!(validate_extract_special_entry_size(archive_entry_type, entry_size));
let (fpath, is_dir) = {
let path_bytes = extract_try!(f.path_bytes().map_err(map_extract_archive_error));
let path = match strict_extract_entry_path(path_bytes.as_ref()) {
Ok(path) => path,
Err(error) => {
extract_try!(error.ignore_or_return(extract_options.ignore_errors));
continue;
}
};
if is_empty_extract_entry_path(path) {
continue;
}
let is_dir = entry_kind == ExtractEntryKind::Directory || is_legacy_null_directory(f.header(), path);
if is_dir && extract_options.ignore_dirs {
continue;
}
let fpath = match normalize_extract_entry_key(path, extract_options.prefix.as_deref(), is_dir) {
Ok(fpath) => fpath,
Err(error) => {
extract_try!(ExtractEntryError::Fatal(error).ignore_or_return(extract_options.ignore_errors));
continue;
}
};
(fpath, is_dir)
};
if let Err(error) = validate_extract_member_key(&fpath, extract_limits) {
extract_try!(error.ignore_or_return(extract_options.ignore_errors));
continue;
}
if batch_state.keys.contains(&fpath)
|| durable_quota
|| max_inflight <= 1
|| (!is_dir && entry_size > EXTRACT_SMALL_MEMBER_MAX_BYTES as u64)
{
extract_try!(
flush_extract_batch(&mut batch, &mut batch_state, &commit_context, extract_options.ignore_errors,).await
);
}
extract_try!(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 = extract_try!(req_info_mut(&mut auth_req));
req_info.bucket = Some(bucket.clone());
req_info.object = Some(fpath.clone());
req_info.version_id = None;
}
let mut size = extract_try!(
i64::try_from(entry_size).map_err(|_| s3_error!(InvalidArgument, "Archive entry size does not fit into i64"))
);
// mtime 0 or a negative GNU base-256 value means "unset". xl.meta
// also cannot represent the Unix epoch as an object mod_time, so
// those cases fall back to the upload time (rustfs#4842).
let archive_entry_mod_time = extract_try!(extract_archive_entry_mod_time(f.header()));
let mut metadata = HashMap::new();
let has_explicit_object_lock_retention = object_lock_mode.is_some() || object_lock_retain_until_date.is_some();
extract_try!(apply_put_request_metadata(
&mut metadata,
&member_request_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(),
));
extract_try!(apply_bucket_default_lock_retention(
&bucket,
object_lock_config_state,
&mut metadata,
has_explicit_object_lock_retention,
));
let mut opts = extract_try!(
put_opts_with_replication_authorization(
&bucket,
&fpath,
outer_version_id.clone(),
&member_request_headers,
metadata.clone(),
replication_authorized,
)
.await
.map_err(ApiError::from)
);
if let Some(quota_check) = extract_quota_check.as_ref() {
extract_try!(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 = match apply_extract_entry_pax_extensions(
&mut f,
&bucket,
&fpath,
object_lock_config_state,
&mut metadata,
&mut opts,
)
.await
{
Ok(authorization) => authorization,
Err(error) => {
extract_try!(error.ignore_or_return(extract_options.ignore_errors));
continue;
}
};
total_expanded_pax_metadata = extract_try!(
total_expanded_pax_metadata
.checked_add(pax_authorization.expanded_metadata_bytes)
.ok_or_else(|| {
object_s3_error(S3ErrorCode::InvalidArgument, "Snowball expanded PAX metadata size overflowed")
})
);
extract_try!(validate_extract_expanded_pax_metadata_total(total_expanded_pax_metadata,));
if let Some(quota_check) = extract_quota_check.as_ref() {
let next_legacy_quota_growth = extract_try!(
legacy_quota_growth
.checked_add(extract_entry_quota_growth(
if is_dir { ExtractEntryKind::Directory } else { entry_kind },
entry_size,
))
.ok_or_else(|| {
object_s3_error(
S3ErrorCode::InvalidArgument,
"Archive quota growth overflowed while processing entries",
)
})
);
extract_try!(ensure_legacy_archive_size_within_quota(quota_check, next_legacy_quota_growth,));
legacy_quota_growth = next_legacy_quota_growth;
}
for (name, value) in &pax_authorization.headers {
extract_try!(auth_req.headers.try_insert(name.clone(), value.clone()).map_err(|_| {
object_s3_error(S3ErrorCode::InvalidArgument, "Snowball IAM condition header capacity exceeded")
}));
}
let explicit_version_id = pax_authorization.version_id.as_deref().or(outer_version_id.as_deref());
let authorization_version_id = extract_try!(
explicit_version_id
.map(|version_id| apply_extract_version_id(version_id, &mut opts))
.transpose()
);
extract_try!(req_info_mut(&mut auth_req)).version_id = authorization_version_id;
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())
};
let iam_requirements = extract_member_iam_requirements(
&metadata,
effective_object_lock_legal_hold_status.as_ref(),
effective_object_lock_mode.as_ref(),
effective_object_lock_retain_until_date.as_ref(),
explicit_version_id,
opts.delete_marker_replication_status() == ReplicationStatusType::Replica,
);
extract_try!(authorize_request(&mut auth_req, Action::S3Action(S3Action::PutObjectAction)).await);
if iam_requirements.tagging {
extract_try!(authorize_request(&mut auth_req, Action::S3Action(S3Action::PutObjectTaggingAction)).await);
}
if iam_requirements.retention {
extract_try!(authorize_request(&mut auth_req, Action::S3Action(S3Action::PutObjectRetentionAction)).await);
}
if iam_requirements.legal_hold {
extract_try!(authorize_request(&mut auth_req, Action::S3Action(S3Action::PutObjectLegalHoldAction)).await);
}
if iam_requirements.replication {
extract_try!(authorize_request(&mut auth_req, Action::S3Action(S3Action::ReplicateObjectAction)).await);
}
drop(auth_req);
if archive_entry_mod_time.is_some() {
opts.mod_time = archive_entry_mod_time;
}
debug!("Extracting file: {}, size: {} bytes", fpath, size);
if is_dir {
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();
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());
write_plan = write_plan.with_compression(algorithm);
}
extract_try!(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) = extract_try!(
sse_encryption(EncryptionRequest {
bucket: &bucket,
key: &fpath,
server_side_encryption: effective_sse.clone(),
ssekms_key_id: effective_kms_key_id.clone(),
ssekms_context: extract_try!(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 = extract_try!(encryption_material_to_metadata(&material));
metadata.extend(encryption_metadata.clone());
opts.user_defined.extend(encryption_metadata);
}
opts.user_defined.extend(metadata);
// Reserve the global member lifecycle before constructing the
// potentially large replication decision. If this archive already
// owns a batch, drain it before waiting so requests cannot deadlock
// while each retains lifecycle capacity needed by its own batch.
let member_permit = extract_try!(
acquire_extract_member_lifecycle_permit(
&mut batch,
&mut batch_state,
&commit_context,
extract_options.ignore_errors,
)
.await
);
// One immutable decision drives both the pending metadata and the
// post-commit schedule, matching the regular PUT contract.
let replication = must_replicate_object(
&bucket,
&fpath,
&opts.user_defined,
"".to_string(),
opts.delete_marker_replication_status(),
opts.clone(),
)
.await;
if replication.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,
replication.pending_status().unwrap_or_default(),
);
}
// Never wait while retaining a fully constructed replication
// decision. The lifecycle gate bounds builders, and this byte gate
// accounts every serial or staged member until storage returns.
let manager = get_concurrency_manager();
let member_size = extract_try!(
usize::try_from(size)
.map_err(|_| object_s3_error(S3ErrorCode::InvalidArgument, "Snowball member size does not fit into usize"))
);
let staging_weight = extract_try!(checked_extract_member_staging_weight(&fpath, member_size, &opts, &replication,));
let action = batch_state.action(&fpath, member_size, staging_weight, max_inflight, durable_quota);
if matches!(action, ExtractBatchAction::FlushThenStage | ExtractBatchAction::SerialBarrier) {
extract_try!(
flush_extract_batch(&mut batch, &mut batch_state, &commit_context, extract_options.ignore_errors,).await
);
}
let mut staging_permit = try_acquire_extract_staging_permit(manager, staging_weight);
if staging_permit.is_err() && !batch.is_empty() {
extract_try!(
flush_extract_batch(&mut batch, &mut batch_state, &commit_context, extract_options.ignore_errors,).await
);
staging_permit = try_acquire_extract_staging_permit(manager, staging_weight);
}
let staging_permit = match staging_permit {
Ok(permit) => permit,
Err(error) if extract_options.ignore_errors => {
warn!(error = %error, "Archive object staging skipped due to ignore-errors");
continue;
}
Err(error) => extract_try!(Err::<OwnedSemaphorePermit, _>(error)),
};
if action == ExtractBatchAction::SerialBarrier {
// Large and durable-quota members retain the legacy streaming
// path. Await the owner inline so the producer never advances
// the TAR while its Entry is alive. The complete commit and
// notification aggregation move into that owner before ECStore
// starts, so caller cancellation cannot interrupt a mutation
// after its quorum commit point.
let member_context = commit_context.clone();
let owner_context = commit_context.clone();
if is_dir {
drop(f);
extract_try!(
run_extract_outcomes_owner(owner_context, extract_options.ignore_errors, async move {
vec![
commit_extract_member(
member_context,
ExtractPreparedMember {
archive_seq: extracted_entry_count,
key: fpath,
size,
actual_size,
body: std::io::Cursor::new(Vec::new()),
write_plan,
opts,
replication,
staging_permit,
member_permit,
},
)
.await,
]
})
.await
);
} else {
extract_try!(
run_extract_outcomes_owner(owner_context, extract_options.ignore_errors, async move {
vec![
commit_extract_member(
member_context,
ExtractPreparedMember {
archive_seq: extracted_entry_count,
key: fpath,
size,
actual_size,
body: f,
write_plan,
opts,
replication,
staging_permit,
member_permit,
},
)
.await,
]
})
.await
);
}
continue;
}
let body = if is_dir {
drop(f);
ExtractStagedBody::empty()
} else {
extract_try!(stage_extract_member_body(&mut f, member_size).await)
};
batch_state.record(&fpath, staging_weight);
batch.push(ExtractPreparedMember {
archive_seq: extracted_entry_count,
key: fpath,
size,
actual_size,
body,
write_plan,
opts,
replication,
staging_permit,
member_permit,
});
}
extract_try!(flush_extract_batch(&mut batch, &mut batch_state, &commit_context, extract_options.ignore_errors,).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,
};
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 = {
let state = archive_upload_state
.lock()
.map_err(|_| object_s3_error(S3ErrorCode::InternalError, "Archive upload state lock was poisoned"))?;
if !state.body_complete {
return Err(object_s3_error(
S3ErrorCode::UnexpectedContent,
"Archive decoder did not consume the complete request body",
));
}
state.etag.as_ref().map(|etag| to_s3s_etag(etag))
};
// Keep decoder admission through body-complete validation, then release
// it before response checksum and completion bookkeeping.
drop(decoder);
apply_trailing_checksums(
input.checksum_algorithm.as_ref().map(|a| a.as_str()),
&req.trailing_headers,
&mut checksums,
);
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);
put_request_guard.finish_ok();
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use http::{HeaderMap, HeaderName, HeaderValue};
use s3s::dto::{ObjectLockConfiguration, ObjectLockEnabled};
use tokio::io::AsyncReadExt;
use tokio_tar::{Builder, EntryType, Header};
struct RecordingNotify {
events: tokio::sync::mpsc::UnboundedSender<String>,
}
#[async_trait::async_trait]
impl crate::runtime_sources::NotifyInterface for RecordingNotify {
async fn notify(&self, args: rustfs_notify::EventArgs) {
let _ = self.events.send(args.version_id);
}
async fn add_event_specific_rules(
&self,
_bucket_name: &str,
_region: &str,
_event_rules: &[(Vec<rustfs_targets::EventName>, String, String, Vec<rustfs_targets::arn::TargetID>)],
) -> Result<(), rustfs_notify::NotificationError> {
Ok(())
}
async fn clear_bucket_notification_rules(&self, _bucket_name: &str) -> Result<(), rustfs_notify::NotificationError> {
Ok(())
}
}
async fn recording_extract_commit_context(
notify: Arc<dyn crate::runtime_sources::NotifyInterface>,
) -> Arc<ExtractCommitContext> {
Arc::new(ExtractCommitContext {
store: crate::app::gating_test_env::shared_gating_ecstore().await,
cache_adapter: ObjectDataCacheAdapter::disabled_arc(),
notify,
bucket: "snowball-owner-test".to_string(),
quota_enabled: false,
request_context: request_context::RequestContext::from_headers(&HeaderMap::new()),
req_params: hashbrown::HashMap::new(),
host: String::new(),
port: 0,
user_agent: String::new(),
wrote_any_entry: AtomicBool::new(false),
})
}
#[tokio::test]
async fn snowball_archive_decoder_admission_is_global_and_lifetime_bound() {
struct PanicOnRead;
struct ErrorOnRead;
impl AsyncRead for PanicOnRead {
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
panic!("a saturated decoder admission must not inspect the archive body")
}
}
impl AsyncRead for ErrorOnRead {
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Err(std::io::Error::other("injected decoder source failure")))
}
}
let manager = ConcurrencyManager::new();
let clone = manager.clone();
let mut held = Vec::new();
while let Some(permit) = manager.try_acquire_snowball_archive_decoder() {
held.push(permit);
}
assert!(!held.is_empty(), "the global decoder gate must admit at least one archive");
let error = match build_admitted_extract_archive_decoder(&clone, "archive.tar", PanicOnRead).await {
Ok(_) => panic!("a saturated decoder gate must reject without constructing another decoder"),
Err(error) => error,
};
assert_eq!(error.code(), &S3ErrorCode::SlowDown);
drop(
held.pop()
.expect("one decoder permit must be available for the lifetime test"),
);
let error = match build_admitted_extract_archive_decoder(&clone, "archive.tar", ErrorOnRead).await {
Ok(_) => panic!("archive inspection failure must remain an error"),
Err(error) => error,
};
assert_eq!(error.code(), &S3ErrorCode::InvalidArgument);
let released_after_error = clone
.try_acquire_snowball_archive_decoder()
.expect("archive inspection failure must release decoder capacity");
drop(released_after_error);
let mut builder = Builder::new(Vec::new());
let mut header = Header::new_gnu();
header.set_size(0);
header.set_cksum();
builder
.append_data(&mut header, "member.txt", &b""[..])
.await
.expect("decoder lifetime fixture should append its member");
let archive_bytes = builder.into_inner().await.expect("decoder lifetime fixture should finalize");
let expected_length = u64::try_from(archive_bytes.len()).expect("fixture length must fit u64");
let upload_state = Arc::new(Mutex::new(ExtractArchiveUploadState::default()));
let tracked_archive =
ExtractArchiveEtagReader::new(std::io::Cursor::new(archive_bytes), expected_length, upload_state.clone());
let decoder = build_admitted_extract_archive_decoder(&clone, "archive.tar", tracked_archive)
.await
.expect("released decoder capacity must be reusable");
assert!(
manager.try_acquire_snowball_archive_decoder().is_none(),
"the decoder reader must retain admission while it is active"
);
let extract_limits = put_object_extract_limits();
let decoder = ExtractDecodedLimitReader::new(decoder, extract_limits.max_decoded_size);
let mut archive = build_put_object_extract_archive(decoder, extract_limits);
let mut entries = archive.entries().expect("admitted archive entries should be readable");
let entry = entries
.next()
.await
.expect("admitted archive should contain its member")
.expect("admitted archive member should parse");
assert_eq!(entry.path_bytes().expect("archive member path should parse").as_ref(), b"member.txt");
drop(entry);
assert!(entries.next().await.is_none(), "admitted archive should contain one member");
drop(entries);
let mut decoder = match archive.into_inner() {
Ok(decoder) => decoder,
Err(_) => panic!("admitted archive should finalize"),
};
tokio::io::copy(&mut decoder, &mut tokio::io::sink())
.await
.expect("admitted archive should consume its remaining transport body");
assert!(
upload_state
.lock()
.expect("archive upload state lock must remain healthy")
.body_complete,
"transport-length validation must complete while decoder admission is held"
);
assert!(
manager.try_acquire_snowball_archive_decoder().is_none(),
"archive finalization and transport validation must retain decoder admission"
);
drop(decoder);
assert!(
clone.try_acquire_snowball_archive_decoder().is_some(),
"dropping the finalized decoder must release its global slot"
);
let cancelled =
build_admitted_extract_archive_decoder(&manager, "archive.tar", std::io::Cursor::new(b"cancelled".to_vec()))
.await
.expect("the decoder gate must remain reusable");
drop(cancelled);
assert!(
clone.try_acquire_snowball_archive_decoder().is_some(),
"dropping an unfinished decoder must release admission for cancellation"
);
}
#[test]
fn snowball_max_inflight_has_a_serial_compatibility_floor_and_bounded_ceiling() {
assert_eq!(EXTRACT_DEFAULT_MAX_INFLIGHT, EXTRACT_BATCH_MAX_MEMBERS);
assert_eq!(normalize_put_object_extract_max_inflight(0), 1);
assert_eq!(normalize_put_object_extract_max_inflight(1), 1);
assert_eq!(normalize_put_object_extract_max_inflight(usize::MAX), EXTRACT_BATCH_MAX_MEMBERS);
assert_eq!(
select_put_object_extract_max_inflight(EXTRACT_BATCH_MAX_MEMBERS, false, false),
1,
"requests that stop on write errors must preserve serial member semantics"
);
assert_eq!(
select_put_object_extract_max_inflight(EXTRACT_BATCH_MAX_MEMBERS, true, false),
EXTRACT_BATCH_MAX_MEMBERS,
"ignore-errors requests may use bounded parallel member commits"
);
assert_eq!(
select_put_object_extract_max_inflight(EXTRACT_BATCH_MAX_MEMBERS, true, true),
1,
"quota accounting can fail after storage commit and must remain serial"
);
}
#[test]
fn snowball_archive_limits_preserve_defaults_and_clamp_operator_overrides() {
let defaults = ArchiveLimits::default();
assert_eq!(
normalize_put_object_extract_limits(
rustfs_config::DEFAULT_SNOWBALL_MAX_ENTRY_BYTES,
rustfs_config::DEFAULT_SNOWBALL_MAX_UNPACKED_BYTES,
),
defaults
);
let minimum = normalize_put_object_extract_limits(0, 0);
assert_eq!(minimum.max_entry_size, 1);
assert_eq!(minimum.max_total_unpacked_size, 1);
assert_eq!(minimum.max_decoded_size, 2);
let bounded = normalize_put_object_extract_limits(u64::MAX, u64::MAX);
assert_eq!(bounded.max_entry_size, rustfs_config::MAX_SNOWBALL_ENTRY_BYTES);
assert_eq!(bounded.max_total_unpacked_size, rustfs_config::MAX_SNOWBALL_UNPACKED_BYTES);
assert_eq!(
bounded.max_decoded_size,
rustfs_config::MAX_SNOWBALL_UNPACKED_BYTES + rustfs_config::MAX_SNOWBALL_ENTRY_BYTES
);
let entry_is_bounded_by_the_request_total = normalize_put_object_extract_limits(1024, 512);
assert_eq!(entry_is_bounded_by_the_request_total.max_entry_size, 512);
assert_eq!(entry_is_bounded_by_the_request_total.max_total_unpacked_size, 512);
}
#[test]
fn snowball_batch_state_flushes_on_duplicates_limits_and_serial_barriers() {
let mut state = ExtractBatchState::default();
assert_eq!(
state.action("first", 4096, 8192, EXTRACT_BATCH_MAX_MEMBERS, false),
ExtractBatchAction::Stage
);
state.record("first", 8192);
assert_eq!(
state.action("first", 4096, 8192, EXTRACT_BATCH_MAX_MEMBERS, false),
ExtractBatchAction::FlushThenStage
);
assert_eq!(
state.action("large", EXTRACT_SMALL_MEMBER_MAX_BYTES + 1, 8192, EXTRACT_BATCH_MAX_MEMBERS, false,),
ExtractBatchAction::SerialBarrier
);
assert_eq!(
state.action("quota", 4096, 8192, EXTRACT_BATCH_MAX_MEMBERS, true),
ExtractBatchAction::SerialBarrier
);
assert_eq!(state.action("compat", 4096, 8192, 1, false), ExtractBatchAction::SerialBarrier);
assert_eq!(
state.action(
"exact-small-boundary",
EXTRACT_SMALL_MEMBER_MAX_BYTES,
8192,
EXTRACT_BATCH_MAX_MEMBERS,
false,
),
ExtractBatchAction::Stage
);
assert_eq!(
state.action(
"oversized-context",
4096,
EXTRACT_BATCH_MAX_STAGING_BYTES + 1,
EXTRACT_BATCH_MAX_MEMBERS,
false,
),
ExtractBatchAction::SerialBarrier
);
state.clear();
state.staging_bytes = EXTRACT_BATCH_MAX_STAGING_BYTES - 1024;
assert_eq!(
state.action("exact-memory", 4096, 1024, EXTRACT_BATCH_MAX_MEMBERS, false),
ExtractBatchAction::Stage
);
assert_eq!(
state.action("memory", 4096, 2048, EXTRACT_BATCH_MAX_MEMBERS, false),
ExtractBatchAction::FlushThenStage
);
state.clear();
for index in 0..EXTRACT_BATCH_MAX_MEMBERS {
state.record(&format!("key-{index}"), 1);
}
assert_eq!(
state.action("overflow", 1, 1, EXTRACT_BATCH_MAX_MEMBERS, false),
ExtractBatchAction::FlushThenStage
);
}
#[test]
fn snowball_staging_weight_includes_body_capacity_path_and_frozen_metadata() {
let mut opts = ObjectOptions::default();
opts.user_defined.insert("x-amz-meta-one".to_string(), "value".to_string());
opts.eval_metadata = Some(HashMap::from([("auth-view".to_string(), "retained".to_string())]));
let path = "prefix/object";
let weight = checked_extract_member_staging_weight(path, 1, &opts, &ReplicateDecision::new())
.expect("valid metadata must have a weight");
assert_eq!(
weight,
EXTRACT_SMALL_MEMBER_MAX_BYTES
+ EXTRACT_MEMBER_CONTEXT_OVERHEAD_BYTES
+ (2 * path.len())
+ "x-amz-meta-one".len()
+ "value".len()
+ EXTRACT_METADATA_ENTRY_OVERHEAD_BYTES
+ "auth-view".len()
+ "retained".len()
+ EXTRACT_METADATA_ENTRY_OVERHEAD_BYTES
);
let empty_weight = checked_extract_member_staging_weight(path, 0, &ObjectOptions::default(), &ReplicateDecision::new())
.expect("empty member must have a weight");
assert_eq!(empty_weight, EXTRACT_MEMBER_CONTEXT_OVERHEAD_BYTES + (2 * path.len()));
}
#[test]
fn snowball_staging_weight_accounts_replication_target_capacity() {
let path = "replicated/object";
let opts = ObjectOptions::default();
let baseline = checked_extract_member_staging_weight(path, 0, &opts, &ReplicateDecision::new())
.expect("empty replication decision must have a weight");
let mut target_name = String::with_capacity(128);
target_name.push_str("target");
let mut replication = ReplicateDecision::new();
replication.targets_map.insert(target_name, Default::default());
let map_allocation =
checked_extract_hash_map_allocation(&replication.targets_map).expect("target map allocation must fit");
let retained_strings = {
let (name, target) = replication.targets_map.iter_mut().next().expect("target must exist");
let mut arn = String::with_capacity(EXTRACT_BATCH_MAX_STAGING_BYTES);
arn.push('a');
target.arn = arn;
let mut id = String::with_capacity(256);
id.push_str("id");
target.id = id;
name.capacity() + target.arn.capacity() + target.id.capacity()
};
let weight = checked_extract_member_staging_weight(path, 0, &opts, &replication)
.expect("replication decision must have a bounded weight");
assert_eq!(weight, baseline + retained_strings + map_allocation);
assert_eq!(
ExtractBatchState::default().action("replicated/object", 0, weight, EXTRACT_BATCH_MAX_MEMBERS, false),
ExtractBatchAction::SerialBarrier,
"a retained target capacity above the micro-batch budget must remain serial"
);
}
#[test]
fn snowball_serial_members_consume_the_global_staging_budget() {
let manager = ConcurrencyManager::new();
let serial_weight = EXTRACT_BATCH_MAX_STAGING_BYTES + 1;
assert_eq!(
ExtractBatchState::default().action("large-context", 0, serial_weight, EXTRACT_BATCH_MAX_MEMBERS, false),
ExtractBatchAction::SerialBarrier
);
let first = try_acquire_extract_staging_permit(&manager, serial_weight)
.expect("the first serial member must reserve its retained context");
let error = try_acquire_extract_staging_permit(&manager, serial_weight)
.expect_err("a second serial member must not exceed the global staging budget");
assert_eq!(error.code(), &S3ErrorCode::SlowDown);
drop(first);
assert!(
try_acquire_extract_staging_permit(&manager, serial_weight).is_ok(),
"serial staging capacity must be reusable after storage releases it"
);
}
#[test]
fn snowball_staging_admission_rejects_without_retaining_waiters() {
let manager = ConcurrencyManager::new();
let full_budget = manager
.try_acquire_snowball_staging_bytes(
u32::try_from(SNOWBALL_STAGING_BYTES_LIMIT).expect("the staging budget must fit into u32"),
)
.expect("the exact global staging budget must be available");
let error =
try_acquire_extract_staging_permit(&manager, 1).expect_err("a saturated staging budget must reject immediately");
assert_eq!(error.code(), &S3ErrorCode::SlowDown);
drop(full_budget);
let error = try_acquire_extract_staging_permit(&manager, SNOWBALL_STAGING_BYTES_LIMIT + 1)
.expect_err("a single retained context larger than the global budget must reject");
assert_eq!(error.code(), &S3ErrorCode::SlowDown);
}
#[tokio::test]
async fn snowball_staging_rejects_truncated_and_oversized_member_bodies() {
let mut truncated = std::io::Cursor::new(b"ab".to_vec());
let error = stage_extract_member_body(&mut truncated, 3)
.await
.err()
.expect("a truncated TAR member must fail staging");
assert_eq!(error.code(), &S3ErrorCode::IncompleteBody);
let mut oversized = std::io::Cursor::new(b"abc".to_vec());
let error = stage_extract_member_body(&mut oversized, 2)
.await
.err()
.expect("a TAR member longer than its declared size must fail staging");
assert_eq!(error.code(), &S3ErrorCode::UnexpectedContent);
}
#[tokio::test]
async fn snowball_staging_consumes_each_tar_entry_before_advancing() {
let mut builder = Builder::new(Vec::new());
for (path, payload) in [("first", b"first-body".as_slice()), ("second", b"second-body".as_slice())] {
let mut header = Header::new_gnu();
header.set_size(payload.len() as u64);
header.set_cksum();
builder
.append_data(&mut header, path, std::io::Cursor::new(payload))
.await
.expect("TAR member must append");
}
let archive_bytes = builder.into_inner().await.expect("TAR fixture must finalize");
let mut archive = Archive::new(std::io::Cursor::new(archive_bytes));
let mut entries = archive.entries().expect("TAR entries must open");
let mut first = entries
.next()
.await
.expect("first entry must exist")
.expect("first entry must parse");
let mut staged = stage_extract_member_body(&mut first, "first-body".len())
.await
.expect("first body must stage");
let mut staged_bytes = Vec::new();
staged.read_to_end(&mut staged_bytes).await.expect("staged body must read");
assert_eq!(staged_bytes, b"first-body");
drop(first);
let mut second = entries
.next()
.await
.expect("second entry must exist")
.expect("second entry must parse");
let mut second_bytes = Vec::new();
second.read_to_end(&mut second_bytes).await.expect("second body must read");
assert_eq!(second_bytes, b"second-body");
drop(second);
drop(entries);
assert!(archive.into_inner().is_ok(), "no TAR entry may escape the sequential producer");
}
#[test]
fn snowball_batch_error_selection_uses_archive_order() {
let outcomes = vec![
ExtractCommitOutcome {
archive_seq: 2,
event: None,
error: Some(ExtractCommitError::Fatal(object_s3_error(S3ErrorCode::InvalidArgument, "later"))),
},
ExtractCommitOutcome {
archive_seq: 1,
event: None,
error: Some(ExtractCommitError::Fatal(object_s3_error(S3ErrorCode::NoSuchKey, "earlier"))),
},
];
let (_, error) = ordered_extract_outcomes(outcomes, false);
let error = error.expect("the earliest archive error must be returned");
assert_eq!(error.code(), &S3ErrorCode::NoSuchKey);
}
#[test]
fn snowball_batch_notifications_follow_archive_order() {
let outcomes = vec![
ExtractCommitOutcome {
archive_seq: 2,
event: Some(EventArgsBuilder::default().version_id("second").build()),
error: None,
},
ExtractCommitOutcome {
archive_seq: 1,
event: Some(EventArgsBuilder::default().version_id("first").build()),
error: None,
},
];
let (events, error) = ordered_extract_outcomes(outcomes, false);
assert!(error.is_none(), "successful outcomes must be accepted");
assert_eq!(
events.iter().map(|event| event.version_id.as_str()).collect::<Vec<_>>(),
["first", "second"]
);
}
#[tokio::test]
async fn snowball_notification_dispatch_detaches_slow_delivery() {
let started = Arc::new(tokio::sync::Notify::new());
spawn_extract_notification_batch(None, vec![EventArgsBuilder::default().version_id("slow-target").build()], {
let started = started.clone();
move |_event| {
let started = started.clone();
async move {
started.notify_one();
std::future::pending::<()>().await;
}
}
});
tokio::time::timeout(std::time::Duration::from_secs(1), started.notified())
.await
.expect("detached notification delivery must start without blocking the caller");
}
#[tokio::test]
#[serial_test::serial]
async fn snowball_commit_owner_survives_request_cancellation() {
let entered = Arc::new(tokio::sync::Barrier::new(2));
let release = Arc::new(tokio::sync::Barrier::new(2));
let (events, mut received_events) = tokio::sync::mpsc::unbounded_channel();
let context = recording_extract_commit_context(Arc::new(RecordingNotify { events })).await;
let manager = ConcurrencyManager::new();
let owner_permit = manager
.acquire_snowball_member_commit()
.await
.expect("owner lifecycle admission must remain open");
let mut other_permits = Vec::with_capacity(SNOWBALL_MEMBER_COMMIT_LIMIT - 1);
for _ in 1..SNOWBALL_MEMBER_COMMIT_LIMIT {
other_permits.push(
manager
.acquire_snowball_member_commit()
.await
.expect("remaining lifecycle admission must remain open"),
);
}
let request = spawn_traced_join({
let entered = entered.clone();
let release = release.clone();
async move {
run_extract_outcomes_owner(context, false, async move {
let owner_permit = owner_permit;
entered.wait().await;
release.wait().await;
let outcome = ExtractCommitOutcome {
archive_seq: 1,
event: Some(EventArgsBuilder::default().version_id("cancelled-owner-event").build()),
error: None,
};
drop(owner_permit);
vec![outcome]
})
.await
}
});
tokio::time::timeout(std::time::Duration::from_secs(1), entered.wait())
.await
.expect("commit owner must start before the request waits for storage");
request.abort();
let join_error = request.await.expect_err("request task must be cancelled");
assert!(join_error.is_cancelled());
assert!(
manager.try_acquire_snowball_member_commit().is_none(),
"a cancelled request must not admit a 33rd member while its detached tail is running"
);
release.wait().await;
let replacement = tokio::time::timeout(std::time::Duration::from_secs(1), async {
loop {
if let Some(permit) = manager.try_acquire_snowball_member_commit() {
break permit;
}
tokio::task::yield_now().await;
}
})
.await
.expect("commit owner must finish after request cancellation");
let event = tokio::time::timeout(std::time::Duration::from_secs(1), received_events.recv())
.await
.expect("the detached owner must dispatch its committed event")
.expect("the notification recorder must remain open");
assert_eq!(event, "cancelled-owner-event");
assert!(
matches!(
received_events.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty | tokio::sync::mpsc::error::TryRecvError::Disconnected)
),
"the detached owner must dispatch the committed event exactly once"
);
drop((replacement, other_permits));
}
#[test]
fn snowball_ignore_errors_skips_storage_write_failure_and_keeps_later_success() {
let outcomes = vec![
ExtractCommitOutcome {
archive_seq: 1,
event: None,
error: Some(ExtractCommitError::StorageWrite(object_s3_error(
S3ErrorCode::InternalError,
"injected write failure",
))),
},
ExtractCommitOutcome {
archive_seq: 2,
event: Some(EventArgsBuilder::default().version_id("later-success").build()),
error: None,
},
];
let (events, error) = ordered_extract_outcomes(outcomes, true);
assert!(error.is_none(), "ignore-errors must skip a storage-only write failure");
assert_eq!(events.len(), 1);
assert_eq!(events[0].version_id, "later-success");
let slowdown = ExtractCommitError::StorageWrite(object_s3_error(S3ErrorCode::SlowDown, "injected admission rejection"));
assert!(
slowdown.into_unignored(true).is_none(),
"ignore-errors must treat member admission rejection as a skipped write"
);
let slowdown = ExtractCommitError::StorageWrite(object_s3_error(S3ErrorCode::SlowDown, "injected admission rejection"));
assert_eq!(
slowdown
.into_unignored(false)
.expect("non-ignore requests must return admission rejection")
.code(),
&S3ErrorCode::SlowDown
);
}
#[test]
fn snowball_ignore_errors_never_skips_reader_or_other_fatal_failures() {
let outcomes = vec![ExtractCommitOutcome {
archive_seq: 1,
event: None,
error: Some(ExtractCommitError::Fatal(object_s3_error(
S3ErrorCode::IncompleteBody,
"injected reader failure",
))),
}];
let (_, error) = ordered_extract_outcomes(outcomes, true);
let error = error.expect("ignore-errors must not hide reader, codec, length, resource, or post-commit failures");
assert_eq!(error.code(), &S3ErrorCode::IncompleteBody);
}
#[tokio::test]
async fn snowball_batch_runner_polls_commits_concurrently_and_drains_every_outcome() {
let completed = Arc::new(AtomicUsize::new(0));
let active = Arc::new(AtomicUsize::new(0));
let max_active = Arc::new(AtomicUsize::new(0));
let barrier = Arc::new(tokio::sync::Barrier::new(3));
let outcomes = tokio::time::timeout(
std::time::Duration::from_secs(1),
run_extract_commits(1..=3, {
let active = active.clone();
let max_active = max_active.clone();
let barrier = barrier.clone();
let completed = completed.clone();
move |archive_seq| {
let active = active.clone();
let max_active = max_active.clone();
let barrier = barrier.clone();
let completed = completed.clone();
async move {
let current = active.fetch_add(1, Ordering::AcqRel) + 1;
max_active.fetch_max(current, Ordering::AcqRel);
barrier.wait().await;
active.fetch_sub(1, Ordering::AcqRel);
completed.fetch_add(1, Ordering::Relaxed);
ExtractCommitOutcome {
archive_seq,
event: None,
error: (archive_seq == 2)
.then(|| ExtractCommitError::Fatal(object_s3_error(S3ErrorCode::InvalidArgument, "injected"))),
}
}
}
}),
)
.await
.expect("the production batch runner must poll all commits concurrently");
assert_eq!(outcomes.len(), 3);
assert_eq!(completed.load(Ordering::Relaxed), 3);
assert_eq!(max_active.load(Ordering::Acquire), 3);
assert_eq!(outcomes.iter().filter(|outcome| outcome.error.is_some()).count(), 1);
}
#[test]
fn archive_format_uses_only_the_ambiguous_zlib_extension_as_a_fallback() {
assert_eq!(
resolve_extract_archive_format("archive.zlib", CompressionFormat::Tar),
CompressionFormat::Zlib
);
assert_eq!(
resolve_extract_archive_format("archive.zz", CompressionFormat::Tar),
CompressionFormat::Zlib
);
assert_eq!(
resolve_extract_archive_format("raw-but-named.tar.gz", CompressionFormat::Tar),
CompressionFormat::Tar
);
assert_eq!(
resolve_extract_archive_format("gzip-but-named.zlib", CompressionFormat::Gzip),
CompressionFormat::Gzip
);
}
#[tokio::test]
async fn raw_tar_member_names_starting_with_codec_magic_are_not_misdetected() {
let cases = [
("PK\u{3}\u{4}-member.txt", b"PK\x03\x04".as_slice()),
("BZh9-report.txt", b"BZh9".as_slice()),
("\u{4}\"M\u{18}-report.txt", b"\x04\x22\x4d\x18".as_slice()),
];
for (path, expected_prefix) in cases {
let mut builder = Builder::new(Vec::new());
let mut header = Header::new_gnu();
header.set_size(0);
header.set_cksum();
builder
.append_data(&mut header, path, &b""[..])
.await
.expect("raw TAR fixture should accept the codec-like member name");
let bytes = builder.into_inner().await.expect("raw TAR fixture should finalize");
assert_eq!(&bytes[..expected_prefix.len()], expected_prefix);
let (format, sniffed) = CompressionFormat::sniff(std::io::Cursor::new(bytes))
.await
.expect("raw TAR prefix should be inspected");
assert_eq!(format, CompressionFormat::Tar, "member path={path:?}");
let decoder = format.get_decoder(sniffed).expect("raw TAR decoder should be created");
let mut archive = Archive::new(decoder);
let mut entries = archive.entries().expect("raw TAR entry stream should be created");
let entry = entries
.next()
.await
.expect("raw TAR should contain its first member")
.expect("raw TAR member should parse");
assert_eq!(entry.path_bytes().expect("raw TAR member path should parse").as_ref(), path.as_bytes());
}
}
#[tokio::test]
async fn archive_etag_reader_validates_sha256_before_completion() {
let payload = b"archive-with-wrong-sha256".to_vec();
let expected_length = i64::try_from(payload.len()).expect("fixture length must fit i64");
let hash_reader = HashReader::from_stream(
std::io::Cursor::new(payload),
expected_length,
expected_length,
None,
Some("00".repeat(32)),
false,
)
.expect("hash reader should be created");
let state = Arc::new(Mutex::new(ExtractArchiveUploadState::default()));
let mut reader = ExtractArchiveEtagReader::new(
hash_reader,
u64::try_from(expected_length).expect("fixture length must fit u64"),
state.clone(),
);
let mut output = Vec::new();
let err = reader
.read_to_end(&mut output)
.await
.expect_err("SHA-256 must be checked before upload completion");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(!state.lock().expect("archive state lock must remain healthy").body_complete);
}
fn pax_record(key: &str, value: &[u8]) -> Vec<u8> {
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
}
async fn entry_with_local_pax(record: &[u8], entry_type: EntryType) -> tokio_tar::Entry<Archive<std::io::Cursor<Vec<u8>>>> {
let mut builder = Builder::new(std::io::Cursor::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
.expect("local PAX fixture should be appended");
let mut member = Header::new_ustar();
member.set_size(0);
member.set_entry_type(entry_type);
if entry_type == EntryType::Symlink {
member.set_link_name("target").expect("symlink fixture should have a target");
}
builder
.append_data(&mut member, "member", std::io::Cursor::new(Vec::new()))
.await
.expect("member fixture should be appended");
let bytes = builder
.into_inner()
.await
.expect("fixture builder should finish")
.into_inner();
let mut archive = Archive::new(std::io::Cursor::new(bytes));
let mut entries = archive.entries().expect("fixture archive should be iterable");
entries
.next()
.await
.expect("fixture should contain a logical member")
.expect("fixture member should parse")
}
#[tokio::test]
async fn archive_etag_reader_validates_raw_eof_before_returning_final_byte() {
let payload = b"decoder-consumed-exact-body".to_vec();
let state = Arc::new(Mutex::new(ExtractArchiveUploadState::default()));
let mut reader = ExtractArchiveEtagReader::new(
std::io::Cursor::new(payload.clone()),
u64::try_from(payload.len()).expect("fixture length must fit u64"),
state.clone(),
);
let mut output = Vec::new();
reader
.read_to_end(&mut output)
.await
.expect("exact body should validate through raw EOF");
assert_eq!(output, payload);
let expected_etag = hex_simd::encode_to_string(Md5::digest(&payload), hex_simd::AsciiCase::Lower);
let state = state.lock().expect("archive state lock must remain healthy");
assert!(state.body_complete);
assert_eq!(state.etag.as_deref(), Some(expected_etag.as_str()));
}
#[tokio::test]
async fn archive_etag_reader_rejects_short_and_overlong_bodies() {
let payload = b"body-length-fixture".to_vec();
let short_state = Arc::new(Mutex::new(ExtractArchiveUploadState::default()));
let mut short = ExtractArchiveEtagReader::new(
std::io::Cursor::new(payload.clone()),
u64::try_from(payload.len() + 1).expect("fixture length must fit u64"),
short_state.clone(),
);
let short_err = short
.read_to_end(&mut Vec::new())
.await
.expect_err("short body must be rejected");
assert_eq!(short_err.kind(), std::io::ErrorKind::UnexpectedEof);
assert!(
!short_state
.lock()
.expect("archive state lock must remain healthy")
.body_complete
);
let overlong_state = Arc::new(Mutex::new(ExtractArchiveUploadState::default()));
let mut overlong = ExtractArchiveEtagReader::new(
std::io::Cursor::new(payload.clone()),
u64::try_from(payload.len() - 1).expect("fixture length must fit u64"),
overlong_state.clone(),
);
let overlong_err = overlong
.read_to_end(&mut Vec::new())
.await
.expect_err("overlong body must be rejected");
assert_eq!(overlong_err.kind(), std::io::ErrorKind::InvalidData);
assert!(
!overlong_state
.lock()
.expect("archive state lock must remain healthy")
.body_complete
);
}
#[tokio::test]
async fn archive_etag_reader_validates_content_md5_before_completion() {
let payload = b"archive-with-wrong-content-md5".to_vec();
let expected_length = i64::try_from(payload.len()).expect("fixture length must fit i64");
let hash_reader = HashReader::from_stream(
std::io::Cursor::new(payload),
expected_length,
expected_length,
Some("00000000000000000000000000000000".to_string()),
None,
false,
)
.expect("hash reader should be created");
let state = Arc::new(Mutex::new(ExtractArchiveUploadState::default()));
let mut reader = ExtractArchiveEtagReader::new(
hash_reader,
u64::try_from(expected_length).expect("fixture length must fit u64"),
state.clone(),
);
let err = reader
.read_to_end(&mut Vec::new())
.await
.expect_err("Content-MD5 must be checked before upload completion");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(!state.lock().expect("archive state lock must remain healthy").body_complete);
}
#[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_for_test(&mut entry, "bucket", "object", &state, &mut metadata, &mut opts)
.await
.unwrap_err()
.into_s3_error();
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_for_test(
&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() || opts.delete_marker_replication_status() == ReplicationStatusType::Replica,
),
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_eq!(
authorization
.headers
.get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER)
.and_then(|value| value.to_str().ok()),
Some("ON")
);
assert!(authorization.object_lock_legal_hold_status.is_some());
}
"version-id" => assert_eq!(opts.version_id.as_deref(), Some(Uuid::nil().to_string().as_str())),
"replication-status" => {
assert!(!authorization.headers.contains_key(AMZ_BUCKET_REPLICATION_STATUS));
assert_eq!(opts.delete_marker_replication_status(), ReplicationStatusType::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")),
("non-exact-null-version-id", pax_record("minio.versionId", b"NULL")),
];
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_for_test(
&mut entry,
"bucket",
"object",
&state,
&mut HashMap::new(),
&mut ObjectOptions::default(),
)
.await
.unwrap_err()
.into_s3_error();
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_for_test(&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));
assert_eq!(opts.delete_marker_replication_status(), ReplicationStatusType::Replica);
}
#[test]
fn snowball_pax_retention_auth_view_normalizes_offset_to_same_instant() {
let mut overrides = ExtractPaxOverrides::default();
overrides
.overlay_record("minio.metadata.x-amz-object-lock-mode", "GOVERNANCE")
.expect("retention mode should parse");
overrides
.overlay_record("minio.metadata.x-amz-object-lock-retain-until-date", "2099-01-01T00:00:00-02:00")
.expect("offset retention date should parse");
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 mut metadata = HashMap::new();
let authorization =
apply_extract_pax_overrides(&overrides, "bucket", "object", &state, &mut metadata, &mut ObjectOptions::default())
.expect("valid offset retention should apply");
let auth_value = authorization
.headers
.get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER)
.and_then(|value| value.to_str().ok())
.expect("IAM view should contain canonical retention date");
assert_eq!(auth_value, "2099-01-01T02:00:00Z");
let stored_value = metadata
.get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER)
.expect("retention date should be persisted");
let auth_instant = OffsetDateTime::parse(auth_value, &Rfc3339).expect("canonical auth time should parse");
let stored_instant = OffsetDateTime::parse(stored_value, &Rfc3339).expect("stored retention time should parse");
assert_eq!(auth_instant, stored_instant);
}
#[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_for_test(
&mut entry,
"bucket",
"object",
&metadata_sys::ObjectLockConfigState::ConfirmedAbsent,
&mut metadata,
&mut ObjectOptions::default(),
)
.await
.unwrap_err()
.into_s3_error();
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_for_test(
&mut entry,
"bucket",
"object",
&state,
&mut metadata,
&mut ObjectOptions::default(),
)
.await
.unwrap_err()
.into_s3_error();
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
assert!(!metadata.contains_key(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER));
}
#[test]
fn snowball_entry_type_allowlist_matches_minio_object_semantics() {
for entry_type in [
EntryType::Regular,
EntryType::new(b'\0'),
EntryType::Char,
EntryType::Block,
EntryType::Fifo,
EntryType::GNUSparse,
] {
assert_eq!(classify_extract_entry_type(entry_type), ExtractEntryKind::Object);
}
assert_eq!(classify_extract_entry_type(EntryType::Directory), ExtractEntryKind::Directory);
for entry_type in [
EntryType::Link,
EntryType::Symlink,
EntryType::Continuous,
EntryType::XGlobalHeader,
EntryType::XHeader,
EntryType::SolarisXHeader,
EntryType::Other(b'9'),
] {
assert_eq!(classify_extract_entry_type(entry_type), ExtractEntryKind::Skip);
}
}
#[test]
fn snowball_legacy_null_regular_directory_uses_effective_path_suffix() {
let mut header = Header::new_old();
header.as_mut_bytes()[TAR_TYPEFLAG_OFFSET] = b'\0';
assert!(is_legacy_null_directory(&header, "directory/"));
assert!(!is_legacy_null_directory(&header, "object"));
header.set_entry_type(EntryType::Regular);
assert!(!is_legacy_null_directory(&header, "directory/"));
}
#[test]
fn snowball_special_members_require_zero_declared_size() {
for entry_type in [EntryType::Char, EntryType::Block, EntryType::Fifo] {
validate_extract_special_entry_size(entry_type, 0).expect("zero-sized special member should be accepted");
assert!(validate_extract_special_entry_size(entry_type, 1).is_err());
}
validate_extract_special_entry_size(EntryType::GNUSparse, 1).expect("GNU sparse members retain payload semantics");
}
#[test]
fn snowball_header_views_preserve_auth_context_and_filter_member_storage() {
let mut headers = HeaderMap::new();
headers.insert(AMZ_SNOWBALL_EXTRACT, HeaderValue::from_static("true"));
headers.insert(AMZ_SNOWBALL_EXTRACT_COMPAT, HeaderValue::from_static("true"));
headers.insert(AMZ_MINIO_SNOWBALL_PREFIX, HeaderValue::from_static("prefix"));
headers.insert("x-amz-meta-acme-snowball-ignore-dirs", HeaderValue::from_static("true"));
headers.insert("snowball-auto-extract", HeaderValue::from_static("true"));
headers.insert("rustfs-snowball-ignore-errors", HeaderValue::from_static("true"));
headers.insert("x-amz-meta-owner", HeaderValue::from_static("alice"));
headers.insert("cache-control", HeaderValue::from_static("max-age=60"));
let auth_headers = headers.clone();
let member_headers = snowball_member_headers(&headers);
assert_eq!(auth_headers, headers, "IAM conditions must see every signed request header");
assert!(auth_headers.contains_key(AMZ_SNOWBALL_EXTRACT));
assert!(auth_headers.contains_key(AMZ_MINIO_SNOWBALL_PREFIX));
assert!(!member_headers.contains_key(AMZ_SNOWBALL_EXTRACT));
assert!(!member_headers.contains_key(AMZ_SNOWBALL_EXTRACT_COMPAT));
assert!(!member_headers.contains_key(AMZ_MINIO_SNOWBALL_PREFIX));
assert!(!member_headers.contains_key("x-amz-meta-acme-snowball-ignore-dirs"));
assert!(!member_headers.contains_key("snowball-auto-extract"));
assert!(!member_headers.contains_key("rustfs-snowball-ignore-errors"));
assert_eq!(member_headers.get("x-amz-meta-owner"), Some(&HeaderValue::from_static("alice")));
assert_eq!(member_headers.get("cache-control"), Some(&HeaderValue::from_static("max-age=60")));
}
#[test]
fn snowball_pax_tagging_reuses_put_tag_parser_and_validator() {
let mut valid = ExtractPaxOverrides::default();
valid
.overlay_record("minio.metadata.x-amz-tagging", "project=rustfs&label=snowball%20import")
.expect("encoded tags should fit in a PAX header");
let mut metadata = HashMap::new();
apply_extract_pax_overrides(
&valid,
"bucket",
"tagged.txt",
&metadata_sys::ObjectLockConfigState::ConfirmedAbsent,
&mut metadata,
&mut ObjectOptions::default(),
)
.expect("valid object tags should be canonicalized");
assert_eq!(
metadata.get(AMZ_OBJECT_TAGGING).map(String::as_str),
Some("project=rustfs&label=snowball+import")
);
let too_many = (0..11)
.map(|index| format!("k{index}=v{index}"))
.collect::<Vec<_>>()
.join("&");
for (case, tagging) in [
("duplicate", "project=rustfs&project=cli".to_string()),
("bad-percent-encoding", "project=rustfs%ZZ".to_string()),
("too-many", too_many),
] {
let mut invalid = ExtractPaxOverrides::default();
invalid
.overlay_record("minio.metadata.x-amz-tagging", &tagging)
.expect("tag validation should happen at member application");
let err = apply_extract_pax_overrides(
&invalid,
"bucket",
"tagged.txt",
&metadata_sys::ObjectLockConfigState::ConfirmedAbsent,
&mut HashMap::new(),
&mut ObjectOptions::default(),
)
.expect_err("invalid PAX object tags must be rejected");
assert_eq!(err.code(), &S3ErrorCode::InvalidTag, "{case}");
}
}
#[test]
fn snowball_pax_auth_view_only_contains_applied_condition_fields() {
let mut overrides = ExtractPaxOverrides::default();
for (name, value) in [
("user-agent", "trusted"),
("authorization", "AWS4-HMAC-SHA256 injected"),
("x-amz-server-side-encryption", "AES256"),
(AMZ_STORAGE_CLASS, "STANDARD"),
("x-amz-tagging", "project=rustfs"),
] {
overrides
.overlay_record(&format!("minio.metadata.{name}"), value)
.expect("test PAX metadata should parse");
}
let authorization = apply_extract_pax_overrides(
&overrides,
"bucket",
"object",
&metadata_sys::ObjectLockConfigState::ConfirmedAbsent,
&mut HashMap::new(),
&mut ObjectOptions::default(),
)
.expect("allowed PAX metadata should apply");
assert_eq!(authorization.headers.len(), 2);
assert_eq!(authorization.headers.get(AMZ_STORAGE_CLASS), Some(&HeaderValue::from_static("STANDARD")));
assert_eq!(
authorization.headers.get("x-amz-tagging"),
Some(&HeaderValue::from_static("project=rustfs"))
);
for prohibited in ["user-agent", "authorization", "x-amz-server-side-encryption"] {
assert!(!authorization.headers.contains_key(prohibited));
}
}
#[test]
fn snowball_pax_rejects_invalid_storage_class() {
let mut overrides = ExtractPaxOverrides::default();
overrides
.overlay_record("minio.metadata.x-amz-storage-class", "INVALID")
.expect("storage class validation should happen after PAX parsing");
let err = apply_extract_pax_overrides(
&overrides,
"bucket",
"object",
&metadata_sys::ObjectLockConfigState::ConfirmedAbsent,
&mut HashMap::new(),
&mut ObjectOptions::default(),
)
.expect_err("invalid PAX storage class must be rejected");
assert_eq!(err.code(), &S3ErrorCode::InvalidStorageClass);
}
#[test]
fn snowball_pax_enforces_effective_metadata_budgets_without_panicking() {
let header_name = "x-test";
let exact_header_value = "v".repeat(EXTRACT_MAX_EFFECTIVE_PAX_HEADER_BYTES - header_name.len());
let mut exact_headers = ExtractPaxOverrides::default();
exact_headers
.overlay_record(&format!("minio.metadata.{header_name}"), &exact_header_value)
.expect("exact effective header budget should be accepted");
assert_eq!(exact_headers.header_bytes, EXTRACT_MAX_EFFECTIVE_PAX_HEADER_BYTES);
assert!(
exact_headers
.overlay_record(
&format!("minio.metadata.{header_name}"),
&"v".repeat(EXTRACT_MAX_EFFECTIVE_PAX_HEADER_BYTES - header_name.len() + 1),
)
.is_err()
);
let user_name = "x-amz-meta-owner";
let exact_user_value = "u".repeat(EXTRACT_MAX_EFFECTIVE_PAX_USER_METADATA_BYTES - user_name.len());
let mut exact_user_metadata = ExtractPaxOverrides::default();
exact_user_metadata
.overlay_record(&format!("minio.metadata.{user_name}"), &exact_user_value)
.expect("exact user metadata budget should be accepted");
assert_eq!(exact_user_metadata.user_metadata_bytes, EXTRACT_MAX_EFFECTIVE_PAX_USER_METADATA_BYTES);
assert!(
exact_user_metadata
.overlay_record(
&format!("minio.metadata.{user_name}"),
&"u".repeat(EXTRACT_MAX_EFFECTIVE_PAX_USER_METADATA_BYTES - user_name.len() + 1),
)
.is_err()
);
let mut many_fields = ExtractPaxOverrides::default();
let mut first_error = None;
for index in 0..EXTRACT_MAX_EFFECTIVE_PAX_FIELDS + 1 {
if let Err(err) = many_fields.overlay_record(&format!("minio.metadata.x-field-{index}"), "v") {
first_error = Some(err);
break;
}
}
assert!(first_error.is_some(), "bounded PAX state must reject before HeaderMap capacity");
assert!(many_fields.headers.len() < EXTRACT_MAX_EFFECTIVE_PAX_FIELDS);
}
#[test]
fn snowball_expanded_pax_metadata_total_accepts_exact_limit() {
validate_extract_expanded_pax_metadata_total(EXTRACT_MAX_EXPANDED_PAX_METADATA_BYTES)
.expect("exact expanded metadata limit should be accepted");
assert!(validate_extract_expanded_pax_metadata_total(EXTRACT_MAX_EXPANDED_PAX_METADATA_BYTES + 1).is_err());
}
#[test]
fn snowball_pax_metadata_precedence_is_outer_then_local() {
let mut local = ExtractPaxOverrides::default();
local
.overlay_record("minio.metadata.x-amz-meta-snowball-auto-extract", "true")
.expect("transport metadata should be ignored");
local
.overlay_record("minio.metadata.X-Amz-Meta-Owner", "local")
.expect("local owner metadata should parse");
local
.overlay_record("minio.metadata.x-amz-tagging", "classification=public")
.expect("local tags should parse");
let mut local_metadata = HashMap::from([("owner".to_string(), "outer".to_string())]);
let authorization = apply_extract_pax_overrides(
&local,
"bucket",
"local.txt",
&metadata_sys::ObjectLockConfigState::ConfirmedAbsent,
&mut local_metadata,
&mut ObjectOptions::default(),
)
.expect("local PAX metadata should apply");
assert_eq!(local_metadata.get("owner").map(String::as_str), Some("local"));
assert_eq!(local_metadata.get(AMZ_OBJECT_TAGGING).map(String::as_str), Some("classification=public"));
assert!(!local_metadata.contains_key("snowball-auto-extract"));
assert!(!authorization.headers.contains_key(AMZ_SNOWBALL_EXTRACT));
}
#[test]
fn snowball_version_id_accepts_exact_null_and_requires_versioning_for_uuids() {
let mut unversioned = ObjectOptions::default();
assert_eq!(
apply_extract_version_id("null", &mut unversioned).expect("exact null should be accepted"),
"null"
);
assert_eq!(unversioned.version_id.as_deref(), Some(Uuid::nil().to_string().as_str()));
assert!(apply_extract_version_id("NULL", &mut ObjectOptions::default()).is_err());
let version_id = Uuid::new_v4().to_string();
assert!(apply_extract_version_id(&version_id, &mut ObjectOptions::default()).is_err());
let mut versioned = ObjectOptions {
versioned: true,
..Default::default()
};
assert_eq!(
apply_extract_version_id(&version_id, &mut versioned).expect("UUID should be accepted for a versioned key"),
version_id
);
}
#[test]
fn snowball_notification_version_id_follows_member_versioning_state() {
let nil = Some(Uuid::nil());
assert!(extract_notification_version_id(nil, false, false).is_empty());
assert_eq!(extract_notification_version_id(nil, true, false), "null");
assert_eq!(extract_notification_version_id(nil, false, true), "null");
assert!(extract_notification_version_id(None, true, false).is_empty());
let version_id = Uuid::new_v4();
assert_eq!(extract_notification_version_id(Some(version_id), true, false), version_id.to_string());
}
#[test]
fn snowball_iam_requirements_follow_final_member_state() {
let metadata = HashMap::from([(AMZ_OBJECT_TAGGING.to_string(), "project=snowball".to_string())]);
let legal_hold = ObjectLockLegalHoldStatus::from("ON".to_string());
let mode = ObjectLockMode::from("GOVERNANCE".to_string());
let retain_until = Timestamp::from(OffsetDateTime::now_utc());
assert_eq!(
extract_member_iam_requirements(&metadata, Some(&legal_hold), Some(&mode), Some(&retain_until), Some("null"), true,),
ExtractMemberIamRequirements {
tagging: true,
retention: true,
legal_hold: true,
replication: true,
}
);
let bucket_default_retention = HashMap::from([
(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), "COMPLIANCE".to_string()),
(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), "2099-01-01T00:00:00Z".to_string()),
]);
assert!(!extract_member_iam_requirements(&bucket_default_retention, None, None, None, None, false,).retention);
}
#[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 extract_entry_error_boundary_only_ignores_recoverable_members() {
let recoverable = ExtractEntryError::Recoverable(object_s3_error(S3ErrorCode::InvalidArgument, "invalid member"));
recoverable
.ignore_or_return(true)
.expect("recoverable member should be skipped");
let recoverable = ExtractEntryError::Recoverable(object_s3_error(S3ErrorCode::InvalidArgument, "invalid member"));
assert_eq!(
recoverable
.ignore_or_return(false)
.expect_err("recoverable member should fail without ignore-errors")
.code(),
&S3ErrorCode::InvalidArgument
);
let fatal = ExtractEntryError::Fatal(object_s3_error(S3ErrorCode::InvalidArgument, "invalid archive"));
assert_eq!(
fatal
.ignore_or_return(true)
.expect_err("fatal archive errors must ignore ignore-errors")
.code(),
&S3ErrorCode::InvalidArgument
);
}
#[test]
fn strict_extract_entry_path_rejects_non_utf8_without_lossy_replacement() {
let invalid = strict_extract_entry_path(b"same-\xff-key").expect_err("non-UTF-8 member path must be rejected");
assert!(invalid.is_recoverable());
invalid
.ignore_or_return(true)
.expect("ignore-errors should skip an invalid member key");
}
#[test]
fn classify_extract_entry_type_skips_links_extensions_and_continuous_entries() {
assert_eq!(classify_extract_entry_type(EntryType::Regular), ExtractEntryKind::Object);
assert_eq!(classify_extract_entry_type(EntryType::Directory), ExtractEntryKind::Directory);
for entry_type in [
EntryType::Link,
EntryType::Symlink,
EntryType::Continuous,
EntryType::XGlobalHeader,
EntryType::Other(b'V'),
] {
assert_eq!(
classify_extract_entry_type(entry_type),
ExtractEntryKind::Skip,
"{entry_type:?} must not be materialized as an object"
);
}
}
#[test]
fn extract_entry_quota_growth_counts_only_materialized_files() {
assert_eq!(extract_entry_quota_growth(ExtractEntryKind::Object, 9), 9);
assert_eq!(extract_entry_quota_growth(ExtractEntryKind::Directory, 9), 0);
assert_eq!(extract_entry_quota_growth(ExtractEntryKind::Skip, 9), 0);
}
#[test]
fn archive_mod_time_treats_negative_gnu_base256_as_unset() {
let mut header = Header::new_gnu();
header.as_old_mut().mtime.fill(0xff);
assert_eq!(
extract_archive_entry_mod_time(&header).expect("negative GNU mtime should be accepted"),
None
);
}
#[test]
fn archive_mod_time_keeps_malformed_octal_fatal() {
let mut header = Header::new_ustar();
header.as_old_mut().mtime.fill(b'9');
let err = extract_archive_entry_mod_time(&header).expect_err("malformed octal mtime must be rejected");
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
}
#[test]
fn pax_metadata_budget_is_fatal_even_with_ignore_errors() {
let limits = ArchiveLimits {
max_pax_metadata_size: 3,
..ArchiveLimits::default()
};
let mut entry_size = 0;
let mut total_size = 0;
let err = record_extract_pax_metadata_bytes(&mut entry_size, &mut total_size, 2, 2, limits)
.expect_err("PAX metadata over the resource budget must fail");
assert!(!err.is_recoverable());
assert!(err.ignore_or_return(true).is_err(), "ignore-errors must not bypass resource limits");
}
#[test]
fn pax_metadata_record_budget_is_fatal_even_with_ignore_errors() {
let limits = ArchiveLimits {
max_pax_metadata_records: 1,
..ArchiveLimits::default()
};
let mut entry_records = 0;
let mut total_records = 0;
record_extract_pax_metadata_record(&mut entry_records, &mut total_records, limits)
.expect("first PAX record should fit the budget");
let err = record_extract_pax_metadata_record(&mut entry_records, &mut total_records, limits)
.expect_err("second PAX record must exceed the per-entry budget");
assert!(!err.is_recoverable());
assert!(err.ignore_or_return(true).is_err(), "ignore-errors must not bypass record limits");
let limits = ArchiveLimits {
max_pax_metadata_records: 2,
max_total_pax_metadata_records: 1,
..ArchiveLimits::default()
};
let mut first_entry_records = 0;
let mut second_entry_records = 0;
let mut total_records = 0;
record_extract_pax_metadata_record(&mut first_entry_records, &mut total_records, limits)
.expect("first archive PAX record should fit the total budget");
let err = record_extract_pax_metadata_record(&mut second_entry_records, &mut total_records, limits)
.expect_err("second archive PAX record must exceed the total budget");
assert!(!err.is_recoverable());
}
#[tokio::test]
async fn extract_archive_builder_rejects_oversized_extension_payload() {
let record = pax_record("comment", b"value");
let mut builder = Builder::new(Vec::new());
let mut extension = Header::new_ustar();
extension.set_entry_type(EntryType::XHeader);
extension.set_size(u64::try_from(record.len()).expect("fixture size must fit u64"));
extension.set_cksum();
builder
.append_data(&mut extension, "pax", record.as_slice())
.await
.expect("PAX extension fixture should be appended");
let bytes = builder.into_inner().await.expect("PAX extension fixture should finalize");
let limits = ArchiveLimits {
max_pax_metadata_size: u64::try_from(record.len() - 1).expect("fixture size must fit u64"),
..ArchiveLimits::default()
};
let mut archive = build_put_object_extract_archive(std::io::Cursor::new(bytes), limits);
let err = archive
.entries()
.expect("archive entry stream should be created")
.next()
.await
.expect("extension header should produce a result")
.expect_err("dependency must reject the extension before buffering its payload");
assert_eq!(err.to_string(), "archive extension entry size limit exceeded");
}
#[tokio::test]
async fn pax_metadata_budget_counts_format_skipped_members() {
let record = pax_record("comment", b"oversized-symlink-metadata");
let mut entry = entry_with_local_pax(&record, EntryType::Symlink).await;
let limits = ArchiveLimits {
max_pax_metadata_size: 8,
..ArchiveLimits::default()
};
let mut total_size = 0;
let mut total_records = 0;
let err = count_extract_entry_pax_metadata(&mut entry, &mut total_size, &mut total_records, limits)
.await
.expect_err("format-skipped members must still consume the PAX budget");
assert!(!err.is_recoverable());
assert!(err.ignore_or_return(true).is_err());
}
#[tokio::test]
async fn pax_metadata_budget_precedes_recoverable_semantic_errors() {
let invalid_key = "minio.metadata.x-amz-meta-owner";
let mut record = pax_record(invalid_key, b"\0");
record.extend(pax_record("comment", b"oversized-tail"));
let mut entry = entry_with_local_pax(&record, EntryType::Regular).await;
let semantic_err = apply_extract_entry_pax_extensions(
&mut entry,
"bucket",
"member",
&metadata_sys::ObjectLockConfigState::ConfirmedAbsent,
&mut HashMap::new(),
&mut ObjectOptions::default(),
)
.await
.expect_err("NUL metadata value should be a recoverable member error");
assert!(semantic_err.is_recoverable());
let limits = ArchiveLimits {
max_pax_metadata_size: u64::try_from(invalid_key.len() + 1).expect("fixture size must fit u64"),
..ArchiveLimits::default()
};
let mut total_size = 0;
let mut total_records = 0;
let budget_err = count_extract_entry_pax_metadata(&mut entry, &mut total_size, &mut total_records, limits)
.await
.expect_err("the oversized tail must be counted before ignore-errors can skip the member");
assert!(!budget_err.is_recoverable());
assert!(budget_err.ignore_or_return(true).is_err());
}
#[tokio::test]
async fn extract_decoded_reader_enforces_exact_byte_limit() {
let mut exact = ExtractDecodedLimitReader::new(std::io::Cursor::new(b"1234"), 4);
let mut exact_bytes = Vec::new();
exact
.read_to_end(&mut exact_bytes)
.await
.expect("decoded stream at the limit should succeed");
assert_eq!(exact_bytes, b"1234");
let mut oversized = ExtractDecodedLimitReader::new(std::io::Cursor::new(b"12345"), 4);
let mut oversized_bytes = Vec::new();
let err = oversized
.read_to_end(&mut oversized_bytes)
.await
.expect_err("decoded stream over the limit must fail");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[tokio::test]
async fn extract_member_read_tracker_keeps_integrity_errors_fatal_under_ignore_errors() {
struct FailingReader;
impl AsyncRead for FailingReader {
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "member decoder failed")))
}
}
let reader = HashReader::from_stream(FailingReader, 1, 1, None, None, false).unwrap();
let (mut tracked, failed) = track_extract_member_read_errors(reader).unwrap();
let mut output = Vec::new();
let err = tracked.read_to_end(&mut output).await.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(failed.load(Ordering::Acquire));
let classified = classify_extract_member_write_error(object_s3_error_default(S3ErrorCode::IncompleteBody), &failed)
.into_unignored(true)
.expect("a reader-backed store error must remain fatal");
assert_eq!(classified.code(), &S3ErrorCode::IncompleteBody);
let storage_only_failure = AtomicBool::new(false);
assert!(
classify_extract_member_write_error(object_s3_error_default(S3ErrorCode::InternalError), &storage_only_failure)
.into_unignored(true)
.is_none(),
"a storage-only write failure may be ignored"
);
}
#[tokio::test]
async fn snowball_extract_body_guard_aborts_stalled_upload() {
let body = StreamingBlob::wrap(futures::stream::pending::<Result<Bytes, std::io::Error>>());
let mut guarded = guard_put_object_body_read_timeout(
body,
"test-bucket",
"archive.tar",
"snowball-timeout",
Some(512),
Duration::from_millis(1),
);
let err = guarded
.next()
.await
.expect("stalled Snowball body should yield an error")
.expect_err("stalled Snowball body must not hang");
let io_err = err
.downcast_ref::<std::io::Error>()
.expect("stall error should retain its I/O kind");
assert_eq!(io_err.kind(), std::io::ErrorKind::TimedOut);
}
#[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());
}
}