Compare commits

...

10 Commits

Author SHA1 Message Date
overtrue ccded18c9b ci: raise the test-count baseline to the merged integration tree 2026-10-08 21:36:45 +08:00
overtrue 4e28cafe02 Merge remote-tracking branch 'origin/gateway/integration' into gw/T0.3-test-count-ratchet 2026-10-08 21:36:23 +08:00
Chris 484f1254ca test(ecstore): pin the persisted bucket-config XML bytes for 13 families (#8408)
Before any DTO or codec is replaced, pin the exact bytes that
deserialize followed by serialize produces for every persisted
bucket-configuration XML family in its three source shapes (AWS
documentation, MinIO-written, RustFS-written), and the bytes RustFS
writes today for a typed value. Byte equality only: a structural
comparison would let a codec replacement change the persisted encoding
unnoticed.

The generator script refuses to overwrite existing goldens without
--force, refuses on dirty codec paths, cannot report success from its
write mode, and records the generating commit in the fixtures README.

Refs rustfs/backlog#2744, rustfs/backlog#2734
2026-10-08 21:25:56 +08:00
Chris c9c16efbb6 ci: add the per-crate test-count ratchet (#8404)
Quick Checks now fails any PR that lowers the number of test attributes
in a workspace crate or adds #[ignore], measured per crate against the
exact rows of .config/test-count-baseline.txt (rustfs/backlog#2734
section 1 rule 3). The counter widens the spec's literal
#[(tokio::)?test] to the tokio attributes that carry arguments and both
spellings of #[test_case(...)], and anchors at line start so
commented-out attributes do not count. The initial baseline covers 52
crates, 21077 tests and 164 ignored.

Refs rustfs/backlog#2738
Refs rustfs/backlog#2734
2026-10-08 21:25:42 +08:00
Chris f2a59073ca feat(s3-types): add the RustFS-owned S3 error type and s3_error! macro (#8406)
`rustfs-s3-types` now provides `S3ErrorCode` (generated by build.rs from
src/codes.txt, the grep-derived call surface, plus `Custom`), `S3Error`,
`S3Result`, `StdError` and the `s3_error` macro with the same call syntax
as s3s, so the T1.2 codemod is a `use` swap. Statuses are hand-written and
pinned to the s3s behaviour RustFS ships today by an oracle test that uses
s3s as a dev-dependency only. The `From` conversions for the legacy edge
live behind the off-by-default `compat-s3s` feature (the orphan rule
forbids them in rustfs/src/error.rs); `rustfs` enables it.

Refs rustfs/backlog#2742, rustfs/backlog#2734
2026-10-08 04:11:00 -07:00
Chris c1af94d1d1 refactor(server): group the s3s-compat tower layers under legacy_compat (#8410)
Move the ten tower layers that patch s3s behaviour out of
rustfs/src/server/layer.rs into rustfs/src/server/legacy_compat/, one file
per layer with its tests. Each file header names the s3s behaviour it
patches, the gateway mechanism that replaces it (Replaced by:), and the task
that removes it (Removed by: T2.12, rustfs/backlog#2771). The layers keep
their code, their order in external_service_stack!, and their behaviour; no
layer is deleted.

Add server::http::tests::stack_census, which renders the ordered service
chain of the real external_service_stack! expansion and compares it with a
committed constant, so a reorder, insertion or removal is a visible diff.

Route the two s3s error types the moved layers use through a
storage_api::server::legacy_compat shim so the split adds no s3s-importing
file, and lower the s3s footprint baselines to the measured values (files 209
to 208, s3_error! lines 1586 to 1585). Keep the server runtime-source bypass
guard covering the moved code.

Refs rustfs/backlog#2787, rustfs/backlog#2734
2026-10-08 02:57:47 -07:00
Chris f84bbfe28f feat(server): add the RUSTFS_S3_STACK selector and dual-stack e2e lane (#8411) 2026-10-07 21:01:39 -07:00
rustfs-cla-assistant[bot] 378a2ff7f8 Merge remote-tracking branch 'origin/main' into gateway/integration 2026-10-08 02:18:57 +00:00
rustfs-cla-assistant[bot] b88b53aa6a Merge remote-tracking branch 'origin/main' into gateway/integration 2026-10-07 21:18:17 +00:00
overtrue 1302ca0893 ci: add the per-crate test-count ratchet
Quick Checks now fails any PR that lowers the number of test attributes
in a workspace crate or adds #[ignore], measured per crate against the
exact rows of .config/test-count-baseline.txt (rustfs/backlog#2734
section 1 rule 3). The counter widens the spec's literal
#[(tokio::)?test] to the tokio attributes that carry arguments and both
spellings of #[test_case(...)], and anchors at line start so
commented-out attributes do not count. The initial baseline covers 52
crates, 21077 tests and 164 ignored.

Refs rustfs/backlog#2738
Refs rustfs/backlog#2734
2026-10-08 01:05:23 +08:00
123 changed files with 6740 additions and 2962 deletions
+5
View File
@@ -70,6 +70,11 @@ s3s-footprint-check: ## Check the s3s dependency footprint ratchet stays frozen
@echo "📦 Checking s3s footprint ratchet..."
./scripts/check_s3s_footprint.sh
.PHONY: test-count-ratchet-check
test-count-ratchet-check: ## Check no workspace crate lost tests or gained #[ignore] against the committed baseline
@echo "🧪 Checking per-crate test-count ratchet..."
./scripts/check_test_count_ratchet.sh
.PHONY: fips-wording-check
fips-wording-check: ## Check docs and crates/kms do not over-claim crypto capabilities
@echo "📣 Checking cryptographic capability wording guard..."
+2 -2
View File
@@ -20,11 +20,11 @@ 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 error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-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 error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check test-count-ratchet-check fips-wording-check embedded-secrets-check test-wiring-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 error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check log-analyzer-rules-check offline-enrollment-e2e-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 error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check test-count-ratchet-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check log-analyzer-rules-check offline-enrollment-e2e-check clippy-check test ## Run full pre-PR checks with clippy and tests
@echo "✅ All pre-PR checks passed!"
.PHONY: dev-check
+63
View File
@@ -0,0 +1,63 @@
# Per-crate test-count baseline consumed by scripts/check_test_count_ratchet.sh
# (rustfs/backlog#2734 section 1 rule 3; T0.3 in rustfs/backlog#2738).
#
# One row per workspace member: <package>, tests, ignored, tab-separated.
# tests counts #[test], #[tokio::test], #[tokio::test(...)] and #[test_case(...)]
# attribute lines under the package directory; ignored counts #[ignore] and
# #[ignore = "..."]. Both columns are exact: CI fails when a crate's tests fall
# below or rise above its row, or when its ignored count changes. Raise a row in
# the PR that adds tests; lowering one is a reviewed statement that tests were
# intentionally removed. Regenerate with:
# scripts/check_test_count_ratchet.sh --update
e2e_test 870 18
rustfs 5490 62
rustfs-audit 70 0
rustfs-checksums 26 0
rustfs-common 23 0
rustfs-concurrency 27 0
rustfs-config 43 0
rustfs-connect-symbol-catalog 6 0
rustfs-credentials 24 0
rustfs-crypto 67 0
rustfs-data-usage 54 0
rustfs-ecstore 6220 1
rustfs-extension-schema 23 0
rustfs-filemeta 306 0
rustfs-heal 836 3
rustfs-heal-contracts 6 0
rustfs-iam 357 0
rustfs-io-core 49 0
rustfs-io-metrics 123 0
rustfs-keystone 29 0
rustfs-kms 715 25
rustfs-license 2 0
rustfs-lifecycle 152 0
rustfs-lock 135 0
rustfs-log-analyzer 87 0
rustfs-madmin 209 0
rustfs-notify 136 0
rustfs-object-capacity 92 0
rustfs-object-data-cache 140 0
rustfs-obs 362 0
rustfs-policy 663 0
rustfs-protocols 733 1
rustfs-protos 52 0
rustfs-replication 200 0
rustfs-rio 198 0
rustfs-rio-v2 34 6
rustfs-s3-client 65 0
rustfs-s3-ops 8 0
rustfs-s3-types 68 0
rustfs-s3select-api 206 0
rustfs-s3select-query 188 2
rustfs-scanner 971 20
rustfs-scanner-metrics 75 0
rustfs-security-governance 20 0
rustfs-signer 30 0
rustfs-storage-api 31 0
rustfs-targets 603 26
rustfs-test-utils 1 0
rustfs-tls-runtime 20 0
rustfs-trusted-proxies 68 0
rustfs-utils 216 0
rustfs-zip 30 0
+4
View File
@@ -86,6 +86,10 @@ runs:
shell: bash
run: ./scripts/check_s3s_footprint.sh
- name: Check per-crate test-count ratchet
shell: bash
run: ./scripts/check_test_count_ratchet.sh
- name: Check cryptographic capability wording
shell: bash
run: ./scripts/check_fips_wording.sh
+96 -5
View File
@@ -16,7 +16,9 @@ name: Continuous Integration
on:
push:
branches: [ main ]
# gateway/integration receives the daily merge of main as a direct push
# (rustfs/backlog#2734), so its pushes must prove the result like main's.
branches: [ main, gateway/integration ]
paths-ignore:
- "**.md"
- "docs/**"
@@ -780,6 +782,8 @@ jobs:
needs: [ build-rustfs-debug-binary ]
runs-on: sm-standard-2
timeout-minutes: 30
env:
RUSTFS_S3_STACK: legacy
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
@@ -898,18 +902,105 @@ jobs:
path: ${{ runner.temp }}/rustfs-e2e-*/rustfs.log
retention-days: 3
# The same e2e-smoke suite against the same debug binary with the gateway
# S3 stack selected (rustfs/backlog#2734). Report-only: it is outside the
# Test and Lint aggregate and becomes required at T2.10. The archive and run
# steps must stay identical to e2e-tests; scripts/ci_gate.py enforces that.
e2e-tests-gateway:
name: e2e-smoke (gateway, report-only until T2.10)
needs: [ build-rustfs-debug-binary ]
runs-on: sm-standard-2
timeout-minutes: 30
continue-on-error: true
env:
RUSTFS_S3_STACK: gateway
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
rust-version: stable
cache-shared-key: ci-dev
cache-save-if: 'false'
install-build-packaging-tools: 'false'
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install awscurl
run: |
python3 -m pip install --user --upgrade pip "awscurl==0.44"
echo "AWSCURL_PATH=$HOME/.local/bin/awscurl" >> "$GITHUB_ENV"
- name: Verify awscurl
run: test -x "$AWSCURL_PATH"
- name: Download debug binary
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustfs-debug-binary
path: target/debug
- name: Make binary executable
run: chmod +x ./target/debug/rustfs
- name: Archive e2e smoke test binaries
env:
NEXTEST_ARCHIVE: ${{ runner.temp }}/rustfs-e2e-smoke.tar.zst
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-smoke-list.json
run: |
cargo nextest archive --profile e2e-smoke -p e2e_test --archive-file "${NEXTEST_ARCHIVE}"
cargo nextest list --profile e2e-smoke --archive-file "${NEXTEST_ARCHIVE}" --message-format json > "${NEXTEST_LISTING}"
python3 ./scripts/check_test_wiring.py --check-profile e2e-smoke "${NEXTEST_LISTING}"
./scripts/check_security_smoke_count.sh check "${NEXTEST_LISTING}"
- name: Run e2e smoke suite
env:
NEXTEST_ARCHIVE: ${{ runner.temp }}/rustfs-e2e-smoke.tar.zst
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-smoke-logs
SAMPLER_INTERVAL_SECS: "15"
run: |
./scripts/ci/resource_sampler.sh start e2e-smoke
trap './scripts/ci/resource_sampler.sh stop' EXIT
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke --archive-file "${NEXTEST_ARCHIVE}" \
--status-level all --final-status-level all --failure-output final
- name: Upload e2e smoke diagnostics
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-smoke-gateway-diagnostics-${{ github.run_number }}
path: |
${{ runner.temp }}/rustfs-e2e-smoke-logs/
${{ runner.temp }}/rustfs-e2e-smoke-list.json
if-no-files-found: warn
- name: Upload e2e smoke JUnit report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-smoke-gateway-junit-${{ github.run_number }}
path: target/nextest/e2e-smoke/junit.xml
if-no-files-found: warn
e2e-full:
name: End-to-End Tests (full merge gate)
# Merge gate only (backlog#1149 ci-5): the never-automated user-visible
# suites — KMS, object_lock, multipart_auth, quota, checksum, encryption,
# security-boundary, ... — via the e2e-full nextest profile. Too heavy for
# every PR, so it is gated to main pushes, the merge queue, and manual
# dispatch. protocols / the 7 cluster suites / replication / #[ignore] are
# owned by other lanes (see .config/nextest.toml profile.e2e-full).
# every PR, so it is gated to main and gateway/integration pushes, the merge
# queue, and manual dispatch. protocols / the 7 cluster suites / replication
# / #[ignore] are owned by other lanes (see .config/nextest.toml profile.e2e-full).
if: >-
github.event_name == 'workflow_dispatch' ||
github.event_name == 'merge_group' ||
(github.event_name == 'push' && github.ref == 'refs/heads/main')
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/gateway/integration'))
needs: [ build-rustfs-debug-binary ]
runs-on: sm-standard-2
timeout-minutes: 55
Generated
+2
View File
@@ -10703,6 +10703,8 @@ name = "rustfs-s3-types"
version = "1.0.1"
dependencies = [
"hotpath",
"http 1.5.0",
"s3s",
"serde",
"serde_json",
]
+5 -1
View File
@@ -48,7 +48,7 @@ members = [
"crates/replication", # Replication contracts and wire formats
"crates/concurrency", # Concurrency management for RustFS - timeout, locking, backpressure, and I/O scheduling
"crates/s3-client", # S3 client for engine-side consumption of remote S3 endpoints (tiering, transition targets)
"crates/s3-types", # S3 event type definitions
"crates/s3-types", # S3 event and error type definitions
"crates/s3-ops", # S3 operation definitions and mapping
"crates/s3select-api", # S3 Select API interface
"crates/s3select-query", # S3 Select query engine
@@ -323,6 +323,10 @@ rust-embed = { version = "8.12.0" }
rustc-hash = { version = "2.1.3" }
s3s = { git = "https://github.com/s3s-project/s3s", rev = "5761ddfe4c509fcac87e3b99fd5bd9e176b442c0", features = ["minio"] }
s3s-sigv4 = { git = "https://github.com/s3s-project/s3s", rev = "5761ddfe4c509fcac87e3b99fd5bd9e176b442c0" }
# The same s3s pin under the name the legacy edge of the gateway migration uses
# (crates/s3-types, feature `compat-s3s`). Keep `rev` identical to `s3s` above.
# DELETE BY T4.2 (rustfs/backlog#2784).
legacy-s3s = { package = "s3s", git = "https://github.com/s3s-project/s3s", rev = "5761ddfe4c509fcac87e3b99fd5bd9e176b442c0", features = ["minio"] }
serial_test = "4.0.1"
shadow-rs = { default-features = false, version = "2.0.0" }
siphasher = "1.0.4"
@@ -0,0 +1,495 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Byte-level goldens for the 13 persisted bucket-configuration XML families
//! (rustfs/backlog#2744).
//!
//! Responsible for: pinning the exact bytes that `deserialize` followed by
//! `serialize` produces for every family in its three source shapes (AWS
//! documentation shape, MinIO-written shape, RustFS-written shape), and the
//! exact bytes RustFS writes for a typed value. Only bytes are compared; a
//! structural comparison would let a codec replacement change the persisted
//! encoding without failing here.
//!
//! Not responsible for: validating configurations, touching the bucket
//! metadata store, or choosing a canonical shape.
//!
//! Upstream: `rustfs_ecstore::api::bucket::utils::{deserialize, serialize}`
//! and the s3s DTOs they are generic over. Downstream: the DTO/codec
//! replacement (T1.4) and the persisted-bytes comparison (T3.7), which must
//! keep every golden green without regenerating it. Regeneration is reserved
//! to `scripts/gen_bucket_config_goldens.sh`, which sets
//! `BUCKET_CONFIG_GOLDENS_WRITE`: in that mode every test writes its files
//! and then fails on purpose, so a write run never prints a green line.
use rustfs_ecstore::api::bucket::utils::{deserialize, serialize};
use s3s::dto::{
AbortIncompleteMultipartUpload, AccelerateConfiguration, BucketAccelerateStatus, BucketLifecycleConfiguration,
BucketLoggingStatus, BucketVersioningStatus, CORSConfiguration, CORSRule, Condition, DefaultRetention, DelMarkerExpiration,
DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication, DeleteReplicationStatus, Destination,
ErrorDocument, Event, ExcludedPrefix, ExistingObjectReplication, ExistingObjectReplicationStatus, ExpirationStatus,
FilterRule, FilterRuleName, IndexDocument, LifecycleExpiration, LifecycleRule, LifecycleRuleAndOperator, LifecycleRuleFilter,
LoggingEnabled, NoncurrentVersionExpiration, NotificationConfiguration, NotificationConfigurationFilter,
ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRetentionMode, ObjectLockRule, Payer, PublicAccessBlockConfiguration,
QueueConfiguration, Redirect, ReplicaModifications, ReplicaModificationsStatus, ReplicationConfiguration, ReplicationRule,
ReplicationRuleAndOperator, ReplicationRuleFilter, ReplicationRuleStatus, RequestPaymentConfiguration, RoutingRule,
S3KeyFilter, ServerSideEncryption, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration,
ServerSideEncryptionRule, SourceSelectionCriteria, StorageClass, Tag, Tagging, Transition, TransitionStorageClass,
VersioningConfiguration, WebsiteConfiguration,
};
use s3s::xml;
use std::path::{Path, PathBuf};
/// Set only by `scripts/gen_bucket_config_goldens.sh`. Every pair test then
/// writes its `.out.xml` (and, for the `rustfs` shape, its `.in.xml`) and
/// fails on purpose, so a write run can never be mistaken for a verification.
const WRITE_ENV: &str = "BUCKET_CONFIG_GOLDENS_WRITE";
fn goldens_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("bucket-config-goldens")
}
fn pair_path(family: &str, shape: &str, side: &str) -> PathBuf {
goldens_dir().join(family).join(format!("{shape}.{side}.xml"))
}
fn read(path: &Path) -> Vec<u8> {
std::fs::read(path).unwrap_or_else(|err| panic!("read {}: {err}", path.display()))
}
fn write(path: &Path, bytes: &[u8]) {
std::fs::write(path, bytes).unwrap_or_else(|err| panic!("write {}: {err}", path.display()));
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|window| window == needle)
}
fn round_trip<T>(input: &[u8], label: &str) -> Vec<u8>
where
T: for<'xml> xml::Deserialize<'xml> + xml::Serialize,
{
let parsed: T = deserialize(input).unwrap_or_else(|err| panic!("{label}: deserialize failed: {err}"));
serialize(&parsed).unwrap_or_else(|err| panic!("{label}: serialize failed: {err}"))
}
/// Byte comparison with a readable report. Parsed values are never compared.
fn assert_bytes_eq(actual: &[u8], expected: &[u8], context: &str) {
if actual != expected {
panic!(
"{context}\n--- expected ({} bytes) ---\n{}\n--- actual ({} bytes) ---\n{}\n",
expected.len(),
String::from_utf8_lossy(expected),
actual.len(),
String::from_utf8_lossy(actual),
);
}
}
/// Checks one `<family>/<shape>` pair. `typed` is the value RustFS writes for
/// the `rustfs` shape; the `aws` and `minio` shapes pass `None`.
fn check_pair<T>(family: &str, shape: &str, typed: Option<&T>)
where
T: for<'xml> xml::Deserialize<'xml> + xml::Serialize,
{
let in_path = pair_path(family, shape, "in");
let out_path = pair_path(family, shape, "out");
let label = format!("{family}/{shape}");
if std::env::var_os(WRITE_ENV).is_some() {
if let Some(value) = typed {
let written = serialize(value).unwrap_or_else(|err| panic!("{label}: typed value failed to serialize: {err}"));
write(&in_path, &written);
}
let input = read(&in_path);
write(&out_path, &round_trip::<T>(&input, &label));
panic!(
"{WRITE_ENV} is set: wrote {} without verifying anything; rerun without {WRITE_ENV}",
out_path.display()
);
}
let input = read(&in_path);
let expected = read(&out_path);
if let Some(value) = typed {
let written = serialize(value).unwrap_or_else(|err| panic!("{label}: typed value failed to serialize: {err}"));
assert_bytes_eq(
&written,
&input,
&format!(
"{label}: RustFS no longer writes the pinned bytes for the typed value ({})",
in_path.display()
),
);
}
let actual = round_trip::<T>(&input, &label);
assert_bytes_eq(
&actual,
&expected,
&format!("{label}: deserialize->serialize bytes drifted from {}", out_path.display()),
);
let again = round_trip::<T>(&expected, &label);
assert_bytes_eq(
&again,
&expected,
&format!("{label}: the golden is not a fixed point of deserialize->serialize"),
);
}
/// The typed values behind every `rustfs.in.xml`: what RustFS writes today
/// through `serialize` when it persists a configuration it built itself.
/// MinIO-only members (`DelMarkerExpiration`, `ExcludeFolders`,
/// `ExcludedPrefixes`, `DeleteReplication`) are included where RustFS accepts
/// them, so the `rustfs` shape is the one that pins their encoding.
mod rustfs_values {
use super::*;
fn tag(key: &str, value: &str) -> Tag {
Tag {
key: Some(key.to_owned()),
value: Some(value.to_owned()),
}
}
pub(super) fn notification() -> NotificationConfiguration {
NotificationConfiguration {
queue_configurations: Some(vec![QueueConfiguration {
events: vec![
Event::from("s3:ObjectCreated:*".to_owned()),
Event::from("s3:ObjectRemoved:*".to_owned()),
],
filter: Some(NotificationConfigurationFilter {
key: Some(S3KeyFilter {
filter_rules: Some(vec![FilterRule {
name: Some(FilterRuleName::from_static(FilterRuleName::PREFIX)),
value: Some("uploads/".to_owned()),
}]),
}),
}),
id: Some("rustfs-webhook".to_owned()),
queue_arn: "arn:rustfs:sqs::primary:webhook".to_owned(),
}]),
..Default::default()
}
}
pub(super) fn lifecycle() -> BucketLifecycleConfiguration {
BucketLifecycleConfiguration {
rules: vec![
LifecycleRule {
abort_incomplete_multipart_upload: Some(AbortIncompleteMultipartUpload {
days_after_initiation: Some(3),
}),
del_marker_expiration: Some(DelMarkerExpiration { days: Some(7) }),
expiration: Some(LifecycleExpiration {
days: Some(30),
..Default::default()
}),
filter: Some(LifecycleRuleFilter {
prefix: Some("tmp/".to_owned()),
..Default::default()
}),
id: Some("expire-tmp".to_owned()),
noncurrent_version_expiration: Some(NoncurrentVersionExpiration {
newer_noncurrent_versions: Some(2),
noncurrent_days: Some(7),
}),
noncurrent_version_transitions: None,
prefix: None,
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
transitions: None,
},
LifecycleRule {
abort_incomplete_multipart_upload: None,
del_marker_expiration: None,
expiration: None,
filter: Some(LifecycleRuleFilter {
and: Some(LifecycleRuleAndOperator {
prefix: Some("archive/".to_owned()),
tags: Some(vec![tag("tier", "cold")]),
..Default::default()
}),
..Default::default()
}),
id: Some("tier-cold".to_owned()),
noncurrent_version_expiration: None,
noncurrent_version_transitions: None,
prefix: None,
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
transitions: Some(vec![Transition {
days: Some(90),
storage_class: Some(TransitionStorageClass::from_static("COLDTIER")),
..Default::default()
}]),
},
],
..Default::default()
}
}
pub(super) fn object_lock() -> ObjectLockConfiguration {
ObjectLockConfiguration {
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
rule: Some(ObjectLockRule {
default_retention: Some(DefaultRetention {
days: Some(30),
mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::GOVERNANCE)),
years: None,
}),
}),
}
}
pub(super) fn versioning() -> VersioningConfiguration {
VersioningConfiguration {
exclude_folders: Some(true),
excluded_prefixes: Some(vec![
ExcludedPrefix {
prefix: Some("tmp/".to_owned()),
},
ExcludedPrefix {
prefix: Some("cache/".to_owned()),
},
]),
mfa_delete: None,
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
}
}
pub(super) fn encryption() -> ServerSideEncryptionConfiguration {
ServerSideEncryptionConfiguration {
rules: vec![ServerSideEncryptionRule {
apply_server_side_encryption_by_default: Some(ServerSideEncryptionByDefault {
kms_master_key_id: Some("rustfs-bucket-key".to_owned()),
sse_algorithm: ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS),
}),
blocked_encryption_types: None,
bucket_key_enabled: Some(false),
}],
}
}
pub(super) fn tagging() -> Tagging {
Tagging {
tag_set: vec![tag("team", "storage"), tag("env", "staging")],
}
}
pub(super) fn replication() -> ReplicationConfiguration {
ReplicationConfiguration {
role: String::new(),
rules: vec![ReplicationRule {
delete_marker_replication: Some(DeleteMarkerReplication {
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)),
}),
delete_replication: Some(DeleteReplication {
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED),
}),
destination: Destination {
bucket: "arn:rustfs:replication::0b6f3c2a-5e6a-4d2b-9c1e-7a8f9d0e1b2c:interop-dr".to_owned(),
storage_class: Some(StorageClass::from_static(StorageClass::STANDARD)),
..Default::default()
},
existing_object_replication: Some(ExistingObjectReplication {
status: ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::ENABLED),
}),
filter: Some(ReplicationRuleFilter {
and: Some(ReplicationRuleAndOperator {
prefix: Some("docs/".to_owned()),
tags: Some(vec![tag("replicate", "yes")]),
}),
..Default::default()
}),
id: Some("rustfs-dr".to_owned()),
prefix: None,
priority: Some(1),
source_selection_criteria: Some(SourceSelectionCriteria {
replica_modifications: Some(ReplicaModifications {
status: ReplicaModificationsStatus::from_static(ReplicaModificationsStatus::ENABLED),
}),
sse_kms_encrypted_objects: None,
}),
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
}],
}
}
pub(super) fn cors() -> CORSConfiguration {
CORSConfiguration {
cors_rules: vec![CORSRule {
allowed_headers: Some(vec!["Authorization".to_owned(), "Content-Type".to_owned()]),
allowed_methods: vec!["GET".to_owned(), "PUT".to_owned()],
allowed_origins: vec!["https://app.example.internal".to_owned()],
expose_headers: Some(vec!["ETag".to_owned(), "x-amz-request-id".to_owned()]),
id: Some("rustfs-app".to_owned()),
max_age_seconds: Some(3600),
}],
}
}
pub(super) fn logging() -> BucketLoggingStatus {
BucketLoggingStatus {
logging_enabled: Some(LoggingEnabled {
target_bucket: "rustfs-access-logs".to_owned(),
target_grants: None,
target_object_key_format: None,
target_prefix: "buckets/interop/".to_owned(),
}),
}
}
pub(super) fn website() -> WebsiteConfiguration {
WebsiteConfiguration {
error_document: Some(ErrorDocument {
key: "404.html".to_owned(),
}),
index_document: Some(IndexDocument {
suffix: "index.html".to_owned(),
}),
redirect_all_requests_to: None,
routing_rules: Some(vec![RoutingRule {
condition: Some(Condition {
http_error_code_returned_equals: None,
key_prefix_equals: Some("legacy/".to_owned()),
}),
redirect: Redirect {
host_name: None,
http_redirect_code: Some("301".to_owned()),
protocol: None,
replace_key_prefix_with: Some("current/".to_owned()),
replace_key_with: None,
},
}]),
}
}
pub(super) fn accelerate() -> AccelerateConfiguration {
AccelerateConfiguration {
status: Some(BucketAccelerateStatus::from_static(BucketAccelerateStatus::SUSPENDED)),
}
}
pub(super) fn request_payment() -> RequestPaymentConfiguration {
RequestPaymentConfiguration {
payer: Payer::from_static(Payer::BUCKET_OWNER),
}
}
pub(super) fn public_access_block() -> PublicAccessBlockConfiguration {
PublicAccessBlockConfiguration {
block_public_acls: Some(true),
block_public_policy: Some(false),
ignore_public_acls: Some(true),
restrict_public_buckets: Some(false),
}
}
}
/// One test per `<family>/<shape>` pair: 13 families x 3 shapes = 39.
mod bucket_config_goldens {
use super::*;
macro_rules! golden {
($name:ident, $family:literal, $shape:literal, $ty:ty) => {
#[test]
fn $name() {
check_pair::<$ty>($family, $shape, None);
}
};
($name:ident, $family:literal, rustfs = $value:expr) => {
#[test]
fn $name() {
check_pair($family, "rustfs", Some(&$value));
}
};
}
golden!(notification_aws, "notification", "aws", NotificationConfiguration);
golden!(notification_minio, "notification", "minio", NotificationConfiguration);
golden!(notification_rustfs, "notification", rustfs = rustfs_values::notification());
golden!(lifecycle_aws, "lifecycle", "aws", BucketLifecycleConfiguration);
golden!(lifecycle_minio, "lifecycle", "minio", BucketLifecycleConfiguration);
golden!(lifecycle_rustfs, "lifecycle", rustfs = rustfs_values::lifecycle());
golden!(object_lock_aws, "object_lock", "aws", ObjectLockConfiguration);
golden!(object_lock_minio, "object_lock", "minio", ObjectLockConfiguration);
golden!(object_lock_rustfs, "object_lock", rustfs = rustfs_values::object_lock());
golden!(versioning_aws, "versioning", "aws", VersioningConfiguration);
golden!(versioning_minio, "versioning", "minio", VersioningConfiguration);
golden!(versioning_rustfs, "versioning", rustfs = rustfs_values::versioning());
golden!(encryption_aws, "encryption", "aws", ServerSideEncryptionConfiguration);
golden!(encryption_minio, "encryption", "minio", ServerSideEncryptionConfiguration);
golden!(encryption_rustfs, "encryption", rustfs = rustfs_values::encryption());
golden!(tagging_aws, "tagging", "aws", Tagging);
golden!(tagging_minio, "tagging", "minio", Tagging);
golden!(tagging_rustfs, "tagging", rustfs = rustfs_values::tagging());
/// `replication/aws.in.xml` is the unknown-element sample from
/// `crates/replication/src/config.rs`
/// (`s3_xml_parser_discards_unknown_replication_elements_before_validation`).
/// Beyond the byte golden, the pair proves the root-level `FutureTopLevel`
/// element is skipped on read and never re-emitted on write.
#[test]
fn replication_aws() {
check_pair::<ReplicationConfiguration>("replication", "aws", None);
let input = read(&pair_path("replication", "aws", "in"));
let output = read(&pair_path("replication", "aws", "out"));
assert!(
contains(&input, b"<FutureTopLevel>future</FutureTopLevel>"),
"the sample must still carry the unknown element"
);
assert!(
!contains(&output, b"FutureTopLevel"),
"an unknown element must not be re-emitted: {}",
String::from_utf8_lossy(&output)
);
}
golden!(replication_minio, "replication", "minio", ReplicationConfiguration);
golden!(replication_rustfs, "replication", rustfs = rustfs_values::replication());
golden!(cors_aws, "cors", "aws", CORSConfiguration);
golden!(cors_minio, "cors", "minio", CORSConfiguration);
golden!(cors_rustfs, "cors", rustfs = rustfs_values::cors());
golden!(logging_aws, "logging", "aws", BucketLoggingStatus);
golden!(logging_minio, "logging", "minio", BucketLoggingStatus);
golden!(logging_rustfs, "logging", rustfs = rustfs_values::logging());
golden!(website_aws, "website", "aws", WebsiteConfiguration);
golden!(website_minio, "website", "minio", WebsiteConfiguration);
golden!(website_rustfs, "website", rustfs = rustfs_values::website());
golden!(accelerate_aws, "accelerate", "aws", AccelerateConfiguration);
golden!(accelerate_minio, "accelerate", "minio", AccelerateConfiguration);
golden!(accelerate_rustfs, "accelerate", rustfs = rustfs_values::accelerate());
golden!(request_payment_aws, "request_payment", "aws", RequestPaymentConfiguration);
golden!(request_payment_minio, "request_payment", "minio", RequestPaymentConfiguration);
golden!(request_payment_rustfs, "request_payment", rustfs = rustfs_values::request_payment());
golden!(public_access_block_aws, "public_access_block", "aws", PublicAccessBlockConfiguration);
golden!(public_access_block_minio, "public_access_block", "minio", PublicAccessBlockConfiguration);
golden!(
public_access_block_rustfs,
"public_access_block",
rustfs = rustfs_values::public_access_block()
);
}
@@ -0,0 +1,58 @@
# Persisted bucket-configuration XML goldens (rustfs/backlog#2744)
Byte-level goldens for the 13 XML families that `BucketMetadata` persists as
`*_config_xml`. They pin what `rustfs_ecstore::api::bucket::utils::deserialize`
followed by `serialize` emits today, so that the DTO/codec replacement (T1.4)
and the persisted-bytes comparison (T3.7) can be proven byte-identical: an
older binary must keep reading what a newer one writes, and the reverse.
Generated at commit: `6b1554003ebf8f2037ffb7da9c9b906527e758da` (2026-10-07T17:40:56Z)
Checked by `crates/ecstore/tests/bucket_config_goldens.rs`
(`cargo nextest run -p rustfs-ecstore bucket_config_goldens`, 39 tests).
Generated once by `scripts/gen_bucket_config_goldens.sh`, which records the
commit above and refuses to run again unless `--force` is passed. Do not
regenerate after a DTO or codec replacement: at that point a diff here is the
finding, not noise.
## Layout
`<family>/<shape>.in.xml` is an input; `<family>/<shape>.out.xml` is the exact
bytes of `serialize(deserialize(in))` on the generating commit. The test
asserts `bytes == out` (never structural equality), that `out` is a fixed point
of the same round trip, and for the `rustfs` shape that `serialize(typed
value)` still equals `rustfs.in.xml`.
| Shape | Source |
| --- | --- |
| `aws` | Written for this corpus from the public S3 XML schema in the shape of the AWS documentation examples: XML declaration, `xmlns` on the root, indented. No AWS prose is copied. |
| `minio` | The bytes a MinIO server persists. Seven families are carved verbatim out of `../minio/bucket_metadata.blob.hex` (MinIO `RELEASE.2025-07-23T15-54-02Z`); the rest are hand-shaped as compact Go `encoding/xml` output with the S3 namespace on the root, because MinIO does not persist those families. |
| `rustfs` | The bytes RustFS writes today through `serialize` for a typed value (`rustfs_values` in the test). This shape carries the MinIO-only members RustFS accepts (`DelMarkerExpiration`, `ExcludeFolders`, `ExcludedPrefixes`, `DeleteReplication`). |
| Family | Root element | `minio.in.xml` provenance |
| --- | --- | --- |
| `notification` | `NotificationConfiguration` | MinIO fixture |
| `lifecycle` | `LifecycleConfiguration` | MinIO fixture (carries the MinIO `ExpiryUpdatedAt` extension) |
| `object_lock` | `ObjectLockConfiguration` | MinIO fixture |
| `versioning` | `VersioningConfiguration` | MinIO fixture |
| `encryption` | `ServerSideEncryptionConfiguration` | MinIO fixture |
| `tagging` | `Tagging` | MinIO fixture |
| `replication` | `ReplicationConfiguration` | MinIO fixture |
| `cors` | `CORSConfiguration` | hand-shaped |
| `logging` | `BucketLoggingStatus` | hand-shaped |
| `website` | `WebsiteConfiguration` | hand-shaped |
| `accelerate` | `AccelerateConfiguration` | hand-shaped |
| `request_payment` | `RequestPaymentConfiguration` | hand-shaped |
| `public_access_block` | `PublicAccessBlockConfiguration` | hand-shaped |
## Unknown elements
`replication/aws.in.xml` is the unknown-element sample from
`crates/replication/src/config.rs`
(`s3_xml_parser_discards_unknown_replication_elements_before_validation`),
re-indented to two spaces. Its golden proves the root-level `FutureTopLevel`
element is skipped on read and never re-emitted on write, which is the
persisted-configuration exception recorded in the root `AGENTS.md`. On the
generating commit every one of the 13 root elements skips unknown children;
nested members (`Rule`, `Filter`, `Destination`, ...) reject them. A
replacement codec must keep that boundary where it is.
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<AccelerateConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Status>Enabled</Status>
</AccelerateConfiguration>
@@ -0,0 +1 @@
<AccelerateConfiguration><Status>Enabled</Status></AccelerateConfiguration>
@@ -0,0 +1 @@
<AccelerateConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>Suspended</Status></AccelerateConfiguration>
@@ -0,0 +1 @@
<AccelerateConfiguration><Status>Suspended</Status></AccelerateConfiguration>
@@ -0,0 +1 @@
<AccelerateConfiguration><Status>Suspended</Status></AccelerateConfiguration>
@@ -0,0 +1 @@
<AccelerateConfiguration><Status>Suspended</Status></AccelerateConfiguration>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<CORSRule>
<ID>AllowWebApp</ID>
<AllowedOrigin>https://www.example.com</AllowedOrigin>
<AllowedMethod>PUT</AllowedMethod>
<AllowedMethod>POST</AllowedMethod>
<AllowedMethod>DELETE</AllowedMethod>
<AllowedHeader>*</AllowedHeader>
<ExposeHeader>x-amz-server-side-encryption</ExposeHeader>
<MaxAgeSeconds>3000</MaxAgeSeconds>
</CORSRule>
<CORSRule>
<AllowedOrigin>*</AllowedOrigin>
<AllowedMethod>GET</AllowedMethod>
</CORSRule>
</CORSConfiguration>
@@ -0,0 +1 @@
<CORSConfiguration><CORSRule><AllowedHeader>*</AllowedHeader><AllowedMethod>PUT</AllowedMethod><AllowedMethod>POST</AllowedMethod><AllowedMethod>DELETE</AllowedMethod><AllowedOrigin>https://www.example.com</AllowedOrigin><ExposeHeader>x-amz-server-side-encryption</ExposeHeader><ID>AllowWebApp</ID><MaxAgeSeconds>3000</MaxAgeSeconds></CORSRule><CORSRule><AllowedMethod>GET</AllowedMethod><AllowedOrigin>*</AllowedOrigin></CORSRule></CORSConfiguration>
@@ -0,0 +1 @@
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><CORSRule><ID>console</ID><AllowedOrigin>https://console.example.internal</AllowedOrigin><AllowedMethod>GET</AllowedMethod><AllowedMethod>HEAD</AllowedMethod><AllowedHeader>Authorization</AllowedHeader><AllowedHeader>Content-Type</AllowedHeader><ExposeHeader>ETag</ExposeHeader><MaxAgeSeconds>600</MaxAgeSeconds></CORSRule></CORSConfiguration>
@@ -0,0 +1 @@
<CORSConfiguration><CORSRule><AllowedHeader>Authorization</AllowedHeader><AllowedHeader>Content-Type</AllowedHeader><AllowedMethod>GET</AllowedMethod><AllowedMethod>HEAD</AllowedMethod><AllowedOrigin>https://console.example.internal</AllowedOrigin><ExposeHeader>ETag</ExposeHeader><ID>console</ID><MaxAgeSeconds>600</MaxAgeSeconds></CORSRule></CORSConfiguration>
@@ -0,0 +1 @@
<CORSConfiguration><CORSRule><AllowedHeader>Authorization</AllowedHeader><AllowedHeader>Content-Type</AllowedHeader><AllowedMethod>GET</AllowedMethod><AllowedMethod>PUT</AllowedMethod><AllowedOrigin>https://app.example.internal</AllowedOrigin><ExposeHeader>ETag</ExposeHeader><ExposeHeader>x-amz-request-id</ExposeHeader><ID>rustfs-app</ID><MaxAgeSeconds>3600</MaxAgeSeconds></CORSRule></CORSConfiguration>
@@ -0,0 +1 @@
<CORSConfiguration><CORSRule><AllowedHeader>Authorization</AllowedHeader><AllowedHeader>Content-Type</AllowedHeader><AllowedMethod>GET</AllowedMethod><AllowedMethod>PUT</AllowedMethod><AllowedOrigin>https://app.example.internal</AllowedOrigin><ExposeHeader>ETag</ExposeHeader><ExposeHeader>x-amz-request-id</ExposeHeader><ID>rustfs-app</ID><MaxAgeSeconds>3600</MaxAgeSeconds></CORSRule></CORSConfiguration>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<ServerSideEncryptionConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Rule>
<ApplyServerSideEncryptionByDefault>
<SSEAlgorithm>aws:kms</SSEAlgorithm>
<KMSMasterKeyID>arn:aws:kms:us-east-1:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab</KMSMasterKeyID>
</ApplyServerSideEncryptionByDefault>
<BucketKeyEnabled>true</BucketKeyEnabled>
</Rule>
</ServerSideEncryptionConfiguration>
@@ -0,0 +1 @@
<ServerSideEncryptionConfiguration><Rule><ApplyServerSideEncryptionByDefault><KMSMasterKeyID>arn:aws:kms:us-east-1:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab</KMSMasterKeyID><SSEAlgorithm>aws:kms</SSEAlgorithm></ApplyServerSideEncryptionByDefault><BucketKeyEnabled>true</BucketKeyEnabled></Rule></ServerSideEncryptionConfiguration>
@@ -0,0 +1 @@
<ServerSideEncryptionConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>AES256</SSEAlgorithm></ApplyServerSideEncryptionByDefault></Rule></ServerSideEncryptionConfiguration>
@@ -0,0 +1 @@
<ServerSideEncryptionConfiguration><Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>AES256</SSEAlgorithm></ApplyServerSideEncryptionByDefault></Rule></ServerSideEncryptionConfiguration>
@@ -0,0 +1 @@
<ServerSideEncryptionConfiguration><Rule><ApplyServerSideEncryptionByDefault><KMSMasterKeyID>rustfs-bucket-key</KMSMasterKeyID><SSEAlgorithm>aws:kms</SSEAlgorithm></ApplyServerSideEncryptionByDefault><BucketKeyEnabled>false</BucketKeyEnabled></Rule></ServerSideEncryptionConfiguration>
@@ -0,0 +1 @@
<ServerSideEncryptionConfiguration><Rule><ApplyServerSideEncryptionByDefault><KMSMasterKeyID>rustfs-bucket-key</KMSMasterKeyID><SSEAlgorithm>aws:kms</SSEAlgorithm></ApplyServerSideEncryptionByDefault><BucketKeyEnabled>false</BucketKeyEnabled></Rule></ServerSideEncryptionConfiguration>
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<LifecycleConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Rule>
<ID>Archive and then delete rule</ID>
<Filter>
<And>
<Prefix>projectdocs/</Prefix>
<Tag>
<Key>department</Key>
<Value>finance</Value>
</Tag>
<ObjectSizeGreaterThan>1024</ObjectSizeGreaterThan>
</And>
</Filter>
<Status>Enabled</Status>
<Transition>
<Days>30</Days>
<StorageClass>STANDARD_IA</StorageClass>
</Transition>
<Transition>
<Days>365</Days>
<StorageClass>GLACIER</StorageClass>
</Transition>
<Expiration>
<Days>3650</Days>
</Expiration>
<NoncurrentVersionTransition>
<NoncurrentDays>90</NoncurrentDays>
<StorageClass>GLACIER</StorageClass>
</NoncurrentVersionTransition>
<NoncurrentVersionExpiration>
<NoncurrentDays>400</NoncurrentDays>
<NewerNoncurrentVersions>3</NewerNoncurrentVersions>
</NoncurrentVersionExpiration>
<AbortIncompleteMultipartUpload>
<DaysAfterInitiation>7</DaysAfterInitiation>
</AbortIncompleteMultipartUpload>
</Rule>
<Rule>
<ID>Expire delete markers</ID>
<Filter>
<Prefix>logs/</Prefix>
</Filter>
<Status>Disabled</Status>
<Expiration>
<ExpiredObjectDeleteMarker>true</ExpiredObjectDeleteMarker>
</Expiration>
</Rule>
</LifecycleConfiguration>
@@ -0,0 +1 @@
<LifecycleConfiguration><Rule><AbortIncompleteMultipartUpload><DaysAfterInitiation>7</DaysAfterInitiation></AbortIncompleteMultipartUpload><Expiration><Days>3650</Days></Expiration><Filter><And><ObjectSizeGreaterThan>1024</ObjectSizeGreaterThan><Prefix>projectdocs/</Prefix><Tag><Key>department</Key><Value>finance</Value></Tag></And></Filter><ID>Archive and then delete rule</ID><NoncurrentVersionExpiration><NewerNoncurrentVersions>3</NewerNoncurrentVersions><NoncurrentDays>400</NoncurrentDays></NoncurrentVersionExpiration><NoncurrentVersionTransition><NoncurrentDays>90</NoncurrentDays><StorageClass>GLACIER</StorageClass></NoncurrentVersionTransition><Status>Enabled</Status><Transition><Days>30</Days><StorageClass>STANDARD_IA</StorageClass></Transition><Transition><Days>365</Days><StorageClass>GLACIER</StorageClass></Transition></Rule><Rule><Expiration><ExpiredObjectDeleteMarker>true</ExpiredObjectDeleteMarker></Expiration><Filter><Prefix>logs/</Prefix></Filter><ID>Expire delete markers</ID><Status>Disabled</Status></Rule></LifecycleConfiguration>
@@ -0,0 +1 @@
<LifecycleConfiguration><Rule><ID>d96i4g89k8h26a95st60</ID><Status>Enabled</Status><Filter><Prefix></Prefix></Filter><Expiration><Days>30</Days></Expiration><NoncurrentVersionExpiration><NoncurrentDays>7</NoncurrentDays></NoncurrentVersionExpiration></Rule><ExpiryUpdatedAt>2026-07-07T15:58:57.337315Z</ExpiryUpdatedAt></LifecycleConfiguration>
@@ -0,0 +1 @@
<LifecycleConfiguration><ExpiryUpdatedAt>2026-07-07T15:58:57.337Z</ExpiryUpdatedAt><Rule><Expiration><Days>30</Days></Expiration><Filter><Prefix></Prefix></Filter><ID>d96i4g89k8h26a95st60</ID><NoncurrentVersionExpiration><NoncurrentDays>7</NoncurrentDays></NoncurrentVersionExpiration><Status>Enabled</Status></Rule></LifecycleConfiguration>
@@ -0,0 +1 @@
<LifecycleConfiguration><Rule><AbortIncompleteMultipartUpload><DaysAfterInitiation>3</DaysAfterInitiation></AbortIncompleteMultipartUpload><DelMarkerExpiration><Days>7</Days></DelMarkerExpiration><Expiration><Days>30</Days></Expiration><Filter><Prefix>tmp/</Prefix></Filter><ID>expire-tmp</ID><NoncurrentVersionExpiration><NewerNoncurrentVersions>2</NewerNoncurrentVersions><NoncurrentDays>7</NoncurrentDays></NoncurrentVersionExpiration><Status>Enabled</Status></Rule><Rule><Filter><And><Prefix>archive/</Prefix><Tag><Key>tier</Key><Value>cold</Value></Tag></And></Filter><ID>tier-cold</ID><Status>Enabled</Status><Transition><Days>90</Days><StorageClass>COLDTIER</StorageClass></Transition></Rule></LifecycleConfiguration>
@@ -0,0 +1 @@
<LifecycleConfiguration><Rule><AbortIncompleteMultipartUpload><DaysAfterInitiation>3</DaysAfterInitiation></AbortIncompleteMultipartUpload><DelMarkerExpiration><Days>7</Days></DelMarkerExpiration><Expiration><Days>30</Days></Expiration><Filter><Prefix>tmp/</Prefix></Filter><ID>expire-tmp</ID><NoncurrentVersionExpiration><NewerNoncurrentVersions>2</NewerNoncurrentVersions><NoncurrentDays>7</NoncurrentDays></NoncurrentVersionExpiration><Status>Enabled</Status></Rule><Rule><Filter><And><Prefix>archive/</Prefix><Tag><Key>tier</Key><Value>cold</Value></Tag></And></Filter><ID>tier-cold</ID><Status>Enabled</Status><Transition><Days>90</Days><StorageClass>COLDTIER</StorageClass></Transition></Rule></LifecycleConfiguration>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<BucketLoggingStatus xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<LoggingEnabled>
<TargetBucket>amzn-s3-demo-logging-bucket</TargetBucket>
<TargetPrefix>logs/</TargetPrefix>
<TargetGrants>
<Grant>
<Grantee xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="AmazonCustomerByEmail">
<EmailAddress>user@example.com</EmailAddress>
</Grantee>
<Permission>READ</Permission>
</Grant>
</TargetGrants>
<TargetObjectKeyFormat>
<PartitionedPrefix>
<PartitionDateSource>EventTime</PartitionDateSource>
</PartitionedPrefix>
</TargetObjectKeyFormat>
</LoggingEnabled>
</BucketLoggingStatus>
@@ -0,0 +1 @@
<BucketLoggingStatus><LoggingEnabled><TargetBucket>amzn-s3-demo-logging-bucket</TargetBucket><TargetGrants><Grant><Grantee xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="AmazonCustomerByEmail"><EmailAddress>user@example.com</EmailAddress></Grantee><Permission>READ</Permission></Grant></TargetGrants><TargetObjectKeyFormat><PartitionedPrefix><PartitionDateSource>EventTime</PartitionDateSource></PartitionedPrefix></TargetObjectKeyFormat><TargetPrefix>logs/</TargetPrefix></LoggingEnabled></BucketLoggingStatus>
@@ -0,0 +1 @@
<BucketLoggingStatus xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><LoggingEnabled><TargetBucket>interop-logs</TargetBucket><TargetPrefix>interop/</TargetPrefix></LoggingEnabled></BucketLoggingStatus>
@@ -0,0 +1 @@
<BucketLoggingStatus><LoggingEnabled><TargetBucket>interop-logs</TargetBucket><TargetPrefix>interop/</TargetPrefix></LoggingEnabled></BucketLoggingStatus>
@@ -0,0 +1 @@
<BucketLoggingStatus><LoggingEnabled><TargetBucket>rustfs-access-logs</TargetBucket><TargetPrefix>buckets/interop/</TargetPrefix></LoggingEnabled></BucketLoggingStatus>
@@ -0,0 +1 @@
<BucketLoggingStatus><LoggingEnabled><TargetBucket>rustfs-access-logs</TargetBucket><TargetPrefix>buckets/interop/</TargetPrefix></LoggingEnabled></BucketLoggingStatus>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<TopicConfiguration>
<Id>ImageUploads</Id>
<Topic>arn:aws:sns:us-east-1:123456789012:s3-events</Topic>
<Event>s3:ObjectCreated:*</Event>
<Filter>
<S3Key>
<FilterRule>
<Name>prefix</Name>
<Value>images/</Value>
</FilterRule>
<FilterRule>
<Name>suffix</Name>
<Value>.jpg</Value>
</FilterRule>
</S3Key>
</Filter>
</TopicConfiguration>
<QueueConfiguration>
<Id>Deletions</Id>
<Queue>arn:aws:sqs:us-east-1:123456789012:s3-deletes</Queue>
<Event>s3:ObjectRemoved:*</Event>
</QueueConfiguration>
<CloudFunctionConfiguration>
<Id>Thumbnails</Id>
<CloudFunction>arn:aws:lambda:us-east-1:123456789012:function:thumbnail</CloudFunction>
<Event>s3:ObjectCreated:Put</Event>
</CloudFunctionConfiguration>
</NotificationConfiguration>
@@ -0,0 +1 @@
<NotificationConfiguration><CloudFunctionConfiguration><Event>s3:ObjectCreated:Put</Event><Id>Thumbnails</Id><CloudFunction>arn:aws:lambda:us-east-1:123456789012:function:thumbnail</CloudFunction></CloudFunctionConfiguration><QueueConfiguration><Event>s3:ObjectRemoved:*</Event><Id>Deletions</Id><Queue>arn:aws:sqs:us-east-1:123456789012:s3-deletes</Queue></QueueConfiguration><TopicConfiguration><Event>s3:ObjectCreated:*</Event><Filter><S3Key><FilterRule><Name>prefix</Name><Value>images/</Value></FilterRule><FilterRule><Name>suffix</Name><Value>.jpg</Value></FilterRule></S3Key></Filter><Id>ImageUploads</Id><Topic>arn:aws:sns:us-east-1:123456789012:s3-events</Topic></TopicConfiguration></NotificationConfiguration>
@@ -0,0 +1 @@
<NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><QueueConfiguration><Id></Id><Event>s3:ObjectCreated:*</Event><Queue>arn:minio:sqs::primary:webhook</Queue></QueueConfiguration></NotificationConfiguration>
@@ -0,0 +1 @@
<NotificationConfiguration><QueueConfiguration><Event>s3:ObjectCreated:*</Event><Id></Id><Queue>arn:minio:sqs::primary:webhook</Queue></QueueConfiguration></NotificationConfiguration>
@@ -0,0 +1 @@
<NotificationConfiguration><QueueConfiguration><Event>s3:ObjectCreated:*</Event><Event>s3:ObjectRemoved:*</Event><Filter><S3Key><FilterRule><Name>prefix</Name><Value>uploads/</Value></FilterRule></S3Key></Filter><Id>rustfs-webhook</Id><Queue>arn:rustfs:sqs::primary:webhook</Queue></QueueConfiguration></NotificationConfiguration>
@@ -0,0 +1 @@
<NotificationConfiguration><QueueConfiguration><Event>s3:ObjectCreated:*</Event><Event>s3:ObjectRemoved:*</Event><Filter><S3Key><FilterRule><Name>prefix</Name><Value>uploads/</Value></FilterRule></S3Key></Filter><Id>rustfs-webhook</Id><Queue>arn:rustfs:sqs::primary:webhook</Queue></QueueConfiguration></NotificationConfiguration>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<ObjectLockConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<ObjectLockEnabled>Enabled</ObjectLockEnabled>
<Rule>
<DefaultRetention>
<Mode>COMPLIANCE</Mode>
<Years>1</Years>
</DefaultRetention>
</Rule>
</ObjectLockConfiguration>
@@ -0,0 +1 @@
<ObjectLockConfiguration><ObjectLockEnabled>Enabled</ObjectLockEnabled><Rule><DefaultRetention><Mode>COMPLIANCE</Mode><Years>1</Years></DefaultRetention></Rule></ObjectLockConfiguration>
@@ -0,0 +1 @@
<ObjectLockConfiguration><ObjectLockEnabled>Enabled</ObjectLockEnabled><Rule><DefaultRetention><Mode>GOVERNANCE</Mode><Days>7</Days></DefaultRetention></Rule></ObjectLockConfiguration>
@@ -0,0 +1 @@
<ObjectLockConfiguration><ObjectLockEnabled>Enabled</ObjectLockEnabled><Rule><DefaultRetention><Days>7</Days><Mode>GOVERNANCE</Mode></DefaultRetention></Rule></ObjectLockConfiguration>
@@ -0,0 +1 @@
<ObjectLockConfiguration><ObjectLockEnabled>Enabled</ObjectLockEnabled><Rule><DefaultRetention><Days>30</Days><Mode>GOVERNANCE</Mode></DefaultRetention></Rule></ObjectLockConfiguration>
@@ -0,0 +1 @@
<ObjectLockConfiguration><ObjectLockEnabled>Enabled</ObjectLockEnabled><Rule><DefaultRetention><Days>30</Days><Mode>GOVERNANCE</Mode></DefaultRetention></Rule></ObjectLockConfiguration>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<PublicAccessBlockConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<BlockPublicAcls>TRUE</BlockPublicAcls>
<IgnorePublicAcls>TRUE</IgnorePublicAcls>
<BlockPublicPolicy>TRUE</BlockPublicPolicy>
<RestrictPublicBuckets>TRUE</RestrictPublicBuckets>
</PublicAccessBlockConfiguration>
@@ -0,0 +1 @@
<PublicAccessBlockConfiguration><BlockPublicAcls>true</BlockPublicAcls><BlockPublicPolicy>true</BlockPublicPolicy><IgnorePublicAcls>true</IgnorePublicAcls><RestrictPublicBuckets>true</RestrictPublicBuckets></PublicAccessBlockConfiguration>
@@ -0,0 +1 @@
<PublicAccessBlockConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><BlockPublicAcls>true</BlockPublicAcls><IgnorePublicAcls>false</IgnorePublicAcls><BlockPublicPolicy>true</BlockPublicPolicy><RestrictPublicBuckets>false</RestrictPublicBuckets></PublicAccessBlockConfiguration>
@@ -0,0 +1 @@
<PublicAccessBlockConfiguration><BlockPublicAcls>true</BlockPublicAcls><BlockPublicPolicy>true</BlockPublicPolicy><IgnorePublicAcls>false</IgnorePublicAcls><RestrictPublicBuckets>false</RestrictPublicBuckets></PublicAccessBlockConfiguration>
@@ -0,0 +1 @@
<PublicAccessBlockConfiguration><BlockPublicAcls>true</BlockPublicAcls><BlockPublicPolicy>false</BlockPublicPolicy><IgnorePublicAcls>true</IgnorePublicAcls><RestrictPublicBuckets>false</RestrictPublicBuckets></PublicAccessBlockConfiguration>
@@ -0,0 +1 @@
<PublicAccessBlockConfiguration><BlockPublicAcls>true</BlockPublicAcls><BlockPublicPolicy>false</BlockPublicPolicy><IgnorePublicAcls>true</IgnorePublicAcls><RestrictPublicBuckets>false</RestrictPublicBuckets></PublicAccessBlockConfiguration>
@@ -0,0 +1,12 @@
<ReplicationConfiguration>
<Role></Role>
<FutureTopLevel>future</FutureTopLevel>
<Rule>
<ID>unknown</ID>
<Status>Enabled</Status>
<Destination>
<Bucket>arn:aws:s3:::destination</Bucket>
</Destination>
</Rule>
</ReplicationConfiguration>
@@ -0,0 +1 @@
<ReplicationConfiguration><Role></Role><Rule><Destination><Bucket>arn:aws:s3:::destination</Bucket></Destination><ID>unknown</ID><Status>Enabled</Status></Rule></ReplicationConfiguration>
@@ -0,0 +1 @@
<ReplicationConfiguration><Rule><ID>d96i4m09k8h2vldifkag</ID><Status>Enabled</Status><Priority>1</Priority><DeleteMarkerReplication><Status>Enabled</Status></DeleteMarkerReplication><DeleteReplication><Status>Enabled</Status></DeleteReplication><Destination><Bucket>arn:minio:replication::ef5859af-120a-4218-94b5-be23470f3c60:interop-dr</Bucket></Destination><SourceSelectionCriteria><ReplicaModifications><Status>Enabled</Status></ReplicaModifications></SourceSelectionCriteria><Filter><Prefix></Prefix></Filter><ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication></Rule><Role></Role></ReplicationConfiguration>
@@ -0,0 +1 @@
<ReplicationConfiguration><Role></Role><Rule><DeleteMarkerReplication><Status>Enabled</Status></DeleteMarkerReplication><DeleteReplication><Status>Enabled</Status></DeleteReplication><Destination><Bucket>arn:minio:replication::ef5859af-120a-4218-94b5-be23470f3c60:interop-dr</Bucket></Destination><ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication><Filter><Prefix></Prefix></Filter><ID>d96i4m09k8h2vldifkag</ID><Priority>1</Priority><SourceSelectionCriteria><ReplicaModifications><Status>Enabled</Status></ReplicaModifications></SourceSelectionCriteria><Status>Enabled</Status></Rule></ReplicationConfiguration>
@@ -0,0 +1 @@
<ReplicationConfiguration><Role></Role><Rule><DeleteMarkerReplication><Status>Enabled</Status></DeleteMarkerReplication><DeleteReplication><Status>Enabled</Status></DeleteReplication><Destination><Bucket>arn:rustfs:replication::0b6f3c2a-5e6a-4d2b-9c1e-7a8f9d0e1b2c:interop-dr</Bucket><StorageClass>STANDARD</StorageClass></Destination><ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication><Filter><And><Prefix>docs/</Prefix><Tag><Key>replicate</Key><Value>yes</Value></Tag></And></Filter><ID>rustfs-dr</ID><Priority>1</Priority><SourceSelectionCriteria><ReplicaModifications><Status>Enabled</Status></ReplicaModifications></SourceSelectionCriteria><Status>Enabled</Status></Rule></ReplicationConfiguration>
@@ -0,0 +1 @@
<ReplicationConfiguration><Role></Role><Rule><DeleteMarkerReplication><Status>Enabled</Status></DeleteMarkerReplication><DeleteReplication><Status>Enabled</Status></DeleteReplication><Destination><Bucket>arn:rustfs:replication::0b6f3c2a-5e6a-4d2b-9c1e-7a8f9d0e1b2c:interop-dr</Bucket><StorageClass>STANDARD</StorageClass></Destination><ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication><Filter><And><Prefix>docs/</Prefix><Tag><Key>replicate</Key><Value>yes</Value></Tag></And></Filter><ID>rustfs-dr</ID><Priority>1</Priority><SourceSelectionCriteria><ReplicaModifications><Status>Enabled</Status></ReplicaModifications></SourceSelectionCriteria><Status>Enabled</Status></Rule></ReplicationConfiguration>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<RequestPaymentConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Payer>Requester</Payer>
</RequestPaymentConfiguration>
@@ -0,0 +1 @@
<RequestPaymentConfiguration><Payer>Requester</Payer></RequestPaymentConfiguration>
@@ -0,0 +1 @@
<RequestPaymentConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Payer>BucketOwner</Payer></RequestPaymentConfiguration>
@@ -0,0 +1 @@
<RequestPaymentConfiguration><Payer>BucketOwner</Payer></RequestPaymentConfiguration>
@@ -0,0 +1 @@
<RequestPaymentConfiguration><Payer>BucketOwner</Payer></RequestPaymentConfiguration>
@@ -0,0 +1 @@
<RequestPaymentConfiguration><Payer>BucketOwner</Payer></RequestPaymentConfiguration>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<Tagging xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<TagSet>
<Tag>
<Key>Project</Key>
<Value>Project One</Value>
</Tag>
<Tag>
<Key>User</Key>
<Value>jsmith</Value>
</Tag>
</TagSet>
</Tagging>
@@ -0,0 +1 @@
<Tagging><TagSet><Tag><Key>Project</Key><Value>Project One</Value></Tag><Tag><Key>User</Key><Value>jsmith</Value></Tag></TagSet></Tagging>
@@ -0,0 +1 @@
<Tagging><TagSet><Tag><Key>team</Key><Value>storage</Value></Tag><Tag><Key>env</Key><Value>prod</Value></Tag></TagSet></Tagging>
@@ -0,0 +1 @@
<Tagging><TagSet><Tag><Key>team</Key><Value>storage</Value></Tag><Tag><Key>env</Key><Value>prod</Value></Tag></TagSet></Tagging>
@@ -0,0 +1 @@
<Tagging><TagSet><Tag><Key>team</Key><Value>storage</Value></Tag><Tag><Key>env</Key><Value>staging</Value></Tag></TagSet></Tagging>
@@ -0,0 +1 @@
<Tagging><TagSet><Tag><Key>team</Key><Value>storage</Value></Tag><Tag><Key>env</Key><Value>staging</Value></Tag></TagSet></Tagging>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Status>Enabled</Status>
<MfaDelete>Disabled</MfaDelete>
</VersioningConfiguration>
@@ -0,0 +1 @@
<VersioningConfiguration><MfaDelete>Disabled</MfaDelete><Status>Enabled</Status></VersioningConfiguration>
@@ -0,0 +1 @@
<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>Enabled</Status></VersioningConfiguration>
@@ -0,0 +1 @@
<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>
@@ -0,0 +1 @@
<VersioningConfiguration><ExcludeFolders>true</ExcludeFolders><ExcludedPrefixes><Prefix>tmp/</Prefix></ExcludedPrefixes><ExcludedPrefixes><Prefix>cache/</Prefix></ExcludedPrefixes><Status>Enabled</Status></VersioningConfiguration>
@@ -0,0 +1 @@
<VersioningConfiguration><ExcludeFolders>true</ExcludeFolders><ExcludedPrefixes><Prefix>tmp/</Prefix></ExcludedPrefixes><ExcludedPrefixes><Prefix>cache/</Prefix></ExcludedPrefixes><Status>Enabled</Status></VersioningConfiguration>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<WebsiteConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<IndexDocument>
<Suffix>index.html</Suffix>
</IndexDocument>
<ErrorDocument>
<Key>error.html</Key>
</ErrorDocument>
<RoutingRules>
<RoutingRule>
<Condition>
<KeyPrefixEquals>docs/</KeyPrefixEquals>
<HttpErrorCodeReturnedEquals>404</HttpErrorCodeReturnedEquals>
</Condition>
<Redirect>
<Protocol>https</Protocol>
<HostName>www.example.com</HostName>
<ReplaceKeyPrefixWith>documents/</ReplaceKeyPrefixWith>
<HttpRedirectCode>301</HttpRedirectCode>
</Redirect>
</RoutingRule>
</RoutingRules>
</WebsiteConfiguration>
@@ -0,0 +1 @@
<WebsiteConfiguration><ErrorDocument><Key>error.html</Key></ErrorDocument><IndexDocument><Suffix>index.html</Suffix></IndexDocument><RoutingRules><RoutingRule><Condition><HttpErrorCodeReturnedEquals>404</HttpErrorCodeReturnedEquals><KeyPrefixEquals>docs/</KeyPrefixEquals></Condition><Redirect><HostName>www.example.com</HostName><HttpRedirectCode>301</HttpRedirectCode><Protocol>https</Protocol><ReplaceKeyPrefixWith>documents/</ReplaceKeyPrefixWith></Redirect></RoutingRule></RoutingRules></WebsiteConfiguration>
@@ -0,0 +1 @@
<WebsiteConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><IndexDocument><Suffix>index.html</Suffix></IndexDocument><ErrorDocument><Key>404.html</Key></ErrorDocument></WebsiteConfiguration>
@@ -0,0 +1 @@
<WebsiteConfiguration><ErrorDocument><Key>404.html</Key></ErrorDocument><IndexDocument><Suffix>index.html</Suffix></IndexDocument></WebsiteConfiguration>
@@ -0,0 +1 @@
<WebsiteConfiguration><ErrorDocument><Key>404.html</Key></ErrorDocument><IndexDocument><Suffix>index.html</Suffix></IndexDocument><RoutingRules><RoutingRule><Condition><KeyPrefixEquals>legacy/</KeyPrefixEquals></Condition><Redirect><HttpRedirectCode>301</HttpRedirectCode><ReplaceKeyPrefixWith>current/</ReplaceKeyPrefixWith></Redirect></RoutingRule></RoutingRules></WebsiteConfiguration>
@@ -0,0 +1 @@
<WebsiteConfiguration><ErrorDocument><Key>404.html</Key></ErrorDocument><IndexDocument><Suffix>index.html</Suffix></IndexDocument><RoutingRules><RoutingRule><Condition><KeyPrefixEquals>legacy/</KeyPrefixEquals></Condition><Redirect><HttpRedirectCode>301</HttpRedirectCode><ReplaceKeyPrefixWith>current/</ReplaceKeyPrefixWith></Redirect></RoutingRule></RoutingRules></WebsiteConfiguration>
+17 -2
View File
@@ -20,23 +20,38 @@ license.workspace = true
repository.workspace = true
rust-version.workspace = true
homepage.workspace = true
description = "S3 event type definitions for RustFS."
keywords = ["s3", "event", "types", "rustfs"]
description = "S3 event and error type definitions for RustFS."
keywords = ["s3", "event", "error", "types", "rustfs"]
categories = ["data-structures"]
build = "build.rs"
[lints]
workspace = true
[features]
default = []
# Legacy edge only: `From` conversions between this crate's error types and the
# s3s error types, so the s3s-facing `impl S3` can keep using `?` while the
# gateway migration is in flight (rustfs/backlog#2734). Off by default; the
# runtime dependency tree of this crate carries no s3s unless a consumer asks.
# DELETE BY T4.2 (rustfs/backlog#2784) together with src/compat_s3s.rs.
compat-s3s = ["dep:legacy-s3s"]
hotpath = ["hotpath/hotpath"]
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
[dependencies]
hotpath.workspace = true
http = { workspace = true }
# The s3s package under the name the legacy edge knows it by. Only
# src/compat_s3s.rs may use it; see that file's header for the footprint note.
legacy-s3s = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["raw_value"] }
[dev-dependencies]
# Oracle for the parity tests in src/compat_s3s.rs; never a normal dependency.
legacy-s3s = { workspace = true }
[lib]
doctest = false
+149
View File
@@ -0,0 +1,149 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Generates the `S3ErrorCode` enum from `src/codes.txt`.
//!
//! Responsible for: turning the byte-sorted name list into the enum, its
//! `NAMED` table, `as_str`, and the exact and lowercase name lookups.
//! Not responsible for: HTTP statuses, parsing policy, or `Display`; those are
//! hand-written in `src/error/code.rs` against the generated enum.
//! Upstream: `src/codes.txt`. Downstream: `src/error/code.rs` includes the output.
use std::env;
use std::fmt::Write as _;
use std::fs;
use std::path::Path;
const CODES: &str = "src/codes.txt";
const OUTPUT: &str = "s3_error_code.rs";
fn main() {
println!("cargo:rerun-if-changed={CODES}");
println!("cargo:rerun-if-changed=build.rs");
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("cargo always sets CARGO_MANIFEST_DIR for build scripts");
let out_dir = env::var("OUT_DIR").expect("cargo always sets OUT_DIR for build scripts");
let text = fs::read_to_string(Path::new(&manifest_dir).join(CODES))
.unwrap_or_else(|error| panic!("{CODES}: cannot read the code list: {error}"));
let names = parse_names(&text).unwrap_or_else(|error| panic!("{CODES}: {error}"));
let generated = render(&names);
fs::write(Path::new(&out_dir).join(OUTPUT), generated)
.unwrap_or_else(|error| panic!("{OUTPUT}: cannot write the generated enum: {error}"));
}
/// Reads the named variants: one identifier per line, `#` comments and blank
/// lines skipped, byte-sorted and unique, with `Custom` present exactly once
/// and excluded from the result because it is emitted as a tuple variant.
fn parse_names(text: &str) -> Result<Vec<&str>, String> {
let mut names: Vec<&str> = Vec::new();
let mut lowercase = std::collections::HashSet::new();
let mut custom_lines = 0usize;
for (index, raw) in text.lines().enumerate() {
let line_no = index + 1;
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line != raw {
return Err(format!("line {line_no}: surrounding whitespace is not allowed"));
}
if !is_variant_name(line) {
return Err(format!("line {line_no}: {line:?} is not an UpperCamelCase ASCII identifier"));
}
if line == "Custom" {
custom_lines += 1;
continue;
}
if let Some(previous) = names.last()
&& *previous >= line
{
return Err(format!(
"line {line_no}: {line:?} must come after {previous:?}; the list is byte-sorted and unique, regenerate it with the command in the header"
));
}
if !lowercase.insert(line.to_ascii_lowercase()) {
return Err(format!("line {line_no}: {line:?} collides with another name ignoring ASCII case"));
}
names.push(line);
}
if custom_lines != 1 {
return Err(format!("`Custom` must be listed exactly once, found {custom_lines} lines"));
}
if names.is_empty() {
return Err("no named variants found".to_owned());
}
Ok(names)
}
fn is_variant_name(name: &str) -> bool {
let mut chars = name.chars();
chars.next().is_some_and(|first| first.is_ascii_uppercase()) && chars.all(|c| c.is_ascii_alphanumeric())
}
fn render(names: &[&str]) -> String {
let mut out = String::new();
out.push_str("// Generated by build.rs from src/codes.txt. Do not edit.\n\n");
out.push_str("/// Error codes raised by RustFS, named exactly as they appear on the wire.\n");
out.push_str("///\n");
out.push_str("/// The named set is derived from RustFS's own call surface (`src/codes.txt`);\n");
out.push_str("/// anything outside it travels as [`S3ErrorCode::Custom`].\n");
out.push_str("#[derive(Debug, Clone, PartialEq, Eq, Hash)]\n");
out.push_str("pub enum S3ErrorCode {\n");
for name in names {
let _ = writeln!(out, " {name},");
}
out.push_str(" /// A code outside the named set, carried verbatim.\n");
out.push_str(" Custom(::std::borrow::Cow<'static, str>),\n");
out.push_str("}\n\n");
out.push_str("impl S3ErrorCode {\n");
out.push_str(" /// Every named variant in `src/codes.txt` order; `Custom` is not a member.\n");
out.push_str(" pub const NAMED: &'static [S3ErrorCode] = &[\n");
for name in names {
let _ = writeln!(out, " S3ErrorCode::{name},");
}
out.push_str(" ];\n\n");
out.push_str(" /// The wire name of the code; a `Custom` code yields its own text.\n");
out.push_str(" pub fn as_str(&self) -> &str {\n");
out.push_str(" match self {\n");
for name in names {
let _ = writeln!(out, " Self::{name} => {name:?},");
}
out.push_str(" Self::Custom(code) => code,\n");
out.push_str(" }\n");
out.push_str(" }\n\n");
out.push_str(" fn named_from_exact(name: &str) -> Option<Self> {\n");
out.push_str(" match name {\n");
for name in names {
let _ = writeln!(out, " {name:?} => Some(Self::{name}),");
}
out.push_str(" _ => None,\n");
out.push_str(" }\n");
out.push_str(" }\n\n");
out.push_str(" fn named_from_lowercase(lowercase: &str) -> Option<Self> {\n");
out.push_str(" match lowercase {\n");
for name in names {
let lower = name.to_ascii_lowercase();
let _ = writeln!(out, " {lower:?} => Some(Self::{name}),");
}
out.push_str(" _ => None,\n");
out.push_str(" }\n");
out.push_str(" }\n");
out.push_str("}\n");
out
}
+170
View File
@@ -0,0 +1,170 @@
# Variant names of `rustfs_s3_types::S3ErrorCode`.
#
# Derived from RustFS's own call surface, never from s3s: every `S3ErrorCode::<Name>`
# path and every `s3_error!(<Name>` invocation in the tree. `Custom` is listed because
# the grep finds it; build.rs emits it as the tuple variant `Custom(Cow<'static, str>)`.
# Names are byte-sorted and unique; build.rs refuses the file otherwise.
#
# Regenerate from the repository root with:
#
# { git grep -oh 'S3ErrorCode::[A-Za-z0-9]*' -- '*.rs';
# git grep -oh 's3_error!([A-Za-z0-9]*' -- '*.rs' | sed 's/s3_error!(/S3ErrorCode::/'; } \
# | sed 's/^S3ErrorCode:://' | grep -E '^[A-Z]' | LC_ALL=C sort -u
#
# The second grep exists because the macro expands to `S3ErrorCode::<Name>`: two codes
# (`MalformedPolicy`, `ServerSideEncryptionConfigurationNotFoundError`) are raised only
# through the macro. `grep -E '^[A-Z]'` drops the `from_bytes` method reference.
AccessDenied
AllAccessDisabled
AmbiguousFieldName
AuthorizationHeaderMalformed
BadDigest
BucketAlreadyExists
BucketAlreadyOwnedByYou
BucketNotEmpty
Busy
CSVParsingError
CastFailed
ClientTokenConflict
Custom
EmptyRequestBody
EntityTooLarge
EntityTooSmall
EvaluatorBindingDoesNotExist
EvaluatorInvalidArguments
EvaluatorInvalidTimestampFormatPattern
EvaluatorInvalidTimestampFormatPatternSymbol
EvaluatorInvalidTimestampFormatPatternSymbolForParsing
EvaluatorInvalidTimestampFormatPatternToken
EvaluatorTimestampFormatPatternDuplicateFields
EvaluatorTimestampFormatPatternHourClockAmPmMismatch
EvaluatorUnterminatedTimestampFormatPatternToken
ExpiredToken
ExpressionTooLong
IllegalSqlFunctionArgument
IncompleteBody
IncorrectEndpoint
IncorrectSqlFunctionArgumentType
IntegerOverflow
InternalError
InvalidAccessKeyId
InvalidArgument
InvalidBucketName
InvalidBucketState
InvalidCast
InvalidColumnIndex
InvalidCompressionFormat
InvalidDataSource
InvalidDataType
InvalidDigest
InvalidExpressionType
InvalidFileHeaderInfo
InvalidJsonType
InvalidKeyPath
InvalidLocationConstraint
InvalidObjectState
InvalidPart
InvalidPartOrder
InvalidPolicyDocument
InvalidQuoteFields
InvalidRange
InvalidRegion
InvalidRequest
InvalidRequestParameter
InvalidStorageClass
InvalidTableAlias
InvalidTag
InvalidTextEncoding
JSONParsingError
KeyTooLongError
LexerInvalidChar
LexerInvalidIONLiteral
LexerInvalidLiteral
LexerInvalidOperator
LikeInvalidInputs
MalformedPOSTRequest
MalformedPolicy
MalformedXML
MetadataTooLarge
MethodNotAllowed
MissingContentLength
MissingRequestBodyError
MissingRequiredParameter
MissingSecurityHeader
NoSuchBucket
NoSuchBucketPolicy
NoSuchCORSConfiguration
NoSuchKey
NoSuchLifecycleConfiguration
NoSuchObjectLockConfiguration
NoSuchResource
NoSuchTagSet
NoSuchUpload
NoSuchVersion
NoSuchWebsiteConfiguration
NotImplemented
NotModified
ObjectLockConfigurationNotFoundError
ObjectSerializationConflict
OperationAborted
OverMaxRecordSize
ParquetParsingError
ParseAsteriskIsNotAloneInSelectList
ParseCannotMixSqbAndWildcardInSelectList
ParseCastArity
ParseEmptySelect
ParseExpected2TokenTypes
ParseExpectedArgumentDelimiter
ParseExpectedDatePart
ParseExpectedExpression
ParseExpectedIdentForAlias
ParseExpectedIdentForAt
ParseExpectedIdentForGroupName
ParseExpectedKeyword
ParseExpectedLeftParenAfterCast
ParseExpectedLeftParenBuiltinFunctionCall
ParseExpectedLeftParenValueConstructor
ParseExpectedMember
ParseExpectedNumber
ParseExpectedRightParenBuiltinFunctionCall
ParseExpectedTokenType
ParseExpectedTypeName
ParseExpectedWhenClause
ParseInvalidContextForWildcardInSelectList
ParseInvalidTypeParam
ParseMalformedJoin
ParseMissingIdentAfterAt
ParseNonUnaryAgregateFunctionCall
ParseSelectMissingFrom
ParseUnexpectedOperator
ParseUnexpectedTerm
ParseUnexpectedToken
ParseUnknownOperator
ParseUnsupportedAlias
ParseUnsupportedCallWithStar
ParseUnsupportedCase
ParseUnsupportedCaseClause
ParseUnsupportedLiteralsGroupBy
ParseUnsupportedSelect
ParseUnsupportedSyntax
ParseUnsupportedToken
PreconditionFailed
ReplicationConfigurationNotFoundError
RequestTimeTooSkewed
RequestTimeout
ResponseInterrupted
RestoreAlreadyInProgress
ServerSideEncryptionConfigurationNotFoundError
ServiceUnavailable
SignatureDoesNotMatch
SlowDown
TruncatedInput
UnauthorizedAccess
UnexpectedContent
UnsupportedFunction
UnsupportedRangeHeader
UnsupportedScanRangeInput
UnsupportedSqlOperation
UnsupportedSqlStructure
UnsupportedSyntax
ValueParseFailure
+376
View File
@@ -0,0 +1,376 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! The legacy edge between RustFS's S3 error types and the s3s ones.
//!
//! Responsible for: the `From` conversions in both directions, field by field,
//! and the parity tests that pin this crate's statuses and parsing to the pinned
//! s3s revision, which the tests use as an oracle.
//! Not responsible for: anything the s3s-facing `impl S3` does with the result,
//! or the default messages s3s attaches to a bare code (restored by s3s's own
//! constructor on the way out, never stored here).
//! Upstream: `legacy_s3s`, the s3s package under the name the edge knows it
//! by. Downstream: `rustfs` with the `compat-s3s` feature.
//! DELETE BY T4.2 (rustfs/backlog#2784).
//!
//! Footprint note: `scripts/check_s3s_footprint.sh` counts files whose Rust
//! paths name the s3s crate directly. This file reaches s3s through the
//! renamed dependency, so that counter does not see it. The rename is stated here and
//! in the PR rather than hidden; the edge whitelist of T0.6
//! (rustfs/backlog#2741) is expected to list this file explicitly.
use crate::{S3Error, S3ErrorCode};
use std::borrow::Cow;
use std::fmt;
impl From<S3ErrorCode> for legacy_s3s::S3ErrorCode {
fn from(code: S3ErrorCode) -> Self {
match code {
S3ErrorCode::Custom(Cow::Borrowed(text)) => Self::Custom(text.into()),
S3ErrorCode::Custom(Cow::Owned(text)) => Self::Custom(text.into()),
// s3s parses every valid UTF-8 name, as its own named variant when it
// knows the name and as `Custom` otherwise, so a code s3s lacks still
// reaches the wire verbatim. Today every name is known to s3s; the
// oracle test pins that.
named => {
let Ok(code) = named.as_str().parse::<Self>();
code
}
}
}
}
impl From<legacy_s3s::S3ErrorCode> for S3ErrorCode {
fn from(code: legacy_s3s::S3ErrorCode) -> Self {
match code {
legacy_s3s::S3ErrorCode::Custom(text) => Self::Custom(Cow::Owned(String::from(&*text))),
named => {
let Ok(code) = named.as_str().parse::<Self>();
code
}
}
}
}
impl From<S3Error> for legacy_s3s::S3Error {
fn from(error: S3Error) -> Self {
let parts = error.into_parts();
let mut out = Self::new(parts.code.into());
if let Some(message) = parts.message {
out.set_message(message);
}
if let Some(request_id) = parts.request_id {
out.set_request_id(request_id);
}
if let Some(status) = parts.status {
out.set_status_code(status);
}
if let Some(source) = parts.source {
out.set_source(source);
}
if let Some(headers) = parts.headers {
out.set_headers(headers);
}
out
}
}
impl From<legacy_s3s::S3Error> for S3Error {
fn from(error: legacy_s3s::S3Error) -> Self {
let code: S3ErrorCode = error.code().clone().into();
let mut out = Self::new(code.clone());
if let Some(message) = error.message() {
out.set_message(message.to_owned());
}
if let Some(request_id) = error.request_id() {
out.set_request_id(request_id);
}
// s3s folds the code's own status into `status_code()`, so only a
// status that differs from the code's own was set explicitly.
if let Some(status) = error.status_code()
&& Some(status) != code.status_code()
{
out.set_status_code(status);
}
if let Some(headers) = error.headers() {
out.set_headers(headers.clone());
}
if error.source().is_some() {
out.set_source(Box::new(LegacyCause(error)));
}
out
}
}
/// Carries an s3s error's cause across the edge. s3s lends its cause by
/// reference only, so the whole error travels inside and this wrapper presents
/// the cause's own text and chain, as if the cause itself had been moved.
#[derive(Debug)]
struct LegacyCause(legacy_s3s::S3Error);
impl LegacyCause {
fn cause(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> {
self.0.source()
}
}
impl fmt::Display for LegacyCause {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.cause() {
Some(cause) => fmt::Display::fmt(cause, f),
None => fmt::Display::fmt(&self.0, f),
}
}
}
impl std::error::Error for LegacyCause {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.cause().and_then(std::error::Error::source)
}
}
#[cfg(test)]
mod tests {
use super::*;
use http::{HeaderMap, HeaderValue, StatusCode};
use std::io;
fn oracle(name: &str) -> legacy_s3s::S3ErrorCode {
let Ok(code) = name.parse();
code
}
fn is_custom(code: &legacy_s3s::S3ErrorCode) -> bool {
matches!(code, legacy_s3s::S3ErrorCode::Custom(_))
}
fn full_error() -> S3Error {
let mut error = S3Error::with_message(S3ErrorCode::NoSuchKey, "object is gone");
error.set_request_id("req-42");
error.set_status_code(StatusCode::FORBIDDEN);
error.set_source(Box::new(io::Error::other("disk cause")));
let mut headers = HeaderMap::new();
headers.insert("x-amz-delete-marker", HeaderValue::from_static("true"));
headers.insert("x-amz-version-id", HeaderValue::from_static("v1"));
error.set_headers(headers);
error
}
// ---- oracle: statuses and parsing ----
#[test]
fn every_code_matches_the_s3s_status() {
let mut mismatches = Vec::new();
for code in S3ErrorCode::NAMED {
let name = code.as_str();
let legacy = oracle(name);
if is_custom(&legacy) {
mismatches.push(format!("{name}: unknown to s3s"));
continue;
}
if legacy.as_str() != name {
mismatches.push(format!("{name}: s3s spells it {}", legacy.as_str()));
}
if legacy.status_code() != code.status_code() {
mismatches.push(format!("{name}: s3s {:?}, ours {:?}", legacy.status_code(), code.status_code()));
}
}
assert!(mismatches.is_empty(), "{} mismatches:\n{}", mismatches.len(), mismatches.join("\n"));
assert_eq!(oracle("TierNotFound").status_code(), None);
assert_eq!(S3ErrorCode::Custom("TierNotFound".into()).status_code(), None);
}
#[test]
fn parsing_matches_s3s_for_exact_case_folded_unknown_and_invalid_input() {
let mut inputs: Vec<Vec<u8>> = Vec::new();
for code in S3ErrorCode::NAMED {
let name = code.as_str();
inputs.push(name.into());
inputs.push(name.to_ascii_lowercase().into());
inputs.push(name.to_ascii_uppercase().into());
}
for unknown in ["TierNotFound", "", "NoSuchKeyX", " NoSuchKey", "nosuchkey "] {
inputs.push(unknown.into());
}
inputs.push(b"NoSuch\xffKey".to_vec());
for input in &inputs {
let ours = S3ErrorCode::from_bytes(input);
let theirs = legacy_s3s::S3ErrorCode::from_bytes(input);
assert_eq!(
ours.as_ref().map(S3ErrorCode::as_str),
theirs.as_ref().map(legacy_s3s::S3ErrorCode::as_str),
"{}",
String::from_utf8_lossy(input)
);
assert_eq!(
ours.as_ref().map(|code| matches!(code, S3ErrorCode::Custom(_))),
theirs.as_ref().map(is_custom),
"{}",
String::from_utf8_lossy(input)
);
}
assert_eq!(S3ErrorCode::from_bytes(b"NoSuch\xffKey"), None);
}
// ---- codes across the edge ----
#[test]
fn named_codes_round_trip_as_named_s3s_codes() {
for code in S3ErrorCode::NAMED {
let legacy: legacy_s3s::S3ErrorCode = code.clone().into();
assert!(!is_custom(&legacy), "{code} crossed the edge as Custom");
assert_eq!(legacy.as_str(), code.as_str());
let back: S3ErrorCode = legacy.into();
assert_eq!(&back, code);
}
}
#[test]
fn custom_code_round_trips_verbatim() {
for code in [
S3ErrorCode::Custom("TierNotFound".into()),
S3ErrorCode::Custom(String::from("XMinioAdminTierNotFound").into()),
] {
let legacy: legacy_s3s::S3ErrorCode = code.clone().into();
assert!(is_custom(&legacy), "{code}");
assert_eq!(legacy.as_str(), code.as_str());
let back: S3ErrorCode = legacy.into();
assert_eq!(back, code);
}
}
#[test]
fn a_custom_code_spelled_like_a_named_one_stays_custom_in_both_directions() {
let code = S3ErrorCode::Custom("NoSuchKey".into());
let legacy: legacy_s3s::S3ErrorCode = code.clone().into();
assert!(is_custom(&legacy));
assert_eq!(legacy.status_code(), None);
let back: S3ErrorCode = legacy.into();
assert_eq!(back, code);
assert_ne!(back, S3ErrorCode::NoSuchKey);
let legacy = legacy_s3s::S3ErrorCode::Custom("NoSuchKey".into());
let ours: S3ErrorCode = legacy.into();
assert_eq!(ours, S3ErrorCode::Custom("NoSuchKey".into()));
}
// ---- errors across the edge ----
#[test]
fn error_round_trip_preserves_message_request_id_status_headers_and_cause() {
let legacy: legacy_s3s::S3Error = full_error().into();
assert_eq!(legacy.code().as_str(), "NoSuchKey");
assert_eq!(legacy.message(), Some("object is gone"));
assert_eq!(legacy.request_id(), Some("req-42"));
assert_eq!(legacy.status_code(), Some(StatusCode::FORBIDDEN));
assert_eq!(legacy.source().map(ToString::to_string).as_deref(), Some("disk cause"));
let headers = legacy.headers().expect("headers crossed the edge");
assert_eq!(headers["x-amz-delete-marker"], "true");
assert_eq!(headers["x-amz-version-id"], "v1");
let back: S3Error = legacy.into();
assert_eq!(back.code(), &S3ErrorCode::NoSuchKey);
assert_eq!(back.message(), Some("object is gone"));
assert_eq!(back.request_id(), Some("req-42"));
assert_eq!(back.status_code(), Some(StatusCode::FORBIDDEN));
assert_eq!(back.source().map(ToString::to_string).as_deref(), Some("disk cause"));
let headers = back.headers().expect("headers crossed back");
assert_eq!(headers.len(), 2);
assert_eq!(headers["x-amz-delete-marker"], "true");
assert_eq!(headers["x-amz-version-id"], "v1");
assert_eq!(back.into_parts().status, Some(StatusCode::FORBIDDEN));
}
#[test]
fn a_bare_code_renders_the_same_message_s3s_gives_a_bare_code_today() {
for code in S3ErrorCode::NAMED {
let crossed: legacy_s3s::S3Error = S3Error::new(code.clone()).into();
let native = legacy_s3s::S3Error::new(code.clone().into());
assert_eq!(crossed.message(), native.message(), "{code}");
assert_eq!(crossed.status_code(), native.status_code(), "{code}");
}
}
#[test]
fn absent_fields_stay_absent_from_ours_to_s3s() {
let legacy: legacy_s3s::S3Error = S3Error::new(S3ErrorCode::Custom("TierNotFound".into())).into();
assert_eq!(legacy.message(), None);
assert_eq!(legacy.request_id(), None);
assert_eq!(legacy.status_code(), None);
assert!(legacy.source().is_none());
assert!(legacy.headers().is_none());
}
#[test]
fn absent_fields_stay_absent_from_s3s_to_ours() {
let legacy = legacy_s3s::S3Error::new(legacy_s3s::S3ErrorCode::Custom("TierNotFound".into()));
let ours: S3Error = legacy.into();
assert_eq!(ours.message(), None);
assert_eq!(ours.request_id(), None);
assert_eq!(ours.status_code(), None);
assert!(ours.source().is_none());
assert!(ours.headers().is_none());
}
#[test]
fn the_s3s_default_status_does_not_become_an_explicit_status_on_our_side() {
let legacy = legacy_s3s::S3Error::new(legacy_s3s::S3ErrorCode::NoSuchKey);
assert_eq!(legacy.status_code(), Some(StatusCode::NOT_FOUND));
let ours: S3Error = legacy.into();
assert_eq!(ours.status_code(), Some(StatusCode::NOT_FOUND));
assert_eq!(ours.into_parts().status, None);
}
#[test]
fn an_explicit_status_on_a_custom_code_survives_both_directions() {
let mut ours = S3Error::new(S3ErrorCode::Custom("XMinioKmsKeyNotFound".into()));
ours.set_status_code(StatusCode::BAD_REQUEST);
let legacy: legacy_s3s::S3Error = ours.into();
assert_eq!(legacy.status_code(), Some(StatusCode::BAD_REQUEST));
let back: S3Error = legacy.into();
assert_eq!(back.into_parts().status, Some(StatusCode::BAD_REQUEST));
}
#[test]
fn an_s3s_error_without_a_cause_converts_without_inventing_one() {
let mut legacy = legacy_s3s::S3Error::new(legacy_s3s::S3ErrorCode::InternalError);
legacy.set_message("no cause attached");
let ours: S3Error = legacy.into();
assert!(ours.source().is_none());
assert!(std::error::Error::source(&ours).is_none());
assert_eq!(ours.message(), Some("no cause attached"));
}
#[test]
fn a_cause_brought_back_from_s3s_keeps_its_text_and_its_chain() {
let mut legacy = legacy_s3s::S3Error::new(legacy_s3s::S3ErrorCode::InternalError);
legacy.set_source(Box::new(io::Error::new(io::ErrorKind::TimedOut, "peer timed out")));
let ours: S3Error = legacy.into();
let cause = ours.source().expect("cause crossed back");
assert_eq!(cause.to_string(), "peer timed out");
assert!(std::error::Error::source(cause).is_none(), "an io::Error has no further cause");
assert_eq!(
std::error::Error::source(&ours).map(ToString::to_string).as_deref(),
Some("peer timed out")
);
}
#[test]
fn headers_do_not_appear_when_none_were_set() {
let legacy: legacy_s3s::S3Error = S3Error::with_message(S3ErrorCode::NoSuchKey, "m").into();
assert!(legacy.headers().is_none());
let back: S3Error = legacy.into();
assert!(back.headers().is_none());
}
}
+251
View File
@@ -0,0 +1,251 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! `S3ErrorCode`: the generated code set with its HTTP statuses and parsing.
//!
//! Responsible for: including the enum that `build.rs` generates from
//! `src/codes.txt`, the hand-written status table, wire-name parsing
//! (`from_bytes`, `FromStr`) and `Display`.
//! Not responsible for: default messages (there are none here; the legacy edge
//! restores s3s's own) or carrying message, status and headers (`S3Error`).
//! Upstream: the `build.rs` output. Downstream: `S3Error`, `compat_s3s`.
use http::StatusCode;
use std::borrow::Cow;
use std::convert::Infallible;
use std::fmt;
use std::str::FromStr;
include!(concat!(env!("OUT_DIR"), "/s3_error_code.rs"));
impl S3ErrorCode {
/// The HTTP status a response carrying this code uses; `None` for a
/// [`S3ErrorCode::Custom`] code, whose status the raiser sets explicitly.
///
/// Sources: the AWS S3 error response list
/// (<https://docs.aws.amazon.com/AmazonS3/latest/API/ErrorResponses.html>)
/// for the REST codes, and the SelectObjectContent error list
/// (<https://docs.aws.amazon.com/AmazonS3/latest/API/API_SelectObjectContent.html>),
/// whose codes are all 400. Every row is checked against the pinned s3s
/// behaviour by `compat_s3s::tests::every_code_matches_the_s3s_status`.
/// The match is exhaustive on purpose: a code added to `codes.txt` without
/// a row here does not compile.
pub fn status_code(&self) -> Option<StatusCode> {
let status = match self {
Self::NotModified => StatusCode::NOT_MODIFIED,
Self::UnauthorizedAccess => StatusCode::UNAUTHORIZED,
Self::AccessDenied
| Self::AllAccessDisabled
| Self::InvalidAccessKeyId
| Self::InvalidObjectState
// s3s answers 403 here; AWS does not document InvalidRegion.
| Self::InvalidRegion
| Self::RequestTimeTooSkewed
| Self::SignatureDoesNotMatch => StatusCode::FORBIDDEN,
Self::NoSuchBucket
| Self::NoSuchBucketPolicy
| Self::NoSuchCORSConfiguration
| Self::NoSuchKey
| Self::NoSuchLifecycleConfiguration
| Self::NoSuchObjectLockConfiguration
| Self::NoSuchResource
| Self::NoSuchTagSet
| Self::NoSuchUpload
| Self::NoSuchVersion
| Self::NoSuchWebsiteConfiguration
| Self::ObjectLockConfigurationNotFoundError
| Self::ReplicationConfigurationNotFoundError => StatusCode::NOT_FOUND,
Self::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
Self::BucketAlreadyExists
| Self::BucketAlreadyOwnedByYou
| Self::BucketNotEmpty
| Self::ClientTokenConflict
| Self::InvalidBucketState
| Self::OperationAborted
| Self::RestoreAlreadyInProgress => StatusCode::CONFLICT,
Self::MissingContentLength => StatusCode::LENGTH_REQUIRED,
Self::PreconditionFailed => StatusCode::PRECONDITION_FAILED,
Self::InvalidRange => StatusCode::RANGE_NOT_SATISFIABLE,
Self::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
Self::NotImplemented => StatusCode::NOT_IMPLEMENTED,
Self::Busy | Self::ServiceUnavailable | Self::SlowDown => StatusCode::SERVICE_UNAVAILABLE,
// Rows pinned to the s3s behaviour RustFS ships today where the AWS
// list says otherwise (a ledger item for rustfs/backlog#2684, not a
// change this crate may make): IncorrectEndpoint is 421 at AWS,
// ServerSideEncryptionConfigurationNotFoundError is 404 at AWS, and
// ResponseInterrupted is undocumented at AWS.
Self::IncorrectEndpoint
| Self::ResponseInterrupted
| Self::ServerSideEncryptionConfigurationNotFoundError
// REST request errors.
| Self::AuthorizationHeaderMalformed
| Self::BadDigest
| Self::EntityTooLarge
| Self::EntityTooSmall
| Self::ExpiredToken
| Self::IncompleteBody
| Self::InvalidArgument
| Self::InvalidBucketName
| Self::InvalidDigest
| Self::InvalidLocationConstraint
| Self::InvalidPart
| Self::InvalidPartOrder
| Self::InvalidPolicyDocument
| Self::InvalidRequest
| Self::InvalidRequestParameter
| Self::InvalidStorageClass
| Self::InvalidTag
| Self::KeyTooLongError
| Self::MalformedPOSTRequest
| Self::MalformedPolicy
| Self::MalformedXML
| Self::MetadataTooLarge
| Self::MissingRequestBodyError
| Self::MissingRequiredParameter
| Self::MissingSecurityHeader
| Self::RequestTimeout
| Self::UnexpectedContent
| Self::UnsupportedRangeHeader => StatusCode::BAD_REQUEST,
// SelectObjectContent errors.
Self::AmbiguousFieldName
| Self::CSVParsingError
| Self::CastFailed
| Self::EmptyRequestBody
| Self::EvaluatorBindingDoesNotExist
| Self::EvaluatorInvalidArguments
| Self::EvaluatorInvalidTimestampFormatPattern
| Self::EvaluatorInvalidTimestampFormatPatternSymbol
| Self::EvaluatorInvalidTimestampFormatPatternSymbolForParsing
| Self::EvaluatorInvalidTimestampFormatPatternToken
| Self::EvaluatorTimestampFormatPatternDuplicateFields
| Self::EvaluatorTimestampFormatPatternHourClockAmPmMismatch
| Self::EvaluatorUnterminatedTimestampFormatPatternToken
| Self::ExpressionTooLong
| Self::IllegalSqlFunctionArgument
| Self::IncorrectSqlFunctionArgumentType
| Self::IntegerOverflow
| Self::InvalidCast
| Self::InvalidColumnIndex
| Self::InvalidCompressionFormat
| Self::InvalidDataSource
| Self::InvalidDataType
| Self::InvalidExpressionType
| Self::InvalidFileHeaderInfo
| Self::InvalidJsonType
| Self::InvalidKeyPath
| Self::InvalidQuoteFields
| Self::InvalidTableAlias
| Self::InvalidTextEncoding
| Self::JSONParsingError
| Self::LexerInvalidChar
| Self::LexerInvalidIONLiteral
| Self::LexerInvalidLiteral
| Self::LexerInvalidOperator
| Self::LikeInvalidInputs
| Self::ObjectSerializationConflict
| Self::OverMaxRecordSize
| Self::ParquetParsingError
| Self::ParseAsteriskIsNotAloneInSelectList
| Self::ParseCannotMixSqbAndWildcardInSelectList
| Self::ParseCastArity
| Self::ParseEmptySelect
| Self::ParseExpected2TokenTypes
| Self::ParseExpectedArgumentDelimiter
| Self::ParseExpectedDatePart
| Self::ParseExpectedExpression
| Self::ParseExpectedIdentForAlias
| Self::ParseExpectedIdentForAt
| Self::ParseExpectedIdentForGroupName
| Self::ParseExpectedKeyword
| Self::ParseExpectedLeftParenAfterCast
| Self::ParseExpectedLeftParenBuiltinFunctionCall
| Self::ParseExpectedLeftParenValueConstructor
| Self::ParseExpectedMember
| Self::ParseExpectedNumber
| Self::ParseExpectedRightParenBuiltinFunctionCall
| Self::ParseExpectedTokenType
| Self::ParseExpectedTypeName
| Self::ParseExpectedWhenClause
| Self::ParseInvalidContextForWildcardInSelectList
| Self::ParseInvalidTypeParam
| Self::ParseMalformedJoin
| Self::ParseMissingIdentAfterAt
| Self::ParseNonUnaryAgregateFunctionCall
| Self::ParseSelectMissingFrom
| Self::ParseUnexpectedOperator
| Self::ParseUnexpectedTerm
| Self::ParseUnexpectedToken
| Self::ParseUnknownOperator
| Self::ParseUnsupportedAlias
| Self::ParseUnsupportedCallWithStar
| Self::ParseUnsupportedCase
| Self::ParseUnsupportedCaseClause
| Self::ParseUnsupportedLiteralsGroupBy
| Self::ParseUnsupportedSelect
| Self::ParseUnsupportedSyntax
| Self::ParseUnsupportedToken
| Self::TruncatedInput
| Self::UnsupportedFunction
| Self::UnsupportedScanRangeInput
| Self::UnsupportedSqlOperation
| Self::UnsupportedSqlStructure
| Self::UnsupportedSyntax
| Self::ValueParseFailure => StatusCode::BAD_REQUEST,
Self::Custom(_) => return None,
};
Some(status)
}
/// Parses a wire code: the exact name first, then the name ignoring ASCII
/// case, and otherwise [`S3ErrorCode::Custom`] carrying the input verbatim.
/// Only bytes that are not UTF-8 are refused.
pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
let text = std::str::from_utf8(bytes).ok()?;
Some(Self::parse(text))
}
fn parse(text: &str) -> Self {
Self::named_from_exact(text)
.or_else(|| Self::named_from_lowercase(&text.to_ascii_lowercase()))
.unwrap_or_else(|| Self::Custom(Cow::Owned(text.to_owned())))
}
}
impl FromStr for S3ErrorCode {
type Err = Infallible;
fn from_str(text: &str) -> Result<Self, Self::Err> {
Ok(Self::parse(text))
}
}
impl fmt::Display for S3ErrorCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
+233
View File
@@ -0,0 +1,233 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! The S3 error carrier shared by every RustFS crate.
//!
//! Responsible for: `S3Error` and its accessor contract, `S3Result`, `StdError`,
//! `S3ErrorParts`, and the `s3_error` macro.
//! Not responsible for: the code set and its statuses (`code.rs`), s3s
//! conversions (`crate::compat_s3s`), or rendering an error onto the wire.
//! Upstream: `http`. Downstream: every crate that raises S3 errors; the legacy
//! edge converts through `compat_s3s`, the gateway edge through `into_parts`.
mod code;
use http::{HeaderMap, StatusCode};
use std::borrow::Cow;
use std::convert::Infallible;
use std::fmt;
pub use code::S3ErrorCode;
/// A boxed, thread-safe error cause.
pub type StdError = Box<dyn std::error::Error + Send + Sync + 'static>;
/// `Result` with [`S3Error`] as the default error type.
pub type S3Result<T = (), E = S3Error> = Result<T, E>;
/// An S3 error: a code plus the optional message, request id, explicit HTTP
/// status, cause, and response headers.
///
/// The fields live behind one box so `S3Result<T>` stays pointer-sized on the
/// error path. `status_code()` answers with the explicit status when one was
/// set and with the code's own status otherwise, so a bare `new(code)` already
/// carries the status the wire needs.
#[derive(Debug)]
pub struct S3Error(Box<S3ErrorParts>);
/// The owned fields of an [`S3Error`], as handed out by [`S3Error::into_parts`].
#[derive(Debug)]
pub struct S3ErrorParts {
/// The wire error code.
pub code: S3ErrorCode,
/// The message, when one was given; there is no per-code default here.
pub message: Option<Cow<'static, str>>,
/// The request id, when one was attached.
pub request_id: Option<String>,
/// The explicitly set status only; `None` means "use the code's status".
pub status: Option<StatusCode>,
/// The cause, when one was attached.
pub source: Option<StdError>,
/// Response headers that must accompany the error, when any.
pub headers: Option<HeaderMap>,
}
impl S3Error {
/// An error carrying only the code.
pub fn new(code: S3ErrorCode) -> Self {
Self(Box::new(S3ErrorParts {
code,
message: None,
request_id: None,
status: None,
source: None,
headers: None,
}))
}
/// An error with a message.
pub fn with_message(code: S3ErrorCode, message: impl Into<Cow<'static, str>>) -> Self {
let mut error = Self::new(code);
error.0.message = Some(message.into());
error
}
/// An error whose message is rendered from format arguments; a plain
/// literal is borrowed rather than copied. The `s3_error` macro expands to
/// this constructor.
pub fn with_message_fmt(code: S3ErrorCode, args: fmt::Arguments<'_>) -> Self {
let message = match args.as_str() {
Some(literal) => Cow::Borrowed(literal),
None => Cow::Owned(args.to_string()),
};
Self::with_message(code, message)
}
/// An error with a cause and no message.
pub fn with_source(code: S3ErrorCode, source: StdError) -> Self {
let mut error = Self::new(code);
error.0.source = Some(source);
error
}
/// An `InternalError` wrapping `source`, for `map_err`.
pub fn internal_error<E>(source: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
Self::with_source(S3ErrorCode::InternalError, Box::new(source))
}
/// Replaces the code; an explicit status, if any, is kept.
pub fn set_code(&mut self, code: S3ErrorCode) {
self.0.code = code;
}
/// Replaces the message.
pub fn set_message(&mut self, message: impl Into<Cow<'static, str>>) {
self.0.message = Some(message.into());
}
/// Replaces the request id.
pub fn set_request_id(&mut self, request_id: impl Into<String>) {
self.0.request_id = Some(request_id.into());
}
/// Replaces the cause.
pub fn set_source(&mut self, source: StdError) {
self.0.source = Some(source);
}
/// Pins the HTTP status, overriding the code's own.
pub fn set_status_code(&mut self, status: StatusCode) {
self.0.status = Some(status);
}
/// Replaces the response headers.
pub fn set_headers(&mut self, headers: HeaderMap) {
self.0.headers = Some(headers);
}
/// The wire error code.
pub fn code(&self) -> &S3ErrorCode {
&self.0.code
}
/// The message, if one was given.
pub fn message(&self) -> Option<&str> {
self.0.message.as_deref()
}
/// The request id, if one was attached.
pub fn request_id(&self) -> Option<&str> {
self.0.request_id.as_deref()
}
/// The cause, if one was attached.
pub fn source(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> {
self.0.source.as_deref()
}
/// The explicit status if one was set, otherwise the code's own status;
/// `None` only for a [`S3ErrorCode::Custom`] code without an explicit status.
pub fn status_code(&self) -> Option<StatusCode> {
self.0.status.or_else(|| self.0.code.status_code())
}
/// Response headers that must accompany the error, if any.
pub fn headers(&self) -> Option<&HeaderMap> {
self.0.headers.as_ref()
}
/// Takes the error apart, for conversion into another error type.
pub fn into_parts(self) -> S3ErrorParts {
*self.0
}
}
impl fmt::Display for S3Error {
/// Renders the present fields in the same shape the s3s error used, so log
/// lines do not change when a crate switches to this type.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let parts = &*self.0;
let mut out = f.debug_struct("S3Error");
out.field("code", &parts.code);
if let Some(message) = &parts.message {
out.field("message", message);
}
if let Some(request_id) = &parts.request_id {
out.field("request_id", request_id);
}
if let Some(status) = &parts.status {
out.field("status_code", status);
}
if let Some(source) = &parts.source {
out.field("source", source);
}
out.finish_non_exhaustive()
}
}
impl std::error::Error for S3Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.0.source.as_deref().map(|source| source as _)
}
}
impl From<S3ErrorCode> for S3Error {
fn from(code: S3ErrorCode) -> Self {
Self::new(code)
}
}
impl From<Infallible> for S3Error {
fn from(never: Infallible) -> Self {
match never {}
}
}
/// Builds an [`S3Error`] from a code identifier, optionally with a message.
///
/// Three call forms: the code alone, the code with a literal message, and the
/// code with a format string and its arguments. A message literal is borrowed;
/// a formatted message is rendered once.
#[macro_export]
macro_rules! s3_error {
($code:ident $(,)?) => {
$crate::S3Error::new($crate::S3ErrorCode::$code)
};
($code:ident, $($arg:tt)+) => {
$crate::S3Error::with_message_fmt($crate::S3ErrorCode::$code, ::std::format_args!($($arg)+))
};
}
+12
View File
@@ -12,6 +12,18 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! RustFS-owned S3 contract types: event names and the S3 error carrier.
//!
//! Responsible for: `EventName`, and the error surface (`S3Error`, `S3ErrorCode`,
//! `S3Result`, the `s3_error` macro) that every RustFS crate raises S3 errors with.
//! Not responsible for: I/O, global state, wire rendering, or any s3s type; the
//! legacy conversions live behind the `compat-s3s` feature in `compat_s3s` only.
//! Upstream: `http`. Downstream: every crate that raises or inspects S3 errors.
#[cfg(any(test, feature = "compat-s3s"))]
mod compat_s3s;
mod error;
mod event_name;
pub use error::{S3Error, S3ErrorCode, S3ErrorParts, S3Result, StdError};
pub use event_name::{EventName, ParseEventNameError, event_schema_version};
+358
View File
@@ -0,0 +1,358 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Public-surface tests for the S3 error types: the three macro call forms, the
//! `S3Error` accessor contract, and `S3ErrorCode` parsing and status lookup.
//! Not responsible for: s3s parity, which `src/compat_s3s.rs` owns.
//! Upstream: the crate's public API only. Downstream: none.
use http::{HeaderMap, HeaderValue, StatusCode};
// Imported under an alias on purpose: the s3s footprint ratchet still counts
// `s3_error` invocation lines as s3s usage, and tests of the RustFS-owned macro
// must not move that counter.
use rustfs_s3_types::s3_error as s3err;
use rustfs_s3_types::{S3Error, S3ErrorCode, S3Result, StdError};
use std::convert::Infallible;
use std::io;
const CODES: &str = include_str!("../src/codes.txt");
fn listed_names() -> Vec<&'static str> {
CODES
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#') && *line != "Custom")
.collect()
}
fn cause_text(err: &S3Error) -> Option<String> {
err.source().map(ToString::to_string)
}
// ---- the three macro call forms ----
#[test]
fn macro_code_only_form() {
let err = s3err!(NoSuchKey);
assert_eq!(err.code(), &S3ErrorCode::NoSuchKey);
assert_eq!(err.message(), None);
assert_eq!(err.status_code(), Some(StatusCode::NOT_FOUND));
}
#[test]
fn macro_literal_message_form() {
let err = s3err!(InvalidRequest, "bucket name is empty");
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
assert_eq!(err.message(), Some("bucket name is empty"));
}
#[test]
fn macro_format_form_with_positional_and_captured_arguments() {
let bucket = "b1";
let err = s3err!(InvalidArgument, "bucket {bucket} part {}", 7);
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
assert_eq!(err.message(), Some("bucket b1 part 7"));
}
// The multi-line call shape is the subject of this test; rustfmt would fold it.
#[rustfmt::skip]
#[test]
fn macro_multi_line_and_trailing_comma_forms_match_the_single_line_form() {
let multi = s3err!(
InternalError,
"disk {} offline",
3,
);
let single = s3err!(InternalError, "disk {} offline", 3);
assert_eq!(multi.code(), single.code());
assert_eq!(multi.message(), single.message());
let bare = s3err!(InternalError,);
assert_eq!(bare.message(), None);
}
#[test]
fn macro_literal_form_does_not_treat_braces_as_a_message_to_format_later() {
let err = s3err!(InvalidRequest, "{{literal braces}}");
assert_eq!(err.message(), Some("{literal braces}"));
}
#[test]
fn macro_errors_propagate_with_the_question_mark_operator() {
fn inner() -> S3Result<()> {
Err(s3err!(AccessDenied))
}
fn outer() -> S3Result<u8> {
inner()?;
Ok(1)
}
assert_eq!(outer().unwrap_err().code(), &S3ErrorCode::AccessDenied);
}
// ---- S3Error contract ----
#[test]
fn new_sets_nothing_but_the_code() {
let err = S3Error::new(S3ErrorCode::NoSuchBucket);
assert_eq!(err.code(), &S3ErrorCode::NoSuchBucket);
assert_eq!(err.message(), None);
assert_eq!(err.request_id(), None);
assert!(err.source().is_none());
assert!(err.headers().is_none());
}
#[test]
fn status_code_falls_back_to_the_code_default() {
assert_eq!(S3Error::new(S3ErrorCode::NoSuchKey).status_code(), Some(StatusCode::NOT_FOUND));
assert_eq!(
S3Error::new(S3ErrorCode::InternalError).status_code(),
Some(StatusCode::INTERNAL_SERVER_ERROR)
);
}
#[test]
fn status_code_prefers_an_explicit_status_over_the_code_default() {
let mut err = S3Error::new(S3ErrorCode::NoSuchKey);
err.set_status_code(StatusCode::FORBIDDEN);
assert_eq!(err.status_code(), Some(StatusCode::FORBIDDEN));
}
#[test]
fn custom_code_without_an_explicit_status_has_no_status() {
let err = S3Error::new(S3ErrorCode::Custom("TierNotFound".into()));
assert_eq!(err.status_code(), None);
}
#[test]
fn set_code_moves_the_default_status_with_the_code() {
let mut err = S3Error::new(S3ErrorCode::NoSuchKey);
err.set_code(S3ErrorCode::ServiceUnavailable);
assert_eq!(err.code(), &S3ErrorCode::ServiceUnavailable);
assert_eq!(err.status_code(), Some(StatusCode::SERVICE_UNAVAILABLE));
err.set_code(S3ErrorCode::Custom("X".into()));
assert_eq!(err.status_code(), None);
}
#[test]
fn set_code_does_not_disturb_an_explicit_status() {
let mut err = S3Error::new(S3ErrorCode::NoSuchKey);
err.set_status_code(StatusCode::GONE);
err.set_code(S3ErrorCode::InternalError);
assert_eq!(err.status_code(), Some(StatusCode::GONE));
}
#[test]
fn with_message_accepts_static_and_owned_strings() {
let fixed = S3Error::with_message(S3ErrorCode::InvalidPart, "static");
let owned = S3Error::with_message(S3ErrorCode::InvalidPart, String::from("owned"));
assert_eq!(fixed.message(), Some("static"));
assert_eq!(owned.message(), Some("owned"));
}
#[test]
fn set_message_replaces_rather_than_appends() {
let mut err = S3Error::with_message(S3ErrorCode::InvalidPart, "first");
err.set_message("second");
assert_eq!(err.message(), Some("second"));
}
#[test]
fn with_source_and_internal_error_expose_the_cause_both_ways() {
let err = S3Error::internal_error(io::Error::other("disk gone"));
assert_eq!(err.code(), &S3ErrorCode::InternalError);
assert_eq!(cause_text(&err).as_deref(), Some("disk gone"));
assert_eq!(std::error::Error::source(&err).map(ToString::to_string).as_deref(), Some("disk gone"));
let boxed: StdError = Box::new(io::Error::other("link reset"));
let err = S3Error::with_source(S3ErrorCode::ServiceUnavailable, boxed);
assert_eq!(err.code(), &S3ErrorCode::ServiceUnavailable);
assert_eq!(err.message(), None);
assert_eq!(cause_text(&err).as_deref(), Some("link reset"));
}
#[test]
fn set_source_replaces_the_previous_cause() {
let mut err = S3Error::internal_error(io::Error::other("first"));
err.set_source(Box::new(io::Error::other("second")));
assert_eq!(cause_text(&err).as_deref(), Some("second"));
}
#[test]
fn headers_and_request_id_are_absent_until_set() {
let mut err = S3Error::new(S3ErrorCode::PreconditionFailed);
assert!(err.headers().is_none());
assert_eq!(err.request_id(), None);
let mut headers = HeaderMap::new();
headers.insert("x-amz-expiration", HeaderValue::from_static("soon"));
err.set_headers(headers);
err.set_request_id("req-1");
assert_eq!(err.headers().map(|h| h["x-amz-expiration"].as_bytes()), Some(&b"soon"[..]));
assert_eq!(err.request_id(), Some("req-1"));
}
#[test]
fn into_parts_hands_back_every_field_owned() {
let mut err = S3Error::with_message(S3ErrorCode::InvalidRange, "bad range");
err.set_request_id("req-9");
err.set_status_code(StatusCode::RANGE_NOT_SATISFIABLE);
err.set_source(Box::new(io::Error::other("cause")));
let mut headers = HeaderMap::new();
headers.insert("content-range", HeaderValue::from_static("bytes */10"));
err.set_headers(headers);
let parts = err.into_parts();
assert_eq!(parts.code, S3ErrorCode::InvalidRange);
assert_eq!(parts.message.as_deref(), Some("bad range"));
assert_eq!(parts.request_id.as_deref(), Some("req-9"));
assert_eq!(parts.status, Some(StatusCode::RANGE_NOT_SATISFIABLE));
assert_eq!(parts.source.map(|s| s.to_string()).as_deref(), Some("cause"));
assert_eq!(parts.headers.map(|h| h.len()), Some(1));
}
#[test]
fn into_parts_reports_the_explicit_status_only() {
let parts = S3Error::new(S3ErrorCode::NoSuchKey).into_parts();
assert_eq!(parts.status, None, "the code default is not an explicit status");
assert_eq!(parts.message, None);
assert_eq!(parts.request_id, None);
assert!(parts.source.is_none());
assert!(parts.headers.is_none());
}
#[test]
fn display_names_the_code_and_the_present_fields_only() {
let err = S3Error::with_message(S3ErrorCode::NoSuchKey, "gone");
let text = err.to_string();
assert!(text.contains("NoSuchKey") && text.contains("gone"), "{text}");
let bare = S3Error::new(S3ErrorCode::NoSuchKey).to_string();
assert!(bare.contains("NoSuchKey"), "{bare}");
assert!(!bare.contains("message") && !bare.contains("request_id"), "{bare}");
}
#[test]
fn from_code_and_from_infallible_conversions_exist() {
let err: S3Error = S3ErrorCode::SlowDown.into();
assert_eq!(err.code(), &S3ErrorCode::SlowDown);
fn never(value: Result<(), Infallible>) -> S3Result<()> {
value?;
Ok(())
}
assert!(never(Ok(())).is_ok());
}
#[test]
fn s3_error_is_send_sync_and_keeps_results_pointer_sized() {
fn assert_send_sync<T: Send + Sync + 'static>() {}
assert_send_sync::<S3Error>();
assert_eq!(std::mem::size_of::<S3Result<()>>(), std::mem::size_of::<usize>());
}
// ---- S3ErrorCode: generated table, statuses, parsing ----
#[test]
fn named_table_matches_the_code_list_exactly() {
let listed = listed_names();
let generated: Vec<&str> = S3ErrorCode::NAMED.iter().map(S3ErrorCode::as_str).collect();
assert_eq!(generated, listed);
assert!(!S3ErrorCode::NAMED.iter().any(|code| matches!(code, S3ErrorCode::Custom(_))));
}
#[test]
fn code_list_is_byte_sorted_and_unique() {
let listed = listed_names();
let mut sorted = listed.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(listed, sorted);
}
#[test]
fn every_named_code_has_an_error_or_not_modified_status() {
for code in S3ErrorCode::NAMED {
let status = code.status_code().unwrap_or_else(|| panic!("{code} has no status"));
assert!(
status == StatusCode::NOT_MODIFIED || status.is_client_error() || status.is_server_error(),
"{code}: {status}"
);
}
}
#[test]
fn as_str_and_display_are_the_wire_name() {
assert_eq!(S3ErrorCode::NoSuchKey.as_str(), "NoSuchKey");
assert_eq!(S3ErrorCode::NoSuchKey.to_string(), "NoSuchKey");
assert_eq!(S3ErrorCode::Custom("TierNotFound".into()).as_str(), "TierNotFound");
assert_eq!(S3ErrorCode::Custom("TierNotFound".into()).to_string(), "TierNotFound");
}
#[test]
fn parsing_recovers_every_named_code_from_its_exact_name() {
for code in S3ErrorCode::NAMED {
assert_eq!(S3ErrorCode::from_bytes(code.as_str().as_bytes()).as_ref(), Some(code));
assert_eq!(code.as_str().parse::<S3ErrorCode>().as_ref(), Ok(code));
}
}
#[test]
fn parsing_is_ascii_case_insensitive() {
assert_eq!(S3ErrorCode::from_bytes(b"nosuchkey"), Some(S3ErrorCode::NoSuchKey));
assert_eq!(S3ErrorCode::from_bytes(b"NOSUCHKEY"), Some(S3ErrorCode::NoSuchKey));
assert_eq!("noSuchKEY".parse(), Ok(S3ErrorCode::NoSuchKey));
}
#[test]
fn parsing_rejects_invalid_utf8() {
assert_eq!(S3ErrorCode::from_bytes(b"NoSuch\xffKey"), None);
assert_eq!(S3ErrorCode::from_bytes(&[0xC3]), None);
}
#[test]
fn parsing_never_promotes_an_unknown_name_to_a_named_variant() {
for input in [
"NoSuchKeyX",
"NoSuchKe",
" NoSuchKey",
"NoSuchKey ",
"No Such Key",
"TierNotFound",
"",
] {
let parsed = S3ErrorCode::from_bytes(input.as_bytes()).unwrap_or_else(|| panic!("{input:?} is valid UTF-8"));
assert_eq!(parsed, S3ErrorCode::Custom(input.to_owned().into()), "{input:?}");
assert_eq!(input.parse::<S3ErrorCode>(), Ok(parsed), "{input:?}");
}
}
#[test]
fn parsing_keeps_the_original_spelling_of_an_unknown_name() {
assert_eq!(
S3ErrorCode::from_bytes(b"tiernotfound")
.map(|c| c.as_str().to_owned())
.as_deref(),
Some("tiernotfound")
);
}
#[test]
fn a_custom_code_never_equals_the_named_code_with_the_same_text() {
let custom = S3ErrorCode::Custom("NoSuchKey".into());
assert_ne!(custom, S3ErrorCode::NoSuchKey);
assert_eq!(custom.as_str(), S3ErrorCode::NoSuchKey.as_str());
assert_eq!(custom.status_code(), None);
}
#[test]
fn custom_codes_compare_by_text_not_by_ownership() {
assert_eq!(S3ErrorCode::Custom("X".into()), S3ErrorCode::Custom(String::from("X").into()));
assert_ne!(S3ErrorCode::Custom("X".into()), S3ErrorCode::Custom("x".into()));
}
+4 -3
View File
@@ -19,9 +19,9 @@ Every PR enters `ci.yml`. The `classify-changes` job uses the base revision of `
PRs changing only Rust sources under `crates/e2e_test/src/` and known E2E selection digests (optionally with those documentation paths) use the `e2e` scope. They keep workspace validation, the server build, E2E smoke, and both S3 lanes; independent workflows retain their path selection. Connect boundary, serial ILM, optional protocol/rio-v2, and real io_uring jobs are skipped because their sources and test inputs are unchanged. Manifests, fixtures, shared nextest configuration, scripts, production sources, and unknown paths still select `full`. A dependency on `e2e_test` from another workspace member or reachable local dependency disables this shortcut. All non-PR events retain their full selection.
`required-checks` runs even after failed or skipped dependencies. `scripts/ci_gate.py verify` rejects missing jobs, unexpected jobs, failure, cancellation, and unexpected skips; optional lanes are required only on their declared events. `Workspace Test and Lint` is the ordinary Rust job, while `Test and Lint` uniquely names the aggregate. New validation jobs must update both its direct dependencies and the script contract. Test this wiring and its failure cases with `python3 scripts/ci_gate.py --self-test`.
`required-checks` runs even after failed or skipped dependencies. `scripts/ci_gate.py verify` rejects missing jobs, unexpected jobs, failure, cancellation, and unexpected skips; optional lanes are required only on their declared events. `Workspace Test and Lint` is the ordinary Rust job, while `Test and Lint` uniquely names the aggregate. New validation jobs must update both its direct dependencies and the script contract. A report-only lane is registered in `REPORT_ONLY_JOBS` instead: it stays out of the aggregate, must keep its distinguishing environment, and must keep the steps it reruns identical to the required job it mirrors. Test this wiring and its failure cases with `python3 scripts/ci_gate.py --self-test`.
The `gateway/integration` ruleset (`24663286`) requires the same three contexts with the same review rules for the s3s to gateway integration branch (rustfs/backlog#2734). It omits `required_linear_history` because `gateway-integration-sync.yml` lands `--no-ff` merge commits from `main` on that branch every weekday, and it names the `rustfs-cla-assistant` app as the bypass actor that pushes them. `ci.yml` runs on pull requests targeting either branch.
The `gateway/integration` ruleset (`24663286`) requires the same three contexts with the same review rules for the s3s to gateway integration branch (rustfs/backlog#2734). It omits `required_linear_history` because `gateway-integration-sync.yml` lands `--no-ff` merge commits from `main` on that branch every weekday, and it names the `rustfs-cla-assistant` app as the bypass actor that pushes them. `ci.yml` runs on pull requests targeting either branch and on pushes to either branch; a push to `gateway/integration` also runs `e2e-full`, because the daily merge commit lands without a pull request and must be proven like a `main` push.
Verify the live rule before changing merge policy:
@@ -54,6 +54,7 @@ The four site-replication state-writer concurrency proofs reserve the available
| PR, non-doc change | `Build RustFS Debug Binary` | `ci.yml` `build-rustfs-debug-binary` | Via aggregate; prerequisite for black-box jobs | `python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks` (binary plus its `rustfs.e2e.json` sidecar) |
| PR, full selection | `io_uring Integration (real)` | `ci.yml` `uring-integration` | Via aggregate | `cargo test -p rustfs-ecstore --lib uring_ -- --test-threads=1 --nocapture` |
| PR, non-doc change | `End-to-End Tests` | `ci.yml` `e2e-tests` | Via aggregate | `python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke -p e2e_test`, then `./scripts/e2e-run.sh ./target/debug/rustfs <data-dir>` under the same wrapper; membership guards `scripts/check_test_wiring.py --check-profile e2e-smoke <listing.json>` and `scripts/check_security_smoke_count.sh check <listing.json>` |
| PR targeting or push to `gateway/integration`, non-doc change | `e2e-smoke (gateway, report-only until T2.10)` | `ci.yml` `e2e-tests-gateway` | Report-only until rustfs/backlog#2734 T2.10 makes it required; `continue-on-error`, outside the aggregate | `RUSTFS_S3_STACK=gateway python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke -p e2e_test`; `scripts/ci_gate.py` keeps its archive and run steps identical to `e2e-tests`, which sets `RUSTFS_S3_STACK=legacy` on this branch |
| PR, non-doc change | `S3 Implemented Tests` | `ci.yml` `s3-implemented-tests` | Via aggregate | build `rustfs`, then `scripts/s3-tests/run.sh` with the job's `DEPLOY_MODE` / `TEST_MODE` / `MAXFAIL` env |
| PR, non-doc change | `S3 Lifecycle Behavior Tests` | `ci.yml` `s3-lifecycle-behavior-tests` | Via aggregate | `scripts/s3-tests/run.sh` with the job's accelerated-scanner env |
| PR touching `paths` in `audit.yml` | `Cargo Deny`, `Workflow Pin Report`, `Dependency Review` | `audit.yml` `cargo-deny`, `workflow-pin-report`, `dependency-review` | Report-only | `cargo deny check`; `scripts/security/check_workflow_pins.sh` |
@@ -65,7 +66,7 @@ The four site-replication state-writer concurrency proofs reserve the available
| PR touching `paths` in `oidc-keycloak.yml` | `OIDC Keycloak live gate` | `oidc-keycloak.yml` `oidc-keycloak-live` | Report-only | `cargo build --locked -p rustfs --bin rustfs`, then `bash scripts/test/oidc_keycloak_live.sh ./target/debug/rustfs` |
| PR touching `paths` in `targets-integration.yml` | `PostgreSQL, MySQL, AMQP, and NATS` | `targets-integration.yml` `targets-live` | Report-only | start the containers as in the job, export the `RUSTFS_TEST_*` DSNs, then the job's `cargo test --locked -p rustfs-targets --test <name> -- --ignored --test-threads=1` commands |
| PR, documentation-only selection | `Quick Checks`, `Typos`, `Test and Lint` | `ci.yml` `quick-checks`, `typos`, `required-checks` | Required directly or via aggregate | Quick Checks commands; `python3 scripts/ci_gate.py --self-test` |
| `merge_group`; push to `main` | `End-to-End Tests (full merge gate)` | `ci.yml` `e2e-full` | Via aggregate on these events | `python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-full -p e2e_test` (CI adds `--binary "$RUSTFS_E2E_STARTUP_CAS_BINARY"` for the downloaded binary and sidecar) |
| `merge_group`; push to `main` or `gateway/integration` | `End-to-End Tests (full merge gate)` | `ci.yml` `e2e-full` | Via aggregate on these events | `python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-full -p e2e_test` (CI adds `--binary "$RUSTFS_E2E_STARTUP_CAS_BINARY"` for the downloaded binary and sidecar) |
e2e filters live in `.config/nextest.toml`; extend a profile instead of adding a second selector. Before a profile runs, `scripts/check_test_wiring.py` compares its listing to the committed digest in `.config/e2e-<profile>-selection.txt`, so a silent test drop fails closed.
+1 -1
View File
@@ -249,7 +249,7 @@ rustfs-protocols = { workspace = true }
rustfs-protos = { workspace = true }
rustfs-rio = { workspace = true }
rustfs-s3-client = { workspace = true }
rustfs-s3-types = { workspace = true }
rustfs-s3-types = { workspace = true, features = ["compat-s3s"] }
rustfs-s3-ops = { workspace = true }
rustfs-security-governance = { workspace = true }
rustfs-data-usage = { workspace = true }
+66
View File
@@ -24,12 +24,71 @@ use rustfs_config::{
};
use rustfs_credentials::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY, Masked};
use std::collections::HashSet;
use std::fmt;
use std::str::FromStr;
use std::sync::{Mutex, OnceLock};
pub(crate) const LEGACY_ENV_RUSTFS_ROOT_USER: &str = "RUSTFS_ROOT_USER";
pub(crate) const LEGACY_ENV_RUSTFS_ROOT_PASSWORD: &str = "RUSTFS_ROOT_PASSWORD";
static LEGACY_CREDENTIAL_WARNED_KEYS: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
/// Development-only selector between the s3s-based S3 stack and the gateway
/// stack while both coexist on the integration branch (rustfs/backlog#2734).
/// It is removed when the gateway stack becomes the only one.
const ENV_RUSTFS_S3_STACK: &str = "RUSTFS_S3_STACK";
/// The S3 protocol stack that serves requests.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum S3Stack {
/// The s3s-based stack every release ships today.
#[default]
Legacy,
/// The rustfs-gateway stack under construction.
Gateway,
}
/// A `RUSTFS_S3_STACK` value other than the exact names `legacy` and `gateway`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InvalidS3Stack {
value: String,
}
impl fmt::Display for InvalidS3Stack {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, r#"{ENV_RUSTFS_S3_STACK} must be "legacy" or "gateway", got {:?}"#, self.value)
}
}
impl std::error::Error for InvalidS3Stack {}
impl FromStr for S3Stack {
type Err = InvalidS3Stack;
fn from_str(value: &str) -> Result<Self, Self::Err> {
// Exact names only: a near miss must refuse startup rather than fall
// back to a stack the operator did not ask for.
match value {
"legacy" => Ok(Self::Legacy),
"gateway" => Ok(Self::Gateway),
_ => Err(InvalidS3Stack { value: value.to_owned() }),
}
}
}
impl S3Stack {
/// Unset selects the default; a set but invalid value, including an empty
/// one, is a startup error.
fn from_env() -> std::io::Result<Self> {
match std::env::var_os(ENV_RUSTFS_S3_STACK) {
None => Ok(Self::default()),
Some(value) => value
.to_string_lossy()
.parse()
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err)),
}
}
}
fn warn_legacy_credential_env_once(legacy_key: &str, canonical_key: &str) {
let warned = LEGACY_CREDENTIAL_WARNED_KEYS.get_or_init(|| Mutex::new(HashSet::new()));
let mut warned = match warned.lock() {
@@ -148,6 +207,9 @@ pub struct Config {
/// Workload profile for adaptive buffer sizing
pub buffer_profile: String,
/// S3 protocol stack selected by `RUSTFS_S3_STACK`
pub s3_stack: S3Stack,
}
impl Config {
@@ -179,6 +241,7 @@ impl Config {
kms_allow_insecure_dev_defaults: false,
buffer_profile_disable: false,
buffer_profile: "GeneralPurpose".to_string(),
s3_stack: S3Stack::default(),
}
}
@@ -232,6 +295,7 @@ impl Config {
// Region is optional, but if not set, we should default to "us-east-1" for signing compatibility with AWS S3 clients
let region = region.or_else(|| Some(RUSTFS_REGION.to_string()));
let s3_stack = S3Stack::from_env()?;
Ok(Config {
volumes,
@@ -256,6 +320,7 @@ impl Config {
kms_allow_insecure_dev_defaults,
buffer_profile_disable,
buffer_profile,
s3_stack,
})
}
@@ -299,6 +364,7 @@ impl std::fmt::Debug for Config {
.field("kms_allow_insecure_dev_defaults", &self.kms_allow_insecure_dev_defaults)
.field("buffer_profile_disable", &self.buffer_profile_disable)
.field("buffer_profile", &self.buffer_profile)
.field("s3_stack", &self.s3_stack)
.finish()
}
}
+58 -1
View File
@@ -16,7 +16,7 @@
#[allow(unsafe_op_in_unsafe_fn)]
mod tests {
use crate::config::cli::{InspectCommands, default_server_opts};
use crate::config::{CommandResult, Config, Opt, TlsCommands};
use crate::config::{CommandResult, Config, Opt, S3Stack, TlsCommands};
use crate::storage_api::config_test::DisksLayout;
use rustfs_config::{DEFAULT_CONSOLE_ADDRESS, DEFAULT_CONSOLE_ENABLE, DEFAULT_OBS_ENDPOINT, RUSTFS_REGION};
use rustfs_credentials::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY};
@@ -916,4 +916,61 @@ mod tests {
});
});
}
/// Resolves the server configuration with `RUSTFS_S3_STACK` set to `value`
/// (`None` removes it), so each case starts from a known environment.
fn config_with_s3_stack(value: Option<&str>) -> std::io::Result<Config> {
temp_env::with_var("RUSTFS_S3_STACK", value, || Config::from_opt(Opt::parse_from(["rustfs", "/data/vol1"])))
}
fn assert_s3_stack_rejected(value: &str) {
let err = match config_with_s3_stack(Some(value)) {
Ok(config) => panic!("RUSTFS_S3_STACK={value:?} must refuse startup, got {:?}", config.s3_stack),
Err(err) => err,
};
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
format!(r#"RUSTFS_S3_STACK must be "legacy" or "gateway", got "{value}""#)
);
}
#[test]
#[serial]
fn s3_stack_unset_selects_legacy() {
let config = config_with_s3_stack(None).expect("unset RUSTFS_S3_STACK should parse");
assert_eq!(config.s3_stack, S3Stack::Legacy);
}
#[test]
#[serial]
fn s3_stack_legacy_selects_legacy() {
let config = config_with_s3_stack(Some("legacy")).expect("RUSTFS_S3_STACK=legacy should parse");
assert_eq!(config.s3_stack, S3Stack::Legacy);
}
#[test]
#[serial]
fn s3_stack_gateway_selects_gateway() {
let config = config_with_s3_stack(Some("gateway")).expect("RUSTFS_S3_STACK=gateway should parse");
assert_eq!(config.s3_stack, S3Stack::Gateway);
}
#[test]
#[serial]
fn s3_stack_rejects_capitalized_gateway() {
assert_s3_stack_rejected("Gateway");
}
#[test]
#[serial]
fn s3_stack_rejects_both() {
assert_s3_stack_rejected("both");
}
#[test]
#[serial]
fn s3_stack_rejects_empty_value() {
assert_s3_stack_rejected("");
}
}
+1 -1
View File
@@ -78,7 +78,7 @@ pub use cli::{ConnectTopCaptureOpts, ConnectTopCommands};
pub use cli::{DiagnoseFormat, DiagnoseOpts};
pub use cli::{InspectBucketMetaOpts, InspectCommands, InspectOpts};
pub use cli::{TlsCommands, TlsInspectOpts, TlsOpts};
pub use config_struct::Config;
pub use config_struct::{Config, InvalidS3Stack, S3Stack};
pub use info::execute_info;
pub use opt::Opt;
pub(crate) use snapshot::get_config_snapshot;

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