Compare commits

..

9 Commits

Author SHA1 Message Date
overtrue 1d305aeb5b docs(sse): record SSE-C as measured-readable in the migration matrix
The SSE-C row moves from unverified to supported now that a MinIO-generated SSE-C object reads end to end, with the test that establishes it. Single-part stays unverified for all three schemes — the fixture harness still cannot load objects MinIO inlined into xl.meta.

Also notes the one behavioural difference an operator will observe: a MinIO SSE-C object stores no customer-key MD5, so a wrong key is refused by the decryption rather than by the earlier parameter-mismatch check. Different error, same outcome.

Refs rustfs/backlog#1638.
2026-08-19 09:25:10 +08:00
overtrue fd26567cd7 fix(sse): read MinIO SSE-C objects, and pin the customer-key check
MinIO SSE-C objects were unreadable for the same reason its managed objects were: the dispatch demanded a header MinIO never persists. A MinIO SSE-C object stores exactly three keys — the internal sealed key, IV, and seal algorithm — and keeps the customer algorithm on the request, returning it on the response. Recognizing the internal sealed-key slot as well is the whole fix on that side; the customer key still has to be supplied by the caller.

The stored customer-key MD5 needed narrower handling. MinIO stores none, so comparing against it made every migrated object fail. The comparison is now skipped only when there is nothing to compare — no stored MD5 *and* the object carries MinIO's SSE-C slot — which does not weaken what the check buys: it is an early, friendlier rejection, while the key itself is proven by the object-key unseal, whose AEAD fails on a wrong key. A negative test holds that line by reading the fixture with a well-formed but wrong customer key.

Writing that test surfaced a gap worth closing on its own: disabling the stored-MD5 comparison for *every* object left all 115 tests in this file green, so nothing guarded it for RustFS-written objects either, and a later widening of the skip would have gone unnoticed. ssec_stored_md5_mismatch_is_refused_when_an_md5_is_stored now fails when that happens.

Verified against fixtures from a real MinIO server: the interop suite is 7/7, including SSE-C multipart, and both mutations — widening the MD5 skip, and the earlier slot-vs-key-id inference — turn it red.

Refs rustfs/backlog#1638.
2026-08-19 09:24:30 +08:00
overtrue 035a6f431a docs(sse): state MinIO interop by measured shape, and pin the production key path
The migration warning said RustFS cannot read MinIO-encrypted objects at all. That is no longer true for the shapes this branch fixes, and a blanket 'no' costs a migrating operator a full decrypt-and-recopy they may not need. It is replaced by a table that states support per object shape, with the test that establishes each row.

The table's honesty depends on one caveat worth stating in code rather than prose: every interop reader test injected its master key through __RUSTFS_SSE_SIMPLE_CMK, which is #[cfg(test)]-only and never reaches LocalSseDekProvider::new_from_env. Those tests going green therefore said nothing about a deployment. A new case reads a fixture through RUSTFS_SSE_S3_MASTER_KEY alone — the sole production entry point — so the claim now rests on the path operators actually run.

Single-part and SSE-C objects are listed as unverified rather than unsupported, because the fixture harness cannot yet load them (small objects live inline in xl.meta, sharded across disks) and they have never been read in a test either way. Conflating 'untested' with 'broken' in the other direction would be the same failure the old warning made.

The reverse direction gets an explicit ruling: MinIO cannot read RustFS-written objects, and the MinIO-branded headers RustFS writes are RustFS-internal rather than a compatibility promise — no coexistence plan should assume two-way reads.

StaticConfig's doc comment is corrected on the same basis: it described MinIO's ciphertext as the legacy JSON encoding, and claimed MinIO-written objects cannot be read at all, which now belongs to the object read path rather than to this backend.

Refs rustfs/backlog#1638.
2026-08-19 08:36:18 +08:00
overtrue 0e4953aeea Merge remote-tracking branch 'upstream/overtrue/kms-1638-d2-minio-sse-read' into overtrue/kms-1638-d2-minio-sse-read 2026-08-19 07:11:23 +08:00
overtrue 99ec65247a fix(sse): gate the MinIO data-key trait method behind rio-v2
The method's only call site sits in the rio-v2 branch of the managed read path, so a build without that feature carried a trait method nothing could reach — a warning under default features and, with -D warnings, a hard failure of the sftp lane. The declaration now carries the same gate its implementation and its sibling decrypt_legacy_sse_dek already had.

Verified against the lane that caught it (cargo clippy -p rustfs --features sftp --all-targets -- -D warnings, clean), plus the default build and the rio-v2 interop suite (4 passed).

Refs rustfs/backlog#1638.
2026-08-19 07:11:10 +08:00
Zhengchao An 6feb573f74 Merge branch 'main' into overtrue/kms-1638-d2-minio-sse-read 2026-08-19 07:09:40 +08:00
houseme d6814af2bc Merge branch 'main' into overtrue/kms-1638-d2-minio-sse-read 2026-08-18 23:54:31 +08:00
houseme dbef072bfe Merge branch 'main' into overtrue/kms-1638-d2-minio-sse-read 2026-08-18 12:46:12 +08:00
overtrue 420bfa859b fix(sse): read objects that MinIO encrypted
RustFS could not read a single MinIO-encrypted object. Two independent blockers, and backlog#1638 could only argue them statically because the fixtures the interop tests consume are generated, not checked in — so those tests had never once run. With the fixture lab working, both are now measured, fixed and covered.

The detection gate required `x-amz-server-side-encryption` to be present. MinIO never persists it: `crypto.S3.CreateMetadata` writes only the `X-Minio-Internal-*` family and the public header is synthesized onto the response by `DecryptObjectInfo`. Every MinIO object therefore fell out of the managed path and failed with "encrypted object metadata is incomplete". The scheme is now inferred from which sealed-key slot is present, which is self-consistent by construction: the slot decides both which header the unseal reads and which domain string the sealing key is derived under, so an inference that disagreed with the slot could not silently derive a wrong key. Inferring from the KMS key id would NOT be safe — MinIO writes `-S3-Kms-Key-Id` on SSE-S3 objects too, which the fixtures show and a mutation test pins.

Past the gate, the data key itself could not be unwrapped. Its wire format is `sealed_bytes || iv[16] || nonce[12]` — the randomness trails the ciphertext rather than leading it — with a per-ciphertext sealing key of `HMAC-SHA256(master, iv)` and the encryption context bound as associated data (`internal/kms/secret-key.go`). Note this is not the `{"aead":...}` JSON that backlog#1638's analysis described: current MinIO writes the raw layout and treats JSON only as a legacy encoding, normalizing it into the same byte order. Both are decoded here, in a decoder of their own — `LocalSseDekEnvelope`'s `deny_unknown_fields` is untouched, since loosening it to admit MinIO's shape would also admit malformed RustFS envelopes that backlog#1567 requires to keep failing closed.

Routing between the two decoders cannot key on metadata: RustFS's own writer fills MinIO's slots while storing a RustFS envelope in them, so neither the slot nor the header name distinguishes writers. It keys on the data key's own shape instead, recognizing the two strict RustFS JSON shapes positively and leaving only the remainder to MinIO — so neither decoder is ever handed the other's format. Three round-trip tests caught an earlier slot-based attempt doing exactly that.

Fail-closed is preserved throughout: a scheme that cannot be established still returns None, and the read plan independently classifies the object as encrypted from its markers and refuses to serve it without material, so no path degrades into returning ciphertext as plaintext.

The interop harness also gets a provider reset. The DEK provider is cached process-wide, so a case that ran earlier kept serving its master key to every later case — which silently made the wrong-key negative test unable to fail. It fails correctly now, and the whole suite is meaningful for the first time.

