Files
rustfs/rustfs/src/app/object/extract.rs
T
Zhengchao An d8c3b1bb26 fix(app): fail closed on an unreadable bucket encryption config (#7183)
The object write path read the bucket default encryption configuration
with `.ok()`, which made "this bucket has no default encryption" and "the
encryption configuration cannot be read" the same value. A bucket whose
encryption blob is damaged therefore stored plaintext objects the
operator had mandated be encrypted, with nothing returned to the client
and nothing in the object to tell those writes apart afterwards.

PUT, COPY and the snowball extract path now share one resolver: an
absent configuration still writes plaintext exactly as before, and every
other outcome refuses the write, carrying the accessor's typed error so
a damaged blob surfaces as a deterministic InternalError while a
transient metadata read failure surfaces as the retryable
ServiceUnavailable. A missing bucket and a cold metadata cache both
still resolve to "no configuration", so neither becomes a refusal. This
matches `prepare_sse_configuration` in `storage::sse`, the resolver the
multipart writer has always used, which fails closed on this lookup.
2026-09-05 14:13:59 +08:00

4794 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 = load_bucket_default_sse_config(&bucket).await?;
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_GENERATION, Uuid::new_v4().to_string());
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());
}
}