Files
rustfs/crates/e2e_test/src/degraded_read_eof_regression_test.rs
T
Zhengchao An ac646cfbe4 test(e2e): large-object degraded-read EOF truncation regression net (dist-13) (#4709)
Adds an S3-API-level e2e regression net proving that a large-object GET on a
degraded EC set never returns a silently truncated body under a full
Content-Length -- the historical "unexpected EOF" bug fixed on main by
rustfs#4594 (short-body GetObject stream -> UnexpectedEof), rustfs#4560
(in-place per-part legacy degradation for the lazy multipart reader), and
rustfs#4585 (DARE package-boundary truncation detection). Those fixes each
ship a *unit* regression; this covers the layer they do not -- the full HTTP
GET path streaming a real body reconstructed from real on-disk EC shards.

New file crates/e2e_test/src/degraded_read_eof_regression_test.rs, single-node
4-disk (EC 2+2) DiskFaultHarness, three scenarios:

  (a) one disk offline: a 6 MiB single object (>=2 EC stripes) and a 3x5 MiB
      multipart object GET back byte-identical with the correct Content-Length.
  (b) mid-stream bitrot within quorum: 2 of 4 shards corrupted mid-file on a
      large multipart object still reconstructs the full, hash-matching body.
  (c) beyond read quorum (the heart of the net): 3 of 4 shards corrupted
      mid-file -- the read MUST fail cleanly (non-2xx or a mid-stream body
      error), NEVER close with a truncated body under the full Content-Length.

The shared get_checked() helper panics on the forbidden outcome (a clean 2xx
whose collected body is shorter than the advertised Content-Length), so the
truncation bug can never be silently tolerated.

CI placement: these spawn a 4-disk server per test and are resource-heavy, so
they stay OUT of the fast PR e2e-smoke filter. A new e2e-reliability nextest
test-group (max-threads=1) serializes them (and the existing
reliability_disk_fault_test) across nextest's process boundary; ci-7's nightly
full-e2e run picks them up. Visible via `cargo nextest list -p e2e_test`.

Refs rustfs/backlog#1150 (dist-13), rustfs/backlog#1155, rustfs#4594,
rustfs#4560, rustfs#4585, rustfs#2955.
2026-07-11 02:49:48 +00:00

444 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.
//! E2E regression net for the large-object degraded-read "unexpected EOF"
//! silent-truncation bug (dist-13, backlog#1150 / master plan backlog#1155).
//!
//! The bug: an EC reconstruct-read that failed *mid-stream* (a later block or
//! part could not be reconstructed) used to close the HTTP body cleanly under a
//! full `Content-Length`, so the client saw a `200` with a **truncated body**
//! and treated it as complete (e.g. `mc mirror` persisted the short file).
//!
//! It was fixed in three layers on `main`, each with its own *unit* regression:
//! * rustfs#4594 — `GetObjectStreamingReader::poll_read` now returns
//! `UnexpectedEof` on a short body instead of a clean `Ok(())`
//! (`rustfs/src/app/object_usecase.rs`,
//! `app::object_usecase::tests::get_object_streaming_reader_errors_on_short_eof`).
//! * rustfs#4560 — the lazy multipart codec reader degrades a later part to
//! the legacy per-part decode in place, and surfaces reconstruction errors
//! instead of silently truncating
//! (`crates/ecstore/src/set_disk/read.rs`, `set_disk::read` tests ~:4379).
//! * rustfs#4585 — DARE (`rio-v2`) detects stream truncation at a package
//! boundary (`crates/rio-v2/src/encrypt_reader.rs`).
//!
//! Those unit tests cover the reader layers in isolation. This suite covers the
//! layer they do not: the **full HTTP GET path** through a real spawned server,
//! streaming a real body reconstructed from real on-disk EC shards. The single
//! load-bearing invariant asserted everywhere below is:
//!
//! **A `2xx` GET response whose body collects without error MUST deliver the
//! complete object — its length equals the advertised `Content-Length` and
//! its bytes hash to the original. A short-but-clean body is the historical
//! bug and fails the test loudly. A degraded read that cannot be served must
//! fail (non-2xx, or a mid-stream body error), never truncate silently.**
//!
//! Topology: single-node 4-disk EC set (default 2 data + 2 parity), driven by
//! the in-process `DiskFaultHarness` (see `chaos.rs`). Objects are sized well
//! above the inline threshold and span multiple 1 MiB EC blocks so every read
//! exercises real shard reconstruction and a genuine mid-stream failure window.
#[cfg(test)]
mod tests {
use crate::chaos::DiskFaultHarness;
use crate::common::init_logging;
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
use serial_test::serial;
use sha2::{Digest, Sha256};
use std::error::Error;
use tokio::time::{Duration, timeout};
use tracing::info;
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
const GET_TIMEOUT: Duration = Duration::from_secs(90);
const PUT_TIMEOUT: Duration = Duration::from_secs(90);
/// 1 MiB EC block (`BLOCK_SIZE_V2`). A "≥2 EC stripes" object is anything
/// spanning more than one block; we use 6 MiB single objects and 5 MiB
/// multipart parts so every read crosses several block boundaries.
const MIB: usize = 1024 * 1024;
fn sha256_hex(data: &[u8]) -> String {
Sha256::digest(data).iter().map(|b| format!("{b:02x}")).collect()
}
/// Deterministic pseudo-random payload so corruption offsets and hashes are
/// reproducible across the 3x local flake runs.
fn payload(len: usize, seed: u8) -> Vec<u8> {
(0..len)
.map(|i| (i as u64).wrapping_mul(2654435761).wrapping_add(seed as u64) as u8)
.collect()
}
/// Render an error and its full source chain (SDK errors nest the useful
/// detail below the terse top-level `Display`).
fn render_err(err: &(dyn Error + 'static)) -> String {
let mut out = err.to_string();
let mut source = err.source();
while let Some(cause) = source {
out.push_str(" -> ");
out.push_str(&cause.to_string());
source = cause.source();
}
out
}
/// Outcome of a truncation-checked GET. The forbidden fourth possibility —
/// a `2xx` with a clean but short body — never reaches a variant: it panics
/// inside [`get_checked`] so it can never be silently tolerated.
#[derive(Debug)]
enum GetOutcome {
/// `2xx` and the fully collected body matches the expected length + hash.
CompleteMatch,
/// The transfer failed cleanly: a non-2xx status / send error, or a
/// mid-stream body error such as the `UnexpectedEof` from rustfs#4594.
/// Acceptable only for reads degraded beyond the read quorum.
CleanFailure(String),
}
/// Perform a GET and enforce the no-silent-truncation invariant.
///
/// Returns [`GetOutcome::CompleteMatch`] on a full, correct body, or
/// [`GetOutcome::CleanFailure`] on any clean failure. It **panics** if the
/// server returns a `2xx` whose body collects cleanly but is shorter than
/// the advertised `Content-Length` / expected length — that is exactly the
/// dist-13 bug.
async fn get_checked(
client: &Client,
bucket: &str,
key: &str,
expected_len: usize,
expected_sha256: &str,
phase: &str,
) -> Result<GetOutcome, Box<dyn Error + Send + Sync>> {
let send = timeout(GET_TIMEOUT, client.get_object().bucket(bucket).key(key).send())
.await
.map_err(|_| format!("GET {key} send timed out during {phase}"))?;
let response = match send {
Ok(response) => response,
Err(err) => {
return Ok(GetOutcome::CleanFailure(format!(
"GET {key} failed before body during {phase}: {}",
render_err(&err)
)));
}
};
// The advertised body length: this is the `expected` value that the
// fixed streaming reader (rustfs#4594) checks its emitted bytes against.
let advertised = response.content_length();
let collected = timeout(GET_TIMEOUT, response.body.collect())
.await
.map_err(|_| format!("GET {key} body collect timed out during {phase}"))?;
let bytes = match collected {
Ok(aggregated) => aggregated.into_bytes(),
Err(err) => {
// Fixed behavior for a beyond-quorum read: the stream aborts
// mid-body instead of ending cleanly short.
return Ok(GetOutcome::CleanFailure(format!(
"GET {key} body errored mid-stream during {phase}: {}",
render_err(&err)
)));
}
};
// Heart of the regression net: a 2xx body that collected without error
// MUST be the whole object. A short clean body under a full
// Content-Length is the historical silent truncation — fail loudly.
if let Some(advertised_len) = advertised {
assert_eq!(
bytes.len() as i64,
advertised_len,
"SILENT TRUNCATION during {phase}: GET {key} returned 2xx advertising \
Content-Length {advertised_len} but the body collected cleanly at {} bytes \
(dist-13 regression: rustfs#4594/#4560/#4585)",
bytes.len()
);
}
assert_eq!(
bytes.len(),
expected_len,
"GET {key} during {phase} returned a clean 2xx body of {} bytes, expected {expected_len} \
(silent truncation regression)",
bytes.len()
);
assert_eq!(
sha256_hex(&bytes),
expected_sha256,
"GET {key} during {phase} returned a full-length body whose hash does not match the original"
);
Ok(GetOutcome::CompleteMatch)
}
/// PUT a single-part object and return `(expected_len, sha256)`.
async fn put_object(
client: &Client,
bucket: &str,
key: &str,
body: Vec<u8>,
) -> Result<(usize, String), Box<dyn Error + Send + Sync>> {
let len = body.len();
let digest = sha256_hex(&body);
timeout(
PUT_TIMEOUT,
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(body))
.send(),
)
.await
.map_err(|_| format!("PUT {key} timed out"))??;
Ok((len, digest))
}
/// Complete a multipart upload from the given parts and return
/// `(total_len, sha256_of_concatenation)`.
async fn put_multipart(
client: &Client,
bucket: &str,
key: &str,
parts: Vec<Vec<u8>>,
) -> Result<(usize, String), Box<dyn Error + Send + Sync>> {
let full: Vec<u8> = parts.iter().flatten().copied().collect();
let total_len = full.len();
let digest = sha256_hex(&full);
let create = client.create_multipart_upload().bucket(bucket).key(key).send().await?;
let upload_id = create.upload_id().ok_or("missing upload id")?.to_string();
let mut completed = Vec::with_capacity(parts.len());
for (index, part_body) in parts.into_iter().enumerate() {
let part_number = (index + 1) as i32;
let uploaded = timeout(
PUT_TIMEOUT,
client
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.part_number(part_number)
.body(ByteStream::from(part_body))
.send(),
)
.await
.map_err(|_| format!("upload_part {part_number} for {key} timed out"))??;
completed.push(
CompletedPart::builder()
.part_number(part_number)
.e_tag(uploaded.e_tag().ok_or("missing part etag")?)
.build(),
);
}
timeout(
PUT_TIMEOUT,
client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed)).build())
.send(),
)
.await
.map_err(|_| format!("complete_multipart_upload {key} timed out"))??;
Ok((total_len, digest))
}
/// Scenario (a) — one disk offline: a large single-part object (≥2 EC
/// stripes) and a multipart object (3 parts × 5 MiB) must GET back as a
/// full, byte-identical body with the correct Content-Length. No early EOF.
#[tokio::test]
#[serial]
async fn degraded_read_large_objects_with_one_disk_offline_return_full_body() -> TestResult {
init_logging();
info!("dist-13 (a): large-object degraded read with one of four disks offline");
let mut harness = DiskFaultHarness::new(4).await?;
harness.start_server().await?;
let client = harness.env.create_s3_client();
let bucket = "dist13-degraded-offline";
client.create_bucket().bucket(bucket).send().await?;
// 6 MiB single object: 6 EC blocks, far above the inline threshold.
let single_key = "eof/large-single.bin";
let (single_len, single_sha) = put_object(&client, bucket, single_key, payload(6 * MIB, 41)).await?;
// Multipart: 3 parts × 5 MiB (the S3 minimum part size).
let multi_key = "eof/large-multipart.bin";
let (multi_len, multi_sha) = put_multipart(
&client,
bucket,
multi_key,
vec![payload(5 * MIB, 42), payload(5 * MIB, 43), payload(5 * MIB, 44)],
)
.await?;
// Baseline with every disk online.
assert!(matches!(
get_checked(&client, bucket, single_key, single_len, &single_sha, "baseline single").await?,
GetOutcome::CompleteMatch
));
assert!(matches!(
get_checked(&client, bucket, multi_key, multi_len, &multi_sha, "baseline multipart").await?,
GetOutcome::CompleteMatch
));
// One disk offline: EC 2+2 still has 3 shards, a full read quorum.
harness.take_disk_offline(0)?;
assert!(
matches!(
get_checked(&client, bucket, single_key, single_len, &single_sha, "degraded single").await?,
GetOutcome::CompleteMatch
),
"6 MiB single object must reconstruct fully with disk0 offline"
);
assert!(
matches!(
get_checked(&client, bucket, multi_key, multi_len, &multi_sha, "degraded multipart").await?,
GetOutcome::CompleteMatch
),
"15 MiB multipart object must reconstruct fully with disk0 offline"
);
Ok(())
}
/// Scenario (b) — bitrot within the read quorum: two shards of a large
/// multipart object are silently corrupted mid-file (2 of 4 in a 2+2 set,
/// leaving exactly a quorum). The GET must reconstruct the object and return
/// the complete, byte-identical body. Because the corruption sits in the
/// middle of the part file, block 0 reads clean, the `200` + full
/// Content-Length are already committed, and the bad block is only hit
/// mid-stream — the exact window the fixes had to reconstruct through rather
/// than truncate.
#[tokio::test]
#[serial]
async fn degraded_read_reconstructs_through_midstream_bitrot_within_quorum() -> TestResult {
init_logging();
info!("dist-13 (b): mid-stream bitrot within quorum must reconstruct a full body");
let mut harness = DiskFaultHarness::new(4).await?;
harness.start_server().await?;
let client = harness.env.create_s3_client();
let bucket = "dist13-bitrot-quorum";
client.create_bucket().bucket(bucket).send().await?;
let key = "eof/bitrot-multipart.bin";
let (len, sha) = put_multipart(
&client,
bucket,
key,
vec![payload(5 * MIB, 51), payload(5 * MIB, 52), payload(5 * MIB, 53)],
)
.await?;
assert!(matches!(
get_checked(&client, bucket, key, len, &sha, "baseline before corruption").await?,
GetOutcome::CompleteMatch
));
// Corrupt two shards (disks 0 and 1). A 2+2 set reconstructs from any
// two of four shards, so two clean shards remain and the read must
// still deliver the whole object.
harness.corrupt_object_shard(0, bucket, key)?;
harness.corrupt_object_shard(1, bucket, key)?;
assert!(
matches!(
get_checked(&client, bucket, key, len, &sha, "read through 2-shard bitrot").await?,
GetOutcome::CompleteMatch
),
"2-shard bitrot in a 2+2 set is within quorum and must reconstruct the full body"
);
// A repeat read confirms reconstruction is stable, not a one-shot.
assert!(matches!(
get_checked(&client, bucket, key, len, &sha, "second read through bitrot").await?,
GetOutcome::CompleteMatch
));
Ok(())
}
/// Scenario (c) — degraded beyond the read quorum (THE HEART OF THE NET):
/// three shards of a large multipart object are corrupted mid-file (3 of 4
/// in a 2+2 set → only one clean shard, below the 2-shard quorum). Block 0
/// still reads clean, so the server commits `200` + the full Content-Length
/// and starts streaming; the corrupted middle block then cannot be
/// reconstructed. The read MUST fail — a non-2xx status or a mid-stream body
/// error — and MUST NOT close cleanly with a truncated body under the full
/// Content-Length. `get_checked` panics on that forbidden outcome, so this
/// test fails loudly if the truncation bug ever returns.
#[tokio::test]
#[serial]
async fn beyond_quorum_degraded_read_never_silently_truncates() -> TestResult {
init_logging();
info!("dist-13 (c): beyond-quorum degraded read must fail, never 200+truncated");
let mut harness = DiskFaultHarness::new(4).await?;
harness.start_server().await?;
let client = harness.env.create_s3_client();
let bucket = "dist13-beyond-quorum";
client.create_bucket().bucket(bucket).send().await?;
let key = "eof/beyond-quorum-multipart.bin";
let (len, sha) = put_multipart(
&client,
bucket,
key,
vec![payload(5 * MIB, 61), payload(5 * MIB, 62), payload(5 * MIB, 63)],
)
.await?;
assert!(matches!(
get_checked(&client, bucket, key, len, &sha, "baseline before over-corruption").await?,
GetOutcome::CompleteMatch
));
// Corrupt three of four shards: below the 2-shard read quorum, so the
// corrupted block cannot be reconstructed.
harness.corrupt_object_shard(0, bucket, key)?;
harness.corrupt_object_shard(1, bucket, key)?;
harness.corrupt_object_shard(2, bucket, key)?;
// The invariant is enforced inside get_checked (it panics on a clean
// short 2xx body). A CleanFailure here is the correct, fixed behavior;
// an (implausible) CompleteMatch would also be acceptable. The only
// failing outcome is the silent truncation the net exists to catch.
match get_checked(&client, bucket, key, len, &sha, "beyond-quorum read").await? {
GetOutcome::CleanFailure(reason) => {
info!("beyond-quorum read failed cleanly as required: {reason}");
}
GetOutcome::CompleteMatch => {
info!("beyond-quorum read unexpectedly reconstructed a full body; not a truncation, accepted");
}
}
Ok(())
}
}