mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-06 03:59:14 +00:00
fix(s3): align Snowball codec compatibility (#6943)
* fix(s3): harden Snowball extract error boundaries * fix(s3): close Snowball extract compatibility gaps * fix(s3): verify Snowball request body completion * test(s3): reject forged Snowball streaming signatures * build(deps): pin Snowball archive parser limits * fix(s3): preserve Snowball trailer and member errors * docs(architecture): register Snowball tar fork cleanup * refactor(s3): route Snowball errors through object boundary * ci(deps): allow pinned tokio-tar source * fix: align Snowball archive codec detection * fix(s3): harden Snowball codec compatibility * fix(s3): preserve Snowball codec compatibility * test(zip): align yield wake assertion with Tokio * fix(rio): preserve legacy large-block reads * fix(zip): accept blank tar numeric fields
This commit is contained in:
@@ -63,6 +63,22 @@ struct ExtractArchiveUploadState {
|
||||
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 {
|
||||
@@ -1008,12 +1024,6 @@ impl DefaultObjectUsecase {
|
||||
let body =
|
||||
tokio::io::BufReader::with_capacity(buffer_size, StreamReader::new(body.map(|f| f.map_err(s3s_body_error_to_io))));
|
||||
|
||||
let Some(ext) = Path::new(&key).extension().and_then(|s| s.to_str()) else {
|
||||
return Err(s3_error!(InvalidArgument, "key extension not found"));
|
||||
};
|
||||
|
||||
let ext = ext.to_owned();
|
||||
|
||||
let md5hex = if let Some(base64_md5) = content_md5 {
|
||||
let md5 = base64_simd::STANDARD
|
||||
.decode_to_vec(base64_md5.as_bytes())
|
||||
@@ -1036,16 +1046,18 @@ impl DefaultObjectUsecase {
|
||||
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 decoder = CompressionFormat::from_extension(&ext)
|
||||
.get_decoder(ExtractArchiveEtagReader::new(
|
||||
archive_reader,
|
||||
expected_archive_length,
|
||||
archive_upload_state.clone(),
|
||||
))
|
||||
.map_err(|e| {
|
||||
error!(error = ?e, "Archive decoder creation failed");
|
||||
s3_error!(InvalidArgument, "get_decoder err")
|
||||
let tracked_archive =
|
||||
ExtractArchiveEtagReader::new(archive_reader, expected_archive_length, archive_upload_state.clone());
|
||||
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")
|
||||
})?;
|
||||
let decoder = ExtractDecodedLimitReader::new(decoder, extract_limits.max_decoded_size);
|
||||
|
||||
let mut ar = build_put_object_extract_archive(decoder, extract_limits);
|
||||
@@ -1488,6 +1500,92 @@ mod tests {
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio_tar::{Builder, EntryType, Header};
|
||||
|
||||
#[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;
|
||||
|
||||
@@ -161,7 +161,7 @@ use rustfs_utils::http::{
|
||||
};
|
||||
use rustfs_utils::path::{encode_dir_object, is_dir_object, path_join_buf};
|
||||
use rustfs_utils::retry::{DEFAULT_RETRY_CAP, DEFAULT_RETRY_UNIT, MAX_JITTER, RetryTimer};
|
||||
use rustfs_zip::{ArchiveLimits, CompressionFormat};
|
||||
use rustfs_zip::{ArchiveLimits, CompressionFormat, ZipError};
|
||||
use s3s::StdError;
|
||||
use s3s::dto::{
|
||||
CacheControl, Checksum, ChecksumAlgorithm, ChecksumType, ContentDisposition, ContentEncoding, ContentLanguage, ContentType,
|
||||
|
||||
Reference in New Issue
Block a user