test(ecstore): add validation suite coverage gates (#4378)

This commit is contained in:
Zhengchao An
2026-07-07 23:50:12 +08:00
committed by GitHub
parent 3ed414cdb4
commit 742a59884d
7 changed files with 1634 additions and 63 deletions
+253 -10
View File
@@ -709,15 +709,109 @@ mod tests {
}
}
#[derive(Clone, Default)]
struct FailingWriteWriter;
impl AsyncWrite for FailingWriteWriter {
fn poll_write(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &[u8]) -> Poll<std::io::Result<usize>> {
Poll::Ready(Err(std::io::Error::other("injected write failure")))
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
}
#[derive(Clone, Default)]
struct ShortWriteWriter;
impl AsyncWrite for ShortWriteWriter {
fn poll_write(self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &[u8]) -> Poll<std::io::Result<usize>> {
Poll::Ready(Ok(buf.len().saturating_sub(1)))
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
}
#[derive(Clone, Default)]
struct ShutdownFailWriter {
buffered: Vec<u8>,
}
impl AsyncWrite for ShutdownFailWriter {
fn poll_write(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &[u8]) -> Poll<std::io::Result<usize>> {
self.buffered.extend_from_slice(buf);
Poll::Ready(Ok(buf.len()))
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Err(std::io::Error::other("injected shutdown failure")))
}
}
fn bitrot_writer<W>(writer: W, shard_size: usize) -> BitrotWriterWrapper
where
W: AsyncWrite + Send + Sync + Unpin + 'static,
{
BitrotWriterWrapper::new(CustomWriter::new_tokio_writer(writer), shard_size, HashAlgorithm::HighwayHash256S)
}
#[tokio::test]
async fn multi_writer_short_write_fails_before_shutdown() {
let mut writers = vec![Some(bitrot_writer(ShortWriteWriter, 16))];
let err = {
let mut writer = MultiWriter::new(&mut writers, 1);
writer
.write(vec![Bytes::from_static(b"short-write payload")])
.await
.expect_err("short writes must fail the shard writer")
};
assert!(err.to_string().contains("Failed to write data"));
assert!(writers[0].is_none(), "short-write shard must be removed before commit");
}
#[tokio::test]
async fn drain_queued_inflight_bytes_consumes_pending_blocks() {
let (tx, mut rx) = mpsc::channel(2);
tx.send(vec![Bytes::from_static(b"queued")]).await.unwrap();
drop(tx);
drain_queued_inflight_bytes(&mut rx).await;
assert!(rx.recv().await.is_none());
}
#[tokio::test]
async fn drain_queued_batched_inflight_bytes_consumes_pending_batches() {
let (tx, mut rx) = mpsc::channel(2);
tx.send(vec![vec![Bytes::from_static(b"queued")]]).await.unwrap();
drop(tx);
drain_queued_batched_inflight_bytes(&mut rx).await;
assert!(rx.recv().await.is_none());
}
#[tokio::test]
async fn encode_shutdowns_writers_after_small_shards() {
let committed = Arc::new(Mutex::new(Vec::new()));
let writer = DeferredCommitWriter::new(committed.clone());
let mut writers = vec![Some(BitrotWriterWrapper::new(
CustomWriter::new_tokio_writer(writer),
16,
HashAlgorithm::HighwayHash256S,
))];
let mut writers = vec![Some(bitrot_writer(writer, 16))];
let erasure = Arc::new(Erasure::new(1, 0, 16));
let reader = tokio::io::BufReader::new(Cursor::new(b"small payload".to_vec()));
@@ -727,6 +821,87 @@ mod tests {
assert!(!committed.lock().unwrap().is_empty());
}
#[tokio::test]
async fn encode_streaming_write_quorum_failure_aborts_and_reports_error() {
const DATA_SHARDS: usize = 2;
const PARITY_SHARDS: usize = 2;
const BLOCK_SIZE: usize = 64;
let committed = Arc::new(Mutex::new(Vec::new()));
let mut writers = vec![
Some(bitrot_writer(DeferredCommitWriter::new(committed.clone()), BLOCK_SIZE / DATA_SHARDS)),
Some(bitrot_writer(FailingWriteWriter, BLOCK_SIZE / DATA_SHARDS)),
None,
None,
];
let payload = vec![3u8; BLOCK_SIZE * 8];
let erasure = Arc::new(Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE));
let reader = tokio::io::BufReader::new(Cursor::new(payload));
let err = erasure
.encode(reader, &mut writers, DATA_SHARDS)
.await
.expect_err("streaming encode must fail when write quorum is unavailable");
assert!(err.to_string().contains("Failed to write data"));
}
#[tokio::test]
async fn encode_inline_small_write_quorum_failure_does_not_commit_partial_data() {
const DATA_SHARDS: usize = 2;
const PARITY_SHARDS: usize = 2;
const BLOCK_SIZE: usize = 64;
let committed = Arc::new(Mutex::new(Vec::new()));
let mut writers = vec![
Some(bitrot_writer(DeferredCommitWriter::new(committed.clone()), BLOCK_SIZE / DATA_SHARDS)),
Some(bitrot_writer(FailingWriteWriter, BLOCK_SIZE / DATA_SHARDS)),
None,
None,
];
let erasure = Arc::new(Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE));
let reader = tokio::io::BufReader::new(Cursor::new(b"write quorum failure payload".to_vec()));
let err = erasure
.encode_inline_small(reader, &mut writers, DATA_SHARDS)
.await
.expect_err("write quorum failure must fail the inline encode");
assert!(err.to_string().contains("Failed to write data"));
assert!(
committed.lock().expect("committed buffer should be lockable").is_empty(),
"successful writer must not be committed when write quorum fails before shutdown"
);
}
#[tokio::test]
async fn encode_inline_small_shutdown_quorum_failure_is_reported() {
const DATA_SHARDS: usize = 2;
const PARITY_SHARDS: usize = 2;
const BLOCK_SIZE: usize = 64;
let committed = Arc::new(Mutex::new(Vec::new()));
let mut writers = vec![
Some(bitrot_writer(DeferredCommitWriter::new(committed.clone()), BLOCK_SIZE / DATA_SHARDS)),
Some(bitrot_writer(ShutdownFailWriter::default(), BLOCK_SIZE / DATA_SHARDS)),
Some(bitrot_writer(ShutdownFailWriter::default(), BLOCK_SIZE / DATA_SHARDS)),
Some(bitrot_writer(ShutdownFailWriter::default(), BLOCK_SIZE / DATA_SHARDS)),
];
let erasure = Arc::new(Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE));
let reader = tokio::io::BufReader::new(Cursor::new(b"shutdown quorum failure payload".to_vec()));
let err = erasure
.encode_inline_small(reader, &mut writers, DATA_SHARDS)
.await
.expect_err("shutdown quorum failure must fail the inline encode");
assert!(err.to_string().contains("Failed to shutdown writers"));
assert!(
!committed.lock().expect("committed buffer should be lockable").is_empty(),
"the successful writer should have committed before shutdown quorum failure was reported"
);
}
#[tokio::test]
async fn encode_returns_unexpected_eof_for_truncated_limited_reader() {
let committed = Arc::new(Mutex::new(Vec::new()));
@@ -773,11 +948,7 @@ mod tests {
async fn encode_works_on_current_thread_runtime() {
let committed = Arc::new(Mutex::new(Vec::new()));
let writer = DeferredCommitWriter::new(committed);
let mut writers = vec![Some(BitrotWriterWrapper::new(
CustomWriter::new_tokio_writer(writer),
16,
HashAlgorithm::HighwayHash256S,
))];
let mut writers = vec![Some(bitrot_writer(writer, 16))];
let erasure = Arc::new(Erasure::new(1, 0, 16));
let reader = tokio::io::BufReader::new(Cursor::new(b"current-thread payload".to_vec()));
@@ -786,6 +957,78 @@ mod tests {
assert_eq!(written, b"current-thread payload".len());
}
#[tokio::test]
async fn encode_batched_writes_full_and_tail_batches() {
const DATA_SHARDS: usize = 2;
const PARITY_SHARDS: usize = 2;
const TOTAL_SHARDS: usize = DATA_SHARDS + PARITY_SHARDS;
const BLOCK_SIZE: usize = 32;
let committed: Vec<Arc<Mutex<Vec<u8>>>> = (0..TOTAL_SHARDS).map(|_| Arc::new(Mutex::new(Vec::new()))).collect();
let mut writers: Vec<Option<BitrotWriterWrapper>> = committed
.iter()
.map(|c| Some(bitrot_writer(DeferredCommitWriter::new(c.clone()), BLOCK_SIZE / DATA_SHARDS)))
.collect();
let payload = vec![7u8; BLOCK_SIZE * 5 + 3];
let erasure = Arc::new(Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE));
let reader = tokio::io::BufReader::new(Cursor::new(payload.clone()));
let (_reader, total) = erasure
.encode_batched(reader, &mut writers, DATA_SHARDS)
.await
.expect("batched encode should write full and tail batches");
assert_eq!(total, payload.len());
for (index, committed) in committed.iter().enumerate() {
assert!(
!committed.lock().expect("committed buffer should be lockable").is_empty(),
"shard {index} should receive committed batched data"
);
}
}
#[tokio::test]
async fn encode_batched_write_quorum_failure_aborts_and_reports_error() {
const DATA_SHARDS: usize = 2;
const PARITY_SHARDS: usize = 2;
const BLOCK_SIZE: usize = 32;
let committed = Arc::new(Mutex::new(Vec::new()));
let mut writers = vec![
Some(bitrot_writer(DeferredCommitWriter::new(committed.clone()), BLOCK_SIZE / DATA_SHARDS)),
Some(bitrot_writer(FailingWriteWriter, BLOCK_SIZE / DATA_SHARDS)),
None,
None,
];
let payload = vec![9u8; BLOCK_SIZE * 8];
let erasure = Arc::new(Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE));
let reader = tokio::io::BufReader::new(Cursor::new(payload));
let err = erasure
.encode_batched(reader, &mut writers, DATA_SHARDS)
.await
.expect_err("batched encode must fail when write quorum is unavailable");
assert!(err.to_string().contains("Failed to write data"));
}
#[tokio::test]
async fn encode_batched_rejects_zero_block_size() {
let committed = Arc::new(Mutex::new(Vec::new()));
let writer = DeferredCommitWriter::new(committed);
let mut writers = vec![Some(bitrot_writer(writer, 16))];
let erasure = Arc::new(Erasure::new(1, 0, 0));
let reader = tokio::io::BufReader::new(Cursor::new(b"payload".to_vec()));
let err = erasure
.encode_batched(reader, &mut writers, 1)
.await
.expect_err("zero block size must be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert!(err.to_string().contains("block_size"));
}
/// encode_inline_small: empty reader returns (reader, 0) without writing to any shard.
#[tokio::test]
async fn encode_inline_small_empty_stream_returns_zero() {
@@ -1056,6 +1056,80 @@ mod tests {
}
}
#[test]
fn decode_data_rejects_missing_data_above_parity_budget() {
for uses_legacy in [false, true] {
let erasure = Erasure::new_with_options(3, 2, 64, uses_legacy);
let data = b"read restore must fail when too many data shards are gone";
let encoded = erasure.encode_data(data).expect("encode should succeed");
let mut shards = optional_shards(&encoded);
shards[0] = None;
shards[1] = None;
shards[2] = None;
let err = erasure
.decode_data(&mut shards)
.expect_err("decode_data must fail when missing data shards exceed parity");
assert!(!err.to_string().is_empty());
assert!(shards.iter().take(erasure.data_shards).any(Option::is_none));
}
}
#[test]
fn decode_data_and_parity_rejects_wrong_shard_count() {
for uses_legacy in [false, true] {
let erasure = Erasure::new_with_options(4, 2, 128, uses_legacy);
let data = b"wrong shard count should not be accepted as a restored object";
let encoded = erasure.encode_data(data).expect("encode should succeed");
let mut shards = optional_shards(&encoded);
let _ = shards.pop();
let err = erasure
.decode_data_and_parity(&mut shards)
.expect_err("decode_data_and_parity must reject wrong shard count");
assert!(!err.to_string().is_empty());
}
}
#[test]
fn decode_data_with_verification_rejects_corrupt_surplus_parity() {
let erasure = Erasure::new(3, 2, 128);
let data = b"verified reads must fail closed on stale surplus parity";
let encoded = erasure.encode_data(data).expect("encode should succeed");
let mut shards = optional_shards(&encoded);
shards[1] = None;
shards[erasure.data_shards].as_mut().expect("parity shard should be present")[0] ^= 0x7d;
let err = erasure
.decode_data_with_reconstruction_verification(&mut shards)
.expect_err("verified decode must reject inconsistent parity");
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn verify_data_and_parity_rejects_missing_and_mismatched_shards() {
let erasure = Erasure::new(4, 2, 128);
let data = b"verification must reject incomplete or malformed shard sets";
let encoded = erasure.encode_data(data).expect("encode should succeed");
let mut shards = optional_shards(&encoded);
shards[0] = None;
let missing = erasure
.verify_data_and_parity(&shards)
.expect_err("verification must reject missing shards");
assert!(missing.to_string().contains("missing shard"));
let mut mismatched = optional_shards(&encoded);
let _ = mismatched[0].as_mut().expect("data shard should be present").pop();
let mismatch = erasure
.verify_data_and_parity(&mismatched)
.expect_err("verification must reject mismatched shard lengths");
assert!(mismatch.to_string().contains("inconsistent shard length"));
}
#[test]
fn decode_data_and_parity_leaves_complete_shards_unchanged() {
let erasure = Erasure::new(4, 2, 128);
@@ -3427,6 +3427,43 @@ impl SetDisks {
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
use tokio::io::AsyncReadExt;
fn metadata_test_fileinfo(object: &str) -> FileInfo {
let mut fi = FileInfo::new(object, 2, 2);
fi.volume = "bucket".to_string();
fi.name = object.to_string();
fi.size = 1;
fi.erasure.index = 1;
fi.metadata.insert("etag".to_string(), "etag-1".to_string());
fi.add_object_part(1, "part-etag-1".to_string(), 1, None, 1, None, None);
fi
}
fn read_part_test_part(number: usize, etag: &str) -> ObjectPartInfo {
ObjectPartInfo {
number,
etag: etag.to_string(),
..Default::default()
}
}
fn read_part_test_error(number: usize, error: &str) -> ObjectPartInfo {
ObjectPartInfo {
number,
error: Some(error.to_string()),
..Default::default()
}
}
fn failed_read_repair_submitter(_request: rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture {
Box::pin(async { ReadRepairAdmissionOutcome::Failed("injected submit failure".to_string()) })
}
fn accepted_read_repair_submitter(_request: rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture {
Box::pin(async { ReadRepairAdmissionOutcome::Response(HealAdmissionResult::Accepted) })
}
#[test]
fn dangling_delete_grace_defaults_to_one_hour() {
@@ -3444,4 +3481,224 @@ mod tests {
assert!(dangling_delete_grace().is_zero());
});
}
#[tokio::test]
async fn multipart_codec_streaming_reader_zero_buffer_is_noop() {
let reader = tokio::io::BufReader::new(Cursor::new(b"payload".to_vec()));
let mut reader = MultipartCodecStreamingReader::new(vec![Box::new(reader)]);
let mut out = [];
let read = reader
.read(&mut out)
.await
.expect("zero-length read should not poll inner readers");
assert_eq!(read, 0);
assert_eq!(reader.readers.len(), 1);
}
#[test]
fn metadata_fanout_observation_classifies_invalid_and_ignored_results() {
let invalid = MetadataFanoutObservation::from_file_info(&FileInfo::default(), Duration::from_millis(7));
assert_eq!(invalid.outcome, GET_METADATA_RESPONSE_ERROR);
assert!(!invalid.valid);
assert!(!invalid.ignored);
let ignored = MetadataFanoutObservation::from_error(&DiskError::DiskNotFound, Duration::from_millis(9));
assert_eq!(ignored.outcome, GET_METADATA_RESPONSE_DISK_NOT_FOUND);
assert!(!ignored.valid);
assert!(ignored.ignored);
let corrupt = MetadataFanoutObservation::from_error(&DiskError::FileCorrupt, Duration::from_millis(11));
assert_eq!(corrupt.outcome, GET_METADATA_RESPONSE_CORRUPT);
assert!(!corrupt.ignored);
}
#[test]
fn metadata_fanout_diagnostics_reports_counts_and_latency_edges() {
let diagnostics = MetadataFanoutDiagnostics::new(
Duration::from_millis(40),
vec![
MetadataFanoutObservation {
outcome: GET_METADATA_RESPONSE_VALID,
elapsed: Duration::from_millis(30),
valid: true,
ignored: false,
},
MetadataFanoutObservation {
outcome: GET_METADATA_RESPONSE_IGNORED,
elapsed: Duration::from_millis(10),
valid: false,
ignored: true,
},
MetadataFanoutObservation {
outcome: GET_METADATA_RESPONSE_ERROR,
elapsed: Duration::from_millis(20),
valid: false,
ignored: false,
},
],
);
assert_eq!(diagnostics.total_responses(), 3);
assert_eq!(diagnostics.valid_responses(), 1);
assert_eq!(diagnostics.ignored_responses(), 1);
assert_eq!(diagnostics.error_responses(), 2);
assert_eq!(diagnostics.first_response_latency(), Some(Duration::from_millis(10)));
assert_eq!(diagnostics.first_valid_response_latency(), Some(Duration::from_millis(30)));
assert_eq!(diagnostics.slowest_response_latency(), Some(Duration::from_millis(30)));
assert_eq!(diagnostics.quorum_candidate_latency(0), Some(Duration::ZERO));
assert_eq!(diagnostics.quorum_candidate_latency(1), Some(Duration::from_millis(30)));
assert_eq!(diagnostics.quorum_candidate_latency(2), None);
}
#[test]
fn metadata_quorum_accumulator_counts_invalid_metadata_and_ignored_errors() {
let mut accumulator = MetadataQuorumAccumulator::new(4, 2, true);
accumulator.observe_file_info(&FileInfo::default());
accumulator.observe_error(&DiskError::DiskNotFound);
assert_eq!(accumulator.hard_errors, 1);
assert_eq!(accumulator.ignored_errors, 1);
assert_eq!(accumulator.final_miss_reason(), GET_METADATA_EARLY_STOP_REASON_ERROR);
}
#[test]
fn metadata_quorum_accumulator_candidate_quorum_handles_zero_parity_and_invalid_candidates() {
let accumulator = MetadataQuorumAccumulator::new(4, 0, true);
let candidate = metadata_test_fileinfo("object");
assert_eq!(accumulator.candidate_read_quorum(&candidate), Some(4));
assert_eq!(accumulator.missing_response_quorum(), 4);
let accumulator = MetadataQuorumAccumulator::new(4, 2, true);
let mut deleted = candidate.clone();
deleted.deleted = true;
assert_eq!(accumulator.candidate_read_quorum(&deleted), None);
let mut empty = candidate.clone();
empty.size = 0;
assert_eq!(accumulator.candidate_read_quorum(&empty), None);
let mut impossible_parity = candidate;
impossible_parity.erasure.parity_blocks = 4;
assert_eq!(accumulator.candidate_read_quorum(&impossible_parity), None);
}
#[test]
fn confirmed_missing_part_error_recognizes_legacy_and_s3_markers() {
assert!(!is_confirmed_missing_part_error(None));
assert!(is_confirmed_missing_part_error(Some("file not found")));
assert!(is_confirmed_missing_part_error(Some("No such file or directory")));
assert!(is_confirmed_missing_part_error(Some("Specified part could not be found")));
assert!(is_confirmed_missing_part_error(Some("part.7 not found")));
assert!(!is_confirmed_missing_part_error(Some("part.7 missing")));
assert!(!is_confirmed_missing_part_error(Some("permission denied")));
}
#[test]
fn resolve_read_part_handles_mismatched_and_transient_responses_without_false_missing() {
let responses = vec![
Some(Vec::new()),
Some(vec![read_part_test_error(1, "permission denied")]),
None,
];
let err = resolve_read_part_from_responses("bucket", "upload/part.1.meta", 1, 0, 1, &responses, 2)
.expect_err("mismatched and transient responses must not be treated as confirmed missing");
assert_eq!(err, DiskError::ErasureReadQuorum);
}
#[test]
fn resolve_read_part_accepts_alternate_missing_error_markers() {
let responses = vec![
Some(vec![read_part_test_error(1, "Specified part could not be found")]),
Some(vec![read_part_test_error(1, "part.1 not found")]),
Some(vec![read_part_test_part(1, "stale-etag")]),
];
let part = resolve_read_part_from_responses("bucket", "upload/part.1.meta", 1, 0, 1, &responses, 2)
.expect("alternate missing markers should satisfy missing quorum");
assert_eq!(part.number, 1);
assert_eq!(part.error.as_deref(), Some("part.1 not found"));
assert!(part.etag.is_empty());
}
#[test]
fn shard_read_costs_for_empty_disk_set_are_empty() {
assert!(shard_read_costs_for_disks(&[]).is_empty());
}
#[tokio::test]
async fn reserve_read_repair_heal_dedupes_by_object_version_and_set() {
let object = format!("object-{}", Uuid::new_v4());
let key = reserve_read_repair_heal("bucket", &object, Some("version-1"), 1, 2)
.await
.expect("first read-repair reservation should be accepted");
assert_eq!(key.version_id.as_deref(), Some("version-1"));
assert!(
reserve_read_repair_heal("bucket", &object, Some("version-1"), 1, 2)
.await
.is_none()
);
release_read_repair_heal_reservation(&key).await;
let retry_key = reserve_read_repair_heal("bucket", &object, Some("version-1"), 1, 2)
.await
.expect("released read-repair reservation should allow retry");
release_read_repair_heal_reservation(&retry_key).await;
}
#[tokio::test]
async fn submit_read_repair_heal_releases_reservation_after_submitter_failure() {
let object = format!("object-{}", Uuid::new_v4());
submit_read_repair_heal_with_submitter(
ReadRepairHealSubmission {
bucket: "bucket",
object: &object,
version_id: None,
pool_index: 1,
set_index: 2,
part_number: Some(1),
reason: "test",
},
failed_read_repair_submitter,
)
.await;
for _ in 0..20 {
if let Some(key) = reserve_read_repair_heal("bucket", &object, None, 1, 2).await {
release_read_repair_heal_reservation(&key).await;
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("failed read-repair submission should release its dedup reservation");
}
#[tokio::test]
async fn submit_read_repair_heal_keeps_admitted_reservation_deduped() {
let object = format!("object-{}", Uuid::new_v4());
submit_read_repair_heal_with_submitter(
ReadRepairHealSubmission {
bucket: "bucket",
object: &object,
version_id: None,
pool_index: 3,
set_index: 4,
part_number: None,
reason: "test",
},
accepted_read_repair_submitter,
)
.await;
assert!(reserve_read_repair_heal("bucket", &object, None, 3, 4).await.is_none());
let key = ReadRepairHealCacheKey::new("bucket", &object, None, 3, 4);
release_read_repair_heal_reservation(&key).await;
}
}
+51 -18
View File
@@ -31,7 +31,7 @@ mod storage_api;
use rustfs_filemeta::{FileInfoOpts, get_file_info};
use rustfs_utils::HashAlgorithm;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use storage_api::legacy_bitrot_read::{DiskOption, Endpoint, STORAGE_FORMAT_FILE, create_bitrot_reader, new_disk};
use tokio::fs;
@@ -44,15 +44,46 @@ fn workspace_root() -> PathBuf {
.to_path_buf()
}
fn legacy_test_root() -> PathBuf {
std::env::var_os("RUSTFS_LEGACY_TEST_ROOT")
.map(PathBuf::from)
.unwrap_or_else(workspace_root)
}
fn legacy_test_disk(root: &Path) -> String {
if let Some(disk) = std::env::var_os("RUSTFS_LEGACY_TEST_DISK") {
return disk.to_string_lossy().into_owned();
}
if root.join("test/.minio.sys/format.json").exists() {
"test".to_string()
} else {
"test1".to_string()
}
}
fn legacy_format_exists(root: &Path, disk_name: &str) -> bool {
let disk_root = root.join(disk_name);
disk_root.join(".rustfs.sys/format.json").exists() || disk_root.join(".minio.sys/format.json").exists()
}
fn legacy_object_meta_exists(root: &Path, disk_name: &str, bucket: &str, object: &str) -> bool {
root.join(disk_name)
.join(bucket)
.join(object)
.join(STORAGE_FORMAT_FILE)
.exists()
}
fn legacy_test_data_exists() -> bool {
if std::env::var("RUSTFS_SKIP_LEGACY_TEST").unwrap_or_default() == "1" {
return false;
}
let root = workspace_root();
let format_path = root.join("test1/.rustfs.sys/format.json");
let ktvzip_meta = root.join("test1/vvvv/ktvzip.tar.gz").join(STORAGE_FORMAT_FILE);
let path_traversal_meta = root.join("test1/vvvv/path_traversal.md").join(STORAGE_FORMAT_FILE);
format_path.exists() && (ktvzip_meta.exists() || path_traversal_meta.exists())
let root = legacy_test_root();
let disk_name = legacy_test_disk(&root);
let bucket = "vvvv";
legacy_format_exists(&root, &disk_name)
&& (legacy_object_meta_exists(&root, &disk_name, bucket, "ktvzip.tar.gz")
|| legacy_object_meta_exists(&root, &disk_name, bucket, "path_traversal.md"))
}
async fn run_legacy_bitrot_test_for_object(root: &std::path::Path, disk_name: &str, bucket: &str, object: &str) -> bool {
@@ -227,23 +258,25 @@ async fn run_legacy_bitrot_test_for_object(root: &std::path::Path, disk_name: &s
#[tokio::test]
async fn test_legacy_bitrot_read() {
if !legacy_test_data_exists() {
eprintln!("Skipping legacy bitrot test: test1/vvvv xl.meta or RUSTFS_SKIP_LEGACY_TEST");
eprintln!("Skipping legacy bitrot test: legacy fixture xl.meta not found or RUSTFS_SKIP_LEGACY_TEST=1");
return;
}
// let root = workspace_root();
let root = PathBuf::from("/Users/weisd/project/minio");
let disk_name = "test";
let root = legacy_test_root();
let disk_name = legacy_test_disk(&root);
let bucket = "vvvv";
let mut checked = 0usize;
// Try both EC (part files) and inline objects
let ok = run_legacy_bitrot_test_for_object(&root, disk_name, bucket, "ktvzip.tar.gz").await;
for object in ["ktvzip.tar.gz", "path_traversal.md"] {
if !legacy_object_meta_exists(&root, &disk_name, bucket, object) {
eprintln!("Skipping missing legacy object fixture: {object}");
continue;
}
eprintln!("ok: {:?}", ok);
checked += 1;
let ok = run_legacy_bitrot_test_for_object(&root, &disk_name, bucket, object).await;
assert!(ok, "create_bitrot_reader failed for {object}");
}
assert!(ok, "create_bitrot_reader failed for both ktvzip.tar.gz and path_traversal.md");
let ok = run_legacy_bitrot_test_for_object(&root, disk_name, bucket, "path_traversal.md").await;
assert!(ok, "create_bitrot_reader failed for path_traversal.md");
assert!(checked > 0, "legacy bitrot fixture preflight found no readable objects");
}
@@ -117,6 +117,52 @@ fn sha256_hex(bytes: &[u8]) -> String {
hex_simd::encode_to_string(Sha256::digest(bytes), hex_simd::AsciiCase::Lower)
}
async fn load_fixture_reader_input(case_id: &str) -> (ObjectInfo, Vec<u8>, String) {
let case_dir = require_fixture_case(case_id);
let manifest: ManifestRecord = read_json(&case_dir.join("manifest.json"));
let expected_sha256 = read_plaintext_sha256(&case_dir);
let file_info = load_file_info(&case_dir, &manifest);
let encrypted = encrypted_fixture_bytes(&case_dir, &manifest, &file_info).await;
let object_info = load_object_info(&file_info, &manifest);
(object_info, encrypted, expected_sha256)
}
async fn read_fixture_plaintext(encrypted: Vec<u8>, object_info: ObjectInfo, kms_key_b64: String) -> Result<Vec<u8>, String> {
let object_size = object_info.size;
async_with_vars(
[
("__RUSTFS_SSE_SIMPLE_CMK", Some(kms_key_b64)),
("RUSTFS_SSE_S3_MASTER_KEY", None::<String>),
],
async move {
let (mut reader, offset, length) = GetObjectReader::new(
Box::new(Cursor::new(encrypted)),
None,
&object_info,
&ObjectOptions::default(),
&http::HeaderMap::new(),
)
.await
.map_err(|err| format!("construct GetObjectReader from MinIO raw fixture: {err:?}"))?;
if offset != 0 || length != object_size {
return Err(format!("unexpected fixture range offset={offset} length={length} size={object_size}"));
}
let mut plaintext = Vec::new();
reader
.read_to_end(&mut plaintext)
.await
.map_err(|err| format!("read plaintext from MinIO raw fixture: {err}"))?;
Ok(plaintext)
},
)
.await
}
async fn encrypted_fixture_bytes(case_dir: &Path, manifest: &ManifestRecord, file_info: &FileInfo) -> Vec<u8> {
let mut disks = Vec::with_capacity(file_info.erasure.distribution.len());
for disk_number in 1..=file_info.erasure.distribution.len() {
@@ -204,43 +250,44 @@ async fn reads_minio_generated_sse_kms_multipart_fixture() {
assert_fixture_round_trip("sse-kms-multipart-8m", 8 * 1024 * 1024).await;
}
#[tokio::test]
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
async fn rejects_minio_generated_sse_s3_fixture_with_wrong_kms_key() {
let (object_info, encrypted, _) = load_fixture_reader_input("sse-s3-multipart-8m").await;
let wrong_key_b64 = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=".to_string();
let result = read_fixture_plaintext(encrypted, object_info, wrong_key_b64).await;
assert!(result.is_err(), "wrong KMS key must fail closed");
}
#[tokio::test]
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
async fn rejects_minio_generated_sse_s3_fixture_with_truncated_ciphertext() {
let (object_info, mut encrypted, expected_sha256) = load_fixture_reader_input("sse-s3-multipart-8m").await;
encrypted.truncate(encrypted.len() / 2);
let result = read_fixture_plaintext(encrypted, object_info, minio_static_kms_key_b64()).await;
if let Ok(plaintext) = result {
assert_ne!(
sha256_hex(&plaintext),
expected_sha256,
"truncated ciphertext must not restore the original plaintext"
);
}
}
async fn assert_fixture_round_trip(case_id: &str, expected_size: i64) {
let case_dir = require_fixture_case(case_id);
let manifest: ManifestRecord = read_json(&case_dir.join("manifest.json"));
let expected_sha256 = read_plaintext_sha256(&case_dir);
let file_info = load_file_info(&case_dir, &manifest);
let encrypted = encrypted_fixture_bytes(&case_dir, &manifest, &file_info).await;
let object_info = load_object_info(&file_info, &manifest);
let (object_info, encrypted, expected_sha256) = load_fixture_reader_input(case_id).await;
let object_size = object_info.size;
let kms_key_b64 = minio_static_kms_key_b64();
async_with_vars(
[
("__RUSTFS_SSE_SIMPLE_CMK", Some(kms_key_b64)),
("RUSTFS_SSE_S3_MASTER_KEY", None::<String>),
],
async {
let (mut reader, offset, length) = GetObjectReader::new(
Box::new(Cursor::new(encrypted)),
None,
&object_info,
&ObjectOptions::default(),
&http::HeaderMap::new(),
)
.await
.expect("construct GetObjectReader from MinIO raw fixture");
let plaintext = read_fixture_plaintext(encrypted, object_info, kms_key_b64)
.await
.expect("fixture must restore with the configured KMS key");
let mut plaintext = Vec::new();
reader
.read_to_end(&mut plaintext)
.await
.expect("read plaintext from MinIO raw fixture");
assert_eq!(offset, 0);
assert_eq!(length, object_info.size);
assert_eq!(reader.object_info.size, expected_size);
assert_eq!(plaintext.len(), expected_size as usize);
assert_eq!(sha256_hex(&plaintext), expected_sha256);
},
)
.await;
assert_eq!(object_size, expected_size);
assert_eq!(plaintext.len(), expected_size as usize);
assert_eq!(sha256_hex(&plaintext), expected_sha256);
}
@@ -0,0 +1,376 @@
# ECStore Validation Suite Design
This document defines the validation suite RustFS should use before claiming
ECStore erasure-coding correctness, durability, and fault-resilience coverage.
It is a suite design, not a claim that all tests already exist.
## Goal
Provide one command that runs the full ECStore confidence suite and produces a
single pass/fail result plus artifacts. The command should exercise both:
- white-box invariants in `rustfs-ecstore`, `rustfs-filemeta`, and related
helpers;
- black-box S3 and admin behavior against real single-node and distributed
erasure deployments.
The suite can reduce release risk; it cannot prove the absence of all defects.
It must therefore combine deterministic matrices, negative tests, fuzz/corpus
tests, chaos tests, and explicit artifact review.
## Proposed Entry Point
Use the top-level runner for repeatable local, CI, and release checks:
```bash
scripts/run_ecstore_validation_suite.sh --profile full
```
Profiles:
| Profile | Purpose | Expected cost |
| --- | --- | --- |
| `quick` | PR smoke for EC logic and existing single-node reliability tests. | minutes |
| `full` | Release gate: white-box, e2e, chaos, S3 compatibility subset, coverage. | hours |
| `destructive` | Manual/nightly gate: distributed 4-node/16-disk, crash/restart, rebalance/decommission fault injection. | hours+ |
| `fuzz` | Malformed metadata/RPC/corpus fuzzing with fixed seed output. | bounded by budget |
The current runner wires the existing high-signal checks. The matrix below
still tracks required follow-up coverage before `full` can be treated as a
complete release gate.
The runner writes artifacts under
`target/ecstore-validation/<timestamp>/`:
- command transcript and environment;
- randomized seeds;
- object manifests with SHA256;
- per-disk layout snapshots before and after mutation;
- server logs;
- junit/nextest output;
- `blackbox-matrix.tsv` with selected black-box and fixture gates;
- coverage report;
- failure reproducer instructions.
## Acceptance Rules
The suite passes only when all selected profile commands pass and every
scenario asserts both API-visible behavior and on-disk state where applicable.
Required fail-closed rules:
- never return corrupted object bytes;
- never silently accept forged or split-brain metadata;
- never downgrade write quorum to read quorum;
- never leave a mixed old/new object after partial commit;
- never panic on malformed EC metadata;
- return typed errors or quorum failures for invalid states.
Unit-test coverage is a hard release-gate input: `rustfs-ecstore` unit line
coverage for the EC-critical scope must be at least 95%, with 100% as the
target for EC read, write, decode, heal, metadata quorum, and rollback paths.
A lower threshold is only acceptable for a temporary, explicitly documented
exception tied to missing testability or unreachable code. Full-crate coverage
is still reported as an observation metric, but it must not hide EC regressions
behind unrelated modules.
Tests must not pass by only checking constants, helper calls, deleted branches,
or implementation details. Every test needs a reader-facing or storage-state
assertion.
Fixture-backed checks are optional for local smoke runs, but explicit in the
artifact stream. `--require-fixtures` turns missing legacy or MinIO generated
fixtures into an early `ecstore-fixture-gate` failure before expensive black-box
steps run. The MinIO generated fixture gate requires both
`RUSTFS_MINIO_FIXTURE_ROOT` and `RUSTFS_MINIO_STATIC_KMS_KEY_B64`.
## White-Box Matrix
### Erasure Algorithm
Target files:
- `crates/ecstore/src/erasure/coding/erasure.rs`
- `crates/ecstore/src/erasure/coding/encode.rs`
- `crates/ecstore/src/erasure/coding/decode.rs`
- `crates/ecstore/src/erasure/coding/decode_reader.rs`
- `crates/ecstore/src/erasure/codec/bridge.rs`
Coverage:
| Area | Scenarios | Assertions |
| --- | --- | --- |
| Shard geometry | legacy/current shard-size formulas; lengths `0`, `1`, `block-1`, `block`, `block+1`, multi-block tail | no divide-by-zero; shard/file/range offsets match expected |
| Encode/decode | `(data, parity)` sets `2+2`, `4+2`, `8+8`; random payloads; missing shards up to parity | reconstructed data equals original |
| Negative reconstruction | missing shards above parity; inconsistent shard lengths; corrupt surplus parity | typed error, no partial success |
| Source verification | missing data shard plus extra parity source | rebuilt parity must match source parity |
| Legacy compatibility | old shard formula and legacy checksum data | legacy files decode and heal correctly |
| Streaming decode | legacy engine vs RustFS codec engine on same stripe stream | bytes and errors are equivalent |
| Range output | head/middle/tail/suffix; cross-block and final-short-stripe ranges | exact byte range, no over-read/under-read |
Add property tests with fixed replay seeds for payload, range, and missing-shard
selection.
### Bitrot and Reader Alignment
Target files:
- `crates/ecstore/src/erasure/coding/bitrot.rs`
- `crates/ecstore/src/erasure/coding/decode.rs`
- `crates/ecstore/src/set_disk/core/io_primitives.rs`
- `crates/ecstore/src/set_disk/shard_source.rs`
Coverage:
| Area | Scenarios | Assertions |
| --- | --- | --- |
| Hash framing | valid hash+data; wrong hash; truncated hash; truncated data | invalid data never succeeds |
| Short shard | short read under normal hash, `skip_verify`, and hash-none | `UnexpectedEof` or equivalent typed error |
| Lockstep reads | mid-stream data shard failure; pending/timeout reader; final short stripe | each live reader advances exactly one stripe; failed reader retires |
| Adaptive reads | hedged parity fallback and timeout retirement | no shard desync; reconstructed bytes match original |
| Shard source order | out-of-order read completion and missing slots | slots resolve by shard index |
| Deferred readers | data-blocks-first setup opens deferred parity at correct offset | parity fallback uses aligned data |
Use instrumented readers that record shard index, stripe index, read count,
offset, and retirement reason.
### Metadata, Quorum, and Commit Atomicity
Target files:
- `crates/filemeta/src/filemeta/version.rs`
- `crates/ecstore/src/set_disk/read.rs`
- `crates/ecstore/src/set_disk/metadata.rs`
- `crates/ecstore/src/set_disk/ops/object.rs`
- `crates/ecstore/src/set_disk/core/io_primitives.rs`
- `crates/ecstore/src/disk/local.rs`
Coverage:
| Area | Scenarios | Assertions |
| --- | --- | --- |
| Metadata tamper | same `version_id`/`mod_time`, divergent data dir, parts, ETag, size, checksum, inline flag, erasure distribution | previous committed version or read quorum error; no arbitrary latest |
| Early stop | valid quorum, stale quorum, corrupt trailing disks, slow trailing disks | early-stop only on safe identity |
| Quorum downgrade | read quorum vs write quorum; delete marker quorum; version-not-found quorum | no mutation below write quorum |
| Rename atomicity | failure before data rename, after data rename, after metadata rename, cleanup failure | object is old or new; never mixed |
| Rollback | failed commit quorum and stale temp data | rollback preserves old metadata/data |
| Malformed metadata | oversized lengths, bad CRC, invalid versions, invalid UUID/timestamp/enum, huge parts | bounded memory; typed error; no panic |
Fault injection should be explicit and deterministic, preferably through local
disk mocks for unit tests and process-level disk manipulation for e2e tests.
## Black-Box Matrix
Use `crates/e2e_test` for real S3/admin behavior and extend
`crates/e2e_test/src/chaos.rs` rather than duplicating ad hoc helpers.
The current runner emits `blackbox-matrix.tsv` for every invocation. It is a
machine-readable manifest of selected black-box scenarios, their commands,
required fixture environment, and whether each row is enabled, disabled, or
missing an optional/required fixture.
Current runner rows:
| Profile | Scenario | Gate | Fixture env |
| --- | --- | --- | --- |
| `quick` | single-node disk fault read/write | e2e black box | none |
| `quick` | degraded erasure disk rebuild | e2e black box | none |
| `quick` | namespace lock quorum under EC ops | e2e black box | none |
| `full` | legacy bitrot read fixture restore | fixture black box | `RUSTFS_LEGACY_TEST_ROOT`, `RUSTFS_LEGACY_TEST_DISK` |
| `full` | MinIO generated encrypted read and negative restore fixture | fixture black box | `RUSTFS_MINIO_FIXTURE_ROOT`, `RUSTFS_MINIO_STATIC_KMS_KEY_B64` |
| `full` | S3 multipart/range/versioning/delete subset | S3 black box | none |
| `destructive` | distributed cluster concurrency | e2e black box | none |
| `destructive` | stale multipart cleanup cluster | e2e black box | none |
| `destructive` | delete marker migration semantics | e2e black box | none |
### Single-Node 4-Disk EC
| Scenario | Required assertions |
| --- | --- |
| baseline PUT/GET/HEAD/List for tiny, inline, block-boundary, multi-block, multipart objects | SHA256 manifest matches; metadata is consistent on all disks |
| one disk offline during read | existing objects readable; no corrupted bytes |
| one disk offline during write | write succeeds only when write quorum holds; restored disk is healed |
| above-parity disk loss | GET/PUT fails with quorum error; no partial bytes accepted |
| corrupt data shard and parity shard | GET returns original bytes or fails closed; read-repair/heal restores |
| corrupt inline `xl.meta` | fail closed or heal; no forged inline data |
| range read with offline/corrupt shard | exact range bytes; invalid ranges produce expected S3 errors |
| multipart part resend and concurrent same-part writes | final complete object matches chosen committed parts |
| crash during multipart complete/put/delete | after restart only old or new full version is visible |
Existing anchors:
- `crates/e2e_test/src/reliability_disk_fault_test.rs`
- `crates/e2e_test/src/heal_erasure_disk_rebuild_test.rs`
- `crates/e2e_test/src/chaos.rs`
### Distributed 4-Node / 16-Disk EC
| Scenario | Required assertions |
| --- | --- |
| node/disk outage while reading large objects | no EOF/truncation; SHA256 manifest matches |
| write while one remote node is down | write follows quorum; later heal reconstructs remote disk |
| remote shard bitrot | degraded read uses clean shards; no bad bytes |
| concurrent GET/PUT/DELETE/List on same key | no 500 for expected conflicts; no dirty reads |
| range GET matrix for large objects | sequential and parallel ranges match full-object hash |
| internode timeout/slow disk | typed error or fallback; no desync |
This layer is mandatory because single-process unit tests cannot prove RPC,
HTTP/2, timeout, and distributed quorum behavior.
### Versioning, Delete Markers, and Migration
| Scenario | Required assertions |
| --- | --- |
| latest delete marker | GET/HEAD/ListObjectVersions match S3 semantics |
| explicit `versionId` for old versions | exact old bytes and metadata |
| suspended/null version | no version ordering regression |
| delete marker during heal/rebalance/decommission | marker visibility and history are preserved |
| orphan directory cleanup | real objects are not purged; tombstones behave correctly |
### Heal, Rebalance, and Decommission
| Scenario | Required assertions |
| --- | --- |
| auto heal and admin deep heal | data hash unchanged; `xl.meta` and format data rebuilt |
| heal interruption/restart | idempotent recovery; no dangling temp objects |
| two-pool rebalance with versioned/multipart objects | source and target pools have consistent versions |
| decommission cancel/restart/finalize | target readable; source cleanup safe |
| rebalance/decommission with node outage | progress resumes; no duplicate or missing versions |
Existing scripts under `scripts/test/decommission_*.sh` should be wrapped into
the `destructive` profile only after they emit machine-readable pass/fail
artifacts.
## S3 Compatibility and Large Object Gates
The full profile should include a targeted S3 compatibility subset, not the
entire compatibility suite by default:
```bash
TESTEXPR="multipart or range or versioning or delete" \
DEPLOY_MODE=build \
MAXFAIL=0 \
./scripts/s3-tests/run.sh
```
Large object gates:
- `scripts/run_get_codec_streaming_smoke.sh` for legacy/codec GET parity;
- `scripts/run_gt1g_get_http_matrix.sh` for sequential and parallel range GET;
- `scripts/run_gt1g_multipart_put_matrix.sh` for multipart PUT paths.
These should be artifact-producing optional stages in `full` or `destructive`
profiles, not hidden local-only commands.
## Fuzz and Corpus Gates
Add bounded fuzz/corpus tests for:
- `xl.meta` MessagePack and legacy filemeta versions;
- protobuf/RPC payload decoding;
- checksum and bitrot headers;
- range offset/length overflow;
- object names with path traversal, encoded separators, and symlink components;
- huge inline metadata and huge part counts.
Each corpus failure must save the input bytes and the minimized reproducer under
the suite artifact directory.
## Coverage Snapshot Target
The runner enforces unit line coverage for `rustfs-ecstore` in `full` and
`destructive` profiles. The default threshold is 95%, and the target remains
100% for EC-critical paths:
```bash
scripts/run_ecstore_validation_suite.sh --profile full --unit-coverage-min 95
scripts/run_ecstore_validation_suite.sh --profile full --unit-coverage-min 100
scripts/run_ecstore_validation_suite.sh --profile full --unit-coverage-scope crate
```
The default hard gate scope is `ec-critical`, covering:
- `crates/ecstore/src/erasure/**`
- `crates/ecstore/src/set_disk/read.rs`
- `crates/ecstore/src/set_disk/shard_source.rs`
- `crates/ecstore/src/set_disk/metadata.rs`
- `crates/ecstore/src/set_disk/ops/object.rs`
- `crates/ecstore/src/set_disk/core/io_primitives.rs`
- `crates/ecstore/src/disk/local.rs`
Minimum release-gate target:
- `cargo-llvm-cov` must be installed unless `--skip-coverage` is explicitly set;
- `rustfs-ecstore` EC-critical unit line coverage is at least 95%;
- EC read/write/decode/heal/quorum/rollback code should trend toward 100%;
- full-crate unit line coverage is recorded for visibility but is not the EC
hard gate;
- all HIGH rows in this document have positive and negative tests;
- changed EC/read/write/heal lines are covered;
- branch coverage is reviewed for reconstruction, quorum, and error paths;
- uncovered branches are either intentionally unreachable or tracked.
The `quick` profile also runs
`cargo test -p rustfs-ecstore --lib -- --test-threads=1` so the white-box smoke
includes all current `rustfs-ecstore` library unit tests without parallel test
context cross-talk, not only focused EC filters.
Coverage artifacts:
- `target/ecstore-validation/<timestamp>/coverage/ecstore/lcov.info`
- `target/ecstore-validation/<timestamp>/coverage/ecstore/summary.tsv`
- `target/ecstore-validation/<timestamp>/coverage/ecstore/files.tsv`
Local validation on 2026-07-07 found that the current suite is not yet at the
target: full-crate unit line coverage was 69.32%; the EC-critical scope was
84.32% after the first negative read/write/recovery additions. The lowest
EC-critical files were `set_disk/ops/object.rs`, `io_primitives.rs`,
`disk/local.rs`, `bitrot.rs`, and `encode.rs`. These are gaps to close before
the default 95% gate can pass.
## Initial Command Set
Use the runner first:
```bash
scripts/run_ecstore_validation_suite.sh --profile quick
scripts/run_ecstore_validation_suite.sh --profile full
scripts/run_ecstore_validation_suite.sh --profile destructive
scripts/run_ecstore_validation_suite.sh --profile fuzz
```
The split-run equivalent for the quick profile is:
```bash
cargo test -p rustfs-filemeta --lib
cargo test -p rustfs-ecstore --lib erasure
cargo test -p rustfs-ecstore --lib set_disk::read
cargo test -p rustfs-ecstore --lib set_disk::core::io_primitives
cargo test -p rustfs-ecstore --lib set_disk::tests::test_rename_data_quorum_failure_rolls_back_destination_object
cargo test -p rustfs-ecstore --lib disk::local
cargo test -p rustfs-ecstore --lib -- --test-threads=1
cargo test --package e2e_test reliability_disk_fault_test -- --nocapture
cargo test --package e2e_test heal_erasure_disk_rebuild_test -- --nocapture
cargo test --package e2e_test namespace_lock_quorum_test -- --nocapture
```
Fixture-backed tests should run when the fixture path is present:
```bash
cargo test -p rustfs-ecstore --test legacy_bitrot_read_test -- --nocapture
cargo test -p rustfs-ecstore --features rio-v2 --test minio_generated_read_test -- --ignored --nocapture
```
## Multi-Expert Adversarial Review Summary
Three independent read-only reviews were run for this design:
| Reviewer | Main challenge | Resulting requirement |
| --- | --- | --- |
| Algorithm correctness | A single run only samples one timing/layout/hash combination. | matrix/property tests plus instrumented reader alignment checks |
| Black-box reliability | Existing tests miss distributed, shard-desync, range+fault, and migration-fault combinations. | add 4-node/16-disk and destructive profiles |
| Security and fault review | Metadata tamper, quorum downgrade, bitrot bypass, and rename atomicity must be end-to-end. | HIGH matrix rows block completion claims |
Current verdict: design accepted as a target. The runner exists and captures
artifacts; implementation remains incomplete until the missing matrix rows have
artifact-backed evidence.
+541
View File
@@ -0,0 +1,541 @@
#!/usr/bin/env bash
set -euo pipefail
PROFILE="quick"
OUT_DIR=""
DRY_RUN=false
SKIP_E2E=false
SKIP_S3_TESTS=false
SKIP_COVERAGE=false
REQUIRE_FIXTURES=false
UNIT_COVERAGE_MIN="95"
UNIT_COVERAGE_TARGET="100"
UNIT_COVERAGE_SCOPE="ec-critical"
usage() {
cat <<'USAGE'
Usage:
scripts/run_ecstore_validation_suite.sh [options]
Profiles:
quick Core EC read/write/recovery checks for PR smoke (default)
full quick + s3-tests subset + fixture tests + coverage
destructive full + cluster/concurrency/destructive recovery checks
fuzz bounded fuzz smoke for malformed storage inputs
Options:
--profile <quick|full|destructive|fuzz>
--out-dir <path> Default: target/ecstore-validation/<timestamp>
--skip-e2e Skip e2e_test commands
--skip-s3-tests Skip scripts/s3-tests/run.sh in full/destructive
--skip-coverage Explicitly skip the mandatory unit coverage gate
--unit-coverage-min <percent>
Minimum rustfs-ecstore unit line coverage (default: 95)
--unit-coverage-scope <ec-critical|crate>
Coverage scope for the hard gate (default: ec-critical)
--require-fixtures Fail when optional EC fixture tests cannot run
--dry-run Print commands and write metadata without executing
-h, --help
Artifacts:
<out-dir>/run-metadata.env
<out-dir>/summary.tsv
<out-dir>/blackbox-matrix.tsv
<out-dir>/logs/*.log
USAGE
}
arg_value() {
local flag="$1"
local value="${2:-}"
if [[ -z "$value" || "$value" == --* ]]; then
echo "ERROR: missing value for $flag" >&2
exit 1
fi
printf '%s\n' "$value"
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--profile) PROFILE="$(arg_value "$1" "${2:-}")"; shift 2 ;;
--out-dir) OUT_DIR="$(arg_value "$1" "${2:-}")"; shift 2 ;;
--skip-e2e) SKIP_E2E=true; shift ;;
--skip-s3-tests) SKIP_S3_TESTS=true; shift ;;
--skip-coverage) SKIP_COVERAGE=true; shift ;;
--unit-coverage-min) UNIT_COVERAGE_MIN="$(arg_value "$1" "${2:-}")"; shift 2 ;;
--unit-coverage-scope) UNIT_COVERAGE_SCOPE="$(arg_value "$1" "${2:-}")"; shift 2 ;;
--require-fixtures) REQUIRE_FIXTURES=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
-h|--help) usage; exit 0 ;;
*)
echo "ERROR: unknown arg: $1" >&2
usage
exit 1
;;
esac
done
}
validate_args() {
case "$PROFILE" in
quick|full|destructive|fuzz) ;;
*)
echo "ERROR: --profile must be quick, full, destructive, or fuzz" >&2
exit 1
;;
esac
if ! [[ "$UNIT_COVERAGE_MIN" =~ ^([0-9]|[1-9][0-9]|100)(\.[0-9]+)?$ ]]; then
echo "ERROR: --unit-coverage-min must be between 0 and 100" >&2
exit 1
fi
case "$UNIT_COVERAGE_SCOPE" in
ec-critical|crate) ;;
*)
echo "ERROR: --unit-coverage-scope must be ec-critical or crate" >&2
exit 1
;;
esac
}
setup_workspace() {
local root
root="$(git rev-parse --show-toplevel)"
cd "$root"
if [[ -z "$OUT_DIR" ]]; then
OUT_DIR="target/ecstore-validation/$(date -u +%Y%m%dT%H%M%SZ)"
fi
mkdir -p "$OUT_DIR/logs"
SUMMARY="$OUT_DIR/summary.tsv"
BLACKBOX_MATRIX="$OUT_DIR/blackbox-matrix.tsv"
printf 'status\tlabel\tlog\n' >"$SUMMARY"
}
write_metadata() {
{
printf 'profile=%s\n' "$PROFILE"
printf 'started_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf 'dry_run=%s\n' "$DRY_RUN"
printf 'skip_e2e=%s\n' "$SKIP_E2E"
printf 'skip_s3_tests=%s\n' "$SKIP_S3_TESTS"
printf 'skip_coverage=%s\n' "$SKIP_COVERAGE"
printf 'unit_coverage_min=%s\n' "$UNIT_COVERAGE_MIN"
printf 'unit_coverage_target=%s\n' "$UNIT_COVERAGE_TARGET"
printf 'unit_coverage_scope=%s\n' "$UNIT_COVERAGE_SCOPE"
printf 'require_fixtures=%s\n' "$REQUIRE_FIXTURES"
printf 'git_commit=%s\n' "$(git rev-parse HEAD 2>/dev/null || true)"
printf 'git_branch=%s\n' "$(git branch --show-current 2>/dev/null || true)"
printf 'rustc=%s\n' "$(rustc --version 2>/dev/null || true)"
printf 'cargo=%s\n' "$(cargo --version 2>/dev/null || true)"
} >"$OUT_DIR/run-metadata.env"
}
safe_label() {
printf '%s\n' "$1" | tr -c 'A-Za-z0-9_.-' '_'
}
quote_command() {
local quoted=()
local arg
for arg in "$@"; do
quoted+=("$(printf '%q' "$arg")")
done
printf '%s ' "${quoted[@]}"
}
record_skip() {
local label="$1"
local reason="$2"
local log="$OUT_DIR/logs/$(safe_label "$label").log"
printf 'SKIP: %s\n' "$reason" | tee "$log"
printf 'SKIP\t%s\t%s\n' "$label" "$log" >>"$SUMMARY"
}
run_step() {
local label="$1"
shift
local log="$OUT_DIR/logs/$(safe_label "$label").log"
{
printf 'label=%s\n' "$label"
printf 'command='
quote_command "$@"
printf '\n'
} >"$log"
if [[ "$DRY_RUN" == "true" ]]; then
printf 'DRY-RUN: '
quote_command "$@"
printf '\n'
printf 'DRY_RUN\t%s\t%s\n' "$label" "$log" >>"$SUMMARY"
return 0
fi
printf 'RUN: %s\n' "$label"
if "$@" 2>&1 | tee -a "$log"; then
printf 'PASS\t%s\t%s\n' "$label" "$log" >>"$SUMMARY"
else
local status=$?
printf 'FAIL\t%s\t%s\n' "$label" "$log" >>"$SUMMARY"
return "$status"
fi
}
run_core_unit_steps() {
run_step "filemeta-lib" cargo test -p rustfs-filemeta --lib
run_step "ecstore-erasure-lib" cargo test -p rustfs-ecstore --lib erasure
run_step "ecstore-set-disk-read-lib" cargo test -p rustfs-ecstore --lib set_disk::read
run_step "ecstore-io-primitives-lib" cargo test -p rustfs-ecstore --lib set_disk::core::io_primitives
run_step "ecstore-rename-rollback" \
cargo test -p rustfs-ecstore --lib set_disk::tests::test_rename_data_quorum_failure_rolls_back_destination_object
run_step "ecstore-disk-local-lib" cargo test -p rustfs-ecstore --lib disk::local
run_step "ecstore-lib-all" cargo test -p rustfs-ecstore --lib -- --test-threads=1
}
run_quick_e2e_steps() {
if [[ "$SKIP_E2E" == "true" ]]; then
record_skip "e2e-quick" "--skip-e2e was set"
return
fi
run_step "e2e-reliability-disk-fault" cargo test --package e2e_test reliability_disk_fault_test -- --nocapture
run_step "e2e-heal-erasure-disk-rebuild" cargo test --package e2e_test heal_erasure_disk_rebuild_test -- --nocapture
run_step "e2e-namespace-lock-quorum" cargo test --package e2e_test namespace_lock_quorum_test -- --nocapture
}
run_quick_profile() {
run_core_unit_steps
run_quick_e2e_steps
}
fixture_available() {
[[ -n "${RUSTFS_MINIO_FIXTURE_ROOT:-}" && -d "${RUSTFS_MINIO_FIXTURE_ROOT}" && -n "${RUSTFS_MINIO_STATIC_KMS_KEY_B64:-}" ]]
}
legacy_fixture_available() {
local root="${RUSTFS_LEGACY_TEST_ROOT:-$(pwd)}"
local disk="${RUSTFS_LEGACY_TEST_DISK:-}"
if [[ -z "$disk" ]]; then
if [[ -f "$root/test/.minio.sys/format.json" ]]; then
disk="test"
else
disk="test1"
fi
fi
if [[ ! -f "$root/$disk/.rustfs.sys/format.json" && ! -f "$root/$disk/.minio.sys/format.json" ]]; then
return 1
fi
[[ -f "$root/$disk/vvvv/ktvzip.tar.gz/xl.meta" || -f "$root/$disk/vvvv/path_traversal.md/xl.meta" ]]
}
minio_fixture_missing_reason() {
if [[ -z "${RUSTFS_MINIO_FIXTURE_ROOT:-}" ]]; then
printf 'RUSTFS_MINIO_FIXTURE_ROOT is not set\n'
elif [[ ! -d "${RUSTFS_MINIO_FIXTURE_ROOT}" ]]; then
printf 'RUSTFS_MINIO_FIXTURE_ROOT does not exist: %s\n' "$RUSTFS_MINIO_FIXTURE_ROOT"
elif [[ -z "${RUSTFS_MINIO_STATIC_KMS_KEY_B64:-}" ]]; then
printf 'RUSTFS_MINIO_STATIC_KMS_KEY_B64 is not set\n'
else
printf 'unknown MinIO fixture gate failure\n'
fi
}
write_blackbox_matrix() {
local e2e_status="enabled"
local s3_status="enabled"
local destructive_status="enabled"
local legacy_status="missing-optional"
local minio_status="missing-optional"
if [[ "$SKIP_E2E" == "true" ]]; then
e2e_status="disabled"
destructive_status="disabled"
fi
if [[ "$SKIP_S3_TESTS" == "true" ]]; then
s3_status="disabled"
fi
if legacy_fixture_available; then
legacy_status="enabled"
elif [[ "$REQUIRE_FIXTURES" == "true" ]]; then
legacy_status="missing-required"
fi
if fixture_available; then
minio_status="enabled"
elif [[ "$REQUIRE_FIXTURES" == "true" ]]; then
minio_status="missing-required"
fi
{
printf 'profile\tscenario\tgate\tcommand\tfixture_env\tstatus\n'
printf 'quick\tsingle-node disk fault read/write\tblack-box\tcargo test --package e2e_test reliability_disk_fault_test -- --nocapture\tnone\t%s\n' "$e2e_status"
printf 'quick\theal degraded erasure disk rebuild\tblack-box\tcargo test --package e2e_test heal_erasure_disk_rebuild_test -- --nocapture\tnone\t%s\n' "$e2e_status"
printf 'quick\tnamespace lock quorum under EC ops\tblack-box\tcargo test --package e2e_test namespace_lock_quorum_test -- --nocapture\tnone\t%s\n' "$e2e_status"
printf 'full\tlegacy bitrot read fixture restore\tfixture\tcargo test -p rustfs-ecstore --test legacy_bitrot_read_test -- --nocapture\tRUSTFS_LEGACY_TEST_ROOT,RUSTFS_LEGACY_TEST_DISK\t%s\n' "$legacy_status"
printf 'full\tMinIO generated encrypted read and negative restore fixture\tfixture\tcargo test -p rustfs-ecstore --features rio-v2 --test minio_generated_read_test -- --ignored --nocapture\tRUSTFS_MINIO_FIXTURE_ROOT,RUSTFS_MINIO_STATIC_KMS_KEY_B64\t%s\n' "$minio_status"
printf 'full\tS3 multipart range versioning delete subset\tblack-box\tenv TESTEXPR=\"multipart or range or versioning or delete\" DEPLOY_MODE=build MAXFAIL=0 ./scripts/s3-tests/run.sh\tnone\t%s\n' "$s3_status"
printf 'destructive\tdistributed cluster concurrency\tblack-box\tcargo test --package e2e_test cluster_concurrency_test -- --nocapture\tnone\t%s\n' "$destructive_status"
printf 'destructive\tstale multipart cleanup cluster\tblack-box\tcargo test --package e2e_test stale_multipart_cleanup_cluster_test -- --nocapture\tnone\t%s\n' "$destructive_status"
printf 'destructive\tdelete marker migration semantics\tblack-box\tcargo test --package e2e_test delete_marker_migration_semantics_test -- --nocapture\tnone\t%s\n' "$destructive_status"
} >"$BLACKBOX_MATRIX"
}
run_fixture_gate() {
local label="ecstore-fixture-gate"
local log="$OUT_DIR/logs/$(safe_label "$label").log"
local legacy_ok=false
local minio_ok=false
if legacy_fixture_available; then
legacy_ok=true
fi
if fixture_available; then
minio_ok=true
fi
{
printf 'label=%s\n' "$label"
printf 'legacy_fixture_available=%s\n' "$legacy_ok"
printf 'minio_fixture_available=%s\n' "$minio_ok"
printf 'require_fixtures=%s\n' "$REQUIRE_FIXTURES"
if [[ "$minio_ok" != "true" ]]; then
printf 'minio_fixture_reason=%s\n' "$(minio_fixture_missing_reason)"
fi
} | tee "$log"
if [[ "$DRY_RUN" == "true" ]]; then
printf 'DRY_RUN\t%s\t%s\n' "$label" "$log" >>"$SUMMARY"
return 0
fi
if [[ "$legacy_ok" == "true" && "$minio_ok" == "true" ]]; then
printf 'PASS\t%s\t%s\n' "$label" "$log" >>"$SUMMARY"
return 0
fi
if [[ "$REQUIRE_FIXTURES" == "true" ]]; then
printf 'FAIL\t%s\t%s\n' "$label" "$log" >>"$SUMMARY"
return 1
fi
printf 'SKIP\t%s\t%s\n' "$label" "$log" >>"$SUMMARY"
}
run_fixture_steps() {
if legacy_fixture_available; then
run_step "ecstore-legacy-bitrot-read-fixture" cargo test -p rustfs-ecstore --test legacy_bitrot_read_test -- --nocapture
elif [[ "$REQUIRE_FIXTURES" == "true" ]]; then
echo "ERROR: RUSTFS_LEGACY_TEST_ROOT/RUSTFS_LEGACY_TEST_DISK is required for legacy bitrot fixture tests" >&2
exit 1
else
record_skip "ecstore-legacy-bitrot-read-fixture" "legacy bitrot fixture is not present"
fi
if fixture_available; then
run_step "ecstore-minio-generated-read-fixture" \
cargo test -p rustfs-ecstore --features rio-v2 --test minio_generated_read_test -- --ignored --nocapture
elif [[ "$REQUIRE_FIXTURES" == "true" ]]; then
echo "ERROR: $(minio_fixture_missing_reason)" >&2
exit 1
else
record_skip "ecstore-minio-generated-read-fixture" "$(minio_fixture_missing_reason)"
fi
}
run_s3_subset() {
if [[ "$SKIP_S3_TESTS" == "true" ]]; then
record_skip "s3-tests-ec-subset" "--skip-s3-tests was set"
return
fi
run_step "s3-tests-ec-subset" \
env TESTEXPR="multipart or range or versioning or delete" DEPLOY_MODE=build MAXFAIL=0 ./scripts/s3-tests/run.sh
}
write_coverage_reports() {
local lcov_path="$1"
local coverage_summary="$2"
local coverage_files="$3"
local root="$4"
awk -v root="$root" -v scope="$UNIT_COVERAGE_SCOPE" -v summary="$coverage_summary" -v files="$coverage_files" '
function is_crate_file(f) {
return index(f, root "/crates/ecstore/src/") == 1
}
function is_ec_critical_file(f) {
return index(f, root "/crates/ecstore/src/erasure/") == 1 ||
f == root "/crates/ecstore/src/set_disk/read.rs" ||
f == root "/crates/ecstore/src/set_disk/shard_source.rs" ||
f == root "/crates/ecstore/src/set_disk/metadata.rs" ||
f == root "/crates/ecstore/src/set_disk/ops/object.rs" ||
f == root "/crates/ecstore/src/set_disk/core/io_primitives.rs" ||
f == root "/crates/ecstore/src/disk/local.rs"
}
function is_gate_file(f) {
return scope == "crate" ? is_crate_file(f) : is_ec_critical_file(f)
}
function pct(hit, found) {
return found == 0 ? 0 : (hit / found) * 100
}
function flush() {
if (file == "") {
return
}
if (is_crate_file(file)) {
crate_hit += hit
crate_found += found
}
if (is_ec_critical_file(file)) {
ec_hit += hit
ec_found += found
}
if (is_gate_file(file)) {
gate_hit += hit
gate_found += found
}
if (is_crate_file(file)) {
printf "%s\t%.2f\t%d\t%d\n", file, pct(hit, found), hit, found >> files
}
}
BEGIN {
print "file\tline_coverage\thit\tfound" > files
}
/^SF:/ {
flush()
file = substr($0, 4)
hit = 0
found = 0
next
}
/^LH:/ { hit += substr($0, 4); next }
/^LF:/ { found += substr($0, 4); next }
END {
flush()
if (gate_found == 0) {
exit 2
}
print "metric\tactual\tmin\ttarget\tscope" > summary
printf "gate_line_coverage\t%.2f\t%s\t%s\t%s\n", pct(gate_hit, gate_found), min, target, scope >> summary
printf "ec_critical_line_coverage\t%.2f\t%s\t%s\tec-critical\n", pct(ec_hit, ec_found), min, target >> summary
printf "crate_line_coverage\t%.2f\t%s\t%s\tcrate\n", pct(crate_hit, crate_found), min, target >> summary
}
' min="$UNIT_COVERAGE_MIN" target="$UNIT_COVERAGE_TARGET" "$lcov_path"
}
run_coverage() {
if [[ "$SKIP_COVERAGE" == "true" ]]; then
record_skip "ecstore-coverage" "--skip-coverage was set"
return
fi
local label="ecstore-unit-coverage"
local log="$OUT_DIR/logs/$(safe_label "$label").log"
local coverage_dir="$OUT_DIR/coverage/ecstore"
local lcov_path="$coverage_dir/lcov.info"
local coverage_summary="$coverage_dir/summary.tsv"
local coverage_files="$coverage_dir/files.tsv"
local cmd=(cargo llvm-cov -p rustfs-ecstore --lib --lcov --output-path "$lcov_path" -- --test-threads=1)
local root
root="$(pwd)"
mkdir -p "$coverage_dir"
{
printf 'label=%s\n' "$label"
printf 'unit_coverage_min=%s\n' "$UNIT_COVERAGE_MIN"
printf 'unit_coverage_target=%s\n' "$UNIT_COVERAGE_TARGET"
printf 'unit_coverage_scope=%s\n' "$UNIT_COVERAGE_SCOPE"
printf 'command='
quote_command "${cmd[@]}"
printf '\n'
} >"$log"
if [[ "$DRY_RUN" == "true" ]]; then
printf 'DRY-RUN: '
quote_command "${cmd[@]}"
printf '\n'
printf 'DRY_RUN\t%s\t%s\n' "$label" "$log" >>"$SUMMARY"
return
fi
if ! command -v cargo-llvm-cov >/dev/null 2>&1; then
printf 'ERROR: cargo-llvm-cov is required for the ECStore unit coverage gate\n' | tee -a "$log"
printf 'FAIL\t%s\t%s\n' "$label" "$log" >>"$SUMMARY"
return 127
fi
printf 'RUN: %s\n' "$label"
if "${cmd[@]}" 2>&1 | tee -a "$log"; then
:
else
local status=$?
printf 'FAIL\t%s\t%s\n' "$label" "$log" >>"$SUMMARY"
return "$status"
fi
if ! write_coverage_reports "$lcov_path" "$coverage_summary" "$coverage_files" "$root"; then
printf 'ERROR: unable to compute scoped coverage from %s\n' "$lcov_path" | tee -a "$log"
printf 'FAIL\t%s\t%s\n' "$label" "$log" >>"$SUMMARY"
return 1
fi
local line_coverage
line_coverage="$(awk -F '\t' '$1 == "gate_line_coverage" { print $2 }' "$coverage_summary")"
printf 'gate_line_coverage=%s%% scope=%s min=%s%% target=%s%%\n' \
"$line_coverage" "$UNIT_COVERAGE_SCOPE" "$UNIT_COVERAGE_MIN" "$UNIT_COVERAGE_TARGET" | tee -a "$log"
printf 'coverage_summary=%s\ncoverage_files=%s\n' "$coverage_summary" "$coverage_files" | tee -a "$log"
if awk -v actual="$line_coverage" -v min="$UNIT_COVERAGE_MIN" 'BEGIN { exit !(actual + 0 >= min + 0) }'; then
printf 'PASS\t%s\t%s\n' "$label" "$log" >>"$SUMMARY"
else
printf 'FAIL: %s coverage %s%% is below %s%%\n' "$UNIT_COVERAGE_SCOPE" "$line_coverage" "$UNIT_COVERAGE_MIN" | tee -a "$log"
printf 'FAIL\t%s\t%s\n' "$label" "$log" >>"$SUMMARY"
return 1
fi
}
run_full_profile() {
run_quick_profile
run_fixture_gate
run_fixture_steps
run_s3_subset
run_coverage
}
run_destructive_profile() {
run_full_profile
if [[ "$SKIP_E2E" == "true" ]]; then
record_skip "e2e-destructive" "--skip-e2e was set"
return
fi
run_step "e2e-cluster-concurrency" cargo test --package e2e_test cluster_concurrency_test -- --nocapture
run_step "e2e-stale-multipart-cleanup-cluster" cargo test --package e2e_test stale_multipart_cleanup_cluster_test -- --nocapture
run_step "e2e-delete-marker-migration-semantics" cargo test --package e2e_test delete_marker_migration_semantics_test -- --nocapture
}
run_fuzz_profile() {
local max_total_time="${MAX_TOTAL_TIME:-60}"
run_step "fuzz-storage-inputs" env MAX_TOTAL_TIME="$max_total_time" ./scripts/fuzz/run.sh
}
main() {
parse_args "$@"
validate_args
setup_workspace
write_metadata
write_blackbox_matrix
case "$PROFILE" in
quick) run_quick_profile ;;
full) run_full_profile ;;
destructive) run_destructive_profile ;;
fuzz) run_fuzz_profile ;;
esac
printf 'summary=%s\n' "$SUMMARY"
printf 'artifacts=%s\n' "$OUT_DIR"
}
main "$@"