mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-09 14:49:25 +00:00
fix(rio): map truncated put bodies to incompletebody (#3168)
* fix(rio): surface incomplete put bodies Propagate incomplete PUT request bodies as IncompleteBody instead of allowing erasure encode to treat truncated input as a normal EOF.\n\n- mark premature EOFs in HardLimitReader with an explicit IncompleteBody error\n- preserve EOF error chains through read_full and map them to S3 IncompleteBody\n- stop erasure encode from swallowing UnexpectedEof on truncated input\n- add regression tests for reader, erasure encode, and API error mapping\n\nRefs: rustfs/backlog#654 * fix(s3): honor decoded length for aws chunked put Use x-amz-decoded-content-length for aws-chunked PutObject requests so trailer-checksum uploads are sized against the decoded payload instead of the wire-encoded content-length.\n\n- prefer decoded content length for aws-chunked put bodies\n- add a regression test covering the size selection logic\n- keeps the incomplete body fix working for truly truncated uploads while restoring checksum trailer compatibility\n\nRefs: rustfs/backlog#654 * fix(io): follow up review comments on incompletebody handling Address PR review feedback by restoring read_full's existing EOF contract, adding a dedicated read_full_or_eof helper for erasure encoding, covering nested incomplete-body error chains, and documenting plus hardening aws-chunked size selection.\n\n- keep read_full returning early EOF on empty reads\n- use read_full_or_eof only in erasure encoding paths\n- detect aws-chunked via content-encoding or transfer-encoding\n- add nested error-chain and aws-chunked regression tests\n\nRefs: rustfs/backlog#654 * fix(rio): surface incomplete put bodies Propagate incomplete PUT request bodies as IncompleteBody instead of allowing erasure encode to treat truncated input as a normal EOF.\n\n- mark premature EOFs in HardLimitReader with an explicit IncompleteBody error\n- preserve EOF error chains through read_full and map them to S3 IncompleteBody\n- stop erasure encode from swallowing UnexpectedEof on truncated input\n- add regression tests for reader, erasure encode, and API error mapping\n\nRefs: rustfs/backlog#654 * fix(s3): honor decoded length for aws chunked put Use x-amz-decoded-content-length for aws-chunked PutObject requests so trailer-checksum uploads are sized against the decoded payload instead of the wire-encoded content-length.\n\n- prefer decoded content length for aws-chunked put bodies\n- add a regression test covering the size selection logic\n- keeps the incomplete body fix working for truly truncated uploads while restoring checksum trailer compatibility\n\nRefs: rustfs/backlog#654 * fix(io): follow up review comments on incompletebody handling Address PR review feedback by restoring read_full's existing EOF contract, adding a dedicated read_full_or_eof helper for erasure encoding, covering nested incomplete-body error chains, and documenting plus hardening aws-chunked size selection.\n\n- keep read_full returning early EOF on empty reads\n- use read_full_or_eof only in erasure encoding paths\n- detect aws-chunked via content-encoding or transfer-encoding\n- add nested error-chain and aws-chunked regression tests\n\nRefs: rustfs/backlog#654 * fix(rio): reject bytes beyond hard limit * fix(ecstore): reject zero-sized erasure blocks
This commit is contained in:
@@ -129,6 +129,28 @@ const ACCEPT_RANGES_BYTES: &str = "bytes";
|
||||
const MAX_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 64 * 1024 * 1024;
|
||||
static GET_OBJECT_BUFFER_THRESHOLD_WARNED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn decoded_content_length_from_headers(headers: &HeaderMap) -> S3Result<Option<i64>> {
|
||||
let Some(val) = headers.get(AMZ_DECODED_CONTENT_LENGTH) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match atoi::atoi::<i64>(val.as_bytes()) {
|
||||
Some(x) => Ok(Some(x)),
|
||||
None => Err(s3_error!(UnexpectedContent)),
|
||||
}
|
||||
}
|
||||
|
||||
fn request_uses_aws_chunked(headers: &HeaderMap) -> bool {
|
||||
let has_aws_chunked = |header_name: &str| {
|
||||
headers
|
||||
.get(header_name)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.split(',').any(|part| part.trim().eq_ignore_ascii_case("aws-chunked")))
|
||||
};
|
||||
|
||||
has_aws_chunked("content-encoding") || has_aws_chunked("transfer-encoding")
|
||||
}
|
||||
|
||||
struct DeadlockRequestGuard {
|
||||
deadlock_detector: Arc<deadlock_detector::DeadlockDetector>,
|
||||
request_id: String,
|
||||
@@ -1728,18 +1750,12 @@ impl DefaultObjectUsecase {
|
||||
|
||||
let Some(body) = body else { return Err(s3_error!(IncompleteBody)) };
|
||||
|
||||
let mut 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));
|
||||
}
|
||||
}
|
||||
let decoded_content_length = decoded_content_length_from_headers(&req.headers)?;
|
||||
let mut size = match (request_uses_aws_chunked(&req.headers), decoded_content_length, content_length) {
|
||||
(true, Some(decoded), _) => decoded,
|
||||
(_, _, Some(c)) => c,
|
||||
(_, Some(decoded), None) => decoded,
|
||||
_ => return Err(s3_error!(UnexpectedContent)),
|
||||
};
|
||||
|
||||
if size == -1 {
|
||||
@@ -4633,6 +4649,26 @@ mod tests {
|
||||
assert!(!should_use_zero_copy(2 * 1024 * 1024, &headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aws_chunked_put_prefers_decoded_content_length() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("content-encoding", HeaderValue::from_static("aws-chunked"));
|
||||
headers.insert(AMZ_DECODED_CONTENT_LENGTH, HeaderValue::from_static("71680"));
|
||||
|
||||
let decoded = decoded_content_length_from_headers(&headers).expect("decoded content length should parse");
|
||||
assert!(request_uses_aws_chunked(&headers));
|
||||
assert_eq!(decoded, Some(71680));
|
||||
|
||||
let resolved = match (request_uses_aws_chunked(&headers), decoded, Some(99999)) {
|
||||
(true, Some(decoded), _) => decoded,
|
||||
(_, _, Some(c)) => c,
|
||||
(_, Some(decoded), None) => decoded,
|
||||
_ => unreachable!("test provides a valid size source"),
|
||||
};
|
||||
|
||||
assert_eq!(resolved, 71680);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_buffer_get_object_in_memory_respects_hard_safety_cap() {
|
||||
let info = ObjectInfo::default();
|
||||
|
||||
+53
-4
@@ -181,6 +181,31 @@ impl ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
fn error_chain_has_type<T>(err: &(dyn std::error::Error + 'static)) -> bool
|
||||
where
|
||||
T: std::error::Error + 'static,
|
||||
{
|
||||
if err.downcast_ref::<T>().is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(io_err) = err.downcast_ref::<std::io::Error>()
|
||||
&& let Some(inner) = io_err.get_ref()
|
||||
&& error_chain_has_type::<T>(inner)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut current = Some(err);
|
||||
while let Some(err) = current {
|
||||
if err.downcast_ref::<T>().is_some() {
|
||||
return true;
|
||||
}
|
||||
current = err.source();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
impl From<ApiError> for S3Error {
|
||||
fn from(err: ApiError) -> Self {
|
||||
let mut s3e = S3Error::with_message(err.code, err.message);
|
||||
@@ -260,17 +285,18 @@ impl From<std::io::Error> for ApiError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
// Check if the error is a ChecksumMismatch (BadDigest)
|
||||
if let Some(inner) = err.get_ref() {
|
||||
if inner.downcast_ref::<rustfs_rio::ChecksumMismatch>().is_some() {
|
||||
if error_chain_has_type::<rustfs_rio::ChecksumMismatch>(inner) || error_chain_has_type::<rustfs_rio::BadDigest>(inner)
|
||||
{
|
||||
return ApiError {
|
||||
code: S3ErrorCode::BadDigest,
|
||||
message: ApiError::error_code_to_message(&S3ErrorCode::BadDigest),
|
||||
source: Some(Box::new(err)),
|
||||
};
|
||||
}
|
||||
if inner.downcast_ref::<rustfs_rio::BadDigest>().is_some() {
|
||||
if error_chain_has_type::<rustfs_rio::IncompleteBody>(inner) {
|
||||
return ApiError {
|
||||
code: S3ErrorCode::BadDigest,
|
||||
message: ApiError::error_code_to_message(&S3ErrorCode::BadDigest),
|
||||
code: S3ErrorCode::IncompleteBody,
|
||||
message: ApiError::error_code_to_message(&S3ErrorCode::IncompleteBody),
|
||||
source: Some(Box::new(err)),
|
||||
};
|
||||
}
|
||||
@@ -447,6 +473,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_error_from_unexpected_eof_maps_to_incomplete_body() {
|
||||
let io_error = IoError::new(ErrorKind::UnexpectedEof, rustfs_rio::IncompleteBody { remaining: 7 });
|
||||
let api_error: ApiError = io_error.into();
|
||||
|
||||
assert_eq!(api_error.code, S3ErrorCode::IncompleteBody);
|
||||
assert_eq!(api_error.message, ApiError::error_code_to_message(&S3ErrorCode::IncompleteBody));
|
||||
assert!(api_error.source.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_error_from_nested_unexpected_eof_maps_to_incomplete_body() {
|
||||
let nested = IoError::new(
|
||||
ErrorKind::UnexpectedEof,
|
||||
IoError::new(ErrorKind::UnexpectedEof, rustfs_rio::IncompleteBody { remaining: 7 }),
|
||||
);
|
||||
let api_error: ApiError = nested.into();
|
||||
|
||||
assert_eq!(api_error.code, S3ErrorCode::IncompleteBody);
|
||||
assert_eq!(api_error.message, ApiError::error_code_to_message(&S3ErrorCode::IncompleteBody));
|
||||
assert!(api_error.source.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_error_from_iam_error() {
|
||||
let iam_error = rustfs_iam::error::Error::other("IAM test error");
|
||||
|
||||
Reference in New Issue
Block a user