Refs rustfs/backlog#1638.
2026-08-18 09:32:29 +08:00
159 changed files with 3308 additions and 4184 deletions
-5
View File
@@ -70,11 +70,6 @@ fips-wording-check: ## Check docs and crates/kms do not over-claim crypto capabi
@echo "📣 Checking cryptographic capability wording guard..."
./scripts/check_fips_wording.sh
.PHONY: embedded-secrets-check
embedded-secrets-check: ## Check no private key material or credential literal is committed
@echo "🔑 Checking embedded secret material guard..."
./scripts/check_embedded_secrets.sh
.PHONY: log-analyzer-rules-check
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
@echo "🩺 Checking log-analyzer rule anchors..."
+3 -3
View File
@@ -19,13 +19,13 @@ planning-docs-check: ## Check that no planning-type documents are committed
./scripts/check_no_planning_docs.sh
.PHONY: pre-commit
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
@echo "✅ All pre-commit checks passed!"
.PHONY: pre-pr
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check doc-paths-check planning-docs-check log-analyzer-rules-check clippy-check test ## Run full pre-PR checks with clippy and tests
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check doc-paths-check planning-docs-check log-analyzer-rules-check clippy-check test ## Run full pre-PR checks with clippy and tests
@echo "✅ All pre-PR checks passed!"
.PHONY: dev-check
dev-check: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check doc-paths-check planning-docs-check quick-check ## Run fast local development checks
dev-check: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check doc-paths-check planning-docs-check quick-check ## Run fast local development checks
@echo "✅ Fast development checks passed!"
-1
View File
@@ -34,7 +34,6 @@ script-tests: ## Run shell script tests
./scripts/test_exact_1mib_handoff_abba.sh
./scripts/test_pinned_paired_abba_bench.sh
./scripts/test_manual_transition_runbooks.sh
./scripts/check_embedded_secrets.sh --self-test
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
python3 ./scripts/check_object_data_cache_follower_samples.py --self-test
./scripts/validate_object_data_cache_cold_stampede.sh --self-test
-3
View File
@@ -120,9 +120,6 @@ jobs:
- name: Check cryptographic capability wording
run: ./scripts/check_fips_wording.sh
- name: Check no embedded secret material
run: ./scripts/check_embedded_secrets.sh
- name: Check no planning docs committed
run: ./scripts/check_no_planning_docs.sh
-3
View File
@@ -155,9 +155,6 @@ jobs:
- name: Check cryptographic capability wording
run: ./scripts/check_fips_wording.sh
- name: Check no embedded secret material
run: ./scripts/check_embedded_secrets.sh
- name: Check no planning docs committed
run: ./scripts/check_no_planning_docs.sh
+3 -9
View File
@@ -522,16 +522,10 @@ jobs:
for f in "$DEB_FILE" "$RPM_FILE"; do
if [[ -n "$f" && -f "$f" ]]; then
base="$(basename "$f")"
# GitHub stores release asset names with '~' normalized to '.'
# (e.g. rustfs_1.0.0~rc.2_amd64.deb is stored as
# rustfs_1.0.0.rc.2_amd64.deb), so checksum entries must
# reference the name as stored on the release.
github_base="${base//\~/.}"
# Remove any stale entry (both naming variants), then append
# Remove any stale entry, then append the fresh digest
grep -Fv -- "$base" "$checksum_file" > "${checksum_file}.tmp" || true
grep -Fv -- "$github_base" "${checksum_file}.tmp" > "${checksum_file}.tmp2" || true
mv "${checksum_file}.tmp2" "$checksum_file"
(cd "$(dirname "$f")" && "$checksum_cmd" -- "$github_base") >> "$checksum_file"
mv "${checksum_file}.tmp" "$checksum_file"
(cd "$(dirname "$f")" && "$checksum_cmd" -- "$base") >> "$checksum_file"
fi
done
+2 -9
View File
@@ -236,19 +236,12 @@ async fn audit_pipeline_reports_empty_runtime_snapshots() {
}
#[tokio::test]
async fn stopping_audit_replay_workers_is_a_no_op_when_there_are_none() {
async fn audit_runtime_facade_stops_empty_replay_workers() {
let registry = Arc::new(Mutex::new(AuditRegistry::new()));
let replay_workers = Arc::new(RwLock::new(rustfs_targets::ReplayWorkerManager::new()));
let facade = AuditRuntimeFacade::new(registry, Arc::clone(&replay_workers));
let facade = AuditRuntimeFacade::new(registry, replay_workers);
facade.stop_replay_workers().await;
// The stop path takes the manager's workers and hands them to the adapter,
// so an empty facade must leave it empty rather than wedge it, and a second
// call — which shutdown paths make — must stay harmless (rustfs/backlog#1836).
assert!(replay_workers.read().await.is_empty());
facade.stop_replay_workers().await;
assert!(replay_workers.read().await.is_empty());
}
#[tokio::test]
+76
View File
@@ -13,6 +13,82 @@
// limitations under the License.
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[allow(dead_code)]
#[derive(Debug, Default)]
struct TimedAction {
count: u64,
acc_time: u64,
min_time: Option<u64>,
max_time: Option<u64>,
bytes: u64,
}
#[allow(dead_code)]
impl TimedAction {
// Avg returns the average time spent on the action.
pub fn avg(&self) -> Option<Duration> {
if self.count == 0 {
return None;
}
Some(Duration::from_nanos(self.acc_time / self.count))
}
// AvgBytes returns the average bytes processed.
pub fn avg_bytes(&self) -> u64 {
if self.count == 0 {
return 0;
}
self.bytes / self.count
}
// Merge other into t.
pub fn merge(&mut self, other: TimedAction) {
self.count += other.count;
self.acc_time += other.acc_time;
self.bytes += other.bytes;
if self.count == 0 {
self.min_time = other.min_time;
}
if let Some(other_min) = other.min_time {
self.min_time = self.min_time.map_or(Some(other_min), |min| Some(min.min(other_min)));
}
self.max_time = self
.max_time
.map_or(other.max_time, |max| Some(max.max(other.max_time.unwrap_or(0))));
}
}
#[allow(dead_code)]
#[derive(Debug)]
enum SizeCategory {
SizeLessThan1KiB = 0,
SizeLessThan1MiB,
SizeLessThan10MiB,
SizeLessThan100MiB,
SizeLessThan1GiB,
SizeGreaterThan1GiB,
// Add new entries here
SizeLastElemMarker,
}
impl std::fmt::Display for SizeCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match *self {
SizeCategory::SizeLessThan1KiB => "SizeLessThan1KiB",
SizeCategory::SizeLessThan1MiB => "SizeLessThan1MiB",
SizeCategory::SizeLessThan10MiB => "SizeLessThan10MiB",
SizeCategory::SizeLessThan100MiB => "SizeLessThan100MiB",
SizeCategory::SizeLessThan1GiB => "SizeLessThan1GiB",
SizeCategory::SizeGreaterThan1GiB => "SizeGreaterThan1GiB",
SizeCategory::SizeLastElemMarker => "SizeLastElemMarker",
};
write!(f, "{s}")
}
}
#[derive(Clone, Debug, Default, Copy)]
pub struct AccElem {
pub total: u64,
+9
View File
@@ -228,6 +228,15 @@ pub const DEFAULT_SCANNER_MAX_CONCURRENT_DISK_SCANS: usize = 4;
/// Default object interval for cooperative scanner yields.
pub const DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS: u64 = 128;
/// Compatibility flag kept for Patch 3 rollback windows.
///
/// Inline scanner heal execution has been removed in favor of heal-candidate enqueue.
/// When this flag is enabled, RustFS logs a warning and continues to use enqueue-based heal.
pub const ENV_SCANNER_INLINE_HEAL_ENABLE: &str = "RUSTFS_SCANNER_INLINE_HEAL_ENABLE";
/// Default inline scanner heal compatibility mode.
pub const DEFAULT_SCANNER_INLINE_HEAL_ENABLE: bool = false;
/// Scanner speed preset controlling throttling behavior.
///
/// Each preset defines three parameters:
+4
View File
@@ -92,11 +92,15 @@ pub const NOTIFY_SUB_SYSTEMS: &[&str] = &[
pub const NOTIFY_KAFKA_SUB_SYS: &str = "notify_kafka";
pub const NOTIFY_MQTT_SUB_SYS: &str = "notify_mqtt";
pub const NOTIFY_MYSQL_SUB_SYS: &str = "notify_mysql";
#[allow(dead_code)]
pub const NOTIFY_NATS_SUB_SYS: &str = "notify_nats";
#[allow(dead_code)]
pub const NOTIFY_NSQ_SUB_SYS: &str = "notify_nsq";
#[allow(dead_code)]
pub const NOTIFY_ES_SUB_SYS: &str = "notify_elasticsearch";
pub const NOTIFY_AMQP_SUB_SYS: &str = "notify_amqp";
pub const NOTIFY_POSTGRES_SUB_SYS: &str = "notify_postgres";
#[allow(dead_code)]
pub const NOTIFY_REDIS_SUB_SYS: &str = "notify_redis";
pub const NOTIFY_REDIS_DEFAULT_CHANNEL: &str = "rustfs_notify_channel";
pub const NOTIFY_PULSAR_SUB_SYS: &str = "notify_pulsar";
+8
View File
@@ -203,6 +203,14 @@ mod tests {
assert!(result.is_err());
}
#[test]
fn test_source_does_not_embed_private_key() {
let source = include_str!("license_token.rs");
let forbidden = ["BEGIN", "PRIVATE KEY"].join(" ");
assert!(!source.contains(&forbidden));
}
#[test]
fn test_parse_signed_license_token_rejects_invalid_token() {
let mut rng = rand::rng();
+55 -88
View File
@@ -317,15 +317,15 @@ pub struct SizeSummary {
/// Number of delete markers
pub delete_markers: usize,
/// Replicated size
pub replicated_size: i64,
pub replicated_size: usize,
/// Replicated count
pub replicated_count: usize,
/// Pending size
pub pending_size: i64,
pub pending_size: usize,
/// Failed size
pub failed_size: i64,
pub failed_size: usize,
/// Replica size
pub replica_size: i64,
pub replica_size: usize,
/// Replica count
pub replica_count: usize,
/// Pending count
@@ -334,21 +334,19 @@ pub struct SizeSummary {
pub failed_count: usize,
/// Replication target stats
pub repl_target_stats: HashMap<String, ReplTargetSizeSummary>,
/// Per-tier accounting, keyed by storage class or remote tier name
pub tier_stats: HashMap<String, TierStats>,
}
/// Replication target size summary
#[derive(Debug, Default, Clone)]
pub struct ReplTargetSizeSummary {
/// Replicated size
pub replicated_size: i64,
pub replicated_size: usize,
/// Replicated count
pub replicated_count: usize,
/// Pending size
pub pending_size: i64,
pub pending_size: usize,
/// Failed size
pub failed_size: i64,
pub failed_size: usize,
/// Pending count
pub pending_count: usize,
/// Failed count
@@ -712,6 +710,28 @@ impl DataUsageEntry {
self.children.insert(hash.key());
}
pub fn add_sizes(&mut self, summary: &SizeSummary) {
self.size += summary.total_size;
self.versions += summary.versions;
self.delete_markers += summary.delete_markers;
self.obj_sizes.add(summary.total_size as u64);
self.obj_versions.add(summary.versions as u64);
let replication_stats = self.replication_stats.get_or_insert_with(ReplicationAllStats::default);
replication_stats.replica_size += summary.replica_size as u64;
replication_stats.replica_count += summary.replica_count as u64;
for (arn, st) in &summary.repl_target_stats {
let tgt_stat = replication_stats.targets.entry(arn.to_string()).or_default();
tgt_stat.pending_size += st.pending_size as u64;
tgt_stat.failed_size += st.failed_size as u64;
tgt_stat.replicated_size += st.replicated_size as u64;
tgt_stat.replicated_count += st.replicated_count as u64;
tgt_stat.failed_count += st.failed_count as u64;
tgt_stat.pending_count += st.pending_count as u64;
}
}
pub fn merge(&mut self, other: &DataUsageEntry) {
self.objects += other.objects;
self.versions += other.versions;
@@ -1702,6 +1722,14 @@ impl BucketUsageInfo {
}
/// Add size summary to this bucket usage
pub fn add_size_summary(&mut self, summary: &SizeSummary) {
self.size += summary.total_size as u64;
self.versions_count += summary.versions as u64;
self.delete_markers_count += summary.delete_markers as u64;
self.replica_size += summary.replica_size as u64;
self.replica_count += summary.replica_count as u64;
}
/// Merge another BucketUsageInfo into this one
pub fn merge(&mut self, other: &BucketUsageInfo) {
self.size += other.size;
@@ -1747,32 +1775,29 @@ impl SizeSummary {
Self::default()
}
/// Add another SizeSummary to this one.
///
/// Saturating throughout: a scan that overflows a counter should report the
/// ceiling rather than panic in a debug build or wrap in a release one.
/// Add another SizeSummary to this one
pub fn add(&mut self, other: &SizeSummary) {
self.total_size = self.total_size.saturating_add(other.total_size);
self.versions = self.versions.saturating_add(other.versions);
self.delete_markers = self.delete_markers.saturating_add(other.delete_markers);
self.replicated_size = self.replicated_size.saturating_add(other.replicated_size);
self.replicated_count = self.replicated_count.saturating_add(other.replicated_count);
self.pending_size = self.pending_size.saturating_add(other.pending_size);
self.failed_size = self.failed_size.saturating_add(other.failed_size);
self.replica_size = self.replica_size.saturating_add(other.replica_size);
self.replica_count = self.replica_count.saturating_add(other.replica_count);
self.pending_count = self.pending_count.saturating_add(other.pending_count);
self.failed_count = self.failed_count.saturating_add(other.failed_count);
self.total_size += other.total_size;
self.versions += other.versions;
self.delete_markers += other.delete_markers;
self.replicated_size += other.replicated_size;
self.replicated_count += other.replicated_count;
self.pending_size += other.pending_size;
self.failed_size += other.failed_size;
self.replica_size += other.replica_size;
self.replica_count += other.replica_count;
self.pending_count += other.pending_count;
self.failed_count += other.failed_count;
// Merge replication target stats
for (target, stats) in &other.repl_target_stats {
let entry = self.repl_target_stats.entry(target.clone()).or_default();
entry.replicated_size = entry.replicated_size.saturating_add(stats.replicated_size);
entry.replicated_count = entry.replicated_count.saturating_add(stats.replicated_count);
entry.pending_size = entry.pending_size.saturating_add(stats.pending_size);
entry.failed_size = entry.failed_size.saturating_add(stats.failed_size);
entry.pending_count = entry.pending_count.saturating_add(stats.pending_count);
entry.failed_count = entry.failed_count.saturating_add(stats.failed_count);
entry.replicated_size += stats.replicated_size;
entry.replicated_count += stats.replicated_count;
entry.pending_size += stats.pending_size;
entry.failed_size += stats.failed_size;
entry.pending_count += stats.pending_count;
entry.failed_count += stats.failed_count;
}
}
}
@@ -2318,64 +2343,6 @@ mod tests {
assert_eq!(usage1.versions_count, 15);
}
#[test]
fn size_summary_add_saturates_instead_of_overflowing() {
// The scanner folds one summary per object into a per-prefix total, so a
// counter at its ceiling must stay there rather than panic in a debug
// build or wrap in a release one (backlog#1828).
let mut summary = SizeSummary {
total_size: usize::MAX,
versions: usize::MAX,
replicated_size: i64::MAX,
pending_size: i64::MAX,
failed_size: i64::MAX,
replica_size: i64::MAX,
..Default::default()
};
summary.repl_target_stats.insert(
"arn".to_string(),
ReplTargetSizeSummary {
replicated_size: i64::MAX,
pending_size: i64::MAX,
failed_size: i64::MAX,
..Default::default()
},
);
let mut increment = SizeSummary {
total_size: 1,
versions: 1,
replicated_size: 1,
pending_size: 1,
failed_size: 1,
replica_size: 1,
..Default::default()
};
increment.repl_target_stats.insert(
"arn".to_string(),
ReplTargetSizeSummary {
replicated_size: 1,
pending_size: 1,
failed_size: 1,
..Default::default()
},
);
summary.add(&increment);
assert_eq!(summary.total_size, usize::MAX);
assert_eq!(summary.versions, usize::MAX);
assert_eq!(summary.replicated_size, i64::MAX);
assert_eq!(summary.pending_size, i64::MAX);
assert_eq!(summary.failed_size, i64::MAX);
assert_eq!(summary.replica_size, i64::MAX);
let target = summary.repl_target_stats.get("arn").expect("target survives the merge");
assert_eq!(target.replicated_size, i64::MAX);
assert_eq!(target.pending_size, i64::MAX);
assert_eq!(target.failed_size, i64::MAX);
}
#[test]
fn test_size_summary_add() {
let mut summary1 = SizeSummary::new();
+5
View File
@@ -38,6 +38,7 @@ mod tests {
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4;
use s3s::Body;
use serial_test::serial;
use std::error::Error;
use std::io::Read;
use std::process::{Command, Stdio};
@@ -161,6 +162,7 @@ mod tests {
/// A fully authenticated but non-admin credential must be rejected with
/// `403 AccessDenied` on an admin API, while the root credential succeeds.
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn non_admin_credential_denied_on_admin_api() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -196,6 +198,7 @@ mod tests {
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn non_admin_credential_denied_on_manual_transition_run() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -323,6 +326,7 @@ mod tests {
/// credential is accepted and the old one is rejected, on both the S3 data
/// plane and the admin plane.
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn root_credential_rotation_takes_effect() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -385,6 +389,7 @@ mod tests {
/// runtime. We capture the child's stdout/stderr directly (the shared
/// harness inherits stdio) and poll for the warning until it appears.
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn default_credentials_emit_startup_warning() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -33,6 +33,7 @@ use aws_sdk_s3::config::{Credentials, Region};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::{Client, Config};
use reqwest::StatusCode;
use serial_test::serial;
use std::error::Error;
use tokio::time::{Duration, sleep};
@@ -177,6 +178,7 @@ async fn assert_admin_status(
}
#[tokio::test]
#[serial]
async fn test_update_service_account_enforces_owner_and_parent_scope() -> TestResult {
init_logging();
@@ -346,6 +348,7 @@ async fn test_update_service_account_enforces_owner_and_parent_scope() -> TestRe
/// Full user -> policy -> service-account lifecycle, proving each management
/// call takes effect on the data plane, not just that the endpoint answers 200.
#[tokio::test]
#[serial]
async fn test_admin_user_policy_service_account_crud_lifecycle() -> TestResult {
init_logging();
@@ -570,6 +573,7 @@ async fn test_admin_user_policy_service_account_crud_lifecycle() -> TestResult {
/// non-admin credential with 403 AccessDenied (sec-4 assertion pattern; the
/// gate implementation itself is owned by sec-4 / admin_auth_test).
#[tokio::test]
#[serial]
async fn test_admin_iam_endpoints_deny_non_admin_credential() -> TestResult {
init_logging();
@@ -21,6 +21,7 @@ use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4;
use s3s::Body;
use serde::Deserialize;
use serial_test::serial;
use std::error::Error;
use std::process::Command;
use tokio::time::{Duration, sleep, timeout};
@@ -99,6 +100,7 @@ fn offline_server_count(info: &InfoMessage) -> usize {
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn test_single_admin_timeout_does_not_immediately_mark_peer_offline() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -18,6 +18,7 @@
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use aws_sdk_s3::types::PublicAccessBlockConfiguration;
use serial_test::serial;
use tracing::info;
async fn setup_public_bucket(
@@ -72,6 +73,7 @@ async fn anonymous_get_object(
/// Issue #2036: Anonymous GetObject should succeed when bucket policy allows it
/// and no PublicAccessBlock configuration exists (ConfigNotFound).
#[tokio::test]
#[serial]
async fn test_anonymous_access_allowed_when_public_access_block_missing() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
{
init_logging();
@@ -98,6 +100,7 @@ async fn test_anonymous_access_allowed_when_public_access_block_missing() -> Res
/// Anonymous GetObject should be denied when RestrictPublicBuckets is true.
#[tokio::test]
#[serial]
async fn test_anonymous_access_denied_when_restrict_public_buckets_enabled()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
@@ -134,6 +137,7 @@ async fn test_anonymous_access_denied_when_restrict_public_buckets_enabled()
/// Anonymous GetObject should succeed when PublicAccessBlock exists but
/// RestrictPublicBuckets is explicitly false.
#[tokio::test]
#[serial]
async fn test_anonymous_access_allowed_when_restrict_public_buckets_disabled()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
@@ -172,6 +176,7 @@ async fn test_anonymous_access_allowed_when_restrict_public_buckets_disabled()
/// reaches authorization through a fallback branch, and that branch has to apply the
/// same public-access gate as a direct grant.
#[tokio::test]
#[serial]
async fn ghsa_x298_anonymous_list_object_versions_denied_when_restrict_public_buckets_enabled()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
@@ -18,11 +18,13 @@
//! completely inert with default configuration.
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use serial_test::serial;
use tracing::info;
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
#[tokio::test]
#[serial]
async fn api_rate_limit_enforces_429_with_retry_after_when_enabled() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -85,6 +87,7 @@ async fn api_rate_limit_enforces_429_with_retry_after_when_enabled() -> TestResu
}
#[tokio::test]
#[serial]
async fn api_rate_limit_bucket_dimension_throttles_per_bucket() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -130,6 +133,7 @@ async fn api_rate_limit_bucket_dimension_throttles_per_bucket() -> TestResult {
}
#[tokio::test]
#[serial]
async fn api_rate_limit_stays_inert_by_default() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -24,6 +24,7 @@ mod tests {
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::{pre_sign_v4, sign_v4};
use s3s::Body;
use serial_test::serial;
use sha2::{Digest, Sha256};
use std::error::Error;
use std::io::{Cursor, Write};
@@ -338,6 +339,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_archive_put_allows_content_encoding_by_default() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -365,6 +367,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_archive_put_rejects_content_encoding_when_strict_mode_enabled() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -388,6 +391,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_archive_put_with_aws_chunked_does_not_persist_content_encoding_by_default()
-> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -423,6 +427,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_archive_put_with_aws_chunked_and_effective_encoding_roundtrips_by_default()
-> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -458,6 +463,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_archive_put_with_aws_chunked_allowed_when_strict_mode_enabled() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -492,6 +498,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_archive_put_with_aws_chunked_and_effective_encoding_rejects_when_strict_mode_enabled()
-> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -522,6 +529,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_archive_download_roundtrip_with_http_compression_enabled() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -583,6 +591,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_archive_multipart_roundtrip_preserves_bytes() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -678,6 +687,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_multipart_get_ignores_empty_conditional_etag_headers() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -713,6 +723,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_archive_multipart_with_aws_chunked_and_effective_encoding_roundtrips_by_default()
-> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -742,6 +753,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_archive_multipart_with_aws_chunked_allowed_when_strict_mode_enabled() -> Result<(), Box<dyn Error + Send + Sync>>
{
init_logging();
@@ -771,6 +783,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_archive_multipart_with_aws_chunked_and_effective_encoding_rejects_when_strict_mode_enabled()
-> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -803,6 +816,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_presigned_get_and_reverse_proxy_preserve_multipart_bytes() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -24,9 +24,11 @@ mod tests {
};
use http::Method;
use http::header::CONTENT_TYPE;
use serial_test::serial;
use tracing::info;
#[tokio::test]
#[serial]
async fn test_dummy_bucket_compatibility_endpoints() {
init_logging();
info!("Starting test: dummy-compat bucket APIs should match S3-compatible behavior");
@@ -234,6 +236,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_dummy_bucket_compatibility_endpoints_no_such_bucket() {
init_logging();
info!("Starting test: dummy-compat bucket APIs should return NoSuchBucket for missing bucket");
@@ -389,6 +392,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_dummy_bucket_endpoints_http_contracts() {
init_logging();
info!("Starting test: dummy-compat bucket API HTTP contracts");
@@ -18,6 +18,7 @@
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::config::{Credentials, Region};
use aws_sdk_s3::{Client, Config};
use serial_test::serial;
use tracing::info;
async fn create_user(
@@ -50,6 +51,7 @@ fn create_user_client(env: &RustFSTestEnvironment, access_key: &str, secret_key:
}
#[tokio::test]
#[serial]
async fn test_bucket_policy_authenticated_user() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if !crate::common::awscurl_available() {
@@ -35,6 +35,7 @@ mod tests {
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use rustfs_data_usage::DataUsageInfo;
use serial_test::serial;
use std::error::Error;
use tokio::time::{Duration, sleep};
use tracing::info;
@@ -58,6 +59,7 @@ mod tests {
/// 3. Query admin data usage API
/// 4. Verify object count > 0
#[tokio::test]
#[serial]
async fn test_bucket_object_count_updates_after_put() -> TestResult {
init_logging();
info!("RT-09: bucket object count updates after PUT");
@@ -124,6 +126,7 @@ mod tests {
/// Regression pattern: stats remain unchanged after objects are deleted
/// (rustfs#5615).
#[tokio::test]
#[serial]
async fn test_bucket_object_count_updates_after_delete() -> TestResult {
init_logging();
info!("RT-09b: bucket object count updates after DELETE");
@@ -217,6 +220,7 @@ mod tests {
/// Regression pattern: DataUsageInfo undercounts versioned bucket versions
/// and delete markers (rustfs#3898).
#[tokio::test]
#[serial]
async fn test_versioned_bucket_stats_count_all_versions() -> TestResult {
init_logging();
info!("RT-09c: versioned bucket stats count all versions");
@@ -26,6 +26,7 @@ mod tests {
use base64::Engine;
use md5::{Digest as Md5Digest, Md5};
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
use serial_test::serial;
use sha2::Sha256;
use tracing::info;
@@ -89,6 +90,7 @@ mod tests {
/// PutObject with Content-MD5: upload succeeds and GetObject returns same content.
#[tokio::test]
#[serial]
async fn test_put_object_with_content_md5() {
init_logging();
info!("TEST: PutObject with Content-MD5");
@@ -124,6 +126,7 @@ mod tests {
/// PutObject with x-amz-checksum-sha256: upload succeeds and GetObject returns same content.
#[tokio::test]
#[serial]
async fn test_put_object_with_checksum_sha256() {
init_logging();
info!("TEST: PutObject with x-amz-checksum-sha256");
@@ -161,6 +164,7 @@ mod tests {
/// PutObject with a SHA256 checksum that does NOT match the body must be
/// rejected (BadDigest / checksum mismatch), NOT accepted with HTTP 200.
#[tokio::test]
#[serial]
async fn test_put_object_rejects_mismatched_sha256() {
init_logging();
info!("TEST: PutObject rejects mismatched x-amz-checksum-sha256 (issue #4341)");
@@ -208,6 +212,7 @@ mod tests {
/// After PutObject with a correct SHA256 checksum, HeadObject with
/// ChecksumMode=ENABLED must return that stored base64 SHA256 digest.
#[tokio::test]
#[serial]
async fn test_head_object_returns_stored_sha256() {
init_logging();
info!("TEST: HeadObject returns stored SHA256 with ChecksumMode=ENABLED (issue #4341)");
@@ -253,6 +258,7 @@ mod tests {
/// Multipart upload with checksum: CreateMultipartUpload, UploadPart(s) with checksum_sha256, CompleteMultipartUpload; then GetObject verifies content.
/// Uses part size >= 5MB (server minimum) for two parts.
#[tokio::test]
#[serial]
async fn test_multipart_upload_with_checksum() {
init_logging();
info!("TEST: MultipartUpload with checksum (checksum_sha256 on parts)");
@@ -350,6 +356,7 @@ mod tests {
/// Regression test for issue #2282:
/// CRC64NVME full-object checksum should match between direct PutObject and multipart upload.
#[tokio::test]
#[serial]
async fn test_crc64nvme_matches_between_put_object_and_multipart_upload() {
init_logging();
info!("TEST: CRC64NVME matches between direct PutObject and multipart upload");
@@ -485,6 +492,7 @@ mod tests {
/// value is rejected with BadDigest and nothing is stored. Full HEAD/GET header
/// echo round-trip is additionally exercised by the boto3+awscrt e2e.
#[tokio::test]
#[serial]
async fn test_additional_checksums_verify_on_write() {
init_logging();
info!("TEST: additional checksums (XXHash3/64/128, SHA-512, MD5) verify-on-write");
@@ -16,6 +16,7 @@ use crate::common::RustFSTestClusterEnvironment;
use aws_sdk_s3::Client;
use aws_sdk_s3::error::SdkError;
use bytes::Bytes;
use serial_test::serial;
use std::sync::Arc;
use tokio::sync::Barrier;
use tracing::{info, warn};
@@ -134,6 +135,7 @@ async fn run_race_iteration(
}
#[tokio::test]
#[serial]
async fn test_conditional_put_race_cluster() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
crate::common::init_logging();
info!("Starting conditional PUT race test with auto cluster");
@@ -190,6 +192,7 @@ async fn test_conditional_put_race_cluster() -> Result<(), Box<dyn std::error::E
}
#[tokio::test]
#[serial]
async fn test_conditional_put_basic_cluster() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
crate::common::init_logging();
info!("Starting basic conditional PUT test with auto cluster");
@@ -31,6 +31,7 @@
//! (toxiproxy / socket proxy) and 5GiB large-object budgets.
use crate::common::{ClusterTopology, RustFSTestClusterEnvironment};
use serial_test::serial;
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
@@ -57,6 +58,7 @@ async fn put_get_roundtrip(cluster: &RustFSTestClusterEnvironment, key: &str, pa
/// 4 nodes x 2 drives, single pool: the multi-drive layout boots and round-trips.
#[tokio::test]
#[serial]
async fn cluster_multidrive_single_pool_smoke() -> TestResult {
crate::common::init_logging();
@@ -79,6 +81,7 @@ async fn cluster_multidrive_single_pool_smoke() -> TestResult {
/// Two single-node pools, 2 drives each: the multi-pool layout boots and
/// round-trips. Every pool is a distinct erasure pool (`pool_idx` 0 and 1).
#[tokio::test]
#[serial]
async fn cluster_two_pool_smoke() -> TestResult {
crate::common::init_logging();
+7
View File
@@ -3,6 +3,7 @@
use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
use serial_test::serial;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
@@ -101,6 +102,7 @@ async fn start_rustfs_with_compression(env: &mut RustFSTestEnvironment) -> Resul
}
#[tokio::test]
#[serial]
async fn test_compression_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting compression roundtrip test");
@@ -228,6 +230,7 @@ async fn fetch_range(
/// (rustfs/rustfs#5957: multipart uploads previously bypassed disk compression
/// entirely).
#[tokio::test]
#[serial]
async fn test_compression_multipart_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting multipart compression roundtrip test");
@@ -346,6 +349,7 @@ const MPU_HIGH_RATIO_BUCKET: &str = "compression-mpu-high-ratio-bucket";
/// reproduced the mid-payload Pending truncation (rustfs/rustfs#5957). Every GET shape must return
/// the exact original bytes, and the stored size must show the data really was compressed.
#[tokio::test]
#[serial]
async fn test_compression_multipart_high_ratio_binary_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting multipart high-ratio binary compression roundtrip test");
@@ -442,6 +446,7 @@ const MPU_COPY_RANGE_LEN: usize = 5 * 1024 * 1024;
/// range must be decompressed on read and re-compressed into the destination part, so the final
/// object has to match "source prefix + uploaded tail" byte for byte.
#[tokio::test]
#[serial]
async fn test_compression_multipart_upload_part_copy_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting multipart upload-part-copy compression roundtrip test");
@@ -565,6 +570,7 @@ const MPU_THREE_PARTS_TAIL_SIZE: usize = 512 * 1024;
/// Three-part upload with uneven part sizes: each partNumber GET must map back to exactly one
/// compressed part stream, and a suffix range must resolve inside the trailing part.
#[tokio::test]
#[serial]
async fn test_compression_multipart_three_parts_part_number_gets() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting three-part multipart compression partNumber test");
@@ -683,6 +689,7 @@ async fn start_rustfs_with_compression_and_sse(
/// shape must still return the original plaintext bytes. Physical size must shrink because the
/// compression runs before encryption.
#[tokio::test]
#[serial]
async fn test_compression_multipart_sse_s3_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use aws_sdk_s3::types::ServerSideEncryption;
@@ -18,6 +18,7 @@
//! concurrency — a queued connection is served only after a held one closes.
use crate::common::{RustFSTestEnvironment, init_logging};
use serial_test::serial;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
@@ -56,6 +57,7 @@ async fn read_response_head(stream: &mut TcpStream, dur: Duration) -> Option<Str
}
#[tokio::test]
#[serial]
async fn connection_cap_releases_permits_on_close() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -87,6 +89,7 @@ async fn open_and_stall(addr: &str) -> std::io::Result<TcpStream> {
}
#[tokio::test]
#[serial]
async fn connection_cap_blocks_excess_connections_until_permits_free() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -33,6 +33,7 @@
//! serve the unauthenticated console endpoints at all.
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use serial_test::serial;
use std::error::Error;
use tokio::time::{Duration, sleep};
@@ -57,6 +58,7 @@ async fn wait_for_console_ready(console_base: &str) -> Result<reqwest::Response,
}
#[tokio::test]
#[serial]
async fn test_console_over_the_wire_smoke() -> TestResult {
init_logging();
@@ -22,10 +22,12 @@
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::primitives::ByteStream;
use serial_test::serial;
use tracing::info;
/// Verify Content-Encoding header roundtrips through PUT, GET, and HEAD operations
#[tokio::test]
#[serial]
async fn test_content_encoding_roundtrip() {
init_logging();
info!("Starting Content-Encoding roundtrip test");
@@ -103,6 +105,7 @@ mod tests {
/// Issue #1857: Content-Encoding "aws-chunked" is used by SigV4 streaming clients and must
/// not be stored or returned. Upload with aws-chunked and verify GET/HEAD do not return it.
#[tokio::test]
#[serial]
async fn test_content_encoding_aws_chunked_not_returned_issue_1857() {
init_logging();
info!("Issue #1857: aws-chunked must not be persisted or returned");
@@ -158,6 +161,7 @@ mod tests {
/// Issue #2475 / Route A: when aws-chunked is combined with an effective object encoding,
/// only the effective encoding should roundtrip through GET/HEAD.
#[tokio::test]
#[serial]
async fn test_content_encoding_aws_chunked_with_effective_encoding_roundtrip() {
init_logging();
info!("aws-chunked,gzip should persist only gzip");
@@ -30,6 +30,7 @@ mod tests {
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
use serial_test::serial;
use sha2::{Digest, Sha256};
use tracing::info;
@@ -113,6 +114,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_copy_supports_all_checksum_algorithms() {
init_logging();
@@ -194,6 +196,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_copy_without_algorithm_preserves_every_supported_source_checksum() {
init_logging();
@@ -259,6 +262,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_copy_without_algorithm_preserves_composite_checksum_type() {
init_logging();
@@ -348,6 +352,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_copy_rejects_unknown_algorithm_without_destination_mutation() {
init_logging();
@@ -448,6 +453,7 @@ mod tests {
/// bytes, return it in `CopyObjectResult.ChecksumSHA256`, and persist it so a checksum-mode
/// HEAD on the destination returns the identical value.
#[tokio::test]
#[serial]
async fn test_copy_with_checksum_algorithm_returns_and_persists_sha256() {
init_logging();
info!("Issue #4996: CopyObject with ChecksumAlgorithm=SHA256 must return and persist the checksum");
@@ -517,6 +523,7 @@ mod tests {
/// No algorithm requested: when the source object already carries a checksum, the copy must
/// preserve it on the destination (AWS default), visible via a checksum-mode HEAD.
#[tokio::test]
#[serial]
async fn test_copy_without_algorithm_preserves_source_checksum() {
init_logging();
info!("Issue #4996: CopyObject without ChecksumAlgorithm must preserve the source object's checksum");
@@ -596,6 +603,7 @@ mod tests {
/// checksum-not-inherited path, and exercises the CRC32 code path (a different branch of
/// ChecksumType::from_string than SHA256).
#[tokio::test]
#[serial]
async fn test_copy_requested_algorithm_overrides_source_checksum() {
init_logging();
info!("Issue #4996: a requested CopyObject checksum algorithm must override the source object's algorithm");
@@ -22,9 +22,11 @@ mod tests {
use aws_sdk_s3::types::{
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, MetadataDirective, StorageClass, VersioningConfiguration,
};
use serial_test::serial;
use tracing::info;
#[tokio::test]
#[serial]
async fn copy_object_standard_metadata_copy_replace_and_clear() {
init_logging();
info!("Issue #2789: self-copy metadata replacement must preserve object data");
@@ -298,6 +300,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn copy_object_replace_accepts_each_standard_field_independently() {
init_logging();
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
@@ -413,6 +416,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn copy_object_replace_handles_versioned_multipart_source() {
init_logging();
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
@@ -526,6 +530,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn invalid_replacement_metadata_does_not_mutate_destination() {
init_logging();
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
@@ -21,6 +21,7 @@ mod tests {
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, MetadataDirective, TaggingDirective, VersioningConfiguration};
use serial_test::serial;
use std::collections::BTreeMap;
async fn object_tags(client: &Client, bucket: &str, key: &str) -> BTreeMap<String, String> {
@@ -38,6 +39,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn copy_object_applies_copy_replace_and_empty_tagging_directives() {
init_logging();
let mut env = RustFSTestEnvironment::new()
@@ -303,6 +305,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn copy_object_tag_replacement_honors_request_tag_policy_denial() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
{
init_logging();
@@ -21,9 +21,11 @@ mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use serial_test::serial;
use tracing::info;
#[tokio::test]
#[serial]
async fn test_self_copy_of_historical_version_restores_data_and_metadata() {
init_logging();
info!("Issue #4238: self-copy of a historical version must be allowed and preserve metadata");
@@ -163,6 +165,7 @@ mod tests {
/// version copied via `x-amz-copy-source-version-id` (SDK `CopySourceVersionId`), kept distinct
/// from the newly created destination `x-amz-version-id`.
#[tokio::test]
#[serial]
async fn test_copy_of_non_latest_source_version_returns_copy_source_version_id() {
init_logging();
info!("Issue #4976: versioned CopyObject must return x-amz-copy-source-version-id for the exact source version");
@@ -47,6 +47,7 @@ mod tests {
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4;
use s3s::Body;
use serial_test::serial;
use std::error::Error;
/// Signed raw `PUT` copy request with an explicit copy-source conditional
@@ -83,6 +84,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_copy_source_if_unmodified_since_valid_and_invalid() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -25,11 +25,13 @@
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::types::{BucketLocationConstraint, CreateBucketConfiguration};
use serial_test::serial;
use std::error::Error;
/// `CreateBucket` with a `LocationConstraint` body must pass SigV4 validation
/// and create the bucket, mirroring `minio-go` `MakeBucket(bucket, "us-east-1")`.
#[tokio::test]
#[serial]
async fn test_create_bucket_with_us_east_1_location_constraint() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -60,6 +62,7 @@ mod tests {
/// A plain `CreateBucket` (no body) must also succeed; guards against a
/// regression where an empty body would be hashed incorrectly during SigV4.
#[tokio::test]
#[serial]
async fn test_create_bucket_without_location_constraint() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
+3
View File
@@ -15,6 +15,7 @@
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use rustfs_data_usage::DataUsageInfo;
use serial_test::serial;
use tokio::time::{Duration, sleep};
use crate::common::{FAST_DATA_USAGE_SCANNER_ENV, RustFSTestEnvironment, TEST_BUCKET, awscurl_get, init_logging};
@@ -59,6 +60,7 @@ where
/// Regression test for data usage accuracy (issue #1012).
/// Launches rustfs, writes 1000 objects, then asserts admin data usage reports the full count.
#[tokio::test(flavor = "multi_thread")]
#[serial]
#[ignore = "Starts a rustfs server and requires awscurl; enable when running full E2E"]
async fn data_usage_reports_all_objects() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
@@ -116,6 +118,7 @@ async fn data_usage_reports_all_objects() -> Result<(), Box<dyn std::error::Erro
/// Regression test for issue #3898.
/// Versioned buckets should expose versions and delete markers through admin data usage.
#[tokio::test(flavor = "multi_thread")]
#[serial]
#[ignore = "Starts a rustfs server and requires awscurl; enable when running full E2E"]
async fn data_usage_reports_versioned_objects_and_delete_markers() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
@@ -18,6 +18,7 @@ mod tests {
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use serial_test::serial;
async fn create_versioned_bucket(client: &Client, bucket: &str) {
client
@@ -71,6 +72,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_versioning_only_delete_marker_has_minio_compatible_visibility_for_migration_proof() {
init_logging();
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
@@ -111,6 +113,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_versioning_delete_marker_plus_history_remains_visible_for_migration_proof() {
init_logging();
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
@@ -24,6 +24,7 @@ mod tests {
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4;
use s3s::Body;
use serial_test::serial;
use std::error::Error;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
@@ -91,6 +92,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_delete_object_version_without_content_length_succeeds() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
info!("🧪 TEST: signed DELETE Object?versionId succeeds without Content-Length");
@@ -29,6 +29,7 @@ mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::Client;
use aws_sdk_s3::types::{BucketVersioningStatus, Delete, ObjectIdentifier, VersioningConfiguration};
use serial_test::serial;
use tracing::info;
fn create_s3_client(env: &RustFSTestEnvironment) -> Client {
@@ -41,6 +42,7 @@ mod tests {
/// a versioned bucket, calling `list_object_versions` **immediately** (with
/// no sleep) returns the newly-created DeleteMarker with `is_latest = true`.
#[tokio::test]
#[serial]
async fn test_delete_objects_delete_marker_immediately_visible() {
init_logging();
info!("🧪 TEST: DeleteMarker from delete_objects is immediately visible via list_object_versions");
@@ -188,6 +190,7 @@ mod tests {
/// a single `delete_objects` call all have their delete markers visible
/// immediately afterwards.
#[tokio::test]
#[serial]
async fn test_delete_objects_multiple_keys_delete_markers_immediately_visible() {
init_logging();
info!("🧪 TEST: Multiple delete markers from delete_objects are immediately visible");
@@ -33,6 +33,7 @@ mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, Delete, ObjectIdentifier, VersioningConfiguration};
use serial_test::serial;
use std::error::Error;
use tracing::info;
@@ -50,6 +51,7 @@ mod tests {
/// 4. Verify the object is NOT in LIST
/// 5. Verify HEAD returns 404
#[tokio::test]
#[serial]
async fn test_delete_removes_object_from_list() -> TestResult {
init_logging();
info!("RT-05: delete removes object from list");
@@ -130,6 +132,7 @@ mod tests {
/// Regression pattern: batch delete returns success but some objects
/// remain in LIST.
#[tokio::test]
#[serial]
async fn test_batch_delete_removes_all_objects() -> TestResult {
init_logging();
info!("RT-05c: batch delete removes all objects");
@@ -209,6 +212,7 @@ mod tests {
/// Covers the pattern where permanent deletion of a specific version
/// fails with FileAccessDenied (rustfs#4978).
#[tokio::test]
#[serial]
async fn test_versioned_permanent_delete() -> TestResult {
init_logging();
info!("RT-05d: versioned permanent delete");
@@ -279,6 +283,7 @@ mod tests {
/// Covers the pattern where creating a delete marker and then listing
/// versions shows incorrect state (rustfs#760).
#[tokio::test]
#[serial]
async fn test_versioned_delete_marker_and_list_consistency() -> TestResult {
init_logging();
info!("RT-05e: versioned delete marker and list consistency");
@@ -374,6 +379,7 @@ mod tests {
/// Regression pattern: after delete, the object data files remain on disk
/// (rustfs#5029: Node Does Not Remove Files After Reconnect).
#[tokio::test]
#[serial]
async fn test_delete_removes_object_head_returns_404() -> TestResult {
init_logging();
info!("RT-05f: delete → HEAD 404 consistency");
@@ -32,6 +32,7 @@
mod tests {
use crate::common::{RustFSTestClusterEnvironment, init_logging};
use aws_sdk_s3::primitives::ByteStream;
use serial_test::serial;
use std::error::Error;
use tokio::time::{Duration, sleep};
use tracing::info;
@@ -49,6 +50,7 @@ mod tests {
/// 3. Verify all nodes report healthy
/// 4. Verify S3 operations work through any node
#[tokio::test]
#[serial]
async fn test_four_node_cluster_startup_and_health() -> TestResult {
init_logging();
info!("RT-10: 4-node cluster startup and health");
@@ -101,6 +103,7 @@ mod tests {
/// Regression pattern: after a node restart, it cannot rejoin the cluster
/// or enters a faulty state (rustfs#2601).
#[tokio::test]
#[serial]
async fn test_cluster_survives_node_restart() -> TestResult {
init_logging();
info!("RT-10b: cluster survives node restart");
@@ -165,6 +168,7 @@ mod tests {
/// Regression pattern: bucket metadata is not replicated to all nodes,
/// causing NoSuchBucket errors on some nodes (rustfs#3191).
#[tokio::test]
#[serial]
async fn test_bucket_visible_from_all_nodes() -> TestResult {
init_logging();
info!("RT-10c: bucket visible from all nodes");
@@ -23,6 +23,7 @@ use aws_sdk_s3::config::{Credentials, Region};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{Delete, ObjectIdentifier, Tag, Tagging};
use aws_sdk_s3::{Client, Config};
use serial_test::serial;
use tracing::info;
use uuid::Uuid;
@@ -173,6 +174,7 @@ async fn cleanup_bucket_and_object(admin: &Client, bucket: &str, key: &str) {
/// IAM identity policy: GetObject allowed only when `s3:ExistingObjectTag/security` == `public`.
#[tokio::test]
#[serial]
async fn test_e2e_iam_policy_existing_object_tag_get_object() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if !awscurl_available() {
@@ -231,6 +233,7 @@ async fn test_e2e_iam_policy_existing_object_tag_get_object() -> Result<(), Box<
/// Bucket policy: same `ExistingObjectTag` condition; user has no canned IAM policy attached.
#[tokio::test]
#[serial]
async fn test_e2e_bucket_policy_existing_object_tag_get_object() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if !awscurl_available() {
@@ -292,6 +295,7 @@ async fn test_e2e_bucket_policy_existing_object_tag_get_object() -> Result<(), B
/// STS `AssumeRole` with inline `Policy` (session policy): GetObject only when `ExistingObjectTag/security` is `public`.
#[tokio::test]
#[serial]
async fn test_e2e_sts_assume_role_session_policy_existing_object_tag() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if !awscurl_available() {
@@ -368,6 +372,7 @@ async fn test_e2e_sts_assume_role_session_policy_existing_object_tag() -> Result
/// STS inline session policy: DeleteObjects must evaluate `s3:DeleteObject` per requested object key.
#[tokio::test]
#[serial]
async fn test_e2e_sts_session_policy_delete_objects_object_prefix_only() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if !awscurl_available() {
@@ -66,6 +66,7 @@ mod tests {
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
use serial_test::serial;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::error::Error;
@@ -276,6 +277,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn codec_streaming_matches_legacy_duplex_body_and_headers() -> TestResult {
init_logging();
+4
View File
@@ -17,6 +17,7 @@
use crate::common::{RustFSTestEnvironment, admin_request, awscurl_delete, awscurl_get, awscurl_put, init_logging};
use aws_sdk_s3::config::{Credentials, Region};
use aws_sdk_s3::{Client, Config};
use serial_test::serial;
use tracing::info;
fn create_user_s3_client(env: &RustFSTestEnvironment, access_key: &str, secret_key: &str) -> Client {
@@ -83,6 +84,7 @@ async fn update_group_members_rejects_invalid_new_group_names() -> Result<(), Bo
/// Test that deleting a group with members fails, and deleting an empty group succeeds.
#[tokio::test(flavor = "multi_thread")]
#[serial]
#[ignore = "requires awscurl and spawns a real RustFS server"]
async fn test_delete_group_requires_empty_membership() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
@@ -142,6 +144,7 @@ async fn test_delete_group_requires_empty_membership() -> Result<(), Box<dyn std
/// Test that a user with only group membership (no explicit user policy) gets group policies
/// and can perform actions allowed by the group (regression test for #2028.1).
#[tokio::test(flavor = "multi_thread")]
#[serial]
#[ignore = "requires awscurl and spawns a real RustFS server"]
async fn test_user_with_only_group_gets_group_policies() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
@@ -209,6 +212,7 @@ async fn test_user_with_only_group_gets_group_policies() -> Result<(), Box<dyn s
/// Test that after deleting a user who was the only member of a group, the group can be deleted
/// (regression test for #2028.2: delete group uses backend membership, not stale cache).
#[tokio::test(flavor = "multi_thread")]
#[serial]
#[ignore = "requires awscurl and spawns a real RustFS server"]
async fn test_delete_group_after_deleting_user() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
@@ -16,6 +16,7 @@ use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use aws_sdk_s3::presigning::PresigningConfig;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
use serial_test::serial;
use std::time::Duration;
use tracing::info;
@@ -28,6 +29,7 @@ fn list_contains_key(output: &aws_sdk_s3::operation::list_objects_v2::ListObject
}
#[tokio::test]
#[serial]
async fn head_object_consistency_after_write_and_multipart_and_presigned_head()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
@@ -1,5 +1,6 @@
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::primitives::ByteStream;
use serial_test::serial;
use tracing::info;
const RANGE_HEAD_BUCKET: &str = "range-head-test-bucket";
@@ -7,6 +8,7 @@ const RANGE_HEAD_KEY: &str = "range-head-object.bin";
const ACCEPT_RANGES_BYTES: &str = "bytes";
#[tokio::test]
#[serial]
async fn head_object_advertises_accept_ranges() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting HeadObject Accept-Ranges regression test");
@@ -19,6 +19,7 @@ mod tests {
use crate::chaos::signed_admin_post;
use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging};
use aws_sdk_s3::primitives::ByteStream;
use serial_test::serial;
use std::collections::HashSet;
use std::error::Error;
use std::path::{Path, PathBuf};
@@ -62,6 +63,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_auto_heal_rebuilds_runtime_wiped_disk_without_restart() {
init_logging();
info!("Issue #1533: auto heal should rebuild a runtime-wiped disk in a 4-disk single-node erasure set without restart");
@@ -180,6 +182,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_admin_deep_heal_rebuilds_cleared_disk_in_single_node_erasure_set() {
init_logging();
info!("Discussion #2964: admin deep heal should rebuild a wiped disk in a 4-disk single-node erasure set");
@@ -329,6 +332,7 @@ mod tests {
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn test_cluster_root_heal_rebuilds_replaced_remote_disk() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
info!("Root recursive heal should rebuild data on a remote node after its disk is replaced and the node rejoins");
@@ -440,6 +444,7 @@ mod tests {
/// topology early-return or the merge hard-fail) turns the down-window
/// response into a 500 and fails this test.
#[tokio::test]
#[serial]
async fn test_background_heal_status_degrades_while_peer_down_and_recovers_after_rejoin()
-> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -101,6 +101,7 @@ use rustfs_config::{
};
use rustfs_protos::canonical_make_volume_request_body;
use rustfs_protos::proto_gen::node_service::{MakeVolumeRequest, MakeVolumeResponse, PingRequest, PingResponse};
use serial_test::serial;
use sha2::{Digest, Sha256};
use std::error::Error;
use tonic::{Code, Request, Response, Status};
@@ -396,6 +397,7 @@ fn assert_rejected(result: Result<MakeVolumeResponse, Status>, expected: Code, e
/// Grouped into one server start because each case is independent and spawning
/// a `rustfs` process per assertion would dominate the runtime.
#[tokio::test]
#[serial]
async fn internode_rpc_signature_default_posture_e2e() -> TestResult {
init_logging();
align_rpc_secret_with_server();
@@ -422,6 +424,7 @@ async fn internode_rpc_signature_default_posture_e2e() -> TestResult {
/// epoch is learned from a real response, then the same server is restarted in place to prove its
/// replacement epoch rejects the captured request even though the nonce cache is necessarily new.
#[tokio::test]
#[serial]
async fn replay_scope_rejects_replay_path_transplant_and_stale_epoch_e2e() -> TestResult {
init_logging();
align_rpc_secret_with_server();
@@ -494,6 +497,7 @@ async fn replay_scope_rejects_replay_path_transplant_and_stale_epoch_e2e() -> Te
/// A mutating v2 request cannot use that lane; once the epoch proof is returned, the first v3
/// mutation succeeds. This protects a server restart without reopening a general downgrade path.
#[tokio::test]
#[serial]
async fn replay_scope_strict_requires_v3_after_ping_bootstrap_e2e() -> TestResult {
init_logging();
align_rpc_secret_with_server();
@@ -700,6 +704,7 @@ async fn legacy_only_signature_is_accepted_in_default_posture(url: &str) {
///
/// The paired v2 positive control rules out "strict simply breaks everything".
#[tokio::test]
#[serial]
async fn signature_strict_rejects_legacy_only_downgrade() -> TestResult {
init_logging();
align_rpc_secret_with_server();
@@ -736,6 +741,7 @@ async fn signature_strict_rejects_legacy_only_downgrade() -> TestResult {
/// takes the still-open legacy lane), which is what pins the rejection to the
/// handler's digest gate; the cited message confirms which check spoke.
#[tokio::test]
#[serial]
async fn body_digest_strict_rejects_digestless_mutation() -> TestResult {
init_logging();
align_rpc_secret_with_server();
@@ -27,10 +27,12 @@ use aws_sdk_s3::types::{
ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, ServerSideEncryptionRule,
};
use rustfs_rio::{Checksum, ChecksumType};
use serial_test::serial;
use tracing::{debug, info, warn};
/// Test 1: When bucket is configured with default SSE-S3 encryption, put_object should automatically apply encryption
#[tokio::test]
#[serial]
async fn test_bucket_default_sse_s3_put_object() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Testing bucket default SSE-S3 encryption impact on put_object");
@@ -153,6 +155,7 @@ async fn test_bucket_default_sse_s3_put_object() -> Result<(), Box<dyn std::erro
/// Test 2: When bucket is configured with default SSE-KMS encryption, put_object should automatically apply encryption and use the specified KMS key
#[tokio::test]
#[serial]
async fn test_bucket_default_sse_kms_put_object() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Testing bucket default SSE-KMS encryption impact on put_object");
@@ -272,6 +275,7 @@ async fn test_bucket_default_sse_kms_put_object() -> Result<(), Box<dyn std::err
/// Test 3: When bucket is configured with default encryption, create_multipart_upload should inherit the configuration
#[tokio::test]
#[serial]
async fn test_bucket_default_sse_kms_multipart_crc32() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Testing bucket default encryption impact on create_multipart_upload");
@@ -469,6 +473,7 @@ async fn test_bucket_default_sse_kms_multipart_crc32() -> Result<(), Box<dyn std
/// Test 4: Explicitly specified encryption parameters in requests should override bucket default configuration
#[tokio::test]
#[serial]
async fn test_explicit_encryption_overrides_bucket_default() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Testing explicitly specified encryption parameters override bucket default configuration");
@@ -564,6 +569,7 @@ async fn test_explicit_encryption_overrides_bucket_default() -> Result<(), Box<d
/// Test 5: Setting SSE-KMS without a specific key ID should auto-populate the
/// default KMS key ID so that GetBucketEncryption returns it (issue #3039).
#[tokio::test]
#[serial]
async fn test_sse_kms_without_key_id_populates_default() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Testing SSE-KMS without explicit key ID populates default key");
@@ -20,6 +20,7 @@ use super::common::{
};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, ServerSideEncryption, VersioningConfiguration};
use serial_test::serial;
use std::error::Error;
use uuid::Uuid;
@@ -385,6 +386,7 @@ async fn assert_versioned_sse_kms_roundtrip_and_cleanup(
}
#[tokio::test]
#[serial]
async fn test_configured_local_kms_admin_and_versioned_cleanup() -> TestResult {
let mut env = LocalKMSTestEnvironment::new().await?;
env.base_env.start_rustfs_server(Vec::new()).await?;
@@ -432,6 +434,7 @@ async fn test_configured_local_kms_admin_and_versioned_cleanup() -> TestResult {
}
#[tokio::test]
#[serial]
#[ignore = "requires a Vault binary"]
async fn test_configured_vault_kms_admin_and_versioned_cleanup() -> TestResult {
let mut env = VaultTestEnvironment::new().await?;
@@ -32,9 +32,11 @@ use aws_sdk_s3::types::{
MetadataDirective, ServerSideEncryption, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration,
ServerSideEncryptionRule,
};
use serial_test::serial;
use tracing::info;
#[tokio::test]
#[serial]
async fn test_metadata_replace_self_copy_of_sse_object_stays_decryptable() {
init_logging();
info!("same-key CopyObject with REPLACE metadata must not re-key an SSE-S3 object");
@@ -134,6 +136,7 @@ async fn test_metadata_replace_self_copy_of_sse_object_stays_decryptable() {
}
#[tokio::test]
#[serial]
async fn test_metadata_replace_self_copy_dropping_sse_rewrites_plaintext() {
init_logging();
info!("same-key CopyObject that drops SSE must rewrite the data, not orphan the ciphertext");
@@ -230,6 +233,7 @@ async fn test_metadata_replace_self_copy_dropping_sse_rewrites_plaintext() {
}
#[tokio::test]
#[serial]
async fn test_metadata_replace_self_copy_under_bucket_default_sse_stays_decryptable() {
init_logging();
info!("bucket default encryption must also keep a same-key copy off the metadata-only path");
@@ -25,9 +25,11 @@ use super::common::{LocalKMSTestEnvironment, create_key_with_specific_id};
use crate::common::init_logging;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, ServerSideEncryption, VersioningConfiguration};
use serial_test::serial;
use tracing::info;
#[tokio::test]
#[serial]
async fn test_self_copy_of_historical_sse_s3_version_is_readable() {
init_logging();
info!("Issue #4238 (SSE): restoring an encrypted historical version must stay decryptable");
@@ -22,6 +22,7 @@ use aws_sdk_s3::types::{
CompletedMultipartUpload, CompletedPart, ServerSideEncryption, ServerSideEncryptionByDefault,
ServerSideEncryptionConfiguration, ServerSideEncryptionRule,
};
use serial_test::serial;
use std::collections::{HashMap, VecDeque};
use tracing::info;
@@ -81,6 +82,7 @@ pub(super) fn assert_storage_encrypted(storage_root: &std::path::Path, bucket: &
}
#[tokio::test]
#[serial]
async fn test_head_reports_managed_metadata_for_sse_s3() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Validating SSE-S3 managed encryption metadata exposure");
@@ -141,6 +143,7 @@ async fn test_head_reports_managed_metadata_for_sse_s3() -> Result<(), Box<dyn s
}
#[tokio::test]
#[serial]
async fn test_head_reports_managed_metadata_for_sse_kms_and_copy() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Validating SSE-KMS managed encryption metadata (including copy)");
@@ -244,6 +247,7 @@ async fn test_head_reports_managed_metadata_for_sse_kms_and_copy() -> Result<(),
}
#[tokio::test]
#[serial]
async fn test_multipart_upload_writes_encrypted_data() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Validating ciphertext persistence for multipart SSE-KMS uploads");
@@ -35,6 +35,7 @@ use aws_sdk_s3::config::{Config, Credentials, Region};
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::ServerSideEncryption;
use serial_test::serial;
use std::time::Duration;
use tracing::info;
@@ -208,6 +209,7 @@ fn disable_body(key_id: &str) -> String {
/// Data-path matrix: SSE-KMS writes and reads are authorized against the resolved key.
#[tokio::test]
#[serial]
async fn sse_kms_per_key_authorization_negative_matrix() -> TestResult {
init_logging();
@@ -353,6 +355,7 @@ async fn sse_kms_per_key_authorization_negative_matrix() -> TestResult {
/// Runs without the SSE enforcement switch: admin scoping is unconditional, and
/// leaving the switch off proves the two planes are independent.
#[tokio::test]
#[serial]
async fn kms_admin_per_key_authorization_negative_matrix() -> TestResult {
init_logging();
@@ -24,11 +24,13 @@ use super::common::{
test_sse_kms_encryption, test_sse_s3_encryption,
};
use crate::common::{TEST_BUCKET, init_logging};
use serial_test::serial;
use tokio::time::{Duration, sleep};
use tracing::info;
/// Comprehensive test: Full KMS workflow with all encryption types
#[tokio::test]
#[serial]
async fn test_comprehensive_kms_full_workflow() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("🏁 Start the KMS full-featured synthesis test");
@@ -97,6 +99,7 @@ async fn test_mixed_encryption_workload(
/// Comprehensive stress test: Large dataset with multiple encryption types
#[tokio::test]
#[serial]
async fn test_comprehensive_stress_test() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("💪 Start the KMS stress test");
@@ -131,6 +134,7 @@ async fn test_comprehensive_stress_test() -> Result<(), Box<dyn std::error::Erro
/// Test encryption key isolation and security
#[tokio::test]
#[serial]
async fn test_comprehensive_key_isolation() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("🔐 Begin the comprehensive test of encryption key isolation");
@@ -202,6 +206,7 @@ async fn test_comprehensive_key_isolation() -> Result<(), Box<dyn std::error::Er
/// Test concurrent encryption operations
#[tokio::test]
#[serial]
async fn test_comprehensive_concurrent_operations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("⚡ Started comprehensive testing of concurrent encryption operations");
@@ -247,6 +252,7 @@ async fn test_comprehensive_concurrent_operations() -> Result<(), Box<dyn std::e
/// Test encryption/decryption performance with different file sizes
#[tokio::test]
#[serial]
async fn test_comprehensive_performance_benchmark() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("📊 Start KMS performance benchmarking");
@@ -26,6 +26,7 @@ use crate::common::{TEST_BUCKET, init_logging};
use aws_sdk_s3::types::ServerSideEncryption;
use base64::Engine;
use md5::{Digest as Md5Digest, Md5};
use serial_test::serial;
use std::sync::Arc;
use tokio::sync::Semaphore;
use tracing::{info, warn};
@@ -38,6 +39,7 @@ fn md5_hex(input: impl AsRef<[u8]>) -> String {
/// Test encryption of zero-byte files (empty files)
#[tokio::test]
#[serial]
async fn test_kms_zero_byte_file_encryption() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("🧪 Testing KMS encryption with zero-byte files");
@@ -111,6 +113,7 @@ async fn test_kms_zero_byte_file_encryption() -> Result<(), Box<dyn std::error::
/// Test encryption of single-byte files
#[tokio::test]
#[serial]
async fn test_kms_single_byte_file_encryption() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("🧪 Testing KMS encryption with single-byte files");
@@ -203,6 +206,7 @@ async fn test_kms_single_byte_file_encryption() -> Result<(), Box<dyn std::error
/// Test multipart upload boundary conditions (minimum 5MB part size)
#[tokio::test]
#[serial]
async fn test_kms_multipart_boundary_conditions() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("🧪 Testing KMS multipart upload boundary conditions");
@@ -278,6 +282,7 @@ async fn test_kms_multipart_boundary_conditions() -> Result<(), Box<dyn std::err
/// Test invalid key scenarios and error handling
#[tokio::test]
#[serial]
async fn test_kms_invalid_key_scenarios() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("🧪 Testing KMS invalid key scenarios and error handling");
@@ -365,6 +370,7 @@ async fn test_kms_invalid_key_scenarios() -> Result<(), Box<dyn std::error::Erro
/// Test concurrent encryption operations
#[tokio::test]
#[serial]
async fn test_kms_concurrent_encryption() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("🧪 Testing KMS concurrent encryption operations");
@@ -472,6 +478,7 @@ async fn test_kms_concurrent_encryption() -> Result<(), Box<dyn std::error::Erro
/// Test key validation and security properties
#[tokio::test]
#[serial]
async fn test_kms_key_validation_security() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("🧪 Testing KMS key validation and security properties");
@@ -24,6 +24,7 @@
use super::common::LocalKMSTestEnvironment;
use crate::common::{TEST_BUCKET, init_logging};
use aws_sdk_s3::types::ServerSideEncryption;
use serial_test::serial;
use std::fs;
use std::time::Duration;
use tokio::time::sleep;
@@ -31,6 +32,7 @@ use tracing::{info, warn};
/// Test KMS behavior when key directory is temporarily unavailable
#[tokio::test]
#[serial]
async fn test_kms_key_directory_unavailable() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("🧪 Testing KMS behavior with unavailable key directory");
@@ -121,6 +123,7 @@ async fn test_kms_key_directory_unavailable() -> Result<(), Box<dyn std::error::
/// Test handling of corrupted key files
#[tokio::test]
#[serial]
async fn test_kms_corrupted_key_files() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("🧪 Testing KMS behavior with corrupted key files");
@@ -212,6 +215,7 @@ async fn test_kms_corrupted_key_files() -> Result<(), Box<dyn std::error::Error
/// Test multipart upload interruption and recovery
#[tokio::test]
#[serial]
async fn test_kms_multipart_upload_interruption() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("🧪 Testing KMS multipart upload interruption and recovery");
@@ -395,6 +399,7 @@ async fn test_kms_multipart_upload_interruption() -> Result<(), Box<dyn std::err
/// Test KMS resilience to temporary resource constraints
#[tokio::test]
#[serial]
async fn test_kms_resource_constraints() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("🧪 Testing KMS behavior under resource constraints");
@@ -51,6 +51,7 @@ use aws_sdk_s3::types::{
TransitionStorageClass,
};
use serde::Deserialize;
use serial_test::serial;
use std::time::{Duration as StdDuration, Instant};
use tracing::info;
@@ -423,6 +424,7 @@ async fn wait_for_restore_complete(client: &Client, bucket: &str, key: &str, dea
/// filter as the cause of the deletion and proves the encrypted bucket stays
/// readable end to end after the scanner has run.
#[tokio::test]
#[serial]
async fn ilm_expiration_on_sse_kms_bucket_under_enforcement() -> TestResult {
init_logging();
@@ -483,6 +485,7 @@ async fn ilm_expiration_on_sse_kms_bucket_under_enforcement() -> TestResult {
/// (the mechanism `reliant/tiering.rs` established), so the test does not
/// depend on scanner scheduling; the 1s scanner cycle stays on as a backstop.
#[tokio::test]
#[serial]
async fn ilm_transition_on_sse_kms_bucket_under_enforcement_reads_back() -> TestResult {
init_logging();
@@ -24,9 +24,11 @@ use super::common::{
test_kms_key_management, test_sse_c_encryption,
};
use crate::common::{TEST_BUCKET, init_logging};
use serial_test::serial;
use tracing::{error, info};
#[tokio::test]
#[serial]
async fn test_local_kms_end_to_end() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_if_kms_admin_tool_unavailable("test_local_kms_end_to_end") {
@@ -112,6 +114,7 @@ async fn test_local_kms_end_to_end() -> Result<(), Box<dyn std::error::Error + S
}
#[tokio::test]
#[serial]
async fn test_local_kms_key_isolation() {
init_logging();
info!("Starting Local KMS Key Isolation Test");
@@ -212,6 +215,7 @@ async fn test_local_kms_key_isolation() {
}
#[tokio::test]
#[serial]
async fn test_local_kms_large_file() {
init_logging();
info!("Starting Local KMS Large File Test");
@@ -294,6 +298,7 @@ async fn test_local_kms_large_file() {
}
#[tokio::test]
#[serial]
async fn test_local_kms_multipart_upload() {
init_logging();
info!("Starting Local KMS Multipart Upload Test");
@@ -647,6 +652,7 @@ async fn test_multipart_upload_with_sse_c(
}
/// Test large multipart upload to verify streaming encryption works correctly
#[allow(dead_code)]
async fn test_large_multipart_upload(
s3_client: &aws_sdk_s3::Client,
bucket: &str,
@@ -23,10 +23,12 @@
use super::common::{LocalKMSTestEnvironment, sse_customer_key_md5_base64};
use crate::common::{TEST_BUCKET, init_logging};
use serial_test::serial;
use tracing::{debug, info};
/// Step 1: Test the basic single-file encryption function (ensure that SSE-S3 works properly in non-sharded scenarios)
#[tokio::test]
#[serial]
async fn test_step1_basic_single_file_encryption() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("🧪 Step 1: Test the basic single-file encryption function");
@@ -83,6 +85,7 @@ async fn test_step1_basic_single_file_encryption() -> Result<(), Box<dyn std::er
/// Step 2: Test the unencrypted shard upload (make sure the shard upload base is working properly)
#[tokio::test]
#[serial]
async fn test_step2_basic_multipart_upload_without_encryption() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("🧪 Step 2: Test unencrypted shard uploads");
@@ -181,6 +184,7 @@ async fn test_step2_basic_multipart_upload_without_encryption() -> Result<(), Bo
/// Step 3: Test Shard Upload + SSE-S3 Encryption (Focus Test)
#[tokio::test]
#[serial]
async fn test_step3_multipart_upload_with_sse_s3() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("🧪 Step 3: Test Shard Upload + SSE-S3 Encryption");
@@ -304,6 +308,7 @@ async fn test_step3_multipart_upload_with_sse_s3() -> Result<(), Box<dyn std::er
/// Step 4: test larger multipart uploads (streaming encryption)
#[tokio::test]
#[serial]
async fn test_step4_large_multipart_upload_with_encryption() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("🧪 Step 4: test large-file multipart encryption");
@@ -429,6 +434,7 @@ async fn test_step4_large_multipart_upload_with_encryption() -> Result<(), Box<d
/// Step 5: test multipart uploads for every encryption mode
#[tokio::test]
#[serial]
async fn test_step5_all_encryption_types_multipart() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("🧪 Step 5: test multipart uploads for every encryption mode");
+3
View File
@@ -19,6 +19,7 @@
//! filtering, and comprehensive reporting capabilities.
use crate::common::init_logging;
use serial_test::serial;
use std::time::Instant;
use tokio::time::{Duration, sleep};
use tracing::{debug, error, info, warn};
@@ -457,6 +458,7 @@ impl KMSTestSuite {
/// Quick test suite for critical tests only
#[tokio::test]
#[serial]
async fn test_kms_critical_suite() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let config = TestSuiteConfig {
categories: vec![TestCategory::CoreFunctionality, TestCategory::MultipartEncryption],
@@ -479,6 +481,7 @@ async fn test_kms_critical_suite() -> Result<(), Box<dyn std::error::Error + Sen
/// Full comprehensive test suite
#[tokio::test]
#[serial]
async fn test_kms_full_suite() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let suite = KMSTestSuite::new();
let results = suite.run_test_suite().await;
@@ -24,6 +24,7 @@ mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use serial_test::serial;
use std::error::Error;
use tracing::info;
@@ -35,6 +36,7 @@ mod tests {
/// PUT with a leading-slash key must succeed and the object must be
/// readable under the normalized key (leading slash stripped).
#[tokio::test]
#[serial]
async fn test_put_object_with_leading_slash_key() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
info!("Starting test: PUT object with leading slash in key (Issue #2427)");
@@ -92,6 +94,7 @@ mod tests {
/// Duplicate and repeated slashes after a leading slash collapse MinIO-style.
#[tokio::test]
#[serial]
async fn test_put_object_with_duplicate_slashes_normalized() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
info!("Starting test: duplicate slash normalization (Issue #2427)");
@@ -36,6 +36,7 @@ mod tests {
BucketLifecycleConfiguration, BucketVersioningStatus, ExpirationStatus, LifecycleExpiration, LifecycleRule,
LifecycleRuleFilter, NoncurrentVersionExpiration, VersioningConfiguration,
};
use serial_test::serial;
use std::error::Error;
use tracing::info;
@@ -79,6 +80,7 @@ mod tests {
///
/// This tests the rule persistence path (rustfs#4963: 3 days → 0 days).
#[tokio::test]
#[serial]
async fn test_lifecycle_expiration_rule_persists_correctly() -> TestResult {
init_logging();
info!("RT-03: lifecycle expiration rule persists correctly");
@@ -146,6 +148,7 @@ mod tests {
/// Covers the pattern where noncurrent version expiration rules are
/// accepted but old versions are never cleaned up.
#[tokio::test]
#[serial]
async fn test_lifecycle_noncurrent_version_expiration_rule_persists() -> TestResult {
init_logging();
info!("RT-03b: noncurrent version expiration rule persists");
@@ -230,6 +233,7 @@ mod tests {
/// after restart. Transition rules require a configured remote tier
/// (tested in reliant/tiering.rs), so this test uses expiration only.
#[tokio::test]
#[serial]
async fn test_lifecycle_prefix_rule_persists() -> TestResult {
init_logging();
info!("RT-04: lifecycle prefix rule persists");
@@ -290,6 +294,7 @@ mod tests {
/// Regression pattern: DELETE on a versioned object fails or does not
/// create a delete marker, or the delete marker is not visible in LIST.
#[tokio::test]
#[serial]
async fn test_delete_marker_creation_and_visibility() -> TestResult {
init_logging();
info!("RT-05b: delete marker creation and visibility");
@@ -27,6 +27,7 @@ mod tests {
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4;
use s3s::Body;
use serial_test::serial;
use std::error::Error;
/// Sends a SigV4-signed `GET` where the signature is computed over `sign_path`
@@ -66,6 +67,7 @@ mod tests {
/// `GET /` (path-style service call) returns `ListBuckets`.
#[tokio::test]
#[serial]
async fn test_list_buckets_single_slash() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -84,6 +86,7 @@ mod tests {
/// compat layer rewrites `//` to `/` before `s3s` parses/verifies the request,
/// so both routing and signature verification operate on `/`.
#[tokio::test]
#[serial]
async fn test_list_buckets_double_slash_browser_compat() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -102,6 +105,7 @@ mod tests {
/// (`GET //bucket`) must be left untouched by the compat layer — it is not a
/// `ListBuckets` request and s3s continues to reject the empty bucket name.
#[tokio::test]
#[serial]
async fn test_double_slash_rewrite_is_narrowly_scoped() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -15,6 +15,7 @@
use crate::common::{RustFSTestEnvironment, admin_ok, build_test_s3_config, build_test_sts_client, init_logging};
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use serial_test::serial;
use tokio::time::{Duration, Instant};
fn user_client(env: &RustFSTestEnvironment, access_key: &str, secret_key: &str, session_token: Option<&str>) -> Client {
@@ -75,6 +76,7 @@ async fn create_service_account(
}
#[tokio::test]
#[serial]
async fn list_buckets_filters_with_iam_bucket_resources() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -24,6 +24,7 @@ mod tests {
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4;
use s3s::Body;
use serial_test::serial;
use std::error::Error;
use tracing::info;
@@ -53,6 +54,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_list_object_versions_metadata_extension_returns_metadata_tags_and_internal()
-> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -21,6 +21,7 @@ mod tests {
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use serial_test::serial;
use tracing::info;
fn create_s3_client(env: &RustFSTestEnvironment) -> Client {
@@ -28,6 +29,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_list_object_versions_immediately_returns_latest_put_after_delete_marker() {
init_logging();
info!("🧪 TEST: ListObjectVersions returns the newest version immediately after put -> delete -> put");
@@ -180,6 +182,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_list_object_versions_prefix_with_marker_object_returns_children() {
init_logging();
info!("🧪 TEST: ListObjectVersions returns prefix children when a marker object also exists");
@@ -17,6 +17,7 @@ mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use serial_test::serial;
use tracing::info;
/// Helper function to create an S3 client for testing
@@ -59,6 +60,7 @@ mod tests {
/// The bug was that "folder/" (the object) and "folder/" (derived prefix) were both added to CommonPrefixes
/// when delimiter was "/" because the deduplication check was explicitly skipped for "/" delimiter.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_unique_common_prefixes() {
init_logging();
info!("Starting test: ListObjectsV2 should return unique CommonPrefixes");
@@ -138,6 +140,7 @@ mod tests {
/// When both "marker/subdir/" and "marker/subdir/file.txt" exist, listing with
/// Prefix="marker/" must not duplicate "marker/subdir/file.txt" in Contents.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_unique_contents_with_explicit_directory_markers() {
init_logging();
info!("Starting test: ListObjectsV2 should return unique keys with explicit directory markers");
@@ -205,6 +208,7 @@ mod tests {
/// and never produce the prefix entry `a/`. Delimiter="/" listings then
/// returned Contents `a` but silently dropped CommonPrefix `a/`.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_object_and_same_named_prefix_coexist() {
init_logging();
info!("Starting test: ListObjectsV2 should return both object `a` and CommonPrefix `a/`");
@@ -23,6 +23,7 @@ mod tests {
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4;
use s3s::Body;
use serial_test::serial;
use std::error::Error;
use tracing::info;
@@ -52,6 +53,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_list_objects_v2_metadata_extension_returns_metadata_tags_and_internal()
-> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -30,6 +30,7 @@ mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use serial_test::serial;
use std::collections::HashSet;
use tracing::info;
@@ -60,6 +61,7 @@ mod tests {
/// Test for Issue #2775: continuation forwarding must not
/// skip a child directory when the prefix component repeats in the key.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_repeated_prefix_continuation() {
init_logging();
info!("Starting test: ListObjectsV2 repeated-prefix continuation");
@@ -185,6 +187,7 @@ mod tests {
/// This is the core bug from issue #1596: the server was returning
/// IsTruncated=true even when all objects fit within the requested max_keys.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_not_truncated_when_all_objects_returned() {
init_logging();
info!("Starting test: ListObjectsV2 should not be truncated when all objects fit within max_keys");
@@ -249,6 +252,7 @@ mod tests {
/// 2. NextContinuationToken is returned (not NextMarker)
/// 3. Using ContinuationToken fetches the remaining objects
#[tokio::test]
#[serial]
async fn test_list_objects_v2_pagination_with_continuation_token() {
init_logging();
info!("Starting test: ListObjectsV2 pagination with continuation token");
@@ -390,6 +394,7 @@ mod tests {
/// Edge case: when max_keys exactly equals the number of objects,
/// IsTruncated should be false.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_max_keys_equals_object_count() {
init_logging();
info!("Starting test: ListObjectsV2 with max_keys equal to object count");
@@ -450,6 +455,7 @@ mod tests {
///
/// Edge case: IsTruncated should be false for empty bucket.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_empty_bucket() {
init_logging();
info!("Starting test: ListObjectsV2 with empty bucket");
@@ -489,6 +495,7 @@ mod tests {
/// Test ListObjectsV2 caps max_keys above the service limit and still paginates.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_max_keys_above_limit_returns_token() {
init_logging();
info!("Starting test: ListObjectsV2 with max_keys above limit");
@@ -556,6 +563,7 @@ mod tests {
/// S3 semantics: when max_keys is 0, the response should include no objects
/// and IsTruncated should be false.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_max_keys_zero() {
init_logging();
info!("Starting test: ListObjectsV2 with max_keys=0");
@@ -612,6 +620,7 @@ mod tests {
/// With max_keys=1000, all 5 visible results (3 prefixes + 2 objects) fit in one
/// page, so IsTruncated must be false even though raw entry count is much larger.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_delimiter_collapsed_prefix_no_false_truncation() {
init_logging();
info!("Starting test: ListObjectsV2 delimiter collapsed-prefix no false truncation");
@@ -735,6 +744,7 @@ mod tests {
/// Each page returns up to 50 CommonPrefixes. The server must correctly set
/// IsTruncated and provide a valid continuation token across all pages.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_delimiter_small_page_traverses_all() {
init_logging();
info!("Starting test: ListObjectsV2 delimiter small page traverses all keys");
@@ -857,6 +867,7 @@ mod tests {
/// but after delimiter collapse only 10 CommonPrefixes are visible (10 < 1000).
/// IsTruncated must be false since there are no additional visible results.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_raw_exceeds_maxkeys_but_visible_below() {
init_logging();
info!("Starting test: ListObjectsV2 raw > MaxKeys but visible < MaxKeys after collapse");
@@ -959,6 +970,7 @@ mod tests {
/// This complements test_list_objects_v2_max_keys_above_limit_returns_token which
/// tests the non-delimiter case.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_maxkeys_above_limit_with_delimiter() {
init_logging();
info!("Starting test: ListObjectsV2 MaxKeys above limit with delimiter");
@@ -1029,6 +1041,7 @@ mod tests {
/// the next page: with keys `a`, `a.txt`, `zz` and max_keys=1, page 2
/// returned `zz` and `a.txt` was never listed.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_continuation_keeps_keys_after_marker_stem() {
init_logging();
info!("Starting test: continuation must not skip keys sorting below the cursor tag");
@@ -31,6 +31,7 @@
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::primitives::ByteStream;
use serial_test::serial;
use std::collections::HashSet;
use std::error::Error;
use tracing::info;
@@ -48,6 +49,7 @@ mod tests {
/// 3. Verify all 100 keys are returned exactly once
/// 4. Verify no duplicates or skipped keys
#[tokio::test]
#[serial]
async fn test_list_objects_v2_completeness_100_objects() -> TestResult {
init_logging();
info!("RT-06: listing completeness with 100 objects");
@@ -131,6 +133,7 @@ mod tests {
/// Regression pattern: prefix filter returns empty or includes wrong keys
/// (rustfs#5051: empty results for shallow prefixes).
#[tokio::test]
#[serial]
async fn test_list_objects_v2_prefix_filter_correctness() -> TestResult {
init_logging();
info!("RT-06b: prefix filter correctness");
@@ -230,6 +233,7 @@ mod tests {
/// Regression pattern: delimiter handling produces incorrect CommonPrefixes
/// or misses objects at the delimiter boundary.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_delimiter_common_prefixes() -> TestResult {
init_logging();
info!("RT-06c: delimiter and CommonPrefixes");
@@ -286,6 +290,7 @@ mod tests {
/// Regression pattern: IsTruncated=false when there are more objects
/// (rustfs#4810: walk_dir timeout truncation with false IsTruncated).
#[tokio::test]
#[serial]
async fn test_list_objects_v2_is_truncated_correctness() -> TestResult {
init_logging();
info!("RT-06d: IsTruncated correctness");
@@ -13,6 +13,7 @@
// limitations under the License.
use crate::common::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY, RustFSTestEnvironment};
use serial_test::serial;
use std::path::Path;
use std::process::Command;
use std::time::Duration;
@@ -72,6 +73,7 @@ fn count_files(root: &Path) -> usize {
}
#[tokio::test]
#[serial]
async fn test_mc_mirror_small_bucket_completes_without_list_timeout() -> TestResult {
crate::common::init_logging();
info!("Starting issue #3107 mc mirror regression test");
+610 -235
View File
@@ -17,7 +17,6 @@
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use async_compression::tokio::write::{BzEncoder, XzEncoder};
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
use aws_sdk_s3::operation::head_object::HeadObjectOutput;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
ServerSideEncryption, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, ServerSideEncryptionRule,
@@ -349,71 +348,6 @@ async fn run_post_object_policy_case(
Ok(())
}
/// One accepted POST Object upload driven end-to-end (backlog#1838): starts a
/// fresh server, allows anonymous PutObject on `bucket`, posts an anonymous
/// POST Object form whose policy carries `policy_conditions` and whose form
/// carries `form_field` on top of the mandatory key+policy fields, then asserts
/// 204 with an empty body, that `read_stored` observes the submitted value on
/// the stored object, and that the object body round-tripped unchanged.
/// `case` prefixes every assertion message so a failing table row is
/// identifiable at a glance.
#[allow(clippy::too_many_arguments)]
async fn run_post_object_accept_case(
bucket: &str,
object_key: &str,
policy_conditions: Vec<serde_json::Value>,
form_field: (&str, &str),
file_mime: &str,
file_body: &[u8],
read_stored: fn(&HeadObjectOutput) -> Option<&str>,
case: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let admin_client = env.create_s3_client();
admin_client.create_bucket().bucket(bucket).send().await?;
allow_anonymous_put_object(&admin_client, bucket).await?;
let policy = encode_post_policy(policy_conditions);
let (field_name, field_value) = form_field;
let post_form = reqwest::multipart::Form::new()
.text("key", object_key.to_string())
.text("policy", policy)
.text(field_name.to_string(), field_value.to_string())
.part(
"file",
reqwest::multipart::Part::bytes(file_body.to_vec())
.file_name("upload.txt")
.mime_str(file_mime)?,
);
let post_resp = local_http_client()
.post(format!("{}/{}", env.url, bucket))
.multipart(post_form)
.send()
.await?;
let status = post_resp.status();
let response_body = post_resp.text().await?;
assert_eq!(status, reqwest::StatusCode::NO_CONTENT, "[{case}] unexpected status");
assert!(
response_body.is_empty(),
"[{case}] 204 response should not contain a body, got: {response_body}"
);
let head = admin_client.head_object().bucket(bucket).key(object_key).send().await?;
assert_eq!(read_stored(&head), Some(field_value), "[{case}] stored {field_name} mismatch");
let get_out = admin_client.get_object().bucket(bucket).key(object_key).send().await?;
let uploaded = get_out.body.collect().await?.into_bytes();
assert_eq!(uploaded.as_ref(), file_body, "[{case}] uploaded body mismatch");
Ok(())
}
/// Table-driven fold of the nine `*_missing_from_policy_conditions` POST
/// Object tests (backlog#1838 PR1). Every row keeps its original test's exact
/// bucket, key, form field, file body, and expected error strings; the shared
@@ -1600,6 +1534,59 @@ async fn test_anonymous_post_object_accepts_sse_s3_missing_from_policy_condition
Ok(())
}
#[tokio::test]
async fn test_anonymous_post_object_accepts_storage_class_exact_policy_match()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "anon-post-storage-class";
let object_key = "post-storage-class-object.txt";
let expected_body = b"post-storage-class-body".to_vec();
let storage_class = "REDUCED_REDUNDANCY";
let admin_client = env.create_s3_client();
admin_client.create_bucket().bucket(bucket).send().await?;
allow_anonymous_put_object(&admin_client, bucket).await?;
let policy = encode_post_policy(vec![
serde_json::json!({ "bucket": bucket }),
serde_json::json!({ "key": object_key }),
serde_json::json!({ "x-amz-storage-class": storage_class }),
serde_json::json!(["content-length-range", 0, 1024]),
]);
let post_form = reqwest::multipart::Form::new()
.text("key", object_key.to_string())
.text("policy", policy)
.text("x-amz-storage-class", storage_class)
.part(
"file",
reqwest::multipart::Part::bytes(expected_body.clone())
.file_name("upload.txt")
.mime_str("text/plain")?,
);
let post_resp = local_http_client()
.post(format!("{}/{}", env.url, bucket))
.multipart(post_form)
.send()
.await?;
assert_eq!(post_resp.status(), reqwest::StatusCode::NO_CONTENT);
let head = admin_client.head_object().bucket(bucket).key(object_key).send().await?;
assert_eq!(head.storage_class().map(|value| value.as_str()), Some(storage_class));
let uploaded = admin_client.get_object().bucket(bucket).key(object_key).send().await?;
let uploaded = uploaded.body.collect().await?.into_bytes();
assert_eq!(uploaded.as_ref(), expected_body.as_slice());
Ok(())
}
#[tokio::test]
async fn test_anonymous_post_object_rejects_storage_class_missing_from_policy_conditions()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
@@ -2597,182 +2584,512 @@ async fn test_anonymous_post_object_rejects_success_action_redirect_missing_from
Ok(())
}
/// Table-driven fold of the eleven accepted POST Object form-field tests
/// (backlog#1838 PR4). Every row keeps its original test's exact bucket, key,
/// form field, submitted value, policy condition, file MIME type, and file
/// body; the shared shape is: the policy covers the field (exact condition or
/// `starts-with` prefix), the form submits it, the upload returns 204 with an
/// empty body, and the stored object echoes the submitted value back.
#[tokio::test]
async fn test_anonymous_post_object_accepts_fields_covered_by_policy_conditions()
async fn test_anonymous_post_object_accepts_metadata_field_covered_by_starts_with()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
// (case, bucket, object_key, field, submitted value, `starts-with` prefix
// (`None` pins the field to an exact policy condition), file part MIME type,
// file body, stored-value accessor)
type Case = (
&'static str,
&'static str,
&'static str,
&'static str,
&'static str,
Option<&'static str>,
&'static str,
&'static [u8],
fn(&HeadObjectOutput) -> Option<&str>,
);
let cases: &[Case] = &[
(
"storage-class",
"anon-post-storage-class",
"post-storage-class-object.txt",
"x-amz-storage-class",
"REDUCED_REDUNDANCY",
None,
"text/plain",
b"post-storage-class-body",
|head: &HeadObjectOutput| head.storage_class().map(|value| value.as_str()),
),
(
"metadata-starts-with",
"anon-post-policy-meta-accept",
"uploads/meta-object.txt",
"x-amz-meta-project",
"alpha-demo",
Some("alpha-"),
"text/plain",
b"post-policy-meta-body",
|head: &HeadObjectOutput| head.metadata().and_then(|meta| meta.get("project")).map(String::as_str),
),
(
"content-type",
"anon-post-policy-content-type-accept",
"uploads/content-type-accept.txt",
"Content-Type",
"text/plain",
None,
"text/plain",
b"post-policy-content-type-accept",
|head: &HeadObjectOutput| head.content_type(),
),
(
"content-type-starts-with",
"anon-post-policy-content-type-accept",
"uploads/content-type-object.txt",
"Content-Type",
"image/png",
Some("image/"),
"image/png",
b"post-policy-content-type-body",
|head: &HeadObjectOutput| head.content_type(),
),
(
"content-disposition",
"anon-post-policy-disposition-accept",
"uploads/disposition-object.txt",
"Content-Disposition",
"attachment; filename=\"upload.txt\"",
None,
"text/plain",
b"post-policy-disposition-body",
|head: &HeadObjectOutput| head.content_disposition(),
),
(
"cache-control",
"anon-post-policy-cache-control-accept",
"uploads/cache-control-object.txt",
"Cache-Control",
"max-age=60",
None,
"text/plain",
b"post-policy-cache-control-body",
|head: &HeadObjectOutput| head.cache_control(),
),
(
"content-language",
"anon-post-policy-content-language-accept",
"uploads/content-language-object.txt",
"Content-Language",
"en-US",
None,
"text/plain",
b"post-policy-content-language-body",
|head: &HeadObjectOutput| head.content_language(),
),
(
"content-encoding",
"anon-post-policy-content-encoding-accept",
"uploads/content-encoding-object.txt",
"Content-Encoding",
"gzip",
None,
"text/plain",
b"post-policy-content-encoding-body",
|head: &HeadObjectOutput| head.content_encoding(),
),
(
"website-redirect-location",
"anon-post-policy-website-redirect-accept",
"uploads/website-redirect-object.txt",
"x-amz-website-redirect-location",
"/docs/landing.html",
None,
"text/plain",
b"post-policy-website-redirect-body",
|head: &HeadObjectOutput| head.website_redirect_location(),
),
(
"expires",
"anon-post-policy-expires-accept",
"uploads/expires-object.txt",
"Expires",
"Wed, 21 Oct 2037 07:28:00 GMT",
None,
"text/plain",
b"post-policy-expires-body",
|head: &HeadObjectOutput| head.expires_string(),
),
(
"metadata-exact",
"anon-post-policy-meta-exact-accept",
"uploads/meta-exact-accept-object.txt",
"x-amz-meta-project",
"alpha-demo",
None,
"text/plain",
b"post-policy-meta-exact-body",
|head: &HeadObjectOutput| head.metadata().and_then(|meta| meta.get("project")).map(String::as_str),
),
];
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
for (case, bucket, object_key, field, value, starts_with_prefix, file_mime, file_body, read_stored) in cases {
let condition = match starts_with_prefix {
Some(prefix) => serde_json::json!(["starts-with", format!("${field}"), prefix]),
None => {
let mut exact = serde_json::Map::new();
exact.insert((*field).to_string(), serde_json::Value::String((*value).to_string()));
serde_json::Value::Object(exact)
}
};
let bucket = "anon-post-policy-meta-accept";
let object_key = "uploads/meta-object.txt";
let metadata_value = "alpha-demo";
let expected_body = b"post-policy-meta-body".to_vec();
run_post_object_accept_case(
bucket,
object_key,
vec![
serde_json::json!({ "bucket": bucket }),
serde_json::json!({ "key": object_key }),
condition,
serde_json::json!(["content-length-range", 0, 1024]),
],
(field, value),
file_mime,
file_body,
*read_stored,
case,
)
let admin_client = env.create_s3_client();
admin_client.create_bucket().bucket(bucket).send().await?;
allow_anonymous_put_object(&admin_client, bucket).await?;
let policy = encode_post_policy(vec![
serde_json::json!({ "bucket": bucket }),
serde_json::json!({ "key": object_key }),
serde_json::json!(["starts-with", "$x-amz-meta-project", "alpha-"]),
serde_json::json!(["content-length-range", 0, 1024]),
]);
let post_form = reqwest::multipart::Form::new()
.text("key", object_key.to_string())
.text("policy", policy)
.text("x-amz-meta-project", metadata_value)
.part(
"file",
reqwest::multipart::Part::bytes(expected_body.clone())
.file_name("upload.txt")
.mime_str("text/plain")?,
);
let post_resp = local_http_client()
.post(format!("{}/{}", env.url, bucket))
.multipart(post_form)
.send()
.await?;
}
let status = post_resp.status();
let response_body = post_resp.text().await?;
assert_eq!(status, reqwest::StatusCode::NO_CONTENT);
assert!(response_body.is_empty(), "204 response should not contain a body, got: {response_body}");
let head = admin_client.head_object().bucket(bucket).key(object_key).send().await?;
let metadata = head.metadata().expect("head_object should expose uploaded metadata");
assert_eq!(metadata.get("project").map(String::as_str), Some(metadata_value));
Ok(())
}
#[tokio::test]
async fn test_anonymous_post_object_accepts_content_type_field_exact_policy_match()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "anon-post-policy-content-type-accept";
let object_key = "uploads/content-type-accept.txt";
let content_type = "text/plain";
let expected_body = b"post-policy-content-type-accept".to_vec();
let admin_client = env.create_s3_client();
admin_client.create_bucket().bucket(bucket).send().await?;
allow_anonymous_put_object(&admin_client, bucket).await?;
let policy = encode_post_policy(vec![
serde_json::json!({ "bucket": bucket }),
serde_json::json!({ "key": object_key }),
serde_json::json!({ "Content-Type": content_type }),
serde_json::json!(["content-length-range", 0, 1024]),
]);
let post_form = reqwest::multipart::Form::new()
.text("key", object_key.to_string())
.text("policy", policy)
.text("Content-Type", content_type)
.part(
"file",
reqwest::multipart::Part::bytes(expected_body.clone())
.file_name("upload.txt")
.mime_str(content_type)?,
);
let post_resp = local_http_client()
.post(format!("{}/{}", env.url, bucket))
.multipart(post_form)
.send()
.await?;
let status = post_resp.status();
let response_body = post_resp.text().await?;
assert_eq!(status, reqwest::StatusCode::NO_CONTENT);
assert!(response_body.is_empty(), "204 response should not contain a body, got: {response_body}");
let head = admin_client.head_object().bucket(bucket).key(object_key).send().await?;
assert_eq!(head.content_type(), Some(content_type));
let get_out = admin_client.get_object().bucket(bucket).key(object_key).send().await?;
let uploaded = get_out.body.collect().await?.into_bytes();
assert_eq!(uploaded.as_ref(), expected_body.as_slice());
Ok(())
}
#[tokio::test]
async fn test_anonymous_post_object_accepts_content_type_field_covered_by_starts_with()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "anon-post-policy-content-type-accept";
let object_key = "uploads/content-type-object.txt";
let content_type = "image/png";
let expected_body = b"post-policy-content-type-body".to_vec();
let admin_client = env.create_s3_client();
admin_client.create_bucket().bucket(bucket).send().await?;
allow_anonymous_put_object(&admin_client, bucket).await?;
let policy = encode_post_policy(vec![
serde_json::json!({ "bucket": bucket }),
serde_json::json!({ "key": object_key }),
serde_json::json!(["starts-with", "$Content-Type", "image/"]),
serde_json::json!(["content-length-range", 0, 1024]),
]);
let post_form = reqwest::multipart::Form::new()
.text("key", object_key.to_string())
.text("policy", policy)
.text("Content-Type", content_type)
.part(
"file",
reqwest::multipart::Part::bytes(expected_body.clone())
.file_name("upload.txt")
.mime_str(content_type)?,
);
let post_resp = local_http_client()
.post(format!("{}/{}", env.url, bucket))
.multipart(post_form)
.send()
.await?;
let status = post_resp.status();
let response_body = post_resp.text().await?;
assert_eq!(status, reqwest::StatusCode::NO_CONTENT);
assert!(response_body.is_empty(), "204 response should not contain a body, got: {response_body}");
let head = admin_client.head_object().bucket(bucket).key(object_key).send().await?;
assert_eq!(head.content_type(), Some(content_type));
let get_out = admin_client.get_object().bucket(bucket).key(object_key).send().await?;
let uploaded = get_out.body.collect().await?.into_bytes();
assert_eq!(uploaded.as_ref(), expected_body.as_slice());
Ok(())
}
#[tokio::test]
async fn test_anonymous_post_object_accepts_content_disposition_field_exact_policy_match()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "anon-post-policy-disposition-accept";
let object_key = "uploads/disposition-object.txt";
let content_disposition = "attachment; filename=\"upload.txt\"";
let expected_body = b"post-policy-disposition-body".to_vec();
let admin_client = env.create_s3_client();
admin_client.create_bucket().bucket(bucket).send().await?;
allow_anonymous_put_object(&admin_client, bucket).await?;
let policy = encode_post_policy(vec![
serde_json::json!({ "bucket": bucket }),
serde_json::json!({ "key": object_key }),
serde_json::json!({ "Content-Disposition": content_disposition }),
serde_json::json!(["content-length-range", 0, 1024]),
]);
let post_form = reqwest::multipart::Form::new()
.text("key", object_key.to_string())
.text("policy", policy)
.text("Content-Disposition", content_disposition)
.part(
"file",
reqwest::multipart::Part::bytes(expected_body.clone())
.file_name("upload.txt")
.mime_str("text/plain")?,
);
let post_resp = local_http_client()
.post(format!("{}/{}", env.url, bucket))
.multipart(post_form)
.send()
.await?;
let status = post_resp.status();
let response_body = post_resp.text().await?;
assert_eq!(status, reqwest::StatusCode::NO_CONTENT);
assert!(response_body.is_empty(), "204 response should not contain a body, got: {response_body}");
let head = admin_client.head_object().bucket(bucket).key(object_key).send().await?;
assert_eq!(head.content_disposition(), Some(content_disposition));
let get_out = admin_client.get_object().bucket(bucket).key(object_key).send().await?;
let uploaded = get_out.body.collect().await?.into_bytes();
assert_eq!(uploaded.as_ref(), expected_body.as_slice());
Ok(())
}
#[tokio::test]
async fn test_anonymous_post_object_accepts_cache_control_field_exact_policy_match()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "anon-post-policy-cache-control-accept";
let object_key = "uploads/cache-control-object.txt";
let cache_control = "max-age=60";
let expected_body = b"post-policy-cache-control-body".to_vec();
let admin_client = env.create_s3_client();
admin_client.create_bucket().bucket(bucket).send().await?;
allow_anonymous_put_object(&admin_client, bucket).await?;
let policy = encode_post_policy(vec![
serde_json::json!({ "bucket": bucket }),
serde_json::json!({ "key": object_key }),
serde_json::json!({ "Cache-Control": cache_control }),
serde_json::json!(["content-length-range", 0, 1024]),
]);
let post_form = reqwest::multipart::Form::new()
.text("key", object_key.to_string())
.text("policy", policy)
.text("Cache-Control", cache_control)
.part(
"file",
reqwest::multipart::Part::bytes(expected_body.clone())
.file_name("upload.txt")
.mime_str("text/plain")?,
);
let post_resp = local_http_client()
.post(format!("{}/{}", env.url, bucket))
.multipart(post_form)
.send()
.await?;
let status = post_resp.status();
let response_body = post_resp.text().await?;
assert_eq!(status, reqwest::StatusCode::NO_CONTENT);
assert!(response_body.is_empty(), "204 response should not contain a body, got: {response_body}");
let head = admin_client.head_object().bucket(bucket).key(object_key).send().await?;
assert_eq!(head.cache_control(), Some(cache_control));
let get_out = admin_client.get_object().bucket(bucket).key(object_key).send().await?;
let uploaded = get_out.body.collect().await?.into_bytes();
assert_eq!(uploaded.as_ref(), expected_body.as_slice());
Ok(())
}
#[tokio::test]
async fn test_anonymous_post_object_accepts_content_language_field_exact_policy_match()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "anon-post-policy-content-language-accept";
let object_key = "uploads/content-language-object.txt";
let content_language = "en-US";
let expected_body = b"post-policy-content-language-body".to_vec();
let admin_client = env.create_s3_client();
admin_client.create_bucket().bucket(bucket).send().await?;
allow_anonymous_put_object(&admin_client, bucket).await?;
let policy = encode_post_policy(vec![
serde_json::json!({ "bucket": bucket }),
serde_json::json!({ "key": object_key }),
serde_json::json!({ "Content-Language": content_language }),
serde_json::json!(["content-length-range", 0, 1024]),
]);
let post_form = reqwest::multipart::Form::new()
.text("key", object_key.to_string())
.text("policy", policy)
.text("Content-Language", content_language)
.part(
"file",
reqwest::multipart::Part::bytes(expected_body.clone())
.file_name("upload.txt")
.mime_str("text/plain")?,
);
let post_resp = local_http_client()
.post(format!("{}/{}", env.url, bucket))
.multipart(post_form)
.send()
.await?;
let status = post_resp.status();
let response_body = post_resp.text().await?;
assert_eq!(status, reqwest::StatusCode::NO_CONTENT);
assert!(response_body.is_empty(), "204 response should not contain a body, got: {response_body}");
let head = admin_client.head_object().bucket(bucket).key(object_key).send().await?;
assert_eq!(head.content_language(), Some(content_language));
let get_out = admin_client.get_object().bucket(bucket).key(object_key).send().await?;
let uploaded = get_out.body.collect().await?.into_bytes();
assert_eq!(uploaded.as_ref(), expected_body.as_slice());
Ok(())
}
#[tokio::test]
async fn test_anonymous_post_object_accepts_content_encoding_field_exact_policy_match()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "anon-post-policy-content-encoding-accept";
let object_key = "uploads/content-encoding-object.txt";
let content_encoding = "gzip";
let expected_body = b"post-policy-content-encoding-body".to_vec();
let admin_client = env.create_s3_client();
admin_client.create_bucket().bucket(bucket).send().await?;
allow_anonymous_put_object(&admin_client, bucket).await?;
let policy = encode_post_policy(vec![
serde_json::json!({ "bucket": bucket }),
serde_json::json!({ "key": object_key }),
serde_json::json!({ "Content-Encoding": content_encoding }),
serde_json::json!(["content-length-range", 0, 1024]),
]);
let post_form = reqwest::multipart::Form::new()
.text("key", object_key.to_string())
.text("policy", policy)
.text("Content-Encoding", content_encoding)
.part(
"file",
reqwest::multipart::Part::bytes(expected_body.clone())
.file_name("upload.txt")
.mime_str("text/plain")?,
);
let post_resp = local_http_client()
.post(format!("{}/{}", env.url, bucket))
.multipart(post_form)
.send()
.await?;
let status = post_resp.status();
let response_body = post_resp.text().await?;
assert_eq!(status, reqwest::StatusCode::NO_CONTENT);
assert!(response_body.is_empty(), "204 response should not contain a body, got: {response_body}");
let head = admin_client.head_object().bucket(bucket).key(object_key).send().await?;
assert_eq!(head.content_encoding(), Some(content_encoding));
let get_out = admin_client.get_object().bucket(bucket).key(object_key).send().await?;
let uploaded = get_out.body.collect().await?.into_bytes();
assert_eq!(uploaded.as_ref(), expected_body.as_slice());
Ok(())
}
#[tokio::test]
async fn test_anonymous_post_object_accepts_website_redirect_location_exact_policy_match()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "anon-post-policy-website-redirect-accept";
let object_key = "uploads/website-redirect-object.txt";
let website_redirect_location = "/docs/landing.html";
let expected_body = b"post-policy-website-redirect-body".to_vec();
let admin_client = env.create_s3_client();
admin_client.create_bucket().bucket(bucket).send().await?;
allow_anonymous_put_object(&admin_client, bucket).await?;
let policy = encode_post_policy(vec![
serde_json::json!({ "bucket": bucket }),
serde_json::json!({ "key": object_key }),
serde_json::json!({ "x-amz-website-redirect-location": website_redirect_location }),
serde_json::json!(["content-length-range", 0, 1024]),
]);
let post_form = reqwest::multipart::Form::new()
.text("key", object_key.to_string())
.text("policy", policy)
.text("x-amz-website-redirect-location", website_redirect_location)
.part(
"file",
reqwest::multipart::Part::bytes(expected_body.clone())
.file_name("upload.txt")
.mime_str("text/plain")?,
);
let post_resp = local_http_client()
.post(format!("{}/{}", env.url, bucket))
.multipart(post_form)
.send()
.await?;
let status = post_resp.status();
let response_body = post_resp.text().await?;
assert_eq!(status, reqwest::StatusCode::NO_CONTENT);
assert!(response_body.is_empty(), "204 response should not contain a body, got: {response_body}");
let head = admin_client.head_object().bucket(bucket).key(object_key).send().await?;
assert_eq!(head.website_redirect_location(), Some(website_redirect_location));
let get_out = admin_client.get_object().bucket(bucket).key(object_key).send().await?;
let uploaded = get_out.body.collect().await?.into_bytes();
assert_eq!(uploaded.as_ref(), expected_body.as_slice());
Ok(())
}
#[tokio::test]
async fn test_anonymous_post_object_accepts_expires_field_exact_policy_match()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "anon-post-policy-expires-accept";
let object_key = "uploads/expires-object.txt";
let expires = "Wed, 21 Oct 2037 07:28:00 GMT";
let expected_body = b"post-policy-expires-body".to_vec();
let admin_client = env.create_s3_client();
admin_client.create_bucket().bucket(bucket).send().await?;
allow_anonymous_put_object(&admin_client, bucket).await?;
let policy = encode_post_policy(vec![
serde_json::json!({ "bucket": bucket }),
serde_json::json!({ "key": object_key }),
serde_json::json!({ "Expires": expires }),
serde_json::json!(["content-length-range", 0, 1024]),
]);
let post_form = reqwest::multipart::Form::new()
.text("key", object_key.to_string())
.text("policy", policy)
.text("Expires", expires)
.part(
"file",
reqwest::multipart::Part::bytes(expected_body.clone())
.file_name("upload.txt")
.mime_str("text/plain")?,
);
let post_resp = local_http_client()
.post(format!("{}/{}", env.url, bucket))
.multipart(post_form)
.send()
.await?;
let status = post_resp.status();
let response_body = post_resp.text().await?;
assert_eq!(status, reqwest::StatusCode::NO_CONTENT);
assert!(response_body.is_empty(), "204 response should not contain a body, got: {response_body}");
let head = admin_client.head_object().bucket(bucket).key(object_key).send().await?;
assert_eq!(head.expires_string(), Some(expires));
let get_out = admin_client.get_object().bucket(bucket).key(object_key).send().await?;
let uploaded = get_out.body.collect().await?.into_bytes();
assert_eq!(uploaded.as_ref(), expected_body.as_slice());
Ok(())
}
@@ -3123,6 +3440,64 @@ async fn test_anonymous_post_object_accepts_tagging_field_exact_policy_match()
Ok(())
}
#[tokio::test]
async fn test_anonymous_post_object_accepts_metadata_field_exact_policy_match()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "anon-post-policy-meta-exact-accept";
let object_key = "uploads/meta-exact-accept-object.txt";
let metadata_value = "alpha-demo";
let expected_body = b"post-policy-meta-exact-body".to_vec();
let admin_client = env.create_s3_client();
admin_client.create_bucket().bucket(bucket).send().await?;
allow_anonymous_put_object(&admin_client, bucket).await?;
let policy = encode_post_policy(vec![
serde_json::json!({ "bucket": bucket }),
serde_json::json!({ "key": object_key }),
serde_json::json!({ "x-amz-meta-project": metadata_value }),
serde_json::json!(["content-length-range", 0, 1024]),
]);
let post_form = reqwest::multipart::Form::new()
.text("key", object_key.to_string())
.text("policy", policy)
.text("x-amz-meta-project", metadata_value)
.part(
"file",
reqwest::multipart::Part::bytes(expected_body.clone())
.file_name("upload.txt")
.mime_str("text/plain")?,
);
let post_resp = local_http_client()
.post(format!("{}/{}", env.url, bucket))
.multipart(post_form)
.send()
.await?;
let status = post_resp.status();
let response_body = post_resp.text().await?;
assert_eq!(status, reqwest::StatusCode::NO_CONTENT);
assert!(response_body.is_empty(), "204 response should not contain a body, got: {response_body}");
let head = admin_client.head_object().bucket(bucket).key(object_key).send().await?;
let metadata = head.metadata().expect("head_object should expose uploaded metadata");
assert_eq!(metadata.get("project").map(String::as_str), Some(metadata_value));
let get_out = admin_client.get_object().bucket(bucket).key(object_key).send().await?;
let uploaded = get_out.body.collect().await?.into_bytes();
assert_eq!(uploaded.as_ref(), expected_body.as_slice());
Ok(())
}
#[tokio::test]
async fn test_anonymous_post_object_allows_x_ignore_fields_outside_policy_conditions()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
@@ -16,6 +16,7 @@ use crate::common::RustFSTestClusterEnvironment;
use aws_sdk_s3::Client;
use aws_sdk_s3::error::SdkError;
use bytes::Bytes;
use serial_test::serial;
use std::sync::Arc;
use tokio::sync::Barrier;
use tracing::{info, warn};
@@ -50,6 +51,7 @@ fn format_s3_error(err: SdkError<aws_sdk_s3::operation::put_object::PutObjectErr
}
#[tokio::test]
#[serial]
async fn test_concurrent_cluster_overwrites_do_not_fail_namespace_lock_quorum() -> TestResult {
crate::common::init_logging();
info!("Starting namespace lock quorum regression test with auto cluster");
@@ -126,6 +128,7 @@ async fn test_concurrent_cluster_overwrites_do_not_fail_namespace_lock_quorum()
/// `StorageError::other(...)` → `StorageError::Io(...)`, which fell through to
/// `S3ErrorCode::InternalError` (500) in the error mapping.
#[tokio::test]
#[serial]
async fn test_concurrent_put_same_key_never_returns_500() -> TestResult {
crate::common::init_logging();
info!("Starting concurrent PUT 500 regression test");
@@ -37,6 +37,7 @@ use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use aws_sdk_s3::primitives::ByteStream;
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::request_signature_v4::{SIGN_V4_ALGORITHM, get_scope, get_signature, get_signing_key};
use serial_test::serial;
use std::fmt::Write as _;
use time::macros::format_description;
use time::{Duration, OffsetDateTime};
@@ -182,6 +183,7 @@ async fn setup(env: &mut RustFSTestEnvironment) -> Result<(), Box<dyn std::error
/// this, every negative assertion below could pass for the wrong reason (a
/// broken signer that never produces a valid signature).
#[tokio::test]
#[serial]
async fn valid_header_sigv4_request_succeeds() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -212,6 +214,7 @@ async fn valid_header_sigv4_request_succeeds() -> Result<(), Box<dyn std::error:
/// (a) Tampering the `Signature=` component must be rejected with
/// SignatureDoesNotMatch / 403.
#[tokio::test]
#[serial]
async fn tampered_signature_returns_signature_does_not_match() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -251,6 +254,7 @@ async fn tampered_signature_returns_signature_does_not_match() -> Result<(), Box
/// (b) A valid AccessKeyId paired with the wrong secret key must be rejected
/// with SignatureDoesNotMatch / 403.
#[tokio::test]
#[serial]
async fn wrong_secret_key_returns_signature_does_not_match() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -275,6 +279,7 @@ async fn wrong_secret_key_returns_signature_does_not_match() -> Result<(), Box<d
/// signature itself is valid (it covers the *declared* hash), so the server is
/// forced to detect the payload/hash mismatch while streaming the body.
#[tokio::test]
#[serial]
async fn tampered_payload_is_rejected() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -315,6 +320,7 @@ async fn tampered_payload_is_rejected() -> Result<(), Box<dyn std::error::Error
/// x-amz-date both derive from the same skewed timestamp, so skew — not a
/// signature mismatch — is the failure.
#[tokio::test]
#[serial]
async fn skewed_date_returns_request_time_too_skewed() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -338,6 +344,7 @@ async fn skewed_date_returns_request_time_too_skewed() -> Result<(), Box<dyn std
/// structurally invalid SigV4 header that must be rejected before any
/// credential/service handling.
#[tokio::test]
#[serial]
async fn malformed_authorization_header_returns_clean_4xx() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -32,6 +32,7 @@
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use serial_test::serial;
use std::error::Error;
use tracing::info;
@@ -46,6 +47,7 @@ mod tests {
/// starts successfully with notification enabled and can serve S3 requests.
/// A full webhook delivery test is in notification_webhook_test.rs.
#[tokio::test]
#[serial]
async fn test_notification_enabled_server_starts_cleanly() -> TestResult {
init_logging();
info!("RT-01: notification enabled server starts cleanly");
@@ -90,6 +92,7 @@ mod tests {
/// 3. Restart server
/// 4. Verify notification config still exists
#[tokio::test]
#[serial]
async fn test_notification_config_survives_restart() -> TestResult {
init_logging();
info!("RT-02: notification config survives restart");
@@ -47,6 +47,7 @@ use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
use rustfs_utils::http::headers::{AMZ_REQUEST_ID, REQUEST_ID_HEADER};
use s3s::Body;
use serde_json::Value;
use serial_test::serial;
use std::error::Error;
use std::io::Cursor;
use std::path::Path;
@@ -624,6 +625,7 @@ fn assert_generated_request_id_correlation(record: &Value, request_id: &str) {
/// RUSTFS_NOTIFY_ENABLE, an HTTPS webhook using a configured CA must become
/// online and receive a real S3 event POST.
#[tokio::test]
#[serial]
async fn test_https_webhook_target_delivers_event_with_notify_env_enabled() -> TestResult {
init_logging();
@@ -678,6 +680,7 @@ async fn test_https_webhook_target_delivers_event_with_notify_env_enabled() -> T
/// PUT / multipart-complete / DELETE each deliver one event with correct fields,
/// and the prefix/suffix filter drops non-matching keys.
#[tokio::test]
#[serial]
async fn test_webhook_event_delivery_and_filtering() -> TestResult {
init_logging();
@@ -897,6 +900,7 @@ async fn test_webhook_event_delivery_and_filtering() -> TestResult {
/// An event queued while the target endpoint rejects delivery survives on the
/// durable store and is redelivered once the endpoint comes back.
#[tokio::test]
#[serial]
async fn test_webhook_redelivers_event_after_target_recovers() -> TestResult {
init_logging();
+17
View File
@@ -20,6 +20,7 @@ use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::{pre_sign_v4, sign_v4};
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
use s3s::Body;
use serial_test::serial;
use std::collections::HashMap;
use std::error::Error;
use time::OffsetDateTime;
@@ -547,6 +548,7 @@ async fn read_listen_notification_event(
}
#[tokio::test]
#[serial]
async fn test_notification_target_persists_across_restart_and_delete() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -606,6 +608,7 @@ async fn test_notification_target_persists_across_restart_and_delete() -> Result
}
#[tokio::test]
#[serial]
async fn test_notification_target_with_path_is_online_via_transport_probe() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -638,6 +641,7 @@ async fn test_notification_target_with_path_is_online_via_transport_probe() -> R
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_accepts_presigned_requests() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -678,6 +682,7 @@ async fn test_get_object_lambda_accepts_presigned_requests() -> Result<(), Box<d
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_accepts_named_webhook_target_arn() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -717,6 +722,7 @@ async fn test_get_object_lambda_accepts_named_webhook_target_arn() -> Result<(),
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_invokes_runtime_webhook_target() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -784,6 +790,7 @@ async fn test_get_object_lambda_invokes_runtime_webhook_target() -> Result<(), B
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_passthroughs_non_success_webhook_response() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -843,6 +850,7 @@ async fn test_get_object_lambda_passthroughs_non_success_webhook_response() -> R
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_rejects_success_response_without_auth_headers() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -888,6 +896,7 @@ async fn test_get_object_lambda_rejects_success_response_without_auth_headers()
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_rejects_success_response_with_mismatched_auth_headers() -> Result<(), Box<dyn Error + Send + Sync>>
{
init_logging();
@@ -934,6 +943,7 @@ async fn test_get_object_lambda_rejects_success_response_with_mismatched_auth_he
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_rejects_unsupported_target_type() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -970,6 +980,7 @@ async fn test_get_object_lambda_rejects_unsupported_target_type() -> Result<(),
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_rejects_unconfigured_target() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -1006,6 +1017,7 @@ async fn test_get_object_lambda_rejects_unconfigured_target() -> Result<(), Box<
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_rejects_disabled_target() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -1051,6 +1063,7 @@ async fn test_get_object_lambda_rejects_disabled_target() -> Result<(), Box<dyn
}
#[tokio::test]
#[serial]
async fn test_configure_object_lambda_target_rejects_invalid_endpoint() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -1093,6 +1106,7 @@ async fn test_configure_object_lambda_target_rejects_invalid_endpoint() -> Resul
}
#[tokio::test]
#[serial]
async fn test_configure_object_lambda_notify_webhook_rejects_response_header_timeout_key()
-> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -1126,6 +1140,7 @@ async fn test_configure_object_lambda_notify_webhook_rejects_response_header_tim
}
#[tokio::test]
#[serial]
async fn test_listen_notification_emits_after_put_object() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -1169,6 +1184,7 @@ async fn test_listen_notification_emits_after_put_object() -> Result<(), Box<dyn
}
#[tokio::test]
#[serial]
async fn test_listen_notification_emits_on_empty_bucket_when_notify_disabled() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -1203,6 +1219,7 @@ async fn test_listen_notification_emits_on_empty_bucket_when_notify_disabled() -
}
#[tokio::test]
#[serial]
async fn test_listen_notification_fans_in_remote_node_events() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -13,6 +13,7 @@
// limitations under the License.
use aws_sdk_s3::primitives::ByteStream;
use serial_test::serial;
use std::path::{Path, PathBuf};
use uuid::Uuid;
@@ -23,6 +24,7 @@ const TEST_OBJECT: &str = "large-object.bin";
const PAYLOAD_SIZE: usize = 512 * 1024;
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn unversioned_overwrite_removes_previous_physical_data_dir() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
@@ -35,6 +35,7 @@ impl TestCategory {}
#[derive(Debug, Clone)]
pub struct TestDefinition {
pub name: String,
#[allow(dead_code)]
pub category: TestCategory,
pub is_critical: bool,
}
@@ -43,6 +43,7 @@ use aws_sdk_s3::presigning::{PresignedRequest, PresigningConfig};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::{Client, Config};
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
use serial_test::serial;
use std::time::{Duration, SystemTime};
use tracing::info;
@@ -156,6 +157,7 @@ async fn setup(env: &mut RustFSTestEnvironment) -> Result<(), Box<dyn std::error
/// stored bytes. Without this, every negative assertion could pass for the
/// wrong reason (a server that rejects all presigned URLs).
#[tokio::test]
#[serial]
async fn valid_presigned_get_succeeds() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -180,6 +182,7 @@ async fn valid_presigned_get_succeeds() -> Result<(), Box<dyn std::error::Error
/// Positive control (PUT): a valid presigned PUT must store the object, which we
/// verify with a follow-up authenticated HEAD.
#[tokio::test]
#[serial]
async fn valid_presigned_put_succeeds() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -208,6 +211,7 @@ async fn valid_presigned_put_succeeds() -> Result<(), Box<dyn std::error::Error
/// ("Request has expired"). s3s checks expiry BEFORE the signature, so the
/// signature here is otherwise valid — only the elapsed window is at fault.
#[tokio::test]
#[serial]
async fn expired_presigned_get_is_rejected() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -232,6 +236,7 @@ async fn expired_presigned_get_is_rejected() -> Result<(), Box<dyn std::error::E
/// (b) Tampering the `X-Amz-Signature` query value must be rejected with 403 /
/// SignatureDoesNotMatch.
#[tokio::test]
#[serial]
async fn tampered_signature_returns_signature_does_not_match() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -257,6 +262,7 @@ async fn tampered_signature_returns_signature_does_not_match() -> Result<(), Box
/// (c) A presigned URL generated with the WRONG secret (but the real access key
/// id) must be rejected with 403 / SignatureDoesNotMatch.
#[tokio::test]
#[serial]
async fn wrong_secret_key_returns_signature_does_not_match() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -284,6 +290,7 @@ async fn wrong_secret_key_returns_signature_does_not_match() -> Result<(), Box<d
/// check runs during auth, before any object lookup, so the swapped key need
/// not even exist.
#[tokio::test]
#[serial]
async fn tampered_target_key_returns_signature_does_not_match() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -318,6 +325,7 @@ async fn tampered_target_key_returns_signature_does_not_match() -> Result<(), Bo
/// (e / acceptance 4 negative half) Tampering the signature of a presigned PUT
/// must be rejected with 403 / SignatureDoesNotMatch — the write must not land.
#[tokio::test]
#[serial]
async fn tampered_presigned_put_returns_signature_does_not_match() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -22,6 +22,7 @@ use crate::protocols::sftp_compliance::{
};
use crate::protocols::sftp_core::{test_sftp_core_operations, test_sftp_idle_timeout_disconnects};
use crate::protocols::webdav_core::test_webdav_core_operations;
use serial_test::serial;
use std::time::Instant;
use tokio::time::{Duration, sleep};
use tracing::{error, info};
@@ -228,6 +229,7 @@ fn all_protocol_tests() -> Vec<TestDefinition> {
/// Test suite
#[tokio::test]
#[serial]
async fn test_protocol_core_suite() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let suite = ProtocolTestSuite::new();
let results = suite.run_test_suite().await;
+15
View File
@@ -15,6 +15,7 @@
use crate::common::{RustFSTestEnvironment, admin_request, awscurl_delete, awscurl_get, awscurl_post, awscurl_put, init_logging};
use aws_sdk_s3::Client;
use http::{Method, StatusCode};
use serial_test::serial;
use tokio::time::{Duration, sleep, timeout};
use tracing::{debug, info};
@@ -254,6 +255,7 @@ mod integration_tests {
use aws_sdk_s3::error::ProvideErrorMetadata;
#[tokio::test]
#[serial]
async fn test_quota_basic_operations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
@@ -298,6 +300,7 @@ mod integration_tests {
/// with 400 UnexpectedContent, and an over-quota aws-chunked PUT must still get the quota
/// rejection.
#[tokio::test]
#[serial]
async fn test_quota_admission_aws_chunked_declared_encoding() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
@@ -349,6 +352,7 @@ mod integration_tests {
}
#[tokio::test]
#[serial]
async fn test_quota_update_and_clear() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
@@ -384,6 +388,7 @@ mod integration_tests {
}
#[tokio::test]
#[serial]
async fn test_quota_delete_operations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
@@ -420,6 +425,7 @@ mod integration_tests {
}
#[tokio::test]
#[serial]
async fn test_quota_usage_tracking() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
@@ -458,6 +464,7 @@ mod integration_tests {
}
#[tokio::test]
#[serial]
async fn test_quota_statistics() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
@@ -491,6 +498,7 @@ mod integration_tests {
}
#[tokio::test]
#[serial]
async fn test_quota_check_api() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
@@ -531,6 +539,7 @@ mod integration_tests {
}
#[tokio::test]
#[serial]
async fn test_quota_multiple_buckets() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
@@ -571,6 +580,7 @@ mod integration_tests {
}
#[tokio::test]
#[serial]
async fn test_quota_error_handling() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
@@ -606,6 +616,7 @@ mod integration_tests {
}
#[tokio::test]
#[serial]
async fn test_quota_http_endpoints() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
@@ -671,6 +682,7 @@ mod integration_tests {
/// Test that a normal user with `readwrite` policy can read quota but cannot set/clear quota.
#[tokio::test]
#[serial]
async fn test_quota_normal_user_permissions() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
@@ -726,6 +738,7 @@ mod integration_tests {
}
#[tokio::test]
#[serial]
async fn test_quota_copy_operations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
@@ -771,6 +784,7 @@ mod integration_tests {
}
#[tokio::test]
#[serial]
async fn test_quota_batch_delete() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
@@ -829,6 +843,7 @@ mod integration_tests {
}
#[tokio::test]
#[serial]
async fn test_quota_multipart_upload() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
@@ -6,6 +6,7 @@ use aws_sdk_s3::config::{Credentials, Region};
use aws_sdk_s3::error::SdkError;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
use bytes::Bytes;
use serial_test::serial;
use std::error::Error;
const ENDPOINT: &str = "http://localhost:9000";
@@ -88,6 +89,7 @@ fn generate_test_key(prefix: &str) -> String {
}
#[tokio::test]
#[serial]
#[ignore = "requires running RustFS server at localhost:9000"]
async fn test_conditional_put_okay() -> Result<(), Box<dyn std::error::Error>> {
let client = create_aws_s3_client().await?;
@@ -130,6 +132,7 @@ async fn test_conditional_put_okay() -> Result<(), Box<dyn std::error::Error>> {
}
#[tokio::test]
#[serial]
#[ignore = "requires running RustFS server at localhost:9000"]
async fn test_conditional_put_failed() -> Result<(), Box<dyn std::error::Error>> {
let client = create_aws_s3_client().await?;
@@ -192,6 +195,7 @@ async fn test_conditional_put_failed() -> Result<(), Box<dyn std::error::Error>>
}
#[tokio::test]
#[serial]
#[ignore = "requires running RustFS server at localhost:9000"]
async fn test_conditional_put_when_object_does_not_exist() -> Result<(), Box<dyn std::error::Error>> {
let client = create_aws_s3_client().await?;
@@ -236,6 +240,7 @@ async fn test_conditional_put_when_object_does_not_exist() -> Result<(), Box<dyn
}
#[tokio::test]
#[serial]
#[ignore = "requires running RustFS server at localhost:9000"]
async fn test_conditional_multi_part_upload() -> Result<(), Box<dyn std::error::Error>> {
let client = create_aws_s3_client().await?;
@@ -24,6 +24,7 @@ use aws_sdk_s3::Client;
use aws_sdk_s3::config::{Credentials, Region};
use aws_sdk_s3::error::SdkError;
use bytes::Bytes;
use serial_test::serial;
use std::error::Error;
use tracing::info;
@@ -69,6 +70,7 @@ async fn setup_test_bucket(client: &Client) -> Result<(), Box<dyn Error>> {
}
#[tokio::test]
#[serial]
#[ignore = "requires running RustFS server at localhost:9000"]
async fn test_get_deleted_object_returns_nosuchkey() -> Result<(), Box<dyn std::error::Error>> {
// Initialize logging
@@ -142,6 +144,7 @@ async fn test_get_deleted_object_returns_nosuchkey() -> Result<(), Box<dyn std::
/// Test that HeadObject on a deleted object also returns NoSuchKey
#[tokio::test]
#[serial]
#[ignore = "requires running RustFS server at localhost:9000"]
async fn test_head_deleted_object_returns_nosuchkey() -> Result<(), Box<dyn std::error::Error>> {
let _ = tracing_subscriber::fmt()
@@ -193,6 +196,7 @@ async fn test_head_deleted_object_returns_nosuchkey() -> Result<(), Box<dyn std:
/// Test GetObject with non-existent key (never existed)
#[tokio::test]
#[serial]
#[ignore = "requires running RustFS server at localhost:9000"]
async fn test_get_nonexistent_object_returns_nosuchkey() -> Result<(), Box<dyn std::error::Error>> {
let _ = tracing_subscriber::fmt()
@@ -229,6 +233,7 @@ async fn test_get_nonexistent_object_returns_nosuchkey() -> Result<(), Box<dyn s
/// Test multiple consecutive GetObject calls on deleted object
/// This ensures the fix is stable and doesn't have race conditions
#[tokio::test]
#[serial]
#[ignore = "requires running RustFS server at localhost:9000"]
async fn test_multiple_gets_deleted_object() -> Result<(), Box<dyn std::error::Error>> {
let _ = tracing_subscriber::fmt()
@@ -25,6 +25,7 @@ use aws_sdk_s3::config::{Credentials, Region};
use aws_sdk_s3::error::SdkError;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use bytes::Bytes;
use serial_test::serial;
use std::error::Error;
use tracing::info;
@@ -84,6 +85,7 @@ async fn setup_test_bucket(client: &Client) -> Result<(), Box<dyn Error>> {
/// Test that HeadObject on a deleted object returns NoSuchKey when versioning is enabled
#[tokio::test]
#[serial]
#[ignore = "requires running RustFS server at localhost:9000"]
async fn test_head_deleted_object_versioning_returns_nosuchkey() -> Result<(), Box<dyn std::error::Error>> {
let _ = tracing_subscriber::fmt()
@@ -30,6 +30,7 @@ use reqwest::{Certificate, Client, Response, StatusCode};
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4;
use s3s::Body;
use serial_test::serial;
use std::error::Error;
use std::path::Path;
use std::process::Command;
@@ -156,6 +157,7 @@ async fn start_tls_rustfs_server(env: &mut RustFSTestEnvironment, tls_dir: &Path
}
#[tokio::test]
#[serial]
async fn test_head_missing_object_over_tls_http2_is_bodyless() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
+8
View File
@@ -20,6 +20,7 @@ use aws_sdk_s3::types::{
CsvInput, CsvOutput, ExpressionType, FileHeaderInfo, InputSerialization, JsonInput, JsonOutput, JsonType, OutputSerialization,
};
use bytes::Bytes;
use serial_test::serial;
use std::error::Error;
const ENDPOINT: &str = "http://localhost:9000";
@@ -117,6 +118,7 @@ async fn process_select_response(
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
#[ignore = "requires running RustFS server at localhost:9000"]
async fn test_select_object_content_csv_basic() -> Result<(), Box<dyn Error>> {
let client = create_aws_s3_client().await?;
@@ -158,6 +160,7 @@ async fn test_select_object_content_csv_basic() -> Result<(), Box<dyn Error>> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
#[ignore = "requires running RustFS server at localhost:9000"]
async fn test_select_object_content_csv_aggregation() -> Result<(), Box<dyn Error>> {
let client = create_aws_s3_client().await?;
@@ -203,6 +206,7 @@ async fn test_select_object_content_csv_aggregation() -> Result<(), Box<dyn Erro
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
#[ignore = "requires running RustFS server at localhost:9000"]
async fn test_select_object_content_json_basic() -> Result<(), Box<dyn Error>> {
let client = create_aws_s3_client().await?;
@@ -244,6 +248,7 @@ async fn test_select_object_content_json_basic() -> Result<(), Box<dyn Error>> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
#[ignore = "requires running RustFS server at localhost:9000"]
async fn test_select_object_content_csv_limit() -> Result<(), Box<dyn Error>> {
let client = create_aws_s3_client().await?;
@@ -286,6 +291,7 @@ async fn test_select_object_content_csv_limit() -> Result<(), Box<dyn Error>> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
#[ignore = "requires running RustFS server at localhost:9000"]
async fn test_select_object_content_csv_order_by() -> Result<(), Box<dyn Error>> {
let client = create_aws_s3_client().await?;
@@ -331,6 +337,7 @@ async fn test_select_object_content_csv_order_by() -> Result<(), Box<dyn Error>>
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
#[ignore = "requires running RustFS server at localhost:9000"]
async fn test_select_object_content_error_handling() -> Result<(), Box<dyn Error>> {
let client = create_aws_s3_client().await?;
@@ -366,6 +373,7 @@ async fn test_select_object_content_error_handling() -> Result<(), Box<dyn Error
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
#[ignore = "requires running RustFS server at localhost:9000"]
async fn test_select_object_content_nonexistent_object() -> Result<(), Box<dyn Error>> {
let client = create_aws_s3_client().await?;
@@ -60,6 +60,7 @@ use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4;
use s3s::Body;
use s3s::header::X_AMZ_REPLICATION_STATUS;
use serial_test::serial;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::convert::Infallible;
@@ -4629,6 +4630,7 @@ async fn test_bucket_replication_sse_c_multipart_passthrough() -> TestResult {
/// fails without any PUT reaching the target (capability cache, proven from
/// the target journal); plaintext objects still replicate COMPLETED.
#[tokio::test]
#[serial]
async fn test_ssec_replication_fails_closed_when_target_drops_passthrough_headers() -> TestResult {
init_logging();
@@ -4738,6 +4740,7 @@ async fn test_ssec_replication_fails_closed_when_target_drops_passthrough_header
/// a capability limit, and a plaintext-only deployment against a MinIO-like
/// target must not turn red.
#[tokio::test]
#[serial]
async fn test_replication_check_flags_ssec_passthrough_dropping_target() -> TestResult {
init_logging();
@@ -4828,6 +4831,7 @@ async fn test_replication_check_flags_ssec_passthrough_dropping_target() -> Test
/// HEAD-back audit against the recovered RustFS target — and the replica must
/// be readable with the customer key.
#[tokio::test]
#[serial]
async fn test_bucket_replication_sse_c_heals_after_target_outage() -> TestResult {
init_logging();
@@ -4920,6 +4924,7 @@ async fn test_bucket_replication_sse_c_heals_after_target_outage() -> TestResult
/// the existing-object resync (`replicate_all` transport, N2-audited), land as
/// a REPLICA, and read back with the customer key.
#[tokio::test]
#[serial]
async fn test_bucket_replication_sse_c_existing_object_resync() -> TestResult {
init_logging();
@@ -8814,6 +8819,7 @@ async fn start_read_proxy_lab(
/// headers are forwarded verbatim, and an inbound request that was itself
/// proxied is answered locally (404) without touching the target.
#[tokio::test]
#[serial]
async fn test_get_and_head_proxy_unreplicated_object_to_replication_target() -> TestResult {
init_logging();
@@ -8996,6 +9002,7 @@ async fn test_get_and_head_proxy_unreplicated_object_to_replication_target() ->
/// proxied to the replication target with the anti-loop marker, mirroring
/// MinIO `proxyGetTaggingToRepTarget`.
#[tokio::test]
#[serial]
async fn test_get_object_tagging_proxies_unreplicated_object_to_replication_target() -> TestResult {
init_logging();
@@ -25,6 +25,7 @@ use crate::common::{RustFSTestEnvironment, awscurl_available, awscurl_put, init_
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart, Tag, Tagging};
use serial_test::serial;
use std::error::Error;
use tracing::info;
@@ -35,6 +36,7 @@ use tracing::info;
/// far beyond that limit and assert the server rejects it with the specific
/// error, rather than accepting an arbitrarily large control-plane body.
#[tokio::test]
#[serial]
async fn test_large_xml_body_rejection() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -90,6 +92,7 @@ async fn test_large_xml_body_rejection() -> Result<(), Box<dyn Error + Send + Sy
/// Excessive multipart parts must be rejected.
#[tokio::test]
#[serial]
async fn test_excessive_multipart_parts() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -146,6 +149,7 @@ async fn test_excessive_multipart_parts() -> Result<(), Box<dyn Error + Send + S
/// (last-writer-wins, no torn/garbage state) and that it is absent after a
/// subsequent delete.
#[tokio::test]
#[serial]
async fn test_concurrent_object_operations() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -228,6 +232,7 @@ async fn test_concurrent_object_operations() -> Result<(), Box<dyn Error + Send
/// pattern used by the other admin-API E2E tests in this crate; the test is
/// skipped when `awscurl` is not installed.
#[tokio::test]
#[serial]
async fn test_tiering_url_validation() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
if !awscurl_available() {
@@ -23,6 +23,7 @@
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path};
use serial_test::serial;
use std::net::TcpListener;
use std::time::{Duration, Instant};
@@ -30,6 +31,7 @@ mod tests {
/// while :9001 is occupied: the server exits at startup, and the harness
/// must surface that promptly rather than waiting out the 60s timeout.
#[tokio::test]
#[serial]
async fn test_start_fails_fast_when_server_exits_during_startup() {
init_logging();
@@ -17,6 +17,7 @@ mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use serial_test::serial;
use std::error::Error;
use std::io::Cursor;
@@ -100,6 +101,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn snowball_auto_extract_supports_minio_prefix_and_directory_markers() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -136,6 +138,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn snowball_auto_extract_supports_standard_headers_with_combined_extract_options()
-> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -226,6 +229,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn snowball_auto_extract_ignores_directories_when_requested() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -264,6 +268,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn snowball_auto_extract_ignores_invalid_entries_when_requested() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -299,6 +304,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn snowball_auto_extract_rejects_parent_dir_entry_without_cross_bucket_write()
-> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -341,6 +347,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn snowball_auto_extract_prefers_exact_minio_prefix_over_suffix_fallback() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
+15
View File
@@ -34,6 +34,7 @@ mod tests {
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4;
use s3s::Body;
use serial_test::serial;
use std::error::Error;
use tracing::{debug, info};
@@ -92,6 +93,7 @@ mod tests {
/// mc cp README.md "local/dummy/a%20f+/b/c/3/README.md"
/// ```
#[tokio::test]
#[serial]
async fn test_object_with_space_in_path() {
init_logging();
info!("Starting test: object with space in path");
@@ -173,6 +175,7 @@ mod tests {
/// /test/data/org_main-org/dashboards/ES+net/LHC+Data+Challenge/firefly-details.json
/// ```
#[tokio::test]
#[serial]
async fn test_object_with_plus_in_path() {
init_logging();
info!("Starting test: object with plus sign in path");
@@ -242,6 +245,7 @@ mod tests {
/// Test with mixed special characters
#[tokio::test]
#[serial]
async fn test_object_with_mixed_special_chars() {
init_logging();
info!("Starting test: object with mixed special characters");
@@ -301,6 +305,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_signed_get_missing_object_with_trailing_equals_returns_no_such_key() -> Result<(), Box<dyn Error + Send + Sync>>
{
init_logging();
@@ -329,6 +334,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_signed_get_existing_object_with_trailing_equals_returns_content() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -367,6 +373,7 @@ mod tests {
/// Test DELETE operation with special characters
#[tokio::test]
#[serial]
async fn test_delete_object_with_special_chars() {
init_logging();
info!("Starting test: DELETE object with special characters");
@@ -414,6 +421,7 @@ mod tests {
/// Test exact scenario from the issue
#[tokio::test]
#[serial]
async fn test_issue_scenario_exact() {
init_logging();
info!("Starting test: Exact scenario from GitHub issue");
@@ -486,6 +494,7 @@ mod tests {
/// Test HEAD object with special characters
#[tokio::test]
#[serial]
async fn test_head_object_with_special_chars() {
init_logging();
info!("Starting test: HEAD object with special characters");
@@ -529,6 +538,7 @@ mod tests {
/// Test COPY object with special characters in both source and destination
#[tokio::test]
#[serial]
async fn test_copy_object_with_special_chars() {
init_logging();
info!("Starting test: COPY object with special characters");
@@ -587,6 +597,7 @@ mod tests {
/// Test Unicode characters in object keys
#[tokio::test]
#[serial]
async fn test_unicode_characters_in_path() {
init_logging();
info!("Starting test: Unicode characters in object paths");
@@ -650,6 +661,7 @@ mod tests {
/// Test special characters in different parts of the path
#[tokio::test]
#[serial]
async fn test_special_chars_in_different_path_positions() {
init_logging();
info!("Starting test: Special characters in different path positions");
@@ -707,6 +719,7 @@ mod tests {
/// Test that control characters are properly rejected
#[tokio::test]
#[serial]
async fn test_control_characters_rejected() {
init_logging();
info!("Starting test: Control characters should be rejected");
@@ -756,6 +769,7 @@ mod tests {
/// Test LIST with various special character prefixes
#[tokio::test]
#[serial]
async fn test_list_with_special_char_prefixes() {
init_logging();
info!("Starting test: LIST with special character prefixes");
@@ -824,6 +838,7 @@ mod tests {
/// Test delimiter-based listing with special characters
#[tokio::test]
#[serial]
async fn test_list_with_delimiter_and_special_chars() {
init_logging();
info!("Starting test: LIST with delimiter and special characters");
@@ -16,6 +16,7 @@ use crate::common::{RustFSTestClusterEnvironment, init_logging};
use aws_sdk_s3::error::SdkError;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::CompletedMultipartUpload;
use serial_test::serial;
use tokio::time::{Duration, sleep};
use tracing::info;
use uuid::Uuid;
@@ -100,6 +101,7 @@ async fn wait_for_cleanup_on_all_nodes(
}
#[tokio::test]
#[serial]
async fn test_stale_multipart_cleanup_removes_incomplete_upload_across_cluster()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
@@ -25,6 +25,7 @@ use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper_util::rt::TokioIo;
use serde_json::Value;
use serial_test::serial;
use std::collections::BTreeSet;
use std::convert::Infallible;
use std::error::Error;
@@ -349,6 +350,7 @@ impl Drop for OpaMock {
}
#[tokio::test]
#[serial]
async fn test_sts_query_responses_are_aws_sdk_compatible() -> TestResult {
init_logging();
@@ -485,6 +487,7 @@ async fn test_sts_query_responses_are_aws_sdk_compatible() -> TestResult {
}
#[tokio::test]
#[serial]
async fn test_sts_assume_role_opa_contract() -> TestResult {
init_logging();
@@ -556,6 +559,7 @@ async fn test_sts_assume_role_opa_contract() -> TestResult {
}
#[tokio::test]
#[serial]
async fn test_list_buckets_opa_contract() -> TestResult {
init_logging();
@@ -641,6 +645,7 @@ async fn test_list_buckets_opa_contract() -> TestResult {
}
#[tokio::test]
#[serial]
async fn test_sts_and_list_buckets_fail_closed_while_opa_is_initializing() -> TestResult {
init_logging();
@@ -657,6 +662,7 @@ async fn test_sts_and_list_buckets_fail_closed_while_opa_is_initializing() -> Te
}
#[tokio::test]
#[serial]
async fn test_sts_and_list_buckets_fail_closed_after_opa_validation_failure() -> TestResult {
init_logging();
@@ -673,6 +679,7 @@ async fn test_sts_and_list_buckets_fail_closed_after_opa_validation_failure() ->
}
#[tokio::test]
#[serial]
async fn test_sts_query_rate_limit_error_is_aws_sdk_compatible() -> TestResult {
init_logging();
@@ -30,6 +30,7 @@
mod tests {
use crate::common::{RustFSTestEnvironment, admin_ok, init_logging};
use serde_json::Value;
use serial_test::serial;
use std::error::Error;
use tracing::info;
@@ -41,6 +42,7 @@ mod tests {
/// validates that an expiration-only rule (the persistence path) survives
/// a server restart.
#[tokio::test]
#[serial]
async fn test_lifecycle_rule_persists_after_restart() -> TestResult {
init_logging();
info!("RT-13: lifecycle rule persists after restart");
@@ -103,6 +105,7 @@ mod tests {
/// Regression pattern: tier add/verify/delete API fails or the tier
/// configuration is not persisted (rustfs#5218).
#[tokio::test]
#[serial]
async fn test_admin_tier_list_endpoint_returns_json() -> TestResult {
init_logging();
info!("RT-13b: admin tier list endpoint returns JSON");
@@ -132,6 +135,7 @@ mod tests {
/// is not persisted (rustfs#5013), causing the scanner to not run or
/// use stale settings.
#[tokio::test]
#[serial]
async fn test_scanner_config_persists_after_restart() -> TestResult {
init_logging();
info!("RT-13c: scanner config persists after restart");
@@ -34,6 +34,7 @@ use rcgen::generate_simple_self_signed;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{ClientConfig, ClientConnection, DigitallySignedStruct, Error as RustlsError, SignatureScheme, StreamOwned};
use serial_test::serial;
use sha2::{Digest, Sha256};
use std::error::Error;
use std::io::{Read, Write};
@@ -241,6 +242,7 @@ async fn roundtrip_and_return(mut session: TlsSession) -> Result<TlsSession, Box
}
#[tokio::test]
#[serial]
async fn test_tls_certificate_hot_reload_live_listener() -> TestResult {
init_logging();
// Install the process-wide rustls crypto provider (idempotent).
@@ -27,6 +27,7 @@ mod tests {
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, VersioningConfiguration};
use serial_test::serial;
use tracing::info;
fn create_s3_client(env: &RustFSTestEnvironment) -> Client {
@@ -85,6 +86,7 @@ mod tests {
/// Test 1: PutObject should return version_id when versioning is enabled
/// This directly addresses the Veeam issue from #1066
#[tokio::test]
#[serial]
async fn test_put_object_returns_version_id_with_versioning() {
init_logging();
info!("🧪 TEST: PutObject returns version_id with versioning enabled");
@@ -128,6 +130,7 @@ mod tests {
/// Test 2: CopyObject should return version_id when versioning is enabled
#[tokio::test]
#[serial]
async fn test_copy_object_returns_version_id_with_versioning() {
init_logging();
info!("🧪 TEST: CopyObject returns version_id with versioning enabled");
@@ -182,6 +185,7 @@ mod tests {
/// Test 3: CompleteMultipartUpload should return version_id when versioning is enabled
#[tokio::test]
#[serial]
async fn test_multipart_upload_returns_version_id_with_versioning() {
init_logging();
info!("🧪 TEST: CompleteMultipartUpload returns version_id with versioning enabled");
@@ -256,6 +260,7 @@ mod tests {
/// Test 4: PutObject should NOT return version_id when versioning is NOT enabled
/// This ensures we didn't break non-versioned buckets
#[tokio::test]
#[serial]
async fn test_put_object_without_versioning() {
init_logging();
info!("🧪 TEST: PutObject behavior without versioning (no regression)");
@@ -291,6 +296,7 @@ mod tests {
/// Test 5: Basic S3 operations still work correctly (no regression)
#[tokio::test]
#[serial]
async fn test_basic_s3_operations_no_regression() {
init_logging();
info!("🧪 TEST: Basic S3 operations work correctly (no regression)");
@@ -357,6 +363,7 @@ mod tests {
/// Test 6: Veeam-specific scenario simulation
/// Simulates the exact workflow that Veeam uses when backing up data
#[tokio::test]
#[serial]
async fn test_veeam_backup_workflow_simulation() {
init_logging();
info!("🧪 TEST: Veeam VBR backup workflow simulation (Issue #1066)");
@@ -406,6 +413,7 @@ mod tests {
}
#[tokio::test]
#[serial]
async fn test_terraform_put_after_delete() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
@@ -448,6 +456,7 @@ mod tests {
/// Test 7: PutObject should omit version_id when versioning is Suspended
#[tokio::test]
#[serial]
async fn test_put_object_omits_version_id_with_suspended_versioning() {
init_logging();
info!("🧪 TEST: PutObject omits version_id with versioning suspended");
@@ -491,6 +500,7 @@ mod tests {
/// Test 8: CopyObject should omit version_id when versioning is Suspended
#[tokio::test]
#[serial]
async fn test_copy_object_omits_version_id_with_suspended_versioning() {
init_logging();
info!("🧪 TEST: CopyObject omits version_id with versioning suspended");
@@ -541,6 +551,7 @@ mod tests {
/// Test 9: CompleteMultipartUpload should omit version_id when versioning is Suspended
#[tokio::test]
#[serial]
async fn test_multipart_upload_omits_version_id_with_suspended_versioning() {
init_logging();
info!("🧪 TEST: CompleteMultipartUpload omits version_id with versioning suspended");
+69 -234
View File
@@ -19,7 +19,7 @@ pub mod local_snapshot;
use crate::storage_api_contracts::{
bucket::{BucketOperations as _, BucketOptions},
list::{ListOperations as _, StorageListObjectVersionsInfo},
object::{EcstoreObjectIO, HTTPPreconditions, ObjectOperations as _},
object::{EcstoreObjectIO, HTTPPreconditions, ObjectIO as _, ObjectOperations as _},
};
use crate::{
bucket::{metadata_sys::get_replication_config, versioning::VersioningApi as _, versioning_sys::BucketVersioningSys},
@@ -2009,102 +2009,80 @@ pub async fn apply_bucket_usage_memory_overlay(data_usage_info: &mut DataUsageIn
}
// Helper functions for DataUsageCache operations
/// How many times `load_data_usage_cache` tries a read that failed for a
/// transient reason before giving up.
const DATA_USAGE_CACHE_LOAD_ATTEMPTS: usize = 5;
const DATA_USAGE_CACHE_LOAD_BASE_DELAY: std::time::Duration = std::time::Duration::from_millis(100);
const DATA_USAGE_CACHE_LOAD_MAX_DELAY: std::time::Duration = std::time::Duration::from_millis(1_000);
/// Result of one attempt at reading a data-usage cache object.
enum DataUsageCacheRead {
Loaded(DataUsageCache),
/// The object is definitively not there, so retrying cannot turn the read
/// into a hit.
Absent,
}
/// True when the error means the cache object does not exist, as opposed to a
/// transient failure that is worth another attempt.
///
/// `SetDisks::get_object_reader` runs its failures through `to_object_err`,
/// which rewrites `FileNotFound` to `ObjectNotFound` and `VolumeNotFound` to
/// `BucketNotFound`, so those are the variants that actually arrive here. The
/// raw pair is matched too because callers reading through a different layer
/// can still surface it.
fn is_data_usage_cache_absent(err: &Error) -> bool {
matches!(
err,
Error::FileNotFound | Error::VolumeNotFound | Error::ObjectNotFound(..) | Error::BucketNotFound(..)
)
}
async fn read_data_usage_cache_object<S>(store: &S, key: &str) -> crate::error::Result<DataUsageCacheRead>
where
S: EcstoreObjectIO,
{
use crate::disk::RUSTFS_META_BUCKET;
pub async fn load_data_usage_cache(store: &crate::set_disk::SetDisks, name: &str) -> crate::error::Result<DataUsageCache> {
use crate::disk::{BUCKET_META_PREFIX, RUSTFS_META_BUCKET};
use crate::object_api::ObjectOptions;
use http::HeaderMap;
match store
.get_object_reader(
RUSTFS_META_BUCKET,
key,
None,
HeaderMap::new(),
&ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await
{
// A cache object that fails to decode is treated as absent rather than
// as an error: a corrupt cache should not stall the caller, and the
// next scanner pass rewrites it.
Ok(mut reader) => Ok(DataUsageCache::unmarshal(&reader.read_all().await?)
.map(DataUsageCacheRead::Loaded)
.unwrap_or(DataUsageCacheRead::Absent)),
Err(err) if is_data_usage_cache_absent(&err) => Ok(DataUsageCacheRead::Absent),
Err(err) => Err(err),
}
}
/// Load a data-usage cache, preferring the prefixed key and falling back to the
/// legacy unprefixed one.
///
/// A cache that is absent under both keys yields an empty cache; a transient
/// read failure is retried with capped, jittered backoff and surfaces as an
/// error once the attempts are exhausted.
pub(crate) async fn load_data_usage_cache<S>(store: &S, name: &str) -> crate::error::Result<DataUsageCache>
where
S: EcstoreObjectIO,
{
use crate::disk::BUCKET_META_PREFIX;
use rand::RngExt;
use std::path::Path;
use std::time::Duration;
use tokio::time::sleep;
let prefixed = Path::new(BUCKET_META_PREFIX).join(name);
let prefixed = prefixed
.to_str()
.ok_or_else(|| Error::other("data usage cache path is not valid UTF-8"))?
.to_owned();
rustfs_utils::retry::retry_with_backoff(
|| async {
match read_data_usage_cache_object(store, &prefixed).await? {
DataUsageCacheRead::Loaded(cache) => Ok(cache),
DataUsageCacheRead::Absent => match read_data_usage_cache_object(store, name).await? {
DataUsageCacheRead::Loaded(cache) => Ok(cache),
DataUsageCacheRead::Absent => Ok(DataUsageCache::default()),
let mut d = DataUsageCache::default();
let mut retries = 0;
while retries < 5 {
let path = Path::new(BUCKET_META_PREFIX).join(name);
match store
.get_object_reader(
RUSTFS_META_BUCKET,
path.to_str().unwrap(),
None,
HeaderMap::new(),
&ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await
{
Ok(mut reader) => {
if let Ok(info) = DataUsageCache::unmarshal(&reader.read_all().await?) {
d = info
}
break;
}
},
DATA_USAGE_CACHE_LOAD_ATTEMPTS,
DATA_USAGE_CACHE_LOAD_BASE_DELAY,
DATA_USAGE_CACHE_LOAD_MAX_DELAY,
)
.await
Err(err) => match err {
Error::FileNotFound | Error::VolumeNotFound => {
match store
.get_object_reader(
RUSTFS_META_BUCKET,
name,
None,
HeaderMap::new(),
&ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await
{
Ok(mut reader) => {
if let Ok(info) = DataUsageCache::unmarshal(&reader.read_all().await?) {
d = info
}
break;
}
Err(_) => match err {
Error::FileNotFound | Error::VolumeNotFound => {
break;
}
_ => {}
},
}
}
_ => {
break;
}
},
}
retries += 1;
let dur = {
let mut rng = rand::rng();
rng.random_range(0..1_000)
};
sleep(Duration::from_millis(dur)).await;
}
Ok(d)
}
/// Persist the current in-memory compression total to the backend.
@@ -2242,7 +2220,6 @@ pub async fn init_compression_total_memory_from_backend(store: Arc<ECStore>) {
#[cfg(test)]
mod tests {
use super::*;
use crate::storage_api_contracts::object::ObjectIO as _;
use rustfs_data_usage::BucketUsageInfo;
use rustfs_lock::{LocalClient, LockRequest, LockType, NamespaceLock, ObjectKey};
use serial_test::serial;
@@ -2475,148 +2452,6 @@ mod tests {
}
}
/// Minimal ObjectIO backing `load_data_usage_cache` tests: records the keys
/// read and fails the first N reads with a transient (non-absence) error.
#[derive(Debug, Default)]
struct UsageCacheReadStore {
transient_failures: Mutex<usize>,
reads: Mutex<Vec<String>>,
}
impl UsageCacheReadStore {
fn failing_first(n: usize) -> Self {
Self {
transient_failures: Mutex::new(n),
reads: Mutex::new(Vec::new()),
}
}
async fn read_keys(&self) -> Vec<String> {
self.reads.lock().await.clone()
}
}
#[async_trait::async_trait]
impl crate::storage_api_contracts::object::ObjectIO for UsageCacheReadStore {
type Error = Error;
type RangeSpec = crate::storage_api_contracts::range::HTTPRangeSpec;
type HeaderMap = http::HeaderMap;
type ObjectOptions = ObjectOptions;
type ObjectInfo = ObjectInfo;
type GetObjectReader = crate::object_api::GetObjectReader;
type PutObjectReader = PutObjReader;
async fn get_object_reader(
&self,
_bucket: &str,
object: &str,
_range: Option<Self::RangeSpec>,
_h: Self::HeaderMap,
_opts: &Self::ObjectOptions,
) -> Result<Self::GetObjectReader, Self::Error> {
self.reads.lock().await.push(object.to_string());
let mut remaining = self.transient_failures.lock().await;
if *remaining > 0 {
*remaining -= 1;
return Err(Error::other("transient read failure"));
}
// `SetDisks::get_object_reader` reports a missing object through
// `to_object_err`, so the absence that reaches the caller is
// `ObjectNotFound`, not the raw `FileNotFound`.
Err(Error::ObjectNotFound(RUSTFS_META_BUCKET.to_string(), object.to_string()))
}
async fn put_object(
&self,
_bucket: &str,
_object: &str,
_data: &mut Self::PutObjectReader,
_opts: &Self::ObjectOptions,
) -> Result<Self::ObjectInfo, Self::Error> {
unimplemented!("load_data_usage_cache never writes")
}
}
fn prefixed_usage_key(name: &str) -> String {
std::path::Path::new(crate::disk::BUCKET_META_PREFIX)
.join(name)
.to_str()
.expect("utf-8 path")
.to_string()
}
#[test]
fn data_usage_cache_absence_covers_the_variants_that_actually_arrive() {
// `to_object_err` rewrites the raw storage variants before they reach
// `load_data_usage_cache`; classifying only the raw pair would treat a
// missing cache as a transient failure and retry it.
assert!(is_data_usage_cache_absent(&Error::ObjectNotFound(
"bucket".to_string(),
"object".to_string()
)));
assert!(is_data_usage_cache_absent(&Error::BucketNotFound("bucket".to_string())));
assert!(is_data_usage_cache_absent(&Error::FileNotFound));
assert!(is_data_usage_cache_absent(&Error::VolumeNotFound));
assert!(!is_data_usage_cache_absent(&Error::other("transient read failure")));
assert!(!is_data_usage_cache_absent(&Error::DiskNotFound));
}
#[tokio::test]
async fn load_data_usage_cache_treats_absence_as_an_empty_cache_without_retrying() {
let name = "usage-cache";
let store = UsageCacheReadStore::default();
let cache = load_data_usage_cache(&store, name).await.expect("absence is not an error");
assert!(cache.cache.is_empty());
assert_eq!(
store.read_keys().await,
vec![prefixed_usage_key(name), name.to_string()],
"the prefixed key is tried first, then the legacy one, and neither absence is retried"
);
}
#[tokio::test]
async fn load_data_usage_cache_retries_a_transient_failure() {
let name = "usage-cache";
// Two transient failures, then the object reads as absent.
let store = UsageCacheReadStore::failing_first(2);
let cache = load_data_usage_cache(&store, name)
.await
.expect("retry should reach the absent read");
assert!(cache.cache.is_empty());
assert_eq!(
store.read_keys().await,
vec![
prefixed_usage_key(name),
prefixed_usage_key(name),
prefixed_usage_key(name),
name.to_string(),
],
"a transient failure retries the prefixed read rather than falling through"
);
}
#[tokio::test]
async fn load_data_usage_cache_surfaces_a_persistent_failure() {
let name = "usage-cache";
let store = UsageCacheReadStore::failing_first(usize::MAX);
let err = load_data_usage_cache(&store, name)
.await
.expect_err("an exhausted retry must not be reported as an empty cache");
assert!(err.to_string().contains("transient read failure"));
assert_eq!(
store.read_keys().await.len(),
DATA_USAGE_CACHE_LOAD_ATTEMPTS,
"every attempt is used before giving up"
);
}
async fn clear_usage_memory_cache_for_test() {
memory_cache().write().await.clear();
*cache_updating().write().await = false;
@@ -679,7 +679,7 @@ async fn get_pools_info(all_disks: &[Disk]) -> Result<HashMap<i32, HashMap<i32,
if erasure_set.id == 0 {
erasure_set.id = d.set_index;
match load_data_usage_cache(
store.pools[d.pool_index as usize].disk_set[d.set_index as usize].as_ref(),
&store.pools[d.pool_index as usize].disk_set[d.set_index as usize].clone(),
DATA_USAGE_CACHE_NAME,
)
.await
+4 -23
View File
@@ -853,32 +853,13 @@ mod tests {
#[test]
fn test_error_conversions() {
// A plain io::Error carries no typed payload to recover, so it lands in
// `Io` rather than being guessed at from its kind — `NotFound` here must
// not silently become `FileNotFound`, which quorum aggregation counts as
// a different error (rustfs/backlog#1836).
// Test From implementations
let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "test");
let disk_error: DiskError = io_error.into();
match &disk_error {
DiskError::Io(inner) => assert_eq!(inner.kind(), std::io::ErrorKind::NotFound),
other => panic!("a plain io::Error must stay typed as Io, got {other:?}"),
}
let _disk_error: DiskError = io_error.into();
// A typed DiskError boxed through io::Error round-trips back to itself
// instead of degrading to `Io`.
let boxed: std::io::Error = std::io::Error::other(DiskError::VolumeNotFound);
assert_eq!(DiskError::from(boxed), DiskError::VolumeNotFound);
// serde_json errors have no dedicated variant and fold into `other`,
// keeping the original message.
let json_str = r#"{"invalid": json}"#;
let json_str = r#"{"invalid": json}"#; // Invalid JSON
let json_error = serde_json::from_str::<serde_json::Value>(json_str).unwrap_err();
let json_message = json_error.to_string();
let disk_error: DiskError = json_error.into();
assert!(
disk_error.to_string().contains(&json_message),
"the json error message must survive the conversion: {disk_error}"
);
let _disk_error: DiskError = json_error.into();
}
#[test]

Some files were not shown because too many files have changed in this diff Show More