Files
rustfs/crates/zip/src/lib.rs
T
cxymds ff28b79088 fix(s3): harden Snowball archive extraction (#6942)
* 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

* ci(e2e): refresh Snowball smoke selection
2026-08-31 11:18:09 +00:00

244 lines
8.4 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use async_compression::tokio::bufread::{BzDecoder, GzipDecoder, XzDecoder, ZlibDecoder, ZstdDecoder};
use thiserror::Error;
use tokio::io::{AsyncRead, BufReader};
pub type Result<T> = std::result::Result<T, ZipError>;
#[derive(Debug, Error)]
pub enum ZipError {
#[error("unsupported {operation} for format {format:?}")]
UnsupportedFormat {
format: CompressionFormat,
operation: &'static str,
},
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum CompressionFormat {
Gzip,
Bzip2,
Zip,
Xz,
Zlib,
Zstd,
Tar,
Unknown,
}
/// Archive guardrails. The values are carried here so every archive caller
/// shares one default policy; enforcement belongs to the caller, which maps a
/// breach onto its own protocol error.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ArchiveLimits {
pub max_entries: usize,
pub max_entry_size: u64,
pub max_total_unpacked_size: u64,
pub max_decoded_size: u64,
pub max_path_length: usize,
pub max_pax_metadata_size: u64,
pub max_total_pax_metadata_size: u64,
pub max_pax_metadata_records: usize,
pub max_total_pax_metadata_records: usize,
pub validate_entry_paths: bool,
}
impl Default for ArchiveLimits {
fn default() -> Self {
Self {
max_entries: 100_000,
max_entry_size: 1_073_741_824,
max_total_unpacked_size: 10_737_418_240,
max_decoded_size: 11_811_160_064,
max_path_length: 1024,
max_pax_metadata_size: 1_048_576,
max_total_pax_metadata_size: 67_108_864,
max_pax_metadata_records: 4_096,
max_total_pax_metadata_records: 100_000,
validate_entry_paths: true,
}
}
}
impl CompressionFormat {
/// Map an archive extension onto the stream codec needed to read it.
/// Tar-family suffixes (`tgz`, `tbz2`, `txz`, `tzst`, ...) resolve to their
/// codec because the tar container itself is read from the decoded stream.
pub fn from_extension(ext: &str) -> Self {
match ext.to_ascii_lowercase().as_str() {
"gz" | "gzip" | "tgz" => CompressionFormat::Gzip,
"bz2" | "bzip2" | "tbz" | "tbz2" => CompressionFormat::Bzip2,
"xz" | "txz" => CompressionFormat::Xz,
"zlib" | "zz" => CompressionFormat::Zlib,
"zst" | "zstd" | "tzst" => CompressionFormat::Zstd,
"tar" => CompressionFormat::Tar,
"zip" => CompressionFormat::Zip,
_ => CompressionFormat::Unknown,
}
}
pub fn extension(&self) -> &'static str {
match self {
CompressionFormat::Gzip => "gz",
CompressionFormat::Bzip2 => "bz2",
CompressionFormat::Zip => "zip",
CompressionFormat::Xz => "xz",
CompressionFormat::Zlib => "zlib",
CompressionFormat::Zstd => "zst",
CompressionFormat::Tar => "tar",
CompressionFormat::Unknown => "",
}
}
pub fn get_decoder<R>(&self, input: R) -> Result<Box<dyn AsyncRead + Send + Unpin>>
where
R: AsyncRead + Send + Unpin + 'static,
{
let reader = BufReader::new(input);
let decoder: Box<dyn AsyncRead + Send + Unpin + 'static> = match self {
CompressionFormat::Gzip => {
let mut decoder = GzipDecoder::new(reader);
decoder.multiple_members(true);
Box::new(decoder)
}
CompressionFormat::Bzip2 => {
let mut decoder = BzDecoder::new(reader);
decoder.multiple_members(true);
Box::new(decoder)
}
CompressionFormat::Zlib => {
let mut decoder = ZlibDecoder::new(reader);
decoder.multiple_members(true);
Box::new(decoder)
}
CompressionFormat::Xz => {
let mut decoder = XzDecoder::new(reader);
decoder.multiple_members(true);
Box::new(decoder)
}
CompressionFormat::Zstd => {
let mut decoder = ZstdDecoder::new(reader);
decoder.multiple_members(true);
Box::new(decoder)
}
CompressionFormat::Tar => Box::new(reader),
CompressionFormat::Zip => {
return Err(ZipError::UnsupportedFormat {
format: *self,
operation: "stream decoding",
});
}
CompressionFormat::Unknown => {
return Err(ZipError::UnsupportedFormat {
format: *self,
operation: "decoding",
});
}
};
Ok(decoder)
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_compression::tokio::write::GzipEncoder;
use std::mem::size_of;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[test]
fn test_compression_format_from_extension() {
assert_eq!(CompressionFormat::from_extension("gz"), CompressionFormat::Gzip);
assert_eq!(CompressionFormat::from_extension("ZIP"), CompressionFormat::Zip);
assert_eq!(CompressionFormat::from_extension("tzst"), CompressionFormat::Zstd);
assert_eq!(CompressionFormat::from_extension("txt"), CompressionFormat::Unknown);
}
#[test]
fn test_compression_format_size_is_small() {
assert!(size_of::<CompressionFormat>() <= 8);
assert!(size_of::<Option<CompressionFormat>>() <= 16);
}
#[tokio::test]
async fn test_get_decoder_round_trips_gzip_stream() {
let mut encoder = GzipEncoder::new(Vec::new());
encoder.write_all(b"payload").await.expect("gzip encode should succeed");
encoder.shutdown().await.expect("gzip encoder shutdown should succeed");
let mut decoder = CompressionFormat::Gzip
.get_decoder(std::io::Cursor::new(encoder.into_inner()))
.expect("gzip decoder should be created");
let mut decoded = Vec::new();
decoder.read_to_end(&mut decoded).await.expect("gzip decode should succeed");
assert_eq!(decoded, b"payload");
}
#[tokio::test]
async fn test_get_decoder_consumes_concatenated_gzip_members() {
async fn gzip_member(payload: &[u8]) -> Vec<u8> {
let mut encoder = GzipEncoder::new(Vec::new());
encoder.write_all(payload).await.expect("gzip encode should succeed");
encoder.shutdown().await.expect("gzip encoder shutdown should succeed");
encoder.into_inner()
}
let mut encoded = gzip_member(b"first-").await;
encoded.extend(gzip_member(b"second").await);
let mut decoder = CompressionFormat::Gzip
.get_decoder(std::io::Cursor::new(encoded))
.expect("gzip decoder should be created");
let mut decoded = Vec::new();
decoder
.read_to_end(&mut decoded)
.await
.expect("concatenated gzip members should decode");
assert_eq!(decoded, b"first-second");
}
#[tokio::test]
async fn test_get_decoder_rejects_zip_and_unknown_formats() {
let zip_err = CompressionFormat::Zip
.get_decoder(std::io::Cursor::new(Vec::<u8>::new()))
.err()
.expect("zip stream decoding should be rejected");
assert!(matches!(
zip_err,
ZipError::UnsupportedFormat {
format: CompressionFormat::Zip,
operation: "stream decoding",
}
));
let unknown_err = CompressionFormat::Unknown
.get_decoder(std::io::Cursor::new(Vec::<u8>::new()))
.err()
.expect("unknown format decoding should be rejected");
assert!(matches!(
unknown_err,
ZipError::UnsupportedFormat {
format: CompressionFormat::Unknown,
operation: "decoding",
}
));
}
}