diff --git a/.config/nextest.toml b/.config/nextest.toml index e460509de..9907f4f4f 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -138,7 +138,7 @@ test-group = 'e2e-inline-boundaries' # does not cross nextest process boundaries, so keep every Vault-backed test in # one group. [[profile.default.overrides]] -filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))' +filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::kms_rekey_sweep_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))' test-group = 'e2e-vault' # --------------------------------------------------------------------------- @@ -196,6 +196,15 @@ test-group = 'ecstore-serial-flaky' filter = 'package(rustfs-ecstore) & test(walk_dir_does_not_charge_consumer_backpressure_to_the_stall_budget)' retries = 2 +# Serialize the relocated-pool GET resume regression under the ci profile too +# (see the matching default-profile override near the top). No longer a +# quarantine: the fixture race (rustfs#6701/rustfs#6703) was fixed by #6707, +# which made the staging tolerate quorum-tolerated disk gaps; only the 8-disk +# cross-disk-IO serialization remains. +[[profile.ci.overrides]] +filter = 'package(rustfs) & test(execute_get_object_resumes_from_relocated_pool_without_splicing_body)' +test-group = 'ecstore-serial-flaky' + # Serialize the 4-disk reliability / degraded-read e2e tests under the ci # profile too (see the e2e-reliability test-group note near the top). Not a # quarantine: no retries, just single-threaded so several 4-disk servers never @@ -211,10 +220,6 @@ test-group = 'e2e-reliability' filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::multipart::tests::crash_consistency::/)' test-group = 'ecstore-serial-flaky' -[[profile.ci.overrides]] -filter = 'package(rustfs) & test(execute_get_object_resumes_from_relocated_pool_without_splicing_body)' -test-group = 'ecstore-serial-flaky' - # Match the default-profile embedded test isolation without quarantining or # retrying failures in CI. [[profile.ci.overrides]] @@ -484,5 +489,5 @@ filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)' test-group = 'e2e-inline-boundaries' [[profile.e2e-full.overrides]] -filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))' +filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::kms_rekey_sweep_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))' test-group = 'e2e-vault' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acb3ad106..4dc00bf5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -429,6 +429,11 @@ jobs: # - test_noncurrent_{expiry,transition}_still_works_after_immediate_compensation_transition: # noncurrent transition/expiry after an immediate compensation transition. - name: Run ignored ILM integration tests serially + env: + # Match the measured Test and Lint link budget. The default exposed + # all 14 pod CPUs and a cold cache spent the full 80m compiling + # without starting one ILM test (main run 32982910990). + CARGO_BUILD_JOBS: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '3' || '2' }} run: | mkdir -p artifacts/ilm-integration set +e @@ -492,9 +497,70 @@ jobs: # job's 90-minute budget before any test starts. CARGO_BUILD_JOBS: "2" run: | - cargo nextest run -p rustfs -p rustfs-ecstore --features rio-v2 + # --profile ci so the quarantine list (and its junit flaky markers) + # covers this leg too; the default profile is the local no-retry + # profile and silently ignored quarantined flakes here (rustfs#6703). + cargo nextest run --profile ci -p rustfs -p rustfs-ecstore --features rio-v2 cargo test -p rustfs --doc --features rio-v2 + connect-short-credential-boundary: + name: Connect Short Credential Boundary + if: github.event_name != 'pull_request' || github.event.action != 'closed' + needs: [ quick-checks ] + runs-on: sm-standard-4 + timeout-minutes: 60 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 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' + install-test-tools: 'false' + + - name: Run short credential behavior tests + env: + CARGO_BUILD_JOBS: "2" + run: | + cargo test -p rustfs --test connect_registration \ + --features connect-e2e-short-credentials \ + registration_enforces_build_profile_credential_lifetime -- --exact + cargo test -p rustfs --test connect_registration \ + --features connect-e2e-short-credentials \ + rotation_waits_for_threshold_and_stops_on_revocation -- --exact + + - name: Reject short credentials in release builds + env: + CARGO_BUILD_JOBS: "2" + run: | + log="$(mktemp)" + set +e + CARGO_TERM_COLOR=never cargo check -p rustfs --release \ + --features connect-e2e-short-credentials >"$log" 2>&1 + status=$? + set -e + cat "$log" + expected='error: connect-e2e-short-credentials is restricted to debug builds' + summary="error: could not compile \`rustfs\` (lib) due to 1 previous error" + expected_count="$(grep -Fxc "$expected" "$log" || true)" + summary_count="$(grep -Fc "$summary" "$log" || true)" + error_count="$(grep -Ec '^error(:|\[)' "$log" || true)" + if [ "$status" -ne 101 ] || [ "$expected_count" -ne 1 ] \ + || [ "$summary_count" -ne 1 ] || [ "$error_count" -ne 2 ]; then + echo "release feature gate did not fail solely at the expected compile_error" >&2 + rm -f "$log" + exit 1 + fi + rm -f "$log" + test-and-lint-protocols: name: "Test and Lint (${{ matrix.features.name }})" if: github.event_name != 'pull_request' || github.event.action != 'closed' @@ -544,7 +610,10 @@ jobs: # main nextest lane; Clippy is metadata-only and needs no such limit. CARGO_BUILD_JOBS: "2" run: | - cargo nextest run -p rustfs -p rustfs-protocols ${{ matrix.features.flags }} + # --profile ci so the quarantine list (and its junit flaky markers) + # covers this leg too; the default profile is the local no-retry + # profile and silently ignored quarantined flakes here (rustfs#6703). + cargo nextest run --profile ci -p rustfs -p rustfs-protocols ${{ matrix.features.flags }} build-rustfs-debug-binary: name: Build RustFS Debug Binary diff --git a/.github/workflows/rustfs-pool-expand-test.yml b/.github/workflows/rustfs-pool-expand-test.yml index 8fb7c4c15..98d6cdd15 100644 --- a/.github/workflows/rustfs-pool-expand-test.yml +++ b/.github/workflows/rustfs-pool-expand-test.yml @@ -58,9 +58,12 @@ defaults: env: RUSTFS_ACCESS_KEY: ${{ secrets.RUSTFS_ACCESS_KEY }} RUSTFS_SECRET_KEY: ${{ secrets.RUSTFS_SECRET_KEY }} - RUSTFS_API_ENDPOINT: ${{ vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT }} - RUSTFS_NODES: ${{ vars.RUSTFS_NODES }} - RUSTFS_SSH_USER: ${{ vars.RUSTFS_SSH_USER }} + RUSTFS_API_ENDPOINT: ${{ secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT }} + RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }} + RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }} + # Package used by the scheduled run (workflow_dispatch inputs are empty for + # schedule events), i.e. the latest nightly deb published by nightly-gnu.yml. + RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }} jobs: pool-expansion-test: @@ -81,18 +84,32 @@ jobs: df -h /data | tail -1 - name: Reset test environment (before) - if: ${{ inputs.cleanup_before }} + if: ${{ inputs.cleanup_before != 'false' }} run: | chmod +x scripts/test/rustfs_pool_expand.sh ./scripts/test/rustfs_pool_expand.sh --reset -y + - name: Install RustFS package & start first pool + run: | + ARGS=(--steps 1,2,3 -y --endpoint "${{ env.RUSTFS_API_ENDPOINT }}") + if [ -n "${{ inputs.package_url }}" ]; then + ARGS+=(--package-url "${{ inputs.package_url }}") + elif [ -n "${{ inputs.rustfs_version }}" ]; then + ARGS+=(--version "${{ inputs.rustfs_version }}") + else + ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}") + fi + ./scripts/test/rustfs_pool_expand.sh "${ARGS[@]}" + - name: Preflight checks run: | ARGS=(--preflight --endpoint "${{ env.RUSTFS_API_ENDPOINT }}") if [ -n "${{ inputs.package_url }}" ]; then ARGS+=(--package-url "${{ inputs.package_url }}") - else + elif [ -n "${{ inputs.rustfs_version }}" ]; then ARGS+=(--version "${{ inputs.rustfs_version }}") + else + ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}") fi ./scripts/test/rustfs_pool_expand.sh "${ARGS[@]}" @@ -100,24 +117,19 @@ jobs: id: pool_test run: | set -o pipefail - STEPS="1,2,3,4,5,6" - if [ "${{ inputs.pools }}" = "3" ]; then + STEPS="4,5,6" + if [ "${{ inputs.pools || '3' }}" = "3" ]; then STEPS="$STEPS,7,8" - if [ "${{ inputs.run_decommission }}" = "true" ]; then + if [ "${{ inputs.run_decommission != 'false' }}" = "true" ]; then STEPS="$STEPS,9" fi fi - ARGS=(--steps "$STEPS" --with-warp -y \ + ./scripts/test/rustfs_pool_expand.sh \ + --steps "$STEPS" --with-warp -y \ --endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \ - --storage-threshold "${{ inputs.storage_threshold }}" \ - --warp-duration "${{ inputs.warp_duration }}" \ - --log-file /tmp/rustfs-pool-test.log) - if [ -n "${{ inputs.package_url }}" ]; then - ARGS+=(--package-url "${{ inputs.package_url }}") - else - ARGS+=(--version "${{ inputs.rustfs_version }}") - fi - ./scripts/test/rustfs_pool_expand.sh "${ARGS[@]}" + --storage-threshold "${{ inputs.storage_threshold || '50' }}" \ + --warp-duration "${{ inputs.warp_duration || '10m' }}" \ + --log-file /tmp/rustfs-pool-test.log - name: Upload test logs if: always() @@ -130,7 +142,7 @@ jobs: if-no-files-found: warn - name: Reset test environment (after) - if: ${{ always() && inputs.cleanup_after }} + if: ${{ always() && inputs.cleanup_after != 'false' }} run: | ./scripts/test/rustfs_pool_expand.sh --reset -y @@ -138,5 +150,5 @@ jobs: if: failure() run: | echo "RustFS pool expansion test failed" - echo "Package source: ${{ inputs.package_url || format('release {0}', inputs.rustfs_version) }}" + echo "Package source: ${{ inputs.package_url || inputs.rustfs_version || 'nightly (R2 latest)' }}" echo "See the uploaded log artifact for details." diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 31fbf0066..5aaf9163f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -73,7 +73,7 @@ The main crate is organized in layers, top to bottom: |-------|-----------|----------------| | **Server** | `server/` | HTTP listener, TLS, CORS, compression, middleware, graceful shutdown | | **Admin** | `admin/` | Admin API routing, 30+ handler modules, web console | -| **App** | `app/` | Use-case orchestration: object_usecase, bucket_usecase, multipart_usecase | +| **App** | `app/` | Use-case orchestration: object (per-operation modules under `app/object/`, re-exported as `object_usecase`), bucket_usecase, multipart_usecase | | **Storage** | `storage/` | S3 API translation, erasure-coded FS, SSE encryption, RPC, concurrency | | **Auth** | `auth.rs` | S3 signature verification, credential validation | | **Config** | `config/` | CLI parsing, config struct, workload profiles | @@ -93,7 +93,7 @@ refactors. | Domain | Current workspace crates | Responsibility | |--------|--------------------------|----------------| | Foundation | `checksums`, `common`, `config`, `data-usage`, `heal-contracts`, `scanner-contracts`, `utils` | Shared configuration, data-usage models, heal/scanner domain contracts, utilities, and checksums. | -| I/O and storage | `concurrency`, `ecstore`, `filemeta`, `heal`, `io-core`, `io-metrics`, `lifecycle`, `lock`, `object-capacity`, `object-data-cache`, `replication`, `rio`, `rio-v2`, `scanner`, `storage-api` | Erasure-coded object storage, metadata, recovery, lifecycle, replication, locking, cache, and I/O pipelines. | +| I/O and storage | `concurrency`, `ecstore`, `filemeta`, `heal`, `io-core`, `io-metrics`, `lifecycle`, `lock`, `object-capacity`, `object-data-cache`, `replication`, `rio`, `rio-v2`, `s3-client`, `scanner`, `storage-api` | Erasure-coded object storage, metadata, recovery, lifecycle, replication, locking, cache, I/O pipelines, and the engine-side S3 client for remote tier/transition targets. | | Security and identity | `credentials`, `crypto`, `iam`, `keystone`, `kms`, `policy`, `security-governance`, `signer`, `tls-runtime`, `trusted-proxies` | Credentials, authentication, authorization, encryption, key management, TLS, and security contracts. | | Protocols and contracts | `extension-schema`, `madmin`, `protos`, `protocols`, `s3-ops`, `s3-types`, `s3select-api`, `s3select-query` | Admin, inter-node, S3, S3 Select, and optional protocol contracts. | | Operations and integration | `audit`, `notify`, `obs`, `targets`, `zip` | Auditing, observability, event delivery, notification targets, and archive support. | @@ -115,8 +115,15 @@ default build (lifecycle: 1. **Layers flow downward.** Server → Admin/App → Storage → ecstore → rio/io-core. No upward imports. -2. **Leaf crates have zero internal dependencies.** `config`, `credentials`, `crypto`, - `io-metrics`, and `madmin` should depend only on external crates. +2. **Leaf crates depend only on external crates, with adjudicated exceptions + pinned by a guard.** `config`, `credentials`, and `crypto` take no internal + dependency. `io-metrics` takes exactly `rustfs-s3-ops` (transitively + `rustfs-s3-types`), a pure contract crate with no I/O and no global state — + adjudicated in rustfs/backlog#1834. `madmin` left the leaf set when #6166 made + it the SigV4-signed admin SDK client; its internal dependency surface is pinned + to exactly `rustfs-signer`. Both pins live in the leaf allowlist in + `scripts/check_architecture_migration_rules.sh`; any other internal dependency + fails the guard ([crate boundaries](docs/architecture/crate-boundaries.md)). - ✅ RESOLVED: the historical `utils → config` and `common → filemeta`/`madmin` edges were removed; do not reintroduce them (see Known Structural Issues). @@ -138,15 +145,19 @@ default build (lifecycle: `BackpressureSettings` copy that lingered in io-metrics was removed (rustfs/backlog#1833). -4. **ecstore does not know about HTTP or S3 protocol details.** It operates on - storage-level abstractions (objects, buckets, disks, pools). - - ⚠️ VIOLATED: 58 files under `crates/ecstore/src` reference `s3s` - (`rg -l 's3s' crates/ecstore/src | wc -l`), `crates/ecstore/src/client/` - is a ~9.4K-line embedded S3 HTTP client, and `crates/ecstore/Cargo.toml` - depends on `s3s`, `http`, `hyper`/`hyper-util`/`hyper-rustls`, and - `reqwest`. Target state: the engine's need to act as an S3 client - (tiering, replication targets) is served by an extracted client crate, - and ecstore holds no wire or DTO types. +4. **ecstore does not *serve* HTTP or the S3 wire protocol.** It operates on + storage-level abstractions (objects, buckets, disks, pools) and holds no + wire or DTO types of the serving surface. *Consuming* remote S3-compatible + endpoints (ILM tier warm backends, transition targets) is a legitimate + engine capability, but it lives in the dedicated `rustfs-s3-client` crate + (`crates/s3-client`, extracted from the formerly embedded + `crates/ecstore/src/client/` by rustfs/backlog#1842), not inside ecstore. + - ⚠️ PARTIALLY VIOLATED: serving-side `s3s` references remain in ecstore + (bucket metadata/replication/lifecycle DTOs and error mapping). The + count is ratcheted shrink-only by `scripts/check_s3s_footprint.sh` + (`S3S_ECSTORE_FILES_BASELINE`; the `object_lock` module was converted to + storage-level types as the first ratchet step). Target state: the + baseline reaches zero and ecstore's `Cargo.toml` drops `s3s`. 5. **The `rustfs` binary crate is the only place that wires everything together.** Individual crates should be testable in isolation. @@ -321,6 +332,8 @@ The binary (`main.rs`) boots in this order: - **"Where is replication configured?"** `admin/handlers/replication.rs` and `admin/handlers/site_replication.rs` for API, + `rustfs/src/site_replication/` for the site-replication service subsystem + (state, peer transport, retry queue, repair, hooks), `ecstore/src/bucket/replication/` for engine - **"Where do I add a new admin endpoint?"** diff --git a/Cargo.lock b/Cargo.lock index ba722d3d5..c92b53e4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -767,9 +767,9 @@ dependencies = [ [[package]] name = "async-rs" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cd5147201b63ba6883ffabca3a153822f71541748d7108e3e799beaeb283131" +checksum = "c5f55b2bcef73a79a2feb5496478693077b60b7337896f0b72431cd5311d988a" dependencies = [ "async-compat", "async-global-executor", @@ -972,9 +972,9 @@ dependencies = [ [[package]] name = "aws-sdk-kms" -version = "1.116.0" +version = "1.117.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "484ecdbea2a1cfc0e6eea69ce0a665f93913671b303ba40b2361b1d826544e7e" +checksum = "83b602641be84ebe5f96606cfefe4b96efaae1fd947c1b34ea8513b8ac0d2d8d" dependencies = [ "arc-swap", "aws-credential-types", @@ -998,9 +998,9 @@ dependencies = [ [[package]] name = "aws-sdk-s3" -version = "1.143.0" +version = "1.144.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ade5433c9561daac7c0c6bc910f1240b4f8ec0d6148b0b463aac0691d747c9" +checksum = "30dc8bf6baaf7d46336a0ca2c69f223d9b90d7a801fb3e28f7ea17b00dc6b1de" dependencies = [ "arc-swap", "aws-credential-types", @@ -1025,7 +1025,7 @@ dependencies = [ "http 0.2.12", "http 1.5.0", "http-body 1.1.0", - "lru 0.16.4", + "lru 0.18.2", "percent-encoding", "regex-lite", "sha2 0.11.0", @@ -1035,9 +1035,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.107.0" +version = "1.108.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "769b0abd0f89cfe11da5099986dd493e4f94347ce9a4562cb86ddecfe926b6c0" +checksum = "c15301b04372832947916607983b114b3374b9db0be058a00fb7513800de1f05" dependencies = [ "arc-swap", "aws-credential-types", @@ -1061,9 +1061,9 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.109.0" +version = "1.110.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4075b8a2c8cda4076a3dcc43b9d6dabd93e0c2502abeaaf7e14aaead9bb312b" +checksum = "72cc2c205cb27108183cf1856333f7d584c2ba0f505421b4209ca5828f9ea899" dependencies = [ "arc-swap", "aws-credential-types", @@ -1087,9 +1087,9 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.112.0" +version = "1.113.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f582002918346a3e685be1b391c7bea155073088cea6bd4e4b7663df9e43b6c" +checksum = "68182ecb449f7537db0f4d5d25917789cf41e32074a9fe47b6a0b847fe1d2032" dependencies = [ "arc-swap", "aws-credential-types", @@ -1617,13 +1617,29 @@ dependencies = [ [[package]] name = "blake2" -version = "0.11.0-rc.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ "digest 0.11.3", ] +[[package]] +name = "blazesym" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "847a0a95b041ad5aae1bdc44f2bd54743f76eb0065c4f86b139f81343c287eaf" +dependencies = [ + "cpp_demangle", + "crc32fast", + "flate2", + "gimli 0.33.0", + "libc", + "memmap2", + "rustc-demangle", + "tempfile", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1807,6 +1823,12 @@ dependencies = [ "libbz2-rs-sys", ] +[[package]] +name = "c-enum" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd17eb909a8c6a894926bfcc3400a4bb0e732f5a57d37b1f14e8b29e329bace8" + [[package]] name = "camino" version = "1.2.5" @@ -2095,9 +2117,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "futures-core", @@ -2301,6 +2323,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpp_demangle" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0667304c32ea56cb4cd6d2d7c0cfe9a2f8041229db8c033af7f8d69492429def" +dependencies = [ + "cfg-if", +] + [[package]] name = "cpubits" version = "0.1.1" @@ -3454,25 +3485,23 @@ dependencies = [ [[package]] name = "deadpool" -version = "0.12.3" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +checksum = "3e98a7e119cd347f4201e1159b19831029e203e2d8b790547708e8157b4acf1e" dependencies = [ "deadpool-runtime", - "lazy_static", - "num_cpus", "tokio", ] [[package]] name = "deadpool-postgres" -version = "0.14.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d697d376cbfa018c23eb4caab1fd1883dd9c906a8c034e8d9a3cb06a7e0bef9" +checksum = "65a536565624b97fc19f758cd01b15d12908d3344425066efc8162236fbd3749" dependencies = [ "async-trait", "deadpool", - "getrandom 0.2.17", + "getrandom 0.4.3", "tokio", "tokio-postgres", "tracing", @@ -3480,9 +3509,9 @@ dependencies = [ [[package]] name = "deadpool-runtime" -version = "0.1.4" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae" dependencies = [ "tokio", ] @@ -3686,35 +3715,61 @@ dependencies = [ ] [[package]] -name = "dial9-macro" -version = "0.3.7" +name = "dial9-core" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a7e31f073f2e14e5a9d338c543a0601aeaf7c43fc428cd59ce417230d0db37d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "dial9-tokio-telemetry" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b511dfd54f5191f7eb86856fe19d8e3c8f71673bd256e1bfa1c8128fbbc0cdb" +checksum = "9e8cbbc8955394be626249a3b52ddd6bf664373661eaeae70d87eb12bf6f20b6" dependencies = [ "arc-swap", "bon", "bytes", "crossbeam-queue", - "dial9-macro", + "dial9-trace-format", + "flate2", + "futures-util", + "libc", + "metrique", + "metrique-timesource", + "tokio", + "tokio-util", + "tracing", + "ulid", +] + +[[package]] +name = "dial9-perf-self-profile" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f65948455c504bf08576c7b5cfe93bfcaa486d661dc4ee85c2a6309ab89629" +dependencies = [ + "blazesym", + "bon", + "bytes", + "crossbeam-utils", + "dial9-core", + "dial9-trace-format", + "libc", + "perf-event-data", + "perf-event-open-sys2", + "tracing", +] + +[[package]] +name = "dial9-tokio-telemetry" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1244091367c805b5d98a6a590f8ab992efda06e6d2560a6967ef898ffd30a2" +dependencies = [ + "bon", + "bytes", + "dial9-core", + "dial9-perf-self-profile", "dial9-trace-format", "flate2", "futures-util", "hostname", "libc", - "metrique", "metrique-timesource", - "metrique-writer", "pin-project-lite", "serde", "serde_json", @@ -3726,20 +3781,22 @@ dependencies = [ [[package]] name = "dial9-trace-format" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3636d6ec60d94840cc414dcd6a95c77b3ba0a7b86d43fd035b89662eeb5cfa7" +checksum = "86083b7240114b2d0da4a7e9d041571d80ca3b1f92ae0ec794f1be21709daa6a" dependencies = [ "dial9-trace-format-derive", "serde", + "typeid", ] [[package]] name = "dial9-trace-format-derive" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fff7c2855b73d0de34bc31d6dc7afbf0f6ce230a668403ac2b57b21d1ffe3928" +checksum = "9309248f12e414d88bcc9505f78b0c9ed47f79c5b62db611493e19a612cdc9d6" dependencies = [ + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.119", @@ -3859,7 +3916,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "e2e_test" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "anyhow", "astral-tokio-tar", @@ -3869,7 +3926,7 @@ dependencies = [ "aws-sdk-s3", "aws-sdk-sts", "aws-smithy-http-client", - "base64 0.23.1", + "base64-simd", "bytes", "chrono", "clap", @@ -3877,7 +3934,7 @@ dependencies = [ "flatbuffers", "flate2", "futures", - "hex", + "hex-simd", "hmac 0.13.0", "hotpath", "http 1.5.0", @@ -4559,6 +4616,9 @@ version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" dependencies = [ + "fnv", + "hashbrown 0.16.1", + "indexmap 2.14.0", "stable_deref_trait", ] @@ -4582,20 +4642,20 @@ dependencies = [ [[package]] name = "google-cloud-auth" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f54aab44c16b8463ae11b165a87c3d484780231f157bb1ed65843d591beb5abd" +checksum = "ff461519b1a948200f163574be072753bcfb462a323f0eb426629d89872dd685" dependencies = [ "async-trait", "aws-lc-rs", - "base64 0.22.1", + "base64 0.23.1", "bytes", - "chrono", "google-cloud-gax", "hex", "hmac 0.13.0", "http 1.5.0", - "jsonwebtoken 10.4.0", + "jiff", + "jsonwebtoken", "reqwest", "rustc_version", "rustls", @@ -4611,9 +4671,9 @@ dependencies = [ [[package]] name = "google-cloud-gax" -version = "1.13.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9a46dd0fd026bbc4a5d84e6ab0c941cee6e3b057976a0bb107fdb5238ce598f" +checksum = "c5615cff28ee59cfe52fbb4c11b8b1e77f650296e2ea4f4c2b7757ac6b19e752" dependencies = [ "bytes", "futures", @@ -4626,13 +4686,14 @@ dependencies = [ "serde_json", "thiserror 2.0.20", "tokio", + "tokio-stream", ] [[package]] name = "google-cloud-gax-internal" -version = "0.7.16" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb04c54317ace06d489213f761797240b3046142a9b7ce6b9a82a9d134e193d1" +checksum = "d2766757d877a7a8ac23da9884cb0e3f10ed9b75a0ce59801ce6b19bf9d5819e" dependencies = [ "bytes", "futures", @@ -4669,9 +4730,9 @@ dependencies = [ [[package]] name = "google-cloud-iam-v1" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34cdf5acc7ef946ee2db7a7f62bd436d8395a6543b4beef110cdc061fcf578bb" +checksum = "5f962b40234b1531e6ef73f7558871c96e117231e962086c98804323fb8d2c82" dependencies = [ "async-trait", "bytes", @@ -4687,9 +4748,9 @@ dependencies = [ [[package]] name = "google-cloud-longrunning" -version = "1.12.0" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6ce05df0aea2c08472983ce2bbbed9483cbb637b89ff69a7c4ef94371fe4f2" +checksum = "1c0363c5389ffda2b55cd8a86eef4b19a3481a48467c91dc5d77f626a9572766" dependencies = [ "async-trait", "bytes", @@ -4705,9 +4766,9 @@ dependencies = [ [[package]] name = "google-cloud-lro" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd7cca2b991d619525d72a170ca7f413cb520872702442da22ac9af650a8e786" +checksum = "47af3deef75c14a2983c430898d960c765bddbcc9f9188ca0563108e9227cfe7" dependencies = [ "google-cloud-gax", "google-cloud-gax-internal", @@ -4734,14 +4795,13 @@ dependencies = [ [[package]] name = "google-cloud-storage" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9227f65175fa91a6e41f246797917697efdadfe09dd8ea84ad8b737a71efbd28" +checksum = "973399251b245c63f1d02d0768772833dcf1372ee358f593fb9159de8fe9c7d4" dependencies = [ "async-trait", - "base64 0.22.1", + "base64 0.23.1", "bytes", - "chrono", "crc32c", "futures", "google-cloud-auth", @@ -4756,6 +4816,7 @@ dependencies = [ "hex", "http 1.5.0", "http-body 1.1.0", + "jiff", "md5", "percent-encoding", "prost 0.14.4", @@ -5785,22 +5846,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonwebtoken" -version = "10.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" -dependencies = [ - "aws-lc-rs", - "base64 0.22.1", - "getrandom 0.2.17", - "js-sys", - "serde", - "serde_json", - "signature 2.2.0", - "zeroize", -] - [[package]] name = "jsonwebtoken" version = "11.0.0" @@ -6358,7 +6403,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b55bfa39e6f5e44a37a59a794915ce36d04371471cf62ae365cd9703f58a5e0" dependencies = [ "itoa", - "jiff", "metrique-core", "metrique-macro", "metrique-service-metrics", @@ -6367,7 +6411,6 @@ dependencies = [ "metrique-writer-core", "metrique-writer-macro", "ryu", - "serde_json", "tokio", ] @@ -6763,7 +6806,7 @@ dependencies = [ "ed25519-dalek 2.2.0", "getrandom 0.2.17", "log", - "rand 0.8.7", + "rand 0.8.8", "signatory", ] @@ -6819,7 +6862,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83" dependencies = [ - "rand 0.8.7", + "rand 0.8.8", ] [[package]] @@ -6854,7 +6897,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.7", + "rand 0.8.8", "smallvec", "zeroize", ] @@ -6968,11 +7011,11 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "chrono", "getrandom 0.2.17", "http 1.5.0", - "rand 0.8.7", + "rand 0.8.8", "serde", "serde_json", "serde_path_to_error", @@ -7150,7 +7193,7 @@ dependencies = [ "oauth2", "p256 0.13.2", "p384 0.13.1", - "rand 0.8.7", + "rand 0.8.8", "rsa 0.9.10", "serde", "serde-value", @@ -7612,6 +7655,27 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "perf-event-data" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "575828d9d7d205188048eb1508560607a03d21eafdbba47b8cade1736c1c28e1" +dependencies = [ + "bitflags 2.13.1", + "c-enum", + "perf-event-open-sys2", +] + +[[package]] +name = "perf-event-open-sys2" +version = "5.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c25955321465255e437600b54296983fab1feac2cd0c38958adeb26dbae49e" +dependencies = [ + "libc", + "memoffset", +] + [[package]] name = "petgraph" version = "0.7.1" @@ -8145,8 +8209,8 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ - "heck 0.4.1", - "itertools 0.10.5", + "heck 0.5.0", + "itertools 0.14.0", "log", "multimap", "once_cell", @@ -8165,8 +8229,8 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ - "heck 0.4.1", - "itertools 0.10.5", + "heck 0.5.0", + "itertools 0.14.0", "log", "multimap", "petgraph 0.8.3", @@ -8187,7 +8251,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -8200,7 +8264,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -8284,7 +8348,7 @@ dependencies = [ "prost 0.13.5", "prost-build 0.13.5", "prost-derive 0.13.5", - "rand 0.8.7", + "rand 0.8.8", "regex", "rustls", "tokio", @@ -8561,9 +8625,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -9301,7 +9365,7 @@ dependencies = [ [[package]] name = "rustfs" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "aes-gcm", "anyhow", @@ -9314,7 +9378,6 @@ dependencies = [ "aws-config", "aws-sdk-s3", "axum", - "base64 0.23.1", "base64-simd", "bytes", "chacha20poly1305", @@ -9371,6 +9434,7 @@ dependencies = [ "rustfs-extension-schema", "rustfs-filemeta", "rustfs-heal", + "rustfs-heal-contracts", "rustfs-iam", "rustfs-io-core", "rustfs-io-metrics", @@ -9380,7 +9444,6 @@ dependencies = [ "rustfs-log-analyzer", "rustfs-madmin", "rustfs-mimalloc", - "rustfs-mimalloc-sys", "rustfs-notify", "rustfs-object-capacity", "rustfs-object-data-cache", @@ -9389,11 +9452,13 @@ dependencies = [ "rustfs-protocols", "rustfs-protos", "rustfs-rio", + "rustfs-s3-client", "rustfs-s3-ops", "rustfs-s3-types", "rustfs-s3select-api", "rustfs-s3select-query", "rustfs-scanner", + "rustfs-scanner-contracts", "rustfs-security-governance", "rustfs-signer", "rustfs-storage-api", @@ -9441,9 +9506,8 @@ dependencies = [ [[package]] name = "rustfs-audit" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ - "async-trait", "const-str", "futures", "hashbrown 0.17.1", @@ -9464,7 +9528,7 @@ dependencies = [ [[package]] name = "rustfs-checksums" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "base64-simd", "bytes", @@ -9480,12 +9544,10 @@ dependencies = [ [[package]] name = "rustfs-common" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "hotpath", "metrics", - "rustfs-heal-contracts", - "rustfs-scanner-contracts", "smallvec", "tokio", "tonic", @@ -9495,7 +9557,7 @@ dependencies = [ [[package]] name = "rustfs-concurrency" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "hotpath", "insta", @@ -9508,7 +9570,7 @@ dependencies = [ [[package]] name = "rustfs-config" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "const-str", "hotpath", @@ -9518,7 +9580,7 @@ dependencies = [ [[package]] name = "rustfs-credentials" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "base64-simd", "hmac 0.13.0", @@ -9532,14 +9594,14 @@ dependencies = [ [[package]] name = "rustfs-crypto" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "aes-gcm", "argon2", "base64-simd", "chacha20poly1305", "hotpath", - "jsonwebtoken 11.0.0", + "jsonwebtoken", "pbkdf2 0.13.0", "rand 0.10.2", "rsa 0.10.0-rc.18", @@ -9553,7 +9615,7 @@ dependencies = [ [[package]] name = "rustfs-data-usage" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "hotpath", "rmp-serde", @@ -9563,7 +9625,7 @@ dependencies = [ [[package]] name = "rustfs-ecstore" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "ahash", "arc-swap", @@ -9575,19 +9637,16 @@ dependencies = [ "aws-smithy-http-client", "aws-smithy-runtime-api", "aws-smithy-types", - "base64 0.23.1", "base64-simd", "byteorder", "bytes", "bytesize", "chrono", "criterion", - "enumset", "faster-hex", "flatbuffers", "futures", "futures-util", - "glob", "google-cloud-auth", "google-cloud-storage", "hex-simd", @@ -9616,7 +9675,6 @@ dependencies = [ "path-absolutize", "pin-project-lite", "proptest", - "quick-xml 0.42.0", "rand 0.10.2", "ratelimit", "rcgen", @@ -9625,7 +9683,6 @@ dependencies = [ "reqwest", "rmp", "rmp-serde", - "rustfs-checksums", "rustfs-common", "rustfs-concurrency", "rustfs-config", @@ -9634,6 +9691,7 @@ dependencies = [ "rustfs-data-usage", "rustfs-erasure-codec", "rustfs-filemeta", + "rustfs-heal-contracts", "rustfs-io-metrics", "rustfs-lifecycle", "rustfs-lock", @@ -9647,9 +9705,8 @@ dependencies = [ "rustfs-rio-v2", "rustfs-s3-client", "rustfs-s3-types", - "rustfs-signer", + "rustfs-scanner-contracts", "rustfs-storage-api", - "rustfs-tls-runtime", "rustfs-uring", "rustfs-utils", "rustix", @@ -9660,11 +9717,9 @@ dependencies = [ "serde_json", "serde_urlencoded", "serial_test", - "sha1 0.11.0", "sha2 0.11.0", "shadow-rs", "smallvec", - "starshard", "temp-env", "tempfile", "thiserror 2.0.20", @@ -9706,7 +9761,7 @@ dependencies = [ [[package]] name = "rustfs-extension-schema" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "hotpath", "serde", @@ -9716,7 +9771,7 @@ dependencies = [ [[package]] name = "rustfs-filemeta" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "ahash", "arc-swap", @@ -9744,10 +9799,10 @@ dependencies = [ [[package]] name = "rustfs-heal" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "async-trait", - "base64 0.23.1", + "base64-simd", "bytes", "crc-fast", "futures", @@ -9758,6 +9813,7 @@ dependencies = [ "rustfs-concurrency", "rustfs-config", "rustfs-ecstore", + "rustfs-heal-contracts", "rustfs-lock", "rustfs-madmin", "rustfs-storage-api", @@ -9779,7 +9835,7 @@ dependencies = [ [[package]] name = "rustfs-heal-contracts" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "serde", "tokio", @@ -9788,7 +9844,7 @@ dependencies = [ [[package]] name = "rustfs-iam" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "arc-swap", "async-trait", @@ -9798,7 +9854,7 @@ dependencies = [ "hmac 0.13.0", "hotpath", "http 1.5.0", - "jsonwebtoken 11.0.0", + "jsonwebtoken", "moka", "openidconnect", "pollster", @@ -9836,7 +9892,7 @@ dependencies = [ [[package]] name = "rustfs-io-core" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "bytes", "hotpath", @@ -9848,7 +9904,7 @@ dependencies = [ [[package]] name = "rustfs-io-metrics" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "criterion", "hotpath", @@ -9912,7 +9968,7 @@ dependencies = [ [[package]] name = "rustfs-keystone" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "bytes", "futures", @@ -9939,7 +9995,7 @@ dependencies = [ [[package]] name = "rustfs-kms" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "aes-gcm", "anyhow", @@ -9951,9 +10007,9 @@ dependencies = [ "aws-smithy-http-client", "aws-smithy-runtime-api", "aws-smithy-types", - "base64 0.23.1", + "base64-simd", "chacha20poly1305", - "hex", + "hex-simd", "hotpath", "http 1.5.0", "insta", @@ -9989,16 +10045,16 @@ dependencies = [ [[package]] name = "rustfs-lifecycle" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "async-trait", "hotpath", "metrics", "metrics-util", "proptest", - "rustfs-common", "rustfs-config", "rustfs-replication", + "rustfs-scanner-contracts", "rustfs-storage-api", "s3s", "serial_test", @@ -10012,7 +10068,7 @@ dependencies = [ [[package]] name = "rustfs-lock" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "async-trait", "compact_str", @@ -10035,7 +10091,7 @@ dependencies = [ [[package]] name = "rustfs-log-analyzer" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "chrono", "flate2", @@ -10054,7 +10110,7 @@ dependencies = [ [[package]] name = "rustfs-madmin" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "hotpath", "http 1.5.0", @@ -10092,7 +10148,7 @@ dependencies = [ [[package]] name = "rustfs-notify" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "arc-swap", "async-trait", @@ -10127,7 +10183,7 @@ dependencies = [ [[package]] name = "rustfs-object-capacity" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "criterion", "futures", @@ -10146,7 +10202,7 @@ dependencies = [ [[package]] name = "rustfs-object-data-cache" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "bytes", "criterion", @@ -10163,7 +10219,7 @@ dependencies = [ [[package]] name = "rustfs-obs" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "chrono", "crossbeam-channel", @@ -10193,9 +10249,11 @@ dependencies = [ "rustfs-common", "rustfs-config", "rustfs-ecstore", + "rustfs-heal-contracts", "rustfs-iam", "rustfs-io-metrics", "rustfs-notify", + "rustfs-scanner-contracts", "rustfs-security-governance", "rustfs-storage-api", "rustfs-utils", @@ -10219,7 +10277,7 @@ dependencies = [ [[package]] name = "rustfs-policy" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "async-trait", "base64-simd", @@ -10227,7 +10285,7 @@ dependencies = [ "hotpath", "ipnetwork", "jiff", - "jsonwebtoken 11.0.0", + "jsonwebtoken", "moka", "pollster", "proptest", @@ -10250,18 +10308,18 @@ dependencies = [ [[package]] name = "rustfs-protocols" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "astral-tokio-tar", "async-compression", "async-trait", "axum", - "base64 0.23.1", + "base64-simd", "bytes", "dav-server", "futures", "futures-util", - "hex", + "hex-simd", "hmac 0.13.0", "hotpath", "http 1.5.0", @@ -10312,7 +10370,7 @@ dependencies = [ [[package]] name = "rustfs-protos" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "flatbuffers", "hotpath", @@ -10320,6 +10378,7 @@ dependencies = [ "rmp-serde", "rustfs-common", "rustfs-config", + "rustfs-heal-contracts", "rustfs-io-metrics", "rustfs-tls-runtime", "rustfs-utils", @@ -10336,7 +10395,7 @@ dependencies = [ [[package]] name = "rustfs-replication" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "byteorder", "bytes", @@ -10354,12 +10413,12 @@ dependencies = [ [[package]] name = "rustfs-rio" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "aes-gcm", "arc-swap", "axum", - "base64 0.23.1", + "base64-simd", "bytes", "crc-fast", "faster-hex", @@ -10394,12 +10453,12 @@ dependencies = [ [[package]] name = "rustfs-rio-v2" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "aes-gcm", "bytes", "chacha20poly1305", - "hex", + "hex-simd", "hmac 0.13.0", "hotpath", "minlz", @@ -10417,7 +10476,7 @@ dependencies = [ [[package]] name = "rustfs-s3-client" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "base64-simd", "bytes", @@ -10447,7 +10506,6 @@ dependencies = [ "s3s", "serde", "serde_json", - "sha1 0.11.0", "sha2 0.11.0", "thiserror 2.0.20", "time", @@ -10462,7 +10520,7 @@ dependencies = [ [[package]] name = "rustfs-s3-ops" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "hotpath", "rustfs-s3-types", @@ -10470,7 +10528,7 @@ dependencies = [ [[package]] name = "rustfs-s3-types" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "hotpath", "serde", @@ -10479,7 +10537,7 @@ dependencies = [ [[package]] name = "rustfs-s3select-api" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "async-trait", "bytes", @@ -10509,7 +10567,7 @@ dependencies = [ [[package]] name = "rustfs-s3select-query" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "async-recursion", "async-trait", @@ -10528,7 +10586,7 @@ dependencies = [ [[package]] name = "rustfs-scanner" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "async-trait", "bytes", @@ -10547,8 +10605,10 @@ dependencies = [ "rustfs-data-usage", "rustfs-ecstore", "rustfs-filemeta", + "rustfs-heal-contracts", "rustfs-lock", "rustfs-s3-types", + "rustfs-scanner-contracts", "rustfs-storage-api", "rustfs-utils", "s3s", @@ -10568,7 +10628,7 @@ dependencies = [ [[package]] name = "rustfs-scanner-contracts" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "chrono", "jiff", @@ -10583,7 +10643,7 @@ dependencies = [ [[package]] name = "rustfs-security-governance" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "hotpath", "thiserror 2.0.20", @@ -10591,7 +10651,7 @@ dependencies = [ [[package]] name = "rustfs-signer" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "base64-simd", "bytes", @@ -10609,7 +10669,7 @@ dependencies = [ [[package]] name = "rustfs-storage-api" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "async-trait", "hotpath", @@ -10624,7 +10684,7 @@ dependencies = [ [[package]] name = "rustfs-targets" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "arc-swap", "async-nats", @@ -10678,7 +10738,7 @@ dependencies = [ [[package]] name = "rustfs-test-utils" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "hotpath", "rustfs-data-usage", @@ -10694,7 +10754,7 @@ dependencies = [ [[package]] name = "rustfs-tls-runtime" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "arc-swap", "hotpath", @@ -10715,7 +10775,7 @@ dependencies = [ [[package]] name = "rustfs-trusted-proxies" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "async-trait", "axum", @@ -10752,7 +10812,7 @@ dependencies = [ [[package]] name = "rustfs-utils" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "ahash", "base64-simd", @@ -10795,7 +10855,7 @@ dependencies = [ [[package]] name = "rustfs-zip" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" dependencies = [ "async-compression", "hotpath", @@ -10986,7 +11046,7 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "s3s" version = "0.15.0" -source = "git+https://github.com/rustfs/s3s.git?rev=5f22e8d0a37e83f531f653024aac11c72586479a#5f22e8d0a37e83f531f653024aac11c72586479a" +source = "git+https://github.com/rustfs/s3s.git?rev=0f6f83d98b37fd9edcaa3be573db4aa8f568e088#0f6f83d98b37fd9edcaa3be573db4aa8f568e088" dependencies = [ "arc-swap", "arrayvec", @@ -12118,7 +12178,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -12466,7 +12526,7 @@ dependencies = [ "futures-sink", "http 1.5.0", "httparse", - "rand 0.8.7", + "rand 0.8.8", "rustls-pki-types", "tokio", "tokio-rustls", @@ -12818,12 +12878,28 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.5", + "web-time", +] + [[package]] name = "unarray" version = "0.1.4" @@ -12958,9 +13034,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.25.0" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "getrandom 0.4.3", "js-sys", diff --git a/Cargo.toml b/Cargo.toml index 60a9f4182..aa9a4bce0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,7 +72,7 @@ edition = "2024" license = "Apache-2.0" repository = "https://github.com/rustfs/rustfs" rust-version = "1.97.1" -version = "1.0.0-rc.3" +version = "1.0.0-rc.4" homepage = "https://rustfs.com" description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. " keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"] @@ -89,55 +89,55 @@ redundant_clone = "warn" [workspace.dependencies] # RustFS Internal Crates -rustfs = { path = "./rustfs", version = "1.0.0-rc.3" } -rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.3" } -rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.0-rc.3" } -rustfs-scanner-contracts = { path = "crates/scanner-contracts", version = "1.0.0-rc.3" } -rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.3" } -rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.3" } -rustfs-common = { path = "crates/common", version = "1.0.0-rc.3" } -rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.3" } -rustfs-config = { path = "./crates/config", version = "1.0.0-rc.3" } -rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.3" } -rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.3" } -rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.3" } -rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.3" } -rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.3" } -rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.3" } -rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.3" } -rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.3" } -rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.3" } -rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.3" } -rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.3" } -rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.3" } -rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.3" } -rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.3" } -rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.3" } -rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.3", default-features = false } -rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.3" } -rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.3" } -rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.3" } -rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.3" } -rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.3" } -rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.3" } -rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.3" } -rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.3" } -rustfs-s3-client = { path = "crates/s3-client", version = "1.0.0-rc.3" } -rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.3" } -rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.3" } -rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.3" } -rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.3" } -rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.3" } -rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.3" } -rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.3" } -rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.3" } -rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.3" } -rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.3" } -rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.3" } -rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.3" } -rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.3" } -rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.3" } -rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.3" } +rustfs = { path = "./rustfs", version = "1.0.0-rc.4" } +rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.4" } +rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.0-rc.4" } +rustfs-scanner-contracts = { path = "crates/scanner-contracts", version = "1.0.0-rc.4" } +rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.4" } +rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.4" } +rustfs-common = { path = "crates/common", version = "1.0.0-rc.4" } +rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.4" } +rustfs-config = { path = "./crates/config", version = "1.0.0-rc.4" } +rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.4" } +rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.4" } +rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.4" } +rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.4" } +rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.4" } +rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.4" } +rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.4" } +rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.4" } +rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.4" } +rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.4" } +rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.4" } +rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.4" } +rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.4" } +rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.4" } +rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.4" } +rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.4", default-features = false } +rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.4" } +rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.4" } +rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.4" } +rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.4" } +rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.4" } +rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.4" } +rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.4" } +rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.4" } +rustfs-s3-client = { path = "crates/s3-client", version = "1.0.0-rc.4" } +rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.4" } +rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.4" } +rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.4" } +rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.4" } +rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.4" } +rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.4" } +rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.4" } +rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.4" } +rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.4" } +rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.4" } +rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.4" } +rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.4" } +rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.4" } +rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.4" } +rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.4" } # Async Runtime and Networking async-channel = "2.5.0" @@ -199,7 +199,7 @@ serde_urlencoded = "0.7.1" # releases. aes-gcm = { version = "=0.11.1" } argon2 = { version = "=0.6.0-rc.8" } -blake2 = "=0.11.0-rc.6" +blake2 = "=0.11.0" chacha20poly1305 = { version = "=0.11.0" } crc-fast = "1.10.0" hmac = { version = "0.13.0" } @@ -237,13 +237,12 @@ atoi = "3.1.0" atomic_enum = "0.3.0" aws-config = { version = "1.11.0" } aws-credential-types = { version = "1.3.0" } -aws-sdk-kms = { default-features = false, version = "1.116.0" } -aws-sdk-s3 = { default-features = false, version = "1.143.0" } -aws-sdk-sts = { default-features = false, version = "1.112.0" } +aws-sdk-kms = { default-features = false, version = "1.117.0" } +aws-sdk-s3 = { default-features = false, version = "1.144.0" } +aws-sdk-sts = { default-features = false, version = "1.113.0" } aws-smithy-http-client = { default-features = false, version = "1.4.0" } aws-smithy-runtime-api = { version = "1.15.0" } aws-smithy-types = { version = "1.6.2" } -base64 = "0.23.1" base64-simd = "0.8.0" brotli = "8.0.4" clap = { version = "4.6.6" } @@ -260,13 +259,12 @@ enumset = "1.1.14" faster-hex = "0.10.0" flate2 = "1.1.9" glob = "0.3.4" -google-cloud-storage = "1.17.0" -google-cloud-auth = "1.15.0" +google-cloud-storage = "1.18.0" +google-cloud-auth = "1.16.0" hashbrown = { version = "0.17.1" } # Base32 for RFC 6238 TOTP shared secrets (RFC 4648 unpadded, the alphabet # every authenticator app expects). Already in the graph transitively. data-encoding = "2.11.1" -hex = "0.4.3" hex-simd = "0.8.0" highway = { version = "1.3.0" } hostname = "0.4.2" @@ -306,7 +304,7 @@ rustify = { version = "0.7", default-features = false } rustix = { version = "1.1.4" } rust-embed = { version = "8.12.0" } rustc-hash = { version = "2.1.3" } -s3s = { git = "https://github.com/rustfs/s3s.git", rev = "5f22e8d0a37e83f531f653024aac11c72586479a", version = "0.15.0", features = ["minio"] } +s3s = { git = "https://github.com/rustfs/s3s.git", rev = "0f6f83d98b37fd9edcaa3be573db4aa8f568e088", version = "0.15.0", features = ["minio"] } serial_test = "4.0.1" shadow-rs = { default-features = false, version = "2.0.0" } siphasher = "1.0.3" @@ -329,7 +327,7 @@ tracing-subscriber = { version = "0.3.23" } transform-stream = "0.3.1" url = "2.5.8" urlencoding = "2.1.3" -uuid = { version = "1.25.0" } +uuid = { version = "1.26.0" } vaultrs = { version = "0.8.0" } tar = "0.4.46" walkdir = "2.5.0" @@ -343,7 +341,7 @@ zstd = "0.13.3" # Observability and Metrics metrics = "0.24.6" metrics-util = "0.20" -dial9-tokio-telemetry = "0.3" +dial9-tokio-telemetry = "0.5.0" opentelemetry = { version = "0.32.0" } opentelemetry-appender-tracing = { version = "0.32.0" } opentelemetry-otlp = { version = "0.32.0" } @@ -366,7 +364,6 @@ dav-server = "0.11.0" # Performance Analysis and Memory Profiling rustfs-mimalloc = { version = "0.5.1" } -rustfs-mimalloc-sys = { version = "0.5.1" } hotpath = { version = "0.24.0", default-features = false } # Snapshot testing for output format regression detection insta = { version = "1.48" } diff --git a/README.md b/README.md index 179867a7f..f516d2597 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ chown -R 10001:10001 data logs docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest # Using specific version -docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.3 +docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.4 ``` If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command diff --git a/README_ZH.md b/README_ZH.md index f20c23597..3f85561ba 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -112,7 +112,7 @@ chown -R 10001:10001 data logs docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest # 使用指定版本运行 -docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.3 +docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.4 ``` 如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录: diff --git a/crates/audit/Cargo.toml b/crates/audit/Cargo.toml index 6d81d97ef..5f45dda92 100644 --- a/crates/audit/Cargo.toml +++ b/crates/audit/Cargo.toml @@ -67,7 +67,7 @@ tokio = { workspace = true, features = ["sync", "fs", "rt-multi-thread", "time", tracing = { workspace = true, features = ["std", "attributes"] } [dev-dependencies] -async-trait = { workspace = true } +rustfs-targets = { workspace = true, features = ["test-support"] } temp-env = { workspace = true } url = { workspace = true } diff --git a/crates/audit/src/pipeline.rs b/crates/audit/src/pipeline.rs index 316dea93c..9330b7707 100644 --- a/crates/audit/src/pipeline.rs +++ b/crates/audit/src/pipeline.rs @@ -564,88 +564,21 @@ impl AuditRuntimeFacade { mod tests { use super::AuditPipeline; use crate::{AuditEntry, AuditError, AuditRegistry}; - use async_trait::async_trait; - use rustfs_targets::arn::TargetID; - use rustfs_targets::store::{Key, Store}; - use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta}; - use rustfs_targets::{StoreError, Target, TargetError}; + use rustfs_targets::testkit::MockTarget; use std::sync::Arc; use tokio::sync::{Mutex, Notify}; - /// Mock target whose `save()` outcome is fixed at construction so tests can - /// force full-success / full-failure / partial-failure fan-outs. - #[derive(Clone)] - struct MockTarget { - id: TargetID, - fail: bool, - health_gate: Option<(Arc, Arc)>, - } - - impl MockTarget { - fn new(id: &str, fail: bool) -> Self { - Self { - id: TargetID::new(id.to_string(), "webhook".to_string()), - fail, - health_gate: None, - } - } - - fn with_health_gate(mut self, started: Arc, release: Arc) -> Self { - self.health_gate = Some((started, release)); - self - } - } - - #[async_trait] - impl Target for MockTarget - where - E: rustfs_targets::PluginEvent, - { - fn id(&self) -> TargetID { - self.id.clone() - } - - async fn is_active(&self) -> Result { - if let Some((started, release)) = &self.health_gate { - started.notify_one(); - release.notified().await; - } - Ok(true) - } - - async fn save(&self, _event: Arc>) -> Result<(), TargetError> { - if self.fail { - Err(TargetError::Configuration("forced save failure".to_string())) - } else { - Ok(()) - } - } - - async fn send_raw_from_store(&self, _key: Key, _body: Vec, _meta: QueuedPayloadMeta) -> Result<(), TargetError> { - Ok(()) - } - - async fn close(&self) -> Result<(), TargetError> { - Ok(()) - } - - fn store(&self) -> Option<&(dyn Store + Send + Sync)> { - None - } - - fn clone_dyn(&self) -> Box + Send + Sync> { - Box::new(self.clone()) - } - - fn is_enabled(&self) -> bool { - true - } + /// Builds a mock target whose `save()` outcome is fixed at construction so tests can force + /// full-success / full-failure / partial-failure fan-outs. + fn mock_target(id: &str, fail: bool) -> MockTarget { + let target = MockTarget::new(id, "webhook"); + if fail { target.with_save_failures(usize::MAX) } else { target } } fn pipeline_with(targets: Vec) -> AuditPipeline { let mut registry = AuditRegistry::new(); for target in targets { - registry.add_target(target.id.to_string(), Box::new(target)); + registry.add_target(target.target_id().to_string(), Box::new(target)); } AuditPipeline::new(Arc::new(Mutex::new(registry))) } @@ -658,7 +591,7 @@ mod tests { // dispatch must return Err rather than swallowing the failures as Ok. #[tokio::test] async fn dispatch_returns_err_when_all_targets_fail() { - let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", true), MockTarget::new("b:webhook", true)]); + let pipeline = pipeline_with(vec![mock_target("a:webhook", true), mock_target("b:webhook", true)]); let result = pipeline.dispatch(entry()).await; assert!(matches!(result, Err(AuditError::Target(_))), "expected Err, got {result:?}"); } @@ -667,13 +600,13 @@ mod tests { // so dispatch reports success (degradation is logged, not propagated). #[tokio::test] async fn dispatch_returns_ok_on_partial_failure() { - let pipeline = pipeline_with(vec![MockTarget::new("ok:webhook", false), MockTarget::new("bad:webhook", true)]); + let pipeline = pipeline_with(vec![mock_target("ok:webhook", false), mock_target("bad:webhook", true)]); pipeline.dispatch(entry()).await.expect("partial success should return Ok"); } #[tokio::test] async fn dispatch_returns_ok_when_all_targets_succeed() { - let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", false), MockTarget::new("b:webhook", false)]); + let pipeline = pipeline_with(vec![mock_target("a:webhook", false), mock_target("b:webhook", false)]); pipeline.dispatch(entry()).await.expect("all-success should return Ok"); } @@ -686,9 +619,10 @@ mod tests { #[tokio::test] async fn health_probe_does_not_hold_the_registry_lock() { - let started = Arc::new(Notify::new()); let release = Arc::new(Notify::new()); - let pipeline = pipeline_with(vec![MockTarget::new("blocked", false).with_health_gate(started.clone(), release.clone())]); + let target = mock_target("blocked", false).with_health_gate(release.clone()); + let started = target.health_started(); + let pipeline = pipeline_with(vec![target]); let registry = Arc::clone(&pipeline.registry); let snapshot_task = tokio::spawn(async move { pipeline.snapshot_target_health().await }); started.notified().await; @@ -706,14 +640,14 @@ mod tests { // whole-batch loss instead of returning Ok. #[tokio::test] async fn dispatch_batch_returns_err_when_all_targets_fail() { - let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", true)]); + let pipeline = pipeline_with(vec![mock_target("a:webhook", true)]); let result = pipeline.dispatch_batch(vec![entry(), entry()]).await; assert!(matches!(result, Err(AuditError::Target(_))), "expected Err, got {result:?}"); } #[tokio::test] async fn dispatch_batch_returns_ok_when_all_targets_succeed() { - let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", false), MockTarget::new("b:webhook", false)]); + let pipeline = pipeline_with(vec![mock_target("a:webhook", false), mock_target("b:webhook", false)]); pipeline .dispatch_batch(vec![entry(), entry()]) .await diff --git a/crates/audit/src/registry.rs b/crates/audit/src/registry.rs index 0ee82a92d..4e88d0a89 100644 --- a/crates/audit/src/registry.rs +++ b/crates/audit/src/registry.rs @@ -286,70 +286,10 @@ impl AuditRegistry { #[cfg(test)] mod tests { use super::AuditRegistry; - use crate::{AuditEntry, AuditError}; - use rustfs_targets::arn::TargetID; - use rustfs_targets::store::{Key, Store}; - use rustfs_targets::target::{ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta}; - use rustfs_targets::{StoreError, Target, TargetError}; - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; - - #[derive(Clone)] - struct CloseTestTarget { - id: TargetID, - close_calls: Arc, - fail_on_close: bool, - } - - impl CloseTestTarget { - fn new(id: TargetID, close_calls: Arc, fail_on_close: bool) -> Self { - Self { - id, - close_calls, - fail_on_close, - } - } - } - - #[async_trait::async_trait] - impl Target for CloseTestTarget { - fn id(&self) -> TargetID { - self.id.clone() - } - - async fn is_active(&self) -> Result { - Ok(true) - } - - async fn save(&self, _event: Arc>) -> Result<(), TargetError> { - Ok(()) - } - - async fn send_raw_from_store(&self, _key: Key, _body: Vec, _meta: QueuedPayloadMeta) -> Result<(), TargetError> { - Ok(()) - } - - async fn close(&self) -> Result<(), TargetError> { - self.close_calls.fetch_add(1, Ordering::SeqCst); - if self.fail_on_close { - Err(TargetError::Unknown("close failed".to_string())) - } else { - Ok(()) - } - } - - fn store(&self) -> Option<&(dyn Store + Send + Sync)> { - None - } - - fn clone_dyn(&self) -> Box + Send + Sync> { - Box::new(self.clone()) - } - - fn is_enabled(&self) -> bool { - true - } - } + use crate::AuditError; + use rustfs_targets::TargetError; + use rustfs_targets::target::ChannelTargetType; + use rustfs_targets::testkit::MockTarget; #[test] fn registry_registers_amqp_factory() { @@ -361,23 +301,21 @@ mod tests { #[tokio::test] async fn close_all_returns_first_error_and_clears_targets() { let mut registry = AuditRegistry::new(); - let ok_calls = Arc::new(AtomicUsize::new(0)); - let fail_calls = Arc::new(AtomicUsize::new(0)); + let ok = MockTarget::new("ok", "webhook"); + let ok_observer = ok.clone(); + let fail = MockTarget::new("fail", "webhook") + .with_close_failures(usize::MAX) + .with_close_failure_error(|| TargetError::Unknown("close failed".to_string())); + let fail_observer = fail.clone(); - let ok_id = TargetID::new("ok".to_string(), "webhook".to_string()); - let fail_id = TargetID::new("fail".to_string(), "webhook".to_string()); - - registry.add_target(ok_id.to_string(), Box::new(CloseTestTarget::new(ok_id, Arc::clone(&ok_calls), false))); - registry.add_target( - fail_id.to_string(), - Box::new(CloseTestTarget::new(fail_id, Arc::clone(&fail_calls), true)), - ); + registry.add_target(ok.target_id().to_string(), Box::new(ok)); + registry.add_target(fail.target_id().to_string(), Box::new(fail)); let result = registry.close_all().await; assert!(matches!(result, Err(AuditError::Target(TargetError::Unknown(_))))); - assert_eq!(ok_calls.load(Ordering::SeqCst), 1); - assert_eq!(fail_calls.load(Ordering::SeqCst), 1); + assert_eq!(ok_observer.close_call_count(), 1); + assert_eq!(fail_observer.close_call_count(), 1); assert!(registry.list_targets().is_empty()); } } diff --git a/crates/audit/src/system.rs b/crates/audit/src/system.rs index 4d79a04e6..0dcddd59e 100644 --- a/crates/audit/src/system.rs +++ b/crates/audit/src/system.rs @@ -577,76 +577,17 @@ fn warn_audit_state(state: &str, reason: Option<&str>) { mod tests { use super::{AuditSystem, AuditSystemState}; use crate::{AuditEntry, AuditError}; - use async_trait::async_trait; use rustfs_targets::ReplayWorkerManager; - use rustfs_targets::arn::TargetID; - use rustfs_targets::store::{Key, Store}; - use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta}; - use rustfs_targets::{StoreError, Target, TargetError}; + use rustfs_targets::testkit::MockTarget; use std::collections::HashMap; use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::sync::mpsc; - #[derive(Clone)] - struct TestTarget { - close_calls: Arc, - id: TargetID, - } - - impl TestTarget { - fn new(id: &str, name: &str) -> Self { - Self { - close_calls: Arc::new(AtomicUsize::new(0)), - id: TargetID::new(id.to_string(), name.to_string()), - } - } - } - - #[async_trait] - impl Target for TestTarget - where - E: rustfs_targets::PluginEvent, - { - fn id(&self) -> TargetID { - self.id.clone() - } - - async fn is_active(&self) -> Result { - Ok(true) - } - - async fn save(&self, _event: Arc>) -> Result<(), TargetError> { - Ok(()) - } - - async fn send_raw_from_store(&self, _key: Key, _body: Vec, _meta: QueuedPayloadMeta) -> Result<(), TargetError> { - Ok(()) - } - - async fn close(&self) -> Result<(), TargetError> { - self.close_calls.fetch_add(1, Ordering::SeqCst); - Ok(()) - } - - fn store(&self) -> Option<&(dyn Store + Send + Sync)> { - None - } - - fn clone_dyn(&self) -> Box + Send + Sync> { - Box::new(self.clone()) - } - - fn is_enabled(&self) -> bool { - true - } - } - #[tokio::test] async fn reload_with_empty_config_stops_existing_runtime() { let system = AuditSystem::new(); - let target = TestTarget::new("primary", "webhook"); - let close_calls = Arc::clone(&target.close_calls); + let target = MockTarget::new("primary", "webhook"); + let observer = target.clone(); { let mut registry = system.registry.lock().await; @@ -671,7 +612,7 @@ mod tests { assert_eq!(system.get_state().await, AuditSystemState::Stopped); assert!(system.list_targets().await.is_empty()); assert_eq!(system.runtime_status_snapshot().await, ReplayWorkerManager::new().snapshot(0)); - assert_eq!(close_calls.load(Ordering::SeqCst), 1); + assert_eq!(observer.close_call_count(), 1); assert_eq!(*system.config.read().await, Some(rustfs_config::server_config::Config(HashMap::new()))); } @@ -693,7 +634,7 @@ mod tests { // Seed a target + replay worker so both critical sections touch real state. { let mut registry = system.registry.lock().await; - registry.add_target("primary:webhook".to_string(), Box::new(TestTarget::new("primary", "webhook"))); + registry.add_target("primary:webhook".to_string(), Box::new(MockTarget::new("primary", "webhook"))); } { let mut replay_workers = system.stream_cancellers.write().await; @@ -793,8 +734,8 @@ mod tests { async fn commit_closes_old_targets_before_installing_new() { let system = AuditSystem::new(); - let old = TestTarget::new("old", "webhook"); - let old_close = Arc::clone(&old.close_calls); + let old = MockTarget::new("old", "webhook"); + let old_observer = old.clone(); { let mut registry = system.registry.lock().await; registry.add_target("old:webhook".to_string(), Box::new(old)); @@ -809,17 +750,17 @@ mod tests { *state = AuditSystemState::Running; } - let new = TestTarget::new("new", "webhook"); - let new_close = Arc::clone(&new.close_calls); + let new = MockTarget::new("new", "webhook"); + let new_observer = new.clone(); system .commit_runtime_targets(vec![Box::new(new)], AuditSystemState::Running) .await .expect("commit should succeed"); // Old target closed exactly once during the pre-install shutdown. - assert_eq!(old_close.load(Ordering::SeqCst), 1); + assert_eq!(old_observer.close_call_count(), 1); // New target installed and left open. - assert_eq!(new_close.load(Ordering::SeqCst), 0); + assert_eq!(new_observer.close_call_count(), 0); assert_eq!(system.list_targets().await, vec!["new:webhook".to_string()]); // Old replay worker stopped; the store-less new target adds none. assert_eq!(system.runtime_status_snapshot().await.replay_worker_count, 0); diff --git a/crates/audit/tests/pipeline_layer_test.rs b/crates/audit/tests/pipeline_layer_test.rs index 5d5608db2..903244ee0 100644 --- a/crates/audit/tests/pipeline_layer_test.rs +++ b/crates/audit/tests/pipeline_layer_test.rs @@ -12,136 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -use async_trait::async_trait; use rustfs_audit::{AuditEntry, AuditError, AuditPipeline, AuditRegistry, AuditRuntimeFacade, AuditRuntimeView}; -use rustfs_targets::arn::TargetID; -use rustfs_targets::store::{Key, Store}; -use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta}; -use rustfs_targets::{SharedTarget, StoreError, Target, TargetError}; -use serde::{Serialize, de::DeserializeOwned}; +use rustfs_targets::SharedTarget; +use rustfs_targets::testkit::MockTarget; use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::sync::{Mutex, RwLock}; -#[derive(Clone)] -struct TestTarget { - close_calls: Arc, - id: TargetID, - init_calls: Arc, -} - -impl TestTarget { - fn new(id: &str, name: &str) -> Self { - Self { - close_calls: Arc::new(AtomicUsize::new(0)), - id: TargetID::new(id.to_string(), name.to_string()), - init_calls: Arc::new(AtomicUsize::new(0)), - } - } -} - -#[async_trait] -impl Target for TestTarget -where - E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned, -{ - fn id(&self) -> TargetID { - self.id.clone() - } - - async fn is_active(&self) -> Result { - Ok(true) - } - - async fn save(&self, _event: Arc>) -> Result<(), TargetError> { - Ok(()) - } - - async fn send_raw_from_store(&self, _key: Key, _body: Vec, _meta: QueuedPayloadMeta) -> Result<(), TargetError> { - Ok(()) - } - - async fn close(&self) -> Result<(), TargetError> { - self.close_calls.fetch_add(1, Ordering::SeqCst); - Ok(()) - } - - fn store(&self) -> Option<&(dyn Store + Send + Sync)> { - None - } - - fn clone_dyn(&self) -> Box + Send + Sync> { - Box::new(self.clone()) - } - - async fn init(&self) -> Result<(), TargetError> { - self.init_calls.fetch_add(1, Ordering::SeqCst); - Ok(()) - } - - fn is_enabled(&self) -> bool { - true - } -} - -/// A target whose `save()` always fails, used to exercise the dispatch +/// Builds a target whose `save()` always fails, used to exercise the dispatch /// failure-propagation paths. -#[derive(Clone)] -struct FailingTarget { - id: TargetID, - save_calls: Arc, -} - -impl FailingTarget { - fn new(id: &str, name: &str) -> Self { - Self { - id: TargetID::new(id.to_string(), name.to_string()), - save_calls: Arc::new(AtomicUsize::new(0)), - } - } -} - -#[async_trait] -impl Target for FailingTarget -where - E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned, -{ - fn id(&self) -> TargetID { - self.id.clone() - } - - async fn is_active(&self) -> Result { - Ok(true) - } - - async fn save(&self, _event: Arc>) -> Result<(), TargetError> { - self.save_calls.fetch_add(1, Ordering::SeqCst); - Err(TargetError::Storage("disk full".to_string())) - } - - async fn send_raw_from_store(&self, _key: Key, _body: Vec, _meta: QueuedPayloadMeta) -> Result<(), TargetError> { - Ok(()) - } - - async fn close(&self) -> Result<(), TargetError> { - Ok(()) - } - - fn store(&self) -> Option<&(dyn Store + Send + Sync)> { - None - } - - fn clone_dyn(&self) -> Box + Send + Sync> { - Box::new(self.clone()) - } - - async fn init(&self) -> Result<(), TargetError> { - Ok(()) - } - - fn is_enabled(&self) -> bool { - true - } +fn failing_target(id: &str, name: &str) -> MockTarget { + MockTarget::new(id, name).with_save_failures(usize::MAX) } fn pipeline_with_targets(targets: Vec<(&str, SharedTarget)>) -> AuditPipeline { @@ -154,8 +34,8 @@ fn pipeline_with_targets(targets: Vec<(&str, SharedTarget)>) -> Audi #[tokio::test] async fn audit_pipeline_dispatch_propagates_total_failure() { - let failing = FailingTarget::new("primary", "webhook"); - let save_calls = Arc::clone(&failing.save_calls); + let failing = failing_target("primary", "webhook"); + let observer = failing.clone(); let pipeline = pipeline_with_targets(vec![("primary:webhook", Arc::new(failing))]); let result = pipeline.dispatch(Arc::new(AuditEntry::default())).await; @@ -164,13 +44,13 @@ async fn audit_pipeline_dispatch_propagates_total_failure() { matches!(result, Err(AuditError::Target(_))), "dispatch must surface an error when every target fails, got {result:?}" ); - assert_eq!(save_calls.load(Ordering::SeqCst), 1, "the failing target should have been invoked"); + assert_eq!(observer.save_call_count(), 1, "the failing target should have been invoked"); } #[tokio::test] async fn audit_pipeline_dispatch_tolerates_partial_failure() { - let failing = FailingTarget::new("primary", "webhook"); - let healthy = TestTarget::new("secondary", "webhook"); + let failing = failing_target("primary", "webhook"); + let healthy = MockTarget::new("secondary", "webhook"); let pipeline = pipeline_with_targets(vec![ ("primary:webhook", Arc::new(failing)), ("secondary:webhook", Arc::new(healthy)), @@ -186,7 +66,7 @@ async fn audit_pipeline_dispatch_tolerates_partial_failure() { #[tokio::test] async fn audit_pipeline_dispatch_batch_propagates_total_failure() { - let failing = FailingTarget::new("primary", "webhook"); + let failing = failing_target("primary", "webhook"); let pipeline = pipeline_with_targets(vec![("primary:webhook", Arc::new(failing))]); let entries = vec![Arc::new(AuditEntry::default()), Arc::new(AuditEntry::default())]; @@ -200,8 +80,8 @@ async fn audit_pipeline_dispatch_batch_propagates_total_failure() { #[tokio::test] async fn audit_pipeline_dispatch_batch_tolerates_partial_failure() { - let failing = FailingTarget::new("primary", "webhook"); - let healthy = TestTarget::new("secondary", "webhook"); + let failing = failing_target("primary", "webhook"); + let healthy = MockTarget::new("secondary", "webhook"); let pipeline = pipeline_with_targets(vec![ ("primary:webhook", Arc::new(failing)), ("secondary:webhook", Arc::new(healthy)), @@ -266,9 +146,8 @@ async fn audit_runtime_facade_activates_empty_target_list() { async fn audit_runtime_view_upsert_and_remove_target() { let registry = Arc::new(Mutex::new(AuditRegistry::new())); let runtime_view = AuditRuntimeView::new(registry.clone()); - let target = TestTarget::new("primary", "webhook"); - let init_calls = Arc::clone(&target.init_calls); - let close_calls = Arc::clone(&target.close_calls); + let target = MockTarget::new("primary", "webhook"); + let observer = target.clone(); runtime_view .upsert_target("primary:webhook".to_string(), Box::new(target)) @@ -276,7 +155,7 @@ async fn audit_runtime_view_upsert_and_remove_target() { .expect("upsert should succeed"); assert_eq!(runtime_view.list_targets().await, vec!["primary:webhook".to_string()]); - assert_eq!(init_calls.load(Ordering::SeqCst), 1); + assert_eq!(observer.init_call_count(), 1); runtime_view .remove_target("primary:webhook") @@ -284,7 +163,7 @@ async fn audit_runtime_view_upsert_and_remove_target() { .expect("remove should succeed"); assert!(runtime_view.list_targets().await.is_empty()); - assert_eq!(close_calls.load(Ordering::SeqCst), 1); + assert_eq!(observer.close_call_count(), 1); } #[tokio::test] @@ -292,7 +171,7 @@ async fn audit_runtime_facade_replace_targets_commits_runtime_state() { let registry = Arc::new(Mutex::new(AuditRegistry::new())); let replay_workers = Arc::new(RwLock::new(rustfs_targets::ReplayWorkerManager::new())); let facade = AuditRuntimeFacade::new(registry.clone(), replay_workers.clone()); - let target = TestTarget::new("primary", "webhook"); + let target = MockTarget::new("primary", "webhook"); let activation = rustfs_targets::RuntimeActivation { replay_workers: rustfs_targets::ReplayWorkerManager::new(), targets: vec![Arc::new(target) as rustfs_targets::SharedTarget], diff --git a/crates/checksums/src/lib.rs b/crates/checksums/src/lib.rs index 5b566fe83..479ebeed0 100644 --- a/crates/checksums/src/lib.rs +++ b/crates/checksums/src/lib.rs @@ -41,14 +41,22 @@ pub const XXHASH_64_NAME: &str = "xxhash64"; pub const XXHASH_128_NAME: &str = "xxhash128"; pub const MD5_NAME: &str = "md5"; -/// One of three deliberately separate checksum registries (backlog#1833): -/// this enum owns the **streaming-hash algorithm registry**, including the -/// RustFS extensions (sha512, xxhash3/64/128). The on-disk xl.meta bitset -/// lives in `rustfs_rio::ChecksumType` (crates/rio/src/checksum.rs, varint -/// bits are append-only), and the MinIO-port client keeps its own -/// `ChecksumMode` (crates/ecstore/src/client/checksum.rs). When adding an -/// algorithm, extend all three (or record why not) — they do not derive from -/// each other. +/// The canonical checksum-algorithm registry (backlog#1833, backlog#1844): +/// this enum owns the streaming-hash implementations and, via the exhaustive +/// per-algorithm metadata methods below, the wire names, header names, digest +/// lengths, and checksum-type capabilities — including the RustFS extensions +/// (sha512, xxhash3/64/128). The MinIO-port client's `ChecksumMode` +/// (crates/s3-client/src/checksum.rs) delegates all per-algorithm dispatch +/// here through its `algorithm()` bridge. The on-disk xl.meta bitset remains +/// deliberately separate in `rustfs_rio::ChecksumType` +/// (crates/rio/src/checksum.rs, varint bits are append-only), and rio also +/// keeps its own hot-path hasher shells — equivalence with this crate's +/// hashers is enforced by both test suites pinning the same official +/// known-answer vectors (backlog#1844 PR3 verdict, recorded on +/// `rustfs_rio::ChecksumType`). When adding an algorithm: add the variant +/// here (the exhaustive matches force every metadata decision), bridge it in +/// the client, and allocate an xl.meta bit + hasher + shared vector in rio +/// (or record why not). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[non_exhaustive] pub enum ChecksumAlgorithm { @@ -120,6 +128,84 @@ impl ChecksumAlgorithm { Self::Xxhash128 => XXHASH_128_NAME, } } + + // Per-algorithm wire metadata. These matches are deliberately exhaustive + // (no `_` arm): adding a ChecksumAlgorithm variant without deciding its + // name, header, digest length, and checksum-type support must fail to + // compile rather than silently inherit a default (backlog#1844). + + /// The canonical `x-amz-checksum-algorithm` wire value (uppercase), as + /// carried in S3 requests/responses and stored checksum maps. + pub fn s3_algorithm_name(&self) -> &'static str { + match self { + Self::Crc32 => "CRC32", + Self::Crc32c => "CRC32C", + Self::Crc64Nvme => "CRC64NVME", + Self::Sha1 => "SHA1", + Self::Sha256 => "SHA256", + Self::Sha512 => "SHA512", + Self::Xxhash3 => "XXHASH3", + Self::Xxhash64 => "XXHASH64", + Self::Xxhash128 => "XXHASH128", + } + } + + /// The `x-amz-checksum-*` HTTP header that carries this algorithm's + /// base64-encoded digest. + pub fn http_header_name(&self) -> &'static str { + match self { + Self::Crc32 => http::CRC_32_HEADER_NAME, + Self::Crc32c => http::CRC_32_C_HEADER_NAME, + Self::Crc64Nvme => http::CRC_64_NVME_HEADER_NAME, + Self::Sha1 => http::SHA_1_HEADER_NAME, + Self::Sha256 => http::SHA_256_HEADER_NAME, + Self::Sha512 => http::SHA_512_HEADER_NAME, + Self::Xxhash3 => http::XXHASH_3_HEADER_NAME, + Self::Xxhash64 => http::XXHASH_64_HEADER_NAME, + Self::Xxhash128 => http::XXHASH_128_HEADER_NAME, + } + } + + /// Raw (unencoded) digest length in bytes. + pub fn raw_len(&self) -> usize { + match self { + Self::Crc32 | Self::Crc32c => 4, + Self::Crc64Nvme => 8, + Self::Sha1 => 20, + Self::Sha256 => 32, + Self::Sha512 => 64, + Self::Xxhash3 | Self::Xxhash64 => 8, + Self::Xxhash128 => 16, + } + } + + /// Whether the algorithm supports the S3 COMPOSITE multipart checksum + /// type. Per the AWS registry, every algorithm does except CRC64NVME, + /// which is FULL_OBJECT-only. + pub fn supports_composite(&self) -> bool { + match self { + Self::Crc64Nvme => false, + Self::Crc32 + | Self::Crc32c + | Self::Sha1 + | Self::Sha256 + | Self::Sha512 + | Self::Xxhash3 + | Self::Xxhash64 + | Self::Xxhash128 => true, + } + } + + /// Whether the algorithm supports the S3 FULL_OBJECT checksum type, i.e. + /// part digests can be linearly combined into the whole-object digest. + /// Only the CRC family has this property; the hash algorithms are + /// COMPOSITE-only. + pub fn supports_full_object(&self) -> bool { + match self { + Self::Crc32 | Self::Crc32c | Self::Crc64Nvme => true, + Self::Sha1 | Self::Sha256 | Self::Sha512 | Self::Xxhash3 | Self::Xxhash64 | Self::Xxhash128 => false, + } + } } pub trait Checksum: Send + Sync { @@ -731,6 +817,77 @@ mod tests { assert_eq!(&raw[..], reference.digest128().to_be_bytes().as_slice()); } + #[test] + fn test_algorithm_metadata_is_consistent_for_every_variant() { + use crate::Checksum; + + // Cross-checks the per-algorithm metadata methods against the hasher + // implementations themselves, so the registry cannot drift from the + // code that computes digests (backlog#1844). The list must cover every + // variant; the metadata methods use exhaustive matches, so a new + // variant that is missing here still fails to compile there first. + let all = [ + ChecksumAlgorithm::Crc32, + ChecksumAlgorithm::Crc32c, + ChecksumAlgorithm::Crc64Nvme, + ChecksumAlgorithm::Sha1, + ChecksumAlgorithm::Sha256, + ChecksumAlgorithm::Sha512, + ChecksumAlgorithm::Xxhash3, + ChecksumAlgorithm::Xxhash64, + ChecksumAlgorithm::Xxhash128, + ]; + + for algorithm in all { + // Digest length must match what the hasher actually produces. + let mut hasher = algorithm.into_impl(); + hasher.update(b"metadata consistency probe"); + assert_eq!( + algorithm.raw_len(), + Checksum::size(&*algorithm.into_impl()) as usize, + "{algorithm:?} raw_len() != hasher size()" + ); + assert_eq!(hasher.finalize().len(), algorithm.raw_len(), "{algorithm:?} finalize length != raw_len()"); + + // Header name must match the hasher's own header binding. + assert_eq!( + algorithm.http_header_name(), + algorithm.into_impl().header_name(), + "{algorithm:?} http_header_name() != HttpChecksum::header_name()" + ); + assert_eq!( + algorithm.http_header_name(), + format!("x-amz-checksum-{}", algorithm.as_str()), + "{algorithm:?} header must be x-amz-checksum-" + ); + + // The uppercase wire name and the lowercase parse name must be the + // same word, and the wire name must parse back to the variant. + assert!( + algorithm.s3_algorithm_name().eq_ignore_ascii_case(algorithm.as_str()), + "{algorithm:?} s3_algorithm_name() and as_str() diverge" + ); + assert_eq!(algorithm.s3_algorithm_name().parse::().unwrap(), algorithm); + } + + // AWS checksum-type support table: CRC64NVME is FULL_OBJECT-only, the + // CRC family supports FULL_OBJECT, everything else is COMPOSITE-only. + for algorithm in all { + let composite = algorithm.supports_composite(); + let full_object = algorithm.supports_full_object(); + assert!(composite || full_object, "{algorithm:?} supports no checksum type at all"); + match algorithm { + ChecksumAlgorithm::Crc32 | ChecksumAlgorithm::Crc32c => { + assert!(composite && full_object, "{algorithm:?} must support both checksum types") + } + ChecksumAlgorithm::Crc64Nvme => { + assert!(!composite && full_object, "CRC64NVME must be FULL_OBJECT-only") + } + _ => assert!(composite && !full_object, "{algorithm:?} must be COMPOSITE-only"), + } + } + } + #[test] fn test_xxhash64_matches_direct_computation_big_endian_seed0() { use crate::Xxhash64; diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index b78526088..1d610aed2 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -39,10 +39,6 @@ tokio = { workspace = true, features = ["fs", "rt-multi-thread"] } tonic = { workspace = true, features = ["gzip", "deflate"] } uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] } metrics = { workspace = true } -# Transitional shim deps (backlog#1843): dropped with the re-export shims once -# every consumer imports the contracts crates directly. -rustfs-heal-contracts = { workspace = true } -rustfs-scanner-contracts = { workspace = true } smallvec = { workspace = true } tracing = { workspace = true } diff --git a/crates/common/src/globals.rs b/crates/common/src/globals.rs index d4fe349e2..e4cd89c88 100644 --- a/crates/common/src/globals.rs +++ b/crates/common/src/globals.rs @@ -27,10 +27,6 @@ pub static GLOBAL_ROOT_CERT: LazyLock>>> = LazyLock::new(| pub static GLOBAL_MTLS_IDENTITY: LazyLock>> = LazyLock::new(|| RwLock::new(None)); pub static GLOBAL_OUTBOUND_TLS_GENERATION: LazyLock = LazyLock::new(|| AtomicU64::new(0)); -// Transitional re-export shim (backlog#1843): the node init-time global moved -// to rustfs-scanner-contracts, whose metrics report reads it directly. -pub use rustfs_scanner_contracts::{GLOBAL_INIT_TIME, get_global_init_time, set_global_init_time_now}; - /// Log level to use when reporting cached gRPC connection eviction. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ConnectionEvictionLogLevel { diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 9cfc5bfea..4c7d8cfe3 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -19,13 +19,6 @@ mod readiness; pub mod table_catalog; pub mod trace_bus; -// Transitional re-export shims (backlog#1843): these modules moved to the -// rustfs-heal-contracts / rustfs-scanner-contracts crates. Consumers migrate -// to the new paths crate by crate; the shims are deleted once -// `rg 'rustfs_common::(metrics|heal_channel|last_minute)'` reports zero hits. -pub use rustfs_heal_contracts::heal_channel; -pub use rustfs_scanner_contracts::{last_minute, metrics}; - pub use globals::*; pub use readiness::{GlobalReadiness, SystemStage}; diff --git a/crates/e2e_test/Cargo.toml b/crates/e2e_test/Cargo.toml index 81184ad52..75a539561 100644 --- a/crates/e2e_test/Cargo.toml +++ b/crates/e2e_test/Cargo.toml @@ -121,10 +121,10 @@ tracing-subscriber = { workspace = true, features = ["env-filter", "time"] } uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] } urlencoding.workspace = true walkdir.workspace = true -base64 = { workspace = true } +base64-simd = { workspace = true } rand = { workspace = true, features = ["serde"] } chrono = { workspace = true, features = ["serde"] } -hex = { workspace = true } +hex-simd = { workspace = true } md-5 = { workspace = true } opentelemetry-proto = { workspace = true } prost.workspace = true diff --git a/crates/e2e_test/src/checksum_upload_test.rs b/crates/e2e_test/src/checksum_upload_test.rs index 78b5d9fdd..3a242c3b2 100644 --- a/crates/e2e_test/src/checksum_upload_test.rs +++ b/crates/e2e_test/src/checksum_upload_test.rs @@ -24,7 +24,6 @@ mod tests { use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::types::{ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart}; use aws_smithy_http_client::Builder as SmithyHttpClientBuilder; - use base64::Engine; use md5::{Digest as Md5Digest, Md5}; use rustfs_rio::{Checksum, ChecksumType as RioChecksumType}; use sha2::Sha256; @@ -74,12 +73,12 @@ mod tests { let mut hasher = Md5::new(); hasher.update(body); let digest = hasher.finalize(); - base64::engine::general_purpose::STANDARD.encode(digest.as_slice()) + base64_simd::STANDARD.encode_to_string(digest.as_slice()) } fn checksum_sha256_base64(body: &[u8]) -> String { let digest = Sha256::digest(body); - base64::engine::general_purpose::STANDARD.encode(digest.as_slice()) + base64_simd::STANDARD.encode_to_string(digest.as_slice()) } fn checksum_crc64nvme_base64(body: &[u8]) -> String { diff --git a/crates/e2e_test/src/compression_test.rs b/crates/e2e_test/src/compression_test.rs index 1ed0269ec..f9df6167d 100644 --- a/crates/e2e_test/src/compression_test.rs +++ b/crates/e2e_test/src/compression_test.rs @@ -652,11 +652,10 @@ const MPU_SSE_COMPRESSION_BUCKET: &str = "compression-mpu-sse-bucket"; async fn start_rustfs_with_compression_and_sse( env: &mut RustFSTestEnvironment, ) -> Result<(), Box> { - use base64::Engine; env.cleanup_existing_processes().await?; let binary_path = rustfs_binary_path(); - let master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]); + let master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]); // Server output goes to a file inside the per-test temp dir so a failing // run can be diagnosed from the child's logs. let server_log = std::fs::File::create(format!("{}/server.log", env.temp_dir))?; diff --git a/crates/e2e_test/src/copy_object_checksum_test.rs b/crates/e2e_test/src/copy_object_checksum_test.rs index b4ac11bbb..a39b04141 100644 --- a/crates/e2e_test/src/copy_object_checksum_test.rs +++ b/crates/e2e_test/src/copy_object_checksum_test.rs @@ -27,8 +27,7 @@ mod tests { VersioningConfiguration, }; use aws_smithy_http_client::Builder as SmithyHttpClientBuilder; - use base64::Engine as _; - use base64::engine::general_purpose::STANDARD as BASE64; + use base64_simd::STANDARD as BASE64; use rustfs_rio::{Checksum, ChecksumType as RioChecksumType}; use sha2::{Digest, Sha256}; use tracing::info; @@ -465,7 +464,7 @@ mod tests { create_versioned_bucket(&client, dst_bucket).await; let content = b"deterministic synthetic payload for copy-object checksum #4996"; - let expected_sha256 = BASE64.encode(Sha256::digest(content)); + let expected_sha256 = BASE64.encode_to_string(Sha256::digest(content)); client .put_object() @@ -534,7 +533,7 @@ mod tests { create_versioned_bucket(&client, dst_bucket).await; let content = b"another deterministic payload whose source checksum must survive the copy"; - let expected_sha256 = BASE64.encode(Sha256::digest(content)); + let expected_sha256 = BASE64.encode_to_string(Sha256::digest(content)); // Store the source WITH a SHA-256 checksum so it has one to preserve. let put_src = client @@ -614,7 +613,7 @@ mod tests { create_versioned_bucket(&client, dst_bucket).await; let content = b"payload whose copy must be re-checksummed with a different algorithm"; - let expected_sha256 = BASE64.encode(Sha256::digest(content)); + let expected_sha256 = BASE64.encode_to_string(Sha256::digest(content)); // Source is stored WITH a SHA-256 checksum. client diff --git a/crates/e2e_test/src/degraded_read_eof_regression_test.rs b/crates/e2e_test/src/degraded_read_eof_regression_test.rs index 9b3c66649..5592d831e 100644 --- a/crates/e2e_test/src/degraded_read_eof_regression_test.rs +++ b/crates/e2e_test/src/degraded_read_eof_regression_test.rs @@ -23,8 +23,8 @@ //! It was fixed in three layers on `main`, each with its own *unit* regression: //! * rustfs#4594 — `GetObjectStreamingReader::poll_read` now returns //! `UnexpectedEof` on a short body instead of a clean `Ok(())` -//! (`rustfs/src/app/object_usecase.rs`, -//! `app::object_usecase::tests::get_object_streaming_reader_errors_on_short_eof`). +//! (`rustfs/src/app/object/get.rs`, +//! `app::object::get::tests::get_object_streaming_reader_errors_on_short_eof`). //! * rustfs#4560 — the lazy multipart codec reader degrades a later part to //! the legacy per-part decode in place, and surfaces reconstruction errors //! instead of silently truncating diff --git a/crates/e2e_test/src/fake_s3_target/mod.rs b/crates/e2e_test/src/fake_s3_target/mod.rs index aad643043..c5d7edffb 100644 --- a/crates/e2e_test/src/fake_s3_target/mod.rs +++ b/crates/e2e_test/src/fake_s3_target/mod.rs @@ -1113,7 +1113,7 @@ fn md5_bytes(input: impl AsRef<[u8]>) -> [u8; 16] { fn md5_hex(input: impl AsRef<[u8]>) -> String { let mut hasher = Md5::new(); hasher.update(input.as_ref()); - hex::encode(hasher.finalize()) + hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower) } fn ensure_store_budget(state: &StoreState, removed_bytes: usize, added_bytes: usize, adds_version: bool) -> S3Result { @@ -1375,7 +1375,7 @@ impl S3 for FakeBackend { Some(value) => value, None => { let (digest, _body_permit) = md5_digest(body.clone(), _body_permit).await?; - hex::encode(digest) + hex_simd::encode_to_string(digest, hex_simd::AsciiCase::Lower) } }; let version = ObjectVersion { @@ -1660,7 +1660,7 @@ impl S3 for FakeBackend { } let body = collect_stream(input.body, input.content_length, fault.as_ref(), &self.control).await?; let (digest, _body_permit) = md5_digest(body.clone(), _body_permit).await?; - let e_tag = hex::encode(digest); + let e_tag = hex_simd::encode_to_string(digest, hex_simd::AsciiCase::Lower); let mut state = lock(&self.store); let existing_bytes = state .uploads diff --git a/crates/e2e_test/src/inline_fast_path_cluster_test.rs b/crates/e2e_test/src/inline_fast_path_cluster_test.rs index 8de63303f..5583a1b84 100644 --- a/crates/e2e_test/src/inline_fast_path_cluster_test.rs +++ b/crates/e2e_test/src/inline_fast_path_cluster_test.rs @@ -28,7 +28,6 @@ use aws_sdk_s3::types::{ BucketLifecycleConfiguration, BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, ExpirationStatus, LifecycleRule, LifecycleRuleFilter, ServerSideEncryption, Transition, TransitionStorageClass, VersioningConfiguration, }; -use base64::Engine; use bytes::Bytes; use flate2::read::GzDecoder; use http::header::{CONTENT_ENCODING, HOST}; @@ -1808,7 +1807,7 @@ async fn four_node_inline_fallback_controls() -> TestResult { let collector = OtlpMetricCollector::start().await?; let mut cluster = RustFSTestClusterEnvironment::new(4).await?; configure_reader_metric_cluster(&mut cluster, &collector); - let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]); + let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]); cluster.set_env("RUSTFS_SSE_S3_MASTER_KEY", &sse_master_key); cluster.start().await?; @@ -2017,7 +2016,7 @@ async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls() -> Te let collector = OtlpMetricCollector::start().await?; let mut cluster = RustFSTestClusterEnvironment::new(4).await?; - let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]); + let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]); cluster.set_env("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key); cluster.set_env("RUSTFS_COMPRESSION_ENABLED", "true"); cluster.set_env("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true"); @@ -2489,7 +2488,7 @@ async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls_during_ hot.set_env("RUSTFS_SCANNER_CYCLE", "1"); hot.set_env("RUSTFS_ILM_PROCESS_TIME", "1"); - let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]); + let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]); hot.set_env("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key); hot.set_env("RUSTFS_COMPRESSION_ENABLED", "true"); hot.start().await?; diff --git a/crates/e2e_test/src/kms/common.rs b/crates/e2e_test/src/kms/common.rs index b551a9849..af9b410db 100644 --- a/crates/e2e_test/src/kms/common.rs +++ b/crates/e2e_test/src/kms/common.rs @@ -27,7 +27,7 @@ use aws_sdk_s3::Client; use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError}; use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::types::ServerSideEncryption; -use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use base64_simd::STANDARD as BASE64; use http::header::{CONTENT_TYPE, HOST}; use md5::{Digest as Md5Digest, Md5}; use rustfs_signer::constants::UNSIGNED_PAYLOAD; @@ -64,7 +64,7 @@ pub fn init_logging() { pub fn sse_customer_key_md5_base64(key: &str) -> String { let mut hasher = Md5::new(); hasher.update(key.as_bytes()); - BASE64.encode(hasher.finalize()) + BASE64.encode_to_string(hasher.finalize()) } pub fn assert_s3_error(result: Result>, status: u16, code: &str, message: &str, context: &str) @@ -365,7 +365,7 @@ pub async fn create_key_with_specific_id(key_dir: &str, key_id: &str) -> Result< "created_at": format!("{}[UTC]", chrono::Utc::now().to_rfc3339()), "rotated_at": serde_json::Value::Null, "created_by": "e2e-test", - "encrypted_key_material": BASE64.encode(key_data), + "encrypted_key_material": BASE64.encode_to_string(key_data), "nonce": Vec::::new() }); @@ -383,7 +383,7 @@ pub async fn test_sse_c_encryption(s3_client: &Client, bucket: &str) -> Result<( info!("Testing SSE-C encryption"); let test_key = "01234567890123456789012345678901"; // 32-byte key - let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key); + let test_key_b64 = base64_simd::STANDARD.encode_to_string(test_key); let test_key_md5 = sse_customer_key_md5_base64(test_key); let test_data = b"Hello, KMS SSE-C World!"; let object_key = "test-sse-c-object"; @@ -551,8 +551,8 @@ pub async fn test_error_scenarios(s3_client: &Client, bucket: &str) -> Result<() // Test SSE-C with wrong key for download let test_key = "01234567890123456789012345678901"; let wrong_key = "98765432109876543210987654321098"; - let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key); - let wrong_key_b64 = base64::engine::general_purpose::STANDARD.encode(wrong_key); + let test_key_b64 = base64_simd::STANDARD.encode_to_string(test_key); + let wrong_key_b64 = base64_simd::STANDARD.encode_to_string(wrong_key); let test_key_md5 = sse_customer_key_md5_base64(test_key); let wrong_key_md5 = sse_customer_key_md5_base64(wrong_key); let test_data = b"Test data for error scenarios"; @@ -807,7 +807,7 @@ pub async fn test_multipart_upload_with_config( // Prepare encryption parameters let (sse_c_key_b64, sse_c_key_md5) = match &config.encryption_type { EncryptionType::SSEC { key, key_md5 } => { - let key_b64 = base64::engine::general_purpose::STANDARD.encode(key); + let key_b64 = base64_simd::STANDARD.encode_to_string(key); (Some(key_b64), Some(key_md5.clone())) } _ => (None, None), diff --git a/crates/e2e_test/src/kms/kms_comprehensive_test.rs b/crates/e2e_test/src/kms/kms_comprehensive_test.rs index d4bbf604a..8569118bf 100644 --- a/crates/e2e_test/src/kms/kms_comprehensive_test.rs +++ b/crates/e2e_test/src/kms/kms_comprehensive_test.rs @@ -177,7 +177,7 @@ async fn test_comprehensive_key_isolation() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box { // SSE-C let key = format!("testkey{i:026}"); // 32-byte key - let key_b64 = base64::engine::general_purpose::STANDARD.encode(&key); + let key_b64 = base64_simd::STANDARD.encode_to_string(&key); let key_md5 = sse_customer_key_md5_base64(&key); client @@ -535,8 +534,8 @@ async fn test_kms_key_validation_security() -> Result<(), Box Result<(), Box) -> String { "conditions": conditions, }); - base64::engine::general_purpose::STANDARD.encode(policy.to_string()) + base64_simd::STANDARD.encode_to_string(policy.to_string()) } fn sse_customer_key_md5_base64(key: &str) -> String { let mut hasher = Md5::new(); hasher.update(key.as_bytes()); - base64::engine::general_purpose::STANDARD.encode(hasher.finalize()) + base64_simd::STANDARD.encode_to_string(hasher.finalize()) } fn md5_hex(input: impl AsRef<[u8]>) -> String { let mut hasher = Md5::new(); hasher.update(input.as_ref()); - hex::encode(hasher.finalize()) + hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower) } async fn create_restricted_user( @@ -97,7 +96,7 @@ fn restricted_user_client(env: &RustFSTestEnvironment, username: &str, secret_ke const LOCAL_SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY"; fn local_sse_master_key_value() -> String { - base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]) + base64_simd::STANDARD.encode_to_string([0x42u8; 32]) } async fn make_tar(files: &[(&str, &[u8])], dirs: &[&str]) -> Vec { @@ -1887,7 +1886,7 @@ async fn test_anonymous_post_object_allows_sse_c_fields_outside_policy_condition let object_key = "sse-c-object.txt"; let expected_body = b"anonymous-post-sse-c".to_vec(); let customer_key = "01234567890123456789012345678901"; - let customer_key_b64 = base64::engine::general_purpose::STANDARD.encode(customer_key); + let customer_key_b64 = base64_simd::STANDARD.encode_to_string(customer_key); let customer_key_md5 = sse_customer_key_md5_base64(customer_key); let admin_client = env.create_s3_client(); @@ -1941,7 +1940,7 @@ async fn test_anonymous_post_object_allows_sse_c_fields_outside_policy_condition .bucket(bucket) .key(object_key) .sse_customer_algorithm("AES256") - .sse_customer_key(base64::engine::general_purpose::STANDARD.encode(customer_key)) + .sse_customer_key(base64_simd::STANDARD.encode_to_string(customer_key)) .sse_customer_key_md5(customer_key_md5) .send() .await?; @@ -1963,8 +1962,8 @@ async fn test_anonymous_post_object_rejects_sse_c_exact_policy_mismatch() -> Res let object_key = "sse-c-mismatch-object.txt"; let policy_key = "01234567890123456789012345678901"; let request_key = "abcdefghijklmnopqrstuvwxyzABCDEF"; - let policy_key_b64 = base64::engine::general_purpose::STANDARD.encode(policy_key); - let request_key_b64 = base64::engine::general_purpose::STANDARD.encode(request_key); + let policy_key_b64 = base64_simd::STANDARD.encode_to_string(policy_key); + let request_key_b64 = base64_simd::STANDARD.encode_to_string(request_key); let admin_client = env.create_s3_client(); admin_client.create_bucket().bucket(bucket).send().await?; @@ -3526,7 +3525,7 @@ async fn test_signed_put_object_extract_preserves_sse_s3_and_redirect() -> Resul init_logging(); let mut env = RustFSTestEnvironment::new().await?; - let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]); + let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]); env.start_rustfs_server_with_env(vec![], &[("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key.as_str())]) .await?; @@ -3799,7 +3798,7 @@ async fn test_signed_put_object_extract_uses_bucket_default_sse_s3() -> Result<( init_logging(); let mut env = RustFSTestEnvironment::new().await?; - let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]); + let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]); env.start_rustfs_server_with_env(vec![], &[("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key.as_str())]) .await?; @@ -3925,7 +3924,7 @@ async fn test_signed_put_object_extract_preserves_sse_c() -> Result<(), Box PresigningConfig { } /// Flip bytes inside the `X-Amz-Signature=` query value without changing its -/// length, producing a structurally valid but incorrect signature. +/// length, producing a structurally valid but incorrect signature. Every hex +/// digit is replaced by its complement (15 - v), which has no fixed point, so +/// the tamper changes the value no matter which digits the signature contains. fn tamper_signature(uri: &str) -> String { let marker = "X-Amz-Signature="; let idx = uri.find(marker).expect("presigned uri must carry X-Amz-Signature") + marker.len(); @@ -96,10 +98,9 @@ fn tamper_signature(uri: &str) -> String { let (sig, tail) = rest.split_at(end); let tampered: String = sig .chars() - .map(|c| match c { - '0' => 'f', - 'a' => '0', - other => other, + .map(|c| { + let v = c.to_digit(16).expect("X-Amz-Signature value must be hex"); + char::from_digit(15 - v, 16).expect("complement of a hex digit is a hex digit") }) .collect(); assert_ne!(sig, tampered, "tamper must actually change the signature hex"); diff --git a/crates/e2e_test/src/protocols/webdav_core.rs b/crates/e2e_test/src/protocols/webdav_core.rs index 90c266c69..39136987d 100644 --- a/crates/e2e_test/src/protocols/webdav_core.rs +++ b/crates/e2e_test/src/protocols/webdav_core.rs @@ -35,7 +35,6 @@ use crate::common::local_http_client; use crate::common::rustfs_binary_path_with_features; use crate::protocols::test_env::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY, ProtocolTestEnvironment}; use anyhow::Result; -use base64::Engine; use http::header::{CONTENT_TYPE, HOST}; use reqwest::Client; use rustfs_signer::constants::UNSIGNED_PAYLOAD; @@ -64,7 +63,7 @@ fn basic_auth_header() -> String { fn basic_auth_header_for(access_key: &str, secret_key: &str) -> String { let credentials = format!("{}:{}", access_key, secret_key); - let encoded = base64::engine::general_purpose::STANDARD.encode(credentials); + let encoded = base64_simd::STANDARD.encode_to_string(credentials); format!("Basic {}", encoded) } diff --git a/crates/e2e_test/src/reliant/grpc_lock_server.rs b/crates/e2e_test/src/reliant/grpc_lock_server.rs index 9481f365d..a03a8a11e 100644 --- a/crates/e2e_test/src/reliant/grpc_lock_server.rs +++ b/crates/e2e_test/src/reliant/grpc_lock_server.rs @@ -23,8 +23,9 @@ use rustfs_protos::{ proto_gen::node_service::{ BatchGenerallyLockRequest, BatchGenerallyLockResponse, BatchReadVersionRequest, BatchReadVersionResponse, GenerallyLockRequest, GenerallyLockResponse, GenerallyLockResult, PingRequest, PingResponse, - SnapshotLeaseMutationResponse, SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest, SnapshotLeaseRequest, - SnapshotLeaseResponse, node_service_server::NodeService, + ScannerPublicationLeaseReleaseRequest, ScannerPublicationLeaseReleaseResponse, ScannerPublicationLeaseRequest, + ScannerPublicationLeaseResponse, SnapshotLeaseMutationResponse, SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest, + SnapshotLeaseRequest, SnapshotLeaseResponse, node_service_server::NodeService, }, }; use std::pin::Pin; @@ -126,6 +127,20 @@ impl NodeService for MinimalLockNodeService { Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs")) } + async fn acquire_scanner_publication_lease( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs")) + } + + async fn release_scanner_publication_lease( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs")) + } + async fn lock(&self, request: Request) -> Result, Status> { let request = request.into_inner(); let args: LockRequest = match serde_json::from_str(&request.args) { diff --git a/crates/e2e_test/src/replication_extension_test.rs b/crates/e2e_test/src/replication_extension_test.rs index 479fbeeed..98b9a5d8c 100644 --- a/crates/e2e_test/src/replication_extension_test.rs +++ b/crates/e2e_test/src/replication_extension_test.rs @@ -34,7 +34,7 @@ use aws_sdk_s3::types::{ VersioningConfiguration, }; use aws_sdk_s3::{Client, Config}; -use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use base64_simd::STANDARD as BASE64_STANDARD; use bytes::Bytes; use flate2::read::GzDecoder; use futures::{Stream, StreamExt}; @@ -1244,7 +1244,7 @@ async fn wait_for_source_replication_pending_or_failed( } async fn wait_for_source_replication_status(client: &Client, bucket: &str, key: &str, expected: &str, ssec: bool) -> TestResult { - let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY); + let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY); let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY); let wait = async { loop { @@ -1339,7 +1339,7 @@ async fn assert_failed_replication_stays_absent_for( ssec: bool, duration: Duration, ) -> TestResult { - let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY); + let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY); let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY); let wait = async { let deadline = tokio::time::Instant::now() + duration; @@ -4332,7 +4332,7 @@ async fn test_bucket_replication_sse_c_contract() -> TestResult { let target_client = target_env.create_s3_client(); let key = "ssec-contract.txt"; let body = b"repl-17 SSE-C payload"; - let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY); + let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY); let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY); source_client @@ -4387,7 +4387,7 @@ async fn test_bucket_replication_sse_c_contract() -> TestResult { ); // A wrong customer key must fail too. - let wrong_key = BASE64_STANDARD.encode("99999999999999999999999999999999"); + let wrong_key = BASE64_STANDARD.encode_to_string("99999999999999999999999999999999"); let wrong_key_md5 = sse_customer_key_md5_base64("99999999999999999999999999999999"); let wrong_read = target_client .get_object() @@ -4423,7 +4423,7 @@ async fn test_bucket_replication_sse_c_multipart_passthrough() -> TestResult { let source_client = source_env.create_s3_client(); let target_client = target_env.create_s3_client(); let key = "ssec-mp-contract.bin"; - let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY); + let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY); let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY); let created = source_client @@ -4568,7 +4568,7 @@ async fn test_ssec_replication_fails_closed_when_target_drops_passthrough_header .await?; put_bucket_replication(&source_env, source_bucket, &target_arn).await?; - let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY); + let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY); let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY); let put_ssec = |key: &'static str| { source_client @@ -4741,7 +4741,7 @@ async fn test_bucket_replication_sse_c_heals_after_target_outage() -> TestResult let source_client = source_env.create_s3_client(); let key = "ssec-heal-contract.txt"; let body = b"repl-22 ssec heal payload".to_vec(); - let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY); + let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY); let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY); // Target outage: the SSE-C write cannot replicate. @@ -4856,7 +4856,7 @@ async fn test_bucket_replication_sse_c_existing_object_resync() -> TestResult { // The SSE-C object exists before any replication wiring. let key = "ssec-existing-contract.txt"; let body = b"repl-22 ssec existing-object payload".to_vec(); - let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY); + let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY); let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY); source_client .put_object() @@ -9152,7 +9152,7 @@ async fn test_get_and_head_proxy_unreplicated_object_to_replication_target() -> // the real SSE-C decryption; the plaintext fake simply ignores them). target.take_requests(); let ssec_key = "01234567890123456789012345678901"; - let ssec_key_b64 = BASE64_STANDARD.encode(ssec_key); + let ssec_key_b64 = BASE64_STANDARD.encode_to_string(ssec_key); let ssec_key_md5 = sse_customer_key_md5_base64(ssec_key); let _ = source_client .get_object() diff --git a/crates/e2e_test/src/ssec_copy_test.rs b/crates/e2e_test/src/ssec_copy_test.rs index e84ed9b11..0ca783ff3 100644 --- a/crates/e2e_test/src/ssec_copy_test.rs +++ b/crates/e2e_test/src/ssec_copy_test.rs @@ -21,7 +21,6 @@ use aws_sdk_s3::error::BoxError; use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::types::{BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, VersioningConfiguration}; use aws_smithy_http_client::Builder as SmithyHttpClientBuilder; -use base64::Engine; use md5::{Digest as Md5Digest, Md5}; use std::collections::HashMap; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -107,8 +106,8 @@ fn customer_key(byte: u8) -> CustomerKey { hasher.update(raw); CustomerKey { raw: String::from_utf8_lossy(&raw).into_owned(), - encoded: base64::engine::general_purpose::STANDARD.encode(raw), - md5: base64::engine::general_purpose::STANDARD.encode(hasher.finalize()), + encoded: base64_simd::STANDARD.encode_to_string(raw), + md5: base64_simd::STANDARD.encode_to_string(hasher.finalize()), } } diff --git a/crates/ecstore/Cargo.toml b/crates/ecstore/Cargo.toml index e56d6cffb..2af43aa47 100644 --- a/crates/ecstore/Cargo.toml +++ b/crates/ecstore/Cargo.toml @@ -25,7 +25,6 @@ keywords = ["erasure-coding", "storage", "rustfs", "Minio", "solomon"] categories = ["web-programming", "development-tools", "filesystem"] documentation = "https://docs.rs/rustfs-ecstore/latest/rustfs_ecstore/" - # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lints] workspace = true @@ -45,7 +44,6 @@ hotpath = [ "hotpath/async-channel", "hotpath/parking_lot", "hotpath/reqwest-0-13", - "rustfs-checksums/hotpath", "rustfs-common/hotpath", "rustfs-concurrency/hotpath", "rustfs-config/hotpath", @@ -63,16 +61,13 @@ hotpath = [ "rustfs-rio/hotpath", "rustfs-rio-v2?/hotpath", "rustfs-s3-types/hotpath", - "rustfs-signer/hotpath", "rustfs-storage-api/hotpath", - "rustfs-tls-runtime/hotpath", "rustfs-utils/hotpath", "rustfs-crypto/hotpath", ] hotpath-alloc = [ "hotpath", "hotpath/hotpath-alloc", - "rustfs-checksums/hotpath-alloc", "rustfs-common/hotpath-alloc", "rustfs-concurrency/hotpath-alloc", "rustfs-config/hotpath-alloc", @@ -90,16 +85,13 @@ hotpath-alloc = [ "rustfs-rio/hotpath-alloc", "rustfs-rio-v2?/hotpath-alloc", "rustfs-s3-types/hotpath-alloc", - "rustfs-signer/hotpath-alloc", "rustfs-storage-api/hotpath-alloc", - "rustfs-tls-runtime/hotpath-alloc", "rustfs-utils/hotpath-alloc", "rustfs-crypto/hotpath-alloc", ] hotpath-cpu = [ "hotpath", "hotpath/hotpath-cpu", - "rustfs-checksums/hotpath-cpu", "rustfs-common/hotpath-cpu", "rustfs-concurrency/hotpath-cpu", "rustfs-config/hotpath-cpu", @@ -117,9 +109,7 @@ hotpath-cpu = [ "rustfs-rio/hotpath-cpu", "rustfs-rio-v2?/hotpath-cpu", "rustfs-s3-types/hotpath-cpu", - "rustfs-signer/hotpath-cpu", "rustfs-storage-api/hotpath-cpu", - "rustfs-tls-runtime/hotpath-cpu", "rustfs-utils/hotpath-cpu", "rustfs-crypto/hotpath-cpu", ] @@ -129,20 +119,18 @@ hotpath-cpu = [ test-util = [] [dependencies] -starshard = { workspace = true } hotpath.workspace = true rustfs-filemeta.workspace = true rustfs-utils = { workspace = true, features = ["full"] } rustfs-rio.workspace = true rustfs-rio-v2 = { workspace = true, optional = true } -rustfs-signer.workspace = true rustfs-storage-api.workspace = true -rustfs-tls-runtime.workspace = true -rustfs-checksums.workspace = true rustfs-config = { workspace = true, features = ["notify", "audit", "server-config-model"] } rustfs-concurrency.workspace = true rustfs-credentials = { workspace = true } rustfs-common.workspace = true +rustfs-heal-contracts.workspace = true +rustfs-scanner-contracts.workspace = true rustfs-policy.workspace = true rustfs-protos.workspace = true rustfs-replication.workspace = true @@ -158,7 +146,6 @@ bytes = { workspace = true, features = ["serde"] } byteorder = { workspace = true } chrono = { workspace = true, features = ["serde"] } jiff = { workspace = true, features = ["serde"] } -glob = { workspace = true } thiserror.workspace = true flatbuffers.workspace = true futures.workspace = true @@ -169,7 +156,6 @@ serde = { workspace = true, features = ["derive"] } time = { workspace = true, features = ["parsing", "formatting", "macros", "serde"] } bytesize.workspace = true serde_json = { workspace = true, features = ["raw_value"] } -quick-xml = { workspace = true, features = ["serialize", "async-tokio"] } s3s = { workspace = true, features = ["minio"] } http.workspace = true opentelemetry.workspace = true @@ -189,9 +175,7 @@ rmp.workspace = true rmp-serde.workspace = true tokio-util = { workspace = true, features = ["io", "compat"] } tokio-stream = { workspace = true, features = ["sync"] } -base64 = { workspace = true } hmac = { workspace = true } -sha1 = { workspace = true } sha2 = { workspace = true } hex-simd = { workspace = true } tempfile.workspace = true @@ -206,7 +190,6 @@ tonic = { workspace = true, features = ["gzip", "deflate"] } xxhash-rust = { workspace = true, features = ["xxh64", "xxh3"] } tower = { workspace = true, features = ["timeout"] } async-channel.workspace = true -enumset = { workspace = true } num_cpus = { workspace = true } rand = { workspace = true, features = ["serde"] } pin-project-lite.workspace = true diff --git a/crates/ecstore/src/api/mod.rs b/crates/ecstore/src/api/mod.rs index ea9822793..558a3a984 100644 --- a/crates/ecstore/src/api/mod.rs +++ b/crates/ecstore/src/api/mod.rs @@ -154,6 +154,12 @@ pub mod bucket { pub mod object_lock { pub use crate::bucket::object_lock::{ObjectLockApi, ObjectLockStatusExt}; + pub mod types { + pub use crate::bucket::object_lock::types::{ + DefaultRetention, LegalHoldStatus, ObjectLegalHold, ObjectRetention, RetentionMode, + }; + } + pub mod objectlock { pub use crate::bucket::object_lock::objectlock::{get_object_legalhold_meta, get_object_retention_meta}; } @@ -249,26 +255,8 @@ pub mod capacity { pub use crate::store::utils::is_reserved_or_invalid_bucket; } -pub mod client { - pub mod admin_handler_utils { - pub use crate::client::admin_handler_utils::AdminError; - } - - pub mod api_put_object { - pub use crate::client::api_put_object::{AdvancedPutOptions, PutObjectOptions}; - } - - pub mod object_api_utils { - pub use crate::client::object_api_utils::{ObjReaderFn, PutObjReader, get_raw_etag, new_getobjectreader, to_s3s_etag}; - } - - pub mod transition_api { - pub use crate::client::transition_api::{ - BucketLookupType, CreateBucketConfiguration, LocationConstraint, ObjectInfo, ObjectMultipartInfo, Options, - PutObjectPartOptions, ReadCloser, ReaderImpl, RequestMetadata, RestoreInfo, SendRequest, TransitionClient, - TransitionCore, UploadInfo, to_object_info, - }; - } +pub mod object_api_utils { + pub use crate::object_api::object_api_utils::{ObjReaderFn, PutObjReader, get_raw_etag, new_getobjectreader, to_s3s_etag}; } pub mod cluster { diff --git a/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_audit.rs b/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_audit.rs index cfeb644a8..06ea063a7 100644 --- a/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_audit.rs +++ b/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_audit.rs @@ -16,8 +16,8 @@ use super::runtime_boundary as runtime_sources; use crate::bucket::lifecycle::lifecycle; use crate::object_api::ObjectInfo; use crate::services::event_notification::{EventArgs, send_event}; -use rustfs_common::metrics::IlmAction; use rustfs_s3_types::EventName; +use rustfs_scanner_contracts::metrics::IlmAction; const LIFECYCLE_EXPIRY_USER_AGENT: &str = "Internal: [ILM-Expiry]"; const LIFECYCLE_TRANSITION_USER_AGENT: &str = "Internal: [ILM-Transition]"; diff --git a/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs b/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs index 0a6ab0746..46eaa2d66 100644 --- a/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs +++ b/crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs @@ -73,9 +73,6 @@ use crate::store::ECStore; use async_channel::{Receiver as A_Receiver, Sender as A_Sender, bounded}; use http::HeaderMap; use rand::RngExt as _; -use rustfs_common::metrics::{ - IlmAction, Metrics, ScannerLifecycleExpiryStateUpdate, ScannerLifecycleTransitionStateUpdate, global_metrics, -}; use rustfs_config::{ DEFAULT_TRANSITION_QUEUE_CAPACITY, DEFAULT_TRANSITION_QUEUE_SEND_TIMEOUT_MS, DEFAULT_TRANSITION_WORKERS_ABSOLUTE_MAX, DEFAULT_TRANSITION_WORKERS_CAP, ENV_MAX_EXPIRY_WORKERS, ENV_TRANSITION_QUEUE_CAPACITY, ENV_TRANSITION_QUEUE_SEND_TIMEOUT_MS, @@ -85,6 +82,9 @@ use rustfs_data_usage::TierStats; use rustfs_filemeta::{ FileInfo, FileInfoOpts, NULL_VERSION_ID, RestoreStatusOps, TRANSITION_COMPLETE, get_file_info, is_restored_object_on_disk, }; +use rustfs_scanner_contracts::metrics::{ + IlmAction, Metrics, ScannerLifecycleExpiryStateUpdate, ScannerLifecycleTransitionStateUpdate, global_metrics, +}; use rustfs_utils::{ get_env_i64, get_env_usize, path::encode_dir_object, @@ -940,7 +940,7 @@ impl ExpiryState { let version_count = u64::try_from(v.versions.len()).unwrap_or(u64::MAX); let trace = LifecycleExpiryTrace::for_batch(&v.bucket, &v.event, &v.src, version_count); trace.emit(EVENT_LIFECYCLE_DELETE_DISPATCHED, "delete_dispatched", None); - crate::client::object_handlers_common::delete_object_versions( + crate::bucket::lifecycle::object_handlers_common::delete_object_versions( &api, &v.bucket, &v.versions, @@ -5432,8 +5432,6 @@ mod tests { use crate::bucket::lifecycle::tier_sweeper::Jentry; use crate::bucket::metadata::{BUCKET_LIFECYCLE_CONFIG, BUCKET_VERSIONING_CONFIG}; use crate::bucket::metadata_sys; - #[cfg(feature = "test-util")] - use crate::client::transition_api::ReaderImpl; use crate::disk::endpoint::Endpoint; use crate::disk::{RUSTFS_META_MULTIPART_BUCKET, STORAGE_FORMAT_FILE}; use crate::error::{Error, is_err_invalid_upload_id}; @@ -5461,11 +5459,13 @@ mod tests { use futures::FutureExt; #[cfg(feature = "test-util")] use http::HeaderMap; - use rustfs_common::metrics::{IlmAction, global_metrics}; use rustfs_config::ENV_MAX_EXPIRY_WORKERS; use rustfs_config::ENV_TRANSITION_WORKERS_ABSOLUTE_MAX; use rustfs_data_usage::TierStats; use rustfs_filemeta::{FileInfo, FileMeta}; + #[cfg(feature = "test-util")] + use rustfs_s3_client::transition_api::ReaderImpl; + use rustfs_scanner_contracts::metrics::{IlmAction, global_metrics}; use s3s::dto::{ BucketLifecycleConfiguration, DefaultRetention, ExpirationStatus, LifecycleExpiration, LifecycleRule, MetadataEntry, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRetentionMode, ObjectLockRule, OutputLocation, RestoreRequest, @@ -6353,7 +6353,7 @@ mod tests { assert_eq!(err.kind(), std::io::ErrorKind::Other); let admin_err = err .get_ref() - .and_then(|source| source.downcast_ref::()) + .and_then(|source| source.downcast_ref::()) .expect("identity mismatch should retain the typed tier error"); assert_eq!(admin_err.code, crate::services::tier::tier::ERR_TIER_INVALID_CONFIG.code); assert_eq!(new_backend.get_count().await, 0); @@ -11564,7 +11564,7 @@ mod tests { lease .put( "remote/object", - crate::client::transition_api::ReaderImpl::Body(bytes::Bytes::from_static(b"candidate")), + rustfs_s3_client::transition_api::ReaderImpl::Body(bytes::Bytes::from_static(b"candidate")), 9, ) .await diff --git a/crates/ecstore/src/bucket/lifecycle/mod.rs b/crates/ecstore/src/bucket/lifecycle/mod.rs index 20956ca70..5006dce1b 100644 --- a/crates/ecstore/src/bucket/lifecycle/mod.rs +++ b/crates/ecstore/src/bucket/lifecycle/mod.rs @@ -21,6 +21,7 @@ pub mod evaluator; pub mod manual_transition_job; mod metadata_boundary; pub(crate) use metadata_boundary::{LifecycleExpiryConfigs, get_expiry_configs}; +mod object_handlers_common; mod object_lock_boundary; pub use self::core as lifecycle; mod replication_sink; diff --git a/crates/ecstore/src/client/object_handlers_common.rs b/crates/ecstore/src/bucket/lifecycle/object_handlers_common.rs similarity index 95% rename from crates/ecstore/src/client/object_handlers_common.rs rename to crates/ecstore/src/bucket/lifecycle/object_handlers_common.rs index fa4739252..95f332e64 100644 --- a/crates/ecstore/src/client/object_handlers_common.rs +++ b/crates/ecstore/src/bucket/lifecycle/object_handlers_common.rs @@ -21,7 +21,7 @@ const EVENT_LIFECYCLE_CLEANUP_SKIPPED: &str = "lifecycle_cleanup_skipped"; const EVENT_LIFECYCLE_CLEANUP_FAILED: &str = "lifecycle_cleanup_failed"; use crate::bucket::lifecycle::lifecycle; -use crate::bucket::replication::{ReplicationLifecycleBridge, ReplicationObjectBridge}; +use crate::bucket::lifecycle::replication_sink::{self, ReplicationObjectBridge}; use crate::object_api::ObjectOptions; use crate::storage_api_contracts::object::{ObjectOperations as _, ObjectToDelete}; use crate::store::ECStore; @@ -84,7 +84,7 @@ pub async fn delete_object_versions( if deleted_obj.replication_state.is_none() { continue; } - ReplicationLifecycleBridge::schedule_delete(bucket.to_string(), deleted_obj.clone()).await; + replication_sink::schedule_delete(bucket.to_string(), deleted_obj.clone()).await; } for (i, err) in errors.iter().enumerate() { diff --git a/crates/ecstore/src/bucket/lifecycle/object_lock_boundary.rs b/crates/ecstore/src/bucket/lifecycle/object_lock_boundary.rs index 79ec3c7a1..21d1c0656 100644 --- a/crates/ecstore/src/bucket/lifecycle/object_lock_boundary.rs +++ b/crates/ecstore/src/bucket/lifecycle/object_lock_boundary.rs @@ -26,7 +26,8 @@ pub(crate) fn check_object_lock_for_deletion_with_config( obj_info: &ObjectInfo, bypass_governance: bool, ) -> crate::error::Result> { - objectlock_sys::check_object_lock_for_deletion_with_config(config, obj_info, bypass_governance) + let default_retention = config.and_then(crate::bucket::metadata_sys::default_retention_from_object_lock_config); + objectlock_sys::check_object_lock_for_deletion_with_default_retention(default_retention.as_ref(), obj_info, bypass_governance) } #[cfg(test)] diff --git a/crates/ecstore/src/bucket/lifecycle/replication_sink.rs b/crates/ecstore/src/bucket/lifecycle/replication_sink.rs index 32cd6b57a..70b700883 100644 --- a/crates/ecstore/src/bucket/lifecycle/replication_sink.rs +++ b/crates/ecstore/src/bucket/lifecycle/replication_sink.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use rustfs_common::metrics::IlmAction; +use rustfs_scanner_contracts::metrics::IlmAction; use crate::bucket::lifecycle::lifecycle::ObjectOpts; use crate::bucket::replication::ReplicationLifecycleBridge; @@ -77,7 +77,7 @@ mod tests { use crate::bucket::replication::{DeleteReplicationConfigSnapshot, ReplicationObjectBridge}; use crate::object_api::{ObjectInfo, ObjectOptions}; use crate::storage_api_contracts::object::ObjectToDelete; - use rustfs_common::metrics::IlmAction; + use rustfs_scanner_contracts::metrics::IlmAction; use s3s::dto::{ BucketVersioningStatus, DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication, DeleteReplicationStatus, Destination, ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus, diff --git a/crates/ecstore/src/bucket/lifecycle/tier_sweeper.rs b/crates/ecstore/src/bucket/lifecycle/tier_sweeper.rs index b2786fb18..0d622576d 100644 --- a/crates/ecstore/src/bucket/lifecycle/tier_sweeper.rs +++ b/crates/ecstore/src/bucket/lifecycle/tier_sweeper.rs @@ -22,11 +22,11 @@ use super::runtime_boundary as runtime_sources; use crate::bucket::lifecycle::bucket_lifecycle_ops::ExpiryOp; use crate::bucket::lifecycle::lifecycle::{self, ObjectOpts}; use crate::bucket::lifecycle::tier_delete_journal::persist_tier_delete_journal_entry; -use crate::client::signer_error::error_chain_contains_signer_header_marker; use crate::object_api::ObjectInfo; use crate::services::tier::tier::{TierConfigMgr, TierDestinationId, TierOperationLease}; use crate::storage_api_contracts::lifecycle::TransitionedObject; use crate::store::ECStore; +use rustfs_s3_client::signer_error::error_chain_contains_signer_header_marker; use rustfs_utils::get_env_usize; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -670,7 +670,7 @@ pub(crate) fn transitioned_delete_journal_entry_for_source( #[cfg(test)] mod test { - use crate::client::signer_error::invalid_utf8_header_error; + use rustfs_s3_client::signer_error::invalid_utf8_header_error; use super::{ CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES, ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED, diff --git a/crates/ecstore/src/bucket/metadata.rs b/crates/ecstore/src/bucket/metadata.rs index 32b3fb35f..944fe08d9 100644 --- a/crates/ecstore/src/bucket/metadata.rs +++ b/crates/ecstore/src/bucket/metadata.rs @@ -13,7 +13,7 @@ // limitations under the License. use super::msgp_decode::{read_msgp_ext8_time, skip_msgp_value, write_msgp_time}; -use super::object_lock::ObjectLockApi; +use super::object_lock::{ObjectLockApi, ObjectLockStatusExt}; use super::versioning::VersioningApi; use super::{quota::BucketQuota, target::BucketTargets}; use crate::bucket::replication::invalid_replication_config_status_field; @@ -39,6 +39,26 @@ use time::{Date, OffsetDateTime, PrimitiveDateTime, Time as CivilTime, UtcOffset use tracing::error; use uuid::Uuid; +// The serving-layer DTO impls for the storage-level Object Lock traits live +// here because this module owns the persisted `ObjectLockConfiguration` +// during the s3s ratchet migration (rustfs/backlog#1842). +impl ObjectLockApi for ObjectLockConfiguration { + fn enabled(&self) -> bool { + self.object_lock_enabled + .as_ref() + .is_some_and(|v| v.as_str() == s3s::dto::ObjectLockEnabled::ENABLED) + } +} + +impl ObjectLockStatusExt for s3s::dto::ObjectLockLegalHoldStatus { + fn valid(&self) -> bool { + matches!( + self.as_str(), + s3s::dto::ObjectLockLegalHoldStatus::ON | s3s::dto::ObjectLockLegalHoldStatus::OFF + ) + } +} + fn read_msgp_str(rd: &mut R) -> Result { let len = rmp::decode::read_str_len(rd)? as usize; let mut buf = vec![0u8; len]; diff --git a/crates/ecstore/src/bucket/metadata_sys.rs b/crates/ecstore/src/bucket/metadata_sys.rs index 2cbb0eff4..3e0e6ded6 100644 --- a/crates/ecstore/src/bucket/metadata_sys.rs +++ b/crates/ecstore/src/bucket/metadata_sys.rs @@ -27,7 +27,7 @@ use crate::storage_api_contracts::heal::HealOperations as _; use crate::storage_api_contracts::namespace::NamespaceLocking as _; use crate::store::{ECStore, await_bucket_namespace_operation}; use futures::future::join_all; -use rustfs_common::heal_channel::HealOpts; +use rustfs_heal_contracts::heal_channel::HealOpts; use rustfs_policy::policy::BucketPolicy; use s3s::dto::ReplicationConfiguration; use s3s::dto::{ @@ -167,6 +167,53 @@ pub(crate) fn object_lock_config_state_from_authoritative_metadata(bm: &BucketMe Ok(ObjectLockConfigState::ConfirmedAbsent) } +/// Convert the persisted serving-layer configuration into the storage-level +/// [`DefaultRetention`](crate::bucket::object_lock::types::DefaultRetention) +/// the WORM evaluation code consumes (rustfs/backlog#1842). A rule without a +/// usable GOVERNANCE/COMPLIANCE mode converts to `None`, exactly like the +/// evaluation code has always ignored such rules; days/years are passed +/// through untouched so an invalid period still fails closed at evaluation. +pub(crate) fn default_retention_from_object_lock_config( + config: &ObjectLockConfiguration, +) -> Option { + let default_retention = config.rule.as_ref()?.default_retention.as_ref()?; + let mode = crate::bucket::object_lock::types::RetentionMode::parse(default_retention.mode.as_ref()?.as_str())?; + Some(crate::bucket::object_lock::types::DefaultRetention { + mode, + days: default_retention.days, + years: default_retention.years, + }) +} + +/// Test-only builder for a `Configured` Object Lock state carrying a default +/// retention, so storage-side tests do not have to name serving-layer DTOs. +#[cfg(test)] +pub(crate) fn configured_object_lock_state_for_tests( + mode: crate::bucket::object_lock::types::RetentionMode, + days: i32, +) -> ObjectLockConfigState { + ObjectLockConfigState::Configured { + config: ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + rule: Some(s3s::dto::ObjectLockRule { + default_retention: Some(s3s::dto::DefaultRetention { + mode: Some(s3s::dto::ObjectLockRetentionMode::from_static(match mode { + crate::bucket::object_lock::types::RetentionMode::Governance => { + s3s::dto::ObjectLockRetentionMode::GOVERNANCE + } + crate::bucket::object_lock::types::RetentionMode::Compliance => { + s3s::dto::ObjectLockRetentionMode::COMPLIANCE + } + })), + days: Some(days), + years: None, + }), + }), + }, + updated_at: OffsetDateTime::now_utc(), + } +} + fn validate_authoritative_object_lock_config(config: &ObjectLockConfiguration) -> Result<()> { if config.object_lock_enabled.as_ref().map(ObjectLockEnabled::as_str) != Some(ObjectLockEnabled::ENABLED) { return Err(Error::other("persisted bucket Object Lock enabled state is invalid")); diff --git a/crates/ecstore/src/bucket/object_lock/mod.rs b/crates/ecstore/src/bucket/object_lock/mod.rs index 0a821f81a..9f6f3b8ce 100644 --- a/crates/ecstore/src/bucket/object_lock/mod.rs +++ b/crates/ecstore/src/bucket/object_lock/mod.rs @@ -14,27 +14,18 @@ pub mod objectlock; pub mod objectlock_sys; +pub mod types; -use s3s::dto::{ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHoldStatus}; - +/// Whether a bucket Object Lock configuration has locking enabled. The +/// serving-layer `ObjectLockConfiguration` DTO implements this in +/// the bucket-metadata module, which owns the persisted configuration type +/// during the s3s ratchet migration (rustfs/backlog#1842). pub trait ObjectLockApi { fn enabled(&self) -> bool; } -impl ObjectLockApi for ObjectLockConfiguration { - fn enabled(&self) -> bool { - self.object_lock_enabled - .as_ref() - .is_some_and(|v| v.as_str() == ObjectLockEnabled::ENABLED) - } -} - +/// Whether a legal-hold status value is one of the two valid wire values. +/// Implemented for the serving-layer DTO in the bucket-metadata module. pub trait ObjectLockStatusExt { fn valid(&self) -> bool; } - -impl ObjectLockStatusExt for ObjectLockLegalHoldStatus { - fn valid(&self) -> bool { - matches!(self.as_str(), ObjectLockLegalHoldStatus::ON | ObjectLockLegalHoldStatus::OFF) - } -} diff --git a/crates/ecstore/src/bucket/object_lock/objectlock.rs b/crates/ecstore/src/bucket/object_lock/objectlock.rs index 48f20f29d..a45e1ac50 100644 --- a/crates/ecstore/src/bucket/object_lock/objectlock.rs +++ b/crates/ecstore/src/bucket/object_lock/objectlock.rs @@ -12,8 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -use s3s::dto::{Date, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockRetention, ObjectLockRetentionMode}; -use s3s::header::{X_AMZ_OBJECT_LOCK_LEGAL_HOLD, X_AMZ_OBJECT_LOCK_MODE, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE}; +use super::types::{LegalHoldStatus, ObjectLegalHold, ObjectRetention, RetentionMode}; +use rustfs_utils::http::headers::{ + AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, +}; use std::collections::HashMap; use time::{OffsetDateTime, format_description}; @@ -31,65 +33,48 @@ pub fn utc_now_ntp() -> OffsetDateTime { OffsetDateTime::now_utc() } -pub fn get_object_retention_meta(meta: &HashMap) -> ObjectLockRetention { - // Note: X_AMZ_OBJECT_LOCK_MODE.as_str() is already lowercase ("x-amz-object-lock-mode") - let mode_str = meta.get(X_AMZ_OBJECT_LOCK_MODE.as_str()); +pub fn get_object_retention_meta(meta: &HashMap) -> ObjectRetention { + // The persisted metadata keys are the lowercase wire header names. + let mode_str = meta.get(AMZ_OBJECT_LOCK_MODE_LOWER); let Some(mode_str) = mode_str else { - return ObjectLockRetention { - mode: None, - retain_until_date: None, - }; + return ObjectRetention::default(); }; // If mode is invalid, return empty retention (don't panic) let Some(mode) = parse_ret_mode(mode_str.as_str()) else { - return ObjectLockRetention { - mode: None, - retain_until_date: None, - }; + return ObjectRetention::default(); }; - let till_str = meta.get(X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str()); + let till_str = meta.get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER); - let retain_until_date = till_str - .and_then(|s| OffsetDateTime::parse(s, &format_description::well_known::Iso8601::DEFAULT).ok()) - .map(Date::from); + let retain_until_date = + till_str.and_then(|s| OffsetDateTime::parse(s, &format_description::well_known::Iso8601::DEFAULT).ok()); - ObjectLockRetention { + ObjectRetention { mode: Some(mode), retain_until_date, } } -pub fn get_object_legalhold_meta(meta: &HashMap) -> ObjectLockLegalHold { - // Note: X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str() is already lowercase - let hold_str = meta.get(X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str()); +pub fn get_object_legalhold_meta(meta: &HashMap) -> ObjectLegalHold { + let hold_str = meta.get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER); - match hold_str.and_then(|s| parse_legalhold_status(s)) { - Some(status) => ObjectLockLegalHold { status: Some(status) }, - None => ObjectLockLegalHold { status: None }, + ObjectLegalHold { + status: hold_str.and_then(|s| parse_legalhold_status(s)), } } -/// Parse retention mode string into ObjectLockRetentionMode. +/// Parse retention mode string into [`RetentionMode`]. /// Returns None for invalid/unknown mode strings instead of panicking. -pub fn parse_ret_mode(mode_str: &str) -> Option { - match mode_str.to_uppercase().as_str() { - "GOVERNANCE" => Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::GOVERNANCE)), - "COMPLIANCE" => Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)), - _ => None, - } +pub fn parse_ret_mode(mode_str: &str) -> Option { + RetentionMode::parse(mode_str) } -/// Parse legal hold status string into ObjectLockLegalHoldStatus. +/// Parse legal hold status string into [`LegalHoldStatus`]. /// Returns None for invalid/unknown status strings instead of panicking. -pub fn parse_legalhold_status(hold_str: &str) -> Option { - match hold_str.to_uppercase().as_str() { - "ON" => Some(ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::ON)), - "OFF" => Some(ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::OFF)), - _ => None, - } +pub fn parse_legalhold_status(hold_str: &str) -> Option { + LegalHoldStatus::parse(hold_str) } #[cfg(test)] @@ -101,25 +86,25 @@ mod tests { // Test uppercase let mode = parse_ret_mode("GOVERNANCE"); assert!(mode.is_some()); - assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE); + assert_eq!(mode.unwrap().as_str(), RetentionMode::GOVERNANCE); let mode = parse_ret_mode("COMPLIANCE"); assert!(mode.is_some()); - assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::COMPLIANCE); + assert_eq!(mode.unwrap().as_str(), RetentionMode::COMPLIANCE); // Test lowercase let mode = parse_ret_mode("governance"); assert!(mode.is_some()); - assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE); + assert_eq!(mode.unwrap().as_str(), RetentionMode::GOVERNANCE); let mode = parse_ret_mode("compliance"); assert!(mode.is_some()); - assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::COMPLIANCE); + assert_eq!(mode.unwrap().as_str(), RetentionMode::COMPLIANCE); // Test mixed case let mode = parse_ret_mode("Governance"); assert!(mode.is_some()); - assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE); + assert_eq!(mode.unwrap().as_str(), RetentionMode::GOVERNANCE); } #[test] @@ -136,20 +121,20 @@ mod tests { // Test uppercase let status = parse_legalhold_status("ON"); assert!(status.is_some()); - assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::ON); + assert_eq!(status.unwrap().as_str(), LegalHoldStatus::ON); let status = parse_legalhold_status("OFF"); assert!(status.is_some()); - assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::OFF); + assert_eq!(status.unwrap().as_str(), LegalHoldStatus::OFF); // Test lowercase let status = parse_legalhold_status("on"); assert!(status.is_some()); - assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::ON); + assert_eq!(status.unwrap().as_str(), LegalHoldStatus::ON); let status = parse_legalhold_status("off"); assert!(status.is_some()); - assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::OFF); + assert_eq!(status.unwrap().as_str(), LegalHoldStatus::OFF); } #[test] @@ -175,7 +160,7 @@ mod tests { meta.insert("x-amz-object-lock-mode".to_string(), "GOVERNANCE".to_string()); let retention = get_object_retention_meta(&meta); assert!(retention.mode.is_some()); - assert_eq!(retention.mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE); + assert_eq!(retention.mode.unwrap().as_str(), RetentionMode::GOVERNANCE); assert!(retention.retain_until_date.is_none()); } @@ -196,7 +181,7 @@ mod tests { meta.insert("x-amz-object-lock-retain-until-date".to_string(), "2030-01-01T00:00:00Z".to_string()); let retention = get_object_retention_meta(&meta); assert!(retention.mode.is_some()); - assert_eq!(retention.mode.unwrap().as_str(), ObjectLockRetentionMode::COMPLIANCE); + assert_eq!(retention.mode.unwrap().as_str(), RetentionMode::COMPLIANCE); assert!(retention.retain_until_date.is_some()); } @@ -210,17 +195,11 @@ mod tests { meta.insert("x-amz-object-lock-legal-hold".to_string(), "ON".to_string()); let retention = get_object_retention_meta(&meta); - assert_eq!( - retention.mode.as_ref().map(|mode| mode.as_str()), - Some(ObjectLockRetentionMode::COMPLIANCE) - ); + assert_eq!(retention.mode.as_ref().map(|mode| mode.as_str()), Some(RetentionMode::COMPLIANCE)); assert!(retention.retain_until_date.is_some(), "persisted retention date must remain readable"); let legal_hold = get_object_legalhold_meta(&meta); - assert_eq!( - legal_hold.status.as_ref().map(|status| status.as_str()), - Some(ObjectLockLegalHoldStatus::ON) - ); + assert_eq!(legal_hold.status.as_ref().map(|status| status.as_str()), Some(LegalHoldStatus::ON)); } #[test] @@ -236,7 +215,7 @@ mod tests { meta.insert("x-amz-object-lock-legal-hold".to_string(), "ON".to_string()); let legalhold = get_object_legalhold_meta(&meta); assert!(legalhold.status.is_some()); - assert_eq!(legalhold.status.unwrap().as_str(), ObjectLockLegalHoldStatus::ON); + assert_eq!(legalhold.status.unwrap().as_str(), LegalHoldStatus::ON); } #[test] @@ -245,7 +224,7 @@ mod tests { meta.insert("x-amz-object-lock-legal-hold".to_string(), "OFF".to_string()); let legalhold = get_object_legalhold_meta(&meta); assert!(legalhold.status.is_some()); - assert_eq!(legalhold.status.unwrap().as_str(), ObjectLockLegalHoldStatus::OFF); + assert_eq!(legalhold.status.unwrap().as_str(), LegalHoldStatus::OFF); } #[test] diff --git a/crates/ecstore/src/bucket/object_lock/objectlock_sys.rs b/crates/ecstore/src/bucket/object_lock/objectlock_sys.rs index 4eb0f79fb..4931948a3 100644 --- a/crates/ecstore/src/bucket/object_lock/objectlock_sys.rs +++ b/crates/ecstore/src/bucket/object_lock/objectlock_sys.rs @@ -12,12 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::bucket::metadata_sys::{ObjectLockConfigState, get_object_lock_config, get_object_lock_config_state}; +use crate::bucket::metadata_sys::{ + ObjectLockConfigState, default_retention_from_object_lock_config, get_object_lock_config, get_object_lock_config_state, +}; use crate::bucket::object_lock::objectlock; +use crate::bucket::object_lock::types::{DefaultRetention, LegalHoldStatus, RetentionMode}; use crate::error::{Error, Result, StorageError}; use crate::object_api::{ObjectInfo, ObjectOptions}; -use s3s::dto::{Date, DefaultRetention, ObjectLockConfiguration, ObjectLockLegalHoldStatus, ObjectLockRetentionMode}; -use s3s::header::{X_AMZ_OBJECT_LOCK_LEGAL_HOLD, X_AMZ_OBJECT_LOCK_MODE, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE}; +use rustfs_utils::http::headers::{ + AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, +}; use std::sync::Arc; use time::OffsetDateTime; @@ -29,11 +33,12 @@ impl BucketObjectLockSys { Arc::new(Self {}) } + /// The bucket's active default retention, if the bucket has an + /// authoritative Object Lock configuration with a usable + /// GOVERNANCE/COMPLIANCE default retention rule. pub async fn get(bucket: &str) -> Option { - if let Ok(object_lock_config) = get_object_lock_config(bucket).await - && let Some(object_lock_rule) = object_lock_config.0.rule - { - return object_lock_rule.default_retention; + if let Ok(object_lock_config) = get_object_lock_config(bucket).await { + return default_retention_from_object_lock_config(&object_lock_config.0); } None } @@ -54,13 +59,10 @@ pub(crate) fn ensure_recursive_force_delete_allowed_for_state(bucket: &str, stat } /// Check if a retention period is still active based on mode and retain_until_date -pub fn is_retention_active(mode: &str, retain_until_date: Option<&s3s::dto::Date>) -> bool { - if mode != ObjectLockRetentionMode::COMPLIANCE && mode != ObjectLockRetentionMode::GOVERNANCE { - return false; - } +pub fn is_retention_active(_mode: RetentionMode, retain_until_date: Option) -> bool { if let Some(retain_until) = retain_until_date { let now = objectlock::utc_now_ntp(); - return OffsetDateTime::from(retain_until.clone()).unix_timestamp() > now.unix_timestamp(); + return retain_until.unix_timestamp() > now.unix_timestamp(); } false } @@ -68,23 +70,20 @@ pub fn is_retention_active(mode: &str, retain_until_date: Option<&s3s::dto::Date /// Check if retention modification is blocked for the given object. pub fn check_retention_for_modification( user_defined: &std::collections::HashMap, - new_mode: Option<&str>, + new_mode: Option, new_retain_until: Option, bypass_governance: bool, ) -> Option { let retention = objectlock::get_object_retention_meta(user_defined); - let Some(mode) = &retention.mode else { - return None; - }; + let mode = retention.mode?; - let mode_str = mode.as_str(); - if !is_retention_active(mode_str, retention.retain_until_date.as_ref()) { + if !is_retention_active(mode, retention.retain_until_date) { return None; } - let existing_retain_until = retention.retain_until_date.as_ref().map(|d| OffsetDateTime::from(d.clone())); - let mode_changed = new_mode != Some(mode_str); + let existing_retain_until = retention.retain_until_date; + let mode_changed = new_mode != Some(mode); // Check if new retention period is shorter than existing let is_shortening = match (&existing_retain_until, &new_retain_until) { @@ -93,35 +92,34 @@ pub fn check_retention_for_modification( _ => false, }; - // COMPLIANCE mode: cannot shorten retention at all (even with bypass) - // Can only extend the retention period - if mode_str == ObjectLockRetentionMode::COMPLIANCE { - if mode_changed || is_shortening { - return Some(ObjectLockBlockReason::Retention { - mode: mode_str.to_string(), - retain_until: existing_retain_until, - }); + match mode { + // COMPLIANCE mode: cannot shorten retention at all (even with bypass) + // Can only extend the retention period + RetentionMode::Compliance => { + if mode_changed || is_shortening { + return Some(ObjectLockBlockReason::Retention { + mode, + retain_until: existing_retain_until, + }); + } + // Extending retention in COMPLIANCE mode is allowed + None } - // Extending retention in COMPLIANCE mode is allowed - return None; - } - - // GOVERNANCE mode: extending is always allowed, shortening requires bypass - // This matches AWS S3 behavior where: - // - Extending retention: allowed without bypass permission - // - Shortening/removing retention: requires bypass permission - if mode_str == ObjectLockRetentionMode::GOVERNANCE { - if (mode_changed || is_shortening) && !bypass_governance { - return Some(ObjectLockBlockReason::Retention { - mode: mode_str.to_string(), - retain_until: existing_retain_until, - }); + // GOVERNANCE mode: extending is always allowed, shortening requires bypass + // This matches AWS S3 behavior where: + // - Extending retention: allowed without bypass permission + // - Shortening/removing retention: requires bypass permission + RetentionMode::Governance => { + if (mode_changed || is_shortening) && !bypass_governance { + return Some(ObjectLockBlockReason::Retention { + mode, + retain_until: existing_retain_until, + }); + } + // Extending retention or shortening with bypass is allowed + None } - // Extending retention or shortening with bypass is allowed - return None; } - - None } pub fn add_years(dt: OffsetDateTime, years: i32) -> OffsetDateTime { @@ -137,8 +135,7 @@ pub fn add_years(dt: OffsetDateTime, years: i32) -> OffsetDateTime { /// Check if an object has legal hold enabled. /// Returns true if legal hold is ON. fn has_legal_hold(user_defined: &std::collections::HashMap) -> bool { - let lhold = objectlock::get_object_legalhold_meta(user_defined); - matches!(lhold.status, Some(ref st) if st.as_str() == ObjectLockLegalHoldStatus::ON) + objectlock::get_object_legalhold_meta(user_defined).is_on() } /// Whether an authorized replication write (`ObjectOptions::replication_request`) @@ -172,11 +169,11 @@ pub fn replication_write_may_pass_worm_gate( // Delete markers are never locked (same as the WORM gate). return Ok(true); } - let config = object_lock_config_from_state(state)?; + let default_retention = default_retention_from_state(state)?; if legal_hold_locks(obj_info)? && opts.replication_legalhold_timestamp.is_none() { return Ok(false); } - let retention_locked = active_retention(config, obj_info)?.is_some(); + let retention_locked = active_retention(default_retention.as_ref(), obj_info)?.is_some(); Ok(!(retention_locked && opts.replication_retention_timestamp.is_none())) } @@ -204,8 +201,8 @@ pub fn is_object_locked_by_metadata(user_defined: &std::collections::HashMap, }, } @@ -246,30 +243,28 @@ impl ObjectLockBlockReason { /// Check if retention blocks deletion based on mode and bypass permission. /// Returns Some(ObjectLockBlockReason) if blocked, None if allowed. fn check_retention_blocks_deletion( - mode_str: &str, + mode: RetentionMode, retain_until: Option, bypass_governance: bool, ) -> Option { // COMPLIANCE mode cannot be bypassed; GOVERNANCE can only be bypassed with permission - let can_bypass = mode_str == ObjectLockRetentionMode::GOVERNANCE && bypass_governance; + let can_bypass = mode == RetentionMode::Governance && bypass_governance; if !can_bypass { - return Some(ObjectLockBlockReason::Retention { - mode: mode_str.to_string(), - retain_until, - }); + return Some(ObjectLockBlockReason::Retention { mode, retain_until }); } None } -/// Check an object's lock metadata using an already resolved bucket Object -/// Lock configuration. `None` means the configuration is confirmed absent. +/// Check an object's lock metadata using an already resolved bucket default +/// retention. `None` means the bucket configuration is confirmed absent or +/// carries no usable default retention rule. /// /// # S3 Standard Behavior /// - COMPLIANCE mode: Cannot be deleted even with bypass header /// - GOVERNANCE mode: Can be deleted if bypass_governance is true (caller must verify s3:BypassGovernanceRetention permission) /// - Legal Hold: Cannot be bypassed regardless of mode -pub(crate) fn check_object_lock_for_deletion_with_config( - config: Option<&ObjectLockConfiguration>, +pub(crate) fn check_object_lock_for_deletion_with_default_retention( + default_retention: Option<&DefaultRetention>, obj_info: &ObjectInfo, bypass_governance: bool, ) -> Result> { @@ -281,8 +276,8 @@ pub(crate) fn check_object_lock_for_deletion_with_config( return Ok(Some(ObjectLockBlockReason::LegalHold)); } - if let Some((mode_str, retain_until)) = active_retention(config, obj_info)? - && let Some(reason) = check_retention_blocks_deletion(mode_str, Some(retain_until), bypass_governance) + if let Some((mode, retain_until)) = active_retention(default_retention, obj_info)? + && let Some(reason) = check_retention_blocks_deletion(mode, Some(retain_until), bypass_governance) { return Ok(Some(reason)); } @@ -300,56 +295,41 @@ fn persisted_lock_value<'a>(obj_info: &'a ObjectInfo, key: &str) -> Option<&'a S /// Whether the version's persisted legal hold is ON. Any other non-empty /// value than ON/OFF is malformed metadata and fails closed. fn legal_hold_locks(obj_info: &ObjectInfo) -> Result { - let Some(status) = persisted_lock_value(obj_info, X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str()) else { + let Some(status) = persisted_lock_value(obj_info, AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER) else { return Ok(false); }; - if status.eq_ignore_ascii_case(ObjectLockLegalHoldStatus::ON) { - return Ok(true); + match LegalHoldStatus::parse(status) { + Some(LegalHoldStatus::On) => Ok(true), + Some(LegalHoldStatus::Off) => Ok(false), + None => Err(Error::other("persisted object legal-hold metadata is invalid")), } - if !status.eq_ignore_ascii_case(ObjectLockLegalHoldStatus::OFF) { - return Err(Error::other("persisted object legal-hold metadata is invalid")); - } - Ok(false) } /// The retention that currently locks the version, if any: the explicit /// persisted retention when the keys are present, otherwise the bucket /// default retention computed from the version's modification time. Returns /// `(mode, retain_until)` only while the retention is still active. -fn active_retention<'a>( - config: Option<&'a ObjectLockConfiguration>, +fn active_retention( + default_retention: Option<&DefaultRetention>, obj_info: &ObjectInfo, -) -> Result> { - let mode = persisted_lock_value(obj_info, X_AMZ_OBJECT_LOCK_MODE.as_str()); - let retain_until = persisted_lock_value(obj_info, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str()); +) -> Result> { + let mode = persisted_lock_value(obj_info, AMZ_OBJECT_LOCK_MODE_LOWER); + let retain_until = persisted_lock_value(obj_info, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER); match (mode, retain_until) { (None, None) => {} (Some(mode), Some(retain_until)) => { let mode = objectlock::parse_ret_mode(mode).ok_or_else(|| Error::other("persisted object retention mode is invalid"))?; let retain_until = OffsetDateTime::parse(retain_until, &time::format_description::well_known::Iso8601::DEFAULT) - .map(Date::from) .map_err(|_| Error::other("persisted object retention date is invalid"))?; - let mode_str = match mode.as_str() { - ObjectLockRetentionMode::COMPLIANCE => ObjectLockRetentionMode::COMPLIANCE, - ObjectLockRetentionMode::GOVERNANCE => ObjectLockRetentionMode::GOVERNANCE, - _ => return Err(Error::other("persisted object retention mode is invalid")), - }; - return Ok(is_retention_active(mode_str, Some(&retain_until)).then(|| (mode_str, OffsetDateTime::from(retain_until)))); + return Ok(is_retention_active(mode, Some(retain_until)).then_some((mode, retain_until))); } _ => return Err(Error::other("persisted object retention metadata is incomplete")), } - let Some(default_retention) = config.and_then(|config| config.rule.as_ref()?.default_retention.as_ref()) else { + let Some(default_retention) = default_retention else { return Ok(None); }; - let Some(mode) = &default_retention.mode else { - return Ok(None); - }; - let mode_str = mode.as_str(); - if mode_str != ObjectLockRetentionMode::COMPLIANCE && mode_str != ObjectLockRetentionMode::GOVERNANCE { - return Ok(None); - } // Calculate retention expiration date from object modification time let mod_time = obj_info .mod_time @@ -363,12 +343,16 @@ fn active_retention<'a>( .ok_or_else(|| Error::other("persisted bucket Object Lock retention period is invalid"))?; add_years(mod_time, years) }; - Ok((retain_until.unix_timestamp() > now.unix_timestamp()).then_some((mode_str, retain_until))) + Ok((retain_until.unix_timestamp() > now.unix_timestamp()).then_some((default_retention.mode, retain_until))) } -fn object_lock_config_from_state(state: &ObjectLockConfigState) -> Result> { +/// The bucket default retention carried by an authoritative Object Lock +/// state. `ConfirmedAbsent` and a configuration without a usable default +/// retention rule are both `None`; a fabricated state is an error, never a +/// pass. +fn default_retention_from_state(state: &ObjectLockConfigState) -> Result> { match state { - ObjectLockConfigState::Configured { config, .. } => Ok(Some(config)), + ObjectLockConfigState::Configured { config, .. } => Ok(default_retention_from_object_lock_config(config)), ObjectLockConfigState::ConfirmedAbsent => Ok(None), ObjectLockConfigState::Fabricated => Err(Error::other("bucket Object Lock metadata is not authoritative")), } @@ -379,7 +363,11 @@ pub(crate) fn check_object_lock_for_deletion_with_state( obj_info: &ObjectInfo, bypass_governance: bool, ) -> Result> { - check_object_lock_for_deletion_with_config(object_lock_config_from_state(state)?, obj_info, bypass_governance) + check_object_lock_for_deletion_with_default_retention( + default_retention_from_state(state)?.as_ref(), + obj_info, + bypass_governance, + ) } /// Compatibility wrapper for callers that predate fallible metadata lookup. @@ -402,7 +390,10 @@ pub async fn check_object_lock_for_deletion( #[cfg(test)] mod tests { use super::*; - use s3s::dto::{ObjectLockEnabled, ObjectLockRule}; + use crate::bucket::metadata_sys::configured_object_lock_state_for_tests; + use rustfs_utils::http::headers::{ + AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, + }; use time::{Date, Month, PrimitiveDateTime, Time}; fn make_datetime(year: i32, month: u8, day: u8) -> OffsetDateTime { @@ -411,51 +402,46 @@ mod tests { PrimitiveDateTime::new(date, time).assume_utc() } - fn default_retention_config(mode: &'static str) -> ObjectLockConfiguration { - ObjectLockConfiguration { - object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), - rule: Some(ObjectLockRule { - default_retention: Some(DefaultRetention { - mode: Some(ObjectLockRetentionMode::from_static(mode)), - days: Some(30), - years: None, - }), - }), + fn default_retention(mode: RetentionMode) -> DefaultRetention { + DefaultRetention { + mode, + days: Some(30), + years: None, } } #[test] fn deletion_with_config_blocks_active_default_compliance_even_with_bypass() { - let config = default_retention_config(ObjectLockRetentionMode::COMPLIANCE); + let retention = default_retention(RetentionMode::Compliance); let obj_info = ObjectInfo { mod_time: Some(OffsetDateTime::now_utc()), ..Default::default() }; - let result = check_object_lock_for_deletion_with_config(Some(&config), &obj_info, true); + let result = check_object_lock_for_deletion_with_default_retention(Some(&retention), &obj_info, true); assert!(matches!(result, Ok(Some(ObjectLockBlockReason::Retention { .. })))); } #[test] fn deletion_with_config_allows_active_default_governance_with_bypass() { - let config = default_retention_config(ObjectLockRetentionMode::GOVERNANCE); + let retention = default_retention(RetentionMode::Governance); let obj_info = ObjectInfo { mod_time: Some(OffsetDateTime::now_utc()), ..Default::default() }; assert!(matches!( - check_object_lock_for_deletion_with_config(Some(&config), &obj_info, true), + check_object_lock_for_deletion_with_default_retention(Some(&retention), &obj_info, true), Ok(None) )); } #[test] fn deletion_with_default_retention_rejects_missing_object_mod_time() { - let config = default_retention_config(ObjectLockRetentionMode::COMPLIANCE); + let retention = default_retention(RetentionMode::Compliance); - let err = check_object_lock_for_deletion_with_config(Some(&config), &ObjectInfo::default(), false) + let err = check_object_lock_for_deletion_with_default_retention(Some(&retention), &ObjectInfo::default(), false) .expect_err("default retention needs an authoritative object modification time"); assert!(err.to_string().contains("modification time")); @@ -465,7 +451,7 @@ mod tests { fn deletion_with_confirmed_absence_still_blocks_explicit_compliance() { let retain_until = OffsetDateTime::now_utc() + time::Duration::days(30); let mut user_defined = std::collections::HashMap::new(); - user_defined.insert("x-amz-object-lock-mode".to_string(), ObjectLockRetentionMode::COMPLIANCE.to_string()); + user_defined.insert("x-amz-object-lock-mode".to_string(), RetentionMode::COMPLIANCE.to_string()); user_defined.insert( "x-amz-object-lock-retain-until-date".to_string(), retain_until @@ -477,7 +463,7 @@ mod tests { ..Default::default() }; - let result = check_object_lock_for_deletion_with_config(None, &obj_info, true); + let result = check_object_lock_for_deletion_with_default_retention(None, &obj_info, true); assert!(matches!(result, Ok(Some(ObjectLockBlockReason::Retention { .. })))); } @@ -501,16 +487,13 @@ mod tests { #[test] fn deletion_rejects_incomplete_persisted_retention_metadata() { let mut user_defined = std::collections::HashMap::new(); - user_defined.insert( - X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(), - ObjectLockRetentionMode::COMPLIANCE.to_string(), - ); + user_defined.insert(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), RetentionMode::COMPLIANCE.to_string()); let obj_info = ObjectInfo { user_defined: Arc::new(user_defined), ..Default::default() }; - let err = check_object_lock_for_deletion_with_config(None, &obj_info, false) + let err = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false) .expect_err("mode without retain-until date must fail closed"); assert!(err.to_string().contains("incomplete")); @@ -523,29 +506,24 @@ mod tests { .expect("retain-until date should format"); let cases = [ ("invalid mode", Some("INVALID"), Some(valid_date.as_str()), "retention mode"), - ( - "invalid date", - Some(ObjectLockRetentionMode::COMPLIANCE), - Some("not-a-date"), - "retention date", - ), + ("invalid date", Some(RetentionMode::COMPLIANCE), Some("not-a-date"), "retention date"), ("date only", None, Some(valid_date.as_str()), "incomplete"), ]; for (case, mode, retain_until, expected) in cases { let mut user_defined = std::collections::HashMap::new(); if let Some(mode) = mode { - user_defined.insert(X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(), mode.to_string()); + user_defined.insert(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), mode.to_string()); } if let Some(retain_until) = retain_until { - user_defined.insert(X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str().to_string(), retain_until.to_string()); + user_defined.insert(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), retain_until.to_string()); } let obj_info = ObjectInfo { user_defined: Arc::new(user_defined), ..Default::default() }; - let err = check_object_lock_for_deletion_with_config(None, &obj_info, false).expect_err(case); + let err = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false).expect_err(case); assert!(err.to_string().contains(expected), "unexpected {case} error: {err}"); } } @@ -579,10 +557,6 @@ mod tests { /// source timestamp of every category that currently locks the version. #[test] fn replication_write_passes_worm_gate_only_with_every_locking_category_timestamp() { - use rustfs_utils::http::headers::{ - AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, - }; - let hold = [(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, "ON")]; let retention = [ (AMZ_OBJECT_LOCK_MODE_LOWER, "GOVERNANCE"), @@ -623,19 +597,15 @@ mod tests { } /// The bucket default retention locks a version that carries no explicit - /// retention keys (`check_object_lock_for_deletion_with_config` judges it - /// from the modification time), so the replication bypass must demand the - /// retention source timestamp for it too — a tagging-only replication - /// write must not overwrite the default-protected version unjudged. + /// retention keys (`check_object_lock_for_deletion_with_default_retention` + /// judges it from the modification time), so the replication bypass must + /// demand the retention source timestamp for it too — a tagging-only + /// replication write must not overwrite the default-protected version + /// unjudged. #[test] fn replication_write_under_bucket_default_retention_requires_retention_timestamp() { - use rustfs_utils::http::headers::{AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER}; - - for mode in [ObjectLockRetentionMode::COMPLIANCE, ObjectLockRetentionMode::GOVERNANCE] { - let state = ObjectLockConfigState::Configured { - config: default_retention_config(mode), - updated_at: OffsetDateTime::now_utc(), - }; + for mode in [RetentionMode::Compliance, RetentionMode::Governance] { + let state = configured_object_lock_state_for_tests(mode, 30); let no_keys = lock_object_info(std::collections::HashMap::new()); assert!( check_object_lock_for_deletion_with_state(&state, &no_keys, false) @@ -689,8 +659,6 @@ mod tests { /// state or malformed persisted lock metadata; both are errors, not a pass. #[test] fn replication_write_worm_gate_fails_closed_on_unverifiable_lock_state() { - use rustfs_utils::http::headers::AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER; - let opts = replication_opts(true, true); let err = replication_write_may_pass_worm_gate( &ObjectLockConfigState::Fabricated, @@ -705,10 +673,7 @@ mod tests { .expect_err("malformed legal hold must not be judged"); assert!(err.to_string().contains("legal-hold")); - let state = ObjectLockConfigState::Configured { - config: default_retention_config(ObjectLockRetentionMode::COMPLIANCE), - updated_at: OffsetDateTime::now_utc(), - }; + let state = configured_object_lock_state_for_tests(RetentionMode::Compliance, 30); let no_mod_time = ObjectInfo::default(); let err = replication_write_may_pass_worm_gate(&state, &no_mod_time, &opts) .expect_err("default retention without a modification time must not be judged"); @@ -722,10 +687,6 @@ mod tests { /// (rustfs/backlog#1953). #[test] fn deletion_treats_cleared_empty_lock_metadata_as_unlocked() { - use rustfs_utils::http::headers::{ - AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, - }; - let cases: [(&str, &[&str]); 3] = [ ( "cleared retention", @@ -749,7 +710,7 @@ mod tests { ..Default::default() }; - let result = check_object_lock_for_deletion_with_config(None, &obj_info, false); + let result = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false); assert!(matches!(result, Ok(None)), "{case}: empty lock keys must read as unlocked: {result:?}"); } } @@ -757,13 +718,13 @@ mod tests { #[test] fn deletion_rejects_invalid_persisted_legal_hold_metadata() { let mut user_defined = std::collections::HashMap::new(); - user_defined.insert(X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str().to_string(), "INVALID".to_string()); + user_defined.insert(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER.to_string(), "INVALID".to_string()); let obj_info = ObjectInfo { user_defined: Arc::new(user_defined), ..Default::default() }; - let err = check_object_lock_for_deletion_with_config(None, &obj_info, false) + let err = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false) .expect_err("invalid legal-hold value must fail closed"); assert!(err.to_string().contains("legal-hold")); @@ -829,42 +790,29 @@ mod tests { assert_eq!(result.day(), 4); } - #[test] - fn test_is_retention_active_invalid_mode() { - // Invalid mode should return false - assert!(!is_retention_active("INVALID", None)); - assert!(!is_retention_active("", None)); - } - #[test] fn test_is_retention_active_no_date() { // Valid mode but no retain_until_date should return false - assert!(!is_retention_active(ObjectLockRetentionMode::COMPLIANCE, None)); - assert!(!is_retention_active(ObjectLockRetentionMode::GOVERNANCE, None)); + assert!(!is_retention_active(RetentionMode::Compliance, None)); + assert!(!is_retention_active(RetentionMode::Governance, None)); } #[test] fn test_is_retention_active_future_date() { // Valid mode with future retain_until_date should return true let future_date = OffsetDateTime::now_utc() + time::Duration::days(30); - let s3_date = s3s::dto::Date::from(future_date); - assert!(is_retention_active(ObjectLockRetentionMode::COMPLIANCE, Some(&s3_date))); - let future_date = OffsetDateTime::now_utc() + time::Duration::days(30); - let s3_date = s3s::dto::Date::from(future_date); - assert!(is_retention_active(ObjectLockRetentionMode::GOVERNANCE, Some(&s3_date))); + assert!(is_retention_active(RetentionMode::Compliance, Some(future_date))); + assert!(is_retention_active(RetentionMode::Governance, Some(future_date))); } #[test] fn test_is_retention_active_past_date() { // Valid mode with past retain_until_date should return false let past_date = OffsetDateTime::now_utc() - time::Duration::days(30); - let s3_date = s3s::dto::Date::from(past_date); - assert!(!is_retention_active(ObjectLockRetentionMode::COMPLIANCE, Some(&s3_date))); - let past_date = OffsetDateTime::now_utc() - time::Duration::days(30); - let s3_date = s3s::dto::Date::from(past_date); - assert!(!is_retention_active(ObjectLockRetentionMode::GOVERNANCE, Some(&s3_date))); + assert!(!is_retention_active(RetentionMode::Compliance, Some(past_date))); + assert!(!is_retention_active(RetentionMode::Governance, Some(past_date))); } #[test] @@ -890,10 +838,7 @@ mod tests { // Extending by another 30 days should be allowed let new_retain = Some(existing_retain + time::Duration::days(30)); - assert!( - check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::COMPLIANCE), new_retain, false) - .is_none() - ); + assert!(check_retention_for_modification(&user_defined, Some(RetentionMode::Compliance), new_retain, false).is_none()); } #[test] @@ -911,8 +856,7 @@ mod tests { // Shortening to 30 days should be blocked let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(30)); - let result = - check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::COMPLIANCE), new_retain, false); + let result = check_retention_for_modification(&user_defined, Some(RetentionMode::Compliance), new_retain, false); assert!(result.is_some()); assert!(matches!(result, Some(ObjectLockBlockReason::Retention { .. }))); } @@ -950,8 +894,7 @@ mod tests { // Shortening from 30 days to 15 days without bypass should be blocked let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(15)); - let result = - check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::GOVERNANCE), new_retain, false); + let result = check_retention_for_modification(&user_defined, Some(RetentionMode::Governance), new_retain, false); assert!(result.is_some()); } @@ -971,10 +914,7 @@ mod tests { // Extending from 30 days to 60 days without bypass should be allowed let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(60)); - assert!( - check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::GOVERNANCE), new_retain, false) - .is_none() - ); + assert!(check_retention_for_modification(&user_defined, Some(RetentionMode::Governance), new_retain, false).is_none()); } #[test] @@ -992,10 +932,7 @@ mod tests { // Shortening from 30 days to 15 days with bypass should be allowed let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(15)); - assert!( - check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::GOVERNANCE), new_retain, true) - .is_none() - ); + assert!(check_retention_for_modification(&user_defined, Some(RetentionMode::Governance), new_retain, true).is_none()); } #[test] @@ -1010,12 +947,8 @@ mod tests { .unwrap(), ); - let result = check_retention_for_modification( - &user_defined, - Some(ObjectLockRetentionMode::COMPLIANCE), - Some(existing_retain), - false, - ); + let result = + check_retention_for_modification(&user_defined, Some(RetentionMode::Compliance), Some(existing_retain), false); assert!(result.is_some()); } @@ -1032,13 +965,8 @@ mod tests { ); assert!( - check_retention_for_modification( - &user_defined, - Some(ObjectLockRetentionMode::COMPLIANCE), - Some(existing_retain), - true, - ) - .is_none() + check_retention_for_modification(&user_defined, Some(RetentionMode::Compliance), Some(existing_retain), true) + .is_none() ); } @@ -1054,12 +982,8 @@ mod tests { .unwrap(), ); - let result = check_retention_for_modification( - &user_defined, - Some(ObjectLockRetentionMode::GOVERNANCE), - Some(existing_retain), - true, - ); + let result = + check_retention_for_modification(&user_defined, Some(RetentionMode::Governance), Some(existing_retain), true); assert!(result.is_some()); } diff --git a/crates/ecstore/src/bucket/object_lock/types.rs b/crates/ecstore/src/bucket/object_lock/types.rs new file mode 100644 index 000000000..46f2165c4 --- /dev/null +++ b/crates/ecstore/src/bucket/object_lock/types.rs @@ -0,0 +1,179 @@ +// 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. + +//! Storage-level Object Lock types (rustfs/backlog#1842). +//! +//! The engine evaluates WORM state from persisted object metadata and the +//! bucket default retention; none of that needs S3 wire/DTO types. The +//! serving layer converts to/from its wire DTOs at its own boundary, and the +//! bucket-metadata module converts the persisted `ObjectLockConfiguration` +//! into [`DefaultRetention`] when handing it to the evaluation code here. + +use std::fmt; +use time::OffsetDateTime; + +/// Object Lock retention mode. Persisted metadata and the bucket default +/// retention only ever carry these two values; anything else is either +/// malformed metadata (fail-closed at the parse site) or an inactive +/// configuration (ignored at the conversion site). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RetentionMode { + Governance, + Compliance, +} + +impl RetentionMode { + pub const GOVERNANCE: &'static str = "GOVERNANCE"; + pub const COMPLIANCE: &'static str = "COMPLIANCE"; + + /// Parse the canonical S3 wire spelling, case-insensitively (matching the + /// historical `parse_ret_mode` behavior). Returns `None` for anything + /// that is not GOVERNANCE/COMPLIANCE. + pub fn parse(value: &str) -> Option { + if value.eq_ignore_ascii_case(Self::GOVERNANCE) { + Some(Self::Governance) + } else if value.eq_ignore_ascii_case(Self::COMPLIANCE) { + Some(Self::Compliance) + } else { + None + } + } + + /// Parse only the exact canonical wire spelling. Use this for a mode a + /// caller supplies in a *request*: the retention-modification gate has + /// always compared the requested mode literally against the canonical + /// persisted mode, so a non-canonical spelling must stay "not the same + /// mode" (and therefore blocked), not be normalized into a match. + pub fn parse_exact(value: &str) -> Option { + match value { + Self::GOVERNANCE => Some(Self::Governance), + Self::COMPLIANCE => Some(Self::Compliance), + _ => None, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::Governance => Self::GOVERNANCE, + Self::Compliance => Self::COMPLIANCE, + } + } +} + +impl fmt::Display for RetentionMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Object Lock legal hold status (ON/OFF). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LegalHoldStatus { + On, + Off, +} + +impl LegalHoldStatus { + pub const ON: &'static str = "ON"; + pub const OFF: &'static str = "OFF"; + + /// Parse the canonical S3 wire spelling, case-insensitively (matching the + /// historical `parse_legalhold_status` behavior). + pub fn parse(value: &str) -> Option { + if value.eq_ignore_ascii_case(Self::ON) { + Some(Self::On) + } else if value.eq_ignore_ascii_case(Self::OFF) { + Some(Self::Off) + } else { + None + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::On => Self::ON, + Self::Off => Self::OFF, + } + } +} + +impl fmt::Display for LegalHoldStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// An object version's retention as read from persisted metadata. `mode` is +/// `None` when the metadata carries no (or an unparsable) retention mode. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ObjectRetention { + pub mode: Option, + pub retain_until_date: Option, +} + +/// An object version's legal hold as read from persisted metadata. `status` +/// is `None` when the metadata carries no (or an unparsable) legal hold. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ObjectLegalHold { + pub status: Option, +} + +impl ObjectLegalHold { + pub fn is_on(&self) -> bool { + self.status == Some(LegalHoldStatus::On) + } +} + +/// The bucket's default Object Lock retention, converted from the persisted +/// configuration. Conversion only yields a value for an active default +/// retention (a valid GOVERNANCE/COMPLIANCE mode); a rule without a usable +/// mode converts to `None`, matching how the evaluation code has always +/// ignored such rules. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DefaultRetention { + pub mode: RetentionMode, + pub days: Option, + pub years: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The modification gate compares a *requested* mode against the canonical + /// persisted mode literally: a non-canonical spelling must not normalize + /// into a match, or a client could shorten GOVERNANCE retention without + /// bypass by spelling the mode differently. `parse_exact` is that pin. + #[test] + fn parse_exact_accepts_only_canonical_spellings() { + assert_eq!(RetentionMode::parse_exact("GOVERNANCE"), Some(RetentionMode::Governance)); + assert_eq!(RetentionMode::parse_exact("COMPLIANCE"), Some(RetentionMode::Compliance)); + for non_canonical in ["governance", "Governance", "compliance", "Compliance", "", "INVALID"] { + assert_eq!(RetentionMode::parse_exact(non_canonical), None, "{non_canonical:?} must not parse"); + } + } + + /// Persisted metadata parsing stays case-insensitive (the historical + /// `parse_ret_mode` / `parse_legalhold_status` behavior): on-disk values + /// written by older builds must keep locking. + #[test] + fn parse_is_case_insensitive_for_persisted_values() { + assert_eq!(RetentionMode::parse("governance"), Some(RetentionMode::Governance)); + assert_eq!(RetentionMode::parse("Compliance"), Some(RetentionMode::Compliance)); + assert_eq!(LegalHoldStatus::parse("on"), Some(LegalHoldStatus::On)); + assert_eq!(LegalHoldStatus::parse("Off"), Some(LegalHoldStatus::Off)); + assert_eq!(RetentionMode::parse("INVALID"), None); + assert_eq!(LegalHoldStatus::parse("MAYBE"), None); + } +} diff --git a/crates/ecstore/src/bucket/quota/checker.rs b/crates/ecstore/src/bucket/quota/checker.rs index 0d71095e3..44a113652 100644 --- a/crates/ecstore/src/bucket/quota/checker.rs +++ b/crates/ecstore/src/bucket/quota/checker.rs @@ -15,8 +15,8 @@ use super::{BucketQuota, QuotaCheckResult, QuotaError, QuotaOperation}; use crate::bucket::metadata_sys::{BucketMetadataSys, update, update_if_incarnation}; use crate::data_usage::get_bucket_usage_memory; -use rustfs_common::metrics::Metric; use rustfs_config::QUOTA_CONFIG_FILE; +use rustfs_scanner_contracts::metrics::Metric; use std::sync::Arc; use std::time::Instant; use time::OffsetDateTime; @@ -120,9 +120,9 @@ impl QuotaChecker { let duration = start_time.elapsed(); // inc_time is now a plain fn (not async) — no .await needed. - rustfs_common::metrics::Metrics::inc_time(Metric::QuotaCheck, duration); + rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaCheck, duration); if !allowed { - rustfs_common::metrics::Metrics::inc_time(Metric::QuotaViolation, duration); + rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaViolation, duration); } Ok(result) @@ -185,7 +185,7 @@ impl QuotaChecker { .await .map_err(QuotaError::StorageError)?; - rustfs_common::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed()); + rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed()); Ok(updated_at) } @@ -206,7 +206,7 @@ impl QuotaChecker { } .map_err(QuotaError::StorageError)?; - rustfs_common::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed()); + rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed()); Ok(updated_at) } diff --git a/crates/ecstore/src/bucket/replication/replication_storage_boundary.rs b/crates/ecstore/src/bucket/replication/replication_storage_boundary.rs index 9126f3563..1fa113f9c 100644 --- a/crates/ecstore/src/bucket/replication/replication_storage_boundary.rs +++ b/crates/ecstore/src/bucket/replication/replication_storage_boundary.rs @@ -18,7 +18,6 @@ use tokio_util::sync::CancellationToken; use super::replication_error_boundary::Error; use super::replication_filemeta_boundary::{replication_state_from_filemeta, version_purge_status_from_filemeta}; pub(crate) type ReplicationObjectStore = crate::store::ECStore; -pub(crate) use crate::client::api_get_options::{AdvancedGetOptions, StatObjectOptions}; pub(crate) use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader}; pub(crate) use crate::storage_api_contracts::list::{ ListOperations, StorageListObjectVersionsInfo, StorageListObjectsV2Info, StorageObjectInfoOrErr, StorageWalkOptions, @@ -29,6 +28,7 @@ pub(crate) use crate::storage_api_contracts::object::{ }; pub(crate) use crate::storage_api_contracts::range::HTTPRangeSpec; pub(crate) use rustfs_replication::{DeletedObject as ReplicationDeletedObject, ObjectToDelete as ReplicationObjectToDelete}; +pub(crate) use rustfs_s3_client::api_get_options::{AdvancedGetOptions, StatObjectOptions}; type ListObjectsV2Info = StorageListObjectsV2Info; type ListObjectVersionsInfo = StorageListObjectVersionsInfo; diff --git a/crates/ecstore/src/bucket/replication/replication_target_boundary.rs b/crates/ecstore/src/bucket/replication/replication_target_boundary.rs index 50aa14cd7..dbe3a29d5 100644 --- a/crates/ecstore/src/bucket/replication/replication_target_boundary.rs +++ b/crates/ecstore/src/bucket/replication/replication_target_boundary.rs @@ -200,7 +200,7 @@ impl ReplicationTargetStore { } pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo) -> Result<(PutObjectOptions, bool)> { - use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD}; + use base64_simd::STANDARD as BASE64_STANDARD; use rustfs_utils::http::{AMZ_CHECKSUM_TYPE, AMZ_CHECKSUM_TYPE_FULL_OBJECT}; let mut meta = HashMap::new(); @@ -252,7 +252,7 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo) && !checksum_data.is_empty() { if is_ssec { - let encoded = BASE64_STANDARD.encode(checksum_data); + let encoded = BASE64_STANDARD.encode_to_string(checksum_data); insert_header_map(&mut meta, SUFFIX_REPLICATION_SSEC_CRC, encoded); } else if object_info.is_encrypted() { // Encrypted checksums cannot be exposed as plaintext headers, and diff --git a/crates/ecstore/src/client/mod.rs b/crates/ecstore/src/client/mod.rs deleted file mode 100644 index de0c2f7a5..000000000 --- a/crates/ecstore/src/client/mod.rs +++ /dev/null @@ -1,27 +0,0 @@ -// 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-consuming client moved to the `rustfs-s3-client` crate -// (rustfs/backlog#1842). This shim keeps `crate::client::*` paths working for -// in-crate consumers during the migration window; it is deleted once every -// consumer imports `rustfs_s3_client` directly. Only the two server-side -// modules below (misfiled here historically) remain as real ecstore code. - -pub use rustfs_s3_client::{ - admin_handler_utils, api_get_options, api_list, api_put_object, api_remove, api_s3_datatypes, credentials, provider_versions, - signer_error, transition_api, -}; - -pub mod object_api_utils; -pub mod object_handlers_common; diff --git a/crates/ecstore/src/cluster/rpc/http_auth.rs b/crates/ecstore/src/cluster/rpc/http_auth.rs index 00b0dd2bf..157da2c57 100644 --- a/crates/ecstore/src/cluster/rpc/http_auth.rs +++ b/crates/ecstore/src/cluster/rpc/http_auth.rs @@ -31,8 +31,7 @@ use crate::storage_api_contracts::internode::{ NS_SCANNER_PROTOCOL_VERSION, PUT_FILE_AUTH_TRAILER_DIGEST_LEN, PUT_FILE_AUTH_TRAILER_LEN, PUT_FILE_AUTH_TRAILER_MAC_LEN, PUT_FILE_AUTH_TRAILER_MAGIC, PUT_FILE_CAPABILITY_VERSION, }; -use base64::Engine as _; -use base64::engine::general_purpose; + use hmac::{Hmac, KeyInit, Mac}; use http::uri::Authority; use http::{HeaderMap, HeaderValue, Method, Uri}; @@ -523,11 +522,11 @@ fn generate_signature(secret: &str, url: &str, method: &Method, timestamp: i64) let mut mac = ::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size"); mac.update(data.as_bytes()); let result = mac.finalize(); - general_purpose::STANDARD.encode(result.into_bytes()) + base64_simd::STANDARD.encode_to_string(result.into_bytes()) } fn verify_signature(secret: &str, url: &str, method: &Method, timestamp: i64, signature: &str) -> bool { - let Ok(signature) = general_purpose::STANDARD.decode(signature) else { + let Ok(signature) = base64_simd::STANDARD.decode_to_vec(signature) else { return false; }; @@ -745,11 +744,11 @@ fn generate_signature_v2(secret: &str, scope: SignatureV2Scope<'_>) -> std::io:: let mut mac = ::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?; update_signature_v2(&mut mac, scope); - Ok(general_purpose::STANDARD.encode(mac.finalize().into_bytes())) + Ok(base64_simd::STANDARD.encode_to_string(mac.finalize().into_bytes())) } fn verify_signature_v2(secret: &str, scope: SignatureV2Scope<'_>, signature: &str) -> bool { - let Ok(signature) = general_purpose::STANDARD.decode(signature) else { + let Ok(signature) = base64_simd::STANDARD.decode_to_vec(signature) else { return false; }; let Ok(mut mac) = ::new_from_slice(secret.as_bytes()) else { @@ -792,11 +791,11 @@ fn generate_replay_scope_signature(secret: &str, scope: ReplayScope<'_>) -> std: let mut mac = ::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?; update_replay_scope(&mut mac, scope); - Ok(general_purpose::STANDARD.encode(mac.finalize().into_bytes())) + Ok(base64_simd::STANDARD.encode_to_string(mac.finalize().into_bytes())) } fn verify_replay_scope_signature(secret: &str, scope: ReplayScope<'_>, signature: &str) -> bool { - let Ok(signature) = general_purpose::STANDARD.decode(signature) else { + let Ok(signature) = base64_simd::STANDARD.decode_to_vec(signature) else { return false; }; let Ok(mut mac) = ::new_from_slice(secret.as_bytes()) else { @@ -821,15 +820,15 @@ fn generate_boot_epoch_proof(secret: &str, audience: &str, challenge: Uuid, boot let mut mac = ::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?; update_boot_epoch_proof(&mut mac, audience, challenge, boot_epoch); - Ok(general_purpose::STANDARD.encode(mac.finalize().into_bytes())) + Ok(base64_simd::STANDARD.encode_to_string(mac.finalize().into_bytes())) } fn verify_boot_epoch_proof(secret: &str, audience: &str, challenge: Uuid, boot_epoch: Uuid, proof: &str) -> std::io::Result<()> { if audience.is_empty() || challenge.is_nil() || boot_epoch.is_nil() { return Err(std::io::Error::other("Invalid RPC boot epoch proof scope")); } - let proof = general_purpose::STANDARD - .decode(proof) + let proof = base64_simd::STANDARD + .decode_to_vec(proof) .map_err(|_| std::io::Error::other("Invalid RPC boot epoch proof"))?; let mut mac = ::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?; @@ -862,7 +861,7 @@ fn generate_replay_cache_capability_proof( let mut mac = ::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?; update_replay_cache_capability_proof(&mut mac, audience, challenge, boot_epoch); - Ok(general_purpose::STANDARD.encode(mac.finalize().into_bytes())) + Ok(base64_simd::STANDARD.encode_to_string(mac.finalize().into_bytes())) } fn verify_replay_cache_capability_proof( @@ -872,8 +871,8 @@ fn verify_replay_cache_capability_proof( boot_epoch: Uuid, proof: &str, ) -> std::io::Result<()> { - let proof = general_purpose::STANDARD - .decode(proof) + let proof = base64_simd::STANDARD + .decode_to_vec(proof) .map_err(|_| std::io::Error::other("Invalid RPC replay cache capability proof"))?; let mut mac = ::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?; @@ -1988,9 +1987,9 @@ mod tests { let method = Method::GET; let timestamp = 1640995200; let signature = generate_signature(secret, url, &method, timestamp); - let mut tampered = general_purpose::STANDARD.decode(&signature).unwrap(); + let mut tampered = base64_simd::STANDARD.decode_to_vec(&signature).unwrap(); tampered[0] ^= 1; - let tampered_signature = general_purpose::STANDARD.encode(tampered); + let tampered_signature = base64_simd::STANDARD.encode_to_string(tampered); assert!(verify_signature(secret, url, &method, timestamp, &signature)); assert!(!verify_signature(secret, url, &method, timestamp, &tampered_signature)); diff --git a/crates/ecstore/src/cluster/rpc/peer_rest_client.rs b/crates/ecstore/src/cluster/rpc/peer_rest_client.rs index a3ebe48aa..64ceb292e 100644 --- a/crates/ecstore/src/cluster/rpc/peer_rest_client.rs +++ b/crates/ecstore/src/cluster/rpc/peer_rest_client.rs @@ -113,6 +113,21 @@ fn peer_failure_without_details(op: &str, bucket: Option<&str>) -> Error { } } +/// Decode a control-plane response failure. Peers at or above the typed +/// `ControlPlaneErrorCode` change (backlog#1845) carry a machine-readable +/// discriminant beside the legacy `error_info` string; prefer it, then fall +/// back to the string, then to the detail-free per-op failure. +/// RUSTFS_COMPAT_TODO(not-initialized-error-code-v1): string fallback for peers that predate the typed wire code. Remove after the minimum supported RustFS peer version always sends error_code. +fn control_plane_failure(op: &str, bucket: Option<&str>, error_code: Option, error_info: Option) -> Error { + if error_code == Some(rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32) { + return Error::RemoteNotInitialized; + } + match error_info { + Some(msg) => Error::other(msg), + None => peer_failure_without_details(op, bucket), + } +} + fn decode_bucket_stats_response(response: GetBucketStatsDataResponse) -> Result { if !response.success { return Err(Error::other( @@ -845,10 +860,12 @@ impl PeerRestClient { let response = client.local_storage_info(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("local_storage_info", None)); + return Err(control_plane_failure( + "local_storage_info", + None, + response.error_code, + response.error_info, + )); } let data = response.storage_info; @@ -1235,11 +1252,10 @@ impl PeerRestClient { Err(status) => return Err(status.into()), }; if !response.success { - return Err(Error::other( - response - .error_info - .unwrap_or_else(|| "peer background heal status failed without an error".to_string()), - )); + return Err(match (response.error_code, response.error_info) { + (None, None) => Error::other("peer background heal status failed without an error"), + (error_code, error_info) => control_plane_failure("background_heal_status", None, error_code, error_info), + }); } Ok(Some(response.bg_heal_state.to_vec())) } @@ -1267,11 +1283,12 @@ impl PeerRestClient { Err(status) => return Err(status.into()), }; if !response.success { - return Err(Error::other( - response - .error_info - .unwrap_or_else(|| "peer replacement recovery status failed without an error".to_string()), - )); + return Err(match (response.error_code, response.error_info) { + (None, None) => Error::other("peer replacement recovery status failed without an error"), + (error_code, error_info) => { + control_plane_failure("replacement_recovery_status", None, error_code, error_info) + } + }); } Ok(Some(response.recovery_status.to_vec())) } @@ -1489,10 +1506,12 @@ impl PeerRestClient { let response = client.load_bucket_metadata(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("load_bucket_metadata", Some(bucket))); + return Err(control_plane_failure( + "load_bucket_metadata", + Some(bucket), + response.error_code, + response.error_info, + )); } Ok(()) } @@ -1531,10 +1550,7 @@ impl PeerRestClient { let response = client.delete_policy(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("delete_policy", None)); + return Err(control_plane_failure("delete_policy", None, response.error_code, response.error_info)); } Ok(()) } @@ -1554,10 +1570,7 @@ impl PeerRestClient { let response = client.load_policy(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("load_policy", None)); + return Err(control_plane_failure("load_policy", None, response.error_code, response.error_info)); } Ok(()) } @@ -1579,10 +1592,12 @@ impl PeerRestClient { let response = client.load_policy_mapping(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("load_policy_mapping", None)); + return Err(control_plane_failure( + "load_policy_mapping", + None, + response.error_code, + response.error_info, + )); } Ok(()) } @@ -1602,10 +1617,7 @@ impl PeerRestClient { let response = client.delete_user(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("delete_user", None)); + return Err(control_plane_failure("delete_user", None, response.error_code, response.error_info)); } Ok(()) } @@ -1625,10 +1637,12 @@ impl PeerRestClient { let response = client.delete_service_account(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("delete_service_account", None)); + return Err(control_plane_failure( + "delete_service_account", + None, + response.error_code, + response.error_info, + )); } Ok(()) } @@ -1649,10 +1663,7 @@ impl PeerRestClient { let response = client.load_user(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("load_user", None)); + return Err(control_plane_failure("load_user", None, response.error_code, response.error_info)); } Ok(()) } @@ -1672,10 +1683,12 @@ impl PeerRestClient { let response = client.load_service_account(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("load_service_account", None)); + return Err(control_plane_failure( + "load_service_account", + None, + response.error_code, + response.error_info, + )); } Ok(()) } @@ -1695,10 +1708,7 @@ impl PeerRestClient { let response = client.load_group(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("load_group", None)); + return Err(control_plane_failure("load_group", None, response.error_code, response.error_info)); } Ok(()) } @@ -1716,10 +1726,12 @@ impl PeerRestClient { let response = client.reload_site_replication_config(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("reload_site_replication_config", None)); + return Err(control_plane_failure( + "reload_site_replication_config", + None, + response.error_code, + response.error_info, + )); } Ok(()) } @@ -1987,10 +1999,7 @@ impl PeerRestClient { let response = client.reload_pool_meta(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("reload_pool_meta", None)); + return Err(control_plane_failure("reload_pool_meta", None, response.error_code, response.error_info)); } Ok(()) @@ -2011,10 +2020,7 @@ impl PeerRestClient { let response = client.stop_rebalance(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("stop_rebalance", None)); + return Err(control_plane_failure("stop_rebalance", None, response.error_code, response.error_info)); } Ok(()) @@ -2045,10 +2051,12 @@ impl PeerRestClient { "peer rebalance metadata response" ); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("load_rebalance_meta", None)); + return Err(control_plane_failure( + "load_rebalance_meta", + None, + response.error_code, + response.error_info, + )); } Ok(()) @@ -2073,10 +2081,12 @@ impl PeerRestClient { let response = client.start_decommission(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("start_decommission", None)); + return Err(control_plane_failure( + "start_decommission", + None, + response.error_code, + response.error_info, + )); } Ok(()) @@ -2097,10 +2107,12 @@ impl PeerRestClient { let response = client.cancel_decommission(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("decommission_cancel", None)); + return Err(control_plane_failure( + "decommission_cancel", + None, + response.error_code, + response.error_info, + )); } Ok(()) @@ -2121,10 +2133,12 @@ impl PeerRestClient { let response = client.clear_decommission(request).await?.into_inner(); if !response.success { - if let Some(msg) = response.error_info { - return Err(Error::other(msg)); - } - return Err(peer_failure_without_details("clear_decommission", None)); + return Err(control_plane_failure( + "clear_decommission", + None, + response.error_code, + response.error_info, + )); } Ok(()) @@ -2180,7 +2194,7 @@ impl PeerRestClient { Err(status) => return tier_config_reload_status_outcome(status), }; if !response.success { - return tier_config_reload_remote_failure(response.error_info); + return tier_config_reload_remote_failure(response.error_code, response.error_info); } TierConfigReloadOutcome::Success @@ -2239,7 +2253,13 @@ fn is_tier_config_reload_connection_failure(err: &Error) -> bool { /// reload every `TIER_CONFIG_RELOAD_RETRY_CAP`, and `Terminal` stays reachable /// for transport and gRPC status failures, which is where a genuinely /// unrecoverable peer surfaces. -fn tier_config_reload_remote_failure(error_info: Option) -> TierConfigReloadOutcome { +fn tier_config_reload_remote_failure(error_code: Option, error_info: Option) -> TierConfigReloadOutcome { + // Remote rejections are transient by design (see the doc comment above); + // the typed not-initialized code keeps the error typed for downstream + // classifiers instead of a bare string (backlog#1845). + if error_code == Some(rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32) { + return TierConfigReloadOutcome::TransientRetrySameChannel(Error::RemoteNotInitialized); + } TierConfigReloadOutcome::TransientRetrySameChannel(Error::other(error_info.unwrap_or_default())) } @@ -2278,6 +2298,63 @@ mod tests { use temp_env::async_with_vars; use tracing_subscriber::{Registry, fmt::MakeWriter, layer::SubscriberExt}; + #[test] + fn control_plane_failure_prefers_typed_not_initialized_code() { + use rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode; + let code = Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32); + + // Typed code wins even when the legacy string is present (dual-write). + let err = control_plane_failure("load_bucket_metadata", Some("b"), code, Some("errServerNotInitialized".to_string())); + assert!(matches!(err, Error::RemoteNotInitialized)); + assert!(crate::error::is_err_not_initialized(&err), "typed variant must satisfy the predicate"); + + // Legacy peers: no code, string only — the substring fallback still classifies. + let err = control_plane_failure("load_bucket_metadata", Some("b"), None, Some("errServerNotInitialized".to_string())); + assert!(crate::error::is_err_not_initialized(&err), "legacy string form must keep classifying"); + + // No code, no string: detail-free per-op failure, not misread as not-initialized. + let err = control_plane_failure("load_bucket_metadata", Some("b"), None, None); + assert!(!crate::error::is_err_not_initialized(&err)); + assert!(err.to_string().contains("load_bucket_metadata")); + + // Unspecified code behaves like no code. + let err = control_plane_failure( + "load_bucket_metadata", + None, + Some(ControlPlaneErrorCode::ControlPlaneErrorUnspecified as i32), + Some("boom".to_string()), + ); + assert!(!matches!(err, Error::RemoteNotInitialized)); + assert_eq!(err.to_string(), "Io error: boom"); + } + + #[test] + fn control_plane_not_initialized_wire_value_is_pinned() { + // The discriminant is wire contract: old peers ignore it, but a renumber + // would silently flip classification on mixed-version clusters. + use rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode; + assert_eq!(ControlPlaneErrorCode::ControlPlaneErrorUnspecified as i32, 0); + assert_eq!(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32, 1); + } + + #[test] + fn tier_config_reload_remote_failure_keeps_typed_not_initialized() { + use rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode; + let outcome = tier_config_reload_remote_failure( + Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), + Some("errServerNotInitialized".to_string()), + ); + match outcome { + TierConfigReloadOutcome::TransientRetrySameChannel(err) => { + assert!(matches!(err, Error::RemoteNotInitialized)); + } + TierConfigReloadOutcome::TransientReconnect(err) | TierConfigReloadOutcome::Terminal(err) => { + panic!("not-initialized must stay retry-same-channel, got {err}") + } + TierConfigReloadOutcome::Success => panic!("a rejection cannot classify as success"), + } + } + #[test] fn scanner_publication_lease_response_rejects_stale_generation_and_session() { let token = Uuid::new_v4(); @@ -3031,11 +3108,11 @@ mod tests { // retired: the channel is healthy, so the rejection reflects remote state // that the next attempt can find healed. assert!(matches!( - tier_config_reload_remote_failure(Some("backend unavailable".to_string())), + tier_config_reload_remote_failure(None, Some("backend unavailable".to_string())), TierConfigReloadOutcome::TransientRetrySameChannel(_) )); assert!(matches!( - tier_config_reload_remote_failure(Some("errServerNotInitialized".to_string())), + tier_config_reload_remote_failure(None, Some("errServerNotInitialized".to_string())), TierConfigReloadOutcome::TransientRetrySameChannel(_) )); assert!(matches!( @@ -3080,7 +3157,7 @@ mod tests { ] { assert!( matches!( - tier_config_reload_remote_failure(Some(error_info.to_string())), + tier_config_reload_remote_failure(None, Some(error_info.to_string())), TierConfigReloadOutcome::TransientRetrySameChannel(_) ), "a peer that rejected the apply must stay retryable so it converges: {error_info}" @@ -3089,7 +3166,7 @@ mod tests { // An absent error message is still a rejection, not a reason to stop. assert!(matches!( - tier_config_reload_remote_failure(None), + tier_config_reload_remote_failure(None, None), TierConfigReloadOutcome::TransientRetrySameChannel(_) )); diff --git a/crates/ecstore/src/cluster/rpc/peer_s3_client.rs b/crates/ecstore/src/cluster/rpc/peer_s3_client.rs index cbd944fb0..8e5ebcecc 100644 --- a/crates/ecstore/src/cluster/rpc/peer_s3_client.rs +++ b/crates/ecstore/src/cluster/rpc/peer_s3_client.rs @@ -36,7 +36,7 @@ use crate::{ }; use async_trait::async_trait; use futures::future::join_all; -use rustfs_common::heal_channel::{DriveState, HealItemType, HealOpts, RUSTFS_RESERVED_BUCKET}; +use rustfs_heal_contracts::heal_channel::{DriveState, HealItemType, HealOpts, RUSTFS_RESERVED_BUCKET}; use rustfs_madmin::heal_commands::{HealDriveInfo, HealResultItem}; use rustfs_protos::proto_gen::node_service::node_service_client::NodeServiceClient; use rustfs_protos::proto_gen::node_service::{ diff --git a/crates/ecstore/src/cluster/rpc/remote_disk.rs b/crates/ecstore/src/cluster/rpc/remote_disk.rs index 6410cf6d6..2116f7fb5 100644 --- a/crates/ecstore/src/cluster/rpc/remote_disk.rs +++ b/crates/ecstore/src/cluster/rpc/remote_disk.rs @@ -35,7 +35,11 @@ use crate::disk::{ health_state::{RuntimeDriveHealthState, get_drive_returning_probe_interval, record_drive_runtime_state}, validate_batch_read_version_item_count, }; -use crate::disk::{disk_store::DiskHealthTracker, error::DiskError, local::ScanGuard}; +use crate::disk::{ + disk_store::DiskHealthTracker, + error::{DiskError, is_terminal_read_error, terminal_read_error_to_io}, + local::ScanGuard, +}; use crate::set_disk::DEFAULT_READ_BUFFER_SIZE; use bytes::Bytes; use futures::lock::Mutex; @@ -324,6 +328,39 @@ where } } +/// Mark a terminal fresh-shard recovery failure for adaptive retirement while +/// retaining its typed `DiskError` and original I/O kind. The decoder checks +/// the marker independently of the kind because not-found and transport +/// failures are terminal too, but must not be reported as timeouts. +fn remote_read_error_to_io(error: DiskError) -> io::Error { + terminal_read_error_to_io(error) +} + +/// Retire a remote shard after its stream can no longer be trusted. A body +/// error that arrives after the one permitted resume is terminal: retaining +/// the reader would let the next stripe poll an already misaligned stream. +fn remote_terminal_io_error(error: io::Error) -> io::Error { + if is_terminal_read_error(&error) { + return error; + } + terminal_read_error_to_io(DiskError::from(error)) +} + +fn remote_terminal_message_to_io(message: &'static str) -> io::Error { + terminal_read_error_to_io(DiskError::Io(io::Error::other(message))) +} + +fn remote_terminal_eof_to_io() -> io::Error { + terminal_read_error_to_io(DiskError::Io(io::Error::new( + io::ErrorKind::UnexpectedEof, + "remote read ended before requested length", + ))) +} + +fn remote_terminal_task_error_to_io(error: JoinError) -> io::Error { + terminal_read_error_to_io(DiskError::other(error)) +} + struct AbortOnDropTask(JoinHandle); impl AbortOnDropTask { @@ -418,6 +455,9 @@ impl RetryingRemoteReader { impl AsyncRead for RetryingRemoteReader { fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + if buf.remaining() == 0 { + return Poll::Ready(Ok(())); + } loop { // After the absolute cutoff, let initial progress win over a stale fresh-open. let resume_pending = if let Some(resume) = self.resume.as_mut() { @@ -431,14 +471,14 @@ impl AsyncRead for RetryingRemoteReader { Poll::Ready(Ok(Err(error))) => { self.resume = None; if self.reader.is_none() { - return Poll::Ready(Err(io::Error::other(error))); + return Poll::Ready(Err(remote_read_error_to_io(error))); } continue; } Poll::Ready(Err(error)) => { self.resume = None; if self.reader.is_none() { - return Poll::Ready(Err(io::Error::other(error))); + return Poll::Ready(Err(remote_terminal_task_error_to_io(error))); } continue; } @@ -479,6 +519,9 @@ impl AsyncRead for RetryingRemoteReader { } else { self.resume = None; } + } else if produced == 0 && self.request.length != 0 && self.emitted < self.request.length { + self.reader = None; + return Poll::Ready(Err(remote_terminal_eof_to_io())); } return Poll::Ready(Ok(())); } @@ -494,7 +537,10 @@ impl AsyncRead for RetryingRemoteReader { self.reader = None; continue; } - Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Err(error)) => { + self.reader = None; + return Poll::Ready(Err(remote_terminal_io_error(error))); + } } } } @@ -585,21 +631,23 @@ impl rustfs_rio::ChunkReader for RetryingRemoteChunkReader { Poll::Ready(Ok(Ok(None))) => { self.resume = None; if self.reader.is_none() { - return Poll::Ready(Err(io::Error::other("remote resume transport did not provide a chunk reader"))); + return Poll::Ready(Err(remote_terminal_message_to_io( + "remote resume transport did not provide a chunk reader", + ))); } continue; } Poll::Ready(Ok(Err(error))) => { self.resume = None; if self.reader.is_none() { - return Poll::Ready(Err(io::Error::other(error))); + return Poll::Ready(Err(remote_read_error_to_io(error))); } continue; } Poll::Ready(Err(error)) => { self.resume = None; if self.reader.is_none() { - return Poll::Ready(Err(io::Error::other(error))); + return Poll::Ready(Err(remote_terminal_task_error_to_io(error))); } continue; } @@ -635,10 +683,26 @@ impl rustfs_rio::ChunkReader for RetryingRemoteChunkReader { return Poll::Ready(Ok(Some(chunk))); } Poll::Ready(Ok(None)) if resume_pending => { + // A clean EOF from the original stream wins when the + // request is unbounded (or has already emitted its full + // bounded length). Waiting for a speculative fresh open + // in that case can turn a successful read into a recovery + // timeout, especially on the read_file/unbounded path. + if self.request.length == 0 || self.emitted >= self.request.length { + self.reader = None; + self.resume = None; + return Poll::Ready(Ok(None)); + } self.reader = None; continue; } - Poll::Ready(Ok(None)) => return Poll::Ready(Ok(None)), + Poll::Ready(Ok(None)) => { + if self.request.length != 0 && self.emitted < self.request.length { + self.reader = None; + return Poll::Ready(Err(remote_terminal_eof_to_io())); + } + return Poll::Ready(Ok(None)); + } Poll::Ready(Err(error)) if !self.retried && is_retryable_remote_body_error(&error) => { self.retried = true; self.reader = None; @@ -651,7 +715,10 @@ impl rustfs_rio::ChunkReader for RetryingRemoteChunkReader { self.reader = None; continue; } - Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Err(error)) => { + self.reader = None; + return Poll::Ready(Err(remote_terminal_io_error(error))); + } } } } @@ -5154,6 +5221,7 @@ mod tests { enum ResumeReadStep { PartialThenReset(Vec), Data(Vec), + Eof, } #[derive(Debug, Default)] @@ -5412,6 +5480,7 @@ mod tests { struct PendingFreshOpenTransport { fresh_read_drops: Arc, fresh_chunk_drops: Arc, + initial_chunk_eof: bool, } #[async_trait::async_trait] @@ -5429,6 +5498,9 @@ mod tests { } async fn open_read_chunks(&self, _request: ReadStreamRequest) -> Result> { + if self.initial_chunk_eof { + return Ok(Some(resume_step_chunk_reader(ResumeReadStep::Eof))); + } Ok(Some(Box::new(ChunkPartialThenErrorReader { data: None, error: Some(io::Error::new(std_io::ErrorKind::ConnectionReset, "stream reset")), @@ -5457,6 +5529,70 @@ mod tests { } } + #[derive(Debug)] + struct TerminalFreshOpenTransport { + fresh_read_opens: Arc, + fresh_chunk_opens: Arc, + chunk_returns_none: bool, + } + + impl TerminalFreshOpenTransport { + fn new(chunk_returns_none: bool) -> Self { + Self { + fresh_read_opens: Arc::new(AtomicUsize::new(0)), + fresh_chunk_opens: Arc::new(AtomicUsize::new(0)), + chunk_returns_none, + } + } + } + + #[async_trait::async_trait] + impl InternodeDataTransport for TerminalFreshOpenTransport { + async fn open_read(&self, _request: ReadStreamRequest) -> Result { + Ok(Box::new(PartialThenErrorReader { + cursor: Cursor::new(Vec::new()), + error: Some(io::Error::new(std_io::ErrorKind::ConnectionReset, "stream reset")), + })) + } + + async fn open_read_fresh(&self, _request: ReadStreamRequest) -> Result { + self.fresh_read_opens.fetch_add(1, Ordering::Relaxed); + Err(DiskError::FileNotFound) + } + + async fn open_read_chunks(&self, _request: ReadStreamRequest) -> Result> { + Ok(Some(Box::new(ChunkPartialThenErrorReader { + data: Some(Bytes::from_static(b"x")), + error: Some(io::Error::new(std_io::ErrorKind::ConnectionReset, "stream reset")), + }))) + } + + async fn open_read_chunks_fresh(&self, _request: ReadStreamRequest) -> Result> { + self.fresh_chunk_opens.fetch_add(1, Ordering::Relaxed); + if self.chunk_returns_none { + Ok(None) + } else { + Err(DiskError::FileNotFound) + } + } + + async fn open_write(&self, _request: WriteStreamRequest) -> Result { + panic!("open_write should not be used in terminal fresh-open tests"); + } + + async fn open_walk_dir(&self, _request: WalkDirStreamRequest) -> Result { + panic!("open_walk_dir should not be used in terminal fresh-open tests"); + } + + fn name(&self) -> &'static str { + "terminal-fresh-open-test" + } + + fn capabilities(&self) -> InternodeDataTransportCapabilities { + InternodeDataTransportCapabilities::tcp_http() + } + } + fn resume_step_reader(step: ResumeReadStep) -> FileReader { match step { ResumeReadStep::PartialThenReset(data) => Box::new(PartialThenErrorReader { @@ -5464,6 +5600,7 @@ mod tests { error: Some(io::Error::new(std_io::ErrorKind::ConnectionReset, "stream reset")), }), ResumeReadStep::Data(data) => Box::new(Cursor::new(data)), + ResumeReadStep::Eof => Box::new(Cursor::new(Vec::new())), } } @@ -5477,6 +5614,7 @@ mod tests { data: Some(Bytes::from(data)), error: None, }), + ResumeReadStep::Eof => Box::new(ChunkPartialThenErrorReader { data: None, error: None }), } } @@ -5553,6 +5691,430 @@ mod tests { } } + fn partial_hashed_shard(shard_size: usize) -> (rustfs_utils::HashAlgorithm, Vec, usize) { + let checksum = rustfs_utils::HashAlgorithm::HighwayHash256S; + let data = vec![0x5a; shard_size]; + let hash_bytes = { + let hash = checksum.hash_encode(&data); + hash.as_ref().to_vec() + }; + let hash_len = hash_bytes.len(); + let encoded_length = hash_len + data.len(); + let mut prefix = Vec::with_capacity(hash_len + shard_size / 2); + prefix.extend_from_slice(&hash_bytes); + prefix.extend_from_slice(&data[..shard_size / 2]); + (checksum, prefix, encoded_length) + } + + #[test] + fn remote_read_error_conversion_preserves_recovery_classification() { + for disk_error in [DiskError::Timeout, DiskError::SourceStalled] { + let error = remote_read_error_to_io(disk_error); + assert_eq!(error.kind(), std_io::ErrorKind::TimedOut); + } + + let error = remote_read_error_to_io(DiskError::Timeout); + assert!( + error + .get_ref() + .and_then(|source| source.downcast_ref::()) + .is_some() + ); + assert!(matches!(DiskError::from(error), DiskError::Timeout)); + + let error = remote_read_error_to_io(DiskError::SourceStalled); + assert!(matches!(DiskError::from(error), DiskError::SourceStalled)); + + let error = + remote_read_error_to_io(DiskError::Io(io::Error::new(std_io::ErrorKind::ConnectionReset, "connection reset"))); + assert_eq!(error.kind(), std_io::ErrorKind::ConnectionReset); + assert!(crate::disk::error::is_terminal_read_error(&error)); + assert!(matches!(DiskError::from(error), DiskError::Io(inner) if inner.kind() == std_io::ErrorKind::ConnectionReset)); + } + + #[test] + fn remote_reader_zero_capacity_poll_is_a_noop() { + let transport: Arc = Arc::new(PendingFreshOpenTransport::default()); + let mut reader = RetryingRemoteReader::new_with_timeouts( + Box::new(Cursor::new(b"x".to_vec())), + transport, + resume_request(1), + None, + None, + ); + let mut empty = []; + let mut read_buf = ReadBuf::new(&mut empty); + let mut cx = Context::from_waker(std::task::Waker::noop()); + + assert!(matches!(Pin::new(&mut reader).poll_read(&mut cx, &mut read_buf), Poll::Ready(Ok(())))); + assert!(reader.reader.is_some(), "zero-capacity polls must not retire the remote reader"); + + let mut output = Vec::new(); + futures::executor::block_on(reader.read_to_end(&mut output)).expect("the reader should remain usable"); + assert_eq!(output, b"x"); + } + + #[tokio::test(start_paused = true)] + async fn remote_reader_fresh_open_timeout_preserves_timed_out_kind() { + let transport = Arc::new(PendingFreshOpenTransport::default()); + let transport_for_reader: Arc = transport.clone(); + let mut reader = RetryingRemoteReader::new_with_timeouts( + resume_step_reader(ResumeReadStep::PartialThenReset(Vec::new())), + transport_for_reader, + resume_request(1), + None, + Some(Duration::from_secs(1)), + ); + + let error = reader + .read_to_end(&mut Vec::new()) + .await + .expect_err("a hung fresh open must surface its recovery timeout"); + + assert_eq!(error.kind(), std_io::ErrorKind::TimedOut); + assert!( + error + .get_ref() + .and_then(|source| source.downcast_ref::()) + .is_some() + ); + assert!(matches!(DiskError::from(error), DiskError::Timeout)); + assert_eq!(transport.fresh_read_drops.load(Ordering::Relaxed), 1); + } + + #[tokio::test(start_paused = true)] + async fn remote_chunk_reader_fresh_open_timeout_preserves_timed_out_kind() { + let transport = Arc::new(PendingFreshOpenTransport::default()); + let transport_for_reader: Arc = transport.clone(); + let mut reader = RetryingRemoteChunkReader::new_with_timeouts( + resume_step_chunk_reader(ResumeReadStep::PartialThenReset(b"x".to_vec())), + transport_for_reader, + resume_request(2), + None, + Some(Duration::from_secs(1)), + ); + + let error = reader + .read_to_end(&mut Vec::new()) + .await + .expect_err("a hung fresh chunk open must surface its recovery timeout"); + + assert_eq!(error.kind(), std_io::ErrorKind::TimedOut); + assert!( + error + .get_ref() + .and_then(|source| source.downcast_ref::()) + .is_some() + ); + assert!(matches!(DiskError::from(error), DiskError::Timeout)); + assert_eq!(transport.fresh_chunk_drops.load(Ordering::Relaxed), 1); + } + + #[tokio::test(start_paused = true)] + async fn remote_chunk_reader_unbounded_clean_eof_wins_over_speculative_resume() { + let transport = Arc::new(PendingFreshOpenTransport { + initial_chunk_eof: true, + ..PendingFreshOpenTransport::default() + }); + let transport_for_reader: Arc = transport.clone(); + let mut reader = RetryingRemoteChunkReader::new_with_timeouts( + resume_step_chunk_reader(ResumeReadStep::Eof), + transport_for_reader, + resume_request(0), + Some(Duration::ZERO), + Some(Duration::from_secs(1)), + ); + + let mut output = Vec::new(); + reader + .read_to_end(&mut output) + .await + .expect("clean EOF from an unbounded original stream should finish the read"); + assert!(output.is_empty()); + // The executor may abort the speculative task before it is first + // polled, in which case the pending-open future never constructs its + // drop probe. The dedicated drop-cancellation tests cover the + // already-polled case; this regression only needs to establish that a + // clean unbounded EOF is not converted into a recovery timeout. + } + + #[tokio::test(start_paused = true)] + async fn remote_reader_fresh_open_non_timeout_error_is_retired_from_adaptive_decode() { + let transport = Arc::new(TerminalFreshOpenTransport::new(false)); + let transport_for_reader: Arc = transport.clone(); + let retry = RetryingRemoteReader::new_with_timeouts( + resume_step_reader(ResumeReadStep::PartialThenReset(Vec::new())), + transport_for_reader, + resume_request(8), + None, + Some(Duration::from_secs(1)), + ); + let shard = BitrotReader::new(ShardReader::Stream(Box::new(retry)), 8, rustfs_utils::HashAlgorithm::None, false); + let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 8), 0, 16); + + let (_, first_errors) = parallel.read().await; + assert!(matches!(first_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound))); + assert_eq!(transport.fresh_read_opens.load(Ordering::Relaxed), 1); + + let (_, second_errors) = parallel.read().await; + assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound))); + assert_eq!(transport.fresh_read_opens.load(Ordering::Relaxed), 1); + } + + #[tokio::test(start_paused = true)] + async fn remote_chunk_reader_missing_fresh_reader_is_retired_from_adaptive_decode() { + let transport = Arc::new(TerminalFreshOpenTransport::new(true)); + let transport_for_reader: Arc = transport.clone(); + let retry = RetryingRemoteChunkReader::new_with_timeouts( + resume_step_chunk_reader(ResumeReadStep::PartialThenReset(b"x".to_vec())), + transport_for_reader, + resume_request(8), + None, + Some(Duration::from_secs(1)), + ); + let shard = BitrotReader::new(ShardReader::Chunked(Box::new(retry)), 8, rustfs_utils::HashAlgorithm::None, false); + let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 8), 0, 16); + + let (_, first_errors) = parallel.read().await; + assert!( + matches!(first_errors.first().and_then(Option::as_ref), Some(DiskError::Io(error)) if error.kind() == std_io::ErrorKind::Other), + "unexpected first errors: {first_errors:?}" + ); + assert_eq!(transport.fresh_chunk_opens.load(Ordering::Relaxed), 1); + + let (_, second_errors) = parallel.read().await; + assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound))); + assert_eq!(transport.fresh_chunk_opens.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn remote_reader_fresh_open_short_eof_is_retired_from_adaptive_decode() { + // A successful fresh open can still end before the bounded request. + // Treat that as terminal immediately so the next stripe does not poll + // an already exhausted reader and defer the failure to BitrotReader. + let transport = Arc::new(ResumeTransport::with_read_steps(vec![ResumeReadStep::Eof])); + let transport_for_reader: Arc = transport.clone(); + let retry = RetryingRemoteReader::new_with_timeouts( + resume_step_reader(ResumeReadStep::PartialThenReset(b"01".to_vec())), + transport_for_reader, + resume_request(7), + None, + None, + ); + let shard = BitrotReader::new(ShardReader::Stream(Box::new(retry)), 7, rustfs_utils::HashAlgorithm::None, false); + let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 7), 0, 14); + + let (_, first_errors) = parallel.read().await; + assert!(matches!( + first_errors.first().and_then(Option::as_ref), + Some(DiskError::Io(error)) if error.kind() == std_io::ErrorKind::UnexpectedEof + )); + assert_eq!( + transport + .fresh_read_requests + .lock() + .expect("fresh read request lock should not be poisoned") + .len(), + 1 + ); + + let (_, second_errors) = parallel.read().await; + assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound))); + assert_eq!( + transport + .fresh_read_requests + .lock() + .expect("fresh read request lock should not be poisoned") + .len(), + 1 + ); + } + + #[tokio::test] + async fn remote_chunk_reader_fresh_open_short_eof_is_retired_from_adaptive_decode() { + let transport = Arc::new(ResumeTransport::with_chunk_steps(vec![ResumeReadStep::Eof])); + let transport_for_reader: Arc = transport.clone(); + let retry = RetryingRemoteChunkReader::new_with_timeouts( + resume_step_chunk_reader(ResumeReadStep::PartialThenReset(b"01".to_vec())), + transport_for_reader, + resume_request(7), + None, + None, + ); + let shard = BitrotReader::new(ShardReader::Chunked(Box::new(retry)), 7, rustfs_utils::HashAlgorithm::None, false); + let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 7), 0, 14); + + let (_, first_errors) = parallel.read().await; + assert!(matches!( + first_errors.first().and_then(Option::as_ref), + Some(DiskError::Io(error)) if error.kind() == std_io::ErrorKind::UnexpectedEof + )); + assert_eq!( + transport + .fresh_chunk_requests + .lock() + .expect("fresh chunk request lock should not be poisoned") + .len(), + 1 + ); + + let (_, second_errors) = parallel.read().await; + assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound))); + assert_eq!( + transport + .fresh_chunk_requests + .lock() + .expect("fresh chunk request lock should not be poisoned") + .len(), + 1 + ); + } + + #[tokio::test] + async fn remote_reader_second_body_reset_is_retired_from_adaptive_decode() { + // The first connection emits a prefix, the one permitted fresh + // connection emits another prefix, and then resets again. The second + // reset must retire the reader so the next stripe cannot consume a + // misaligned stream. + let transport = Arc::new(ResumeTransport::with_read_steps(vec![ResumeReadStep::PartialThenReset(b"23".to_vec())])); + let transport_for_reader: Arc = transport.clone(); + let retry = RetryingRemoteReader::new_with_timeouts( + resume_step_reader(ResumeReadStep::PartialThenReset(b"01".to_vec())), + transport_for_reader, + resume_request(7), + None, + None, + ); + let shard = BitrotReader::new(ShardReader::Stream(Box::new(retry)), 7, rustfs_utils::HashAlgorithm::None, false); + let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 7), 0, 14); + + let (_, first_errors) = parallel.read().await; + assert!(matches!( + first_errors.first().and_then(Option::as_ref), + Some(DiskError::Io(error)) if error.kind() == std_io::ErrorKind::ConnectionReset + )); + assert_eq!( + transport + .fresh_read_requests + .lock() + .expect("fresh read request lock should not be poisoned") + .len(), + 1 + ); + + let (_, second_errors) = parallel.read().await; + assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound))); + assert_eq!( + transport + .fresh_read_requests + .lock() + .expect("fresh read request lock should not be poisoned") + .len(), + 1 + ); + } + + #[tokio::test] + async fn remote_chunk_reader_second_body_reset_is_retired_from_adaptive_decode() { + let transport = Arc::new(ResumeTransport::with_chunk_steps(vec![ResumeReadStep::PartialThenReset(b"23".to_vec())])); + let transport_for_reader: Arc = transport.clone(); + let retry = RetryingRemoteChunkReader::new_with_timeouts( + resume_step_chunk_reader(ResumeReadStep::PartialThenReset(b"01".to_vec())), + transport_for_reader, + resume_request(7), + None, + None, + ); + let shard = BitrotReader::new(ShardReader::Chunked(Box::new(retry)), 7, rustfs_utils::HashAlgorithm::None, false); + let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 7), 0, 14); + + let (_, first_errors) = parallel.read().await; + assert!(matches!( + first_errors.first().and_then(Option::as_ref), + Some(DiskError::Io(error)) if error.kind() == std_io::ErrorKind::ConnectionReset + )); + assert_eq!( + transport + .fresh_chunk_requests + .lock() + .expect("fresh chunk request lock should not be poisoned") + .len(), + 1 + ); + + let (_, second_errors) = parallel.read().await; + assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound))); + assert_eq!( + transport + .fresh_chunk_requests + .lock() + .expect("fresh chunk request lock should not be poisoned") + .len(), + 1 + ); + } + + #[tokio::test(start_paused = true)] + #[serial] + async fn remote_reader_hashed_timeout_is_retired_from_adaptive_decode() { + temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_DISK_READ_TIMEOUT, Some("5"))], async { + const SHARD_SIZE: usize = 64; + let (checksum, encoded_prefix, encoded_length) = partial_hashed_shard(SHARD_SIZE); + let transport = Arc::new(PendingFreshOpenTransport::default()); + let transport_for_reader: Arc = transport.clone(); + let retry = RetryingRemoteReader::new_with_timeouts( + resume_step_reader(ResumeReadStep::PartialThenReset(encoded_prefix)), + transport_for_reader, + resume_request(encoded_length), + None, + Some(Duration::from_secs(1)), + ); + let shard = BitrotReader::new(ShardReader::Stream(Box::new(retry)), SHARD_SIZE, checksum, false); + let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, SHARD_SIZE), 0, SHARD_SIZE * 2); + + let (first_buffers, first_errors) = parallel.read().await; + assert!(first_buffers.first().and_then(Option::as_ref).is_none()); + assert!(matches!(first_errors.first().and_then(Option::as_ref), Some(DiskError::Timeout))); + assert_eq!(transport.fresh_read_drops.load(Ordering::Relaxed), 1); + + // A TimedOut error retires the dead slot. The next stripe therefore + // reports the slot as unavailable instead of polling a reader whose + // fresh connection already timed out. + let (_, second_errors) = parallel.read().await; + assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound))); + }) + .await; + } + + #[tokio::test(start_paused = true)] + #[serial] + async fn remote_chunk_reader_hashed_timeout_is_retired_from_adaptive_decode() { + temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_DISK_READ_TIMEOUT, Some("5"))], async { + const SHARD_SIZE: usize = 64; + let (checksum, encoded_prefix, encoded_length) = partial_hashed_shard(SHARD_SIZE); + let transport = Arc::new(PendingFreshOpenTransport::default()); + let transport_for_reader: Arc = transport.clone(); + let retry = RetryingRemoteChunkReader::new_with_timeouts( + resume_step_chunk_reader(ResumeReadStep::PartialThenReset(encoded_prefix)), + transport_for_reader, + resume_request(encoded_length), + None, + Some(Duration::from_secs(1)), + ); + let shard = BitrotReader::new(ShardReader::Chunked(Box::new(retry)), SHARD_SIZE, checksum, false); + let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, SHARD_SIZE), 0, SHARD_SIZE * 2); + + let (first_buffers, first_errors) = parallel.read().await; + assert!(first_buffers.first().and_then(Option::as_ref).is_none()); + assert!(matches!(first_errors.first().and_then(Option::as_ref), Some(DiskError::Timeout))); + assert_eq!(transport.fresh_chunk_drops.load(Ordering::Relaxed), 1); + + let (_, second_errors) = parallel.read().await; + assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound))); + }) + .await; + } + #[tokio::test] async fn remote_reader_resumes_from_emitted_bytes_without_duplicates() { let transport = Arc::new(ResumeTransport::with_read_steps(vec![ResumeReadStep::Data(b"456789".to_vec())])); diff --git a/crates/ecstore/src/config/com.rs b/crates/ecstore/src/config/com.rs index 98409f513..6039b0368 100644 --- a/crates/ecstore/src/config/com.rs +++ b/crates/ecstore/src/config/com.rs @@ -27,7 +27,6 @@ use crate::storage_api_contracts::{ }; use crate::store::ECStore; use http::HeaderMap; -use rustfs_common::heal_channel::{HealOpts, HealScanMode}; use rustfs_config::audit::{ AUDIT_AMQP_KEYS, AUDIT_AMQP_SUB_SYS, AUDIT_KAFKA_KEYS, AUDIT_KAFKA_SUB_SYS, AUDIT_MQTT_KEYS, AUDIT_MQTT_SUB_SYS, AUDIT_MYSQL_KEYS, AUDIT_MYSQL_SUB_SYS, AUDIT_NATS_KEYS, AUDIT_NATS_SUB_SYS, AUDIT_POSTGRES_KEYS, AUDIT_POSTGRES_SUB_SYS, @@ -46,8 +45,10 @@ use rustfs_config::{ SCANNER_SUB_SYS, }; use rustfs_filemeta::FileInfo; +use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode}; use serde_json::{Map, Value}; use std::collections::{HashMap, HashSet}; +use std::hash::{DefaultHasher, Hash, Hasher}; use std::sync::LazyLock; use std::sync::{Arc, RwLock}; use tokio::io::AsyncReadExt; @@ -168,6 +169,7 @@ const EVENT_SERVER_CONFIG_READ_FAILED: &str = "server_config_read_failed"; const EVENT_SERVER_CONFIG_HEAL_RESULT: &str = "server_config_heal_result"; const EVENT_SERVER_CONFIG_RECOVERED: &str = "server_config_recovered_after_heal"; const EVENT_SERVER_CONFIG_FALLBACK: &str = "server_config_corruption_fallback"; +const EVENT_SERVER_CONFIG_SCALAR_SECTION_IGNORED: &str = "server_config_scalar_section_ignored"; fn config_corruption_recovery_enabled() -> bool { rustfs_utils::get_env_bool(ENV_CONFIG_RECOVER_ON_CORRUPTION, DEFAULT_CONFIG_RECOVER_ON_CORRUPTION) @@ -218,6 +220,55 @@ fn server_config_decrypt_fn() -> Option { SERVER_CONFIG_DECRYPT_FN.read().ok().and_then(|guard| guard.clone()) } +/// Dedup set for [`warn_ignored_scalar_section`], keyed by (subsystem, value) +/// hash. The persisted config is re-read on a short interval (event-notifier +/// reconcile runs every 5s), so an undeduplicated warn would flood the log with +/// one line per cycle for the same unchanged remnant. +static IGNORED_SCALAR_SECTION_WARNED: LazyLock>> = LazyLock::new(|| RwLock::new(HashSet::new())); +const IGNORED_SCALAR_SECTION_WARNED_CAP: usize = 64; + +fn should_warn_ignored_scalar_section(subsystem_key: &str, config_value: &Value) -> bool { + let mut hasher = DefaultHasher::new(); + subsystem_key.hash(&mut hasher); + config_value.to_string().hash(&mut hasher); + let digest = hasher.finish(); + + let Ok(mut seen) = IGNORED_SCALAR_SECTION_WARNED.write() else { + return true; + }; + if seen.contains(&digest) { + return false; + } + if seen.len() >= IGNORED_SCALAR_SECTION_WARNED_CAP { + seen.clear(); + } + seen.insert(digest); + true +} + +fn warn_ignored_scalar_section(subsystem_key: &str, config_value: &Value) { + if !should_warn_ignored_scalar_section(subsystem_key, config_value) { + return; + } + let mut rendered = config_value.to_string(); + if rendered.len() > 128 { + let mut cut = 128; + while !rendered.is_char_boundary(cut) { + cut -= 1; + } + rendered.truncate(cut); + rendered.push('…'); + } + warn!( + event = EVENT_SERVER_CONFIG_SCALAR_SECTION_IGNORED, + component = LOG_COMPONENT_CONFIG, + subsystem = LOG_SUBSYSTEM_CONFIG, + config_subsystem = subsystem_key, + ignored_value = %rendered, + "Ignoring persisted {subsystem_key} config with a legacy scalar shape; it carries no decodable settings and is dropped on the next config save" + ); +} + #[cfg(test)] fn replace_server_config_decrypt_fn_for_test(decrypt_fn: Option) -> Option { SERVER_CONFIG_DECRYPT_FN @@ -980,10 +1031,20 @@ fn decode_scalar_config_value(config_value: &Value, descriptor: ScalarConfigDesc } Ok(overrides) } - _ => Err(Error::other(format!( - "invalid external {} config shape: expected an object or KVS array", - descriptor.subsystem_key - ))), + // Scalar and null section shapes (`"heal": ""`, `"scanner": null`, + // `"heal": {"default": null}`) are legacy remnants that carry no + // decodable settings; failing the whole config decode over them bricks + // every consumer of the persisted config (startup falls back to the + // default config, the admin config API cannot read-modify-write, and + // the notify reconciler retries forever). Ignore them with a deduped + // warning instead; the next config save scrubs them (see + // `normalize_scalar_section_seed`). Malformed values inside object or + // KVS-array shapes stay hard errors above — those shapes are where + // data (including fields from newer versions) can live. + _ => { + warn_ignored_scalar_section(descriptor.subsystem_key, config_value); + Ok(KVS::new()) + } } } @@ -1192,6 +1253,44 @@ fn decode_server_config_blob(data: &[u8]) -> Result { Ok(cfg) } +/// True when a persisted scanner/heal section value is a shape the decoder +/// warn-ignores instead of decoding: a bare scalar or null, or an object whose +/// nested `default`/`_` member is such a shape. Object and KVS-array shapes +/// with decodable structure are never ignorable — they are where data +/// (including fields written by newer versions) can live. +fn scalar_section_shape_is_ignorable(value: &Value) -> bool { + match value { + Value::Object(config_obj) => config_obj + .get("default") + .or_else(|| config_obj.get(DEFAULT_DELIMITER)) + .is_some_and(scalar_section_shape_is_ignorable), + Value::Array(_) => false, + _ => true, + } +} + +/// Strip warn-ignored scalar section shapes (see +/// [`scalar_section_shape_is_ignorable`]) from a scanner/heal seed value so the +/// canonical rewrite drops the remnant instead of preserving it verbatim, which +/// would keep tripping the decoder on every future read. +fn normalize_scalar_section_seed(existing: Option) -> Option { + match existing? { + Value::Object(mut config_obj) => { + for nested_key in ["default", DEFAULT_DELIMITER] { + if config_obj.get(nested_key).is_some_and(scalar_section_shape_is_ignorable) { + let nested = config_obj.remove(nested_key); + if let Some(normalized) = normalize_scalar_section_seed(nested) { + config_obj.insert(nested_key.to_string(), normalized); + } + } + } + (!config_obj.is_empty()).then_some(Value::Object(config_obj)) + } + value if scalar_section_shape_is_ignorable(&value) => None, + value => Some(value), + } +} + fn parse_object_seed(data: &[u8]) -> Option> { let value: Value = serde_json::from_slice(data).ok()?; value.as_object().cloned() @@ -1811,10 +1910,7 @@ fn encode_server_config_blob(cfg: &Config, seed: Option<&[u8]>) -> Result bool { matches!(root.get("version"), Some(Value::String(v)) if !v.trim().is_empty()) && matches!(root.get("storageclass"), Some(Value::Object(_))) && !root.contains_key("storage_class") - && !matches!(root.get(HEAL_SUB_SYS), Some(Value::Null)) + && !matches!(root.get(HEAL_SUB_SYS), Some(value) if scalar_section_shape_is_ignorable(value)) + && !matches!(root.get(SCANNER_SUB_SYS), Some(value) if scalar_section_shape_is_ignorable(value)) } fn configs_semantically_equal(lhs: &Config, rhs: &Config) -> bool { @@ -2701,7 +2798,7 @@ mod tests { lookup_configs, new_and_save_server_config, read_config, read_config_no_lock_preserve_empty_with_metadata, read_config_preserve_empty, read_config_with_metadata, read_config_without_migrate, read_server_config_snapshot, save_server_config, save_server_config_snapshot, save_server_config_snapshot_with_generation, - server_config_transaction_lock_path, storage_class_kvs_mut, + server_config_transaction_lock_path, should_warn_ignored_scalar_section, storage_class_kvs_mut, }; use crate::config::{audit, heal, notify, oidc, scanner}; use crate::disk::endpoint::Endpoint; @@ -3550,28 +3647,98 @@ mod tests { } #[test] - fn invalid_scalar_and_nested_null_config_shapes_remain_rejected() { + fn legacy_scalar_section_shapes_decode_as_no_override_and_canonicalize_on_save() { + let base = decode_server_config_blob(br#"{"version":"33","storageclass":{"standard":"","rrs":""}}"#) + .expect("base config should decode"); + + let ignorable_sections = [ + (SCANNER_SUB_SYS, r#""scanner":null"#), + (SCANNER_SUB_SYS, r#""scanner":"cycle=61""#), + (HEAL_SUB_SYS, r#""heal":"""#), + (HEAL_SUB_SYS, r#""heal":false"#), + (HEAL_SUB_SYS, r#""heal":0"#), + (HEAL_SUB_SYS, r#""heal":{"default":null}"#), + (HEAL_SUB_SYS, r#""heal":{"_":null}"#), + ]; + + for (section_key, section) in ignorable_sections { + let input = format!(r#"{{"version":"33","storageclass":{{"standard":"","rrs":""}},{section}}}"#); + let cfg = decode_server_config_blob(input.as_bytes()) + .unwrap_or_else(|err| panic!("legacy scalar section {section} should be ignored, got: {err}")); + assert_eq!(cfg, base, "ignored section {section} must contribute no overrides"); + assert!( + !is_standard_object_server_config(input.as_bytes()), + "seed with {section} must not count as standard so a save rewrites it" + ); + + let encoded = + encode_server_config_blob(&cfg, Some(input.as_bytes())).expect("legacy seed should canonicalize on save"); + let value: Value = serde_json::from_slice(&encoded).expect("canonical config should be valid JSON"); + assert!( + value.get(section_key).is_none(), + "canonical save must scrub the {section} remnant, got: {value}" + ); + assert!(is_standard_object_server_config(&encoded)); + decode_server_config_blob(&encoded).expect("canonicalized config must decode cleanly"); + } + } + + #[test] + fn scrubbing_ignorable_nested_member_preserves_unknown_section_fields() { + let input = br#"{"version":"33","storageclass":{"standard":"","rrs":""},"heal":{"_":null,"future_flag":"keep"}}"#; + + let cfg = decode_server_config_blob(input).expect("ignorable nested member should not fail decode"); + assert!(!is_standard_object_server_config(input)); + + let encoded = encode_server_config_blob(&cfg, Some(input)).expect("seed should canonicalize on save"); + let value: Value = serde_json::from_slice(&encoded).expect("canonical config should be valid JSON"); + assert!(value[HEAL_SUB_SYS].get(DEFAULT_DELIMITER).is_none(), "null member must be scrubbed"); + assert_eq!( + value[HEAL_SUB_SYS]["future_flag"].as_str(), + Some("keep"), + "unknown section fields must survive the scrub" + ); + assert!(is_standard_object_server_config(&encoded)); + decode_server_config_blob(&encoded).expect("canonicalized config must decode cleanly"); + } + + #[test] + fn value_level_scalar_config_errors_remain_rejected() { let invalid_sections = [ - r#""scanner":null"#, - r#""heal":"""#, - r#""heal":false"#, - r#""heal":0"#, - r#""heal":{"default":null}"#, - r#""heal":{"_":null}"#, r#""heal":{"bitrot_cycle":null}"#, r#""heal":[{"key":"bitrot_cycle","value":null}]"#, ]; for section in invalid_sections { let input = format!(r#"{{"version":"33","storageclass":{{"standard":"","rrs":""}},{section}}}"#); - let err = decode_server_config_blob(input.as_bytes()).expect_err("invalid scalar shape must remain rejected"); + let err = decode_server_config_blob(input.as_bytes()) + .expect_err("value-level errors inside object/array shapes must remain rejected"); assert!( - err.to_string().contains("expected"), + err.to_string().contains("expected a scalar"), "invalid section {section} returned an unrelated error: {err}" ); } } + #[test] + fn ignored_scalar_sections_do_not_defeat_unrecognized_config_detection() { + let err = decode_server_config_blob(br#"{"scanner":"","heal":null}"#) + .expect_err("a config with only ignorable sections and no recognized header is still unrecognized"); + assert!( + err.to_string().contains("unrecognized external server config shape"), + "unexpected error: {err}" + ); + } + + #[test] + fn ignored_scalar_section_warning_dedups_by_content() { + let value = Value::String("dedup-probe".to_string()); + assert!(should_warn_ignored_scalar_section("dedup-test-subsystem", &value)); + assert!(!should_warn_ignored_scalar_section("dedup-test-subsystem", &value)); + let changed = Value::String("dedup-probe-changed".to_string()); + assert!(should_warn_ignored_scalar_section("dedup-test-subsystem", &changed)); + } + #[test] fn valid_heal_object_and_kvs_array_shapes_remain_accepted() { let empty_object = br#"{"version":"33","storageclass":{"standard":"","rrs":""},"heal":{}}"#; @@ -4018,18 +4185,6 @@ mod tests { ); } - #[test] - fn test_decode_rejects_scalar_scanner_section() { - let err = decode_server_config_blob(br#"{"version":"33","scanner":"cycle=61"}"#) - .expect_err("scalar scanner sections should be rejected"); - - assert!( - err.to_string() - .contains("invalid external scanner config shape: expected an object or KVS array"), - "unexpected scanner decode error: {err}" - ); - } - #[test] fn test_decode_rejects_malformed_known_scanner_kvs_value() { let err = decode_server_config_blob(br#"{"scanner":[{"key":"cycle","value":{"seconds":61}}]}"#) @@ -4710,7 +4865,7 @@ mod tests { decode_persisted_server_config, fallback_server_config_after_corruption, is_server_config_corrupt_error, read_config_without_migrate_with_recovery, replace_server_config_decrypt_fn_for_test, }; - use rustfs_common::heal_channel::HealOpts; + use rustfs_heal_contracts::heal_channel::HealOpts; use std::sync::Mutex; /// Bytes mirroring issue #4156: a bitrot-corrupted `config.json` whose @@ -5065,7 +5220,7 @@ mod tests { #[tokio::test] async fn server_config_snapshot_serializes_read_modify_write_transactions() { let baseline = encode_server_config_blob(&Config::new(), None).expect("baseline config should encode"); - let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(baseline), None)); + let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(baseline.clone()), None)); let first = read_server_config_snapshot(store.clone()) .await .expect("first config snapshot"); @@ -5079,7 +5234,11 @@ mod tests { .await .expect("second transaction should acquire after the first snapshot is dropped") .expect("second config snapshot"); - assert!(configs_semantically_equal(&second.config, &Config::new())); + // Compare raw bytes against the baseline blob rather than a fresh + // Config::new(): the process-global DEFAULT_KVS can be registered by a + // sibling test mid-run, which would make a Config::new() evaluated here + // diverge from the baseline encoded above. + assert_eq!(second.raw.as_deref(), Some(baseline.as_slice())); } #[tokio::test] diff --git a/crates/ecstore/src/core/pools.rs b/crates/ecstore/src/core/pools.rs index 2645b4912..3feb70173 100644 --- a/crates/ecstore/src/core/pools.rs +++ b/crates/ecstore/src/core/pools.rs @@ -13,7 +13,7 @@ // limitations under the License. use crate::bucket::replication::replication_state_from_filemeta; -#[cfg(test)] +#[cfg(all(test, feature = "test-util"))] use crate::bucket::utils::is_meta_bucketname; use crate::bucket::versioning_sys::BucketVersioningSys; use crate::bucket::{ @@ -70,8 +70,8 @@ use http::HeaderMap; #[cfg(test)] use rmp_serde::Deserializer; use rmp_serde::Serializer; -use rustfs_common::heal_channel::HealOpts; use rustfs_filemeta::{FileInfoVersions, MetaCacheEntries, MetaCacheEntry, MetadataResolutionParams}; +use rustfs_heal_contracts::heal_channel::HealOpts; use rustfs_utils::crypto::{hex_sha256, is_sha256_checksum}; use rustfs_utils::path::{encode_dir_object, path_join, path_to_bucket_object, path_to_bucket_object_with_base_path}; use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration, ReplicationConfiguration}; @@ -8503,7 +8503,7 @@ impl ECStore { .await } - #[cfg(test)] + #[cfg(all(test, feature = "test-util"))] pub(crate) async fn decommission_entry_for_test_with_bucket_incarnation( self: &Arc, idx: usize, @@ -8671,7 +8671,7 @@ impl ECStore { Ok(()) } - #[cfg(test)] + #[cfg(all(test, feature = "test-util"))] pub(crate) async fn decommission_pool_for_test( self: &Arc, rx: CancellationToken, @@ -9587,7 +9587,7 @@ impl ECStore { Ok(receipt_paths) } - #[cfg(test)] + #[cfg(all(test, feature = "test-util"))] async fn persist_decommission_durable_ilm_manifest(&self, source_pool_idx: usize) -> Result<()> { let run_token = self.durable_ilm_receipt_run_token(source_pool_idx).await?; self.persist_decommission_durable_ilm_manifest_for_run(source_pool_idx, &run_token) @@ -9705,7 +9705,7 @@ impl ECStore { Ok(receipts) } - #[cfg(test)] + #[cfg(all(test, feature = "test-util"))] async fn persist_decommission_durable_ilm_receipt( &self, source_pool_idx: usize, @@ -10309,7 +10309,7 @@ impl ECStore { Ok(()) } - #[cfg(test)] + #[cfg(all(test, feature = "test-util"))] async fn verify_and_cleanup_decommissioned_durable_ilm_record( &self, source_pool_idx: usize, @@ -10381,7 +10381,7 @@ impl ECStore { resolve_decommission_entry_cleanup_delete_result(cleanup_result, RUSTFS_META_BUCKET, path) } - #[cfg(test)] + #[cfg(all(test, feature = "test-util"))] pub(crate) async fn verify_and_cleanup_decommissioned_durable_ilm_record_for_test( &self, source_pool_idx: usize, @@ -10392,12 +10392,12 @@ impl ECStore { .await } - #[cfg(test)] + #[cfg(all(test, feature = "test-util"))] pub(crate) async fn decommission_durable_ilm_receipt_count_for_test(&self, source_pool_idx: usize) -> Result { Ok(self.list_decommission_durable_ilm_receipts(source_pool_idx).await?.len()) } - #[cfg(test)] + #[cfg(all(test, feature = "test-util"))] pub(crate) async fn decommission_durable_ilm_receipt_paths_for_test( &self, source_pool_idx: usize, @@ -10405,7 +10405,7 @@ impl ECStore { self.list_decommission_durable_ilm_receipts(source_pool_idx).await } - #[cfg(test)] + #[cfg(all(test, feature = "test-util"))] pub(crate) async fn persist_decommission_durable_ilm_receipt_for_test( &self, source_pool_idx: usize, @@ -10424,12 +10424,12 @@ impl ECStore { Ok(decommission_durable_ilm_receipt_path(&run_token, source_path, record.id_kind, &record.id)) } - #[cfg(test)] + #[cfg(all(test, feature = "test-util"))] pub(crate) async fn persist_decommission_durable_ilm_manifest_for_test(&self, source_pool_idx: usize) -> Result<()> { self.persist_decommission_durable_ilm_manifest(source_pool_idx).await } - #[cfg(test)] + #[cfg(all(test, feature = "test-util"))] pub(crate) async fn cleanup_decommission_durable_ilm_receipts_for_test(&self, source_pool_idx: usize) -> Result<()> { self.cleanup_decommission_durable_ilm_receipts(source_pool_idx).await } @@ -10782,7 +10782,7 @@ impl ECStore { self.ensure_decommission_multipart_uploads_drained(idx, pool.as_ref(), &buckets) .await } - #[cfg(test)] + #[cfg(all(test, feature = "test-util"))] pub(crate) async fn check_after_decommission_for_test(self: &Arc, idx: usize) -> Result<()> { let generation = self.active_decommission_generation(idx).await?; self.check_after_decommission(idx, &CancellationToken::new(), generation) diff --git a/crates/ecstore/src/core/sets.rs b/crates/ecstore/src/core/sets.rs index 648b7cf0d..0a814865f 100644 --- a/crates/ecstore/src/core/sets.rs +++ b/crates/ecstore/src/core/sets.rs @@ -46,9 +46,9 @@ use futures::{ stream::{FuturesUnordered, StreamExt}, }; use http::HeaderMap; -use rustfs_common::heal_channel::HealOpts; -use rustfs_common::heal_channel::{DriveState, HealItemType}; use rustfs_filemeta::FileInfo; +use rustfs_heal_contracts::heal_channel::HealOpts; +use rustfs_heal_contracts::heal_channel::{DriveState, HealItemType}; use rustfs_lock::NamespaceLockWrapper; use rustfs_madmin::heal_commands::HealResultItem; use rustfs_utils::{crc_hash, path::path_join_buf, sip_hash}; diff --git a/crates/ecstore/src/diagnostics/admin_server_info.rs b/crates/ecstore/src/diagnostics/admin_server_info.rs index 017b462a2..3bbf7cf05 100644 --- a/crates/ecstore/src/diagnostics/admin_server_info.rs +++ b/crates/ecstore/src/diagnostics/admin_server_info.rs @@ -26,7 +26,7 @@ use crate::{ use crate::data_usage::load_data_usage_cache; use crate::storage_api_contracts::admin::StorageAdminApi; use crate::storage_api_contracts::bucket::BucketOptions; -use rustfs_common::heal_channel::DriveState; +use rustfs_heal_contracts::heal_channel::DriveState; use rustfs_madmin::{ BackendDisks, Disk, ErasureSetInfo, ITEM_INITIALIZING, ITEM_OFFLINE, ITEM_ONLINE, ITEM_UNKNOWN, InfoMessage, MemStats, ServerProperties, diff --git a/crates/ecstore/src/disk/error.rs b/crates/ecstore/src/disk/error.rs index 6f771f790..dff44d78b 100644 --- a/crates/ecstore/src/disk/error.rs +++ b/crates/ecstore/src/disk/error.rs @@ -23,6 +23,16 @@ pub type Result = core::result::Result; const METACACHE_OUTPUT_STREAM_CLOSED: &str = "metacache output stream closed"; +/// Marker carried by a shard-read `io::Error` when the underlying reader can +/// no longer be realigned after a fresh remote open failed. The marker is +/// deliberately separate from the `ErrorKind`: a terminal read must retire +/// its reader, while its original typed disk error and I/O kind still need to +/// survive quorum/error mapping. +#[derive(Debug)] +pub(crate) struct TerminalReadError { + source: DiskError, +} + // DiskError == StorageErr #[derive(Debug, thiserror::Error)] pub enum DiskError { @@ -168,6 +178,67 @@ pub enum DiskError { RemoteClientUnavailable(String), } +impl TerminalReadError { + pub(crate) fn new(source: DiskError) -> Self { + Self { source } + } + + fn into_source(self) -> DiskError { + self.source + } +} + +impl std::fmt::Display for TerminalReadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.source.fmt(f) + } +} + +impl StdError for TerminalReadError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(&self.source) + } +} + +fn classify_internode_missing_error(error: &InternodeHttpError) -> Option { + if error.is_remote_file_not_found() { + return Some(DiskError::FileNotFound); + } + if error.is_remote_volume_not_found() { + return Some(DiskError::VolumeNotFound); + } + None +} + +/// Wrap a terminal shard-read failure without changing its typed +/// classification. Timeout-like disk errors retain `TimedOut`; other errors +/// retain their inner I/O kind or use `Other` when no more specific kind exists. +pub(crate) fn terminal_read_error_to_io(error: DiskError) -> io::Error { + let kind = match &error { + DiskError::Io(inner) => inner.kind(), + DiskError::SourceStalled | DiskError::Timeout => io::ErrorKind::TimedOut, + DiskError::DiskNotFound + | DiskError::FileNotFound + | DiskError::FileVersionNotFound + | DiskError::PathNotFound + | DiskError::VolumeNotFound => io::ErrorKind::NotFound, + DiskError::DiskAccessDenied | DiskError::FileAccessDenied | DiskError::VolumeAccessDenied => { + io::ErrorKind::PermissionDenied + } + DiskError::DiskFull => io::ErrorKind::StorageFull, + DiskError::FileCorrupt | DiskError::PartMissingOrCorrupt | DiskError::BitrotHashAlgoInvalid => io::ErrorKind::InvalidData, + _ => io::ErrorKind::Other, + }; + io::Error::new(kind, TerminalReadError::new(error)) +} + +/// Whether an I/O error marks a shard reader as terminal for adaptive decode. +pub(crate) fn is_terminal_read_error(error: &io::Error) -> bool { + error + .get_ref() + .is_some_and(|source| source.downcast_ref::().is_some()) +} + impl From for DiskError { fn from(error: crate::erasure::coding::ErasureConstructionError) -> Self { Self::Io(error.into_io_error()) @@ -344,6 +415,21 @@ impl From for DiskError { return DiskError::VolumeNotFound; } } + let e = match e.downcast::() { + Ok(terminal_error) => { + let source = terminal_error.into_source(); + if let DiskError::Io(io_error) = &source + && let Some(internode_error) = io_error + .get_ref() + .and_then(|source| source.downcast_ref::()) + && let Some(classified) = classify_internode_missing_error(internode_error) + { + return classified; + } + return source; + } + Err(e) => e, + }; match e.downcast::() { Ok(disk_error) => disk_error, // Mirror `From for StorageError`: a StorageError boxed @@ -679,6 +765,32 @@ mod tests { use super::*; use std::collections::HashMap; + #[test] + fn terminal_read_error_preserves_kind_and_disk_classification() { + let timeout = terminal_read_error_to_io(DiskError::Timeout); + assert_eq!(timeout.kind(), io::ErrorKind::TimedOut); + assert!(is_terminal_read_error(&timeout)); + assert!(matches!(DiskError::from(timeout), DiskError::Timeout)); + + let missing = terminal_read_error_to_io(DiskError::FileNotFound); + assert_eq!(missing.kind(), io::ErrorKind::NotFound); + assert!(is_terminal_read_error(&missing)); + assert!(matches!(DiskError::from(missing), DiskError::FileNotFound)); + + let reset = terminal_read_error_to_io(DiskError::Io(io::Error::new(io::ErrorKind::ConnectionReset, "connection reset"))); + assert_eq!(reset.kind(), io::ErrorKind::ConnectionReset); + assert!(is_terminal_read_error(&reset)); + assert!(matches!(DiskError::from(reset), DiskError::Io(error) if error.kind() == io::ErrorKind::ConnectionReset)); + + for (remote_error, expected) in [ + (rustfs_rio::new_test_remote_file_not_found_http_io_error(), DiskError::FileNotFound), + (rustfs_rio::new_test_remote_volume_not_found_http_io_error(), DiskError::VolumeNotFound), + ] { + let wrapped = terminal_read_error_to_io(DiskError::Io(remote_error)); + assert_eq!(DiskError::from(wrapped), expected); + } + } + #[test] fn other_preserves_erasure_construction_source_chain() { use crate::erasure::coding::ErasureConstructionError; diff --git a/crates/ecstore/src/ecstore_validation_blackbox.rs b/crates/ecstore/src/ecstore_validation_blackbox.rs index 30e309e4f..4db7e5104 100644 --- a/crates/ecstore/src/ecstore_validation_blackbox.rs +++ b/crates/ecstore/src/ecstore_validation_blackbox.rs @@ -208,7 +208,7 @@ async fn blackbox_get_restores_body_after_one_shard_file_is_removed() { // Serialized: forces the reader-setup strategy through a process-global env var. #[serial_test::serial] async fn blackbox_heal_requests_preserve_repair_scope() { - use rustfs_common::heal_channel::{ + use rustfs_heal_contracts::heal_channel::{ HealAdmissionResult, HealChannelCommand, HealChannelPriority, HealChannelReceiver, HealChannelRequest, HealRequestSource, }; @@ -238,7 +238,7 @@ async fn blackbox_heal_requests_preserve_repair_scope() { // (failing their submitter, which releases their dedup reservation), and // fail fast once the receiver drops at test end. Tests that must observe a // deterministic channel state serialize under the same serial key. - let mut heal_rx = rustfs_common::heal_channel::init_heal_channel() + let mut heal_rx = rustfs_heal_contracts::heal_channel::init_heal_channel() .expect("this must be the only ecstore test that owns the heal channel receiver"); // Ordinary PUTs use the same admission channel as read repair. A single diff --git a/crates/ecstore/src/erasure/coding/decode.rs b/crates/ecstore/src/erasure/coding/decode.rs index 8b616c058..7c8b38b54 100644 --- a/crates/ecstore/src/erasure/coding/decode.rs +++ b/crates/ecstore/src/erasure/coding/decode.rs @@ -20,7 +20,7 @@ use crate::diagnostics::get::{ record_get_object_pipeline_failure, record_get_stage_duration_if_enabled, }; use crate::disk::disk_store::get_object_disk_read_timeout; -use crate::disk::error::Error; +use crate::disk::error::{Error, is_terminal_read_error}; use crate::disk::error_reduce::reduce_errs; use crate::erasure::codec::workspace::ShardBufferPool; use crate::erasure::coding::{BitrotReader, Erasure}; @@ -36,12 +36,16 @@ use std::future::Future; use std::io; use std::io::ErrorKind; use std::pin::Pin; +use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::io::AsyncWrite; use tokio::io::AsyncWriteExt; use tracing::{debug, error, warn}; type ShardReadFuture<'a> = Pin, Error>, bool)> + Send + 'a>>; +type OwnedShardReadFuture<'a, R> = + Pin, Error>, Option>, bool)> + Send + 'a>>; +pub(crate) type DeferredReaderReopener = Arc Option> + Send + Sync>; type ShardIndexes = SmallVec<[usize; INLINE_SHARD_SLOTS]>; type ActiveReaders = SmallVec<[bool; INLINE_SHARD_SLOTS]>; @@ -56,6 +60,41 @@ const SHARD_LOCALITY_SCHEDULING_OFF: &str = "off"; const SHARD_LOCALITY_SCHEDULING_OBSERVE: &str = "observe"; const SHARD_LOCALITY_SCHEDULING_ON: &str = "on"; +/// Read-ahead contract selected by the caller of the erasure decoder. +/// +/// Ordinary GETs retain the configured overlap and all-shard lockstep +/// behavior. A server-side copy holds its source while the destination can +/// apply backpressure, so it uses the demand-bound variant: no speculative +/// stripe read and data shards only until reconstruction needs parity. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum DecodeReadPolicy { + #[default] + Default, + DemandBound, +} + +/// Maximum number of deferred parity reads that a demand-bound stripe may have +/// in flight at once. The window is deliberately small: a 16+16 layout must +/// not turn one slow data shard into 16 simultaneous HTTP/H2 opens. The +/// window is refilled as results arrive, so larger erasure sets still make +/// progress without an unbounded fan-out. +const MAX_DEMAND_BOUND_PARITY_IN_FLIGHT: usize = 4; + +tokio::task_local! { + static DECODE_READ_POLICY: DecodeReadPolicy; +} + +pub(crate) fn decode_read_policy() -> DecodeReadPolicy { + DECODE_READ_POLICY.try_with(|policy| *policy).unwrap_or_default() +} + +pub(crate) async fn with_decode_read_policy(policy: DecodeReadPolicy, future: F) -> F::Output +where + F: Future, +{ + DECODE_READ_POLICY.scope(policy, future).await +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ShardLocalitySchedulingMode { Off, @@ -136,10 +175,11 @@ const DEFAULT_RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE: bool = false; /// Whether the data-shards-only lockstep GET read is enabled (backlog#923). pub(crate) fn get_lockstep_data_shards_only_enabled() -> bool { - rustfs_utils::get_env_bool( - ENV_RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE, - DEFAULT_RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE, - ) + matches!(decode_read_policy(), DecodeReadPolicy::DemandBound) + || rustfs_utils::get_env_bool( + ENV_RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE, + DEFAULT_RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE, + ) } /// Get whether bitrot-decode overlap is enabled. @@ -170,7 +210,8 @@ fn is_bitrot_decode_overlap_enabled() -> bool { /// pre-existing strictly-serial read → reconstruct → emit behaviour, byte for /// byte. fn legacy_stripe_prefetch_enabled() -> bool { - get_decode_stripe_prefetch_count() > 1 || is_bitrot_decode_overlap_enabled() + !matches!(decode_read_policy(), DecodeReadPolicy::DemandBound) + && (get_decode_stripe_prefetch_count() > 1 || is_bitrot_decode_overlap_enabled()) } /// Outcome of reconstructing and emitting a single already-read stripe in the @@ -268,6 +309,92 @@ fn shard_role(index: usize, data_shards: usize) -> &'static str { } } +#[allow(clippy::too_many_arguments)] +async fn read_shard_result( + index: usize, + read_cost: ShardReadCost, + reader: &mut BitrotReader, + recycled_buf: Option>, + shard_size: usize, + data_shards: usize, + read_timeout: Duration, + metrics_path: Option<&'static str>, +) -> (Result, Error>, bool) +where + R: crate::erasure::coding::ShardSource, +{ + let role = shard_role(index, data_shards); + // Capacity, not length: `read_appending` writes every byte it returns, so + // the buffer never needs zeroing first (rustfs/backlog#1159). + let mut buf = recycled_buf.unwrap_or_else(|| Vec::with_capacity(shard_size)); + buf.clear(); + let read_start = metrics_path.map(|_| Instant::now()); + let read_result = if read_timeout.is_zero() { + reader.read_appending(&mut buf, shard_size).await + } else { + match tokio::time::timeout(read_timeout, reader.read_appending(&mut buf, shard_size)).await { + Ok(result) => result, + Err(_) => { + let timeout_error = io::Error::new(ErrorKind::TimedOut, "shard read timed out"); + let error_class = classify_io_error(&timeout_error).as_str(); + if let Some(path) = metrics_path { + rustfs_io_metrics::record_get_object_shard_read_observation( + path, + index, + role, + read_cost.as_str(), + GET_SHARD_READ_OUTCOME_ERROR, + error_class, + 0, + read_start.map_or(0.0, |read_start| read_start.elapsed().as_secs_f64()), + reader.last_verify_duration().as_secs_f64(), + ); + } + return (Err(Error::from(timeout_error)), true); + } + } + }; + + match read_result { + Ok(n) => { + debug_assert_eq!(buf.len(), n, "read_appending must grow the buffer by exactly n"); + if let Some(path) = metrics_path { + rustfs_io_metrics::record_get_object_shard_read_observation( + path, + index, + role, + read_cost.as_str(), + GET_SHARD_READ_OUTCOME_SUCCESS, + GET_SHARD_READ_ERROR_NONE, + n, + read_start.map_or(0.0, |read_start| read_start.elapsed().as_secs_f64()), + reader.last_verify_duration().as_secs_f64(), + ); + } + (Ok(buf), false) + } + Err(e) => { + let verify_duration_secs = reader.last_verify_duration().as_secs_f64(); + let error_class = classify_io_error(&e).as_str(); + let should_retire = e.kind() == ErrorKind::TimedOut || is_terminal_read_error(&e); + if let Some(path) = metrics_path { + rustfs_io_metrics::record_get_object_shard_read_observation( + path, + index, + role, + read_cost.as_str(), + GET_SHARD_READ_OUTCOME_ERROR, + error_class, + 0, + read_start.map_or(0.0, |read_start| read_start.elapsed().as_secs_f64()), + verify_duration_secs, + ); + } + (Err(Error::from(e)), should_retire) + } + } +} + #[allow(clippy::too_many_arguments)] fn read_shard<'a, R>( index: usize, @@ -285,75 +412,18 @@ where let role = shard_role(index, data_shards); if let Some(reader) = reader { Box::pin(async move { - // Capacity, not length: `read_appending` writes every byte it returns, so - // the buffer never needs zeroing first (rustfs/backlog#1159). - let mut buf = recycled_buf.unwrap_or_else(|| Vec::with_capacity(shard_size)); - buf.clear(); - let read_start = metrics_path.map(|_| Instant::now()); - let read_result = if read_timeout.is_zero() { - reader.read_appending(&mut buf, shard_size).await - } else { - match tokio::time::timeout(read_timeout, reader.read_appending(&mut buf, shard_size)).await { - Ok(result) => result, - Err(_) => { - let timeout_error = io::Error::new(ErrorKind::TimedOut, "shard read timed out"); - let error_class = classify_io_error(&timeout_error).as_str(); - if let Some(path) = metrics_path { - rustfs_io_metrics::record_get_object_shard_read_observation( - path, - index, - role, - read_cost.as_str(), - GET_SHARD_READ_OUTCOME_ERROR, - error_class, - 0, - read_start.map_or(0.0, |read_start| read_start.elapsed().as_secs_f64()), - reader.last_verify_duration().as_secs_f64(), - ); - } - return (index, read_cost, Err(Error::from(timeout_error)), true); - } - } - }; - - match read_result { - Ok(n) => { - debug_assert_eq!(buf.len(), n, "read_appending must grow the buffer by exactly n"); - if let Some(path) = metrics_path { - rustfs_io_metrics::record_get_object_shard_read_observation( - path, - index, - role, - read_cost.as_str(), - GET_SHARD_READ_OUTCOME_SUCCESS, - GET_SHARD_READ_ERROR_NONE, - n, - read_start.map_or(0.0, |read_start| read_start.elapsed().as_secs_f64()), - reader.last_verify_duration().as_secs_f64(), - ); - } - (index, read_cost, Ok(buf), false) - } - Err(e) => { - let verify_duration_secs = reader.last_verify_duration().as_secs_f64(); - let error_class = classify_io_error(&e).as_str(); - let should_retire = e.kind() == ErrorKind::TimedOut; - if let Some(path) = metrics_path { - rustfs_io_metrics::record_get_object_shard_read_observation( - path, - index, - role, - read_cost.as_str(), - GET_SHARD_READ_OUTCOME_ERROR, - error_class, - 0, - read_start.map_or(0.0, |read_start| read_start.elapsed().as_secs_f64()), - verify_duration_secs, - ); - } - (index, read_cost, Err(Error::from(e)), should_retire) - } - } + let (result, should_retire) = read_shard_result( + index, + read_cost, + reader, + recycled_buf, + shard_size, + data_shards, + read_timeout, + metrics_path, + ) + .await; + (index, read_cost, result, should_retire) }) } else { Box::pin(async move { @@ -375,6 +445,121 @@ where } } +#[allow(clippy::too_many_arguments)] +fn read_shard_owned<'a, R>( + index: usize, + read_cost: ShardReadCost, + reader: Option>, + recycled_buf: Option>, + shard_size: usize, + data_shards: usize, + read_timeout: Duration, + metrics_path: Option<&'static str>, +) -> OwnedShardReadFuture<'a, R> +where + R: crate::erasure::coding::ShardSource + 'a, +{ + let role = shard_role(index, data_shards); + Box::pin(async move { + let Some(mut reader) = reader else { + if let Some(path) = metrics_path { + rustfs_io_metrics::record_get_object_shard_read_observation( + path, + index, + role, + read_cost.as_str(), + GET_SHARD_READ_OUTCOME_MISSING, + GET_SHARD_READ_ERROR_MISSING, + 0, + 0.0, + 0.0, + ); + } + return (index, read_cost, Err(Error::FileNotFound), None, false); + }; + let (result, should_retire) = read_shard_result( + index, + read_cost, + &mut reader, + recycled_buf, + shard_size, + data_shards, + read_timeout, + metrics_path, + ) + .await; + (index, read_cost, result, Some(reader), should_retire) + }) +} + +#[allow(clippy::too_many_arguments)] +fn launch_owned_shard<'a, R>( + sets: &mut FuturesUnordered>, + readers: &mut [Option>], + buffers: &mut ShardBufferPool, + active: &mut [bool], + scheduled: &mut usize, + index: usize, + shard_size: usize, + data_shards: usize, + read_cost: ShardReadCost, + read_timeout: Duration, + metrics_path: Option<&'static str>, +) -> bool +where + R: crate::erasure::coding::ShardSource + 'a, +{ + let Some(reader) = readers.get_mut(index).and_then(Option::take) else { + return false; + }; + launch_owned_reader( + sets, + buffers, + active, + scheduled, + index, + reader, + shard_size, + data_shards, + read_cost, + read_timeout, + metrics_path, + ) +} + +#[allow(clippy::too_many_arguments)] +fn launch_owned_reader<'a, R>( + sets: &mut FuturesUnordered>, + buffers: &mut ShardBufferPool, + active: &mut [bool], + scheduled: &mut usize, + index: usize, + reader: BitrotReader, + shard_size: usize, + data_shards: usize, + read_cost: ShardReadCost, + read_timeout: Duration, + metrics_path: Option<&'static str>, +) -> bool +where + R: crate::erasure::coding::ShardSource + 'a, +{ + let recycled_buf = Some(buffers.take(index, shard_size)); + *scheduled += 1; + active[index] = true; + sets.push(read_shard_owned( + index, + read_cost, + Some(reader), + recycled_buf, + shard_size, + data_shards, + read_timeout, + metrics_path, + )); + true +} + pin_project! { pub(crate) struct ParallelReader { #[pin] @@ -400,6 +585,11 @@ pub(crate) struct ParallelReader { // it to the current stripe when it is engaged mid-object (backlog#923). engaged: SmallVec<[bool; INLINE_SHARD_SLOTS]>, deferred_handles: Vec>, + // Copy-source hedges use a fresh deferred reader so cancelling a hedge + // never consumes the unopened reader reserved for a later stripe. The + // vector is empty for callers that do not provide a reopen factory (tests + // and the ordinary GET path retain the handle-based behavior). + deferred_reopeners: Vec>>, stripe_index: usize, } } @@ -607,6 +797,7 @@ where stripe_state: None, engaged, deferred_handles: Vec::new(), + deferred_reopeners: Vec::new(), stripe_index: 0, } } @@ -622,6 +813,17 @@ where self.deferred_handles = handles; self } + + /// Attach factories for unopened parity readers. A factory must return a + /// reader already aligned to the requested stripe. Keeping the original + /// deferred reader in `self.readers` lets a cancelled hedge be discarded + /// without poisoning the next-stripe reserve. + pub(crate) fn with_deferred_parity_reopeners(mut self, mut reopeners: Vec>>) -> Self { + reopeners.resize_with(self.readers.len(), || None); + reopeners.truncate(self.readers.len()); + self.deferred_reopeners = reopeners; + self + } } #[allow(clippy::too_many_arguments)] @@ -675,6 +877,34 @@ fn shard_read_hedge_delay(read_timeout: Duration) -> Option { } } +/// Return the number of new deferred parity readers that may be admitted for +/// the current demand-bound stripe. The window is based on concrete state, +/// not on setup candidates: at most `missing_data + 1` parity reads are useful +/// for a verification quorum, and the global in-flight cap keeps a wide EC +/// layout from opening every remaining shard at once. As a read completes the +/// caller invokes this again, which refills one slot after a failure or a +/// successful-but-insufficient parity result. +fn demand_bound_parity_admission_limit(shards: &[Option>], active: &[bool], data_shards: usize) -> usize { + let missing_data = shards.iter().take(data_shards).filter(|shard| shard.is_none()).count(); + if missing_data == 0 { + return 0; + } + + let successes = shards.iter().filter(|shard| shard.is_some()).count(); + let needed_for_verification = (data_shards + 1).saturating_sub(successes); + let desired = missing_data + .saturating_add(1) + .min(needed_for_verification) + .min(MAX_DEMAND_BOUND_PARITY_IN_FLIGHT); + let active_parity = active + .iter() + .enumerate() + .skip(data_shards) + .filter(|(_, is_active)| **is_active) + .count(); + desired.saturating_sub(active_parity) +} + fn shard_locality_remote_avoid_potential(remote_scheduled: usize, low_cost_available: usize, data_shards: usize) -> usize { let theoretical_remote_needed = data_shards.saturating_sub(low_cost_available); remote_scheduled.saturating_sub(theoretical_remote_needed) @@ -1045,6 +1275,11 @@ where /// realigned (no pending deferred handle) is likewise retired instead of /// being read out of position. async fn read_lockstep(&mut self, state: &mut StripeReadState) { + if matches!(decode_read_policy(), DecodeReadPolicy::DemandBound) { + self.read_lockstep_demand_bound(state).await; + return; + } + let num_readers = self.readers.len(); state.reset(num_readers, self.data_shards); let shard_size = if self.offset + self.shard_size > self.shard_file_size { @@ -1102,6 +1337,7 @@ where } let data_shards = self.data_shards; + let read_timeout = self.read_timeout; let metrics_path = self.metrics_path; let locality_preference_enabled = self.locality_preference_enabled; @@ -1295,17 +1531,431 @@ where } } - /// Attempt to bring an as-yet-unread parity reader into the lockstep read - /// set at `stripe_index`. + /// Demand-bound lockstep stripe read used by server-side copy sources. /// - /// At stripe 0 every reader is still positioned at the stream start, so - /// engagement is trivially aligned. Past stripe 0 the parity reader must - /// still be an unopened deferred reader: its pending open offset is - /// advanced by `stripe_index` bitrot blocks (the `bitrot_encoded_range` - /// geometry) so its first read returns the current stripe. A parity reader - /// that cannot be realigned is retired for the rest of the object, - /// mirroring the retire-on-error rule: reading it would return an earlier - /// stripe and reintroduce the backlog#832 desync. + /// The ordinary lockstep path can cancel every in-flight reader once it + /// has a quorum because all of its parity readers are already engaged. + /// Copy sources keep parity unopened until a data reader is missing. A + /// hedge therefore has to race the deferred parity reads against the + /// original data reads and may retire the latter only after the parity has + /// produced an actual decode-plus-verification quorum. The futures own + /// their readers so disjoint data/parity slots can be admitted while the + /// other group is still pending; dropping an abandoned future retires its + /// stream without leaving a borrowed slot behind. + async fn read_lockstep_demand_bound(&mut self, state: &mut StripeReadState) { + let num_readers = self.readers.len(); + state.reset(num_readers, self.data_shards); + let shard_size = if self.offset + self.shard_size > self.shard_file_size { + self.shard_file_size - self.offset + } else { + self.shard_size + }; + + let (shards, errs) = state.parts_mut(); + if shard_size == 0 { + return; + } + + self.offset += shard_size; + let stripe_index = self.stripe_index; + self.stripe_index += 1; + self.buffers.ensure_slots(num_readers); + + // A data slot retired on an earlier stripe is already missing. The + // bounded parity launcher below admits enough substitutes before the + // first data future is polled, preserving the lockstep alignment. + let missing_data_readers = self.readers.iter().take(self.data_shards).filter(|r| r.is_none()).count(); + + let data_shards = self.data_shards; + let read_timeout = self.read_timeout; + let metrics_path = self.metrics_path; + let stripe_read_start = metrics_path.map(|_| Instant::now()); + let mut retire_readers = ShardIndexes::new(); + let mut scheduled = 0usize; + let mut success = 0usize; + let mut completed = 0usize; + let mut failed = 0usize; + let mut first_shard_recorded = false; + let mut active = vec![false; num_readers]; + let mut temporary_parity = vec![false; num_readers]; + // A deferred parity slot is attempted at most once per stripe. A + // failed disposable hedge keeps its unopened reserve for the next + // stripe, but must not be relaunched in a tight same-stripe retry + // loop (which would defeat the bounded fan-out and amplify a remote + // outage). + let mut attempted_parity = vec![false; num_readers]; + // Once a data reader has returned an error (or was already missing at + // setup), the loss is permanent for lockstep alignment. Use the + // deferred handle and keep parity engaged across subsequent stripes; + // disposable reopeners are reserved for an as-yet unresolved slow + // data reader. + let mut data_failure_seen = missing_data_readers > 0; + let mut sets: FuturesUnordered> = FuturesUnordered::new(); + // Once a deferred parity reader has been admitted, a concrete + // `data_shards + 1` result is enough to finish a degraded stripe and + // abandon only the still-pending readers. Setup counts never set this + // flag: they are candidates, not successful shards. + let mut fallback_admitted = false; + + // Move engaged readers into owned futures. This leaves the slots free + // so a deferred parity reader can be admitted while these reads wait. + for i in 0..num_readers { + if !self.engaged[i] || self.readers[i].is_none() { + continue; + } + let read_cost = self.read_costs.get(i).copied().unwrap_or(ShardReadCost::Unknown); + let _ = launch_owned_shard( + &mut sets, + &mut self.readers, + &mut self.buffers, + &mut active, + &mut scheduled, + i, + shard_size, + data_shards, + read_cost, + read_timeout, + metrics_path, + ); + } + + if missing_data_readers > 0 { + let want = (missing_data_readers + 1).min(MAX_DEMAND_BOUND_PARITY_IN_FLIGHT); + if self.launch_demand_bound_parity( + stripe_index, + want, + &mut sets, + &mut active, + &mut temporary_parity, + &mut attempted_parity, + false, + &mut scheduled, + shard_size, + data_shards, + read_timeout, + metrics_path, + ) > 0 + { + fallback_admitted = true; + } + } + + let hedge_delay = shard_read_hedge_delay(read_timeout); + let hedge_sleep = hedge_delay.map(tokio::time::sleep); + tokio::pin!(hedge_sleep); + let mut hedged = false; + + loop { + let item = if !hedged { + match hedge_sleep.as_mut().as_pin_mut() { + Some(sleep) => tokio::select! { + biased; + item = sets.next() => item, + _ = sleep => { + hedged = true; + // Do not cancel a pending data read based on setup + // counts. Admit every still-unengaged parity + // reader as a bounded hedge batch; only concrete + // successful results below can satisfy the quorum. + let data_missing = shards.iter().take(data_shards).any(|shard| shard.is_none()); + if data_missing { + let launched = self.launch_demand_bound_parity( + stripe_index, + demand_bound_parity_admission_limit(shards, &active, data_shards), + &mut sets, + &mut active, + &mut temporary_parity, + &mut attempted_parity, + true, + &mut scheduled, + shard_size, + data_shards, + read_timeout, + metrics_path, + ); + if launched > 0 { + fallback_admitted = true; + } + } + continue; + } + }, + None => sets.next().await, + } + } else { + sets.next().await + }; + + let Some((i, _read_cost, result, reader, should_retire)) = item else { + // A fast failure can drain the initial data futures before the + // hedge timer fires (and a zero timeout intentionally has no + // timer). Do not return a false quorum just because the + // FuturesUnordered is momentarily empty: admit the deferred + // parity candidates and race them now. + let data_missing = shards.iter().take(data_shards).any(|shard| shard.is_none()); + data_failure_seen |= errs.iter().take(data_shards).any(Option::is_some); + if data_missing && success <= data_shards { + let launched = self.launch_demand_bound_parity( + stripe_index, + demand_bound_parity_admission_limit(shards, &active, data_shards), + &mut sets, + &mut active, + &mut temporary_parity, + &mut attempted_parity, + !data_failure_seen, + &mut scheduled, + shard_size, + data_shards, + read_timeout, + metrics_path, + ); + if launched > 0 { + fallback_admitted = true; + continue; + } + } + break; + }; + let result_failed = result.is_err(); + active[i] = false; + completed += 1; + if !first_shard_recorded { + if let Some(path) = metrics_path { + record_get_stage_duration_if_enabled(path, GET_STAGE_STRIPE_READ_FIRST_SHARD, stripe_read_start); + } + first_shard_recorded = true; + } + + match result { + Ok(v) => { + shards[i] = Some(v); + success += 1; + // A successful reader consumed exactly one aligned stripe + // and remains usable on the following stripe. + if temporary_parity[i] { + // A reopener hedge is disposable. Keep the unopened + // reserve untouched even when the hedge wins: promoting + // the one-stripe reader would make every later healthy + // stripe read parity and would leave a reset/timeout + // without a way to reopen it at the next stripe. + drop(reader); + self.engaged[i] = false; + } else if !should_retire { + self.readers[i] = reader; + } + } + Err(e) => { + failed += 1; + if i < data_shards { + data_failure_seen = true; + } + if temporary_parity[i] { + // A disposable hedge reader is independent of the + // unopened deferred reserve. Its timeout/reset may be + // transient, so discard only the hedge and keep the + // reserve available for a later stripe. The factory + // already removed a slot when it could not produce an + // aligned reader at all; an error after launch must + // not turn that setup failure policy into permanent + // disk retirement. Do not publish this disposable + // error into `errs`: `emit_decoded_stripe` uses that + // vector for terminal FileNotFound/FileCorrupt + // attribution, and a speculative failure must not + // fail a stripe that later reaches a real quorum. + self.engaged[i] = false; + } else { + errs[i] = Some(e); + // Lockstep cannot safely reuse a reader after any + // error, even when the low-level classifier called it + // nonfatal. + self.readers[i] = None; + retire_readers.push(i); + } + } + } + + // A degraded stripe should keep a small parity window full. Admit + // it immediately after any result (especially a fast data error, + // or a zero-timeout read where no hedge timer exists). Refill one + // slot after a parity failure/success rather than opening every + // candidate at once. Pending data futures stay in `sets` and can + // still win the race if the source recovers. + let data_missing = shards.iter().take(data_shards).any(|shard| shard.is_none()); + if data_missing && success <= data_shards && (result_failed || fallback_admitted) { + let launched = self.launch_demand_bound_parity( + stripe_index, + demand_bound_parity_admission_limit(shards, &active, data_shards), + &mut sets, + &mut active, + &mut temporary_parity, + &mut attempted_parity, + !data_failure_seen, + &mut scheduled, + shard_size, + data_shards, + read_timeout, + metrics_path, + ); + if launched > 0 { + fallback_admitted = true; + } + } + + // Once a real quorum is present, abandon only the still-pending + // futures. If a parity read failed, the pending original data + // reader remains in the race and can still rescue the stripe; no + // optimistic setup count may retire it early. A healthy stripe + // can also finish as soon as every data shard has returned, even + // when the hedge timer has not fired. + let succeeded = shards.iter().filter(|shard| shard.is_some()).count(); + let data_missing = shards.iter().take(data_shards).any(|shard| shard.is_none()); + if !data_missing { + break; + } + if fallback_admitted && succeeded > data_shards { + break; + } + } + + // Dropping `sets` cancels all remaining owned reads. A temporary + // parity hedge has an untouched deferred reserve in `self.readers`, so + // it can be abandoned without poisoning the next stripe. All other + // active readers were consumed in this stripe and must be retired. + for i in 0..num_readers { + if active[i] { + if temporary_parity[i] { + // The factory reader is disposable; leave the original + // deferred reader unengaged and available for a later + // stripe. + self.engaged[i] = false; + continue; + } + if shards[i].is_none() && errs[i].is_none() { + errs[i] = Some(Error::from(io::Error::new(ErrorKind::TimedOut, "shard read hedged after a slow shard"))); + retire_readers.push(i); + } + self.readers[i] = None; + } + } + drop(sets); + + if let Some(path) = metrics_path { + record_get_stage_duration_if_enabled(path, GET_STAGE_STRIPE_READ_QUORUM, stripe_read_start); + rustfs_io_metrics::record_get_object_shard_read_fanout(path, scheduled, completed, success, failed); + } + + for i in retire_readers { + self.readers[i] = None; + } + } + + /// Launch a bounded demand admission for deferred parity. `disposable` + /// selects a speculative hedge (a fresh reopener whose reserve remains + /// unopened) versus a confirmed loss (the deferred handle is engaged and + /// retained across stripes). Tests and legacy callers without a reopener + /// use the handle-based reader as a persistent fallback. + #[allow(clippy::too_many_arguments)] + fn launch_demand_bound_parity<'a>( + &mut self, + stripe_index: usize, + max_new: usize, + sets: &mut FuturesUnordered>, + active: &mut [bool], + temporary_parity: &mut [bool], + attempted_parity: &mut [bool], + disposable: bool, + scheduled: &mut usize, + shard_size: usize, + data_shards: usize, + read_timeout: Duration, + metrics_path: Option<&'static str>, + ) -> usize + where + R: 'a, + { + let mut launched = 0; + for idx in self.data_shards..self.readers.len() { + if launched >= max_new || self.engaged[idx] || self.readers[idx].is_none() || active[idx] || attempted_parity[idx] { + continue; + } + + // Mark before invoking the factory/handle so a setup failure is + // also bounded to one attempt for this stripe. + attempted_parity[idx] = true; + + let reopener = self.deferred_reopeners.get(idx).and_then(Option::clone); + let (reader, temporary) = if disposable { + if let Some(reopener) = reopener { + let Some(reader) = reopener(stripe_index) else { + // A factory failure is terminal for this parity slot. + // Do not leave an apparently available reader that + // cannot be aligned to the current stripe. + self.readers[idx] = None; + continue; + }; + (reader, true) + } else { + // Tests and legacy callers without a factory retain the + // handle-based fallback. It is a persistent admission, + // because consuming that reserve is the only safe way to + // keep the stream aligned for the next stripe. + if !self.try_engage_parity(idx, stripe_index) { + continue; + } + let Some(reader) = self.readers[idx].take() else { + self.engaged[idx] = false; + continue; + }; + (reader, false) + } + } else if self.try_engage_parity(idx, stripe_index) { + let Some(reader) = self.readers[idx].take() else { + self.engaged[idx] = false; + continue; + }; + (reader, false) + } else if let Some(reopener) = reopener { + // A setup without a stripe handle can still make a known + // missing slot persistent by promoting the factory reader. + // This is a compatibility fallback; production CopySource + // setup supplies both a reserve and a handle. + let Some(reader) = reopener(stripe_index) else { + self.readers[idx] = None; + continue; + }; + // A non-disposable reopener is the persistent reserve when a + // setup did not retain a stripe handle. Mark it engaged just + // like the handle path so subsequent stripes reuse the + // aligned reader instead of reopening the remote shard. + self.engaged[idx] = true; + (reader, false) + } else { + continue; + }; + + let read_cost = self.read_costs.get(idx).copied().unwrap_or(ShardReadCost::Unknown); + if launch_owned_reader( + sets, + &mut self.buffers, + active, + scheduled, + idx, + reader, + shard_size, + data_shards, + read_cost, + read_timeout, + metrics_path, + ) { + temporary_parity[idx] = temporary; + launched += 1; + } else if !temporary { + self.engaged[idx] = false; + } + } + launched + } + fn try_engage_parity(&mut self, idx: usize, stripe_index: usize) -> bool { if stripe_index == 0 { self.engaged[idx] = true; @@ -1539,7 +2189,7 @@ impl Erasure { W: AsyncWrite + Send + Sync + Unpin, R: crate::erasure::coding::ShardSource, { - self.decode_inner(writer, readers, offset, length, total_length, None, Vec::new()) + self.decode_inner(writer, readers, offset, length, total_length, None, Vec::new(), Vec::new()) .await } @@ -1557,13 +2207,17 @@ impl Erasure { W: AsyncWrite + Send + Sync + Unpin, R: crate::erasure::coding::ShardSource, { - self.decode_inner(writer, readers, offset, length, total_length, Some(read_costs), Vec::new()) + self.decode_inner(writer, readers, offset, length, total_length, Some(read_costs), Vec::new(), Vec::new()) .await } /// GET decode entry point that also carries the deferred-parity stripe /// handles from bitrot reader setup, so unengaged parity readers can be /// opened aligned to the stripe where a data shard fails (backlog#923). + #[allow( + dead_code, + reason = "kept as the compatibility wrapper for existing decode callers and tests" + )] #[allow(clippy::too_many_arguments)] pub(crate) async fn decode_with_stripe_handles( &self, @@ -1579,8 +2233,49 @@ impl Erasure { W: AsyncWrite + Send + Sync + Unpin, R: crate::erasure::coding::ShardSource, { - self.decode_inner(writer, readers, offset, length, total_length, read_costs, deferred_handles) - .await + self.decode_with_stripe_handles_and_reopeners( + writer, + readers, + offset, + length, + total_length, + read_costs, + deferred_handles, + Vec::new(), + ) + .await + } + + /// Decode entry point with disposable, stripe-aligned parity reopeners. + /// CopySource uses these to hedge a slow data read without consuming the + /// unopened parity reserve when the data stream recovers first. + #[allow(clippy::too_many_arguments)] + pub(crate) async fn decode_with_stripe_handles_and_reopeners( + &self, + writer: &mut W, + readers: Vec>>, + offset: usize, + length: usize, + total_length: usize, + read_costs: Option>, + deferred_handles: Vec>, + deferred_reopeners: Vec>>, + ) -> (usize, Option) + where + W: AsyncWrite + Send + Sync + Unpin, + R: crate::erasure::coding::ShardSource, + { + self.decode_inner( + writer, + readers, + offset, + length, + total_length, + read_costs, + deferred_handles, + deferred_reopeners, + ) + .await } /// Reconstruct and emit one already-read stripe. @@ -1708,6 +2403,7 @@ impl Erasure { total_length: usize, read_costs: Option>, deferred_handles: Vec>, + deferred_reopeners: Vec>>, ) -> (usize, Option) where W: AsyncWrite + Send + Sync + Unpin, @@ -1756,7 +2452,8 @@ impl Erasure { } else { ParallelReader::new_for_decode(readers, self.clone(), offset, total_length, Some(GET_OBJECT_PATH_LEGACY_DUPLEX)) } - .with_deferred_parity_handles(deferred_handles); + .with_deferred_parity_handles(deferred_handles) + .with_deferred_parity_reopeners(deferred_reopeners); let start = offset / self.block_size; let end = end_offset.saturating_sub(1) / self.block_size; @@ -1992,12 +2689,6 @@ mod tests { #[test] fn parallel_reader_keeps_stripe_scratch_out_of_line() { - eprintln!( - "parallel_reader={} stripe_state={} cached_state={}", - std::mem::size_of::>>>(), - std::mem::size_of::(), - std::mem::size_of::>>() - ); assert_eq!( std::mem::size_of::>>(), std::mem::size_of::(), @@ -2156,11 +2847,16 @@ mod tests { sleep: Option>>, }, Pending, + /// Parks without self-waking so a surrounding timer can make a + /// deterministic cancellation decision (unlike `Pending`, which is + /// intentionally a busy-waking fixture for timeout tests). + Parked, PartialThenPending { data: Vec, emitted: bool, }, TimedOut, + TerminalFileNotFound, /// Serves `cursor` (typically the first stripe's bytes) normally, then /// once it is exhausted parks on a long `sleep` instead of returning EOF — /// modelling a shard whose *next*-stripe read never completes (a wedged or @@ -2192,6 +2888,7 @@ mod tests { cx.waker().wake_by_ref(); Poll::Pending } + TestShardReader::Parked => Poll::Pending, TestShardReader::PartialThenPending { data, emitted } => { if *emitted { cx.waker().wake_by_ref(); @@ -2204,6 +2901,9 @@ mod tests { Poll::Ready(Ok(())) } TestShardReader::TimedOut => Poll::Ready(Err(io::Error::new(ErrorKind::TimedOut, "test shard read timed out"))), + TestShardReader::TerminalFileNotFound => { + Poll::Ready(Err(crate::disk::error::terminal_read_error_to_io(Error::FileNotFound))) + } TestShardReader::PrefixThenSlow { cursor, stall, sleep } => { let before = buf.filled().len(); match Pin::new(cursor).poll_read(cx, buf) { @@ -3110,6 +3810,36 @@ mod tests { }); } + /// A copy source must remain demand-bound even when an operator has opted + /// into the ordinary GET overlap switches. The policy also enables the + /// deferred-parity lockstep mode so healthy copies do not consume parity + /// streams until reconstruction needs them. + #[tokio::test] + #[serial_test::serial] + async fn demand_bound_policy_disables_stripe_read_ahead_and_uses_data_only_lockstep() { + temp_env::async_with_vars( + [ + (ENV_RUSTFS_GET_DECODE_STRIPE_PREFETCH_COUNT, Some("8")), + (ENV_RUSTFS_GET_BITROT_DECODE_OVERLAP_ENABLE, Some("true")), + (ENV_RUSTFS_GET_LOCKSTEP_DATA_SHARDS_ONLY_ENABLE, Some("false")), + ], + async { + assert!(legacy_stripe_prefetch_enabled()); + assert!(!get_lockstep_data_shards_only_enabled()); + + with_decode_read_policy(DecodeReadPolicy::DemandBound, async { + assert!(!legacy_stripe_prefetch_enabled()); + assert!(get_lockstep_data_shards_only_enabled()); + }) + .await; + + assert!(legacy_stripe_prefetch_enabled()); + assert!(!get_lockstep_data_shards_only_enabled()); + }, + ) + .await; + } + /// Cancel-safety (https://github.com/rustfs/backlog/issues/1310): when the /// current stripe's emit fails (client disconnect / broken pipe), the /// speculatively prefetched next-stripe read must be *cancelled*, not waited @@ -4174,6 +4904,342 @@ mod tests { assert_eq!(DATA_SHARDS + 1, bufs.iter().filter(|buf| buf.is_some()).count()); } + /// Demand-bound lockstep regression: a slow data shard must be hedged as + /// soon as deferred parity can provide the decode-plus-verification quorum. + /// Before this guard, the hedge timer only looked at already-completed + /// readers, so a 2+2 stripe with one ready data shard waited out the full + /// read timeout even though both parity readers were available to engage. + #[tokio::test] + async fn test_demand_bound_lockstep_hedges_to_deferred_parity_quorum() { + const NUM_SHARDS: usize = 1; + const BLOCK_SIZE: usize = 64; + const DATA_SHARDS: usize = 2; + const PARITY_SHARDS: usize = 2; + const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS; + + let hash_algo = HashAlgorithm::None; + let slow_until = TokioInstant::now() + Duration::from_secs(60); + let readers = vec![ + Some(BitrotReader::new( + TestShardReader::ReadyAt { + cursor: Cursor::new(vec![0_u8; SHARD_SIZE * NUM_SHARDS]), + ready_at: slow_until, + sleep: None, + }, + SHARD_SIZE, + hash_algo.clone(), + false, + )), + Some(BitrotReader::new( + TestShardReader::Ready(Cursor::new(vec![1_u8; SHARD_SIZE * NUM_SHARDS])), + SHARD_SIZE, + hash_algo.clone(), + false, + )), + Some(BitrotReader::new( + TestShardReader::Ready(Cursor::new(vec![2_u8; SHARD_SIZE * NUM_SHARDS])), + SHARD_SIZE, + hash_algo.clone(), + false, + )), + Some(BitrotReader::new( + TestShardReader::Ready(Cursor::new(vec![3_u8; SHARD_SIZE * NUM_SHARDS])), + SHARD_SIZE, + hash_algo, + false, + )), + ]; + + let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE); + let (bufs, errs, engaged, readers_remaining) = with_decode_read_policy(DecodeReadPolicy::DemandBound, async { + let mut parallel_reader = ParallelReader::new_with_metrics_path_read_costs_timeout_and_reconstruction_verification( + readers, + erasure, + 0, + NUM_SHARDS * BLOCK_SIZE, + None, + vec![ShardReadCost::Unknown; DATA_SHARDS + PARITY_SHARDS], + Duration::from_secs(60), + true, + ); + let (bufs, errs) = tokio::time::timeout(Duration::from_secs(2), parallel_reader.read()) + .await + .expect("deferred parity must cover a hedged data shard without waiting for read_timeout"); + ( + bufs, + errs, + parallel_reader.engaged.clone(), + parallel_reader.readers.iter().map(Option::is_some).collect::>(), + ) + }) + .await; + + assert!(matches!(&errs[0], Some(DiskError::Io(err)) if err.kind() == ErrorKind::TimedOut)); + assert_eq!(bufs.iter().filter(|buf| buf.is_some()).count(), DATA_SHARDS + 1); + assert_eq!(engaged.as_slice(), &[true, true, true, true]); + assert_eq!(readers_remaining, vec![false, true, true, true]); + } + + /// A fast data failure must admit deferred parity immediately. There is + /// intentionally no hedge timer when `read_timeout == 0`, so relying on + /// the timer would drain the initial futures and return a false quorum + /// before the healthy parity readers are ever opened. + #[tokio::test] + async fn test_demand_bound_admits_parity_after_fast_data_failure_without_timer() { + const NUM_SHARDS: usize = 1; + const BLOCK_SIZE: usize = 64; + const DATA_SHARDS: usize = 2; + const PARITY_SHARDS: usize = 2; + const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS; + + let hash_algo = HashAlgorithm::None; + let readers = vec![ + Some(BitrotReader::new(TestShardReader::TimedOut, SHARD_SIZE, hash_algo.clone(), false)), + Some(BitrotReader::new( + TestShardReader::Ready(Cursor::new(vec![1_u8; SHARD_SIZE * NUM_SHARDS])), + SHARD_SIZE, + hash_algo.clone(), + false, + )), + Some(BitrotReader::new( + TestShardReader::Ready(Cursor::new(vec![2_u8; SHARD_SIZE * NUM_SHARDS])), + SHARD_SIZE, + hash_algo.clone(), + false, + )), + Some(BitrotReader::new( + TestShardReader::Ready(Cursor::new(vec![3_u8; SHARD_SIZE * NUM_SHARDS])), + SHARD_SIZE, + hash_algo, + false, + )), + ]; + + let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE); + let (bufs, errs, engaged, readers_remaining) = with_decode_read_policy(DecodeReadPolicy::DemandBound, async { + let mut parallel_reader = ParallelReader::new_with_metrics_path_read_timeout_and_reconstruction_verification( + readers, + erasure, + 0, + NUM_SHARDS * BLOCK_SIZE, + None, + Duration::ZERO, + true, + ); + let (bufs, errs) = tokio::time::timeout(Duration::from_millis(500), parallel_reader.read()) + .await + .expect("fast data failure must immediately race healthy parity without a hedge timer"); + ( + bufs, + errs, + parallel_reader.engaged.clone(), + parallel_reader.readers.iter().map(Option::is_some).collect::>(), + ) + }) + .await; + + assert!(matches!(&errs[0], Some(DiskError::Io(err)) if err.kind() == ErrorKind::TimedOut)); + assert_eq!(bufs.iter().filter(|buf| buf.is_some()).count(), DATA_SHARDS + 1); + assert_eq!(engaged.as_slice(), &[true, true, true, true]); + assert_eq!(readers_remaining, vec![false, true, true, true]); + } + + #[tokio::test] + async fn test_demand_bound_canceled_hedge_preserves_deferred_parity_for_next_stripe() { + const BLOCK_SIZE: usize = 64; + const DATA_SHARDS: usize = 2; + const PARITY_SHARDS: usize = 2; + const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS; + + let hash_algo = HashAlgorithm::None; + let parity_calls = Arc::new(AtomicUsize::new(0)); + let mut reopeners: Vec>> = vec![None; DATA_SHARDS + PARITY_SHARDS]; + for (idx, slot) in reopeners.iter_mut().enumerate().skip(DATA_SHARDS).take(PARITY_SHARDS) { + let parity_calls = Arc::clone(&parity_calls); + *slot = Some(Arc::new(move |_stripe_index| { + let call = parity_calls.fetch_add(1, Ordering::SeqCst); + let reader = if call == 0 { + // One hedge succeeds before the slow data reader returns; + // it must still remain disposable rather than being + // promoted into the next stripe. + TestShardReader::Ready(Cursor::new(vec![idx as u8; SHARD_SIZE])) + } else if call < PARITY_SHARDS { + TestShardReader::Parked + } else { + TestShardReader::Ready(Cursor::new(vec![idx as u8; SHARD_SIZE * 2])) + }; + Some(BitrotReader::new(reader, SHARD_SIZE, HashAlgorithm::None, false)) + })); + } + + let slow_until = TokioInstant::now() + Duration::from_millis(150); + let readers = vec![ + Some(BitrotReader::new( + TestShardReader::ReadyAt { + cursor: Cursor::new(vec![0_u8; SHARD_SIZE * 4]), + ready_at: slow_until, + sleep: None, + }, + SHARD_SIZE, + hash_algo.clone(), + false, + )), + Some(BitrotReader::new( + TestShardReader::Ready(Cursor::new(vec![1_u8; SHARD_SIZE * 4])), + SHARD_SIZE, + hash_algo.clone(), + false, + )), + Some(BitrotReader::new(TestShardReader::Pending, SHARD_SIZE, hash_algo.clone(), false)), + Some(BitrotReader::new(TestShardReader::Pending, SHARD_SIZE, hash_algo, false)), + ]; + + let (first_parity_reserved, second_result) = with_decode_read_policy(DecodeReadPolicy::DemandBound, async { + let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE); + let mut parallel_reader = ParallelReader::new_with_metrics_path_read_timeout_and_reconstruction_verification( + readers, + erasure, + 0, + BLOCK_SIZE * 4, + None, + Duration::from_secs(1), + true, + ) + .with_deferred_parity_reopeners(reopeners); + + let (first_buffers, first_errors) = tokio::time::timeout(Duration::from_secs(1), parallel_reader.read()) + .await + .expect("healthy data recovery must not wait for canceled parity hedges"); + assert_eq!(first_buffers.iter().filter(|buffer| buffer.is_some()).count(), DATA_SHARDS + 1); + assert!(first_errors.iter().take(DATA_SHARDS).all(Option::is_none)); + assert_eq!(parity_calls.load(Ordering::SeqCst), PARITY_SHARDS); + assert!(parallel_reader.readers[2].is_some()); + assert!(parallel_reader.readers[3].is_some()); + assert!(!parallel_reader.engaged[2]); + assert!(!parallel_reader.engaged[3]); + + // A healthy following stripe must not inherit the one-stripe hedge + // reader. If a successful temporary hedge were promoted, this + // read would schedule parity again and violate demand-bound + // read-ahead. + let (healthy_buffers, healthy_errors) = tokio::time::timeout(Duration::from_secs(1), parallel_reader.read()) + .await + .expect("a healthy following stripe must complete without parity fan-out"); + assert!(healthy_errors.iter().take(DATA_SHARDS).all(Option::is_none)); + assert_eq!(healthy_buffers.iter().filter(|buffer| buffer.is_some()).count(), DATA_SHARDS); + assert_eq!(parity_calls.load(Ordering::SeqCst), PARITY_SHARDS); + assert!(!parallel_reader.engaged[2]); + assert!(!parallel_reader.engaged[3]); + + // A later stripe loses data shard 0. The deferred reserves must be + // available again; the second pair of factory calls returns the + // aligned parity bytes for this stripe. + parallel_reader.readers[0] = + Some(BitrotReader::new(TestShardReader::TimedOut, SHARD_SIZE, HashAlgorithm::None, false)); + let (second_buffers, second_errors) = tokio::time::timeout(Duration::from_secs(1), parallel_reader.read()) + .await + .expect("the next degraded stripe must reuse the preserved parity reserve"); + assert!(matches!(&second_errors[0], Some(DiskError::Io(error)) if error.kind() == ErrorKind::TimedOut)); + assert_eq!(second_buffers.iter().filter(|buffer| buffer.is_some()).count(), DATA_SHARDS + 1); + + // Once the parity readers have been persistently engaged, another + // degraded stripe reuses their aligned streams; no new factory + // calls (and therefore no new remote opens) are permitted. + let (third_buffers, third_errors) = tokio::time::timeout(Duration::from_secs(1), parallel_reader.read()) + .await + .expect("persistent parity readers must cover a subsequent degraded stripe"); + assert!(third_errors[0].is_none(), "an already-retired data slot has no new read error"); + assert_eq!(third_buffers.iter().filter(|buffer| buffer.is_some()).count(), DATA_SHARDS + 1); + assert_eq!(parity_calls.load(Ordering::SeqCst), PARITY_SHARDS * 2); + ( + parallel_reader.readers[2].is_some() && parallel_reader.readers[3].is_some(), + (third_buffers, third_errors), + ) + }) + .await; + + assert!(first_parity_reserved); + assert_eq!(parity_calls.load(Ordering::SeqCst), PARITY_SHARDS * 2); + // `second_result` carries the third (persistently degraded) stripe; + // the data slot was already retired by the preceding stripe, so it + // must not emit a fresh timeout or trigger another remote open. + let (buffers, errors) = second_result; + assert!(errors[0].is_none()); + assert_eq!(buffers.iter().filter(|buffer| buffer.is_some()).count(), DATA_SHARDS + 1); + } + + /// A disposable parity hedge is advisory. Its terminal error must not be + /// copied into the stripe error vector, because the emitter treats + /// FileNotFound/FileCorrupt there as an object-level failure even after a + /// healthy data reader has recovered and supplied a complete stripe. + #[tokio::test] + async fn test_demand_bound_disposable_parity_error_does_not_poison_recovered_stripe() { + const BLOCK_SIZE: usize = 64; + const DATA_SHARDS: usize = 2; + const PARITY_SHARDS: usize = 2; + const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS; + + let parity_calls = Arc::new(AtomicUsize::new(0)); + let mut reopeners: Vec>> = vec![None; DATA_SHARDS + PARITY_SHARDS]; + for (idx, slot) in reopeners.iter_mut().enumerate().skip(DATA_SHARDS).take(PARITY_SHARDS) { + let parity_calls = Arc::clone(&parity_calls); + *slot = Some(Arc::new(move |_stripe_index| { + let call = parity_calls.fetch_add(1, Ordering::SeqCst); + let reader = if call == 0 { + TestShardReader::TerminalFileNotFound + } else { + TestShardReader::Ready(Cursor::new(vec![idx as u8; SHARD_SIZE])) + }; + Some(BitrotReader::new(reader, SHARD_SIZE, HashAlgorithm::None, false)) + })); + } + + let slow_until = TokioInstant::now() + Duration::from_millis(150); + let readers = vec![ + Some(BitrotReader::new( + TestShardReader::ReadyAt { + cursor: Cursor::new(vec![0_u8; SHARD_SIZE]), + ready_at: slow_until, + sleep: None, + }, + SHARD_SIZE, + HashAlgorithm::None, + false, + )), + Some(BitrotReader::new( + TestShardReader::Ready(Cursor::new(vec![1_u8; SHARD_SIZE])), + SHARD_SIZE, + HashAlgorithm::None, + false, + )), + Some(BitrotReader::new(TestShardReader::Pending, SHARD_SIZE, HashAlgorithm::None, false)), + Some(BitrotReader::new(TestShardReader::Pending, SHARD_SIZE, HashAlgorithm::None, false)), + ]; + + let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE); + let mut output = Vec::new(); + let (written, error) = with_decode_read_policy(DecodeReadPolicy::DemandBound, async { + erasure + .decode_with_stripe_handles_and_reopeners( + &mut output, + readers, + 0, + BLOCK_SIZE, + BLOCK_SIZE, + None, + Vec::new(), + reopeners, + ) + .await + }) + .await; + + assert_eq!(parity_calls.load(Ordering::SeqCst), PARITY_SHARDS); + assert_eq!(written, BLOCK_SIZE); + assert_eq!(output.len(), BLOCK_SIZE); + assert!(error.is_none(), "a failed disposable hedge must not fail a recovered stripe: {error:?}"); + } + /// Lockstep verification-quorum regression (backlog#1156). When a data shard is /// missing, the hedge must settle only at `data_shards + 1` (decode quorum plus /// a reconstruction-verification source), never at exactly `data_shards` — that diff --git a/crates/ecstore/src/error/mod.rs b/crates/ecstore/src/error/mod.rs index a7e78d972..e7e50deb1 100644 --- a/crates/ecstore/src/error/mod.rs +++ b/crates/ecstore/src/error/mod.rs @@ -228,6 +228,14 @@ pub enum StorageError { /// during quorum aggregation (backlog#1845). #[error("remote rpc client unavailable: {0}")] RemoteClientUnavailable(String), + + /// A peer answered a control-plane RPC but its storage/IAM layer is not + /// initialized yet. Typed form of the legacy "errServerNotInitialized" + /// error_info string (backlog#1845); the wire carries it as + /// `ControlPlaneErrorCode::ControlPlaneErrorNotInitialized` alongside the + /// legacy string for rolling-upgrade compatibility. + #[error("remote peer not initialized")] + RemoteNotInitialized, } impl From for StorageError { @@ -581,6 +589,7 @@ impl Clone for StorageError { limit: *limit, }, StorageError::RemoteClientUnavailable(detail) => StorageError::RemoteClientUnavailable(detail.clone()), + StorageError::RemoteNotInitialized => StorageError::RemoteNotInitialized, } } } @@ -670,6 +679,7 @@ impl StorageError { StorageError::NamespaceLockQuorumUnavailable { .. } => StorageErrorCode::NamespaceLockQuorumUnavailable, StorageError::QuotaExceeded { .. } => StorageErrorCode::QuotaExceeded, StorageError::RemoteClientUnavailable(_) => StorageErrorCode::RemoteClientUnavailable, + StorageError::RemoteNotInitialized => StorageErrorCode::RemoteNotInitialized, } } @@ -800,6 +810,7 @@ impl StorageError { limit: Default::default(), }), StorageErrorCode::RemoteClientUnavailable => Some(StorageError::RemoteClientUnavailable(Default::default())), + StorageErrorCode::RemoteNotInitialized => Some(StorageError::RemoteNotInitialized), } } } @@ -943,7 +954,13 @@ pub fn is_err_operation_canceled(err: &Error) -> bool { #[allow(dead_code, reason = "predicate asserted by this file's tests (backlog#1823)")] pub fn is_err_not_initialized(err: &Error) -> bool { - err.to_string().contains("errServerNotInitialized") || err.to_string().contains("ServerNotInitialized") + // Typed-first: peers at or above the ControlPlaneErrorCode change decode to + // the typed variant. The substring form only matches legacy peers' string + // responses. + // RUSTFS_COMPAT_TODO(not-initialized-error-code-v1): substring fallback for peers that predate the typed wire code. Remove after the minimum supported RustFS peer version always sends error_code. + matches!(err, StorageError::RemoteNotInitialized) + || err.to_string().contains("errServerNotInitialized") + || err.to_string().contains("ServerNotInitialized") } /// Strict "not found" predicate that only matches genuine object/version/volume diff --git a/crates/ecstore/src/io_support/bitrot.rs b/crates/ecstore/src/io_support/bitrot.rs index 88a3da910..371a93560 100644 --- a/crates/ecstore/src/io_support/bitrot.rs +++ b/crates/ecstore/src/io_support/bitrot.rs @@ -346,16 +346,11 @@ impl AsyncRead for DeferredObjectReader { } fn disk_error_to_io_error(err: DiskError) -> io::Error { - let kind = match err { - DiskError::Timeout | DiskError::SourceStalled => io::ErrorKind::TimedOut, - DiskError::DiskNotFound | DiskError::FileNotFound | DiskError::FileVersionNotFound | DiskError::PathNotFound => { - io::ErrorKind::NotFound - } - DiskError::FileCorrupt | DiskError::PartMissingOrCorrupt | DiskError::BitrotHashAlgoInvalid => io::ErrorKind::InvalidData, - DiskError::Io(io_err) => return io_err, - _ => io::ErrorKind::Other, - }; - io::Error::new(kind, err.to_string()) + // Keep the typed disk error attached to deferred-reader failures. The + // decoder uses the marker to retire a stream that can no longer be + // realigned, while quorum reduction still sees Timeout/NotFound instead + // of an opaque `DiskError::Io` wrapper. + crate::disk::error::terminal_read_error_to_io(err) } async fn open_disk_reader( diff --git a/crates/ecstore/src/layout/set_heal.rs b/crates/ecstore/src/layout/set_heal.rs index 702016f24..e32beb84f 100644 --- a/crates/ecstore/src/layout/set_heal.rs +++ b/crates/ecstore/src/layout/set_heal.rs @@ -14,7 +14,7 @@ use crate::disk::{DiskInfo, error::DiskError}; use crate::layout::{endpoints::Endpoints, format::FormatV3}; -use rustfs_common::heal_channel::DriveState; +use rustfs_heal_contracts::heal_channel::DriveState; use rustfs_madmin::heal_commands::HealDriveInfo; pub(crate) fn formats_to_drives_info( diff --git a/crates/ecstore/src/lib.rs b/crates/ecstore/src/lib.rs index da4abbd39..bd2f1fa58 100644 --- a/crates/ecstore/src/lib.rs +++ b/crates/ecstore/src/lib.rs @@ -56,7 +56,6 @@ mod storage_api_contracts; mod store; // pub mod checksum; -mod client; mod event; use rustfs_concurrency::WorkloadAdmissionSnapshotProvider; diff --git a/crates/ecstore/src/memory_observability.rs b/crates/ecstore/src/memory_observability.rs deleted file mode 100644 index bebf7fdce..000000000 --- a/crates/ecstore/src/memory_observability.rs +++ /dev/null @@ -1,33 +0,0 @@ - -/// Check mimalloc arena configuration and log diagnostics -pub fn log_mimalloc_diagnostics() { - #[cfg(feature = "mimalloc")] - { - use rustfs_mimalloc::MiMalloc; - - // Check arena_max_object_size - let arena_max_obj_size = MiMalloc::option_get_size( - rustfs_mimalloc_sys::mi_option_t::mi_option_arena_max_object_size - ); - tracing::info!( - arena_max_object_size_bytes = arena_max_obj_size, - "mimalloc arena_max_object_size" - ); - - // Check if pagemap is enabled - let pagemap_commit = MiMalloc::option_is_enabled( - rustfs_mimalloc_sys::mi_option_t::mi_option_pagemap_commit - ); - tracing::info!( - pagemap_commit = pagemap_commit, - "mimalloc pagemap_commit" - ); - - // Log version - let version = MiMalloc::version(); - tracing::info!( - mimalloc_version = version, - "mimalloc version" - ); - } -} diff --git a/crates/ecstore/src/object_api/mod.rs b/crates/ecstore/src/object_api/mod.rs index a57a7069b..4548d5d94 100644 --- a/crates/ecstore/src/object_api/mod.rs +++ b/crates/ecstore/src/object_api/mod.rs @@ -14,6 +14,8 @@ // #730: object API readers keep staged compatibility paths during facade migration. +pub mod object_api_utils; + use crate::bucket::metadata_sys::get_versioning_config; use crate::bucket::replication::{ DeleteReplicationConfigSnapshot, ReplicateDecision, ReplicationState, ReplicationStatusType, VersionPurgeStatusType, diff --git a/crates/ecstore/src/client/object_api_utils.rs b/crates/ecstore/src/object_api/object_api_utils.rs similarity index 100% rename from crates/ecstore/src/client/object_api_utils.rs rename to crates/ecstore/src/object_api/object_api_utils.rs diff --git a/crates/ecstore/src/object_api/readers.rs b/crates/ecstore/src/object_api/readers.rs index 9fc69118d..8bcc23974 100644 --- a/crates/ecstore/src/object_api/readers.rs +++ b/crates/ecstore/src/object_api/readers.rs @@ -1407,8 +1407,7 @@ fn multipart_part_numbers(parts: &[ObjectPartInfo]) -> Vec { #[cfg(test)] mod tests { use super::*; - use base64::Engine; - use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; + use base64_simd::STANDARD as BASE64_STANDARD; use md5::{Digest, Md5}; use rustfs_utils::http::{SSEC_ALGORITHM_HEADER, SSEC_KEY_MD5_HEADER}; use std::collections::HashMap; @@ -1468,7 +1467,7 @@ mod tests { request: ReadEncryptionRequest<'_>, ) -> std::result::Result, EncryptionResolutionError> { if let Some(encoded) = request.metadata.get(TEST_OBJECT_KEY_HEADER) { - let decoded = BASE64_STANDARD.decode(encoded).map_err(|_| { + let decoded = BASE64_STANDARD.decode_to_vec(encoded).map_err(|_| { EncryptionResolutionError::new(EncryptionResolutionErrorKind::InvalidMetadata, "invalid test object key") })?; let key_bytes = decoded.try_into().map_err(|_| { @@ -1493,7 +1492,7 @@ mod tests { .map_err(|_| { EncryptionResolutionError::new(EncryptionResolutionErrorKind::InvalidRequest, "invalid test encryption key") })?; - let decoded = BASE64_STANDARD.decode(encoded).map_err(|_| { + let decoded = BASE64_STANDARD.decode_to_vec(encoded).map_err(|_| { EncryptionResolutionError::new(EncryptionResolutionErrorKind::InvalidRequest, "invalid test encryption key") })?; let key_bytes = decoded.try_into().map_err(|_| { @@ -1505,7 +1504,7 @@ mod tests { let base_nonce = request .metadata .get(TEST_NONCE_HEADER) - .and_then(|encoded| BASE64_STANDARD.decode(encoded).ok()) + .and_then(|encoded| BASE64_STANDARD.decode_to_vec(encoded).ok()) .and_then(|bytes| bytes.try_into().ok()) .unwrap_or_else(|| fixture_nonce(request.bucket, request.object)); Ok(Some(ReadEncryptionMaterial { @@ -1533,7 +1532,7 @@ mod tests { let mut headers = HeaderMap::new(); headers.insert( TEST_DIRECT_KEY_HEADER, - HeaderValue::from_str(&BASE64_STANDARD.encode(key_bytes)).expect("test key header is valid"), + HeaderValue::from_str(&BASE64_STANDARD.encode_to_string(key_bytes)).expect("test key header is valid"), ); headers } @@ -2439,7 +2438,10 @@ mod tests { user_defined: Arc::new(HashMap::from([ ("X-Amz-Server-Side-Encryption".to_string(), "aws:kms".to_string()), ("X-Amz-Server-Side-Encryption-Iv".to_string(), "AAAAAAAAAAAAAAAA".to_string()), - ("X-Amz-Server-Side-Encryption-Key".to_string(), BASE64_STANDARD.encode([7_u8; 32])), + ( + "X-Amz-Server-Side-Encryption-Key".to_string(), + BASE64_STANDARD.encode_to_string([7_u8; 32]), + ), ("x-rustfs-encryption-original-size".to_string(), "64".to_string()), ])), ..Default::default() @@ -2748,7 +2750,7 @@ mod tests { ("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()), ( "x-amz-server-side-encryption-customer-key-md5".to_string(), - BASE64_STANDARD.encode(md5_bytes(key_bytes)), + BASE64_STANDARD.encode_to_string(md5_bytes(key_bytes)), ), ( "x-amz-server-side-encryption-customer-original-size".to_string(), @@ -2796,11 +2798,11 @@ mod tests { name: object.to_string(), size: encrypted.len() as i64, user_defined: Arc::new(HashMap::from([ - (TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode(object_key)), + (TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode_to_string(object_key)), ("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()), ( "x-amz-server-side-encryption-customer-key-md5".to_string(), - BASE64_STANDARD.encode(md5_bytes(customer_key)), + BASE64_STANDARD.encode_to_string(md5_bytes(customer_key)), ), ( "x-amz-server-side-encryption-customer-original-size".to_string(), @@ -2856,7 +2858,7 @@ mod tests { ("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()), ( "x-amz-server-side-encryption-customer-key-md5".to_string(), - BASE64_STANDARD.encode(md5_bytes(key_bytes)), + BASE64_STANDARD.encode_to_string(md5_bytes(key_bytes)), ), ( "x-amz-server-side-encryption-customer-original-size".to_string(), @@ -3008,13 +3010,13 @@ mod tests { ("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()), ( "x-amz-server-side-encryption-customer-key-md5".to_string(), - BASE64_STANDARD.encode(md5_bytes(key_bytes)), + BASE64_STANDARD.encode_to_string(md5_bytes(key_bytes)), ), ( "x-amz-server-side-encryption-customer-original-size".to_string(), total_plaintext.to_string(), ), - (TEST_NONCE_HEADER.to_string(), BASE64_STANDARD.encode(LEGACY_FIXTURE_BASE_NONCE)), + (TEST_NONCE_HEADER.to_string(), BASE64_STANDARD.encode_to_string(LEGACY_FIXTURE_BASE_NONCE)), ]) } @@ -3751,11 +3753,11 @@ mod tests { name: object.to_string(), size: encrypted.len() as i64, user_defined: Arc::new(HashMap::from([ - (TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode(object_key)), + (TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode_to_string(object_key)), ("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()), ( "x-amz-server-side-encryption-customer-key-md5".to_string(), - BASE64_STANDARD.encode(md5_bytes(customer_key)), + BASE64_STANDARD.encode_to_string(md5_bytes(customer_key)), ), ( "x-amz-server-side-encryption-customer-original-size".to_string(), @@ -3821,7 +3823,7 @@ mod tests { ("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()), ( "x-amz-server-side-encryption-customer-key-md5".to_string(), - BASE64_STANDARD.encode(md5_bytes(key_bytes)), + BASE64_STANDARD.encode_to_string(md5_bytes(key_bytes)), ), ( "x-amz-server-side-encryption-customer-original-size".to_string(), @@ -3964,11 +3966,11 @@ mod tests { ..Default::default() }]), user_defined: Arc::new(HashMap::from([ - (TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode(object_key)), + (TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode_to_string(object_key)), ("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()), ( "x-amz-server-side-encryption-customer-key-md5".to_string(), - BASE64_STANDARD.encode(md5_bytes(customer_key)), + BASE64_STANDARD.encode_to_string(md5_bytes(customer_key)), ), ( "x-amz-server-side-encryption-customer-original-size".to_string(), diff --git a/crates/ecstore/src/object_api/types.rs b/crates/ecstore/src/object_api/types.rs index ef7874ca6..1b3a8dbcb 100644 --- a/crates/ecstore/src/object_api/types.rs +++ b/crates/ecstore/src/object_api/types.rs @@ -281,7 +281,7 @@ pub struct QuotaAdmission { pub struct LifecycleDeleteAllRequest { pub(crate) version_id: Option, pub(crate) delete_marker: bool, - pub(crate) action: rustfs_common::metrics::IlmAction, + pub(crate) action: rustfs_scanner_contracts::metrics::IlmAction, pub(crate) rule_id: String, pub(crate) phase: LifecycleDeleteAllPhase, } diff --git a/crates/ecstore/src/services/metrics_realtime.rs b/crates/ecstore/src/services/metrics_realtime.rs index 290504fcb..c6056414d 100644 --- a/crates/ecstore/src/services/metrics_realtime.rs +++ b/crates/ecstore/src/services/metrics_realtime.rs @@ -18,7 +18,7 @@ use crate::storage_api_contracts::admin::StorageAdminApi; #[cfg(test)] use chrono::Utc; use jiff::Timestamp; -use rustfs_common::{heal_channel::DriveState, metrics::global_metrics}; +use rustfs_heal_contracts::heal_channel::DriveState; use rustfs_io_metrics::internode_metrics::global_internode_metrics; use rustfs_madmin::metrics::{ DiskIOStats, DiskMetric, LastMinute as MadminLastMinute, NetDevLine, NetMetrics, RPCMetrics, RealtimeMetrics, @@ -32,6 +32,7 @@ use rustfs_madmin::metrics::{ ScannerSourceCycleSnapshot as MadminScannerSourceCycleSnapshot, ScannerSourceWorkSnapshot as MadminScannerSourceWorkSnapshot, ScannerUsageFreshnessSnapshot as MadminScannerUsageFreshnessSnapshot, TimedAction as MadminTimedAction, }; +use rustfs_scanner_contracts::metrics::global_metrics; use rustfs_utils::os::get_drive_stats; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; @@ -81,7 +82,7 @@ fn unix_millis_to_jiff_timestamp(millis: u64, fallback: Timestamp) -> Timestamp } } -fn to_madmin_scanner_metrics(metrics: rustfs_common::metrics::ScannerMetricsReport) -> MadminScannerMetrics { +fn to_madmin_scanner_metrics(metrics: rustfs_scanner_contracts::metrics::ScannerMetricsReport) -> MadminScannerMetrics { MadminScannerMetrics { collected_at: metrics.collected_at, current_cycle: metrics.current_cycle, @@ -563,8 +564,8 @@ async fn collect_local_disks_metrics(disks: &HashSet) -> HashMap Option { disks.push(rustfs_madmin::Disk { endpoint: ep.to_string(), state: if online { - rustfs_common::heal_channel::DriveState::Ok.to_string() + rustfs_heal_contracts::heal_channel::DriveState::Ok.to_string() } else { ItemState::Offline.to_string().to_owned() }, diff --git a/crates/ecstore/src/services/tier/test_util.rs b/crates/ecstore/src/services/tier/test_util.rs index d9623684d..3dc112057 100644 --- a/crates/ecstore/src/services/tier/test_util.rs +++ b/crates/ecstore/src/services/tier/test_util.rs @@ -68,7 +68,6 @@ use tokio::io::AsyncReadExt; use tokio::sync::{Mutex, Notify, RwLock}; use uuid::Uuid; -use crate::client::transition_api::{ReadCloser, ReaderImpl}; use crate::disk::endpoint::Endpoint; use crate::disk::format::FormatV3; use crate::disk::{DiskAPI, DiskOption, FORMAT_CONFIG_FILE, RUSTFS_META_BUCKET, STORAGE_FORMAT_FILE, new_disk}; @@ -78,6 +77,7 @@ use crate::services::tier::warm_backend::{ TransitionCandidateProbe, WarmBackend, WarmBackendGetOpts, build_transition_put_options, }; use rustfs_filemeta::FileMeta; +use rustfs_s3_client::transition_api::{ReadCloser, ReaderImpl}; use rustfs_utils::path::path_join_buf; /// One-shot barrier before rejected transition cleanup resolves its ECStore. diff --git a/crates/ecstore/src/services/tier/tier.rs b/crates/ecstore/src/services/tier/tier.rs index eb0108b85..037a2ddfc 100644 --- a/crates/ecstore/src/services/tier/tier.rs +++ b/crates/ecstore/src/services/tier/tier.rs @@ -47,7 +47,6 @@ use tokio::{ }; use tracing::{debug, error, info, warn}; -use crate::client::{admin_handler_utils::AdminError, provider_versions::ProviderVersionCapabilities}; use crate::error::{Error, Result, StorageError}; use crate::services::tier::{ tier_admin::TierCreds, @@ -80,6 +79,7 @@ use crate::{ }; use rustfs_filemeta::FileInfo; use rustfs_rio::HashReader; +use rustfs_s3_client::{admin_handler_utils::AdminError, provider_versions::ProviderVersionCapabilities}; use rustfs_utils::path::{SLASH_SEPARATOR, path_join}; use s3s::S3ErrorCode; @@ -1529,14 +1529,14 @@ impl WarmBackend for SharedWarmBackendProxy { self.0.validate_remote_version_id(remote_version_id) } - async fn put(&self, object: &str, r: crate::client::transition_api::ReaderImpl, length: i64) -> io::Result { + async fn put(&self, object: &str, r: rustfs_s3_client::transition_api::ReaderImpl, length: i64) -> io::Result { self.0.put(object, r, length).await } async fn put_with_meta( &self, object: &str, - r: crate::client::transition_api::ReaderImpl, + r: rustfs_s3_client::transition_api::ReaderImpl, length: i64, meta: HashMap, ) -> io::Result { @@ -1548,7 +1548,7 @@ impl WarmBackend for SharedWarmBackendProxy { object: &str, rv: &str, opts: crate::services::tier::warm_backend::WarmBackendGetOpts, - ) -> io::Result { + ) -> io::Result { self.0.get(object, rv, opts).await } @@ -5778,8 +5778,8 @@ mod tests { // lets us drive `remove`/`verify` through every branch. // --------------------------------------------------------------------- - use crate::client::transition_api::{ReadCloser, ReaderImpl}; use crate::services::tier::warm_backend::{WarmBackend, WarmBackendGetOpts}; + use rustfs_s3_client::transition_api::{ReadCloser, ReaderImpl}; fn empty_mgr() -> TierConfigMgr { TierConfigMgr { diff --git a/crates/ecstore/src/services/tier/tier_handlers.rs b/crates/ecstore/src/services/tier/tier_handlers.rs index 07768c7ae..b68d19886 100644 --- a/crates/ecstore/src/services/tier/tier_handlers.rs +++ b/crates/ecstore/src/services/tier/tier_handlers.rs @@ -12,9 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::client::admin_handler_utils::AdminError; use http::status::StatusCode; use lazy_static::lazy_static; +use rustfs_s3_client::admin_handler_utils::AdminError; lazy_static! { pub static ref ERR_TIER_ALREADY_EXISTS: AdminError = AdminError { diff --git a/crates/ecstore/src/services/tier/tier_mutation_peer.rs b/crates/ecstore/src/services/tier/tier_mutation_peer.rs index e0207f070..a7f261e2f 100644 --- a/crates/ecstore/src/services/tier/tier_mutation_peer.rs +++ b/crates/ecstore/src/services/tier/tier_mutation_peer.rs @@ -22,9 +22,9 @@ use super::tier_mutation_intent::{ MAX_TIER_MUTATION_INTENT_SIZE, TierMutationIntent, TierMutationIntentState, advance_tier_mutation_intent_record_idempotent, load_tier_mutation_intent_record, save_tier_mutation_intent_record_if_absent, }; -use crate::client::admin_handler_utils::AdminError; use crate::error::{Error, StorageError}; use crate::store::ECStore; +use rustfs_s3_client::admin_handler_utils::AdminError; pub const MAX_TIER_MUTATION_PEER_COMMIT_ETAG_SIZE: usize = rustfs_protos::TIER_MUTATION_RPC_MAX_COMMIT_PAYLOAD_SIZE; diff --git a/crates/ecstore/src/services/tier/warm_backend.rs b/crates/ecstore/src/services/tier/warm_backend.rs index 464dd41a9..8e0a59546 100644 --- a/crates/ecstore/src/services/tier/warm_backend.rs +++ b/crates/ecstore/src/services/tier/warm_backend.rs @@ -18,11 +18,6 @@ #![allow(unused_must_use)] #![allow(clippy::all)] -use crate::client::{ - admin_handler_utils::AdminError, - api_put_object::{AdvancedPutOptions, PutObjectOptions}, - transition_api::{ReadCloser, ReaderImpl}, -}; use crate::error::is_err_bucket_not_found; use crate::services::tier::{ tier::{ERR_TIER_INVALID_CONFIG, ERR_TIER_TYPE_UNSUPPORTED}, @@ -41,6 +36,11 @@ use crate::services::tier::{ }; use bytes::Bytes; use http::StatusCode; +use rustfs_s3_client::{ + admin_handler_utils::AdminError, + api_put_object::{AdvancedPutOptions, PutObjectOptions}, + transition_api::{ReadCloser, ReaderImpl}, +}; use rustfs_utils::http::headers::{ CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE, EXPIRES, HeaderExt as _, }; diff --git a/crates/ecstore/src/services/tier/warm_backend_aliyun.rs b/crates/ecstore/src/services/tier/warm_backend_aliyun.rs index ddf283742..410913c14 100644 --- a/crates/ecstore/src/services/tier/warm_backend_aliyun.rs +++ b/crates/ecstore/src/services/tier/warm_backend_aliyun.rs @@ -21,17 +21,17 @@ use std::collections::HashMap; use std::sync::Arc; -use crate::client::{ - admin_handler_utils::AdminError, - api_put_object::PutObjectOptions, - credentials::{Credentials, SignatureType, Static, Value}, - transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, -}; use crate::services::tier::{ tier_config::TierAliyun, warm_backend::{WarmBackend, WarmBackendGetOpts, build_transition_put_options}, warm_backend_s3::WarmBackendS3, }; +use rustfs_s3_client::{ + admin_handler_utils::AdminError, + api_put_object::PutObjectOptions, + credentials::{Credentials, SignatureType, Static, Value}, + transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, +}; use tracing::warn; const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5; diff --git a/crates/ecstore/src/services/tier/warm_backend_azure.rs b/crates/ecstore/src/services/tier/warm_backend_azure.rs index 5dd0b8c67..61b3d2e9e 100644 --- a/crates/ecstore/src/services/tier/warm_backend_azure.rs +++ b/crates/ecstore/src/services/tier/warm_backend_azure.rs @@ -21,17 +21,17 @@ use std::collections::HashMap; use std::sync::Arc; -use crate::client::{ - admin_handler_utils::AdminError, - api_put_object::PutObjectOptions, - credentials::{Credentials, SignatureType, Static, Value}, - transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, -}; use crate::services::tier::{ tier_config::TierAzure, warm_backend::{WarmBackend, WarmBackendGetOpts, build_transition_put_options}, warm_backend_s3::WarmBackendS3, }; +use rustfs_s3_client::{ + admin_handler_utils::AdminError, + api_put_object::PutObjectOptions, + credentials::{Credentials, SignatureType, Static, Value}, + transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, +}; use tracing::warn; const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5; diff --git a/crates/ecstore/src/services/tier/warm_backend_gcs.rs b/crates/ecstore/src/services/tier/warm_backend_gcs.rs index 35aecfb6d..adf6bf11b 100644 --- a/crates/ecstore/src/services/tier/warm_backend_gcs.rs +++ b/crates/ecstore/src/services/tier/warm_backend_gcs.rs @@ -30,15 +30,15 @@ use google_cloud_storage::client::Storage; use google_cloud_storage::client::StorageControl; use std::convert::TryFrom; -use crate::client::{ - admin_handler_utils::AdminError, - api_put_object::PutObjectOptions, - transition_api::{Options, ReadCloser, ReaderImpl}, -}; use crate::services::tier::{ tier_config::TierGCS, warm_backend::{WarmBackend, WarmBackendGetOpts}, }; +use rustfs_s3_client::{ + admin_handler_utils::AdminError, + api_put_object::PutObjectOptions, + transition_api::{Options, ReadCloser, ReaderImpl}, +}; use tracing::warn; const _MAX_PART_SIZE: i64 = 1024 * 1024 * 1024 * 5; diff --git a/crates/ecstore/src/services/tier/warm_backend_huaweicloud.rs b/crates/ecstore/src/services/tier/warm_backend_huaweicloud.rs index e33736750..4e0900fd9 100644 --- a/crates/ecstore/src/services/tier/warm_backend_huaweicloud.rs +++ b/crates/ecstore/src/services/tier/warm_backend_huaweicloud.rs @@ -21,17 +21,17 @@ use std::collections::HashMap; use std::sync::Arc; -use crate::client::{ - admin_handler_utils::AdminError, - api_put_object::PutObjectOptions, - credentials::{Credentials, SignatureType, Static, Value}, - transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, -}; use crate::services::tier::{ tier_config::TierHuaweicloud, warm_backend::{WarmBackend, WarmBackendGetOpts, build_transition_put_options}, warm_backend_s3::WarmBackendS3, }; +use rustfs_s3_client::{ + admin_handler_utils::AdminError, + api_put_object::PutObjectOptions, + credentials::{Credentials, SignatureType, Static, Value}, + transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, +}; use tracing::warn; const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5; diff --git a/crates/ecstore/src/services/tier/warm_backend_minio.rs b/crates/ecstore/src/services/tier/warm_backend_minio.rs index a97a01baf..8205a3e56 100644 --- a/crates/ecstore/src/services/tier/warm_backend_minio.rs +++ b/crates/ecstore/src/services/tier/warm_backend_minio.rs @@ -21,17 +21,17 @@ use std::collections::HashMap; use std::sync::Arc; -use crate::client::{ - admin_handler_utils::AdminError, - api_put_object::PutObjectOptions, - credentials::{Credentials, SignatureType, Static, Value}, - transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, -}; use crate::services::tier::{ tier_config::TierMinIO, warm_backend::{TransitionCandidateProbe, WarmBackend, WarmBackendGetOpts, build_transition_put_options}, warm_backend_s3::WarmBackendS3, }; +use rustfs_s3_client::{ + admin_handler_utils::AdminError, + api_put_object::PutObjectOptions, + credentials::{Credentials, SignatureType, Static, Value}, + transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, +}; use tracing::warn; const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5; diff --git a/crates/ecstore/src/services/tier/warm_backend_r2.rs b/crates/ecstore/src/services/tier/warm_backend_r2.rs index cee1e1108..685c3338e 100644 --- a/crates/ecstore/src/services/tier/warm_backend_r2.rs +++ b/crates/ecstore/src/services/tier/warm_backend_r2.rs @@ -21,17 +21,17 @@ use std::collections::HashMap; use std::sync::Arc; -use crate::client::{ - admin_handler_utils::AdminError, - api_put_object::PutObjectOptions, - credentials::{Credentials, SignatureType, Static, Value}, - transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, -}; use crate::services::tier::{ tier_config::TierR2, warm_backend::{TransitionCandidateProbe, WarmBackend, WarmBackendGetOpts, build_transition_put_options}, warm_backend_s3::WarmBackendS3, }; +use rustfs_s3_client::{ + admin_handler_utils::AdminError, + api_put_object::PutObjectOptions, + credentials::{Credentials, SignatureType, Static, Value}, + transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, +}; use tracing::warn; const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5; diff --git a/crates/ecstore/src/services/tier/warm_backend_rustfs.rs b/crates/ecstore/src/services/tier/warm_backend_rustfs.rs index 503f1238e..dc1f4aec5 100644 --- a/crates/ecstore/src/services/tier/warm_backend_rustfs.rs +++ b/crates/ecstore/src/services/tier/warm_backend_rustfs.rs @@ -21,17 +21,17 @@ use std::collections::HashMap; use std::sync::Arc; -use crate::client::{ - admin_handler_utils::AdminError, - api_put_object::PutObjectOptions, - credentials::{Credentials, SignatureType, Static, Value}, - transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, -}; use crate::services::tier::{ tier_config::TierRustFS, warm_backend::{TransitionCandidateProbe, WarmBackend, WarmBackendGetOpts, build_transition_put_options}, warm_backend_s3::WarmBackendS3, }; +use rustfs_s3_client::{ + admin_handler_utils::AdminError, + api_put_object::PutObjectOptions, + credentials::{Credentials, SignatureType, Static, Value}, + transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, +}; const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5; const MAX_PARTS_COUNT: i64 = 10000; diff --git a/crates/ecstore/src/services/tier/warm_backend_s3.rs b/crates/ecstore/src/services/tier/warm_backend_s3.rs index 814947987..55a29418c 100644 --- a/crates/ecstore/src/services/tier/warm_backend_s3.rs +++ b/crates/ecstore/src/services/tier/warm_backend_s3.rs @@ -22,7 +22,15 @@ use std::collections::HashMap; use std::sync::Arc; use url::Url; -use crate::client::{ +use crate::services::tier::{ + tier_config::TierS3, + warm_backend::{ + TransitionCandidateIdentity, TransitionCandidateProbe, TransitionCandidateReconciler, WarmBackend, WarmBackendGetOpts, + build_transition_put_options, + }, +}; +use http::HeaderMap; +use rustfs_s3_client::{ api_get_options::GetObjectOptions, api_list::ListObjectsOptions, api_put_object::PutObjectOptions, @@ -33,14 +41,6 @@ use crate::client::{ transition_api::{BucketLookupType, Options, TransitionClient, TransitionCore}, transition_api::{ReadCloser, ReaderImpl}, }; -use crate::services::tier::{ - tier_config::TierS3, - warm_backend::{ - TransitionCandidateIdentity, TransitionCandidateProbe, TransitionCandidateReconciler, WarmBackend, WarmBackendGetOpts, - build_transition_put_options, - }, -}; -use http::HeaderMap; use rustfs_utils::egress::validate_outbound_url; use rustfs_utils::path::SLASH_SEPARATOR; use s3s::dto::BucketVersioningStatus; @@ -379,7 +379,7 @@ impl TransitionCandidateVersions { #[cfg(test)] mod tests { use super::*; - use crate::client::api_s3_datatypes::{ListVersionsResult, Version}; + use rustfs_s3_client::api_s3_datatypes::{ListVersionsResult, Version}; #[tokio::test] async fn new_rejects_loopback_endpoint_before_network_setup() { diff --git a/crates/ecstore/src/services/tier/warm_backend_tencent.rs b/crates/ecstore/src/services/tier/warm_backend_tencent.rs index 9b5fa33a9..3d7e856e4 100644 --- a/crates/ecstore/src/services/tier/warm_backend_tencent.rs +++ b/crates/ecstore/src/services/tier/warm_backend_tencent.rs @@ -21,17 +21,17 @@ use std::collections::HashMap; use std::sync::Arc; -use crate::client::{ - admin_handler_utils::AdminError, - api_put_object::PutObjectOptions, - credentials::{Credentials, SignatureType, Static, Value}, - transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, -}; use crate::services::tier::{ tier_config::TierTencent, warm_backend::{WarmBackend, WarmBackendGetOpts, build_transition_put_options}, warm_backend_s3::WarmBackendS3, }; +use rustfs_s3_client::{ + admin_handler_utils::AdminError, + api_put_object::PutObjectOptions, + credentials::{Credentials, SignatureType, Static, Value}, + transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore}, +}; use tracing::warn; const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5; diff --git a/crates/ecstore/src/services/tier/warm_backend_wasabi.rs b/crates/ecstore/src/services/tier/warm_backend_wasabi.rs index cb367bcdc..2311e6576 100644 --- a/crates/ecstore/src/services/tier/warm_backend_wasabi.rs +++ b/crates/ecstore/src/services/tier/warm_backend_wasabi.rs @@ -21,14 +21,12 @@ use std::{ use s3s::header::{X_AMZ_DELETE_MARKER, X_AMZ_VERSION_ID}; use uuid::Uuid; -use crate::{ - client::transition_api::{BucketLookupType, ReadCloser, ReaderImpl}, - services::tier::{ - tier_config::{TierS3, TierWasabi}, - warm_backend::{WarmBackend, WarmBackendGetOpts}, - warm_backend_s3::WarmBackendS3, - }, +use crate::services::tier::{ + tier_config::{TierS3, TierWasabi}, + warm_backend::{WarmBackend, WarmBackendGetOpts}, + warm_backend_s3::WarmBackendS3, }; +use rustfs_s3_client::transition_api::{BucketLookupType, ReadCloser, ReaderImpl}; const WASABI_VERSIONING_DRIFT_ERROR: &str = "Wasabi tier bucket versioning changed after configuration"; @@ -184,7 +182,7 @@ impl WarmBackend for WarmBackendWasabi { #[cfg(test)] mod tests { use super::*; - use crate::client::{ + use rustfs_s3_client::{ credentials::{Credentials, SignatureType, Static, Value}, transition_api::{Options, TransitionCore}, }; @@ -221,7 +219,7 @@ mod tests { async fn backend_for_endpoint(endpoint: &str, max_retries: i64) -> WarmBackendWasabi { let client = Arc::new( - crate::client::transition_api::TransitionClient::new( + rustfs_s3_client::transition_api::TransitionClient::new( endpoint, Options { creds: Credentials::new(Static(Value { @@ -653,7 +651,7 @@ mod tests { assert_ne!(read, 0, "connection closed before request headers were received"); request.extend_from_slice(&buffer[..read]); } - let body = vec![b'x'; crate::client::transition_api::MAX_S3_ERROR_RESPONSE_SIZE + 1]; + let body = vec![b'x'; rustfs_s3_client::transition_api::MAX_S3_ERROR_RESPONSE_SIZE + 1]; let head = format!( "HTTP/1.1 500 Internal Server Error\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() diff --git a/crates/ecstore/src/set_disk/core/io_primitives.rs b/crates/ecstore/src/set_disk/core/io_primitives.rs index 2cc3935a6..30b221256 100644 --- a/crates/ecstore/src/set_disk/core/io_primitives.rs +++ b/crates/ecstore/src/set_disk/core/io_primitives.rs @@ -22,20 +22,47 @@ //! byte-identical to the pre-move sources; only the module header //! (`use super::*;` -> `use super::super::*;`) and item visibility change. -use super::super::*; +#[cfg(test)] +use super::super::ENV_RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE; +#[cfg(test)] +use super::super::ENV_RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT; +#[cfg(test)] +use super::super::ENV_RUSTFS_GET_METADATA_EARLY_STOP_ENABLE; +#[cfg(test)] +use super::super::ENV_RUSTFS_GET_METADATA_SLOWTAIL_FAULT_BUCKET; +#[cfg(test)] +use super::super::ENV_RUSTFS_GET_METADATA_SLOWTAIL_FAULT_DELAY_MS; +#[cfg(test)] +use super::super::ENV_RUSTFS_GET_METADATA_SLOWTAIL_FAULT_DISKS; +#[cfg(test)] +use super::super::ENV_RUSTFS_GET_METADATA_SLOWTAIL_FAULT_OBJECT_PREFIX; +#[cfg(test)] +use super::super::get_metadata_slowtail_fault_delay; +use super::super::{ + Bytes, CHECK_PART_DISK_NOT_FOUND, DeleteOptions, DiskError, DiskStore, EVENT_SET_DISK_RENAME_TAIL_DRAIN_FAILED, + EVENT_SET_DISK_WRITE, Error, FileInfo, FileMeta, FileMetaShallowVersion, GetCodecStreamingFallbackReason, + GetObjectMetadataCacheEntry, HTTPPreconditions, HashAlgorithm, HealAdmissionResult, HealChannelPriority, HealRequestSource, + LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_SET_DISK, MultipartWriteQuorumContext, OBJECT_OP_IGNORED_ERRS, ObjectOptions, + ObjectPartInfo, OffsetDateTime, RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, RawFileInfo, ReadMultipleReq, + ReadMultipleResp, ReadOptions, Result, SLASH_SEPARATOR, STORAGE_FORMAT_FILE, SetDisks, SnapshotLeaseToken, StorageError, + UpdateMetadataOpts, Uuid, build_inline_bitrot_readers_from_refs, can_try_inline_data_shards_direct, + capacity_scope_from_disks, coding, collect_inline_data_shard_fileinfos_by_index_or_reason, current_dirty_generation, debug, + disk, file_info_is_valid_for_metadata, get_metadata_slowtail_fault_request, info, inline_erasure_shard_file_offset, + inline_erasure_shard_size, is_err_object_not_found, is_err_version_not_found, is_get_metadata_data_read_early_stop_enabled, + is_get_metadata_early_stop_bounded_fanout_enabled, is_get_metadata_early_stop_enabled, is_object_dangling, + is_version_early_stop_enabled, issue3031_diag_enabled, join_all, join_errs, log_multipart_write_quorum_failure, + merge_file_meta_versions, path_join_buf, record_global_dirty_scope, reduce_read_quorum_errs, reduce_write_quorum_errs, + send_heal_request_with_admission, should_prevent_write, to_object_err, try_read_inline_data_shards_direct, warn, +}; +#[cfg(test)] +use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE; +#[cfg(test)] +use crate::diagnostics::get::GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_IDENTITY_MISMATCH; +#[cfg(test)] +use crate::diagnostics::get::GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_PAYLOAD; use crate::diagnostics::get::{ - GET_DIRECT_MEMORY_SUBPATH_DISK_DATA_BLOCKS, GET_DIRECT_MEMORY_SUBPATH_INLINE_BUFFERED, GET_METADATA_CACHE_DECISION_HIT, - GET_METADATA_CACHE_DECISION_MISS, GET_METADATA_CACHE_DECISION_REJECT, GET_METADATA_CACHE_DECISION_SKIP, - GET_METADATA_CACHE_REASON_DATA_MOVEMENT, GET_METADATA_CACHE_REASON_DELETE_MARKER, GET_METADATA_CACHE_REASON_DIST_ERASURE, - GET_METADATA_CACHE_REASON_INCL_FREE_VERSIONS, GET_METADATA_CACHE_REASON_INSUFFICIENT_CACHED_QUORUM, - GET_METADATA_CACHE_REASON_META_BUCKET, GET_METADATA_CACHE_REASON_NO_LOCK, GET_METADATA_CACHE_REASON_NOT_FOUND_OR_EXPIRED, - GET_METADATA_CACHE_REASON_NOT_READ_DATA, GET_METADATA_CACHE_REASON_PART_NUMBER, - GET_METADATA_CACHE_REASON_RAW_DATA_MOVEMENT_READ, GET_METADATA_CACHE_REASON_USABLE, GET_METADATA_CACHE_REASON_VERSION_ID, - GET_METADATA_CACHE_REASON_VERSION_SUSPENDED, GET_METADATA_CACHE_REASON_VERSIONED, GET_METADATA_EARLY_STOP_REASON_CONFLICTING_METADATA, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_BODY_VERIFY, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_DELETED, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_GEOMETRY, - GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_IDENTITY_MISMATCH, - GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_PAYLOAD, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_SHARD, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_NOT_INLINE, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_PART_SHAPE, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_REMOTE, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_SIZE, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_TRANSFORMED, @@ -45,16 +72,26 @@ use crate::diagnostics::get::{ GET_METADATA_EARLY_STOP_REASON_VERSION_MATCH_QUORUM, GET_METADATA_EARLY_STOP_REASON_VERSION_NOT_FOUND, GET_METADATA_RESPONSE_CORRUPT, GET_METADATA_RESPONSE_DISK_NOT_FOUND, GET_METADATA_RESPONSE_ERROR, GET_METADATA_RESPONSE_IGNORED, GET_METADATA_RESPONSE_NOT_FOUND, GET_METADATA_RESPONSE_TIMEOUT, GET_METADATA_RESPONSE_VALID, - GET_METADATA_RESPONSE_VERSION_NOT_FOUND, GET_OBJECT_PATH_CODEC_STREAMING, GET_OBJECT_PATH_DIRECT_MEMORY, - GET_OBJECT_PATH_INTERNAL_META, GET_OBJECT_PATH_LEGACY_DUPLEX, GET_OBJECT_PATH_SET_DISK, GET_STAGE_DECODE, - GET_STAGE_METADATA_CACHE_LOOKUP, GET_STAGE_METADATA_RESOLVE, GET_STAGE_RANGE, GET_STAGE_READER_SETUP, - GET_STAGE_READER_SETUP_DROP_PENDING, GET_STAGE_READER_SETUP_SCHEDULE, GET_STAGE_READER_SETUP_WAIT_QUORUM, - GET_STAGE_READER_TASK_BITROT_READER_INIT, GET_STAGE_READER_TASK_FILE_OPEN, GET_STAGE_READER_TASK_READER_CONSTRUCTION, - GetObjectFailureReason, classify_disk_error, get_stage_timer_if_enabled, record_get_object_pipeline_failure, - record_get_object_pipeline_failure_for_path, record_get_stage_duration_if_enabled, + GET_METADATA_RESPONSE_VERSION_NOT_FOUND, GET_OBJECT_PATH_DIRECT_MEMORY, GET_OBJECT_PATH_INTERNAL_META, + GET_OBJECT_PATH_LEGACY_DUPLEX, GET_STAGE_READER_SETUP_DROP_PENDING, GET_STAGE_READER_SETUP_SCHEDULE, + GET_STAGE_READER_SETUP_WAIT_QUORUM, GET_STAGE_READER_TASK_BITROT_READER_INIT, GET_STAGE_READER_TASK_FILE_OPEN, + GET_STAGE_READER_TASK_READER_CONSTRUCTION, get_stage_timer_if_enabled, record_get_stage_duration_if_enabled, }; -use crate::disk::disk_store::{DiskStoreRenameDataExt, get_drive_metadata_timeout}; +#[cfg(test)] +use crate::disk::CHECK_PART_FILE_NOT_FOUND; +use crate::disk::DiskAPI; +#[cfg(test)] +use crate::disk::DiskOption; +#[cfg(test)] +use crate::disk::RUSTFS_META_TMP_BUCKET; +use crate::disk::disk_store::get_drive_metadata_timeout; +#[cfg(test)] +use crate::disk::endpoint::Endpoint; +#[cfg(test)] +use crate::disk::format::FormatV3; use crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX; +#[cfg(test)] +use crate::disk::new_disk; use crate::disk::{ BATCH_READ_VERSION_MAX_ITEMS, BatchReadVersionItem, BatchReadVersionReq, BatchReadVersionResp, DataDirDeleteStatus, Disk, OldCurrentSize, PART_TRANSACTION_NEW_META, PART_TRANSACTION_OLD_META, PART_TRANSACTION_ROLLBACK, PartTransactionAction, @@ -65,9 +102,11 @@ use crate::io_support::bitrot::ShardReader; use crate::io_support::bitrot::{ BitrotReaderStageMetrics, DeferredReaderStripeHandle, adjust_shard_read_params, create_bitrot_reader_from_bytes_with_stage_metrics, create_deferred_bitrot_reader_with_stripe_handle, - object_mmap_read_enabled, object_mmap_read_max_length, + object_mmap_read_max_length, }; +use crate::set_disk::runtime_sources; use crate::set_disk::shard_source::ShardReadCost; +use crate::storage_api_contracts::object::ObjectOperations; use futures::FutureExt as _; use futures::stream::{FuturesUnordered, StreamExt}; use metrics::counter; @@ -281,6 +320,9 @@ async fn flush_read_version_coalescer_pending( return; } + // Only the #[cfg(test)] counter-recording block below reads this. + #[cfg(not(test))] + let _ = lane_key; #[cfg(test)] { let mut observed_paths = HashSet::new(); @@ -1364,7 +1406,7 @@ pub(in crate::set_disk) enum ReadRepairAdmissionOutcome { pub(in crate::set_disk) type ReadRepairAdmissionFuture = Pin + Send>>; pub(in crate::set_disk) type ReadRepairAdmissionSubmitter = - fn(rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture; + fn(rustfs_heal_contracts::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture; pub(in crate::set_disk) struct ReadRepairHealSubmission<'a> { pub(in crate::set_disk) bucket: &'a str, @@ -1385,7 +1427,7 @@ pub(in crate::set_disk) struct ReadRepairHealSubmission<'a> { } pub(in crate::set_disk) fn send_read_repair_heal_request( - request: rustfs_common::heal_channel::HealChannelRequest, + request: rustfs_heal_contracts::heal_channel::HealChannelRequest, ) -> ReadRepairAdmissionFuture { Box::pin(async { match send_heal_request_with_admission(request).await { @@ -1453,7 +1495,7 @@ pub(in crate::set_disk) async fn submit_read_repair_heal_with_submitter( let _ = rustfs_common::mrf_channel::try_send_mrf_intent_typed(kind, bucket, object, version_uuid, Some(scope)); } - let mut request = rustfs_common::heal_channel::create_heal_request_with_options( + let mut request = rustfs_heal_contracts::heal_channel::create_heal_request_with_options( bucket.to_string(), Some(object.to_string()), false, @@ -1517,6 +1559,7 @@ pub(in crate::set_disk) async fn submit_read_repair_heal_with_submitter( } pub(in crate::set_disk) type ObjectBitrotReader = BitrotReader; +pub(in crate::set_disk) type DeferredReaderReopener = crate::erasure::coding::decode::DeferredReaderReopener; pub(in crate::set_disk) type BitrotReaderTask<'a> = Pin, DiskError>)> + Send + 'a>>; @@ -1533,6 +1576,10 @@ pub(in crate::set_disk) struct BitrotReaderSetup { /// readers. The lockstep GET decode uses them to open a parity shard /// aligned to the stripe where a data shard failed (backlog#923). pub(in crate::set_disk) deferred_stripe_handles: Vec>, + /// Factories for a fresh, stripe-aligned parity reader. CopySource hedges + /// use these disposable readers so an abandoned hedge leaves the original + /// deferred reserve untouched. + pub(in crate::set_disk) deferred_reopeners: Vec>, pub(in crate::set_disk) errors: Vec>, pub(in crate::set_disk) scheduled: Vec, pub(in crate::set_disk) attempted: Vec, @@ -1595,6 +1642,16 @@ pub(in crate::set_disk) fn get_bitrot_reader_setup_strategy( mode: BitrotReaderSetupMode, prefer_data_blocks_first: bool, ) -> BitrotReaderSetupStrategy { + // CopyObject holds the source reader behind a backpressured destination. + // Keep its setup demand-bound even when an operator has retained the + // legacy all-shards environment setting for ordinary GETs. + if matches!( + crate::set_disk::get_object_read_policy(), + crate::set_disk::GetObjectReadPolicy::CopySource + ) { + return BitrotReaderSetupStrategy::DataBlocksFirst; + } + match mode { BitrotReaderSetupMode::ReadQuorum if prefer_data_blocks_first @@ -1620,6 +1677,7 @@ impl BitrotReaderSetup { Self { readers: (0..shards).map(|_| None).collect(), deferred_stripe_handles: (0..shards).map(|_| None).collect(), + deferred_reopeners: (0..shards).map(|_| None).collect(), errors: vec![Some(DiskError::DiskNotFound); shards], scheduled: vec![false; shards], attempted: vec![false; shards], @@ -1814,6 +1872,41 @@ pub(in crate::set_disk) fn next_unscheduled_reader_index( .find(|idx| !setup.scheduled[*idx]) } +/// Build a cloneable opener for an unopened deferred shard. The returned +/// reader is aligned to the requested stripe before its first poll, while the +/// source reader created during setup remains untouched as a reserve. +#[allow(clippy::too_many_arguments)] +fn deferred_reader_reopener( + inline_data: Option, + disk: Option, + bucket: &str, + path: &str, + read_offset: usize, + read_length: usize, + shard_size: usize, + checksum_algo: HashAlgorithm, + skip_verify_bitrot: bool, + use_mmap_read: bool, +) -> DeferredReaderReopener { + let bucket = bucket.to_owned(); + let path = path.to_owned(); + Arc::new(move |stripe_index| { + let (reader, handle) = create_deferred_bitrot_reader_with_stripe_handle( + inline_data.clone(), + disk.clone(), + &bucket, + &path, + read_offset, + read_length, + shard_size, + checksum_algo.clone(), + skip_verify_bitrot, + use_mmap_read, + ); + handle.advance_stripes(stripe_index).then_some(reader) + }) +} + #[allow(clippy::too_many_arguments)] pub(in crate::set_disk) fn fill_deferred_bitrot_readers( setup: &mut BitrotReaderSetup, @@ -1836,6 +1929,15 @@ pub(in crate::set_disk) fn fill_deferred_bitrot_readers( return; } + // Only CopySource uses disposable, stripe-aligned reopeners. Ordinary GET + // readers use the existing deferred handle and should not retain one + // heap-allocated closure (plus cloned path/disk state) for every parity + // slot. + let copy_source_demand_bound = matches!( + crate::set_disk::get_object_read_policy(), + crate::set_disk::GetObjectReadPolicy::CopySource + ); + for idx in 0..disks.len() { if setup.attempted[idx] { continue; @@ -1849,6 +1951,20 @@ pub(in crate::set_disk) fn fill_deferred_bitrot_readers( let disk = disks[idx].clone(); let data_dir = files[idx].data_dir.unwrap_or_default(); let path = format!("{object}/{data_dir}/part.{part_number}"); + let reopener = copy_source_demand_bound.then(|| { + deferred_reader_reopener( + inline_data.clone(), + disk.clone(), + bucket, + &path, + read_offset, + read_length, + shard_size, + checksum_algo.clone(), + skip_verify_bitrot, + use_mmap_read, + ) + }); let (reader, stripe_handle) = create_deferred_bitrot_reader_with_stripe_handle( inline_data, disk, @@ -1862,6 +1978,7 @@ pub(in crate::set_disk) fn fill_deferred_bitrot_readers( use_mmap_read, ); setup.retain_deferred_reader(idx, reader, stripe_handle); + setup.deferred_reopeners[idx] = reopener; } // With the data-shards-only lockstep gate on (backlog#923), the GET decode @@ -1887,6 +2004,20 @@ pub(in crate::set_disk) fn fill_deferred_bitrot_readers( let disk = disks[idx].clone(); let data_dir = files[idx].data_dir.unwrap_or_default(); let path = format!("{object}/{data_dir}/part.{part_number}"); + let reopener = copy_source_demand_bound.then(|| { + deferred_reader_reopener( + inline_data.clone(), + disk.clone(), + bucket, + &path, + read_offset, + read_length, + shard_size, + checksum_algo.clone(), + skip_verify_bitrot, + use_mmap_read, + ) + }); let (reader, stripe_handle) = create_deferred_bitrot_reader_with_stripe_handle( inline_data, disk, @@ -1901,6 +2032,7 @@ pub(in crate::set_disk) fn fill_deferred_bitrot_readers( ); setup.readers[idx] = Some(reader); setup.deferred_stripe_handles[idx] = Some(stripe_handle); + setup.deferred_reopeners[idx] = reopener; } } @@ -2210,6 +2342,10 @@ pub(in crate::set_disk) async fn create_bitrot_readers_until_quorum_with_prefere let strategy = get_bitrot_reader_setup_strategy(mode, prefer_data_blocks_first); if use_mmap_read + && !matches!( + crate::set_disk::get_object_read_policy(), + crate::set_disk::GetObjectReadPolicy::CopySource + ) && let Some(mut setup) = try_create_bitrot_readers_via_batch_pread( files, disks, @@ -3580,7 +3716,7 @@ pub(in crate::set_disk) async fn finish_rename_tail_heal< tail_drain: tokio::task::JoinHandle>, guard_release: tokio::sync::oneshot::Receiver, guards: Guards, - request: rustfs_common::heal_channel::HealChannelRequest, + request: rustfs_heal_contracts::heal_channel::HealChannelRequest, finalize: Finalize, cleanup: Cleanup, submit: Submit, @@ -3590,7 +3726,7 @@ pub(in crate::set_disk) async fn finish_rename_tail_heal< FinalizeFuture: Future + Send, Cleanup: FnOnce(Guards, Vec) -> CleanupFuture + Send, CleanupFuture: Future + Send, - Submit: FnOnce(rustfs_common::heal_channel::HealChannelRequest) -> SubmitFuture + Send, + Submit: FnOnce(rustfs_heal_contracts::heal_channel::HealChannelRequest) -> SubmitFuture + Send, SubmitFuture: Future + Send, { let (needs_heal, tail_cleanup, tail_complete) = match tail_drain.await { @@ -4939,16 +5075,17 @@ impl SetDisks { // reclaim_orphan_data_dirs. Reuses the existing heal channel, which // deduplicates and back-pressures via admission; failures only drop // the return value (same shape as multipart's existing heal enqueue). - let _ = - rustfs_common::heal_channel::send_heal_request(rustfs_common::heal_channel::create_heal_request_with_options( + let _ = rustfs_heal_contracts::heal_channel::send_heal_request( + rustfs_heal_contracts::heal_channel::create_heal_request_with_options( bucket.to_string(), Some(object.to_string()), false, - Some(rustfs_common::heal_channel::HealChannelPriority::Normal), + Some(rustfs_heal_contracts::heal_channel::HealChannelPriority::Normal), Some(self.pool_index), Some(self.set_index), - )) - .await; + ), + ) + .await; } } @@ -6667,7 +6804,7 @@ mod tests { use super::*; use std::io::Cursor; use tempfile::TempDir; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::io::AsyncReadExt; #[test] fn write_precondition_lookup_errors_fail_closed_unless_absence_is_known() { @@ -6800,18 +6937,24 @@ mod tests { write_raw_file_meta_unchecked(disk, bucket, object, metadata).await; } - fn failed_read_repair_submitter(_request: rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture { + fn failed_read_repair_submitter( + _request: rustfs_heal_contracts::heal_channel::HealChannelRequest, + ) -> ReadRepairAdmissionFuture { Box::pin(async { ReadRepairAdmissionOutcome::Failed("injected submit failure".to_string()) }) } - fn accepted_read_repair_submitter(_request: rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture { + fn accepted_read_repair_submitter( + _request: rustfs_heal_contracts::heal_channel::HealChannelRequest, + ) -> ReadRepairAdmissionFuture { Box::pin(async { ReadRepairAdmissionOutcome::Response(HealAdmissionResult::Accepted) }) } - fn dropped_read_repair_submitter(_request: rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture { + fn dropped_read_repair_submitter( + _request: rustfs_heal_contracts::heal_channel::HealChannelRequest, + ) -> ReadRepairAdmissionFuture { Box::pin(async { ReadRepairAdmissionOutcome::Response(HealAdmissionResult::Dropped( - rustfs_common::heal_channel::HealAdmissionDropReason::PolicyDropped, + rustfs_heal_contracts::heal_channel::HealAdmissionDropReason::PolicyDropped, )) }) } @@ -8853,7 +8996,7 @@ mod tests { tail_drain, released, (), - rustfs_common::heal_channel::HealChannelRequest::default(), + rustfs_heal_contracts::heal_channel::HealChannelRequest::default(), move || async move { *finalize_captured.lock().expect("finalize recorder should not poison") = true; }, diff --git a/crates/ecstore/src/set_disk/ctx.rs b/crates/ecstore/src/set_disk/ctx.rs index 3b7f681b3..a2af75199 100644 --- a/crates/ecstore/src/set_disk/ctx.rs +++ b/crates/ecstore/src/set_disk/ctx.rs @@ -23,7 +23,7 @@ //! This module only establishes the borrow handle. It moves no trait impl and //! changes no runtime behavior. -use super::*; +use super::{Arc, DiskStore, Endpoint, FormatV3, LockClient, RwLock, SetDisks}; /// Lightweight, `Copy` handle borrowing the shared [`SetDisks`] core state. /// diff --git a/crates/ecstore/src/set_disk/metadata.rs b/crates/ecstore/src/set_disk/metadata.rs index 3842e516f..8b88e590f 100644 --- a/crates/ecstore/src/set_disk/metadata.rs +++ b/crates/ecstore/src/set_disk/metadata.rs @@ -12,8 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -use super::*; +use super::{ + Bytes, DATA_MOVEMENT_MULTIPART_PREFIX, DiskError, DiskStore, FileInfo, HashMap, HashSet, OBJECT_OP_IGNORED_ERRS, ObjProps, + OffsetDateTime, SetDisks, Sha256, TRANSITION_COMPLETE, Uuid, debug, disk, error, file_info_is_valid_for_metadata, hex, + reduce_read_quorum_errs, warn, +}; +#[cfg(test)] +use crate::disk::DiskOption; +#[cfg(test)] +use crate::disk::endpoint::Endpoint; +#[cfg(test)] +use crate::disk::new_disk; use rustfs_utils::http; +use sha2::Digest; #[derive(Clone, Copy)] struct FileInfoIdentityGroup { diff --git a/crates/ecstore/src/set_disk/mod.rs b/crates/ecstore/src/set_disk/mod.rs index 1909484c4..9eeb72373 100644 --- a/crates/ecstore/src/set_disk/mod.rs +++ b/crates/ecstore/src/set_disk/mod.rs @@ -38,24 +38,15 @@ //! read primitives it drives. //! - `metadata.rs`, `replication.rs`, `shard_source.rs` — supporting helpers. -// #730: SetDisks still hosts staged read/heal/write migration helpers. -#![allow(unused_imports)] -#![allow(unused_variables)] - use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE; use crate::bucket::metadata_sys; use crate::bucket::metadata_sys::ObjectLockConfigState; use crate::bucket::object_lock::objectlock_sys::{ - check_object_lock_for_deletion_with_config, check_object_lock_for_deletion_with_state, check_retention_for_modification, - replication_write_may_pass_worm_gate, + check_object_lock_for_deletion_with_state, check_retention_for_modification, replication_write_may_pass_worm_gate, }; -use crate::bucket::replication::{ - ReplicateDecision, ReplicationObjectBridge, ReplicationState, ReplicationStatusType, VersionPurgeStatusType, - replication_state_to_filemeta, -}; -use crate::bucket::versioning::VersioningApi; -use crate::bucket::versioning_sys::BucketVersioningSys; -use crate::client::{object_api_utils::get_raw_etag, transition_api::ObjectReader, transition_api::ReaderImpl}; +#[cfg(test)] +use crate::bucket::replication::ReplicationState; +use crate::bucket::replication::{ReplicateDecision, ReplicationObjectBridge, ReplicationStatusType, VersionPurgeStatusType}; use crate::cluster::rpc::heal_bucket_local_on_disks; use crate::data_usage::record_compression_total_memory; use crate::diagnostics::get::{ @@ -74,9 +65,11 @@ use crate::disk::error_reduce::{ BUCKET_OP_IGNORED_ERRS, OBJECT_OP_IGNORED_ERRS, build_write_quorum_failure_summary, count_errs, reduce_read_quorum_errs, reduce_write_quorum_errs, }; +#[cfg(test)] +use crate::disk::has_part_err; use crate::disk::{ self, CHECK_PART_DISK_NOT_FOUND, CHECK_PART_FILE_CORRUPT, CHECK_PART_FILE_NOT_FOUND, CHECK_PART_SUCCESS, CHECK_PART_UNKNOWN, - conv_part_err_to_int, has_part_err, + conv_part_err_to_int, }; use crate::disk::{STORAGE_FORMAT_FILE, count_part_not_success}; use crate::erasure::codec::bridge::{ @@ -88,17 +81,17 @@ use crate::error::{GenericError, ObjectApiError, is_err_object_not_found}; use crate::io_support::bitrot::{create_bitrot_reader, create_bitrot_reader_from_bytes, create_bitrot_writer}; use crate::object_api::ObjectOptions; use crate::object_api::get_object_body_cache_hook; +use crate::object_api::object_api_utils::get_raw_etag; use crate::runtime::instance::{InstanceContext, bootstrap_ctx}; use crate::runtime::sources as runtime_sources; -use crate::services::batch_processor::AsyncBatchProcessor; +#[cfg(test)] +use crate::storage_api_contracts::multipart::MultipartOperations; use crate::storage_api_contracts::{ bucket::{BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, MakeBucketOptions}, list::{StorageListObjectVersionsInfo, StorageListObjectsV2Info, StorageObjectInfoOrErr, StorageWalkOptions}, - multipart::{ - CompletePart, ListMultipartsInfo, ListPartsInfo, MultipartInfo, MultipartOperations as _, MultipartUploadResult, PartInfo, - }, + multipart::{CompletePart, ListMultipartsInfo, ListPartsInfo, MultipartInfo, MultipartUploadResult, PartInfo}, namespace::NamespaceLocking as _, - object::{DeleteAccounting, DeletedObject, HTTPPreconditions, ObjectIO as _, ObjectOperations as _, ObjectToDelete}, + object::{DeleteAccounting, DeletedObject, HTTPPreconditions, ObjectIO as _, ObjectToDelete}, range::HTTPRangeSpec, }; use crate::store::utils::is_reserved_or_invalid_bucket; @@ -109,7 +102,7 @@ use crate::{ disk::{ CheckPartsResp, DeleteOptions, DiskAPI, DiskInfo, DiskInfoOptions, DiskOption, DiskStore, FileInfoVersions, RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_TMP_BUCKET, ReadMultipleReq, ReadMultipleResp, ReadOptions, - SnapshotLeaseToken, UpdateMetadataOpts, endpoint::Endpoint, error::DiskError, format::FormatV3, new_disk, + SnapshotLeaseToken, UpdateMetadataOpts, endpoint::Endpoint, error::DiskError, format::FormatV3, }, error::{StorageError, to_object_err}, object_api::{GetObjectReader, NamespaceLockFence, ObjectInfo, ObjectLockConfigSnapshot, PutObjReader}, @@ -122,42 +115,40 @@ use crate::{ }; use bytes::Bytes; use bytesize::ByteSize; -use chrono::Utc; use futures::future::join_all; -use futures::task::AtomicWaker; -use glob::Pattern; use http::HeaderMap; use md5::{Digest as Md5Digest, Md5}; -use rand::{Rng, seq::SliceRandom}; use regex::Regex; -use rustfs_common::heal_channel::{ - DriveState, HealAdmissionResult, HealChannelPriority, HealItemType, HealOpts, HealRequestSource, HealScanMode, - send_heal_disk, send_heal_request_with_admission, -}; use rustfs_config::MI_B; use rustfs_filemeta::{ - FileInfo, FileMeta, FileMetaShallowVersion, MetaCacheEntries, MetaCacheEntry, MetadataResolutionParams, ObjectPartInfo, - RawFileInfo, file_info_from_raw, merge_file_meta_versions, + FileInfo, FileMeta, FileMetaShallowVersion, MetaCacheEntries, MetaCacheEntry, ObjectPartInfo, RawFileInfo, + merge_file_meta_versions, +}; +use rustfs_heal_contracts::heal_channel::{ + DriveState, HealAdmissionResult, HealChannelPriority, HealItemType, HealOpts, HealRequestSource, HealScanMode, + send_heal_disk, send_heal_request_with_admission, }; use rustfs_io_metrics::{ record_object_lock_diag_acquire_duration, record_object_lock_diag_enabled, record_object_lock_diag_hold_duration, record_object_lock_diag_slow_acquire, record_object_lock_diag_slow_hold, }; use rustfs_lock::LockClient; +#[cfg(test)] +use rustfs_lock::LockManager; use rustfs_lock::fast_lock::types::LockResult; -use rustfs_lock::local_lock::LocalLock; -use rustfs_lock::{FastLockGuard, LockManager, NamespaceLock, NamespaceLockGuard, NamespaceLockWrapper, ObjectKey}; +use rustfs_lock::{FastLockGuard, NamespaceLock, NamespaceLockGuard, NamespaceLockWrapper, ObjectKey}; use rustfs_madmin::heal_commands::{HealDriveInfo, HealResultItem, Infos}; use rustfs_object_capacity::capacity_scope::{ CapacityScope, CapacityScopeDisk, current_dirty_generation, record_capacity_scope, record_global_dirty_scope, }; +use rustfs_s3_client::transition_api::{ObjectReader, ReaderImpl}; use rustfs_s3_types::EventName; #[cfg(test)] use rustfs_utils::http::SSEC_ALGORITHM_HEADER; use rustfs_utils::http::headers::AMZ_OBJECT_TAGGING; use rustfs_utils::http::headers::AMZ_STORAGE_CLASS; use rustfs_utils::http::headers::{ - CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE, EXPIRES, HeaderExt as _, + CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE, EXPIRES, }; use rustfs_utils::http::{ SUFFIX_ACTUAL_OBJECT_SIZE_CAP, SUFFIX_ACTUAL_SIZE, SUFFIX_BUCKET_INCARNATION_ID, SUFFIX_COMPRESSION, SUFFIX_COMPRESSION_SIZE, @@ -170,30 +161,29 @@ use rustfs_utils::{ path::{SLASH_SEPARATOR, encode_dir_object, has_suffix, path_join_buf}, }; use s3s::header::{X_AMZ_OBJECT_LOCK_LEGAL_HOLD, X_AMZ_OBJECT_LOCK_MODE, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE, X_AMZ_RESTORE}; -use sha2::{Digest, Sha256}; -use std::future::Future; +use sha2::Sha256; use std::hash::{BuildHasher, Hash, Hasher}; use std::mem::{self}; use std::pin::Pin; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, OnceLock}; use std::task::{Context, Poll}; use std::time::{Instant, SystemTime, UNIX_EPOCH}; use std::{ collections::{HashMap, HashSet}, - io::{Cursor, Write}, + io::Cursor, path::Path, time::Duration, }; use time::OffsetDateTime; +#[cfg(test)] +use tokio::sync::mpsc; +use tokio::sync::mpsc::Sender; +#[cfg(test)] +use tokio::time::timeout; use tokio::{ - io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader, ReadBuf}, - sync::{RwLock, broadcast}, -}; -use tokio::{ - select, - sync::mpsc::{self, Sender}, - time::{interval, timeout}, + io::{AsyncRead, AsyncWrite, BufReader, ReadBuf}, + sync::RwLock, }; use tokio_util::sync::CancellationToken; use tracing::error; @@ -346,7 +336,7 @@ const ENV_RUSTFS_MULTIPART_PUT_LARGE_BATCH_MIN_SIZE_BYTES: &str = "RUSTFS_MULTIP const DEFAULT_RUSTFS_MULTIPART_PUT_LARGE_BATCH_MIN_SIZE_BYTES: usize = 128 * 1024 * 1024; static CACHED_MULTIPART_PUT_LARGE_BATCH_MIN_SIZE_BYTES: OnceLock = OnceLock::new(); -use crate::io_support::rio::{EtagResolvable, HashReader, HashReaderMut, TryGetIndex as _}; +use crate::io_support::rio::HashReader; pub const DEFAULT_READ_BUFFER_SIZE: usize = MI_B; // 1 MiB = 1024 * 1024; pub const MAX_PARTS_COUNT: usize = 10000; @@ -792,6 +782,65 @@ const ENV_RUSTFS_GET_METADATA_SLOWTAIL_FAULT_OBJECT_PREFIX: &str = "RUSTFS_GET_M const ENV_RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH: &str = "RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH"; const DEFAULT_RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH: bool = true; +/// Identifies the caller's read contract for policies that are deliberately +/// narrower than the storage API's ordinary GET contract. +/// +/// Server-side copy consumes a source reader while a destination writer is +/// applying backpressure. Its source read must not speculatively open the +/// next multipart part: those extra shard streams can share an internode H2 +/// connection with the current part and starve the lockstep decoder. Keep +/// this context internal so the public `ObjectOptions` and storage traits do +/// not acquire a copy-only field. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum GetObjectReadPolicy { + #[default] + Default, + CopySource, +} + +impl GetObjectReadPolicy { + pub(crate) const fn allows_multipart_setup_prefetch(self) -> bool { + matches!(self, Self::Default) + } +} + +tokio::task_local! { + static GET_OBJECT_READ_POLICY: GetObjectReadPolicy; + static GET_OBJECT_READ_CANCELLATION: tokio_util::sync::CancellationToken; +} + +pub(crate) fn get_object_read_policy() -> GetObjectReadPolicy { + GET_OBJECT_READ_POLICY.try_with(|policy| *policy).unwrap_or_default() +} + +pub(crate) async fn with_get_object_read_policy(policy: GetObjectReadPolicy, future: F) -> F::Output +where + F: std::future::Future, +{ + let decode_policy = match policy { + GetObjectReadPolicy::Default => crate::erasure::coding::decode::DecodeReadPolicy::Default, + GetObjectReadPolicy::CopySource => crate::erasure::coding::decode::DecodeReadPolicy::DemandBound, + }; + crate::erasure::coding::decode::with_decode_read_policy(decode_policy, GET_OBJECT_READ_POLICY.scope(policy, future)).await +} + +/// Return the request-owned cancellation token for a copy source, when one is +/// installed. The token is read before the detached legacy producer is spawned; +/// Tokio task-local values do not cross that spawn boundary on their own. +pub(crate) fn get_object_read_cancellation() -> Option { + GET_OBJECT_READ_CANCELLATION.try_with(|token| token.clone()).ok() +} + +pub(crate) async fn with_get_object_read_cancellation( + cancellation: tokio_util::sync::CancellationToken, + future: F, +) -> F::Output +where + F: std::future::Future, +{ + GET_OBJECT_READ_CANCELLATION.scope(cancellation, future).await +} + static OBJECT_LOCK_DIAG_ENABLED: OnceLock = OnceLock::new(); mod core; @@ -812,7 +861,7 @@ pub(crate) use ops::object::DeleteObjectCommitBarrier; #[cfg(feature = "test-util")] pub(crate) use ops::object::TransitionCleanupStoreBarrier as SetDiskTransitionCleanupStoreBarrier; pub(crate) use ops::object::body_cache_plaintext_len; -#[cfg(test)] +#[cfg(all(test, feature = "test-util"))] pub(crate) use ops::object::cleanup_rejected_transition_upload_durably; #[cfg(any(test, feature = "test-util"))] pub use ops::object::{PutObjectCommitBarrier, PutObjectCommitPause}; @@ -969,8 +1018,7 @@ mod prepared_get_object_metadata_tests { use crate::ecstore_validation_blackbox::make_local_set_disks; use crate::object_api::{BLOCK_SIZE_V2, PutObjReader}; use crate::set_disk::core::io_primitives::{bounded_metadata_fanout_order, disk_call_counters, rename_fanout_barrier}; - use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions}; - use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _}; + use crate::storage_api_contracts::bucket::MakeBucketOptions; use crate::test_metrics::CapturingRecorder; use http::HeaderMap; use tokio::io::AsyncReadExt; @@ -2296,6 +2344,7 @@ enum GetCodecStreamingFallbackReason { InvalidMinSize, ReadQuorumNotSafe, MultipartPartLimit, + CopySourceDemandBound, } impl GetCodecStreamingFallbackReason { @@ -2317,6 +2366,7 @@ impl GetCodecStreamingFallbackReason { Self::InvalidMinSize => "invalid_min_size", Self::ReadQuorumNotSafe => "read_quorum_not_safe", Self::MultipartPartLimit => "multipart_part_limit", + Self::CopySourceDemandBound => "copy_source_demand_bound", } } } @@ -2623,6 +2673,17 @@ fn get_codec_streaming_reader_gate( prefer_data_blocks_first_reader_setup: false, }; } + if matches!(get_object_read_policy(), GetObjectReadPolicy::CopySource) { + // The codec reader has its own bounded fill worker. It may still + // request an additional stripe for a plain single-part object even + // when multipart setup prefetch is disabled, so copy sources use the + // legacy demand-bound reader for every object class. + return GetCodecStreamingGate { + object_class, + decision: GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::CopySourceDemandBound), + prefer_data_blocks_first_reader_setup: false, + }; + } if !config.rollout.is_opted_in() { return GetCodecStreamingGate { object_class, @@ -3111,8 +3172,9 @@ pub struct SetDisks { #[cfg(test)] storage_class_config_override: Arc>>>, #[cfg(test)] - rename_tail_heal_capture: - Arc>>>, + rename_tail_heal_capture: Arc< + std::sync::Mutex>>, + >, } // DistributedLock sends the raw ObjectKey to its clients; LockRegistry clones @@ -3388,7 +3450,10 @@ impl DiskHealthEntry { } impl SetDisks { - pub(in crate::set_disk) async fn submit_rename_tail_heal(&self, request: rustfs_common::heal_channel::HealChannelRequest) { + pub(in crate::set_disk) async fn submit_rename_tail_heal( + &self, + request: rustfs_heal_contracts::heal_channel::HealChannelRequest, + ) { #[cfg(test)] { let capture = self @@ -3402,13 +3467,13 @@ impl SetDisks { } } - let _ = rustfs_common::heal_channel::send_heal_request(request).await; + let _ = rustfs_heal_contracts::heal_channel::send_heal_request(request).await; } #[cfg(test)] pub(in crate::set_disk) fn capture_test_rename_tail_heals( &self, - ) -> tokio::sync::mpsc::UnboundedReceiver { + ) -> tokio::sync::mpsc::UnboundedReceiver { let (capture, requests) = tokio::sync::mpsc::unbounded_channel(); let mut slot = self .rename_tail_heal_capture @@ -4612,7 +4677,10 @@ fn check_object_lock_retention_update(bucket: &str, object: &str, obj_info: &Obj if let Some(retention) = &opts.object_lock_retention && check_retention_for_modification( &obj_info.user_defined, - retention.mode.as_deref(), + retention + .mode + .as_deref() + .and_then(crate::bucket::object_lock::types::RetentionMode::parse_exact), retention.retain_until, retention.bypass_governance, ) @@ -5891,7 +5959,7 @@ mod tests { use crate::set_disk::core::io_primitives::rename_fanout_barrier; use crate::storage_api_contracts::{ heal::HealOperations as _, lifecycle::TransitionedObject, list::ListOperations as _, multipart::CompletePart, - namespace::NamespaceLocking as _, object::ObjectIO as _, object::ObjectOperations as _, + object::ObjectOperations as _, }; use crate::store::init_format::save_format_file; use crate::store::list_objects::ListPathOptions; @@ -5908,6 +5976,29 @@ mod tests { use tokio::fs; use tokio::io::AsyncReadExt; + #[tokio::test] + async fn copy_source_read_policy_is_scoped_and_demand_bound() { + assert_eq!(get_object_read_policy(), GetObjectReadPolicy::Default); + assert!(GetObjectReadPolicy::Default.allows_multipart_setup_prefetch()); + assert!(!GetObjectReadPolicy::CopySource.allows_multipart_setup_prefetch()); + + with_get_object_read_policy(GetObjectReadPolicy::CopySource, async { + assert_eq!(get_object_read_policy(), GetObjectReadPolicy::CopySource); + assert!(!get_object_read_policy().allows_multipart_setup_prefetch()); + assert_eq!( + crate::erasure::coding::decode::decode_read_policy(), + crate::erasure::coding::decode::DecodeReadPolicy::DemandBound + ); + }) + .await; + + assert_eq!(get_object_read_policy(), GetObjectReadPolicy::Default); + assert_eq!( + crate::erasure::coding::decode::decode_read_policy(), + crate::erasure::coding::decode::DecodeReadPolicy::Default + ); + } + #[test] fn complete_part_error_maps_confirmed_missing_to_invalid_part() { for err in ["file not found", "Specified part could not be found", "part.7 not found"] { diff --git a/crates/ecstore/src/set_disk/ops/bitrot_self_verify.rs b/crates/ecstore/src/set_disk/ops/bitrot_self_verify.rs index 93c038e6a..52af4a3d0 100644 --- a/crates/ecstore/src/set_disk/ops/bitrot_self_verify.rs +++ b/crates/ecstore/src/set_disk/ops/bitrot_self_verify.rs @@ -12,7 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -use super::super::*; +use super::super::{ + Cursor, DiskStore, EVENT_SET_DISK_WRITE, Error, FileInfo, HashAlgorithm, LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_SET_DISK, + Result, join_all, warn, +}; +use crate::disk::DiskAPI; +#[cfg(test)] +use crate::disk::RUSTFS_META_TMP_BUCKET; +use crate::set_disk::coding; +#[cfg(test)] +use bytes::Bytes; /// Null out any disk whose shard writer failed (or was never created) so its /// truncated/absent shard is not committed by the final rename, and return the @@ -132,7 +141,6 @@ pub(in crate::set_disk::ops) async fn verify_written_bitrot_shards( mod tests { use super::super::object::hermetic_set_disks_support::hermetic_set_disks_for_pool_with_default_parity; use super::*; - use crate::disk::DiskAPI as _; async fn encode_streaming_shard(data: &[u8], shard_size: usize) -> Bytes { let mut writer = coding::BitrotWriter::new(Cursor::new(Vec::new()), shard_size, HashAlgorithm::HighwayHash256S); diff --git a/crates/ecstore/src/set_disk/ops/bucket.rs b/crates/ecstore/src/set_disk/ops/bucket.rs index 904e59e19..50e84ee68 100644 --- a/crates/ecstore/src/set_disk/ops/bucket.rs +++ b/crates/ecstore/src/set_disk/ops/bucket.rs @@ -19,7 +19,12 @@ //! `for SetDisks`, so its associated-type bounds are unchanged and runtime //! behavior is the same. -use super::super::*; +use super::super::{ + BUCKET_OP_IGNORED_ERRS, BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, DiskError, Error, HashMap, + MakeBucketOptions, Result, SetDisks, is_reserved_or_invalid_bucket, join_all, reduce_write_quorum_errs, +}; +use crate::api::bucket::metadata_sys; +use crate::disk::DiskAPI; impl SetDisks { pub(crate) async fn list_bucket_for_scanner(&self, _opts: &BucketOptions) -> Result<(Vec, bool)> { diff --git a/crates/ecstore/src/set_disk/ops/heal.rs b/crates/ecstore/src/set_disk/ops/heal.rs index adcea3a60..171e83fc2 100644 --- a/crates/ecstore/src/set_disk/ops/heal.rs +++ b/crates/ecstore/src/set_disk/ops/heal.rs @@ -12,9 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -use super::super::*; +use super::super::{ + Bytes, CHECK_PART_FILE_CORRUPT, CHECK_PART_SUCCESS, DeleteOptions, DiskError, DiskStore, DriveState, EVENT_SET_DISK_HEAL, + Error, FileInfo, HashAlgorithm, HashMap, HealDriveInfo, HealItemType, HealOpts, HealResultItem, HealScanMode, Infos, + LOG_SUBSYSTEM_SET_DISK, ObjectInfo, ObjectOptions, ObjectPartInfo, Path, RUSTFS_META_TMP_BUCKET, ReadOptions, Result, + SLASH_SEPARATOR, SetDisks, StorageError, Uuid, coding, count_errs, count_part_not_success, create_bitrot_reader, + create_bitrot_writer, debug, disk, disks_with_all_parts, encode_dir_object, error, file_info_is_valid_for_metadata, + formats_match_reference_slots, get_format_erasure_in_quorum, get_lock_acquire_timeout, has_suffix, + heal_bucket_local_on_disks, is_object_dir_dangling, join_all, load_format_erasure_all, path_join_buf, save_format_file, + should_heal_object_on_disk, stat_all_dirs, to_object_err, warn, +}; use crate::disk::DataDirDeleteStatus; -use crate::disk::disk_store::DiskStoreRenameDataExt; +use crate::disk::DiskAPI; use crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX; use crate::io_support::bitrot::object_mmap_read_enabled; use crate::storage_api_contracts::namespace::NamespaceLocking as _; @@ -2386,8 +2395,8 @@ mod heal_result_report_tests { store::init_format::{load_format_erasure, save_format_file}, }; use bytes::Bytes; - use rustfs_common::heal_channel::{DriveState, HealOpts, HealScanMode}; use rustfs_filemeta::{BLOCK_SIZE_V2, FileInfo, ObjectPartInfo, TRANSITION_COMPLETE}; + use rustfs_heal_contracts::heal_channel::{DriveState, HealOpts, HealScanMode}; use std::sync::{Arc, Mutex}; use tempfile::TempDir; use time::OffsetDateTime; diff --git a/crates/ecstore/src/set_disk/ops/heal_walk.rs b/crates/ecstore/src/set_disk/ops/heal_walk.rs index fe0ce30fc..aa627e415 100644 --- a/crates/ecstore/src/set_disk/ops/heal_walk.rs +++ b/crates/ecstore/src/set_disk/ops/heal_walk.rs @@ -22,8 +22,14 @@ //! every `(object, version)` present on ANY disk, feeding each to the existing //! per-version `SetDisks::heal_object`. -use super::super::*; +use super::super::{ + Arc, CancellationToken, DiskError, ListPathRawOptions, MetaCacheEntries, MetaCacheEntry, SetDisks, debug, disk, list_path_raw, +}; +#[cfg(test)] +use crate::disk::DiskAPI; use crate::object_api::ObjectInfo; +#[cfg(test)] +use rustfs_filemeta::FileMeta; use std::collections::HashSet; use std::sync::Mutex; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; diff --git a/crates/ecstore/src/set_disk/ops/list.rs b/crates/ecstore/src/set_disk/ops/list.rs index 59dc1401a..a25258f5a 100644 --- a/crates/ecstore/src/set_disk/ops/list.rs +++ b/crates/ecstore/src/set_disk/ops/list.rs @@ -22,7 +22,11 @@ //! runtime behavior is unchanged. use super::super::ctx::SetDisksCtx; -use super::super::*; +use super::super::{ + Arc, CancellationToken, DeleteOptions, DiskError, DiskStore, Error, ListObjectVersionsInfo, ListObjectsV2Info, + OBJECT_OP_IGNORED_ERRS, ObjectInfoOrErr, Result, Sender, SetDisks, WalkOptions, debug, join_all, reduce_write_quorum_errs, +}; +use crate::disk::DiskAPI; impl SetDisks { #[tracing::instrument(skip(self))] diff --git a/crates/ecstore/src/set_disk/ops/locking.rs b/crates/ecstore/src/set_disk/ops/locking.rs index be33c0c43..e2674ac38 100644 --- a/crates/ecstore/src/set_disk/ops/locking.rs +++ b/crates/ecstore/src/set_disk/ops/locking.rs @@ -19,9 +19,19 @@ //! here; the contract stays implemented `for SetDisks`, so its associated-type //! bounds are unchanged and helper access is via inherent calls. -use super::super::*; +use super::super::{ + Arc, DiskError, DiskInfo, DiskInfoOptions, DiskOption, DiskStore, Endpoint, Error, FormatV3, HealChannelPriority, LockResult, + NamespaceLock, NamespaceLockWrapper, ObjectKey, Result, SetDisks, StorageError, debug, disk, info, load_format_erasure, + send_heal_disk, warn, +}; +use crate::disk::DiskAPI; use crate::disk::health_state::DriveMembershipSnapshot; +#[cfg(test)] +use crate::disk::new_disk; use crate::runtime::sources as runtime_sources; +use rand::prelude::SliceRandom; +#[cfg(test)] +use uuid::Uuid; #[async_trait::async_trait] impl crate::storage_api_contracts::namespace::NamespaceLocking for SetDisks { diff --git a/crates/ecstore/src/set_disk/ops/multipart.rs b/crates/ecstore/src/set_disk/ops/multipart.rs index 163f99e8b..820f8fb03 100644 --- a/crates/ecstore/src/set_disk/ops/multipart.rs +++ b/crates/ecstore/src/set_disk/ops/multipart.rs @@ -20,24 +20,79 @@ //! contract stays implemented `for SetDisks`, so its associated-type bounds are //! unchanged; method bodies are moved verbatim and runtime behavior is the same. -use super::super::*; +#[cfg(test)] +use super::super::GetObjectMetadataCacheKey; +#[cfg(test)] +use super::super::MetadataCacheInvalidationProbe; +#[cfg(test)] +use super::super::capacity_scope_from_disks; +use super::super::{ + AMZ_STORAGE_CLASS, Arc, Bytes, CompletePart, Cursor, DiskError, DiskStore, EVENT_SET_DISK_MULTIPART, Error, FileInfo, + GLOBAL_MIN_PART_SIZE, HashAlgorithm, HashMap, HashReader, HashSet, HealChannelPriority, Instant, LOG_COMPONENT_ECSTORE, + LOG_SUBSYSTEM_SET_DISK, ListMultipartsInfo, ListPartsInfo, MAX_PARTS_COUNT, MULTIPART_WRITE_QUORUM_RENAME_PART, + MULTIPART_WRITE_QUORUM_UPLOAD_METADATA, MULTIPART_WRITE_QUORUM_WRITER_SETUP, MultipartInfo, MultipartUploadResult, + MultipartWriteQuorumContext, NamespaceLockFence, OBJECT_OP_IGNORED_ERRS, ObjectInfo, ObjectLockDiagGuard, ObjectOptions, + ObjectPartInfo, OffsetDateTime, PartInfo, PutObjReader, RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_TMP_BUCKET, + RUSTFS_MULTIPART_BUCKET_KEY, RUSTFS_MULTIPART_OBJECT_KEY, Result, SLASH_SEPARATOR, SUFFIX_ACTUAL_OBJECT_SIZE_CAP, + SUFFIX_ACTUAL_SIZE, SUFFIX_BUCKET_INCARNATION_ID, SUFFIX_COMPRESSION_SIZE, SUFFIX_REPLICATION_SSEC_CRC, + SUFFIX_RESTORE_OPERATION_ID, SetDisks, SmallWritePath, StorageError, Uuid, WriteLayout, + check_object_lock_for_deletion_with_state, classify_multipart_part_write_path, coding, complete_multipart_part_error, + complete_multipart_part_error_result, complete_part_checksum, completed_multipart_object_part, contains_key_str, + create_bitrot_writer, debug, disk, error, get_complete_multipart_md5, get_header_map, get_str, insert_str, + is_err_object_not_found, is_err_version_not_found, is_min_allowed_part_size, log_multipart_write_quorum_failure, + parts_after_marker, path_join_buf, record_compression_total_memory, reduce_read_quorum_errs, reduce_write_quorum_errs, + remove_header_map, resolve_write_layout, restore_commit_operation_id_from_metadata, should_persist_encryption_original_size, + strip_internal_multipart_metadata, to_object_err, warn, +}; use super::bitrot_self_verify::{BitrotSelfVerifyTarget, drop_failed_writer_disks, verify_written_bitrot_shards}; +#[cfg(test)] +use super::object::old_data_cleanup_receipt_path; use super::object::{ assign_object_transaction_epoch, object_transaction_fencing_fleet_proof, object_transaction_fencing_fleet_proof_matches, - object_transaction_fencing_requested, old_data_cleanup_receipt_path, read_object_transaction_epoch_fence, - verify_object_transaction_epoch_fence, + object_transaction_fencing_requested, read_object_transaction_epoch_fence, verify_object_transaction_epoch_fence, }; +use crate::api::config::storageclass; +#[cfg(test)] +use crate::bucket::metadata_sys::ObjectLockConfigState; use crate::bucket::quota::reservation; use crate::crash_inject::{self, CrashPoint}; +use crate::disk::DiskAPI; +#[cfg(test)] +use crate::disk::DiskOption; +#[cfg(test)] +use crate::disk::STORAGE_FORMAT_FILE; +#[cfg(test)] +use crate::disk::new_disk; use crate::multipart_listing::paginate_multipart_listing; +#[cfg(test)] +use crate::object_api::ObjectLockConfigSnapshot; use crate::set_disk::core::io_primitives::finish_rename_tail_heal; +use crate::set_disk::mem; +use crate::set_disk::metadata_sys; +use crate::set_disk::runtime_sources; +#[cfg(test)] +use crate::storage_api_contracts::multipart::MultipartOperations; +#[cfg(test)] +use crate::storage_api_contracts::object::HTTPPreconditions; +use crate::storage_api_contracts::object::ObjectOperations; use futures::{StreamExt, stream}; +#[cfg(test)] +use http::HeaderMap; +use rustfs_rio::EtagResolvable; +use rustfs_rio::TryGetIndex; +#[cfg(test)] +use rustfs_utils::http::SSEC_ALGORITHM_HEADER; +#[cfg(test)] +use rustfs_utils::http::SUFFIX_COMPRESSION; use std::future::Future; #[cfg(test)] use std::sync::atomic::AtomicBool; #[cfg(any(test, feature = "test-util"))] use std::sync::atomic::{AtomicUsize, Ordering}; +#[cfg(any(test, feature = "test-util"))] use std::time::Duration; +#[cfg(test)] +use tokio::io::AsyncReadExt; use tokio::task::JoinSet; const MULTIPART_LIST_IO_CONCURRENCY: usize = 16; @@ -2749,7 +2804,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks { needs_immediate_heal = rename_commit.needs_immediate_heal(); if let Some(rename_tail_drain) = rename_commit.tail_drain.take() { tail_owns_staging_cleanup = true; - let mut request = rustfs_common::heal_channel::create_heal_request_with_options( + let mut request = rustfs_heal_contracts::heal_channel::create_heal_request_with_options( commit_bucket.clone(), Some(commit_object.clone()), false, @@ -2846,7 +2901,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks { let committed_file_info = rename_commit.committed_file_info; if needs_immediate_heal { - let mut request = rustfs_common::heal_channel::create_heal_request_with_options( + let mut request = rustfs_heal_contracts::heal_channel::create_heal_request_with_options( commit_bucket.clone(), Some(commit_object.clone()), false, @@ -2859,7 +2914,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks { .or_else(|| commit_version_suspended.then(Uuid::nil)) .map(|version_id| version_id.to_string()); tokio::spawn(async move { - let _ = rustfs_common::heal_channel::send_heal_request(request).await; + let _ = rustfs_heal_contracts::heal_channel::send_heal_request(request).await; }); } @@ -2998,7 +3053,7 @@ fn resolve_complete_etag(opts: &ObjectOptions, uploaded_parts: &[CompletePart]) mod tests { use super::*; use crate::config::storageclass::lookup_config_for_pools_without_env; - use crate::disk::{DiskAPI as _, ReadOptions}; + use crate::disk::ReadOptions; use crate::disk::{endpoint::Endpoint, format::FormatV3}; use crate::layout::endpoints::SetupType; use crate::services::notification_sys::install_remote_version_state_fleet_proof_for_test; @@ -3014,7 +3069,7 @@ mod tests { }; use crate::set_disk::ops::object::{PutObjectCommitBarrier, PutObjectCommitPause}; use crate::storage_api_contracts::namespace::NamespaceLocking as _; - use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _}; + use crate::storage_api_contracts::object::ObjectIO as _; use rustfs_config::server_config::KVS; use rustfs_lock::{LockClient, client::local::LocalClient}; use serial_test::serial; @@ -3640,11 +3695,17 @@ mod tests { async fn object_transaction_epochs(disks: &[DiskStore], bucket: &str, object: &str) -> Vec> { let mut epochs = Vec::with_capacity(disks.len()); + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); for (disk_index, disk) in disks.iter().enumerate() { - let file_info = disk - .read_version("", bucket, object, "", &ReadOptions::default()) - .await - .unwrap_or_else(|err| panic!("disk {disk_index} should persist object metadata: {err}")); + let file_info = loop { + match disk.read_version("", bucket, object, "", &ReadOptions::default()).await { + Ok(file_info) => break file_info, + Err(DiskError::FileNotFound) if tokio::time::Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(25)).await; + } + Err(err) => panic!("disk {disk_index} should persist object metadata: {err}"), + } + }; epochs.push( file_info .object_transaction_epoch() @@ -3707,22 +3768,34 @@ mod tests { let (upload_id, parts) = stage_upload_with_create_opts(&set_disks, bucket, object, b"multipart fenced epoch", &ObjectOptions::default()).await; - temp_env::async_with_vars( + let epochs = temp_env::async_with_vars( [ (rustfs_config::ENV_OBJECT_TRANSACTION_FENCING_WRITE, Some("true")), (rustfs_config::ENV_OBJECT_TRANSACTION_FENCING_FLEET_CONFIRMED, Some("true")), + (ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, Some("true")), ], async { + let rename_barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier::PHASE_RENAME); set_disks .clone() .complete_multipart_upload(bucket, object, &upload_id, parts.clone(), &ObjectOptions::default()) .await .expect("fenced multipart completion should commit with a live proof"); + + tokio::time::timeout(Duration::from_secs(30), rename_barrier.wait_until_paused()) + .await + .expect("multipart completion should leave one rename tail in flight after quorum ACK"); + let disks = disk_stores.clone(); + let mut epochs = tokio::spawn(async move { object_transaction_epochs(&disks, bucket, object).await }); + assert!( + tokio::time::timeout(Duration::from_millis(100), &mut epochs).await.is_err(), + "epoch read-back should wait for the lagging rename tail" + ); + rename_barrier.release(); + epochs.await.expect("epoch read-back should finish after the rename tail") }, ) .await; - - let epochs = object_transaction_epochs(&disk_stores, bucket, object).await; let first = epochs[0].expect("fenced multipart completion should persist an epoch"); assert!(!first.is_nil()); assert!(epochs.into_iter().all(|epoch| epoch == Some(first))); @@ -4870,7 +4943,7 @@ mod tests { #[serial(metadata_cache_invalidation_probe)] async fn complete_multipart_generation_retires_cached_snapshot() { use crate::storage_api_contracts::multipart::MultipartOperations as _; - use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _}; + use crate::storage_api_contracts::object::ObjectIO as _; let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await; let bucket = "multipart-metadata-generation-bucket"; @@ -7369,9 +7442,7 @@ mod tests { mod crash_consistency { use super::*; use crate::crash_inject::{self, CrashPoint}; - use crate::storage_api_contracts::object::ObjectIO as _; use http::HeaderMap; - use tokio::io::AsyncReadExt as _; /// 1 MiB keeps every object off the 128 KiB inline fast path, so the /// commit moves real erasure shards through `rename_data`. diff --git a/crates/ecstore/src/set_disk/ops/object.rs b/crates/ecstore/src/set_disk/ops/object.rs index 30be81498..0c3054bfe 100644 --- a/crates/ecstore/src/set_disk/ops/object.rs +++ b/crates/ecstore/src/set_disk/ops/object.rs @@ -20,11 +20,74 @@ //! SetDisks core (io_primitives) via inherent calls. use ahash::AHashMap; -use super::super::*; +#[cfg(test)] +use super::super::MetadataCacheInvalidationProbe; +use super::super::{ + AMZ_OBJECT_TAGGING, AMZ_STORAGE_CLASS, Arc, AsyncWrite, AtomicU64, BufReader, Bytes, CACHE_CONTROL, CONTENT_DISPOSITION, + CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE, CompletePart, Cursor, DeleteAccounting, DeleteOptions, DeletedObject, + DiskError, DiskStore, EVENT_SET_DISK_COMMIT_TAIL_SLOW, EVENT_SET_DISK_PUT_OBJECT_STAGE_SUMMARY, EVENT_SET_DISK_WRITE, + EXPIRES, Error, EventArgs, EventName, FastLockGuard, FileInfo, FileInfoVersions, + GET_CODEC_STREAMING_OBJECT_CLASS_PLAIN_SINGLE_PART, GET_OBJECT_PATH_BODY_CACHE, GET_OBJECT_PATH_CODEC_STREAMING, + GET_OBJECT_PATH_DIRECT_MEMORY, GET_OBJECT_PATH_EMPTY, GET_OBJECT_PATH_INLINE_DIRECT, GET_OBJECT_PATH_INTERNAL_META, + GET_OBJECT_PATH_LEGACY_DUPLEX, GET_OBJECT_PATH_REMOTE_TRANSITION, GET_OBJECT_PATH_SET_DISK, GET_STAGE_DECODE, GET_STAGE_EMIT, + GET_STAGE_INLINE_PREPARE, GET_STAGE_LOCK_ACQUIRE, GET_STAGE_METADATA, GET_STAGE_OBJECT_INFO, GET_STAGE_PATH_DECISION, + GET_STAGE_READER_SETUP, GenericError, GetCodecStreamingDecision, GetDirectMemoryDecision, GetObjectReader, HTTPRangeSpec, + HashAlgorithm, HashMap, HashReader, HashSet, HeaderMap, HealChannelPriority, InstanceContext, Instant, LOG_COMPONENT_ECSTORE, + LOG_SUBSYSTEM_SET_DISK, OBJECT_OP_IGNORED_ERRS, ObjectApiError, ObjectInfo, ObjectKey, ObjectLockConfigSnapshot, + ObjectLockConfigState, ObjectOptions, ObjectReader, ObjectToDelete, OffsetDateTime, Ordering, Pin, PutObjReader, + RUSTFS_META_BUCKET, RUSTFS_META_TMP_BUCKET, ReaderImpl, ReplicateDecision, ReplicationObjectBridge, Result, + SET_DISK_COMMIT_TAIL_WARN_THRESHOLD_MS, SLASH_SEPARATOR, SUFFIX_ACTUAL_SIZE, SUFFIX_COMPRESSION, SUFFIX_COMPRESSION_SIZE, + SUFFIX_RESTORE_OPERATION_ID, SetDisks, SmallWritePath, StorageError, TRANSITION_COMPLETE, UpdateMetadataOpts, Uuid, + WriteLayout, X_AMZ_OBJECT_LOCK_LEGAL_HOLD, X_AMZ_OBJECT_LOCK_MODE, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE, X_AMZ_RESTORE, + adaptive_duplex_buffer_size, build_get_object_info, build_inline_bitrot_readers, build_inline_bitrot_readers_from_refs, + can_try_inline_data_shards_direct, check_object_lock_delete, check_object_lock_for_deletion_with_state, + check_object_lock_retention_update, classify_get_codec_streaming_object_class, classify_put_write_path, + classify_storage_error, collect_inline_data_shard_fileinfos_by_index, contains_key_str, create_bitrot_writer, debug, + delete_file_info_version_id, disk, ensure_delete_commit_locks_held, error, finish_set_disk_read_lock, + get_codec_streaming_reader_gate, get_object_body_cache_hook, get_raw_etag, get_small_object_direct_memory_decision, + get_stage_timer_if_enabled, get_str, get_transitioned_object_reader_with_tier_manager, inline_erasure_shard_file_offset, + inline_erasure_shard_size, insert_str, is_deadlock_detection_enabled, is_err_object_not_found, is_err_version_not_found, + is_explicit_null_version, is_lock_optimization_enabled, issue3031_diag_enabled, join_all, known_put_object_storage_size, + path_join_buf, put_restore_opts, record_compression_total_memory, record_get_codec_streaming_gate_decision, + record_get_direct_memory_decision, record_get_object_pipeline_failure, record_get_object_pipeline_failure_for_path, + record_get_object_reader_path_observation, record_get_stage_duration_if_enabled, record_lock_acquire, + reduce_write_quorum_errs, release_materialized_read_lock, replication_write_may_pass_worm_gate, require_restore_operation_id, + resolve_delete_version_state, resolve_tiered_decommission_write_quorum_result, resolve_write_layout, + restore_commit_operation_id_from_metadata, restore_operation_id_from_metadata, send_event, + set_disk_delete_creates_delete_marker, should_force_delete_marker_for_missing_version, + should_persist_encryption_original_size, should_preserve_delete_replication_state, should_use_inline_fast_path, + take_prepared_get_object_metadata, to_object_err, try_read_inline_data_shards_direct, warn, +}; use super::bitrot_self_verify::{BitrotSelfVerifyTarget, drop_failed_writer_disks, verify_written_bitrot_shards}; +use crate::api::config::storageclass; +use crate::bucket::lifecycle::bucket_lifecycle_ops::LifecycleOps; use crate::bucket::utils::is_meta_bucketname; +use crate::bucket::versioning::VersioningApi; +use crate::disk::DiskAPI; +use crate::set_disk::coding; +use crate::set_disk::core::io_primitives::GetCodecStreamingReaderBuildOutcome; +use crate::set_disk::mem; +use crate::set_disk::metadata_sys; use crate::set_disk::read::GetObjectDownstreamWriter; +use crate::set_disk::runtime_sources; +use crate::storage_api_contracts::multipart::MultipartOperations; +use crate::storage_api_contracts::object::ObjectIO; +use crate::storage_api_contracts::object::ObjectOperations; +use rustfs_lock::LockManager; +use rustfs_rio::EtagResolvable; +use rustfs_rio::HashReaderMut; +use rustfs_rio::TryGetIndex; +use rustfs_utils::http::HeaderExt; +use tokio::io::AsyncWriteExt; +#[cfg(all(test, feature = "test-util"))] +use super::super::GetObjectMetadataCacheEntry; +#[cfg(test)] +use super::super::GetObjectMetadataCacheKey; +#[cfg(test)] +use super::super::capacity_scope_from_disks; +#[cfg(all(test, feature = "test-util"))] +use super::super::get_lock_acquire_timeout; use crate::bucket::lifecycle::{ tier_delete_journal::{ enqueue_committed_tier_delete_journal_entry, persist_tier_delete_journal_entry, @@ -48,6 +111,20 @@ use crate::bucket::replication::{ }; use crate::data_usage::quota_object_size; use crate::diagnostics::get::GetObjectFailureReason; +#[cfg(test)] +use crate::disk::DiskOption; +#[cfg(all(test, feature = "test-util"))] +use crate::disk::RUSTFS_META_MULTIPART_BUCKET; +#[cfg(test)] +use crate::disk::ReadOptions; +#[cfg(test)] +use crate::disk::STORAGE_FORMAT_FILE; +#[cfg(test)] +use crate::disk::endpoint::Endpoint; +#[cfg(test)] +use crate::disk::format::FormatV3; +#[cfg(test)] +use crate::disk::new_disk; use crate::disk::{DataDirDeleteStatus, OldCurrentSize}; use crate::error::is_err_invalid_upload_id; use crate::object_api::{GetObjectBodySource, get_object_body_cache_hook_suppressed}; @@ -55,13 +132,30 @@ use crate::object_api::{NamespaceLockFence, SCANNER_PUBLICATION_LEASE_FENCE_META use crate::services::notification_sys::RemoteVersionStateFleetProofToken; use crate::services::tier::tier::{TierConfigMgr, TierOperationLease}; use crate::set_disk::core::io_primitives::{RenameTailCleanup, finish_rename_tail_heal}; +#[cfg(test)] +use crate::storage_api_contracts::namespace::NamespaceLocking; +#[cfg(test)] +use crate::storage_api_contracts::object::HTTPPreconditions; use crate::store::ECStore; use crate::store::utils::clean_metadata; use futures::FutureExt as _; use http::HeaderValue; +#[cfg(test)] +use rustfs_filemeta::FileMeta; +#[cfg(all(test, feature = "test-util"))] +use rustfs_filemeta::ObjectPartInfo; use rustfs_utils::path::decode_dir_object; +#[cfg(test)] +use rustfs_utils::path::encode_dir_object; use std::future::Future; -use std::sync::OnceLock; +use std::task::{Context, Poll}; +#[cfg(any(test, feature = "test-util"))] +use std::time::Duration; +#[cfg(test)] +use tokio::io::AsyncReadExt; +use tokio::io::{AsyncRead, ReadBuf}; +#[cfg(all(test, feature = "test-util"))] +use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; const OLD_DATA_CLEANUP_RECEIPT_FILE: &str = ".rustfs-old-data-cleanup-receipt.json"; @@ -353,7 +447,7 @@ mod lifecycle_delete_all_plan_tests { crate::object_api::LifecycleDeleteAllRequest { version_id: Some(version_id), delete_marker: true, - action: rustfs_common::metrics::IlmAction::DelMarkerDeleteAllVersionsAction, + action: rustfs_scanner_contracts::metrics::IlmAction::DelMarkerDeleteAllVersionsAction, rule_id: "rule".to_string(), phase: crate::object_api::LifecycleDeleteAllPhase::Preflight, } @@ -546,7 +640,7 @@ mod lifecycle_delete_all_plan_tests { let request = crate::object_api::LifecycleDeleteAllRequest { version_id: None, delete_marker: false, - action: rustfs_common::metrics::IlmAction::DeleteAllVersionsAction, + action: rustfs_scanner_contracts::metrics::IlmAction::DeleteAllVersionsAction, rule_id: "rule".to_string(), phase: crate::object_api::LifecycleDeleteAllPhase::Preflight, }; @@ -1065,11 +1159,6 @@ mod restore_metadata_update_tests { } } -#[cfg(test)] -mod delete_replication_transport_tests { - use super::*; -} - fn erasure_from_file_info(fi: &FileInfo, uses_legacy: bool) -> Result { coding::Erasure::try_new_with_options(fi.erasure.data_blocks, fi.erasure.parity_blocks, fi.erasure.block_size, uses_legacy) .map_err(Error::from) @@ -1121,6 +1210,37 @@ where Ok((reader, offset, length)) } +/// Cancels a detached legacy GET producer when its consumer is dropped. +/// +/// The producer owns the shard readers and the object read lock, while the +/// consumer owns only the duplex read half. Closing that half eventually +/// unblocks a writer, but can leave a producer stuck in reader setup or remote +/// recovery until a lower-level timeout fires. This small boundary wrapper +/// provides an explicit cancellation signal without changing the public +/// `GetObjectReader` shape. +struct ProducerCancellationReader { + inner: R, + cancellation: CancellationToken, +} + +impl ProducerCancellationReader { + fn new(inner: R, cancellation: CancellationToken) -> Self { + Self { inner, cancellation } + } +} + +impl AsyncRead for ProducerCancellationReader { + fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl Drop for ProducerCancellationReader { + fn drop(&mut self) { + self.cancellation.cancel(); + } +} + fn data_read_metadata_early_stop_request_shape_allowed(range: &Option, opts: &ObjectOptions) -> bool { range.is_none() && opts.part_number.is_none() @@ -1861,7 +1981,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks { ) .await? { - core::io_primitives::GetCodecStreamingReaderBuildOutcome::Reader(stream) => { + GetCodecStreamingReaderBuildOutcome::Reader(stream) => { record_get_codec_streaming_gate_decision( codec_streaming_gate.object_class, GetCodecStreamingDecision::Use, @@ -1875,7 +1995,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks { reader.body_source = body_source; return Ok(finish_set_disk_read_lock(reader, read_lock_guard.take(), bucket, object)); } - core::io_primitives::GetCodecStreamingReaderBuildOutcome::Fallback(reason) => { + GetCodecStreamingReaderBuildOutcome::Fallback(reason) => { record_get_codec_streaming_gate_decision( codec_streaming_gate.object_class, GetCodecStreamingDecision::Fallback(reason), @@ -1908,12 +2028,25 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks { // lookup on the streaming miss path (ODC-16). reader.body_source = body_source; + // The producer is otherwise detached from the returned reader. Tie its + // lifetime to the source stream so a cancelled copy (or an abandoned + // GET) releases in-flight shard opens, response bodies, and the read + // lock immediately instead of waiting for a disk timeout. + let producer_cancellation = crate::set_disk::get_object_read_cancellation(); + if let Some(cancellation) = producer_cancellation.as_ref() { + reader.stream = Box::new(ProducerCancellationReader::new(reader.stream, cancellation.clone())); + } + // let disks = disks.clone(); let bucket = bucket.to_owned(); let object = object.to_owned(); let set_index = self.set_index; let pool_index = self.pool_index; let skip_verify = opts.skip_verify_bitrot; + // The producer runs in a separate Tokio task, so carry the caller's + // read policy across the task boundary explicitly. Tokio task-local + // values are not inherited by spawned tasks. + let read_policy = crate::set_disk::get_object_read_policy(); let erasure_cache = Arc::clone(&self.erasure_cache); let (fi, files, disks) = snapshot.into_owned(); tokio::spawn(async move { @@ -1923,26 +2056,40 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks { // `get_object_with_fileinfo` also waits on `writer`, so an outer timeout // would incorrectly treat downstream backpressure as disk-read latency. // Disk read timeouts must be enforced at the actual disk I/O operations. - let producer_result = Self::get_object_with_fileinfo( - &bucket, - &object, - erasure_cache, - offset, - length, - &mut writer, - fi, - files, - &disks, - set_index, - pool_index, - skip_verify, - false, - GET_OBJECT_PATH_LEGACY_DUPLEX, - object_class.as_str(), - size_bucket, - ) - .await; - if let Err(e) = &producer_result { + let producer_result = tokio::select! { + biased; + result = crate::set_disk::with_get_object_read_policy( + read_policy, + Self::get_object_with_fileinfo( + &bucket, + &object, + erasure_cache, + offset, + length, + &mut writer, + fi, + files, + &disks, + set_index, + pool_index, + skip_verify, + false, + GET_OBJECT_PATH_LEGACY_DUPLEX, + object_class.as_str(), + size_bucket, + ), + ) => result, + _ = async { + if let Some(cancellation) = producer_cancellation.as_ref() { + cancellation.cancelled().await; + } else { + std::future::pending::<()>().await; + } + } => Err(Error::OperationCanceled), + }; + if let Err(e) = &producer_result + && !matches!(e, Error::OperationCanceled) + { let reason = classify_storage_error(e); if reason == GetObjectFailureReason::DownstreamClosed { debug!( @@ -3243,7 +3390,7 @@ impl SetDisks { needs_immediate_heal = rename_commit.needs_immediate_heal(); if let Some(rename_tail_drain) = rename_commit.tail_drain.take() { tail_owns_tmp_cleanup = true; - let mut request = rustfs_common::heal_channel::create_heal_request_with_options( + let mut request = rustfs_heal_contracts::heal_channel::create_heal_request_with_options( commit_bucket.clone(), Some(commit_object.clone()), false, @@ -3351,7 +3498,7 @@ impl SetDisks { let mut fi = rename_commit.committed_file_info; if needs_immediate_heal { - let mut request = rustfs_common::heal_channel::create_heal_request_with_options( + let mut request = rustfs_heal_contracts::heal_channel::create_heal_request_with_options( commit_bucket.clone(), Some(commit_object.clone()), false, @@ -3363,7 +3510,7 @@ impl SetDisks { .or_else(|| commit_version_suspended.then(Uuid::nil)) .map(|version_id| version_id.to_string()); tokio::spawn(async move { - let _ = rustfs_common::heal_channel::send_heal_request(request).await; + let _ = rustfs_heal_contracts::heal_channel::send_heal_request(request).await; }); } @@ -3795,6 +3942,28 @@ mod legacy_duplex_producer_reader_tests { assert_eq!(out, b"complete"); } + #[tokio::test] + async fn producer_cancellation_reader_cancels_pending_producer_on_drop() { + let cancellation = CancellationToken::new(); + let producer_cancellation = cancellation.clone(); + let producer = tokio::spawn(async move { + tokio::select! { + _ = producer_cancellation.cancelled() => true, + _ = std::future::pending::<()>() => false, + } + }); + + let reader = ProducerCancellationReader::new(tokio::io::empty(), cancellation); + drop(reader); + + assert!( + tokio::time::timeout(std::time::Duration::from_secs(1), producer) + .await + .expect("dropping the consumer should cancel the producer promptly") + .expect("producer task should not panic") + ); + } + #[tokio::test] async fn legacy_duplex_reader_ignores_zero_capacity_read_buf() { let (mut writer, reader) = tokio::io::duplex(64); @@ -4985,7 +5154,7 @@ pub(in crate::set_disk::ops) fn object_transaction_fencing_requested() -> bool { #[cfg(not(test))] fn object_transaction_fencing_requested_cached() -> bool { - static REQUESTED: OnceLock = OnceLock::new(); + static REQUESTED: std::sync::OnceLock = std::sync::OnceLock::new(); *REQUESTED.get_or_init(load_object_transaction_fencing_requested) } @@ -7077,7 +7246,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks { Some(scope), ); } - let mut request = rustfs_common::heal_channel::create_heal_request_with_options( + let mut request = rustfs_heal_contracts::heal_channel::create_heal_request_with_options( bucket.to_string(), Some(object.to_string()), false, @@ -7086,7 +7255,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks { Some(self.set_index), ); request.object_version_id = (!version_id.is_empty()).then(|| version_id.to_string()); - if let Err(e) = rustfs_common::heal_channel::send_heal_request(request).await { + if let Err(e) = rustfs_heal_contracts::heal_channel::send_heal_request(request).await { warn!( bucket, object, @@ -8503,7 +8672,6 @@ mod replication_lww_tests { use super::hermetic_set_disks_support::hermetic_set_disks_isolated as hermetic_set_disks; use super::*; - use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _}; use rustfs_utils::http::headers::{ AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, AMZ_OBJECT_TAGGING, }; @@ -9131,8 +9299,7 @@ mod inline_put_commit_path_tests { use super::hermetic_set_disks_support::hermetic_set_disks_isolated as hermetic_set_disks; use super::*; use crate::config::storageclass::lookup_config_for_pools_without_env; - use crate::disk::{DiskAPI as _, ReadOptions}; - use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _}; + use crate::disk::ReadOptions; use rustfs_config::server_config::KVS; use serial_test::serial; use tokio::io::AsyncReadExt; @@ -9525,7 +9692,6 @@ mod get_object_downstream_close_accounting_tests { }; use crate::disk::RUSTFS_META_BUCKET; use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions}; - use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _}; use crate::test_metrics::CapturingRecorder; use std::time::Duration; @@ -9811,8 +9977,7 @@ mod get_object_downstream_close_accounting_tests { mod metadata_mutation_generation_tests { use super::hermetic_set_disks_support::hermetic_set_disks_isolated as hermetic_set_disks; use super::*; - use crate::disk::{DiskAPI as _, ReadOptions}; - use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _}; + use crate::disk::ReadOptions; async fn put_and_prime( set_disks: &Arc, @@ -10072,15 +10237,12 @@ mod transition_commit_failure_tests { use super::hermetic_set_disks_support::hermetic_set_disks; use super::*; use crate::bucket::lifecycle::lifecycle::{TRANSITION_COMPLETE, TRANSITION_PENDING, TransitionOptions}; - use crate::disk::DiskAPI as _; use crate::services::tier::test_util::{MockWarmBackend, register_mock_tier}; use crate::services::tier::tier::TierConfigMgr; - use crate::storage_api_contracts::multipart::MultipartOperations as _; - use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _}; use http::HeaderMap; use rustfs_filemeta::{RestoreStatusOps as _, parse_restore_obj_status}; use s3s::dto::RestoreRequest; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::io::AsyncReadExt; pub(super) fn restore_operation_id_metadata(operation_id: Uuid) -> HashMap { let mut metadata = HashMap::new(); @@ -11826,11 +11988,9 @@ mod transition_upload_integrity_tests { use super::transition_commit_failure_tests::{restore_metadata, restore_operation_id_metadata}; use super::*; use crate::bucket::lifecycle::lifecycle::{TRANSITION_PENDING, TransitionOptions}; - use crate::disk::DiskAPI as _; use crate::layout::endpoints::SetupType; use crate::services::tier::test_util::register_mock_tier; use crate::set_disk::replication::RestoreFinalizeBarrier; - use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _}; use http::HeaderMap; use rustfs_filemeta::RestoreStatusOps as _; use rustfs_lock::client::local::LocalClient; @@ -13584,9 +13744,7 @@ mod transition_source_identity_matrix_tests { use super::hermetic_set_disks_support::hermetic_set_disks; use super::*; use crate::bucket::lifecycle::lifecycle::{TRANSITION_PENDING, TransitionOptions}; - use crate::disk::DiskAPI as _; use crate::services::tier::test_util::register_mock_tier; - use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _}; #[test] fn transition_source_identity_treats_nil_version_as_null_source() { @@ -13832,7 +13990,7 @@ mod heterogeneous_pool_put_tests { }; use super::*; use crate::config::storageclass::lookup_config_for_pools_without_env; - use crate::disk::{DiskAPI as _, ReadOptions}; + use crate::disk::ReadOptions; use crate::services::notification_sys::install_remote_version_state_fleet_proof_for_test; use rustfs_config::server_config::KVS; use serial_test::serial; @@ -14256,7 +14414,6 @@ mod put_object_tmp_cleanup_tests { use super::hermetic_set_disks_support::hermetic_set_disks_isolated as hermetic_set_disks; use super::*; - use crate::disk::DiskAPI as _; use crate::set_disk::core::io_primitives::{ ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, rename_fanout_barrier, rename_fault_injection, }; @@ -15873,7 +16030,7 @@ mod put_object_tags_early_stop_regression_tests { use super::hermetic_set_disks_support::hermetic_set_disks_isolated as hermetic_set_disks; use super::*; - use crate::disk::{DiskAPI as _, ReadOptions}; + use crate::disk::ReadOptions; #[tokio::test] async fn put_object_tags_writes_all_online_disks_under_early_stop() { @@ -15960,9 +16117,7 @@ mod put_object_tags_early_stop_regression_tests { mod object_tagging_namespace_lock_tests { use super::hermetic_set_disks_support::hermetic_set_disks_isolated as hermetic_set_disks; use super::*; - use crate::disk::{DiskAPI as _, ReadOptions}; - use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _}; - use tokio::io::AsyncReadExt as _; + use crate::disk::ReadOptions; #[derive(Clone, Copy, Debug)] enum CompetingMutation { @@ -16234,7 +16389,6 @@ mod delete_objects_lock_gating_tests { use super::hermetic_set_disks_support::hermetic_set_disks_isolated as hermetic_set_disks; use super::hermetic_set_disks_support::hermetic_set_disks_with_lockers_and_ctx; use super::*; - use crate::disk::DiskAPI as _; use serial_test::serial; async fn put_plain_object(set_disks: &Arc, bucket: &str, object: &str) { @@ -16547,7 +16701,7 @@ mod delete_objects_lock_gating_tests { lifecycle_delete_all: Some(crate::object_api::LifecycleDeleteAllRequest { version_id: Some(trigger_version_id), delete_marker: false, - action: rustfs_common::metrics::IlmAction::DeleteAllVersionsAction, + action: rustfs_scanner_contracts::metrics::IlmAction::DeleteAllVersionsAction, rule_id: "rule".to_string(), phase: crate::object_api::LifecycleDeleteAllPhase::History, }), diff --git a/crates/ecstore/src/set_disk/read.rs b/crates/ecstore/src/set_disk/read.rs index 15e7e4e2a..794be00d9 100644 --- a/crates/ecstore/src/set_disk/read.rs +++ b/crates/ecstore/src/set_disk/read.rs @@ -12,7 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -use super::*; +use super::{ + Arc, Bytes, DiskError, DiskStore, ErasureCache, Error, FileInfo, GetCodecStreamingFallbackReason, GetObjectFileInfo, + GetObjectMetadataCacheEntry, GetObjectMetadataCacheGeneration, GetObjectMetadataCacheKey, GetObjectReadPolicy, HashAlgorithm, + LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_SET_DISK, OBJECT_OP_IGNORED_ERRS, ObjectInfo, ObjectOptions, RUSTFS_META_BUCKET, + ReadOptions, Result, SetDisks, StorageError, adaptive_duplex_buffer_size, build_get_codec_streaming_decode_engine, + build_inline_bitrot_readers_from_refs, collect_inline_data_shard_fileinfos_by_index, debug, error, + get_codec_streaming_metrics_path, get_codec_streaming_multipart_max_parts, get_object_read_policy, + is_codec_streaming_multipart_enabled, is_multipart_reader_setup_prefetch_enabled, object_fits_single_block, + reduce_read_quorum_errs, to_object_err, try_read_inline_data_shards_direct, warn, +}; use crate::diagnostics::get::{ GET_DIRECT_MEMORY_SUBPATH_DISK_DATA_BLOCKS, GET_DIRECT_MEMORY_SUBPATH_INLINE_BUFFERED, GET_METADATA_CACHE_DECISION_HIT, GET_METADATA_CACHE_DECISION_MISS, GET_METADATA_CACHE_DECISION_REJECT, GET_METADATA_CACHE_DECISION_SKIP, @@ -22,43 +31,152 @@ use crate::diagnostics::get::{ GET_METADATA_CACHE_REASON_NOT_READ_DATA, GET_METADATA_CACHE_REASON_PART_CHECKSUMS, GET_METADATA_CACHE_REASON_PART_NUMBER, GET_METADATA_CACHE_REASON_RAW_DATA_MOVEMENT_READ, GET_METADATA_CACHE_REASON_STALE_PUBLICATION, GET_METADATA_CACHE_REASON_USABLE, GET_METADATA_CACHE_REASON_VERSION_ID, GET_METADATA_CACHE_REASON_VERSION_SUSPENDED, - GET_METADATA_CACHE_REASON_VERSIONED, GET_METADATA_EARLY_STOP_REASON_CONFLICTING_METADATA, - GET_METADATA_EARLY_STOP_REASON_DELETE_MARKER, GET_METADATA_EARLY_STOP_REASON_ERROR, - GET_METADATA_EARLY_STOP_REASON_INSUFFICIENT_QUORUM, GET_METADATA_EARLY_STOP_REASON_NOT_FOUND, - GET_METADATA_EARLY_STOP_REASON_UNSAFE_REQUEST, GET_METADATA_EARLY_STOP_REASON_VALID_QUORUM, - GET_METADATA_EARLY_STOP_REASON_VERSION_MATCH_QUORUM, GET_METADATA_EARLY_STOP_REASON_VERSION_NOT_FOUND, - GET_METADATA_RESPONSE_CORRUPT, GET_METADATA_RESPONSE_DISK_NOT_FOUND, GET_METADATA_RESPONSE_ERROR, - GET_METADATA_RESPONSE_IGNORED, GET_METADATA_RESPONSE_NOT_FOUND, GET_METADATA_RESPONSE_TIMEOUT, GET_METADATA_RESPONSE_VALID, - GET_METADATA_RESPONSE_VERSION_NOT_FOUND, GET_OBJECT_PATH_CODEC_STREAMING, GET_OBJECT_PATH_DIRECT_MEMORY, - GET_OBJECT_PATH_INTERNAL_META, GET_OBJECT_PATH_LEGACY_DUPLEX, GET_OBJECT_PATH_SET_DISK, GET_STAGE_DECODE, - GET_STAGE_METADATA_CACHE_LOOKUP, GET_STAGE_METADATA_RESOLVE, GET_STAGE_RANGE, GET_STAGE_READER_SETUP, - GET_STAGE_READER_SETUP_DROP_PENDING, GET_STAGE_READER_SETUP_SCHEDULE, GET_STAGE_READER_SETUP_WAIT_QUORUM, - GET_STAGE_READER_TASK_BITROT_READER_INIT, GET_STAGE_READER_TASK_FILE_OPEN, GET_STAGE_READER_TASK_READER_CONSTRUCTION, - GetObjectFailureReason, classify_disk_error, get_stage_timer_if_enabled, mark_get_object_downstream_closed, - record_get_object_pipeline_failure, record_get_object_pipeline_failure_for_path, record_get_stage_duration_if_enabled, -}; -use crate::erasure::coding::BitrotReader; -use crate::io_support::bitrot::{ - BitrotReaderStageMetrics, DeferredReaderStripeHandle, create_bitrot_reader_with_stage_metrics, create_deferred_bitrot_reader, - object_mmap_read_enabled, + GET_METADATA_CACHE_REASON_VERSIONED, GET_OBJECT_PATH_DIRECT_MEMORY, GET_OBJECT_PATH_INTERNAL_META, + GET_OBJECT_PATH_LEGACY_DUPLEX, GET_OBJECT_PATH_SET_DISK, GET_STAGE_DECODE, GET_STAGE_METADATA_CACHE_LOOKUP, + GET_STAGE_METADATA_RESOLVE, GET_STAGE_RANGE, GET_STAGE_READER_SETUP, GET_STAGE_READER_TASK_BITROT_READER_INIT, + GET_STAGE_READER_TASK_FILE_OPEN, GET_STAGE_READER_TASK_READER_CONSTRUCTION, GetObjectFailureReason, classify_disk_error, + get_stage_timer_if_enabled, mark_get_object_downstream_closed, record_get_object_pipeline_failure, + record_get_object_pipeline_failure_for_path, record_get_stage_duration_if_enabled, }; +use crate::disk::DiskAPI; +use crate::io_support::bitrot::{BitrotReaderStageMetrics, DeferredReaderStripeHandle, object_mmap_read_enabled}; +use crate::set_disk::coding; +use crate::set_disk::runtime_sources; use crate::set_disk::shard_source::ShardReadCost; -use futures::stream::{FuturesUnordered, StreamExt}; -use metrics::counter; use std::{ - collections::{HashMap, VecDeque}, future::Future, io::IoSlice, pin::Pin, - sync::OnceLock, task::{Context, Poll}, time::{Duration, Instant}, }; use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; -use tokio::sync::RwLock; -use tokio::task::JoinSet; +#[cfg(test)] +use super::DEFAULT_GET_OBJECT_METADATA_CACHE_MAX_ENTRIES; +#[cfg(test)] +use super::DEFAULT_RUSTFS_GET_CODEC_STREAMING_MIN_SIZE; +#[cfg(test)] +use super::ENV_RUSTFS_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED; +#[cfg(test)] +use super::ENV_RUSTFS_GET_CODEC_STREAMING_DATA_BLOCKS_FIRST_ENABLE; +#[cfg(test)] +use super::ENV_RUSTFS_GET_CODEC_STREAMING_DATA_BLOCKS_FIRST_MAX_SIZE; +#[cfg(test)] +use super::ENV_RUSTFS_GET_CODEC_STREAMING_ENABLE; +#[cfg(test)] +use super::ENV_RUSTFS_GET_CODEC_STREAMING_ENGINE; +#[cfg(test)] +use super::ENV_RUSTFS_GET_CODEC_STREAMING_HEADER_COMPAT_CONFIRMED; +#[cfg(test)] +use super::ENV_RUSTFS_GET_CODEC_STREAMING_MIN_SIZE; +#[cfg(test)] +use super::ENV_RUSTFS_GET_CODEC_STREAMING_MULTIPART_ENABLE; +#[cfg(test)] +use super::ENV_RUSTFS_GET_CODEC_STREAMING_MULTIPART_MAX_PARTS; +#[cfg(test)] +use super::ENV_RUSTFS_GET_CODEC_STREAMING_ROLLOUT; +#[cfg(test)] +use super::ENV_RUSTFS_GET_CODEC_STREAMING_ROLLOUT_PCT; +#[cfg(test)] +use super::ENV_RUSTFS_GET_CODEC_STREAMING_RUSTFS_MIN_SIZE; +#[cfg(test)] +use super::ENV_RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE; +#[cfg(test)] +use super::ENV_RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT; +#[cfg(test)] +use super::ENV_RUSTFS_GET_METADATA_EARLY_STOP_ENABLE; +#[cfg(test)] +use super::ENV_RUSTFS_GET_METADATA_VERSION_EARLY_STOP_ENABLE; +#[cfg(test)] +use super::ENV_RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH; +#[cfg(test)] +use super::ENV_RUSTFS_GET_OBJECT_METADATA_CACHE_MAX_ENTRIES; +#[cfg(test)] +use super::GET_OBJECT_METADATA_CACHE_TTL; +#[cfg(test)] +use super::GetCodecStreamingConfig; +#[cfg(test)] +use super::GetCodecStreamingDecision; +#[cfg(test)] +use super::GetCodecStreamingEngine; +#[cfg(test)] +use super::GetCodecStreamingGate; +#[cfg(test)] +use super::GetCodecStreamingObjectClass; +#[cfg(test)] +use super::GetCodecStreamingRollout; +#[cfg(test)] +use super::classify_get_codec_streaming_object_class; use super::core::io_primitives::*; +#[cfg(test)] +use super::get_codec_streaming_config_cached_core; +#[cfg(test)] +use super::get_codec_streaming_engine; +#[cfg(test)] +use super::get_codec_streaming_reader_gate; +#[cfg(test)] +use super::get_object_metadata_cache_max_entries; +#[cfg(test)] +use super::is_get_metadata_data_read_early_stop_enabled; +#[cfg(test)] +use super::is_get_metadata_early_stop_bounded_fanout_enabled; +#[cfg(test)] +use super::is_get_metadata_early_stop_enabled; +#[cfg(test)] +use super::is_version_early_stop_enabled; +#[cfg(test)] +use super::load_get_codec_streaming_config; +#[cfg(test)] +use super::with_get_object_read_policy; +#[cfg(test)] +use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE; +#[cfg(test)] +use crate::diagnostics::get::GET_OBJECT_PATH_CODEC_STREAMING_LEGACY_ENGINE; +#[cfg(test)] +use crate::diagnostics::get::GET_OBJECT_PATH_CODEC_STREAMING_RUSTFS_ENGINE; +#[cfg(test)] +use crate::diagnostics::get::{ + GET_METADATA_EARLY_STOP_REASON_CONFLICTING_METADATA, GET_METADATA_EARLY_STOP_REASON_DELETE_MARKER, + GET_METADATA_EARLY_STOP_REASON_ERROR, GET_METADATA_EARLY_STOP_REASON_INSUFFICIENT_QUORUM, + GET_METADATA_EARLY_STOP_REASON_NOT_FOUND, GET_METADATA_EARLY_STOP_REASON_UNSAFE_REQUEST, + GET_METADATA_EARLY_STOP_REASON_VALID_QUORUM, GET_METADATA_EARLY_STOP_REASON_VERSION_MATCH_QUORUM, + GET_METADATA_EARLY_STOP_REASON_VERSION_NOT_FOUND, GET_METADATA_RESPONSE_CORRUPT, GET_METADATA_RESPONSE_DISK_NOT_FOUND, + GET_METADATA_RESPONSE_ERROR, GET_METADATA_RESPONSE_IGNORED, GET_METADATA_RESPONSE_NOT_FOUND, GET_METADATA_RESPONSE_TIMEOUT, + GET_METADATA_RESPONSE_VALID, GET_METADATA_RESPONSE_VERSION_NOT_FOUND, +}; +#[cfg(test)] +use crate::disk::ReadMultipleResp; +#[cfg(test)] +use crate::disk::format::FormatV3; +#[cfg(test)] +use crate::erasure::codec::bridge::CodecStreamingDecodeEngine; +#[cfg(test)] +use crate::erasure::codec::bridge::GET_CODEC_STREAMING_ENGINE_RUSTFS; +#[cfg(test)] +use crate::object_api::PutObjReader; +#[cfg(test)] +use crate::storage_api_contracts::object::ObjectIO; +#[cfg(test)] +use crate::storage_api_contracts::range::HTTPRangeSpec; +#[cfg(test)] +use rustfs_filemeta::ObjectPartInfo; +#[cfg(test)] +use rustfs_heal_contracts::heal_channel::HealAdmissionResult; +#[cfg(test)] +use rustfs_heal_contracts::heal_channel::HealChannelPriority; +#[cfg(test)] +use rustfs_utils::http::SUFFIX_COMPRESSION; +#[cfg(test)] +use rustfs_utils::http::insert_str; +#[cfg(test)] +use time::OffsetDateTime; +#[cfg(test)] +use tokio::sync::RwLock; +#[cfg(test)] +use tokio::time::timeout; +#[cfg(test)] +use uuid::Uuid; pub(super) struct GetObjectDownstreamWriter { inner: W, @@ -792,7 +910,7 @@ impl SetDisks { let use_mmap_read = object_mmap_read_enabled(); let files = Arc::new(files); let disks = Arc::new(disks); - let prefetch_enabled = is_multipart_reader_setup_prefetch_enabled(); + let prefetch_enabled = multipart_reader_setup_prefetch_enabled(get_object_read_policy()); let mut prefetched: Option<(usize, PrefetchedReaderSetup)> = None; let mut total_read = 0; @@ -1065,8 +1183,9 @@ impl SetDisks { let unattempted_data_shards = !reader_setup.data_shards_attempted(erasure.data_shards); let readers = reader_setup.readers; let deferred_stripe_handles = reader_setup.deferred_stripe_handles; + let deferred_reopeners = reader_setup.deferred_reopeners; let (written, err) = erasure - .decode_with_stripe_handles( + .decode_with_stripe_handles_and_reopeners( writer, readers, part_offset, @@ -1074,6 +1193,7 @@ impl SetDisks { part_size, read_costs, deferred_stripe_handles, + deferred_reopeners, ) .await; let decode_elapsed = decode_stage_start.elapsed(); @@ -1476,6 +1596,7 @@ impl SetDisks { erasure.clone(), reader_setup.readers, reader_setup.deferred_stripe_handles, + reader_setup.deferred_reopeners, read_costs, part_offset, part_length, @@ -1488,6 +1609,7 @@ impl SetDisks { let readers = reader_setup.readers; let deferred_stripe_handles = reader_setup.deferred_stripe_handles; + let deferred_reopeners = reader_setup.deferred_reopeners; let source = if let Some(read_costs) = read_costs { coding::decode::ParallelReader::new_with_metrics_path_read_costs_and_reconstruction_verification( readers, @@ -1506,7 +1628,8 @@ impl SetDisks { Some(metrics_path), ) } - .with_deferred_parity_handles(deferred_stripe_handles); + .with_deferred_parity_handles(deferred_stripe_handles) + .with_deferred_parity_reopeners(deferred_reopeners); let engine = build_get_codec_streaming_decode_engine(erasure.clone())?; let reader = coding::decode_reader::ErasureDecodeReader::new_with_metrics_path(source, engine, part_length, metrics_path)?; @@ -1535,6 +1658,10 @@ fn multipart_part_checksum_algo(fi: &FileInfo, part_number: usize) -> HashAlgori } } +fn multipart_reader_setup_prefetch_enabled(policy: GetObjectReadPolicy) -> bool { + policy.allows_multipart_setup_prefetch() && is_multipart_reader_setup_prefetch_enabled() +} + /// Run one part's bitrot reader setup and measure its wall-clock duration. /// /// Shared by the synchronous path and the prefetch task in @@ -1762,10 +1889,12 @@ impl Drop for LazyMultipartCodecStreamingReader { /// background task drives the decode into the write half while the returned /// reader drains the read half. No extra file descriptors are opened — the /// readers are moved in from the setup that just ran. +#[allow(clippy::too_many_arguments)] fn build_legacy_per_part_fallback_reader( erasure: coding::Erasure, readers: Vec>, deferred_stripe_handles: Vec>, + deferred_reopeners: Vec>, read_costs: Option>, part_offset: usize, part_length: usize, @@ -1775,7 +1904,7 @@ fn build_legacy_per_part_fallback_reader( let (read_half, mut write_half) = tokio::io::duplex(buffer); let decode = tokio::spawn(async move { let (_written, err) = erasure - .decode_with_stripe_handles( + .decode_with_stripe_handles_and_reopeners( &mut write_half, readers, part_offset, @@ -1783,6 +1912,7 @@ fn build_legacy_per_part_fallback_reader( part_size, read_costs, deferred_stripe_handles, + deferred_reopeners, ) .await; // Dropping `write_half` on return signals EOF to the reader half. @@ -1896,7 +2026,7 @@ fn is_get_object_metadata_cache_request_eligible(bucket: &str, opts: &ObjectOpti #[cfg(test)] mod metadata_cache_tests { use super::*; - use rustfs_common::heal_channel::HealAdmissionDropReason; + use rustfs_heal_contracts::heal_channel::HealAdmissionDropReason; use serial_test::serial; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Mutex, OnceLock}; @@ -1974,7 +2104,9 @@ mod metadata_cache_tests { } } - fn slow_read_repair_submitter(_request: rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture { + fn slow_read_repair_submitter( + _request: rustfs_heal_contracts::heal_channel::HealChannelRequest, + ) -> ReadRepairAdmissionFuture { SLOW_READ_REPAIR_SUBMITTER_CALLS.fetch_add(1, Ordering::Relaxed); Box::pin(async { tokio::time::sleep(Duration::from_millis(250)).await; @@ -1982,14 +2114,18 @@ mod metadata_cache_tests { }) } - fn dropped_read_repair_submitter(_request: rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture { + fn dropped_read_repair_submitter( + _request: rustfs_heal_contracts::heal_channel::HealChannelRequest, + ) -> ReadRepairAdmissionFuture { DROPPED_READ_REPAIR_SUBMITTER_CALLS.fetch_add(1, Ordering::Relaxed); Box::pin(async { ReadRepairAdmissionOutcome::Response(HealAdmissionResult::Dropped(HealAdmissionDropReason::PolicyDropped)) }) } - fn capture_read_repair_submitter(request: rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture { + fn capture_read_repair_submitter( + request: rustfs_heal_contracts::heal_channel::HealChannelRequest, + ) -> ReadRepairAdmissionFuture { CAPTURED_READ_REPAIR_CALLS.fetch_add(1, Ordering::Relaxed); *CAPTURED_READ_REPAIR_PRIORITY.lock().expect("capture mutex poisoned") = Some(request.priority); Box::pin(async { @@ -3230,6 +3366,7 @@ mod metadata_cache_tests { mod tests { use super::*; use crate::erasure::coding::BitrotWriter; + use serial_test::serial; use std::io::{Cursor, ErrorKind, IoSlice}; use std::sync::{ Arc, @@ -3240,6 +3377,15 @@ mod tests { const CODEC_STREAMING_TEST_BUCKET: &str = "bucket"; const CODEC_STREAMING_TEST_OBJECT: &str = "object"; + #[test] + #[serial] + fn multipart_reader_setup_prefetch_is_disabled_only_for_copy_sources() { + temp_env::with_var(ENV_RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH, Some("true"), || { + assert!(multipart_reader_setup_prefetch_enabled(GetObjectReadPolicy::Default)); + assert!(!multipart_reader_setup_prefetch_enabled(GetObjectReadPolicy::CopySource)); + }); + } + #[tokio::test] async fn downstream_writer_marks_closed_duplex_reader_as_downstream_close() { let (reader, inner) = tokio::io::duplex(64); @@ -5475,6 +5621,7 @@ mod tests { erasure, setup.readers, setup.deferred_stripe_handles, + Vec::new(), None, 0, data.len(), @@ -5525,6 +5672,7 @@ mod tests { erasure, setup.readers, setup.deferred_stripe_handles, + Vec::new(), None, 0, part2_len, @@ -5565,6 +5713,7 @@ mod tests { erasure, setup.readers, setup.deferred_stripe_handles, + Vec::new(), None, 0, data.len(), @@ -6001,6 +6150,49 @@ mod tests { ); } + #[tokio::test] + #[serial] + async fn codec_streaming_reader_gate_keeps_copy_source_demand_bound_for_all_classes() { + temp_env::async_with_vars( + [ + (ENV_RUSTFS_GET_CODEC_STREAMING_ENABLE, Some("true")), + (ENV_RUSTFS_GET_CODEC_STREAMING_ROLLOUT, Some("benchmark")), + (ENV_RUSTFS_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED, Some("true")), + (ENV_RUSTFS_GET_CODEC_STREAMING_HEADER_COMPAT_CONFIRMED, Some("true")), + (ENV_RUSTFS_GET_CODEC_STREAMING_MULTIPART_ENABLE, Some("true")), + (ENV_RUSTFS_GET_CODEC_STREAMING_MIN_SIZE, Some("1")), + ], + async { + let fi = codec_streaming_test_fileinfo(1024, 2); + let object_info = codec_streaming_test_object_info(&fi); + let normal = codec_streaming_reader_gate_for_test(&None, &object_info, &fi, true); + assert_eq!(normal.decision, GetCodecStreamingDecision::Use); + + let plain_fi = codec_streaming_test_fileinfo(1024, 1); + let plain_object_info = codec_streaming_test_object_info(&plain_fi); + let normal_plain = codec_streaming_reader_gate_for_test(&None, &plain_object_info, &plain_fi, true); + assert_eq!(normal_plain.object_class, GetCodecStreamingObjectClass::PlainSinglePart); + assert_eq!(normal_plain.decision, GetCodecStreamingDecision::Use); + + let copy = with_get_object_read_policy(GetObjectReadPolicy::CopySource, async { + let multipart = codec_streaming_reader_gate_for_test(&None, &object_info, &fi, true); + let plain = codec_streaming_reader_gate_for_test(&None, &plain_object_info, &plain_fi, true); + (multipart, plain) + }) + .await; + assert_eq!( + copy.0.decision, + GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::CopySourceDemandBound) + ); + assert_eq!( + copy.1.decision, + GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::CopySourceDemandBound) + ); + }, + ) + .await; + } + #[test] fn codec_streaming_reader_gate_keeps_multipart_default_off() { temp_env::with_vars( @@ -6102,6 +6294,10 @@ mod tests { assert_eq!(GetCodecStreamingFallbackReason::InvalidMinSize.as_str(), "invalid_min_size"); assert_eq!(GetCodecStreamingFallbackReason::ReadQuorumNotSafe.as_str(), "read_quorum_not_safe"); assert_eq!(GetCodecStreamingFallbackReason::MultipartPartLimit.as_str(), "multipart_part_limit"); + assert_eq!( + GetCodecStreamingFallbackReason::CopySourceDemandBound.as_str(), + "copy_source_demand_bound" + ); assert_eq!(GetCodecStreamingObjectClass::PlainSinglePart.as_str(), "plain_single_part"); assert_eq!(GetCodecStreamingObjectClass::Range.as_str(), "range"); assert_eq!(GetCodecStreamingObjectClass::Encrypted.as_str(), "encrypted"); diff --git a/crates/ecstore/src/set_disk/replication.rs b/crates/ecstore/src/set_disk/replication.rs index 5b38c8211..c1c0eeaf7 100644 --- a/crates/ecstore/src/set_disk/replication.rs +++ b/crates/ecstore/src/set_disk/replication.rs @@ -12,11 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -use super::*; +use super::{ + Error, FileInfo, NamespaceLockFence, ObjectInfo, ObjectOptions, OffsetDateTime, Result, SetDisks, StorageError, + UpdateMetadataOpts, Uuid, X_AMZ_RESTORE, get_raw_etag, restore_operation_id_from_metadata, +}; use crate::bucket::lifecycle::lifecycle; use rustfs_filemeta::RestoreStatusOps; use rustfs_utils::http::headers::{AMZ_RESTORE_EXPIRY_DAYS, AMZ_RESTORE_REQUEST_DATE}; use s3s::dto::{RestoreStatus, Timestamp}; +#[cfg(all(test, feature = "test-util"))] +use std::sync::Arc; #[cfg(all(test, feature = "test-util"))] struct RestoreFinalizeBarrierState { diff --git a/crates/ecstore/src/set_disk/transition_matrix_tests.rs b/crates/ecstore/src/set_disk/transition_matrix_tests.rs index a137d55a6..e43f83f8c 100644 --- a/crates/ecstore/src/set_disk/transition_matrix_tests.rs +++ b/crates/ecstore/src/set_disk/transition_matrix_tests.rs @@ -12,10 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -use super::*; +use super::{ + Arc, Duration, GetObjectMetadataCacheKey, HeaderMap, MakeBucketOptions, ObjectOptions, OffsetDateTime, PutObjReader, + SetDisks, Uuid, +}; use crate::bucket::lifecycle::lifecycle::{TRANSITION_COMPLETE, TRANSITION_PENDING, TransitionOptions, expected_expiry_time}; use crate::ecstore_validation_blackbox::make_local_set_disks; use crate::services::tier::test_util::register_mock_tier; +use crate::storage_api_contracts::bucket::BucketOperations; use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _}; use rustfs_filemeta::{RestoreStatusOps as _, parse_restore_obj_status}; use tokio::io::AsyncReadExt; diff --git a/crates/ecstore/src/store/heal.rs b/crates/ecstore/src/store/heal.rs index 206218492..9159d38b3 100644 --- a/crates/ecstore/src/store/heal.rs +++ b/crates/ecstore/src/store/heal.rs @@ -608,7 +608,7 @@ mod tests { use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations}; use crate::store::init_format::{load_format_erasure, save_format_file}; use crate::store::init_local_disks_with_instance_ctx; - use rustfs_common::heal_channel::DriveState; + use rustfs_heal_contracts::heal_channel::DriveState; use tokio_util::sync::CancellationToken; #[derive(Debug)] diff --git a/crates/ecstore/src/store/init.rs b/crates/ecstore/src/store/init.rs index 65292980a..257dd7bcb 100644 --- a/crates/ecstore/src/store/init.rs +++ b/crates/ecstore/src/store/init.rs @@ -700,7 +700,6 @@ mod tests { validate_durable_ilm_record, }, bucket::metadata::{BUCKET_LIFECYCLE_CONFIG, BUCKET_VERSIONING_CONFIG}, - client::transition_api::ReaderImpl, config::com, core::pools::DecomBucketInfo, data_movement::SourceCleanupDeleteBarrier, @@ -750,6 +749,8 @@ mod tests { #[cfg(feature = "test-util")] use rustfs_protos::{TIER_MUTATION_RPC_PROTOCOL_VERSION, TierMutationRpcPhase}; use rustfs_rio::{Checksum, ChecksumType}; + #[cfg(feature = "test-util")] + use rustfs_s3_client::transition_api::ReaderImpl; use rustfs_utils::{ CompressionAlgorithm, http::{SUFFIX_COMPRESSION, insert_str}, @@ -9537,7 +9538,7 @@ mod tests { .expect("unknown transition metadata should be written"); } let lifecycle_event = crate::bucket::lifecycle::lifecycle::Event { - action: rustfs_common::metrics::IlmAction::DeleteAllVersionsAction, + action: rustfs_scanner_contracts::metrics::IlmAction::DeleteAllVersionsAction, rule_id: "delete-all-versions".to_string(), ..Default::default() }; @@ -9709,7 +9710,7 @@ mod tests { lifecycle_delete_all: Some(crate::object_api::LifecycleDeleteAllRequest { version_id: original.version_id, delete_marker: false, - action: rustfs_common::metrics::IlmAction::DeleteAllVersionsAction, + action: rustfs_scanner_contracts::metrics::IlmAction::DeleteAllVersionsAction, rule_id: "rule".to_string(), phase: crate::object_api::LifecycleDeleteAllPhase::Preflight, }), diff --git a/crates/ecstore/src/store/list_objects.rs b/crates/ecstore/src/store/list_objects.rs index 683e9eeb3..e3ed34d24 100644 --- a/crates/ecstore/src/store/list_objects.rs +++ b/crates/ecstore/src/store/list_objects.rs @@ -42,7 +42,7 @@ use crate::storage_api_contracts::{ }; use crate::store::ECStore; use crate::store::utils::is_reserved_or_invalid_bucket; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use base64_simd::STANDARD as BASE64_STANDARD; use bytes::Bytes; use futures::future::join_all; use rand::seq::SliceRandom; @@ -1398,11 +1398,11 @@ async fn persist_observed_list_objects_mutation(store: Option<&ECStore>, bucket: } fn encode_persistent_list_metadata_string(value: &str) -> String { - BASE64_STANDARD.encode(value.as_bytes()) + BASE64_STANDARD.encode_to_string(value.as_bytes()) } fn decode_persistent_list_metadata_string(value: &str) -> Option { - let bytes = BASE64_STANDARD.decode(value).ok()?; + let bytes = BASE64_STANDARD.decode_to_vec(value).ok()?; String::from_utf8(bytes).ok() } diff --git a/crates/ecstore/src/store/mod.rs b/crates/ecstore/src/store/mod.rs index 6e8ba2e9b..2f3d87c7a 100644 --- a/crates/ecstore/src/store/mod.rs +++ b/crates/ecstore/src/store/mod.rs @@ -64,9 +64,9 @@ use futures::future::join_all; use http::HeaderMap; use lazy_static::lazy_static; use rand::RngExt as _; -use rustfs_common::heal_channel::{HealItemType, HealOpts}; use rustfs_config::server_config::Config; use rustfs_filemeta::FileInfo; +use rustfs_heal_contracts::heal_channel::{HealItemType, HealOpts}; use rustfs_lock::{LocalClient, LockClient, NamespaceLockWrapper}; use rustfs_madmin::heal_commands::HealResultItem; use rustfs_utils::path::{decode_dir_object, encode_dir_object, path_join_buf}; diff --git a/crates/ecstore/src/store/object.rs b/crates/ecstore/src/store/object.rs index c5b7c321c..6e3ce4214 100644 --- a/crates/ecstore/src/store/object.rs +++ b/crates/ecstore/src/store/object.rs @@ -490,7 +490,7 @@ fn decommission_mutation_fence_for_test( .map(|hook| hook.fence.clone()) } -#[cfg(test)] +#[cfg(all(test, feature = "test-util"))] struct DecommissionFreeVersionSourceRaceState { bucket: String, object: String, @@ -498,17 +498,17 @@ struct DecommissionFreeVersionSourceRaceState { release: tokio::sync::Notify, } -#[cfg(test)] +#[cfg(all(test, feature = "test-util"))] pub(crate) struct DecommissionFreeVersionSourceRaceBarrier { state: Arc, } -#[cfg(test)] +#[cfg(all(test, feature = "test-util"))] static DECOMMISSION_FREE_VERSION_SOURCE_RACE_BARRIER: std::sync::OnceLock< std::sync::Mutex>>, > = std::sync::OnceLock::new(); -#[cfg(test)] +#[cfg(all(test, feature = "test-util"))] impl DecommissionFreeVersionSourceRaceBarrier { pub(crate) fn install(bucket: &str, object: &str) -> Self { let state = Arc::new(DecommissionFreeVersionSourceRaceState { @@ -537,7 +537,7 @@ impl DecommissionFreeVersionSourceRaceBarrier { } } -#[cfg(test)] +#[cfg(all(test, feature = "test-util"))] impl Drop for DecommissionFreeVersionSourceRaceBarrier { fn drop(&mut self) { self.state.release.notify_one(); @@ -551,7 +551,7 @@ impl Drop for DecommissionFreeVersionSourceRaceBarrier { } } -#[cfg(test)] +#[cfg(all(test, feature = "test-util"))] async fn pause_decommission_free_version_before_source_lock(bucket: &str, object: &str) { let state = DECOMMISSION_FREE_VERSION_SOURCE_RACE_BARRIER .get_or_init(|| std::sync::Mutex::new(None)) @@ -2385,7 +2385,7 @@ impl ECStore { &object, )? }; - #[cfg(test)] + #[cfg(all(test, feature = "test-util"))] if is_free_version { pause_decommission_free_version_before_source_lock(bucket, logical_object).await; } @@ -2468,6 +2468,33 @@ impl ECStore { Self::resolve_decommission_tiered_object_result(result, bucket, &object) } + /// Open a source reader for a server-side copy. + /// + /// Copy consumers hold the source reader while a destination write can + /// apply backpressure. Keep that read contract explicit at the storage + /// boundary so the lower-level legacy multipart pipeline can suppress its + /// speculative next-part setup without changing the public `ObjectIO` + /// trait or `ObjectOptions` layout. + pub async fn get_object_reader_for_copy( + &self, + bucket: &str, + object: &str, + range: Option, + h: HeaderMap, + opts: &ObjectOptions, + ) -> Result<(GetObjectReader, tokio_util::sync::CancellationToken)> { + let cancellation = tokio_util::sync::CancellationToken::new(); + let reader = crate::set_disk::with_get_object_read_cancellation( + cancellation.clone(), + crate::set_disk::with_get_object_read_policy( + crate::set_disk::GetObjectReadPolicy::CopySource, + self.handle_get_object_reader(bucket, object, range, h, opts), + ), + ) + .await?; + Ok((reader, cancellation)) + } + #[instrument(level = "debug", skip(self, h))] #[hotpath::measure(impl_type = "ECStore")] pub(super) async fn handle_get_object_reader( diff --git a/crates/ecstore/src/store/rebalance.rs b/crates/ecstore/src/store/rebalance.rs index 4521e087f..ddcd918ac 100644 --- a/crates/ecstore/src/store/rebalance.rs +++ b/crates/ecstore/src/store/rebalance.rs @@ -1049,7 +1049,7 @@ mod tests { lifecycle_delete_all: Some(crate::object_api::LifecycleDeleteAllRequest { version_id: Some(trigger_id), delete_marker: false, - action: rustfs_common::metrics::IlmAction::DeleteAllVersionsAction, + action: rustfs_scanner_contracts::metrics::IlmAction::DeleteAllVersionsAction, rule_id: "rule".to_string(), phase: crate::object_api::LifecycleDeleteAllPhase::Preflight, }), @@ -1219,7 +1219,7 @@ mod tests { lifecycle_delete_all: Some(crate::object_api::LifecycleDeleteAllRequest { version_id: Some(marker_id), delete_marker: true, - action: rustfs_common::metrics::IlmAction::DelMarkerDeleteAllVersionsAction, + action: rustfs_scanner_contracts::metrics::IlmAction::DelMarkerDeleteAllVersionsAction, rule_id: "rule".to_string(), phase: crate::object_api::LifecycleDeleteAllPhase::Preflight, }), diff --git a/crates/ecstore/tests/ecstore_contract_compat_test.rs b/crates/ecstore/tests/ecstore_contract_compat_test.rs index d1d303f7a..ca20d42e5 100644 --- a/crates/ecstore/tests/ecstore_contract_compat_test.rs +++ b/crates/ecstore/tests/ecstore_contract_compat_test.rs @@ -14,8 +14,8 @@ mod storage_api; -use rustfs_common::heal_channel::HealOpts; use rustfs_filemeta::FileInfo; +use rustfs_heal_contracts::heal_channel::HealOpts; use rustfs_lock::NamespaceLockWrapper; use rustfs_madmin::heal_commands::HealResultItem; use storage_api::contract_compat::{ diff --git a/crates/heal/Cargo.toml b/crates/heal/Cargo.toml index ec2336fd7..511595429 100644 --- a/crates/heal/Cargo.toml +++ b/crates/heal/Cargo.toml @@ -77,6 +77,7 @@ rustfs-ecstore = { workspace = true } rustfs-lock = { workspace = true } rustfs-storage-api = { workspace = true } rustfs-common = { workspace = true } +rustfs-heal-contracts = { workspace = true } rustfs-madmin = { workspace = true } rustfs-utils = { workspace = true } tokio = { workspace = true, features = ["sync", "io-util", "time", "macros", "fs", "rt-multi-thread"] } @@ -89,7 +90,7 @@ uuid = { workspace = true, features = ["v4", "serde", "fast-rng", "macro-diagnos async-trait = { workspace = true } futures = { workspace = true } metrics = { workspace = true } -base64 = { workspace = true } +base64-simd = { workspace = true } bytes = { workspace = true } crc-fast = { workspace = true } sha2 = { workspace = true } diff --git a/crates/heal/src/heal/channel.rs b/crates/heal/src/heal/channel.rs index e00435100..7e2d38de0 100644 --- a/crates/heal/src/heal/channel.rs +++ b/crates/heal/src/heal/channel.rs @@ -19,7 +19,7 @@ use crate::heal::{ utils, }; use crate::{Error, Result}; -use rustfs_common::heal_channel::{ +use rustfs_heal_contracts::heal_channel::{ HealAdmissionReceipt, HealAdmissionResult, HealChannelCommand, HealChannelPriority, HealChannelReceiver, HealChannelRequest, HealChannelResponse, HealReceiptCommand, HealReceiptReceiver, HealRequestSource, HealScanMode, publish_heal_response, }; @@ -763,7 +763,7 @@ mod tests { use super::*; use crate::heal::manager::HealConfig; use crate::heal::storage::{HealObjectInfo, HealStorageAPI}; - use rustfs_common::heal_channel::{ + use rustfs_heal_contracts::heal_channel::{ HealAdmissionDropReason, HealAdmissionResult, HealChannelPriority, HealChannelRequest, HealRequestSource, HealScanMode, }; use std::sync::Arc; @@ -793,14 +793,14 @@ mod tests { _bucket: &str, _object: &str, _version_id: Option<&str>, - _opts: &rustfs_common::heal_channel::HealOpts, + _opts: &rustfs_heal_contracts::heal_channel::HealOpts, ) -> crate::Result<(rustfs_madmin::heal_commands::HealResultItem, Option)> { Ok((rustfs_madmin::heal_commands::HealResultItem::default(), None)) } async fn heal_bucket( &self, _bucket: &str, - _opts: &rustfs_common::heal_channel::HealOpts, + _opts: &rustfs_heal_contracts::heal_channel::HealOpts, ) -> crate::Result { Ok(rustfs_madmin::heal_commands::HealResultItem::default()) } @@ -1368,7 +1368,7 @@ mod tests { source: HealRequestSource::Admin, ..Default::default() }; - let mut responses = rustfs_common::heal_channel::subscribe_heal_responses(); + let mut responses = rustfs_heal_contracts::heal_channel::subscribe_heal_responses(); let (tx, rx) = oneshot::channel(); processor diff --git a/crates/heal/src/heal/erasure_healer.rs b/crates/heal/src/heal/erasure_healer.rs index 92a245c92..4d1e0b7c5 100644 --- a/crates/heal/src/heal/erasure_healer.rs +++ b/crates/heal/src/heal/erasure_healer.rs @@ -24,7 +24,7 @@ use crate::heal::{ use crate::{Error, Result}; use futures::{StreamExt, stream::FuturesUnordered}; use metrics::{counter, gauge}; -use rustfs_common::heal_channel::{HealOpts, HealRequestSource, HealScanMode}; +use rustfs_heal_contracts::heal_channel::{HealOpts, HealRequestSource, HealScanMode}; use rustfs_madmin::heal_commands::HealResultItem; use std::sync::{ Arc, @@ -1346,7 +1346,7 @@ impl ErasureSetHealer { #[cfg(test)] mod tests { use super::{ErasureSetHealer, PageConcurrencyGuard}; - use rustfs_common::heal_channel::{HealRequestSource, HealScanMode}; + use rustfs_heal_contracts::heal_channel::{HealRequestSource, HealScanMode}; use std::sync::{ Arc, atomic::{AtomicUsize, Ordering}, @@ -1523,7 +1523,7 @@ mod resume_loop_tests { BUCKET_META_PREFIX, DiskOption, DiskStore, EcstoreError, Endpoint, HealDiskExt as _, RUSTFS_META_BUCKET, new_disk, }; use crate::{Error, Result}; - use rustfs_common::heal_channel::{HealOpts, HealRequestSource}; + use rustfs_heal_contracts::heal_channel::{HealOpts, HealRequestSource}; use rustfs_madmin::heal_commands::{HealDriveInfo, HealResultItem, Infos}; use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/crates/heal/src/heal/manager.rs b/crates/heal/src/heal/manager.rs index b6016c9e4..cb159772a 100644 --- a/crates/heal/src/heal/manager.rs +++ b/crates/heal/src/heal/manager.rs @@ -20,8 +20,10 @@ use crate::heal::{ }; use crate::{Error, Result}; use metrics::{counter, gauge}; -use rustfs_common::heal_channel::{HealAdmissionDropReason, HealAdmissionReceipt, HealAdmissionResult, HealRequestSource}; use rustfs_concurrency::{AdmissionState, WorkloadAdmissionSnapshotProvider, WorkloadClass}; +use rustfs_heal_contracts::heal_channel::{ + HealAdmissionDropReason, HealAdmissionReceipt, HealAdmissionResult, HealRequestSource, +}; use rustfs_madmin::heal_commands::HealResultItem; #[cfg(test)] use std::sync::LazyLock; diff --git a/crates/heal/src/heal/manager/tests.rs b/crates/heal/src/heal/manager/tests.rs index 585b7a4e7..37a100f4b 100644 --- a/crates/heal/src/heal/manager/tests.rs +++ b/crates/heal/src/heal/manager/tests.rs @@ -17,8 +17,8 @@ use crate::heal::EcstoreError; use crate::heal::resume::{CheckpointManager, ReplacementTargetIdentity}; use crate::heal::storage::{HealObjectInfo, HealStorageAPI}; use crate::heal::task::{BatchHealFailure, HealOptions, HealPriority, HealRequest, HealTask, HealType}; -use rustfs_common::heal_channel::{HealOpts, HealRequestSource}; use rustfs_concurrency::{WorkloadAdmissionRegistrySnapshot, WorkloadAdmissionSnapshot}; +use rustfs_heal_contracts::heal_channel::{HealOpts, HealRequestSource}; use rustfs_madmin::heal_commands::HealResultItem; use std::sync::Mutex as StdMutex; use tempfile::TempDir; diff --git a/crates/heal/src/heal/mrf_queue.rs b/crates/heal/src/heal/mrf_queue.rs index 5cbe8e79b..026653d03 100644 --- a/crates/heal/src/heal/mrf_queue.rs +++ b/crates/heal/src/heal/mrf_queue.rs @@ -35,8 +35,8 @@ use super::{DiskStore, HealDiskExt as _, local_disk_map_read}; use crate::heal::manager::{HealManager, MrfRepairNoticeTarget}; use metrics::{counter, gauge}; -use rustfs_common::heal_channel::{HealAdmissionDropReason, HealAdmissionResult}; use rustfs_common::mrf_channel::{MRF_MAX_ATTEMPTS, MrfIntent}; +use rustfs_heal_contracts::heal_channel::{HealAdmissionDropReason, HealAdmissionResult}; use std::collections::{HashSet, VecDeque}; use std::sync::Arc; use std::time::Duration; @@ -483,7 +483,7 @@ pub(crate) fn build_heal_request(intent: &MrfIntent) -> HealRequest { options.set_index = usize::try_from(scope.set_index).ok(); } let mut request = HealRequest::new(heal_type, options, priority); - request.source = rustfs_common::heal_channel::HealRequestSource::Mrf; + request.source = rustfs_heal_contracts::heal_channel::HealRequestSource::Mrf; request } diff --git a/crates/heal/src/heal/resume/checkpoint.rs b/crates/heal/src/heal/resume/checkpoint.rs index b3f097c9f..505f10a4d 100644 --- a/crates/heal/src/heal/resume/checkpoint.rs +++ b/crates/heal/src/heal/resume/checkpoint.rs @@ -13,7 +13,6 @@ // limitations under the License. use crate::{Error, Result}; -use base64::Engine as _; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashSet; @@ -827,7 +826,7 @@ impl CheckpointManager { } fn checkpoint_digest(checkpoint_data: &[u8]) -> String { - base64::engine::general_purpose::STANDARD.encode(Sha256::digest(checkpoint_data)) + base64_simd::STANDARD.encode_to_string(Sha256::digest(checkpoint_data)) } fn digest_path(task_id: &str) -> std::path::PathBuf { diff --git a/crates/heal/src/heal/storage.rs b/crates/heal/src/heal/storage.rs index 8c2c5fc2f..1f857aca1 100644 --- a/crates/heal/src/heal/storage.rs +++ b/crates/heal/src/heal/storage.rs @@ -14,9 +14,8 @@ use crate::{Error, Result}; use async_trait::async_trait; -use base64::Engine as _; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use rustfs_common::heal_channel::{HealOpts, HealScanMode}; +use base64_simd::URL_SAFE_NO_PAD; +use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode}; use rustfs_madmin::heal_commands::HealResultItem; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -147,7 +146,7 @@ pub(crate) fn encode_heal_token(marker: Option<&str>, version_marker: Option<&st // serde_json of a simple two-Option struct cannot fail; fall back to an // empty object rather than panicking if it somehow does. let json = serde_json::to_vec(&payload).unwrap_or_else(|_| b"{}".to_vec()); - format!("{HEAL_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(json)) + format!("{HEAL_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode_to_string(json)) } /// Decode an opaque heal continuation token back into `(marker, version_marker)`. @@ -174,7 +173,7 @@ pub(crate) fn decode_heal_token(token: &str) -> (Option, Option) return (None, None); }; - let bytes = match URL_SAFE_NO_PAD.decode(encoded) { + let bytes = match URL_SAFE_NO_PAD.decode_to_vec(encoded) { Ok(bytes) => bytes, Err(e) => { warn!( @@ -234,7 +233,7 @@ const DISK_WALK_TOKEN_PREFIX: &str = "dw1:"; /// enumerators can never misread each other's cursor: a `dw1:` token decodes to /// `(None, None)` under the B5 decoder, and a `v1:` token decodes to `None` here. pub(crate) fn encode_disk_walk_token(next_forward: &str) -> String { - format!("{DISK_WALK_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(next_forward.as_bytes())) + format!("{DISK_WALK_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode_to_string(next_forward.as_bytes())) } /// Decode a disk-walk continuation token back into the `next_forward` object key. @@ -261,7 +260,7 @@ pub(crate) fn decode_disk_walk_token(token: &str) -> Option { return None; }; - let bytes = match URL_SAFE_NO_PAD.decode(encoded) { + let bytes = match URL_SAFE_NO_PAD.decode_to_vec(encoded) { Ok(bytes) => bytes, Err(e) => { warn!( @@ -1513,7 +1512,6 @@ mod tests { decode_disk_walk_token, decode_heal_token, encode_disk_walk_token, encode_heal_token, is_transient_object_exists_error, is_transient_object_exists_message, next_heal_listing_token, }; - use base64::Engine as _; #[test] fn next_heal_listing_token_returns_none_for_complete_page() { @@ -1564,7 +1562,7 @@ mod tests { assert_eq!(decode_heal_token("no-prefix-here"), (None, None)); assert_eq!(decode_heal_token("v1:!!!not-base64!!!"), (None, None)); // valid base64 of non-JSON bytes. - let bad_json = format!("v1:{}", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"not json")); + let bad_json = format!("v1:{}", base64_simd::URL_SAFE_NO_PAD.encode_to_string(b"not json")); assert_eq!(decode_heal_token(&bad_json), (None, None)); // a raw v2-style list_objects_v2 token (no "v1:" prefix) resets cleanly. assert_eq!(decode_heal_token("some-opaque-legacy-token"), (None, None)); @@ -1576,7 +1574,7 @@ mod tests { // list_object_versions returns NotImplemented for that pairing. // Craft a token whose JSON encodes (None, Some) directly and confirm coercion. let json = br#"{"m":null,"v":"orphan-version"}"#; - let token = format!("v1:{}", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json)); + let token = format!("v1:{}", base64_simd::URL_SAFE_NO_PAD.encode_to_string(json)); assert_eq!(decode_heal_token(&token), (None, None), "version-only marker must coerce to (None, None)"); } diff --git a/crates/heal/src/heal/task.rs b/crates/heal/src/heal/task.rs index c825869fe..ffd2ac618 100644 --- a/crates/heal/src/heal/task.rs +++ b/crates/heal/src/heal/task.rs @@ -23,8 +23,8 @@ use crate::heal::{ }; use crate::{Error, Result}; use metrics::{counter, histogram}; -use rustfs_common::heal_channel::{HealOpts, HealRequestSource, HealScanMode}; use rustfs_common::trace_bus::{TraceEvent, TraceFunc, TraceKind, trace_emit}; +use rustfs_heal_contracts::heal_channel::{HealOpts, HealRequestSource, HealScanMode}; use rustfs_madmin::heal_commands::HealResultItem; use rustfs_utils::path::SLASH_SEPARATOR; use serde::{Deserialize, Serialize}; diff --git a/crates/heal/src/lib.rs b/crates/heal/src/lib.rs index 7b91df47a..762c6c0c6 100644 --- a/crates/heal/src/lib.rs +++ b/crates/heal/src/lib.rs @@ -176,7 +176,7 @@ pub async fn init_heal_manager_with_workload_provider( let channel_receiver = if force_channel_failure { Err("forced heal channel initialization failure") } else { - rustfs_common::heal_channel::init_heal_channels() + rustfs_heal_contracts::heal_channel::init_heal_channels() }; let (receiver, receipt_receiver) = match channel_receiver { Ok(receivers) => receivers, @@ -358,7 +358,7 @@ mod tests { heal::storage::HealStorageAPI, init_heal_manager, run_owned_initialization, }; use crate::heal::storage_api::status::BucketInfo; - use rustfs_common::heal_channel::HealOpts; + use rustfs_heal_contracts::heal_channel::HealOpts; use rustfs_madmin::heal_commands::HealResultItem; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/crates/heal/tests/heal_b5_versioned_regression_test.rs b/crates/heal/tests/heal_b5_versioned_regression_test.rs index 05d789d4f..81cfdc79a 100644 --- a/crates/heal/tests/heal_b5_versioned_regression_test.rs +++ b/crates/heal/tests/heal_b5_versioned_regression_test.rs @@ -25,7 +25,6 @@ #![recursion_limit = "256"] use http::HeaderMap; -use rustfs_common::heal_channel::{HealOpts, HealScanMode}; use rustfs_heal::heal::{ manager::{HealConfig, HealManager}, storage::{ @@ -33,6 +32,7 @@ use rustfs_heal::heal::{ }, task::{HealOptions, HealPriority, HealRequest, HealTaskStatus, HealType}, }; +use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode}; use serial_test::serial; use std::{ path::{Path, PathBuf}, diff --git a/crates/heal/tests/heal_b920_subquorum_union_test.rs b/crates/heal/tests/heal_b920_subquorum_union_test.rs index eabda8e11..6be2d0595 100644 --- a/crates/heal/tests/heal_b920_subquorum_union_test.rs +++ b/crates/heal/tests/heal_b920_subquorum_union_test.rs @@ -24,10 +24,10 @@ #![recursion_limit = "256"] use http::HeaderMap; -use rustfs_common::heal_channel::{HealOpts, HealScanMode}; use rustfs_heal::heal::storage::{ ECStoreHealStorage, HealListItem, HealObjectOptions as ObjectOptions, HealPutObjReader as PutObjReader, HealStorageAPI, }; +use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode}; use serial_test::serial; use std::{ future::Future, diff --git a/crates/heal/tests/heal_bug_fixes_test.rs b/crates/heal/tests/heal_bug_fixes_test.rs index 59ff1bb3f..d2690b6be 100644 --- a/crates/heal/tests/heal_bug_fixes_test.rs +++ b/crates/heal/tests/heal_bug_fixes_test.rs @@ -121,14 +121,14 @@ fn test_heal_task_status_atomic_update() { _bucket: &str, _object: &str, _version_id: Option<&str>, - _opts: &rustfs_common::heal_channel::HealOpts, + _opts: &rustfs_heal_contracts::heal_channel::HealOpts, ) -> rustfs_heal::Result<(rustfs_madmin::heal_commands::HealResultItem, Option)> { Ok((rustfs_madmin::heal_commands::HealResultItem::default(), None)) } async fn heal_bucket( &self, _bucket: &str, - _opts: &rustfs_common::heal_channel::HealOpts, + _opts: &rustfs_heal_contracts::heal_channel::HealOpts, ) -> rustfs_heal::Result { Ok(rustfs_madmin::heal_commands::HealResultItem::default()) } @@ -221,7 +221,7 @@ async fn test_heal_task_transient_object_exists_skip_avoids_recreate() { _bucket: &str, _object: &str, _version_id: Option<&str>, - _opts: &rustfs_common::heal_channel::HealOpts, + _opts: &rustfs_heal_contracts::heal_channel::HealOpts, ) -> rustfs_heal::Result<(rustfs_madmin::heal_commands::HealResultItem, Option)> { self.heal_object_calls.fetch_add(1, Ordering::SeqCst); Ok((rustfs_madmin::heal_commands::HealResultItem::default(), None)) @@ -230,7 +230,7 @@ async fn test_heal_task_transient_object_exists_skip_avoids_recreate() { async fn heal_bucket( &self, _bucket: &str, - _opts: &rustfs_common::heal_channel::HealOpts, + _opts: &rustfs_heal_contracts::heal_channel::HealOpts, ) -> rustfs_heal::Result { Ok(rustfs_madmin::heal_commands::HealResultItem::default()) } diff --git a/crates/heal/tests/heal_integration_test.rs b/crates/heal/tests/heal_integration_test.rs index 940a69f73..213b43e86 100644 --- a/crates/heal/tests/heal_integration_test.rs +++ b/crates/heal/tests/heal_integration_test.rs @@ -15,12 +15,12 @@ #![recursion_limit = "256"] use http::HeaderMap; -use rustfs_common::heal_channel::{HealOpts, HealScanMode}; use rustfs_heal::heal::{ manager::{HealConfig, HealManager}, storage::{ECStoreHealStorage, HealObjectOptions as ObjectOptions, HealPutObjReader as PutObjReader, HealStorageAPI}, task::{HealOptions, HealPriority, HealRequest, HealTaskStatus, HealType}, }; +use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode}; use serial_test::serial; use std::{ path::{Path, PathBuf}, diff --git a/crates/kms/Cargo.toml b/crates/kms/Cargo.toml index f8491d5a2..77f853097 100644 --- a/crates/kms/Cargo.toml +++ b/crates/kms/Cargo.toml @@ -50,8 +50,8 @@ aes-gcm = { workspace = true, features = ["rand_core"] } argon2 = { workspace = true } chacha20poly1305 = { workspace = true } rand = { workspace = true, features = ["serde"] } -base64 = { workspace = true } -hex = { workspace = true } +base64-simd = { workspace = true } +hex-simd = { workspace = true } sha2 = { workspace = true } subtle = { workspace = true } zeroize = { workspace = true, features = ["derive"] } diff --git a/crates/kms/examples/kms_dr_drill.rs b/crates/kms/examples/kms_dr_drill.rs index 9074bb202..7c673b4e3 100644 --- a/crates/kms/examples/kms_dr_drill.rs +++ b/crates/kms/examples/kms_dr_drill.rs @@ -23,7 +23,7 @@ //! Exit status is the verdict: 0 when every check held, 1 otherwise, so a //! scheduled drill fails its job instead of quietly filing a bad report. -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use base64_simd::STANDARD as BASE64; use rustfs_kms::backup::{BackupKek, DrillDataset, DrillDisaster, DrillRequest, DrillVerdict, run_local_drill}; use std::path::PathBuf; use std::process::ExitCode; @@ -100,7 +100,11 @@ fn disaster_from_env() -> Result { fn kek_from_env() -> Result { let raw = Zeroizing::new(required(ENV_KEK)?); - let decoded = Zeroizing::new(BASE64.decode(raw.trim()).map_err(|_| format!("{ENV_KEK} must be base64"))?); + let decoded = Zeroizing::new( + BASE64 + .decode_to_vec(raw.trim()) + .map_err(|_| format!("{ENV_KEK} must be base64"))?, + ); if decoded.len() != 32 { return Err(format!("{ENV_KEK} must decode to exactly 32 bytes")); } diff --git a/crates/kms/examples/local_kms_key_decrypt.rs b/crates/kms/examples/local_kms_key_decrypt.rs index 37f174ccc..75365d90a 100644 --- a/crates/kms/examples/local_kms_key_decrypt.rs +++ b/crates/kms/examples/local_kms_key_decrypt.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use base64_simd::STANDARD as BASE64_STANDARD; use rustfs_kms::{LocalConfig, backends::local::LocalKmsClient}; use std::io::{self, Write}; use std::path::{Path, PathBuf}; @@ -69,7 +69,7 @@ async fn run() -> Result<(), String> { .decrypt_key_material_for_export(&key_id) .await .map_err(|error| error.to_string())?; - let encoded = Zeroizing::new(BASE64_STANDARD.encode(key_material.as_ref())); + let encoded = Zeroizing::new(BASE64_STANDARD.encode_to_string(key_material.as_ref())); let mut stdout = io::stdout().lock(); writeln!(stdout, "{}", encoded.as_str()).map_err(|error| format!("failed to write decrypted key: {error}")) diff --git a/crates/kms/src/audit.rs b/crates/kms/src/audit.rs index df809926e..87a09e080 100644 --- a/crates/kms/src/audit.rs +++ b/crates/kms/src/audit.rs @@ -305,7 +305,7 @@ pub fn redact_encryption_context(encryption_context: &HashMap) - } fn digest_value(value: &str) -> String { - let digest = hex::encode(Sha256::digest(value.as_bytes())); + let digest = hex_simd::encode_to_string(Sha256::digest(value.as_bytes()), hex_simd::AsciiCase::Lower); format!("{DIGEST_PREFIX}{}", &digest[..DIGEST_LEN]) } diff --git a/crates/kms/src/backends/aws.rs b/crates/kms/src/backends/aws.rs index 93b85c956..87ee53ae7 100644 --- a/crates/kms/src/backends/aws.rs +++ b/crates/kms/src/backends/aws.rs @@ -848,8 +848,7 @@ mod tests { use aws_sdk_kms::config::{BehaviorVersion, Credentials, Region}; use aws_smithy_http_client::test_util::{NeverClient, ReplayEvent, StaticReplayClient}; use aws_smithy_types::body::SdkBody; - use base64::Engine as _; - use base64::engine::general_purpose::STANDARD as BASE64; + use base64_simd::STANDARD as BASE64; use std::sync::atomic::{AtomicU64, Ordering}; /// AWS KMS speaks awsJson1_1; every request goes to `/` on the regional @@ -977,8 +976,8 @@ mod tests { let ciphertext = b"encrypted-data-key".to_vec(); let (http_client, backend) = scripted_backend(vec![ok_event(serde_json::json!({ "KeyId": "arn:aws:kms:us-east-1:111122223333:key/test-key", - "Plaintext": BASE64.encode(&plaintext), - "CiphertextBlob": BASE64.encode(&ciphertext), + "Plaintext": BASE64.encode_to_string(&plaintext), + "CiphertextBlob": BASE64.encode_to_string(&ciphertext), }))]); let response = backend @@ -997,7 +996,7 @@ mod tests { let plaintext = b"recovered-data-key".to_vec(); let (_http, backend) = scripted_backend(vec![ok_event(serde_json::json!({ "KeyId": "arn:aws:kms:us-east-1:111122223333:key/test-key", - "Plaintext": BASE64.encode(&plaintext), + "Plaintext": BASE64.encode_to_string(&plaintext), "EncryptionAlgorithm": "SYMMETRIC_DEFAULT", }))]); @@ -1061,8 +1060,8 @@ mod tests { error_event(400, "ThrottlingException", "rate exceeded"), ok_event(serde_json::json!({ "KeyId": "test-key", - "Plaintext": BASE64.encode([1u8; 32]), - "CiphertextBlob": BASE64.encode(b"blob"), + "Plaintext": BASE64.encode_to_string([1u8; 32]), + "CiphertextBlob": BASE64.encode_to_string(b"blob"), })), ]); diff --git a/crates/kms/src/backends/contract_tests.rs b/crates/kms/src/backends/contract_tests.rs index ed1342f1f..8b25643b0 100644 --- a/crates/kms/src/backends/contract_tests.rs +++ b/crates/kms/src/backends/contract_tests.rs @@ -41,8 +41,7 @@ use crate::types::{ DescribeKeyRequest, EncryptRequest, GenerateDataKeyRequest, KeySpec, KeyState, KeyUsage, ObjectEncryptionContext, RewrapDataKeyRequest, }; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64; +use base64_simd::STANDARD as BASE64; use rand::RngExt as _; use std::collections::HashMap; use std::sync::Arc; @@ -285,7 +284,7 @@ async fn static_backend_stateless_contract() { let key_id = "static-contract-key"; let mut raw_key = [0u8; 32]; rand::rng().fill(&mut raw_key[..]); - let config = KmsConfig::static_kms(key_id.to_string(), BASE64.encode(raw_key)); + let config = KmsConfig::static_kms(key_id.to_string(), BASE64.encode_to_string(raw_key)); let static_backend = StaticKmsBackend::new(config).await.expect("static backend should build"); let backend: &dyn KmsBackend = &static_backend; diff --git a/crates/kms/src/backends/local.rs b/crates/kms/src/backends/local.rs index b694f366a..ac10274fc 100644 --- a/crates/kms/src/backends/local.rs +++ b/crates/kms/src/backends/local.rs @@ -33,7 +33,7 @@ use aes_gcm::{ }; use argon2::{Algorithm, Argon2, Params, Version}; use async_trait::async_trait; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use base64_simd::STANDARD as BASE64; use jiff::Zoned; use rand::RngExt; use serde::de::{self, IgnoredAny, MapAccess, Visitor}; @@ -1268,7 +1268,7 @@ impl LocalKmsClient { } let encrypted_bytes = BASE64 - .decode(&stored_key.encrypted_key_material) + .decode_to_vec(&stored_key.encrypted_key_material) .map_err(|e| KmsError::material_corrupt(key_id, format!("stored key material is not valid base64: {e}")))?; let effective_protection = if stored_key.at_rest_protection == StoredKeyProtection::LegacyUnspecified { @@ -1406,13 +1406,17 @@ impl LocalKmsClient { .encrypt(&nonce, key_material) .map_err(|e| KmsError::cryptographic_error("encrypt", e.to_string()))?; // Encode encrypted bytes to base64 string - (BASE64.encode(&encrypted), nonce.to_vec(), StoredKeyProtection::EncryptedMasterKey) + ( + BASE64.encode_to_string(&encrypted), + nonce.to_vec(), + StoredKeyProtection::EncryptedMasterKey, + ) } else { warn!( key_id = %master_key.key_id, "Local KMS is storing key material as plaintext-dev-only because no master key is configured" ); - (BASE64.encode(key_material), Vec::new(), StoredKeyProtection::PlaintextDevOnly) + (BASE64.encode_to_string(key_material), Vec::new(), StoredKeyProtection::PlaintextDevOnly) }; let stored_key = StoredMasterKey { @@ -2648,10 +2652,10 @@ mod tests { let tampered_material = { let mut material = BASE64 - .decode(pristine["encrypted_key_material"].as_str().expect("material is a string")) + .decode_to_vec(pristine["encrypted_key_material"].as_str().expect("material is a string")) .expect("decode pristine material"); *material.last_mut().expect("material is not empty") ^= 0x01; - BASE64.encode(&material) + BASE64.encode_to_string(&material) }; type PoisonCase = (&'static str, Vec, fn(&KmsError) -> bool); @@ -2976,7 +2980,7 @@ mod tests { "created_at": "2024-01-01T00:00:00+00:00", "rotated_at": serde_json::Value::Null, "created_by": "legacy-test", - "encrypted_key_material": BASE64.encode([7u8; 32]), + "encrypted_key_material": BASE64.encode_to_string([7u8; 32]), "nonce": Vec::::new() }); diff --git a/crates/kms/src/backends/mod.rs b/crates/kms/src/backends/mod.rs index a6213724e..6fcb26a9b 100644 --- a/crates/kms/src/backends/mod.rs +++ b/crates/kms/src/backends/mod.rs @@ -722,8 +722,7 @@ impl Default for BackendCapabilities { mod tests { use super::*; use crate::config::KmsConfig; - use base64::Engine as _; - use base64::engine::general_purpose::STANDARD as BASE64; + use base64_simd::STANDARD as BASE64; /// Backend that implements only the trait-mandated operations and relies /// on the default `capabilities` implementation. @@ -958,7 +957,7 @@ mod tests { #[tokio::test] async fn static_backend_capabilities_golden() { - let config = KmsConfig::static_kms("static-key".to_string(), BASE64.encode([0u8; 32])); + let config = KmsConfig::static_kms("static-key".to_string(), BASE64.encode_to_string([0u8; 32])); let backend = static_kms::StaticKmsBackend::new(config) .await .expect("static backend should build"); diff --git a/crates/kms/src/backends/static_kms.rs b/crates/kms/src/backends/static_kms.rs index 2f3560497..2ad2d3e1a 100644 --- a/crates/kms/src/backends/static_kms.rs +++ b/crates/kms/src/backends/static_kms.rs @@ -434,8 +434,7 @@ mod tests { use crate::backends::KmsBackend as KmsBackendTrait; use crate::config::{BackendConfig, KmsBackend, StaticConfig}; use crate::encryption::is_data_key_envelope; - use base64::Engine as _; - use base64::engine::general_purpose::STANDARD as BASE64; + use base64_simd::STANDARD as BASE64; /// Generate a random 32-byte key and return (key_id, raw_key). fn random_static_key(key_id: &str) -> (String, [u8; 32]) { @@ -447,7 +446,7 @@ mod tests { fn static_config(key_id: &str, raw_key: &[u8; 32]) -> StaticConfig { StaticConfig { key_id: key_id.to_string(), - secret_key: BASE64.encode(raw_key), + secret_key: BASE64.encode_to_string(raw_key), } } diff --git a/crates/kms/src/backends/vault.rs b/crates/kms/src/backends/vault.rs index 0a787631a..2bfb97c6c 100644 --- a/crates/kms/src/backends/vault.rs +++ b/crates/kms/src/backends/vault.rs @@ -33,7 +33,7 @@ use crate::persisted_observability::{BoundedUnknownFieldName, UnknownFieldSummar use crate::policy::{self, AttemptError, OpClass, RetryPolicy}; use crate::types::*; use async_trait::async_trait; -use base64::{Engine as _, engine::general_purpose}; +use base64_simd::STANDARD as BASE64; use jiff::Zoned; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -527,8 +527,8 @@ fn decode_stored_key_material(key_id: &str, encrypted_material: &str) -> Result< // Mirrors `decrypt_key_material`: stored material is currently base64 without an // additional encryption layer. - let key_material = general_purpose::STANDARD - .decode(encrypted_material) + let key_material = BASE64 + .decode_to_vec(encrypted_material) .map_err(|e| KmsError::material_corrupt(key_id, format!("stored key material is not valid base64: {e}")))?; // Key material must be exactly 32 bytes for AES-256. @@ -693,7 +693,7 @@ impl VaultKmsClient { /// confidentiality. Any identity with KV read access to the key path can recover the /// plaintext master key. async fn encrypt_key_material(&self, key_material: &[u8]) -> Result { - Ok(general_purpose::STANDARD.encode(key_material)) + Ok(base64_simd::STANDARD.encode_to_string(key_material)) } /// Read the immutable material record of one key version. @@ -2405,7 +2405,7 @@ mod tests { tags: HashMap::new(), deletion_date: None, rotated_at: None, - encrypted_key_material: general_purpose::STANDARD.encode([0x42u8; 32]), + encrypted_key_material: base64_simd::STANDARD.encode_to_string([0x42u8; 32]), baseline_version: None, wrap_budget_reserved: 0, } @@ -2869,21 +2869,21 @@ mod tests { )); // Truncated material: valid base64 of fewer than 32 bytes. - let truncated = general_purpose::STANDARD.encode([0x42u8; 16]); + let truncated = base64_simd::STANDARD.encode_to_string([0x42u8; 16]); assert!(matches!( decode_stored_key_material("poisoned", &truncated), Err(KmsError::MaterialCorrupt { key_id, .. }) if key_id == "poisoned" )); // Oversized material: valid base64 of more than 32 bytes. - let oversized = general_purpose::STANDARD.encode([0x42u8; 33]); + let oversized = base64_simd::STANDARD.encode_to_string([0x42u8; 33]); assert!(matches!( decode_stored_key_material("poisoned", &oversized), Err(KmsError::MaterialCorrupt { key_id, .. }) if key_id == "poisoned" )); // Well-formed material still decodes. - let valid = general_purpose::STANDARD.encode([0x42u8; 32]); + let valid = base64_simd::STANDARD.encode_to_string([0x42u8; 32]); assert_eq!( decode_stored_key_material("healthy", &valid).expect("valid material must decode"), vec![0x42u8; 32] @@ -3046,7 +3046,7 @@ mod tests { description: None, metadata: HashMap::new(), tags: HashMap::new(), - encrypted_key_material: general_purpose::STANDARD.encode([0x42u8; 32]), + encrypted_key_material: base64_simd::STANDARD.encode_to_string([0x42u8; 32]), baseline_version: Some(1), deletion_date: None, rotated_at: None, @@ -3760,7 +3760,7 @@ mod tests { /// Base64 material distinct from `healthy_key_data`'s, standing in for the /// material a concurrent rotation committed. fn rotated_material() -> String { - general_purpose::STANDARD.encode([0x43u8; 32]) + base64_simd::STANDARD.encode_to_string([0x43u8; 32]) } /// The issue's lost-update scenario: node A disables a key while node B's @@ -4358,7 +4358,7 @@ mod tests { let material_v2 = [0x43u8; 32]; let record_v2 = VaultKeyVersionRecord { version: 2, - encrypted_key_material: general_purpose::STANDARD.encode(material_v2), + encrypted_key_material: base64_simd::STANDARD.encode_to_string(material_v2), created_at: Zoned::now(), }; // A well-formed envelope wrapped under version 2 — under a reverted @@ -4900,8 +4900,8 @@ mod tests { #[tokio::test] async fn wired_decrypt_of_pre_versioning_envelope_adds_no_request() { let key_data = healthy_key_data(); - let key_material = general_purpose::STANDARD - .decode(&key_data.encrypted_key_material) + let key_material = BASE64 + .decode_to_vec(&key_data.encrypted_key_material) .expect("decode fixture material"); let (encrypted_key, nonce) = AesDekCrypto::new() .encrypt(&key_material, b"dek-plaintext", &[]) diff --git a/crates/kms/src/backends/vault_transit.rs b/crates/kms/src/backends/vault_transit.rs index 5b49ed87b..b4177e950 100644 --- a/crates/kms/src/backends/vault_transit.rs +++ b/crates/kms/src/backends/vault_transit.rs @@ -31,7 +31,7 @@ use crate::persisted_observability::{BoundedUnknownFieldName, UnknownFieldSummar use crate::policy::{self, AttemptError, OpClass, RetryPolicy}; use crate::types::*; use async_trait::async_trait; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use base64_simd::STANDARD as BASE64; use jiff::Zoned; use moka::future::Cache; use serde::{Deserialize, Serialize}; @@ -479,7 +479,7 @@ impl VaultTransitKmsClient { .map(|(key, value)| (key.clone(), value.clone())) .collect(); let serialized = serde_json::to_vec(&ordered)?; - Ok(Some(BASE64.encode(serialized))) + Ok(Some(BASE64.encode_to_string(serialized))) } fn map_vault_error(key_id: &str, error: vaultrs::error::ClientError, operation: &str) -> KmsError { @@ -524,7 +524,7 @@ impl VaultTransitKmsClient { plaintext: &[u8], encryption_context: &HashMap, ) -> Result { - let plaintext_b64 = BASE64.encode(plaintext); + let plaintext_b64 = BASE64.encode_to_string(plaintext); let plaintext_b64 = plaintext_b64.as_str(); let aad = Self::canonicalize_context(encryption_context)?; let aad = aad.as_deref(); @@ -568,7 +568,7 @@ impl VaultTransitKmsClient { .await?; BASE64 - .decode(response.plaintext) + .decode_to_vec(response.plaintext) .map_err(|e| KmsError::cryptographic_error("base64_decode", e.to_string())) } @@ -3031,7 +3031,7 @@ mod tests { ScriptedResponse::ok(kv2_write_ack()), // decrypt of the pre-rotation envelope; Vault owns the transit // crypto, so the recovered material is the responder's to hand back. - ScriptedResponse::ok(serde_json::json!({ "plaintext": BASE64.encode(RECOVERED_DEK) })), + ScriptedResponse::ok(serde_json::json!({ "plaintext": BASE64.encode_to_string(RECOVERED_DEK) })), ]) .await; @@ -3243,7 +3243,7 @@ mod tests { // rewrap, context-bound route: latest-version read, then decrypt, // then re-encrypt under the newest version. ScriptedResponse::ok(transit_key_read_data_up_to("wired-key", 2)), - ScriptedResponse::ok(serde_json::json!({ "plaintext": BASE64.encode(RECOVERED_DEK) })), + ScriptedResponse::ok(serde_json::json!({ "plaintext": BASE64.encode_to_string(RECOVERED_DEK) })), ScriptedResponse::ok(serde_json::json!({ "ciphertext": "vault:v2:rewrapped" })), ]) .await; diff --git a/crates/kms/src/backup/drill.rs b/crates/kms/src/backup/drill.rs index 1ffc77d5b..2ba81fedf 100644 --- a/crates/kms/src/backup/drill.rs +++ b/crates/kms/src/backup/drill.rs @@ -945,7 +945,7 @@ async fn tree_digest(root: &Path) -> Result { lines.push(format!( "{relative}\u{1f}{}\u{1f}{modified}\u{1f}{}", metadata.len(), - hex::encode(Sha256::digest(&content)) + hex_simd::encode_to_string(Sha256::digest(&content), hex_simd::AsciiCase::Lower) )); } lines.sort(); @@ -1199,7 +1199,7 @@ mod tests { let text = String::from_utf8(encoded.clone()).expect("evidence is utf-8"); assert!(!text.contains(DRILL_MASTER_KEY), "the evidence must not carry the master key"); assert!( - !text.contains(&hex::encode([0x37u8; 32])), + !text.contains(&hex_simd::encode_to_string([0x37u8; 32], hex_simd::AsciiCase::Lower)), "the evidence must not carry backup KEK material" ); diff --git a/crates/kms/src/backup/local_export.rs b/crates/kms/src/backup/local_export.rs index e26f7cd35..ca0ab58da 100644 --- a/crates/kms/src/backup/local_export.rs +++ b/crates/kms/src/backup/local_export.rs @@ -568,7 +568,10 @@ pub(crate) fn compute_master_key_verifier(master_key: &str, salt: Option<&[u8]>, let mut hasher = Sha256::new(); hasher.update(&framing); hasher.update(derived.as_slice()); - Ok(format!("{prefix}{}", hex::encode(hasher.finalize()))) + Ok(format!( + "{prefix}{}", + hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower) + )) } /// The bundle-level protection label is the weakest state observed across diff --git a/crates/kms/src/backup/local_restore.rs b/crates/kms/src/backup/local_restore.rs index 3fe2612f2..9fadb89d1 100644 --- a/crates/kms/src/backup/local_restore.rs +++ b/crates/kms/src/backup/local_restore.rs @@ -72,7 +72,7 @@ use aes_gcm::{ Aes256Gcm, Nonce, aead::{Aead, KeyInit}, }; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use base64_simd::STANDARD as BASE64; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use tokio::fs; @@ -640,7 +640,7 @@ fn decode_key_record( return Err(BackupError::corrupted(format!("bundled key record '{stem}' carries no key material")).into()); } let material = - Zeroizing::new(BASE64.decode(&probe.encrypted_key_material).map_err(|error| { + Zeroizing::new(BASE64.decode_to_vec(&probe.encrypted_key_material).map_err(|error| { BackupError::corrupted(format!("bundled key record '{stem}' material is not valid base64: {error}")) })?); if !allowed_modes.contains(&protection_mode(probe.at_rest_protection)) { diff --git a/crates/kms/src/backup/manifest.rs b/crates/kms/src/backup/manifest.rs index 28ea05f76..0b75fc924 100644 --- a/crates/kms/src/backup/manifest.rs +++ b/crates/kms/src/backup/manifest.rs @@ -69,7 +69,7 @@ impl ContentDigest { pub fn sha256_of(bytes: &[u8]) -> Self { Self { algorithm: DigestAlgorithm::Sha256, - hex: hex::encode(Sha256::digest(bytes)), + hex: hex_simd::encode_to_string(Sha256::digest(bytes), hex_simd::AsciiCase::Lower), } } diff --git a/crates/kms/src/config.rs b/crates/kms/src/config.rs index bb670609b..506e09a18 100644 --- a/crates/kms/src/config.rs +++ b/crates/kms/src/config.rs @@ -367,9 +367,8 @@ impl StaticConfig { /// Decode the base64-encoded secret key into raw bytes. /// Returns an error if the key is not valid base64 or is not exactly 32 bytes. pub fn decode_key(&self) -> Result<[u8; 32]> { - use base64::Engine as _; - let bytes = base64::engine::general_purpose::STANDARD - .decode(&self.secret_key) + let bytes = base64_simd::STANDARD + .decode_to_vec(&self.secret_key) .map_err(|e| KmsError::configuration_error(format!("Static KMS secret key is not valid base64: {e}")))?; if bytes.len() != 32 { return Err(KmsError::configuration_error(format!( @@ -1963,9 +1962,7 @@ mod tests { #[test] fn static_kms_config_serialization_does_not_expose_key_material() { - use base64::Engine as _; - - let encoded_key = base64::engine::general_purpose::STANDARD.encode([0x5au8; 32]); + let encoded_key = base64_simd::STANDARD.encode_to_string([0x5au8; 32]); let config = KmsConfig::static_kms("static-key".to_string(), encoded_key.clone()); let serialized = serde_json::to_string(&config).expect("static KMS config should serialize"); @@ -2569,14 +2566,12 @@ mod tests { #[test] fn test_from_env_reads_static_secret_file_and_sets_default_key() { - use base64::Engine as _; - let temp_dir = TempDir::new().expect("create temp dir for static KMS secret"); let secret_path = temp_dir.path().join("static-kms-secret"); // Named `*_key_b64` (not `*_secret`) so the logging-guardrails check does not // flag these fixture interpolations as secrets leaking into log strings. - let file_key_b64 = base64::engine::general_purpose::STANDARD.encode([7u8; 32]); - let env_key_b64 = base64::engine::general_purpose::STANDARD.encode([9u8; 32]); + let file_key_b64 = base64_simd::STANDARD.encode_to_string([7u8; 32]); + let env_key_b64 = base64_simd::STANDARD.encode_to_string([9u8; 32]); std::fs::write(&secret_path, format!("file-key:{file_key_b64}\n")).expect("write static KMS secret file"); with_vars( diff --git a/crates/kms/src/config_secret.rs b/crates/kms/src/config_secret.rs index 4903b384d..f08a7a988 100644 --- a/crates/kms/src/config_secret.rs +++ b/crates/kms/src/config_secret.rs @@ -46,8 +46,7 @@ use crate::error::{KmsError, Result}; use aes_gcm::aead::{Aead, Payload}; use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce}; use argon2::{Algorithm, Argon2, Params, Version}; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64_simd::STANDARD as BASE64_STANDARD; use rand::RngExt; use serde_json::Value; use sha2::{Digest, Sha256}; @@ -284,14 +283,16 @@ fn seal_value(label: &str, plaintext: &str, secret: &str) -> Result { payload.extend_from_slice(&salt); payload.extend_from_slice(&nonce); payload.extend_from_slice(&ciphertext); - Ok(format!("{SEALED_VALUE_PREFIX}{}", BASE64_STANDARD.encode(payload))) + Ok(format!("{SEALED_VALUE_PREFIX}{}", BASE64_STANDARD.encode_to_string(payload))) } fn open_value(label: &str, sealed: &str, secret: &str) -> Result { let encoded = sealed .strip_prefix(SEALED_VALUE_PREFIX) .expect("caller checks the sealed prefix"); - let payload = BASE64_STANDARD.decode(encoded).map_err(|_| sealed_value_unreadable(label))?; + let payload = BASE64_STANDARD + .decode_to_vec(encoded) + .map_err(|_| sealed_value_unreadable(label))?; if payload.len() <= LOCAL_KMS_MASTER_KEY_SALT_LEN + NONCE_LEN { return Err(sealed_value_unreadable(label)); } diff --git a/crates/kms/src/manager.rs b/crates/kms/src/manager.rs index f2cc936c5..07e4daaaa 100644 --- a/crates/kms/src/manager.rs +++ b/crates/kms/src/manager.rs @@ -675,7 +675,6 @@ mod tests { use crate::error::KmsError; use crate::types::{KeyMetadata, KeySpec, KeyState, KeyStatus, KeyUsage}; use async_trait::async_trait; - use base64::Engine as _; use jiff::Zoned; use std::collections::HashMap; use std::sync::Mutex; @@ -1110,8 +1109,13 @@ mod tests { .await .expect("enable should succeed"); - let base64 = base64::engine::general_purpose::STANDARD; - let encodings = |bytes: &[u8]| vec![hex::encode(bytes), base64.encode(bytes)]; + let base64 = base64_simd::STANDARD; + let encodings = |bytes: &[u8]| { + vec![ + hex_simd::encode_to_string(bytes, hex_simd::AsciiCase::Lower), + base64.encode_to_string(bytes), + ] + }; let mut forbidden = vec![grant_token.to_string()]; forbidden.extend(encodings(&data_key.plaintext_key)); forbidden.extend(encodings(&decrypted.plaintext)); diff --git a/crates/kms/src/probe.rs b/crates/kms/src/probe.rs index 3ec8f8b03..f65a71a44 100644 --- a/crates/kms/src/probe.rs +++ b/crates/kms/src/probe.rs @@ -551,7 +551,6 @@ mod tests { ListKeysRequest, ListKeysResponse, }; use async_trait::async_trait; - use base64::Engine as _; use metrics_util::MetricKind; use metrics_util::debugging::{DebugValue, DebuggingRecorder}; use std::future::Future; @@ -562,8 +561,7 @@ mod tests { } async fn static_backend() -> Arc { - let config = - KmsConfig::static_kms("static-key".to_string(), base64::engine::general_purpose::STANDARD.encode([0x42u8; 32])); + let config = KmsConfig::static_kms("static-key".to_string(), base64_simd::STANDARD.encode_to_string([0x42u8; 32])); Arc::new(StaticKmsBackend::new(config).await.expect("static backend should build")) } diff --git a/crates/kms/src/service.rs b/crates/kms/src/service.rs index 7efe65b94..c4f0a6054 100644 --- a/crates/kms/src/service.rs +++ b/crates/kms/src/service.rs @@ -23,7 +23,6 @@ use crate::encryption::context_aad; use crate::error::{KmsError, Result}; use crate::manager::KmsManager; use crate::types::*; -use base64::Engine; use jiff::Zoned; use md5::{Digest as Md5Digest, Md5}; use rand::random; @@ -40,7 +39,7 @@ use zeroize::Zeroize; fn md5_hex(input: impl AsRef<[u8]>) -> String { let mut hasher = Md5::new(); hasher.update(input.as_ref()); - hex::encode(hasher.finalize()) + hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower) } /// Data key for object encryption @@ -836,19 +835,16 @@ impl ObjectEncryptionService { // Internal headers for decryption headers.insert( INTERNAL_ENCRYPTION_IV_HEADER.to_string(), - base64::engine::general_purpose::STANDARD.encode(&metadata.iv), + base64_simd::STANDARD.encode_to_string(&metadata.iv), ); if let Some(ref tag) = metadata.tag { - headers.insert( - INTERNAL_ENCRYPTION_TAG_HEADER.to_string(), - base64::engine::general_purpose::STANDARD.encode(tag), - ); + headers.insert(INTERNAL_ENCRYPTION_TAG_HEADER.to_string(), base64_simd::STANDARD.encode_to_string(tag)); } headers.insert( INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), - base64::engine::general_purpose::STANDARD.encode(&metadata.encrypted_data_key), + base64_simd::STANDARD.encode_to_string(&metadata.encrypted_data_key), ); // Whatever the object was sealed under is what gets stored: for a @@ -906,14 +902,14 @@ impl ObjectEncryptionService { let iv = headers .get(INTERNAL_ENCRYPTION_IV_HEADER) .ok_or_else(|| KmsError::validation_error("Missing IV header"))?; - let iv = base64::engine::general_purpose::STANDARD - .decode(iv) + let iv = base64_simd::STANDARD + .decode_to_vec(iv) .map_err(|e| KmsError::validation_error(format!("Invalid IV: {e}")))?; let tag = if let Some(tag_str) = headers.get(INTERNAL_ENCRYPTION_TAG_HEADER) { Some( - base64::engine::general_purpose::STANDARD - .decode(tag_str) + base64_simd::STANDARD + .decode_to_vec(tag_str) .map_err(|e| KmsError::validation_error(format!("Invalid tag: {e}")))?, ) } else { @@ -921,8 +917,8 @@ impl ObjectEncryptionService { }; let encrypted_data_key = if let Some(key_str) = headers.get(INTERNAL_ENCRYPTION_KEY_HEADER) { - base64::engine::general_purpose::STANDARD - .decode(key_str) + base64_simd::STANDARD + .decode_to_vec(key_str) .map_err(|e| KmsError::validation_error(format!("Invalid encrypted key: {e}")))? } else { Vec::new() // Empty for SSE-C diff --git a/crates/kms/src/service_manager.rs b/crates/kms/src/service_manager.rs index ec67af821..e52186a4b 100644 --- a/crates/kms/src/service_manager.rs +++ b/crates/kms/src/service_manager.rs @@ -763,10 +763,10 @@ pub async fn get_global_encryption_service() -> Option KmsConfig { - KmsConfig::static_kms(key_id.to_string(), BASE64_STANDARD.encode([fill; 32])) + KmsConfig::static_kms(key_id.to_string(), BASE64_STANDARD.encode_to_string([fill; 32])) } /// End-to-end wiring check for the AWS backend: an admin configure request @@ -822,7 +822,7 @@ mod tests { #[tokio::test] async fn redacted_config_omits_static_key_material() { let manager = KmsServiceManager::new(); - let encoded_key = base64::engine::general_purpose::STANDARD.encode([0x5au8; 32]); + let encoded_key = base64_simd::STANDARD.encode_to_string([0x5au8; 32]); manager .configure(KmsConfig::static_kms("static-key".to_string(), encoded_key)) .await @@ -1020,7 +1020,6 @@ mod tests { #[tokio::test] async fn configure_cannot_replace_existing_local_backend() { - use base64::Engine as _; use tempfile::TempDir; let key_dir = TempDir::new().expect("create local KMS directory"); @@ -1029,7 +1028,7 @@ mod tests { let manager = KmsServiceManager::new(); manager.configure(local.clone()).await.expect("configure local KMS"); - let encoded_key = base64::engine::general_purpose::STANDARD.encode([0x5au8; 32]); + let encoded_key = base64_simd::STANDARD.encode_to_string([0x5au8; 32]); let error = manager .configure(KmsConfig::static_kms("static-key".to_string(), encoded_key)) .await diff --git a/crates/kms/tests/behavior_lifecycle.rs b/crates/kms/tests/behavior_lifecycle.rs index c0867b209..9a85ec940 100644 --- a/crates/kms/tests/behavior_lifecycle.rs +++ b/crates/kms/tests/behavior_lifecycle.rs @@ -451,6 +451,5 @@ async fn harness_restart_brings_the_service_back_over_the_same_state() { } fn base64_of(bytes: &[u8]) -> String { - use base64::Engine as _; - base64::engine::general_purpose::STANDARD.encode(bytes) + base64_simd::STANDARD.encode_to_string(bytes) } diff --git a/crates/kms/tests/behavior_objects.rs b/crates/kms/tests/behavior_objects.rs index 979cf0bb8..dbab6b311 100644 --- a/crates/kms/tests/behavior_objects.rs +++ b/crates/kms/tests/behavior_objects.rs @@ -667,7 +667,7 @@ async fn sse_c_round_trips_and_rejects_the_wrong_key() { async fn sse_c_validates_the_supplied_key_md5() { let (_kms, service) = service_with_key("sse-c-md5-unused").await; let customer_key = [0x33u8; 32]; - let correct_md5 = hex::encode(md5_of(&customer_key)); + let correct_md5 = hex_simd::encode_to_string(md5_of(&customer_key), hex_simd::AsciiCase::Lower); service .encrypt_object_with_customer_key(BUCKET, "md5-ok.bin", payload(64).as_slice(), &customer_key, Some(&correct_md5)) diff --git a/crates/kms/tests/common/mod.rs b/crates/kms/tests/common/mod.rs index e795e5f28..2c0cb9a92 100644 --- a/crates/kms/tests/common/mod.rs +++ b/crates/kms/tests/common/mod.rs @@ -34,8 +34,7 @@ use std::future::Future; use std::path::PathBuf; use std::sync::{Arc, Mutex}; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64; +use base64_simd::STANDARD as BASE64; use rustfs_kms::backends::BackendCapabilities; use rustfs_kms::{ CreateKeyRequest, DeleteKeyRequest, KeyUsage, KmsConfig, KmsError, KmsManager, KmsServiceManager, KmsServiceStatus, @@ -51,7 +50,7 @@ pub const STATIC_KEY_ID: &str = "behavior-static-key"; /// Fixed rather than random so a failure is reproducible; it is test-only /// material and never leaves this crate's test binaries. pub fn static_secret_key() -> String { - BASE64.encode([0x5au8; 32]) + BASE64.encode_to_string([0x5au8; 32]) } /// Which backend a harness instance is running. diff --git a/crates/lifecycle/Cargo.toml b/crates/lifecycle/Cargo.toml index 483b0e7d7..df5fc1de4 100644 --- a/crates/lifecycle/Cargo.toml +++ b/crates/lifecycle/Cargo.toml @@ -30,7 +30,6 @@ default = [] hotpath = [ "hotpath/hotpath", "hotpath/tokio", - "rustfs-common/hotpath", "rustfs-config/hotpath", "rustfs-replication/hotpath", "rustfs-storage-api/hotpath", @@ -38,7 +37,6 @@ hotpath = [ hotpath-alloc = [ "hotpath", "hotpath/hotpath-alloc", - "rustfs-common/hotpath-alloc", "rustfs-config/hotpath-alloc", "rustfs-replication/hotpath-alloc", "rustfs-storage-api/hotpath-alloc", @@ -46,7 +44,6 @@ hotpath-alloc = [ hotpath-cpu = [ "hotpath", "hotpath/hotpath-cpu", - "rustfs-common/hotpath-cpu", "rustfs-config/hotpath-cpu", "rustfs-replication/hotpath-cpu", "rustfs-storage-api/hotpath-cpu", @@ -56,7 +53,7 @@ hotpath-cpu = [ hotpath.workspace = true async-trait.workspace = true metrics.workspace = true -rustfs-common.workspace = true +rustfs-scanner-contracts.workspace = true rustfs-config = { workspace = true, features = ["constants"] } rustfs-replication.workspace = true rustfs-storage-api.workspace = true diff --git a/crates/lifecycle/src/core.rs b/crates/lifecycle/src/core.rs index 7dea47b71..2eadd6064 100644 --- a/crates/lifecycle/src/core.rs +++ b/crates/lifecycle/src/core.rs @@ -62,7 +62,7 @@ const ERR_LIFECYCLE_EXPIRED_OBJECT_DELETE_MARKER_WITH_TAGS: &str = const ERR_LIFECYCLE_RULE_MUST_HAVE_ACTION: &str = "Rule must have at least one of Expiration, Transition, NoncurrentVersionExpiration, NoncurrentVersionTransition, or DelMarkerExpiration"; const ERR_LIFECYCLE_PREFIX_FILTER_CONFLICT: &str = "Legacy Prefix and Filter cannot both be present in a lifecycle rule. Use Filter.Prefix instead of the top-level Prefix element."; -pub use rustfs_common::metrics::IlmAction; +pub use rustfs_scanner_contracts::metrics::IlmAction; #[async_trait::async_trait] pub trait RuleValidate { diff --git a/crates/lifecycle/src/evaluator.rs b/crates/lifecycle/src/evaluator.rs index 9ccc5b862..3b4bca449 100644 --- a/crates/lifecycle/src/evaluator.rs +++ b/crates/lifecycle/src/evaluator.rs @@ -18,8 +18,8 @@ use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration, ObjectLock use time::OffsetDateTime; use tracing::info; -use rustfs_common::metrics::IlmAction; use rustfs_replication::ReplicationStatusType; +use rustfs_scanner_contracts::metrics::IlmAction; use crate::object_lock; use crate::{Event, Lifecycle, ObjectOpts}; @@ -197,7 +197,7 @@ mod tests { use std::collections::HashMap; use std::sync::Arc; - use rustfs_common::metrics::IlmAction; + use rustfs_scanner_contracts::metrics::IlmAction; use s3s::dto::{ BucketLifecycleConfiguration, DefaultRetention, ExpirationStatus, LifecycleExpiration, LifecycleRule, NoncurrentVersionExpiration, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRetentionMode, ObjectLockRule, diff --git a/crates/lifecycle/src/lib.rs b/crates/lifecycle/src/lib.rs index 8986c9523..234dd9d7c 100644 --- a/crates/lifecycle/src/lib.rs +++ b/crates/lifecycle/src/lib.rs @@ -20,5 +20,5 @@ mod tagging; pub use core::*; pub use evaluator::Evaluator; -pub use rustfs_common::metrics::IlmAction; pub use rustfs_replication::{ReplicationStatusType, VersionPurgeStatusType}; +pub use rustfs_scanner_contracts::metrics::IlmAction; diff --git a/crates/notify/Cargo.toml b/crates/notify/Cargo.toml index 024a4ac64..1bf4fb78a 100644 --- a/crates/notify/Cargo.toml +++ b/crates/notify/Cargo.toml @@ -91,6 +91,7 @@ metrics = { workspace = true } quick-xml = { workspace = true, features = ["serialize", "serde-types", "encoding"] } [dev-dependencies] +rustfs-targets = { workspace = true, features = ["test-support"] } tokio = { workspace = true, features = ["test-util", "fs", "rt-multi-thread"] } tracing-subscriber = { workspace = true, features = ["env-filter", "time"] } axum = { workspace = true } diff --git a/crates/notify/src/lifecycle.rs b/crates/notify/src/lifecycle.rs index b0b37b612..86f97ae70 100644 --- a/crates/notify/src/lifecycle.rs +++ b/crates/notify/src/lifecycle.rs @@ -476,6 +476,7 @@ mod tests { use rustfs_targets::arn::TargetID; use rustfs_targets::store::{Key, QueueStore, Store}; use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta}; + use rustfs_targets::testkit::MockTarget; use rustfs_targets::{ EventName, ReplayWorkerManager, StoreError, Target, TargetError, TargetPluginDescriptor, TargetPluginRegistry, }; @@ -488,108 +489,6 @@ mod tests { const RETRY_INIT_TARGET_TYPE: &str = "lifecycle_retry_init"; const REPLAY_TARGET_TYPE: &str = "lifecycle_replay"; - struct BlockingInitState { - close_calls: AtomicUsize, - init_entered: Notify, - } - - #[derive(Clone)] - struct BlockingInitTarget { - id: TargetID, - state: Arc, - } - - #[async_trait] - impl Target for BlockingInitTarget { - fn id(&self) -> TargetID { - self.id.clone() - } - - async fn is_active(&self) -> Result { - Ok(true) - } - - async fn save(&self, _event: Arc>) -> Result<(), TargetError> { - Ok(()) - } - - async fn send_raw_from_store(&self, _key: Key, _body: Vec, _meta: QueuedPayloadMeta) -> Result<(), TargetError> { - Ok(()) - } - - async fn close(&self) -> Result<(), TargetError> { - self.state.close_calls.fetch_add(1, Ordering::SeqCst); - Ok(()) - } - - fn store(&self) -> Option<&(dyn Store + Send + Sync)> { - None - } - - fn clone_dyn(&self) -> Box + Send + Sync> { - Box::new(self.clone()) - } - - async fn init(&self) -> Result<(), TargetError> { - self.state.init_entered.notify_one(); - std::future::pending::<()>().await; - Ok(()) - } - - fn is_enabled(&self) -> bool { - true - } - } - - #[derive(Clone)] - struct RetryInitTarget { - fail_once: Arc, - id: TargetID, - should_fail: bool, - } - - #[async_trait] - impl Target for RetryInitTarget { - fn id(&self) -> TargetID { - self.id.clone() - } - - async fn is_active(&self) -> Result { - Ok(true) - } - - async fn save(&self, _event: Arc>) -> Result<(), TargetError> { - Ok(()) - } - - async fn send_raw_from_store(&self, _key: Key, _body: Vec, _meta: QueuedPayloadMeta) -> Result<(), TargetError> { - Ok(()) - } - - async fn close(&self) -> Result<(), TargetError> { - Ok(()) - } - - fn store(&self) -> Option<&(dyn Store + Send + Sync)> { - None - } - - fn clone_dyn(&self) -> Box + Send + Sync> { - Box::new(self.clone()) - } - - async fn init(&self) -> Result<(), TargetError> { - if self.should_fail && self.fail_once.swap(false, Ordering::AcqRel) { - return Err(TargetError::Initialization("forced transient init failure".to_string())); - } - Ok(()) - } - - fn is_enabled(&self) -> bool { - true - } - } - struct ReplayState { active_workers: AtomicUsize, completed_deliveries: AtomicUsize, @@ -929,28 +828,24 @@ mod tests { } fn blocking_init_runtime() -> ( - Arc, + Arc, + MockTarget, NotifyLifecycleCoordinator, NotifyRuntimeView, Arc, Config, ) { - let state = Arc::new(BlockingInitState { - close_calls: AtomicUsize::new(0), - init_entered: Notify::new(), - }); + // One observed template feeds every factory-constructed instance, so the returned + // observer sees the shared init signal and close counter across generations. + let init_entered = Arc::new(Notify::new()); + let template = MockTarget::new("primary", BLOCKING_INIT_TARGET_TYPE).with_blocking_init(init_entered.clone()); + let observer = template.clone(); let mut plugins = TargetPluginRegistry::::new(); - let factory_state = state.clone(); plugins.register(TargetPluginDescriptor::new( BLOCKING_INIT_TARGET_TYPE, &[ENABLE_KEY], |_config| Ok(()), - move |id, _config| { - Ok(Box::new(BlockingInitTarget { - id: TargetID::new(id, BLOCKING_INIT_TARGET_TYPE.to_string()), - state: factory_state.clone(), - })) - }, + move |id, _config| Ok(Box::new(template.clone().with_id(&id, BLOCKING_INIT_TARGET_TYPE))), )); let config = config_with_enabled_test_target(BLOCKING_INIT_TARGET_TYPE, "primary"); let handoff_count = Arc::new(AtomicUsize::new(0)); @@ -962,7 +857,7 @@ mod tests { observer_count.fetch_add(1, Ordering::SeqCst); })), ); - (state, coordinator, runtime_view, handoff_count, config) + (init_entered, observer, coordinator, runtime_view, handoff_count, config) } async fn recv_until_generation(receiver: &mut mpsc::UnboundedReceiver, expected: usize, context: &str) { @@ -1001,10 +896,10 @@ mod tests { #[tokio::test] async fn real_runtime_disable_supersedes_target_init_and_leaves_no_runtime_state() { - let (state, coordinator, runtime_view, handoff_count, config) = blocking_init_runtime(); + let (init_entered, observer, coordinator, runtime_view, handoff_count, config) = blocking_init_runtime(); let enable = coordinator.set_mode(true, Some(config)); - state.init_entered.notified().await; + init_entered.notified().await; let disable = coordinator.set_mode(false, None); enable.wait().await.expect("superseded real enable should finish"); @@ -1013,7 +908,7 @@ mod tests { let status = runtime_view.runtime_status_snapshot().await; assert_eq!(status.target_count, 0); assert_eq!(status.replay_worker_count, 0); - assert_eq!(state.close_calls.load(Ordering::SeqCst), 1); + assert_eq!(observer.close_call_count(), 1); assert_eq!(handoff_count.load(Ordering::SeqCst), 0); assert_eq!(coordinator.state(), NotificationRuntimeState::LiveOnly); } @@ -1057,10 +952,10 @@ mod tests { #[tokio::test] async fn real_runtime_terminate_cancels_target_init_and_is_final() { - let (state, coordinator, runtime_view, handoff_count, config) = blocking_init_runtime(); + let (init_entered, observer, coordinator, runtime_view, handoff_count, config) = blocking_init_runtime(); let enable = coordinator.set_mode(true, Some(config)); - state.init_entered.notified().await; + init_entered.notified().await; let terminate = coordinator.terminate(); enable.wait().await.expect("superseded real enable should finish"); @@ -1069,7 +964,7 @@ mod tests { let status = runtime_view.runtime_status_snapshot().await; assert_eq!(status.target_count, 0); assert_eq!(status.replay_worker_count, 0); - assert_eq!(state.close_calls.load(Ordering::SeqCst), 1); + assert_eq!(observer.close_call_count(), 1); assert_eq!(handoff_count.load(Ordering::SeqCst), 0); assert_eq!(coordinator.state(), NotificationRuntimeState::Terminated); let err = coordinator @@ -1085,18 +980,19 @@ mod tests { #[tokio::test] async fn partial_activation_reports_error_and_same_config_can_retry() { - let fail_once = Arc::new(AtomicBool::new(true)); + // The template's single-failure init budget is shared by every clone the factory hands + // out, so the "bad" instance fails once in the first generation and recovers on retry. + let bad_template = MockTarget::new("bad", RETRY_INIT_TARGET_TYPE).with_init_failures(1); let mut plugins = TargetPluginRegistry::::new(); - let factory_fail_once = fail_once.clone(); plugins.register(TargetPluginDescriptor::new( RETRY_INIT_TARGET_TYPE, &[ENABLE_KEY], |_config| Ok(()), move |id, _config| { - Ok(Box::new(RetryInitTarget { - should_fail: id == "bad", - id: TargetID::new(id, RETRY_INIT_TARGET_TYPE.to_string()), - fail_once: factory_fail_once.clone(), + Ok(Box::new(if id == "bad" { + bad_template.clone() + } else { + MockTarget::new(&id, RETRY_INIT_TARGET_TYPE) })) }, )); diff --git a/crates/notify/src/notifier.rs b/crates/notify/src/notifier.rs index 7b1aaf237..ad378679a 100644 --- a/crates/notify/src/notifier.rs +++ b/crates/notify/src/notifier.rs @@ -610,18 +610,10 @@ impl TargetList { mod tests { use super::*; use crate::{rule_engine::NotifyRuleEngine, rules::RulesMap}; - use async_trait::async_trait; use rustfs_s3_types::EventName; - use rustfs_targets::StoreError; - use rustfs_targets::{ - ReplayWorkerManager, TargetError, - store::{Key, QueueStore, Store}, - target::{EntityTarget, QueuedPayload, QueuedPayloadMeta}, - }; - use std::sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }; + use rustfs_targets::testkit::MockTarget; + use rustfs_targets::{ReplayWorkerManager, store::QueueStore}; + use std::sync::Arc; use tokio::sync::Notify; #[tokio::test] @@ -680,105 +672,6 @@ mod tests { assert!(suffix_targets.is_empty()); } - #[derive(Clone)] - struct TestTarget { - block_first_save: Option<(Arc, Arc)>, - close_calls: Arc, - close_entered: Option>, - id: TargetID, - enabled: bool, - save_calls: Arc, - selected_calls: Arc, - store: Option>, - } - - impl TestTarget { - fn new(id: &str, name: &str, enabled: bool) -> Self { - Self { - block_first_save: None, - close_calls: Arc::new(AtomicUsize::new(0)), - close_entered: None, - id: TargetID::new(id.to_string(), name.to_string()), - enabled, - save_calls: Arc::new(AtomicUsize::new(0)), - selected_calls: Arc::new(AtomicUsize::new(0)), - store: None, - } - } - - fn with_blocked_first_save(mut self, entered: Arc, release: Arc) -> Self { - self.block_first_save = Some((entered, release)); - self - } - - fn with_store(mut self, store: QueueStore) -> Self { - self.store = Some(store); - self - } - - fn with_close_observer(mut self, close_entered: Arc) -> Self { - self.close_entered = Some(close_entered); - self - } - } - - #[async_trait] - impl Target for TestTarget - where - E: rustfs_targets::PluginEvent, - { - fn id(&self) -> TargetID { - self.id.clone() - } - - async fn is_active(&self) -> Result { - Ok(self.enabled) - } - - async fn save(&self, _event: Arc>) -> Result<(), TargetError> { - let call = self.save_calls.fetch_add(1, Ordering::SeqCst); - if call == 0 - && let Some((entered, release)) = &self.block_first_save - { - entered.notify_one(); - release.notified().await; - } - Ok(()) - } - - async fn send_raw_from_store(&self, _key: Key, _body: Vec, _meta: QueuedPayloadMeta) -> Result<(), TargetError> { - Ok(()) - } - - async fn close(&self) -> Result<(), TargetError> { - self.close_calls.fetch_add(1, Ordering::SeqCst); - if let Some(close_entered) = &self.close_entered { - close_entered.notify_one(); - } - Ok(()) - } - - fn store(&self) -> Option<&(dyn Store + Send + Sync)> { - self.store - .as_ref() - .map(|store| store as &(dyn Store + Send + Sync)) - } - - fn clone_dyn(&self) -> Box + Send + Sync> { - let cloned = self.clone(); - Box::new(cloned) - } - - async fn init(&self) -> Result<(), TargetError> { - Ok(()) - } - - fn is_enabled(&self) -> bool { - self.selected_calls.fetch_add(1, Ordering::SeqCst); - self.enabled - } - } - #[tokio::test] async fn lifecycle_pause_drains_entered_deferred_dispatch_and_blocks_new_dispatch() { let metrics = Arc::new(NotificationMetrics::new()); @@ -787,12 +680,12 @@ mod tests { let save_entered = Arc::new(Notify::new()); let save_release = Arc::new(Notify::new()); let queue_dir = tempfile::tempdir().expect("queue tempdir should be created"); - let target = TestTarget::new("gated-target", "webhook", true) - .with_blocked_first_save(save_entered.clone(), save_release.clone()) - .with_store(QueueStore::new(queue_dir.path(), 16, ".event")); + let target = MockTarget::new("gated-target", "webhook") + .with_first_save_gate(save_entered.clone(), save_release.clone()) + .with_store(Arc::new(QueueStore::new(queue_dir.path(), 16, ".event"))); let mut rules_map = RulesMap::new(); - rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*".to_string(), target.id.clone()); + rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*".to_string(), target.target_id()); rule_engine.set_bucket_rules("bucket", rules_map).await; notifier .target_list() @@ -817,7 +710,7 @@ mod tests { } }); save_entered.notified().await; - assert_eq!(target.save_calls.load(Ordering::SeqCst), 1); + assert_eq!(target.save_call_count(), 1); let mut pause = Box::pin(facade.pause_dispatch()); tokio::select! { @@ -830,7 +723,7 @@ mod tests { first_dispatch.await.expect("first dispatch task should finish"); let pause_guard = pause.await; - let replacement = TestTarget::new("gated-target", "webhook", true); + let replacement = MockTarget::new("gated-target", "webhook"); { let target_list = notifier.target_list(); let mut target_list = target_list.write().await; @@ -848,19 +741,19 @@ mod tests { _ = std::future::ready(()) => {} } assert_eq!( - replacement.selected_calls.load(Ordering::SeqCst), + replacement.enabled_call_count(), 0, "a paused dispatch must not select a target from the replacement generation early" ); - assert_eq!(target.save_calls.load(Ordering::SeqCst), 1); - assert_eq!(replacement.save_calls.load(Ordering::SeqCst), 0); + assert_eq!(target.save_call_count(), 1); + assert_eq!(replacement.save_call_count(), 0); drop(pause_guard); second_dispatch.await; - assert_eq!(target.selected_calls.load(Ordering::SeqCst), 1); - assert_eq!(target.save_calls.load(Ordering::SeqCst), 1); - assert_eq!(replacement.selected_calls.load(Ordering::SeqCst), 1); - assert_eq!(replacement.save_calls.load(Ordering::SeqCst), 1); + assert_eq!(target.enabled_call_count(), 1); + assert_eq!(target.save_call_count(), 1); + assert_eq!(replacement.enabled_call_count(), 1); + assert_eq!(replacement.save_call_count(), 1); } #[tokio::test] @@ -870,11 +763,10 @@ mod tests { let notifier = Arc::new(EventNotifier::new(metrics.clone(), rule_engine.clone())); let save_entered = Arc::new(Notify::new()); let save_release = Arc::new(Notify::new()); - let target = - TestTarget::new("direct-target", "webhook", true).with_blocked_first_save(save_entered.clone(), save_release.clone()); + let target = MockTarget::new("direct-target", "webhook").with_first_save_gate(save_entered.clone(), save_release.clone()); let mut rules_map = RulesMap::new(); - rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*".to_string(), target.id.clone()); + rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*".to_string(), target.target_id()); rule_engine.set_bucket_rules("bucket", rules_map).await; notifier .target_list() @@ -921,13 +813,11 @@ mod tests { }); let first_entered = Arc::new(Notify::new()); let first_release = Arc::new(Notify::new()); - let close_entered = Arc::new(Notify::new()); - let target = TestTarget::new("direct-target", "webhook", true) - .with_blocked_first_save(first_entered.clone(), first_release.clone()) - .with_close_observer(close_entered); + let target = + MockTarget::new("direct-target", "webhook").with_first_save_gate(first_entered.clone(), first_release.clone()); let mut rules_map = RulesMap::new(); - rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*".to_string(), target.id.clone()); + rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*".to_string(), target.target_id()); rule_engine.set_bucket_rules("bucket", rules_map).await; notifier .target_list() @@ -964,7 +854,7 @@ mod tests { } }); tokio::time::timeout(std::time::Duration::from_secs(1), async { - while target.selected_calls.load(Ordering::SeqCst) != 2 { + while target.enabled_call_count() != 2 { tokio::task::yield_now().await; } }) @@ -978,14 +868,14 @@ mod tests { result = &mut replace => panic!("replacement closed a generation with selected direct sends: {result:?}"), _ = std::future::ready(()) => {} } - assert_eq!(target.close_calls.load(Ordering::SeqCst), 0); + assert_eq!(target.close_call_count(), 0); first_release.notify_one(); first.await.expect("first direct dispatch should finish"); second.await.expect("permit-waiting direct dispatch should be cancelled"); replace.await.expect("replacement should close after direct leases drain"); - assert_eq!(target.save_calls.load(Ordering::SeqCst), 1); - assert_eq!(target.close_calls.load(Ordering::SeqCst), 1); + assert_eq!(target.save_call_count(), 1); + assert_eq!(target.close_call_count(), 1); assert_eq!(metrics.processing_count(), 0); assert_eq!(metrics.processed_count(), 1); assert_eq!(metrics.skipped_count(), 1); @@ -999,12 +889,12 @@ mod tests { let save_entered = Arc::new(Notify::new()); let save_release = Arc::new(Notify::new()); let queue_dir = tempfile::tempdir().expect("queue tempdir should be created"); - let target = TestTarget::new("deferred", "webhook", true) - .with_blocked_first_save(save_entered.clone(), save_release.clone()) - .with_store(QueueStore::new(queue_dir.path(), 16, ".event")); + let target = MockTarget::new("deferred", "webhook") + .with_first_save_gate(save_entered.clone(), save_release.clone()) + .with_store(Arc::new(QueueStore::new(queue_dir.path(), 16, ".event"))); let mut rules_map = RulesMap::new(); - rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*".to_string(), target.id.clone()); + rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*".to_string(), target.target_id()); rule_engine.set_bucket_rules("bucket", rules_map).await; notifier .target_list() @@ -1060,10 +950,10 @@ mod tests { let mut targets = Vec::new(); let mut rules_map = RulesMap::new(); for index in 0..TARGETS { - let target = TestTarget::new(&format!("deferred-{index}"), "webhook", true) - .with_blocked_first_save(entered.clone(), release.clone()) - .with_store(QueueStore::new(queue_dir.path().join(index.to_string()), 16, ".event")); - rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*".to_string(), target.id.clone()); + let target = MockTarget::new(&format!("deferred-{index}"), "webhook") + .with_first_save_gate(entered.clone(), release.clone()) + .with_store(Arc::new(QueueStore::new(queue_dir.path().join(index.to_string()), 16, ".event"))); + rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*".to_string(), target.target_id()); notifier .target_list() .write() @@ -1082,12 +972,7 @@ mod tests { .await; } }); - let total_calls = || { - targets - .iter() - .map(|target| target.save_calls.load(Ordering::SeqCst)) - .sum::() - }; + let total_calls = || targets.iter().map(|target| target.save_call_count()).sum::(); tokio::time::timeout(std::time::Duration::from_secs(1), async { while total_calls() != LIMIT { tokio::task::yield_now().await; @@ -1122,20 +1007,19 @@ mod tests { }); let direct_entered = Arc::new(Notify::new()); let direct_release = Arc::new(Notify::new()); - let direct = - TestTarget::new("direct", "webhook", true).with_blocked_first_save(direct_entered.clone(), direct_release.clone()); + let direct = MockTarget::new("direct", "webhook").with_first_save_gate(direct_entered.clone(), direct_release.clone()); let deferred_entered = Arc::new(Notify::new()); let deferred_release = Arc::new(Notify::new()); let queue_dir = tempfile::tempdir().expect("queue tempdir should be created"); - let deferred = TestTarget::new("deferred", "webhook", true) - .with_blocked_first_save(deferred_entered.clone(), deferred_release.clone()) - .with_store(QueueStore::new(queue_dir.path(), 16, ".event")); + let deferred = MockTarget::new("deferred", "webhook") + .with_first_save_gate(deferred_entered.clone(), deferred_release.clone()) + .with_store(Arc::new(QueueStore::new(queue_dir.path(), 16, ".event"))); let mut direct_rules = RulesMap::new(); - direct_rules.add_rule_config(&[EventName::ObjectCreatedPut], "*".to_string(), direct.id.clone()); + direct_rules.add_rule_config(&[EventName::ObjectCreatedPut], "*".to_string(), direct.target_id()); rule_engine.set_bucket_rules("direct-bucket", direct_rules).await; let mut deferred_rules = RulesMap::new(); - deferred_rules.add_rule_config(&[EventName::ObjectCreatedPut], "*".to_string(), deferred.id.clone()); + deferred_rules.add_rule_config(&[EventName::ObjectCreatedPut], "*".to_string(), deferred.target_id()); rule_engine.set_bucket_rules("deferred-bucket", deferred_rules).await; { let target_list = notifier.target_list(); @@ -1198,12 +1082,12 @@ mod tests { let rule_engine = NotifyRuleEngine::new(); let notifier = EventNotifier::new(Arc::new(NotificationMetrics::new()), rule_engine.clone()); - let enabled_target = TestTarget::new("enabled-target", "webhook", true); - let disabled_target = TestTarget::new("disabled-target", "webhook", false); + let enabled_target = MockTarget::new("enabled-target", "webhook"); + let disabled_target = MockTarget::new("disabled-target", "webhook").disabled(); let mut rules_map = RulesMap::new(); - rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*".to_string(), enabled_target.id.clone()); - rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*".to_string(), disabled_target.id.clone()); + rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*".to_string(), enabled_target.target_id()); + rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*".to_string(), disabled_target.target_id()); rule_engine.set_bucket_rules("bucket", rules_map).await; notifier @@ -1222,17 +1106,17 @@ mod tests { let event = Arc::new(Event::new_test_event("bucket", "object", EventName::ObjectCreatedPut)); notifier.send(event).await; - assert_eq!(enabled_target.save_calls.load(Ordering::SeqCst), 1); - assert_eq!(disabled_target.save_calls.load(Ordering::SeqCst), 0); + assert_eq!(enabled_target.save_call_count(), 1); + assert_eq!(disabled_target.save_call_count(), 0); } #[tokio::test] async fn send_event_respects_prefix_suffix_filters() { let rule_engine = NotifyRuleEngine::new(); let notifier = EventNotifier::new(Arc::new(NotificationMetrics::new()), rule_engine.clone()); - let target = TestTarget::new("filtered-target", "webhook", true); + let target = MockTarget::new("filtered-target", "webhook"); let mut rules_map = RulesMap::new(); - rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "uploads/*.csv".to_string(), target.id.clone()); + rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "uploads/*.csv".to_string(), target.target_id()); rule_engine.set_bucket_rules("bucket", rules_map).await; notifier.target_list().write().await.add(Arc::new(target.clone())).unwrap(); @@ -1248,7 +1132,7 @@ mod tests { ))) .await; - assert_eq!(target.save_calls.load(Ordering::SeqCst), 0); + assert_eq!(target.save_call_count(), 0); notifier .send(Arc::new(Event::new_test_event( @@ -1258,72 +1142,18 @@ mod tests { ))) .await; - assert_eq!(target.save_calls.load(Ordering::SeqCst), 1); + assert_eq!(target.save_call_count(), 1); } - /// A store-backed (deferred) target. `save` only enqueues to the store, so - /// the actual delivery happens later in the replay worker. - #[derive(Clone)] - struct DeferredTestTarget { - id: TargetID, - save_calls: Arc, - store: QueueStore, - } - - impl DeferredTestTarget { - fn new(id: &str, name: &str) -> Self { - Self { - id: TargetID::new(id.to_string(), name.to_string()), - save_calls: Arc::new(AtomicUsize::new(0)), - // The store is never actually written to here: `save` below only bumps a - // counter. It just has to exist so the notifier treats this target as - // store-backed (deferred delivery), exercising the deferred counting path. - store: QueueStore::new(std::env::temp_dir().join("rustfs-notify-979-noop-store"), 0, ""), - } - } - } - - #[async_trait] - impl Target for DeferredTestTarget - where - E: rustfs_targets::PluginEvent, - { - fn id(&self) -> TargetID { - self.id.clone() - } - - async fn is_active(&self) -> Result { - Ok(true) - } - - async fn save(&self, _event: Arc>) -> Result<(), TargetError> { - self.save_calls.fetch_add(1, Ordering::SeqCst); - Ok(()) - } - - async fn send_raw_from_store(&self, _key: Key, _body: Vec, _meta: QueuedPayloadMeta) -> Result<(), TargetError> { - Ok(()) - } - - async fn close(&self) -> Result<(), TargetError> { - Ok(()) - } - - fn store(&self) -> Option<&(dyn Store + Send + Sync)> { - Some(&self.store) - } - - fn clone_dyn(&self) -> Box + Send + Sync> { - Box::new(self.clone()) - } - - async fn init(&self) -> Result<(), TargetError> { - Ok(()) - } - - fn is_enabled(&self) -> bool { - true - } + /// Builds a store-backed (deferred) mock target. `save` only bumps the mock's counter, so the + /// attached store is never written to: it just has to exist so the notifier treats the target + /// as store-backed (deferred delivery), exercising the deferred counting path. + fn deferred_test_target(id: &str, name: &str) -> MockTarget { + MockTarget::new(id, name).with_store(Arc::new(QueueStore::new( + std::env::temp_dir().join("rustfs-notify-979-noop-store"), + 0, + "", + ))) } /// Regression test for backlog#979 (a): dispatching to a store-backed @@ -1338,9 +1168,9 @@ mod tests { let rule_engine = NotifyRuleEngine::new(); let notifier = EventNotifier::new(metrics.clone(), rule_engine.clone()); - let target = DeferredTestTarget::new("deferred-target", "webhook"); + let target = deferred_test_target("deferred-target", "webhook"); let mut rules_map = RulesMap::new(); - rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*".to_string(), target.id.clone()); + rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*".to_string(), target.target_id()); rule_engine.set_bucket_rules("bucket", rules_map).await; notifier.target_list().write().await.add(Arc::new(target.clone())).unwrap(); @@ -1349,7 +1179,7 @@ mod tests { .send(Arc::new(Event::new_test_event("bucket", "object", EventName::ObjectCreatedPut))) .await; - assert_eq!(target.save_calls.load(Ordering::SeqCst), 1); + assert_eq!(target.save_call_count(), 1); assert_eq!( metrics.processing_count(), 1, @@ -1394,87 +1224,20 @@ mod tests { let rule_engine = NotifyRuleEngine::new(); let notifier = EventNotifier::new(Arc::new(NotificationMetrics::new()), rule_engine); - let old_target = ClosableTestTarget::new("old", "webhook"); + let old_target = MockTarget::new("old", "webhook"); notifier .init_bucket_targets_shared(vec![Arc::new(old_target.clone()) as SharedTarget]) .await .expect("initial install should succeed"); - assert_eq!(old_target.close_calls.load(Ordering::SeqCst), 0, "target must not close on first install"); + assert_eq!(old_target.close_call_count(), 0, "target must not close on first install"); - let new_target = ClosableTestTarget::new("new", "webhook"); + let new_target = MockTarget::new("new", "webhook"); notifier .init_bucket_targets_shared(vec![Arc::new(new_target.clone()) as SharedTarget]) .await .expect("replacement install should succeed"); - assert_eq!( - old_target.close_calls.load(Ordering::SeqCst), - 1, - "the replaced target must be closed exactly once" - ); - assert_eq!( - new_target.close_calls.load(Ordering::SeqCst), - 0, - "the freshly installed target must stay open" - ); - } - - /// A target that records `close()` invocations, for lifecycle assertions. - #[derive(Clone)] - struct ClosableTestTarget { - id: TargetID, - close_calls: Arc, - } - - impl ClosableTestTarget { - fn new(id: &str, name: &str) -> Self { - Self { - id: TargetID::new(id.to_string(), name.to_string()), - close_calls: Arc::new(AtomicUsize::new(0)), - } - } - } - - #[async_trait] - impl Target for ClosableTestTarget - where - E: rustfs_targets::PluginEvent, - { - fn id(&self) -> TargetID { - self.id.clone() - } - - async fn is_active(&self) -> Result { - Ok(true) - } - - async fn save(&self, _event: Arc>) -> Result<(), TargetError> { - Ok(()) - } - - async fn send_raw_from_store(&self, _key: Key, _body: Vec, _meta: QueuedPayloadMeta) -> Result<(), TargetError> { - Ok(()) - } - - async fn close(&self) -> Result<(), TargetError> { - self.close_calls.fetch_add(1, Ordering::SeqCst); - Ok(()) - } - - fn store(&self) -> Option<&(dyn Store + Send + Sync)> { - None - } - - fn clone_dyn(&self) -> Box + Send + Sync> { - Box::new(self.clone()) - } - - async fn init(&self) -> Result<(), TargetError> { - Ok(()) - } - - fn is_enabled(&self) -> bool { - true - } + assert_eq!(old_target.close_call_count(), 1, "the replaced target must be closed exactly once"); + assert_eq!(new_target.close_call_count(), 0, "the freshly installed target must stay open"); } } diff --git a/crates/notify/src/runtime_facade.rs b/crates/notify/src/runtime_facade.rs index f692880c5..282b77c9b 100644 --- a/crates/notify/src/runtime_facade.rs +++ b/crates/notify/src/runtime_facade.rs @@ -409,108 +409,14 @@ mod tests { Event, integration::NotificationMetrics, notifier::EventNotifier, rule_engine::NotifyRuleEngine, runtime_view::NotifyRuntimeView, }; - use async_trait::async_trait; use rustfs_targets::arn::TargetID; - use rustfs_targets::store::{Key, QueueStore, Store}; - use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta}; - use rustfs_targets::{ReplayWorkerManager, SharedTarget, StoreError, Target, TargetError}; + use rustfs_targets::store::QueueStore; + use rustfs_targets::target::QueuedPayload; + use rustfs_targets::testkit::MockTarget; + use rustfs_targets::{ReplayWorkerManager, SharedTarget, TargetError}; use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::sync::{Notify, RwLock, Semaphore}; - #[derive(Clone)] - struct TestTarget { - close_entered: Option>, - close_error: bool, - close_release: Option>, - close_calls: Arc, - id: TargetID, - store: Option>, - } - - impl TestTarget { - fn new(id: &str, name: &str) -> Self { - Self { - close_entered: None, - close_error: false, - close_release: None, - close_calls: Arc::new(AtomicUsize::new(0)), - id: TargetID::new(id.to_string(), name.to_string()), - store: None, - } - } - - fn with_blocking_close(mut self, entered: Arc, release: Arc) -> Self { - self.close_entered = Some(entered); - self.close_release = Some(release); - self - } - - fn with_close_error(mut self) -> Self { - self.close_error = true; - self - } - - fn with_store(mut self, store: QueueStore) -> Self { - self.store = Some(store); - self - } - } - - #[async_trait] - impl Target for TestTarget - where - E: rustfs_targets::PluginEvent, - { - fn id(&self) -> TargetID { - self.id.clone() - } - - async fn is_active(&self) -> Result { - Ok(true) - } - - async fn save(&self, _event: Arc>) -> Result<(), TargetError> { - Ok(()) - } - - async fn send_raw_from_store(&self, _key: Key, _body: Vec, _meta: QueuedPayloadMeta) -> Result<(), TargetError> { - Ok(()) - } - - async fn close(&self) -> Result<(), TargetError> { - self.close_calls.fetch_add(1, Ordering::SeqCst); - if let Some(entered) = &self.close_entered { - entered.notify_one(); - } - if let Some(release) = &self.close_release { - release.notified().await; - } - if self.close_error { - return Err(TargetError::Storage("forced close failure".to_string())); - } - Ok(()) - } - - fn store(&self) -> Option<&(dyn Store + Send + Sync)> { - self.store - .as_ref() - .map(|store| store as &(dyn Store + Send + Sync)) - } - - fn clone_dyn(&self) -> Box + Send + Sync> { - Box::new(self.clone()) - } - - async fn init(&self) -> Result<(), TargetError> { - Ok(()) - } - - fn is_enabled(&self) -> bool { - true - } - } - fn build_facade() -> (NotifyRuntimeFacade, Arc, Arc>) { let metrics = Arc::new(NotificationMetrics::new()); let notifier = Arc::new(EventNotifier::new(metrics.clone(), NotifyRuleEngine::new())); @@ -555,8 +461,8 @@ mod tests { async fn compatibility_activation_stays_dormant_until_ordered_replace() { let (facade, _, replay_workers) = build_facade(); let queue_root = tempfile::tempdir().expect("queue root"); - let store = QueueStore::new_with_compression(queue_root.path(), 16, ".event", false); - let target = TestTarget::new("primary", "webhook").with_store(store); + let store = QueueStore::::new_with_compression(queue_root.path(), 16, ".event", false); + let target = MockTarget::new("primary", "webhook").with_store(Arc::new(store)); let activation = facade.activate_targets_with_replay(vec![Box::new(target)]).await; assert_eq!(activation.targets.len(), 1); @@ -574,7 +480,7 @@ mod tests { #[tokio::test] async fn runtime_facade_replace_targets_commits_runtime_state() { let (facade, notifier, replay_workers) = build_facade(); - let target = TestTarget::new("primary", "webhook"); + let target = MockTarget::new("primary", "webhook"); let activation = rustfs_targets::RuntimeActivation { replay_workers: ReplayWorkerManager::new(), targets: vec![Arc::new(target) as SharedTarget], @@ -594,9 +500,9 @@ mod tests { #[tokio::test] async fn runtime_queries_do_not_wait_for_target_close() { let (facade, notifier, replay_workers) = build_facade(); - let close_entered = Arc::new(Notify::new()); - let close_release = Arc::new(Notify::new()); - let target = TestTarget::new("primary", "webhook").with_blocking_close(close_entered.clone(), close_release.clone()); + let target = MockTarget::new("primary", "webhook"); + target.set_block_on_close(true); + let observer = target.clone(); facade .replace_targets(rustfs_targets::RuntimeActivation { replay_workers: ReplayWorkerManager::new(), @@ -609,12 +515,12 @@ mod tests { let facade = facade.clone(); async move { facade.shutdown_checked().await } }); - close_entered.notified().await; + observer.close_started().notified().await; let target_list = notifier.target_list(); assert!(target_list.try_read().is_ok(), "target list lock must not be held during close"); assert!(replay_workers.try_read().is_ok(), "replay manager lock must not be held during close"); - close_release.notify_one(); + observer.close_gate().add_permits(1); shutdown .await .expect("shutdown task should not panic") @@ -664,7 +570,7 @@ mod tests { #[tokio::test] async fn shutdown_returns_close_error_after_detaching_runtime() { let (facade, notifier, replay_workers) = build_facade(); - let target = TestTarget::new("primary", "webhook").with_close_error(); + let target = MockTarget::new("primary", "webhook").with_close_failures(usize::MAX); facade .replace_targets(rustfs_targets::RuntimeActivation { replay_workers: ReplayWorkerManager::new(), @@ -686,9 +592,10 @@ mod tests { #[tokio::test(start_paused = true)] async fn shutdown_bounds_a_target_that_never_closes() { let (facade, notifier, replay_workers) = build_facade(); - let close_entered = Arc::new(Notify::new()); - let never_release = Arc::new(Notify::new()); - let target = TestTarget::new("primary", "webhook").with_blocking_close(close_entered.clone(), never_release); + // The close gate never receives a permit, so this target's close blocks forever. + let target = MockTarget::new("primary", "webhook"); + target.set_block_on_close(true); + let observer = target.clone(); facade .replace_targets(rustfs_targets::RuntimeActivation { replay_workers: ReplayWorkerManager::new(), @@ -701,7 +608,7 @@ mod tests { let facade = facade.clone(); async move { facade.shutdown_checked().await } }); - close_entered.notified().await; + observer.close_started().notified().await; tokio::time::advance(super::TARGET_CLOSE_TIMEOUT).await; let err = shutdown diff --git a/crates/notify/src/runtime_view.rs b/crates/notify/src/runtime_view.rs index b17df852e..3ea587113 100644 --- a/crates/notify/src/runtime_view.rs +++ b/crates/notify/src/runtime_view.rs @@ -76,128 +76,13 @@ impl NotifyRuntimeView { mod tests { use super::NotifyRuntimeView; use crate::{Event, notifier::TargetList}; - use async_trait::async_trait; use rustfs_targets::arn::TargetID; - use rustfs_targets::store::{Key, Store}; - use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliverySnapshot}; - use rustfs_targets::{ReplayWorkerManager, StoreError, Target, TargetError}; - use std::sync::{ - Arc, - atomic::{AtomicU64, Ordering}, - }; + use rustfs_targets::target::TargetDeliverySnapshot; + use rustfs_targets::testkit::MockTarget; + use rustfs_targets::{ReplayWorkerManager, Target}; + use std::sync::Arc; use tokio::sync::{Notify, RwLock}; - #[derive(Clone)] - struct TestTarget { - active: bool, - enabled: bool, - failed_messages: Arc, - failed_store_length: u64, - id: TargetID, - health_started: Option>, - health_release: Option>, - total_messages: Arc, - } - - impl TestTarget { - fn new(id: &str, name: &str) -> Self { - Self { - active: true, - enabled: true, - failed_messages: Arc::new(AtomicU64::new(0)), - failed_store_length: 0, - id: TargetID::new(id.to_string(), name.to_string()), - health_started: None, - health_release: None, - total_messages: Arc::new(AtomicU64::new(0)), - } - } - - fn with_active(mut self, active: bool) -> Self { - self.active = active; - self - } - - fn with_failed_store_length(mut self, failed_store_length: u64) -> Self { - self.failed_store_length = failed_store_length; - self - } - - fn with_enabled(mut self, enabled: bool) -> Self { - self.enabled = enabled; - self - } - - fn with_health_gate(mut self, started: Arc, release: Arc) -> Self { - self.health_started = Some(started); - self.health_release = Some(release); - self - } - - fn record_successes(&self, count: u64) { - self.total_messages.store(count, Ordering::Relaxed); - } - - fn record_failures(&self, count: u64) { - self.failed_messages.store(count, Ordering::Relaxed); - } - } - - #[async_trait] - impl Target for TestTarget - where - E: rustfs_targets::PluginEvent, - { - fn id(&self) -> TargetID { - self.id.clone() - } - - async fn is_active(&self) -> Result { - if let (Some(started), Some(release)) = (&self.health_started, &self.health_release) { - started.notify_one(); - release.notified().await; - } - Ok(self.active) - } - - async fn save(&self, _event: Arc>) -> Result<(), TargetError> { - Ok(()) - } - - async fn send_raw_from_store(&self, _key: Key, _body: Vec, _meta: QueuedPayloadMeta) -> Result<(), TargetError> { - Ok(()) - } - - async fn close(&self) -> Result<(), TargetError> { - Ok(()) - } - - fn store(&self) -> Option<&(dyn Store + Send + Sync)> { - None - } - - fn clone_dyn(&self) -> Box + Send + Sync> { - Box::new(self.clone()) - } - - async fn init(&self) -> Result<(), TargetError> { - Ok(()) - } - - fn is_enabled(&self) -> bool { - self.enabled - } - - fn delivery_snapshot(&self) -> TargetDeliverySnapshot { - TargetDeliverySnapshot { - failed_messages: self.failed_messages.load(Ordering::Relaxed), - failed_store_length: self.failed_store_length, - queue_length: 0, - total_messages: self.total_messages.load(Ordering::Relaxed), - } - } - } - #[tokio::test] async fn runtime_view_reports_empty_runtime_queries() { let runtime_view = NotifyRuntimeView::new( @@ -230,12 +115,22 @@ mod tests { let target_list = Arc::new(RwLock::new(TargetList::new())); let replay_workers = Arc::new(RwLock::new(ReplayWorkerManager::new())); - let online = Arc::new(TestTarget::new("primary", "webhook").with_failed_store_length(7)); - online.record_successes(3); - online.record_failures(1); + let online = Arc::new(MockTarget::new("primary", "webhook").with_delivery_snapshot(TargetDeliverySnapshot { + failed_messages: 1, + failed_store_length: 7, + queue_length: 0, + total_messages: 3, + })); - let disabled = Arc::new(TestTarget::new("backup", "mqtt").with_enabled(false).with_active(false)); - disabled.record_successes(2); + let disabled = Arc::new( + MockTarget::new("backup", "mqtt") + .disabled() + .with_active(false) + .with_delivery_snapshot(TargetDeliverySnapshot { + total_messages: 2, + ..TargetDeliverySnapshot::default() + }), + ); { let mut targets = target_list.write().await; @@ -287,13 +182,13 @@ mod tests { #[tokio::test] async fn health_probe_does_not_hold_the_target_list_read_lock() { let target_list = Arc::new(RwLock::new(TargetList::new())); - let started = Arc::new(Notify::new()); let release = Arc::new(Notify::new()); - let target = Arc::new(TestTarget::new("blocked", "webhook").with_health_gate(started.clone(), release.clone())); + let target = MockTarget::new("blocked", "webhook").with_health_gate(release.clone()); + let started = target.health_started(); target_list .write() .await - .add(target as Arc + Send + Sync>) + .add(Arc::new(target) as Arc + Send + Sync>) .expect("test target should be added"); let runtime_view = NotifyRuntimeView::new(target_list.clone(), Arc::new(RwLock::new(ReplayWorkerManager::new()))); diff --git a/crates/obs/Cargo.toml b/crates/obs/Cargo.toml index 17ba113ed..ae12858f1 100644 --- a/crates/obs/Cargo.toml +++ b/crates/obs/Cargo.toml @@ -74,7 +74,7 @@ hotpath-cpu = [ # Tokio runtime-level telemetry. Requires a `--cfg tokio_unstable` build; the # build script fails the compile when that flag is missing. Off by default so # ordinary builds neither pay for nor depend on Tokio's unstable API. -dial9 = ["dep:dial9-tokio-telemetry"] +dial9 = ["dep:dial9-tokio-telemetry", "dial9-tokio-telemetry/process-resource"] # # NOTE: there is deliberately no `dial9-taskdump` feature. dial9 only captures a # task dump for futures it wrapped itself, i.e. those spawned via @@ -105,6 +105,8 @@ workspace = true hotpath.workspace = true rustfs-audit = { workspace = true } rustfs-common = { workspace = true } +rustfs-heal-contracts = { workspace = true } +rustfs-scanner-contracts = { workspace = true } rustfs-config = { workspace = true, features = ["observability"] } # NOTE: This dependency on rustfs-ecstore is a known architectural limitation. # The obs crate imports types from ecstore for metrics collection. diff --git a/crates/obs/src/metrics/collectors/scanner.rs b/crates/obs/src/metrics/collectors/scanner.rs index 1e0221908..ed0b42d2c 100644 --- a/crates/obs/src/metrics/collectors/scanner.rs +++ b/crates/obs/src/metrics/collectors/scanner.rs @@ -493,7 +493,7 @@ mod tests { use super::*; use crate::metrics::report::report_metrics; use metrics_util::debugging::DebuggingRecorder; - use rustfs_common::metrics::{Metric, Metrics}; + use rustfs_scanner_contracts::metrics::{Metric, Metrics}; fn prometheus_counter_name(name: &str) -> String { if name.ends_with("_total") { diff --git a/crates/obs/src/metrics/stats_collector.rs b/crates/obs/src/metrics/stats_collector.rs index 0d3863762..8ee95ed47 100644 --- a/crates/obs/src/metrics/stats_collector.rs +++ b/crates/obs/src/metrics/stats_collector.rs @@ -37,16 +37,16 @@ use crate::metrics::{ }; use crate::node_identity::current_local_node_identity; use jiff::Timestamp; -use rustfs_common::heal_channel::HealScanMode; -use rustfs_common::metrics::{ - ScannerActiveBucketDriveSnapshot, ScannerBucketDriveResultSnapshot, ScannerMetricsReport, ScannerSourceWorkSnapshot, - global_metrics, -}; +use rustfs_heal_contracts::heal_channel::HealScanMode; use rustfs_io_metrics::internode_metrics::global_internode_metrics; use rustfs_io_metrics::{ ProcessResourceSnapshot, ProcessSampler, ProcessStatusSnapshot, ProcessSystemSnapshot, s3_op_metrics_snapshot, snapshot_process_resource_and_system, snapshot_process_resource_and_system_with, }; +use rustfs_scanner_contracts::metrics::{ + ScannerActiveBucketDriveSnapshot, ScannerBucketDriveResultSnapshot, ScannerMetricsReport, ScannerSourceWorkSnapshot, + global_metrics, +}; use std::{ collections::{HashMap, HashSet}, sync::Arc, @@ -1667,7 +1667,7 @@ pub async fn collect_compression_cluster_stats() -> Option PathBuf { PathBuf::from(&self.output_dir).join(&self.file_prefix) } diff --git a/crates/obs/src/telemetry/dial9/enabled.rs b/crates/obs/src/telemetry/dial9/enabled.rs index 2efbd3009..912514ee5 100644 --- a/crates/obs/src/telemetry/dial9/enabled.rs +++ b/crates/obs/src/telemetry/dial9/enabled.rs @@ -23,15 +23,22 @@ use super::config::Dial9Config; use super::state::{dial9_runtime_state, measure_disk_usage_bytes}; use super::{EVENT_DIAL9_STATE, LOG_COMPONENT_OBS, LOG_SUBSYSTEM_DIAL9}; use crate::TelemetryError; -use dial9_tokio_telemetry::telemetry::{ProcessResourceUsageConfig, RotatingWriter, TracedRuntime}; +use dial9_tokio_telemetry::telemetry::{ + Dial9Handle, Dial9HandleTokioExt, DiskBuffer, ProcessResourceUsageConfig, RecorderPerfExt, TokioAttachOptions, recorder, +}; use std::time::Duration; use tracing::{info, warn}; -pub use dial9_tokio_telemetry::telemetry::TelemetryGuard; +pub type TelemetryGuard = Dial9Handle; + +type ShutdownRecorder = Box; /// How often the background refresher restates trace-file disk usage. const DISK_USAGE_REFRESH_INTERVAL: Duration = Duration::from_secs(60); +/// Maximum time spent flushing the recorder during graceful shutdown. +const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); + /// Name recorded in segment metadata so the trace viewer can label workers. const RUNTIME_NAME: &str = "rustfs-worker"; @@ -43,13 +50,14 @@ const RUNTIME_NAME: &str = "rustfs-worker"; /// are lost. pub struct Dial9SessionGuard { guard: TelemetryGuard, + shutdown: Option, config: Dial9Config, } impl Dial9SessionGuard { /// Whether the underlying telemetry session is recording. pub fn is_active(&self) -> bool { - self.guard.is_enabled() + self.guard.is_enabled() && self.guard.is_connected() && !self.guard.is_stopped() } } @@ -72,8 +80,10 @@ impl Drop for Dial9SessionGuard { state = "flushed", "dial9 state changed" ); - // `TelemetryGuard`'s own `Drop` flushes buffered events and seals the - // active segment; it runs immediately after this body. + + if let Some(shutdown) = self.shutdown.take() { + shutdown(); + } } } @@ -97,53 +107,58 @@ pub fn build_traced_runtime( TelemetryError::Io(format!("Failed to create dial9 output directory '{}': {e}", config.output_dir)) })?; - let writer = RotatingWriter::new(config.base_path(), config.max_file_size, config.total_disk_budget()).map_err(|e| { - dial9_runtime_state().record_runtime_error(&config); - TelemetryError::Io(format!("Failed to create dial9 RotatingWriter: {e}")) - })?; + let writer = DiskBuffer::builder() + .base_path(config.base_path()) + .max_file_size(config.max_file_size) + .max_total_size(config.total_disk_budget()) + .build() + .map_err(|e| { + dial9_runtime_state().record_runtime_error(&config); + TelemetryError::Io(format!("Failed to create dial9 DiskBuffer: {e}")) + })?; - // `with_trace_path` transitions the builder into the state that spawns the - // background worker, which drives the segment pipeline. - let traced = TracedRuntime::builder() - .with_trace_path(&config.output_dir) - .with_task_tracking(true) - .with_runtime_name(RUNTIME_NAME) - .with_process_resource_usage(ProcessResourceUsageConfig::default()); + let recorder = recorder(writer) + .with_process_resource_usage(ProcessResourceUsageConfig::default()) + .build(); + let guard = recorder.handle().clone(); + let shutdown: ShutdownRecorder = Box::new(move || recorder.graceful_shutdown(SHUTDOWN_TIMEOUT)); - // `build_and_start` rather than `build`: `build` returns a live guard that - // never records, writing segments that contain only a header. - // - // No `with_task_dumps` here. dial9 captures a task dump only for futures it + let attached = guard + .attach_tokio_runtime( + builder, + TokioAttachOptions::builder() + .runtime_name(RUNTIME_NAME) + .task_tracking_enabled(true) + .build(), + ) + .map(|runtime| (runtime, guard, shutdown)); + + // No task dumps here. dial9 captures a task dump only for futures it // wrapped itself, i.e. those spawned via `dial9_tokio_telemetry::spawn`; // `tokio::spawn` gets no wrapper. RustFS spawns with `tokio::spawn` - // throughout, so calling `with_task_dumps` records nothing. Measured on an + // throughout, so enabling task dumps records nothing. Measured on an // identical workload: 0 dumps via `tokio::spawn`, 14709 via `dial9::spawn`. // See rustfs/backlog#1157 (D9-16) and dial9-rs/dial9#477. // // No `with_s3_uploader` here: dial9's `worker-s3` feature carries a // vulnerable TLS stack. See the note in `crates/obs/Cargo.toml`. - finish_traced_runtime(traced.build_and_start(builder, writer), config) + finish_traced_runtime(attached, config) } /// Publish the outcome of a traced-runtime build and start the background /// disk-usage refresher. fn finish_traced_runtime( - started: std::io::Result<(tokio::runtime::Runtime, TelemetryGuard)>, + started: std::io::Result<(tokio::runtime::Runtime, TelemetryGuard, ShutdownRecorder)>, config: Dial9Config, ) -> Result<(tokio::runtime::Runtime, Dial9SessionGuard), TelemetryError> { - let (runtime, guard) = started.map_err(|e| { + let (runtime, guard, shutdown) = started.map_err(|e| { dial9_runtime_state().record_runtime_error(&config); - TelemetryError::Io(format!("Failed to build dial9 TracedRuntime: {e}")) + TelemetryError::Io(format!("Failed to attach dial9 runtime telemetry: {e}")) })?; - // `is_enabled` distinguishes a live guard from the inert one a lenient - // config produces after a build failure. It does NOT mean recording has - // started — a guard from `build` (rather than `build_and_start`) reports - // `true` while writing segments that contain only a header. Recording is - // guaranteed by the `build_and_start` call above, not by this check. if !guard.is_enabled() { dial9_runtime_state().record_runtime_error(&config); - return Err(TelemetryError::Io("dial9 TracedRuntime built with telemetry disabled".to_string())); + return Err(TelemetryError::Io("dial9 runtime telemetry attached with recording disabled".to_string())); } dial9_runtime_state().record_runtime_started(&config); @@ -160,7 +175,14 @@ fn finish_traced_runtime( "dial9 state changed" ); - Ok((runtime, Dial9SessionGuard { guard, config })) + Ok(( + runtime, + Dial9SessionGuard { + guard, + shutdown: Some(shutdown), + config, + }, + )) } /// Periodically restate trace-file disk usage so the metrics collector can read diff --git a/crates/obs/src/telemetry/dial9/mod.rs b/crates/obs/src/telemetry/dial9/mod.rs index 08c294c33..a186abf9c 100644 --- a/crates/obs/src/telemetry/dial9/mod.rs +++ b/crates/obs/src/telemetry/dial9/mod.rs @@ -49,13 +49,13 @@ //! //! # Known observability gap //! -//! `dial9`'s `RotatingWriter` stops accepting writes (its internal `Finished` -//! state) when the output directory disappears or a segment cannot be sealed, -//! and it exposes no way to observe that from outside. `TelemetryGuard::is_enabled` -//! reports how the session was *built*, not whether it is still writing. There -//! is therefore no `writer_healthy` metric: it could only ever be hard-coded to -//! `1`. Watch `rustfs_dial9_disk_usage_bytes` — a session that is recording but -//! whose disk usage stops growing has most likely hit this state. +//! `dial9`'s `DiskBuffer` stops accepting writes when the output directory +//! disappears or a segment cannot be sealed, and it exposes no way to observe +//! that from outside. `Dial9Handle::is_enabled` reports whether the recorder is +//! connected and unpaused, not whether the disk writer is still making progress. +//! There is therefore no `writer_healthy` metric: it could only ever be +//! hard-coded to `1`. Watch `rustfs_dial9_disk_usage_bytes` — a session that is +//! recording but whose disk usage stops growing has most likely hit this state. //! Reported upstream as dial9-rs/dial9#658. mod config; diff --git a/crates/obs/src/telemetry/dial9/state.rs b/crates/obs/src/telemetry/dial9/state.rs index 644fc3bce..f3a53d559 100644 --- a/crates/obs/src/telemetry/dial9/state.rs +++ b/crates/obs/src/telemetry/dial9/state.rs @@ -27,6 +27,9 @@ use std::sync::OnceLock; use std::sync::RwLock; use std::sync::atomic::{AtomicU64, Ordering}; +/// Segment filename stem used by `dial9` rotating disk buffers. +const DIAL9_SEGMENT_STEM: &str = "trace"; + /// Point-in-time view of dial9 runtime state. #[derive(Debug, Clone, Default)] pub(crate) struct Dial9RuntimeSnapshot { @@ -66,8 +69,8 @@ impl Dial9RuntimeState { pub(super) fn record_config(&self, config: &Dial9Config) { *self.trace_dir.write().expect("dial9 trace_dir lock should not be poisoned") = Some(TraceLocation { - output_dir: PathBuf::from(&config.output_dir), - file_prefix: config.file_prefix.clone(), + output_dir: config.base_path(), + file_prefix: DIAL9_SEGMENT_STEM.to_string(), }); if !config.enabled { self.active_sessions.store(0, Ordering::Relaxed); @@ -168,11 +171,11 @@ mod tests { #[test] fn measure_disk_usage_sums_only_matching_prefix() { let dir = tempdir().expect("create temp dir"); - std::fs::write(dir.path().join("rustfs-tokio.0.bin"), vec![0_u8; 128]).expect("write segment"); - std::fs::write(dir.path().join("rustfs-tokio.1.bin"), vec![0_u8; 64]).expect("write segment"); + std::fs::write(dir.path().join("trace.0.bin"), vec![0_u8; 128]).expect("write segment"); + std::fs::write(dir.path().join("trace.1.bin"), vec![0_u8; 64]).expect("write segment"); std::fs::write(dir.path().join("unrelated.log"), vec![0_u8; 4096]).expect("write unrelated"); - assert_eq!(measure_disk_usage_bytes(dir.path(), "rustfs-tokio"), 192); + assert_eq!(measure_disk_usage_bytes(dir.path(), DIAL9_SEGMENT_STEM), 192); } #[test] diff --git a/crates/protocols/Cargo.toml b/crates/protocols/Cargo.toml index a121ebef9..7f8170d9c 100644 --- a/crates/protocols/Cargo.toml +++ b/crates/protocols/Cargo.toml @@ -99,14 +99,14 @@ swift = [ "dep:md-5", "dep:hmac", "dep:sha1", - "dep:hex", + "dep:hex-simd", "dep:ipnetwork", "dep:rustfs-trusted-proxies", "dep:astral-tokio-tar", - "dep:base64", + "dep:base64-simd", "dep:async-compression", ] -webdav = ["dep:dav-server", "dep:hyper", "dep:hyper-util", "dep:http", "dep:http-body-util", "dep:tokio-rustls", "dep:base64", "dep:rustls", "dep:percent-encoding", "dep:rustfs-tls-runtime", "dep:subtle"] +webdav = ["dep:dav-server", "dep:hyper", "dep:hyper-util", "dep:http", "dep:http-body-util", "dep:tokio-rustls", "dep:base64-simd", "dep:rustls", "dep:percent-encoding", "dep:rustfs-tls-runtime", "dep:subtle"] sftp = ["dep:russh", "dep:russh-sftp", "dep:uuid", "dep:subtle", "dep:tokio-util", "dep:socket2"] [dependencies] @@ -163,11 +163,11 @@ urlencoding = { workspace = true, optional = true } md-5 = { workspace = true, optional = true } hmac = { workspace = true, optional = true } sha1 = { workspace = true, optional = true } -hex = { workspace = true, optional = true } +hex-simd = { workspace = true, optional = true } ipnetwork = { workspace = true, optional = true } rustfs-trusted-proxies = { workspace = true, optional = true } astral-tokio-tar = { workspace = true, optional = true } -base64 = { workspace = true, optional = true } +base64-simd = { workspace = true, optional = true } async-compression = { workspace = true, optional = true, features = ["tokio", "gzip", "bzip2"] } # WebDAV specific dependencies (optional) diff --git a/crates/protocols/src/swift/account.rs b/crates/protocols/src/swift/account.rs index 3cdab2114..aeae24b6a 100644 --- a/crates/protocols/src/swift/account.rs +++ b/crates/protocols/src/swift/account.rs @@ -97,7 +97,7 @@ fn get_account_metadata_bucket_name(account: &str) -> String { let mut hasher = Sha256::new(); hasher.update(account.as_bytes()); let hash_bytes = hasher.finalize(); - let hash = hex::encode(hash_bytes); + let hash = hex_simd::encode_to_string(hash_bytes, hex_simd::AsciiCase::Lower); format!("swift-account-{}", &hash[0..16]) } diff --git a/crates/protocols/src/swift/formpost.rs b/crates/protocols/src/swift/formpost.rs index 052dad787..a63b5f67f 100644 --- a/crates/protocols/src/swift/formpost.rs +++ b/crates/protocols/src/swift/formpost.rs @@ -182,7 +182,7 @@ pub fn generate_signature( mac.update(message.as_bytes()); let result = mac.finalize(); - let signature = hex::encode(result.into_bytes()); + let signature = hex_simd::encode_to_string(result.into_bytes(), hex_simd::AsciiCase::Lower); Ok(signature) } @@ -213,10 +213,10 @@ pub fn validate_formpost(path: &str, request: &FormPostRequest, key: &str) -> Sw // the sibling TempURL/SFTP checks. Decode the hex first so the comparison runs // over the raw HMAC bytes and does not leak via string length; a non-hex // provided signature can never match and is rejected the same way. - let expected_bytes = - hex::decode(&expected_sig).map_err(|e| SwiftError::InternalServerError(format!("Signature encoding error: {}", e)))?; + let expected_bytes = hex_simd::decode_to_vec(&expected_sig) + .map_err(|e| SwiftError::InternalServerError(format!("Signature encoding error: {}", e)))?; - let signatures_match = match hex::decode(request.signature.trim()) { + let signatures_match = match hex_simd::decode_to_vec(request.signature.trim()) { Ok(provided_bytes) => super::tempurl::constant_time_compare(&provided_bytes, &expected_bytes), Err(_) => false, }; diff --git a/crates/protocols/src/swift/slo.rs b/crates/protocols/src/swift/slo.rs index c53cd1f4d..8e29e6ecd 100644 --- a/crates/protocols/src/swift/slo.rs +++ b/crates/protocols/src/swift/slo.rs @@ -84,7 +84,11 @@ impl SLOManifest { let mut hasher = Md5::new(); hasher.update(etag_concat.as_bytes()); - format!("\"{}-{}\"", hex::encode(hasher.finalize()), self.segments.len()) + format!( + "\"{}-{}\"", + hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower), + self.segments.len() + ) } /// Validate manifest against actual segments diff --git a/crates/protocols/src/swift/sync.rs b/crates/protocols/src/swift/sync.rs index 60a8749ec..2d9b9e1d6 100644 --- a/crates/protocols/src/swift/sync.rs +++ b/crates/protocols/src/swift/sync.rs @@ -300,7 +300,7 @@ pub fn generate_sync_signature(path: &str, key: &str) -> SwiftResult { mac.update(path.as_bytes()); let result = mac.finalize(); - Ok(hex::encode(result.into_bytes())) + Ok(hex_simd::encode_to_string(result.into_bytes(), hex_simd::AsciiCase::Lower)) } /// Verify sync signature diff --git a/crates/protocols/src/swift/tempurl.rs b/crates/protocols/src/swift/tempurl.rs index 2c0e7fbad..87b6fcd39 100644 --- a/crates/protocols/src/swift/tempurl.rs +++ b/crates/protocols/src/swift/tempurl.rs @@ -125,7 +125,7 @@ impl TempURL { // Hex-encode result let result = mac.finalize(); - let signature = hex::encode(result.into_bytes()); + let signature = hex_simd::encode_to_string(result.into_bytes(), hex_simd::AsciiCase::Lower); Ok(signature) } diff --git a/crates/protocols/src/webdav/server.rs b/crates/protocols/src/webdav/server.rs index fd1f56c09..8162e8601 100644 --- a/crates/protocols/src/webdav/server.rs +++ b/crates/protocols/src/webdav/server.rs @@ -675,8 +675,7 @@ fn fixed_body(message: impl Into) -> WebDavBody { /// Decode base64 string fn base64_decode(encoded: &str) -> Result, ()> { - use base64::Engine; - base64::engine::general_purpose::STANDARD.decode(encoded).map_err(|_| ()) + base64_simd::STANDARD.decode_to_vec(encoded).map_err(|_| ()) } #[cfg(test)] diff --git a/crates/protocols/tests/swift_metadata_persistence.rs b/crates/protocols/tests/swift_metadata_persistence.rs index 6b0b2df8d..ad636cb09 100644 --- a/crates/protocols/tests/swift_metadata_persistence.rs +++ b/crates/protocols/tests/swift_metadata_persistence.rs @@ -56,7 +56,7 @@ fn keystone_credentials(project_id: &str) -> Credentials { fn account_metadata_bucket_name(account: &str) -> String { let mut hasher = Sha256::new(); hasher.update(account.as_bytes()); - let hash = hex::encode(hasher.finalize()); + let hash = hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower); format!("swift-account-{}", &hash[0..16]) } diff --git a/crates/protos/Cargo.toml b/crates/protos/Cargo.toml index 27d723a48..b9c1c5c1f 100644 --- a/crates/protos/Cargo.toml +++ b/crates/protos/Cargo.toml @@ -65,6 +65,7 @@ hotpath-cpu = [ [dependencies] hotpath.workspace = true rustfs-common.workspace = true +rustfs-heal-contracts.workspace = true rustfs-io-metrics.workspace = true rustfs-config.workspace = true rustfs-tls-runtime.workspace = true diff --git a/crates/protos/src/generated/proto_gen/node_service.rs b/crates/protos/src/generated/proto_gen/node_service.rs index cb5d4a582..ceaaa7a95 100644 --- a/crates/protos/src/generated/proto_gen/node_service.rs +++ b/crates/protos/src/generated/proto_gen/node_service.rs @@ -142,7 +142,8 @@ pub struct DeleteRequest { pub path: ::prost::alloc::string::String, #[prost(string, tag = "4")] pub options: ::prost::alloc::string::String, - /// Optional scanner publication lease token. + /// Optional scanner publication lease token. When present, the target binds + /// the complete delete operation to its movement read admission. #[prost(bytes = "bytes", tag = "5")] pub scanner_publication_lease_token: ::prost::bytes::Bytes, } @@ -396,6 +397,9 @@ pub struct RenameDataRequest { pub dst_path: ::prost::alloc::string::String, #[prost(bytes = "bytes", tag = "7")] pub file_info_bin: ::prost::bytes::Bytes, + /// Optional target-side scanner publication lease. Empty preserves the + /// legacy rename request body; a non-empty token is checked at the target's + /// rename linearization point. #[prost(bytes = "bytes", tag = "8")] pub scanner_publication_lease_token: ::prost::bytes::Bytes, } @@ -719,7 +723,7 @@ pub struct DeleteVersionsRequest { #[prost(bytes = "bytes", tag = "6")] pub opts_bin: ::prost::bytes::Bytes, } -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct DeleteVersionsResponse { #[prost(bool, tag = "1")] pub success: bool, @@ -841,6 +845,8 @@ pub struct LocalStorageInfoResponse { pub storage_info: ::prost::bytes::Bytes, #[prost(string, optional, tag = "3")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "4")] + pub error_code: ::core::option::Option, } #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct ServerInfoRequest { @@ -1043,6 +1049,8 @@ pub struct LoadBucketMetadataResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct DeleteBucketMetadataRequest { @@ -1067,6 +1075,8 @@ pub struct DeletePolicyResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct LoadPolicyRequest { @@ -1079,6 +1089,8 @@ pub struct LoadPolicyResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct LoadPolicyMappingRequest { @@ -1095,6 +1107,8 @@ pub struct LoadPolicyMappingResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct DeleteUserRequest { @@ -1107,6 +1121,8 @@ pub struct DeleteUserResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct DeleteServiceAccountRequest { @@ -1119,6 +1135,8 @@ pub struct DeleteServiceAccountResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct LoadUserRequest { @@ -1133,6 +1151,8 @@ pub struct LoadUserResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct LoadServiceAccountRequest { @@ -1145,6 +1165,8 @@ pub struct LoadServiceAccountResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct LoadGroupRequest { @@ -1157,6 +1179,8 @@ pub struct LoadGroupResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct ReloadSiteReplicationConfigRequest {} @@ -1166,6 +1190,8 @@ pub struct ReloadSiteReplicationConfigResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct SignalServiceRequest { @@ -1218,11 +1244,17 @@ pub struct ScannerActivityResponse { pub dirty_usage_generation: u64, #[prost(bool, tag = "9")] pub dirty_usage_pending: bool, + /// v7 fields. They are optional so v6 peers can continue to decode the + /// response shape while newer readers fail closed when they are absent. #[prost(uint64, optional, tag = "10")] pub movement_generation: ::core::option::Option, #[prost(bool, optional, tag = "11")] pub publication_blocked: ::core::option::Option, } +/// A short-lived storage-owned read admission used only around a final +/// scanner metadata publication. It is intentionally separate from the +/// ScannerActivity observation wire so v6/v7 rolling compatibility remains +/// unchanged. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct ScannerPublicationLeaseRequest { #[prost(bytes = "bytes", tag = "1")] @@ -1231,8 +1263,14 @@ pub struct ScannerPublicationLeaseRequest { pub expected_movement_generation: u64, #[prost(uint64, tag = "3")] pub ttl_ms: u64, + /// The activity instance is a process session nonce. It is intentionally + /// separate from the storage-owned deployment identity returned by the + /// lease response so a restart cannot reuse an old session token. #[prost(string, tag = "4")] pub expected_session_id: ::prost::alloc::string::String, + /// A non-empty token turns the acquire RPC into an in-place validation of an + /// existing lease. Keeping this on the existing RPC lets old peers reject + /// the proof without changing the v7 activity wire shape. #[prost(bytes = "bytes", tag = "5")] pub token: ::prost::bytes::Bytes, } @@ -1288,6 +1326,8 @@ pub struct BackgroundHealStatusResponse { pub bg_heal_state: ::prost::bytes::Bytes, #[prost(string, optional, tag = "3")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "4")] + pub error_code: ::core::option::Option, } #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct ReplacementRecoveryStatusRequest {} @@ -1299,6 +1339,8 @@ pub struct ReplacementRecoveryStatusResponse { pub recovery_status: ::prost::bytes::Bytes, #[prost(string, optional, tag = "3")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "4")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct HealControlRequest { @@ -1356,6 +1398,8 @@ pub struct ReloadPoolMetaResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct StopRebalanceRequest { @@ -1368,6 +1412,8 @@ pub struct StopRebalanceResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct LoadRebalanceMetaRequest { @@ -1380,6 +1426,8 @@ pub struct LoadRebalanceMetaResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct StartDecommissionRequest { @@ -1392,6 +1440,8 @@ pub struct StartDecommissionResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct CancelDecommissionRequest { @@ -1404,6 +1454,8 @@ pub struct CancelDecommissionResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct ClearDecommissionRequest { @@ -1416,6 +1468,8 @@ pub struct ClearDecommissionResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct LoadTransitionTierConfigRequest {} @@ -1425,6 +1479,8 @@ pub struct LoadTransitionTierConfigResponse { pub success: bool, #[prost(string, optional, tag = "2")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, + #[prost(enumeration = "ControlPlaneErrorCode", optional, tag = "3")] + pub error_code: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct TierMutationPrepareRequest { @@ -1486,6 +1542,38 @@ pub struct GetLiveEventsResponse { #[prost(string, optional, tag = "5")] pub error_info: ::core::option::Option<::prost::alloc::string::String>, } +/// Typed control-plane error discriminants carried alongside the legacy +/// error_info string on control-plane responses. Rolling-upgrade compat: +/// old peers ignore the field and keep reading error_info. +/// RUSTFS_COMPAT_TODO(not-initialized-error-code-v1): legacy string dual-write. Remove after the minimum supported RustFS peer version always sends error_code. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum ControlPlaneErrorCode { + ControlPlaneErrorUnspecified = 0, + /// The peer answered but its storage/IAM layer is not initialized yet + /// (legacy string form: "errServerNotInitialized"). + ControlPlaneErrorNotInitialized = 1, +} +impl ControlPlaneErrorCode { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::ControlPlaneErrorUnspecified => "CONTROL_PLANE_ERROR_UNSPECIFIED", + Self::ControlPlaneErrorNotInitialized => "CONTROL_PLANE_ERROR_NOT_INITIALIZED", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "CONTROL_PLANE_ERROR_UNSPECIFIED" => Some(Self::ControlPlaneErrorUnspecified), + "CONTROL_PLANE_ERROR_NOT_INITIALIZED" => Some(Self::ControlPlaneErrorNotInitialized), + _ => None, + } + } +} #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] pub enum TierMutationPeerState { @@ -3301,16 +3389,12 @@ pub mod node_service_server { ) -> std::result::Result, tonic::Status>; async fn acquire_scanner_publication_lease( &self, - _request: tonic::Request, - ) -> std::result::Result, tonic::Status> { - Err(tonic::Status::unimplemented("scanner publication leases are unsupported")) - } + request: tonic::Request, + ) -> std::result::Result, tonic::Status>; async fn release_scanner_publication_lease( &self, - _request: tonic::Request, - ) -> std::result::Result, tonic::Status> { - Err(tonic::Status::unimplemented("scanner publication leases are unsupported")) - } + request: tonic::Request, + ) -> std::result::Result, tonic::Status>; async fn background_heal_status( &self, request: tonic::Request, diff --git a/crates/protos/src/heal_control.rs b/crates/protos/src/heal_control.rs index 515c0db75..bcb093e90 100644 --- a/crates/protos/src/heal_control.rs +++ b/crates/protos/src/heal_control.rs @@ -20,7 +20,7 @@ //! contextual lease and nonce validation. use rmp_serde::Deserializer; -use rustfs_common::heal_channel::{ +use rustfs_heal_contracts::heal_channel::{ HealAdmissionDropReason, HealAdmissionResult, HealChannelPriority, HealChannelRequest, HealChannelResponse, HealRequestSource, HealScanMode, }; @@ -617,7 +617,7 @@ mod tests { Admission, ENVELOPE_MAX_SIZE, Envelope, ExecutableCommand, Outcome, RESULT_MAX_SIZE, RequestMetadata, ResultEnvelope, decode_envelope, decode_result, encode_result, }; - use rustfs_common::heal_channel::{HealChannelRequest, HealChannelResponse, HealRequestSource}; + use rustfs_heal_contracts::heal_channel::{HealChannelRequest, HealChannelResponse, HealRequestSource}; use serde::de::{DeserializeSeed, SeqAccess, Visitor, value::Error as ValueError}; fn test_request(request_id: String) -> HealChannelRequest { diff --git a/crates/protos/src/node.proto b/crates/protos/src/node.proto index 26cc3ffed..a51ab51dc 100644 --- a/crates/protos/src/node.proto +++ b/crates/protos/src/node.proto @@ -21,6 +21,17 @@ message Error { string error_info = 2; } +// Typed control-plane error discriminants carried alongside the legacy +// error_info string on control-plane responses. Rolling-upgrade compat: +// old peers ignore the field and keep reading error_info. +// RUSTFS_COMPAT_TODO(not-initialized-error-code-v1): legacy string dual-write. Remove after the minimum supported RustFS peer version always sends error_code. +enum ControlPlaneErrorCode { + CONTROL_PLANE_ERROR_UNSPECIFIED = 0; + // The peer answered but its storage/IAM layer is not initialized yet + // (legacy string form: "errServerNotInitialized"). + CONTROL_PLANE_ERROR_NOT_INITIALIZED = 1; +} + message PingRequest { uint64 version = 1; bytes body = 2; @@ -580,6 +591,7 @@ message LocalStorageInfoResponse { bool success = 1; bytes storage_info = 2; optional string error_info = 3; + optional ControlPlaneErrorCode error_code = 4; } message ServerInfoRequest { @@ -726,6 +738,7 @@ message LoadBucketMetadataRequest { message LoadBucketMetadataResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message DeleteBucketMetadataRequest { @@ -744,6 +757,7 @@ message DeletePolicyRequest { message DeletePolicyResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message LoadPolicyRequest { @@ -753,6 +767,7 @@ message LoadPolicyRequest { message LoadPolicyResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message LoadPolicyMappingRequest { @@ -764,6 +779,7 @@ message LoadPolicyMappingRequest { message LoadPolicyMappingResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message DeleteUserRequest { @@ -773,6 +789,7 @@ message DeleteUserRequest { message DeleteUserResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message DeleteServiceAccountRequest { @@ -782,6 +799,7 @@ message DeleteServiceAccountRequest { message DeleteServiceAccountResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message LoadUserRequest { @@ -792,6 +810,7 @@ message LoadUserRequest { message LoadUserResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message LoadServiceAccountRequest { @@ -801,6 +820,7 @@ message LoadServiceAccountRequest { message LoadServiceAccountResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message LoadGroupRequest { @@ -810,6 +830,7 @@ message LoadGroupRequest { message LoadGroupResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message ReloadSiteReplicationConfigRequest {} @@ -817,6 +838,7 @@ message ReloadSiteReplicationConfigRequest {} message ReloadSiteReplicationConfigResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message SignalServiceRequest { @@ -907,6 +929,7 @@ message BackgroundHealStatusResponse { bool success = 1; bytes bg_heal_state = 2; optional string error_info = 3; + optional ControlPlaneErrorCode error_code = 4; } message ReplacementRecoveryStatusRequest {} @@ -915,6 +938,7 @@ message ReplacementRecoveryStatusResponse { bool success = 1; bytes recovery_status = 2; optional string error_info = 3; + optional ControlPlaneErrorCode error_code = 4; } message HealControlRequest { @@ -955,6 +979,7 @@ message ReloadPoolMetaRequest {} message ReloadPoolMetaResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message StopRebalanceRequest { @@ -964,6 +989,7 @@ message StopRebalanceRequest { message StopRebalanceResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message LoadRebalanceMetaRequest { @@ -973,6 +999,7 @@ message LoadRebalanceMetaRequest { message LoadRebalanceMetaResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message StartDecommissionRequest { @@ -982,6 +1009,7 @@ message StartDecommissionRequest { message StartDecommissionResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message CancelDecommissionRequest { @@ -991,6 +1019,7 @@ message CancelDecommissionRequest { message CancelDecommissionResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message ClearDecommissionRequest { @@ -1000,6 +1029,7 @@ message ClearDecommissionRequest { message ClearDecommissionResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message LoadTransitionTierConfigRequest {} @@ -1007,6 +1037,7 @@ message LoadTransitionTierConfigRequest {} message LoadTransitionTierConfigResponse { bool success = 1; optional string error_info = 2; + optional ControlPlaneErrorCode error_code = 3; } message TierMutationPrepareRequest { diff --git a/crates/rio-v2/Cargo.toml b/crates/rio-v2/Cargo.toml index b941ed7fc..10e36d0ff 100644 --- a/crates/rio-v2/Cargo.toml +++ b/crates/rio-v2/Cargo.toml @@ -57,7 +57,7 @@ hotpath.workspace = true aes-gcm = { workspace = true, features = ["rand_core"] } bytes = { workspace = true, features = ["serde"] } chacha20poly1305.workspace = true -hex.workspace = true +hex-simd.workspace = true hmac.workspace = true minlz.workspace = true pin-project-lite.workspace = true diff --git a/crates/rio-v2/src/encrypt_reader.rs b/crates/rio-v2/src/encrypt_reader.rs index 56f119724..9dab95c2a 100644 --- a/crates/rio-v2/src/encrypt_reader.rs +++ b/crates/rio-v2/src/encrypt_reader.rs @@ -731,12 +731,15 @@ fn random_stream_nonce() -> [u8; 12] { #[cfg(test)] mod tests { use super::*; - use hex::encode as hex_encode; use std::io::Cursor; use tokio::io::AsyncReadExt; const DARE_PACKAGE_SIZE: usize = DARE_HEADER_SIZE + DARE_PAYLOAD_SIZE + DARE_TAG_SIZE; + fn hex_encode(data: impl AsRef<[u8]>) -> String { + hex_simd::encode_to_string(data, hex_simd::AsciiCase::Lower) + } + #[tokio::test] async fn decrypt_reader_can_start_from_non_zero_sequence_number() { let plaintext = vec![0xAB; DARE_PAYLOAD_SIZE * 2 + 19]; diff --git a/crates/rio/Cargo.toml b/crates/rio/Cargo.toml index 87ecc79cb..46723ff2b 100644 --- a/crates/rio/Cargo.toml +++ b/crates/rio/Cargo.toml @@ -81,7 +81,7 @@ serde_json = { workspace = true, features = ["raw_value"] } md-5 = { workspace = true } tracing.workspace = true thiserror.workspace = true -base64.workspace = true +base64-simd.workspace = true sha1.workspace = true sha2.workspace = true xxhash-rust = { workspace = true, features = ["xxh64", "xxh3"] } diff --git a/crates/rio/src/checksum.rs b/crates/rio/src/checksum.rs index cd50db57b..6c177b2c4 100644 --- a/crates/rio/src/checksum.rs +++ b/crates/rio/src/checksum.rs @@ -13,7 +13,7 @@ // limitations under the License. use crate::errors::ChecksumMismatch; -use base64::{Engine as _, engine::general_purpose}; +use base64_simd::STANDARD as BASE64_STANDARD; use bytes::Bytes; use http::HeaderMap; use sha1::Sha1; @@ -31,14 +31,28 @@ pub const RUSTFS_MULTIPART_CHECKSUM_TYPE: &str = "x-rustfs-multipart-checksum-ty /// Checksum type enumeration with flags /// -/// One of three deliberately separate checksum registries (backlog#1833): -/// this bitset owns the **on-disk xl.meta encoding** — the raw `u32` is +/// This bitset owns the **on-disk xl.meta encoding** — the raw `u32` is /// varint-serialized into xl.meta (see `append_to`), so bits are append-only -/// and must never be renumbered. `rustfs_checksums::ChecksumAlgorithm` -/// (crates/checksums/src/lib.rs) owns the streaming-hash algorithm registry, -/// and the MinIO-port client keeps its own `ChecksumMode` -/// (crates/ecstore/src/client/checksum.rs). When adding an algorithm, extend -/// all three (or record why not) — they do not derive from each other. +/// and must never be renumbered. Canonical per-algorithm metadata (wire +/// names, headers, digest lengths, checksum-type capabilities) lives in +/// `rustfs_checksums::ChecksumAlgorithm` (crates/checksums/src/lib.rs), which +/// the MinIO-port client's `ChecksumMode` +/// (crates/s3-client/src/checksum.rs) consumes through its `algorithm()` +/// bridge (backlog#1844). +/// +/// The hasher implementations below stay rio-native rather than delegating +/// to rustfs-checksums (backlog#1844 PR3 verdict): `ChecksumHasher` needs a +/// non-consuming `finalize` plus `reset` on the server hot path, while the +/// checksums `Checksum` trait finalizes by consuming the box; MD5 is a base +/// type here (x-amz-checksum-md5, bit 14) but deliberately not a +/// `ChecksumAlgorithm` variant; and both crates already drive the same +/// backends (crc_fast, sha1/sha2, xxhash_rust, md5). Equivalence is enforced +/// instead by pinning both sides to the same official known-answer vectors — +/// see `hashers_match_rustfs_checksums_known_answer_vectors` below and the +/// digest tests in crates/checksums. When adding an algorithm: extend +/// `ChecksumAlgorithm` (exhaustive matches force the metadata), bridge it in +/// the client, allocate a bit + hasher here, and pin the shared vector in +/// both test suites (or record why a surface is skipped). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct ChecksumType(pub u32); @@ -330,7 +344,7 @@ impl Checksum { let mut hasher = checksum_type.hasher()?; hasher.write_all(data).ok()?; let raw = hasher.finalize(); - let encoded = general_purpose::STANDARD.encode(&raw); + let encoded = BASE64_STANDARD.encode_to_string(&raw); let checksum = Checksum { checksum_type, @@ -369,7 +383,7 @@ impl Checksum { value_string = value.to_string(); } // let raw = base64_simd::URL_SAFE_NO_PAD.decode_to_vec(&value_string).ok()?; - let raw = general_purpose::STANDARD.decode(&value_string).ok()?; + let raw = BASE64_STANDARD.decode_to_vec(&value_string).ok()?; let checksum = Checksum { checksum_type, @@ -413,14 +427,14 @@ impl Checksum { if self.want_parts > 0 && self.want_parts != parts { return Err(ChecksumMismatch { want: format!("{}-{}", self.encoded, self.want_parts), - got: format!("{}-{}", general_purpose::STANDARD.encode(&sum), parts), + got: format!("{}-{}", base64_simd::STANDARD.encode_to_string(&sum), parts), }); } if sum != self.raw { return Err(ChecksumMismatch { want: self.encoded.clone(), - got: general_purpose::STANDARD.encode(&sum), + got: base64_simd::STANDARD.encode_to_string(&sum), }); } @@ -569,7 +583,7 @@ impl Checksum { } } - self.encoded = general_purpose::STANDARD.encode(&self.raw); + self.encoded = base64_simd::STANDARD.encode_to_string(&self.raw); Ok(()) } } @@ -1160,7 +1174,7 @@ pub fn read_checksums(mut buf: &[u8], part: i32) -> (HashMap, bo let checksum_bytes = &buf[..length]; buf = &buf[length..]; - let mut checksum_str = general_purpose::STANDARD.encode(checksum_bytes); + let mut checksum_str = base64_simd::STANDARD.encode_to_string(checksum_bytes); if checksum_type.is(ChecksumType::MULTIPART) { is_multipart = true; @@ -1190,7 +1204,7 @@ pub fn read_checksums(mut buf: &[u8], part: i32) -> (HashMap, bo if part > 0 && (part as u64) <= parts_count { let offset = ((part - 1) as usize) * length; let part_checksum = &buf[offset..offset + length]; - checksum_str = general_purpose::STANDARD.encode(part_checksum); + checksum_str = base64_simd::STANDARD.encode_to_string(part_checksum); } buf = &buf[want_len..]; } @@ -1249,7 +1263,7 @@ pub fn read_part_checksums(mut buf: &[u8]) -> Vec> { let checksum_bytes = &buf[..length]; buf = &buf[length..]; - let checksum_str = general_purpose::STANDARD.encode(checksum_bytes); + let checksum_str = base64_simd::STANDARD.encode_to_string(checksum_bytes); part_checksum.insert(checksum_type.to_string(), checksum_str); } @@ -1553,7 +1567,6 @@ mod tests { // asserted alongside the raw hex so both the digest and its encoding are pinned. #[test] fn xxhash_sha512_regression_lock_non_empty() { - use base64::{Engine as _, engine::general_purpose::STANDARD}; let data = b"The quick brown fox jumps over the lazy dog"; // XXH3-64(fox) = 0xce7d19a5418fb365 is the official upstream vector. @@ -1565,7 +1578,11 @@ mod tests { let got_hex: String = c.raw.iter().map(|b| format!("{b:02x}")).collect(); assert_eq!(got_hex, want_hex, "{t:?} raw hex drifted"); // encoded field must be the standard-base64 of raw (S3 wire form) - assert_eq!(c.encoded, STANDARD.encode(&c.raw), "{t:?} encoded field != base64(raw)"); + assert_eq!( + c.encoded, + base64_simd::STANDARD.encode_to_string(&c.raw), + "{t:?} encoded field != base64(raw)" + ); } } @@ -1643,6 +1660,24 @@ mod tests { } } + // Drift lock for the backlog#1844 PR3 verdict: rio keeps its own hasher + // shells instead of delegating to rustfs-checksums, so both sides pin the + // SAME input and official digests (crates/checksums/src/lib.rs pins these + // values for "test data" in its own tests). If either implementation ever + // changes backend, seed, or byte order, one of the two suites fails loudly. + #[test] + fn hashers_match_rustfs_checksums_known_answer_vectors() { + for (t, want_hex) in [ + (ChecksumType::CRC32, "d308aeb2"), + (ChecksumType::CRC32C, "3379b4ca"), + (ChecksumType::CRC64_NVME, "aecaf3af9c98a855"), + (ChecksumType::SHA1, "f48dd853820860816c75d54d0f584dc863327a7c"), + (ChecksumType::SHA256, "916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9"), + ] { + assert_eq!(raw_hex(t, b"test data"), want_hex, "{t:?} digest drifted from the shared vector"); + } + } + // S11: from_string_with_obj_type dropped to_uppercase() (a per-request heap alloc) // for eq_ignore_ascii_case. Lock that this did not change any behaviour. #[test] diff --git a/crates/rio/src/hash_reader.rs b/crates/rio/src/hash_reader.rs index f33cb7661..49efc1d6b 100644 --- a/crates/rio/src/hash_reader.rs +++ b/crates/rio/src/hash_reader.rs @@ -91,8 +91,7 @@ use crate::Sha256Hasher; use crate::compress_index::{Index, TryGetIndex}; use crate::get_content_checksum; use crate::{DynReader, EtagReader, EtagResolvable, HardLimitReader, HashReaderDetector, WarpReader, boxed_reader, wrap_reader}; -use base64::Engine; -use base64::engine::general_purpose; + use http::HeaderMap; use pin_project_lite::pin_project; use s3s::TrailingHeaders; @@ -582,8 +581,8 @@ impl AsyncRead for HashReader { }) { expected_content_hash.encoded = checksum_str; - expected_content_hash.raw = general_purpose::STANDARD - .decode(&expected_content_hash.encoded) + expected_content_hash.raw = base64_simd::STANDARD + .decode_to_vec(&expected_content_hash.encoded) .map_err(|_| std::io::Error::other("Invalid base64 checksum"))?; if expected_content_hash.raw.is_empty() { @@ -598,7 +597,7 @@ impl AsyncRead for HashReader { && !expected_content_hash.checksum_type.trailing() { expected_content_hash.raw = content_hash; - expected_content_hash.encoded = general_purpose::STANDARD.encode(&expected_content_hash.raw); + expected_content_hash.encoded = base64_simd::STANDARD.encode_to_string(&expected_content_hash.raw); } else if content_hash != expected_content_hash.raw { let expected_hex = hex_simd::encode_to_string(&expected_content_hash.raw, hex_simd::AsciiCase::Lower); let actual_hex = hex_simd::encode_to_string(content_hash, hex_simd::AsciiCase::Lower); diff --git a/crates/s3-client/Cargo.toml b/crates/s3-client/Cargo.toml index af63ae896..280cf3794 100644 --- a/crates/s3-client/Cargo.toml +++ b/crates/s3-client/Cargo.toml @@ -54,7 +54,6 @@ rustls-pki-types.workspace = true s3s = { workspace = true, features = ["minio"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["raw_value"] } -sha1 = { workspace = true } sha2 = { workspace = true } thiserror.workspace = true time = { workspace = true, features = ["parsing", "formatting", "macros", "serde"] } diff --git a/crates/s3-client/src/api_get_object.rs b/crates/s3-client/src/api_get_object.rs index 9f6f49d59..cc06fe388 100644 --- a/crates/s3-client/src/api_get_object.rs +++ b/crates/s3-client/src/api_get_object.rs @@ -69,7 +69,6 @@ impl TransitionClient { stream_sha256: false, trailer: HeaderMap::new(), pre_sign_url: Default::default(), - add_crc: Default::default(), extra_pre_sign_header: Default::default(), bucket_location: Default::default(), expires: Default::default(), diff --git a/crates/s3-client/src/api_list.rs b/crates/s3-client/src/api_list.rs index 3bd1f1edd..c2771d081 100644 --- a/crates/s3-client/src/api_list.rs +++ b/crates/s3-client/src/api_list.rs @@ -103,7 +103,6 @@ impl TransitionClient { stream_sha256: false, trailer: HeaderMap::new(), pre_sign_url: Default::default(), - add_crc: Default::default(), extra_pre_sign_header: Default::default(), bucket_location: Default::default(), expires: Default::default(), @@ -206,7 +205,6 @@ impl TransitionClient { stream_sha256: false, trailer: HeaderMap::new(), pre_sign_url: Default::default(), - add_crc: Default::default(), extra_pre_sign_header: Default::default(), bucket_location: Default::default(), expires: Default::default(), diff --git a/crates/s3-client/src/api_put_object_multipart.rs b/crates/s3-client/src/api_put_object_multipart.rs index 17efdbf3a..d7d1ba7a1 100644 --- a/crates/s3-client/src/api_put_object_multipart.rs +++ b/crates/s3-client/src/api_put_object_multipart.rs @@ -26,7 +26,7 @@ use time::OffsetDateTime; use tracing::warn; use uuid::Uuid; -use crate::checksum::ChecksumMode; +use crate::checksum::{ChecksumMode, checksum_header_value}; use crate::utils::base64_encode; use crate::{ api_error_response::{ @@ -223,7 +223,6 @@ impl TransitionClient { stream_sha256: false, trailer: HeaderMap::new(), pre_sign_url: Default::default(), - add_crc: Default::default(), extra_pre_sign_header: Default::default(), bucket_location: Default::default(), expires: Default::default(), @@ -306,7 +305,6 @@ impl TransitionClient { stream_sha256: p.stream_sha256, trailer: p.trailer.clone(), pre_sign_url: Default::default(), - add_crc: Default::default(), extra_pre_sign_header: Default::default(), bucket_location: Default::default(), expires: Default::default(), @@ -329,31 +327,11 @@ impl TransitionClient { //} let h = resp.headers(); let mut obj_part = ObjectPart { - checksum_crc32: if let Some(h_checksum_crc32) = h.get(ChecksumMode::ChecksumCRC32.key()) { - h_checksum_crc32.to_str().unwrap_or("").to_string() - } else { - "".to_string() - }, - checksum_crc32c: if let Some(h_checksum_crc32c) = h.get(ChecksumMode::ChecksumCRC32C.key()) { - h_checksum_crc32c.to_str().unwrap_or("").to_string() - } else { - "".to_string() - }, - checksum_sha1: if let Some(h_checksum_sha1) = h.get(ChecksumMode::ChecksumSHA1.key()) { - h_checksum_sha1.to_str().unwrap_or("").to_string() - } else { - "".to_string() - }, - checksum_sha256: if let Some(h_checksum_sha256) = h.get(ChecksumMode::ChecksumSHA256.key()) { - h_checksum_sha256.to_str().unwrap_or("").to_string() - } else { - "".to_string() - }, - checksum_crc64nvme: if let Some(h_checksum_crc64nvme) = h.get(ChecksumMode::ChecksumCRC64NVME.key()) { - h_checksum_crc64nvme.to_str().unwrap_or("").to_string() - } else { - "".to_string() - }, + checksum_crc32: checksum_header_value(h, ChecksumMode::ChecksumCRC32), + checksum_crc32c: checksum_header_value(h, ChecksumMode::ChecksumCRC32C), + checksum_sha1: checksum_header_value(h, ChecksumMode::ChecksumSHA1), + checksum_sha256: checksum_header_value(h, ChecksumMode::ChecksumSHA256), + checksum_crc64nvme: checksum_header_value(h, ChecksumMode::ChecksumCRC64NVME), ..Default::default() }; obj_part.size = p.size; @@ -393,7 +371,6 @@ impl TransitionClient { stream_sha256: Default::default(), trailer: Default::default(), pre_sign_url: Default::default(), - add_crc: Default::default(), extra_pre_sign_header: Default::default(), bucket_location: Default::default(), expires: Default::default(), diff --git a/crates/s3-client/src/api_put_object_streaming.rs b/crates/s3-client/src/api_put_object_streaming.rs index 9ae54b893..8c9c7b5a1 100644 --- a/crates/s3-client/src/api_put_object_streaming.rs +++ b/crates/s3-client/src/api_put_object_streaming.rs @@ -31,7 +31,7 @@ use tokio_util::sync::CancellationToken; use tracing::warn; use uuid::Uuid; -use crate::checksum::{ChecksumMode, add_auto_checksum_headers, apply_auto_checksum}; +use crate::checksum::{ChecksumMode, add_auto_checksum_headers, apply_auto_checksum, checksum_header_value}; use crate::{ api_error_response::{err_invalid_argument, err_unexpected_eof, http_resp_to_error_response}, api_put_object::PutObjectOptions, @@ -503,7 +503,6 @@ impl TransitionClient { content_md5_base64: md5_base64.to_string(), content_sha256_hex: sha256_hex.to_string(), stream_sha256: !opts.disable_content_sha256, - add_crc: Default::default(), bucket_location: Default::default(), pre_sign_url: Default::default(), query_values: Default::default(), @@ -511,21 +510,7 @@ impl TransitionClient { expires: Default::default(), trailer: Default::default(), }; - let mut add_crc = false; //self.trailing_header_support && md5_base64 == "" && !s3utils.IsGoogleEndpoint(self.endpoint_url) && (opts.disable_content_sha256 || self.secure); - let mut opts = opts.clone(); - if opts.checksum.is_set() { - req_metadata.add_crc = opts.checksum; - } else if add_crc { - for (k, _) in opts.user_metadata { - if k.to_lowercase().starts_with("x-amz-checksum-") { - add_crc = false; - } - } - if add_crc { - opts.auto_checksum.set_default(ChecksumMode::ChecksumCRC32C); - req_metadata.add_crc = opts.auto_checksum; - } - } + let opts = opts.clone(); if opts.internal.source_version_id != "" { if !opts.internal.source_version_id.is_empty() { @@ -570,31 +555,11 @@ impl TransitionClient { size, expiration: exp_time, expiration_rule_id: rule_id, - checksum_crc32: if let Some(h_checksum_crc32) = h.get(ChecksumMode::ChecksumCRC32.key()) { - h_checksum_crc32.to_str().unwrap_or("").to_string() - } else { - "".to_string() - }, - checksum_crc32c: if let Some(h_checksum_crc32c) = h.get(ChecksumMode::ChecksumCRC32C.key()) { - h_checksum_crc32c.to_str().unwrap_or("").to_string() - } else { - "".to_string() - }, - checksum_sha1: if let Some(h_checksum_sha1) = h.get(ChecksumMode::ChecksumSHA1.key()) { - h_checksum_sha1.to_str().unwrap_or("").to_string() - } else { - "".to_string() - }, - checksum_sha256: if let Some(h_checksum_sha256) = h.get(ChecksumMode::ChecksumSHA256.key()) { - h_checksum_sha256.to_str().unwrap_or("").to_string() - } else { - "".to_string() - }, - checksum_crc64nvme: if let Some(h_checksum_crc64nvme) = h.get(ChecksumMode::ChecksumCRC64NVME.key()) { - h_checksum_crc64nvme.to_str().unwrap_or("").to_string() - } else { - "".to_string() - }, + checksum_crc32: checksum_header_value(h, ChecksumMode::ChecksumCRC32), + checksum_crc32c: checksum_header_value(h, ChecksumMode::ChecksumCRC32C), + checksum_sha1: checksum_header_value(h, ChecksumMode::ChecksumSHA1), + checksum_sha256: checksum_header_value(h, ChecksumMode::ChecksumSHA256), + checksum_crc64nvme: checksum_header_value(h, ChecksumMode::ChecksumCRC64NVME), ..Default::default() }) } diff --git a/crates/s3-client/src/api_remove.rs b/crates/s3-client/src/api_remove.rs index 3002e71ca..5e063e14f 100644 --- a/crates/s3-client/src/api_remove.rs +++ b/crates/s3-client/src/api_remove.rs @@ -99,7 +99,6 @@ impl TransitionClient { stream_sha256: false, trailer: HeaderMap::new(), pre_sign_url: Default::default(), - add_crc: Default::default(), extra_pre_sign_header: Default::default(), bucket_location: Default::default(), expires: Default::default(), @@ -131,7 +130,6 @@ impl TransitionClient { stream_sha256: false, trailer: HeaderMap::new(), pre_sign_url: Default::default(), - add_crc: Default::default(), extra_pre_sign_header: Default::default(), bucket_location: Default::default(), expires: Default::default(), @@ -185,7 +183,6 @@ impl TransitionClient { stream_sha256: false, trailer: HeaderMap::new(), pre_sign_url: Default::default(), - add_crc: Default::default(), extra_pre_sign_header: Default::default(), bucket_location: Default::default(), expires: Default::default(), @@ -347,7 +344,6 @@ impl TransitionClient { stream_sha256: false, trailer: HeaderMap::new(), pre_sign_url: Default::default(), - add_crc: Default::default(), extra_pre_sign_header: Default::default(), bucket_location: Default::default(), expires: Default::default(), @@ -407,7 +403,6 @@ impl TransitionClient { stream_sha256: false, trailer: HeaderMap::new(), pre_sign_url: Default::default(), - add_crc: Default::default(), extra_pre_sign_header: Default::default(), bucket_location: Default::default(), expires: Default::default(), diff --git a/crates/s3-client/src/api_s3_datatypes.rs b/crates/s3-client/src/api_s3_datatypes.rs index 2f6475815..d17eb6cbb 100644 --- a/crates/s3-client/src/api_s3_datatypes.rs +++ b/crates/s3-client/src/api_s3_datatypes.rs @@ -262,32 +262,6 @@ pub struct CompletePart { pub checksum_crc64nvme: String, } -impl CompletePart { - #[allow(dead_code, reason = "MinIO-parity accessor with no caller in this port (backlog#1823)")] - fn checksum(&self, t: &ChecksumMode) -> String { - match t { - ChecksumMode::ChecksumCRC32C => { - return self.checksum_crc32c.clone(); - } - ChecksumMode::ChecksumCRC32 => { - return self.checksum_crc32.clone(); - } - ChecksumMode::ChecksumSHA1 => { - return self.checksum_sha1.clone(); - } - ChecksumMode::ChecksumSHA256 => { - return self.checksum_sha256.clone(); - } - ChecksumMode::ChecksumCRC64NVME => { - return self.checksum_crc64nvme.clone(); - } - _ => { - return "".to_string(); - } - } - } -} - #[derive(Debug, Default, serde::Serialize)] #[serde(rename = "CompleteMultipartUpload")] pub struct CompleteMultipartUpload { diff --git a/crates/s3-client/src/api_stat.rs b/crates/s3-client/src/api_stat.rs index c4bc8c9d8..bac59c8ed 100644 --- a/crates/s3-client/src/api_stat.rs +++ b/crates/s3-client/src/api_stat.rs @@ -104,7 +104,6 @@ impl TransitionClient { stream_sha256: false, trailer: HeaderMap::new(), pre_sign_url: Default::default(), - add_crc: Default::default(), extra_pre_sign_header: Default::default(), bucket_location: Default::default(), expires: Default::default(), @@ -159,7 +158,6 @@ impl TransitionClient { stream_sha256: false, trailer: HeaderMap::new(), pre_sign_url: Default::default(), - add_crc: Default::default(), extra_pre_sign_header: Default::default(), bucket_location: Default::default(), expires: Default::default(), @@ -212,7 +210,6 @@ impl TransitionClient { stream_sha256: false, trailer: HeaderMap::new(), pre_sign_url: Default::default(), - add_crc: Default::default(), extra_pre_sign_header: Default::default(), bucket_location: Default::default(), expires: Default::default(), diff --git a/crates/s3-client/src/checksum.rs b/crates/s3-client/src/checksum.rs index 6b4de15d7..06aacdb06 100644 --- a/crates/s3-client/src/checksum.rs +++ b/crates/s3-client/src/checksum.rs @@ -1,4 +1,3 @@ -#![allow(clippy::map_entry)] // Copyright 2024 RustFS Team // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -12,38 +11,27 @@ // 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. -#![allow(unused_imports)] -#![allow(unused_variables)] -#![allow(unused_mut)] -#![allow(unused_assignments)] -#![allow(unused_must_use)] -#![allow(clippy::all)] -use lazy_static::lazy_static; use rustfs_checksums::ChecksumAlgorithm; use std::collections::HashMap; -use crate::utils::base64_decode; use crate::utils::base64_encode; use crate::{api_put_object::PutObjectOptions, api_s3_datatypes::ObjectPart}; -// s3s::header has no CRC64NVME constant yet; the canonical RustFS copy lives -// in rustfs-utils' headers module. -use rustfs_utils::http::headers::AMZ_CHECKSUM_CRC64NVME; -use s3s::header::{ - X_AMZ_CHECKSUM_ALGORITHM, X_AMZ_CHECKSUM_CRC32, X_AMZ_CHECKSUM_CRC32C, X_AMZ_CHECKSUM_SHA1, X_AMZ_CHECKSUM_SHA256, -}; -use enumset::{EnumSet, EnumSetType, enum_set}; +use enumset::EnumSetType; -/// One of three deliberately separate checksum registries (backlog#1833): -/// this enum is the MinIO-port client's wire vocabulary and stops at the -/// standard S3 set (CRC64NVME is its newest member; the RustFS extensions do -/// not exist on this client path). The streaming-hash registry lives in -/// `rustfs_checksums::ChecksumAlgorithm` (crates/checksums/src/lib.rs) and -/// the on-disk xl.meta bitset in `rustfs_rio::ChecksumType` -/// (crates/rio/src/checksum.rs, varint bits are append-only). When adding an -/// algorithm, extend all three (or record why not) — they do not derive from -/// each other. +/// The MinIO-port client's checksum vocabulary: the standard S3 algorithm set +/// plus the `None`/`FullObject` markers this client's option plumbing needs +/// (the RustFS extension algorithms do not exist on this client path). All +/// per-algorithm dispatch — header names, wire names, digest lengths, +/// checksum-type capabilities, hashers — is delegated through [`Self::algorithm`] +/// to the canonical registry in `rustfs_checksums::ChecksumAlgorithm` +/// (crates/checksums/src/lib.rs); only the variant-to-algorithm bridge lives +/// here. The on-disk xl.meta bitset remains separate in +/// `rustfs_rio::ChecksumType` (crates/rio/src/checksum.rs, varint bits are +/// append-only). When adding an algorithm: extend `ChecksumAlgorithm` (its +/// exhaustive matches force the metadata), add the variant + one `algorithm()` +/// arm here, and allocate an xl.meta bit in rio (or record why not). #[derive(Debug, EnumSetType, Default)] #[enumset(repr = "u8")] pub enum ChecksumMode { @@ -57,34 +45,31 @@ pub enum ChecksumMode { ChecksumFullObject, } -lazy_static! { - static ref C_ChecksumMask: EnumSet = { - let mut s = EnumSet::all(); - s.remove(ChecksumMode::ChecksumFullObject); - s - }; - static ref C_ChecksumFullObjectCRC32: EnumSet = - enum_set!(ChecksumMode::ChecksumCRC32 | ChecksumMode::ChecksumFullObject); - static ref C_ChecksumFullObjectCRC32C: EnumSet = - enum_set!(ChecksumMode::ChecksumCRC32C | ChecksumMode::ChecksumFullObject); -} impl ChecksumMode { - //pub const CRC64_NVME_POLYNOMIAL: i64 = 0xad93d23594c93659; + /// The single bridge from this client vocabulary to the canonical + /// algorithm registry. Every per-algorithm question below goes through + /// here; the marker variants (`ChecksumNone`, bare `ChecksumFullObject`) + /// map to `None` and fail closed at each call site. + pub fn algorithm(&self) -> Option { + match self { + ChecksumMode::ChecksumSHA256 => Some(ChecksumAlgorithm::Sha256), + ChecksumMode::ChecksumSHA1 => Some(ChecksumAlgorithm::Sha1), + ChecksumMode::ChecksumCRC32 => Some(ChecksumAlgorithm::Crc32), + ChecksumMode::ChecksumCRC32C => Some(ChecksumAlgorithm::Crc32c), + ChecksumMode::ChecksumCRC64NVME => Some(ChecksumAlgorithm::Crc64Nvme), + ChecksumMode::ChecksumNone | ChecksumMode::ChecksumFullObject => None, + } + } pub fn base(&self) -> ChecksumMode { - let s = EnumSet::from(*self).intersection(*C_ChecksumMask); - match s.as_u8() { - 1_u8 => ChecksumMode::ChecksumNone, - 2_u8 => ChecksumMode::ChecksumSHA256, - 4_u8 => ChecksumMode::ChecksumSHA1, - 8_u8 => ChecksumMode::ChecksumCRC32, - 16_u8 => ChecksumMode::ChecksumCRC32C, - 32_u8 => ChecksumMode::ChecksumCRC64NVME, - // Fail closed: any mode without a concrete base algorithm (e.g. a - // bare ChecksumFullObject flag) is treated as "no checksum" rather - // than panicking. Callers already gate real work behind - // is_set()/can_composite()/hasher(), so this only removes a crash. - _ => ChecksumMode::ChecksumNone, + // Fail closed: any mode without a concrete base algorithm (e.g. a + // bare ChecksumFullObject flag) is treated as "no checksum" rather + // than panicking. Callers already gate real work behind + // is_set()/can_composite()/hasher(), so this only removes a crash. + if self.algorithm().is_some() { + *self + } else { + ChecksumMode::ChecksumNone } } @@ -93,116 +78,42 @@ impl ChecksumMode { } pub fn key(&self) -> String { - //match c & checksumMask { - match self { - ChecksumMode::ChecksumCRC32 => { - return X_AMZ_CHECKSUM_CRC32.to_string(); - } - ChecksumMode::ChecksumCRC32C => { - return X_AMZ_CHECKSUM_CRC32C.to_string(); - } - ChecksumMode::ChecksumSHA1 => { - return X_AMZ_CHECKSUM_SHA1.to_string(); - } - ChecksumMode::ChecksumSHA256 => { - return X_AMZ_CHECKSUM_SHA256.to_string(); - } - ChecksumMode::ChecksumCRC64NVME => { - return AMZ_CHECKSUM_CRC64NVME.to_string(); - } - _ => { - return "".to_string(); - } - } + self.algorithm().map(|a| a.http_header_name().to_string()).unwrap_or_default() } pub fn can_composite(&self) -> bool { - let s = EnumSet::from(*self).intersection(*C_ChecksumMask); - match s.as_u8() { - 2_u8 => true, - 4_u8 => true, - 8_u8 => true, - 16_u8 => true, - _ => false, - } + self.algorithm().is_some_and(|a| a.supports_composite()) } pub fn can_merge_crc(&self) -> bool { - let s = EnumSet::from(*self).intersection(*C_ChecksumMask); - match s.as_u8() { - 8_u8 => true, - 16_u8 => true, - 32_u8 => true, - _ => false, - } + self.algorithm().is_some_and(|a| a.supports_full_object()) } pub fn full_object_requested(&self) -> bool { - let s = EnumSet::from(*self).intersection(*C_ChecksumMask); - match s.as_u8() { - //C_ChecksumFullObjectCRC32 as u8 => true, - //C_ChecksumFullObjectCRC32C as u8 => true, - 32_u8 => true, - _ => false, - } - } - - pub fn key_capitalized(&self) -> String { - self.key() + // CRC64NVME is FULL_OBJECT-only, so selecting it implies a full-object + // checksum even without an explicit request (AWS behaviour). + self.algorithm() + .is_some_and(|a| a.supports_full_object() && !a.supports_composite()) } pub fn raw_byte_len(&self) -> usize { - let u = EnumSet::from(*self).intersection(*C_ChecksumMask).as_u8(); - if u == ChecksumMode::ChecksumCRC32 as u8 || u == ChecksumMode::ChecksumCRC32C as u8 { - 4 - } else if u == ChecksumMode::ChecksumSHA1 as u8 { - use sha1::Digest; - sha1::Sha1::output_size() as usize - } else if u == ChecksumMode::ChecksumSHA256 as u8 { - use sha2::Digest; - sha2::Sha256::output_size() as usize - } else if u == ChecksumMode::ChecksumCRC64NVME as u8 { - 8 - } else { - 0 - } + self.algorithm().map(|a| a.raw_len()).unwrap_or(0) } pub fn hasher(&self) -> Result, std::io::Error> { - match /*C_ChecksumMask & **/self { - ChecksumMode::ChecksumCRC32 => { - return Ok(ChecksumAlgorithm::Crc32.into_impl()); - } - ChecksumMode::ChecksumCRC32C => { - return Ok(ChecksumAlgorithm::Crc32c.into_impl()); - } - ChecksumMode::ChecksumSHA1 => { - return Ok(ChecksumAlgorithm::Sha1.into_impl()); - } - ChecksumMode::ChecksumSHA256 => { - return Ok(ChecksumAlgorithm::Sha256.into_impl()); - } - ChecksumMode::ChecksumCRC64NVME => { - return Ok(ChecksumAlgorithm::Crc64Nvme.into_impl()); - } - _ => return Err(std::io::Error::other("unsupported checksum type")), - } + self.algorithm() + .map(ChecksumAlgorithm::into_impl) + .ok_or_else(|| std::io::Error::other("unsupported checksum type")) } pub fn is_set(&self) -> bool { - // `ChecksumNone` is the zeroth enum variant, so it occupies bit 0 of the - // `EnumSet` repr and a naive `len() == 1` check reports "no checksum" as a - // configured checksum. A checksum is only "set" when a concrete algorithm - // (one with a real hasher) is selected; the bare `ChecksumFullObject` flag - // has no base algorithm and is likewise not set. Treating `ChecksumNone` - // as set made ILM transitions of >128 MiB objects fail with - // "unsupported checksum type" (rustfs/rustfs#4811): the multipart put path - // took the checksum branch and called `ChecksumNone.hasher()`. - if matches!(self, ChecksumMode::ChecksumNone) { - return false; - } - let s = EnumSet::from(*self).intersection(*C_ChecksumMask); - s.len() == 1 + // A checksum is only "set" when a concrete algorithm (one with a real + // hasher) is selected; `ChecksumNone` and the bare `ChecksumFullObject` + // flag are not. Treating `ChecksumNone` as set made ILM transitions of + // >128 MiB objects fail with "unsupported checksum type" + // (rustfs/rustfs#4811): the multipart put path took the checksum branch + // and called `ChecksumNone.hasher()`. + self.algorithm().is_some() } pub fn set_default(&mut self, t: ChecksumMode) { @@ -221,58 +132,13 @@ impl ChecksumMode { Ok(base64_encode(hash.as_ref())) } - pub fn to_string(&self) -> String { - //match c & checksumMask { - match self { - ChecksumMode::ChecksumCRC32 => { - return "CRC32".to_string(); - } - ChecksumMode::ChecksumCRC32C => { - return "CRC32C".to_string(); - } - ChecksumMode::ChecksumSHA1 => { - return "SHA1".to_string(); - } - ChecksumMode::ChecksumSHA256 => { - return "SHA256".to_string(); - } - ChecksumMode::ChecksumNone => { - return "".to_string(); - } - ChecksumMode::ChecksumCRC64NVME => { - return "CRC64NVME".to_string(); - } - _ => { - return "".to_string(); - } - } - } - - // pub fn check_sum_reader(&self, r: GetObjectReader) -> Result { - // let mut h = self.hasher()?; - // Ok(Checksum::new(self.clone(), h.sum().as_bytes())) - // } - - // pub fn check_sum_bytes(&self, b: &[u8]) -> Result { - // let mut h = self.hasher()?; - // Ok(Checksum::new(self.clone(), h.sum().as_bytes())) - // } - pub fn composite_checksum(&self, p: &mut [ObjectPart]) -> Result { if !self.can_composite() { return Err(std::io::Error::other("cannot do composite checksum")); } - p.sort_by(|i, j| { - if i.part_num < j.part_num { - std::cmp::Ordering::Less - } else if i.part_num > j.part_num { - std::cmp::Ordering::Greater - } else { - std::cmp::Ordering::Equal - } - }); + p.sort_by_key(|part| part.part_num); let c = self.base(); - let mut crc_bytes = Vec::::with_capacity(p.len() * self.raw_byte_len() as usize); + let mut crc_bytes = Vec::::with_capacity(p.len() * self.raw_byte_len()); let mut h = self.hasher()?; for part in p.iter() { let part_checksum = part.checksum_raw(&c)?; @@ -281,9 +147,8 @@ impl ChecksumMode { h.update(crc_bytes.as_ref()); let hash = h.finalize(); Ok(Checksum { - checksum_type: self.clone(), + checksum_type: *self, r: hash.as_ref().to_vec(), - computed: false, }) } @@ -296,6 +161,19 @@ impl ChecksumMode { } } +impl std::fmt::Display for ChecksumMode { + /// The `x-amz-checksum-algorithm` wire value: the algorithm name for + /// concrete modes, `""` for `ChecksumNone`, `""` for a bare + /// `ChecksumFullObject` flag. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.algorithm() { + Some(algorithm) => f.write_str(algorithm.s3_algorithm_name()), + None if matches!(self, ChecksumMode::ChecksumNone) => Ok(()), + None => f.write_str(""), + } + } +} + #[cfg(test)] mod tests { use super::ChecksumMode; @@ -344,6 +222,126 @@ mod tests { } } + #[test] + fn test_delegated_dispatch_preserves_wire_behaviour() { + // Behaviour lock for the backlog#1844 unification: every output that + // reaches the wire (header names, algorithm names, digest lengths, + // checksum-type capabilities) is pinned to the exact values the + // pre-delegation per-algorithm matches produced. If delegation to + // rustfs_checksums::ChecksumAlgorithm ever drifts, this fails loudly. + struct Expected { + mode: ChecksumMode, + key: &'static str, + name: &'static str, + raw_len: usize, + composite: bool, + merge_crc: bool, + full_object: bool, + } + let table = [ + Expected { + mode: ChecksumMode::ChecksumCRC32, + key: "x-amz-checksum-crc32", + name: "CRC32", + raw_len: 4, + composite: true, + merge_crc: true, + full_object: false, + }, + Expected { + mode: ChecksumMode::ChecksumCRC32C, + key: "x-amz-checksum-crc32c", + name: "CRC32C", + raw_len: 4, + composite: true, + merge_crc: true, + full_object: false, + }, + Expected { + mode: ChecksumMode::ChecksumSHA1, + key: "x-amz-checksum-sha1", + name: "SHA1", + raw_len: 20, + composite: true, + merge_crc: false, + full_object: false, + }, + Expected { + mode: ChecksumMode::ChecksumSHA256, + key: "x-amz-checksum-sha256", + name: "SHA256", + raw_len: 32, + composite: true, + merge_crc: false, + full_object: false, + }, + Expected { + mode: ChecksumMode::ChecksumCRC64NVME, + key: "x-amz-checksum-crc64nvme", + name: "CRC64NVME", + raw_len: 8, + composite: false, + merge_crc: true, + full_object: true, + }, + Expected { + mode: ChecksumMode::ChecksumNone, + key: "", + name: "", + raw_len: 0, + composite: false, + merge_crc: false, + full_object: false, + }, + Expected { + mode: ChecksumMode::ChecksumFullObject, + key: "", + name: "", + raw_len: 0, + composite: false, + merge_crc: false, + full_object: false, + }, + ]; + + for e in table { + assert_eq!(e.mode.key(), e.key, "{:?} key()", e.mode); + assert_eq!(e.mode.to_string(), e.name, "{:?} to_string()", e.mode); + assert_eq!(e.mode.raw_byte_len(), e.raw_len, "{:?} raw_byte_len()", e.mode); + assert_eq!(e.mode.can_composite(), e.composite, "{:?} can_composite()", e.mode); + assert_eq!(e.mode.can_merge_crc(), e.merge_crc, "{:?} can_merge_crc()", e.mode); + assert_eq!(e.mode.full_object_requested(), e.full_object, "{:?} full_object_requested()", e.mode); + + // The hasher, when present, must produce digests of the advertised + // length under the advertised header name. + if let Ok(mut hasher) = e.mode.hasher() { + assert_eq!(e.mode.hasher().unwrap().header_name(), e.key, "{:?} hasher header", e.mode); + hasher.update(b"wire behaviour probe"); + assert_eq!(hasher.finalize().len(), e.raw_len, "{:?} digest length", e.mode); + } else { + assert_eq!(e.raw_len, 0, "{:?} has no hasher but a nonzero raw_len", e.mode); + } + } + } + + #[test] + fn test_checksum_header_value_reads_present_absent_and_invalid() { + use super::checksum_header_value; + use http::{HeaderMap, HeaderValue}; + + let mut headers = HeaderMap::new(); + headers.insert("x-amz-checksum-crc32c", HeaderValue::from_static("yZRlqg==")); + headers.insert("x-amz-checksum-sha256", HeaderValue::from_bytes(b"\xff\xfe").unwrap()); + + // Present header returns its value verbatim. + assert_eq!(checksum_header_value(&headers, ChecksumMode::ChecksumCRC32C), "yZRlqg=="); + // Absent header and the unset modes (whose key() is "") return "". + assert_eq!(checksum_header_value(&headers, ChecksumMode::ChecksumCRC32), ""); + assert_eq!(checksum_header_value(&headers, ChecksumMode::ChecksumNone), ""); + // A non-UTF-8 header value degrades to "" instead of erroring. + assert_eq!(checksum_header_value(&headers, ChecksumMode::ChecksumSHA256), ""); + } + #[test] fn test_set_default_upgrades_none() { // With `is_set()` fixed, `set_default` must upgrade an unset mode to the @@ -364,42 +362,9 @@ mod tests { pub struct Checksum { checksum_type: ChecksumMode, r: Vec, - #[allow( - dead_code, - reason = "checksum bookkeeping field kept beside the value it guards (backlog#1823)" - )] - computed: bool, } impl Checksum { - #[allow(dead_code, reason = "MinIO-parity surface with no caller in this port (backlog#1823)")] - fn new(t: ChecksumMode, b: &[u8]) -> Checksum { - if t.is_set() && b.len() == t.raw_byte_len() { - return Checksum { - checksum_type: t, - r: b.to_vec(), - computed: false, - }; - } - Checksum::default() - } - - #[allow(dead_code, reason = "MinIO-parity surface with no caller in this port (backlog#1823)")] - fn new_checksum_string(t: ChecksumMode, s: &str) -> Result { - let b = match base64_decode(s.as_bytes()) { - Ok(b) => b, - Err(err) => return Err(std::io::Error::other(err.to_string())), - }; - if t.is_set() && b.len() == t.raw_byte_len() { - return Ok(Checksum { - checksum_type: t, - r: b, - computed: false, - }); - } - Ok(Checksum::default()) - } - fn is_set(&self) -> bool { self.checksum_type.is_set() && self.r.len() == self.checksum_type.raw_byte_len() } @@ -410,14 +375,17 @@ impl Checksum { } base64_encode(&self.r) } +} - #[allow(dead_code, reason = "MinIO-parity surface with no caller in this port (backlog#1823)")] - fn raw(&self) -> Option> { - if !self.is_set() { - return None; - } - Some(self.r.clone()) - } +/// Read the base64 digest carried by `mode`'s `x-amz-checksum-*` response +/// header, or an empty string when the header is absent (the client's +/// datatypes use `""` for "no checksum"). +pub fn checksum_header_value(headers: &http::HeaderMap, mode: ChecksumMode) -> String { + headers + .get(mode.key()) + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_string() } pub fn add_auto_checksum_headers(opts: &mut PutObjectOptions) { @@ -441,7 +409,7 @@ pub fn apply_auto_checksum(opts: &mut PutObjectOptions, all_parts: &mut [ObjectP let crc = opts.auto_checksum.full_object_checksum(all_parts)?; opts.user_metadata = { let mut hm = HashMap::new(); - hm.insert(opts.auto_checksum.key_capitalized(), crc.encoded()); + hm.insert(opts.auto_checksum.key(), crc.encoded()); hm.insert("X-Amz-Checksum-Type".to_string(), "FULL_OBJECT".to_string()); hm } diff --git a/crates/s3-client/src/transition_api.rs b/crates/s3-client/src/transition_api.rs index 87614ee84..6f45c76e2 100644 --- a/crates/s3-client/src/transition_api.rs +++ b/crates/s3-client/src/transition_api.rs @@ -19,7 +19,6 @@ #![allow(clippy::all)] use crate::bucket_cache::BucketLocationCache; -use crate::checksum::ChecksumMode; use crate::{ api_error_response::ErrorResponse, api_error_response::{err_invalid_argument, http_resp_to_error_response, to_error_response}, @@ -898,7 +897,6 @@ pub struct RequestMetadata { pub content_md5_base64: String, pub content_sha256_hex: String, pub stream_sha256: bool, - pub add_crc: ChecksumMode, pub trailer: HeaderMap, } diff --git a/crates/scanner/Cargo.toml b/crates/scanner/Cargo.toml index d941dce48..91f35715f 100644 --- a/crates/scanner/Cargo.toml +++ b/crates/scanner/Cargo.toml @@ -73,6 +73,8 @@ hotpath-cpu = [ hotpath.workspace = true rustfs-config = { workspace = true, features = ["server-config-model"] } rustfs-common = { workspace = true } +rustfs-heal-contracts = { workspace = true } +rustfs-scanner-contracts = { workspace = true } rustfs-credentials = { workspace = true } rustfs-utils = { workspace = true } tokio = { workspace = true, features = ["fs", "sync", "time", "macros", "rt-multi-thread"] } diff --git a/crates/scanner/src/data_usage_define.rs b/crates/scanner/src/data_usage_define.rs index 084965989..8447c4917 100644 --- a/crates/scanner/src/data_usage_define.rs +++ b/crates/scanner/src/data_usage_define.rs @@ -23,7 +23,6 @@ use std::{ use http::HeaderMap; use metrics::{counter, describe_counter, describe_histogram, histogram}; -use rustfs_common::heal_channel::HealScanMode; #[cfg(test)] use rustfs_config::ENV_SCANNER_CACHE_SAVE_TIMEOUT_SECS; pub use rustfs_data_usage::{ @@ -33,6 +32,7 @@ pub use rustfs_data_usage::{ SizeReconciliationScope, SizeSummary, TierAccountingProof, TierStats, UNKNOWN_TIER, UNKNOWN_TIER_DIAGNOSTIC_BYTE_CAP, UNKNOWN_TIER_DIAGNOSTIC_ENTRY_CAP, UnknownTierStats, hash_path, prefix_usage_in_cache, }; +use rustfs_heal_contracts::heal_channel::HealScanMode; use rustfs_utils::path::{SLASH_SEPARATOR, path_join_buf}; use tokio::time::{Duration, Instant, sleep, timeout}; use tracing::{debug, warn}; diff --git a/crates/scanner/src/lib.rs b/crates/scanner/src/lib.rs index 7f9f985da..2c78928b5 100644 --- a/crates/scanner/src/lib.rs +++ b/crates/scanner/src/lib.rs @@ -79,7 +79,7 @@ pub use remote_scanner::{ remote_scanner_request_matches_envelope, serve_remote_scanner_request, validate_remote_scanner_request_fence, }; pub use runtime_config::{apply_scanner_runtime_config, scanner_runtime_config_status, validate_scanner_runtime_config}; -pub use rustfs_common::last_minute; +pub use rustfs_scanner_contracts::last_minute; pub use scanner::{ ScannerCycleRecoveryMarker, ScannerCycleRecoveryStatus, ScannerCycleScheduleStatus, init_data_scanner, reset_scanner_cycle_recovery, scanner_cycle_recovery_status, scanner_cycle_schedule_status, scanner_topology_digest, diff --git a/crates/scanner/src/remote_scanner/stream.rs b/crates/scanner/src/remote_scanner/stream.rs index 594473196..7617f8fd0 100644 --- a/crates/scanner/src/remote_scanner/stream.rs +++ b/crates/scanner/src/remote_scanner/stream.rs @@ -26,9 +26,9 @@ use crate::{ scanner_publication_admission_for_epoch, scanner_publication_epoch, }; use hmac::{Hmac, KeyInit, Mac}; -use rustfs_common::heal_channel::HealScanMode; -use rustfs_common::metrics::{Metric, Metrics}; use rustfs_credentials::try_get_rpc_token; +use rustfs_heal_contracts::heal_channel::HealScanMode; +use rustfs_scanner_contracts::metrics::{Metric, Metrics}; use rustfs_utils::path::path_join_buf; use serde::{Deserialize, Serialize}; use sha2::Sha256; diff --git a/crates/scanner/src/scanner.rs b/crates/scanner/src/scanner.rs index e31cad163..d985931ad 100644 --- a/crates/scanner/src/scanner.rs +++ b/crates/scanner/src/scanner.rs @@ -40,12 +40,6 @@ use crate::{DataUsageInfo, ScannerActivityGuard, ScannerError, ScannerRuntimeGua use crate::{ScannerConfigObjectDelete, ScannerObjectIO, ScannerObjectOptions}; use bytes::Bytes; use chrono::{DateTime, Utc}; -use rustfs_common::heal_channel::HealScanMode; -use rustfs_common::metrics::{ - CurrentCycle, Metric, Metrics, ScanCyclePartialReason, ScanCycleWorkSnapshot, ScannerUsageSaveResult, ScannerWorkSource, - emit_scan_cycle_complete, emit_scan_cycle_deferred, emit_scan_cycle_partial_with_source, emit_scan_cycle_superseded, - global_metrics, -}; use rustfs_config::ScannerSpeed; #[cfg(test)] use rustfs_config::{ @@ -54,7 +48,13 @@ use rustfs_config::{ }; use rustfs_config::{ENV_SCANNER_CYCLE, ENV_SCANNER_SPEED, ENV_SCANNER_START_DELAY_SECS}; use rustfs_data_usage::observed_data_usage_is_newer; +use rustfs_heal_contracts::heal_channel::HealScanMode; use rustfs_lock::{NamespaceLockGuard, error::LockError}; +use rustfs_scanner_contracts::metrics::{ + CurrentCycle, Metric, Metrics, ScanCyclePartialReason, ScanCycleWorkSnapshot, ScannerUsageSaveResult, ScannerWorkSource, + emit_scan_cycle_complete, emit_scan_cycle_deferred, emit_scan_cycle_partial_with_source, emit_scan_cycle_superseded, + global_metrics, +}; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; use tokio::sync::{Notify, mpsc}; diff --git a/crates/scanner/src/scanner_folder.rs b/crates/scanner/src/scanner_folder.rs index efc0ca26e..231bd147c 100644 --- a/crates/scanner/src/scanner_folder.rs +++ b/crates/scanner/src/scanner_folder.rs @@ -41,19 +41,19 @@ use crate::storage_api::owner::{ #[cfg(test)] use crate::storage_api::owner::{EcstoreExpirationStatus as ExpirationStatus, EcstoreLifecycleRule as LifecycleRule}; use metrics::{counter, describe_counter}; -use rustfs_common::heal_channel::{ - HEAL_DELETE_DANGLING, HealAdmissionDropReason, HealAdmissionResult, HealChannelPriority, HealChannelRequest, - HealRequestSource, HealScanMode, send_heal_request_with_admission, -}; -use rustfs_common::metrics::{ - CloseDiskGuard, IlmAction, Metric, Metrics, ScannerReplicationRepairKind, ScannerSourceWorkUpdate, ScannerWorkSource, - UpdateCurrentPathFn, current_path_updater, global_metrics, -}; use rustfs_common::trace_bus::{TraceEvent, TraceFunc, TraceKind, trace_emit, trace_subscriber_count}; use rustfs_filemeta::{ MAX_META_CACHE_HEAL_CANDIDATES, MAX_META_CACHE_HEAL_TRUNCATED_OBJECTS, MetaCacheEntries, MetaCacheEntry, MetaCacheHealCandidateKind, }; +use rustfs_heal_contracts::heal_channel::{ + HEAL_DELETE_DANGLING, HealAdmissionDropReason, HealAdmissionResult, HealChannelPriority, HealChannelRequest, + HealRequestSource, HealScanMode, send_heal_request_with_admission, +}; +use rustfs_scanner_contracts::metrics::{ + CloseDiskGuard, IlmAction, Metric, Metrics, ScannerReplicationRepairKind, ScannerSourceWorkUpdate, ScannerWorkSource, + UpdateCurrentPathFn, current_path_updater, global_metrics, +}; use rustfs_utils::path::{SLASH_SEPARATOR, path_join_buf}; use time::OffsetDateTime; use tokio::select; diff --git a/crates/scanner/src/scanner_folder/tests.rs b/crates/scanner/src/scanner_folder/tests.rs index 3da33b3b5..3708db8af 100644 --- a/crates/scanner/src/scanner_folder/tests.rs +++ b/crates/scanner/src/scanner_folder/tests.rs @@ -745,7 +745,7 @@ async fn test_scanner_heal_admission_accounting_maps_normal_scan_to_heal() { &metrics, HealScanMode::Normal, Ok(HealAdmissionResult::Dropped( - rustfs_common::heal_channel::HealAdmissionDropReason::QueueFull, + rustfs_heal_contracts::heal_channel::HealAdmissionDropReason::QueueFull, )), ); record_scanner_heal_admission(&metrics, HealScanMode::Normal, Err(())); @@ -1687,7 +1687,7 @@ fn test_describe_heal_admission_formats_unadmitted_results() { assert_eq!(describe_heal_admission(HealAdmissionResult::Full), "queue_full"); assert_eq!( describe_heal_admission(HealAdmissionResult::Dropped( - rustfs_common::heal_channel::HealAdmissionDropReason::QueueFull + rustfs_heal_contracts::heal_channel::HealAdmissionDropReason::QueueFull )), "dropped:queue_full" ); @@ -1879,10 +1879,10 @@ async fn test_scan_folder_exits_when_abandoned_child_listing_finishes() { let healed_versions = Arc::new(Mutex::new(Vec::>::new())); let healed_versions_clone = healed_versions.clone(); let mut heal_rx = - rustfs_common::heal_channel::init_heal_channel().expect("heal channel should initialize once for scanner tests"); + rustfs_heal_contracts::heal_channel::init_heal_channel().expect("heal channel should initialize once for scanner tests"); let _heal_responder = tokio::spawn(async move { while let Some(command) = heal_rx.recv().await { - if let rustfs_common::heal_channel::HealChannelCommand::Start { + if let rustfs_heal_contracts::heal_channel::HealChannelCommand::Start { request, response_tx, .. } = command { diff --git a/crates/scanner/src/scanner_io.rs b/crates/scanner/src/scanner_io.rs index 584dec769..8753b2139 100644 --- a/crates/scanner/src/scanner_io.rs +++ b/crates/scanner/src/scanner_io.rs @@ -24,13 +24,15 @@ use crate::{ use futures::future::join_all; use metrics::counter; use rand::seq::SliceRandom as _; -use rustfs_common::heal_channel::HealScanMode; -use rustfs_common::metrics::{Metric, Metrics, emit_scan_bucket_drive_complete, emit_scan_bucket_drive_partial, global_metrics}; #[cfg(test)] use rustfs_config::{ENV_SCANNER_MAX_CONCURRENT_DISK_SCANS, ENV_SCANNER_MAX_CONCURRENT_SET_SCANS}; use rustfs_data_usage::{BucketTargetUsageInfo, BucketUsageInfo}; use rustfs_filemeta::FileMeta; +use rustfs_heal_contracts::heal_channel::HealScanMode; use rustfs_lock::{LockError, NamespaceLockGuard}; +use rustfs_scanner_contracts::metrics::{ + Metric, Metrics, emit_scan_bucket_drive_complete, emit_scan_bucket_drive_partial, global_metrics, +}; use rustfs_utils::path::path_join_buf; use s3s::dto::{ BucketLifecycleConfiguration, ObjectLockConfiguration, ObjectLockEnabled, ReplicationConfiguration, VersioningConfiguration, diff --git a/crates/scanner/src/scanner_io/guards.rs b/crates/scanner/src/scanner_io/guards.rs index 3c93f759e..8e638a3c0 100644 --- a/crates/scanner/src/scanner_io/guards.rs +++ b/crates/scanner/src/scanner_io/guards.rs @@ -147,13 +147,13 @@ impl Drop for DiskBucketScanActiveGuard { pub(super) struct BucketDriveFailureGuard { failed: bool, - source: rustfs_common::metrics::ScannerWorkSource, + source: rustfs_scanner_contracts::metrics::ScannerWorkSource, bucket: String, drive: String, } impl BucketDriveFailureGuard { - pub(super) fn new(source: rustfs_common::metrics::ScannerWorkSource, bucket: &str, drive: &str) -> Self { + pub(super) fn new(source: rustfs_scanner_contracts::metrics::ScannerWorkSource, bucket: &str, drive: &str) -> Self { Self { failed: true, source, @@ -285,7 +285,7 @@ pub(super) fn scanner_task_join_error(stage: &str, err: tokio::task::JoinError) #[cfg(test)] mod tests { use super::*; - use rustfs_common::metrics::{ScannerWorkSource, global_metrics}; + use rustfs_scanner_contracts::metrics::{ScannerWorkSource, global_metrics}; #[test] fn bucket_drive_failure_guard_retires_active_scan_on_drop() { diff --git a/crates/scanner/src/scanner_io/io_disk.rs b/crates/scanner/src/scanner_io/io_disk.rs index cf209206e..1034d929e 100644 --- a/crates/scanner/src/scanner_io/io_disk.rs +++ b/crates/scanner/src/scanner_io/io_disk.rs @@ -161,8 +161,8 @@ impl ScannerIODisk for Disk { let bucket = cache.info.name.clone(); let disk_path = self.path().to_string_lossy().to_string(); let source = match scan_mode { - HealScanMode::Deep => rustfs_common::metrics::ScannerWorkSource::Bitrot, - HealScanMode::Normal | HealScanMode::Unknown => rustfs_common::metrics::ScannerWorkSource::Usage, + HealScanMode::Deep => rustfs_scanner_contracts::metrics::ScannerWorkSource::Bitrot, + HealScanMode::Normal | HealScanMode::Unknown => rustfs_scanner_contracts::metrics::ScannerWorkSource::Usage, }; global_metrics().record_scan_bucket_drive_start(source, &bucket, &disk_path); let mut failure_guard = BucketDriveFailureGuard::new(source, &bucket, &disk_path); diff --git a/crates/scanner/src/sleeper.rs b/crates/scanner/src/sleeper.rs index de46d53ef..1a554dcdd 100644 --- a/crates/scanner/src/sleeper.rs +++ b/crates/scanner/src/sleeper.rs @@ -16,11 +16,11 @@ use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::{Arc, LazyLock, RwLock}; use std::time::Instant; -use rustfs_common::metrics::global_metrics; use rustfs_config::{ DEFAULT_SCANNER_IDLE_MODE, DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS, ENV_SCANNER_IDLE_MODE, ENV_SCANNER_SPEED, ENV_SCANNER_YIELD_EVERY_N_OBJECTS, ScannerSpeed, }; +use rustfs_scanner_contracts::metrics::global_metrics; use tokio::time::Duration; const MIN_SLEEP: Duration = Duration::from_millis(1); diff --git a/crates/storage-api/src/error.rs b/crates/storage-api/src/error.rs index 49844bf5a..85873ae8b 100644 --- a/crates/storage-api/src/error.rs +++ b/crates/storage-api/src/error.rs @@ -105,6 +105,7 @@ pub enum StorageErrorCode { InvalidPath, QuotaExceeded, RemoteClientUnavailable, + RemoteNotInitialized, } impl StorageErrorCode { @@ -192,6 +193,7 @@ impl StorageErrorCode { Self::InvalidPath => 0x52, Self::QuotaExceeded => 0x53, Self::RemoteClientUnavailable => 0x54, + Self::RemoteNotInitialized => 0x55, } } @@ -279,6 +281,7 @@ impl StorageErrorCode { 0x52 => Some(Self::InvalidPath), 0x53 => Some(Self::QuotaExceeded), 0x54 => Some(Self::RemoteClientUnavailable), + 0x55 => Some(Self::RemoteNotInitialized), _ => None, } } @@ -355,6 +358,7 @@ mod tests { (StorageErrorCode::NamespaceLockQuorumUnavailable, 0x42), (StorageErrorCode::QuotaExceeded, 0x53), (StorageErrorCode::RemoteClientUnavailable, 0x54), + (StorageErrorCode::RemoteNotInitialized, 0x55), ]; const DISK_PRESERVATION_ERROR_CODES: &[(StorageErrorCode, u32)] = &[ diff --git a/crates/targets/Cargo.toml b/crates/targets/Cargo.toml index 29b179e12..81f132b78 100644 --- a/crates/targets/Cargo.toml +++ b/crates/targets/Cargo.toml @@ -13,6 +13,9 @@ documentation = "https://docs.rs/rustfs-targets/latest/rustfs_targets/" [features] default = [] +# Exposes the builder-style `testkit::MockTarget` to downstream test suites. Never enable this +# from a production `[dependencies]` entry; in-crate unit tests get the module via cfg(test). +test-support = [] hotpath = [ "hotpath/hotpath", "hotpath/tokio", diff --git a/crates/targets/src/lib.rs b/crates/targets/src/lib.rs index 7b9c77ce7..02b3bb945 100644 --- a/crates/targets/src/lib.rs +++ b/crates/targets/src/lib.rs @@ -26,6 +26,8 @@ pub mod runtime; pub mod store; pub mod sys; pub mod target; +#[cfg(any(test, feature = "test-support"))] +pub mod testkit; pub use catalog::extension::{ OPS_DIAGNOSTICS_EXTENSION_API_VERSION, OPS_PROFILER_EXTENSION_API_VERSION, S3_HOOK_EXTENSION_API_VERSION, diff --git a/crates/targets/src/plugin.rs b/crates/targets/src/plugin.rs index 43e5ba8ad..8973302be 100644 --- a/crates/targets/src/plugin.rs +++ b/crates/targets/src/plugin.rs @@ -421,61 +421,15 @@ where #[cfg(test)] mod tests { use super::{TargetPluginDescriptor, TargetPluginRegistry}; - use crate::PluginEvent; + use crate::TargetError; use crate::runtime::adapter::BuiltinPluginRuntimeAdapter; - use crate::store::{Key, Store}; - use crate::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta}; - use crate::{StoreError, Target, TargetError}; - use async_trait::async_trait; + use crate::testkit::MockTarget; use rustfs_config::ENABLE_KEY; use rustfs_config::server_config::{Config, KVS}; use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; - #[derive(Clone)] - struct TestTarget { - id: crate::arn::TargetID, - } - - #[async_trait] - impl Target for TestTarget - where - E: PluginEvent, - { - fn id(&self) -> crate::arn::TargetID { - self.id.clone() - } - - async fn is_active(&self) -> Result { - Ok(true) - } - - async fn save(&self, _event: Arc>) -> Result<(), TargetError> { - Ok(()) - } - - async fn send_raw_from_store(&self, _key: Key, _body: Vec, _meta: QueuedPayloadMeta) -> Result<(), TargetError> { - Ok(()) - } - - async fn close(&self) -> Result<(), TargetError> { - Ok(()) - } - - fn store(&self) -> Option<&(dyn Store + Send + Sync)> { - None - } - - fn clone_dyn(&self) -> Box + Send + Sync> { - Box::new(self.clone()) - } - - fn is_enabled(&self) -> bool { - true - } - } - fn builtin_adapter() -> BuiltinPluginRuntimeAdapter { BuiltinPluginRuntimeAdapter::new( Arc::new(|_event| Box::pin(async {})), @@ -494,11 +448,7 @@ mod tests { "test", &[ENABLE_KEY, "endpoint"], |_config| Ok(()), - |id, _config| { - Ok(Box::new(TestTarget { - id: crate::arn::TargetID::new(id, "test".to_string()), - })) - }, + |id, _config| Ok(Box::new(MockTarget::new(&id, "test"))), )); let mut cfg = Config(HashMap::new()); @@ -532,11 +482,7 @@ mod tests { target_type, &[ENABLE_KEY, "endpoint"], |_config| Ok(()), - move |id, _config| { - Ok(Box::new(TestTarget { - id: crate::arn::TargetID::new(id, target_type.to_string()), - })) - }, + move |id, _config| Ok(Box::new(MockTarget::new(&id, target_type))), )); } diff --git a/crates/targets/src/runtime/adapter.rs b/crates/targets/src/runtime/adapter.rs index 87c2a23ff..6ebe996b2 100644 --- a/crates/targets/src/runtime/adapter.rs +++ b/crates/targets/src/runtime/adapter.rs @@ -441,13 +441,10 @@ where #[cfg(test)] mod tests { use super::{BuiltinPluginRuntimeAdapter, MAX_PARALLEL_STORE_OPENS, PluginRuntimeAdapter}; - use crate::PluginEvent; - use crate::arn::TargetID; use crate::store::{Key, QueueStore, Store}; - use crate::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta}; - use crate::{StoreError, Target, TargetError}; - use async_trait::async_trait; - use std::sync::atomic::{AtomicUsize, Ordering}; + use crate::target::QueuedPayload; + use crate::testkit::MockTarget; + use crate::{StoreError, Target}; use std::sync::{Arc, Condvar, Mutex}; use std::time::Duration; use tempfile::tempdir; @@ -569,96 +566,14 @@ mod tests { } } - #[derive(Clone)] - struct TestTarget { - close_calls: Arc, - id: TargetID, - init_calls: Arc, - init_entered: Option>, - init_fails: bool, - store: Option>, - } - - impl TestTarget { - fn new(id: &str, name: &str) -> Self { - Self { - close_calls: Arc::new(AtomicUsize::new(0)), - id: TargetID::new(id.to_string(), name.to_string()), - init_calls: Arc::new(AtomicUsize::new(0)), - init_entered: None, - init_fails: false, - store: None, - } - } - - fn with_failed_init(mut self) -> Self { - self.init_fails = true; - self - } - - fn with_pending_init(mut self, init_entered: Arc) -> Self { - self.init_entered = Some(init_entered); - self - } - - fn with_store(mut self) -> Self { - let dir = tempdir().expect("tempdir should be created for queue store tests"); - let store = QueueStore::::new(dir.path(), 16, ".queue"); - store.open().expect("queue store should open"); - self.store = Some(Arc::new(store)); - self - } - } - - #[async_trait] - impl Target for TestTarget - where - E: PluginEvent, - { - fn id(&self) -> TargetID { - self.id.clone() - } - - async fn is_active(&self) -> Result { - Ok(true) - } - - async fn save(&self, _event: Arc>) -> Result<(), TargetError> { - Ok(()) - } - - async fn send_raw_from_store(&self, _key: Key, _body: Vec, _meta: QueuedPayloadMeta) -> Result<(), TargetError> { - Ok(()) - } - - async fn close(&self) -> Result<(), TargetError> { - self.close_calls.fetch_add(1, Ordering::SeqCst); - Ok(()) - } - - fn store(&self) -> Option<&(dyn Store + Send + Sync)> { - self.store.as_deref() - } - - fn clone_dyn(&self) -> Box + Send + Sync> { - Box::new(self.clone()) - } - - async fn init(&self) -> Result<(), TargetError> { - self.init_calls.fetch_add(1, Ordering::SeqCst); - if let Some(init_entered) = &self.init_entered { - init_entered.notify_one(); - return std::future::pending().await; - } - if self.init_fails { - return Err(TargetError::Configuration("forced init failure".to_string())); - } - Ok(()) - } - - fn is_enabled(&self) -> bool { - true - } + /// Builds the tempdir-backed, already-opened queue store the store-backed mock targets use. + /// The tempdir handle is dropped here on purpose, matching the previous in-module mock: these + /// tests never write through the store, they only need an openable handle. + fn opened_queue_store() -> Arc { + let dir = tempdir().expect("tempdir should be created for queue store tests"); + let store = QueueStore::::new(dir.path(), 16, ".queue"); + store.open().expect("queue store should open"); + Arc::new(store) } fn builtin_adapter() -> BuiltinPluginRuntimeAdapter { @@ -684,7 +599,7 @@ mod tests { #[tokio::test] async fn builtin_adapter_skips_non_store_target_when_init_fails() { let adapter = builtin_adapter(); - let target = TestTarget::new("primary", "webhook").with_failed_init(); + let target = MockTarget::new("primary", "webhook").with_init_failures(usize::MAX); let activation = adapter.activate_with_replay(vec![Box::new(target)]).await; @@ -695,7 +610,9 @@ mod tests { #[tokio::test] async fn builtin_adapter_keeps_store_backed_target_when_init_fails() { let adapter = builtin_adapter(); - let target = TestTarget::new("primary", "webhook").with_failed_init().with_store(); + let target = MockTarget::new("primary", "webhook") + .with_init_failures(usize::MAX) + .with_store(opened_queue_store()); let activation = adapter.activate_with_replay(vec![Box::new(target)]).await; @@ -706,7 +623,9 @@ mod tests { #[tokio::test] async fn prepared_store_target_reports_init_failure_without_dropping_queue_runtime() { let adapter = builtin_adapter(); - let target = TestTarget::new("primary", "webhook").with_failed_init().with_store(); + let target = MockTarget::new("primary", "webhook") + .with_init_failures(usize::MAX) + .with_store(opened_queue_store()); let prepared = adapter.prepare_targets(vec![Box::new(target)]).await; assert_eq!(prepared.targets.len(), 1); @@ -729,11 +648,10 @@ mod tests { async fn cancellable_preparation_returns_current_and_remaining_targets_for_shutdown() { let adapter = builtin_adapter(); let init_entered = Arc::new(Notify::new()); - let first = TestTarget::new("first", "webhook").with_pending_init(init_entered.clone()); - let first_close_calls = first.close_calls.clone(); - let second = TestTarget::new("second", "webhook"); - let second_close_calls = second.close_calls.clone(); - let second_init_calls = second.init_calls.clone(); + let first = MockTarget::new("first", "webhook").with_blocking_init(init_entered.clone()); + let first_observer = first.clone(); + let second = MockTarget::new("second", "webhook"); + let second_observer = second.clone(); let cancellation = CancellationToken::new(); let prepare_adapter = adapter.clone(); let prepare_cancellation = cancellation.clone(); @@ -755,9 +673,9 @@ mod tests { .close_prepared(prepared) .await .expect("cancelled targets should close"); - assert_eq!(first_close_calls.load(Ordering::SeqCst), 1); - assert_eq!(second_close_calls.load(Ordering::SeqCst), 1); - assert_eq!(second_init_calls.load(Ordering::SeqCst), 0); + assert_eq!(first_observer.close_call_count(), 1); + assert_eq!(second_observer.close_call_count(), 1); + assert_eq!(second_observer.init_call_count(), 0); } #[tokio::test] @@ -765,8 +683,11 @@ mod tests { let adapter = builtin_adapter(); let dir = tempdir().expect("tempdir should be created"); let queue_path = dir.path().join("queue"); - let mut target = TestTarget::new("primary", "webhook"); - target.store = Some(Arc::new(QueueStore::::new(&queue_path, 16, ".queue"))); + let target = MockTarget::new("primary", "webhook").with_store(Arc::new(QueueStore::::new( + &queue_path, + 16, + ".queue", + ))); let prepared = adapter.prepare_targets(vec![Box::new(target)]).await; assert!(!queue_path.exists(), "dormant preparation must not open the queue store"); @@ -789,15 +710,18 @@ mod tests { let dir = tempdir().expect("tempdir should be created"); let invalid_base = dir.path().join("not-a-directory"); std::fs::write(&invalid_base, b"file").expect("invalid queue base should be created"); - let mut target = TestTarget::new("primary", "webhook"); - let close_calls = target.close_calls.clone(); - target.store = Some(Arc::new(QueueStore::::new(&invalid_base, 16, ".queue"))); + let target = MockTarget::new("primary", "webhook").with_store(Arc::new(QueueStore::::new( + &invalid_base, + 16, + ".queue", + ))); + let observer = target.clone(); let activation = adapter.activate_with_replay(vec![Box::new(target)]).await; assert!(activation.targets.is_empty()); assert!(activation.replay_workers.is_empty()); - assert_eq!(close_calls.load(Ordering::SeqCst), 1); + assert_eq!(observer.close_call_count(), 1); } #[tokio::test] @@ -810,14 +734,13 @@ mod tests { let mut targets: Vec + Send + Sync>> = Vec::with_capacity(TARGETS); let mut expected_ids = Vec::with_capacity(TARGETS); for index in 0..TARGETS { - let mut target = TestTarget::new(&format!("target-{index}"), "webhook"); - expected_ids.push(target.id.to_string()); let open_gate = gate.clone(); - target.store = Some(Arc::new(TestOpenStore { + let target = MockTarget::new(&format!("target-{index}"), "webhook").with_store(Arc::new(TestOpenStore { before_clone: Arc::new(|| {}), before_open: Arc::new(move || open_gate.enter()), store: QueueStore::new(dir.path().join(index.to_string()), 16, ".queue"), })); + expected_ids.push(target.target_id().to_string()); targets.push(Box::new(target)); } @@ -848,13 +771,12 @@ mod tests { async fn panicking_store_open_rejects_and_closes_only_that_target() { let adapter = builtin_adapter(); let dir = tempdir().expect("tempdir should be created"); - let mut target = TestTarget::new("panicking", "webhook"); - let close_calls = target.close_calls.clone(); - target.store = Some(Arc::new(TestOpenStore { + let target = MockTarget::new("panicking", "webhook").with_store(Arc::new(TestOpenStore { before_clone: Arc::new(|| {}), before_open: Arc::new(|| panic!("forced store open panic: do-not-expose-payload")), store: QueueStore::new(dir.path(), 16, ".queue"), })); + let observer = target.clone(); let prepared = adapter.prepare_targets(vec![Box::new(target)]).await; let (opened, rejected) = adapter.open_prepared_stores(prepared); @@ -875,20 +797,19 @@ mod tests { .close_prepared(rejected) .await .expect("a target rejected after a store panic should close"); - assert_eq!(close_calls.load(Ordering::SeqCst), 1); + assert_eq!(observer.close_call_count(), 1); } #[tokio::test] async fn panicking_store_clone_cannot_publish_target_without_replay_worker() { let adapter = builtin_adapter(); let dir = tempdir().expect("tempdir should be created"); - let mut target = TestTarget::new("panicking-clone", "webhook"); - let close_calls = target.close_calls.clone(); - target.store = Some(Arc::new(TestOpenStore { + let target = MockTarget::new("panicking-clone", "webhook").with_store(Arc::new(TestOpenStore { before_clone: Arc::new(|| panic!("forced store clone panic: do-not-expose-payload")), before_open: Arc::new(|| {}), store: QueueStore::new(dir.path(), 16, ".queue"), })); + let observer = target.clone(); let prepared = adapter.prepare_targets(vec![Box::new(target)]).await; let (opened, open_rejected) = adapter.open_prepared_stores(prepared); @@ -906,14 +827,14 @@ mod tests { .close_prepared(rejected) .await .expect("a target rejected during replay activation should close"); - assert_eq!(close_calls.load(Ordering::SeqCst), 1); + assert_eq!(observer.close_call_count(), 1); } #[tokio::test] async fn builtin_adapter_shutdown_clears_runtime_and_replay_workers() { let adapter = builtin_adapter(); - let target = TestTarget::new("primary", "webhook"); - let close_calls = Arc::clone(&target.close_calls); + let target = MockTarget::new("primary", "webhook"); + let observer = target.clone(); let mut runtime = crate::runtime::TargetRuntimeManager::new(); let mut replay_workers = crate::runtime::ReplayWorkerManager::new(); @@ -933,6 +854,6 @@ mod tests { assert!(runtime.is_empty()); assert!(replay_workers.is_empty()); - assert_eq!(close_calls.load(Ordering::SeqCst), 1); + assert_eq!(observer.close_call_count(), 1); } } diff --git a/crates/targets/src/runtime/mod.rs b/crates/targets/src/runtime/mod.rs index 7999a33ce..d6451df94 100644 --- a/crates/targets/src/runtime/mod.rs +++ b/crates/targets/src/runtime/mod.rs @@ -965,17 +965,12 @@ where #[cfg(test)] mod tests { use super::{HEALTH_PROBE_CONCURRENCY, TargetRuntimeManager, health_snapshots_for_targets}; - use crate::PluginEvent; - use crate::StoreError; - use crate::arn::TargetID; - use crate::store::{Key, Store}; - use crate::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta}; - use crate::{SharedTarget, Target, TargetError}; - use async_trait::async_trait; + use crate::SharedTarget; + use crate::store::Key; + use crate::testkit::MockTarget; use std::sync::Arc; - use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::atomic::Ordering; use std::time::Duration; - use tokio::sync::{Notify, Semaphore}; #[tokio::test(start_paused = true)] async fn seed_interval_start_backdates_by_one_interval() { @@ -1037,108 +1032,11 @@ mod tests { ); } - #[derive(Clone)] - struct TestTarget { - id: TargetID, - block_on_close: Arc, - close_gate: Arc, - close_calls: Arc, - enabled: bool, - health_delay: Duration, - health_drops: Arc, - health_started: Arc, - close_started: Arc, - } - - struct HealthDropGuard(Arc); - - impl Drop for HealthDropGuard { - fn drop(&mut self) { - self.0.fetch_add(1, Ordering::SeqCst); - } - } - - impl TestTarget { - fn new(id: &str, name: &str) -> Self { - Self { - id: TargetID::new(id.to_string(), name.to_string()), - block_on_close: Arc::new(AtomicBool::new(false)), - close_gate: Arc::new(Semaphore::new(0)), - close_calls: Arc::new(AtomicUsize::new(0)), - enabled: true, - health_delay: Duration::ZERO, - health_drops: Arc::new(AtomicUsize::new(0)), - health_started: Arc::new(Notify::new()), - close_started: Arc::new(Notify::new()), - } - } - - fn with_health_delay(id: &str, delay: Duration) -> Self { - Self { - health_delay: delay, - ..Self::new(id, "webhook") - } - } - - fn disabled(id: &str) -> Self { - Self { - enabled: false, - ..Self::new(id, "webhook") - } - } - } - - #[async_trait] - impl Target for TestTarget - where - E: PluginEvent, - { - fn id(&self) -> TargetID { - self.id.clone() - } - - async fn is_active(&self) -> Result { - self.health_started.notify_one(); - let _drop_guard = HealthDropGuard(Arc::clone(&self.health_drops)); - tokio::time::sleep(self.health_delay).await; - Ok(true) - } - - async fn save(&self, _event: Arc>) -> Result<(), TargetError> { - Ok(()) - } - - async fn send_raw_from_store(&self, _key: Key, _body: Vec, _meta: QueuedPayloadMeta) -> Result<(), TargetError> { - Ok(()) - } - - async fn close(&self) -> Result<(), TargetError> { - self.close_calls.fetch_add(1, Ordering::SeqCst); - self.close_started.notify_one(); - if self.block_on_close.load(Ordering::SeqCst) { - let _permit = self.close_gate.acquire().await.expect("close gate should remain open"); - } - Ok(()) - } - - fn store(&self) -> Option<&(dyn Store + Send + Sync)> { - None - } - - fn clone_dyn(&self) -> Box + Send + Sync> { - Box::new(self.clone()) - } - - fn is_enabled(&self) -> bool { - self.enabled - } - } - #[tokio::test] async fn runtime_manager_removes_and_closes_target() { let mut manager = TargetRuntimeManager::::new(); - let target = TestTarget::new("primary", "webhook"); - let close_calls = Arc::clone(&target.close_calls); + let target = MockTarget::new("primary", "webhook"); + let observer = target.clone(); manager.add_boxed(Box::new(target)); assert_eq!(manager.len(), 1); @@ -1146,14 +1044,14 @@ mod tests { let removed = manager.remove_and_close("primary:webhook").await; assert!(removed.is_some()); assert_eq!(manager.len(), 0); - assert_eq!(close_calls.load(Ordering::SeqCst), 1); + assert_eq!(observer.close_call_count(), 1); } #[tokio::test(start_paused = true)] async fn runtime_manager_starts_all_target_closes_before_waiting_for_completion() { let mut manager = TargetRuntimeManager::::new(); - let first = TestTarget::new("first", "webhook"); - let second = TestTarget::new("second", "webhook"); + let first = MockTarget::new("first", "webhook"); + let second = MockTarget::new("second", "webhook"); let first_observer = first.clone(); let second_observer = second.clone(); manager.add_boxed(Box::new(first)); @@ -1164,34 +1062,34 @@ mod tests { .into_iter() .next() .expect("two targets should have a first close key"); - let (blocked, unblocked) = if first_close_key == first_observer.id.to_string() { + let (blocked, unblocked) = if first_close_key == first_observer.target_id().to_string() { (first_observer, second_observer) } else { (second_observer, first_observer) }; - blocked.block_on_close.store(true, Ordering::SeqCst); + blocked.set_block_on_close(true); let close_task = tokio::spawn(async move { manager.clear_and_close().await }); - tokio::time::timeout(std::time::Duration::from_secs(1), blocked.close_started.notified()) + tokio::time::timeout(std::time::Duration::from_secs(1), blocked.close_started().notified()) .await .expect("the first target close should start"); - tokio::time::timeout(std::time::Duration::from_secs(1), unblocked.close_started.notified()) + tokio::time::timeout(std::time::Duration::from_secs(1), unblocked.close_started().notified()) .await .expect("a blocked first close must not prevent the second close from starting"); assert!(!close_task.is_finished(), "clear_and_close must still await the blocked target"); - blocked.close_gate.add_permits(1); + blocked.close_gate().add_permits(1); let errors = close_task.await.expect("clear_and_close task should join"); assert!(errors.is_empty()); - assert_eq!(blocked.close_calls.load(Ordering::SeqCst), 1); - assert_eq!(unblocked.close_calls.load(Ordering::SeqCst), 1); + assert_eq!(blocked.close_call_count(), 1); + assert_eq!(unblocked.close_call_count(), 1); } #[test] fn runtime_manager_snapshots_targets() { let mut manager = TargetRuntimeManager::::new(); - manager.add_boxed(Box::new(TestTarget::new("primary", "webhook"))); + manager.add_boxed(Box::new(MockTarget::new("primary", "webhook"))); let snapshots = manager.snapshots(); assert_eq!(snapshots.len(), 1); @@ -1202,7 +1100,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn health_snapshot_allows_a_four_second_probe() { let mut manager = TargetRuntimeManager::::new(); - manager.add_boxed(Box::new(TestTarget::with_health_delay("slow", Duration::from_secs(4)))); + manager.add_boxed(Box::new(MockTarget::new("slow", "webhook").with_health_delay(Duration::from_secs(4)))); let snapshots = manager.health_snapshots().await; @@ -1214,7 +1112,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn health_snapshot_times_out_after_five_seconds() { let mut manager = TargetRuntimeManager::::new(); - manager.add_boxed(Box::new(TestTarget::with_health_delay("stalled", Duration::from_secs(6)))); + manager.add_boxed(Box::new(MockTarget::new("stalled", "webhook").with_health_delay(Duration::from_secs(6)))); let snapshots = manager.health_snapshots().await; @@ -1227,10 +1125,9 @@ mod tests { async fn health_collection_deadline_does_not_scale_with_target_count() { let mut manager = TargetRuntimeManager::::new(); for index in 0..24 { - manager.add_boxed(Box::new(TestTarget::with_health_delay( - &format!("stalled-{index}"), - Duration::from_secs(30), - ))); + manager.add_boxed(Box::new( + MockTarget::new(&format!("stalled-{index}"), "webhook").with_health_delay(Duration::from_secs(30)), + )); } let started = tokio::time::Instant::now(); @@ -1263,11 +1160,11 @@ mod tests { async fn disabled_target_does_not_wait_for_probe_capacity() { let mut targets: Vec> = (0..HEALTH_PROBE_CONCURRENCY) .map(|index| { - Arc::new(TestTarget::with_health_delay(&format!("stalled-{index}"), Duration::from_secs(30))) + Arc::new(MockTarget::new(&format!("stalled-{index}"), "webhook").with_health_delay(Duration::from_secs(30))) as SharedTarget }) .collect(); - targets.push(Arc::new(TestTarget::disabled("disabled"))); + targets.push(Arc::new(MockTarget::new("disabled", "webhook").disabled())); let snapshots = health_snapshots_for_targets(targets).await; let disabled = snapshots @@ -1281,20 +1178,19 @@ mod tests { #[tokio::test] async fn cancelling_health_collection_drops_in_flight_probe() { - let target = TestTarget::with_health_delay("slow", Duration::from_secs(30)); - let health_drops = Arc::clone(&target.health_drops); - let health_started = Arc::clone(&target.health_started); + let target = MockTarget::new("slow", "webhook").with_health_delay(Duration::from_secs(30)); + let observer = target.clone(); let targets: Vec> = vec![Arc::new(target)]; let collector = tokio::spawn(health_snapshots_for_targets(targets)); - tokio::time::timeout(Duration::from_secs(1), health_started.notified()) + tokio::time::timeout(Duration::from_secs(1), observer.health_started().notified()) .await .expect("health probe should start"); collector.abort(); let join_error = collector.await.expect_err("health collector should be cancelled"); assert!(join_error.is_cancelled()); - assert_eq!(health_drops.load(Ordering::SeqCst), 1); + assert_eq!(observer.health_drop_count(), 1); } #[tokio::test] diff --git a/crates/targets/src/target/mod.rs b/crates/targets/src/target/mod.rs index def914ce6..1d6f48154 100644 --- a/crates/targets/src/target/mod.rs +++ b/crates/targets/src/target/mod.rs @@ -960,76 +960,29 @@ pub(crate) fn ensure_rustls_provider_installed() { #[cfg(test)] pub(crate) mod test_support { - use super::{EntityTarget, QueuedPayload, QueuedPayloadMeta}; - use crate::arn::TargetID; - use crate::store::{FailedEventStore, Key, QueueStore, Store}; - use crate::{StoreError, Target, TargetError}; - use async_trait::async_trait; + use super::{QueuedPayload, QueuedPayloadMeta}; + use crate::Target; + use crate::store::QueueStore; + use crate::testkit::MockTarget; use rustfs_s3_types::EventName; use std::path::PathBuf; use std::sync::Arc; - use std::sync::atomic::{AtomicU64, Ordering}; use uuid::Uuid; - /// A minimal target for failed-store move tests: every delivery method succeeds, the optional - /// store backs the store and failed-store accessors, and final failures land on a shared counter. - #[derive(Clone)] - pub(crate) struct MoveTestTarget { - pub(crate) id: TargetID, - pub(crate) store: Option>>, - pub(crate) failed: Arc, - } - - #[async_trait] - impl Target for MoveTestTarget { - fn id(&self) -> TargetID { - self.id.clone() - } - async fn is_active(&self) -> Result { - Ok(true) - } - async fn save(&self, _event: Arc>) -> Result<(), TargetError> { - Ok(()) - } - async fn send_raw_from_store(&self, _key: Key, _body: Vec, _meta: QueuedPayloadMeta) -> Result<(), TargetError> { - Ok(()) - } - async fn close(&self) -> Result<(), TargetError> { - Ok(()) - } - fn store(&self) -> Option<&(dyn Store + Send + Sync)> { - self.store - .as_deref() - .map(|store| store as &(dyn Store<_, Error = StoreError, Key = Key> + Send + Sync)) - } - fn failed_store(&self) -> Option<&dyn FailedEventStore> { - self.store.as_deref().map(|store| store as &dyn FailedEventStore) - } - fn clone_dyn(&self) -> Box + Send + Sync> { - Box::new(self.clone()) - } - fn is_enabled(&self) -> bool { - true - } - fn record_final_failure(&self) { - self.failed.fetch_add(1, Ordering::Relaxed); - } - } - + /// A minimal target for failed-store move tests: every delivery method succeeds, no store is + /// attached, and final failures land on the mock's shared counter. pub(crate) fn move_test_target() -> Arc + Send + Sync> { - Arc::new(MoveTestTarget { - id: TargetID::new("target-a".to_string(), "nats".to_string()), - store: None, - failed: Arc::new(AtomicU64::new(0)), - }) + Arc::new(MockTarget::new("target-a", "nats")) } + /// Like [`move_test_target`], but the given store backs both the store and failed-store + /// accessors, matching a target whose live queue also parks terminal failures. pub(crate) fn move_test_target_with_store(store: Arc>) -> Arc + Send + Sync> { - Arc::new(MoveTestTarget { - id: TargetID::new("target-a".to_string(), "nats".to_string()), - store: Some(store), - failed: Arc::new(AtomicU64::new(0)), - }) + Arc::new( + MockTarget::new("target-a", "nats") + .with_store(store.clone()) + .with_failed_store(store), + ) } pub(crate) fn failed_store_dir(name: &str) -> PathBuf { @@ -1472,47 +1425,6 @@ mod tests { assert!(dir.starts_with("rustfs-redis-tenant_alpha-"), "unexpected subdir: {dir}"); } - #[derive(Clone)] - struct StoreBackedTarget { - id: TargetID, - store: QueueStore, - } - - #[async_trait] - impl Target for StoreBackedTarget { - fn id(&self) -> TargetID { - self.id.clone() - } - - async fn is_active(&self) -> Result { - Ok(true) - } - - async fn save(&self, _event: Arc>) -> Result<(), TargetError> { - Ok(()) - } - - async fn send_raw_from_store(&self, _key: Key, _body: Vec, _meta: QueuedPayloadMeta) -> Result<(), TargetError> { - Ok(()) - } - - async fn close(&self) -> Result<(), TargetError> { - Ok(()) - } - - fn store(&self) -> Option<&(dyn Store + Send + Sync)> { - Some(&self.store) - } - - fn clone_dyn(&self) -> Box + Send + Sync> { - Box::new(self.clone()) - } - - fn is_enabled(&self) -> bool { - true - } - } - #[tokio::test] async fn send_from_store_purges_missing_or_empty_entry() { let dir = std::env::temp_dir().join(format!("rustfs-send-from-store-{}", Uuid::new_v4())); @@ -1541,10 +1453,10 @@ mod tests { .expect("event file should exist"); std::fs::write(&event_file, b"").unwrap(); - let target = StoreBackedTarget { - id: TargetID::new("primary".to_string(), "webhook".to_string()), - store: store.clone(), - }; + // The default send_from_store implementation is under test here, so the mock must not + // override it; it only supplies the backing store. + let target: Box + Send + Sync> = + Box::new(crate::testkit::MockTarget::new("primary", "webhook").with_store(Arc::new(store.clone()))); // A NotFound/empty entry must be purged (index + file) rather than // silently skipped and replayed forever. diff --git a/crates/targets/src/target/nats/jetstream.rs b/crates/targets/src/target/nats/jetstream.rs index 663525e9a..eeb68af82 100644 --- a/crates/targets/src/target/nats/jetstream.rs +++ b/crates/targets/src/target/nats/jetstream.rs @@ -333,18 +333,15 @@ pub(crate) fn retry_lifetime(ack_timeout: Duration) -> Duration { mod tests { use super::*; use crate::Target; - use crate::arn::TargetID; use crate::store::{FailedEventStore, QueueStore}; use crate::target::TargetType; use crate::target::nats::test_support::*; use crate::target::nats::validation::STREAM_VALIDATION_FAILED_DETAIL; - use crate::target::test_support::{ - MoveTestTarget, failed_store_dir, move_test_target, move_test_target_with_store, sample_queued, - }; + use crate::target::test_support::{failed_store_dir, move_test_target, move_test_target_with_store, sample_queued}; use crate::target::{build_target_tls_fingerprint, persist_queued_payload_to_store}; + use crate::testkit::MockTarget; use async_nats::jetstream::context::PublishError; use rustfs_config::NATS_JETSTREAM_ACK_TIMEOUT_DEFAULT_SECS; - use std::sync::atomic::AtomicU64; use uuid::Uuid; #[test] @@ -1210,12 +1207,11 @@ mod tests { let store = Arc::new(QueueStore::::new_with_compression(&dir, 8, ".test", false)); store.open().unwrap(); - let failed = Arc::new(AtomicU64::new(0)); - let target: Arc + Send + Sync> = Arc::new(MoveTestTarget { - id: TargetID::new("target-a".to_string(), "nats".to_string()), - store: Some(store.clone()), - failed: failed.clone(), - }); + let mock = MockTarget::new("target-a", "nats") + .with_store(store.clone()) + .with_failed_store(store.clone()); + let observer = mock.clone(); + let target: Arc + Send + Sync> = Arc::new(mock); let key = store.put_raw(&sample_queued("minted-id").encode().unwrap()).unwrap(); let error = TargetError::JetStreamPublish { @@ -1225,11 +1221,11 @@ mod tests { move_entry_to_failed_store(&*store, &*store, &target.id(), &key, &error, 0) .await .unwrap(); - assert_eq!(failed.load(Ordering::Relaxed), 0, "the move itself does not count the failure"); + assert_eq!(observer.final_failure_count(), 0, "the move itself does not count the failure"); // The replay worker follows every move with one hook emit that records the failure. target.record_final_failure(); - assert_eq!(failed.load(Ordering::Relaxed), 1, "one failed delivery counts exactly once"); + assert_eq!(observer.final_failure_count(), 1, "one failed delivery counts exactly once"); let _ = store.delete(); } diff --git a/crates/targets/src/testkit.rs b/crates/targets/src/testkit.rs new file mode 100644 index 000000000..c23eacad2 --- /dev/null +++ b/crates/targets/src/testkit.rs @@ -0,0 +1,600 @@ +// 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. + +//! Builder-style [`Target`] mock shared by this crate's unit tests and, behind the off-by-default +//! `test-support` cargo feature, by downstream test suites. +//! +//! The module is compiled only under `cfg(test)` or when a dependent explicitly opts in via the +//! `test-support` feature, so the mock can never reach a production binary. Every knob defaults +//! off: a plain [`MockTarget::new`] is an enabled, reachable, storeless target whose delivery +//! methods all succeed immediately. The mock emits no tracing events. + +use crate::arn::TargetID; +use crate::plugin::PluginEvent; +use crate::store::{FailedEventStore, Key, Store}; +use crate::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliverySnapshot}; +use crate::{StoreError, Target, TargetError}; +use async_trait::async_trait; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::time::Duration; +use tokio::sync::{Notify, Semaphore}; + +/// The queued-payload store handle a [`MockTarget`] serves from its [`Target::store`] accessor. +pub type SharedQueuedStore = Arc + Send + Sync>; + +/// Builds the error a failing mock operation returns, so a test that pins an error variant can +/// shape the failure instead of matching the mock's default. +pub type ErrorFactory = Arc TargetError + Send + Sync>; + +/// Increments its counter when dropped, however the owning future ends, so a test that holds the +/// probe open past its caller's deadline can prove the cancelled health future was actually +/// dropped instead of left running. +struct HealthDropGuard(Arc); + +impl Drop for HealthDropGuard { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + +/// Consumes one unit of a failure budget, returning true while the budget is not exhausted. +/// A budget of `usize::MAX` behaves as "always fail" for any realistic call count. +fn consume_failure_budget(budget: &AtomicUsize) -> bool { + budget + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| remaining.checked_sub(1)) + .is_ok() +} + +/// A configurable mock implementation of [`Target`]. +/// +/// All observable state (call counters, gates, signals) lives behind shared handles, so a clone +/// kept aside keeps observing the original after it is boxed into a runtime, and +/// [`Target::clone_dyn`] clones observe the same counters. Builder methods consume `self` and +/// each knob defaults off; accessors read the shared state from any clone. +#[derive(Clone)] +pub struct MockTarget { + id: TargetID, + enabled: bool, + /// Overrides the [`Target::is_active`] result; defaults to the enabled flag. + active: Option, + health_delay: Duration, + health_started: Arc, + /// When set, `is_active` waits on this handle (after notifying `health_started`) before + /// answering, so a test can hold a probe in flight and release it on demand. + health_gate: Option>, + health_drops: Arc, + enabled_calls: Arc, + init_calls: Arc, + init_failures_remaining: Arc, + /// When set, `init` notifies the handle on entry and then never returns. + blocking_init: Option>, + close_calls: Arc, + close_started: Arc, + block_on_close: Arc, + close_gate: Arc, + close_failures_remaining: Arc, + close_failure_error: Option, + save_calls: Arc, + save_failures_remaining: Arc, + /// When set, the first `save` (counted across all clones) notifies the first handle and then + /// waits on the second before returning; later saves pass straight through. + first_save_gate: Option<(Arc, Arc)>, + final_failures: Arc, + delivery_snapshot: Option, + store: Option, + failed_store: Option>, +} + +impl MockTarget { + /// Creates an enabled, reachable, storeless mock identified as `:`. + pub fn new(id: &str, name: &str) -> Self { + Self { + id: TargetID::new(id.to_string(), name.to_string()), + enabled: true, + active: None, + health_delay: Duration::ZERO, + health_started: Arc::new(Notify::new()), + health_gate: None, + health_drops: Arc::new(AtomicUsize::new(0)), + enabled_calls: Arc::new(AtomicUsize::new(0)), + init_calls: Arc::new(AtomicUsize::new(0)), + init_failures_remaining: Arc::new(AtomicUsize::new(0)), + blocking_init: None, + close_calls: Arc::new(AtomicUsize::new(0)), + close_started: Arc::new(Notify::new()), + block_on_close: Arc::new(AtomicBool::new(false)), + close_gate: Arc::new(Semaphore::new(0)), + close_failures_remaining: Arc::new(AtomicUsize::new(0)), + close_failure_error: None, + save_calls: Arc::new(AtomicUsize::new(0)), + save_failures_remaining: Arc::new(AtomicUsize::new(0)), + first_save_gate: None, + final_failures: Arc::new(AtomicU64::new(0)), + delivery_snapshot: None, + store: None, + failed_store: None, + } + } + + /// Replaces the mock's identity while keeping every shared counter and gate, so a plugin + /// factory can clone one observed template per constructed instance and bind the instance id + /// the registry hands it. + pub fn with_id(mut self, id: &str, name: &str) -> Self { + self.id = TargetID::new(id.to_string(), name.to_string()); + self + } + + /// Marks the target disabled: `is_enabled` returns false and `health` short-circuits. + pub fn disabled(mut self) -> Self { + self.enabled = false; + self + } + + /// Overrides the `is_active` result independently of the enabled flag. + pub fn with_active(mut self, active: bool) -> Self { + self.active = Some(active); + self + } + + /// Makes `is_active` sleep for `delay` (after notifying [`Self::health_started`]) before + /// answering, so probe timeouts and cancellation can be exercised under a paused clock. + pub fn with_health_delay(mut self, delay: Duration) -> Self { + self.health_delay = delay; + self + } + + /// Makes `is_active` wait on `release` after notifying [`Self::health_started`], so a test + /// can hold a probe in flight and release it on demand. Independent of the health delay. + pub fn with_health_gate(mut self, release: Arc) -> Self { + self.health_gate = Some(release); + self + } + + /// Fails the first `failures` `init` calls with [`TargetError::Initialization`], then + /// succeeds. Pass `usize::MAX` for a target whose init always fails. + pub fn with_init_failures(mut self, failures: usize) -> Self { + self.init_failures_remaining = Arc::new(AtomicUsize::new(failures)); + self + } + + /// Makes `init` notify `entered` and then never return, so cancellation of an in-flight + /// initialization can be exercised. + pub fn with_blocking_init(mut self, entered: Arc) -> Self { + self.blocking_init = Some(entered); + self + } + + /// Fails the first `failures` `save` calls with [`TargetError::Request`], then succeeds. + /// Pass `usize::MAX` for a target whose save always fails. + pub fn with_save_failures(mut self, failures: usize) -> Self { + self.save_failures_remaining = Arc::new(AtomicUsize::new(failures)); + self + } + + /// Gates the first `save` (counted across all clones): it notifies `entered` and then waits on + /// `release` before returning. Later saves pass straight through. Several mocks may share one + /// `entered`/`release` pair to gate their first saves collectively. + pub fn with_first_save_gate(mut self, entered: Arc, release: Arc) -> Self { + self.first_save_gate = Some((entered, release)); + self + } + + /// Fails the first `failures` `close` calls with [`TargetError::Storage`] (or the error shaped + /// by [`Self::with_close_failure_error`]), then succeeds. Pass `usize::MAX` for a target whose + /// close always fails. Counting, [`Self::close_started`], and the close gate still run before + /// the failure fires. + pub fn with_close_failures(mut self, failures: usize) -> Self { + self.close_failures_remaining = Arc::new(AtomicUsize::new(failures)); + self + } + + /// Shapes the error a failing `close` returns, for tests that pin the error variant. + pub fn with_close_failure_error(mut self, factory: impl Fn() -> TargetError + Send + Sync + 'static) -> Self { + self.close_failure_error = Some(Arc::new(factory)); + self + } + + /// Serves a fixed snapshot from `delivery_snapshot()` instead of deriving one from the + /// attached stores. + pub fn with_delivery_snapshot(mut self, snapshot: TargetDeliverySnapshot) -> Self { + self.delivery_snapshot = Some(snapshot); + self + } + + /// Serves `store` from the [`Target::store`] accessor. The caller owns the backing store and + /// its directory lifecycle; the mock only hands out the reference. + pub fn with_store(mut self, store: SharedQueuedStore) -> Self { + self.store = Some(store); + self + } + + /// Serves `failed_store` from the [`Target::failed_store`] accessor. Pass the same underlying + /// store as [`Self::with_store`] to model a target whose live queue also parks terminal + /// failures. + pub fn with_failed_store(mut self, failed_store: Arc) -> Self { + self.failed_store = Some(failed_store); + self + } + + /// Returns the mock's identity without needing an event-type annotation. + pub fn target_id(&self) -> TargetID { + self.id.clone() + } + + /// When `block` is set, `close` waits on [`Self::close_gate`] after counting and signalling, + /// until the gate receives a permit. Takes effect for closes that start after the call. + pub fn set_block_on_close(&self, block: bool) { + self.block_on_close.store(block, Ordering::SeqCst); + } + + /// The semaphore a blocked `close` waits on; add a permit to release it. + pub fn close_gate(&self) -> Arc { + Arc::clone(&self.close_gate) + } + + /// Notified once every time a `close` call starts. + pub fn close_started(&self) -> Arc { + Arc::clone(&self.close_started) + } + + /// Notified once every time an `is_active` probe starts. + pub fn health_started(&self) -> Arc { + Arc::clone(&self.health_started) + } + + /// How many times `init` was called across all clones. + pub fn init_call_count(&self) -> usize { + self.init_calls.load(Ordering::SeqCst) + } + + /// How many times `close` was called across all clones. + pub fn close_call_count(&self) -> usize { + self.close_calls.load(Ordering::SeqCst) + } + + /// How many times `save` was called across all clones. + pub fn save_call_count(&self) -> usize { + self.save_calls.load(Ordering::SeqCst) + } + + /// How many times `is_enabled` was called across all clones, so a test can assert whether a + /// dispatcher consulted (selected) this target at all. + pub fn enabled_call_count(&self) -> usize { + self.enabled_calls.load(Ordering::SeqCst) + } + + /// How many `is_active` futures finished or were dropped mid-flight across all clones. + pub fn health_drop_count(&self) -> usize { + self.health_drops.load(Ordering::SeqCst) + } + + /// How many final delivery failures were recorded across all clones. + pub fn final_failure_count(&self) -> u64 { + self.final_failures.load(Ordering::Relaxed) + } +} + +#[async_trait] +impl Target for MockTarget +where + E: PluginEvent, +{ + fn id(&self) -> TargetID { + self.id.clone() + } + + async fn is_active(&self) -> Result { + self.health_started.notify_one(); + // Held across the gate and delay so an aborted probe future is observable via + // health_drop_count. + let _drop_guard = HealthDropGuard(Arc::clone(&self.health_drops)); + if let Some(release) = &self.health_gate { + release.notified().await; + } + tokio::time::sleep(self.health_delay).await; + Ok(self.active.unwrap_or(self.enabled)) + } + + async fn save(&self, _event: Arc>) -> Result<(), TargetError> { + let call = self.save_calls.fetch_add(1, Ordering::SeqCst); + if call == 0 + && let Some((entered, release)) = &self.first_save_gate + { + entered.notify_one(); + release.notified().await; + } + if consume_failure_budget(&self.save_failures_remaining) { + return Err(TargetError::Request("forced save failure".to_string())); + } + Ok(()) + } + + async fn send_raw_from_store(&self, _key: Key, _body: Vec, _meta: QueuedPayloadMeta) -> Result<(), TargetError> { + Ok(()) + } + + async fn close(&self) -> Result<(), TargetError> { + self.close_calls.fetch_add(1, Ordering::SeqCst); + self.close_started.notify_one(); + if self.block_on_close.load(Ordering::SeqCst) { + let _permit = self.close_gate.acquire().await.expect("close gate should remain open"); + } + if consume_failure_budget(&self.close_failures_remaining) { + return Err(match &self.close_failure_error { + Some(factory) => factory(), + None => TargetError::Storage("forced close failure".to_string()), + }); + } + Ok(()) + } + + fn store(&self) -> Option<&(dyn Store + Send + Sync)> { + self.store.as_deref() + } + + fn failed_store(&self) -> Option<&dyn FailedEventStore> { + self.failed_store.as_deref() + } + + fn clone_dyn(&self) -> Box + Send + Sync> { + Box::new(self.clone()) + } + + async fn init(&self) -> Result<(), TargetError> { + self.init_calls.fetch_add(1, Ordering::SeqCst); + if let Some(entered) = &self.blocking_init { + entered.notify_one(); + return std::future::pending().await; + } + if consume_failure_budget(&self.init_failures_remaining) { + return Err(TargetError::Initialization("forced init failure".to_string())); + } + Ok(()) + } + + fn is_enabled(&self) -> bool { + self.enabled_calls.fetch_add(1, Ordering::SeqCst); + self.enabled + } + + fn delivery_snapshot(&self) -> TargetDeliverySnapshot { + // The fixed snapshot wins when configured; otherwise mirror the trait's default impl, + // deriving the depths from the attached stores. + match &self.delivery_snapshot { + Some(snapshot) => snapshot.clone(), + None => TargetDeliverySnapshot { + failed_store_length: self + .failed_store + .as_deref() + .map_or(0, |failed_store| failed_store.failed_len() as u64), + queue_length: self.store.as_deref().map_or(0, |store| store.len() as u64), + ..TargetDeliverySnapshot::default() + }, + } + } + + fn record_final_failure(&self) { + self.final_failures.fetch_add(1, Ordering::Relaxed); + } +} + +#[cfg(test)] +mod tests { + use super::MockTarget; + use crate::Target; + use crate::target::EntityTarget; + use rustfs_s3_types::EventName; + use std::sync::Arc; + + fn sample_event() -> Arc> { + Arc::new(EntityTarget { + object_name: "obj.txt".to_string(), + bucket_name: "bucket-a".to_string(), + event_name: EventName::ObjectCreatedPut, + data: "payload".to_string(), + }) + } + + // Leak-guard contract, part one: the mock is compiled only under cfg(test) or the + // off-by-default `test-support` feature (see lib.rs), so production builds cannot even name + // it. This test documents the default surface a consumer gets when it does opt in. + #[tokio::test] + async fn defaults_are_inert() { + let target = MockTarget::new("primary", "webhook"); + let handle: &dyn Target = ⌖ + + assert_eq!(handle.id().to_string(), "primary:webhook"); + assert!(handle.is_enabled()); + assert!(handle.is_active().await.expect("the default probe succeeds")); + handle.init().await.expect("the default init succeeds"); + handle.save(sample_event()).await.expect("the default save succeeds"); + handle.close().await.expect("the default close succeeds"); + assert!(handle.store().is_none()); + assert!(handle.failed_store().is_none()); + + assert_eq!(target.init_call_count(), 1); + assert_eq!(target.save_call_count(), 1); + assert_eq!(target.close_call_count(), 1); + assert_eq!(target.final_failure_count(), 0); + } + + #[tokio::test] + async fn clones_and_clone_dyn_share_the_same_counters() { + let target = MockTarget::new("primary", "webhook"); + let observer = target.clone(); + let boxed: Box + Send + Sync> = Box::new(target); + let second = boxed.clone_dyn(); + + boxed.close().await.expect("close succeeds"); + second.close().await.expect("close succeeds"); + second.record_final_failure(); + + assert_eq!(observer.close_call_count(), 2); + assert_eq!(observer.final_failure_count(), 1); + } + + #[tokio::test] + async fn init_failure_budget_fails_first_then_succeeds() { + let target = MockTarget::new("primary", "webhook").with_init_failures(2); + let handle: &dyn Target = ⌖ + + assert!(handle.init().await.is_err()); + assert!(handle.init().await.is_err()); + handle.init().await.expect("the failure budget is spent, so init succeeds"); + assert_eq!(target.init_call_count(), 3); + } + + #[tokio::test] + async fn save_failure_budget_fails_first_then_succeeds() { + let target = MockTarget::new("primary", "webhook").with_save_failures(1); + let handle: &dyn Target = ⌖ + + assert!(handle.save(sample_event()).await.is_err()); + handle + .save(sample_event()) + .await + .expect("the failure budget is spent, so save succeeds"); + assert_eq!(target.save_call_count(), 2); + } + + #[tokio::test] + async fn active_override_decouples_the_probe_from_enablement() { + let target = MockTarget::new("primary", "webhook").with_active(false); + let handle: &dyn Target = ⌖ + + assert!(handle.is_enabled()); + assert!(!handle.is_active().await.expect("the probe itself succeeds")); + assert_eq!(target.enabled_call_count(), 1, "every is_enabled call is counted"); + } + + #[tokio::test] + async fn with_id_renames_but_keeps_the_shared_state() { + let template = MockTarget::new("template", "webhook"); + let renamed = template.clone().with_id("instance", "webhook"); + assert_eq!(renamed.target_id().to_string(), "instance:webhook"); + + let handle: &dyn Target = &renamed; + handle.close().await.expect("close succeeds"); + assert_eq!(template.close_call_count(), 1, "a renamed clone still feeds the template's counters"); + } + + #[tokio::test] + async fn first_save_gate_blocks_only_the_first_save() { + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let target = MockTarget::new("primary", "webhook").with_first_save_gate(entered.clone(), release.clone()); + let observer = target.clone(); + + let gated: Arc + Send + Sync> = Arc::new(target); + let first = tokio::spawn({ + let gated = Arc::clone(&gated); + async move { gated.save(sample_event()).await } + }); + entered.notified().await; + assert_eq!(observer.save_call_count(), 1, "the gated save is counted before it parks"); + + gated + .save(sample_event()) + .await + .expect("a later save passes straight through"); + release.notify_one(); + first + .await + .expect("the gated save task should join") + .expect("the gated save succeeds after release"); + assert_eq!(observer.save_call_count(), 2); + } + + #[tokio::test] + async fn health_gate_holds_the_probe_until_released() { + let release = Arc::new(tokio::sync::Notify::new()); + let target = MockTarget::new("primary", "webhook").with_health_gate(release.clone()); + let started = target.health_started(); + + let probing: Arc + Send + Sync> = Arc::new(target); + let probe = tokio::spawn(async move { probing.is_active().await }); + started.notified().await; + assert!(!probe.is_finished(), "the probe must stay in flight until released"); + + release.notify_one(); + assert!( + probe + .await + .expect("the probe task should join") + .expect("the released probe succeeds"), + "the released probe reports the configured reachability" + ); + } + + #[tokio::test] + async fn close_failure_budget_uses_the_configured_error_shape() { + let target = MockTarget::new("primary", "webhook").with_close_failures(1); + let handle: &dyn Target = ⌖ + assert!( + matches!(handle.close().await, Err(crate::TargetError::Storage(_))), + "the default close failure is storage-flavored" + ); + handle.close().await.expect("the failure budget is spent, so close succeeds"); + assert_eq!(target.close_call_count(), 2); + + let pinned = MockTarget::new("primary", "webhook") + .with_close_failures(usize::MAX) + .with_close_failure_error(|| crate::TargetError::Unknown("close failed".to_string())); + let pinned_handle: &dyn Target = &pinned; + assert!(matches!(pinned_handle.close().await, Err(crate::TargetError::Unknown(_)))); + } + + #[tokio::test] + async fn delivery_snapshot_override_replaces_the_derived_snapshot() { + use crate::target::TargetDeliverySnapshot; + + let plain = MockTarget::new("primary", "webhook"); + let plain_handle: &dyn Target = &plain; + assert_eq!(plain_handle.delivery_snapshot(), TargetDeliverySnapshot::default()); + + let fixed = TargetDeliverySnapshot { + failed_messages: 1, + failed_store_length: 7, + queue_length: 0, + total_messages: 3, + }; + let target = MockTarget::new("primary", "webhook").with_delivery_snapshot(fixed.clone()); + let handle: &dyn Target = ⌖ + assert_eq!(handle.delivery_snapshot(), fixed); + } + + // Leak-guard contract, part two: the `test-support` feature must never ship by default and + // must stay a pure cfg gate, so no production dependency edge can drag the mock in. + #[test] + fn test_support_feature_never_ships_by_default() { + let manifest = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")) + .expect("the crate manifest should be readable"); + let default_features = manifest + .lines() + .map(str::trim) + .find(|line| line.starts_with("default = ")) + .expect("the crate manifest should declare a default feature list"); + assert_eq!(default_features, "default = []", "test-support must stay out of the default feature set"); + let feature = manifest + .lines() + .map(str::trim) + .find(|line| line.starts_with("test-support = ")) + .expect("the crate manifest should declare the test-support feature"); + assert_eq!( + feature, "test-support = []", + "test-support must stay a pure cfg gate that activates no dependencies" + ); + } +} diff --git a/docs/architecture/compat-cleanup-register.md b/docs/architecture/compat-cleanup-register.md index 61caadf17..e40a8fade 100644 --- a/docs/architecture/compat-cleanup-register.md +++ b/docs/architecture/compat-cleanup-register.md @@ -34,6 +34,7 @@ for later deletion. - `tonic-013-status-render` peer RPC failure classification: internode failures that reach a node only as text (a peer's error_info payload, a status flattened through format!) are classified by matching the rendering of an Unavailable gRPC status. Releases up to 1.0.0-alpha.38 shipped tonic 0.13, which rendered that status as "status: Unavailable, message: ..."; tonic 0.14 renders it as "code: 'The service is currently unavailable', message: ...". Both forms are matched so an older peer's relayed text still marks an unreachable peer offline. Remove the tonic 0.13 form after the minimum supported RustFS peer version ships tonic 0.14 or later. - `rustfs-5063` pre-beta.9 Local KMS recovery: persisted Local KMS configs from beta.8 and earlier predate the explicit insecure-development flag, and encrypted key files use the legacy SHA-256 KDF. Remove the config fallback after supported upgrades have rewritten or explicitly resaved all pre-beta.9 configs with the development-default field, and remove the legacy KDF after supported upgrades have rewritten all pre-beta.9 Local KMS key files with explicit at-rest protection. - `sse-local-dek-json-v1` legacy local SSE DEK decoding: releases before the JSON envelope wrote wrapped DEKs as `base64(nonce):base64(ciphertext)`, so readers retain that decoder while all new writes use the versioned JSON envelope. Remove the colon decoder after the minimum supported direct-upgrade release writes JSON envelopes and migration tooling has rewritten every retained legacy object. +- `not-initialized-error-code-v1` typed control-plane not-initialized wire code: control-plane RPC responses historically signaled an uninitialized peer only through the literal error_info string "errServerNotInitialized" (one drift site says "storage layer not initialized"). Responses now dual-carry a typed ControlPlaneErrorCode beside the legacy string, and clients prefer the code; the string stays populated and the client substring fallback (is_err_not_initialized, control_plane_failure) stays in place so mixed-version clusters keep classifying older peers' responses. Remove the substring fallback (and stop populating error_info for this case) after the minimum supported RustFS peer version always sends error_code. - `multipart-compression-default-off-window` staged multipart disk-compression rollout: releases before the resumable legacy decompressor fail transient reads of compressed objects under mid-payload suspension, so multipart uploads advertise the compression marker only when RUSTFS_COMPRESSION_MULTIPART_ENABLED is set in addition to RUSTFS_COMPRESSION_ENABLED, keeping rolling upgrades from creating new compressed multipart objects while pre-fix nodes may still serve reads. Flip the default to enabled (and retire the extra switch) after the minimum supported direct-upgrade release ships the resumable decompressor. ## Review Checklist diff --git a/docs/architecture/crate-boundaries.md b/docs/architecture/crate-boundaries.md index accf68b4e..909305556 100644 --- a/docs/architecture/crate-boundaries.md +++ b/docs/architecture/crate-boundaries.md @@ -35,6 +35,23 @@ wire types still live in `rustfs-filemeta`. This keeps the temporary dependency centralized until those wire contracts can move without introducing a `rustfs-replication` / `rustfs-storage-api` cycle. +Leaf crates carry exactly one adjudicated allowed edge: +`io-metrics -> rustfs-s3-ops` (transitively `rustfs-s3-types`). Both are pure +contract crates — types and enums only, no I/O, no global state, no non-contract +internal dependencies — so `io-metrics` reuses the `S3Operation` vocabulary +instead of copying it. `madmin` is no longer counted a leaf: since #6166 it is +the SigV4-signed admin SDK client and deliberately depends on `rustfs-signer`; +the guard pins its internal dependency surface to exactly that edge so it cannot +quietly grow storage-side dependencies. The leaf-crate allowlist in +`scripts/check_architecture_migration_rules.sh` fails any other `rustfs-*` +dependency in `config`, `credentials`, `crypto`, `io-metrics`, or `madmin`, in +either TOML spelling (`rustfs-x = ...` or `rustfs-x.workspace = true`). +Adjudicated in +[`rustfs/backlog#1834`](https://github.com/rustfs/backlog/issues/1834); a further +leaf exception must meet the pure-contract criterion — types and enums only, no +I/O, no globals, no non-contract internal dependencies — and land its guard +allowlist entry alongside the dependency. + Dependency direction also applies to compile-time source reads: `include_str!`/`include!` of a `.rs` file must not resolve outside the including crate's own directory (`scripts/check_layer_dependencies.sh` diff --git a/docs/architecture/ecstore-config-consumer-inventory.md b/docs/architecture/ecstore-config-consumer-inventory.md index 96789d516..4a0a91c8a 100644 --- a/docs/architecture/ecstore-config-consumer-inventory.md +++ b/docs/architecture/ecstore-config-consumer-inventory.md @@ -123,7 +123,7 @@ behind narrower contracts. | Files | Current usage | |---|---| | `rustfs/src/admin/handlers/kms_dynamic.rs` | Uses generic `read_config` and `save_config` for dynamic KMS config objects. | -| `rustfs/src/admin/handlers/site_replication.rs` | Uses generic `read_config`, `save_config`, and `delete_config` for site-replication state objects. | +| `rustfs/src/site_replication/state.rs` | Uses generic `read_config`, `save_config`, and `delete_config` (via the root storage facade) for site-replication state objects. | | `rustfs/src/admin/service/site_replication.rs` | Uses generic `read_config` and `save_config` for site-replication state normalization. | | `rustfs/src/server/module_switch.rs` | Uses generic `read_config` and `save_config` for module-switch config objects. | | `crates/iam/src/store/object.rs` | Uses generic `read_config_no_lock`, `read_config_with_metadata`, `save_config`, `save_config_with_opts`, and `delete_config` helper variants for IAM object-store persistence paths. | diff --git a/docs/architecture/ecstore-module-split-plan.md b/docs/architecture/ecstore-module-split-plan.md index 6d8d7f85a..ef5f12362 100644 --- a/docs/architecture/ecstore-module-split-plan.md +++ b/docs/architecture/ecstore-module-split-plan.md @@ -13,6 +13,7 @@ and rollback steps. | Bucket replication | `crates/ecstore/src/bucket/replication/` | 15,619 lines | Contracts extracted; runtime move pending | | Set disks | `crates/ecstore/src/set_disk/` | state carrier plus operation modules | Keep in ECStore | | Public ECStore facade | `crates/ecstore/src/api/mod.rs` | broad compatibility surface | Shrink only through guarded PRs | +| Embedded S3 client | `crates/s3-client/` (`rustfs-s3-client`) | ~8.4K lines | Extracted (rustfs/backlog#1842) | Measured 2026-08-12: the whole crate is 265 files / ~288K lines (roughly half is inline `#[cfg(test)]` code). The largest single files are `disk/local.rs` @@ -37,6 +38,12 @@ list, multipart, lock, heal, and replication code live in separate modules. The remaining large surface is the shared `SetDisks` state and cross-cutting contracts, not only file layout. +## Completed: S3 Client Extraction (rustfs/backlog#1842) + +`crates/ecstore/src/client/` was a ~8.4K-line hand-written S3 HTTP client the engine uses to *consume* remote S3-compatible endpoints (ILM tier warm backends, transition targets). It was a legitimate engine capability misfiled inside the engine: it pulled `s3s`/`hyper` wire types into ecstore against ARCHITECTURE.md invariant 4, which distinguishes serving the S3 wire protocol (forbidden in ecstore) from consuming it (allowed, but in a dedicated crate). + +The extraction landed as: pure move of the 21 client modules to `crates/s3-client` (`rustfs-s3-client`) with a temporary re-export shim, then direct `rustfs_s3_client::` imports and shim deletion. The two server-side modules historically misfiled under `client/` stayed in ecstore and moved to their real homes: `object_api_utils.rs` under `object_api/`, `object_handlers_common.rs` under `bucket/lifecycle/` (behind the `replication_sink` boundary). The remaining serving-side `s3s` references in ecstore are ratcheted shrink-only by the `S3S_ECSTORE_FILES_BASELINE` counter in `scripts/check_s3s_footprint.sh`; per-module conversions to storage-level types (first: `bucket/object_lock/`) lower the baseline in the same change. + ## Non-Negotiable Rules - Do not split crates in the same PR that moves runtime state or changes diff --git a/docs/architecture/global-state-inventory.md b/docs/architecture/global-state-inventory.md index b110374dd..66726639d 100644 --- a/docs/architecture/global-state-inventory.md +++ b/docs/architecture/global-state-inventory.md @@ -107,14 +107,14 @@ inventory. Generic function-local names such as `CACHE`, `LOCK`, `INIT`, and | `AUTH_FS` | `rustfs/src/storage/access.rs` | Cache or constant / owner-local cache | Authorization tag-condition lookup keeps its filesystem helper private to the access owner. | | `DEADLOCK_DETECTOR` | `rustfs/src/storage/deadlock_detector.rs` | Process-global owner-local state | Deadlock detector lifecycle state stays private to the storage deadlock detector owner. | | `CONCURRENCY_MANAGER`, `ACTIVE_GET_REQUESTS`, `ACTIVE_PUT_REQUESTS` | `rustfs/src/storage/concurrency/*` | Process-global owner-local scheduler state | Storage concurrency manager and request counters remain inside the storage concurrency owner boundary. | -| `GET_OBJECT_BUFFER_THRESHOLD_WARNED`, `GET_READER_STREAM_BUFFER_SIZE_OVERRIDE`, function-local `ENABLED`, `OBJECT_SEEK_SUPPORT_THRESHOLD`, `OBJECT_SEEK_SUPPORT_CONCURRENCY_THRESHOLDS` | `rustfs/src/app/object_usecase.rs` | Cache or constant / owner-local cache | Object GET/seek tuning caches and warning guards stay private to object usecase helpers. | +| `GET_OBJECT_BUFFER_THRESHOLD_WARNED`, `GET_READER_STREAM_BUFFER_SIZE_OVERRIDE`, function-local `ENABLED`, `OBJECT_SEEK_SUPPORT_THRESHOLD`, `OBJECT_SEEK_SUPPORT_CONCURRENCY_THRESHOLDS` | `rustfs/src/app/object/get.rs` | Cache or constant / owner-local cache | Object GET/seek tuning caches and warning guards stay private to object usecase helpers. | | `SUPPORTED_HEADERS` | `rustfs/src/storage/options.rs` | Cache or constant / owner-local constant | Supported-header lookup state stays private to storage option parsing. | | `AUDIT_TARGET_SPECS`, `NOTIFICATION_TARGET_SPECS` | `rustfs/src/admin/handlers/audit.rs`, `rustfs/src/admin/handlers/event.rs`, `rustfs/src/admin/handlers/plugins_instances.rs` | Cache or constant / owner-local constant | Admin target descriptor tables stay private to their handler owners. | -| `SITE_REPLICATION_PEER_CLIENT` | `rustfs/src/admin/handlers/site_replication.rs` | Process-global owner-local cache | Site-replication peer client cache stays private to site-replication handlers. The state RMW transaction holds no process-local mutex — see `rustfs/src/admin/site_replication_state.rs`. | +| `SITE_REPLICATION_PEER_CLIENT` | `rustfs/src/site_replication/transport.rs` | Process-global owner-local cache | Site-replication peer client cache stays private to the site-replication transport module. The state RMW transaction holds no process-local mutex — see `rustfs/src/site_replication/state_lock.rs`. | | `AUDIT_MODULE_ENABLED`, `NOTIFY_MODULE_ENABLED`, `PERSISTED_NOTIFY_MODULE_ENABLED`, `PERSISTED_AUDIT_MODULE_ENABLED`, `PERSISTED_MODULE_SWITCH_CONFIGURED` | `rustfs/src/server/audit.rs`, `rustfs/src/server/event.rs`, `rustfs/src/server/module_switch.rs` | Process-global owner-local toggles | Audit/notify module snapshots stay private to the server module switch owners. | | `DELETE_TAIL_TOTAL`, `DELETE_CLEANUP_TOTAL`, `DELETE_REPLICATION_TOTAL`, `DELETE_NOTIFY_TOTAL` | `rustfs/src/delete_tail_activity.rs` | Process-global owner-local counters | Delete-tail activity counters stay private behind delete-tail activity helpers. | | `EMBEDDED_SERVER_STARTED` | `rustfs/src/startup_lifecycle.rs` | Process-global owner-local guard | Embedded startup single-start protection stays private to startup lifecycle. | -| `TEST_OUTBOUND_TLS_GENERATION` | `rustfs/src/admin/runtime_sources.rs` | Test or fixture state | Outbound TLS generation test hook state stays private to admin runtime-source tests. | +| `TEST_OUTBOUND_TLS_GENERATION` | `rustfs/src/site_replication/mod.rs` | Test or fixture state | Outbound TLS generation test hook state stays private to site-replication transport tests. | | `TEST_REMAINING_FAILURES` | `rustfs/src/startup_iam.rs` | Test or fixture state | IAM startup retry injection state stays private to debug/test startup code. | | `CAPACITY_DIRTY_SCOPE_ENV`, `CAPACITY_DIRTY_SCOPE_INIT`, `GLOBAL_ENV`, function-local `INIT` | `rustfs/src/app/*_test.rs` | Test or fixture state | App integration test fixture state stays private to the owning test modules. | diff --git a/docs/architecture/minio-rustfs-router-compatibility.md b/docs/architecture/minio-rustfs-router-compatibility.md index 00c9ff68c..7055b6710 100644 --- a/docs/architecture/minio-rustfs-router-compatibility.md +++ b/docs/architecture/minio-rustfs-router-compatibility.md @@ -105,7 +105,7 @@ these as 部分兼容 at the client level. | CompleteMultipartUpload | 已实现 | `rustfs/src/storage/ecfs.rs` (`complete_multipart_upload`) | | AbortMultipartUpload | 已实现 | `rustfs/src/storage/ecfs.rs` (`abort_multipart_upload`) | | ListParts | 已实现 | `rustfs/src/storage/ecfs.rs` (`list_parts`) | -| PostObject (POST form upload) | 已实现 | Routed via the POST-object marker into the put-object path (`rustfs/src/app/object_usecase.rs`). See the "POST Object form upload checksum handling: planned" note in `s3-compatibility-matrix.md`. | +| PostObject (POST form upload) | 已实现 | Routed via the POST-object marker into the put-object path (`rustfs/src/app/object/put.rs`). See the "POST Object form upload checksum handling: planned" note in `s3-compatibility-matrix.md`. | | GetObjectTorrent | 行为不一致 | `rustfs/src/storage/ecfs.rs` (`get_object_torrent`) — returns `404 NoSuchKey` by design (not `501 NotImplemented`) so clients degrade gracefully. | For the gate-level view of which of these are covered by executable s3tests, diff --git a/docs/architecture/unified-object-generation.md b/docs/architecture/unified-object-generation.md index fffed4e74..40656c429 100644 --- a/docs/architecture/unified-object-generation.md +++ b/docs/architecture/unified-object-generation.md @@ -199,7 +199,7 @@ upgrade. When the cluster-level generation capability is **not** negotiated on every target disk, the behavior **falls back to current semantics** (existing lock + `is_lock_lost()` check for #1312; degraded-allow read-check for #1318 at -`rustfs/src/app/object_usecase.rs`; full fanout for #1314). Fail-closed is +`rustfs/src/app/object/get.rs`; full fanout for #1314). Fail-closed is **only** an explicit administrator strict mode. Defaulting to fail-closed is forbidden — it makes writes unavailable for the whole rolling-upgrade window. diff --git a/docs/operations/presigned-put-size-limit.md b/docs/operations/presigned-put-size-limit.md new file mode 100644 index 000000000..85f0a746b --- /dev/null +++ b/docs/operations/presigned-put-size-limit.md @@ -0,0 +1,35 @@ +# Presigned PutObject size limit + +RustFS V1 supports an optional, RustFS-specific capability on a SigV4 +presigned `PutObject` URL: + +```text +x-rustfs-max-content-length= +``` + +The backend that creates the URL must add this query parameter to the request +URI before calculating the SigV4 presign. It is part of the canonical query; +adding, removing, or changing it after signing invalidates the signature. A +browser can then upload with a plain `PUT` and does not need a custom size +header. + +RustFS validates the capability after SigV4 authentication and enforces it on +the decoded request body. A declared `Content-Length` above the limit is +rejected before storage. If the body produces more bytes than the limit while +streaming, RustFS returns `EntityTooLarge` and does not publish the object. + +The V1 contract is deliberately narrow: + +- The parameter is accepted only on a SigV4 presigned `PutObject` request. +- Duplicate, case-variant, malformed, negative, or overflowing values return + `InvalidRequest`. +- Requests without the parameter, including ordinary authenticated or + anonymous `PUT`, keep the existing behavior. +- The parameter on `CopyObject`, multipart, `GET`, `HEAD`, `DELETE`, bucket, or + other operations returns `InvalidRequest`. +- Unknown-length and SigV4 streaming-chunked uploads remain unsupported by the + existing PutObject admission contract and are not enabled by this feature. + +This capability is per request; it is not a cumulative multipart-upload cap. +Multipart session limits are planned for V2 under a separate query/API +contract. diff --git a/docs/operations/rustfs-heal-scanner-vs-minio-comprehensive-analysis-2026-08-16.md b/docs/operations/rustfs-heal-scanner-vs-minio-comprehensive-analysis-2026-08-16.md index 931d020e8..da7572639 100644 --- a/docs/operations/rustfs-heal-scanner-vs-minio-comprehensive-analysis-2026-08-16.md +++ b/docs/operations/rustfs-heal-scanner-vs-minio-comprehensive-analysis-2026-08-16.md @@ -3,7 +3,7 @@ > English | [中文版](rustfs-heal-scanner-vs-minio-comprehensive-analysis-2026-08-16_zh.md) - Date: 2026-08-16 (based on that day's `main` code; audit HEAD ≈ `a118d7e4f`) -- Scope: `crates/heal` (src 19,560 lines + tests 2,274 lines), `crates/scanner` (src ~26,000 lines + tests), `crates/data-usage`, the heal/heal_walk/bitrot_self_verify and config parts of `crates/ecstore`, `crates/common/src/heal_channel.rs`, `crates/madmin` (heal/scanner wire types), `rustfs/src` (startup wiring, admin handlers, cluster RPC) +- Scope: `crates/heal` (src 19,560 lines + tests 2,274 lines), `crates/scanner` (src ~26,000 lines + tests), `crates/data-usage`, the heal/heal_walk/bitrot_self_verify and config parts of `crates/ecstore`, `crates/heal-contracts/src/heal_channel.rs`, `crates/madmin` (heal/scanner wire types), `rustfs/src` (startup wiring, admin handlers, cluster RPC) - Parity baseline: minio/minio master (HEAD `7aac2a2c5b`; the repo has entered maintenance mode with master frozen, i.e. its final state) - Method: four parallel audit tracks (heal crate / scanner crate / ecstore integration layer / MinIO source study), with key conclusions verified by hand one by one (points marked "verified first-hand" below were checked against the source directly) - This document supersedes `docs/rustfs-heal-scanner-vs-minio-parity-assessment.md` (2026-06-15, v1). Since v1 there have been more than 80 heal/scanner commits (the full automatic drive-replacement healing chain, the resume state machine, making usage convergence authoritative, cluster-level heal coordination, ILM restore semantics, etc.), so v1's feature inventory and gap judgments are comprehensively outdated; v1 conclusions such as "bloom filter missing" were verified this round to be **misjudgments** (see §5.4). @@ -31,7 +31,7 @@ RustFS splits the heal/scanner functionality that MinIO keeps inside the `cmd/` | Primitives layer | `crates/ecstore/src/set_disk/ops/heal.rs` (~3,240 lines), `ops/heal_walk.rs`, `ops/bitrot_self_verify.rs`; upper wrappers `store/heal.rs`, `store/heal_walk.rs`, `core/sets.rs` | Object/bucket/format/replacement-drive format repair, disk-walk union enumeration, write-path bitrot self-verification; the `rustfs_storage_api::HealOperations` contract is implemented by `SetDisks`/`Sets`/`ECStore` (`crates/storage-api/src/object.rs:503-519`) | | heal runtime | `crates/heal` | Process-level HealManager (priority queue/scheduler/auto disk scanner/resumable resume), HealChannelProcessor (consumes the global heal channel), drive-replacement recovery state machine | | scanner runtime | `crates/scanner` | Data usage scanning, ILM evaluation and enqueueing, heal candidate production, replication usage statistics, remote scanner RPC | -| Shared protocol | `crates/common/src/heal_channel.rs` (~776 lines) | Start/Query/Cancel command channel, `HealOpts`/`HealScanMode`/`HealRequestSource`/`HealAdmission*` shared types, `HealResultItem` (madmin) | +| Shared protocol | `crates/heal-contracts/src/heal_channel.rs` (~776 lines) | Start/Query/Cancel command channel, `HealOpts`/`HealScanMode`/`HealRequestSource`/`HealAdmission*` shared types, `HealResultItem` (madmin) | | Shared data | `crates/data-usage` | `DataUsageEntry/Info`, histograms, `hash_path`; produced by the scanner, consumed by ecstore/admin | Startup chain (wiring verified first-hand): @@ -214,7 +214,7 @@ Compared with MinIO master: MinIO's skip strategy is likewise hash-mod-16 cycles ### 3.5 ILM integration - Per object `ScannerItem::apply_actions` (`scanner_folder.rs:747-1032`): `Evaluator::new(lifecycle).with_lock_retention(...).with_replication_config(...).eval()` batch evaluation. -- Implemented actions (the full IlmAction set, `common/src/metrics.rs:34-45`): expiry deletes (Delete/DeleteRestored/DeleteRestoredVersion), all-versions deletes (DeleteAllVersions/DelMarkerDeleteAllVersions, stop further versions after handling), transition (Transition/TransitionVersion, tier list read at runtime), noncurrent batches (DeleteVersionAction → `enqueue_by_newer_noncurrent`), free-version cleanup (`enqueue_free_version`), object-lock retention constraints. **A one-to-one mapping onto MinIO's 9 ILM actions.** +- Implemented actions (the full IlmAction set, `scanner-contracts/src/metrics.rs:34-45`): expiry deletes (Delete/DeleteRestored/DeleteRestoredVersion), all-versions deletes (DeleteAllVersions/DelMarkerDeleteAllVersions, stop further versions after handling), transition (Transition/TransitionVersion, tier list read at runtime), noncurrent batches (DeleteVersionAction → `enqueue_by_newer_noncurrent`), free-version cleanup (`enqueue_free_version`), object-lock retention constraints. **A one-to-one mapping onto MinIO's 9 ILM actions.** - Execution model: the scanner is the "discover and enqueue" role (the expiry/transition queues live in ecstore `bucket_lifecycle_ops.rs`); actions are consumed by worker pools — the same shape as MinIO's globalExpiryState/globalTransitionState. - AbortIncompleteMultipartUpload is not executed inside scanner/ILM (MinIO likewise: `internal/bucket/lifecycle/rule.go` has a FIXME, and it is actually carried by the `erasureSets.cleanupStaleUploads` global routine); in RustFS it is an independent ecstore background task `init_background_stale_multipart_upload_cleanup` (`bucket_lifecycle_ops.rs:3289-3320`) + on-demand at bucket deletion. - Integration-test coverage: transition+restore, free-version, noncurrent, delete-marker, 0-day, background-scan expiry (`scanner/tests/lifecycle_integration_test.rs:1071-2095`). diff --git a/docs/operations/rustfs-heal-scanner-vs-minio-comprehensive-analysis-2026-08-16_zh.md b/docs/operations/rustfs-heal-scanner-vs-minio-comprehensive-analysis-2026-08-16_zh.md index a75971d1a..9d5cb9461 100644 --- a/docs/operations/rustfs-heal-scanner-vs-minio-comprehensive-analysis-2026-08-16_zh.md +++ b/docs/operations/rustfs-heal-scanner-vs-minio-comprehensive-analysis-2026-08-16_zh.md @@ -3,7 +3,7 @@ > English version: [rustfs-heal-scanner-vs-minio-comprehensive-analysis-2026-08-16.md](rustfs-heal-scanner-vs-minio-comprehensive-analysis-2026-08-16.md) - 日期:2026-08-16(基于 main 分支当日代码,审计时 HEAD ≈ `a118d7e4f`) -- 范围:`crates/heal`(src 19,560 行 + tests 2,274 行)、`crates/scanner`(src 约 26,000 行 + tests)、`crates/data-usage`、`crates/ecstore` 中 heal/heal_walk/bitrot_self_verify 与 config、`crates/common/src/heal_channel.rs`、`crates/madmin`(heal/scanner wire 类型)、`rustfs/src`(startup wiring、admin handlers、集群 RPC) +- 范围:`crates/heal`(src 19,560 行 + tests 2,274 行)、`crates/scanner`(src 约 26,000 行 + tests)、`crates/data-usage`、`crates/ecstore` 中 heal/heal_walk/bitrot_self_verify 与 config、`crates/heal-contracts/src/heal_channel.rs`、`crates/madmin`(heal/scanner wire 类型)、`rustfs/src`(startup wiring、admin handlers、集群 RPC) - 对标基线:minio/minio master(HEAD `7aac2a2c5b`,仓库已进入维护模式,master 冻结,即最终态) - 方法:四路并行审计(heal crate / scanner crate / ecstore 集成层 / MinIO 源码研究),关键结论逐条人工抽验(文内标注"已亲验"处为一手验证) - 本文档取代 `docs/rustfs-heal-scanner-vs-minio-parity-assessment.md`(2026-06-15 v1)。v1 之后 heal/scanner 相关提交超过 80 个(换盘自动修复全链路、resume 状态机、usage 收敛权威化、集群级 heal 协调、ILM restore 语义等),v1 的功能清单与差距判断已全面过时;v1 中"bloom filter 缺失"等结论经本次核实为**误判**(详见 §5.4)。 @@ -31,7 +31,7 @@ RustFS 把 MinIO 在 `cmd/` 内单体的 heal/scanner 拆成三层 + 两个独 | 原语层 | `crates/ecstore/src/set_disk/ops/heal.rs`(~3,240 行)、`ops/heal_walk.rs`、`ops/bitrot_self_verify.rs`;上层封装 `store/heal.rs`、`store/heal_walk.rs`、`core/sets.rs` | 对象/桶/format/替换盘格式修复、disk-walk 并集枚举、写入路径 bitrot 自校验;由 `SetDisks`/`Sets`/`ECStore` 实现 `rustfs_storage_api::HealOperations` 契约(`crates/storage-api/src/object.rs:503-519`) | | heal 运行时 | `crates/heal` | 进程级 HealManager(优先级队列/调度器/auto disk scanner/断点续传 resume)、HealChannelProcessor(消费全局 heal channel)、换盘替换恢复状态机 | | scanner 运行时 | `crates/scanner` | 数据使用扫描、ILM 评估与入队、heal 候选生产、复制用量统计、remote scanner RPC | -| 共享协议 | `crates/common/src/heal_channel.rs`(~776 行) | Start/Query/Cancel 命令通道、`HealOpts`/`HealScanMode`/`HealRequestSource`/`HealAdmission*` 共享类型、`HealResultItem`(madmin) | +| 共享协议 | `crates/heal-contracts/src/heal_channel.rs`(~776 行) | Start/Query/Cancel 命令通道、`HealOpts`/`HealScanMode`/`HealRequestSource`/`HealAdmission*` 共享类型、`HealResultItem`(madmin) | | 共享数据 | `crates/data-usage` | `DataUsageEntry/Info`、直方图、`hash_path`;scanner 产生、ecstore/admin 消费 | 启动链路(已亲验 wiring): @@ -214,7 +214,7 @@ heal crate 侧包装(`task.rs:855-1146`):存在性检查(瞬时错误转 ### 3.5 ILM 集成 - 每对象 `ScannerItem::apply_actions`(`scanner_folder.rs:747-1032`):`Evaluator::new(lifecycle).with_lock_retention(...).with_replication_config(...).eval()` 批量评估。 -- 已实现动作(IlmAction 全集,`common/src/metrics.rs:34-45`):expiry 删除(Delete/DeleteRestored/DeleteRestoredVersion)、全版本删除(DeleteAllVersions/DelMarkerDeleteAllVersions,处理后停止后续版本)、transition(Transition/TransitionVersion,tier 列表运行时读取)、noncurrent 批量(DeleteVersionAction → `enqueue_by_newer_noncurrent`)、free-version 清理(`enqueue_free_version`)、object-lock retention 约束。**与 MinIO 的 9 个 ILM 动作一一对应**。 +- 已实现动作(IlmAction 全集,`scanner-contracts/src/metrics.rs:34-45`):expiry 删除(Delete/DeleteRestored/DeleteRestoredVersion)、全版本删除(DeleteAllVersions/DelMarkerDeleteAllVersions,处理后停止后续版本)、transition(Transition/TransitionVersion,tier 列表运行时读取)、noncurrent 批量(DeleteVersionAction → `enqueue_by_newer_noncurrent`)、free-version 清理(`enqueue_free_version`)、object-lock retention 约束。**与 MinIO 的 9 个 ILM 动作一一对应**。 - 执行模型:scanner 是"发现与入队"角色(expiry 队列/transition 队列在 ecstore `bucket_lifecycle_ops.rs`),动作由 worker 池消费——与 MinIO globalExpiryState/globalTransitionState 同型。 - AbortIncompleteMultipartUpload 不在 scanner/ILM 内执行(MinIO 同样不在:`internal/bucket/lifecycle/rule.go` 有 FIXME,实际由 `erasureSets.cleanupStaleUploads` 全局例程承担);RustFS 由 ecstore 独立后台任务 `init_background_stale_multipart_upload_cleanup`(`bucket_lifecycle_ops.rs:3289-3320`)+ 桶删除时 on-demand。 - 集成测试覆盖:transition+restore、free-version、noncurrent、delete-marker、0-day、后台扫描过期(`scanner/tests/lifecycle_integration_test.rs:1071-2095`)。 diff --git a/flake.nix b/flake.nix index 3c94ffa5a..4b1526448 100644 --- a/flake.nix +++ b/flake.nix @@ -59,7 +59,7 @@ { default = rustPlatform.buildRustPackage { pname = "rustfs"; - version = "1.0.0-rc.3"; + version = "1.0.0-rc.4"; src = ./.; diff --git a/helm/rustfs/Chart.yaml b/helm/rustfs/Chart.yaml index 86fde8ed3..c7b43885a 100644 --- a/helm/rustfs/Chart.yaml +++ b/helm/rustfs/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: rustfs description: RustFS helm chart to deploy RustFS on kubernetes cluster. type: application -version: "1.0.0-rc.3" -appVersion: "1.0.0-rc.3" +version: "1.0.0-rc.4" +appVersion: "1.0.0-rc.4" home: https://rustfs.com icon: https://media.sys.truenas.net/apps/rustfs/icons/icon.svg maintainers: diff --git a/rustfs.spec b/rustfs.spec index c92f84abb..76aff52b7 100644 --- a/rustfs.spec +++ b/rustfs.spec @@ -1,9 +1,9 @@ %global _enable_debug_packages 0 %global _empty_manifest_terminate_build 0 -%global prerelease rc.3 +%global prerelease rc.4 Name: rustfs Version: 1.0.0 -Release: rc.3 +Release: rc.4 Summary: High-performance distributed object storage for MinIO alternative License: Apache-2.0 @@ -58,6 +58,9 @@ install %_builddir/%{name}-%{version}-%{prerelease}/target/%_arch/%_arch-unknown %_bindir/rustfs %changelog +* Thu Aug 27 2026 overtrue +- Update RPM package to RustFS 1.0.0-rc.4 + * Thu Aug 20 2026 overtrue - Update RPM package to RustFS 1.0.0-rc.3 diff --git a/rustfs/Cargo.toml b/rustfs/Cargo.toml index c392d5629..529fc4b73 100644 --- a/rustfs/Cargo.toml +++ b/rustfs/Cargo.toml @@ -44,6 +44,18 @@ name = "rustfs-cli-e2e" path = "src/bin/rustfs-cli.rs" required-features = ["offline-enrollment-e2e-root"] +# The Swift protocol suites are manual (#[ignore]) and need a server built with +# the `swift` feature; gating the test binaries on the same feature keeps the +# default `cargo test` / nextest build from linking two dead test binaries +# (backlog#1846 cluster 4). +[[test]] +name = "swift_container_integration_test" +required-features = ["swift"] + +[[test]] +name = "swift_object_integration_test" +required-features = ["swift"] + [features] default = ["ftps", "webdav"] metrics-gpu = ["rustfs-obs/gpu"] @@ -55,8 +67,9 @@ license = [] io-scheduler-debug = [] # Enable debug information in I/O scheduler tracing-chunk-debug = [] # Enable per-chunk tracing in data plane (high noise, for debugging only) full = ["metrics-gpu", "ftps", "swift", "webdav", "sftp", "pyroscope"] -manual-test-runners = [] e2e-test-hooks = [] +# Shortens Connect credentials only in debug E2E builds. +connect-e2e-short-credentials = [] # Builds the dedicated rustfs-cli-e2e target with a build-time public enrollment root. offline-enrollment-e2e-root = [] rio-v2 = ["rustfs-ecstore/rio-v2"] @@ -213,6 +226,8 @@ hotpath.workspace = true rustfs-heal = { workspace = true } rustfs-audit = { workspace = true } rustfs-common = { workspace = true } +rustfs-heal-contracts = { workspace = true } +rustfs-scanner-contracts = { workspace = true } rustfs-config = { workspace = true, features = ["notify", "server-config-model"] } rustfs-crypto = { workspace = true } rustfs-credentials = { workspace = true } @@ -230,6 +245,7 @@ rustfs-policy = { workspace = true } rustfs-protocols = { workspace = true } rustfs-protos = { workspace = true } rustfs-rio = { workspace = true } +rustfs-s3-client = { workspace = true } rustfs-s3-types = { workspace = true } rustfs-s3-ops = { workspace = true } rustfs-security-governance = { workspace = true } @@ -305,7 +321,6 @@ astral-tokio-tar = { workspace = true } atoi = { workspace = true } atomic_enum = { workspace = true } async_zip = { workspace = true, default-features = false, features = ["tokio", "deflate"] } -base64 = { workspace = true } zeroize = { workspace = true } hmac = { workspace = true } sha2 = { workspace = true } @@ -348,9 +363,6 @@ rustfs-mimalloc = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] libsystemd.workspace = true -[target.'cfg(not(target_os = "windows"))'.dependencies] -rustfs-mimalloc-sys = { workspace = true } - [dev-dependencies] uuid = { workspace = true, features = ["v4", "v5", "fast-rng", "macro-diagnostics"] } serial_test = { workspace = true } diff --git a/rustfs/tests/README_concurrent_download_tool.md b/rustfs/examples/README_concurrent_download_tool.md similarity index 86% rename from rustfs/tests/README_concurrent_download_tool.md rename to rustfs/examples/README_concurrent_download_tool.md index 06a6e7180..fa879088c 100644 --- a/rustfs/tests/README_concurrent_download_tool.md +++ b/rustfs/examples/README_concurrent_download_tool.md @@ -1,4 +1,4 @@ -# Concurrent Download Tool (tests) +# Concurrent Download Tool (example) This tool downloads multiple URLs concurrently and saves files to a target directory. @@ -34,7 +34,7 @@ After run, the tool prints: - latency p95 ms - failure details (`[index] url => error`) when failures exist -If any task fails, the test returns error after printing the summary. +If any task fails, the tool exits with an error after printing the summary. Retry is triggered only for recoverable cases: @@ -45,13 +45,13 @@ Retry is triggered only for recoverable cases: ## Compile check ```bash -cargo test -p rustfs --test concurrent_download_tool --no-run +cargo build -p rustfs --example concurrent_download_tool ``` ## Manual run example The commands below are for manual execution only. -They are not part of automated test runs. +The tool is an example binary, so it is built on demand and never runs as part of the test suites. ```bash DOWNLOAD_URLS="http://127.0.0.1:9001/demo/google-cloud-aiplugin-1.46.1-253.zip?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=HAXVOTZK9MLBJT8KWI4E%2F20260329%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260329T105159Z&X-Amz-Expires=86400&X-Amz-Security-Token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJwYXJlbnQiOiJydXN0ZnNhZG1pbiIsImV4cCI6MTc3NDgyMDgyMX0.tYhQoPRcg0Ysx4KVw9ez7ZpYxsqGgqomtsP_iaeTsKzoii8EVNt74BZm2wbUjXW-FbGXc1pqEYX6wZ5Ncpk9Iw&X-Amz-Signature=15f47b19832f53b34f9e0fe1862d53d71660bbf8f1a512669bb2d041ac8d0697&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject" \ @@ -60,6 +60,6 @@ DOWNLOAD_CONCURRENCY="40" \ DOWNLOAD_REPEAT="40" \ DOWNLOAD_MAX_RETRIES="2" \ DOWNLOAD_RETRY_BACKOFF_MS="300" \ -cargo test -p rustfs --test concurrent_download_tool -- --ignored --nocapture +cargo run -p rustfs --example concurrent_download_tool ``` diff --git a/rustfs/tests/concurrent_download_tool.rs b/rustfs/examples/concurrent_download_tool.rs similarity index 98% rename from rustfs/tests/concurrent_download_tool.rs rename to rustfs/examples/concurrent_download_tool.rs index 58a9f4aba..40fb73ab0 100644 --- a/rustfs/tests/concurrent_download_tool.rs +++ b/rustfs/examples/concurrent_download_tool.rs @@ -12,6 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Manual concurrent-download performance tool: requires a running RustFS server +//! configured via `DOWNLOAD_*` env vars. Run with +//! `cargo run -p rustfs --example concurrent_download_tool`. + use anyhow::{Context, Result, anyhow}; use futures::stream::{self, StreamExt}; use reqwest::{Client, Url}; @@ -368,9 +372,8 @@ async fn run_concurrent_downloads(settings: DownloadSettings) -> Result Result<()> { +#[tokio::main] +async fn main() -> Result<()> { let settings = DownloadSettings::from_env()?; let summary = run_concurrent_downloads(settings).await?; diff --git a/rustfs/tests/gt1g_get_benchmark_tool.rs b/rustfs/examples/gt1g_get_benchmark_tool.rs similarity index 95% rename from rustfs/tests/gt1g_get_benchmark_tool.rs rename to rustfs/examples/gt1g_get_benchmark_tool.rs index 25cdd8683..a683395c5 100644 --- a/rustfs/tests/gt1g_get_benchmark_tool.rs +++ b/rustfs/examples/gt1g_get_benchmark_tool.rs @@ -1,3 +1,20 @@ +// 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. + +//! Manual >1GiB GET benchmark: requires a running RustFS server configured via +//! `GT1G_GET_*` env vars. Run with `cargo run -p rustfs --example gt1g_get_benchmark_tool`. + use anyhow::{Context, Result, anyhow}; use aws_config::BehaviorVersion; use aws_config::meta::region::RegionProviderChain; @@ -511,9 +528,8 @@ async fn run_bench(settings: &ToolSettings, client: &Client) -> Result<()> { Ok(()) } -#[tokio::test] -#[ignore = "manual >1GiB GET benchmark: requires a running RustFS server configured via env vars"] -async fn gt1g_get_benchmark_tool() -> Result<()> { +#[tokio::main] +async fn main() -> Result<()> { let settings = ToolSettings::from_env()?; let client = build_client(&settings).await?; diff --git a/rustfs/src/admin/handlers/batch_job.rs b/rustfs/src/admin/handlers/batch_job.rs index ca3f0e07d..1170d3dde 100644 --- a/rustfs/src/admin/handlers/batch_job.rs +++ b/rustfs/src/admin/handlers/batch_job.rs @@ -37,18 +37,16 @@ use crate::admin::auth::authorize_admin_request; use crate::admin::router::{AdminOperation, Operation, S3Router}; -use crate::admin::utils::read_compatible_admin_body; +use crate::admin::utils::{extract_query_params, json_response, read_compatible_admin_body}; use crate::server::ADMIN_PREFIX; -use http::{HeaderMap, HeaderValue, Uri}; +use http::Uri; use hyper::{Method, StatusCode}; use matchit::Params; use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE; use rustfs_credentials::Credentials; use rustfs_policy::policy::action::{Action, AdminAction}; -use s3s::header::CONTENT_TYPE; use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error}; use serde::Serialize; -use std::collections::HashMap; use tracing::warn; /// Job types recognised by the MinIO batch-job admin API. @@ -58,27 +56,10 @@ use tracing::warn; /// (`NotImplemented`) from "unknown job type" (`InvalidRequest`). const KNOWN_JOB_TYPES: &[&str] = &["replicate", "keyrotate", "expire"]; -fn extract_query_params(uri: &Uri) -> HashMap { - let mut params = HashMap::new(); - if let Some(query) = uri.query() { - for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { - params.insert(key.into_owned(), value.into_owned()); - } - } - params -} - async fn validate_batch_job_admin_request(req: &S3Request, action: AdminAction) -> S3Result { authorize_admin_request(req, vec![Action::AdminAction(action)]).await } -fn json_response(status: StatusCode, value: &T) -> S3Result> { - let data = serde_json::to_vec(value).map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("{e}")))?; - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - Ok(S3Response::with_headers((status, Body::from(data)), headers)) -} - /// Best-effort detection of the declared job type from a MinIO batch-job /// definition body. /// @@ -269,7 +250,7 @@ fn no_such_job(job_id: &str) -> S3Error { #[cfg(test)] mod tests { - use super::{detect_job_type, extract_query_params, require_job_id}; + use super::{detect_job_type, require_job_id}; use http::Uri; #[test] @@ -302,17 +283,16 @@ mod tests { assert_eq!(detect_job_type(b"transmogrify:\n foo: bar\n"), None); } + /// `jobId` reaches the handler percent-decoded — the shared query parser + /// is covered in `crate::admin::utils`; this pins the endpoint's own use + /// of it, including the rejection of a missing or empty id. #[test] - fn extract_query_params_decodes_job_id() { - let uri: Uri = "/rustfs/admin/v3/status-job?jobId=abc%2F123" + fn require_job_id_decodes_and_rejects_missing_and_empty() { + let encoded: Uri = "/rustfs/admin/v3/status-job?jobId=abc%2F123" .parse() .expect("uri should parse"); - let params = extract_query_params(&uri); - assert_eq!(params.get("jobId"), Some(&"abc/123".to_string())); - } + assert_eq!(require_job_id(&encoded).expect("job id"), "abc/123"); - #[test] - fn require_job_id_rejects_missing_and_empty() { let missing: Uri = "/rustfs/admin/v3/status-job".parse().expect("uri should parse"); assert!(require_job_id(&missing).is_err()); diff --git a/rustfs/src/admin/handlers/bucket_meta.rs b/rustfs/src/admin/handlers/bucket_meta.rs index 48ef7fe2f..5de70dd0b 100644 --- a/rustfs/src/admin/handlers/bucket_meta.rs +++ b/rustfs/src/admin/handlers/bucket_meta.rs @@ -30,11 +30,10 @@ use crate::admin::storage_api::error::StorageError; use crate::storage::storage_api::lock_bucket_targets_metadata; use crate::{ admin::{ - auth::validate_admin_request, + auth::authorize_admin_request, router::{AdminOperation, Operation, S3Router}, }, - auth::{check_key_valid, get_session_token}, - server::{ADMIN_PREFIX, RemoteAddr}, + server::ADMIN_PREFIX, }; use http::{HeaderMap, StatusCode}; use hyper::Method; @@ -117,22 +116,11 @@ impl Operation for ExportBucketMetadata { } }; - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::ExportBucketMetadataAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::ExportBucketMetadataAction)]).await?; let Some(store) = object_store_from_extensions(&req.extensions) else { return Err(s3_error!(InternalError, "object store is not initialized")); @@ -412,22 +400,11 @@ impl Operation for ImportBucketMetadata { } }; - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::ImportBucketMetadataAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::ImportBucketMetadataAction)]).await?; let mut input = req.input; let body = match input.store_all_limited(MAX_BUCKET_METADATA_IMPORT_SIZE).await { @@ -1128,3 +1105,93 @@ mod import_persist_tests { assert!(imported_quota_requires_fleet_proof(&durable).expect("durable quota should pass preflight")); } } + +#[cfg(test)] +mod shared_gate_tests { + use super::*; + use http::Uri; + use s3s::S3ErrorCode; + + fn credential_less_request(method: Method, uri: &'static str) -> S3Request { + S3Request { + input: Body::empty(), + method, + uri: Uri::from_static(uri), + headers: HeaderMap::new(), + extensions: http::Extensions::new(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + } + } + + async fn assert_missing_credentials(operation: &dyn Operation, method: Method, uri: &'static str) { + let err = operation + .call(credential_less_request(method, uri), Params::new()) + .await + .expect_err("a bucket metadata admin request without credentials must fail"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some("authentication required")); + } + + /// The shared gate reports "get cred failed"; the per-handler pre-check keeps + /// the message each endpoint has always returned (rustfs/backlog#1829). + #[tokio::test] + async fn bucket_metadata_handlers_keep_their_missing_credentials_response() { + assert_missing_credentials(&ExportBucketMetadata {}, Method::GET, "/rustfs/admin/v3/export-bucket-metadata").await; + assert_missing_credentials(&ImportBucketMetadata {}, Method::PUT, "/rustfs/admin/v3/import-bucket-metadata").await; + } + + fn source_block<'a>(production: &'a str, marker: &str) -> &'a str { + let block = production + .split_once(marker) + .unwrap_or_else(|| panic!("{marker} should exist")) + .1; + let end = ["\npub struct ", "\nfn ", "\n#[derive(", "\n#[cfg(test)]"] + .into_iter() + .filter_map(|boundary| block.find(boundary)) + .min() + .unwrap_or(block.len()); + &block[..end] + } + + fn assert_shared_gate_wiring(block: &str, item: &str, actions: &[&str], binds_credentials: bool) { + assert_eq!( + block.matches("authorize_admin_request(").count(), + 1, + "{item} must use exactly one shared gate" + ); + assert_eq!( + block.matches("Action::AdminAction(").count(), + actions.len(), + "{item} must preserve its exact action-vector length" + ); + for action in actions { + assert!(block.contains(&format!("AdminAction::{action}")), "{item} must authorize with {action}"); + } + assert_eq!( + block.contains("let cred = authorize_admin_request("), + binds_credentials, + "{item} credential binding must match its payload-processing contract" + ); + } + + #[test] + fn bucket_metadata_handlers_use_the_shared_admin_gate_with_their_actions() { + let production = include_str!("bucket_meta.rs") + .split("\n#[cfg(test)]\n") + .next() + .expect("production source must precede tests"); + + for (handler, action) in [ + ("ExportBucketMetadata", "ExportBucketMetadataAction"), + ("ImportBucketMetadata", "ImportBucketMetadataAction"), + ] { + let block = source_block(production, &format!("impl Operation for {handler}")); + assert_shared_gate_wiring(block, handler, &[action], false); + } + + assert!(!production.contains("check_key_valid(get_session_token")); + } +} diff --git a/rustfs/src/admin/handlers/config_admin.rs b/rustfs/src/admin/handlers/config_admin.rs index 82096b741..15bcd7763 100644 --- a/rustfs/src/admin/handlers/config_admin.rs +++ b/rustfs/src/admin/handlers/config_admin.rs @@ -30,10 +30,12 @@ use crate::admin::storage_api::config::{ save_admin_server_config_snapshot, }; use crate::admin::storage_api::contract::list::ListOperations as _; -use crate::admin::utils::{encode_compatible_admin_payload, is_compat_admin_request, read_compatible_admin_body}; +use crate::admin::utils::{ + encode_compatible_admin_payload, extract_query_params, is_compat_admin_request, read_compatible_admin_body, +}; use crate::error::ApiError; use crate::server::ADMIN_PREFIX; -use http::{HeaderMap, HeaderValue, Uri}; +use http::{HeaderMap, HeaderValue}; use hyper::{Method, StatusCode}; use matchit::Params; use rustfs_config::audit::{ @@ -81,7 +83,7 @@ use rustfs_policy::policy::action::{Action, AdminAction}; use s3s::header::CONTENT_TYPE; use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error}; use serde::Serialize; -use std::collections::{BTreeSet, HashMap}; +use std::collections::BTreeSet; use std::env; use std::mem::size_of; use time::OffsetDateTime; @@ -664,18 +666,6 @@ pub fn register_config_route(r: &mut S3Router) -> std::io::Resul Ok(()) } -fn extract_query_params(uri: &Uri) -> HashMap { - let mut params = HashMap::new(); - - if let Some(query) = uri.query() { - for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { - params.insert(key.into_owned(), value.into_owned()); - } - } - - params -} - async fn validate_config_admin_request(req: &S3Request) -> S3Result { // Pre-check keeps this endpoint's historical missing-credentials message; // the shared gate reports "get cred failed". @@ -2282,6 +2272,7 @@ impl Operation for SetConfigHandler { #[cfg(test)] mod tests { use super::*; + use http::Uri; use serial_test::serial; use temp_env::with_vars; diff --git a/rustfs/src/admin/handlers/diagnostics.rs b/rustfs/src/admin/handlers/diagnostics.rs index 37095a619..18758b2fc 100644 --- a/rustfs/src/admin/handlers/diagnostics.rs +++ b/rustfs/src/admin/handlers/diagnostics.rs @@ -26,6 +26,7 @@ use crate::admin::auth::authorize_admin_request; use crate::admin::router::{AdminOperation, Operation, S3Router}; use crate::admin::storage_api::access::spawn_traced; +use crate::admin::utils::json_response; use crate::server::ADMIN_PREFIX; use crate::storage::storage_api::get_global_lock_clients; use bytes::Bytes; @@ -37,7 +38,7 @@ use rustfs_lock::{LockLeaseInfo, LockMode, LockType, ObjectKey, get_global_lock_ use rustfs_policy::policy::action::{Action, AdminAction}; use s3s::header::CONTENT_TYPE; use s3s::stream::{ByteStream, DynByteStream}; -use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, StdError, s3_error}; +use s3s::{Body, S3Error, S3Request, S3Response, S3Result, StdError, s3_error}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::pin::Pin; @@ -48,7 +49,6 @@ use tokio::sync::{Semaphore, SemaphorePermit, mpsc}; use tokio_stream::wrappers::ReceiverStream; use tracing::warn; -const CONTENT_TYPE_JSON: &str = "application/json"; const CONTENT_TYPE_NDJSON: &str = "application/x-ndjson"; pub(crate) const CLIENT_DEVNULL_MAX_BYTES: u64 = 1024 * 1024 * 1024; pub(crate) const CLIENT_DEVNULL_MAX_DURATION: Duration = Duration::from_secs(30); @@ -143,14 +143,6 @@ async fn authorize(req: &S3Request, action: AdminAction) -> S3Result<()> { Ok(()) } -fn json_response(status: StatusCode, value: &T) -> S3Result> { - let data = serde_json::to_vec(value) - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("failed to serialize response: {e}")))?; - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, HeaderValue::from_static(CONTENT_TYPE_JSON)); - Ok(S3Response::with_headers((status, Body::from(data)), headers)) -} - async fn read_body(input: Body) -> S3Result> { let mut input = input; let body = input @@ -1061,6 +1053,7 @@ fn query_values(uri: &Uri, key: &str) -> Vec { mod tests { use super::*; use http::{Extensions, Uri}; + use s3s::S3ErrorCode; fn build_request(method: Method, uri: &'static str) -> S3Request { S3Request { diff --git a/rustfs/src/admin/handlers/group.rs b/rustfs/src/admin/handlers/group.rs index 93e440fbb..5c8d220b9 100644 --- a/rustfs/src/admin/handlers/group.rs +++ b/rustfs/src/admin/handlers/group.rs @@ -16,13 +16,13 @@ use super::iam_error::iam_error_to_s3_error; use crate::{ admin::runtime_sources::current_action_credentials, admin::{ - auth::validate_admin_request, + auth::authorize_admin_request, handlers::site_replication::site_replication_iam_change_hook, router::{AdminOperation, Operation, S3Router}, utils::has_space_be, }, - auth::{check_key_valid, constant_time_eq, get_session_token}, - server::{ADMIN_PREFIX, RemoteAddr}, + auth::constant_time_eq, + server::ADMIN_PREFIX, }; use http::{HeaderMap, StatusCode}; use hyper::Method; @@ -98,22 +98,11 @@ impl Operation for ListGroups { "admin group state" ); - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::ListGroupsAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::ListGroupsAdminAction)]).await?; let Ok(iam_store) = crate::admin::runtime_sources::current_ready_iam_handle() else { return Err(s3_error!(InternalError, "iam is not initialized")); @@ -154,22 +143,11 @@ impl Operation for GetGroup { "admin group state" ); - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::GetGroupAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::GetGroupAdminAction)]).await?; let query = { if let Some(query) = req.uri.query() { @@ -237,22 +215,11 @@ impl Operation for DeleteGroup { "admin group state" ); - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::RemoveUserFromGroupAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::RemoveUserFromGroupAdminAction)]).await?; let group = decode_delete_group_name(¶ms)?; @@ -363,22 +330,11 @@ impl Operation for SetGroupStatus { "admin group state" ); - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::EnableGroupAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::EnableGroupAdminAction)]).await?; let query = { if let Some(query) = req.uri.query() { @@ -488,22 +444,11 @@ impl Operation for UpdateGroupMembers { "admin group state" ); - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::AddUserToGroupAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::AddUserToGroupAdminAction)]).await?; let mut input = req.input; let body = match input.store_all_limited(MAX_ADMIN_REQUEST_BODY_SIZE).await { @@ -673,8 +618,32 @@ impl Operation for UpdateGroupMembers { #[cfg(test)] mod tests { use super::*; + use http::Uri; use matchit::Router; + fn credential_less_request(method: Method, uri: &'static str) -> S3Request { + S3Request { + input: Body::empty(), + method, + uri: Uri::from_static(uri), + headers: HeaderMap::new(), + extensions: http::Extensions::new(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + } + } + + async fn assert_missing_credentials(operation: &dyn Operation, method: Method, uri: &'static str) { + let err = operation + .call(credential_less_request(method, uri), Params::new()) + .await + .expect_err("a group admin request without credentials must fail"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some("authentication required")); + } + fn with_delete_group_params(path: &str, f: impl FnOnce(&Params<'_, '_>) -> T) -> T { let mut router = Router::new(); router @@ -727,4 +696,60 @@ mod tests { assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); assert_eq!(err.message(), Some("group name contains invalid characters")); } + + #[tokio::test] + async fn group_handlers_keep_their_missing_credentials_response() { + assert_missing_credentials(&ListGroups {}, Method::GET, "/rustfs/admin/v3/groups").await; + assert_missing_credentials(&GetGroup {}, Method::GET, "/rustfs/admin/v3/group").await; + assert_missing_credentials(&DeleteGroup {}, Method::DELETE, "/rustfs/admin/v3/group/dev").await; + assert_missing_credentials(&SetGroupStatus {}, Method::PUT, "/rustfs/admin/v3/set-group-status").await; + assert_missing_credentials(&UpdateGroupMembers {}, Method::PUT, "/rustfs/admin/v3/update-group-members").await; + } + + fn source_block<'a>(production: &'a str, marker: &str) -> &'a str { + let block = production + .split_once(marker) + .unwrap_or_else(|| panic!("{marker} should exist")) + .1; + let end = ["\npub struct ", "\nasync fn ", "\npub(crate) async fn ", "\n#[cfg(test)]"] + .into_iter() + .filter_map(|boundary| block.find(boundary)) + .min() + .unwrap_or(block.len()); + &block[..end] + } + + #[test] + fn group_handlers_use_the_shared_admin_gate_with_their_actions() { + let production = include_str!("group.rs") + .split("\n#[cfg(test)]\n") + .next() + .expect("production source must precede tests"); + + for (handler, action) in [ + ("ListGroups", "ListGroupsAdminAction"), + ("GetGroup", "GetGroupAdminAction"), + ("DeleteGroup", "RemoveUserFromGroupAdminAction"), + ("SetGroupStatus", "EnableGroupAdminAction"), + ("UpdateGroupMembers", "AddUserToGroupAdminAction"), + ] { + let block = source_block(production, &format!("impl Operation for {handler}")); + assert_eq!( + block.matches("authorize_admin_request(").count(), + 1, + "{handler} must use exactly one shared gate" + ); + assert_eq!( + block.matches("Action::AdminAction(").count(), + 1, + "{handler} must request exactly one admin action" + ); + assert!( + block.contains(&format!("AdminAction::{action}")), + "{handler} must authorize with {action}" + ); + } + + assert!(!production.contains("check_key_valid(get_session_token")); + } } diff --git a/rustfs/src/admin/handlers/heal.rs b/rustfs/src/admin/handlers/heal.rs index 28999fa94..57cdc2c6c 100644 --- a/rustfs/src/admin/handlers/heal.rs +++ b/rustfs/src/admin/handlers/heal.rs @@ -28,11 +28,11 @@ use futures_util::future::join_all; use http::{HeaderMap, HeaderValue, Uri}; use hyper::{Method, StatusCode}; use matchit::Params; -use rustfs_common::heal_channel::{ - HealAdmissionReceipt, HealChannelPriority, HealChannelRequest, HealOpts, HealRequestSource, HealScanMode, -}; use rustfs_config::MAX_HEAL_REQUEST_SIZE; use rustfs_heal::heal::utils::format_set_disk_id; +use rustfs_heal_contracts::heal_channel::{ + HealAdmissionReceipt, HealChannelPriority, HealChannelRequest, HealOpts, HealRequestSource, HealScanMode, +}; use rustfs_policy::policy::action::{Action, AdminAction}; use rustfs_scanner::scanner::{BackgroundHealInfo, read_background_heal_info}; use rustfs_utils::path::path_join; @@ -960,8 +960,8 @@ async fn submit_cluster_heal_start( } } -fn reject_heal_admission(result: rustfs_common::heal_channel::HealAdmissionResult) -> s3s::S3Error { - use rustfs_common::heal_channel::{HealAdmissionDropReason, HealAdmissionResult}; +fn reject_heal_admission(result: rustfs_heal_contracts::heal_channel::HealAdmissionResult) -> s3s::S3Error { + use rustfs_heal_contracts::heal_channel::{HealAdmissionDropReason, HealAdmissionResult}; match result { HealAdmissionResult::Full | HealAdmissionResult::Dropped(HealAdmissionDropReason::QueueFull) => s3_error!( @@ -996,10 +996,10 @@ async fn submit_cluster_heal_channel_command( envelope: rustfs_protos::heal_control::Envelope, request_id: &str, response_id: String, -) -> S3Result { +) -> S3Result { match route_cluster_heal_control(&context, &route, envelope, request_id, false).await? { rustfs_protos::heal_control::Outcome::Channel { success, data, error } => { - Ok(rustfs_common::heal_channel::HealChannelResponse { + Ok(rustfs_heal_contracts::heal_channel::HealChannelResponse { request_id: response_id, success, data, @@ -1087,7 +1087,7 @@ fn build_heal_channel_request(hip: &HealInitParams) -> HealChannelRequest { } else { hip.hs.recursive }; - let mut heal_request = rustfs_common::heal_channel::create_heal_request( + let mut heal_request = rustfs_heal_contracts::heal_channel::create_heal_request( hip.bucket.clone(), if hip.obj_prefix.is_empty() { None @@ -1115,7 +1115,7 @@ fn build_heal_channel_request(hip: &HealInitParams) -> HealChannelRequest { } fn heal_channel_response_status( - response: &rustfs_common::heal_channel::HealChannelResponse, + response: &rustfs_heal_contracts::heal_channel::HealChannelResponse, ) -> (String, Vec, bool, Option) { let Some(data) = response.data.as_deref() else { return ("running".to_string(), Vec::new(), false, None); @@ -1136,19 +1136,21 @@ fn heal_channel_response_status( } #[cfg(test)] -fn heal_channel_response_summary(response: &rustfs_common::heal_channel::HealChannelResponse) -> String { +fn heal_channel_response_summary(response: &rustfs_heal_contracts::heal_channel::HealChannelResponse) -> String { heal_channel_response_status(response).0 } #[cfg(test)] fn heal_channel_response_items( - response: &rustfs_common::heal_channel::HealChannelResponse, + response: &rustfs_heal_contracts::heal_channel::HealChannelResponse, ) -> Vec { heal_channel_response_status(response).1 } #[cfg(test)] -fn heal_channel_response_progress(response: &rustfs_common::heal_channel::HealChannelResponse) -> Option { +fn heal_channel_response_progress( + response: &rustfs_heal_contracts::heal_channel::HealChannelResponse, +) -> Option { heal_channel_response_status(response).3 } @@ -1524,7 +1526,7 @@ mod tests { use http::StatusCode; use http::Uri; use matchit::Router; - use rustfs_common::heal_channel::{ + use rustfs_heal_contracts::heal_channel::{ HealAdmissionDropReason, HealAdmissionResult, HealChannelPriority, HealOpts, HealRequestSource, HealScanMode, }; use rustfs_scanner::scanner::BackgroundHealInfo; @@ -1718,7 +1720,7 @@ mod tests { dry_run: false, remove: true, recreate: false, - scan_mode: rustfs_common::heal_channel::HealScanMode::Normal, + scan_mode: rustfs_heal_contracts::heal_channel::HealScanMode::Normal, update_parity: false, no_lock: true, pool: Some(1), @@ -2640,11 +2642,15 @@ mod tests { #[test] fn test_heal_channel_response_summary_defaults_to_running() { - let response = rustfs_common::heal_channel::create_heal_response("token".to_string(), true, None, None); + let response = rustfs_heal_contracts::heal_channel::create_heal_response("token".to_string(), true, None, None); assert_eq!(heal_channel_response_summary(&response), "running"); - let response = - rustfs_common::heal_channel::create_heal_response("token".to_string(), true, Some(b"finished".to_vec()), None); + let response = rustfs_heal_contracts::heal_channel::create_heal_response( + "token".to_string(), + true, + Some(b"finished".to_vec()), + None, + ); assert_eq!(heal_channel_response_summary(&response), "finished"); } @@ -2668,7 +2674,7 @@ mod tests { "objectSize": 1024 }] }); - let response = rustfs_common::heal_channel::create_heal_response( + let response = rustfs_heal_contracts::heal_channel::create_heal_response( "token".to_string(), true, Some(serde_json::to_vec(&payload).expect("payload should serialize")), @@ -2695,7 +2701,7 @@ mod tests { "items": [], "progress": progress }); - let response = rustfs_common::heal_channel::create_heal_response( + let response = rustfs_heal_contracts::heal_channel::create_heal_response( "token".to_string(), true, Some(serde_json::to_vec(&payload).expect("payload should serialize")), diff --git a/rustfs/src/admin/handlers/ilm_transition.rs b/rustfs/src/admin/handlers/ilm_transition.rs index 655dc4fbf..c418af8b3 100644 --- a/rustfs/src/admin/handlers/ilm_transition.rs +++ b/rustfs/src/admin/handlers/ilm_transition.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::admin::auth::validate_admin_request; +use crate::admin::auth::authorize_admin_request; use crate::admin::router::{AdminOperation, Operation, S3Router}; use crate::admin::runtime_sources::object_store_from_extensions; use crate::admin::storage_api::bucket::is_reserved_or_invalid_bucket; @@ -30,9 +30,9 @@ use crate::admin::storage_api::lifecycle::{ request_manual_transition_job_cancel, save_manual_transition_job_record, update_manual_transition_job_record, }; use crate::admin::storage_api::runtime::ECStore; -use crate::auth::{check_key_valid, get_session_token}; +use crate::admin::utils::json_response; use crate::server::{ADMIN_PREFIX, RemoteAddr}; -use http::{HeaderMap, HeaderValue}; +use http::HeaderMap; use hyper::{Method, StatusCode}; use matchit::Params; use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE; @@ -41,7 +41,6 @@ use rustfs_utils::{ MaskedAccessKey, http::{AMZ_REQUEST_ID, REQUEST_ID_HEADER}, }; -use s3s::header::CONTENT_TYPE; use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -52,7 +51,6 @@ use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; use uuid::Uuid; -const JSON_CONTENT_TYPE: &str = "application/json"; const DEFAULT_MANUAL_TRANSITION_MAX_OBJECTS: u64 = 10_000; const MAX_MANUAL_TRANSITION_OBJECTS: u64 = 100_000; const MAX_MANUAL_TRANSITION_DURATION_SECONDS: u64 = 3600; @@ -406,20 +404,16 @@ async fn authorize_manual_transition_request(req: &S3Request) -> S3Result< authorize_transition_admin_request(req, AdminAction::SetTierAction).await } +/// The credential pre-check keeps this endpoint family's historical +/// missing-credentials message (the shared gate reports "get cred failed") and +/// still yields the masked actor every transition audit log records. async fn authorize_transition_admin_request(req: &S3Request, action: AdminAction) -> S3Result { let Some(input_cred) = req.credentials.as_ref() else { return Err(s3_error!(InvalidRequest, "authentication required")); }; let actor = MaskedAccessKey(&input_cred.access_key).to_string(); - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - let remote_addr = req - .extensions - .get::>() - .and_then(|opt| opt.map(|addr| addr.0)); - - validate_admin_request(&req.headers, &cred, owner, false, vec![Action::AdminAction(action)], remote_addr).await?; + authorize_admin_request(req, vec![Action::AdminAction(action)]).await?; Ok(actor) } @@ -634,17 +628,6 @@ fn map_manual_transition_job_load_error(err: StorageError, job_id: Uuid) -> S3Er } } -fn json_response(response: &T, status: StatusCode) -> S3Result> { - let body = serde_json::to_vec(response).map_err(|err| { - S3Error::with_message(S3ErrorCode::InternalError, format!("failed to encode manual transition response: {err}")) - })?; - let content_type = HeaderValue::from_str(JSON_CONTENT_TYPE) - .map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("invalid content type: {err}")))?; - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, content_type); - Ok(S3Response::with_headers((status, Body::from(body)), headers)) -} - async fn update_manual_transition_job_record_if_owned( store: Arc, job_id: Uuid, @@ -926,10 +909,10 @@ impl Operation for ManualTransitionRunHandler { cancel_endpoint: Some(status_endpoint), report: record.report, }; - return json_response(&response, StatusCode::ACCEPTED); + return json_response(StatusCode::ACCEPTED, &response); } StartManualTransitionJobResult::Conflict(response) => { - return json_response(&response, StatusCode::CONFLICT); + return json_response(StatusCode::CONFLICT, &response); } } } @@ -965,7 +948,7 @@ impl Operation for ManualTransitionRunHandler { report, }; - json_response(&response, StatusCode::OK) + json_response(StatusCode::OK, &response) } } @@ -1005,7 +988,7 @@ impl Operation for ManualTransitionJobStatusHandler { release_manual_transition_admission(store, &record); } } - json_response(&manual_transition_job_response(record), StatusCode::OK) + json_response(StatusCode::OK, &manual_transition_job_response(record)) } } @@ -1027,7 +1010,7 @@ impl Operation for ManualTransitionJobCancelHandler { { cancel_token.cancel(); } - json_response(&manual_transition_job_response(record), StatusCode::OK) + json_response(StatusCode::OK, &manual_transition_job_response(record)) } } @@ -1044,7 +1027,7 @@ impl Operation for TransitionReconcileInspectHandler { let status = inspect_transition_transaction_for_operator(store, transaction_id) .await .map_err(map_transition_operator_error)?; - json_response(&status, StatusCode::OK) + json_response(StatusCode::OK, &status) } } @@ -1079,7 +1062,7 @@ impl Operation for TransitionReconcileApplyHandler { "exact_delete_completed_journal_already_finalized" }; log_transition_reconcile_applied(transaction_id, "delete_candidate", outcome, &request_id, &actor, &remote_addr); - json_response(&TransitionCandidateDeleteResponse { outcome, result }, StatusCode::OK) + json_response(StatusCode::OK, &TransitionCandidateDeleteResponse { outcome, result }) } ValidatedTransitionReconcileAction::FinalizeMissing => { finalize_missing_transition_transaction_for_operator(store, transaction_id) @@ -1094,12 +1077,12 @@ impl Operation for TransitionReconcileApplyHandler { &remote_addr, ); json_response( + StatusCode::OK, &TransitionFinalizeMissingResponse { outcome: "journal_finalized", journal_retained: false, transaction_id, }, - StatusCode::OK, ) } } @@ -1491,6 +1474,50 @@ mod tests { assert!(!auth_block.contains("AdminAction::ServerInfoAdminAction")); } + /// The transition wrapper now delegates to the shared admin gate, which reports + /// "get cred failed"; its own pre-check keeps the message these endpoints have + /// always returned (rustfs/backlog#1829). + #[tokio::test] + async fn transition_admin_gate_keeps_its_missing_credentials_response() { + let err = authorize_transition_admin_request( + &manual_transition_job_request(Method::GET, "/rustfs/admin/v3/ilm/transition/jobs/job-123"), + AdminAction::ListTierAction, + ) + .await + .expect_err("a transition admin request without credentials must fail"); + + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some("authentication required")); + } + + #[test] + fn transition_admin_gate_routes_through_the_shared_gate() { + let production = include_str!("ilm_transition.rs") + .split("\n#[cfg(test)]\n") + .next() + .expect("production source must precede tests"); + let wrapper = extract_block_between_markers( + production, + "async fn authorize_transition_admin_request", + "fn transition_transaction_id_from_params", + ); + + assert_eq!( + wrapper.matches("authorize_admin_request(").count(), + 1, + "the transition wrapper must use exactly one shared gate" + ); + assert!( + wrapper.contains("authorize_admin_request(req, vec![Action::AdminAction(action)])"), + "the transition wrapper must forward its parameterized action unchanged" + ); + assert!( + wrapper.contains("MaskedAccessKey(&input_cred.access_key)"), + "the transition wrapper must keep returning the masked actor" + ); + assert!(!production.contains("check_key_valid(get_session_token")); + } + #[test] fn manual_transition_job_id_path_param_is_required() { with_manual_transition_job_params("/rustfs/admin/v3/ilm/transition/jobs/job-123", |params| { diff --git a/rustfs/src/admin/handlers/inspect_archive.rs b/rustfs/src/admin/handlers/inspect_archive.rs index e66ba942e..425246ece 100644 --- a/rustfs/src/admin/handlers/inspect_archive.rs +++ b/rustfs/src/admin/handlers/inspect_archive.rs @@ -27,11 +27,10 @@ //! digest does not match. Raw `xl.meta`, object contents, drive paths, //! endpoints, user metadata, and encryption keys are never archive entries. -use crate::admin::auth::validate_admin_request; +use crate::admin::auth::authorize_admin_request; use crate::admin::router::{AdminOperation, Operation, S3Router}; use crate::admin::storage_api::access::spawn_traced; -use crate::auth::{check_key_valid, get_session_token}; -use crate::server::{ADMIN_PREFIX, RemoteAddr}; +use crate::server::ADMIN_PREFIX; use crate::storage::storage_api::DiskError; use crate::storage::{StorageDiskRpcExt, all_local_disk}; use aes_gcm::aead::{Aead, KeyInit, Payload}; @@ -576,16 +575,10 @@ fn inspect_archive_gate_actions() -> Vec { #[async_trait::async_trait] impl Operation for InspectArchiveHandler { async fn call(&self, mut req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials.as_ref() else { + if req.credentials.is_none() { return Err(s3_error!(AccessDenied, "Signature is required")); - }; - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - let remote_addr = req - .extensions - .get::>() - .and_then(|opt| opt.map(|addr| addr.0)); - validate_admin_request(&req.headers, &cred, owner, false, inspect_archive_gate_actions(), remote_addr).await?; + } + authorize_admin_request(&req, inspect_archive_gate_actions()).await?; let body = req .input @@ -1003,5 +996,42 @@ mod tests { .await .expect_err("unsigned request should fail"); assert_eq!(error.code(), &S3ErrorCode::AccessDenied); + // The shared gate reports InvalidRequest / "get cred failed"; the pre-check + // keeps this endpoint's own signature-required response (rustfs/backlog#1829). + assert_eq!(error.message(), Some("Signature is required")); + } + + /// The handler authorizes through the shared admin gate and still derives its + /// action vector from `inspect_archive_gate_actions()` (rustfs/backlog#1829). + #[test] + fn inspect_archive_handler_uses_the_shared_admin_gate_with_its_gate_actions() { + let production = include_str!("inspect_archive.rs") + .split("\n#[cfg(test)]\n") + .next() + .expect("production source must precede tests"); + let block = production + .split_once("impl Operation for InspectArchiveHandler") + .expect("the inspect archive handler must exist") + .1; + + assert_eq!( + block.matches("authorize_admin_request(").count(), + 1, + "InspectArchiveHandler must use exactly one shared gate" + ); + assert!( + block.contains("authorize_admin_request(&req, inspect_archive_gate_actions())"), + "InspectArchiveHandler must keep deriving its actions from inspect_archive_gate_actions()" + ); + assert_eq!( + block.matches("Action::AdminAction(").count(), + 0, + "InspectArchiveHandler must not inline an action vector" + ); + assert!( + !block.contains("let cred = authorize_admin_request("), + "InspectArchiveHandler does not need the authenticated credentials" + ); + assert!(!production.contains("check_key_valid(get_session_token")); } } diff --git a/rustfs/src/admin/handlers/kms_audit.rs b/rustfs/src/admin/handlers/kms_audit.rs index f819a4a90..883131fdf 100644 --- a/rustfs/src/admin/handlers/kms_audit.rs +++ b/rustfs/src/admin/handlers/kms_audit.rs @@ -439,7 +439,6 @@ fn dispatch(entry: AuditEntry) { #[cfg(test)] mod tests { use super::*; - use base64::Engine; use rustfs_kms::backends::local::LocalKmsBackend; use rustfs_kms::config::KmsConfig; use rustfs_kms::types::{CreateKeyRequest, DeleteKeyRequest, DescribeKeyRequest, GenerateDataKeyRequest, KeySpec}; @@ -689,8 +688,8 @@ mod tests { .expect("data key should be generated"); // What the endpoint hands back, and therefore what must not reappear. - let plaintext_b64 = base64::prelude::BASE64_STANDARD.encode(&response.plaintext_key); - let ciphertext_b64 = base64::prelude::BASE64_STANDARD.encode(&response.ciphertext_blob); + let plaintext_b64 = base64_simd::STANDARD.encode_to_string(&response.plaintext_key); + let ciphertext_b64 = base64_simd::STANDARD.encode_to_string(&response.ciphertext_blob); assert!(!response.plaintext_key.is_empty(), "the test must drive real key material"); let redacted = rustfs_kms::redact_encryption_context(&std::collections::HashMap::from([ diff --git a/rustfs/src/admin/handlers/kms_backup.rs b/rustfs/src/admin/handlers/kms_backup.rs index 02682e7da..ba047b77d 100644 --- a/rustfs/src/admin/handlers/kms_backup.rs +++ b/rustfs/src/admin/handlers/kms_backup.rs @@ -44,10 +44,11 @@ use super::kms_audit::{KmsAdminAudit, KmsAdminOperation}; use crate::admin::auth::validate_admin_request; use crate::admin::router::{AdminOperation, Operation, S3Router}; use crate::admin::runtime_sources::{current_deployment_id, current_kms_runtime_service_manager}; +use crate::admin::utils::json_response; use crate::auth::{check_key_valid, get_session_token}; use crate::server::{ADMIN_PREFIX, RemoteAddr}; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; -use hyper::{HeaderMap, Method, StatusCode}; +use base64_simd::STANDARD as BASE64; +use hyper::{Method, StatusCode}; use matchit::Params; use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE; use rustfs_kms::backup::{ @@ -58,7 +59,6 @@ use rustfs_kms::backup::{ use rustfs_kms::config::{BackendConfig, KmsConfig, LocalConfig, VaultAuthMethod}; use rustfs_kms::{KmsBackend, KmsManager, KmsServiceManager, KmsServiceStatus}; use rustfs_policy::policy::action::{Action, KmsAction}; -use s3s::header::CONTENT_TYPE; use s3s::{Body, S3Request, S3Response, S3Result, s3_error}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -429,7 +429,7 @@ impl BackupEnvironment { let decoded = Zeroizing::new( BASE64 - .decode(raw_kek.trim()) + .decode_to_vec(raw_kek.trim()) .map_err(|_| (StatusCode::PRECONDITION_FAILED, format!("{ENV_KMS_BACKUP_KEK} must be base64-encoded")))?, ); if decoded.len() != 32 { @@ -516,7 +516,9 @@ fn reuses_business_secret(kek_material: &[u8], raw_kek: &str, config: &KmsConfig if raw_kek == secret.as_str() || secret.as_bytes() == kek_material { return true; } - BASE64.decode(secret.as_str()).is_ok_and(|decoded| decoded == kek_material) + BASE64 + .decode_to_vec(secret.as_str()) + .is_ok_and(|decoded| decoded == kek_material) }) } @@ -888,13 +890,6 @@ async fn backup_status() -> KmsBackupStatusResponse { // HTTP plumbing // --------------------------------------------------------------------------- -fn json_response(status: StatusCode, value: &T) -> S3Result> { - let data = serde_json::to_vec(value).map_err(|e| s3_error!(InternalError, "failed to serialize response: {}", e))?; - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, "application/json".parse().expect("static content type should parse")); - Ok(S3Response::with_headers((status, Body::from(data)), headers)) -} - #[derive(Debug, Serialize)] struct ErrorResponse { success: bool, @@ -1185,7 +1180,7 @@ mod tests { const DEPLOYMENT: &str = "deployment-under-test"; fn test_kek_bytes() -> Vec { - BASE64.decode(TEST_KEK_B64).expect("test KEK must decode") + BASE64.decode_to_vec(TEST_KEK_B64).expect("test KEK must decode") } fn local_config(key_dir: PathBuf) -> KmsConfig { @@ -1310,7 +1305,7 @@ mod tests { .expect_err("an empty KEK must be refused"); assert_eq!(error.0, StatusCode::PRECONDITION_FAILED); - let short = BASE64.encode([0x11; 16]); + let short = BASE64.encode_to_string([0x11; 16]); let error = BackupEnvironment::build(PathBuf::from("/tmp/root"), &short, "kek".to_string(), 1, &config) .expect_err("a KEK that is not 32 bytes must be refused"); assert_eq!(error.0, StatusCode::PRECONDITION_FAILED); @@ -1353,7 +1348,7 @@ mod tests { ); // An unrelated KEK is accepted. - let independent = BASE64.encode([0x5a; 32]); + let independent = BASE64.encode_to_string([0x5a; 32]); assert!(BackupEnvironment::build(PathBuf::from("/tmp/root"), &independent, "kek".to_string(), 1, &config).is_ok()); } @@ -1372,7 +1367,7 @@ mod tests { let master_key = "local-master-key-super-secret"; let vault_token = "hvs.vault-token-super-secret"; let approle_secret = "approle-secret-id-super-secret"; - let static_key = BASE64.encode([0x7c; 32]); + let static_key = BASE64.encode_to_string([0x7c; 32]); let local = local_config_with_master_key(PathBuf::from("/var/lib/rustfs/kms"), master_key); let kv2 = vault_kv2_config(VaultAuthMethod::Token { diff --git a/rustfs/src/admin/handlers/kms_dynamic.rs b/rustfs/src/admin/handlers/kms_dynamic.rs index 65431c977..5bee555f4 100644 --- a/rustfs/src/admin/handlers/kms_dynamic.rs +++ b/rustfs/src/admin/handlers/kms_dynamic.rs @@ -1422,12 +1422,8 @@ mod tests { #[test] fn static_kms_config_is_not_persisted_with_cluster_configuration() { - use base64::Engine as _; - - let config = rustfs_kms::KmsConfig::static_kms( - "static-key".to_string(), - base64::engine::general_purpose::STANDARD.encode([0x5au8; 32]), - ); + let config = + rustfs_kms::KmsConfig::static_kms("static-key".to_string(), base64_simd::STANDARD.encode_to_string([0x5au8; 32])); assert!(ensure_kms_config_persistable(&config).is_err()); } diff --git a/rustfs/src/admin/handlers/kms_key_lifecycle.rs b/rustfs/src/admin/handlers/kms_key_lifecycle.rs index 32fbdf43c..93ad7834e 100644 --- a/rustfs/src/admin/handlers/kms_key_lifecycle.rs +++ b/rustfs/src/admin/handlers/kms_key_lifecycle.rs @@ -15,13 +15,14 @@ //! KMS key lifecycle admin API handlers: enable, disable and rotate. use super::kms_audit::KmsAdminAudit; -use super::kms_keys::{extract_query_params, scoped_key_id}; +use super::kms_keys::scoped_key_id; use crate::admin::auth::validate_admin_request_with_kms_key; use crate::admin::router::{AdminOperation, Operation, S3Router}; use crate::admin::runtime_sources::current_kms_runtime_service_manager; +use crate::admin::utils::{extract_query_params, json_response}; use crate::auth::{check_key_valid, get_session_token}; use crate::server::{ADMIN_PREFIX, RemoteAddr}; -use hyper::{HeaderMap, Method, StatusCode}; +use hyper::{Method, StatusCode}; use matchit::Params; use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE; use rustfs_kms::{ @@ -29,7 +30,6 @@ use rustfs_kms::{ types::{DescribeKeyRequest, KeyMetadata, OperationContext}, }; use rustfs_policy::policy::action::{Action, KmsAction}; -use s3s::header::CONTENT_TYPE; use s3s::{Body, S3Request, S3Response, S3Result, s3_error}; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -233,13 +233,6 @@ async fn execute_lifecycle( } } -fn json_response(status: StatusCode, response: &KmsKeyLifecycleResponse) -> S3Result> { - let data = serde_json::to_vec(response).map_err(|e| s3_error!(InternalError, "failed to serialize response: {}", e))?; - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, "application/json".parse().expect("static content type should parse")); - Ok(S3Response::with_headers((status, Body::from(data)), headers)) -} - fn unavailable_response(message: &str, key_id: String) -> S3Result> { json_response( StatusCode::SERVICE_UNAVAILABLE, diff --git a/rustfs/src/admin/handlers/kms_key_metadata.rs b/rustfs/src/admin/handlers/kms_key_metadata.rs index 679e501fd..7db8ed396 100644 --- a/rustfs/src/admin/handlers/kms_key_metadata.rs +++ b/rustfs/src/admin/handlers/kms_key_metadata.rs @@ -23,9 +23,10 @@ use super::kms_keys::scoped_key_id; use crate::admin::auth::validate_admin_request_with_kms_key; use crate::admin::router::{AdminOperation, Operation, S3Router}; use crate::admin::runtime_sources::current_kms_runtime_service_manager; +use crate::admin::utils::json_response; use crate::auth::{check_key_valid, get_session_token}; use crate::server::{ADMIN_PREFIX, RemoteAddr}; -use hyper::{HeaderMap, Method, StatusCode}; +use hyper::{Method, StatusCode}; use matchit::Params; use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE; use rustfs_kms::{ @@ -33,7 +34,6 @@ use rustfs_kms::{ types::{DescribeKeyRequest, KeyMetadata}, }; use rustfs_policy::policy::action::{Action, KmsAction}; -use s3s::header::CONTENT_TYPE; use s3s::{Body, S3Request, S3Response, S3Result, s3_error}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -268,13 +268,6 @@ async fn execute_metadata_update( } } -fn json_response(status: StatusCode, response: &KmsKeyMetadataResponse) -> S3Result> { - let data = serde_json::to_vec(response).map_err(|e| s3_error!(InternalError, "failed to serialize response: {}", e))?; - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, "application/json".parse().expect("static content type should parse")); - Ok(S3Response::with_headers((status, Body::from(data)), headers)) -} - fn unavailable_response(message: &str, key_id: String) -> S3Result> { json_response( StatusCode::SERVICE_UNAVAILABLE, @@ -408,7 +401,6 @@ impl Operation for UntagKmsKeyHandler { mod tests { use super::*; use crate::admin::handlers::kms_keys::stable_json_value; - use base64::Engine as _; use rustfs_kms::KmsManager; use rustfs_kms::backends::local::LocalKmsBackend; use rustfs_kms::backends::static_kms::StaticKmsBackend; @@ -431,8 +423,7 @@ mod tests { /// is the backend that must answer every metadata update with a capability /// gap rather than a failure of the request. async fn static_service() -> ObjectEncryptionService { - let config = - KmsConfig::static_kms("static-key".to_string(), base64::engine::general_purpose::STANDARD.encode([0x42u8; 32])); + let config = KmsConfig::static_kms("static-key".to_string(), base64_simd::STANDARD.encode_to_string([0x42u8; 32])); let backend = Arc::new( StaticKmsBackend::new(config.clone()) .await diff --git a/rustfs/src/admin/handlers/kms_keys.rs b/rustfs/src/admin/handlers/kms_keys.rs index de99ae07c..b89c58397 100644 --- a/rustfs/src/admin/handlers/kms_keys.rs +++ b/rustfs/src/admin/handlers/kms_keys.rs @@ -18,10 +18,10 @@ use super::kms_audit::{KmsAdminAudit, KmsAdminOperation}; use crate::admin::auth::{validate_admin_request, validate_admin_request_with_kms_key}; use crate::admin::router::{AdminOperation, Operation, S3Router}; use crate::admin::runtime_sources::{current_kms_runtime_service_manager, current_or_init_kms_runtime_service_manager}; +use crate::admin::utils::extract_query_params; use crate::auth::{check_key_valid, get_session_token}; use crate::kms_deletion_gate::current_key_impact; use crate::server::{ADMIN_PREFIX, RemoteAddr}; -use base64::Engine; use hyper::{HeaderMap, Method, StatusCode}; use matchit::Params; use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE; @@ -101,22 +101,6 @@ pub struct GenerateDataKeyApiResponse { pub ciphertext_blob: String, // Base64 encoded } -/// The query parameters of an admin KMS request. -/// -/// Parsed with `form_urlencoded`, as the rest of the admin surface does, so a -/// parameter written without a value (`?status`) arrives as an empty value -/// rather than disappearing: a validated parameter must be able to tell "not -/// asked for" from "asked for, unreadable". -pub(super) fn extract_query_params(uri: &hyper::Uri) -> HashMap { - let mut params = HashMap::new(); - if let Some(query) = uri.query() { - for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { - params.insert(key.into_owned(), value.into_owned()); - } - } - params -} - /// Status values a `status` filter may name, spelled as the response spells /// them. const KEY_STATUS_FILTERS: &[(&str, KeyStatus)] = &[ @@ -1535,8 +1519,8 @@ impl Operation for GenerateDataKeyHandler { Ok(response) => { let api_response = GenerateDataKeyApiResponse { key_id: response.key_id, - plaintext_key: base64::prelude::BASE64_STANDARD.encode(&response.plaintext_key), - ciphertext_blob: base64::prelude::BASE64_STANDARD.encode(&response.ciphertext_blob), + plaintext_key: base64_simd::STANDARD.encode_to_string(&response.plaintext_key), + ciphertext_blob: base64_simd::STANDARD.encode_to_string(&response.ciphertext_blob), }; let data = serde_json::to_vec(&api_response) diff --git a/rustfs/src/admin/handlers/object_data_cache.rs b/rustfs/src/admin/handlers/object_data_cache.rs index 6e45e7cb6..520e683ed 100644 --- a/rustfs/src/admin/handlers/object_data_cache.rs +++ b/rustfs/src/admin/handlers/object_data_cache.rs @@ -24,20 +24,17 @@ use crate::admin::auth::authorize_admin_request; use crate::admin::router::{AdminOperation, Operation, S3Router}; use crate::admin::runtime_sources::current_object_data_cache; +use crate::admin::utils::json_response; use crate::app::object_data_cache::ObjectDataCacheAdapter; use crate::server::ADMIN_PREFIX; -use http::{HeaderMap, HeaderValue}; use hyper::{Method, StatusCode}; use matchit::Params; use rustfs_object_data_cache::{ObjectDataCacheIdentity, ObjectDataCacheInvalidationReason, ObjectDataCacheInvalidationResult}; use rustfs_policy::policy::action::{Action, AdminAction}; -use s3s::header::CONTENT_TYPE; -use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error}; +use s3s::{Body, S3Request, S3Response, S3Result, s3_error}; use serde::Serialize; use std::sync::Arc; -const JSON_CONTENT_TYPE: &str = "application/json"; - #[derive(Debug, Serialize)] struct ObjectDataCacheStatsResponse { mode: &'static str, @@ -85,14 +82,6 @@ async fn authorize(req: &S3Request, action: AdminAction) -> S3Result<()> { Ok(()) } -fn json_response(body: &T) -> S3Result> { - let data = serde_json::to_vec(body) - .map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("failed to encode response: {err}")))?; - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, HeaderValue::from_static(JSON_CONTENT_TYPE)); - Ok(S3Response::with_headers((StatusCode::OK, Body::from(data)), headers)) -} - fn query_value(req: &S3Request, key: &str) -> Option { req.uri.query().and_then(|query| { url::form_urlencoded::parse(query.as_bytes()) @@ -146,7 +135,7 @@ impl Operation for ObjectDataCacheStatsHandler { }, }; - json_response(&response) + json_response(StatusCode::OK, &response) } } @@ -181,19 +170,24 @@ impl Operation for ObjectDataCacheFlushHandler { }; let (outcome, removed_keys) = invalidation_outcome(&result); - json_response(&ObjectDataCacheFlushResponse { - scope, - bucket, - object, - outcome, - removed_keys, - }) + json_response( + StatusCode::OK, + &ObjectDataCacheFlushResponse { + scope, + bucket, + object, + outcome, + removed_keys, + }, + ) } } #[cfg(test)] mod tests { use super::*; + use http::HeaderMap; + use s3s::S3ErrorCode; #[test] fn flush_outcome_maps_removed_and_noop() { diff --git a/rustfs/src/admin/handlers/oidc.rs b/rustfs/src/admin/handlers/oidc.rs index 42ace4905..19fc7f951 100644 --- a/rustfs/src/admin/handlers/oidc.rs +++ b/rustfs/src/admin/handlers/oidc.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::admin::auth::validate_admin_request; +use crate::admin::auth::authorize_admin_request; use crate::admin::handlers::supervise_admin_mutation; use crate::admin::router::{AdminOperation, Operation, S3Router}; use crate::admin::runtime_sources::{ @@ -23,8 +23,8 @@ use crate::admin::service::federated_identity::DefaultFederatedSessionBinding; use crate::admin::storage_api::config::{ read_admin_config_without_migrate, read_admin_server_config_snapshot, save_admin_server_config_snapshot, }; -use crate::auth::{check_key_valid, get_session_token}; -use crate::server::{ADMIN_PREFIX, CONSOLE_PREFIX, MINIO_ADMIN_PREFIX, RemoteAddr}; +use crate::admin::utils::json_response; +use crate::server::{ADMIN_PREFIX, CONSOLE_PREFIX, MINIO_ADMIN_PREFIX}; use http::StatusCode; use hyper::Method; use matchit::Params; @@ -857,23 +857,15 @@ fn redirect_response(location: &str) -> S3Result> Ok(resp) } +/// The pre-check keeps this endpoint family's historical missing-credentials +/// message; the shared gate reports "get cred failed". async fn authorize_oidc_config_request(req: &S3Request, action: AdminAction) -> S3Result<()> { - let Some(input_cred) = &req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(action)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await + authorize_admin_request(req, vec![Action::AdminAction(action)]).await?; + Ok(()) } async fn parse_json_body(req: &mut S3Request) -> S3Result { @@ -890,16 +882,6 @@ async fn parse_json_body(req: &mut S3Request) -> S3Re serde_json::from_slice(&body).map_err(|e| s3_error!(InvalidRequest, "invalid JSON: {}", e)) } -fn json_response(status: StatusCode, payload: &T) -> S3Result> { - let body = serde_json::to_vec(payload) - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize error: {e}")))?; - - let mut resp = S3Response::new((status, Body::from(body))); - resp.headers - .insert(http::header::CONTENT_TYPE, http::HeaderValue::from_static("application/json")); - Ok(resp) -} - async fn load_server_config_from_store() -> S3Result { let store = oidc_config_store()?; @@ -1847,4 +1829,65 @@ mod tests { .expect("provider KVS should exist"); assert_eq!(kvs.get(OIDC_ISSUER), "https://app.local/realms/app"); } + + /// The OIDC config gate now authorizes through the shared admin gate, which + /// reports "get cred failed"; its pre-check keeps the message these endpoints + /// have always returned (rustfs/backlog#1829). + #[tokio::test] + async fn oidc_config_gate_keeps_its_missing_credentials_message() { + for action in [AdminAction::ServerInfoAdminAction, AdminAction::ConfigUpdateAdminAction] { + let err = authorize_oidc_config_request(&build_oidc_request("/rustfs/admin/v3/idp/openid", None, None), action) + .await + .expect_err("a request without credentials must be rejected"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some("authentication required")); + } + } + + #[test] + fn oidc_config_gate_routes_through_the_shared_gate() { + let production = include_str!("oidc.rs") + .split("\n#[cfg(test)]\n") + .next() + .expect("production source must precede tests"); + let wrapper = production + .split_once("async fn authorize_oidc_config_request") + .expect("the OIDC config gate must exist") + .1 + .split_once("\nasync fn parse_json_body") + .expect("the OIDC config gate must be followed by parse_json_body") + .0; + + assert_eq!( + wrapper.matches("authorize_admin_request(").count(), + 1, + "the OIDC config gate must use exactly one shared gate" + ); + assert!( + wrapper.contains("authorize_admin_request(req, vec![Action::AdminAction(action)])"), + "the OIDC config gate must forward its parameterized action unchanged" + ); + assert!( + !wrapper.contains("let cred = authorize_admin_request("), + "the OIDC config gate does not need the authenticated credentials" + ); + + for (handler, action) in [ + ("GetOidcConfigHandler", "AdminAction::ServerInfoAdminAction"), + ("PutOidcConfigHandler", "AdminAction::ConfigUpdateAdminAction"), + ("DeleteOidcConfigHandler", "AdminAction::ConfigUpdateAdminAction"), + ("ValidateOidcConfigHandler", "AdminAction::ServerInfoAdminAction"), + ] { + let marker = format!("impl Operation for {handler}"); + let block = production + .split_once(marker.as_str()) + .unwrap_or_else(|| panic!("{handler} should exist")) + .1; + let block = &block[..block.find("\npub struct ").unwrap_or(block.len())]; + assert!( + block.contains(&format!("authorize_oidc_config_request(&req, {action})")), + "{handler} must keep authorizing with {action}" + ); + } + } } diff --git a/rustfs/src/admin/handlers/policies.rs b/rustfs/src/admin/handlers/policies.rs index 19bbee516..fdc57baa6 100644 --- a/rustfs/src/admin/handlers/policies.rs +++ b/rustfs/src/admin/handlers/policies.rs @@ -16,13 +16,12 @@ use super::iam_error::iam_error_to_s3_error; use crate::{ admin::runtime_sources::current_action_credentials, admin::{ - auth::validate_admin_request, + auth::authorize_admin_request, handlers::site_replication::site_replication_iam_change_hook, router::{AdminOperation, Operation, S3Router}, utils::{encode_compatible_admin_payload, has_space_be, read_compatible_admin_body}, }, - auth::{check_key_valid, get_session_token}, - server::{ADMIN_PREFIX, RemoteAddr}, + server::ADMIN_PREFIX, }; use http::{HeaderMap, StatusCode}; use hyper::Method; @@ -117,22 +116,11 @@ pub struct ListCannedPolicies {} #[async_trait::async_trait] impl Operation for ListCannedPolicies { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::ListUserPoliciesAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::ListUserPoliciesAdminAction)]).await?; let query = { if let Some(query) = req.uri.query() { @@ -184,22 +172,11 @@ pub struct AddCannedPolicy {} #[async_trait::async_trait] impl Operation for AddCannedPolicy { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::CreatePolicyAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::CreatePolicyAdminAction)]).await?; let query = { if let Some(query) = req.uri.query() { @@ -304,22 +281,11 @@ pub struct InfoCannedPolicy {} #[async_trait::async_trait] impl Operation for InfoCannedPolicy { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::GetPolicyAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::GetPolicyAdminAction)]).await?; let query = { if let Some(query) = req.uri.query() { @@ -370,22 +336,11 @@ pub struct RemoveCannedPolicy {} #[async_trait::async_trait] impl Operation for RemoveCannedPolicy { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::DeletePolicyAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::DeletePolicyAdminAction)]).await?; let query = { if let Some(query) = req.uri.query() { @@ -461,22 +416,11 @@ pub struct SetPolicyForUserOrGroup {} #[async_trait::async_trait] impl Operation for SetPolicyForUserOrGroup { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::AttachPolicyAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::AttachPolicyAdminAction)]).await?; let query = { if let Some(query) = req.uri.query() { @@ -802,24 +746,13 @@ async fn collect_group_policy_mappings( } async fn handle_builtin_policy_entities(req: S3Request) -> S3Result> { - let Some(input_cred) = req.credentials else { - return Err(s3_error!(InvalidRequest, "get cred failed")); - }; - - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, + let cred = authorize_admin_request( + &req, vec![ Action::AdminAction(AdminAction::ListGroupsAdminAction), Action::AdminAction(AdminAction::ListUsersAdminAction), Action::AdminAction(AdminAction::ListUserPoliciesAdminAction), ], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), ) .await?; @@ -941,22 +874,7 @@ pub(crate) async fn handle_builtin_policy_association( req: S3Request, is_attach: bool, ) -> S3Result> { - let Some(input_cred) = req.credentials else { - return Err(s3_error!(InvalidRequest, "get cred failed")); - }; - - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::AttachPolicyAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + let cred = authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::AttachPolicyAdminAction)]).await?; let req_path = req.uri.path().to_string(); let body = read_compatible_admin_body(req.input, MAX_ADMIN_REQUEST_BODY_SIZE, &req_path, &cred.secret_key).await?; @@ -1129,13 +1047,33 @@ impl Operation for ListPolicyEntitiesBuiltin { #[cfg(test)] mod tests { - use super::{ - GroupPolicyEntities, PolicyAssociationReq, SetPolicyForUserOrGroupQuery, UserPolicyEntities, attach_policy_names, - build_policy_mappings, detach_policy_names, direct_user_policy_names, parse_policy_entities_query, - validate_policy_association_req, - }; + use super::*; + use http::Uri; use rustfs_madmin::UserInfo; + fn credential_less_request(method: Method, uri: &'static str) -> S3Request { + S3Request { + input: Body::empty(), + method, + uri: Uri::from_static(uri), + headers: HeaderMap::new(), + extensions: http::Extensions::new(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + } + } + + async fn assert_missing_credentials(operation: &dyn Operation, method: Method, uri: &'static str) { + let err = operation + .call(credential_less_request(method, uri), Params::new()) + .await + .expect_err("an IAM policy request without credentials must fail"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some("authentication required")); + } + #[test] fn set_policy_query_supports_external_parameter_names() { let query: SetPolicyForUserOrGroupQuery = @@ -1244,4 +1182,93 @@ mod tests { vec!["readonly".to_string(), "writeonly".to_string()] ); } + + #[tokio::test] + async fn policy_handlers_keep_their_missing_credentials_response() { + assert_missing_credentials(&ListCannedPolicies {}, Method::GET, "/rustfs/admin/v3/list-canned-policies").await; + assert_missing_credentials(&AddCannedPolicy {}, Method::PUT, "/rustfs/admin/v3/add-canned-policy").await; + assert_missing_credentials(&InfoCannedPolicy {}, Method::GET, "/rustfs/admin/v3/info-canned-policy").await; + assert_missing_credentials(&RemoveCannedPolicy {}, Method::DELETE, "/rustfs/admin/v3/remove-canned-policy").await; + assert_missing_credentials(&SetPolicyForUserOrGroup {}, Method::PUT, "/rustfs/admin/v3/set-user-or-group-policy").await; + + for result in [ + handle_builtin_policy_entities(credential_less_request(Method::GET, "/rustfs/admin/v3/idp/builtin/policy-entities")) + .await, + handle_builtin_policy_association( + credential_less_request(Method::POST, "/rustfs/admin/v3/idp/builtin/policy-association"), + true, + ) + .await, + ] { + let err = result.expect_err("the shared gate must reject missing credentials"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some("get cred failed")); + } + } + + fn source_block<'a>(production: &'a str, marker: &str) -> &'a str { + let block = production + .split_once(marker) + .unwrap_or_else(|| panic!("{marker} should exist")) + .1; + let end = ["\npub struct ", "\nasync fn ", "\npub(crate) async fn ", "\n#[cfg(test)]"] + .into_iter() + .filter_map(|boundary| block.find(boundary)) + .min() + .unwrap_or(block.len()); + &block[..end] + } + + fn assert_shared_gate_wiring(block: &str, item: &str, actions: &[&str], binds_credentials: bool) { + assert_eq!( + block.matches("authorize_admin_request(").count(), + 1, + "{item} must use exactly one shared gate" + ); + assert_eq!( + block.matches("Action::AdminAction(").count(), + actions.len(), + "{item} must preserve its exact action-vector length" + ); + for action in actions { + assert!(block.contains(&format!("AdminAction::{action}")), "{item} must authorize with {action}"); + } + assert_eq!( + block.contains("let cred = authorize_admin_request("), + binds_credentials, + "{item} credential binding must match its payload-processing contract" + ); + } + + #[test] + fn policy_handlers_use_the_shared_admin_gate_with_their_actions() { + let production = include_str!("policies.rs") + .split("\n#[cfg(test)]\n") + .next() + .expect("production source must precede tests"); + + for (handler, action) in [ + ("ListCannedPolicies", "ListUserPoliciesAdminAction"), + ("AddCannedPolicy", "CreatePolicyAdminAction"), + ("InfoCannedPolicy", "GetPolicyAdminAction"), + ("RemoveCannedPolicy", "DeletePolicyAdminAction"), + ("SetPolicyForUserOrGroup", "AttachPolicyAdminAction"), + ] { + let block = source_block(production, &format!("impl Operation for {handler}")); + assert_shared_gate_wiring(block, handler, &[action], false); + } + + let entities = source_block(production, "async fn handle_builtin_policy_entities"); + assert_shared_gate_wiring( + entities, + "handle_builtin_policy_entities", + &["ListGroupsAdminAction", "ListUsersAdminAction", "ListUserPoliciesAdminAction"], + true, + ); + + let association = source_block(production, "pub(crate) async fn handle_builtin_policy_association"); + assert_shared_gate_wiring(association, "handle_builtin_policy_association", &["AttachPolicyAdminAction"], true); + + assert!(!production.contains("check_key_valid(get_session_token")); + } } diff --git a/rustfs/src/admin/handlers/pools.rs b/rustfs/src/admin/handlers/pools.rs index 699b08ce9..c506f2e3d 100644 --- a/rustfs/src/admin/handlers/pools.rs +++ b/rustfs/src/admin/handlers/pools.rs @@ -30,11 +30,10 @@ use crate::{ AdminPoolStatus, QueryPoolStatusRequest, current_endpoints_handle, current_notification_system, default_admin_usecase, }, admin::{ - auth::validate_admin_request, + auth::authorize_admin_request, router::{AdminOperation, Operation, S3Router}, storage_api::runtime::{EndpointServerPools, PeerRestClient}, }, - auth::{check_key_valid, get_session_token}, error::ApiError, server::{ADMIN_PREFIX, RemoteAddr}, }; @@ -480,23 +479,16 @@ impl Operation for ListPools { // GET //pools/list #[tracing::instrument(skip_all)] async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(pool_admin_missing_credentials_error("list pools")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, + authorize_admin_request( + &req, vec![ Action::AdminAction(AdminAction::ServerInfoAdminAction), Action::AdminAction(AdminAction::DecommissionAdminAction), ], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), ) .await?; @@ -577,23 +569,16 @@ impl Operation for StatusPool { // GET //pools/status?pool=http://server{1...4}/disk{1...4} #[tracing::instrument(skip_all)] async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(pool_admin_missing_credentials_error("load pool status")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, + authorize_admin_request( + &req, vec![ Action::AdminAction(AdminAction::ServerInfoAdminAction), Action::AdminAction(AdminAction::DecommissionAdminAction), ], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), ) .await?; @@ -632,23 +617,16 @@ impl Operation for StatusDecommission { // GET //decommission/status[?pool=http://server{1...4}/disk{1...4}] #[tracing::instrument(skip_all)] async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(pool_admin_missing_credentials_error("load decommission status")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, + authorize_admin_request( + &req, vec![ Action::AdminAction(AdminAction::ServerInfoAdminAction), Action::AdminAction(AdminAction::DecommissionAdminAction), ], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), ) .await?; @@ -702,27 +680,16 @@ impl Operation for StartDecommission { "admin pool request state" ); - let Some(input_cred) = req.credentials else { + let Some(input_cred) = req.credentials.as_ref() else { return Err(pool_admin_missing_credentials_error_with_request( "start decommission", &request_id, &remote_addr, )); }; - - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; let actor = MaskedAccessKey(&input_cred.access_key).to_string(); - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::DecommissionAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::DecommissionAdminAction)]).await?; let audit = PoolAuditContext::new(&request_id, &actor, &remote_addr); let Some(endpoints) = endpoints_from_context() else { @@ -866,27 +833,16 @@ impl Operation for CancelDecommission { "admin pool request state" ); - let Some(input_cred) = req.credentials else { + let Some(input_cred) = req.credentials.as_ref() else { return Err(pool_admin_missing_credentials_error_with_request( "cancel decommission", &request_id, &remote_addr, )); }; - - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; let actor = MaskedAccessKey(&input_cred.access_key).to_string(); - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::DecommissionAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::DecommissionAdminAction)]).await?; let audit = PoolAuditContext::new(&request_id, &actor, &remote_addr); let Some(endpoints) = endpoints_from_context() else { @@ -979,27 +935,16 @@ impl Operation for ClearDecommission { "admin pool request state" ); - let Some(input_cred) = req.credentials else { + let Some(input_cred) = req.credentials.as_ref() else { return Err(pool_admin_missing_credentials_error_with_request( "clear decommission", &request_id, &remote_addr, )); }; - - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; let actor = MaskedAccessKey(&input_cred.access_key).to_string(); - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::DecommissionAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::DecommissionAdminAction)]).await?; let audit = PoolAuditContext::new(&request_id, &actor, &remote_addr); let Some(endpoints) = endpoints_from_context() else { @@ -1073,15 +1018,40 @@ impl Operation for ClearDecommission { #[cfg(test)] mod pools_handler_tests { use super::{ - AdminPoolStatus, PoolAuditContext, contextualize_admin_pool_api_error, - decommission_admin_not_initialized_error_with_audit, decommission_peer_target, has_duplicate_indices, - parse_mutation_pool_query, parse_pool_idx_by_id, parse_status_pool_query, pool_admin_missing_credentials_error, - pool_admin_missing_credentials_error_with_request, pool_admin_pool_index_error_with_audit, - pool_admin_pool_not_found_error_with_audit, pool_admin_pool_parse_error_with_audit, pool_admin_query_parse_error, - pool_admin_query_parse_error_with_audit, validate_pool_mutation_leader, validate_start_decommission_guards, + AdminPoolStatus, Body, CancelDecommission, ClearDecommission, HeaderMap, ListPools, Method, Operation, Params, + PoolAuditContext, S3ErrorCode, S3Request, StartDecommission, StatusDecommission, StatusPool, Uri, + contextualize_admin_pool_api_error, decommission_admin_not_initialized_error_with_audit, decommission_peer_target, + has_duplicate_indices, parse_mutation_pool_query, parse_pool_idx_by_id, parse_status_pool_query, + pool_admin_missing_credentials_error, pool_admin_missing_credentials_error_with_request, + pool_admin_pool_index_error_with_audit, pool_admin_pool_not_found_error_with_audit, + pool_admin_pool_parse_error_with_audit, pool_admin_query_parse_error, pool_admin_query_parse_error_with_audit, + validate_pool_mutation_leader, validate_start_decommission_guards, }; use crate::admin::storage_api::runtime::{Endpoint, EndpointServerPools, Endpoints, PoolEndpoints}; + fn credential_less_request(method: Method, uri: &'static str) -> S3Request { + S3Request { + input: Body::empty(), + method, + uri: Uri::from_static(uri), + headers: HeaderMap::new(), + extensions: http::Extensions::new(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + } + } + + async fn assert_missing_credentials(operation: &dyn Operation, method: Method, uri: &'static str, message: &str) { + let err = operation + .call(credential_less_request(method, uri), Params::new()) + .await + .expect_err("a pool admin request without credentials must fail"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some(message)); + } + fn test_pool_endpoints(is_local: bool) -> EndpointServerPools { let mut endpoint = Endpoint::try_from("http://127.0.0.1:9000/disk").expect("test endpoint should parse"); endpoint.is_local = is_local; @@ -1380,4 +1350,124 @@ mod pools_handler_tests { assert_eq!(value["admin_discovery"]["clusterSnapshot"], "/rustfs/admin/v4/cluster/snapshot"); assert_eq!(value["admin_discovery"]["extensionsCatalog"], "/rustfs/admin/v4/extensions/catalog"); } + + /// Routing the pool handlers through the shared admin gate must not change + /// the wire response a caller sees when it sends no credentials at all: each + /// handler keeps its own operation-scoped message (rustfs/backlog#1829). + #[tokio::test] + async fn pool_handlers_keep_their_missing_credentials_response() { + assert_missing_credentials( + &ListPools {}, + Method::GET, + "/rustfs/admin/v3/pools/list", + "Failed to list pools: missing credentials", + ) + .await; + assert_missing_credentials( + &StatusPool {}, + Method::GET, + "/rustfs/admin/v3/pools/status", + "Failed to load pool status: missing credentials", + ) + .await; + assert_missing_credentials( + &StatusDecommission {}, + Method::GET, + "/rustfs/admin/v3/decommission/status", + "Failed to load decommission status: missing credentials", + ) + .await; + assert_missing_credentials( + &StartDecommission {}, + Method::POST, + "/rustfs/admin/v3/pools/decommission", + "Failed to start decommission: missing credentials", + ) + .await; + assert_missing_credentials( + &CancelDecommission {}, + Method::POST, + "/rustfs/admin/v3/pools/cancel", + "Failed to cancel decommission: missing credentials", + ) + .await; + assert_missing_credentials( + &ClearDecommission {}, + Method::POST, + "/rustfs/admin/v3/pools/clear", + "Failed to clear decommission: missing credentials", + ) + .await; + } + + fn source_block<'a>(production: &'a str, marker: &str) -> &'a str { + let block = production + .split_once(marker) + .unwrap_or_else(|| panic!("{marker} should exist")) + .1; + let end = ["\npub struct ", "\nasync fn ", "\npub(crate) async fn ", "\n#[cfg(test)]"] + .into_iter() + .filter_map(|boundary| block.find(boundary)) + .min() + .unwrap_or(block.len()); + &block[..end] + } + + fn assert_shared_gate_wiring(block: &str, item: &str, actions: &[&str], binds_credentials: bool) { + assert_eq!( + block.matches("authorize_admin_request(").count(), + 1, + "{item} must use exactly one shared gate" + ); + assert_eq!( + block.matches("Action::AdminAction(").count(), + actions.len(), + "{item} must preserve its exact action-vector length" + ); + for action in actions { + assert!(block.contains(&format!("AdminAction::{action}")), "{item} must authorize with {action}"); + } + assert_eq!( + block.contains("let cred = authorize_admin_request("), + binds_credentials, + "{item} credential binding must match its payload-processing contract" + ); + } + + /// Pins the gate wiring itself: every pool handler authorizes through + /// `authorize_admin_request` with the same action vector it used before the + /// deduplication, and the mutating handlers keep deriving their audit actor + /// from the caller-supplied access key (rustfs/backlog#1829). + #[test] + fn pool_handlers_use_the_shared_admin_gate_with_their_actions() { + let production = include_str!("pools.rs") + .split("\n#[cfg(test)]\nmod ") + .next() + .expect("production source must precede the test module"); + + let read_actions = ["ServerInfoAdminAction", "DecommissionAdminAction"]; + let mutate_actions = ["DecommissionAdminAction"]; + for (handler, actions) in [ + ("ListPools", read_actions.as_slice()), + ("StatusPool", read_actions.as_slice()), + ("StatusDecommission", read_actions.as_slice()), + ("StartDecommission", mutate_actions.as_slice()), + ("CancelDecommission", mutate_actions.as_slice()), + ("ClearDecommission", mutate_actions.as_slice()), + ] { + let block = source_block(production, &format!("impl Operation for {handler}")); + assert_shared_gate_wiring(block, handler, actions, false); + } + + for handler in ["StartDecommission", "CancelDecommission", "ClearDecommission"] { + let block = source_block(production, &format!("impl Operation for {handler}")); + assert!( + block.contains("let actor = MaskedAccessKey(&input_cred.access_key).to_string();"), + "{handler} must keep masking the caller access key for its audit trail" + ); + } + + assert!(!production.contains("check_key_valid(get_session_token")); + assert!(!production.contains("validate_admin_request(")); + } } diff --git a/rustfs/src/admin/handlers/rebalance.rs b/rustfs/src/admin/handlers/rebalance.rs index 2e134d037..81eb853de 100644 --- a/rustfs/src/admin/handlers/rebalance.rs +++ b/rustfs/src/admin/handlers/rebalance.rs @@ -24,10 +24,9 @@ use crate::admin::storage_api::runtime::{ECStore, NotificationSys}; use crate::{ admin::runtime_sources::current_notification_system, admin::{ - auth::validate_admin_request, + auth::authorize_admin_request, router::{AdminOperation, Operation, S3Router}, }, - auth::{check_key_valid, get_session_token}, server::{ADMIN_PREFIX, RemoteAddr}, }; use http::{HeaderMap, HeaderValue, StatusCode, Uri}; @@ -497,23 +496,12 @@ impl Operation for RebalanceStart { "admin rebalance state" ); - let Some(input_cred) = req.credentials else { + let Some(input_cred) = req.credentials.as_ref() else { return Err(s3_error!(InvalidRequest, "authentication required")); }; - - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; let actor = MaskedAccessKey(&input_cred.access_key).to_string(); - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::RebalanceAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::RebalanceAdminAction)]).await?; if rebalance_query_present(&req.uri) { log_rebalance_request_rejected("start", "invalid_query_parameters", &request_id, &actor, &remote_addr); @@ -792,23 +780,12 @@ impl Operation for RebalanceStatus { "admin rebalance state" ); - let Some(input_cred) = req.credentials else { + let Some(input_cred) = req.credentials.as_ref() else { return Err(s3_error!(InvalidRequest, "authentication required")); }; - - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; let actor = MaskedAccessKey(&input_cred.access_key).to_string(); - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::RebalanceAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::RebalanceAdminAction)]).await?; let Some(store) = object_store_from_extensions(&req.extensions) else { return Err(s3_error!(InternalError, "object layer is not initialized")); @@ -924,23 +901,12 @@ impl Operation for RebalanceStop { "admin rebalance state" ); - let Some(input_cred) = req.credentials else { + let Some(input_cred) = req.credentials.as_ref() else { return Err(s3_error!(InvalidRequest, "authentication required")); }; - - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; let actor = MaskedAccessKey(&input_cred.access_key).to_string(); - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::RebalanceAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::RebalanceAdminAction)]).await?; if rebalance_query_present(&req.uri) { log_rebalance_request_rejected("stop", "invalid_query_parameters", &request_id, &actor, &remote_addr); @@ -1092,7 +1058,8 @@ mod rebalance_handler_tests { use super::build_rebalance_pool_progress; use super::calculate_rebalance_progress; use super::{ - RebalPoolProgress, RebalanceAdminStatus, RebalancePoolStatus, RebalanceStartStep, RebalanceStopPropagationStatus, + Body, HeaderMap, Method, Operation, Params, RebalPoolProgress, RebalanceAdminStatus, RebalancePoolStatus, RebalanceStart, + RebalanceStartStep, RebalanceStatus, RebalanceStop, RebalanceStopPropagationStatus, S3ErrorCode, S3Request, Uri, build_rebalance_admin_status, build_rebalance_pool_statuses, build_rebalance_stop_propagation_status, rebalance_pool_used, rebalance_query_present, rebalance_remaining_buckets, rebalance_rollback_failure_message, rebalance_rollback_stop_failure_message, rebalance_start_rollback_error, rebalance_start_steps, rebalance_stop_target_id, @@ -1104,6 +1071,29 @@ mod rebalance_handler_tests { }; use time::OffsetDateTime; + fn credential_less_request(method: Method, uri: &'static str) -> S3Request { + S3Request { + input: Body::empty(), + method, + uri: Uri::from_static(uri), + headers: HeaderMap::new(), + extensions: http::Extensions::new(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + } + } + + async fn assert_missing_credentials(operation: &dyn Operation, method: Method, uri: &'static str) { + let err = operation + .call(credential_less_request(method, uri), Params::new()) + .await + .expect_err("a rebalance admin request without credentials must fail"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some("authentication required")); + } + fn started_rebalance_meta(id: &str) -> RebalanceMeta { RebalanceMeta { id: id.to_string(), @@ -1928,4 +1918,74 @@ mod rebalance_handler_tests { vec!["peer node-b load_rebalance_meta(start=false) failed: timeout"] ); } + + /// The rebalance handlers pre-check credentials before delegating to the + /// shared admin gate, so a credential-less request keeps returning + /// `InvalidRequest: authentication required` rather than the gate's own + /// "get cred failed" wording (rustfs/backlog#1829). + #[tokio::test] + async fn rebalance_handlers_keep_their_missing_credentials_response() { + assert_missing_credentials(&RebalanceStart {}, Method::POST, "/rustfs/admin/v3/rebalance/start").await; + assert_missing_credentials(&RebalanceStatus {}, Method::GET, "/rustfs/admin/v3/rebalance/status").await; + assert_missing_credentials(&RebalanceStop {}, Method::POST, "/rustfs/admin/v3/rebalance/stop").await; + } + + fn source_block<'a>(production: &'a str, marker: &str) -> &'a str { + let block = production + .split_once(marker) + .unwrap_or_else(|| panic!("{marker} should exist")) + .1; + let end = [ + "\npub struct ", + "\nasync fn ", + "\npub(crate) async fn ", + "\nmod ", + "\n#[cfg(test)]", + ] + .into_iter() + .filter_map(|boundary| block.find(boundary)) + .min() + .unwrap_or(block.len()); + &block[..end] + } + + /// All three rebalance handlers authorize through the single shared gate with + /// the same `RebalanceAdminAction` vector they used before the deduplication, + /// and none of them binds the returned credentials (rustfs/backlog#1829). + #[test] + fn rebalance_handlers_use_the_shared_admin_gate_with_their_actions() { + let production = include_str!("rebalance.rs") + .split("\n#[cfg(test)]\nmod ") + .next() + .expect("production source must precede the test module"); + + for handler in ["RebalanceStart", "RebalanceStatus", "RebalanceStop"] { + let block = source_block(production, &format!("impl Operation for {handler}")); + assert_eq!( + block.matches("authorize_admin_request(").count(), + 1, + "{handler} must use exactly one shared gate" + ); + assert_eq!( + block.matches("Action::AdminAction(").count(), + 1, + "{handler} must preserve its exact action-vector length" + ); + assert!( + block.contains("AdminAction::RebalanceAdminAction"), + "{handler} must authorize with RebalanceAdminAction" + ); + assert!( + !block.contains("let cred = authorize_admin_request("), + "{handler} does not consume the authenticated credentials" + ); + assert!( + block.contains("let actor = MaskedAccessKey(&input_cred.access_key).to_string();"), + "{handler} must keep masking the caller access key for its audit trail" + ); + } + + assert!(!production.contains("check_key_valid(get_session_token")); + assert!(!production.contains("validate_admin_request(")); + } } diff --git a/rustfs/src/admin/handlers/replication.rs b/rustfs/src/admin/handlers/replication.rs index a198329f5..3e8868967 100644 --- a/rustfs/src/admin/handlers/replication.rs +++ b/rustfs/src/admin/handlers/replication.rs @@ -34,11 +34,11 @@ use crate::admin::storage_api::contract::bucket::{BucketOperations, BucketOption use crate::admin::storage_api::contract::list::ListOperations as _; use crate::admin::storage_api::error::StorageError; use crate::admin::storage_api::runtime::PeerRestClient; -use crate::admin::utils::read_compatible_admin_body; +use crate::admin::utils::{extract_query_params, read_compatible_admin_body}; use crate::error::ApiError; use crate::server::ADMIN_PREFIX; use crate::storage::storage_api::lock_bucket_targets_metadata; -use http::{HeaderMap, HeaderValue, Uri}; +use http::{HeaderMap, HeaderValue}; use hyper::{Method, StatusCode}; use jiff::Timestamp; use matchit::Params; @@ -116,18 +116,6 @@ fn site_endpoint_for(endpoint: &str, secure: bool) -> String { } } -fn extract_query_params(uri: &Uri) -> HashMap { - let mut params = HashMap::new(); - - if let Some(query) = uri.query() { - for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { - params.insert(key.into_owned(), value.into_owned()); - } - } - - params -} - fn map_bucket_target_error(err: BucketTargetError) -> S3Error { match err { BucketTargetError::BucketRemoteTargetNotFound { .. } diff --git a/rustfs/src/admin/handlers/scanner.rs b/rustfs/src/admin/handlers/scanner.rs index f932c3832..52fdfc777 100644 --- a/rustfs/src/admin/handlers/scanner.rs +++ b/rustfs/src/admin/handlers/scanner.rs @@ -24,10 +24,12 @@ use chrono::Utc; use http::{HeaderMap, HeaderValue}; use hyper::{Method, StatusCode}; use matchit::Params; -use rustfs_common::metrics::{ScannerLifecycleExpirySnapshot, ScannerMaintenanceControlSnapshot, ScannerMetricsReport}; use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE; use rustfs_credentials::Credentials; use rustfs_policy::policy::action::{Action, AdminAction}; +use rustfs_scanner_contracts::metrics::{ + ScannerLifecycleExpirySnapshot, ScannerMaintenanceControlSnapshot, ScannerMetricsReport, +}; use s3s::header::CONTENT_TYPE; use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error}; use serde::{Deserialize, Serialize}; diff --git a/rustfs/src/admin/handlers/site_replication.rs b/rustfs/src/admin/handlers/site_replication.rs index 3b7637dd5..067f6a5ab 100644 --- a/rustfs/src/admin/handlers/site_replication.rs +++ b/rustfs/src/admin/handlers/site_replication.rs @@ -15,14 +15,9 @@ use crate::admin::auth::authorize_admin_request; use crate::admin::router::{AdminOperation, Operation, S3Router}; use crate::admin::runtime_sources::{ - current_deployment_id, current_endpoints_handle, current_federated_identity_service, current_iam_handle, - current_object_store_handle, current_outbound_tls_generation, current_outbound_tls_state, current_region, - current_replication_pool_handle, current_replication_stats_handle, current_runtime_port, current_server_config, - current_token_signing_key, object_store_from_req, -}; -use crate::admin::site_replication_identity::{ - canonical_endpoint, deployment_id_for_endpoint, is_https_endpoint, mark_unknown_peer_sync_enabled, - normalize_peer_map_by_identity_with, same_identity_endpoint, site_identity_key, + current_deployment_id, current_federated_identity_service, current_iam_handle, current_object_store_handle, current_region, + current_replication_pool_handle, current_replication_stats_handle, current_server_config, current_token_signing_key, + object_store_from_req, }; use crate::admin::storage_api::bucket::metadata::{ BUCKET_CORS_CONFIG, BUCKET_LIFECYCLE_CONFIG, BUCKET_POLICY_CONFIG, BUCKET_QUOTA_CONFIG_FILE, BUCKET_REPLICATION_CONFIG, @@ -32,98 +27,74 @@ use crate::admin::storage_api::bucket::metadata_sys; use crate::admin::storage_api::bucket::quota::BucketQuota; use crate::admin::storage_api::bucket::replication; use crate::admin::storage_api::bucket::replication::{ - OperatorRuleContract, assign_site_replication_rule_priorities, is_site_replication_role, merge_incoming_replication_config, - replication_target_arn_deployment_id, site_replication_rule_deployment_id, + OperatorRuleContract, assign_site_replication_rule_priorities, merge_incoming_replication_config, + replication_target_arn_deployment_id, }; -use crate::admin::storage_api::bucket::target::{ARN, BucketTarget, BucketTargetType, BucketTargets, Credentials}; -use crate::admin::storage_api::bucket::target_sys::BucketTargetSys; +use crate::admin::storage_api::bucket::target::{BucketTarget, BucketTargetType, BucketTargets}; use crate::admin::storage_api::bucket::utils::{deserialize, serialize}; use crate::admin::storage_api::bucket::{AdminReplicationConfigExt as _, AdminVersioningConfigExt as _}; -use crate::admin::storage_api::config::read_admin_config; -#[cfg(test)] -use crate::admin::storage_api::config::save_admin_config; use crate::admin::storage_api::contract::bucket::{ BucketOperations, BucketOptions, DeleteBucketOptions, MakeBucketOptions, SRBucketDeleteOp, }; use crate::admin::storage_api::error::{Error as StorageError, is_err_bucket_not_found}; -use crate::admin::storage_api::runtime::ECStore; -use crate::admin::utils::{encode_compatible_admin_payload, read_compatible_admin_body}; -use crate::auth::constant_time_eq; -use crate::config::get_config_snapshot; +use crate::admin::utils::{empty_response, json_response, read_compatible_admin_body}; use crate::error::ApiError; use crate::server::ADMIN_PREFIX; -use crate::storage::storage_api::{ - delete_config_no_lock, lock_bucket_targets_metadata, read_config_no_lock, save_config_no_lock, with_config_object_read_lock, - with_config_object_write_lock, +use crate::site_replication::identity::{ + canonical_endpoint, is_https_endpoint, mark_unknown_peer_sync_enabled, same_identity_endpoint, site_identity_key, }; -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use crate::storage::storage_api::{lock_bucket_targets_metadata, with_config_object_write_lock}; +use base64_simd::URL_SAFE_NO_PAD; use futures::StreamExt; -use hmac::{Hmac, Mac}; -use http::header::{CONTENT_TYPE, HOST}; -use http::{HeaderMap, HeaderValue, Uri}; +use http::Uri; use hyper::{Method, StatusCode}; use matchit::Params; -use rustfs_config::{ - DEFAULT_CONSOLE_ADDRESS, DEFAULT_DELIMITER, DEFAULT_RUSTFS_TLS_PATH, ENV_RUSTFS_CONSOLE_ADDRESS, ENV_RUSTFS_TLS_PATH, - MAX_ADMIN_REQUEST_BODY_SIZE, -}; +use rustfs_config::{DEFAULT_DELIMITER, MAX_ADMIN_REQUEST_BODY_SIZE}; use rustfs_iam::error::is_err_no_such_service_account; use rustfs_iam::federation::OIDC_VIRTUAL_PARENT_CLAIM; use rustfs_iam::store::object::ObjectStore; -use rustfs_iam::store::{MappedPolicy, UserType, sr_wire_user_type, user_type_from_sr_wire}; +use rustfs_iam::store::user_type_from_sr_wire; use rustfs_iam::sys::{ IamSys, NewServiceAccountOpts, SITE_REPLICATOR_SERVICE_ACCOUNT, UpdateServiceAccountOpts, get_claims_from_token_with_secret, }; use rustfs_madmin::{ - AddOrUpdateUserReq, BucketBandwidth, GroupAddRemove, GroupStatus, IDPSettings, InProgressMetric, InQueueMetric, - LDAPConfigSettings, LDAPSettings, OpenIDProviderSettings, PeerInfo, PeerSite, QStat, ReplProxyMetric, ReplicateAddStatus, - ReplicateEditStatus, ReplicateRemoveStatus, ResyncBucketStatus, SITE_REPL_API_VERSION, SR_IAM_ITEM_STS_ACC, - SR_IAM_ITEM_STS_ACC_LEGACY, SRBucketInfo, SRBucketMeta, SRBucketStatsSummary, SRGroupInfo, SRGroupStatsSummary, SRIAMItem, - SRIAMPolicy, SRIAMUser, SRILMExpiryStatsSummary, SRInfo, SRMetric, SRMetricsSummary, SRPeerError, SRPeerJoinReq, - SRPendingOperation, SRPolicyMapping, SRPolicyStatsSummary, SRRemoveReq, SRResyncOpStatus, SRRetryStats, SRSTSCredential, - SRSessionPolicy, SRSiteSummary, SRStateEditReq, SRStateInfo, SRStatusInfo, SRSvcAccChange, SRSvcAccCreate, - SRUserStatsSummary, SiteReplicationInfo, SyncStatus, WorkerStat, + BucketBandwidth, GroupStatus, IDPSettings, InProgressMetric, InQueueMetric, LDAPConfigSettings, LDAPSettings, + OpenIDProviderSettings, PeerInfo, PeerSite, QStat, ReplProxyMetric, ReplicateAddStatus, ReplicateEditStatus, + ReplicateRemoveStatus, ResyncBucketStatus, SITE_REPL_API_VERSION, SR_IAM_ITEM_STS_ACC, SR_IAM_ITEM_STS_ACC_LEGACY, + SRBucketMeta, SRBucketStatsSummary, SRGroupInfo, SRGroupStatsSummary, SRIAMItem, SRIAMUser, SRILMExpiryStatsSummary, SRInfo, + SRMetric, SRMetricsSummary, SRPeerError, SRPeerJoinReq, SRPendingOperation, SRPolicyMapping, SRPolicyStatsSummary, + SRRemoveReq, SRResyncOpStatus, SRSTSCredential, SRSessionPolicy, SRSiteSummary, SRStateEditReq, SRStateInfo, SRStatusInfo, + SRSvcAccChange, SRSvcAccCreate, SRUserStatsSummary, SiteReplicationInfo, SyncStatus, WorkerStat, }; use rustfs_policy::policy::{ Policy, action::{Action, AdminAction}, }; -use rustfs_signer::constants::UNSIGNED_PAYLOAD; -use rustfs_signer::sign_v4; -use rustfs_tls_runtime::GlobalPublishedOutboundTlsState; -use rustfs_utils::egress::{OutboundUrlError, validate_outbound_url}; -use rustfs_utils::http::get_source_scheme; -use rustls_pki_types::pem::PemObject; use s3s::dto::{ - BucketVersioningStatus, DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication, DeleteReplicationStatus, - Destination, ExistingObjectReplication, ExistingObjectReplicationStatus, ReplicaModifications, ReplicaModificationsStatus, - ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus, SourceSelectionCriteria, VersioningConfiguration, + DeleteMarkerReplicationStatus, DeleteReplicationStatus, ExistingObjectReplicationStatus, ReplicaModificationsStatus, + ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus, }; use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error}; use serde::Deserialize; use serde::Serialize; -use serde::de::{DeserializeOwned, IgnoredAny}; +use serde::de::DeserializeOwned; use serde_json::Value; -use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; -use std::net::{IpAddr, SocketAddr}; -use std::sync::{Arc, LazyLock, Mutex as StdMutex}; +use std::sync::{LazyLock, Mutex as StdMutex}; use std::time::Duration; use time::OffsetDateTime; -use tokio::sync::{Mutex, RwLock}; +use tokio::sync::Mutex; use tracing::{info, warn}; -use url::{Url, form_urlencoded}; +use url::form_urlencoded; use uuid::Uuid; -const LOG_COMPONENT_ADMIN: &str = "admin"; -const LOG_SUBSYSTEM_SITE_REPLICATION: &str = "site_replication"; -const EVENT_ADMIN_SITE_REPLICATION_STATE: &str = "admin_site_replication_state"; +// The site-replication service subsystem (state, peer transport, retry queue, +// repair state machine, broadcast hooks) lives in `crate::site_replication` +// (backlog#1840); re-export it so existing `admin::handlers::site_replication` +// paths keep resolving while this file keeps only the HTTP handlers. +pub(crate) use crate::site_replication::*; + const SERVICE_ACCOUNT_ENVELOPE_VERSION: u64 = 2; -use crate::admin::site_replication_state::{SITE_REPLICATION_STATE_PATH, with_site_replication_state_lock}; -const SITE_REPLICATION_REPAIR_STATE_PATH: &str = "config/site-replication/repair-state.json"; -const SITE_REPLICATION_REPAIR_EXECUTION_LOCK_PATH: &str = "config/site-replication/repair-execution.lock"; // Serializes peer-join admission (staleness check -> IAM upsert -> state // commit) across every node of this site; see admit_peer_join. Never an // actual object — only a namespace-lock key, like the repair execution lock. @@ -140,50 +111,22 @@ const SITE_REPL_RESYNC_CANCEL: &str = "cancel"; const SITE_REPL_RESYNC_STATUS: &str = "status"; const SITE_REPL_RESYNC_DEFAULT_PAGE_SIZE: usize = 100; const SITE_REPL_RESYNC_MAX_PAGE_SIZE: usize = 1000; -const SITE_REPLICATION_PEER_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); -const SITE_REPLICATION_PEER_CONNECT_TIMEOUT: Duration = Duration::from_secs(3); /// Bound on waiting for the lifecycle lock (below). 3x the peer request /// timeout: outlives one full peer round of a healthy concurrent lifecycle /// operation, while converting a holder wedged on unreachable peers into a /// retryable 503 for the waiter instead of an unbounded hang. const SITE_REPLICATION_LIFECYCLE_LOCK_TIMEOUT: Duration = Duration::from_secs(30); -const SITE_REPLICATION_PEER_ERROR_DETAIL_LIMIT: usize = 256; const SITE_REPLICATION_INITIAL_SYNC_ERROR_LIMIT: usize = 32; -const MAX_PEER_CA_CERT_PEM_SIZE: usize = 256 * 1024; -const ALLOW_LOOPBACK_REPLICATION_TARGET_ENV: &str = "RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET"; -const SITE_REPLICATION_RETRY_QUEUE_LIMIT: usize = 256; -const SITE_REPLICATION_RETRY_FAILED_AFTER: u32 = 3; -const SITE_REPLICATION_REPAIR_OPERATION_LIMIT: usize = 32; -const SITE_REPLICATION_REPAIR_IAM_FAMILY: &str = "iam"; -const SITE_REPLICATION_REPAIR_BUCKET_FAMILY: &str = "bucket"; -const SITE_REPLICATION_REPAIR_BUCKET_METADATA_FAMILY: &str = "bucket-metadata"; -const SITE_REPLICATION_REPAIR_REPLICATION_FAMILY: &str = "replication"; -const SITE_REPLICATION_PEER_BUCKET_OPS_PATH: &str = "/rustfs/admin/v3/site-replication/peer/bucket-ops"; -const SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING: &str = "make-with-versioning"; -const SITE_REPLICATION_BUCKET_OP_CONFIGURE_REPLICATION: &str = "configure-replication"; const IDENTITY_LDAP_SUB_SYS: &str = "identity_ldap"; const LEGACY_LDAP_SUB_SYS: &str = "ldapserverconfig"; const SITE_REPLICATION_PEER_JOIN_PATH: &str = "/rustfs/admin/v3/site-replication/peer/join"; -const SITE_REPLICATION_PEER_EDIT_PATH: &str = "/rustfs/admin/v3/site-replication/peer/edit"; const SITE_REPLICATION_PEER_EDIT_CAPABILITY_PATH: &str = "/rustfs/admin/v3/site-replication/peer/edit-capabilities?capability=endpoint-target-refresh"; const SITE_REPLICATION_PEER_TLS_CAPABILITY_PATH: &str = "/rustfs/admin/v3/site-replication/peer/edit-capabilities?capability=peer-tls-settings"; -const SITE_REPLICATION_PEER_DERIVED_RULE_CONTRACT_CAPABILITY_PATH: &str = - "/rustfs/admin/v3/site-replication/peer/edit-capabilities?capability=derived-rule-contract"; const SITE_REPLICATION_PEER_EDIT_REFRESH_PATH: &str = "/rustfs/admin/v3/site-replication/peer/edit?refresh-targets=true"; -/// Peer-edit fencing token, carried as query parameters so a peer that predates -/// the fence simply ignores them (unknown query keys are dropped) and keeps the -/// previous last-writer-wins behaviour. -const SITE_REPLICATION_EDIT_ORIGIN_QUERY: &str = "editOrigin"; -const SITE_REPLICATION_EDIT_GENERATION_QUERY: &str = "editGeneration"; -const SITE_REPLICATION_ENDPOINT_REFRESH_RETRY_PATH: &str = "internal:endpoint-target-refresh"; const SITE_REPLICATION_PEER_REMOVE_PATH: &str = "/rustfs/admin/v3/site-replication/peer/remove"; const SITE_REPLICATION_DEVNULL_PATH: &str = "/rustfs/admin/v3/site-replication/devnull"; -const RUSTFS_ADMIN_V3_PREFIX: &str = "/rustfs/admin/v3"; -const MINIO_ADMIN_V3_PREFIX: &str = "/minio/admin/v3"; -const MINIO_SITE_REPLICATION_PEER_JOIN_PATH: &str = "/minio/admin/v3/site-replication/peer/join"; - fn site_replicator_service_account_policy() -> S3Result { Policy::parse_config( br#"{ @@ -243,144 +186,13 @@ fn site_replicator_service_account_policy() -> S3Result { .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("parse site replicator policy failed: {e}"))) } -#[derive(Clone)] -enum SiteReplicationPeerClientCacheEntry { - Ready(reqwest::Client), - Failed(String), -} - -#[derive(Clone)] -struct SiteReplicationPeerClientCache { - generation: u64, - entry: SiteReplicationPeerClientCacheEntry, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct PeerConnection { - endpoint: Url, - skip_tls_verify: bool, - ca_cert_pem: String, -} - -#[derive(Deserialize, Default)] -struct PeerTlsFieldPresence { - #[serde(rename = "skipTlsVerify")] - skip_tls_verify: Option, - #[serde(rename = "caCertPem")] - ca_cert_pem: Option, -} - -impl PeerTlsFieldPresence { - fn has_skip_tls_verify(&self) -> bool { - self.skip_tls_verify.is_some() - } - - fn has_ca_cert_pem(&self) -> bool { - self.ca_cert_pem.is_some() - } -} - -#[derive(Clone)] -struct PeerDnsResolver { - allow_loopback: bool, - #[cfg(test)] - overrides: Option>>>, -} - -impl PeerDnsResolver { - fn new(allow_loopback: bool) -> Self { - Self { - allow_loopback, - #[cfg(test)] - overrides: None, - } - } - - #[cfg(test)] - fn with_overrides(allow_loopback: bool, overrides: HashMap>) -> Self { - Self { - allow_loopback, - overrides: Some(Arc::new(overrides)), - } - } -} - -impl reqwest::dns::Resolve for PeerDnsResolver { - fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving { - let host = name.as_str().to_string(); - let allow_loopback = self.allow_loopback; - #[cfg(test)] - let overrides = self.overrides.clone(); - Box::pin(async move { - #[cfg(test)] - let overridden = overrides.as_ref().and_then(|entries| entries.get(&host)).cloned(); - #[cfg(not(test))] - let overridden: Option> = None; - - let ips = if let Some(ips) = overridden { - ips - } else { - tokio::net::lookup_host((host.as_str(), 0)) - .await? - .map(|addr| addr.ip()) - .collect() - }; - let addrs = ips - .into_iter() - .filter(|ip| resolved_peer_ip_allowed(&host, *ip, allow_loopback)) - .map(|ip| SocketAddr::new(ip, 0)) - .collect::>(); - if addrs.is_empty() { - return Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - format!("site replication DNS resolution for `{host}` returned no allowed addresses"), - ) - .into()); - } - Ok(Box::new(addrs.into_iter()) as reqwest::dns::Addrs) - }) - } -} - -impl PeerConnection { - fn new(endpoint: &str, skip_tls_verify: bool, ca_cert_pem: &str) -> S3Result { - validate_peer_connection_inner(endpoint, skip_tls_verify, ca_cert_pem, loopback_replication_targets_allowed()) - } - - fn endpoint(&self) -> &str { - self.endpoint.as_str().trim_end_matches('/') - } - - fn uses_default_tls(&self) -> bool { - !self.skip_tls_verify && self.ca_cert_pem.is_empty() - } -} - -impl TryFrom<&PeerInfo> for PeerConnection { - type Error = S3Error; - - fn try_from(peer: &PeerInfo) -> Result { - Self::new(&peer.endpoint, peer.skip_tls_verify, &peer.ca_cert_pem) - } -} - -impl TryFrom<&PeerSite> for PeerConnection { - type Error = S3Error; - - fn try_from(site: &PeerSite) -> Result { - Self::new(&site.endpoint, site.skip_tls_verify, &site.ca_cert_pem) - } -} - -static SITE_REPLICATION_PEER_CLIENT: LazyLock>> = LazyLock::new(|| Mutex::new(None)); // Lock order: lifecycle -> bucket operation -> repair admission -> state -> per-bucket metadata. // "state" is the distributed state-object lock in -// crate::admin::site_replication_state, entered through +// crate::site_replication::state_lock, entered through // update_site_replication_state (P1-15). There is no process-local state // mutex any more: it could not order two nodes of one site, and the call // sites that needed ordering carry a generation fence instead. static SITE_REPLICATION_LIFECYCLE_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); -static SITE_REPLICATION_BUCKET_OP_LOCK: LazyLock> = LazyLock::new(|| RwLock::new(())); static SITE_REPLICATION_ADD_BOOTSTRAP: LazyLock>> = LazyLock::new(|| StdMutex::new(None)); @@ -462,212 +274,6 @@ fn bootstrap_peer_bucket_operation_allowed(bucket: &str, operation: &str, bootst }) } -fn site_replication_peer_client_cache_hit( - cache: &Option, - generation: u64, -) -> Option> { - let cached = cache.as_ref()?; - if cached.generation != generation { - return None; - } - Some(match &cached.entry { - SiteReplicationPeerClientCacheEntry::Ready(client) => Ok(client.clone()), - SiteReplicationPeerClientCacheEntry::Failed(err) => Err(S3Error::with_message( - S3ErrorCode::InternalError, - format!("initialize site replication peer client failed: {err}"), - )), - }) -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -struct SiteReplicationState { - name: String, - service_account_access_key: String, - #[serde(default, skip_serializing)] - service_account_secret_key: String, - service_account_parent: String, - peers: BTreeMap, - updated_at: Option, - resync_status: BTreeMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - pending_rotation: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pending_remove: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pending_endpoint_refresh: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - retry_queue: Vec, - #[serde(default)] - sync_state_initialized: bool, - /// Fencing token for peer-edit delivery, allocated inside the state - /// transaction (the distributed state-object lock). Two nodes of THIS - /// site that accept admin edits concurrently therefore get strictly - /// ordered generations, and a delivery that stalls can be recognised as - /// stale by the receiving site. - #[serde(default)] - edit_generation: u64, - /// Per-origin high-water mark of the peer edits already applied here, - /// keyed by the origin site's deployment id. A delivery whose generation - /// is not above the mark arrived out of order and must not overwrite the - /// newer edit that already landed. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - applied_edit_generations: BTreeMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -struct SiteReplicationRepairState { - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - operations: BTreeMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -struct SiteReplicationRepairOperation { - operation_id: String, - preflight_token: String, - plan_token: String, - status: String, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - sites: BTreeMap, - #[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")] - created_at: Option, - #[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")] - updated_at: Option, - #[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")] - completed_at: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -struct SiteReplicationRepairSiteStatus { - deployment_id: String, - name: String, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - families: BTreeMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -struct SiteReplicationRepairFamilyStatus { - planned: usize, - succeeded: usize, - failed: usize, - #[serde(default)] - retry_events: usize, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - tasks: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - errors: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -struct SiteReplicationRepairTaskStatus { - task_id: String, - status: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - error: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct SiteReplicationRepairRequest { - mode: SiteReplicationRepairMode, - #[serde(default)] - preflight_token: Option, - #[serde(default)] - operation_id: Option, -} - -#[derive(Debug, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -enum SiteReplicationRepairMode { - DryRun, - Execute, -} - -struct SiteReplicationRepairExecutionRequest { - local_peer: PeerInfo, - preflight_token: String, - operation_id: String, - signing_key: String, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct SiteReplicationRepairPreflight { - mode: &'static str, - status: &'static str, - preflight_token: String, - retry_events: usize, - sites: BTreeMap, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct SiteReplicationRepairOperationResponse { - mode: &'static str, - operation_id: String, - status: String, - sites: BTreeMap, - #[serde(with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")] - created_at: Option, - #[serde(with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")] - updated_at: Option, - #[serde(with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")] - completed_at: Option, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct SiteReplicationRepairSiteResponse { - deployment_id: String, - name: String, - families: BTreeMap, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct SiteReplicationRepairFamilyResponse { - planned: usize, - succeeded: usize, - failed: usize, - retry_events: usize, - tasks: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - errors: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -struct SiteReplicationRetryEvent { - id: String, - peer_deployment_id: String, - peer_endpoint: String, - path: String, - retry_count: u32, - failed: bool, - last_error: String, - #[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")] - updated_at: Option, - /// Peer-edit generation whose delivery failed, when the failing send - /// carried one. Settling a *later* success for the same (peer, path) must - /// not erase a failure recorded for a NEWER generation — see - /// [`settle_site_replication_retry_events`]. - #[serde(default, skip_serializing_if = "Option::is_none")] - edit_generation: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -struct PendingEndpointRefresh { - id: String, - peer: PeerInfo, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - remote_peers: BTreeMap, - #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] - acked_deployment_ids: BTreeSet, -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct EndpointRefreshRequest { @@ -675,43 +281,6 @@ struct EndpointRefreshRequest { peer: PeerInfo, } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -struct PendingRotation { - id: String, - access_key: String, - parent: String, - new_secret_key: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - secret_candidates: Vec, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - peers: BTreeMap, - #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] - acked_deployment_ids: BTreeSet, - #[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")] - updated_at: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -struct PendingRemove { - id: String, - req: SRRemoveReq, - service_account_access_key: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - secret_candidates: Vec, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - original_peers: BTreeMap, - #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] - acked_deployment_ids: BTreeSet, - #[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")] - updated_at: Option, -} - -struct SiteReplicationRuntime { - state: SiteReplicationState, - local_peer: PeerInfo, - service_account_secret_key: String, -} - #[derive(Debug, Clone)] struct SiteReplicationAddPreflightInfo { name: String, @@ -724,14 +293,6 @@ struct SiteReplicationAddPreflightInfo { idp_settings: serde_json::Value, } -#[derive(Debug, Default)] -struct SiteReplicationBootstrapPlan { - iam_items: Vec, - bucket_make_ops: Vec, - bucket_items: Vec, - bucket_configure_ops: Vec, -} - #[derive(Debug, Clone, Serialize, Deserialize, Default)] struct SRPeerJoinResponse { peer: PeerInfo, @@ -818,12 +379,6 @@ struct SiteNetPerfNodeResult { error: String, } -impl SiteReplicationState { - fn enabled(&self) -> bool { - self.peers.len() > 1 - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] enum SREntityType { #[default] @@ -970,14 +525,6 @@ fn reject_site_replicator_on_public_admin(cred: &rustfs_credentials::Credentials Ok(()) } -fn json_response(value: &T) -> S3Result> { - let data = serde_json::to_vec(value) - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("failed to serialize response: {e}")))?; - let mut headers = HeaderMap::new(); - headers.insert(s3s::header::CONTENT_TYPE, HeaderValue::from_static("application/json")); - Ok(S3Response::with_headers((StatusCode::OK, Body::from(data)), headers)) -} - fn go_gob_site_netperf_response(value: &SiteNetPerfNodeResult) -> S3Response<(StatusCode, Body)> { let data = encode_go_gob_site_netperf_node_result(value); S3Response::new((StatusCode::OK, Body::from(data))) @@ -1059,10 +606,6 @@ fn write_go_gob_uint(out: &mut Vec, value: u64) { out.extend_from_slice(used); } -fn empty_response(status: StatusCode) -> S3Response<(StatusCode, Body)> { - S3Response::new((status, Body::empty())) -} - async fn read_plain_admin_body(mut input: Body) -> S3Result> { let body = input .store_all_limited(MAX_ADMIN_REQUEST_BODY_SIZE) @@ -1097,489 +640,6 @@ fn parse_public_peer_edit(body: &[u8]) -> S3Result<(PeerInfo, PeerTlsFieldPresen Ok((parse_site_replication_json(body)?, parse_site_replication_json(body)?)) } -fn parse_site_replication_state(data: &[u8]) -> S3Result { - let mut state: SiteReplicationState = serde_json::from_slice(data) - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("invalid site replication state: {e}")))?; - state.peers = normalize_peer_map_by_identity(state.peers); - // A peer-edit high-water mark only fences a CURRENT peer. A site that - // leaves drops below two peers, which clears its own state object and - // restarts its generation counter — a mark left over from the previous - // membership must not reject the edits it sends after it rejoins. This - // pruning covers departures THIS site observed; an origin removed - // unilaterally elsewhere stays in this peer map with its mark, and the - // wall-clock floor in `next_peer_edit_generation` is what lifts its - // restarted counter over that mark. Dropping departed origins on load - // also keeps the map bounded. - state - .applied_edit_generations - .retain(|origin, _| state.peers.contains_key(origin)); - if !state.sync_state_initialized { - if state.enabled() { - mark_unknown_peer_sync_enabled(&mut state.peers); - } - state.sync_state_initialized = true; - } - Ok(state) -} - -async fn load_site_replication_state() -> S3Result { - let Some(store) = current_object_store_handle() else { - return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); - }; - - match read_admin_config(store, SITE_REPLICATION_STATE_PATH).await { - Ok(data) => parse_site_replication_state(&data), - Err(StorageError::ConfigNotFound) => Ok(SiteReplicationState::default()), - Err(err) => Err(S3Error::with_message( - S3ErrorCode::InternalError, - format!("failed to load site replication state: {err}"), - )), - } -} - -/// Whether this deployment participates in site replication (two or more -/// peers in the persisted state). Read by the S3 interface layer to gate -/// replication-config edits (MinIO `ErrReplicationDenyEditError` semantics, -/// issue #1948); a state-read failure propagates so the gate fails closed. -pub(crate) async fn site_replication_enabled() -> S3Result { - Ok(load_site_replication_state().await?.enabled()) -} - -/// Deployment ids of the remote peers the reconciler derives a -/// `site-repl-` rule for on every bucket (the same peer filter as -/// `build_site_replication_config`); empty when site replication is not -/// enabled. Read by the bucket usecase so an S3 replication-config edit keeps -/// exactly the reconciler-owned rules (issue #1948); a state-read failure -/// propagates so the edit fails closed. -pub(crate) async fn site_replication_edit_context() -> S3Result<(HashSet, OperatorRuleContract)> { - let Some(runtime) = runtime_site_replication_targets().await? else { - // Enabled without a service account is a state this site cannot - // broadcast from either; the peers are still the reconciler's. - let state = load_site_replication_state().await?; - if !state.enabled() { - return Ok((HashSet::new(), OperatorRuleContract::Derived)); - } - let peers = remote_peer_deployment_ids(&state, ¤t_local_runtime_peer(&state)); - return Ok((peers, OperatorRuleContract::Legacy)); - }; - let peers = remote_peer_deployment_ids(&runtime.state, &runtime.local_peer); - let contract = site_replication_operator_rule_contract(&runtime).await; - Ok((peers, contract)) -} - -/// Whether every remote peer merges replication configs under the derived -/// contract, probed through the peer capability endpoint. A peer that does -/// not (or cannot be asked) pins the cluster to [`OperatorRuleContract::Legacy`] -/// for this edit: consistency across sites wins over keeping the operator's -/// priority values, and the legacy merge keeps their order anyway. -async fn site_replication_operator_rule_contract(runtime: &SiteReplicationRuntime) -> OperatorRuleContract { - let remote_peers: Vec<&PeerInfo> = runtime - .state - .peers - .values() - .filter(|peer| { - peer.deployment_id != runtime.local_peer.deployment_id - && !same_identity_endpoint(&peer.endpoint, &runtime.local_peer.endpoint) - }) - .collect(); - let probes = futures::future::join_all(remote_peers.iter().map(|peer| async move { - let transport = PeerTransport::for_runtime_peer(peer).await?; - let (status, body) = send_peer_admin_request_raw_with_client( - &transport.client, - &transport.connection, - SITE_REPLICATION_PEER_DERIVED_RULE_CONTRACT_CAPABILITY_PATH, - &runtime.state.service_account_access_key, - &runtime.service_account_secret_key, - &(), - ) - .await?; - peer_capability_response_supported(peer, status, &body) - })) - .await; - operator_rule_contract_from_probes(remote_peers.into_iter().zip(probes)) -} - -fn operator_rule_contract_from_probes<'a>( - probes: impl IntoIterator)>, -) -> OperatorRuleContract { - for (peer, probe) in probes { - match probe { - Ok(true) => {} - Ok(false) => return OperatorRuleContract::Legacy, - Err(err) => { - warn!( - event = EVENT_ADMIN_SITE_REPLICATION_STATE, - component = LOG_COMPONENT_ADMIN, - subsystem = LOG_SUBSYSTEM_SITE_REPLICATION, - result = "derived_rule_contract_probe_failed", - peer = %peer.endpoint, - error = %err, - "admin site replication state" - ); - return OperatorRuleContract::Legacy; - } - } - } - OperatorRuleContract::Derived -} - -fn remote_peer_deployment_ids(state: &SiteReplicationState, local_peer: &PeerInfo) -> HashSet { - state - .peers - .values() - .filter(|peer| { - peer.deployment_id != local_peer.deployment_id && !same_identity_endpoint(&peer.endpoint, &local_peer.endpoint) - }) - .map(|peer| peer.deployment_id.clone()) - .collect() -} - -/// Deployment ids of every site in the cluster, this one included: the set -/// a peer's derived rules can name (its rule towards this site carries this -/// site's id). Empty when site replication is not enabled. -async fn site_replication_deployment_ids() -> S3Result> { - let state = load_site_replication_state().await?; - if !state.enabled() { - return Ok(HashSet::new()); - } - Ok(state.peers.values().map(|peer| peer.deployment_id.clone()).collect()) -} - -async fn load_site_replication_state_no_lock(store: Arc) -> S3Result { - match read_config_no_lock(store, SITE_REPLICATION_STATE_PATH).await { - Ok(data) => parse_site_replication_state(&data), - Err(StorageError::ConfigNotFound) => Ok(SiteReplicationState::default()), - Err(err) => Err(S3Error::with_message( - S3ErrorCode::InternalError, - format!("failed to load site replication state: {err}"), - )), - } -} - -/// Persist-or-clear under an already-held state object lock. Normalizes the -/// peer map exactly once (the historical persist path normalized twice with -/// two full clones — P2-22). -async fn persist_site_replication_state_no_lock(store: Arc, mut state: SiteReplicationState) -> S3Result<()> { - state.peers = normalize_peer_map_by_identity(state.peers); - if state.peers.len() <= 1 && state.pending_rotation.is_none() && state.pending_remove.is_none() { - match delete_config_no_lock(store, SITE_REPLICATION_STATE_PATH).await { - Ok(()) | Err(StorageError::ConfigNotFound) => Ok(()), - Err(err) => Err(S3Error::with_message(S3ErrorCode::InternalError, format!("clear state failed: {err}"))), - } - } else { - let data = serde_json::to_vec(&state) - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize state failed: {e}")))?; - save_config_no_lock(store, SITE_REPLICATION_STATE_PATH, data) - .await - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("save state failed: {e}"))) - } -} - -/// What a state transaction closure decided to do with the state it was -/// handed. `Unchanged` skips the write entirely: the ack markers and the -/// pending-clearing paths run on every retry and mostly find their pending id -/// already gone, and the retry queue shares this object — rewriting it byte -/// for byte only makes those misses contend with the writers that do have -/// something to say. -enum StateCommit { - Changed(T), - Unchanged(T), -} - -/// The site-replication state RMW transaction: load, mutate, persist — all -/// under the distributed state-object write lock (see -/// crate::admin::site_replication_state). No peer network calls and no other -/// config locks inside `update`; anything that has to talk to a peer belongs -/// between two transactions, with the precondition re-checked inside the -/// second one. -async fn update_site_replication_state(update: F) -> S3Result -where - T: Send + 'static, - F: FnOnce(&mut SiteReplicationState) -> S3Result + Send + 'static, -{ - update_site_replication_state_when_changed(move |state| update(state).map(StateCommit::Changed)).await -} - -/// [`update_site_replication_state`] for closures that may find nothing to -/// do — see [`StateCommit`]. -async fn update_site_replication_state_when_changed(update: F) -> S3Result -where - T: Send + 'static, - F: FnOnce(&mut SiteReplicationState) -> S3Result> + Send + 'static, -{ - with_site_replication_state_lock(move || async move { - let store = current_object_store_handle() - .ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()))?; - let mut state = load_site_replication_state_no_lock(store.clone()).await?; - match update(&mut state)? { - StateCommit::Changed(result) => { - persist_site_replication_state_no_lock(store, state).await?; - Ok(result) - } - StateCommit::Unchanged(result) => Ok(result), - } - }) - .await -} - -async fn load_site_replication_repair_state_from_store(store: Arc) -> S3Result { - match read_config_no_lock(store, SITE_REPLICATION_REPAIR_STATE_PATH).await { - Ok(data) => serde_json::from_slice(&data).map_err(|e| { - S3Error::with_message(S3ErrorCode::InternalError, format!("invalid site replication repair state: {e}")) - }), - Err(StorageError::ConfigNotFound) => Ok(SiteReplicationRepairState::default()), - Err(err) => Err(S3Error::with_message( - S3ErrorCode::InternalError, - format!("failed to load site replication repair state: {err}"), - )), - } -} - -async fn save_site_replication_repair_state_to_store(store: Arc, state: &SiteReplicationRepairState) -> S3Result<()> { - let data = serde_json::to_vec(state) - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize repair state failed: {e}")))?; - save_config_no_lock(store, SITE_REPLICATION_REPAIR_STATE_PATH, data) - .await - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("save repair state failed: {e}"))) -} - -async fn read_site_replication_repair_state() -> S3Result { - let store = - current_object_store_handle().ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()))?; - let read_store = store.clone(); - with_config_object_read_lock(store, SITE_REPLICATION_REPAIR_STATE_PATH.to_string(), move || async move { - load_site_replication_repair_state_from_store(read_store).await - }) - .await - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("lock repair state failed: {e}")))? -} - -async fn update_site_replication_repair_state(update: F) -> S3Result -where - T: Send + 'static, - F: FnOnce(&mut SiteReplicationRepairState) -> S3Result + Send + 'static, -{ - let store = - current_object_store_handle().ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()))?; - let read_store = store.clone(); - let save_store = store.clone(); - with_config_object_write_lock(store, SITE_REPLICATION_REPAIR_STATE_PATH.to_string(), move || async move { - let mut state = load_site_replication_repair_state_from_store(read_store).await?; - let result = update(&mut state)?; - save_site_replication_repair_state_to_store(save_store, &state).await?; - Ok(result) - }) - .await - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("lock repair state failed: {e}")))? -} - -/// Test-only seeding of the state object. Every production write goes through -/// [`update_site_replication_state`] — this helper is `cfg(test)` so a new -/// call site cannot reintroduce the pre-P1-15 shape (load through one object -/// lock, save through another, with the mutation in between unprotected). -#[cfg(test)] -async fn save_site_replication_state(state: &SiteReplicationState) -> S3Result<()> { - let Some(store) = current_object_store_handle() else { - return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); - }; - - let mut normalized = state.clone(); - normalized.peers = normalize_peer_map_by_identity(normalized.peers); - - let data = serde_json::to_vec(&normalized) - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize state failed: {e}")))?; - save_admin_config(store, SITE_REPLICATION_STATE_PATH, data) - .await - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("save state failed: {e}")))?; - Ok(()) -} - -fn build_site_replication_peer_client(outbound_tls: &GlobalPublishedOutboundTlsState) -> S3Result { - build_site_replication_peer_client_with_resolver(outbound_tls, PeerDnsResolver::new(loopback_replication_targets_allowed())) -} - -fn build_site_replication_peer_client_with_resolver( - outbound_tls: &GlobalPublishedOutboundTlsState, - resolver: PeerDnsResolver, -) -> S3Result { - let mut builder = reqwest::Client::builder() - .no_proxy() - .timeout(SITE_REPLICATION_PEER_REQUEST_TIMEOUT) - .connect_timeout(SITE_REPLICATION_PEER_CONNECT_TIMEOUT) - .pool_idle_timeout(Some(Duration::from_secs(60))) - .redirect(reqwest::redirect::Policy::none()) - .dns_resolver(resolver); - - if let Some(root_ca_pem) = outbound_tls.root_ca_pem.as_ref() { - let mut reader = std::io::BufReader::new(root_ca_pem.as_slice()); - let certs_der = rustls_pki_types::CertificateDer::pem_reader_iter(&mut reader) - .collect::, _>>() - .map_err(|e| { - S3Error::with_message( - S3ErrorCode::InternalError, - format!("failed to parse published site-replication CA certs: {e}"), - ) - })?; - - for cert_der in certs_der { - let cert = reqwest::Certificate::from_der(cert_der.as_ref()).map_err(|e| { - S3Error::with_message( - S3ErrorCode::InternalError, - format!("failed to load published site-replication CA cert: {e}"), - ) - })?; - builder = builder.add_root_certificate(cert); - } - } - - builder - .build() - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("build site replication peer client failed: {e}"))) -} - -fn build_custom_site_replication_peer_client( - outbound_tls: &GlobalPublishedOutboundTlsState, - connection: &PeerConnection, -) -> S3Result { - build_custom_site_replication_peer_client_with_resolver( - outbound_tls, - connection, - PeerDnsResolver::new(loopback_replication_targets_allowed()), - ) -} - -fn build_custom_site_replication_peer_client_with_resolver( - outbound_tls: &GlobalPublishedOutboundTlsState, - connection: &PeerConnection, - resolver: PeerDnsResolver, -) -> S3Result { - let mut builder = reqwest::Client::builder() - .no_proxy() - .timeout(SITE_REPLICATION_PEER_REQUEST_TIMEOUT) - .connect_timeout(SITE_REPLICATION_PEER_CONNECT_TIMEOUT) - .pool_idle_timeout(Some(Duration::from_secs(60))) - .redirect(reqwest::redirect::Policy::none()) - .dns_resolver(resolver) - .danger_accept_invalid_certs(connection.skip_tls_verify); - - if let Some(root_ca_pem) = outbound_tls.root_ca_pem.as_ref() { - let mut reader = std::io::BufReader::new(root_ca_pem.as_slice()); - let certs_der = rustls_pki_types::CertificateDer::pem_reader_iter(&mut reader) - .collect::, _>>() - .map_err(|e| { - S3Error::with_message( - S3ErrorCode::InternalError, - format!("failed to parse published site-replication CA certs: {e}"), - ) - })?; - for cert_der in certs_der { - let cert = reqwest::Certificate::from_der(cert_der.as_ref()).map_err(|e| { - S3Error::with_message( - S3ErrorCode::InternalError, - format!("failed to load published site-replication CA cert: {e}"), - ) - })?; - builder = builder.add_root_certificate(cert); - } - } - if !connection.ca_cert_pem.is_empty() { - for cert in parse_peer_ca_certificates(&connection.ca_cert_pem)? { - builder = builder.add_root_certificate(cert); - } - } - - builder - .build() - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("build site replication peer client failed: {e}"))) -} - -async fn site_replication_peer_client() -> S3Result { - let generation = current_outbound_tls_generation().0; - let cache = SITE_REPLICATION_PEER_CLIENT.lock().await; - if let Some(hit) = site_replication_peer_client_cache_hit(&cache, generation) { - return hit; - } - drop(cache); - - let outbound_tls = current_outbound_tls_state().await; - let built = build_site_replication_peer_client(&outbound_tls); - let cache_entry = match &built { - Ok(client) => SiteReplicationPeerClientCacheEntry::Ready(client.clone()), - Err(err) => SiteReplicationPeerClientCacheEntry::Failed(err.to_string()), - }; - - let mut cache = SITE_REPLICATION_PEER_CLIENT.lock().await; - if cache.as_ref().is_none_or(|cached| cached.generation <= generation) { - *cache = Some(SiteReplicationPeerClientCache { - generation, - entry: cache_entry, - }); - } - - built -} - -async fn site_replication_client_for(connection: &PeerConnection) -> S3Result { - // Revalidate at the client boundary so callers cannot bypass endpoint/TLS policy. - let connection = PeerConnection::new(connection.endpoint(), connection.skip_tls_verify, &connection.ca_cert_pem)?; - if connection.uses_default_tls() { - return site_replication_peer_client().await; - } - let outbound_tls = current_outbound_tls_state().await; - build_custom_site_replication_peer_client(&outbound_tls, &connection) -} - -fn runtime_peer_connection(peer: &PeerInfo) -> S3Result { - PeerConnection::try_from(peer).map_err(|err| { - S3Error::with_message( - S3ErrorCode::InternalError, - format!("invalid persisted site replication peer `{}`: {err}", peer.endpoint), - ) - }) -} - -struct PeerTransport { - connection: PeerConnection, - client: reqwest::Client, -} - -impl PeerTransport { - async fn for_runtime_peer(peer: &PeerInfo) -> S3Result { - let connection = runtime_peer_connection(peer)?; - let client = site_replication_client_for(&connection).await.map_err(|err| { - S3Error::with_message( - S3ErrorCode::InternalError, - format!("initialize persisted site replication peer `{}` transport failed: {err}", peer.endpoint), - ) - })?; - Ok(Self { connection, client }) - } -} - -fn runtime_tls_enabled_with(endpoints: Option<&crate::admin::storage_api::runtime::EndpointServerPools>) -> bool { - if !rustfs_utils::get_env_str(ENV_RUSTFS_TLS_PATH, DEFAULT_RUSTFS_TLS_PATH).is_empty() { - return true; - } - - if let Some(tls_enabled) = endpoints.and_then(|endpoints| { - endpoints - .as_ref() - .iter() - .flat_map(|pool| pool.endpoints.as_ref().iter()) - .find(|endpoint| endpoint.is_local) - .map(|endpoint| endpoint.url.scheme().eq_ignore_ascii_case("https")) - }) { - return tls_enabled; - } - - false -} - -fn runtime_tls_enabled() -> bool { - let endpoints = current_endpoints_handle(); - runtime_tls_enabled_with(endpoints.as_ref()) -} - fn query_pairs(uri: &Uri) -> HashMap { uri.query() .map(|query| { @@ -1638,16 +698,6 @@ fn sr_edit_ilm_expiry_override(uri: &Uri) -> Option { } } -fn hash_client_secret(secret: Option<&str>) -> String { - let Some(secret) = secret.filter(|secret| !secret.is_empty()) else { - return String::new(); - }; - - let mut hasher = Sha256::new(); - hasher.update(secret.as_bytes()); - URL_SAFE_NO_PAD.encode(hasher.finalize()) -} - fn config_enabled(value: Option) -> bool { matches!(value.as_deref(), Some("on" | "true" | "enabled")) } @@ -1701,96 +751,6 @@ fn load_ldap_idp_settings() -> (LDAPSettings, LDAPConfigSettings) { .unwrap_or_else(|| (LDAPSettings::default(), LDAPConfigSettings::default())) } -fn request_endpoint(uri: &Uri, headers: &HeaderMap) -> String { - let scheme = get_source_scheme(headers) - .and_then(|value| { - value - .split(',') - .next() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_ascii_lowercase) - }) - .or_else(|| uri.scheme_str().map(str::to_ascii_lowercase)) - .unwrap_or_else(|| { - if runtime_tls_enabled() { - "https".to_string() - } else { - "http".to_string() - } - }); - - let host = headers - .get(http::header::HOST) - .and_then(|value| value.to_str().ok()) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .or_else(|| uri.authority().map(|value| value.as_str().to_string())) - .or_else(|| { - current_endpoints_handle().and_then(|endpoints| { - endpoints - .as_ref() - .iter() - .flat_map(|pool| pool.endpoints.as_ref().iter()) - .find(|endpoint| endpoint.is_local) - .map(|endpoint| endpoint.host_port()) - }) - }) - .unwrap_or_else(|| format!("127.0.0.1:{}", current_runtime_port())); - - format!("{scheme}://{host}") -} - -fn runtime_console_port() -> Option { - let console_address = get_config_snapshot() - .map(|snapshot| snapshot.console_address.clone()) - .unwrap_or_else(|| rustfs_utils::get_env_str(ENV_RUSTFS_CONSOLE_ADDRESS, DEFAULT_CONSOLE_ADDRESS)); - - let parse_target = if console_address.starts_with(':') { - format!("127.0.0.1{console_address}") - } else { - console_address - }; - - Url::parse(&format!("http://{parse_target}")) - .ok() - .and_then(|parsed| parsed.port_or_known_default()) -} - -fn site_replication_local_endpoint(uri: &Uri, headers: &HeaderMap) -> String { - let endpoint = request_endpoint(uri, headers); - match Url::parse(&endpoint) { - Ok(mut parsed) => { - if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() { - return request_endpoint(&Uri::from_static("/"), &HeaderMap::new()); - } - if parsed.port_or_known_default() == runtime_console_port() && parsed.set_port(Some(current_runtime_port())).is_ok() { - parsed.to_string().trim_end_matches('/').to_string() - } else { - endpoint - } - } - Err(_) => request_endpoint(&Uri::from_static("/"), &HeaderMap::new()), - } -} - -fn current_local_runtime_endpoint() -> String { - site_replication_local_endpoint(&Uri::from_static("/"), &HeaderMap::new()) -} - -fn infer_site_name(endpoint: &str) -> String { - endpoint - .trim_start_matches("http://") - .trim_start_matches("https://") - .split('/') - .next() - .unwrap_or_default() - .split(':') - .next() - .unwrap_or_default() - .to_string() -} - fn qstat(count: i64, bytes: i64) -> QStat { QStat { count: count as f64, @@ -1802,54 +762,10 @@ fn non_negative_u64(value: i64) -> u64 { value.max(0) as u64 } -fn stored_peer_tls_settings(stored_peer: Option<&PeerInfo>) -> (bool, String) { - stored_peer - .map(|peer| (peer.skip_tls_verify, peer.ca_cert_pem.clone())) - .unwrap_or_default() -} - fn current_local_peer(req: &S3Request, state: &SiteReplicationState) -> PeerInfo { local_peer_at_endpoint(site_replication_local_endpoint(&req.uri, &req.headers), state) } -/// The local peer record as the given state describes it. Split out of -/// [`current_local_peer`] so a state transaction can rebuild it against the -/// state it just loaded: the request the endpoint came from cannot cross into -/// the transaction closure, but the endpoint itself can. -fn local_peer_at_endpoint(endpoint: String, state: &SiteReplicationState) -> PeerInfo { - let deployment_id = current_deployment_id().unwrap_or_else(|| deployment_id_for_endpoint(&endpoint)); - let stored_peer = state.peers.get(&deployment_id); - let (skip_tls_verify, ca_cert_pem) = stored_peer_tls_settings(stored_peer); - - PeerInfo { - endpoint: endpoint.clone(), - name: if state.name.is_empty() { - stored_peer - .map(|peer| peer.name.clone()) - .filter(|name| !name.is_empty()) - .unwrap_or_else(|| infer_site_name(&endpoint)) - } else { - state.name.clone() - }, - deployment_id, - sync_state: stored_peer.map(|peer| peer.sync_state.clone()).unwrap_or(SyncStatus::Unknown), - default_bandwidth: stored_peer.map(|peer| peer.default_bandwidth.clone()).unwrap_or_default(), - replicate_ilm_expiry: stored_peer.is_some_and(|peer| peer.replicate_ilm_expiry), - object_naming_mode: stored_peer.map(|peer| peer.object_naming_mode.clone()).unwrap_or_default(), - skip_tls_verify, - ca_cert_pem, - api_version: Some(SITE_REPL_API_VERSION.to_string()), - } -} - -fn current_local_runtime_peer(state: &SiteReplicationState) -> PeerInfo { - local_peer_at_endpoint(current_local_runtime_endpoint(), state) -} - -fn normalize_peer_map_by_identity(peers: BTreeMap) -> BTreeMap { - normalize_peer_map_by_identity_with(peers, normalize_peer_info) -} - fn existing_peer_for_endpoint(state: &SiteReplicationState, endpoint: &str) -> Option { state .peers @@ -1886,19 +802,6 @@ fn peer_deployment_id_for_endpoint(state: &SiteReplicationState, endpoint: &str) .filter(|deployment_id| !deployment_id.is_empty()) } -fn normalize_peer_info(mut peer: PeerInfo) -> PeerInfo { - if peer.deployment_id.is_empty() { - peer.deployment_id = deployment_id_for_endpoint(&peer.endpoint); - } - if peer.name.is_empty() { - peer.name = infer_site_name(&peer.endpoint); - } - if peer.api_version.is_none() { - peer.api_version = Some(SITE_REPL_API_VERSION.to_string()); - } - peer -} - fn normalize_peer_site(site: PeerSite, replicate_ilm_expiry: bool) -> PeerInfo { normalize_peer_info(PeerInfo { endpoint: site.endpoint, @@ -1914,155 +817,6 @@ fn normalize_peer_site(site: PeerSite, replicate_ilm_expiry: bool) -> PeerInfo { }) } -fn loopback_replication_targets_allowed() -> bool { - std::env::var(ALLOW_LOOPBACK_REPLICATION_TARGET_ENV) - .map(|value| value.eq_ignore_ascii_case("true") || value == "1") - .unwrap_or(false) -} - -fn validate_peer_egress(url: &Url, allow_loopback: bool) -> Result<(), OutboundUrlError> { - match validate_outbound_url(url) { - Ok(()) => Ok(()), - Err(OutboundUrlError::ForbiddenHost { - reason: "private address", - .. - }) => Ok(()), - Err(OutboundUrlError::ForbiddenHost { - reason: "loopback address" | "loopback host", - .. - }) if allow_loopback && peer_url_has_canonical_loopback_host(url) => Ok(()), - Err(err) => Err(err), - } -} - -fn peer_url_has_canonical_loopback_host(url: &Url) -> bool { - match url.host() { - Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), - Some(url::Host::Ipv4(ip)) => ip == std::net::Ipv4Addr::LOCALHOST, - Some(url::Host::Ipv6(ip)) => ip == std::net::Ipv6Addr::LOCALHOST, - None => false, - } -} - -fn resolved_peer_ip_allowed(host: &str, ip: IpAddr, allow_loopback: bool) -> bool { - let Ok(ip_url) = (match ip { - IpAddr::V4(ip) => Url::parse(&format!("http://{ip}")), - IpAddr::V6(ip) => Url::parse(&format!("http://[{ip}]")), - }) else { - return false; - }; - match validate_outbound_url(&ip_url) { - Ok(()) => true, - Err(OutboundUrlError::ForbiddenHost { - reason: "private address", - .. - }) => true, - Err(OutboundUrlError::ForbiddenHost { - reason: "loopback address", - .. - }) => { - allow_loopback - && host.eq_ignore_ascii_case("localhost") - && matches!(ip, IpAddr::V4(std::net::Ipv4Addr::LOCALHOST) | IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)) - } - Err(_) => false, - } -} - -fn parse_peer_ca_certificates(ca_cert_pem: &str) -> S3Result> { - if ca_cert_pem.len() > MAX_PEER_CA_CERT_PEM_SIZE { - return Err(s3_error!(InvalidRequest, "site replication CA certificate exceeds 256 KiB")); - } - if ca_cert_pem.contains("PRIVATE KEY-----") { - return Err(s3_error!( - InvalidRequest, - "site replication CA certificate must not contain a private key" - )); - } - - let mut reader = std::io::BufReader::new(ca_cert_pem.as_bytes()); - let certs_der = rustls_pki_types::CertificateDer::pem_reader_iter(&mut reader) - .collect::, _>>() - .map_err(|e| { - S3Error::with_message(S3ErrorCode::InvalidRequest, format!("invalid site replication CA certificate: {e}")) - })?; - if certs_der.is_empty() { - return Err(s3_error!( - InvalidRequest, - "site replication CA certificate must contain at least one certificate" - )); - } - - let mut root_store = rustls::RootCertStore::empty(); - certs_der - .into_iter() - .map(|cert| { - root_store.add(cert.clone()).map_err(|e| { - S3Error::with_message(S3ErrorCode::InvalidRequest, format!("invalid site replication CA certificate: {e}")) - })?; - reqwest::Certificate::from_der(cert.as_ref()).map_err(|e| { - S3Error::with_message(S3ErrorCode::InvalidRequest, format!("invalid site replication CA certificate: {e}")) - }) - }) - .collect() -} - -fn validate_peer_connection_inner( - endpoint: &str, - skip_tls_verify: bool, - ca_cert_pem: &str, - allow_loopback: bool, -) -> S3Result { - let parsed = Url::parse(endpoint) - .map_err(|e| S3Error::with_message(S3ErrorCode::InvalidRequest, format!("invalid site endpoint `{endpoint}`: {e}")))?; - match parsed.scheme() { - "http" | "https" => {} - scheme => { - return Err(S3Error::with_message( - S3ErrorCode::InvalidRequest, - format!("invalid site endpoint `{endpoint}`: unsupported scheme `{scheme}`"), - )); - } - } - if parsed.host_str().is_none() { - return Err(S3Error::with_message( - S3ErrorCode::InvalidRequest, - format!("invalid site endpoint `{endpoint}`: missing host"), - )); - } - if !parsed.username().is_empty() || parsed.password().is_some() { - return Err(s3_error!(InvalidRequest, "invalid site endpoint `{endpoint}`: userinfo is not allowed")); - } - if parsed.path() != "/" || parsed.query().is_some() || parsed.fragment().is_some() { - return Err(s3_error!( - InvalidRequest, - "invalid site endpoint `{endpoint}`: endpoint must be an origin" - )); - } - validate_peer_egress(&parsed, allow_loopback) - .map_err(|e| S3Error::with_message(S3ErrorCode::InvalidRequest, format!("invalid site endpoint `{endpoint}`: {e}")))?; - - if ca_cert_pem.len() > MAX_PEER_CA_CERT_PEM_SIZE { - return Err(s3_error!(InvalidRequest, "site replication CA certificate exceeds 256 KiB")); - } - let ca_cert_pem = ca_cert_pem.trim(); - if parsed.scheme() != "https" && (skip_tls_verify || !ca_cert_pem.is_empty()) { - return Err(s3_error!(InvalidRequest, "site replication TLS settings require an HTTPS endpoint")); - } - if skip_tls_verify && !ca_cert_pem.is_empty() { - return Err(s3_error!(InvalidRequest, "skipTLSVerify and caCertPem are mutually exclusive")); - } - if !ca_cert_pem.is_empty() { - parse_peer_ca_certificates(ca_cert_pem)?; - } - - Ok(PeerConnection { - endpoint: parsed, - skip_tls_verify, - ca_cert_pem: ca_cert_pem.to_string(), - }) -} - fn validate_proposed_peer(peer: &PeerInfo) -> S3Result<()> { PeerConnection::try_from(peer).map(|_| ()) } @@ -2210,14 +964,10 @@ async fn local_add_preflight_info( async fn remote_add_preflight_info(site: &PeerSite) -> S3Result { let connection = PeerConnection::try_from(site)?; let client = site_replication_client_for(&connection).await?; - let info_body = send_peer_admin_get_request_with_client( - &client, - &connection, - "/rustfs/admin/v3/site-replication/metainfo", - &site.access_key, - &site.secret_key, - ) - .await?; + let info_body = PeerAdminRequest::get(&connection, "/rustfs/admin/v3/site-replication/metainfo", &site.access_key) + .with_client(&client) + .send_get(&site.secret_key) + .await?; let info: SRInfo = serde_json::from_slice(&info_body).map_err(|e| { S3Error::with_message( S3ErrorCode::InvalidRequest, @@ -2237,14 +987,10 @@ async fn remote_add_preflight_info(site: &PeerSite) -> S3Result String { - format!( - "/rustfs/admin/v3/site-replication/peer/bucket-ops?{}", - form_urlencoded::Serializer::new(String::new()) - .append_pair("bucket", bucket) - .append_pair("operation", operation) - .finish() - ) -} - -fn with_site_replication_bootstrap_token(path: &str, token: &str) -> String { - let separator = if path.contains('?') { '&' } else { '?' }; - let query = form_urlencoded::Serializer::new(String::new()) - .append_pair("bootstrapToken", token) - .finish(); - format!("{path}{separator}{query}") -} - fn site_replication_bootstrap_token(uri: &Uri) -> Option { query_pairs(uri).get("bootstrapToken").cloned() } -/// Query for a peer `make-with-versioning` bucket op. `versioningEnabled` -/// always travels so the outbound query matches MinIO's site-replication -/// make-bucket wire contract: MinIO's own create-bucket hook sends -/// `versioningEnabled=true` on this op. RustFS's inbound handler -/// force-enables versioning either way. -fn make_with_versioning_bucket_op_path(bucket: &str, created_at: Option<&str>, lock_enabled: bool) -> String { - let mut query = form_urlencoded::Serializer::new(String::new()); - query.append_pair("bucket", bucket); - query.append_pair("operation", SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING); - query.append_pair("versioningEnabled", "true"); - if let Some(created_at) = created_at { - query.append_pair("createdAt", created_at); - } - if lock_enabled { - query.append_pair("lockEnabled", "true"); - } - format!("{SITE_REPLICATION_PEER_BUCKET_OPS_PATH}?{}", query.finish()) -} - -fn bootstrap_bucket_make_op_path(bucket: &SRBucketInfo) -> String { - let created_at = bucket - .created_at - .and_then(|value| value.format(&time::format_description::well_known::Rfc3339).ok()); - make_with_versioning_bucket_op_path(&bucket.bucket, created_at.as_deref(), bucket.object_lock_config.is_some()) -} - -fn bootstrap_bucket_meta_item(bucket: &SRBucketInfo, item_type: &str, updated_at: Option) -> SRBucketMeta { - SRBucketMeta { - bucket: bucket.bucket.clone(), - r#type: item_type.to_string(), - updated_at, - api_version: Some(SITE_REPL_API_VERSION.to_string()), - derived_rule_contract: true, - ..Default::default() - } -} - -fn bootstrap_bucket_quota_value(bucket: &str, raw: &str) -> S3Result { - serde_json::from_slice(&decode_bucket_meta_wire_value(raw)) - .map_err(|e| s3_error!(InvalidRequest, "invalid quota metadata for bootstrap bucket `{bucket}`: {e}")) -} - -fn append_bootstrap_bucket_item( - items: &mut Vec, - bucket: &SRBucketInfo, - item_type: &str, - value: Option, - updated_at: Option, - apply: impl FnOnce(&mut SRBucketMeta, String) -> S3Result<()>, -) -> S3Result<()> { - if let Some(value) = value { - let mut item = bootstrap_bucket_meta_item(bucket, item_type, updated_at); - apply(&mut item, value)?; - items.push(item); - } - Ok(()) -} - -fn append_bootstrap_bucket_items( - plan: &mut SiteReplicationBootstrapPlan, - bucket: &SRBucketInfo, - replicate_ilm_expiry: bool, -) -> S3Result<()> { - append_bootstrap_bucket_item( - &mut plan.bucket_items, - bucket, - "policy", - bucket.policy.clone().map(|value| value.to_string()), - bucket.policy_updated_at, - |item, value| { - item.policy = - Some(serde_json::from_str(&value).map_err(|e| { - s3_error!(InvalidRequest, "invalid bucket policy for bootstrap bucket `{}`: {e}", item.bucket) - })?); - Ok(()) - }, - )?; - append_bootstrap_bucket_item( - &mut plan.bucket_items, - bucket, - "version-config", - bucket.versioning.clone(), - bucket.versioning_config_updated_at, - |item, value| { - item.versioning = Some(value); - Ok(()) - }, - )?; - append_bootstrap_bucket_item( - &mut plan.bucket_items, - bucket, - "tags", - bucket.tags.clone(), - bucket.tag_config_updated_at, - |item, value| { - item.tags = Some(value); - Ok(()) - }, - )?; - append_bootstrap_bucket_item( - &mut plan.bucket_items, - bucket, - "object-lock-config", - bucket.object_lock_config.clone(), - bucket.object_lock_config_updated_at, - |item, value| { - item.object_lock_config = Some(value); - Ok(()) - }, - )?; - append_bootstrap_bucket_item( - &mut plan.bucket_items, - bucket, - "sse-config", - bucket.sse_config.clone(), - bucket.sse_config_updated_at, - |item, value| { - item.sse_config = Some(value); - Ok(()) - }, - )?; - append_bootstrap_bucket_item( - &mut plan.bucket_items, - bucket, - "replication-config", - bucket.replication_config.clone(), - bucket.replication_config_updated_at, - |item, value| { - item.replication_config = Some(value); - Ok(()) - }, - )?; - append_bootstrap_bucket_item( - &mut plan.bucket_items, - bucket, - "quota-config", - bucket.quota_config.clone(), - bucket.quota_config_updated_at, - |item, value| { - item.quota = Some(bootstrap_bucket_quota_value(&item.bucket, &value)?); - Ok(()) - }, - )?; - if replicate_ilm_expiry { - if bucket.expiry_lc_config.is_some() { - append_bootstrap_bucket_item( - &mut plan.bucket_items, - bucket, - "lc-config", - bucket.expiry_lc_config.clone(), - bucket.expiry_lc_config_updated_at, - |item, value| { - item.expiry_lc_config = Some(value); - // `updated_at` here is the entry's expiry axis (see the - // SRBucketInfo construction), not the wall clock. - item.expiry_updated_at = item.updated_at; - Ok(()) - }, - )?; - } else if bucket.expiry_lc_config_updated_at.is_some() { - // Expiry rules were removed at this axis (lifecycle_expiry_statement): - // an explicit timestamped delete item, so a peer that missed the - // live delete converges on bootstrap/repair instead of keeping - // stale expiry rules. The receiver's staleness guard protects a - // peer whose expiry state is newer. - let mut item = bootstrap_bucket_meta_item(bucket, "lc-config", bucket.expiry_lc_config_updated_at); - item.expiry_updated_at = item.updated_at; - plan.bucket_items.push(item); - } - } - append_bootstrap_bucket_item( - &mut plan.bucket_items, - bucket, - "cors-config", - bucket.cors_config.clone(), - bucket.cors_config_updated_at, - |item, value| { - item.cors = Some(value); - Ok(()) - }, - ) -} - -fn group_status_from_desc(status: &str) -> GroupStatus { - if status.eq_ignore_ascii_case("disabled") { - GroupStatus::Disabled - } else { - GroupStatus::Enabled - } -} - -fn site_replication_info_replicates_ilm_expiry(info: &SRInfo) -> bool { - info.state.peers.values().any(|peer| peer.replicate_ilm_expiry) -} - -fn site_replication_state_replicates_ilm_expiry(state: &SiteReplicationState) -> bool { - state.peers.values().any(|peer| peer.replicate_ilm_expiry) -} - -fn site_replication_bootstrap_plan(info: &SRInfo) -> S3Result { - let mut plan = SiteReplicationBootstrapPlan::default(); - let replicate_ilm_expiry = site_replication_info_replicates_ilm_expiry(info); - - for (name, policy) in &info.policies { - plan.iam_items.push(SRIAMItem { - r#type: "policy".to_string(), - name: name.clone(), - policy: policy.policy.clone(), - updated_at: policy.updated_at, - api_version: Some(SITE_REPL_API_VERSION.to_string()), - ..Default::default() - }); - } - - for (access_key, user) in &info.user_info_map { - if let Some(secret_key) = &user.secret_key { - plan.iam_items.push(SRIAMItem { - r#type: "iam-user".to_string(), - iam_user: Some(rustfs_madmin::SRIAMUser { - access_key: access_key.clone(), - is_delete_req: false, - user_req: Some(AddOrUpdateUserReq { - secret_key: secret_key.clone(), - policy: user.policy_name.clone(), - status: user.status.clone(), - }), - api_version: Some(SITE_REPL_API_VERSION.to_string()), - }), - updated_at: user.updated_at, - api_version: Some(SITE_REPL_API_VERSION.to_string()), - ..Default::default() - }); - } - } - - for (name, desc) in &info.group_desc_map { - plan.iam_items.push(SRIAMItem { - r#type: "group-info".to_string(), - group_info: Some(SRGroupInfo { - update_req: GroupAddRemove { - group: if desc.name.is_empty() { - name.clone() - } else { - desc.name.clone() - }, - members: desc.members.clone(), - status: group_status_from_desc(&desc.status), - is_remove: false, - }, - api_version: Some(SITE_REPL_API_VERSION.to_string()), - }), - updated_at: desc.updated_at, - api_version: Some(SITE_REPL_API_VERSION.to_string()), - ..Default::default() - }); - } - - for mapping in info.user_policies.values().chain(info.group_policies.values()) { - plan.iam_items.push(SRIAMItem { - r#type: "policy-mapping".to_string(), - policy_mapping: Some(mapping.clone()), - updated_at: mapping.updated_at, - api_version: Some(SITE_REPL_API_VERSION.to_string()), - ..Default::default() - }); - } - - for bucket in info.buckets.values() { - plan.bucket_make_ops.push(bootstrap_bucket_make_op_path(bucket)); - append_bootstrap_bucket_items(&mut plan, bucket, replicate_ilm_expiry)?; - plan.bucket_configure_ops - .push(bootstrap_bucket_op_path(&bucket.bucket, "configure-replication")); - } - - Ok(plan) -} - fn build_join_peers( state: &SiteReplicationState, local_peer: &PeerInfo, @@ -2795,23 +1246,6 @@ fn reconcile_peer_with_actual_identity(mut state: SiteReplicationState, actual_p state } -async fn site_replicator_service_account_secret(access_key: &str) -> S3Result { - let Some(iam_sys) = current_iam_handle() else { - return Err(s3_error!(InvalidRequest, "iam not init")); - }; - - iam_sys - .get_site_replicator_service_account_secret(access_key) - .await - .map_err(ApiError::from) - .map_err(Into::into) -} - -fn legacy_site_replicator_state_secret(state: &SiteReplicationState) -> Option { - (state.service_account_access_key == SITE_REPLICATOR_SERVICE_ACCOUNT && !state.service_account_secret_key.is_empty()) - .then(|| state.service_account_secret_key.clone()) -} - async fn set_site_replicator_service_account_secret(parent_user: &str, secret_key: String) -> S3Result { let Some(iam_sys) = current_iam_handle() else { return Err(s3_error!(InvalidRequest, "iam not init")); @@ -3247,390 +1681,6 @@ fn reconcile_site_replication_wiring() -> std::pin::Pin String { - let (path_only, query) = path - .split_once('?') - .map(|(path, query)| (path, Some(query))) - .unwrap_or((path, None)); - let wire_path = if let Some(suffix) = path_only.strip_prefix(RUSTFS_ADMIN_V3_PREFIX) { - format!("{MINIO_ADMIN_V3_PREFIX}{suffix}") - } else { - path_only.to_string() - }; - - match query { - Some(query) => format!("{wire_path}?{query}"), - None => wire_path, - } -} - -fn site_replication_peer_payload_encrypted(wire_path: &str) -> bool { - // MinIO's SRPeerJoin handler force-decrypts the request body, so the - // peer/join payload must always travel encrypted. - wire_path.split_once('?').map(|(path, _)| path).unwrap_or(wire_path) == MINIO_SITE_REPLICATION_PEER_JOIN_PATH -} - -fn site_replication_peer_payload(path: &str, secret_key: &str, payload: Vec) -> S3Result<(Vec, &'static str)> { - if site_replication_peer_payload_encrypted(path) { - encode_compatible_admin_payload(path, secret_key, payload) - } else { - Ok((payload, "application/json")) - } -} - -fn site_replication_peer_url(connection: &PeerConnection, wire_path: &str) -> S3Result { - let path = wire_path.split_once('?').map_or(wire_path, |(path, _)| path); - if !path.starts_with('/') || path.starts_with("//") { - return Err(s3_error!(InvalidRequest, "invalid site replication peer path")); - } - connection - .endpoint - .join(wire_path) - .map_err(|e| S3Error::with_message(S3ErrorCode::InvalidRequest, format!("invalid site replication peer path: {e}"))) -} - -#[cfg(test)] -async fn send_peer_admin_request_raw( - connection: &PeerConnection, - path: &str, - access_key: &str, - secret_key: &str, - body: &T, -) -> S3Result<(StatusCode, Vec)> { - let client = site_replication_client_for(connection).await?; - send_peer_admin_request_raw_with_client(&client, connection, path, access_key, secret_key, body).await -} - -async fn send_peer_admin_request_raw_with_client( - client: &reqwest::Client, - connection: &PeerConnection, - path: &str, - access_key: &str, - secret_key: &str, - body: &T, -) -> S3Result<(StatusCode, Vec)> { - let path = site_replication_peer_wire_path(path); - let url = site_replication_peer_url(connection, &path)?; - let uri = url - .as_str() - .parse::() - .map_err(|e| S3Error::with_message(S3ErrorCode::InvalidRequest, format!("invalid peer endpoint: {e}")))?; - let authority = uri - .authority() - .ok_or_else(|| S3Error::with_message(S3ErrorCode::InvalidRequest, "peer endpoint missing authority".to_string()))? - .to_string(); - let payload = serde_json::to_vec(body) - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize peer request failed: {e}")))?; - let (payload, content_type) = site_replication_peer_payload(&path, secret_key, payload)?; - - let signed = sign_v4( - http::Request::builder() - .method(Method::PUT) - .uri(uri) - .header(HOST, authority) - .header("x-amz-content-sha256", UNSIGNED_PAYLOAD) - .header(CONTENT_TYPE, content_type) - .body(Body::empty()) - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("build peer request failed: {e}")))?, - payload.len() as i64, - access_key, - secret_key, - "", - current_region() - .map(|region| region.to_string()) - .as_deref() - .unwrap_or("us-east-1"), - ); - - let mut req = client.request(reqwest::Method::PUT, url.clone()); - for (name, value) in signed.headers() { - req = req.header(name, value); - } - - let response = req.body(payload).send().await.map_err(|e| { - let classify = if e.is_timeout() { - "timeout" - } else if e.is_connect() && e.to_string().to_ascii_lowercase().contains("dns") { - "dns resolution" - } else if e.to_string().to_ascii_lowercase().contains("certificate") || e.to_string().to_ascii_lowercase().contains("tls") - { - "tls handshake" - } else if e.is_connect() { - "connect" - } else { - "request" - }; - S3Error::with_message(S3ErrorCode::InternalError, format!("peer request to {url} failed ({classify}): {e}")) - })?; - - let status = response.status(); - let body = response - .bytes() - .await - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("read peer response failed: {e}")))?; - - Ok((status, body.to_vec())) -} - -async fn send_peer_admin_request( - connection: &PeerConnection, - path: &str, - access_key: &str, - secret_key: &str, - body: &T, -) -> S3Result> { - let client = site_replication_client_for(connection).await?; - send_peer_admin_request_with_client(&client, connection, path, access_key, secret_key, body).await -} - -async fn send_peer_admin_request_with_client( - client: &reqwest::Client, - connection: &PeerConnection, - path: &str, - access_key: &str, - secret_key: &str, - body: &T, -) -> S3Result> { - let (status, body) = send_peer_admin_request_raw_with_client(client, connection, path, access_key, secret_key, body).await?; - if status.is_success() { - return Ok(body); - } - - let detail = String::from_utf8_lossy(&body).into_owned(); - Err(S3Error::with_message( - S3ErrorCode::InternalError, - format!("peer request to {}{path} failed with {status}: {detail}", connection.endpoint()), - )) -} - -async fn send_peer_admin_request_with_secret_candidates( - connection: &PeerConnection, - path: &str, - access_key: &str, - secret_candidates: &[String], - body: &T, -) -> S3Result> { - let client = site_replication_client_for(connection).await?; - let mut tried = HashSet::new(); - let mut errors = Vec::new(); - - for secret_key in secret_candidates.iter().filter(|secret_key| !secret_key.is_empty()) { - if !tried.insert(secret_key.as_str()) { - continue; - } - - match send_peer_admin_request_with_client(&client, connection, path, access_key, secret_key, body).await { - Ok(body) => return Ok(body), - Err(err) => { - let detail = format!("{err}"); - let may_retry_with_next_secret = peer_error_may_be_secret_mismatch(&detail); - errors.push(summarize_peer_error_detail(&detail)); - if !may_retry_with_next_secret { - break; - } - } - } - } - - Err(S3Error::with_message( - S3ErrorCode::InternalError, - format!( - "peer request to {}{path} failed with all service-account secrets: {}", - connection.endpoint(), - errors.join("; ") - ), - )) -} - -fn peer_error_may_be_secret_mismatch(detail: &str) -> bool { - let detail = detail.to_ascii_lowercase(); - detail.contains("signaturedoesnotmatch") - || detail.contains("accessdenied") - || detail.contains("forbidden") - || detail.contains("401") - || detail.contains("403") -} - -async fn send_peer_admin_get_request( - connection: &PeerConnection, - path: &str, - access_key: &str, - secret_key: &str, -) -> S3Result> { - let client = site_replication_client_for(connection).await?; - send_peer_admin_get_request_with_client(&client, connection, path, access_key, secret_key).await -} - -async fn send_peer_admin_get_request_with_client( - client: &reqwest::Client, - connection: &PeerConnection, - path: &str, - access_key: &str, - secret_key: &str, -) -> S3Result> { - let path = site_replication_peer_wire_path(path); - let url = site_replication_peer_url(connection, &path)?; - let uri = url - .as_str() - .parse::() - .map_err(|e| S3Error::with_message(S3ErrorCode::InvalidRequest, format!("invalid peer endpoint: {e}")))?; - let authority = uri - .authority() - .ok_or_else(|| S3Error::with_message(S3ErrorCode::InvalidRequest, "peer endpoint missing authority".to_string()))? - .to_string(); - - let signed = sign_v4( - http::Request::builder() - .method(Method::GET) - .uri(uri) - .header(HOST, authority) - .header("x-amz-content-sha256", UNSIGNED_PAYLOAD) - .body(Body::empty()) - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("build peer request failed: {e}")))?, - 0, - access_key, - secret_key, - "", - current_region() - .map(|region| region.to_string()) - .as_deref() - .unwrap_or("us-east-1"), - ); - - let mut req = client.request(reqwest::Method::GET, url.clone()); - for (name, value) in signed.headers() { - req = req.header(name, value); - } - - let response = req.send().await.map_err(|e| { - let classify = if e.is_timeout() { - "timeout" - } else if e.is_connect() && e.to_string().to_ascii_lowercase().contains("dns") { - "dns resolution" - } else if e.to_string().to_ascii_lowercase().contains("certificate") || e.to_string().to_ascii_lowercase().contains("tls") - { - "tls handshake" - } else if e.is_connect() { - "connect" - } else { - "request" - }; - S3Error::with_message(S3ErrorCode::InternalError, format!("peer request to {url} failed ({classify}): {e}")) - })?; - - let status = response.status(); - let body = response - .bytes() - .await - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("read peer response failed: {e}")))?; - - if !status.is_success() { - let detail = String::from_utf8_lossy(&body).into_owned(); - return Err(S3Error::with_message( - S3ErrorCode::InternalError, - format!("peer request to {url} failed with {status}: {detail}"), - )); - } - - Ok(body.to_vec()) -} - -async fn runtime_site_replication_targets() -> S3Result> { - let state = load_site_replication_state().await?; - if !state.enabled() || state.service_account_access_key.is_empty() { - return Ok(None); - } - - let service_account_secret_key = match site_replicator_service_account_secret(&state.service_account_access_key).await { - Ok(secret) => secret, - Err(err) => { - let Some(secret) = legacy_site_replicator_state_secret(&state) else { - return Err(err); - }; - warn!( - event = EVENT_ADMIN_SITE_REPLICATION_STATE, - component = LOG_COMPONENT_ADMIN, - subsystem = LOG_SUBSYSTEM_SITE_REPLICATION, - result = "legacy_state_service_account_secret_fallback", - error = ?err, - "admin site replication state" - ); - secret - } - }; - let local_peer = current_local_runtime_peer(&state); - Ok(Some(SiteReplicationRuntime { - state, - local_peer, - service_account_secret_key, - })) -} - -async fn broadcast_site_replication_json(path: &str, body: &T) -> S3Result<()> { - let Some(runtime) = runtime_site_replication_targets().await? else { - return Ok(()); - }; - broadcast_site_replication_json_with_runtime(&runtime, path, body).await -} - -async fn broadcast_site_replication_json_with_runtime( - runtime: &SiteReplicationRuntime, - path: &str, - body: &T, -) -> S3Result<()> { - let state = &runtime.state; - let local_peer = &runtime.local_peer; - - for peer in state.peers.values() { - if peer.deployment_id == local_peer.deployment_id || same_identity_endpoint(&peer.endpoint, &local_peer.endpoint) { - continue; - } - - send_peer_admin_request_with_retry_event( - peer, - path, - &state.service_account_access_key, - &runtime.service_account_secret_key, - body, - ) - .await?; - } - - Ok(()) -} - -async fn send_peer_admin_request_with_retry_event( - peer: &PeerInfo, - path: &str, - access_key: &str, - secret_key: &str, - body: &T, -) -> S3Result> { - let transport = PeerTransport::for_runtime_peer(peer).await?; - send_peer_admin_request_with_retry_event_transport(peer, &transport, path, access_key, secret_key, body).await -} - -async fn send_peer_admin_request_with_retry_event_transport( - peer: &PeerInfo, - transport: &PeerTransport, - path: &str, - access_key: &str, - secret_key: &str, - body: &T, -) -> S3Result> { - match send_peer_admin_request_with_client(&transport.client, &transport.connection, path, access_key, secret_key, body).await - { - Ok(body) => { - dequeue_site_replication_retry_event(peer, path).await; - Ok(body) - } - Err(err) => { - enqueue_site_replication_retry_event(peer, path, &err).await; - Err(err) - } - } -} - async fn send_site_replication_bootstrap_plan( peer: &PeerInfo, service_account_access_key: &str, @@ -3639,52 +1689,40 @@ async fn send_site_replication_bootstrap_plan( ) -> S3Result<()> { let transport = PeerTransport::for_runtime_peer(peer).await?; for item in &plan.iam_items { - send_peer_admin_request_with_retry_event_transport( - peer, - &transport, + PeerAdminRequest::put( + &transport.connection, "/rustfs/admin/v3/site-replication/peer/iam-item", service_account_access_key, - service_account_secret_key, - item, ) + .with_client(&transport.client) + .send_with_retry_event(peer, service_account_secret_key, item) .await?; } let empty = serde_json::json!({}); for path in &plan.bucket_make_ops { - send_peer_admin_request_with_retry_event_transport( - peer, - &transport, - path, - service_account_access_key, - service_account_secret_key, - &empty, - ) - .await?; + PeerAdminRequest::put(&transport.connection, path, service_account_access_key) + .with_client(&transport.client) + .send_with_retry_event(peer, service_account_secret_key, &empty) + .await?; } for item in &plan.bucket_items { - send_peer_admin_request_with_retry_event_transport( - peer, - &transport, + PeerAdminRequest::put( + &transport.connection, "/rustfs/admin/v3/site-replication/peer/bucket-meta", service_account_access_key, - service_account_secret_key, - item, ) + .with_client(&transport.client) + .send_with_retry_event(peer, service_account_secret_key, item) .await?; } for path in &plan.bucket_configure_ops { - send_peer_admin_request_with_retry_event_transport( - peer, - &transport, - path, - service_account_access_key, - service_account_secret_key, - &empty, - ) - .await?; + PeerAdminRequest::put(&transport.connection, path, service_account_access_key) + .with_client(&transport.client) + .send_with_retry_event(peer, service_account_secret_key, &empty) + .await?; } Ok(()) @@ -3737,915 +1775,6 @@ async fn bootstrap_existing_metadata_after_add( errors } -enum SiteReplicationRepairTask<'a> { - Iam(&'a SRIAMItem), - BucketMake(&'a str), - BucketMetadata(&'a SRBucketMeta), - Replication(&'a str), -} - -impl SiteReplicationRepairTask<'_> { - fn family(&self) -> &'static str { - match self { - Self::Iam(_) => SITE_REPLICATION_REPAIR_IAM_FAMILY, - Self::BucketMake(_) => SITE_REPLICATION_REPAIR_BUCKET_FAMILY, - Self::BucketMetadata(_) => SITE_REPLICATION_REPAIR_BUCKET_METADATA_FAMILY, - Self::Replication(_) => SITE_REPLICATION_REPAIR_REPLICATION_FAMILY, - } - } - - fn path(&self) -> &str { - match self { - Self::Iam(_) => "/rustfs/admin/v3/site-replication/peer/iam-item", - Self::BucketMake(path) | Self::Replication(path) => path, - Self::BucketMetadata(_) => "/rustfs/admin/v3/site-replication/peer/bucket-meta", - } - } - - fn id(&self) -> S3Result { - let payload = match self { - Self::Iam(item) => serde_json::to_vec(item), - Self::BucketMake(_) | Self::Replication(_) => serde_json::to_vec(&serde_json::json!({})), - Self::BucketMetadata(item) => serde_json::to_vec(item), - } - .map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize repair task failed: {err}")))?; - let mut digest = Sha256::new(); - digest.update(self.family().as_bytes()); - digest.update([0]); - digest.update(self.path().as_bytes()); - digest.update([0]); - digest.update(payload); - Ok(URL_SAFE_NO_PAD.encode(digest.finalize())) - } - - async fn send(&self, transport: &PeerTransport, access_key: &str, secret_key: &str) -> S3Result> { - match self { - Self::Iam(item) => { - send_peer_admin_request_with_client( - &transport.client, - &transport.connection, - self.path(), - access_key, - secret_key, - item, - ) - .await - } - Self::BucketMetadata(item) => { - send_peer_admin_request_with_client( - &transport.client, - &transport.connection, - self.path(), - access_key, - secret_key, - item, - ) - .await - } - Self::BucketMake(_) | Self::Replication(_) => { - send_peer_admin_request_with_client( - &transport.client, - &transport.connection, - self.path(), - access_key, - secret_key, - &serde_json::json!({}), - ) - .await - } - } - } -} - -fn site_replication_repair_tasks(plan: &SiteReplicationBootstrapPlan) -> Vec<(usize, SiteReplicationRepairTask<'_>)> { - let mut tasks = Vec::with_capacity( - plan.iam_items.len() + plan.bucket_make_ops.len() + plan.bucket_items.len() + plan.bucket_configure_ops.len(), - ); - tasks.extend( - plan.iam_items - .iter() - .enumerate() - .map(|(index, item)| (index, SiteReplicationRepairTask::Iam(item))), - ); - tasks.extend( - plan.bucket_make_ops - .iter() - .enumerate() - .map(|(index, path)| (index, SiteReplicationRepairTask::BucketMake(path))), - ); - tasks.extend( - plan.bucket_items - .iter() - .enumerate() - .map(|(index, item)| (index, SiteReplicationRepairTask::BucketMetadata(item))), - ); - tasks.extend( - plan.bucket_configure_ops - .iter() - .enumerate() - .map(|(index, path)| (index, SiteReplicationRepairTask::Replication(path))), - ); - tasks -} - -fn site_replication_repair_plan_token(state: &SiteReplicationState, plan: &SiteReplicationBootstrapPlan) -> S3Result { - let mut digest = Sha256::new(); - let snapshot = serde_json::to_vec(&( - &state.name, - &state.service_account_access_key, - &state.peers, - state.updated_at, - state.sync_state_initialized, - )) - .map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize repair snapshot failed: {err}")))?; - digest.update(snapshot); - for (_, task) in site_replication_repair_tasks(plan) { - digest.update(task.id()?.as_bytes()); - } - Ok(URL_SAFE_NO_PAD.encode(digest.finalize())) -} - -fn site_replication_repair_preflight_token( - state: &SiteReplicationState, - plan: &SiteReplicationBootstrapPlan, - signing_key: &[u8], -) -> S3Result { - if signing_key.is_empty() { - return Err(S3Error::with_message( - S3ErrorCode::InternalError, - "repair signing key is empty".to_string(), - )); - } - let mut digest = as hmac::digest::KeyInit>::new_from_slice(signing_key) - .map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "invalid repair signing key".to_string()))?; - digest.update(b"rustfs:site-replication:repair-preflight:v1\0"); - digest.update(site_replication_repair_plan_token(state, plan)?.as_bytes()); - for event in state - .retry_queue - .iter() - .filter(|event| retry_event_replayed_by_bootstrap(event)) - { - digest.update(event.id.as_bytes()); - digest.update(&[0]); - digest.update(event.peer_deployment_id.as_bytes()); - digest.update(&[0]); - digest.update(event.path.as_bytes()); - digest.update(&[0]); - } - Ok(URL_SAFE_NO_PAD.encode(digest.finalize().into_bytes())) -} - -fn site_replication_repair_task_checkpoint_id( - signing_key: &[u8], - peer_deployment_id: &str, - task: &SiteReplicationRepairTask<'_>, -) -> S3Result { - let mut digest = as hmac::digest::KeyInit>::new_from_slice(signing_key) - .map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "invalid repair signing key".to_string()))?; - digest.update(b"rustfs:site-replication:repair-task:v1\0"); - digest.update(peer_deployment_id.as_bytes()); - digest.update(&[0]); - digest.update(task.id()?.as_bytes()); - Ok(URL_SAFE_NO_PAD.encode(digest.finalize().into_bytes())) -} - -fn site_replication_repair_sites( - state: &SiteReplicationState, - local_peer: &PeerInfo, - plan: &SiteReplicationBootstrapPlan, - signing_key: &[u8], -) -> S3Result> { - let mut planned = BTreeMap::new(); - let mut family_paths = BTreeMap::>::new(); - for (_, task) in site_replication_repair_tasks(plan) { - let family = task.family().to_string(); - let family_status = planned - .entry(task.family().to_string()) - .or_insert_with(SiteReplicationRepairFamilyStatus::default); - family_status.planned += 1; - family_paths.entry(family).or_default().insert(task.path().to_string()); - } - - let mut sites = BTreeMap::new(); - for peer in state.peers.values().filter(|peer| { - peer.deployment_id != local_peer.deployment_id && !same_identity_endpoint(&peer.endpoint, &local_peer.endpoint) - }) { - let mut families = planned.clone(); - for (_, task) in site_replication_repair_tasks(plan) { - let family = families - .get_mut(task.family()) - .ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair task family is missing".to_string()))?; - family.tasks.push(SiteReplicationRepairTaskStatus { - task_id: site_replication_repair_task_checkpoint_id(signing_key, &peer.deployment_id, &task)?, - status: "planned".to_string(), - error: None, - }); - } - for (family, status) in &mut families { - status.retry_events = state - .retry_queue - .iter() - .filter(|event| { - event.peer_deployment_id == peer.deployment_id - && retry_event_replayed_by_bootstrap(event) - && family_paths.get(family).is_some_and(|paths| paths.contains(&event.path)) - }) - .count(); - } - sites.insert( - peer.deployment_id.clone(), - SiteReplicationRepairSiteStatus { - deployment_id: peer.deployment_id.clone(), - name: peer.name.clone(), - families, - }, - ); - } - Ok(sites) -} - -fn update_site_replication_repair_task( - operation: &mut SiteReplicationRepairOperation, - deployment_id: &str, - family: &str, - family_index: usize, - result: Result<(), &str>, -) -> S3Result<()> { - let site = operation - .sites - .get_mut(deployment_id) - .ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair operation site is missing".to_string()))?; - let family_status = site - .families - .get_mut(family) - .ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair operation family is missing".to_string()))?; - if family_status.succeeded != family_index { - return Err(S3Error::with_message( - S3ErrorCode::InternalError, - "repair operation task checkpoint is invalid".to_string(), - )); - } - let task_status = family_status.tasks.get_mut(family_index).ok_or_else(|| { - S3Error::with_message(S3ErrorCode::InternalError, "repair operation task checkpoint is missing".to_string()) - })?; - family_status.failed = 0; - family_status.errors.clear(); - match result { - Ok(()) => { - family_status.succeeded = family_status.succeeded.saturating_add(1); - task_status.status = "succeeded".to_string(); - task_status.error = None; - } - Err(error) => { - let error = classify_site_replication_repair_error(error).to_string(); - family_status.failed = 1; - family_status.errors.push(error.clone()); - task_status.status = "failed".to_string(); - task_status.error = Some(error); - } - } - Ok(()) -} - -fn site_replication_repair_task_pending( - operation: &SiteReplicationRepairOperation, - deployment_id: &str, - family: &str, - family_index: usize, -) -> S3Result { - let site = operation - .sites - .get(deployment_id) - .ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair operation site is missing".to_string()))?; - let family = site - .families - .get(family) - .ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair operation family is missing".to_string()))?; - if family.succeeded > family_index { - return Ok(false); - } - if family.succeeded < family_index { - return Ok(false); - } - Ok(family.failed == 0) -} - -fn prepare_site_replication_repair_retry(operation: &mut SiteReplicationRepairOperation) { - for family in operation.sites.values_mut().flat_map(|site| site.families.values_mut()) { - family.failed = 0; - family.errors.clear(); - for task in &mut family.tasks { - match task.status.as_str() { - "succeeded" => task.status = "skipped".to_string(), - "failed" => { - task.status = "planned".to_string(); - task.error = None; - } - _ => {} - } - } - } -} - -fn classify_site_replication_repair_error(error: &str) -> &'static str { - let error = error.to_ascii_lowercase(); - if error.contains("accessdenied") - || error.contains("signaturedoesnotmatch") - || error.contains("unauthorized") - || error.contains("forbidden") - || error.contains("401") - || error.contains("403") - { - "authorization-failed" - } else if error.contains("timeout") { - "remote-timeout" - } else if error.contains("dns") { - "remote-dns-failed" - } else if error.contains("tls") || error.contains("certificate") { - "remote-tls-failed" - } else if error.contains("connect") { - "remote-connect-failed" - } else { - "remote-operation-failed" - } -} - -fn summarize_site_replication_repair_operation(operation: &mut SiteReplicationRepairOperation) { - let failed = operation - .sites - .values() - .flat_map(|site| site.families.values()) - .any(|family| family.failed > 0); - let complete = operation - .sites - .values() - .all(|site| site.families.values().all(|family| family.succeeded == family.planned)); - operation.status = if complete { - "success" - } else if failed { - "partial" - } else { - "running" - } - .to_string(); - operation.updated_at = Some(OffsetDateTime::now_utc()); - operation.completed_at = complete.then_some(OffsetDateTime::now_utc()); -} - -fn site_replication_repair_operation_response( - operation: &SiteReplicationRepairOperation, -) -> SiteReplicationRepairOperationResponse { - SiteReplicationRepairOperationResponse { - mode: "execute", - operation_id: operation.operation_id.clone(), - status: operation.status.clone(), - sites: operation - .sites - .iter() - .map(|(deployment_id, site)| { - ( - deployment_id.clone(), - SiteReplicationRepairSiteResponse { - deployment_id: site.deployment_id.clone(), - name: site.name.clone(), - families: site - .families - .iter() - .map(|(family, status)| { - ( - family.clone(), - SiteReplicationRepairFamilyResponse { - planned: status.planned, - succeeded: status.succeeded, - failed: status.failed, - retry_events: status.retry_events, - tasks: status.tasks.clone(), - errors: status.errors.clone(), - }, - ) - }) - .collect(), - }, - ) - }) - .collect(), - created_at: operation.created_at, - updated_at: operation.updated_at, - completed_at: operation.completed_at, - } -} - -fn prune_site_replication_repair_operations(operations: &mut BTreeMap) { - while operations.len() > SITE_REPLICATION_REPAIR_OPERATION_LIMIT { - let Some(oldest) = operations - .iter() - .filter(|(_, operation)| operation.status == "success") - .min_by_key(|(_, operation)| operation.created_at) - .map(|(id, _)| id.clone()) - else { - break; - }; - operations.remove(&oldest); - } -} - -async fn persist_site_replication_repair_operation(operation: &SiteReplicationRepairOperation) -> S3Result<()> { - let operation = operation.clone(); - update_site_replication_repair_state(move |state| { - if let Some(existing) = state.operations.get(&operation.operation_id) - && !constant_time_eq(&existing.preflight_token, &operation.preflight_token) - { - return Err(S3Error::with_message( - S3ErrorCode::ClientTokenConflict, - "repair operation ID is already bound to a different preflight".to_string(), - )); - } - state.operations.insert(operation.operation_id.clone(), operation); - prune_site_replication_repair_operations(&mut state.operations); - Ok(()) - }) - .await -} - -async fn persist_site_replication_repair_task( - operation: &SiteReplicationRepairOperation, - peer: &PeerInfo, - family: &str, - path: &str, -) -> S3Result<()> { - persist_site_replication_repair_operation(operation).await?; - - let family_status = operation - .sites - .get(&peer.deployment_id) - .and_then(|site| site.families.get(family)) - .ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair task status is missing".to_string()))?; - let failure = (family_status.failed > 0).then(|| { - family_status - .errors - .first() - .cloned() - .unwrap_or_else(|| "remote-operation-failed".to_string()) - }); - let peer = peer.clone(); - let path = path.to_string(); - update_site_replication_state(move |state| { - match failure.as_deref() { - Some(error) => upsert_site_replication_retry_event(&mut state.retry_queue, &peer, &path, error, None), - None => { - dequeue_site_replication_retry_events_including_escalated(&mut state.retry_queue, &peer, &path); - } - } - Ok(()) - }) - .await -} - -fn admit_site_replication_repair_operation( - repair_state: &mut SiteReplicationRepairState, - operation_id: String, - supplied_token: &str, - candidate: SiteReplicationRepairOperation, -) -> S3Result { - if let Some(existing) = repair_state.operations.get(&operation_id) { - if !constant_time_eq(&existing.preflight_token, supplied_token) { - return Err(S3Error::with_message( - S3ErrorCode::ClientTokenConflict, - "repair operation ID is already bound to a different preflight".to_string(), - )); - } - if !constant_time_eq(&existing.plan_token, &candidate.plan_token) { - return Err(S3Error::with_message( - S3ErrorCode::PreconditionFailed, - "site replication repair plan changed after partial execution".to_string(), - )); - } - return Ok(existing.clone()); - } - if repair_state - .operations - .values() - .any(|operation| operation.status == "running") - { - return Err(S3Error::with_message( - S3ErrorCode::ClientTokenConflict, - "another site replication repair is active".to_string(), - )); - } - repair_state.operations.insert(operation_id, candidate.clone()); - prune_site_replication_repair_operations(&mut repair_state.operations); - Ok(candidate) -} - -async fn execute_site_replication_repair( - request: SiteReplicationRepairExecutionRequest, -) -> S3Result> { - let store = - current_object_store_handle().ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()))?; - with_config_object_write_lock(store, SITE_REPLICATION_REPAIR_EXECUTION_LOCK_PATH.to_string(), move || async move { - execute_site_replication_repair_locked(request).await - }) - .await - .map_err(|_| { - S3Error::with_message(S3ErrorCode::ClientTokenConflict, "another site replication repair is active".to_string()) - })? -} - -async fn execute_site_replication_repair_locked( - request: SiteReplicationRepairExecutionRequest, -) -> S3Result> { - let state = load_site_replication_state().await?; - if !state.enabled() || state.service_account_access_key.is_empty() { - return Err(s3_error!(InvalidRequest, "site replication is not configured")); - } - let info = build_sr_info(&state, &request.local_peer).await?; - let plan = site_replication_bootstrap_plan(&info)?; - let plan_token = site_replication_repair_plan_token(&state, &plan)?; - let preflight_token = site_replication_repair_preflight_token(&state, &plan, request.signing_key.as_bytes())?; - let sites = site_replication_repair_sites(&state, &request.local_peer, &plan, request.signing_key.as_bytes())?; - - let repair_state = read_site_replication_repair_state().await?; - if let Some(existing) = repair_state.operations.get(&request.operation_id) { - if !constant_time_eq(&existing.preflight_token, &request.preflight_token) { - return Err(S3Error::with_message( - S3ErrorCode::ClientTokenConflict, - "repair operation ID is already bound to a different preflight".to_string(), - )); - } - if existing.status == "success" { - return json_response(&site_replication_repair_operation_response(existing)); - } - if !constant_time_eq(&existing.plan_token, &plan_token) { - return Err(S3Error::with_message( - S3ErrorCode::PreconditionFailed, - "site replication repair plan changed after partial execution".to_string(), - )); - } - } else if !constant_time_eq(&request.preflight_token, &preflight_token) { - return Err(S3Error::with_message( - S3ErrorCode::PreconditionFailed, - "site replication repair preflight is stale".to_string(), - )); - } - - let now = OffsetDateTime::now_utc(); - let candidate = SiteReplicationRepairOperation { - operation_id: request.operation_id.clone(), - preflight_token, - plan_token, - status: "running".to_string(), - sites, - created_at: Some(now), - updated_at: Some(now), - completed_at: None, - }; - let supplied_token = request.preflight_token; - let operation_id = request.operation_id; - let mut operation = update_site_replication_repair_state(move |repair_state| { - admit_site_replication_repair_operation(repair_state, operation_id, &supplied_token, candidate) - }) - .await?; - if operation.status == "success" { - return json_response(&site_replication_repair_operation_response(&operation)); - } - - let service_account_secret_key = site_replicator_service_account_secret(&state.service_account_access_key).await?; - prepare_site_replication_repair_retry(&mut operation); - operation.status = "running".to_string(); - operation.completed_at = None; - operation.updated_at = Some(OffsetDateTime::now_utc()); - persist_site_replication_repair_operation(&operation).await?; - - let tasks = site_replication_repair_tasks(&plan); - for peer in state.peers.values().filter(|peer| { - peer.deployment_id != request.local_peer.deployment_id - && !same_identity_endpoint(&peer.endpoint, &request.local_peer.endpoint) - }) { - let transport = match PeerTransport::for_runtime_peer(peer).await { - Ok(transport) => transport, - Err(err) => { - let error = err.to_string(); - for (family_index, task) in &tasks { - if !site_replication_repair_task_pending(&operation, &peer.deployment_id, task.family(), *family_index)? { - continue; - } - update_site_replication_repair_task( - &mut operation, - &peer.deployment_id, - task.family(), - *family_index, - Err(&error), - )?; - summarize_site_replication_repair_operation(&mut operation); - persist_site_replication_repair_task(&operation, peer, task.family(), task.path()).await?; - } - continue; - } - }; - - for (family_index, task) in &tasks { - if !site_replication_repair_task_pending(&operation, &peer.deployment_id, task.family(), *family_index)? { - continue; - } - let result = task - .send(&transport, &state.service_account_access_key, &service_account_secret_key) - .await; - let error = result.err().map(|err| err.to_string()); - update_site_replication_repair_task( - &mut operation, - &peer.deployment_id, - task.family(), - *family_index, - match error.as_deref() { - Some(error) => Err(error), - None => Ok(()), - }, - )?; - summarize_site_replication_repair_operation(&mut operation); - persist_site_replication_repair_task(&operation, peer, task.family(), task.path()).await?; - } - } - - summarize_site_replication_repair_operation(&mut operation); - persist_site_replication_repair_operation(&operation).await?; - json_response(&site_replication_repair_operation_response(&operation)) -} - -pub async fn site_replication_make_bucket_hook(bucket: &str, lock_enabled: bool) -> S3Result<()> { - let _bucket_op_guard = SITE_REPLICATION_BUCKET_OP_LOCK.read().await; - let runtime = { - // The bucket-op lock is what orders this against add/remove. The - // state is only read here (through the runtime snapshot), and the - // bucket setup below writes bucket metadata, never the state object — - // holding the state transaction across it would put local metadata - // IO inside a distributed lock for nothing. - let Some(runtime) = runtime_site_replication_targets().await? else { - return Ok(()); - }; - - ensure_site_replication_bucket_versioning(bucket).await?; - ensure_site_replication_bucket_setup_with_runtime(bucket, &runtime).await?; - runtime - }; - - broadcast_site_replication_make_bucket(bucket, lock_enabled, Some(&runtime), None).await -} - -async fn broadcast_site_replication_json_using_runtime( - runtime: Option<&SiteReplicationRuntime>, - path: &str, - body: &T, -) -> S3Result<()> { - match runtime { - Some(runtime) => broadcast_site_replication_json_with_runtime(runtime, path, body).await, - None => broadcast_site_replication_json(path, body).await, - } -} - -async fn broadcast_site_replication_make_bucket( - bucket: &str, - lock_enabled: bool, - runtime: Option<&SiteReplicationRuntime>, - bootstrap_token: Option<&str>, -) -> S3Result<()> { - let created_at = current_object_store_handle() - .ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()))? - .get_bucket_info(bucket, &BucketOptions::default()) - .await - .map_err(ApiError::from)? - .created - .unwrap_or_else(OffsetDateTime::now_utc) - .format(&time::format_description::well_known::Rfc3339) - .unwrap_or_default(); - - let path = make_with_versioning_bucket_op_path(bucket, Some(&created_at), lock_enabled); - let path = if let Some(token) = bootstrap_token { - with_site_replication_bootstrap_token(&path, token) - } else { - path - }; - broadcast_site_replication_json_using_runtime(runtime, &path, &serde_json::json!({})).await?; - - let configure_path = bootstrap_bucket_op_path(bucket, "configure-replication"); - let configure_path = if let Some(token) = bootstrap_token { - with_site_replication_bootstrap_token(&configure_path, token) - } else { - configure_path - }; - broadcast_site_replication_json_using_runtime(runtime, &configure_path, &serde_json::json!({})).await -} - -pub async fn site_replication_delete_bucket_hook(bucket: &str, force_delete: bool) -> S3Result<()> { - let operation = if force_delete { - "force-delete-bucket" - } else { - "delete-bucket" - }; - let path = format!( - "/rustfs/admin/v3/site-replication/peer/bucket-ops?{}", - form_urlencoded::Serializer::new(String::new()) - .append_pair("bucket", bucket) - .append_pair("operation", operation) - .finish() - ); - broadcast_site_replication_json(&path, &serde_json::json!({})).await -} - -pub async fn site_replication_bucket_meta_hook(mut item: SRBucketMeta) -> S3Result<()> { - let Some(runtime) = runtime_site_replication_targets().await? else { - return Ok(()); - }; - if item.r#type == "lc-config" && !site_replication_state_replicates_ilm_expiry(&runtime.state) { - return Ok(()); - } - if item.r#type == "lc-config" { - // Only the expiry subset travels (MinIO peers install incoming rules - // verbatim, so transition rules must never leave this site). An empty - // subset becomes a delete, which the receiver merges with the empty - // set — local transition rules there survive. - item.expiry_lc_config = item - .expiry_lc_config - .and_then(|raw| lifecycle_expiry_subset_xml(raw.as_bytes())) - .map(|data| String::from_utf8_lossy(&data).into_owned()); - } - broadcast_site_replication_json_with_runtime( - &runtime, - "/rustfs/admin/v3/site-replication/peer/bucket-meta", - &encode_bucket_meta_wire_item(item), - ) - .await -} - -pub async fn site_replication_iam_change_hook(item: SRIAMItem) -> S3Result<()> { - broadcast_site_replication_json("/rustfs/admin/v3/site-replication/peer/iam-item", &item).await -} - -fn raw_config_to_string(raw: &[u8]) -> Option { - if raw.is_empty() { - return None; - } - String::from_utf8(raw.to_vec()).ok() -} - -fn raw_config_to_base64(raw: &[u8]) -> Option { - (!raw.is_empty()).then(|| BASE64_STANDARD.encode(raw)) -} - -fn encode_bucket_meta_wire_value(value: Option) -> Option { - value.map(|raw| BASE64_STANDARD.encode(raw.as_bytes())) -} - -fn encode_bucket_meta_wire_item(mut item: SRBucketMeta) -> SRBucketMeta { - item.versioning = encode_bucket_meta_wire_value(item.versioning); - item.tags = encode_bucket_meta_wire_value(item.tags); - item.object_lock_config = encode_bucket_meta_wire_value(item.object_lock_config); - item.sse_config = encode_bucket_meta_wire_value(item.sse_config); - item.replication_config = encode_bucket_meta_wire_value(item.replication_config); - item.expiry_lc_config = encode_bucket_meta_wire_value(item.expiry_lc_config); - item.cors = encode_bucket_meta_wire_value(item.cors); - item -} - -fn decode_bucket_meta_wire_value(raw: &str) -> Vec { - BASE64_STANDARD - .decode(raw.as_bytes()) - .ok() - .filter(|decoded| std::str::from_utf8(decoded).is_ok()) - .unwrap_or_else(|| raw.as_bytes().to_vec()) -} - -fn decode_bucket_meta_wire_option(value: Option) -> Option> { - value.map(|raw| decode_bucket_meta_wire_value(&raw)) -} - -fn maybe_time(value: OffsetDateTime) -> Option { - (value != OffsetDateTime::UNIX_EPOCH).then_some(value) -} - -async fn build_sr_info(state: &SiteReplicationState, local_peer: &PeerInfo) -> S3Result { - let Some(store) = current_object_store_handle() else { - return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); - }; - - let mut info = SRInfo { - enabled: state.enabled(), - name: local_peer.name.clone(), - deployment_id: local_peer.deployment_id.clone(), - state: SRStateInfo { - name: local_peer.name.clone(), - peers: state.peers.clone(), - updated_at: state.updated_at, - api_version: Some(SITE_REPL_API_VERSION.to_string()), - }, - api_version: Some(SITE_REPL_API_VERSION.to_string()), - ..Default::default() - }; - - let buckets = store.list_bucket(&BucketOptions::default()).await.map_err(ApiError::from)?; - for bucket in buckets { - let metadata = metadata_sys::get(&bucket.name).await.ok(); - let mut entry = SRBucketInfo { - bucket: bucket.name.clone(), - created_at: bucket.created, - location: current_region().map(|region| region.to_string()).unwrap_or_default(), - api_version: Some(SITE_REPL_API_VERSION.to_string()), - ..Default::default() - }; - - if let Some(metadata) = metadata { - entry.policy = raw_config_to_string(&metadata.policy_config_json).and_then(|raw| serde_json::from_str(&raw).ok()); - entry.versioning = raw_config_to_base64(&metadata.versioning_config_xml); - entry.tags = raw_config_to_base64(&metadata.tagging_config_xml); - entry.object_lock_config = raw_config_to_base64(&metadata.object_lock_config_xml); - entry.sse_config = raw_config_to_base64(&metadata.encryption_config_xml); - entry.replication_config = raw_config_to_base64(&metadata.replication_config_xml); - entry.quota_config = raw_config_to_base64(&metadata.quota_config_json); - // Expiry subset only: this entry feeds both the bootstrap/repair - // plan (peers must not receive transition rules) and cross-site - // consistency views (transition rules are site-local and would - // read as false mismatches). A deleted expiry state is a `None` - // value with the deletion's axis so repair can converge peers - // that missed the live delete. - let expiry_statement = lifecycle_expiry_statement(&metadata); - entry.expiry_lc_config = expiry_statement.as_ref().and_then(|(subset, _)| subset.clone()); - entry.cors_config = raw_config_to_base64(&metadata.cors_config_xml); - entry.policy_updated_at = maybe_time(metadata.policy_config_updated_at); - entry.tag_config_updated_at = maybe_time(metadata.tagging_config_updated_at); - entry.object_lock_config_updated_at = maybe_time(metadata.object_lock_config_updated_at); - entry.sse_config_updated_at = maybe_time(metadata.encryption_config_updated_at); - entry.versioning_config_updated_at = maybe_time(metadata.versioning_config_updated_at); - entry.replication_config_updated_at = maybe_time(metadata.replication_config_updated_at); - entry.quota_config_updated_at = maybe_time(metadata.quota_config_updated_at); - // The expiry axis, not the whole-config write time: local - // transition-only edits inflate the latter, and a repair item - // stamped with it could out-rank a newer real expiry edit on a - // third site. - entry.expiry_lc_config_updated_at = expiry_statement.map(|(_, axis)| axis); - entry.cors_config_updated_at = maybe_time(metadata.cors_config_updated_at); - entry.replication_targets_online = - Some(site_replication_targets_online(&bucket.name, &metadata.replication_config_xml).await); - } - - info.buckets.insert(bucket.name, entry); - } - - if let Some(iam_sys) = current_iam_handle() { - for (name, policy_doc) in iam_sys.list_policy_docs("").await.map_err(ApiError::from)? { - info.policies.insert( - name, - SRIAMPolicy { - policy: serde_json::to_value(policy_doc.policy).ok(), - updated_at: policy_doc.update_date, - api_version: Some(SITE_REPL_API_VERSION.to_string()), - }, - ); - } - - let users = iam_sys.list_users().await.map_err(ApiError::from)?; - for (name, user) in users { - info.user_info_map.insert(name, user); - } - - let groups = iam_sys.list_groups_load().await.map_err(ApiError::from)?; - for group in groups { - let desc = iam_sys.get_group_description(&group).await.map_err(ApiError::from)?; - info.group_desc_map.insert(group.clone(), desc); - } - - let mut user_policies = HashMap::::new(); - iam_sys - .load_mapped_policies(UserType::Reg, false, &mut user_policies) - .await - .map_err(ApiError::from)?; - for (name, mapping) in user_policies { - info.user_policies - .insert(name.clone(), mapped_policy_to_sr_mapping(name, false, UserType::Reg, mapping)); - } - - let mut group_policies = HashMap::::new(); - iam_sys - .load_mapped_policies(UserType::None, true, &mut group_policies) - .await - .map_err(ApiError::from)?; - for (name, mapping) in group_policies { - info.group_policies - .insert(name.clone(), mapped_policy_to_sr_mapping(name, true, UserType::None, mapping)); - } - } - - for (name, bucket_info) in &info.buckets { - if let Some(raw) = bucket_info - .replication_config - .as_ref() - .and_then(|value| serde_json::from_str::(value).ok()) - { - info.replication_cfg.insert(name.clone(), raw); - } - } - - Ok(info) -} - fn local_idp_settings() -> IDPSettings { let mut settings = IDPSettings::default(); if let Some(federation) = current_federated_identity_service() { @@ -4684,18 +1813,6 @@ fn local_idp_settings() -> IDPSettings { settings } -fn mapped_policy_to_sr_mapping(name: String, is_group: bool, user_type: UserType, mapping: MappedPolicy) -> SRPolicyMapping { - SRPolicyMapping { - user_or_group: name, - user_type: sr_wire_user_type(user_type, is_group), - is_group, - policy: mapping.policies, - updated_at: Some(mapping.update_at), - api_version: Some(SITE_REPL_API_VERSION.to_string()), - ..Default::default() - } -} - fn filter_sr_info(mut info: SRInfo, opts: &SRStatusOptions) -> SRInfo { if opts.include_all_defaults() { return info; @@ -4814,13 +1931,9 @@ async fn fetch_peer_sr_info( return Err(s3_error!(InvalidRequest, "site replication service account is not configured")); } - let body = send_peer_admin_get_request( - &runtime_peer_connection(peer)?, - &sr_metainfo_path(uri), - &state.service_account_access_key, - service_account_secret_key, - ) - .await?; + let body = PeerAdminRequest::get(&runtime_peer_connection(peer)?, &sr_metainfo_path(uri), &state.service_account_access_key) + .send_get(service_account_secret_key) + .await?; serde_json::from_slice(&body).map_err(|e| { S3Error::with_message( @@ -5548,16 +2661,6 @@ fn peer_endpoint_refresh_requested(state: &SiteReplicationState, incoming: &Peer .is_some_and(|peer| !peer_connection_settings_match(peer, incoming)) } -fn pending_endpoint_refresh(state: &SiteReplicationState) -> Option { - state.pending_endpoint_refresh.clone().or_else(|| { - state - .retry_queue - .iter() - .find(|event| event.path == SITE_REPLICATION_ENDPOINT_REFRESH_RETRY_PATH) - .and_then(|event| serde_json::from_str(&event.last_error).ok()) - }) -} - fn merge_pending_endpoint_refresh( state: &SiteReplicationState, candidate: &PendingEndpointRefresh, @@ -5645,41 +2748,10 @@ fn endpoint_refresh_target_state(state: &SiteReplicationState, pending: &Pending target_state } -fn parse_endpoint_refresh_status(peer: &PeerInfo, body: &[u8]) -> S3Result<()> { - let status: ReplicateEditStatus = serde_json::from_slice(body).map_err(|_| { - S3Error::with_message( - S3ErrorCode::InternalError, - format!("peer {} does not support endpoint target refresh", peer.endpoint), - ) - })?; - if status.success { - Ok(()) - } else { - Err(S3Error::with_message( - S3ErrorCode::InternalError, - format!("peer {} failed endpoint target refresh: {}", peer.endpoint, status.err_detail), - )) - } -} - fn endpoint_refresh_capability_supported(peer: &PeerInfo, status: StatusCode, body: &[u8]) -> S3Result { peer_capability_response_supported(peer, status, body) } -fn peer_capability_response_supported(peer: &PeerInfo, status: StatusCode, body: &[u8]) -> S3Result { - if status.is_success() { - return Ok(parse_endpoint_refresh_status(peer, body).is_ok()); - } - if matches!(status, StatusCode::BAD_REQUEST | StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED) { - return Ok(false); - } - - Err(S3Error::with_message( - S3ErrorCode::InternalError, - format!("probe site replication capability on peer {} failed with {status}", peer.endpoint), - )) -} - async fn require_add_peer_tls_capability(sites: &[PeerSite], local_peer: &PeerInfo) -> S3Result<()> { if !add_peer_tls_capability_required(sites) { return Ok(()); @@ -5692,15 +2764,10 @@ async fn require_add_peer_tls_capability(sites: &[PeerSite], local_peer: &PeerIn let probes = futures::future::join_all(remote_sites.iter().map(|site| async move { let connection = PeerConnection::try_from(*site)?; let client = site_replication_client_for(&connection).await?; - send_peer_admin_request_raw_with_client( - &client, - &connection, - SITE_REPLICATION_PEER_TLS_CAPABILITY_PATH, - &site.access_key, - &site.secret_key, - &(), - ) - .await + PeerAdminRequest::put(&connection, SITE_REPLICATION_PEER_TLS_CAPABILITY_PATH, &site.access_key) + .with_client(&client) + .send_raw(&site.secret_key, Some(&())) + .await })) .await; for (site, probe) in remote_sites.into_iter().zip(probes) { @@ -5771,15 +2838,10 @@ async fn require_edit_peer_tls_capability( async fn probe_proposed_peer_tls_transport(peer: &PeerInfo, access_key: &str, secret_key: &str) -> S3Result<()> { let connection = PeerConnection::try_from(peer)?; let client = site_replication_client_for(&connection).await?; - let (status, body) = send_peer_admin_request_raw_with_client( - &client, - &connection, - SITE_REPLICATION_PEER_TLS_CAPABILITY_PATH, - access_key, - secret_key, - &(), - ) - .await?; + let (status, body) = PeerAdminRequest::put(&connection, SITE_REPLICATION_PEER_TLS_CAPABILITY_PATH, access_key) + .with_client(&client) + .send_raw(secret_key, Some(&())) + .await?; if peer_capability_response_supported(peer, status, &body)? { Ok(()) } else { @@ -5920,15 +2982,10 @@ async fn send_endpoint_refresh_admin_request_raw_with_transports( let mut last_error = None; let mut last_response = None; for transport in transports { - match send_peer_admin_request_raw_with_client( - &transport.client, - &transport.connection, - path, - access_key, - secret_key, - body, - ) - .await + match PeerAdminRequest::put(&transport.connection, path, access_key) + .with_client(&transport.client) + .send_raw(secret_key, Some(body)) + .await { Ok((status, response)) if matches!(status, StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED | StatusCode::GONE) @@ -5960,13 +3017,13 @@ async fn legacy_peer_bucket_names_with_transports( ) -> S3Result> { let mut last_error = None; for transport in transports { - match send_peer_admin_get_request_with_client( - &transport.client, + match PeerAdminRequest::get( &transport.connection, "/rustfs/admin/v3/site-replication/metainfo?buckets=true", access_key, - secret_key, ) + .with_client(&transport.client) + .send_get(secret_key) .await { Ok(body) => return peer_bucket_names_from_metainfo(transport.connection.endpoint(), &body), @@ -6180,87 +3237,6 @@ fn validate_remove_sites_req(state: &SiteReplicationState, req: &SRRemoveReq) -> Ok(()) } -fn summarize_peer_error_detail(detail: &str) -> String { - let detail = detail.trim(); - let detail_chars = detail.chars().count(); - if detail_chars <= SITE_REPLICATION_PEER_ERROR_DETAIL_LIMIT { - return detail.to_string(); - } - - let suffix = "... (truncated)"; - let take_chars = SITE_REPLICATION_PEER_ERROR_DETAIL_LIMIT.saturating_sub(suffix.chars().count()); - let mut summary: String = detail.chars().take(take_chars).collect(); - summary.push_str(suffix); - summary -} - -/// The wall clock in unix nanoseconds, clamped into u64. A pre-1970 (or -/// post-2554) clock yields 0, which makes the hybrid allocation below -/// degrade to the plain `previous + 1` counter — monotone, never panicking. -fn edit_generation_wall_clock() -> u64 { - u64::try_from(OffsetDateTime::now_utc().unix_timestamp_nanos()).unwrap_or(0) -} - -/// Allocate the next peer-edit generation as a hybrid logical clock: -/// `max(wall clock in unix nanoseconds, previous + 1)`. Called inside the -/// state transaction, so the value is handed out under the distributed -/// state-object lock and two nodes of this site can never take the same one -/// (`previous + 1` keeps the sequence strictly increasing even when two -/// allocations land in one clock tick, and keeps it monotone on a node -/// whose clock stepped backwards mid-lifetime). -/// -/// The wall-clock floor is what survives the counter's death. A site -/// removed while unreachable — the receiver never dropped it from its peer -/// map, so the load-time mark pruning in `parse_site_replication_state` -/// never fired — that later rejoins recreates its state object with the -/// counter back at zero. A plain counter would then hand out generations -/// below the receiver's stale high-water mark and every delivery would be -/// silently fenced until the counter caught up. Jumping to wall time clears -/// that mark: every value the deleted lifetime handed out was capped by the -/// wall clock at its own allocation (or by a prior lifetime's cap, applied -/// inductively), so the recreated lifetime's first allocation exceeds them -/// all — while a pre-removal delivery still in flight stays below the new -/// floor and remains correctly fenced. Marks recorded by pre-hybrid -/// receivers (small plain-counter values) sit far below any wall-clock -/// value, so a restarted origin passes those too — the fix needs only the -/// sender upgraded, nothing on the wire or in the receiver changed. -/// -/// A wall clock that regresses across a delete/recreate (the recreating -/// node's clock behind the clock that fed the previous lifetime) mints -/// below the stale mark and the origin stays fenced — but only until real -/// time passes the previous lifetime's last allocation, because every later -/// allocation takes the wall-clock floor again (and never longer than -/// [`PEER_EDIT_FENCE_STALENESS_WINDOW_NANOS`]: a regression past the window -/// leaves the mark implausibly distant and the origin runs unfenced -/// immediately). Bounded by the skew, -/// self-healing, and no rollback window beyond the plain counter's: a -/// delivery applies only at or above the receiver's mark, so the one -/// cross-lifetime interleaving that can apply stale content — a -/// pre-removal delivery whose generation lands above everything the -/// regressed new lifetime has minted — required the same straggler landing -/// above the mark under the plain counter, where the recreated counter's -/// low restart made it strictly easier to hit. -fn next_peer_edit_generation(state: &mut SiteReplicationState) -> u64 { - state.edit_generation = edit_generation_wall_clock().max(state.edit_generation.saturating_add(1)); - state.edit_generation -} - -/// Build the peer-edit request path carrying the fencing token. The bare -/// constant stays the retry-queue key: the query only fences the wire -/// delivery, and a per-generation key would make every retry event unique. -/// Without a local deployment id there is nothing to fence against, so the -/// unstamped path is sent and the receiver keeps its pre-fence behaviour. -fn peer_edit_path_with_fence(origin: Option<&str>, generation: u64) -> String { - let Some(origin) = origin.filter(|origin| !origin.is_empty()) else { - return SITE_REPLICATION_PEER_EDIT_PATH.to_string(); - }; - let query = form_urlencoded::Serializer::new(String::new()) - .append_pair(SITE_REPLICATION_EDIT_ORIGIN_QUERY, origin) - .append_pair(SITE_REPLICATION_EDIT_GENERATION_QUERY, &generation.to_string()) - .finish(); - format!("{SITE_REPLICATION_PEER_EDIT_PATH}?{query}") -} - /// The (origin site, generation) fence an incoming peer edit carries, when the /// sender stamped one. An unstamped edit (older peer) has no fence and is /// applied as before. @@ -6368,881 +3344,6 @@ fn record_applied_peer_edit_generation(state: &mut SiteReplicationState, origin: *applied = (*applied).max(generation); } -fn retry_event_matches(event: &SiteReplicationRetryEvent, peer: &PeerInfo, path: &str) -> bool { - (event.peer_deployment_id == peer.deployment_id || event.peer_endpoint == peer.endpoint) && event.path == path -} - -const SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH: &str = "internal:retry-snapshot:iam"; -const SITE_REPLICATION_RETRY_BUCKET_METADATA_SNAPSHOT_PATH: &str = "internal:retry-snapshot:bucket-metadata"; - -fn collapsed_retry_queue_path(path: &str) -> Option<&'static str> { - let base_path = path.split_once('?').map(|(base, _)| base).unwrap_or(path); - match base_path { - "/rustfs/admin/v3/site-replication/peer/iam-item" | SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH => { - Some(SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH) - } - "/rustfs/admin/v3/site-replication/peer/bucket-meta" | SITE_REPLICATION_RETRY_BUCKET_METADATA_SNAPSHOT_PATH => { - Some(SITE_REPLICATION_RETRY_BUCKET_METADATA_SNAPSHOT_PATH) - } - _ => None, - } -} - -fn normalize_collapsed_retry_queue_paths(queue: &mut Vec) -> bool { - let mut changed = false; - let mut normalized: Vec = Vec::with_capacity(queue.len()); - for mut event in queue.drain(..) { - if let Some(path) = collapsed_retry_queue_path(&event.path) - && event.path != path - { - event.path = path.to_string(); - changed = true; - } - - let duplicate = normalized.iter().position(|existing| { - existing.path == event.path - && (existing.peer_deployment_id == event.peer_deployment_id || existing.peer_endpoint == event.peer_endpoint) - }); - let Some(index) = duplicate else { - normalized.push(event); - continue; - }; - - changed = true; - let existing = &mut normalized[index]; - let event_is_newer = match (event.updated_at, existing.updated_at) { - (Some(event), Some(existing)) => event >= existing, - (Some(_), None) => true, - _ => false, - }; - if event_is_newer { - let retry_count = existing.retry_count.max(event.retry_count); - *existing = event; - existing.retry_count = retry_count; - } else { - existing.retry_count = existing.retry_count.max(event.retry_count); - } - existing.failed = existing.retry_count >= SITE_REPLICATION_RETRY_FAILED_AFTER; - } - *queue = normalized; - changed -} - -async fn migrate_collapsed_retry_queue_paths() -> S3Result<()> { - update_site_replication_state_when_changed(|state| { - Ok(if normalize_collapsed_retry_queue_paths(&mut state.retry_queue) { - StateCommit::Changed(()) - } else { - StateCommit::Unchanged(()) - }) - }) - .await -} - -#[cfg(test)] -fn dequeue_site_replication_retry_events(queue: &mut Vec, peer: &PeerInfo, path: &str) -> usize { - settle_site_replication_retry_events(queue, peer, path, None) -} - -/// Repair-path settlement: also clears snapshot-escalated entries. Running a -/// repair is the operator's explicit accountability transfer for the -/// possibly-unreplayed deletion the marker records; ordinary delivery -/// successes must not clear it (see [`settle_site_replication_retry_events`]). -fn dequeue_site_replication_retry_events_including_escalated( - queue: &mut Vec, - peer: &PeerInfo, - path: &str, -) -> usize { - let before = queue.len(); - let collapsed_path = collapsed_retry_queue_path(path); - queue.retain(|event| { - !retry_event_matches(event, peer, path) - && !collapsed_path.is_some_and(|collapsed_path| retry_event_matches(event, peer, collapsed_path)) - }); - before.saturating_sub(queue.len()) -} - -/// Remove the retry events for (peer, path) that `generation` is entitled to -/// settle. A successful delivery only proves the peer reached the state the -/// delivery carried: while it was in flight another edit can commit, fail its -/// own delivery, and enqueue for the same (peer, path). Erasing that event -/// would leave the peer on the older edit with no retry left, so an event -/// stamped with a NEWER generation survives. `None` settles unconditionally — -/// the broadcast paths that carry no generation, whose retry events live under -/// their own paths and never collide with peer-edit deliveries. -fn settle_site_replication_retry_events( - queue: &mut Vec, - peer: &PeerInfo, - path: &str, - generation: Option, -) -> usize { - let before = queue.len(); - let collapsed_path = collapsed_retry_queue_path(path); - queue.retain(|event| { - if !retry_event_matches(event, peer, path) { - return true; - } - // A wire-path success identifies no IAM or bucket-metadata entity. - // This also protects legacy rows until the startup migration moves - // them under their internal snapshot path. - if collapsed_path.is_some() { - return true; - } - // A snapshot-escalated entry records a possibly-unreplayed deletion. - // Collapsed paths are shared by every entity, so a later successful - // delivery of a DIFFERENT item proves nothing about the deleted one — - // only a repair settles it (dequeue_..._including_escalated). - if event.last_error == SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER { - return true; - } - match (generation, event.edit_generation) { - (Some(settled), Some(failed)) => failed > settled, - _ => false, - } - }); - before.saturating_sub(queue.len()) -} - -fn upsert_site_replication_retry_event( - queue: &mut Vec, - peer: &PeerInfo, - path: &str, - error: &str, - generation: Option, -) { - let path = collapsed_retry_queue_path(path).unwrap_or(path); - let now = OffsetDateTime::now_utc(); - let detail = summarize_peer_error_detail(error); - if let Some(event) = queue.iter_mut().find(|event| retry_event_matches(event, peer, path)) { - event.retry_count = event.retry_count.saturating_add(1); - event.failed = event.retry_count >= SITE_REPLICATION_RETRY_FAILED_AFTER; - event.last_error = detail; - event.updated_at = Some(now); - // Keep the newest generation: an older delivery that fails afterwards - // must not lower the fence and let its own success settle the event. - event.edit_generation = event.edit_generation.max(generation); - return; - } - - queue.push(SiteReplicationRetryEvent { - id: Uuid::new_v4().to_string(), - peer_deployment_id: peer.deployment_id.clone(), - peer_endpoint: peer.endpoint.clone(), - path: path.to_string(), - retry_count: 1, - failed: false, - last_error: detail, - updated_at: Some(now), - edit_generation: generation, - }); - if queue.len() > SITE_REPLICATION_RETRY_QUEUE_LIMIT { - let overflow = queue.len() - SITE_REPLICATION_RETRY_QUEUE_LIMIT; - queue.drain(0..overflow); - } -} - -fn retry_stats_for_state(state: &SiteReplicationState) -> Option { - if state.retry_queue.is_empty() { - return None; - } - - Some(SRRetryStats { - pending: state.retry_queue.iter().filter(|event| !event.failed).count(), - failed: state.retry_queue.iter().filter(|event| event.failed).count(), - last_error: state - .retry_queue - .iter() - .rev() - .find_map(|event| (!event.last_error.is_empty()).then(|| event.last_error.clone())) - .unwrap_or_default(), - api_version: Some(SITE_REPL_API_VERSION.to_string()), - }) -} - -async fn enqueue_site_replication_retry_event(peer: &PeerInfo, path: &str, error: &S3Error) { - enqueue_site_replication_retry_event_for_generation(peer, path, error, None).await -} - -async fn enqueue_site_replication_retry_event_for_generation( - peer: &PeerInfo, - path: &str, - error: &S3Error, - generation: Option, -) { - let peer_owned = peer.clone(); - let path_owned = path.to_string(); - let error_text = error.to_string(); - let result = update_site_replication_state(move |state| { - // A peer that left the state can never drain its entries again - // (remove_sites already pruned them); recording a late failure for it - // would only pollute retry_stats until the queue cap evicts it. - if state.peers.contains_key(&peer_owned.deployment_id) { - upsert_site_replication_retry_event(&mut state.retry_queue, &peer_owned, &path_owned, &error_text, generation); - } - Ok(()) - }) - .await; - - if let Err(err) = result { - warn!( - component = LOG_COMPONENT_ADMIN, - subsystem = LOG_SUBSYSTEM_SITE_REPLICATION, - event = EVENT_ADMIN_SITE_REPLICATION_STATE, - peer = %peer.endpoint, - path, - error = ?err, - "failed to persist site replication retry event" - ); - } -} - -fn retry_bucket_operation(path: &str) -> Option { - let (base_path, query) = path.split_once('?')?; - if base_path != SITE_REPLICATION_PEER_BUCKET_OPS_PATH { - return None; - } - - form_urlencoded::parse(query.as_bytes()).find_map(|(key, value)| (key == "operation").then(|| value.into_owned())) -} - -fn retry_event_replayed_by_bootstrap(event: &SiteReplicationRetryEvent) -> bool { - matches!( - retry_bucket_operation(&event.path).as_deref(), - Some(SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING | SITE_REPLICATION_BUCKET_OP_CONFIGURE_REPLICATION) - ) -} - -/// Exponential backoff base for the background retry drain, aligned with the -/// reconcile cadence (`site_replication_reconcile::RECONCILE_INTERVAL`). -const SITE_REPLICATION_RETRY_DRAIN_BASE_BACKOFF_SECS: i64 = 600; -/// Backoff ceiling: a permanently failed peer is still probed daily. -const SITE_REPLICATION_RETRY_DRAIN_MAX_BACKOFF_SECS: i64 = 86_400; - -/// What the background drain may do for one retry event. Everything not -/// representable here is operator territory (manual repair). -#[derive(Debug, Clone, PartialEq, Eq)] -enum RetryDrainAction { - /// Constant-path IAM item deliveries collapse into one queue entry per - /// peer and their bodies are not persisted; the only faithful replay is - /// the current IAM snapshot from the bootstrap plan. - IamSnapshot, - /// Same collapse for bucket-meta deliveries: replay the bucket metadata - /// snapshot from the bootstrap plan. - BucketMetadataSnapshot, - /// A self-contained bucket op the bootstrap plan can re-derive for its - /// bucket (`make-with-versioning` / `configure-replication`). - BucketOpReplay { operation: String, bucket: String }, - /// Re-send the current peer records under a fresh edit generation. - PeerEdit, -} - -#[derive(Clone)] -enum RetrySnapshot { - Iam(Vec), - BucketMetadata(Vec), -} - -impl RetrySnapshot { - fn from_plan(action: &RetryDrainAction, plan: &SiteReplicationBootstrapPlan) -> Option { - match action { - RetryDrainAction::IamSnapshot => Some(Self::Iam(plan.iam_items.clone())), - RetryDrainAction::BucketMetadataSnapshot => Some(Self::BucketMetadata(plan.bucket_items.clone())), - _ => None, - } - } - - fn fingerprint(&self) -> S3Result>> { - let mut payloads = match self { - Self::Iam(items) => items.iter().map(serde_json::to_vec).collect::, _>>(), - Self::BucketMetadata(items) => items.iter().map(serde_json::to_vec).collect::, _>>(), - } - .map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize retry snapshot failed: {err}")))?; - payloads.sort_unstable(); - Ok(payloads) - } - - fn replay_after_change(previous: &Self, fresh: &Self, observed_at: OffsetDateTime) -> Self { - match (previous, fresh) { - (Self::Iam(previous), Self::Iam(fresh)) => { - let fresh_keys: HashSet = fresh.iter().filter_map(iam_snapshot_key).collect(); - let mut replay = fresh.clone(); - for item in previous { - if iam_snapshot_key(item).is_some_and(|key| !fresh_keys.contains(&key)) { - replay.extend(iam_snapshot_tombstones(item, observed_at)); - } - } - Self::Iam(replay) - } - (Self::BucketMetadata(previous), Self::BucketMetadata(fresh)) => { - let fresh_keys: HashSet<(&str, &str)> = fresh - .iter() - .map(|item| (item.bucket.as_str(), item.r#type.as_str())) - .collect(); - let mut replay = fresh.clone(); - for item in previous { - if !fresh_keys.contains(&(item.bucket.as_str(), item.r#type.as_str())) { - replay.push(bucket_metadata_snapshot_tombstone(item, observed_at)); - } - } - Self::BucketMetadata(replay) - } - _ => fresh.clone(), - } - } - - async fn send(&self, transport: &PeerTransport, access_key: &str, secret_key: &str) -> S3Result<()> { - match self { - Self::Iam(items) => { - for item in items { - SiteReplicationRepairTask::Iam(item) - .send(transport, access_key, secret_key) - .await?; - } - } - Self::BucketMetadata(items) => { - for item in items { - SiteReplicationRepairTask::BucketMetadata(item) - .send(transport, access_key, secret_key) - .await?; - } - } - } - Ok(()) - } -} - -#[derive(Hash, PartialEq, Eq)] -enum IamSnapshotKey { - Policy(String), - User(String), - Group(String), - PolicyMapping { target: String, user_type: i64, is_group: bool }, -} - -fn iam_snapshot_key(item: &SRIAMItem) -> Option { - match item.r#type.as_str() { - "policy" => Some(IamSnapshotKey::Policy(item.name.clone())), - "iam-user" => item - .iam_user - .as_ref() - .map(|user| IamSnapshotKey::User(user.access_key.clone())), - "group-info" => item - .group_info - .as_ref() - .map(|group| IamSnapshotKey::Group(group.update_req.group.clone())), - "policy-mapping" => item.policy_mapping.as_ref().map(|mapping| IamSnapshotKey::PolicyMapping { - target: mapping.user_or_group.clone(), - user_type: mapping.user_type, - is_group: mapping.is_group, - }), - _ => None, - } -} - -fn iam_snapshot_tombstones(item: &SRIAMItem, observed_at: OffsetDateTime) -> Vec { - let mut tombstone = item.clone(); - tombstone.updated_at = Some(observed_at); - match item.r#type.as_str() { - "policy" => tombstone.policy = None, - "iam-user" => { - if let Some(user) = tombstone.iam_user.as_mut() { - user.is_delete_req = true; - user.user_req = None; - } - } - "group-info" => { - let Some(group) = tombstone.group_info.as_mut() else { - return Vec::new(); - }; - group.update_req.is_remove = true; - if group.update_req.members.is_empty() { - return vec![tombstone]; - } - let mut delete = tombstone.clone(); - if let Some(group) = delete.group_info.as_mut() { - group.update_req.members.clear(); - } - return vec![tombstone, delete]; - } - "policy-mapping" => { - if let Some(mapping) = tombstone.policy_mapping.as_mut() { - mapping.policy.clear(); - } - } - _ => return Vec::new(), - } - vec![tombstone] -} - -fn bucket_metadata_snapshot_tombstone(item: &SRBucketMeta, observed_at: OffsetDateTime) -> SRBucketMeta { - SRBucketMeta { - r#type: item.r#type.clone(), - bucket: item.bucket.clone(), - updated_at: Some(observed_at), - expiry_updated_at: Some(observed_at), - api_version: item.api_version.clone(), - derived_rule_contract: item.derived_rule_contract, - ..Default::default() - } -} - -const SITE_REPLICATION_RETRY_SNAPSHOT_STABILITY_ATTEMPTS: usize = 3; - -fn classify_site_replication_retry_event(event: &SiteReplicationRetryEvent) -> Option { - let snapshot_action = match event.path.as_str() { - SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH => Some(RetryDrainAction::IamSnapshot), - SITE_REPLICATION_RETRY_BUCKET_METADATA_SNAPSHOT_PATH => Some(RetryDrainAction::BucketMetadataSnapshot), - _ => None, - }; - if snapshot_action.is_some() && event.last_error != SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER { - return snapshot_action; - } - if event.path.starts_with("internal:") { - // Marker records store payloads in `last_error` (legacy - // pending-endpoint-refresh backup and snapshot liabilities); they are - // not drainable delivery failures. - return None; - } - if event.last_error == SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER { - // Already snapshot-replayed once for this failure episode; a possible - // deletion cannot be replayed from a snapshot, so re-sending daily - // proves nothing. A new hook failure overwrites the marker. - return None; - } - let base_path = event.path.split_once('?').map(|(base, _)| base).unwrap_or(&event.path); - match base_path { - "/rustfs/admin/v3/site-replication/peer/iam-item" => Some(RetryDrainAction::IamSnapshot), - "/rustfs/admin/v3/site-replication/peer/bucket-meta" => Some(RetryDrainAction::BucketMetadataSnapshot), - SITE_REPLICATION_PEER_EDIT_PATH => Some(RetryDrainAction::PeerEdit), - SITE_REPLICATION_PEER_BUCKET_OPS_PATH => { - let operation = retry_bucket_operation(&event.path)?; - if !matches!( - operation.as_str(), - SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING | SITE_REPLICATION_BUCKET_OP_CONFIGURE_REPLICATION - ) { - // Destructive ops (delete-bucket / force-delete-bucket) are - // operator territory: replaying them against a peer whose - // bucket was since recreated is irreversible. - return None; - } - let bucket = retry_bucket_name(&event.path)?; - Some(RetryDrainAction::BucketOpReplay { operation, bucket }) - } - _ => None, - } -} - -fn retry_bucket_name(path: &str) -> Option { - let (_, query) = path.split_once('?')?; - form_urlencoded::parse(query.as_bytes()) - .find_map(|(key, value)| (key == "bucket" && !value.is_empty()).then(|| value.into_owned())) -} - -/// A collapsed retry event after a stable snapshot resend is escalated with -/// this marker instead of being cleared: the snapshot contains no task for a -/// failed deletion, so remote absence remains operator-visible. Collapsed -/// failures use an internal queue path so ordinary successes and older nodes -/// cannot settle an unrelated entity's liability. -const SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER: &str = "snapshot replayed; a failed deletion cannot be replayed from a snapshot — run site replication repair or re-deliver to settle"; - -/// Escalate a collapsed retry event after its snapshot resend succeeded, -/// unless a newer failure was recorded after `snapshot_updated_at` (that -/// failure belongs to a newer local commit the snapshot did not contain and -/// must keep the entry drain-eligible). -fn escalate_site_replication_retry_events_up_to( - queue: &mut Vec, - peer: &PeerInfo, - path: &str, - snapshot_updated_at: Option, -) -> usize { - let Some(marker_path) = collapsed_retry_queue_path(path) else { - return 0; - }; - - if path != marker_path { - queue.retain(|event| { - if !retry_event_matches(event, peer, path) { - return true; - } - matches!((event.updated_at, snapshot_updated_at), (Some(current), Some(seen)) if current > seen) - || matches!((event.updated_at, snapshot_updated_at), (Some(_), None)) - }); - } - - let marker_index = queue.iter().position(|event| retry_event_matches(event, peer, marker_path)); - let marker_index = marker_index.unwrap_or_else(|| { - queue.push(SiteReplicationRetryEvent { - id: Uuid::new_v4().to_string(), - peer_deployment_id: peer.deployment_id.clone(), - peer_endpoint: peer.endpoint.clone(), - path: marker_path.to_string(), - updated_at: snapshot_updated_at, - ..Default::default() - }); - queue.len() - 1 - }); - let event = &mut queue[marker_index]; - let newer_failure_recorded = match (event.updated_at, snapshot_updated_at) { - (Some(current), Some(seen)) => current > seen, - (Some(_), None) => true, - (None, _) => false, - }; - if newer_failure_recorded && event.last_error != SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER { - return 0; - } - event.failed = true; - event.retry_count = event.retry_count.max(SITE_REPLICATION_RETRY_FAILED_AFTER); - event.last_error = SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER.to_string(); - event.updated_at = Some(OffsetDateTime::now_utc()); - 1 -} - -async fn escalate_site_replication_retry_event_up_to(peer: &PeerInfo, path: &str, snapshot_updated_at: Option) { - let peer_owned = peer.clone(); - let path_owned = path.to_string(); - let result = update_site_replication_state(move |state| { - escalate_site_replication_retry_events_up_to(&mut state.retry_queue, &peer_owned, &path_owned, snapshot_updated_at); - Ok(()) - }) - .await; - - if let Err(err) = result { - warn!( - component = LOG_COMPONENT_ADMIN, - subsystem = LOG_SUBSYSTEM_SITE_REPLICATION, - event = EVENT_ADMIN_SITE_REPLICATION_STATE, - peer = %peer.endpoint, - deployment_id = %peer.deployment_id, - path, - error = ?err, - "failed to escalate site replication retry event" - ); - } -} - -/// Whether the drain may attempt this event now. -fn site_replication_retry_backoff_elapsed(event: &SiteReplicationRetryEvent, now: OffsetDateTime) -> bool { - let Some(updated_at) = event.updated_at else { - return true; - }; - // 600 * 2^8 already exceeds the daily ceiling; capping the shift keeps - // the arithmetic overflow-free for any persisted retry_count. - let exponent = event.retry_count.saturating_sub(1).min(8); - let delay = (SITE_REPLICATION_RETRY_DRAIN_BASE_BACKOFF_SECS << exponent).min(SITE_REPLICATION_RETRY_DRAIN_MAX_BACKOFF_SECS); - now.unix_timestamp().saturating_sub(updated_at.unix_timestamp()) >= delay -} - -/// The subset of the retry queue the background drain is allowed to touch. -fn actionable_site_replication_retry_events(state: &SiteReplicationState, now: OffsetDateTime) -> Vec { - state - .retry_queue - .iter() - .filter(|event| classify_site_replication_retry_event(event).is_some()) - .filter(|event| state.peers.contains_key(&event.peer_deployment_id)) - .filter(|event| site_replication_retry_backoff_elapsed(event, now)) - .cloned() - .collect() -} - -/// Background consumer for the retry queue, run from the reconcile tick. -/// -/// Scope: this settles "delivered once and failed" entries whose replay is -/// faithful (bucket ops, peer edits). Collapsed iam-item / bucket-meta -/// entries are snapshot-resent and then *escalated*, not cleared — a failed -/// deletion leaves no task in the snapshot, so remote absence stays unproven -/// until a later delivery or a manual repair. A hook that never fired (crash -/// between the local commit and the send) leaves no entry at all, so the -/// drain is not a full cross-site diff-heal; manual repair remains the -/// authoritative catch-all. -async fn drain_site_replication_retry_queue() { - if let Err(err) = drain_site_replication_retry_queue_inner().await { - warn!( - event = EVENT_ADMIN_SITE_REPLICATION_STATE, - component = LOG_COMPONENT_ADMIN, - subsystem = LOG_SUBSYSTEM_SITE_REPLICATION, - result = "retry_drain_failed", - error = ?err, - "admin site replication state" - ); - } -} - -async fn drain_site_replication_retry_queue_inner() -> S3Result<()> { - let Some(runtime) = runtime_site_replication_targets().await? else { - return Ok(()); - }; - let actionable = actionable_site_replication_retry_events(&runtime.state, OffsetDateTime::now_utc()); - if actionable.is_empty() { - return Ok(()); - } - let Some(store) = current_object_store_handle() else { - return Ok(()); - }; - if runtime.state.pending_endpoint_refresh.is_some() - || runtime.state.pending_remove.is_some() - || runtime.state.pending_rotation.is_some() - { - // The tick-level gate ran before the reconcilers; a multi-step flow - // (endpoint refresh commits its pending marker without the lifecycle - // guard) may have started since. Re-check on the fresh state. - return Ok(()); - } - // Serialize against operator repair execution. This does NOT close the - // dry-run -> execute window (dry-run takes no lock): a drain settling a - // replayable bucket-op entry in that window changes the preflight token - // and execute fails safe with "preflight is stale" — the operator - // re-runs the dry-run. Lock order matches repair: lifecycle guard (held - // by the reconcile tick) -> repair execution lock -> state object lock - // inside the send bookkeeping. An operator repair holding the lock makes - // this tick skip after the lock-acquire timeout. - with_config_object_write_lock(store, SITE_REPLICATION_REPAIR_EXECUTION_LOCK_PATH.to_string(), move || async move { - drain_site_replication_retry_queue_locked(runtime, actionable).await - }) - .await - .map_err(ApiError::from)? -} - -async fn drain_site_replication_retry_queue_locked( - runtime: SiteReplicationRuntime, - events: Vec, -) -> S3Result<()> { - let needs_plan = events - .iter() - .any(|event| !matches!(classify_site_replication_retry_event(event), Some(RetryDrainAction::PeerEdit))); - // The plan is a full local snapshot (buckets + IAM); build it once per - // tick and only when a snapshot resend is actually due. - let plan = if needs_plan { - let info = build_sr_info(&runtime.state, &runtime.local_peer).await?; - Some(site_replication_bootstrap_plan(&info)?) - } else { - None - }; - - let mut events_by_peer: BTreeMap> = BTreeMap::new(); - for event in events { - events_by_peer - .entry(event.peer_deployment_id.clone()) - .or_default() - .push(event); - } - - let mut settled = 0usize; - let mut failures = 0usize; - for (deployment_id, peer_events) in events_by_peer { - let Some(peer) = runtime.state.peers.get(&deployment_id) else { - continue; - }; - if deployment_id == runtime.local_peer.deployment_id - || same_identity_endpoint(&peer.endpoint, &runtime.local_peer.endpoint) - { - continue; - } - let transport = match PeerTransport::for_runtime_peer(peer).await { - Ok(transport) => transport, - Err(err) => { - // Record the attempt so backoff advances for an unreachable - // peer instead of re-dialing it every tick. - for event in &peer_events { - enqueue_site_replication_retry_event(peer, &event.path, &err).await; - } - failures += peer_events.len(); - continue; - } - }; - for event in peer_events { - let Some(action) = classify_site_replication_retry_event(&event) else { - continue; - }; - match drain_one_site_replication_retry_event(&runtime, peer, &transport, &event, action, plan.as_ref()).await { - Ok(true) => settled += 1, - Ok(false) => {} - Err(_) => failures += 1, - } - } - } - - if settled > 0 || failures > 0 { - info!( - event = EVENT_ADMIN_SITE_REPLICATION_STATE, - component = LOG_COMPONENT_ADMIN, - subsystem = LOG_SUBSYSTEM_SITE_REPLICATION, - result = "retry_drain_settled", - settled, - failures, - "admin site replication state" - ); - } - Ok(()) -} - -/// Replay one retry event against its peer. Returns `Ok(true)` when the -/// event was settled (delivered, or provably stale), `Ok(false)` when it was -/// skipped, and `Err` after a failed delivery (already re-queued with an -/// incremented retry count). -async fn drain_one_site_replication_retry_event( - runtime: &SiteReplicationRuntime, - peer: &PeerInfo, - transport: &PeerTransport, - event: &SiteReplicationRetryEvent, - action: RetryDrainAction, - plan: Option<&SiteReplicationBootstrapPlan>, -) -> S3Result { - let access_key = &runtime.state.service_account_access_key; - let secret_key = &runtime.service_account_secret_key; - match action.clone() { - RetryDrainAction::IamSnapshot | RetryDrainAction::BucketMetadataSnapshot => { - let Some(plan) = plan else { - return Ok(false); - }; - let mut current_snapshot = RetrySnapshot::from_plan(&action, plan).expect("snapshot action has a snapshot"); - let mut replay = current_snapshot.clone(); - for _ in 0..SITE_REPLICATION_RETRY_SNAPSHOT_STABILITY_ATTEMPTS { - let current_fingerprint = current_snapshot.fingerprint()?; - if let Err(err) = replay.send(transport, access_key, secret_key).await { - enqueue_site_replication_retry_event(peer, &event.path, &err).await; - return Err(err); - } - let fresh_info = build_sr_info(&runtime.state, &runtime.local_peer).await?; - let fresh_plan = site_replication_bootstrap_plan(&fresh_info)?; - let fresh_snapshot = RetrySnapshot::from_plan(&action, &fresh_plan).expect("snapshot action has a snapshot"); - if fresh_snapshot.fingerprint()? == current_fingerprint { - escalate_site_replication_retry_event_up_to(peer, &event.path, event.updated_at).await; - return Ok(true); - } - replay = RetrySnapshot::replay_after_change(¤t_snapshot, &fresh_snapshot, OffsetDateTime::now_utc()); - current_snapshot = fresh_snapshot; - } - Ok(false) - } - RetryDrainAction::BucketOpReplay { operation, bucket } => { - let Some(plan) = plan else { - return Ok(false); - }; - // Replay from the CURRENT plan, never the recorded path: the - // recorded query can carry an expired one-shot bootstrap token or - // a stale createdAt. - let make_op = operation == SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING; - let paths = if make_op { - &plan.bucket_make_ops - } else { - &plan.bucket_configure_ops - }; - let tasks: Vec> = paths - .iter() - .filter(|path| retry_bucket_name(path).as_deref() == Some(bucket.as_str())) - .map(|path| { - if make_op { - SiteReplicationRepairTask::BucketMake(path) - } else { - SiteReplicationRepairTask::Replication(path) - } - }) - .collect(); - if tasks.is_empty() { - // The bucket left the plan (deleted, or replication no longer - // configured): the recorded intent is stale, settle it. - dequeue_site_replication_retry_event(peer, &event.path).await; - return Ok(true); - } - for task in &tasks { - if let Err(err) = task.send(transport, access_key, secret_key).await { - enqueue_site_replication_retry_event(peer, &event.path, &err).await; - return Err(err); - } - } - dequeue_site_replication_retry_event(peer, &event.path).await; - Ok(true) - } - RetryDrainAction::PeerEdit => { - // The recorded generation is stale by definition — the receiver - // fences it. Allocate a fresh generation and re-send the current - // peer records (a superset of the failed body; the receiver - // upserts), all inside one state transaction so the fence and the - // bodies agree. - let target_id = peer.deployment_id.clone(); - let (generation, bodies) = update_site_replication_state(move |state| { - if !state.peers.contains_key(&target_id) { - return Ok((None, Vec::new())); - } - Ok((Some(next_peer_edit_generation(state)), state.peers.values().cloned().collect::>())) - }) - .await?; - let Some(generation) = generation else { - // Peer left between the snapshot and now; the queue entry was - // already pruned by remove_sites. - return Ok(false); - }; - let local_deployment_id = Some(runtime.local_peer.deployment_id.as_str()).filter(|id| !id.is_empty()); - let edit_path = peer_edit_path_with_fence(local_deployment_id, generation); - let delivery_fence = local_deployment_id.is_some().then_some(generation); - for body in &bodies { - if let Err(err) = send_peer_admin_request_with_client( - &transport.client, - &transport.connection, - &edit_path, - access_key, - secret_key, - body, - ) - .await - { - enqueue_site_replication_retry_event_for_generation( - peer, - SITE_REPLICATION_PEER_EDIT_PATH, - &err, - delivery_fence, - ) - .await; - return Err(err); - } - } - dequeue_site_replication_retry_event_for_generation(peer, SITE_REPLICATION_PEER_EDIT_PATH, delivery_fence).await; - Ok(true) - } - } -} - -/// Remove a retry event for (peer, path) from the queue on successful delivery. -/// This is a no-op (load + no-op persist skipped) when no matching entry exists, -/// avoiding unnecessary I/O on the common path. -async fn dequeue_site_replication_retry_event(peer: &PeerInfo, path: &str) { - dequeue_site_replication_retry_event_for_generation(peer, path, None).await -} - -async fn dequeue_site_replication_retry_event_for_generation(peer: &PeerInfo, path: &str, generation: Option) { - let result = async { - // Fast path: this sits on every successful hook broadcast, so probe - // with a plain read first and only enter the locked RMW on a hit - // (the transaction re-checks under the lock). - let mut probe = load_site_replication_state().await?; - if settle_site_replication_retry_events(&mut probe.retry_queue, peer, path, generation) == 0 { - return Ok(()); - } - let peer_owned = peer.clone(); - let path_owned = path.to_string(); - update_site_replication_state(move |state| { - settle_site_replication_retry_events(&mut state.retry_queue, &peer_owned, &path_owned, generation); - Ok(()) - }) - .await?; - Ok::<_, S3Error>(()) - } - .await; - - if let Err(err) = result { - warn!( - component = LOG_COMPONENT_ADMIN, - subsystem = LOG_SUBSYSTEM_SITE_REPLICATION, - event = EVENT_ADMIN_SITE_REPLICATION_STATE, - peer = %peer.endpoint, - deployment_id = %peer.deployment_id, - path, - error = ?err, - "failed to dequeue site replication retry event" - ); - } -} - /// The removal's client-facing verdict. /// /// A fully-notified removal keeps answering with the historical success string, @@ -7478,13 +3579,12 @@ async fn drive_pending_remove(pending_remove: &PendingRemove, local_peer: &PeerI { continue; } - if let Err(err) = send_peer_admin_request_with_secret_candidates( + if let Err(err) = PeerAdminRequest::put( &runtime_peer_connection(peer)?, SITE_REPLICATION_PEER_REMOVE_PATH, &pending_remove.service_account_access_key, - &secret_candidates, - &pending_remove.req, ) + .send_with_secret_candidates(&secret_candidates, &pending_remove.req) .await { let err_detail = summarize_peer_error_detail(&format!("{}: {err}", peer.endpoint)); @@ -7717,7 +3817,7 @@ fn site_resync_page(status: &SRResyncOpStatus, limit: usize, offset: usize) -> S }; let encoded = serde_json::to_vec(&token) .map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("encode resync cursor failed: {err}")))?; - URL_SAFE_NO_PAD.encode(encoded) + URL_SAFE_NO_PAD.encode_to_string(encoded) } else { String::new() }; @@ -7736,7 +3836,7 @@ fn parse_site_resync_page(query: &HashMap, status: &SRResyncOpSt } let offset = if let Some(value) = query.get("continuationToken") { let decoded = URL_SAFE_NO_PAD - .decode(value) + .decode_to_vec(value) .map_err(|_| s3_error!(InvalidRequest, "invalid resync continuation token"))?; let token: SiteResyncContinuationToken = serde_json::from_slice(&decoded).map_err(|_| s3_error!(InvalidRequest, "invalid resync continuation token"))?; @@ -7750,161 +3850,6 @@ fn parse_site_resync_page(query: &HashMap, status: &SRResyncOpSt Ok((limit, offset)) } -fn bucket_target_endpoint(target: &BucketTarget) -> String { - let scheme = if target.secure { "https" } else { "http" }; - canonical_endpoint(&format!("{scheme}://{}", target.endpoint)) -} - -fn bucket_target_matches_peer(target: &BucketTarget, peer: &PeerInfo) -> bool { - if !target.deployment_id.is_empty() { - return target.deployment_id == peer.deployment_id; - } - bucket_target_endpoint(target) == canonical_endpoint(&peer.endpoint) -} - -fn site_replication_target_arns_by_peer(config: Option<&s3s::dto::ReplicationConfiguration>) -> HashMap { - let mut arns_by_peer = HashMap::new(); - let Some(config) = config else { - return arns_by_peer; - }; - - let mut configured_arns = Vec::new(); - if !config.role.trim().is_empty() { - configured_arns.push(config.role.clone()); - } - for rule in &config.rules { - let arn = rule.destination.bucket.trim(); - if !arn.is_empty() { - configured_arns.push(arn.to_string()); - } - } - - for arn in configured_arns { - if let Some(deployment_id) = replication_target_arn_deployment_id(&arn) { - arns_by_peer.entry(deployment_id).or_insert(arn); - } - } - - arns_by_peer -} - -fn site_replication_bucket_target_for_peer( - bucket: &str, - state: &SiteReplicationState, - peer: &PeerInfo, - service_account_secret_key: &str, - arn_override: Option, -) -> S3Result> { - if state.service_account_access_key.is_empty() || service_account_secret_key.is_empty() { - return Ok(None); - } - - let parsed = Url::parse(&peer.endpoint) - .ok() - .or_else(|| Url::parse(&format!("http://{}", peer.endpoint.trim())).ok()) - .ok_or_else(|| S3Error::with_message(S3ErrorCode::InvalidRequest, format!("invalid peer endpoint: {}", peer.endpoint)))?; - let host = parsed.host_str().ok_or_else(|| { - S3Error::with_message(S3ErrorCode::InvalidRequest, format!("peer endpoint missing host: {}", peer.endpoint)) - })?; - let port = parsed.port_or_known_default().ok_or_else(|| { - S3Error::with_message(S3ErrorCode::InvalidRequest, format!("peer endpoint missing port: {}", peer.endpoint)) - })?; - let region = current_region() - .map(|region| region.to_string()) - .filter(|region| !region.is_empty()) - .unwrap_or_else(|| "us-east-1".to_string()); - let arn = arn_override.unwrap_or_else(|| { - ARN::new( - BucketTargetType::ReplicationService, - peer.deployment_id.clone(), - String::new(), - bucket.to_string(), - ) - .to_string() - }); - - Ok(Some(BucketTarget { - source_bucket: bucket.to_string(), - endpoint: format!("{host}:{port}"), - credentials: Some(Credentials { - access_key: state.service_account_access_key.clone(), - secret_key: service_account_secret_key.to_string(), - session_token: None, - expiration: None, - }), - target_bucket: bucket.to_string(), - secure: parsed.scheme().eq_ignore_ascii_case("https"), - arn, - region, - target_type: BucketTargetType::ReplicationService, - deployment_id: peer.deployment_id.clone(), - skip_tls_verify: peer.skip_tls_verify, - ca_cert_pem: peer.ca_cert_pem.clone(), - ..Default::default() - })) -} - -fn reconcile_site_replication_bucket_targets( - existing: BucketTargets, - bucket: &str, - state: &SiteReplicationState, - local_peer: &PeerInfo, - config: Option<&s3s::dto::ReplicationConfiguration>, - service_account_secret_key: &str, -) -> S3Result { - if !state.enabled() || state.service_account_access_key.is_empty() || service_account_secret_key.is_empty() { - return Ok(existing); - } - - let configured_arns = site_replication_target_arns_by_peer(config); - let mut targets = existing.targets; - - for peer in state.peers.values() { - if peer.deployment_id == local_peer.deployment_id || same_identity_endpoint(&peer.endpoint, &local_peer.endpoint) { - continue; - } - - let Some(mut target) = site_replication_bucket_target_for_peer( - bucket, - state, - peer, - service_account_secret_key, - configured_arns.get(&peer.deployment_id).cloned(), - )? - else { - continue; - }; - - if let Some(index) = targets.iter().position(|existing| { - existing.target_type == BucketTargetType::ReplicationService - && (bucket_target_matches_peer(existing, peer) || existing.arn == target.arn) - }) { - let existing = targets[index].clone(); - target.path = existing.path; - target.region = existing.region; - target.bandwidth_limit = existing.bandwidth_limit; - target.replication_sync = existing.replication_sync; - target.storage_class = existing.storage_class; - target.health_check_duration = existing.health_check_duration; - target.disable_proxy = existing.disable_proxy; - target.reset_before_date = existing.reset_before_date; - target.reset_id = existing.reset_id; - target.total_downtime = existing.total_downtime; - target.last_online = existing.last_online; - target.online = existing.online; - target.latency = existing.latency; - target.edge = existing.edge; - target.edge_sync_before_expiry = existing.edge_sync_before_expiry; - target.offline_count = existing.offline_count; - targets[index] = target; - } else { - targets.push(target); - } - } - - Ok(BucketTargets { targets }) -} - fn bucket_target_deployment_id(target: &BucketTarget) -> Option { if !target.deployment_id.trim().is_empty() { return Some(target.deployment_id.clone()); @@ -7936,31 +3881,6 @@ fn prune_removed_site_replication_bucket_targets( (BucketTargets { targets }, removed) } -/// Whether every `site-repl-*` rule on this bucket resolves to a live remote target. -/// -/// The rule set alone cannot answer this: a rule can be perfectly formed while the endpoint -/// recorded for its peer is one this site cannot reach, so `update_all_targets` never built -/// a client for it and `replicate_object` drops every object against that ARN. Reads the -/// already-resolved client map rather than rebuilding clients, so it stays cheap enough for -/// the status path. -async fn site_replication_targets_online(bucket: &str, replication_config_xml: &[u8]) -> bool { - let Ok(config) = deserialize::(replication_config_xml) else { - return true; - }; - - for rule in config.rules.iter().filter(|rule| is_derived_site_replication_rule(rule)) { - if BucketTargetSys::get() - .get_remote_target_client_by_arn(bucket, &rule.destination.bucket) - .await - .is_none() - { - return false; - } - } - - true -} - /// Merge a peer's ILM expiry document into the local lifecycle config. /// /// Mirrors MinIO's `mergeWithCurrentLCConfig` with one hardening: incoming @@ -8042,15 +3962,6 @@ fn merge_incoming_lifecycle_config( }) } -/// True when the rule carries the expiry semantics that `replicateILMExpiry` -/// propagates. Del-marker expiration and abort-multipart are deliberately -/// excluded: MinIO's sender never emits them (`CloneNonTransition` drops -/// both), so treating them as traveling state would let a MinIO peer's -/// broadcast delete this site's del-marker-only rules. -fn lifecycle_rule_has_expiry(rule: &s3s::dto::LifecycleRule) -> bool { - rule.expiration.is_some() || rule.noncurrent_version_expiration.is_some() -} - fn lifecycle_rule_has_transition(rule: &s3s::dto::LifecycleRule) -> bool { rule.transitions.as_ref().is_some_and(|transitions| !transitions.is_empty()) || rule @@ -8059,73 +3970,6 @@ fn lifecycle_rule_has_transition(rule: &s3s::dto::LifecycleRule) -> bool { .is_some_and(|transitions| !transitions.is_empty()) } -/// Remove the fields that never travel between sites (MinIO -/// `CloneNonTransition` parity). -fn strip_site_local_lifecycle_fields(rule: &mut s3s::dto::LifecycleRule) { - rule.transitions = None; - rule.noncurrent_version_transitions = None; - rule.abort_incomplete_multipart_upload = None; - rule.del_marker_expiration = None; -} - -/// Reduce a lifecycle XML document to the expiry subset that is allowed to -/// travel between sites (what MinIO's sender emits): transition fields are -/// stripped and rules left with no expiry semantics are dropped. Returns -/// `None` when nothing remains — the receiver then merges with the empty set, -/// which is exactly the "no expiry rules here" statement. A document that -/// fails to parse is forwarded unfiltered (`Some(original)`): the receiver -/// merge strips it anyway, and turning a local parse error into a `None` -/// would delete the peers' replicated expiry rules. -fn lifecycle_expiry_subset_xml(raw: &[u8]) -> Option> { - if raw.is_empty() { - return None; - } - let config: s3s::dto::BucketLifecycleConfiguration = match deserialize(raw) { - Ok(config) => config, - Err(err) => { - warn!("failed to parse local lifecycle config for expiry replication; forwarding unfiltered: {err}"); - return Some(raw.to_vec()); - } - }; - let expiry_updated_at = config.expiry_updated_at.clone(); - let rules: Vec = config - .rules - .into_iter() - .filter_map(|mut rule| { - strip_site_local_lifecycle_fields(&mut rule); - lifecycle_rule_has_expiry(&rule).then_some(rule) - }) - .collect(); - if rules.is_empty() { - return None; - } - let subset = s3s::dto::BucketLifecycleConfiguration { - rules, - expiry_updated_at, - }; - match serialize(&subset) { - Ok(data) => Some(data), - Err(err) => { - warn!("failed to serialize lifecycle expiry subset; forwarding unfiltered: {err}"); - Some(raw.to_vec()) - } - } -} - -/// The expiry replication axis persisted in a lifecycle XML document, if any. -/// Used for the SRInfo bucket entry so bootstrap/repair items carry the -/// expiry axis instead of the whole-config write time (which local -/// transition-only edits inflate). -fn lifecycle_expiry_updated_at(raw: &[u8]) -> Option { - if raw.is_empty() { - return None; - } - deserialize::(raw) - .ok() - .and_then(|config| config.expiry_updated_at) - .map(OffsetDateTime::from) -} - /// The timestamp an incoming lc-config item must beat to be applied. /// /// - Present config with the expiry axis: the axis itself. @@ -8226,48 +4070,6 @@ fn is_zero_rule_lifecycle_tombstone(raw: &[u8]) -> bool { well_formed_document && quick_xml::de::from_reader::<_, Tombstone>(raw).is_ok() } -/// The ILM expiry statement this site contributes to its SRInfo bucket entry -/// (feeding bootstrap/repair and consistency views), if any. -/// `Some((subset_b64, axis))` — a `None` subset means "expiry rules were -/// removed at `axis`" and travels as an explicit timestamped delete item, so -/// a peer that missed the live delete still converges on repair. -fn lifecycle_expiry_statement( - metadata: &crate::admin::storage_api::bucket::metadata::BucketMetadata, -) -> Option<(Option, OffsetDateTime)> { - if metadata.lifecycle_config_xml.is_empty() { - // Deleted vs never configured: the whole-config write time survives - // deletion in bucket metadata and strictly exceeds the created-time - // backfill only after a real write. - return (metadata.lifecycle_config_updated_at > metadata.created).then_some((None, metadata.lifecycle_config_updated_at)); - } - let axis = lifecycle_expiry_updated_at(&metadata.lifecycle_config_xml); - match lifecycle_expiry_subset_xml(&metadata.lifecycle_config_xml) { - Some(subset) => { - // Legacy documents predate the axis field; their whole-config - // write time bounds the last expiry edit. - let axis = axis.unwrap_or(metadata.lifecycle_config_updated_at); - Some((raw_config_to_base64(&subset), axis)) - } - // Transition-only config: with an expiry axis the site once had - // expiry rules and properly removed them — the delete travels at - // that axis. Without one there is nothing to say (a delete stamped - // off the whole-config time would let a local transition edit erase - // newer peer expiry state). - None => axis.map(|axis| (None, axis)), - } -} - -/// Whether `rule` is in the shape the reconciler derives (`site-repl-` -/// naming the deployment its ARN targets). The reconciler rebuilds every such -/// rule from the current peer set — current peer or not, so a leftover from a -/// removed peer or a self-pointing rule is rebuilt away — while the merges -/// keep only the current peers' rules and treat a leftover as operator state -/// the edit replaces. An operator-authored `site-repl-*` id on an operator -/// ARN is outside the shape and survives every pass. -fn is_derived_site_replication_rule(rule: &ReplicationRule) -> bool { - site_replication_rule_deployment_id(rule).is_some() -} - fn replication_rule_deployment_id(rule: &ReplicationRule) -> Option { if let Some(rule_id) = rule.id.as_deref() { if let Some(deployment_id) = rule_id.strip_prefix("site-repl-") @@ -8317,123 +4119,6 @@ fn prune_removed_site_replication_rules( (Some(config), removed) } -fn build_site_replication_rule(arn: &str, priority: i32, rule_id: &str) -> ReplicationRule { - 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.to_string(), - ..Default::default() - }, - existing_object_replication: Some(ExistingObjectReplication { - status: ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::ENABLED), - }), - filter: None, - id: Some(rule_id.to_string()), - prefix: None, - priority: Some(priority), - 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), - } -} - -fn build_site_replication_config( - bucket: &str, - state: &SiteReplicationState, - local_peer: &PeerInfo, - service_account_secret_key: &str, - existing: Option<&ReplicationConfiguration>, -) -> S3Result> { - // Reuse the ARN already recorded for a peer so the rule keeps pointing at the same - // bucket target `reconcile_site_replication_bucket_targets` keys off (a MinIO-era - // `arn:minio:...` target would otherwise be orphaned by a freshly minted ARN). - let configured_arns = site_replication_target_arns_by_peer(existing); - let mut rules = Vec::new(); - for peer in state.peers.values() { - if peer.deployment_id == local_peer.deployment_id || same_identity_endpoint(&peer.endpoint, &local_peer.endpoint) { - continue; - } - - let Some(target) = site_replication_bucket_target_for_peer( - bucket, - state, - peer, - service_account_secret_key, - configured_arns.get(&peer.deployment_id).cloned(), - )? - else { - continue; - }; - rules.push(build_site_replication_rule( - &target.arn, - (rules.len() + 1) as i32, - &format!("site-repl-{}", peer.deployment_id), - )); - } - - if rules.is_empty() { - Ok(None) - } else { - Ok(Some(ReplicationConfiguration { - role: String::new(), - rules, - })) - } -} - -async fn ensure_site_replication_bucket_targets_with_runtime( - bucket: &str, - state: &SiteReplicationState, - local_peer: &PeerInfo, - config: Option<&s3s::dto::ReplicationConfiguration>, - service_account_secret_key: &str, - expected_incarnation_id: Uuid, -) -> S3Result<()> { - let existing = match metadata_sys::list_bucket_targets(bucket).await { - Ok(targets) => targets, - Err(StorageError::ConfigNotFound) => BucketTargets::default(), - Err(err) => return Err(ApiError::from(err).into()), - }; - let existing_json = serde_json::to_vec(&existing) - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize bucket targets failed: {e}")))?; - - let updated = - reconcile_site_replication_bucket_targets(existing, bucket, state, local_peer, config, service_account_secret_key)?; - if updated.targets.is_empty() { - return Ok(()); - } - - let json_targets = serde_json::to_vec(&updated) - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize bucket targets failed: {e}")))?; - // Rewriting identical targets would churn bucket metadata and rebuild every remote S3 - // client — noticeable now that startup reconciles all buckets, not just the one bucket - // an operation touched. - if json_targets == existing_json { - return Ok(()); - } - metadata_sys::update_if_incarnation(bucket, BUCKET_TARGETS_FILE, json_targets, expected_incarnation_id) - .await - .map_err(ApiError::from)?; - Ok(()) -} - -async fn bucket_replication_config_for_target_refresh(bucket: &str) -> S3Result> { - match metadata_sys::get_replication_config(bucket).await { - Ok((config, _)) => Ok(Some(config)), - Err(StorageError::ConfigNotFound) => Ok(None), - Err(err) => Err(ApiError::from(err).into()), - } -} - async fn ensure_site_replication_bucket_targets(bucket: &str) -> S3Result<()> { let expected_incarnation_id = metadata_sys::capture_bucket_metadata_incarnation(bucket) .await @@ -8454,65 +4139,6 @@ async fn ensure_site_replication_bucket_targets(bucket: &str) -> S3Result<()> { .await } -async fn ensure_site_replication_bucket_replication_config_with_runtime( - bucket: &str, - state: &SiteReplicationState, - local_peer: &PeerInfo, - service_account_secret_key: &str, - expected_incarnation_id: Uuid, -) -> S3Result<()> { - let existing = match metadata_sys::get_replication_config(bucket).await { - Ok((existing, _)) => Some(existing), - Err(StorageError::ConfigNotFound) => None, - Err(err) => return Err(ApiError::from(err).into()), - }; - - let Some(desired) = build_site_replication_config(bucket, state, local_peer, service_account_secret_key, existing.as_ref())? - else { - return Ok(()); - }; - - // Derived rules are state owned by this site: rebuild them from the current peer - // set on every pass instead of preserving whatever is on disk. A rule left over - // from a removed peer — or one whose destination ARN names this very deployment, - // which no bucket target can ever satisfy — must not survive, otherwise objects - // are queued against an ARN that resolves to nothing. - let (existing_role, existing_rules) = existing - .map(|config| (config.role, config.rules)) - .unwrap_or_else(|| (String::new(), Vec::new())); - let mut rules: Vec = existing_rules - .iter() - .filter(|rule| !is_derived_site_replication_rule(rule)) - .cloned() - .collect(); - rules.extend(desired.rules); - // Operator priorities are the operator's policy; only the derived rules - // take free slots, by the same function as the config merges so a merged - // write and this pass agree byte for byte. - assign_site_replication_rule_priorities(&mut rules, is_derived_site_replication_rule); - - // Only a `role` naming a current peer is ours to drop — an operator-authored role is - // part of the bucket's S3-visible configuration, and repairing a reverse rule must not - // quietly rewrite it. Same rule as `merge_incoming_replication_config`. - let role = if is_site_replication_role(&existing_role, &remote_peer_deployment_ids(state, local_peer)) { - String::new() - } else { - existing_role.clone() - }; - - if rules == existing_rules && role == existing_role { - return Ok(()); - } - - let data = serialize(&ReplicationConfiguration { role, rules }) - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize replication failed: {e}")))?; - metadata_sys::update_if_incarnation(bucket, BUCKET_REPLICATION_CONFIG, data, expected_incarnation_id) - .await - .map_err(ApiError::from)?; - - Ok(()) -} - async fn ensure_site_replication_bucket_setup(bucket: &str) -> S3Result { let Some(runtime) = runtime_site_replication_targets().await? else { return Ok(false); @@ -8532,40 +4158,6 @@ async fn ensure_site_replication_bucket_setup_for_incarnation(bucket: &str, inca Ok(true) } -async fn ensure_site_replication_bucket_setup_with_runtime(bucket: &str, runtime: &SiteReplicationRuntime) -> S3Result<()> { - let expected_incarnation_id = metadata_sys::capture_bucket_metadata_incarnation(bucket) - .await - .map_err(ApiError::from)?; - ensure_site_replication_bucket_setup_with_runtime_for_incarnation(bucket, runtime, expected_incarnation_id).await -} - -async fn ensure_site_replication_bucket_setup_with_runtime_for_incarnation( - bucket: &str, - runtime: &SiteReplicationRuntime, - expected_incarnation_id: Uuid, -) -> S3Result<()> { - let _targets_guard = lock_bucket_targets_metadata(bucket).await; - let config = bucket_replication_config_for_target_refresh(bucket).await?; - ensure_site_replication_bucket_targets_with_runtime( - bucket, - &runtime.state, - &runtime.local_peer, - config.as_ref(), - &runtime.service_account_secret_key, - expected_incarnation_id, - ) - .await?; - ensure_site_replication_bucket_replication_config_with_runtime( - bucket, - &runtime.state, - &runtime.local_peer, - &runtime.service_account_secret_key, - expected_incarnation_id, - ) - .await?; - Ok(()) -} - async fn cleanup_removed_site_replication_bucket(bucket: &str, removed_deployment_ids: &HashSet) -> S3Result { let expected_incarnation_id = metadata_sys::capture_bucket_metadata_incarnation(bucket) .await @@ -8691,14 +4283,9 @@ async fn probe_reverse_peer_reachability(state: &SiteReplicationState, local_pee continue; } }; - if let Err(err) = send_peer_admin_request( - &connection, - SITE_REPLICATION_DEVNULL_PATH, - &state.service_account_access_key, - &secret_key, - &serde_json::json!({}), - ) - .await + if let Err(err) = PeerAdminRequest::put(&connection, SITE_REPLICATION_DEVNULL_PATH, &state.service_account_access_key) + .send(&secret_key, &serde_json::json!({})) + .await { errors.push(format!("{} is not reachable from this site: {err}", peer.endpoint)); } @@ -9260,31 +4847,6 @@ fn apply_state_edit_req(mut state: SiteReplicationState, body: SRStateEditReq) - state } -fn bucket_versioning_xml() -> S3Result> { - let config = VersioningConfiguration { - status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)), - ..Default::default() - }; - serialize(&config).map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize versioning failed: {e}"))) -} - -async fn ensure_site_replication_bucket_versioning(bucket: &str) -> S3Result<()> { - let expected_incarnation_id = metadata_sys::capture_bucket_metadata_incarnation(bucket) - .await - .map_err(ApiError::from)?; - match metadata_sys::get_versioning_config(bucket).await { - Ok((config, _)) if config.enabled() => return Ok(()), - Ok(_) | Err(StorageError::ConfigNotFound) => {} - Err(err) => return Err(ApiError::from(err).into()), - } - - metadata_sys::update_if_incarnation(bucket, BUCKET_VERSIONING_CONFIG, bucket_versioning_xml()?, expected_incarnation_id) - .await - .map_err(ApiError::from)?; - - Ok(()) -} - fn is_stale_update(local_updated_at: OffsetDateTime, incoming_updated_at: Option) -> bool { incoming_updated_at.is_some_and(|incoming_updated_at| incoming_updated_at < local_updated_at) } @@ -10046,8 +5608,9 @@ impl Operation for SiteReplicationAddHandler { let mut peer_join_req = join_req.clone(); peer_join_req.request.svc_acct_parent = site.access_key.clone(); let connection = PeerConnection::try_from(site)?; - let body = - send_peer_admin_request(&connection, &peer_join_path, &site.access_key, &site.secret_key, &peer_join_req).await?; + let body = PeerAdminRequest::put(&connection, &peer_join_path, &site.access_key) + .send(&site.secret_key, &peer_join_req) + .await?; let mut fallback_peer = existing_peer_for_endpoint(&state, &site.endpoint) .unwrap_or_else(|| normalize_peer_site(site.clone(), replicate_ilm_expiry)); @@ -10160,15 +5723,11 @@ impl Operation for SiteReplicationAddHandler { } }; for peer in state.peers.values() { - if let Err(err) = send_peer_admin_request_with_client( - &transport.client, - &transport.connection, - &finalize_edit_path, - &state.service_account_access_key, - &service_account_secret_key, - peer, - ) - .await + if let Err(err) = + PeerAdminRequest::put(&transport.connection, &finalize_edit_path, &state.service_account_access_key) + .with_client(&transport.client) + .send(&service_account_secret_key, peer) + .await { initial_sync_errors .push(format!("{}: finalize sync state for {} failed: {err}", target.endpoint, peer.endpoint)); @@ -10183,13 +5742,16 @@ impl Operation for SiteReplicationAddHandler { // response below (BUG2) rather than swallowed; they do not abort the overall add. initial_sync_errors.extend(backfill_existing_buckets_after_add(&state, &local_peer, None).await); - json_response(&ReplicateAddStatus { - success: true, - status: SITE_REPL_ADD_SUCCESS.to_string(), - initial_sync_error_message: initial_sync_errors.render(), - api_version: Some(SITE_REPL_API_VERSION.to_string()), - ..Default::default() - }) + json_response( + StatusCode::OK, + &ReplicateAddStatus { + success: true, + status: SITE_REPL_ADD_SUCCESS.to_string(), + initial_sync_error_message: initial_sync_errors.render(), + api_version: Some(SITE_REPL_API_VERSION.to_string()), + ..Default::default() + }, + ) } } @@ -10256,7 +5818,7 @@ impl Operation for SiteReplicationRemoveHandler { site_replication_remove_status(&peer_errors) }; - json_response(&status) + json_response(StatusCode::OK, &status) } } @@ -10287,7 +5849,7 @@ impl Operation for SiteReplicationInfoHandler { validate_site_replication_admin_request(&req, AdminAction::SiteReplicationInfoAction).await?; let state = load_site_replication_state().await?; let local_peer = current_local_peer(&req, &state); - json_response(&site_replication_info_for(&state, &local_peer)) + json_response(StatusCode::OK, &site_replication_info_for(&state, &local_peer)) } } @@ -10301,7 +5863,7 @@ impl Operation for SiteReplicationMetaInfoHandler { let local_peer = current_local_peer(&req, &state); let opts = sr_status_options(&req.uri); let info = filter_sr_info(build_sr_info(&state, &local_peer).await?, &opts); - json_response(&info) + json_response(StatusCode::OK, &info) } } @@ -10314,7 +5876,7 @@ impl Operation for SiteReplicationStatusHandler { let state = load_site_replication_state().await?; let local_peer = current_local_peer(&req, &state); let status = build_status_info(&state, &local_peer, &req.uri).await?; - json_response(&status) + json_response(StatusCode::OK, &status) } } @@ -10569,7 +6131,7 @@ impl Operation for SRPeerJoinHandler { result = "join_superseded", "admin site replication state" ); - return json_response(&superseded_join_response(peer)); + return json_response(StatusCode::OK, &superseded_join_response(peer)); } }; // Fix 1 (receiving side): ensure the joining peer also sets up replication for any @@ -10588,10 +6150,13 @@ impl Operation for SRPeerJoinHandler { "admin site replication state" ); } - json_response(&applied_join_response( - state.peers.get(&local_peer.deployment_id).cloned().unwrap_or(local_peer), - backfill_errors.render(), - )) + json_response( + StatusCode::OK, + &applied_join_response( + state.peers.get(&local_peer.deployment_id).cloned().unwrap_or(local_peer), + backfill_errors.render(), + ), + ) } } @@ -10758,7 +6323,7 @@ impl Operation for SRPeerGetIDPSettingsHandler { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { validate_site_replication_admin_request(&req, AdminAction::SiteReplicationAddAction).await?; - json_response(&local_idp_settings()) + json_response(StatusCode::OK, &local_idp_settings()) } } @@ -11000,15 +6565,11 @@ impl Operation for SiteReplicationEditHandler { 'fanout: for target in remote_targets { let transport = PeerTransport::for_runtime_peer(target).await?; for peer in &peers_to_send { - if let Err(err) = send_peer_admin_request_with_client( - &transport.client, - &transport.connection, - &edit_path, - ¤t_state.service_account_access_key, - &service_account_secret_key, - peer, - ) - .await + if let Err(err) = + PeerAdminRequest::put(&transport.connection, &edit_path, ¤t_state.service_account_access_key) + .with_client(&transport.client) + .send(&service_account_secret_key, peer) + .await { failure = Some((target.clone(), err)); break 'fanout; @@ -11037,12 +6598,15 @@ impl Operation for SiteReplicationEditHandler { } } - json_response(&ReplicateEditStatus { - success: true, - status: SITE_REPL_EDIT_SUCCESS.to_string(), - api_version: Some(SITE_REPL_API_VERSION.to_string()), - ..Default::default() - }) + json_response( + StatusCode::OK, + &ReplicateEditStatus { + success: true, + status: SITE_REPL_EDIT_SUCCESS.to_string(), + api_version: Some(SITE_REPL_API_VERSION.to_string()), + ..Default::default() + }, + ) } } @@ -11052,14 +6616,17 @@ pub struct SRPeerEditCapabilitiesHandler {} impl Operation for SRPeerEditCapabilitiesHandler { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { validate_site_replication_admin_request(&req, AdminAction::SiteReplicationOperationAction).await?; - json_response(&ReplicateEditStatus { - success: query_pairs(&req.uri) - .get("capability") - .is_some_and(|value| peer_edit_capability_supported(value)), - status: SITE_REPL_EDIT_SUCCESS.to_string(), - api_version: Some(SITE_REPL_API_VERSION.to_string()), - ..Default::default() - }) + json_response( + StatusCode::OK, + &ReplicateEditStatus { + success: query_pairs(&req.uri) + .get("capability") + .is_some_and(|value| peer_edit_capability_supported(value)), + status: SITE_REPL_EDIT_SUCCESS.to_string(), + api_version: Some(SITE_REPL_API_VERSION.to_string()), + ..Default::default() + }, + ) } } @@ -11174,30 +6741,39 @@ impl Operation for SRPeerEditHandler { let service_account_access_key = match outcome { PeerEditOutcome::Applied(service_account_access_key) => service_account_access_key, PeerEditOutcome::Acked => { - return json_response(&ReplicateEditStatus { - success: true, - status: SITE_REPL_EDIT_SUCCESS.to_string(), - api_version: Some(SITE_REPL_API_VERSION.to_string()), - ..Default::default() - }); + return json_response( + StatusCode::OK, + &ReplicateEditStatus { + success: true, + status: SITE_REPL_EDIT_SUCCESS.to_string(), + api_version: Some(SITE_REPL_API_VERSION.to_string()), + ..Default::default() + }, + ); } PeerEditOutcome::Rejected(err_detail) => { - return json_response(&ReplicateEditStatus { - success: false, - status: SITE_REPL_EDIT_SUCCESS.to_string(), - err_detail: err_detail.to_string(), - api_version: Some(SITE_REPL_API_VERSION.to_string()), - }); + return json_response( + StatusCode::OK, + &ReplicateEditStatus { + success: false, + status: SITE_REPL_EDIT_SUCCESS.to_string(), + err_detail: err_detail.to_string(), + api_version: Some(SITE_REPL_API_VERSION.to_string()), + }, + ); } }; if endpoint_refresh_requested { if service_account_access_key.is_empty() { - return json_response(&ReplicateEditStatus { - success: false, - status: SITE_REPL_EDIT_SUCCESS.to_string(), - err_detail: "site replicator service account is not configured".to_string(), - api_version: Some(SITE_REPL_API_VERSION.to_string()), - }); + return json_response( + StatusCode::OK, + &ReplicateEditStatus { + success: false, + status: SITE_REPL_EDIT_SUCCESS.to_string(), + err_detail: "site replicator service account is not configured".to_string(), + api_version: Some(SITE_REPL_API_VERSION.to_string()), + }, + ); } let service_account_secret_key = site_replicator_service_account_secret(&service_account_access_key).await?; let pending_id = refresh_id.unwrap_or_default(); @@ -11215,19 +6791,25 @@ impl Operation for SRPeerEditHandler { }) .await?; if !committed { - return json_response(&ReplicateEditStatus { - success: false, - status: SITE_REPL_EDIT_SUCCESS.to_string(), - err_detail: "endpoint target refresh state changed during update".to_string(), - api_version: Some(SITE_REPL_API_VERSION.to_string()), - }); + return json_response( + StatusCode::OK, + &ReplicateEditStatus { + success: false, + status: SITE_REPL_EDIT_SUCCESS.to_string(), + err_detail: "endpoint target refresh state changed during update".to_string(), + api_version: Some(SITE_REPL_API_VERSION.to_string()), + }, + ); } - return json_response(&ReplicateEditStatus { - success: true, - status: SITE_REPL_EDIT_SUCCESS.to_string(), - api_version: Some(SITE_REPL_API_VERSION.to_string()), - ..Default::default() - }); + return json_response( + StatusCode::OK, + &ReplicateEditStatus { + success: true, + status: SITE_REPL_EDIT_SUCCESS.to_string(), + api_version: Some(SITE_REPL_API_VERSION.to_string()), + ..Default::default() + }, + ); } Ok(empty_response(StatusCode::OK)) } @@ -11434,7 +7016,7 @@ impl Operation for SiteReplicationResyncOpHandler { .buckets .sort_by(|left, right| left.bucket.cmp(&right.bucket).then(left.target_arn.cmp(&right.target_arn))); let (limit, offset) = parse_site_resync_page(&query, &status)?; - json_response(&site_resync_page(&status, limit, offset)?) + json_response(StatusCode::OK, &site_resync_page(&status, limit, offset)?) } } @@ -11480,17 +7062,20 @@ impl Operation for SiteReplicationRepairHandler { if body.preflight_token.is_some() || body.operation_id.is_some() { return Err(s3_error!(InvalidRequest, "dry-run does not accept preflightToken or operationId")); } - return json_response(&SiteReplicationRepairPreflight { - mode: "dry-run", - status: "planned", - preflight_token, - retry_events: state - .retry_queue - .iter() - .filter(|event| retry_event_replayed_by_bootstrap(event)) - .count(), - sites, - }); + return json_response( + StatusCode::OK, + &SiteReplicationRepairPreflight { + mode: "dry-run", + status: "planned", + preflight_token, + retry_events: state + .retry_queue + .iter() + .filter(|event| retry_event_replayed_by_bootstrap(event)) + .count(), + sites, + }, + ); } let supplied_token = body @@ -11543,7 +7128,7 @@ impl Operation for SiteReplicationRepairStatusHandler { .get(&operation_id) .cloned() .ok_or_else(|| s3_error!(InvalidRequest, "repair operation was not found"))?; - json_response(&site_replication_repair_operation_response(&operation)) + json_response(StatusCode::OK, &site_replication_repair_operation_response(&operation)) } } @@ -11654,13 +7239,12 @@ impl Operation for SRRotateServiceAccountHandler { // means the peer never installed the new secret. Acking it would // finalize a rotation half the mesh cannot authenticate against // (rustfs/rustfs#5963). - let rotation_error = match send_peer_admin_request_with_secret_candidates( + let rotation_error = match PeerAdminRequest::put( &runtime_peer_connection(peer)?, SITE_REPLICATION_PEER_JOIN_PATH, &pending_rotation.access_key, - &secret_candidates, - &join_req, ) + .send_with_secret_candidates(&secret_candidates, &join_req) .await { Err(err) => Some(summarize_peer_error_detail(&format!("{}: {err}", peer.endpoint))), @@ -11697,35 +7281,38 @@ impl Operation for SRRotateServiceAccountHandler { peer_errors.push("service account rotation is still pending".to_string()); } - json_response(&ReplicateEditStatus { - success: complete && peer_errors.is_empty(), - status: if complete && peer_errors.is_empty() { - "Success" - } else { - "Partial" - } - .to_string(), - err_detail: peer_errors.join("; "), - api_version: Some(SITE_REPL_API_VERSION.to_string()), - }) + json_response( + StatusCode::OK, + &ReplicateEditStatus { + success: complete && peer_errors.is_empty(), + status: if complete && peer_errors.is_empty() { + "Success" + } else { + "Partial" + } + .to_string(), + err_detail: peer_errors.join("; "), + api_version: Some(SITE_REPL_API_VERSION.to_string()), + }, + ) } } #[cfg(test)] mod tests { use super::*; - use crate::admin::runtime_sources::{current_outbound_tls_generation, set_test_outbound_tls_generation}; - use crate::admin::storage_api::runtime::Endpoint; - use crate::admin::storage_api::runtime::{EndpointServerPools, Endpoints, PoolEndpoints}; + use crate::site_replication::identity::deployment_id_for_endpoint; use axum::{Router, extract::State, routing::any}; - use http::{HeaderMap, HeaderValue, Uri}; + use base64_simd::STANDARD as BASE64_STANDARD; + use http::Uri; + use rustfs_madmin::{SRBucketInfo, SRIAMPolicy}; use rustfs_policy::policy::action::S3Action; + use rustfs_tls_runtime::GlobalPublishedOutboundTlsState; use serial_test::serial; use std::sync::{ Arc, Mutex as StdMutex, atomic::{AtomicBool, Ordering}, }; - use temp_env::with_var; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; @@ -12100,14 +7687,6 @@ mod tests { .pem() } - fn empty_outbound_tls_state() -> GlobalPublishedOutboundTlsState { - GlobalPublishedOutboundTlsState { - generation: rustfs_tls_runtime::TlsGeneration(0), - root_ca_pem: None, - mtls_identity: None, - } - } - struct TestTlsIdentity { cert_pem: String, cert_der: rustls_pki_types::CertificateDer<'static>, @@ -12159,131 +7738,6 @@ mod tests { (endpoint, task) } - async fn spawn_test_tls_server() -> (String, String, tokio::task::JoinHandle) { - spawn_test_tls_server_with_response(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok").await - } - - async fn spawn_test_tls_server_with_response(response: &'static [u8]) -> (String, String, tokio::task::JoinHandle) { - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let certified = - rcgen::generate_simple_self_signed(vec!["127.0.0.1".to_string()]).expect("generate TLS server certificate"); - let ca_pem = certified.cert.pem(); - let private_key = rustls_pki_types::PrivateKeyDer::try_from(certified.signing_key.serialize_der()) - .expect("convert TLS server private key"); - let config = rustls::ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(vec![certified.cert.der().clone()], private_key) - .expect("build TLS server config"); - let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(config)); - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind TLS test server"); - let endpoint = format!("https://{}", listener.local_addr().expect("TLS test server address")); - let task = tokio::spawn(async move { - let Ok((stream, _)) = listener.accept().await else { - return false; - }; - let Ok(mut stream) = acceptor.accept(stream).await else { - return false; - }; - let mut request = Vec::new(); - let mut buffer = [0_u8; 1024]; - loop { - let Ok(read) = stream.read(&mut buffer).await else { - return false; - }; - if read == 0 { - return false; - } - request.extend_from_slice(&buffer[..read]); - if request.windows(4).any(|window| window == b"\r\n\r\n") { - break; - } - } - stream.write_all(response).await.is_ok() - }); - (endpoint, ca_pem, task) - } - - #[test] - fn peer_connection_validation_accepts_supported_combinations() { - let ca = valid_test_ca_pem("peer.example.com"); - - assert!(validate_peer_connection_inner("http://10.0.0.5:9000", false, "", false).is_ok()); - assert!(validate_peer_connection_inner("https://peer.example.com", false, "", false).is_ok()); - assert!(validate_peer_connection_inner("https://peer.example.com", true, "", false).is_ok()); - assert!(validate_peer_connection_inner("https://peer.example.com", false, &ca, false).is_ok()); - } - - #[test] - fn peer_connection_validation_rejects_invalid_tls_combinations() { - let ca = valid_test_ca_pem("peer.example.com"); - - for (endpoint, skip_tls_verify, ca_cert_pem) in [ - ("http://10.0.0.5:9000", true, ""), - ("http://10.0.0.5:9000", false, ca.as_str()), - ("https://peer.example.com", true, ca.as_str()), - ] { - assert!(validate_peer_connection_inner(endpoint, skip_tls_verify, ca_cert_pem, false).is_err()); - } - } - - #[test] - fn peer_connection_validation_requires_pure_origin() { - for endpoint in [ - "ftp://peer.example.com", - "https://user@peer.example.com", - "https://peer.example.com/admin", - "https://peer.example.com/?query=1", - "https://peer.example.com/#fragment", - ] { - assert!( - validate_peer_connection_inner(endpoint, false, "", false).is_err(), - "endpoint should be rejected: {endpoint}" - ); - } - assert!(validate_peer_connection_inner("https://peer.example.com/", false, "", false).is_ok()); - } - - #[test] - fn peer_connection_validation_matches_replication_egress_policy() { - assert!(validate_peer_connection_inner("http://10.0.0.5:9000", false, "", false).is_ok()); - assert!(validate_peer_connection_inner("http://127.0.0.1:9000", false, "", false).is_err()); - assert!(validate_peer_connection_inner("http://127.0.0.1:9000", false, "", true).is_ok()); - assert!(validate_peer_connection_inner("http://[::1]:9000", false, "", true).is_ok()); - assert!(validate_peer_connection_inner("http://localhost:9000", false, "", true).is_ok()); - - for endpoint in [ - "http://169.254.169.254", - "http://[fe80::1]:9000", - "http://0.0.0.0:9000", - "http://[::ffff:127.0.0.1]:9000", - "http://[::127.0.0.1]:9000", - "http://[::ffff:169.254.169.254]:9000", - ] { - assert!( - validate_peer_connection_inner(endpoint, false, "", true).is_err(), - "endpoint should remain forbidden with loopback opt-in: {endpoint}" - ); - } - } - - #[test] - fn peer_connection_validation_accepts_multi_cert_ca_and_rejects_unsafe_pem() { - let multi_cert = format!("{}{}", valid_test_ca_pem("one.example.com"), valid_test_ca_pem("two.example.com")); - assert!(validate_peer_connection_inner("https://peer.example.com", false, &multi_cert, false).is_ok()); - - for pem in [ - "not a certificate", - "-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----", - "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----", - "-----BEGIN RSA PRIVATE KEY-----\nsecret\n-----END RSA PRIVATE KEY-----", - ] { - assert!(validate_peer_connection_inner("https://peer.example.com", false, pem, false).is_err()); - } - - let oversized = "x".repeat(MAX_PEER_CA_CERT_PEM_SIZE + 1); - assert!(validate_peer_connection_inner("https://peer.example.com", false, &oversized, false).is_err()); - } - #[test] fn persisted_peer_connection_errors_are_internal_and_refresh_can_use_valid_candidate() { let invalid_peer = PeerInfo { @@ -12315,189 +7769,6 @@ mod tests { assert_eq!(candidates[0].endpoint(), "https://replacement.example.com"); } - #[tokio::test] - async fn peer_dns_resolver_filters_forbidden_addresses_and_reqwest_cannot_bypass() { - let resolver = PeerDnsResolver::with_overrides( - true, - HashMap::from([ - ("public.test".to_string(), vec!["8.8.8.8".parse().expect("public IP")]), - ("private.test".to_string(), vec!["10.0.0.5".parse().expect("private IP")]), - ("metadata.test".to_string(), vec!["169.254.169.254".parse().expect("metadata IP")]), - ("alias.test".to_string(), vec!["127.0.0.1".parse().expect("loopback IP")]), - ("mapped.test".to_string(), vec!["::ffff:127.0.0.1".parse().expect("mapped loopback IP")]), - ("localhost".to_string(), vec!["127.0.0.1".parse().expect("localhost IP")]), - ]), - ); - - for host in ["public.test", "private.test", "localhost"] { - let address_count = reqwest::dns::Resolve::resolve(&resolver, host.parse().expect("resolver test hostname")) - .await - .expect("allowed resolver result") - .count(); - assert_eq!(address_count, 1, "expected one allowed address for {host}"); - } - for host in ["metadata.test", "alias.test", "mapped.test"] { - assert!( - reqwest::dns::Resolve::resolve(&resolver, host.parse().expect("resolver test hostname")) - .await - .is_err(), - "resolver must reject {host}" - ); - } - - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind resolver bypass listener"); - let port = listener.local_addr().expect("resolver bypass listener address").port(); - let accepted = Arc::new(AtomicBool::new(false)); - let accepted_by_server = accepted.clone(); - let server = tokio::spawn(async move { - if listener.accept().await.is_ok() { - accepted_by_server.store(true, Ordering::SeqCst); - } - }); - let client = reqwest::Client::builder() - .no_proxy() - .dns_resolver(resolver) - .build() - .expect("resolver bypass client"); - assert!(client.get(format!("http://alias.test:{port}/")).send().await.is_err()); - assert!(!accepted.load(Ordering::SeqCst)); - server.abort(); - } - - #[tokio::test] - #[serial] - async fn production_peer_clients_ignore_environment_proxies_before_dns_filtering() { - let proxy_listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("bind observable proxy listener"); - let proxy_url = format!("http://{}", proxy_listener.local_addr().expect("observable proxy listener address")); - let (proxy_hit_tx, mut proxy_hit_rx) = tokio::sync::mpsc::unbounded_channel(); - let proxy = tokio::spawn(async move { - while let Ok((_stream, _address)) = proxy_listener.accept().await { - if proxy_hit_tx.send(()).is_err() { - break; - } - } - }); - - temp_env::async_with_vars( - [ - ("HTTP_PROXY", Some(proxy_url.as_str())), - ("HTTPS_PROXY", Some(proxy_url.as_str())), - ("ALL_PROXY", Some(proxy_url.as_str())), - ("http_proxy", Some(proxy_url.as_str())), - ("https_proxy", Some(proxy_url.as_str())), - ("all_proxy", Some(proxy_url.as_str())), - ("NO_PROXY", Some("")), - ("no_proxy", Some("")), - ], - async { - let resolver = PeerDnsResolver::with_overrides( - false, - HashMap::from([("metadata.test".to_string(), vec!["169.254.169.254".parse().expect("metadata IP")])]), - ); - let outbound_tls = empty_outbound_tls_state(); - let default_connection = - validate_peer_connection_inner("http://metadata.test", false, "", false).expect("default peer connection"); - let custom_connection = - validate_peer_connection_inner("https://metadata.test", true, "", false).expect("custom peer connection"); - let default_client = build_site_replication_peer_client_with_resolver(&outbound_tls, resolver.clone()) - .expect("default production peer client"); - let custom_client = - build_custom_site_replication_peer_client_with_resolver(&outbound_tls, &custom_connection, resolver) - .expect("custom production peer client"); - - for (client, connection) in [(&default_client, &default_connection), (&custom_client, &custom_connection)] { - let result = send_peer_admin_get_request_with_client( - client, - connection, - "/rustfs/admin/v3/site-replication/metainfo", - "access-key", - "secret-key", - ) - .await; - assert!(result.is_err(), "forbidden DNS result must fail closed"); - } - }, - ) - .await; - - assert!( - tokio::time::timeout(Duration::from_millis(100), proxy_hit_rx.recv()) - .await - .is_err(), - "site-replication peer traffic must never reach an environment proxy" - ); - proxy.abort(); - } - - #[test] - fn peer_url_join_preserves_wire_path_and_query_encoding() { - let connection = - validate_peer_connection_inner("https://peer.example.com", false, "", false).expect("peer connection for URL join"); - let url = site_replication_peer_url( - &connection, - "/minio/admin/v3/site-replication/peer/bucket-ops?bucket=a%2Fb&operation=configure-replication", - ) - .expect("join peer wire URL"); - - assert_eq!( - url.as_str(), - "https://peer.example.com/minio/admin/v3/site-replication/peer/bucket-ops?bucket=a%2Fb&operation=configure-replication" - ); - } - - #[tokio::test] - async fn peer_clients_isolate_skip_and_custom_ca_trust() { - let outbound_tls = empty_outbound_tls_state(); - - let (ca_endpoint, ca_pem, ca_server) = spawn_test_tls_server().await; - let ca_connection = - validate_peer_connection_inner(&ca_endpoint, false, &ca_pem, true).expect("custom CA peer connection"); - let ca_client = build_custom_site_replication_peer_client(&outbound_tls, &ca_connection).expect("custom CA peer client"); - assert_eq!( - ca_client.get(&ca_endpoint).send().await.expect("custom CA request").status(), - StatusCode::OK - ); - assert!(ca_server.await.expect("custom CA server task")); - - let (untrusted_endpoint, _untrusted_ca, untrusted_server) = spawn_test_tls_server().await; - assert!(ca_client.get(&untrusted_endpoint).send().await.is_err()); - assert!(!untrusted_server.await.expect("untrusted TLS server task")); - - let (other_endpoint, other_ca, other_server) = spawn_test_tls_server().await; - let other_connection = - validate_peer_connection_inner(&other_endpoint, false, &other_ca, true).expect("second custom CA peer connection"); - let other_client = - build_custom_site_replication_peer_client(&outbound_tls, &other_connection).expect("second custom CA peer client"); - assert_eq!( - other_client - .get(&other_endpoint) - .send() - .await - .expect("second custom CA request") - .status(), - StatusCode::OK - ); - assert!(other_server.await.expect("second custom CA server task")); - - let (skip_endpoint, _skip_ca, skip_server) = spawn_test_tls_server().await; - let skip_connection = - validate_peer_connection_inner(&skip_endpoint, true, "", true).expect("skip-verify peer connection"); - let skip_client = - build_custom_site_replication_peer_client(&outbound_tls, &skip_connection).expect("skip-verify peer client"); - assert_eq!( - skip_client - .get(&skip_endpoint) - .send() - .await - .expect("skip-verify request") - .status(), - StatusCode::OK - ); - assert!(skip_server.await.expect("skip-verify server task")); - } - #[tokio::test] #[serial] async fn peer_admin_transport_uses_full_connection_for_get_and_put() { @@ -12508,7 +7779,8 @@ mod tests { .await; let ca_connection = PeerConnection::new(&ca_endpoint, false, &ca_identity.cert_pem).expect("production custom-CA peer connection"); - let get_body = send_peer_admin_get_request(&ca_connection, "/rustfs/admin/v3/site-replication/metainfo", "ak", "sk") + let get_body = PeerAdminRequest::get(&ca_connection, "/rustfs/admin/v3/site-replication/metainfo", "ak") + .send_get("sk") .await .expect("production custom-CA GET"); assert_eq!(get_body, b"ok"); @@ -12521,15 +7793,10 @@ mod tests { ) .await; let skip_connection = PeerConnection::new(&skip_endpoint, true, "").expect("production skip-verify peer connection"); - let (status, put_body) = send_peer_admin_request_raw( - &skip_connection, - "/rustfs/admin/v3/site-replication/peer/edit", - "ak", - "sk", - &serde_json::json!({"peer": "test"}), - ) - .await - .expect("production skip-verify PUT"); + let (status, put_body) = PeerAdminRequest::put(&skip_connection, "/rustfs/admin/v3/site-replication/peer/edit", "ak") + .send_raw("sk", Some(&serde_json::json!({"peer": "test"}))) + .await + .expect("production skip-verify PUT"); assert_eq!(status, StatusCode::OK); assert_eq!(put_body, b"ok"); assert_eq!(skip_server.await.expect("skip-verify PUT server task").as_deref(), Some("PUT")); @@ -12592,38 +7859,6 @@ mod tests { assert!(peer_server.await.expect("unrelated peer isolation server task").is_none()); } - #[tokio::test] - async fn peer_clients_do_not_follow_redirects() { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind redirect test server"); - let endpoint = format!("http://{}", listener.local_addr().expect("redirect test server address")); - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept redirect test request"); - let mut request = [0_u8; 1024]; - let read = stream.read(&mut request).await.expect("read redirect test request"); - assert!(read > 0); - stream - .write_all(b"HTTP/1.1 302 Found\r\nlocation: /followed\r\ncontent-length: 0\r\nconnection: close\r\n\r\n") - .await - .expect("write redirect response"); - }); - - let client = build_site_replication_peer_client(&empty_outbound_tls_state()).expect("default peer client"); - let response = client.get(&endpoint).send().await.expect("redirect test request"); - assert_eq!(response.status(), StatusCode::FOUND); - server.await.expect("redirect test server task"); - - let (tls_endpoint, _tls_ca, tls_server) = spawn_test_tls_server_with_response( - b"HTTP/1.1 302 Found\r\nlocation: /followed\r\ncontent-length: 0\r\nconnection: close\r\n\r\n", - ) - .await; - let connection = validate_peer_connection_inner(&tls_endpoint, true, "", true).expect("custom redirect peer connection"); - let client = build_custom_site_replication_peer_client(&empty_outbound_tls_state(), &connection) - .expect("custom redirect peer client"); - let response = client.get(&tls_endpoint).send().await.expect("custom redirect test request"); - assert_eq!(response.status(), StatusCode::FOUND); - assert!(tls_server.await.expect("custom redirect TLS server task")); - } - fn peer(name: &str, endpoint: &str) -> PeerInfo { PeerInfo { name: name.to_string(), @@ -12639,18 +7874,6 @@ mod tests { } } - #[test] - fn test_stored_peer_tls_settings_preserve_configured_values() { - let stored_peer = PeerInfo { - skip_tls_verify: true, - ca_cert_pem: "custom-ca".to_string(), - ..peer("local", "https://local.example.com") - }; - - assert_eq!(stored_peer_tls_settings(Some(&stored_peer)), (true, "custom-ca".to_string())); - assert_eq!(stored_peer_tls_settings(None), (false, String::new())); - } - #[test] fn test_normalize_peer_site_preserves_tls_settings() { let peer = normalize_peer_site( @@ -12977,320 +8200,6 @@ mod tests { assert!(target_state.peers["remote"].skip_tls_verify); } - fn drain_event(peer: &str, path: &str, retry_count: u32, updated_at: Option) -> SiteReplicationRetryEvent { - SiteReplicationRetryEvent { - id: format!("evt-{peer}"), - peer_deployment_id: peer.to_string(), - peer_endpoint: format!("https://{peer}.example.com"), - path: path.to_string(), - retry_count, - failed: retry_count >= SITE_REPLICATION_RETRY_FAILED_AFTER, - last_error: "remote-operation-failed".to_string(), - updated_at, - edit_generation: None, - } - } - - /// P1-3 red-light: the drain must only ever act on deliveries it can - /// replay faithfully. IAM / bucket-meta entries collapse per (peer, path) - /// with no body persisted — only a snapshot resend is truthful; bucket - /// makes/replication configs are re-derivable; destructive bucket ops and - /// unrelated `internal:` marker records are never background-replayed. - #[test] - fn test_classify_site_replication_retry_event_actions() { - let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("timestamp"); - let classify = |path: &str| classify_site_replication_retry_event(&drain_event("remote", path, 1, Some(now))); - - assert_eq!( - classify("/rustfs/admin/v3/site-replication/peer/iam-item"), - Some(RetryDrainAction::IamSnapshot) - ); - assert_eq!( - classify("/rustfs/admin/v3/site-replication/peer/bucket-meta"), - Some(RetryDrainAction::BucketMetadataSnapshot) - ); - assert_eq!(classify(SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH), Some(RetryDrainAction::IamSnapshot)); - assert_eq!( - classify(SITE_REPLICATION_RETRY_BUCKET_METADATA_SNAPSHOT_PATH), - Some(RetryDrainAction::BucketMetadataSnapshot) - ); - assert_eq!(classify(SITE_REPLICATION_PEER_EDIT_PATH), Some(RetryDrainAction::PeerEdit)); - assert_eq!( - classify( - "/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=make-with-versioning&createdAt=1" - ), - Some(RetryDrainAction::BucketOpReplay { - operation: SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING.to_string(), - bucket: "photos".to_string(), - }) - ); - assert_eq!( - classify("/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=configure-replication"), - Some(RetryDrainAction::BucketOpReplay { - operation: SITE_REPLICATION_BUCKET_OP_CONFIGURE_REPLICATION.to_string(), - bucket: "photos".to_string(), - }) - ); - // Destructive ops are operator territory: replaying a bucket delete - // against a peer whose bucket was since recreated is irreversible. - assert_eq!( - classify("/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=delete-bucket"), - None - ); - assert_eq!( - classify("/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=force-delete-bucket"), - None - ); - // `internal:` records store payloads in `last_error`, not failures. - assert_eq!(classify(SITE_REPLICATION_ENDPOINT_REFRESH_RETRY_PATH), None); - assert_eq!(classify("internal:some-future-marker"), None); - assert_eq!(classify("/rustfs/admin/v3/site-replication/peer/unknown"), None); - } - - #[test] - fn test_retry_snapshot_fingerprint_detects_concurrent_iam_change() { - let old = SRIAMItem { - r#type: "policy".to_string(), - name: "readwrite".to_string(), - updated_at: Some(OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("timestamp")), - ..Default::default() - }; - let mut new = old.clone(); - new.updated_at = Some(OffsetDateTime::from_unix_timestamp(1_700_000_001).expect("timestamp")); - - let sent = RetrySnapshot::Iam(vec![old]); - let changed = RetrySnapshot::Iam(vec![new]); - assert_ne!(sent.fingerprint().unwrap(), changed.fingerprint().unwrap()); - } - - #[test] - fn test_retry_snapshot_replays_a_concurrent_deletion_as_a_tombstone() { - let observed_at = OffsetDateTime::from_unix_timestamp(1_700_000_010).expect("timestamp"); - let policy = SRIAMItem { - r#type: "policy".to_string(), - name: "readwrite".to_string(), - policy: Some(serde_json::json!({"Version": "2012-10-17"})), - ..Default::default() - }; - let replay = - RetrySnapshot::replay_after_change(&RetrySnapshot::Iam(vec![policy]), &RetrySnapshot::Iam(Vec::new()), observed_at); - let RetrySnapshot::Iam(items) = replay else { - panic!("IAM snapshot expected"); - }; - assert_eq!(items.len(), 1); - assert_eq!(items[0].name, "readwrite"); - assert!(items[0].policy.is_none()); - assert_eq!(items[0].updated_at, Some(observed_at)); - - let bucket = SRBucketMeta { - r#type: "tags".to_string(), - bucket: "photos".to_string(), - tags: Some("encoded-tags".to_string()), - ..Default::default() - }; - let replay = RetrySnapshot::replay_after_change( - &RetrySnapshot::BucketMetadata(vec![bucket]), - &RetrySnapshot::BucketMetadata(Vec::new()), - observed_at, - ); - let RetrySnapshot::BucketMetadata(items) = replay else { - panic!("bucket metadata snapshot expected"); - }; - assert_eq!(items.len(), 1); - assert_eq!(items[0].bucket, "photos"); - assert_eq!(items[0].r#type, "tags"); - assert!(items[0].tags.is_none()); - assert_eq!(items[0].updated_at, Some(observed_at)); - } - - /// Exponential backoff gates every attempt: without it a dead peer's - /// entries hit `failed` (retry_count >= 3) within 30 minutes of reconcile - /// ticks and the retry stats lose their signal. - #[test] - fn test_site_replication_retry_backoff_schedule() { - let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("timestamp"); - let at = |secs_ago: i64| Some(now - time::Duration::seconds(secs_ago)); - let elapsed = |retry_count: u32, secs_ago: i64| { - site_replication_retry_backoff_elapsed(&drain_event("remote", "/p", retry_count, at(secs_ago)), now) - }; - - // No record of when it failed: attempt now. - assert!(site_replication_retry_backoff_elapsed(&drain_event("remote", "/p", 1, None), now)); - // First failure: one reconcile interval. - assert!(!elapsed(1, 599)); - assert!(elapsed(1, 601)); - // Third failure: 600 * 2^2 = 2400s. - assert!(!elapsed(3, 1200)); - assert!(elapsed(3, 2401)); - // Ceiling: a long-dead peer is still probed daily, never less often. - assert!(!elapsed(30, 86_000)); - assert!(elapsed(30, 86_401)); - } - - /// The actionable subset respects classification, peer membership and - /// backoff; everything else stays untouched in the queue. - #[test] - fn test_actionable_site_replication_retry_events_filters() { - let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("timestamp"); - let old = Some(now - time::Duration::seconds(700)); - let mut state = SiteReplicationState::default(); - state - .peers - .insert("remote".to_string(), peer("remote", "https://remote.example.com")); - - state.retry_queue = vec![ - // Eligible: known peer, replayable, past backoff. - drain_event("remote", SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH, 1, old), - // Not yet due. - drain_event("remote", "/rustfs/admin/v3/site-replication/peer/bucket-meta", 2, Some(now)), - // Unknown peer (removed since the failure was recorded). - drain_event("gone", "/rustfs/admin/v3/site-replication/peer/iam-item", 1, old), - // Marker record, not a delivery failure. - drain_event("remote", SITE_REPLICATION_ENDPOINT_REFRESH_RETRY_PATH, 0, old), - // Destructive op: operator-only. - drain_event( - "remote", - "/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=delete-bucket", - 1, - old, - ), - ]; - - let actionable = actionable_site_replication_retry_events(&state, now); - assert_eq!(actionable.len(), 1, "only the due, replayable, known-peer event is actionable"); - assert_eq!(actionable[0].path, SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH); - } - - /// The drain settles a peer-edit success under a freshly allocated - /// generation; legacy queue entries carry `edit_generation: None` and - /// must be cleared by that generation-scoped settlement (`(Some, None)` - /// falls through to removal), or the drain would spin on them forever. - #[test] - fn test_settle_clears_legacy_none_generation_event_for_generation_scoped_success() { - let target = peer("remote", "https://remote.example.com"); - let mut queue = vec![drain_event("remote", SITE_REPLICATION_PEER_EDIT_PATH, 1, None)]; - assert!(queue[0].edit_generation.is_none()); - - let settled = settle_site_replication_retry_events(&mut queue, &target, SITE_REPLICATION_PEER_EDIT_PATH, Some(42)); - - assert_eq!(settled, 1, "a legacy None-generation event must settle under a newer generation"); - assert!(queue.is_empty()); - } - - /// A successful snapshot resend cannot prove a failed *deletion* was - /// replayed, so the collapsed entry is escalated (operator-visible, - /// drain-idle) instead of cleared — unless a newer failure was stamped - /// during the delivery window, which keeps the entry drain-eligible. - #[test] - fn test_escalate_up_to_marks_snapshot_replayed_and_keeps_newer_failures() { - let target = peer("remote", "https://remote.example.com"); - let path = "/rustfs/admin/v3/site-replication/peer/iam-item"; - let snapshot_at = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("timestamp"); - - // Failure re-stamped after the snapshot: untouched, still eligible. - let mut queue = vec![drain_event( - "remote", - SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH, - 2, - Some(snapshot_at + time::Duration::seconds(5)), - )]; - assert_eq!( - escalate_site_replication_retry_events_up_to( - &mut queue, - &target, - SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH, - Some(snapshot_at), - ), - 0 - ); - assert!(!queue[0].failed); - assert!( - classify_site_replication_retry_event(&queue[0]).is_some(), - "a newer failure must stay drain-eligible" - ); - - // Unchanged since the snapshot: escalated, kept, drain-idle. - let mut queue = vec![drain_event( - "remote", - SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH, - 2, - Some(snapshot_at), - )]; - assert_eq!( - escalate_site_replication_retry_events_up_to(&mut queue, &target, path, Some(snapshot_at)), - 1 - ); - assert_eq!(queue.len(), 1, "the entry must survive until remote absence is proven"); - assert_eq!(queue[0].path, SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH); - assert!(queue[0].failed); - assert_eq!(queue[0].last_error, SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER); - assert!( - classify_site_replication_retry_event(&queue[0]).is_none(), - "a snapshot-replayed entry must not be re-sent daily" - ); - // Ordinary success dequeues must not clear the marker: collapsed - // paths are shared by every entity, so a successful Bob update - // proves nothing about a failed Alice deletion (second review - // round). - assert_eq!(dequeue_site_replication_retry_events(&mut queue, &target, path), 0); - assert_eq!(queue.len(), 1, "an escalated entry must survive an ordinary delivery success"); - // Only a repair — the operator's accountability transfer — settles it. - assert_eq!(dequeue_site_replication_retry_events_including_escalated(&mut queue, &target, path), 1); - assert!(queue.is_empty()); - - // A failed Alice deletion is stored under the internal path, so a - // successful Bob update on the shared wire path cannot erase it even - // before the drain runs. - let mut queue = Vec::new(); - upsert_site_replication_retry_event(&mut queue, &target, path, "alice delete failed", None); - assert_eq!(queue[0].path, SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH); - assert_eq!(dequeue_site_replication_retry_events(&mut queue, &target, path), 0); - assert_eq!(queue.len(), 1); - assert_eq!(queue[0].path, SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH); - - // A later hook failure overwrites the marker and re-arms the drain. - let mut queue = vec![drain_event("remote", path, 2, Some(snapshot_at))]; - escalate_site_replication_retry_events_up_to(&mut queue, &target, path, Some(snapshot_at)); - upsert_site_replication_retry_event(&mut queue, &target, path, "peer offline", None); - assert!(classify_site_replication_retry_event(&queue[0]).is_some()); - - // Legacy entry without a timestamp: escalated. - let mut queue = vec![drain_event("remote", path, 2, None)]; - assert_eq!( - escalate_site_replication_retry_events_up_to(&mut queue, &target, path, Some(snapshot_at)), - 1 - ); - - // A cloned event can disappear during replay; escalation recreates - // the internal liability while leaving another peer's row untouched. - let mut queue = vec![drain_event("other", path, 2, Some(snapshot_at))]; - assert_eq!( - escalate_site_replication_retry_events_up_to(&mut queue, &target, path, Some(snapshot_at)), - 1 - ); - assert!(!queue[0].failed); - assert_eq!(queue.len(), 2); - assert_eq!(queue[1].peer_deployment_id, target.deployment_id); - assert_eq!(queue[1].path, SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH); - } - - #[test] - fn test_collapsed_retry_queue_migration_preserves_legacy_liability() { - let peer = PeerInfo { - deployment_id: "remote-dep".to_string(), - ..peer("remote", "https://remote.example.com") - }; - let wire_path = "/rustfs/admin/v3/site-replication/peer/iam-item"; - let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("timestamp"); - let mut queue = vec![drain_event("remote-dep", wire_path, 2, Some(now))]; - - assert_eq!(dequeue_site_replication_retry_events(&mut queue, &peer, wire_path), 0); - assert!(normalize_collapsed_retry_queue_paths(&mut queue)); - assert_eq!(queue.len(), 1); - assert_eq!(queue[0].path, SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH); - assert!(!normalize_collapsed_retry_queue_paths(&mut queue)); - } - #[test] fn test_pending_endpoint_refresh_retry_summary_redacts_pem() { let pem = "-----BEGIN CERTIFICATE-----\nsecret-marker\n-----END CERTIFICATE-----"; @@ -13328,31 +8237,6 @@ mod tests { assert!(pending_endpoint_refresh(&state).is_none(), "safe summaries are not pending JSON"); } - #[test] - fn test_legacy_pending_retry_json_remains_readable() { - let legacy = PendingEndpointRefresh { - id: "legacy-refresh".to_string(), - peer: PeerInfo { - deployment_id: "remote".to_string(), - ..peer("remote", "https://remote.example.com") - }, - ..Default::default() - }; - let state = SiteReplicationState { - retry_queue: vec![SiteReplicationRetryEvent { - path: SITE_REPLICATION_ENDPOINT_REFRESH_RETRY_PATH.to_string(), - last_error: serde_json::to_string(&legacy).expect("serialize legacy pending"), - ..Default::default() - }], - ..Default::default() - }; - - assert_eq!( - pending_endpoint_refresh(&state).map(|pending| pending.id).as_deref(), - Some("legacy-refresh") - ); - } - #[test] fn test_pending_endpoint_refresh_ack_merge_is_monotonic() { let latest = PendingEndpointRefresh { @@ -13437,56 +8321,6 @@ mod tests { .await; } - #[test] - fn test_site_replication_bucket_target_replaces_tls_and_preserves_operational_fields() { - let local = PeerInfo { - deployment_id: "local".to_string(), - ..peer("local", "https://local.example.com") - }; - let remote = PeerInfo { - deployment_id: "remote".to_string(), - skip_tls_verify: true, - ..peer("remote", "https://remote.example.com:9443") - }; - let state = SiteReplicationState { - service_account_access_key: "svc".to_string(), - peers: BTreeMap::from([("local".to_string(), local.clone()), ("remote".to_string(), remote.clone())]), - ..Default::default() - }; - let generated = site_replication_bucket_target_for_peer("photos", &state, &remote, "secret", None) - .expect("build target") - .expect("target exists"); - assert!(generated.skip_tls_verify); - assert_eq!(generated.ca_cert_pem, ""); - - let existing = BucketTarget { - arn: generated.arn, - endpoint: "remote.example.com:9443".to_string(), - secure: true, - target_type: BucketTargetType::ReplicationService, - deployment_id: "remote".to_string(), - skip_tls_verify: false, - ca_cert_pem: "old-ca".to_string(), - bandwidth_limit: 42, - disable_proxy: true, - ..Default::default() - }; - let reconciled = reconcile_site_replication_bucket_targets( - BucketTargets { targets: vec![existing] }, - "photos", - &state, - &local, - None, - "secret", - ) - .expect("reconcile targets"); - let target = reconciled.targets.first().expect("reconciled target"); - assert!(target.skip_tls_verify); - assert_eq!(target.ca_cert_pem, ""); - assert_eq!(target.bandwidth_limit, 42); - assert!(target.disable_proxy); - } - #[test] fn test_peer_tls_capability_query_is_supported_and_legacy_response_fails_closed() { let remote = peer("remote", "https://remote.example.com"); @@ -13855,14 +8689,6 @@ mod tests { ); } - #[test] - fn test_bucket_versioning_xml_enables_versioning() { - let data = bucket_versioning_xml().expect("versioning XML should serialize"); - let config: VersioningConfiguration = deserialize(&data).expect("versioning XML should deserialize"); - - assert!(config.enabled()); - } - #[test] fn test_sr_metainfo_path_preserves_status_query() { let uri: Uri = "/rustfs/admin/v3/site-replication/status?buckets=true&entity=bucket&entityvalue=photos" @@ -14089,30 +8915,6 @@ mod tests { .expect("devnull must drain bodies larger than the admin body cap"); } - /// A3 red-light: `versioningEnabled` must travel on every outbound - /// make-with-versioning bucket op so the query matches MinIO's - /// site-replication make-bucket wire contract (MinIO's own hook sends - /// `versioningEnabled=true` on this op). - #[test] - fn test_make_with_versioning_op_paths_send_versioning_enabled() { - let bucket = SRBucketInfo { - bucket: "photos".to_string(), - created_at: Some(OffsetDateTime::UNIX_EPOCH), - object_lock_config: Some(BASE64_STANDARD.encode("")), - ..Default::default() - }; - let bootstrap = bootstrap_bucket_make_op_path(&bucket); - assert!(bootstrap.contains("operation=make-with-versioning"), "{bootstrap}"); - assert!(bootstrap.contains("versioningEnabled=true"), "{bootstrap}"); - assert!(bootstrap.contains("createdAt="), "{bootstrap}"); - assert!(bootstrap.contains("lockEnabled=true"), "{bootstrap}"); - - // The broadcast path (create-bucket hook) shares the same builder. - let broadcast = make_with_versioning_bucket_op_path("photos", Some("1970-01-01T00:00:00Z"), false); - assert!(broadcast.contains("versioningEnabled=true"), "{broadcast}"); - assert!(!broadcast.contains("lockEnabled"), "{broadcast}"); - } - #[tokio::test] #[serial] async fn test_add_bootstrap_scope_only_allows_expected_bucket_setup_until_guard_drops() { @@ -14564,658 +9366,6 @@ mod tests { assert!(err.to_string().contains("different site replication peer set")); } - #[test] - fn test_site_replication_bootstrap_plan_includes_replayable_snapshot_items() { - let mut info = SRInfo::default(); - info.state.peers.insert( - "remote".to_string(), - PeerInfo { - replicate_ilm_expiry: true, - ..peer("remote", "https://remote.example.com") - }, - ); - info.policies.insert( - "readwrite".to_string(), - SRIAMPolicy { - policy: Some(serde_json::json!({"Version": "2012-10-17", "Statement": []})), - updated_at: Some(OffsetDateTime::UNIX_EPOCH), - api_version: Some(SITE_REPL_API_VERSION.to_string()), - }, - ); - info.user_info_map.insert( - "alice".to_string(), - rustfs_madmin::UserInfo { - secret_key: Some("alice-secret".to_string()), - policy_name: Some("readwrite".to_string()), - status: rustfs_madmin::AccountStatus::Enabled, - updated_at: Some(OffsetDateTime::UNIX_EPOCH), - ..Default::default() - }, - ); - info.user_info_map.insert( - "external".to_string(), - rustfs_madmin::UserInfo { - secret_key: None, - status: rustfs_madmin::AccountStatus::Enabled, - ..Default::default() - }, - ); - info.group_desc_map.insert( - "devs".to_string(), - rustfs_madmin::GroupDesc { - name: "devs".to_string(), - status: "enabled".to_string(), - members: vec!["alice".to_string()], - policy: String::new(), - updated_at: Some(OffsetDateTime::UNIX_EPOCH), - }, - ); - info.user_policies.insert( - "alice".to_string(), - SRPolicyMapping { - user_or_group: "alice".to_string(), - user_type: sr_wire_user_type(UserType::Reg, false), - policy: "readwrite".to_string(), - updated_at: Some(OffsetDateTime::UNIX_EPOCH), - ..Default::default() - }, - ); - info.buckets.insert( - "photos".to_string(), - SRBucketInfo { - bucket: "photos".to_string(), - policy: Some(serde_json::json!({"Statement": []})), - versioning: Some(BASE64_STANDARD.encode("")), - quota_config: Some(BASE64_STANDARD.encode(r#"{"quota":1024}"#)), - expiry_lc_config: Some(BASE64_STANDARD.encode("")), - object_lock_config: Some(BASE64_STANDARD.encode("")), - created_at: Some(OffsetDateTime::UNIX_EPOCH), - api_version: Some(SITE_REPL_API_VERSION.to_string()), - ..Default::default() - }, - ); - - let plan = site_replication_bootstrap_plan(&info).expect("bootstrap plan should build"); - - assert_eq!(plan.iam_items.iter().map(|item| item.r#type.as_str()).collect::>(), { - vec!["policy", "iam-user", "group-info", "policy-mapping"] - }); - assert_eq!(plan.bucket_make_ops.len(), 1); - assert!(plan.bucket_make_ops[0].contains("operation=make-with-versioning")); - assert!(plan.bucket_make_ops[0].contains("lockEnabled=true")); - assert_eq!(plan.bucket_configure_ops.len(), 1); - assert!(plan.bucket_configure_ops[0].contains("operation=configure-replication")); - - let bucket_types = plan.bucket_items.iter().map(|item| item.r#type.as_str()).collect::>(); - assert_eq!( - bucket_types, - vec!["policy", "version-config", "object-lock-config", "quota-config", "lc-config"] - ); - let quota = plan - .bucket_items - .iter() - .find(|item| item.r#type == "quota-config") - .and_then(|item| item.quota.as_ref()) - .expect("quota item should exist"); - assert_eq!(quota["quota"], 1024); - } - - #[test] - fn test_site_replication_bootstrap_plan_skips_lifecycle_by_default() { - let mut info = SRInfo::default(); - info.buckets.insert( - "photos".to_string(), - SRBucketInfo { - bucket: "photos".to_string(), - expiry_lc_config: Some(BASE64_STANDARD.encode("")), - api_version: Some(SITE_REPL_API_VERSION.to_string()), - ..Default::default() - }, - ); - - let plan = site_replication_bootstrap_plan(&info).expect("bootstrap plan should build"); - - assert!(!plan.bucket_items.iter().any(|item| item.r#type == "lc-config")); - } - - /// A deleted expiry state (entry value None, axis set) must travel as an - /// explicit timestamped delete item — a peer that missed the live delete - /// otherwise keeps stale expiry rules through every repair (review - /// finding). - #[test] - fn test_site_replication_bootstrap_plan_emits_timestamped_lifecycle_delete() { - let deleted_at = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("timestamp"); - let mut info = SRInfo::default(); - info.state.peers.insert( - "remote-dep".to_string(), - PeerInfo { - replicate_ilm_expiry: true, - ..peer("remote", "https://remote.example.com") - }, - ); - info.buckets.insert( - "photos".to_string(), - SRBucketInfo { - bucket: "photos".to_string(), - expiry_lc_config: None, - expiry_lc_config_updated_at: Some(deleted_at), - api_version: Some(SITE_REPL_API_VERSION.to_string()), - ..Default::default() - }, - ); - - let plan = site_replication_bootstrap_plan(&info).expect("bootstrap plan should build"); - - let item = plan - .bucket_items - .iter() - .find(|item| item.r#type == "lc-config") - .expect("a deleted expiry state must produce an lc-config delete item"); - assert!(item.expiry_lc_config.is_none(), "delete items carry no config body"); - assert_eq!(item.expiry_updated_at, Some(deleted_at)); - assert_eq!(item.updated_at, Some(deleted_at)); - } - - /// What each local lifecycle state contributes to the SRInfo entry: - /// deletions are timestamped statements, never-configured buckets and - /// transition-only configs without an expiry axis say nothing. - #[test] - fn test_lifecycle_expiry_statement_matrix() { - let created = OffsetDateTime::from_unix_timestamp(1_600_000_000).expect("timestamp"); - let mut meta = crate::admin::storage_api::bucket::metadata::BucketMetadata::new("photos"); - meta.created = created; - // Never configured: load backfills the write time to `created`. - meta.lifecycle_config_updated_at = created; - assert!(lifecycle_expiry_statement(&meta).is_none()); - - // Deleted: the write time survives deletion and exceeds creation. - let deleted_at = created + time::Duration::seconds(100); - meta.lifecycle_config_updated_at = deleted_at; - let (subset, axis) = lifecycle_expiry_statement(&meta).expect("deletion is a statement"); - assert!(subset.is_none()); - assert_eq!(axis, deleted_at); - - // Present with expiry rules and the axis: subset + axis travel. - let expiry_axis = created + time::Duration::seconds(50); - let mut config = lc_config(vec![lc_rule("e1", Some(7), None)]); - config.expiry_updated_at = Some(s3s::dto::Timestamp::from(expiry_axis)); - meta.lifecycle_config_xml = serialize(&config).expect("serialize config"); - let (subset, axis) = lifecycle_expiry_statement(&meta).expect("expiry config is a statement"); - assert!(subset.is_some()); - assert_eq!(axis.unix_timestamp(), expiry_axis.unix_timestamp()); - - // Transition-only without an axis: nothing to say (a delete stamped - // off the whole-config time would erase newer peer expiry state). - meta.lifecycle_config_xml = serialize(&lc_config(vec![lc_rule("t1", None, Some(30))])).expect("serialize config"); - assert!(lifecycle_expiry_statement(&meta).is_none()); - - // Transition-only WITH an axis: expiry rules were properly removed — - // the delete travels at that axis. - let mut transition_only = lc_config(vec![lc_rule("t1", None, Some(30))]); - transition_only.expiry_updated_at = Some(s3s::dto::Timestamp::from(expiry_axis)); - meta.lifecycle_config_xml = serialize(&transition_only).expect("serialize config"); - let (subset, axis) = lifecycle_expiry_statement(&meta).expect("removed expiry state is a statement"); - assert!(subset.is_none()); - assert_eq!(axis.unix_timestamp(), expiry_axis.unix_timestamp()); - } - - #[test] - fn test_site_replication_repair_request_is_strict_and_requires_explicit_mode() { - assert!(serde_json::from_str::(r#"{"mode":"dry-run"}"#).is_ok()); - assert!(serde_json::from_str::(r#"{"mode":"execute"}"#).is_ok()); - assert!(serde_json::from_str::(r#"{}"#).is_err()); - assert!(serde_json::from_str::(r#"{"mode":"dry-run","secret":"leak"}"#).is_err()); - } - - #[test] - fn test_site_replication_repair_dry_run_plan_is_non_mutating_and_redacted() { - let state = SiteReplicationState { - name: "local".to_string(), - service_account_access_key: "site-replicator-0".to_string(), - service_account_secret_key: "state-secret".to_string(), - peers: BTreeMap::from([ - ( - "local-dep".to_string(), - PeerInfo { - deployment_id: "local-dep".to_string(), - ..peer("local", "https://local.example.com") - }, - ), - ( - "remote-dep".to_string(), - PeerInfo { - deployment_id: "remote-dep".to_string(), - ..peer("remote", "https://remote.example.com") - }, - ), - ]), - retry_queue: vec![SiteReplicationRetryEvent { - peer_deployment_id: "remote-dep".to_string(), - path: format!( - "{SITE_REPLICATION_PEER_BUCKET_OPS_PATH}?bucket=photos&operation={SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING}" - ), - last_error: "credential=retry-secret".to_string(), - ..Default::default() - }], - ..Default::default() - }; - let plan = SiteReplicationBootstrapPlan { - iam_items: vec![SRIAMItem { - r#type: "iam-user".to_string(), - iam_user: Some(rustfs_madmin::SRIAMUser { - access_key: "alice".to_string(), - user_req: Some(AddOrUpdateUserReq { - secret_key: "iam-secret".to_string(), - policy: None, - status: rustfs_madmin::AccountStatus::Enabled, - }), - ..Default::default() - }), - ..Default::default() - }], - bucket_make_ops: vec![format!( - "{SITE_REPLICATION_PEER_BUCKET_OPS_PATH}?bucket=photos&operation={SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING}" - )], - ..Default::default() - }; - let before = serde_json::to_vec(&state).expect("serialize state before planning"); - let local = state.peers.get("local-dep").expect("local peer"); - - let response = SiteReplicationRepairPreflight { - mode: "dry-run", - status: "planned", - preflight_token: site_replication_repair_preflight_token(&state, &plan, b"test-signing-key") - .expect("preflight token"), - retry_events: state.retry_queue.len(), - sites: site_replication_repair_sites(&state, local, &plan, b"test-signing-key").expect("repair sites"), - }; - let encoded = serde_json::to_string(&response).expect("serialize preflight"); - - assert_eq!(serde_json::to_vec(&state).expect("serialize state after planning"), before); - assert!(!encoded.contains("state-secret")); - assert!(!encoded.contains("iam-secret")); - assert!(!encoded.contains("retry-secret")); - assert!(!encoded.contains("remote.example.com")); - assert_eq!(response.sites["remote-dep"].families[SITE_REPLICATION_REPAIR_IAM_FAMILY].planned, 1); - let bucket_family = &response.sites["remote-dep"].families[SITE_REPLICATION_REPAIR_BUCKET_FAMILY]; - assert_eq!(bucket_family.retry_events, 1); - let task_id = &bucket_family.tasks[0].task_id; - assert_eq!(task_id.len(), 43); - assert!( - task_id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) - ); - assert!(!task_id.contains("bucket")); - assert!(!task_id.contains("photos")); - assert!(!task_id.contains("remote-dep")); - assert_eq!(bucket_family.tasks[0].status, "planned"); - let repeated = site_replication_repair_sites(&state, local, &plan, b"test-signing-key").expect("repeat repair sites"); - assert_eq!( - task_id, - &repeated["remote-dep"].families[SITE_REPLICATION_REPAIR_BUCKET_FAMILY].tasks[0].task_id - ); - let rotated = site_replication_repair_sites(&state, local, &plan, b"rotated-signing-key").expect("rotated repair sites"); - assert_ne!( - task_id, - &rotated["remote-dep"].families[SITE_REPLICATION_REPAIR_BUCKET_FAMILY].tasks[0].task_id - ); - } - - #[test] - fn test_site_replication_repair_preflight_detects_stale_snapshot() { - let mut state = SiteReplicationState { - name: "local".to_string(), - service_account_access_key: "site-replicator-0".to_string(), - peers: BTreeMap::from([( - "remote-dep".to_string(), - PeerInfo { - deployment_id: "remote-dep".to_string(), - ..peer("remote", "https://remote.example.com") - }, - )]), - ..Default::default() - }; - let plan = SiteReplicationBootstrapPlan { - bucket_make_ops: vec![ - "/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=make-with-versioning".to_string(), - ], - ..Default::default() - }; - let original = site_replication_repair_preflight_token(&state, &plan, b"test-signing-key").expect("original token"); - let original_plan = site_replication_repair_plan_token(&state, &plan).expect("original plan token"); - - state.updated_at = Some(OffsetDateTime::UNIX_EPOCH); - let changed = site_replication_repair_preflight_token(&state, &plan, b"test-signing-key").expect("changed token"); - let changed_plan = site_replication_repair_plan_token(&state, &plan).expect("changed plan token"); - - assert_ne!(original, changed); - assert_eq!(original.len(), 43); - assert!( - original - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) - ); - assert_ne!( - changed, - site_replication_repair_preflight_token(&state, &plan, b"different-signing-key").expect("differently signed token") - ); - assert!(site_replication_repair_preflight_token(&state, &plan, b"").is_err()); - - state.retry_queue.push(SiteReplicationRetryEvent { - id: "retry-1".to_string(), - peer_deployment_id: "remote-dep".to_string(), - path: "/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=make-with-versioning".to_string(), - ..Default::default() - }); - let retry_changed = - site_replication_repair_preflight_token(&state, &plan, b"test-signing-key").expect("retry-aware token"); - assert_ne!(changed, retry_changed); - assert_eq!( - changed_plan, - site_replication_repair_plan_token(&state, &plan).expect("retry-stable plan token") - ); - assert_ne!(original_plan, changed_plan, "updated_at changes the plan token"); - } - - #[test] - fn test_site_replication_repair_partial_retry_skips_completed_tasks_and_survives_restart() { - let local = PeerInfo { - deployment_id: "local-dep".to_string(), - ..peer("local", "https://local.example.com") - }; - let remote = PeerInfo { - deployment_id: "remote-dep".to_string(), - ..peer("remote", "https://remote.example.com") - }; - let state = SiteReplicationState { - peers: BTreeMap::from([ - (local.deployment_id.clone(), local.clone()), - (remote.deployment_id.clone(), remote.clone()), - ]), - ..Default::default() - }; - let plan = SiteReplicationBootstrapPlan { - iam_items: vec![SRIAMItem { - r#type: "policy".to_string(), - name: "readwrite".to_string(), - ..Default::default() - }], - bucket_make_ops: vec![ - "/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=make-with-versioning".to_string(), - ], - ..Default::default() - }; - let tasks = site_replication_repair_tasks(&plan); - let (first_index, first_task) = &tasks[0]; - let (second_index, second_task) = &tasks[1]; - let now = OffsetDateTime::UNIX_EPOCH; - let mut operation = SiteReplicationRepairOperation { - operation_id: Uuid::new_v4().to_string(), - preflight_token: site_replication_repair_preflight_token(&state, &plan, b"test-signing-key") - .expect("preflight token"), - plan_token: site_replication_repair_plan_token(&state, &plan).expect("plan token"), - status: "running".to_string(), - sites: site_replication_repair_sites(&state, &local, &plan, b"test-signing-key").expect("repair sites"), - created_at: Some(now), - updated_at: Some(now), - completed_at: None, - }; - - update_site_replication_repair_task(&mut operation, &remote.deployment_id, first_task.family(), *first_index, Ok(())) - .expect("record first success"); - update_site_replication_repair_task( - &mut operation, - &remote.deployment_id, - second_task.family(), - *second_index, - Err("peer response included secret=must-not-leak"), - ) - .expect("record injected failure"); - summarize_site_replication_repair_operation(&mut operation); - assert_eq!(operation.status, "partial"); - assert_eq!( - operation.sites["remote-dep"].families[SITE_REPLICATION_REPAIR_IAM_FAMILY].tasks[0].status, - "succeeded" - ); - assert_eq!( - operation.sites["remote-dep"].families[SITE_REPLICATION_REPAIR_BUCKET_FAMILY].tasks[0].status, - "failed" - ); - assert!( - !site_replication_repair_task_pending(&operation, &remote.deployment_id, first_task.family(), *first_index) - .expect("first task state") - ); - assert!( - !site_replication_repair_task_pending(&operation, &remote.deployment_id, second_task.family(), *second_index) - .expect("failed task waits for retry") - ); - let response = serde_json::to_string(&site_replication_repair_operation_response(&operation)) - .expect("serialize public operation response"); - assert!(!response.contains(&operation.preflight_token)); - assert!(!response.contains(&operation.plan_token)); - - let persisted_state = SiteReplicationRepairState { - operations: BTreeMap::from([(operation.operation_id.clone(), operation)]), - }; - let encoded = serde_json::to_vec(&persisted_state).expect("persist state"); - let recovered_state: SiteReplicationRepairState = serde_json::from_slice(&encoded).expect("load state after restart"); - let mut recovered = recovered_state - .operations - .into_values() - .next() - .expect("recover operation after restart"); - assert_eq!(recovered.sites["remote-dep"].families[SITE_REPLICATION_REPAIR_IAM_FAMILY].succeeded, 1); - assert!(!String::from_utf8(encoded).expect("operation JSON").contains("must-not-leak")); - - prepare_site_replication_repair_retry(&mut recovered); - assert_eq!( - recovered.sites["remote-dep"].families[SITE_REPLICATION_REPAIR_IAM_FAMILY].tasks[0].status, - "skipped" - ); - assert_eq!( - recovered.sites["remote-dep"].families[SITE_REPLICATION_REPAIR_BUCKET_FAMILY].tasks[0].status, - "planned" - ); - assert!( - site_replication_repair_task_pending(&recovered, &remote.deployment_id, second_task.family(), *second_index) - .expect("failed task becomes retryable") - ); - update_site_replication_repair_task(&mut recovered, &remote.deployment_id, second_task.family(), *second_index, Ok(())) - .expect("retry failed task"); - assert!( - !site_replication_repair_task_pending(&recovered, &remote.deployment_id, first_task.family(), *first_index) - .expect("completed task remains skipped") - ); - summarize_site_replication_repair_operation(&mut recovered); - - assert_eq!(recovered.status, "success"); - assert_eq!(recovered.sites["remote-dep"].families[SITE_REPLICATION_REPAIR_IAM_FAMILY].succeeded, 1); - assert_eq!(recovered.sites["remote-dep"].families[SITE_REPLICATION_REPAIR_BUCKET_FAMILY].succeeded, 1); - } - - #[test] - fn test_site_replication_repair_error_classification_is_redacted() { - assert_eq!( - classify_site_replication_repair_error( - "peer request to https://user:secret@example.com failed with 403: token=private" - ), - "authorization-failed" - ); - assert_eq!( - classify_site_replication_repair_error("peer request body contained secret=private"), - "remote-operation-failed" - ); - } - - #[test] - fn test_site_replication_repair_admission_resumes_same_id_and_rejects_conflicts() { - let existing = SiteReplicationRepairOperation { - operation_id: "operation-a".to_string(), - preflight_token: "preflight-a".to_string(), - plan_token: "plan-a".to_string(), - status: "running".to_string(), - ..Default::default() - }; - let mut state = SiteReplicationRepairState { - operations: BTreeMap::from([(existing.operation_id.clone(), existing.clone())]), - }; - - let resumed = admit_site_replication_repair_operation( - &mut state, - existing.operation_id.clone(), - &existing.preflight_token, - existing.clone(), - ) - .expect("same operation ID and preflight should resume"); - assert_eq!(resumed.operation_id, existing.operation_id); - - let conflicting_operation = SiteReplicationRepairOperation { - operation_id: "operation-b".to_string(), - preflight_token: "preflight-b".to_string(), - plan_token: "plan-b".to_string(), - status: "running".to_string(), - ..Default::default() - }; - let conflicting_preflight = conflicting_operation.preflight_token.clone(); - let err = admit_site_replication_repair_operation( - &mut state, - conflicting_operation.operation_id.clone(), - &conflicting_preflight, - conflicting_operation, - ) - .expect_err("a different operation must not pass a persisted running operation"); - assert_eq!(err.code(), &S3ErrorCode::ClientTokenConflict); - - let stale_candidate = SiteReplicationRepairOperation { - plan_token: "plan-changed".to_string(), - ..existing.clone() - }; - let err = admit_site_replication_repair_operation( - &mut state, - existing.operation_id.clone(), - &existing.preflight_token, - stale_candidate, - ) - .expect_err("a resumed operation must remain bound to its original plan"); - assert_eq!(err.code(), &S3ErrorCode::PreconditionFailed); - - let err = - admit_site_replication_repair_operation(&mut state, existing.operation_id.clone(), "different-preflight", existing) - .expect_err("an operation ID must remain bound to its original preflight"); - assert_eq!(err.code(), &S3ErrorCode::ClientTokenConflict); - } - - #[test] - fn test_site_replication_repair_history_never_prunes_retriable_operations() { - let mut operations = (0..=SITE_REPLICATION_REPAIR_OPERATION_LIMIT) - .map(|index| { - ( - format!("success-{index}"), - SiteReplicationRepairOperation { - operation_id: format!("success-{index}"), - status: "success".to_string(), - created_at: OffsetDateTime::from_unix_timestamp(i64::try_from(index).expect("small test index")).ok(), - ..Default::default() - }, - ) - }) - .collect::>(); - operations.insert( - "partial".to_string(), - SiteReplicationRepairOperation { - operation_id: "partial".to_string(), - status: "partial".to_string(), - created_at: Some(OffsetDateTime::UNIX_EPOCH), - ..Default::default() - }, - ); - - prune_site_replication_repair_operations(&mut operations); - - assert!(operations.contains_key("partial")); - assert_eq!(operations.len(), SITE_REPLICATION_REPAIR_OPERATION_LIMIT); - assert!(!operations.contains_key("success-0")); - assert!(!operations.contains_key("success-1")); - } - - #[test] - fn test_site_replication_state_replicates_ilm_expiry_detects_enabled_peer() { - let mut state = SiteReplicationState::default(); - state.peers.insert( - "remote".to_string(), - PeerInfo { - replicate_ilm_expiry: true, - ..peer("remote", "https://remote.example.com") - }, - ); - - assert!(site_replication_state_replicates_ilm_expiry(&state)); - } - - #[test] - fn test_retry_event_upsert_marks_repeated_failures() { - let peer = PeerInfo { - deployment_id: "remote-dep".to_string(), - ..peer("remote", "https://remote.example.com") - }; - let mut queue = Vec::new(); - - upsert_site_replication_retry_event(&mut queue, &peer, "/rustfs/admin/v3/site-replication/peer/iam-item", "first", None); - upsert_site_replication_retry_event(&mut queue, &peer, "/rustfs/admin/v3/site-replication/peer/iam-item", "second", None); - upsert_site_replication_retry_event(&mut queue, &peer, "/rustfs/admin/v3/site-replication/peer/iam-item", "third", None); - - assert_eq!(queue.len(), 1); - assert_eq!(queue[0].path, SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH); - assert_eq!(queue[0].retry_count, SITE_REPLICATION_RETRY_FAILED_AFTER); - assert!(queue[0].failed); - assert_eq!(queue[0].last_error, "third"); - } - - /// P1-15 review follow-up: a successful peer-edit delivery only proves the - /// peer reached the state THAT delivery carried. Settling it must not - /// erase a retry event a newer edit left behind, or the local site sits on - /// edit B, the peer on edit A, and nothing is queued to converge them. - #[test] - fn retry_settlement_must_not_erase_a_newer_generation_failure() { - let peer = PeerInfo { - deployment_id: "remote-dep".to_string(), - ..peer("remote", "https://remote.example.com") - }; - let mut queue = Vec::new(); - - // Edit A (generation 5) delivered successfully and is stalled before - // settling. Edit B (generation 6) commits meanwhile, fails delivery to - // the same peer, and enqueues. - upsert_site_replication_retry_event(&mut queue, &peer, SITE_REPLICATION_PEER_EDIT_PATH, "peer offline", Some(6)); - - // A resumes: its own settlement must leave B's retry alone. - assert_eq!( - settle_site_replication_retry_events(&mut queue, &peer, SITE_REPLICATION_PEER_EDIT_PATH, Some(5)), - 0 - ); - assert_eq!(queue.len(), 1, "the newer edit's retry event was erased by an older success"); - assert_eq!(queue[0].edit_generation, Some(6)); - - // An even older delivery failing afterwards must not lower the fence. - upsert_site_replication_retry_event(&mut queue, &peer, SITE_REPLICATION_PEER_EDIT_PATH, "still offline", Some(4)); - assert_eq!(queue[0].edit_generation, Some(6)); - - // B's own delivery succeeding is what clears it. - assert_eq!( - settle_site_replication_retry_events(&mut queue, &peer, SITE_REPLICATION_PEER_EDIT_PATH, Some(6)), - 1 - ); - assert!(queue.is_empty()); - - // Collapsed broadcast failures live under an internal snapshot path; - // an unrelated success on their shared wire path cannot settle them. - let iam_path = "/rustfs/admin/v3/site-replication/peer/iam-item"; - upsert_site_replication_retry_event(&mut queue, &peer, iam_path, "peer offline", None); - assert_eq!(dequeue_site_replication_retry_events(&mut queue, &peer, iam_path), 0); - assert_eq!(queue[0].path, SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH); - } - /// P1-15 review follow-up: the receiving side of the ordering fence. Two /// nodes of the sending site can fan out in the opposite order to their /// commits; the receiver decides ordering from the generation the sender @@ -15594,126 +9744,6 @@ mod tests { ); } - /// The `previous + 1` half of the hybrid clock: allocations stay strictly - /// increasing even when the wall clock cannot move them forward — two - /// allocations inside one clock tick, or a clock that stepped backwards - /// mid-lifetime (a counter already ahead of the wall clock advances by - /// exactly one per allocation instead of jumping back). Dropping the - /// `previous + 1` half (allocating bare wall time) turns this red. - #[test] - fn hybrid_generation_is_strictly_increasing_when_the_clock_stalls() { - let mut state = SiteReplicationState { - // A counter far ahead of any wall clock this test will see. - edit_generation: u64::MAX / 2, - ..Default::default() - }; - assert_eq!(next_peer_edit_generation(&mut state), u64::MAX / 2 + 1); - assert_eq!(next_peer_edit_generation(&mut state), u64::MAX / 2 + 2); - // Saturation pins at the ceiling instead of wrapping; the equal-value - // escape (`applied > generation` is false for equal) keeps deliveries - // applying rather than fencing the origin out. - state.edit_generation = u64::MAX; - assert_eq!(next_peer_edit_generation(&mut state), u64::MAX); - } - - #[test] - fn test_retry_stats_for_state_counts_pending_and_failed() { - let state = SiteReplicationState { - retry_queue: vec![ - SiteReplicationRetryEvent { - failed: false, - last_error: "pending".to_string(), - ..Default::default() - }, - SiteReplicationRetryEvent { - failed: true, - last_error: "failed".to_string(), - ..Default::default() - }, - ], - ..Default::default() - }; - - let stats = retry_stats_for_state(&state).expect("retry stats should be present"); - - assert_eq!(stats.pending, 1); - assert_eq!(stats.failed, 1); - assert_eq!(stats.last_error, "failed"); - } - - #[test] - fn test_retry_event_dequeue_matches_deployment_id_or_endpoint() { - let peer = PeerInfo { - deployment_id: "current-dep".to_string(), - ..peer("remote", "https://remote.example.com") - }; - let path = SITE_REPLICATION_PEER_EDIT_PATH; - let mut queue = vec![ - SiteReplicationRetryEvent { - id: "same-endpoint".to_string(), - peer_deployment_id: "old-dep".to_string(), - peer_endpoint: "https://remote.example.com".to_string(), - path: path.to_string(), - ..Default::default() - }, - SiteReplicationRetryEvent { - id: "different-path".to_string(), - peer_deployment_id: "old-dep".to_string(), - peer_endpoint: "https://remote.example.com".to_string(), - path: "/rustfs/admin/v3/site-replication/peer/bucket-meta".to_string(), - ..Default::default() - }, - ]; - - let removed = dequeue_site_replication_retry_events(&mut queue, &peer, path); - - assert_eq!(removed, 1); - assert_eq!(queue.len(), 1); - assert_eq!(queue[0].id, "different-path"); - } - - #[test] - fn test_retry_event_replayed_by_bootstrap_only_clears_replayable_bucket_ops() { - let retry_event = |id: &str, path: &str| SiteReplicationRetryEvent { - id: id.to_string(), - path: path.to_string(), - ..Default::default() - }; - let mut queue = vec![ - retry_event( - "make", - "/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=make-with-versioning", - ), - retry_event( - "configure", - "/rustfs/admin/v3/site-replication/peer/bucket-ops?operation=configure-replication&bucket=photos", - ), - retry_event( - "delete", - "/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=delete-bucket", - ), - retry_event( - "force-delete", - "/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=force-delete-bucket", - ), - retry_event( - "purge", - "/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=purge-deleted-bucket", - ), - retry_event( - "unknown", - "/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=custom", - ), - retry_event("iam", "/rustfs/admin/v3/site-replication/peer/iam-item"), - retry_event("bucket-meta", "/rustfs/admin/v3/site-replication/peer/bucket-meta"), - ]; - - queue.retain(|event| !retry_event_replayed_by_bootstrap(event)); - - let retained_ids = queue.iter().map(|event| event.id.as_str()).collect::>(); - assert_eq!(retained_ids, vec!["delete", "force-delete", "purge", "unknown", "iam", "bucket-meta"]); - } - #[test] fn test_remove_sites_prunes_retry_queue_for_removed_peer() { let state = SiteReplicationState { @@ -15832,146 +9862,6 @@ mod tests { assert!(normalized.contains_key("hash-remote")); } - #[test] - fn test_site_identity_key_deduplicates_scheme_drift_on_same_host_port() { - assert_eq!( - site_identity_key("https://node-a.example.com:9000"), - site_identity_key("http://NODE-A.example.com:9000/"), - ); - } - - #[test] - fn test_normalize_peer_map_by_identity_prefers_https_endpoint() { - let peers = BTreeMap::from([ - ( - "peer-http".to_string(), - PeerInfo { - deployment_id: "peer-http".to_string(), - ..peer("peer", "http://node-a.example.com:9000") - }, - ), - ( - "peer-https".to_string(), - PeerInfo { - deployment_id: "peer-https".to_string(), - ..peer("peer", "https://node-a.example.com:9000") - }, - ), - ]); - - let normalized = normalize_peer_map_by_identity(peers); - assert_eq!(normalized.len(), 1); - let normalized_peer = normalized.values().next().expect("normalized peer"); - assert!(normalized_peer.endpoint.starts_with("https://")); - } - - #[test] - fn test_request_endpoint_prefers_forwarded_proto() { - let uri: Uri = "/rustfs/admin/v3/site-replication/status".parse().unwrap(); - let mut headers = HeaderMap::new(); - headers.insert("x-forwarded-scheme", HeaderValue::from_static("http")); - headers.insert("x-forwarded-proto", HeaderValue::from_static("https")); - headers.insert("host", HeaderValue::from_static("node-a.example.com:9000")); - - let endpoint = request_endpoint(&uri, &headers); - - assert_eq!(endpoint, "https://node-a.example.com:9000"); - } - - #[test] - fn test_request_endpoint_uses_absolute_uri_without_host_header() { - let uri: Uri = "https://node-a.example.com:9443/rustfs/admin/v3/site-replication/status" - .parse() - .unwrap(); - let headers = HeaderMap::new(); - - let endpoint = request_endpoint(&uri, &headers); - - assert_eq!(endpoint, "https://node-a.example.com:9443"); - } - - #[test] - fn test_request_endpoint_falls_back_to_https_when_tls_path_is_configured() { - with_var(ENV_RUSTFS_TLS_PATH, Some("/tmp/tls"), || { - let uri: Uri = "/rustfs/admin/v3/site-replication/status".parse().unwrap(); - let headers = HeaderMap::new(); - - let endpoint = request_endpoint(&uri, &headers); - - assert!(endpoint.starts_with("https://")); - }); - } - - #[test] - fn test_site_replication_local_endpoint_uses_api_port_for_console_host_header() { - let uri: Uri = "/rustfs/admin/v3/site-replication/status".parse().unwrap(); - let mut headers = HeaderMap::new(); - headers.insert("x-forwarded-proto", HeaderValue::from_static("https")); - headers.insert("host", HeaderValue::from_static("node-a.example.com:9001")); - - let endpoint = site_replication_local_endpoint(&uri, &headers); - - assert_eq!(endpoint, "https://node-a.example.com:9000"); - } - - #[test] - fn test_site_replication_local_endpoint_preserves_ipv6_host() { - let uri: Uri = "/rustfs/admin/v3/site-replication/status".parse().unwrap(); - let mut headers = HeaderMap::new(); - headers.insert("x-forwarded-proto", HeaderValue::from_static("https")); - headers.insert("host", HeaderValue::from_static("[::1]:9001")); - - let endpoint = site_replication_local_endpoint(&uri, &headers); - - assert_eq!(endpoint, "https://[::1]:9000"); - } - - #[test] - fn test_site_replication_local_endpoint_preserves_non_console_port() { - let uri: Uri = "/rustfs/admin/v3/site-replication/status".parse().unwrap(); - let mut headers = HeaderMap::new(); - headers.insert("x-forwarded-proto", HeaderValue::from_static("https")); - headers.insert("host", HeaderValue::from_static("lb.example.com:9443")); - - let endpoint = site_replication_local_endpoint(&uri, &headers); - - assert_eq!(endpoint, "https://lb.example.com:9443"); - } - - #[test] - fn test_site_replication_local_endpoint_rejects_forwarded_non_http_scheme() { - let uri: Uri = "/rustfs/admin/v3/site-replication/status".parse().unwrap(); - let mut headers = HeaderMap::new(); - headers.insert("x-forwarded-proto", HeaderValue::from_static("ftp")); - headers.insert("host", HeaderValue::from_static("node-a.example.com:9000")); - - let endpoint = site_replication_local_endpoint(&uri, &headers); - - assert!(!endpoint.starts_with("ftp://")); - } - - #[test] - fn test_runtime_tls_enabled_prefers_explicit_tls_over_http_runtime_endpoint() { - let endpoints = EndpointServerPools::from(vec![PoolEndpoints { - legacy: false, - set_count: 1, - drives_per_set: 1, - endpoints: Endpoints::from(vec![Endpoint { - url: Url::parse("http://127.0.0.1:9000/tmp").unwrap(), - is_local: true, - pool_idx: 0, - set_idx: 0, - disk_idx: 0, - }]), - cmd_line: String::new(), - platform: String::new(), - }]); - - with_var(ENV_RUSTFS_TLS_PATH, Some("/tmp/tls"), || { - assert!(runtime_tls_enabled_with(Some(&endpoints))); - }); - } - #[test] fn test_reconcile_peer_with_actual_identity_replaces_endpoint_hash_key() { let mut state = SiteReplicationState::default(); @@ -16023,28 +9913,6 @@ mod tests { assert_eq!(state.name, "new-local"); } - #[test] - fn test_site_replication_state_requires_remote_peer_to_be_enabled() { - let mut state = SiteReplicationState::default(); - state.peers.insert( - "local".to_string(), - PeerInfo { - deployment_id: "local".to_string(), - ..peer("local", "https://local.example.com") - }, - ); - - assert!(!state.enabled()); - } - - #[test] - fn test_sr_remove_req_accepts_null_sites() { - let req: SRRemoveReq = serde_json::from_str(r#"{"all":true,"sites":null}"#).expect("parse remove req"); - - assert!(req.remove_all); - assert!(req.site_names.is_empty()); - } - #[test] fn test_validate_remove_sites_req_rejects_empty_and_unknown_sites() { let mut state = SiteReplicationState { @@ -16357,33 +10225,6 @@ mod tests { assert!(edited.peers.values().all(|peer| peer.replicate_ilm_expiry)); } - #[test] - fn test_bucket_target_matches_peer_by_deployment_id() { - let target = BucketTarget { - deployment_id: "remote-dep".to_string(), - endpoint: "other-host:9000".to_string(), - target_type: BucketTargetType::ReplicationService, - ..Default::default() - }; - let mut remote = peer("remote", "https://remote.example.com"); - remote.deployment_id = "remote-dep".to_string(); - - assert!(bucket_target_matches_peer(&target, &remote)); - } - - #[test] - fn test_bucket_target_matches_peer_by_endpoint() { - let target = BucketTarget { - endpoint: "remote.example.com:443".to_string(), - secure: true, - target_type: BucketTargetType::ReplicationService, - ..Default::default() - }; - let remote = peer("remote", "https://remote.example.com/"); - - assert!(bucket_target_matches_peer(&target, &remote)); - } - #[test] fn test_peer_deployment_id_for_endpoint_matches_normalized_endpoint() { let mut state = SiteReplicationState::default(); @@ -16396,10 +10237,6 @@ mod tests { assert_eq!(deployment_id.as_deref(), Some("remote-dep")); } - fn home_office() -> HashSet { - HashSet::from(["home".to_string(), "office".to_string()]) - } - fn site_repl_config(peer: &str) -> ReplicationConfiguration { ReplicationConfiguration { role: String::new(), @@ -16486,84 +10323,6 @@ mod tests { ); } - fn operator_rule(id: &str) -> ReplicationRule { - ReplicationRule { - id: Some(id.to_string()), - ..build_site_replication_rule("arn:aws:s3:::backup", 1, id) - } - } - - // The one-directional bug: the joined site applied the initiator's replication config - // verbatim, so its own `site-repl-` rule was replaced by a rule pointing at - // itself. No bucket target backs that ARN, so every object was dropped without a log. - #[test] - fn test_merge_incoming_replication_config_keeps_local_reverse_rule() { - let merged = merge_incoming_replication_config( - Some(site_repl_config("home")), - Some(site_repl_config("office")), - &home_office(), - OperatorRuleContract::Derived, - ) - .expect("merge should keep the local rule"); - - assert_eq!(merged.rules.len(), 1); - assert_eq!(merged.rules[0].id.as_deref(), Some("site-repl-office")); - assert_eq!(merged.rules[0].destination.bucket, "arn:rustfs:replication::office:photos"); - } - - // A peer deleting its replication config must not delete the receiver's reverse rule - // either — the delete travels as `replication-config` with no payload. - #[test] - fn test_merge_incoming_replication_config_survives_peer_delete() { - let merged = merge_incoming_replication_config( - None, - Some(site_repl_config("office")), - &home_office(), - OperatorRuleContract::Derived, - ) - .expect("local site rules must survive a peer delete"); - - assert_eq!(merged.rules.len(), 1); - assert_eq!(merged.rules[0].id.as_deref(), Some("site-repl-office")); - } - - #[test] - fn test_merge_incoming_replication_config_replicates_operator_rules() { - let mut incoming = site_repl_config("home"); - incoming.rules.push(operator_rule("nightly-backup")); - incoming.role = "arn:rustfs:replication::home:photos".to_string(); - - let merged = merge_incoming_replication_config( - Some(incoming), - Some(site_repl_config("office")), - &home_office(), - OperatorRuleContract::Derived, - ) - .expect("merge should produce rules"); - - let ids: Vec<_> = merged.rules.iter().filter_map(|rule| rule.id.as_deref()).collect(); - assert_eq!(ids, vec!["nightly-backup", "site-repl-office"]); - assert_eq!(merged.rules[0].priority, Some(1)); - assert_eq!(merged.rules[1].priority, Some(2)); - assert!( - merged.role.is_empty(), - "a site-replication ARN in `role` belongs to the sender and must not be adopted" - ); - } - - #[test] - fn test_merge_incoming_replication_config_returns_none_when_nothing_remains() { - assert!( - merge_incoming_replication_config( - Some(site_repl_config("home")), - None, - &home_office(), - OperatorRuleContract::Derived - ) - .is_none() - ); - } - fn lc_rule(id: &str, expiry_days: Option, transition_days: Option) -> s3s::dto::LifecycleRule { s3s::dto::LifecycleRule { id: Some(id.to_string()), @@ -16933,113 +10692,6 @@ mod tests { assert_eq!(local_lifecycle_staleness_axis(None, whole), whole, "deletion lower bound"); } - /// Sender-side filter: only the expiry subset leaves this site. MinIO - /// peers install incoming rules verbatim, so a full document would plant - /// this site's transition rules there. - #[test] - fn test_lifecycle_expiry_subset_xml_strips_transitions() { - let full = serialize(&lc_config(vec![lc_rule("mixed", Some(1), Some(30)), lc_rule("t-only", None, Some(7))])) - .expect("serialize full config"); - - let subset = lifecycle_expiry_subset_xml(&full).expect("expiry subset should remain"); - let parsed: s3s::dto::BucketLifecycleConfiguration = deserialize(&subset).expect("subset should parse"); - assert_eq!(rule_ids(&parsed), vec!["mixed"]); - assert!(parsed.rules[0].transitions.is_none(), "transition side must not travel"); - - let transition_only = - serialize(&lc_config(vec![lc_rule("t-only", None, Some(7))])).expect("serialize transition-only config"); - assert!( - lifecycle_expiry_subset_xml(&transition_only).is_none(), - "a transition-only config states 'no expiry rules' (delete semantics)" - ); - assert!(lifecycle_expiry_subset_xml(b"").is_none()); - } - - /// A local parse failure must forward the document unfiltered — mapping - /// it to `None` would delete the peers' replicated expiry rules. - #[test] - fn test_lifecycle_expiry_subset_xml_forwards_unparseable_config() { - let garbage = b""; - assert_eq!(lifecycle_expiry_subset_xml(garbage).as_deref(), Some(garbage.as_slice())); - } - - // `role` is part of the bucket's S3-visible configuration. Repairing a reverse rule must - // drop only a role naming a current peer, never an operator's own role — an IAM role or - // a remote target whose ARN carries an empty region — the same rule the merge path - // applies, so both paths agree on what is ours to rewrite. - #[test] - fn test_replication_role_is_only_cleared_when_it_names_a_peer() { - let sites = home_office(); - assert!(!is_site_replication_role("arn:aws:iam::123456789012:role/replication", &sites)); - assert!(!is_site_replication_role("arn:minio:replication::operator-dep:photos", &sites)); - assert!(is_site_replication_role("arn:rustfs:replication::home:photos", &sites)); - - for operator_role in [ - "arn:aws:iam::123456789012:role/replication", - "arn:minio:replication::operator-dep:photos", - ] { - let mut incoming = site_repl_config("home"); - incoming.role = operator_role.to_string(); - let merged = merge_incoming_replication_config( - Some(incoming), - Some(site_repl_config("office")), - &sites, - OperatorRuleContract::Derived, - ) - .expect("merge should produce rules"); - assert_eq!(merged.role, operator_role, "operator role must survive the merge"); - } - } - - // Rules and targets are keyed off the same ARN. Minting a fresh one while - // `reconcile_site_replication_bucket_targets` preserves a MinIO-era `arn:minio:...` - // target would leave the rule pointing at an ARN no target satisfies. - #[test] - fn test_build_site_replication_config_reuses_configured_arn() { - let mut state = SiteReplicationState { - service_account_access_key: "site-replicator-0".to_string(), - ..Default::default() - }; - state.peers.insert( - "local".to_string(), - PeerInfo { - deployment_id: "local".to_string(), - ..peer("local", "https://local.example.com") - }, - ); - state.peers.insert( - "remote".to_string(), - PeerInfo { - deployment_id: "remote".to_string(), - ..peer("remote", "http://remote.example.com:9000") - }, - ); - let existing = ReplicationConfiguration { - role: String::new(), - rules: vec![build_site_replication_rule( - "arn:minio:replication::remote:photos", - 1, - "site-repl-remote", - )], - }; - - let config = build_site_replication_config( - "photos", - &state, - &PeerInfo { - deployment_id: "local".to_string(), - ..peer("local", "https://local.example.com") - }, - "runtime-iam-secret", - Some(&existing), - ) - .expect("build site replication config") - .expect("a remote peer yields one rule"); - - assert_eq!(config.rules.len(), 1); - assert_eq!(config.rules[0].destination.bucket, "arn:minio:replication::remote:photos"); - } - #[test] fn test_reconcile_site_replication_bucket_targets_upserts_remote_peer_targets() { let mut state = SiteReplicationState { @@ -17353,55 +11005,6 @@ mod tests { assert_eq!(updated.rules[1].priority, Some(1), "the derived rule moves to the lowest free slot"); } - // Issue #1948 review: one pre-contract peer pins an S3 edit to the legacy - // merge; only a cluster where every remote peer answered the probe moves - // to the derived contract. A probe error counts as a pre-contract peer. - #[test] - fn test_operator_rule_contract_requires_every_remote_peer() { - let home = normalize_peer_info(PeerInfo { - endpoint: "https://home.example.com".to_string(), - ..Default::default() - }); - let office = normalize_peer_info(PeerInfo { - endpoint: "https://office.example.com".to_string(), - ..Default::default() - }); - - assert_eq!(operator_rule_contract_from_probes([]), OperatorRuleContract::Derived); - assert_eq!( - operator_rule_contract_from_probes([(&home, Ok(true)), (&office, Ok(true))]), - OperatorRuleContract::Derived - ); - assert_eq!( - operator_rule_contract_from_probes([(&home, Ok(true)), (&office, Ok(false))]), - OperatorRuleContract::Legacy - ); - assert_eq!( - operator_rule_contract_from_probes([(&home, Err(s3_error!(InternalError, "unreachable"))), (&office, Ok(true))]), - OperatorRuleContract::Legacy - ); - } - - // The contract travels with the payload: a pre-contract sender's item has - // no marker and is merged the legacy way; every item this site sends is - // marked, bootstrap snapshots included, so a preserved config is never - // renumbered by a peer on the derived contract. - #[test] - fn test_bucket_meta_items_carry_the_derived_rule_contract() { - let legacy: SRBucketMeta = serde_json::from_str(r#"{"type":"replication-config","bucket":"photos"}"#).expect("item"); - assert!(!legacy.derived_rule_contract); - - let bucket = SRBucketInfo { - bucket: "photos".to_string(), - ..Default::default() - }; - let item = bootstrap_bucket_meta_item(&bucket, "replication-config", None); - assert!(item.derived_rule_contract); - let wire = serde_json::to_value(&item).expect("json"); - assert_eq!(wire["derivedRuleContract"], serde_json::Value::Bool(true)); - assert!(bucket_metadata_snapshot_tombstone(&item, OffsetDateTime::now_utc()).derived_rule_contract); - } - // Issue #1948 review: an owner's `site-repl-user` rule on an operator ARN // is outside the derived shape, so neither the prune nor the reconciler // treats it as theirs; a leftover in the derived shape still is. @@ -17434,44 +11037,6 @@ mod tests { assert_eq!(rules, vec![("site-repl-user", Some(9)), ("site-repl-kept-dep", Some(1))]); } - #[test] - fn test_site_replication_state_does_not_serialize_service_account_secret() { - let state = SiteReplicationState { - service_account_access_key: "site-replicator-0".to_string(), - service_account_secret_key: "do-not-persist".to_string(), - ..Default::default() - }; - - let json = serde_json::to_value(&state).expect("serialize state"); - - assert!(json.get("service_account_secret_key").is_none()); - assert!(json.get("service_account_access_key").is_some()); - } - - #[test] - fn test_pending_rotation_serializes_temporary_secret_until_cleanup() { - let state = SiteReplicationState { - service_account_access_key: SITE_REPLICATOR_SERVICE_ACCOUNT.to_string(), - service_account_secret_key: "do-not-persist".to_string(), - pending_rotation: Some(PendingRotation { - id: "rotation-id".to_string(), - access_key: SITE_REPLICATOR_SERVICE_ACCOUNT.to_string(), - parent: "root".to_string(), - new_secret_key: "temporary-new-secret".to_string(), - secret_candidates: vec!["temporary-old-secret".to_string()], - ..Default::default() - }), - ..Default::default() - }; - - let json = serde_json::to_value(&state).expect("serialize state"); - - assert!(json.get("service_account_secret_key").is_none()); - let pending = json.get("pending_rotation").expect("pending rotation should serialize"); - assert_eq!(pending.get("new_secret_key").and_then(Value::as_str), Some("temporary-new-secret")); - assert!(pending.get("secret_candidates").is_some()); - } - #[test] fn test_pending_remote_peer_ack_completion_ignores_local_peer() { let local = PeerInfo { @@ -17559,21 +11124,6 @@ mod tests { ); } - #[test] - fn test_site_replication_peer_payload_encryption_matches_minio_contract() { - assert!(site_replication_peer_payload_encrypted("/minio/admin/v3/site-replication/peer/join")); - assert!(site_replication_peer_payload_encrypted( - "/minio/admin/v3/site-replication/peer/join?bootstrapToken=token" - )); - // The outbound rewrite no longer produces the legacy `/site-replication/join` - // path; it must not be treated as an encrypted MinIO route. - assert!(!site_replication_peer_payload_encrypted("/minio/admin/v3/site-replication/join")); - assert!(!site_replication_peer_payload_encrypted( - "/minio/admin/v3/site-replication/peer/bucket-meta" - )); - assert!(!site_replication_peer_payload_encrypted("/minio/admin/v3/site-replication/peer/iam-item")); - } - #[test] fn test_parse_peer_join_response_tolerates_empty_minio_success_body() { let fallback = PeerInfo { @@ -17606,42 +11156,6 @@ mod tests { assert!(parse_peer_join_response(b"not-json", fallback).is_err()); } - #[test] - fn test_secret_candidate_retry_only_for_auth_errors() { - assert!(peer_error_may_be_secret_mismatch( - "peer request failed with 403 Forbidden: SignatureDoesNotMatch" - )); - assert!(peer_error_may_be_secret_mismatch("AccessDenied")); - assert!(!peer_error_may_be_secret_mismatch("peer request failed (timeout): deadline elapsed")); - assert!(!peer_error_may_be_secret_mismatch("peer request failed (tls handshake): bad certificate")); - } - - #[test] - fn test_bucket_meta_wire_values_are_base64_encoded_and_legacy_raw_decodes() { - let raw = ""; - let item = encode_bucket_meta_wire_item(SRBucketMeta { - r#type: "version-config".to_string(), - bucket: "photos".to_string(), - versioning: Some(raw.to_string()), - ..Default::default() - }); - - let encoded = item.versioning.expect("encoded versioning config"); - - assert_eq!(decode_bucket_meta_wire_value(&encoded), raw.as_bytes()); - assert_eq!(decode_bucket_meta_wire_value(raw), raw.as_bytes()); - assert_ne!(encoded, raw); - } - - #[test] - fn test_metainfo_bucket_config_values_are_base64_encoded() { - let raw = br#""#; - - assert_eq!(raw_config_to_base64(raw), Some(BASE64_STANDARD.encode(raw))); - assert_ne!(raw_config_to_base64(raw), raw_config_to_string(raw)); - assert_eq!(raw_config_to_base64(&[]), None); - } - #[test] fn test_stale_update_detects_older_incoming_timestamp() { let local = OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(20); @@ -17654,49 +11168,6 @@ mod tests { assert!(!is_stale_update(local, None)); } - #[test] - fn test_reconcile_site_replication_bucket_targets_allows_peer_on_same_port_as_local_console() { - with_var("RUSTFS_CONSOLE_ADDRESS", Some(":9001"), || { - let mut state = SiteReplicationState { - service_account_access_key: "site-replicator-0".to_string(), - service_account_secret_key: "secret".to_string(), - ..Default::default() - }; - state.peers.insert( - "local".to_string(), - PeerInfo { - deployment_id: "local".to_string(), - ..peer("local", "https://local.example.com:9000") - }, - ); - state.peers.insert( - "remote".to_string(), - PeerInfo { - deployment_id: "remote".to_string(), - ..peer("remote", "https://remote.example.com:9001") - }, - ); - - let targets = reconcile_site_replication_bucket_targets( - BucketTargets::default(), - "photos", - &state, - &PeerInfo { - deployment_id: "local".to_string(), - ..peer("local", "https://local.example.com:9000") - }, - None, - "secret", - ) - .expect("peer using same numeric port as local console should remain valid"); - - assert_eq!(targets.targets.len(), 1); - let target = &targets.targets[0]; - assert_eq!(target.endpoint, "remote.example.com:9001"); - assert!(target.secure); - }); - } - #[test] fn test_apply_state_edit_req_only_updates_ilm_expiry_flags() { let mut state = SiteReplicationState::default(); @@ -17801,11 +11272,6 @@ mod tests { assert!(filtered.policies.is_empty()); } - #[test] - fn test_hash_client_secret_matches_minio_style_base64url_sha256() { - assert_eq!(hash_client_secret(Some("secret")), "K7gNU3sdo-OL0wNhqoVWhr3g6s1xYv72ol_pe_Unols"); - } - #[test] fn test_ldap_settings_from_kvs_reads_minio_style_keys() { let kvs = rustfs_config::server_config::KVS(vec![ @@ -17847,77 +11313,6 @@ mod tests { assert!(ldap_configs.configs.contains_key("default")); } - #[test] - fn test_site_replication_peer_client_cache_hit_generation_mismatch_returns_none() { - let cache = Some(SiteReplicationPeerClientCache { - generation: 7, - entry: SiteReplicationPeerClientCacheEntry::Failed("cached error".to_string()), - }); - - assert!(site_replication_peer_client_cache_hit(&cache, 8).is_none()); - } - - #[test] - fn test_site_replication_peer_client_cache_hit_returns_cached_ready_client() { - let cache = Some(SiteReplicationPeerClientCache { - generation: 7, - entry: SiteReplicationPeerClientCacheEntry::Ready(reqwest::Client::new()), - }); - - site_replication_peer_client_cache_hit(&cache, 7) - .expect("cache hit expected") - .expect("ready cache entry should return cached client"); - } - - #[test] - fn test_site_replication_peer_client_cache_hit_returns_cached_error() { - let cache = Some(SiteReplicationPeerClientCache { - generation: 7, - entry: SiteReplicationPeerClientCacheEntry::Failed("cached error".to_string()), - }); - - let err = site_replication_peer_client_cache_hit(&cache, 7) - .expect("cache hit expected") - .expect_err("error cache entry should return error"); - assert!(err.to_string().contains("cached error"), "expected cached error detail, got: {}", err); - } - - #[tokio::test] - #[serial] - async fn test_site_replication_peer_client_rebuilds_when_generation_changes() { - let previous_generation = current_outbound_tls_generation().0; - let previous_cache = { - let mut cache = SITE_REPLICATION_PEER_CLIENT.lock().await; - let snapshot = cache.clone(); - *cache = None; - snapshot - }; - - set_test_outbound_tls_generation(101); - site_replication_peer_client() - .await - .expect("initial client build should succeed"); - let cache = SITE_REPLICATION_PEER_CLIENT.lock().await; - let cached = cache.as_ref().expect("cache should be populated"); - assert_eq!(cached.generation, 101); - assert!(matches!(cached.entry, SiteReplicationPeerClientCacheEntry::Ready(_))); - drop(cache); - - set_test_outbound_tls_generation(102); - site_replication_peer_client() - .await - .expect("new generation should rebuild client"); - let cache = SITE_REPLICATION_PEER_CLIENT.lock().await; - let cached = cache.as_ref().expect("cache should be populated"); - assert_eq!(cached.generation, 102); - assert!(matches!(cached.entry, SiteReplicationPeerClientCacheEntry::Ready(_))); - - drop(cache); - set_test_outbound_tls_generation(previous_generation); - let mut cache = SITE_REPLICATION_PEER_CLIENT.lock().await; - *cache = previous_cache; - } - #[test] fn test_site_repl_netperf_reports_unsupported_without_measurements() { let result = unsupported_site_netperf_result("https://peer.example.com".to_string()); @@ -18182,8 +11577,8 @@ mod tests { }; let dep_a_xml = site_config_xml("dep-b"); let dep_b_xml = site_config_xml("dep-a"); - let dep_a_b64 = BASE64_STANDARD.encode(dep_a_xml.as_bytes()); - let dep_b_b64 = BASE64_STANDARD.encode(dep_b_xml.as_bytes()); + let dep_a_b64 = BASE64_STANDARD.encode_to_string(dep_a_xml.as_bytes()); + let dep_b_b64 = BASE64_STANDARD.encode_to_string(dep_b_xml.as_bytes()); // Both sites present the complete config in base64 wire form → NOT a mismatch. assert_eq!( @@ -18229,31 +11624,6 @@ mod tests { ); } - // BUG1: an explicit Disable is a meaningful state and must survive the Unknown -> Enable promotion. - #[test] - fn test_mark_peers_sync_enabled_preserves_disable() { - let mut peers = BTreeMap::new(); - peers.insert( - "a".to_string(), - PeerInfo { - deployment_id: "a".to_string(), - sync_state: SyncStatus::Unknown, - ..peer("a", "https://a.example.com") - }, - ); - peers.insert( - "b".to_string(), - PeerInfo { - deployment_id: "b".to_string(), - sync_state: SyncStatus::Disable, - ..peer("b", "https://b.example.com") - }, - ); - mark_unknown_peer_sync_enabled(&mut peers); - assert_eq!(peers["a"].sync_state, SyncStatus::Enable, "Unknown must be promoted to Enable"); - assert_eq!(peers["b"].sync_state, SyncStatus::Disable, "explicit Disable must be preserved"); - } - #[test] fn test_join_peer_sync_state_waits_for_deferred_commit() { let mut peers = BTreeMap::from([("a".to_string(), peer("a", "https://a.example.com"))]); @@ -18598,45 +11968,6 @@ mod tests { assert!(info.pending_operation.is_none()); } - /// rustfs/rustfs#5963: `replicate info` reported a healthy cluster while - /// every peer operation was failing. The health it used to omit now rides - /// along, and a healthy site still serializes without the new fields. - #[test] - fn site_replication_info_health_fields_are_absent_when_healthy() { - let healthy = SiteReplicationInfo { - enabled: true, - name: "site-a".to_string(), - sites: vec![peer("site-a", "https://site-a.example.com")], - service_account_access_key: SITE_REPLICATOR_SERVICE_ACCOUNT.to_string(), - api_version: Some(SITE_REPL_API_VERSION.to_string()), - retry_stats: None, - pending_operation: None, - }; - let value = serde_json::to_value(&healthy).expect("serialize info"); - assert!(value.get("retryStats").is_none(), "a healthy site must not grow fields: {value}"); - assert!(value.get("pendingOperation").is_none(), "a healthy site must not grow fields: {value}"); - - let degraded = SiteReplicationInfo { - retry_stats: Some(SRRetryStats { - pending: 1, - failed: 4, - last_error: "site replication is not enabled".to_string(), - api_version: Some(SITE_REPL_API_VERSION.to_string()), - }), - ..healthy - }; - let value = serde_json::to_value(°raded).expect("serialize info"); - assert_eq!( - value.pointer("/retryStats/failed").and_then(Value::as_u64), - Some(4), - "a source site whose peer rejects everything must say so in `info`" - ); - assert_eq!( - value.pointer("/retryStats/lastError").and_then(Value::as_str), - Some("site replication is not enabled") - ); - } - // Fix 5: remove --all must purge local state unconditionally even when peer errors occur #[test] fn test_remove_all_purges_local_state_unconditionally() { @@ -18697,44 +12028,6 @@ mod tests { ); } - // Fix 6: ensure_site_replication_bucket_replication_config must reconcile rather than - // early-return so that a bucket propagated to the second site gets a rule back to the first. - #[test] - fn test_reconcile_adds_missing_peer_rules_to_existing_config() { - // Start with a config that has only rule for dep-b (first site's initial config) - let rule_b = build_site_replication_rule("arn:rustfs:replication::dep-b:bucket", 1, "site-repl-dep-b"); - let rule_c = build_site_replication_rule("arn:rustfs:replication::dep-c:bucket", 2, "site-repl-dep-c"); - - let mut existing_rules = vec![rule_b.clone()]; - - // Desired config has rules for both dep-b and dep-c (3-site setup) - let desired_rules = vec![rule_b, rule_c]; - - // Simulate the reconcile: collect existing site-repl rule IDs - let existing_ids: std::collections::HashSet = existing_rules - .iter() - .filter_map(|r| r.id.as_deref()) - .filter(|id| id.starts_with("site-repl-")) - .map(String::from) - .collect(); - - let mut added = false; - for rule in &desired_rules { - let rid = rule.id.as_deref().unwrap_or(""); - if !existing_ids.contains(rid) { - existing_rules.push(rule.clone()); - added = true; - } - } - - assert!(added, "missing rule should have been added"); - assert_eq!(existing_rules.len(), 2, "should now have rules for both peers"); - - let rule_ids: Vec<&str> = existing_rules.iter().filter_map(|r| r.id.as_deref()).collect(); - assert!(rule_ids.contains(&"site-repl-dep-b")); - assert!(rule_ids.contains(&"site-repl-dep-c")); - } - #[test] fn site_resync_summary_reports_partial_failure_and_clamps_counters() { let now = OffsetDateTime::now_utc(); diff --git a/rustfs/src/admin/handlers/system.rs b/rustfs/src/admin/handlers/system.rs index 3715b79ac..250304c85 100644 --- a/rustfs/src/admin/handlers/system.rs +++ b/rustfs/src/admin/handlers/system.rs @@ -13,7 +13,7 @@ // limitations under the License. use super::{cluster_snapshot, metrics}; -use crate::admin::auth::validate_admin_request; +use crate::admin::auth::authorize_admin_request; use crate::admin::handlers::account::{ACCOUNT_INFO_ROUTE, ACCOUNT_PASSWORD_ROUTE}; use crate::admin::handlers::mfa::{ACCOUNT_MFA_ROUTE, MFA_CHALLENGE_ROUTE, USER_MFA_ROUTE}; use crate::admin::route_policy::{ @@ -31,9 +31,8 @@ use crate::admin::storage_api::cluster::{ CapabilityState, CapabilityStatus, ObservabilitySnapshotProvider, TopologySnapshot, TopologySnapshotProvider, }; use crate::admin::storage_api::storageclass as storage_class_contract; -use crate::auth::{check_key_valid, get_session_token}; use crate::runtime_capabilities::{EndpointTopologySnapshotProvider, RustFsObservabilitySnapshotProvider}; -use crate::server::{ADMIN_PREFIX, RemoteAddr}; +use crate::server::ADMIN_PREFIX; use crate::workload_admission::workload_admission_registry_snapshot; use http::{HeaderMap, HeaderValue}; use hyper::{Method, StatusCode}; @@ -248,10 +247,10 @@ fn request_graceful_shutdown() {} #[async_trait::async_trait] impl Operation for ServiceHandle { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials.as_ref() else { + if req.credentials.is_none() { log_system_request_rejected!("service_handle", "missing_credentials"); return Err(s3_error!(InvalidRequest, "get cred failed")); - }; + } let Some(action) = service_action_from_uri(&req.uri) else { log_system_request_rejected!("service_handle", "invalid_action"); @@ -265,10 +264,7 @@ impl Operation for ServiceHandle { ServiceAction::Freeze | ServiceAction::Unfreeze => AdminAction::ServiceFreezeAdminAction, }; - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - let remote_addr = req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)); - validate_admin_request(&req.headers, &cred, owner, false, vec![Action::AdminAction(admin_action)], remote_addr).await?; + authorize_admin_request(&req, vec![Action::AdminAction(admin_action)]).await?; let response = match action { ServiceAction::Restart => { @@ -362,22 +358,11 @@ struct ServerUpdateStatus { #[async_trait::async_trait] impl Operation for UpdateHandler { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials.as_ref() else { + if req.credentials.is_none() { log_system_request_rejected!("server_update", "missing_credentials"); return Err(s3_error!(InvalidRequest, "get cred failed")); - }; - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - let remote_addr = req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)); - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::ServerUpdateAdminAction)], - remote_addr, - ) - .await?; + } + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::ServerUpdateAdminAction)]).await?; // MinIO's server-update downloads and swaps the binary in place. RustFS // intentionally does not implement in-process self-update: binaries are @@ -459,24 +444,12 @@ fn bitrot_selftest_status_str() -> &'static str { #[async_trait::async_trait] impl Operation for ServerInfoHandler { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { log_system_request_rejected!("query_server_info", "missing_credentials"); return Err(s3_error!(InvalidRequest, "get cred failed")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - let remote_addr = req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)); - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::ServerInfoAdminAction)], - remote_addr, - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::ServerInfoAdminAction)]).await?; let usecase = default_admin_usecase(); let info = usecase @@ -536,22 +509,11 @@ impl Operation for InspectDataHandler { use crate::admin::storage_api::object::StorageObjectOptions; use tokio::io::AsyncReadExt; - let Some(input_cred) = req.credentials.as_ref() else { + if req.credentials.is_none() { log_system_request_rejected!("inspect_data", "missing_credentials"); return Err(s3_error!(InvalidRequest, "get cred failed")); - }; - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - let remote_addr = req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)); - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::InspectDataAction)], - remote_addr, - ) - .await?; + } + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::InspectDataAction)]).await?; // MinIO's inspect-data exports a signed archive of raw drive files for a // `volume`/`file` glob. RustFS erasure-codes and (optionally) encrypts @@ -608,24 +570,12 @@ pub struct StorageInfoHandler {} #[async_trait::async_trait] impl Operation for StorageInfoHandler { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { log_system_request_rejected!("query_storage_info", "missing_credentials"); return Err(s3_error!(InvalidRequest, "get cred failed")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - let remote_addr = req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)); - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::StorageInfoAdminAction)], - remote_addr, - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::StorageInfoAdminAction)]).await?; let usecase = default_admin_usecase(); let info = usecase.execute_query_storage_info().await.map_err(S3Error::from)?; @@ -1178,16 +1128,12 @@ fn summarize_named_capability_statuses( #[async_trait::async_trait] impl Operation for RuntimeCapabilitiesHandler { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { log_system_request_rejected!("runtime_capabilities", "missing_credentials"); return Err(s3_error!(InvalidRequest, "get cred failed")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - let remote_addr = req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)); - validate_admin_request(&req.headers, &cred, owner, false, runtime_capabilities_gate_actions(), remote_addr).await?; + authorize_admin_request(&req, runtime_capabilities_gate_actions()).await?; let response = build_runtime_capabilities_response().await.map_err(|err| { log_system_request_failed!("runtime_capabilities", "build_runtime_capabilities_failed", err); @@ -1220,16 +1166,12 @@ pub(crate) fn data_usage_info_gate_actions() -> Vec { #[async_trait::async_trait] impl Operation for DataUsageInfoHandler { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { log_system_request_rejected!("query_data_usage_info", "missing_credentials"); return Err(s3_error!(InvalidRequest, "get cred failed")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - let remote_addr = req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)); - validate_admin_request(&req.headers, &cred, owner, false, data_usage_info_gate_actions(), remote_addr).await?; + authorize_admin_request(&req, data_usage_info_gate_actions()).await?; let usecase = default_admin_usecase(); let info = usecase.execute_query_data_usage_info().await.map_err(S3Error::from)?; @@ -1250,10 +1192,11 @@ impl Operation for DataUsageInfoHandler { #[cfg(test)] mod tests { use super::{ - MANUAL_TRANSITION_JOB_ROUTE, MANUAL_TRANSITION_RUN_ROUTE, OBSERVABILITY_SUMMARY_RESOLVED, RuntimeCapabilitiesHandler, - SITE_REPLICATION_EDIT_ROUTE, SITE_REPLICATION_INFO_ROUTE, SITE_REPLICATION_REPAIR_ROUTE, - SITE_REPLICATION_REPAIR_STATUS_ROUTE, SITE_REPLICATION_RESYNC_ROUTE, ServerInfoResponse, TOPOLOGY_SNAPSHOT_NOT_AVAILABLE, - TOPOLOGY_SUMMARY_RESOLVED, admin_route_capability_from_inventory, build_runtime_capabilities_response, + DataUsageInfoHandler, InspectDataHandler, MANUAL_TRANSITION_JOB_ROUTE, MANUAL_TRANSITION_RUN_ROUTE, + OBSERVABILITY_SUMMARY_RESOLVED, RuntimeCapabilitiesHandler, SITE_REPLICATION_EDIT_ROUTE, SITE_REPLICATION_INFO_ROUTE, + SITE_REPLICATION_REPAIR_ROUTE, SITE_REPLICATION_REPAIR_STATUS_ROUTE, SITE_REPLICATION_RESYNC_ROUTE, ServerInfoHandler, + ServerInfoResponse, ServiceHandle, StorageInfoHandler, TOPOLOGY_SNAPSHOT_NOT_AVAILABLE, TOPOLOGY_SUMMARY_RESOLVED, + UpdateHandler, admin_route_capability_from_inventory, build_runtime_capabilities_response, build_runtime_capabilities_summary, data_usage_info_gate_actions, runtime_capabilities_gate_actions, system_admin_discovery, }; @@ -1908,4 +1851,123 @@ mod tests { .contains(TOPOLOGY_SUMMARY_RESOLVED) ); } + + fn credential_less_request(method: Method, uri: &'static str) -> S3Request { + S3Request { + input: Body::empty(), + method, + uri: Uri::from_static(uri), + headers: HeaderMap::new(), + extensions: Extensions::new(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + } + } + + async fn assert_missing_credentials(operation: &dyn Operation, method: Method, uri: &'static str) { + let err = operation + .call(credential_less_request(method, uri), Params::new()) + .await + .expect_err("a system admin request without credentials must fail"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some("get cred failed")); + } + + /// Every system handler pre-checks credentials before delegating to the + /// shared admin gate, so the credential-less response stays byte-identical to + /// what it was before the deduplication. `ServiceHandle` in particular must + /// keep rejecting on missing credentials *before* it parses the requested + /// service action (rustfs/backlog#1829). + #[tokio::test] + async fn system_handlers_keep_their_missing_credentials_response() { + assert_missing_credentials(&ServiceHandle {}, Method::POST, "/rustfs/admin/v3/service?action=restart").await; + assert_missing_credentials(&ServiceHandle {}, Method::POST, "/rustfs/admin/v3/service").await; + assert_missing_credentials(&UpdateHandler {}, Method::POST, "/rustfs/admin/v3/update").await; + assert_missing_credentials(&ServerInfoHandler {}, Method::GET, "/rustfs/admin/v3/info").await; + assert_missing_credentials(&InspectDataHandler {}, Method::GET, "/rustfs/admin/v3/inspect-data").await; + assert_missing_credentials(&StorageInfoHandler {}, Method::GET, "/rustfs/admin/v3/storageinfo").await; + assert_missing_credentials(&RuntimeCapabilitiesHandler {}, Method::GET, "/rustfs/admin/v4/runtime/capabilities").await; + assert_missing_credentials(&DataUsageInfoHandler {}, Method::GET, "/rustfs/admin/v3/datausageinfo").await; + } + + fn source_block<'a>(production: &'a str, marker: &str) -> &'a str { + let block = production + .split_once(marker) + .unwrap_or_else(|| panic!("{marker} should exist")) + .1; + let end = [ + "\npub struct ", + "\nasync fn ", + "\npub(crate) async fn ", + "\npub fn ", + "\npub(crate) fn ", + "\nfn ", + "\nmod ", + "\n#[cfg(test)]", + ] + .into_iter() + .filter_map(|boundary| block.find(boundary)) + .min() + .unwrap_or(block.len()); + &block[..end] + } + + /// Pins the gate wiring: each system handler authorizes through exactly one + /// `authorize_admin_request` call carrying the same action vector it used + /// before the deduplication. The two gate-action helpers must be passed + /// through by name so the vectors pinned by the tests above keep governing + /// the live gate (rustfs/backlog#1829). + #[test] + fn system_handlers_use_the_shared_admin_gate_with_their_actions() { + let production = include_str!("system.rs") + .split("\n#[cfg(test)]\nmod ") + .next() + .expect("production source must precede the test module"); + + let service_tokens = [ + "AdminAction::ServiceRestartAdminAction", + "AdminAction::ServiceStopAdminAction", + "AdminAction::ServiceFreezeAdminAction", + ]; + let update_tokens = ["AdminAction::ServerUpdateAdminAction"]; + let server_info_tokens = ["AdminAction::ServerInfoAdminAction"]; + let inspect_data_tokens = ["AdminAction::InspectDataAction"]; + let storage_info_tokens = ["AdminAction::StorageInfoAdminAction"]; + let runtime_capabilities_tokens = ["runtime_capabilities_gate_actions()"]; + let data_usage_info_tokens = ["data_usage_info_gate_actions()"]; + + for (handler, inline_admin_actions, tokens) in [ + ("ServiceHandle", 1usize, service_tokens.as_slice()), + ("UpdateHandler", 1, update_tokens.as_slice()), + ("ServerInfoHandler", 1, server_info_tokens.as_slice()), + ("InspectDataHandler", 1, inspect_data_tokens.as_slice()), + ("StorageInfoHandler", 1, storage_info_tokens.as_slice()), + ("RuntimeCapabilitiesHandler", 0, runtime_capabilities_tokens.as_slice()), + ("DataUsageInfoHandler", 0, data_usage_info_tokens.as_slice()), + ] { + let block = source_block(production, &format!("impl Operation for {handler}")); + assert_eq!( + block.matches("authorize_admin_request(").count(), + 1, + "{handler} must use exactly one shared gate" + ); + assert_eq!( + block.matches("Action::AdminAction(").count(), + inline_admin_actions, + "{handler} must preserve its exact inline action-vector length" + ); + for token in tokens { + assert!(block.contains(token), "{handler} must authorize with {token}"); + } + assert!( + !block.contains("let cred = authorize_admin_request("), + "{handler} does not consume the authenticated credentials" + ); + } + + assert!(!production.contains("check_key_valid(get_session_token")); + assert!(!production.contains("validate_admin_request(")); + } } diff --git a/rustfs/src/admin/handlers/table_catalog/mod.rs b/rustfs/src/admin/handlers/table_catalog/mod.rs index a51e356ea..5a8e94033 100644 --- a/rustfs/src/admin/handlers/table_catalog/mod.rs +++ b/rustfs/src/admin/handlers/table_catalog/mod.rs @@ -17,6 +17,7 @@ use crate::admin::runtime_sources::default_admin_usecase; use crate::admin::storage_api::access::{ReqInfo, authorize_internal_object_request}; use crate::admin::storage_api::bucket::metadata::table_catalog_path_hash; use crate::admin::storage_api::runtime::ECStore; +use crate::admin::utils::empty_response; use crate::admin::{ auth::{AdminResourceScope, validate_admin_action_with_bucket_object_for_iam}, router::{AdminOperation, Operation, S3Router}, @@ -969,10 +970,6 @@ fn build_sensitive_json_response(status: StatusCode, body: &T) -> Ok(response) } -fn empty_response(status: StatusCode) -> S3Response<(StatusCode, Body)> { - S3Response::new((status, Body::default())) -} - fn duration_millis_u64(duration: StdDuration) -> u64 { u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) } diff --git a/rustfs/src/admin/handlers/tier.rs b/rustfs/src/admin/handlers/tier.rs index 81b8dd7d9..f41b742c6 100644 --- a/rustfs/src/admin/handlers/tier.rs +++ b/rustfs/src/admin/handlers/tier.rs @@ -24,11 +24,10 @@ use crate::admin::storage_api::tier::{ use crate::{ admin::runtime_sources::{current_daily_tier_stats, current_notification_system, current_tier_config_handle}, admin::{ - auth::validate_admin_request, + auth::authorize_admin_request, router::{AdminOperation, Operation, S3Router}, }, - auth::{check_key_valid, get_session_token}, - server::{ADMIN_PREFIX, RemoteAddr}, + server::ADMIN_PREFIX, }; use http::{HeaderMap, StatusCode, Uri}; use hyper::Method; @@ -220,22 +219,11 @@ impl Operation for AddTier { } }; - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::SetTierAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::SetTierAction)]).await?; let mut input = req.input; let body = match input.store_all_limited(MAX_ADMIN_REQUEST_BODY_SIZE).await { @@ -436,22 +424,11 @@ impl Operation for EditTier { } }; - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::SetTierAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::SetTierAction)]).await?; let mut input = req.input; let body = match input.store_all_limited(MAX_ADMIN_REQUEST_BODY_SIZE).await { @@ -544,22 +521,11 @@ impl Operation for ListTiers { } }; - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "get cred failed")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::ListTierAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::ListTierAction)]).await?; let tier_config_mgr_handle = current_tier_config_handle(); let tier_config_mgr = tier_config_mgr_handle.read().await; @@ -589,22 +555,11 @@ impl Operation for RemoveTier { } }; - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::SetTierAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::SetTierAction)]).await?; let mut force: bool = false; let force_str = query.force.clone().unwrap_or_default(); @@ -671,22 +626,11 @@ pub struct VerifyTier {} #[async_trait::async_trait] impl Operation for VerifyTier { async fn call(&self, req: S3Request, params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::ListTierAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::ListTierAction)]).await?; let tier = resolve_tier_name(&req.uri, ¶ms)?; let tier_config_mgr_handle = current_tier_config_handle(); @@ -705,22 +649,11 @@ pub struct GetTierInfo {} #[async_trait::async_trait] impl Operation for GetTierInfo { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "get cred failed")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::ListTierAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::ListTierAction)]).await?; let query = { if let Some(query) = req.uri.query() { @@ -836,22 +769,11 @@ impl Operation for ClearTier { async fn call(&self, req: S3Request, params: Params<'_, '_>) -> S3Result> { let query = parse_clear_tier_query(&req.uri)?; - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::SetTierAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::SetTierAction)]).await?; let mut force: bool = false; let force_str = query.force; @@ -1186,4 +1108,97 @@ mod tests { stats.insert("ARCHIVE".to_string(), archive); stats } + + fn credential_less_request(method: Method, uri: &'static str) -> S3Request { + S3Request { + input: Body::empty(), + method, + uri: Uri::from_static(uri), + headers: HeaderMap::new(), + extensions: http::Extensions::new(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + } + } + + async fn assert_missing_credentials(operation: &dyn Operation, method: Method, uri: &'static str, message: &str) { + let err = operation + .call(credential_less_request(method, uri), Params::new()) + .await + .expect_err("a tier admin request without credentials must fail"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some(message)); + } + + /// The shared gate reports "get cred failed"; the per-handler pre-check keeps + /// the message each endpoint has always returned (rustfs/backlog#1829). + #[tokio::test] + async fn tier_handlers_keep_their_missing_credentials_response() { + assert_missing_credentials(&AddTier {}, Method::PUT, "/rustfs/admin/v3/tier", "authentication required").await; + assert_missing_credentials(&EditTier {}, Method::POST, "/rustfs/admin/v3/tier/WARM", "authentication required").await; + assert_missing_credentials(&ListTiers {}, Method::GET, "/rustfs/admin/v3/tiers", "get cred failed").await; + assert_missing_credentials(&RemoveTier {}, Method::DELETE, "/rustfs/admin/v3/tier/WARM", "authentication required").await; + assert_missing_credentials(&VerifyTier {}, Method::GET, "/rustfs/admin/v3/tier/WARM", "authentication required").await; + assert_missing_credentials(&GetTierInfo {}, Method::GET, "/rustfs/admin/v3/tier-stats", "get cred failed").await; + assert_missing_credentials(&ClearTier {}, Method::DELETE, "/rustfs/admin/v3/tiers", "authentication required").await; + } + + fn source_block<'a>(production: &'a str, marker: &str) -> &'a str { + let block = production + .split_once(marker) + .unwrap_or_else(|| panic!("{marker} should exist")) + .1; + let end = ["\npub struct ", "\nfn ", "\n#[derive(", "\n#[cfg(test)]"] + .into_iter() + .filter_map(|boundary| block.find(boundary)) + .min() + .unwrap_or(block.len()); + &block[..end] + } + + fn assert_shared_gate_wiring(block: &str, item: &str, actions: &[&str], binds_credentials: bool) { + assert_eq!( + block.matches("authorize_admin_request(").count(), + 1, + "{item} must use exactly one shared gate" + ); + assert_eq!( + block.matches("Action::AdminAction(").count(), + actions.len(), + "{item} must preserve its exact action-vector length" + ); + for action in actions { + assert!(block.contains(&format!("AdminAction::{action}")), "{item} must authorize with {action}"); + } + assert_eq!( + block.contains("let cred = authorize_admin_request("), + binds_credentials, + "{item} credential binding must match its payload-processing contract" + ); + } + + #[test] + fn tier_handlers_use_the_shared_admin_gate_with_their_actions() { + let production = include_str!("tier.rs") + .split("\n#[cfg(test)]\n") + .next() + .expect("production source must precede tests"); + + for (handler, action) in [ + ("AddTier", "SetTierAction"), + ("EditTier", "SetTierAction"), + ("ListTiers", "ListTierAction"), + ("RemoveTier", "SetTierAction"), + ("VerifyTier", "ListTierAction"), + ("GetTierInfo", "ListTierAction"), + ("ClearTier", "SetTierAction"), + ] { + let block = source_block(production, &format!("impl Operation for {handler}")); + assert_shared_gate_wiring(block, handler, &[action], false); + } + + assert!(!production.contains("check_key_valid(get_session_token")); + } } diff --git a/rustfs/src/admin/handlers/user.rs b/rustfs/src/admin/handlers/user.rs index f1e1ef099..a9d4ee4f0 100644 --- a/rustfs/src/admin/handlers/user.rs +++ b/rustfs/src/admin/handlers/user.rs @@ -17,7 +17,7 @@ use super::{account_info, group, service_account, user_iam, user_lifecycle, user use crate::{ admin::runtime_sources::current_action_credentials, admin::{ - auth::validate_admin_request, + auth::{authorize_admin_request, validate_admin_request}, handlers::site_replication::site_replication_iam_change_hook, router::{AdminOperation, Operation, S3Router}, utils::{encode_compatible_admin_payload, has_space_be, read_compatible_admin_body}, @@ -360,7 +360,7 @@ impl Operation for SetUserStatus { return Err(s3_error!(InvalidArgument, "access key is empty")); } - let Some(input_cred) = req.credentials else { + let Some(input_cred) = req.credentials.as_ref() else { return Err(s3_error!(InvalidRequest, "authentication required")); }; @@ -368,18 +368,7 @@ impl Operation for SetUserStatus { return Err(s3_error!(InvalidArgument, "cannot change the status of the current user")); } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::EnableUserAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::EnableUserAdminAction)]).await?; let status = AccountStatus::try_from(query.status.as_deref().unwrap_or_default()) .map_err(|e| S3Error::with_message(S3ErrorCode::InvalidArgument, e))?; @@ -439,22 +428,11 @@ pub struct ListUsers {} #[async_trait::async_trait] impl Operation for ListUsers { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::ListUsersAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + let cred = authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::ListUsersAdminAction)]).await?; let query = { if let Some(query) = req.uri.query() { @@ -499,22 +477,11 @@ pub struct RemoveUser {} #[async_trait::async_trait] impl Operation for RemoveUser { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "authentication required")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::DeleteUserAdminAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + let cred = authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::DeleteUserAdminAction)]).await?; let query = { if let Some(query) = req.uri.query() { @@ -695,22 +662,11 @@ pub struct ExportIam {} #[async_trait::async_trait] impl Operation for ExportIam { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { - let Some(input_cred) = req.credentials else { + if req.credentials.is_none() { return Err(s3_error!(InvalidRequest, "get cred failed")); - }; + } - let (cred, owner) = - check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; - - validate_admin_request( - &req.headers, - &cred, - owner, - false, - vec![Action::AdminAction(AdminAction::ExportIAMAction)], - req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), - ) - .await?; + authorize_admin_request(&req, vec![Action::AdminAction(AdminAction::ExportIAMAction)]).await?; let Ok(iam_store) = crate::admin::runtime_sources::current_ready_iam_handle() else { return Err(s3_error!(InvalidRequest, "iam not init")); @@ -1373,19 +1329,78 @@ impl Operation for ImportIam { #[cfg(test)] mod tests { use super::{ - GROUP_POLICY_MAPPING_USER_TYPE, MAX_IAM_IMPORT_EXPANDED_SIZE, MAX_IAM_IMPORT_SIZE, - SERVICE_ACCOUNT_ACCESS_KEY_MISMATCH_ERROR, SERVICE_ACCOUNT_PARENT_SCOPE_ERROR, add_user_targets_requester_parent, - imported_service_account_access_key_failure, imported_service_account_parent_allowed, - imported_service_account_parent_scope_failure, imported_service_account_status, map_add_user_create_error, - read_import_member, should_check_deny_only, should_reject_group_import_name, should_restore_group_as_disabled, + ExportIam, GROUP_POLICY_MAPPING_USER_TYPE, HeaderMap, ListUsers, MAX_IAM_IMPORT_EXPANDED_SIZE, MAX_IAM_IMPORT_SIZE, + Operation, Params, RemoveUser, SERVICE_ACCOUNT_ACCESS_KEY_MISMATCH_ERROR, SERVICE_ACCOUNT_PARENT_SCOPE_ERROR, + SetUserStatus, add_user_targets_requester_parent, imported_service_account_access_key_failure, + imported_service_account_parent_allowed, imported_service_account_parent_scope_failure, imported_service_account_status, + map_add_user_create_error, read_import_member, should_check_deny_only, should_reject_group_import_name, + should_restore_group_as_disabled, }; + use http::{Extensions, Method, Uri}; use rustfs_credentials::{Credentials, IAM_POLICY_CLAIM_NAME_SA}; use rustfs_iam::error::Error as IamError; use rustfs_madmin::user::SRSvcAccCreate; - use s3s::S3ErrorCode; + use s3s::{Body, S3ErrorCode, S3Request}; use serde_json::Value; use std::collections::HashMap; + fn credential_less_request(method: Method, uri: &'static str) -> S3Request { + S3Request { + input: Body::empty(), + method, + uri: Uri::from_static(uri), + headers: HeaderMap::new(), + extensions: Extensions::new(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + } + } + + async fn assert_missing_credentials(operation: &dyn Operation, method: Method, uri: &'static str, message: &str) { + let err = operation + .call(credential_less_request(method, uri), Params::new()) + .await + .expect_err("a user admin request without credentials must fail"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some(message)); + } + + fn source_block<'a>(production: &'a str, marker: &str) -> &'a str { + let block = production + .split_once(marker) + .unwrap_or_else(|| panic!("{marker} should exist")) + .1; + let end = ["\npub struct ", "\nfn ", "\nconst ", "\n#[cfg(test)]"] + .into_iter() + .filter_map(|boundary| block.find(boundary)) + .min() + .unwrap_or(block.len()); + &block[..end] + } + + fn assert_shared_gate_wiring(block: &str, item: &str, actions: &[&str], binds_credentials: bool) { + assert_eq!( + block.matches("authorize_admin_request(").count(), + 1, + "{item} must use exactly one shared gate" + ); + assert_eq!( + block.matches("Action::AdminAction(").count(), + actions.len(), + "{item} must preserve its exact action-vector length" + ); + for action in actions { + assert!(block.contains(&format!("AdminAction::{action}")), "{item} must authorize with {action}"); + } + assert_eq!( + block.contains("let cred = authorize_admin_request("), + binds_credentials, + "{item} credential binding must match its payload-processing contract" + ); + } + #[test] fn add_user_maps_duplicate_access_keys_to_s3_errors() { let err = map_add_user_create_error(IamError::AccessKeyAlreadyExists); @@ -1777,4 +1792,64 @@ mod tests { "expanded budget must not be smaller than the compressed upload cap" ); } + + /// The shared admin gate answers a credential-less request with `InvalidRequest "get cred failed"`. + /// Each converted handler keeps a pre-check ahead of the gate so its own wire response survives + /// the refactor unchanged. + #[tokio::test] + async fn user_handlers_keep_their_missing_credentials_response() { + assert_missing_credentials( + &SetUserStatus {}, + Method::PUT, + "/rustfs/admin/v3/set-user-status?accessKey=alice", + "authentication required", + ) + .await; + assert_missing_credentials(&ListUsers {}, Method::GET, "/rustfs/admin/v3/list-users", "authentication required").await; + assert_missing_credentials(&RemoveUser {}, Method::DELETE, "/rustfs/admin/v3/remove-user", "authentication required") + .await; + assert_missing_credentials(&ExportIam {}, Method::GET, "/rustfs/admin/v3/export-iam", "get cred failed").await; + } + + #[test] + fn user_handlers_use_the_shared_admin_gate_with_their_actions() { + let production = include_str!("user.rs") + .split("\n#[cfg(test)]\n") + .next() + .expect("production source must precede tests"); + + for (handler, action, binds_credentials) in [ + ("SetUserStatus", "EnableUserAdminAction", false), + ("ListUsers", "ListUsersAdminAction", true), + ("RemoveUser", "DeleteUserAdminAction", true), + ("ExportIam", "ExportIAMAction", false), + ] { + let block = source_block(production, &format!("impl Operation for {handler}")); + assert_shared_gate_wiring(block, handler, &[action], binds_credentials); + } + + // AddUser and GetUserInfo derive a per-request `deny_only` from `should_check_deny_only`, and + // ImportIam consumes the `owner` flag while restoring service accounts. The shared gate pins + // `deny_only = false` and hands back only the credentials, so these three keep the explicit + // `check_key_valid` + `validate_admin_request` preamble. + for (handler, marker) in [ + ("AddUser", "should_check_deny_only(ak, &cred)"), + ("GetUserInfo", "should_check_deny_only(ak, &cred)"), + ("ImportIam", "if !owner {"), + ] { + let block = source_block(production, &format!("impl Operation for {handler}")); + assert!( + block.contains(marker), + "{handler} must keep the context that rules out the shared gate ({marker})" + ); + assert!( + !block.contains("authorize_admin_request("), + "{handler} must not be routed through the shared gate" + ); + assert!( + block.contains("validate_admin_request("), + "{handler} must keep its explicit validate_admin_request call" + ); + } + } } diff --git a/rustfs/src/admin/mod.rs b/rustfs/src/admin/mod.rs index 701c87839..f685f9d4a 100644 --- a/rustfs/src/admin/mod.rs +++ b/rustfs/src/admin/mod.rs @@ -23,8 +23,6 @@ pub(crate) mod route_policy; pub mod router; pub(crate) mod runtime_sources; pub mod service; -pub mod site_replication_identity; -pub(crate) mod site_replication_state; pub(crate) mod storage_api; pub mod utils; diff --git a/rustfs/src/admin/runtime_sources.rs b/rustfs/src/admin/runtime_sources.rs index 0d053bd52..938b0ea86 100644 --- a/rustfs/src/admin/runtime_sources.rs +++ b/rustfs/src/admin/runtime_sources.rs @@ -36,10 +36,8 @@ pub(crate) use crate::runtime_sources::{ }; use rustfs_config::server_config::Config; use rustfs_kms::KmsServiceManager; -use rustfs_tls_runtime::{GlobalPublishedOutboundTlsState, TlsGeneration}; +use rustfs_tls_runtime::GlobalPublishedOutboundTlsState; use std::sync::Arc; -#[cfg(test)] -use std::sync::atomic::{AtomicU64, Ordering}; use tokio::sync::RwLock; pub(crate) fn default_admin_usecase() -> DefaultAdminUsecase { @@ -116,29 +114,6 @@ pub(crate) fn current_or_init_kms_runtime_service_manager() -> Arc TlsGeneration { - root_runtime_sources::current_outbound_tls_generation().unwrap_or_else(empty_outbound_tls_generation) -} - -#[cfg(test)] -fn empty_outbound_tls_generation() -> TlsGeneration { - TlsGeneration(TEST_OUTBOUND_TLS_GENERATION.load(Ordering::Relaxed)) -} - -#[cfg(not(test))] -fn empty_outbound_tls_generation() -> TlsGeneration { - TlsGeneration(0) -} - pub(crate) async fn current_outbound_tls_state() -> GlobalPublishedOutboundTlsState { if let Some(state) = root_runtime_sources::current_outbound_tls_state().await { return state; diff --git a/rustfs/src/admin/service/site_replication.rs b/rustfs/src/admin/service/site_replication.rs index db6fdde22..3f47b584e 100644 --- a/rustfs/src/admin/service/site_replication.rs +++ b/rustfs/src/admin/service/site_replication.rs @@ -13,11 +13,11 @@ // limitations under the License. use crate::admin::runtime_sources::{AppContext, current_app_context, current_object_store_handle_for_context}; -use crate::admin::site_replication_identity::{ +use crate::admin::storage_api::error::Error as StorageError; +use crate::site_replication::identity::{ deployment_id_for_endpoint, mark_unknown_peer_sync_enabled, normalize_peer_map_by_identity_with, }; -use crate::admin::site_replication_state::{SITE_REPLICATION_STATE_PATH, with_site_replication_state_lock_on}; -use crate::admin::storage_api::error::Error as StorageError; +use crate::site_replication::state_lock::{SITE_REPLICATION_STATE_PATH, with_site_replication_state_lock_on}; use crate::storage::storage_api::{read_config_no_lock, save_config_no_lock}; use rustfs_madmin::PeerInfo; use s3s::{S3Error, S3ErrorCode, S3Result}; diff --git a/rustfs/src/admin/storage_api.rs b/rustfs/src/admin/storage_api.rs index c4a597b08..1c8a14c26 100644 --- a/rustfs/src/admin/storage_api.rs +++ b/rustfs/src/admin/storage_api.rs @@ -124,7 +124,7 @@ pub(crate) mod runtime_sources { pub(crate) type DailyAllTierStats = super::DailyAllTierStats; pub(crate) type ECStore = super::ECStore; pub(crate) type NotificationSys = super::NotificationSys; - pub(crate) type ScannerMetricsReport = rustfs_common::metrics::ScannerMetricsReport; + pub(crate) type ScannerMetricsReport = rustfs_scanner_contracts::metrics::ScannerMetricsReport; pub(crate) type StorageClassConfig = crate::storage::storage_api::ecstore_config::storageclass::Config; pub(crate) type TierConfigMgr = crate::storage::storage_api::TierConfigMgr; } @@ -445,8 +445,8 @@ pub(crate) mod replication { pub(crate) use super::ecstore_bucket::replication::{ OperatorRuleContract, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS, REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS, - REPLICATION_WRITABLE_FIELDS, assign_site_replication_rule_priorities, is_site_replication_role, - merge_incoming_replication_config, replication_target_arn_deployment_id, site_replication_rule_deployment_id, + REPLICATION_WRITABLE_FIELDS, assign_site_replication_rule_priorities, merge_incoming_replication_config, + replication_target_arn_deployment_id, }; pub(crate) type BucketReplicationResyncStatus = super::ecstore_bucket::replication::BucketReplicationResyncStatus; pub(crate) type BucketStats = super::ecstore_bucket::replication::BucketStats; @@ -653,8 +653,6 @@ pub(crate) mod replication { pub(crate) mod target { pub(crate) use super::ecstore_bucket::target::duration_from_secs_or_nanos; - #[allow(clippy::upper_case_acronyms)] - pub(crate) type ARN = super::ecstore_bucket::target::ARN; pub(crate) type BucketTarget = super::ecstore_bucket::target::BucketTarget; pub(crate) type BucketTargetType = super::ecstore_bucket::target::BucketTargetType; pub(crate) type BucketTargets = super::ecstore_bucket::target::BucketTargets; diff --git a/rustfs/src/admin/utils.rs b/rustfs/src/admin/utils.rs index b690043c9..205cbbadf 100644 --- a/rustfs/src/admin/utils.rs +++ b/rustfs/src/admin/utils.rs @@ -13,8 +13,12 @@ // limitations under the License. use crate::server::{MINIO_ADMIN_PREFIX, has_path_prefix}; +use http::{HeaderMap, HeaderValue, StatusCode, Uri}; use rustfs_crypto::{decrypt_data, decrypt_stream_io, encrypt_stream_io}; -use s3s::{Body, S3Result, s3_error}; +use s3s::header::CONTENT_TYPE; +use s3s::{Body, S3Error, S3ErrorCode, S3Response, S3Result, s3_error}; +use serde::Serialize; +use std::collections::HashMap; /// Returns `true` if `s` contains any whitespace character. /// @@ -59,6 +63,48 @@ pub(crate) fn encode_compatible_admin_payload(path: &str, secret_key: &str, data } } +/// Serialize `value` as the JSON body of an admin response with `status`. +/// +/// The admin surface answers almost every endpoint this way, so the shape is +/// pinned here rather than re-derived per handler: `Content-Type: +/// application/json`, no other header, and the serialized bytes verbatim as +/// the body. Serialization failure is reported as `InternalError`; the +/// response structs the admin handlers pass here are plain owned data, so that +/// arm is unreachable in practice. +pub(crate) fn json_response(status: StatusCode, value: &T) -> S3Result> { + let data = serde_json::to_vec(value) + .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("failed to serialize response: {e}")))?; + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + Ok(S3Response::with_headers((status, Body::from(data)), headers)) +} + +/// A bodiless admin response carrying only `status`. +/// +/// Used by the endpoints whose success answer is the status code itself +/// (`204 No Content`, or a `200 OK` acknowledgement with nothing to report). +/// No `Content-Type` is set, because there is no content to type. +pub(crate) fn empty_response(status: StatusCode) -> S3Response<(StatusCode, Body)> { + S3Response::new((status, Body::empty())) +} + +/// Collect a request URI's query string into a parameter map. +/// +/// Parsed with `form_urlencoded`, as the rest of the admin surface does, so a +/// parameter written without a value (`?status`) arrives as an empty value +/// rather than disappearing: a validated parameter must be able to tell "not +/// asked for" from "asked for, unreadable". Percent escapes and `+` are +/// decoded, and a repeated key keeps its last occurrence. +pub(crate) fn extract_query_params(uri: &Uri) -> HashMap { + let mut params = HashMap::new(); + if let Some(query) = uri.query() { + for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { + params.insert(key.into_owned(), value.into_owned()); + } + } + params +} + #[cfg(test)] mod tests { use super::*; @@ -119,4 +165,93 @@ mod tests { assert_eq!(decoded, payload); } + + async fn body_bytes(mut body: Body) -> Vec { + body.store_all_limited(64 * 1024).await.expect("body should read").to_vec() + } + + /// The wire contract every admin endpoint that returns JSON depends on: + /// the requested status, `application/json`, and the serialized bytes + /// verbatim — nothing else. + #[tokio::test] + async fn json_response_carries_status_content_type_and_serialized_body() { + #[derive(Serialize)] + struct Payload { + success: bool, + message: &'static str, + } + + let response = json_response( + StatusCode::ACCEPTED, + &Payload { + success: true, + message: "queued", + }, + ) + .expect("payload should serialize"); + + assert_eq!(response.output.0, StatusCode::ACCEPTED); + assert_eq!( + response.headers.get(CONTENT_TYPE).and_then(|value| value.to_str().ok()), + Some("application/json") + ); + assert_eq!(response.headers.len(), 1); + assert_eq!(body_bytes(response.output.1).await, br#"{"success":true,"message":"queued"}"#.to_vec()); + } + + /// A serialization failure must surface as `InternalError` rather than a + /// panic or a half-written body. + #[test] + fn json_response_reports_serialization_failure_as_internal_error() { + struct Unserializable; + + impl Serialize for Unserializable { + fn serialize(&self, _serializer: S) -> Result { + Err(serde::ser::Error::custom("nope")) + } + } + + let err = json_response(StatusCode::OK, &Unserializable).expect_err("serialization must fail"); + assert_eq!(err.code(), &S3ErrorCode::InternalError); + assert!(err.message().unwrap_or_default().contains("failed to serialize response")); + } + + /// The bodiless answer must stay bodiless and must not claim a content + /// type: callers use it for `204 No Content` and bare acknowledgements. + #[tokio::test] + async fn empty_response_has_no_body_and_no_headers() { + for status in [StatusCode::OK, StatusCode::NO_CONTENT] { + let response = empty_response(status); + assert_eq!(response.output.0, status); + assert!(response.headers.is_empty()); + assert!(body_bytes(response.output.1).await.is_empty()); + } + } + + /// Percent escapes must be decoded, so a job id or key id containing `/` + /// arrives whole rather than as its escape sequence. + #[test] + fn extract_query_params_decodes_percent_escapes() { + let uri: Uri = "/rustfs/admin/v3/status-job?jobId=abc%2F123" + .parse() + .expect("uri should parse"); + let params = extract_query_params(&uri); + assert_eq!(params.get("jobId"), Some(&"abc/123".to_string())); + } + + /// A parameter written without a value must arrive as an empty value, not + /// vanish: a validated parameter has to tell "not asked for" from "asked + /// for, unreadable". A missing query string yields no parameters at all. + #[test] + fn extract_query_params_keeps_valueless_parameters_and_survives_no_query() { + let valueless: Uri = "/rustfs/admin/v3/kms/keys?status".parse().expect("uri should parse"); + let params = extract_query_params(&valueless); + assert_eq!(params.get("status"), Some(&String::new())); + + let plus: Uri = "/rustfs/admin/v3/kms/keys?name=a+b".parse().expect("uri should parse"); + assert_eq!(extract_query_params(&plus).get("name"), Some(&"a b".to_string())); + + let bare: Uri = "/rustfs/admin/v3/kms/keys".parse().expect("uri should parse"); + assert!(extract_query_params(&bare).is_empty()); + } } diff --git a/rustfs/src/app/bucket_usecase.rs b/rustfs/src/app/bucket_usecase.rs index 0bc2a3c0a..b7d526fd1 100644 --- a/rustfs/src/app/bucket_usecase.rs +++ b/rustfs/src/app/bucket_usecase.rs @@ -64,9 +64,6 @@ use super::storage_api::bucket_usecase::{ get_validated_store, process_lambda_configurations, process_queue_configurations, process_topic_configurations, request_context, validate_list_object_unordered_with_delimiter, }; -use crate::admin::handlers::site_replication::{ - site_replication_bucket_meta_hook, site_replication_delete_bucket_hook, site_replication_make_bucket_hook, -}; use crate::app::object_data_cache::invalidate_object_data_cache_bucket_after_delete; use crate::app::runtime_sources::{ AppContext, current_app_context, current_encryption_service, current_notification_system, @@ -75,6 +72,9 @@ use crate::app::runtime_sources::{ use crate::auth::get_condition_values_with_client_info; use crate::error::ApiError; use crate::shared_types::RemoteAddr; +use crate::site_replication::{ + site_replication_bucket_meta_hook, site_replication_delete_bucket_hook, site_replication_make_bucket_hook, +}; use crate::storage::storage_api::lock_bucket_targets_metadata; use http::StatusCode; use metrics::counter; diff --git a/rustfs/src/app/capacity_dirty_scope_test.rs b/rustfs/src/app/capacity_dirty_scope_test.rs index e00c21b73..3c84341a1 100644 --- a/rustfs/src/app/capacity_dirty_scope_test.rs +++ b/rustfs/src/app/capacity_dirty_scope_test.rs @@ -17,7 +17,7 @@ use super::storage_api::test::contract::bucket::{BucketOperations, BucketOptions use super::storage_api::test::contract::heal::HealOperations as _; use super::storage_api::test::contract::object::ObjectIO as _; use super::storage_api::test::{ECStore, Endpoint, EndpointServerPools, Endpoints, PoolEndpoints}; -use rustfs_common::heal_channel::{HealOpts, HealScanMode}; +use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode}; use rustfs_object_capacity::capacity_manager::{HybridStrategyConfig, create_isolated_manager}; use serial_test::serial; use std::{ diff --git a/rustfs/src/app/mod.rs b/rustfs/src/app/mod.rs index 107ae3e0c..1b8546f3a 100644 --- a/rustfs/src/app/mod.rs +++ b/rustfs/src/app/mod.rs @@ -20,6 +20,7 @@ pub mod bucket_usecase; pub mod context; pub(crate) mod metadata_route; pub mod multipart_usecase; +pub mod object; pub(crate) mod object_data_cache; pub(crate) mod object_traffic_health; pub mod object_usecase; diff --git a/rustfs/src/app/multipart_usecase.rs b/rustfs/src/app/multipart_usecase.rs index 89d116d14..3accb0fe9 100644 --- a/rustfs/src/app/multipart_usecase.rs +++ b/rustfs/src/app/multipart_usecase.rs @@ -33,7 +33,9 @@ use super::storage_api::multipart_usecase::contract::http::HTTPPreconditions; use super::storage_api::multipart_usecase::contract::multipart::{ CompletePart, MAX_MULTIPART_PART_NUMBER, MultipartOperations as _, MultipartUploadResult, }; -use super::storage_api::multipart_usecase::contract::object::{ObjectIO as _, ObjectOperations as _}; +#[cfg(test)] +use super::storage_api::multipart_usecase::contract::object::ObjectIO as _; +use super::storage_api::multipart_usecase::contract::object::ObjectOperations as _; use super::storage_api::multipart_usecase::contract::range::HTTPRangeSpec; use super::storage_api::multipart_usecase::data_usage::{ quota_object_size, record_bucket_object_version_write_memory, record_bucket_object_write_memory, @@ -76,6 +78,7 @@ use crate::app::object_usecase::{ use crate::app::runtime_sources::{ AppContext, current_app_context, current_object_data_cache_for_context, current_object_store_handle_for_context, }; +use crate::auth::{VerifiedPresignedRequest, reject_presigned_put_max_content_length_for_other_operation}; use crate::capacity::record_capacity_write; use crate::error::ApiError; use crate::table_catalog; @@ -395,6 +398,11 @@ impl DefaultMultipartUsecase { &self, req: S3Request, ) -> S3Result> { + reject_presigned_put_max_content_length_for_other_operation( + &req.headers, + req.uri.query(), + req.extensions.get::().is_some(), + )?; record_s3_op(S3Operation::AbortMultipartUpload); let mut opts = ObjectOptions::default(); apply_bucket_generation_guard(&req, &req.input.bucket, &mut opts)?; @@ -436,6 +444,11 @@ impl DefaultMultipartUsecase { &self, req: S3Request, ) -> S3Result> { + reject_presigned_put_max_content_length_for_other_operation( + &req.headers, + req.uri.query(), + req.extensions.get::().is_some(), + )?; let mut helper = OperationHelper::new( &req, EventName::ObjectCreatedCompleteMultipartUpload, @@ -739,6 +752,11 @@ impl DefaultMultipartUsecase { &self, req: S3Request, ) -> S3Result> { + reject_presigned_put_max_content_length_for_other_operation( + &req.headers, + req.uri.query(), + req.extensions.get::().is_some(), + )?; let helper = OperationHelper::new(&req, EventName::ObjectCreatedCreateMultipartUpload, S3Operation::CreateMultipartUpload) .suppress_event(); @@ -960,6 +978,11 @@ impl DefaultMultipartUsecase { #[instrument(level = "debug", skip(self, req))] #[hotpath::measure(impl_type = "MultipartUsecase")] pub async fn execute_upload_part(&self, req: S3Request) -> S3Result> { + reject_presigned_put_max_content_length_for_other_operation( + &req.headers, + req.uri.query(), + req.extensions.get::().is_some(), + )?; let mut opts = ObjectOptions::default(); apply_bucket_generation_guard(&req, &req.input.bucket, &mut opts)?; let input = req.input; @@ -1227,6 +1250,11 @@ impl DefaultMultipartUsecase { &self, req: S3Request, ) -> S3Result> { + reject_presigned_put_max_content_length_for_other_operation( + &req.headers, + req.uri.query(), + req.extensions.get::().is_some(), + )?; let mut opts = ObjectOptions::default(); apply_bucket_generation_guard(&req, &req.input.bucket, &mut opts)?; let ListMultipartUploadsInput { @@ -1274,6 +1302,11 @@ impl DefaultMultipartUsecase { } pub async fn execute_list_parts(&self, req: S3Request) -> S3Result> { + reject_presigned_put_max_content_length_for_other_operation( + &req.headers, + req.uri.query(), + req.extensions.get::().is_some(), + )?; let mut opts = ObjectOptions::default(); apply_bucket_generation_guard(&req, &req.input.bucket, &mut opts)?; let ListPartsInput { @@ -1305,6 +1338,11 @@ impl DefaultMultipartUsecase { &self, req: S3Request, ) -> S3Result> { + reject_presigned_put_max_content_length_for_other_operation( + &req.headers, + req.uri.query(), + req.extensions.get::().is_some(), + )?; // Captured before `req.input` is destructured below. let copy_principal = SseKmsPrincipal::from_request(&req); let source_bucket = match &req.input.copy_source { @@ -1443,8 +1481,8 @@ impl DefaultMultipartUsecase { .into()); } - let src_reader = store - .get_object_reader(&src_bucket, &src_key, rs.clone(), h, &get_opts) + let (src_reader, _source_cancellation) = store + .get_object_reader_for_copy(&src_bucket, &src_key, rs.clone(), h, &get_opts) .await .map_err(map_get_object_reader_error)?; diff --git a/rustfs/src/app/object/copy.rs b/rustfs/src/app/object/copy.rs new file mode 100644 index 000000000..c9f855662 --- /dev/null +++ b/rustfs/src/app/object/copy.rs @@ -0,0 +1,1144 @@ +// 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. + +//! CopyObject path. + +use super::*; + +use crate::auth::{VerifiedPresignedRequest, reject_presigned_put_max_content_length_for_other_operation}; + +fn copy_namespace_lock_error(bucket: &str, object: &str, mode: &'static str, err: rustfs_lock::LockError) -> StorageError { + match err { + rustfs_lock::LockError::QuorumNotReached { required, achieved } => StorageError::NamespaceLockQuorumUnavailable { + mode, + bucket: bucket.to_owned(), + object: object.to_owned(), + required, + achieved, + }, + other => StorageError::Lock(other), + } +} + +async fn acquire_self_copy_namespace_lock(store: &S, bucket: &str, object: &str) -> S3Result +where + S: NamespaceLocking + ?Sized, +{ + let object = encode_dir_object(object); + let lock = store.new_ns_lock(bucket, &object).await.map_err(ApiError::from)?; + lock.get_write_lock(get_lock_acquire_timeout()) + .await + .map_err(|err| ApiError::from(copy_namespace_lock_error(bucket, &object, "write", err)).into()) +} + +pub(crate) async fn acquire_copy_bucket_lifecycle_lock(store: &S, bucket: &str) -> S3Result +where + S: NamespaceLocking + ?Sized, +{ + let lock = store + .new_ns_lock(bucket, BUCKET_LIFECYCLE_LOCK_OBJECT) + .await + .map_err(ApiError::from)?; + lock.get_read_lock(get_lock_acquire_timeout()).await.map_err(|err| { + ApiError::from(copy_namespace_lock_error( + bucket, + BUCKET_LIFECYCLE_LOCK_OBJECT, + "bucket_lifecycle_read", + err, + )) + .into() + }) +} + +pub(crate) async fn acquire_copy_bucket_lifecycle_locks( + store: &S, + source_bucket: &str, + destination_bucket: &str, +) -> S3Result<(NamespaceLockGuard, Option)> +where + S: NamespaceLocking + ?Sized, +{ + if source_bucket == destination_bucket { + return Ok((acquire_copy_bucket_lifecycle_lock(store, source_bucket).await?, None)); + } + + if source_bucket < destination_bucket { + let source_guard = acquire_copy_bucket_lifecycle_lock(store, source_bucket).await?; + let destination_guard = acquire_copy_bucket_lifecycle_lock(store, destination_bucket).await?; + Ok((source_guard, Some(destination_guard))) + } else { + let destination_guard = acquire_copy_bucket_lifecycle_lock(store, destination_bucket).await?; + let source_guard = acquire_copy_bucket_lifecycle_lock(store, source_bucket).await?; + Ok((source_guard, Some(destination_guard))) + } +} + +impl DefaultObjectUsecase { + pub fn execute_copy_object( + &self, + req: S3Request, + ) -> impl std::future::Future>> + Send + '_ { + Box::pin(self.execute_copy_object_inner(req)) + } + + #[instrument(name = "execute_copy_object", level = "debug", skip(self, req))] + async fn execute_copy_object_inner(&self, req: S3Request) -> S3Result> { + reject_presigned_put_max_content_length_for_other_operation( + &req.headers, + req.uri.query(), + req.extensions.get::().is_some(), + )?; + if let Some(context) = &self.context { + let _ = context.object_store(); + } + + let mut helper = OperationHelper::new(&req, EventName::ObjectCreatedCopy, S3Operation::CopyObject); + let CopyObjectInput { + copy_source, + bucket, + key, + version_id: dest_version_id, + server_side_encryption: requested_sse, + ssekms_key_id: requested_kms_key_id, + sse_customer_algorithm, + sse_customer_key, + sse_customer_key_md5, + copy_source_sse_customer_algorithm, + copy_source_sse_customer_key, + copy_source_sse_customer_key_md5, + metadata_directive, + metadata, + tagging, + tagging_directive, + copy_source_if_match, + copy_source_if_none_match, + cache_control, + content_disposition, + content_encoding, + content_language, + content_type, + expires, + website_redirect_location, + object_lock_legal_hold_status, + object_lock_mode, + object_lock_retain_until_date, + storage_class, + checksum_algorithm, + .. + } = req.input.clone(); + let requested_checksum_type = checksum_algorithm + .as_ref() + .map(|algorithm| rustfs_rio::ChecksumType::from_string(algorithm.as_str())); + if requested_checksum_type.is_some_and(|checksum_type| !checksum_type.is_set()) { + return Err(s3_error!(InvalidArgument, "Unsupported checksum algorithm")); + } + let (src_bucket, src_key, version_id) = match copy_source { + CopySource::AccessPoint { .. } => return Err(s3_error!(NotImplemented)), + CopySource::Outpost { .. } => return Err(s3_error!(NotImplemented)), + CopySource::Bucket { + ref bucket, + ref key, + version_id, + } => (bucket.to_string(), key.to_string(), version_id.map(|v| v.to_string())), + }; + + // Normalize the copy-source version id like GET/HEAD do: trim, treat "null" as the + // nil UUID, and reject malformed ids up front (issue #4238). + let version_id = match version_id { + Some(v) => { + let trimmed = v.trim(); + if trimmed.eq_ignore_ascii_case("null") { + Some(Uuid::nil().to_string()) + } else if Uuid::parse_str(trimmed).is_ok() { + Some(trimmed.to_string()) + } else { + return Err(s3_error!(InvalidArgument, "Invalid version id specified in copy source")); + } + } + None => None, + }; + + if let Some(ref sc) = storage_class + && !is_valid_storage_class(sc.as_str()) + { + return Err(s3_error!(InvalidStorageClass)); + } + let ssekms_context = extract_ssekms_context_from_headers(&req.headers)?; + validate_sse_headers_for_write( + requested_sse.as_ref(), + requested_kms_key_id.as_ref(), + ssekms_context.as_ref(), + sse_customer_algorithm.as_ref(), + sse_customer_key.as_ref(), + sse_customer_key_md5.as_ref(), + true, + )?; + let has_explicit_ssec = sse_customer_algorithm.is_some() || sse_customer_key.is_some() || sse_customer_key_md5.is_some(); + + // Validate both source and destination keys + validate_object_key(&src_key, "COPY (source)")?; + validate_object_key(&key, "COPY (dest)")?; + validate_table_catalog_object_mutation(&bucket, &key).await?; + let replaces_metadata = match metadata_directive.as_ref().map(|directive| directive.as_str()) { + None | Some(MetadataDirective::COPY) => false, + Some(MetadataDirective::REPLACE) => true, + Some(_) => { + return Err(S3Error::with_message( + S3ErrorCode::InvalidArgument, + "The MetadataDirective header is invalid".to_string(), + )); + } + }; + let replacement_metadata = if replaces_metadata { + validate_archive_content_encoding(&key, content_type.as_deref(), content_encoding.as_deref())?; + let mut replacement_metadata = metadata.unwrap_or_default(); + namespace_reserved_user_metadata(&mut replacement_metadata); + apply_standard_object_metadata( + &mut replacement_metadata, + cache_control.as_deref(), + content_disposition.as_deref(), + content_encoding.as_deref(), + content_language.as_deref(), + content_type.as_deref(), + expires.as_ref(), + website_redirect_location.as_deref(), + )?; + Some(replacement_metadata) + } else { + None + }; + + // AWS S3 allows self-copy when metadata directive is REPLACE (used to update metadata in-place), + // when an explicit storage class change is requested, or when restoring a specific historical + // version onto the current key (source carries a versionId). Reject only a true no-op self-copy + // where none of these apply (issue #4238). + let replacement_tags = crate::app::storage_api::object_usecase::s3_api::tagging::resolve_copy_object_tags( + tagging.as_deref(), + tagging_directive.as_ref(), + )?; + + if !replaces_metadata + && tagging_directive.as_ref().map(TaggingDirective::as_str) != Some(TaggingDirective::REPLACE) + && storage_class.is_none() + && version_id.is_none() + && src_bucket == bucket + && src_key == key + { + error!(bucket, key, "Rejected self-copy operation"); + return Err(s3_error!( + InvalidRequest, + "Cannot copy an object to itself. Source and destination must be different." + )); + } + + // warn!("copy_object {}/{}, to {}/{}", &src_bucket, &src_key, &bucket, &key); + + let mut src_opts = copy_src_opts(&src_bucket, &src_key, &req.headers).map_err(ApiError::from)?; + + src_opts.version_id = version_id.clone(); + + let mut src_get_opts = ObjectOptions { + version_id: src_opts.version_id.clone(), + versioned: src_opts.versioned, + version_suspended: src_opts.version_suspended, + ..Default::default() + }; + apply_copy_source_bucket_generation_guard(&req, &src_bucket, &mut src_get_opts)?; + + let mut dst_opts = copy_dst_opts_with_replication_authorization( + &bucket, + &key, + dest_version_id.clone(), + &req.headers, + HashMap::new(), + replication_request_authorized(&req), + ) + .await + .map_err(ApiError::from)?; + apply_bucket_generation_guard(&req, &bucket, &mut dst_opts)?; + + let cp_src_dst_same = path_join_buf(&[&src_bucket, &src_key]) == path_join_buf(&[&bucket, &key]); + let expected_current_version_id = expected_current_version_id(&req.headers)?; + if expected_current_version_id.is_some() + && (!cp_src_dst_same || version_id.is_none() || dest_version_id.is_some() || !dst_opts.versioned) + { + return Err(s3_error!( + InvalidRequest, + "Expected current version precondition requires a versioned same-object historical copy that creates a new version" + )); + } + + let Some(store) = self.object_store() else { + return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); + }; + let (source_bucket_lifecycle_guard, destination_bucket_lifecycle_guard_storage) = + acquire_copy_bucket_lifecycle_locks(store.as_ref(), &src_bucket, &bucket).await?; + let current_source_incarnation_id = store + .bucket_incarnation_id_from_disk(&src_bucket) + .await + .map_err(ApiError::from)?; + if src_get_opts + .expected_bucket_incarnation_id + .is_some_and(|expected| expected != current_source_incarnation_id) + { + return Err(ApiError::from(StorageError::BucketNotFound(src_bucket.clone())).into()); + } + let current_destination_incarnation_id = if src_bucket == bucket { + current_source_incarnation_id + } else { + store.bucket_incarnation_id_from_disk(&bucket).await.map_err(ApiError::from)? + }; + if dst_opts + .expected_bucket_incarnation_id + .is_some_and(|expected| expected != current_destination_incarnation_id) + { + return Err(ApiError::from(StorageError::BucketNotFound(bucket.clone())).into()); + } + let destination_bucket_lifecycle_guard = destination_bucket_lifecycle_guard_storage + .as_ref() + .unwrap_or(&source_bucket_lifecycle_guard); + if source_bucket_lifecycle_guard.is_lock_lost() || destination_bucket_lifecycle_guard.is_lock_lost() { + return Err(ApiError::from(StorageError::NamespaceLockQuorumUnavailable { + mode: "copy_bucket_generation", + bucket: bucket.clone(), + object: key.clone(), + required: 1, + achieved: 0, + }) + .into()); + } + src_get_opts.expected_bucket_incarnation_id = Some(current_source_incarnation_id); + dst_opts.expected_bucket_incarnation_id = Some(current_destination_incarnation_id); + if src_bucket != bucket { + dst_opts.add_bucket_lifecycle_lock_guard(&source_bucket_lifecycle_guard); + } + dst_opts.add_bucket_lifecycle_lock_guard(destination_bucket_lifecycle_guard); + + // Bucket metadata uses the bucket name as its namespace-lock key. Load + // every copy-time bucket snapshot before a same-object key can collide + // with that key (for example, copying `bucket/bucket` onto itself). + let bucket_sse_config = metadata_sys::get_sse_config(&bucket).await.ok(); + let object_lock_config_state = load_bucket_object_lock_config_state(&bucket).await?; + if cp_src_dst_same && key == bucket && expected_current_version_id.is_none() { + dst_opts.object_lock_config_snapshot = + Some(store.object_lock_config_snapshot(&bucket).await.map_err(ApiError::from)?); + } + let mut current_opts: ObjectOptions = internal_object_info_lookup_opts( + get_opts(&bucket, &key, dest_version_id.clone(), None, &req.headers) + .await + .map_err(ApiError::from)?, + ); + + let _self_copy_lock_guard = if cp_src_dst_same && expected_current_version_id.is_none() { + let guard = acquire_self_copy_namespace_lock(store.as_ref(), &bucket, &key).await?; + src_opts.no_lock = true; + src_get_opts.no_lock = true; + dst_opts.no_lock = true; + Some(guard) + } else { + None + }; + if let Some(guard) = _self_copy_lock_guard.as_ref() { + dst_opts.add_namespace_lock_guard(guard); + } + dst_opts.expected_current_version_id = expected_current_version_id.clone(); + + if _self_copy_lock_guard.is_some() { + current_opts.no_lock = true; + } + let previous_current_sizes = match store.get_object_info(&bucket, &key, ¤t_opts).await { + Ok(existing_obj_info) => { + validate_existing_object_lock_for_write(&object_lock_config_state, &existing_obj_info, &dst_opts)?; + if let Some(expected) = expected_current_version_id.as_deref() + && existing_obj_info.version_id.unwrap_or_default().to_string() != expected + { + return Err(s3_error!(PreconditionFailed)); + } + Some((existing_obj_info.size.max(0) as u64, quota_object_size(&existing_obj_info))) + } + Err(err) => { + if expected_current_version_id.is_some() { + return Err(s3_error!(PreconditionFailed)); + } + if !is_err_object_not_found(&err) && !is_err_version_not_found(&err) { + return Err(ApiError::from(err).into()); + } + None + } + }; + + let (mut effective_sse, mut effective_kms_key_id) = resolve_bucket_default_sse( + bucket_sse_config.as_ref().map(|(config, _)| config), + requested_sse, + requested_kms_key_id, + has_explicit_ssec, + ); + + let h = build_ssec_read_headers( + copy_source_sse_customer_algorithm.as_ref(), + copy_source_sse_customer_key.as_ref(), + copy_source_sse_customer_key_md5.as_ref(), + ); + + let copy_principal = SseKmsPrincipal::from_request(&req); + + if source_bucket_lifecycle_guard.is_lock_lost() { + return Err(ApiError::from(StorageError::NamespaceLockQuorumUnavailable { + mode: "copy_source_bucket_generation", + bucket: src_bucket.clone(), + object: src_key.clone(), + required: 1, + achieved: 0, + }) + .into()); + } + + let (gr, source_cancellation) = store + .get_object_reader_for_copy(&src_bucket, &src_key, None, h, &src_get_opts) + .await + .map_err(map_get_object_reader_error)?; + + // The commit owner is intentionally detached so SetDisk can finish + // its rename/cleanup and post-commit publication if the HTTP caller + // goes away. Keep a request-owned guard for the source producer: + // cancellation drops the source read promptly, while the detached + // commit task retains the guards it needs to complete safely. + let _source_cancellation_guard = source_cancellation.clone().drop_guard(); + + let mut src_info = gr.object_info.clone(); + + // A copy reads the source plaintext, so it needs the source key's decrypt permission + // as well as the destination key's generate permission below. The source read resolves + // its material inside the object layer, which has no request identity, so the check + // happens here. + authorize_sse_kms_object_read(copy_principal.as_ref(), &src_info.user_defined).await?; + + // Capture the version actually read from the source before src_info is mutated/consumed + // below. This is the exact source version copied (issue #4976): the response must echo it + // via x-amz-copy-source-version-id, distinct from the destination version_id. + let src_resolved_version_id = src_info.version_id; + + // Source object's existing checksum, if any. When the copy does not request a new + // algorithm, AWS preserves the source object's checksum on the destination (#4996); the + // copy does not transform the plaintext, so we carry the stored value over unchanged + // rather than re-hashing every byte. + let src_checksum = src_info.checksum.as_ref().and_then(|bytes| { + let (pairs, _) = rustfs_rio::read_checksums(bytes.as_ref(), 0); + pairs + .into_iter() + .find_map(|(k, v)| rustfs_rio::Checksum::new_from_string(&k, &v)) + }); + + // Validate copy source conditions + if let Some(if_match) = copy_source_if_match { + if let Some(ref etag) = src_info.etag { + if let Some(strong_etag) = if_match.into_etag() { + if ETag::Strong(etag.clone()) != strong_etag { + return Err(s3_error!(PreconditionFailed)); + } + } else { + // Weak ETag or Any (*) in If-Match should fail per RFC 9110 + return Err(s3_error!(PreconditionFailed)); + } + } else { + return Err(s3_error!(PreconditionFailed)); + } + } + + if let Some(if_none_match) = copy_source_if_none_match + && let Some(ref etag) = src_info.etag + && let Some(strong_etag) = if_none_match.into_etag() + && ETag::Strong(etag.clone()) == strong_etag + { + return Err(s3_error!(PreconditionFailed)); + } + + // A same-name copy is normally serviced as a metadata-only update: the store layer + // rewrites xl.meta in place and leaves the data blocks alone. That shortcut is only sound + // when the destination's physical bytes are identical to the source's, and encryption + // breaks exactly that. The destination metadata is rebuilt from scratch below — + // `strip_managed_encryption_metadata` drops the source DEK and `sse_encryption` mints a + // fresh one — so reusing the stored ciphertext would leave a new DEK sitting beside bytes + // it cannot decrypt, permanently destroying the object (GET fails with an AEAD tag + // mismatch). The mirror case is worse because it is silent: an encrypted source copied + // without any destination SSE keeps its ciphertext while losing the key metadata, so GET + // hands back raw ciphertext as if it were plaintext. So whenever either side is + // encrypted, leave metadata_only = false and let the store layer do a full read/write + // rewrite through put_object, the same resolution the versioned historical-restore path + // uses (issue #4238, crates/ecstore/src/store/object.rs). + // + // This mirrors MinIO's `isSourceEncrypted || isTargetEncrypted -> metadataOnly = false` + // in CopyObjectHandler, with one deliberate difference: MinIO decides "target encrypted" + // from request headers alone, while `effective_sse` here also resolves the bucket default + // encryption rule. `sse_encryption` mints a DEK from that resolved value, so a + // header-only check would miss a self-copy under a bucket default rule. The source half + // deliberately reuses `ObjectInfo::is_encrypted` rather than naming individual headers, + // so a future encryption flavour is covered here the moment it is recognised there. + // + // The zero-copy shortcut is only recoverable for encrypted objects by *preserving* the + // DEK that sealed the bytes and re-wrapping it under a new master key (MinIO's + // `rotateKey` + `keyRotation` flag, which is why it may keep metadataOnly = true). RustFS + // has no such rewrap primitive today; adding one is backlog#1637, and it would enter here + // as an explicit exception rather than by relaxing this guard. + let copy_changes_encryption = src_info.is_encrypted() || effective_sse.is_some() || has_explicit_ssec; + if cp_src_dst_same && src_info.transitioned_object.tier.is_empty() && !copy_changes_encryption { + src_info.metadata_only = true; + } + + // Extract user_defined from Arc for mutation; it will be re-wrapped after all edits. + let mut user_defined = (*src_info.user_defined).clone(); + let effective_tags = replacement_tags.unwrap_or_else(|| (*src_info.user_tags).clone()); + if !replaces_metadata { + let source_expires = src_info.expires.map(Timestamp::from); + insert_expires_metadata(&mut user_defined, source_expires.as_ref())?; + } + + strip_managed_encryption_metadata(&mut user_defined); + + let destination_storage_class = storage_class + .as_ref() + .map(StorageClass::as_str) + .unwrap_or(storageclass::STANDARD); + src_info.storage_class = Some(destination_storage_class.to_string()); + + let actual_size = src_info.get_actual_size().map_err(ApiError::from)?; + + let length = actual_size; + + let mut compress_metadata = HashMap::new(); + + let should_compress = is_disk_compressible(&req.headers, &key) && actual_size > MIN_DISK_COMPRESSIBLE_SIZE as i64; + + if should_compress { + insert_str( + &mut compress_metadata, + SUFFIX_COMPRESSION, + compression_metadata_value(CompressionAlgorithm::default()), + ); + insert_str(&mut compress_metadata, SUFFIX_ACTUAL_SIZE, actual_size.to_string()); + } else { + remove_str(&mut user_defined, SUFFIX_COMPRESSION); + remove_str(&mut user_defined, SUFFIX_ACTUAL_SIZE); + remove_str(&mut user_defined, SUFFIX_COMPRESSION_SIZE); + } + + // Handle MetadataDirective REPLACE: replace user metadata while preserving system metadata. + // System metadata (compression, encryption) is added after this block to ensure + // it's not cleared by the REPLACE operation. + if let Some(replacement_metadata) = replacement_metadata { + user_defined = replacement_metadata; + src_info.content_type = content_type.clone(); + src_info.content_encoding = content_encoding.as_deref().and_then(normalize_content_encoding_for_storage); + src_info.expires = expires.map(OffsetDateTime::from); + } else if metadata_directive.is_some() || website_redirect_location.is_some() { + user_defined.retain(|key, _| !key.eq_ignore_ascii_case(AMZ_WEBSITE_REDIRECT_LOCATION)); + if let Some(website_redirect_location) = website_redirect_location { + user_defined.insert(AMZ_WEBSITE_REDIRECT_LOCATION.to_string(), website_redirect_location); + } + } + + user_defined.retain(|key, _| !key.eq_ignore_ascii_case(AMZ_STORAGE_CLASS)); + if destination_storage_class != storageclass::STANDARD { + user_defined.insert(AMZ_STORAGE_CLASS.to_string(), destination_storage_class.to_string()); + } + + user_defined.retain(|key, _| !key.eq_ignore_ascii_case(AMZ_OBJECT_TAGGING)); + if !effective_tags.is_empty() { + user_defined.insert(AMZ_OBJECT_TAGGING.to_string(), effective_tags.clone()); + } + src_info.user_tags = Arc::new(effective_tags); + + let has_explicit_object_lock_retention = object_lock_mode.is_some() || object_lock_retain_until_date.is_some(); + remove_object_lock_metadata_for_copy(&mut user_defined); + if let Some(object_lock_metadata) = build_put_like_object_lock_metadata( + &bucket, + &object_lock_config_state, + object_lock_legal_hold_status, + object_lock_mode, + object_lock_retain_until_date, + )? { + user_defined.extend(object_lock_metadata); + } + apply_bucket_default_lock_retention( + &bucket, + &object_lock_config_state, + &mut user_defined, + has_explicit_object_lock_retention, + )?; + + let mut write_plan = WritePlan::new(); + let mut reader = if should_compress { + let algorithm = CompressionAlgorithm::default(); + let hrd = HashReader::from_stream(gr.stream, length, actual_size, None, None, false).map_err(ApiError::from)?; + write_plan = write_plan.with_compression(algorithm); + hrd + } else { + HashReader::from_stream(gr.stream, length, actual_size, None, None, false).map_err(ApiError::from)? + }; + + // Give the destination object a checksum so CopyObject returns it and a later checksum-mode + // HEAD/GET matches (#4996). When the caller requests an algorithm, compute it fresh over the + // copied plaintext (the hasher sits on the innermost reader so it digests plaintext). When + // none is requested, carry the source object's stored checksum over unchanged — the copy + // does not alter the plaintext, so re-hashing would be wasted work and would flatten a + // multipart composite value. + match requested_checksum_type { + Some(checksum_type) => { + reader.add_calculated_checksum(checksum_type).map_err(ApiError::from)?; + } + None => { + if let Some(cs) = src_checksum { + reader.add_non_trailing_checksum(Some(cs), true).map_err(ApiError::from)?; + } + } + } + + let encryption_request = EncryptionRequest { + bucket: &bucket, + key: &key, + server_side_encryption: effective_sse.clone(), + ssekms_key_id: effective_kms_key_id.clone(), + ssekms_context, + sse_customer_algorithm: sse_customer_algorithm.clone(), + sse_customer_key, + sse_customer_key_md5: sse_customer_key_md5.clone(), + content_size: actual_size, + principal: copy_principal.as_ref(), + }; + + if let Some(material) = sse_encryption(encryption_request).await? { + effective_sse = Some(material.server_side_encryption.clone()); + effective_kms_key_id = material.kms_key_id.clone(); + + write_plan = write_plan.with_encryption(material.write_encryption(None)); + + user_defined.extend(encryption_material_to_metadata(&material)?); + } + + reader = write_plan.apply(reader, actual_size).map_err(ApiError::from)?; + + src_info.put_object_reader = Some(PutObjReader::new(reader)); + + // check quota + + for (k, v) in compress_metadata { + user_defined.insert(k, v); + } + + // The source object's replication bookkeeping (internal status/timestamp, + // replica state, and the surfaced x-amz-replication-status) describes the + // SOURCE's replication history; carried onto the destination it fakes a + // COMPLETED/REPLICA state for an object that never replicated (MinIO + // filterReplicationStatusMetadata parity). Inbound replica writes are + // exempt: the authorized replication request owns these keys (see + // copy_dst_opts_with_replication_authorization above). + if !dst_opts.replication_request { + user_defined.retain(|k, _| !k.eq_ignore_ascii_case(AMZ_BUCKET_REPLICATION_STATUS)); + remove_str(&mut user_defined, SUFFIX_REPLICATION_STATUS); + remove_str(&mut user_defined, SUFFIX_REPLICATION_TIMESTAMP); + remove_str(&mut user_defined, SUFFIX_REPLICA_STATUS); + remove_str(&mut user_defined, SUFFIX_REPLICA_TIMESTAMP); + } + + // Compute the replication decision exactly once per copy. The same + // immutable `dsc` drives both the pending metadata written below and the + // post-commit schedule (see the reuse site after copy_object), so a + // replication-config hot update cannot split the two phases — same + // contract as the PUT path (https://github.com/rustfs/backlog/issues/1320). + // `must_replicate_object` itself declines inbound replica writes + // (replication_request / REPLICA status), so replicas are never + // re-scheduled outbound. + let dsc = must_replicate_object( + &bucket, + &key, + &user_defined, + "".to_string(), + dst_opts.delete_marker_replication_status(), + dst_opts.clone(), + ) + .await; + if dsc.replicate_any() { + insert_str(&mut user_defined, SUFFIX_REPLICATION_TIMESTAMP, jiff::Zoned::now().to_string()); + insert_str(&mut user_defined, SUFFIX_REPLICATION_STATUS, dsc.pending_status().unwrap_or_default()); + } + + src_info.user_defined = Arc::new(user_defined); + + let quota_check = self + .check_bucket_quota( + &bucket, + QuotaOperation::CopyObject, + u64::try_from(actual_size).map_err(|_| S3Error::new(S3ErrorCode::UnexpectedContent))?, + ) + .await?; + let quota_enabled = quota_check.as_ref().is_some_and(|result| result.quota_limit.is_some()); + if let Some(quota_check) = quota_check.as_ref() { + apply_quota_admission(&mut dst_opts, quota_check)?; + } + let previous_current_size = match previous_current_sizes { + Some((_, Ok(logical_size))) if quota_enabled => Some(logical_size), + Some((_, Err(err))) if quota_enabled => return Err(ApiError::from(err).into()), + Some((physical_size, _)) => Some(physical_size), + None => None, + }; + if let Some(quota_check) = quota_check.as_ref() { + ensure_object_size_within_quota( + quota_check, + u64::try_from(actual_size).map_err(|_| S3Error::new(S3ErrorCode::UnexpectedContent))?, + )?; + } + let has_bucket_metadata = self.bucket_metadata_sys().is_some(); + let cache_adapter = self.object_data_cache(); + let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &key).await; + + let copy_commit = spawn_traced_join({ + let store = Arc::clone(&store); + let src_bucket = src_bucket.clone(); + let src_key = src_key.clone(); + let bucket = bucket.clone(); + let key = key.clone(); + let src_opts = src_opts.clone(); + let dst_opts = dst_opts.clone(); + async move { + let _source_bucket_lifecycle_guard = source_bucket_lifecycle_guard; + let _destination_bucket_lifecycle_guard_storage = destination_bucket_lifecycle_guard_storage; + let _self_copy_lock_guard = _self_copy_lock_guard; + + let oi = store + .copy_object(&src_bucket, &src_key, &bucket, &key, &mut src_info, &src_opts, &dst_opts) + .await + .map_err(ApiError::from)?; + + // Reuse the single pre-commit replication decision (see `dsc` above) so + // the persisted pending marker and the schedule always agree, mirroring + // the PUT path. + if dsc.replicate_any() { + schedule_object_replication(oi.clone(), Arc::clone(&store), dsc).await; + } + + maybe_enqueue_transition_immediate(&oi, LcEventSrc::S3CopyObject).await; + let _ = invalidate_object_data_cache_after_copy_success(&cache_adapter, &bucket, &key).await; + + let dest_versioned = BucketVersioningSys::prefix_enabled(&bucket, &key).await; + if has_bucket_metadata { + let committed_size = quota_accounting_object_size(&oi, quota_enabled)?; + if dest_versioned { + record_bucket_object_version_write_memory(&bucket, previous_current_size, committed_size).await; + } else { + record_bucket_object_write_memory(&bucket, previous_current_size, committed_size).await; + } + } + + rustfs_scanner::record_dirty_usage_bucket(&bucket); + Ok::<_, S3Error>((oi, dest_versioned)) + } + }); + let (oi, dest_versioned) = copy_commit.await.map_err(|err| { + S3Error::with_message(S3ErrorCode::InternalError, format!("copy object commit owner task failed: {err}")) + })??; + + let raw_dest_version = oi.version_id.map(|v| v.to_string()); + let dest_version = if dest_versioned { raw_dest_version } else { None }; + + // Echo the source version that was copied via x-amz-copy-source-version-id (issue #4976). + // AWS/MinIO return this whenever the source bucket carries versioning (enabled or + // suspended); render the null version as "null" like GET/HEAD do. This is the exact source + // version, kept distinct from the destination version_id above. + let src_versioned = BucketVersioningSys::prefix_enabled(&src_bucket, &src_key).await + || BucketVersioningSys::prefix_suspended(&src_bucket, &src_key).await; + let copy_source_version_id = if src_versioned { + src_resolved_version_id.map(|vid| { + if vid == Uuid::nil() { + "null".to_string() + } else { + vid.to_string() + } + }) + } else { + None + }; + + // Report the destination object's checksum in the response, decoded the same way GetObject + // / HeadObject do so the value is identical to a later checksum-mode HEAD/GET (#4996). + let response_checksums = oi + .decrypt_checksums(0, &req.headers) + .map(|(pairs, is_multipart)| classify_response_checksums(pairs, is_multipart)) + .unwrap_or_default(); + + // warn!("copy_object oi {:?}", &oi); + let object_info = oi.clone(); + let mut checksum_md5 = None; + let mut checksum_sha512 = None; + let mut checksum_xxhash3 = None; + let mut checksum_xxhash64 = None; + let mut checksum_xxhash128 = None; + for (name, value) in response_checksums.extra { + match name { + "x-amz-checksum-md5" => checksum_md5 = Some(value), + "x-amz-checksum-sha512" => checksum_sha512 = Some(value), + "x-amz-checksum-xxhash3" => checksum_xxhash3 = Some(value), + "x-amz-checksum-xxhash64" => checksum_xxhash64 = Some(value), + "x-amz-checksum-xxhash128" => checksum_xxhash128 = Some(value), + _ => {} + } + } + let copy_object_result = CopyObjectResult { + e_tag: oi.etag.as_ref().map(|etag| to_s3s_etag(etag)), + last_modified: oi.mod_time.map(Timestamp::from), + checksum_crc32: response_checksums.crc32, + checksum_crc32c: response_checksums.crc32c, + checksum_sha1: response_checksums.sha1, + checksum_sha256: response_checksums.sha256, + checksum_crc64nvme: response_checksums.crc64nvme, + checksum_md5, + checksum_sha512, + checksum_xxhash3, + checksum_xxhash64, + checksum_xxhash128, + checksum_type: response_checksums.checksum_type, + }; + + let output = CopyObjectOutput { + copy_object_result: Some(copy_object_result), + copy_source_version_id, + server_side_encryption: effective_sse, + ssekms_key_id: effective_kms_key_id, + sse_customer_algorithm, + sse_customer_key_md5, + version_id: dest_version, + ..Default::default() + }; + + let version_id = req.input.version_id.clone().unwrap_or_default(); + helper = helper.object(object_info).version_id(version_id); + + let result = Ok(S3Response::new(output)); + let _ = helper.complete(&result); + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http::{HeaderValue, Method}; + use s3s::dto::{ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, ServerSideEncryptionRule}; + use std::sync::Arc; + + // A malformed bucket-default algorithm reaches this resolution only through + // corrupt or hand-edited bucket metadata (PutBucketEncryption validates the + // value), so the invariant is pinned here rather than end-to-end: the copy + // path must resolve managed AES256 exactly like PUT/extract. With an + // unencrypted same-name source and no SSE-C, the resolved default alone + // keeps `copy_changes_encryption` true, so the metadata-only shortcut stays + // off while `sse_encryption` mints a fresh DEK (backlog#1826). + #[test] + fn copy_bucket_default_unknown_sse_algorithm_falls_back_to_aes256() { + let config = ServerSideEncryptionConfiguration { + rules: vec![ServerSideEncryptionRule { + apply_server_side_encryption_by_default: Some(ServerSideEncryptionByDefault { + sse_algorithm: ServerSideEncryption::from(String::from("garbage")), + kms_master_key_id: None, + }), + bucket_key_enabled: None, + }], + }; + + let effective_sse = config + .rules + .first() + .and_then(|rule| rule.apply_server_side_encryption_by_default.as_ref()) + .map(bucket_default_write_sse); + + assert_eq!(effective_sse.as_ref().map(|sse| sse.as_str()), Some(ServerSideEncryption::AES256)); + + // Valid algorithms map to themselves, byte-identical to the PUT path. + for (configured, expected) in [ + (ServerSideEncryption::AES256, ServerSideEncryption::AES256), + (ServerSideEncryption::AWS_KMS, ServerSideEncryption::AWS_KMS), + ] { + let sse = ServerSideEncryptionByDefault { + sse_algorithm: ServerSideEncryption::from_static(configured), + kms_master_key_id: None, + }; + assert_eq!(bucket_default_write_sse(&sse).as_str(), expected); + } + } + + #[tokio::test] + async fn execute_copy_object_rejects_self_copy_without_replace_directive() { + let input = CopyObjectInput::builder() + .copy_source(CopySource::Bucket { + bucket: "test-bucket".into(), + key: "test-key".into(), + version_id: None, + }) + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .build() + .unwrap(); + + let req = build_request(input, Method::PUT); + let usecase = DefaultObjectUsecase::without_context(); + + let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + } + + #[tokio::test] + async fn execute_copy_object_rejects_invalid_storage_class() { + let input = CopyObjectInput::builder() + .copy_source(CopySource::Bucket { + bucket: "src-bucket".into(), + key: "src-key".into(), + version_id: None, + }) + .bucket("dst-bucket".to_string()) + .key("dst-key".to_string()) + .storage_class(Some(StorageClass::from_static("INVALID"))) + .build() + .unwrap(); + + let req = build_request(input, Method::PUT); + let usecase = DefaultObjectUsecase::without_context(); + + let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidStorageClass); + } + + #[tokio::test] + async fn execute_copy_object_allows_self_copy_with_storage_class_change() { + let input = CopyObjectInput::builder() + .copy_source(CopySource::Bucket { + bucket: "test-bucket".into(), + key: "test-key".into(), + version_id: None, + }) + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .storage_class(Some(StorageClass::from_static(storageclass::RRS))) + .build() + .unwrap(); + + let req = build_request(input, Method::PUT); + let usecase = DefaultObjectUsecase::without_context(); + + let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); + // Self-copy with explicit storage class change must pass the self-copy guard. + assert_ne!(err.code(), &S3ErrorCode::InvalidRequest); + } + + #[tokio::test] + #[serial_test::serial] + async fn execute_self_copy_when_object_name_equals_bucket_observes_lock_order() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions}; + use s3s::access::S3Access as _; + + let store = crate::app::gating_test_env::shared_gating_ecstore().await; + if current_app_context().is_none() { + crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; + } + let ambient = current_app_context().expect("self-copy lock-order test requires an AppContext"); + let context = Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms())); + let server_ctx = crate::app::runtime_sources::ServerContextSlot::new(); + assert!(server_ctx.install(Arc::clone(&context))); + let fs = FS::with_server_ctx(server_ctx); + + let bucket = format!("self-copy-lock-order-{}", Uuid::new_v4()); + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("create self-copy test bucket"); + let payload = b"object whose key equals its bucket".to_vec(); + let mut reader = PutObjReader::from_vec(payload.clone()); + let setup_opts = ObjectOptions { + no_lock: true, + ..Default::default() + }; + store + .put_object(&bucket, &bucket, &mut reader, &setup_opts) + .await + .expect("put object whose key equals its bucket"); + + let policy_json = format!( + r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"*"}},"Action":["s3:GetObject","s3:PutObject"],"Resource":["arn:aws:s3:::{bucket}/*"]}}]}}"# + ); + let mut bucket_metadata = (*crate::storage::get_bucket_metadata(&bucket) + .await + .expect("self-copy bucket metadata should be cached")) + .clone(); + bucket_metadata.policy_config = Some(serde_json::from_str(&policy_json).expect("self-copy policy should parse")); + bucket_metadata.policy_config_json = policy_json.into_bytes(); + crate::storage::storage_api::set_bucket_metadata(bucket.clone(), bucket_metadata) + .await + .expect("publish self-copy test policy"); + + let input = CopyObjectInput::builder() + .copy_source(CopySource::Bucket { + bucket: bucket.clone().into(), + key: bucket.clone().into(), + version_id: None, + }) + .bucket(bucket.clone()) + .key(bucket.clone()) + .metadata_directive(Some(MetadataDirective::from_static(MetadataDirective::REPLACE))) + .metadata(Some(HashMap::from([("lock-order".to_string(), "verified".to_string())]))) + .build() + .expect("self-copy input should build"); + let mut req = build_request(input, Method::PUT); + req.extensions.insert(crate::storage::access::ReqInfo::default()); + fs.copy_object(&mut req) + .await + .expect("authorize self-copy whose object key equals its bucket"); + + let response = tokio::time::timeout( + std::time::Duration::from_secs(30), + DefaultObjectUsecase::with_context(Some(context)).execute_copy_object(req), + ) + .await + .expect("lifecycle, authority, and exact object locks must not deadlock") + .expect("self-copy whose object key equals its bucket should succeed"); + assert!(response.output.copy_object_result.is_some()); + let info = store + .get_object_info(&bucket, &bucket, &ObjectOptions::default()) + .await + .expect("self-copied object should remain readable"); + assert_eq!(info.size, payload.len() as i64); + + store + .delete_bucket( + &bucket, + &DeleteBucketOptions { + force: true, + ..Default::default() + }, + ) + .await + .expect("clean up self-copy test bucket"); + } + + #[tokio::test] + async fn execute_copy_object_allows_tiered_self_copy_with_storage_class_change() { + let input = CopyObjectInput::builder() + .copy_source(CopySource::Bucket { + bucket: "test-bucket".into(), + key: "test-key".into(), + version_id: None, + }) + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .storage_class(Some(StorageClass::from_static(storageclass::STANDARD))) + .metadata_directive(Some(MetadataDirective::from_static(MetadataDirective::REPLACE))) + .build() + .unwrap(); + + let req = build_request(input, Method::PUT); + let usecase = DefaultObjectUsecase::without_context(); + + let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); + // Tiered self-copy with STANDARD storage class must pass all validation checks. + // The call fails at store init (no store in unit tests), not at validation. + assert_ne!(err.code(), &S3ErrorCode::InvalidRequest); + assert_ne!(err.code(), &S3ErrorCode::NotImplemented); + } + + #[tokio::test] + async fn execute_copy_object_allows_self_copy_of_historical_version() { + // Restoring a specific historical version onto the current key (same bucket/key with a + // source versionId, default COPY directive) must pass the self-copy guard (issue #4238). + let input = CopyObjectInput::builder() + .copy_source(CopySource::Bucket { + bucket: "test-bucket".into(), + key: "test-key".into(), + version_id: Some("11111111-1111-1111-1111-111111111111".into()), + }) + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .build() + .unwrap(); + + let req = build_request(input, Method::PUT); + let usecase = DefaultObjectUsecase::without_context(); + + let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); + // Must not be rejected by the self-copy guard; it fails later at store init instead. + assert_ne!(err.code(), &S3ErrorCode::InvalidRequest); + } + + #[tokio::test] + async fn execute_copy_object_allows_self_copy_of_null_version() { + // A "null" source version id is a restore of the null version, not a no-op self-copy. + let input = CopyObjectInput::builder() + .copy_source(CopySource::Bucket { + bucket: "test-bucket".into(), + key: "test-key".into(), + version_id: Some("null".into()), + }) + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .build() + .unwrap(); + + let req = build_request(input, Method::PUT); + let usecase = DefaultObjectUsecase::without_context(); + + let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); + assert_ne!(err.code(), &S3ErrorCode::InvalidRequest); + } + + #[tokio::test] + async fn execute_copy_object_rejects_malformed_copy_source_version_id() { + // A malformed (non-null, non-UUID) source version id is rejected up front. + let input = CopyObjectInput::builder() + .copy_source(CopySource::Bucket { + bucket: "src-bucket".into(), + key: "src-key".into(), + version_id: Some("not-a-uuid".into()), + }) + .bucket("dst-bucket".to_string()) + .key("dst-key".to_string()) + .build() + .unwrap(); + + let req = build_request(input, Method::PUT); + let usecase = DefaultObjectUsecase::without_context(); + + let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + } + + #[tokio::test] + async fn execute_copy_object_rejects_expected_version_for_different_destination() { + let input = CopyObjectInput::builder() + .copy_source(CopySource::Bucket { + bucket: "test-bucket".into(), + key: "source-key".into(), + version_id: Some(Uuid::new_v4().to_string().into()), + }) + .bucket("test-bucket".to_string()) + .key("destination-key".to_string()) + .build() + .unwrap(); + let mut req = build_request(input, Method::PUT); + req.headers.insert( + RUSTFS_EXPECTED_CURRENT_VERSION_ID, + HeaderValue::from_str(&Uuid::new_v4().to_string()).unwrap(), + ); + + let err = Box::pin(DefaultObjectUsecase::without_context().execute_copy_object(req)) + .await + .unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + } +} diff --git a/rustfs/src/app/object/delete.rs b/rustfs/src/app/object/delete.rs new file mode 100644 index 000000000..a77d84242 --- /dev/null +++ b/rustfs/src/app/object/delete.rs @@ -0,0 +1,2000 @@ +// 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. + +//! DeleteObject / DeleteObjects path. + +use super::*; + +fn successful_delete_audit_objects( + delete: &s3s::dto::Delete, + successful_results: impl IntoIterator, +) -> Vec { + delete + .objects + .iter() + .zip(successful_results) + .filter(|(_, successful)| *successful) + .map(|(requested, _)| AuditObjectVersion::new(requested.key.clone(), requested.version_id.clone())) + .collect() +} + +fn normalize_delete_objects_version_id( + version_id: Option, +) -> std::result::Result<(Option, Option), String> { + let version_id = version_id.map(|v| v.trim().to_string()).filter(|v| !v.is_empty()); + match version_id { + Some(id) => { + if id.eq_ignore_ascii_case("null") { + Ok((Some("null".to_string()), Some(Uuid::nil()))) + } else { + let uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?; + Ok((Some(id), Some(uuid))) + } + } + None => Ok((None, None)), + } +} + +#[cfg(test)] +type DeleteSnapshotTestHook = (String, Arc, Arc); + +#[cfg(test)] +static DELETE_SNAPSHOT_TEST_HOOK: OnceLock>> = OnceLock::new(); + +#[cfg(test)] +static DELETE_SOURCE_TEST_HOOK: OnceLock>> = OnceLock::new(); + +#[cfg(test)] +static DELETE_OBJECTS_AUTH_TEST_HOOK: OnceLock>> = OnceLock::new(); + +#[cfg(test)] +pub(crate) fn install_delete_snapshot_test_hook( + bucket: String, + loaded: Arc, + resume: Arc, +) { + *DELETE_SNAPSHOT_TEST_HOOK + .get_or_init(|| Mutex::new(None)) + .lock() + .expect("delete snapshot test hook lock should not be poisoned") = Some((bucket, loaded, resume)); +} + +#[cfg(test)] +async fn wait_for_delete_snapshot_test_hook(bucket: &str) { + let hook = { + let mut slot = DELETE_SNAPSHOT_TEST_HOOK + .get_or_init(|| Mutex::new(None)) + .lock() + .expect("delete snapshot test hook lock should not be poisoned"); + if slot.as_ref().is_some_and(|(expected_bucket, _, _)| expected_bucket == bucket) { + slot.take() + } else { + None + } + }; + if let Some((_bucket, loaded, resume)) = hook { + loaded.wait().await; + resume.wait().await; + } +} + +#[cfg(test)] +pub(crate) fn install_delete_source_test_hook( + bucket: String, + loaded: Arc, + resume: Arc, +) { + *DELETE_SOURCE_TEST_HOOK + .get_or_init(|| Mutex::new(None)) + .lock() + .expect("delete source test hook lock should not be poisoned") = Some((bucket, loaded, resume)); +} + +#[cfg(test)] +async fn wait_for_delete_source_test_hook(bucket: &str) { + let hook = { + let mut slot = DELETE_SOURCE_TEST_HOOK + .get_or_init(|| Mutex::new(None)) + .lock() + .expect("delete source test hook lock should not be poisoned"); + if slot.as_ref().is_some_and(|(expected_bucket, _, _)| expected_bucket == bucket) { + slot.take() + } else { + None + } + }; + if let Some((_bucket, loaded, resume)) = hook { + loaded.wait().await; + resume.wait().await; + } +} + +#[cfg(test)] +pub(crate) fn install_delete_objects_auth_test_hook( + bucket: String, + loaded: Arc, + resume: Arc, +) { + *DELETE_OBJECTS_AUTH_TEST_HOOK + .get_or_init(|| Mutex::new(None)) + .lock() + .expect("delete objects auth test hook lock should not be poisoned") = Some((bucket, loaded, resume)); +} + +#[cfg(test)] +async fn wait_for_delete_objects_auth_test_hook(bucket: &str) { + let hook = { + let mut slot = DELETE_OBJECTS_AUTH_TEST_HOOK + .get_or_init(|| Mutex::new(None)) + .lock() + .expect("delete objects auth test hook lock should not be poisoned"); + if slot.as_ref().is_some_and(|(expected_bucket, _, _)| expected_bucket == bucket) { + slot.take() + } else { + None + } + }; + if let Some((_bucket, loaded, resume)) = hook { + loaded.wait().await; + resume.wait().await; + } +} + +fn enrich_delete_replication_state_if_needed( + snapshot: &DeleteReplicationConfigSnapshot, + delete_object: &mut StorageDeletedObject, + obj_info: &ObjectInfo, +) { + let Some(replication_state) = delete_object.replication_state.as_ref() else { + return; + }; + if obj_info.replication_status != ReplicationStatusType::Replica + && !replication_state.replicate_decision_str.is_empty() + && (!replication_state.targets.is_empty() || !replication_state.purge_targets.is_empty()) + { + return; + } + + let Some(config) = snapshot.replication_config() else { + return; + }; + let version_id = if delete_object.delete_marker { + None + } else if delete_object.delete_marker_version_id.is_some() { + delete_object.delete_marker_version_id + } else { + delete_object.version_id + }; + if let Some(local_state) = delete_replication_state_from_config( + config, + obj_info, + version_id, + obj_info.replication_status == ReplicationStatusType::Replica, + ) { + set_deleted_object_replication_state(delete_object, &local_state); + } +} + +fn should_schedule_replica_delete_replication( + snapshot: &DeleteReplicationConfigSnapshot, + replication_source: &ObjectInfo, + version_id: Option, +) -> bool { + let Some(config) = snapshot.replication_config() else { + return false; + }; + + delete_replication_state_from_config(config, replication_source, version_id, true).is_some() +} + +fn validate_undo_delete_version(expected: Option<&str>, requested: Option<&str>) -> S3Result<()> { + if expected.is_some() && expected != requested { + return Err(s3_error!(PreconditionFailed)); + } + Ok(()) +} + +fn delete_creates_delete_marker(opts: &ObjectOptions) -> bool { + opts.version_id.is_none() && opts.versioned && !opts.version_suspended +} + +fn delete_removes_current_object(opts: &ObjectOptions) -> bool { + delete_request_targets_current( + opts.version_id + .as_deref() + .and_then(|version_id| Uuid::parse_str(version_id).ok()), + ) +} + +fn delete_request_targets_current(version_id: Option) -> bool { + version_id.is_none() || version_id.is_some_and(|version_id| version_id.is_nil()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DeleteMemoryUpdate { + DeleteMarker, + Object { size: u64, removed_current_object: bool }, +} + +fn delete_memory_update( + creates_delete_marker: bool, + committed_delete_marker: bool, + requested_current: bool, + accounting_size: Option, + removed_current_object: bool, +) -> Option { + if creates_delete_marker || (committed_delete_marker && requested_current) { + return Some(DeleteMemoryUpdate::DeleteMarker); + } + + (!committed_delete_marker) + .then_some(accounting_size) + .flatten() + .map(|size| DeleteMemoryUpdate::Object { + size, + removed_current_object, + }) +} + +async fn apply_delete_memory_update(bucket: &str, update: Option) { + match update { + Some(DeleteMemoryUpdate::DeleteMarker) => record_bucket_delete_marker_memory(bucket).await, + Some(DeleteMemoryUpdate::Object { + size, + removed_current_object, + }) => record_bucket_object_delete_memory(bucket, size, removed_current_object).await, + None => {} + } +} + +/// `DeleteObjects` is idempotent. A raw filesystem `NotFound` can cross the +/// distributed delete path instead of its usual typed missing-object error. +fn is_delete_objects_not_found(error: &EcstoreError) -> bool { + is_err_object_not_found(error) + || is_err_version_not_found(error) + || matches!(error, StorageError::Io(source) if source.kind() == std::io::ErrorKind::NotFound) +} + +/// Bounded concurrency for the per-object pre-delete stat fanout in +/// `execute_delete_objects` (backlog#929 / HP-8). Keeps the metadata reads for +/// a 1000-key batch from serializing while capping the disk fanout pressure. +const DELETE_OBJECTS_PRE_STAT_CONCURRENCY: usize = 16; + +/// backlog#929 (HP-8): whether the pre-delete `get_object_info` for one entry +/// of a DeleteObjects batch can be skipped without changing behavior. +/// +/// The stat result feeds four consumers, and each must be provably idle: +/// - the app-layer object-lock admission check never runs for deletes that +/// create a delete marker, and non-lock buckets cannot hold retention or +/// legal-hold metadata (`bucket_lock_enabled == false`); +/// - replication reads its authoritative source metadata later while the +/// SetDisks write lock is held, so it does not consume this advisory stat; +/// - usage accounting for delete-marker creation goes through +/// `record_bucket_delete_marker_memory` and never reads the object size +/// (`accounting_creates_delete_marker` is computed from the same versioning +/// snapshot the accounting branch uses); +/// - transitioned-object (ILM tier) cleanup journaling is a no-op for +/// delete-marker creation because no version is removed, so `ObjSweeper` +/// produces no journal entry regardless of the stat result. +/// +/// Object-lock enabled buckets always keep the stat, so their delete path is +/// byte-for-byte the pre-#929 one (see PR #4297). +fn can_skip_delete_objects_pre_stat( + bucket_lock_enabled: bool, + opts: &ObjectOptions, + accounting_creates_delete_marker: bool, +) -> bool { + !bucket_lock_enabled && delete_creates_delete_marker(opts) && accounting_creates_delete_marker +} + +fn complete_delete_noop( + helper: OperationHelper, + bucket: String, + key: String, + version_id: Option, +) -> (S3Result>, OperationHelper) { + let helper = helper + .event_name(EventName::ObjectRemovedNoOP) + .object(ObjectInfo { + name: key, + bucket, + ..Default::default() + }) + .version_id(version_id.unwrap_or_default()); + let result = Ok(S3Response::with_status(DeleteObjectOutput::default(), StatusCode::NO_CONTENT)); + let helper = helper.complete(&result); + (result, helper) +} + +fn delete_response_version_id(version_id: Option, synthetic_version_id: bool) -> Option { + if synthetic_version_id { + None + } else if version_id == Some(Uuid::nil()) { + Some(NULL_VERSION_ID.to_string()) + } else { + version_id.map(|version_id| version_id.to_string()) + } +} + +fn reduce_delete_objects_result<'a>( + object: &ObjectToDelete, + deleted: &'a StorageDeletedObject, + error: Option<&EcstoreError>, + synthetic_version_id: bool, +) -> Result<&'a StorageDeletedObject, s3s::dto::Error> { + match error { + None => Ok(deleted), + Some(error) if is_delete_objects_not_found(error) => Ok(deleted), + Some(error) => { + let api_error = ApiError::from(error.clone()); + Err(s3s::dto::Error { + code: Some(api_error.code.as_str().to_string()), + key: Some(object.object_name.clone()), + message: Some(api_error.message), + version_id: delete_response_version_id(object.version_id, synthetic_version_id), + }) + } + } +} + +impl DefaultObjectUsecase { + #[instrument(level = "debug", skip(self, req))] + pub async fn execute_delete_objects( + &self, + mut req: S3Request, + ) -> S3Result> { + if let Some(context) = &self.context { + let _ = context.object_store(); + } + + let helper = OperationHelper::new(&req, EventName::ObjectRemovedDelete, S3Operation::DeleteObjects).suppress_event(); + let request_context = helper.request_context_or_from_request(&req); + let (bucket, delete) = { + let bucket = req.input.bucket.clone(); + let delete = req.input.delete.clone(); + (bucket, delete) + }; + + if delete.objects.is_empty() || delete.objects.len() > 1000 { + return Err(S3Error::with_message( + S3ErrorCode::InvalidArgument, + "No objects to delete or too many objects to delete".to_string(), + )); + } + + let is_owner = req_info_ref(&req).map(|info| info.is_owner).unwrap_or(false); + if !recursive_force_delete_is_authorized(&req.headers, is_owner, false) { + return Err(S3Error::with_message( + S3ErrorCode::AccessDenied, + "Recursive force-delete is restricted to administrative requests", + )); + } + + let Some(store) = self.object_store() else { + return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); + }; + // Capture the bucket generation before per-object authorization, but + // do not expose a bucket-state error unless at least one object is authorized. + let bucket_generation = load_bucket_generation_from_store(store.as_ref(), &req, &bucket).await; + + let bypass_governance = has_bypass_governance_header(&req.headers); + + #[derive(Default, Clone)] + struct DeleteResult { + delete_object: Option, + error: Option, + synthetic_version_id: bool, + } + + let mut delete_results = vec![DeleteResult::default(); delete.objects.len()]; + + struct AuthorizedDelete { + idx: usize, + object: ObjectToDelete, + } + + let mut authorized_deletes = Vec::with_capacity(delete.objects.len()); + // Issue #5740: keep the first per-key denial of this bulk request at + // warn and demote the rest to debug, so a denied 1000-key DeleteObjects + // cannot flood the log. + let mut bulk_denial_logged = false; + for (idx, obj_id) in delete.objects.iter().enumerate() { + let raw_version_id = obj_id.version_id.clone(); + let (version_id, version_uuid) = match normalize_delete_objects_version_id(raw_version_id.clone()) { + Ok(parsed) => parsed, + Err(err) => { + delete_results[idx].error = Some(s3s::dto::Error { + code: Some("NoSuchVersion".to_string()), + key: Some(obj_id.key.clone()), + message: Some(err), + version_id: raw_version_id, + }); + continue; + } + }; + + { + let req_info = req_info_mut(&mut req)?; + req_info.bucket = Some(bucket.clone()); + req_info.object = Some(obj_id.key.clone()); + req_info.version_id = version_id.clone(); + } + + let auth_res = authorize_request(&mut req, Action::S3Action(S3Action::DeleteObjectAction)).await; + if auth_res.is_err() { + if !bulk_denial_logged { + bulk_denial_logged = true; + req_info_mut(&mut req)?.suppress_denial_log = true; + } + delete_results[idx].error = Some(s3s::dto::Error { + code: Some("AccessDenied".to_string()), + key: Some(obj_id.key.clone()), + message: Some("Access Denied".to_string()), + version_id: version_id.clone(), + }); + continue; + } + + if bypass_governance { + let auth_res = authorize_request(&mut req, Action::S3Action(S3Action::BypassGovernanceRetentionAction)).await; + if auth_res.is_err() { + if !bulk_denial_logged { + bulk_denial_logged = true; + req_info_mut(&mut req)?.suppress_denial_log = true; + } + delete_results[idx].error = Some(s3s::dto::Error { + code: Some("AccessDenied".to_string()), + key: Some(obj_id.key.clone()), + message: Some("Access Denied".to_string()), + version_id: version_id.clone(), + }); + continue; + } + } + + if let Err(err) = validate_table_catalog_object_mutation(&bucket, &obj_id.key).await { + delete_results[idx].error = Some(s3s::dto::Error { + code: Some("InvalidRequest".to_string()), + key: Some(obj_id.key.clone()), + message: Some(err.to_string()), + version_id: version_id.clone(), + }); + continue; + } + + let synthetic_version_id = version_id.is_none() && is_dir_object(&obj_id.key); + let object = ObjectToDelete { + object_name: obj_id.key.clone(), + version_id: version_uuid, + synthetic_version_id, + ..Default::default() + }; + delete_results[idx].synthetic_version_id = synthetic_version_id; + + authorized_deletes.push(AuthorizedDelete { idx, object }); + } + + if authorized_deletes.is_empty() { + let output = DeleteObjectsOutput { + deleted: Some(Vec::new()), + errors: Some(delete_results.into_iter().filter_map(|result| result.error).collect()), + ..Default::default() + }; + let result = Ok(S3Response::new(output)); + let _ = helper.complete(&result); + return result; + } + #[cfg(test)] + wait_for_delete_objects_auth_test_hook(&bucket).await; + req.extensions.insert(bucket_generation?); + let bucket_lock_enabled = object_lock_checks_required(&bucket).await; + + let delete_config_snapshot = Arc::new( + load_delete_config_snapshot(store.as_ref(), &bucket) + .await + .map_err(ApiError::from)?, + ); + let version_cfg = delete_config_snapshot.versioning_config(); + let replicate_deletes = authorized_deletes + .iter() + .any(|authorized| has_active_delete_rule(&delete_config_snapshot, &authorized.object.object_name)); + + struct PreparedDelete { + idx: usize, + object: ObjectToDelete, + opts: ObjectOptions, + skip_stat: bool, + } + + // Phase 1 (serial): derive storage options from the request-scoped + // configuration after every candidate has passed authorization. + let mut prepared_deletes: Vec = Vec::with_capacity(authorized_deletes.len()); + for authorized in authorized_deletes { + let AuthorizedDelete { idx, object } = authorized; + + let metadata = extract_metadata(&req.headers); + let opts: ObjectOptions = del_opts_with_versioning( + &bucket, + &object.object_name, + object.version_id.map(|f| f.to_string()), + &req.headers, + metadata, + version_cfg, + false, + ) + .map_err(ApiError::from)?; + + // backlog#929 (HP-8): the accounting branch after the store delete + // decides delete-marker vs object-delete from this exact snapshot, + // so evaluate it here with the same inputs to keep the stat-skip + // decision and the accounting path provably consistent. + let accounting_creates_delete_marker = object.version_id.is_none() && opts.versioned && !opts.version_suspended; + let skip_stat = can_skip_delete_objects_pre_stat(bucket_lock_enabled, &opts, accounting_creates_delete_marker); + + prepared_deletes.push(PreparedDelete { + idx, + object, + opts, + skip_stat, + }); + } + + struct AdmittedDelete { + idx: usize, + object: ObjectToDelete, + versioned: bool, + version_suspended: bool, + } + + // Phase 2 (bounded concurrency, backlog#929 / HP-8): collect the + // metadata needed for accounting and tier cleanup. Entries are + // independent per key, and `buffered` preserves input order. Object + // Lock admission is enforced later in set_disk under the write lock. + let store_ref = &store; + let bucket_ref = bucket.as_str(); + let admitted_deletes: Vec = + futures::stream::iter(prepared_deletes.into_iter().map(|prepared| async move { + let PreparedDelete { + idx, + mut object, + opts, + skip_stat, + } = prepared; + let synthetic_version_id = object.version_id.is_none() && is_dir_object(&object.object_name); + if !skip_stat { + match store_ref.get_object_info(bucket_ref, &object.object_name, &opts).await { + Ok(_) => {} + Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {} + Err(err) => return Err(ApiError::from(err)), + } + } + + if synthetic_version_id { + object.version_id = Some(Uuid::nil()); + } + + Ok::<_, ApiError>(AdmittedDelete { + idx, + object, + versioned: opts.versioned, + version_suspended: opts.version_suspended, + }) + })) + .buffered(DELETE_OBJECTS_PRE_STAT_CONCURRENCY) + .try_collect() + .await?; + + // Phase 3 (serial): apply outcomes in the original request order so + // per-key success/failure reporting is unchanged. + let mut object_to_delete = Vec::new(); + let mut object_to_delete_idx = Vec::new(); + let mut object_versioning = Vec::new(); + for admitted in admitted_deletes { + object_to_delete_idx.push(admitted.idx); + object_versioning.push((admitted.versioned, admitted.version_suspended)); + object_to_delete.push(admitted.object); + } + let cache_adapter = self.object_data_cache(); + let cache_keys_before_delete = object_to_delete + .iter() + .map(|object| object.object_name.clone()) + .collect::>(); + invalidate_object_data_cache_objects_before_mutation(&cache_adapter, &bucket, cache_keys_before_delete.iter()).await; + + let mut storage_delete_opts = ObjectOptions { + versioned: version_cfg.enabled(), + version_suspended: version_cfg.suspended(), + delete_replication_config_snapshot: Some(Arc::clone(&delete_config_snapshot)), + object_lock_delete: Some(StorageObjectLockDeleteOptions { bypass_governance }), + ..Default::default() + }; + apply_bucket_generation_guard(&req, &bucket, &mut storage_delete_opts)?; + let (dobjs, errs, accounting) = store + .delete_objects_with_tier_delete_journal_and_accounting(&bucket, object_to_delete.clone(), storage_delete_opts) + .await; + + let _manager = get_concurrency_manager(); + let _bucket_clone = bucket.clone(); + let _deleted_objects = dobjs.clone(); + if !errs.is_empty() && errs.iter().all(|err| err.as_ref().is_some_and(is_err_bucket_not_found)) { + let result = Err(S3Error::with_message(S3ErrorCode::NoSuchBucket, "Bucket not found".to_string())); + let _ = helper.complete(&result); + return result; + } + + for (i, err) in errs.iter().enumerate() { + let didx = object_to_delete_idx[i]; + + match reduce_delete_objects_result( + &object_to_delete[i], + &dobjs[i], + err.as_ref(), + delete_results[didx].synthetic_version_id, + ) { + Ok(deleted_object) => { + delete_results[didx].delete_object = Some(deleted_object.clone()); + let (versioned, version_suspended) = object_versioning[i]; + let creates_delete_marker = object_to_delete[i].version_id.is_none() && versioned && !version_suspended; + let committed_delete_marker = dobjs[i].delete_marker; + let delete_accounting = accounting.get(i).and_then(Option::as_ref); + let update = delete_memory_update( + creates_delete_marker, + committed_delete_marker, + delete_request_targets_current(object_to_delete[i].version_id), + delete_accounting.and_then(|value| value.size), + delete_accounting.is_some_and(|value| value.removed_current_object), + ); + apply_delete_memory_update(&bucket, update).await; + } + Err(error) => { + delete_results[didx].error = Some(error); + } + } + } + + let deleted = delete_results + .iter() + .filter_map(|result| result.delete_object.as_ref().map(|object| (result, object))) + .map(|(result, object)| DeletedObject { + delete_marker: { if object.delete_marker { Some(true) } else { None } }, + delete_marker_version_id: delete_response_version_id( + object.delete_marker_version_id, + result.synthetic_version_id, + ), + key: Some(object.object_name.clone()), + version_id: delete_response_version_id(object.version_id, result.synthetic_version_id), + }) + .collect(); + let deleted_cache_keys = delete_results + .iter() + .filter_map(|result| result.delete_object.as_ref().map(|deleted| deleted.object_name.clone())) + .collect::>(); + invalidate_object_data_cache_objects_after_delete_success(&cache_adapter, &bucket, deleted_cache_keys.iter()).await; + + let errors = delete_results + .iter() + .filter_map(|v| v.error.clone()) + .collect::>(); + let output = DeleteObjectsOutput { + deleted: Some(deleted), + errors: Some(errors), + ..Default::default() + }; + let helper = if helper.wants_audit_object_info() { + let audit_objects = + successful_delete_audit_objects(&delete, delete_results.iter().map(|result| result.delete_object.is_some())); + helper.audit_objects(audit_objects) + } else { + helper + }; + + let replication_deletes = if replicate_deletes { + delete_results + .iter() + .filter_map(|result| result.delete_object.as_ref()) + .filter(|dobj| deleted_object_has_pending_replication_delete(dobj)) + .cloned() + .collect::>() + } else { + Vec::new() + }; + if !replication_deletes.is_empty() { + let bucket_for_replication = bucket.clone(); + let replication_task = tokio::spawn(async move { + let _activity_guard = DeleteTailActivityGuard::new(DeleteTailStage::Replication); + schedule_replication_deletes(replication_deletes, bucket_for_replication, REPLICATE_INCOMING_DELETE.to_string()) + .await; + }); + // The spawned task owns every locally committed delete. Dropping the + // join handle on request cancellation therefore cannot lose the tail. + let _ = replication_task.await; + } + + let req_headers = req.headers.clone(); + let notify = current_notify_interface_for_context(self.context.as_deref()); + let req_params = rustfs_targets::extract_params_header(&req_headers); + let resp_elements = + build_event_resp_elements(&S3Response::new(DeleteObjectsOutput::default()), &request_context.request_id); + let deleted_any = delete_results.iter().any(|result| result.delete_object.is_some()); + let notify_bucket = bucket.clone(); + spawn_background_with_context(Some(request_context), async move { + let _activity_guard = DeleteTailActivityGuard::new(DeleteTailStage::Notify); + for res in delete_results { + if let Some(dobj) = res.delete_object { + let event_name = delete_event_name_for_marker(dobj.delete_marker); + let event_args = EventArgsBuilder::new( + event_name, + notify_bucket.clone(), + convert_ecstore_object_info(ObjectInfo { + name: dobj.object_name.clone(), + bucket: notify_bucket.clone(), + ..Default::default() + }), + ) + .version_id(delete_response_version_id(dobj.version_id, res.synthetic_version_id).unwrap_or_default()) + .req_params(req_params.clone()) + .resp_elements(resp_elements.clone()) + .host(get_request_host(&req_headers)) + .user_agent(get_request_user_agent(&req_headers)) + .build(); + + notify.notify(event_args).await; + } + } + }); + + let result = Ok(S3Response::new(output)); + let _ = helper.complete(&result); + if deleted_any { + rustfs_scanner::record_dirty_usage_bucket(&bucket); + } + // Record write operation for capacity management (inline to avoid per-request tokio::spawn overhead) + let manager = get_capacity_manager(); + manager.record_write_operation().await; + result + } + + #[instrument(level = "info", skip(self, req))] + pub async fn execute_delete_object(&self, mut req: S3Request) -> S3Result> { + if let Some(context) = &self.context { + let _ = context.object_store(); + } + + let mut helper = OperationHelper::new(&req, EventName::ObjectRemovedDelete, S3Operation::DeleteObject); + let DeleteObjectInput { + bucket, key, version_id, .. + } = req.input.clone(); + + // Validate object key + validate_object_key(&key, "DELETE")?; + + let replica = req + .headers + .get(AMZ_BUCKET_REPLICATION_STATUS) + .map(|v| v.to_str().unwrap_or_default() == ReplicationStatusType::Replica.as_str()) + .unwrap_or_default(); + + if replica { + authorize_request(&mut req, Action::S3Action(S3Action::ReplicateDeleteAction)).await?; + } + + let is_owner = req_info_ref(&req).map(|info| info.is_owner).unwrap_or(false); + if !recursive_force_delete_is_authorized(&req.headers, is_owner, replica) { + return Err(S3Error::with_message( + S3ErrorCode::AccessDenied, + "Recursive force-delete is restricted to internal or administrative requests", + )); + } + validate_table_catalog_object_mutation(&bucket, &key).await?; + + // Establish bucket existence before any bucket-metadata work (matches + // PUT/GET): nonexistent buckets fail here instead of paying the + // versioning lookups in del_opts/get_opts first. Resolve the store + // through the request-bound server context (backlog#1052 S6), not the + // process-global handle. + let Some(store) = self.object_store() else { + return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); + }; + validate_bucket_exists(&store, &bucket).await?; + + let metadata = extract_metadata(&req.headers); + // Clone version_id before it's moved + let version_id_clone = version_id.clone(); + let synthetic_version_id = version_id_clone.is_none() && is_dir_object(&key); + + let delete_config_snapshot = Arc::new( + load_delete_config_snapshot(store.as_ref(), &bucket) + .await + .map_err(ApiError::from)?, + ); + #[cfg(test)] + wait_for_delete_snapshot_test_hook(&bucket).await; + let version_cfg = delete_config_snapshot.versioning_config(); + let mut opts: ObjectOptions = + del_opts_with_versioning(&bucket, &key, version_id, &req.headers, metadata, version_cfg, replica) + .map_err(ApiError::from)?; + opts.delete_replication_config_snapshot = Some(Arc::clone(&delete_config_snapshot)); + opts.object_lock_delete = Some(StorageObjectLockDeleteOptions { + bypass_governance: has_bypass_governance_header(&req.headers), + }); + apply_bucket_generation_guard(&req, &bucket, &mut opts)?; + let force_delete = opts.delete_prefix; + + // let mut vid = opts.version_id.clone(); + + if replica { + opts.set_replica_status(ReplicationStatusType::Replica); + + // if opts.version_purge_status().is_empty() { + // vid = None; + // } + } + + let expected_current_version_id = expected_current_version_id(&req.headers)?; + if expected_current_version_id.is_some() && (force_delete || !opts.versioned) { + return Err(s3_error!( + InvalidRequest, + "Expected current version precondition requires a version-specific delete in a versioned bucket" + )); + } + validate_undo_delete_version(expected_current_version_id.as_deref(), opts.version_id.as_deref())?; + opts.expected_current_version_id = expected_current_version_id.clone(); + + let replicate_force_delete = force_delete && !replica && has_active_delete_rule(&delete_config_snapshot, &key); + let mut force_delete_intent = None; + + let get_opts = opts.clone(); + let existing_object_info = match store.get_object_info(&bucket, &key, &get_opts).await { + Ok(obj_info) => Some(obj_info), + Err(err) => { + // If object not found, allow deletion to proceed (will return 204 No Content) + if !is_err_object_not_found(&err) && !is_err_version_not_found(&err) { + return Err(ApiError::from(err).into()); + } + None + } + }; + #[cfg(test)] + wait_for_delete_source_test_hook(&bucket).await; + + let cache_adapter = self.object_data_cache(); + // A force (delete_prefix) delete removes every object under `key` as a + // prefix, so invalidating only the exact key would strand every cached + // body beneath it. Use the prefix primitive in that branch (ODC-27). + if force_delete { + let _ = invalidate_object_data_cache_prefix_before_mutation(&cache_adapter, &bucket, &key).await; + } else { + let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &key).await; + } + + if replicate_force_delete + && let Some((target_arns, generation)) = force_delete_target_set(&delete_config_snapshot, &key) + && !target_arns.is_empty() + { + let operation_id = + persist_force_delete_intent(store.clone(), bucket.clone(), key.clone(), target_arns.clone(), generation) + .await + .map_err(ApiError::from)?; + force_delete_intent = Some((operation_id, target_arns, generation)); + } + + let obj_info = { + match store + .delete_object_with_tier_delete_journal(&bucket, &key, opts.clone()) + .await + { + Ok(obj) => obj, + Err(err) => { + if let Some((operation_id, _, _)) = force_delete_intent.as_ref() + && let Err(cleanup_error) = + crate::storage::storage_api::complete_force_delete_intent(store.clone(), *operation_id).await + { + warn!( + bucket = %bucket, + object = %key, + operation_id = %operation_id, + error = %cleanup_error, + "failed to remove uncommitted force-delete intent after local delete failure" + ); + } + if is_err_bucket_not_found(&err) { + return Err(S3Error::with_message(S3ErrorCode::NoSuchBucket, "Bucket not found".to_string())); + } + + if is_err_object_not_found(&err) || is_err_version_not_found(&err) { + let (result, _helper) = complete_delete_noop(helper, bucket, key, version_id_clone); + return result; + } + + if matches!(&err, StorageError::PrefixAccessDenied(_, _)) + && let Some(existing_object_info) = existing_object_info.as_ref() + && let Some(reason) = check_object_lock_for_deletion( + &bucket, + existing_object_info, + has_bypass_governance_header(&req.headers), + ) + .await + { + return Err(S3Error::with_message(S3ErrorCode::AccessDenied, reason.error_message())); + } + + return Err(ApiError::from(err).into()); + } + } + }; + + if force_delete { + let _ = invalidate_object_data_cache_prefix_after_delete(&cache_adapter, &bucket, &key).await; + } else { + let _ = invalidate_object_data_cache_after_delete_success(&cache_adapter, &bucket, &key).await; + } + + // Fast in-memory update for immediate quota and admin usage consistency. + // Prefix/force deletes and synthetic directory entries do not carry one + // committed object identity; leave their cache delta to reconciliation. + let update = if force_delete || obj_info.name.is_empty() || synthetic_version_id { + None + } else { + // The storage commit returns this object's metadata while its + // generation lock is held. Never fall back to a pre-delete stat: + // an overwrite can commit between that stat and this delete. + delete_memory_update( + delete_creates_delete_marker(&opts), + obj_info.delete_marker, + opts.version_id.is_none(), + quota_object_size(&obj_info).ok(), + delete_removes_current_object(&opts), + ) + }; + apply_delete_memory_update(&bucket, update).await; + + if obj_info.name.is_empty() { + if let Some((operation_id, target_arns, generation)) = force_delete_intent { + if let Err(error) = commit_force_delete_intent(store.clone(), operation_id).await { + warn!( + bucket = %bucket, + object = %key, + operation_id = %operation_id, + error = %error, + "failed to mark force-delete intent committed after local delete" + ); + } + let generation = i64::try_from(generation.unix_timestamp_nanos()).unwrap_or(i64::MAX); + schedule_replication_delete( + StorageDeletedObject { + object_name: key.clone(), + force_delete: true, + force_delete_id: Some(operation_id), + force_delete_target_arns: target_arns, + force_delete_generation: Some(generation), + ..Default::default() + }, + bucket.clone(), + REPLICATE_INCOMING_DELETE.to_string(), + ) + .await; + } else if replicate_force_delete { + let mut delete_object = StorageDeletedObject { + object_name: key.clone(), + force_delete: true, + ..Default::default() + }; + if let Some(replication_state) = delete_replication_state_from_config( + delete_config_snapshot + .replication_config() + .unwrap_or_else(|| unreachable!("force-delete requires a replication config")), + &ObjectInfo { + bucket: bucket.clone(), + name: key.clone(), + ..Default::default() + }, + None, + false, + ) { + set_deleted_object_replication_state(&mut delete_object, &replication_state); + } + schedule_replication_delete(delete_object, bucket.clone(), REPLICATE_INCOMING_DELETE.to_string()).await; + } + // Prefix/force-delete returns empty ObjectInfo; still emit bucket notification so webhooks match S3 DELETE. + helper = helper + .event_name(delete_event_name_for_marker(false)) + .object(ObjectInfo { + name: key.clone(), + bucket: bucket.clone(), + ..Default::default() + }) + .version_id(String::new()); + let result = Ok(S3Response::with_status(DeleteObjectOutput::default(), StatusCode::NO_CONTENT)); + // Match non-empty delete path: capacity manager write-op telemetry. + let manager = get_capacity_manager(); + manager.record_write_operation().await; + let _ = helper.complete(&result); + rustfs_scanner::record_dirty_usage_bucket(&bucket); + return result; + } + + let deleted_replication_info = existing_object_info + .as_ref() + .filter(|_| should_use_existing_delete_replication_info(&opts, opts.version_id.is_some())); + let _delete_tail_guard = DeleteTailActivityGuard::new(DeleteTailStage::Tail); + let deleted_object_source = deleted_replication_info.unwrap_or(&obj_info); + let replication_state_source = &obj_info; + let deleted_delete_marker_version = deleted_replication_info.is_some_and(|info| info.delete_marker); + + let delete_replication_version_id = delete_replication_version_id(deleted_object_source, deleted_delete_marker_version); + let schedule_delete_replication = if opts.replication_request && replica { + should_schedule_replica_delete_replication( + &delete_config_snapshot, + replication_state_source, + delete_replication_version_id, + ) + } else { + should_schedule_delete_replication( + &opts, + replication_state_source, + deleted_delete_marker_version, + opts.version_id.is_some(), + ) + }; + + if schedule_delete_replication { + let _activity_guard = DeleteTailActivityGuard::new(DeleteTailStage::Replication); + let mut deleted_object = StorageDeletedObject { + delete_marker: deleted_object_source.delete_marker && !deleted_delete_marker_version, + delete_marker_version_id: if deleted_object_source.delete_marker { + deleted_object_source.version_id + } else { + None + }, + object_name: key.clone(), + version_id: if deleted_object_source.delete_marker { + None + } else { + deleted_object_source.version_id + }, + delete_marker_mtime: deleted_object_source.mod_time, + replication_state: None, + ..Default::default() + }; + set_deleted_object_replication_state(&mut deleted_object, &replication_state_source.replication_state()); + enrich_delete_replication_state_if_needed(&delete_config_snapshot, &mut deleted_object, replication_state_source); + schedule_replication_delete(deleted_object, bucket.clone(), REPLICATE_INCOMING_DELETE.to_string()).await; + } + + let delete_marker = obj_info.delete_marker; + let version_id = obj_info.version_id; + let response_version_id = delete_response_version_id(version_id, synthetic_version_id); + + let output = DeleteObjectOutput { + delete_marker: Some(delete_marker), + version_id: response_version_id.clone(), + ..Default::default() + }; + + let event_name = delete_event_name_for_marker(delete_marker); + + helper = helper.event_name(event_name); + helper = helper.object(obj_info).version_id(response_version_id.unwrap_or_default()); + + let result = Ok(S3Response::new(output)); + // Record write operation for capacity management (inline to avoid per-request tokio::spawn overhead) + let manager = get_capacity_manager(); + manager.record_write_operation().await; + let _ = helper.complete(&result); + rustfs_scanner::record_dirty_usage_bucket(&bucket); + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http::{HeaderMap, HeaderValue, Method}; + use s3s::dto::{ + Delete, DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication, DeleteReplicationStatus, Destination, + ExistingObjectReplication, ExistingObjectReplicationStatus, ObjectIdentifier, ReplicaModifications, + ReplicaModificationsStatus, ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus, SourceSelectionCriteria, + }; + use std::sync::Arc; + + #[test] + fn delete_response_version_id_preserves_null_and_synthetic_semantics() { + let version_id = Uuid::new_v4(); + + assert_eq!(delete_response_version_id(Some(version_id), false), Some(version_id.to_string())); + assert_eq!(delete_response_version_id(Some(Uuid::nil()), false), Some("null".to_string())); + assert_eq!(delete_response_version_id(Some(Uuid::nil()), true), None); + assert_eq!(delete_response_version_id(None, false), None); + } + + #[tokio::test] + async fn execute_delete_object_rejects_invalid_object_key() { + let input = DeleteObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("bad\0key".to_string()) + .build() + .unwrap(); + + let req = build_request(input, Method::DELETE); + let usecase = DefaultObjectUsecase::without_context(); + + let err = Box::pin(usecase.execute_delete_object(req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + } + + #[test] + fn delete_not_found_completes_noop_event_with_version_context() { + temp_env::with_var(rustfs_config::ENV_NOTIFY_ENABLE, Some("true"), || { + crate::server::refresh_notify_module_enabled(); + for (version_id, expected_version) in [(None, ""), (Some("requested-version".to_string()), "requested-version")] { + let input = DeleteObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("missing-key".to_string()) + .version_id(version_id.clone()) + .build() + .expect("delete input should build"); + let mut req = build_request(input, Method::DELETE); + req.extensions.insert(crate::storage::access::ReqInfo { + bucket: Some("test-bucket".to_string()), + object: Some("missing-key".to_string()), + version_id: version_id.clone(), + ..Default::default() + }); + let helper = OperationHelper::new(&req, EventName::ObjectRemovedDelete, S3Operation::DeleteObject); + + let (result, helper) = + complete_delete_noop(helper, "test-bucket".to_string(), "missing-key".to_string(), version_id); + let event = helper.event_args().expect("successful no-op delete should retain an event"); + + assert_eq!(result.expect("no-op delete should succeed").status, Some(StatusCode::NO_CONTENT)); + assert_eq!(event.event_name, EventName::ObjectRemovedNoOP); + assert_eq!(event.bucket_name, "test-bucket"); + assert_eq!(event.object.name, "missing-key"); + assert_eq!(event.version_id, expected_version); + } + }); + crate::server::refresh_notify_module_enabled(); + } + + #[test] + fn undo_delete_requires_version_id_to_match_expected_current() { + let expected = Uuid::new_v4().to_string(); + assert!(validate_undo_delete_version(Some(&expected), Some(&expected)).is_ok()); + assert_eq!( + validate_undo_delete_version(Some(&expected), Some(&Uuid::new_v4().to_string())) + .unwrap_err() + .code(), + &S3ErrorCode::PreconditionFailed + ); + assert_eq!( + validate_undo_delete_version(Some(&expected), None).unwrap_err().code(), + &S3ErrorCode::PreconditionFailed + ); + assert!(validate_undo_delete_version(None, None).is_ok()); + } + + #[tokio::test] + async fn execute_delete_objects_rejects_empty_object_list() { + let input = DeleteObjectsInput::builder() + .bucket("test-bucket".to_string()) + .delete(Delete { + objects: vec![], + quiet: None, + }) + .build() + .unwrap(); + + let req = build_request(input, Method::POST); + let usecase = DefaultObjectUsecase::without_context(); + + let err = usecase.execute_delete_objects(req).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + } + + #[tokio::test] + async fn execute_delete_objects_rejects_more_than_one_thousand_objects_before_store_lookup() { + let objects = (0..1001) + .map(|idx| ObjectIdentifier { + key: format!("test-key-{idx}"), + version_id: None, + ..Default::default() + }) + .collect(); + let input = DeleteObjectsInput::builder() + .bucket("test-bucket".to_string()) + .delete(Delete { objects, quiet: None }) + .build() + .expect("delete objects input should build"); + + let req = build_request(input, Method::POST); + let usecase = DefaultObjectUsecase::without_context(); + + let err = usecase.execute_delete_objects(req).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + } + + #[tokio::test] + async fn execute_delete_objects_returns_internal_error_when_store_uninitialized() { + let input = DeleteObjectsInput::builder() + .bucket("test-bucket".to_string()) + .delete(Delete { + objects: vec![ObjectIdentifier { + key: "test-key".to_string(), + version_id: None, + ..Default::default() + }], + quiet: None, + }) + .build() + .unwrap(); + + let req = build_request(input, Method::POST); + let usecase = DefaultObjectUsecase::without_context(); + + let err = usecase.execute_delete_objects(req).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InternalError); + assert_eq!(err.message(), Some("Not init")); + } + + #[tokio::test] + #[serial_test::serial] + async fn execute_delete_objects_rejects_bucket_recreated_after_authorization() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions}; + + let store = crate::app::gating_test_env::shared_gating_ecstore().await; + if current_app_context().is_none() { + crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; + } + let context = current_app_context().expect("delete objects generation test requires an AppContext"); + let bucket = format!("delete-objects-generation-{}", Uuid::new_v4()); + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("create authorized bucket generation"); + let mut reader = PutObjReader::from_vec(b"old generation".to_vec()); + store + .put_object(&bucket, "object", &mut reader, &ObjectOptions::default()) + .await + .expect("put old-generation object"); + + let policy_json = format!( + r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"*"}},"Action":["s3:DeleteObject"],"Resource":["arn:aws:s3:::{bucket}/*"]}}]}}"# + ); + let mut metadata = (*crate::storage::get_bucket_metadata(&bucket) + .await + .expect("authorized bucket metadata should be cached")) + .clone(); + metadata.policy_config = Some(serde_json::from_str(&policy_json).expect("test policy should parse")); + metadata.policy_config_json = policy_json.into_bytes(); + crate::storage::storage_api::set_bucket_metadata(bucket.clone(), metadata) + .await + .expect("publish test bucket policy"); + + let input = DeleteObjectsInput::builder() + .bucket(bucket.clone()) + .delete(Delete { + objects: vec![ObjectIdentifier { + key: "object".to_string(), + version_id: None, + ..Default::default() + }], + quiet: None, + }) + .build() + .expect("delete objects input should build"); + let mut req = build_request(input, Method::POST); + req.extensions.insert(crate::storage::access::ReqInfo::default()); + let loaded = Arc::new(tokio::sync::Barrier::new(2)); + let resume = Arc::new(tokio::sync::Barrier::new(2)); + install_delete_objects_auth_test_hook(bucket.clone(), Arc::clone(&loaded), Arc::clone(&resume)); + + let usecase = DefaultObjectUsecase::with_context(Some(context)); + let delete = tokio::spawn(async move { usecase.execute_delete_objects(req).await }); + loaded.wait().await; + + store + .delete_bucket( + &bucket, + &DeleteBucketOptions { + force: true, + ..Default::default() + }, + ) + .await + .expect("delete authorized bucket generation"); + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("recreate same bucket name"); + let mut reader = PutObjReader::from_vec(b"new generation".to_vec()); + store + .put_object(&bucket, "object", &mut reader, &ObjectOptions::default()) + .await + .expect("put new-generation object"); + resume.wait().await; + + let err = delete + .await + .expect("delete objects task should join") + .expect_err("old authorization must not delete from the recreated bucket"); + assert_eq!(err.code(), &S3ErrorCode::NoSuchBucket); + store + .get_object_info(&bucket, "object", &ObjectOptions::default()) + .await + .expect("new-generation object must survive the stale batch request"); + } + + #[tokio::test] + async fn execute_delete_object_allows_non_force_request_without_req_info_until_store_lookup() { + let input = DeleteObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .build() + .unwrap(); + + let err = DefaultObjectUsecase::without_context() + .execute_delete_object(build_request(input, Method::DELETE)) + .await + .expect_err("an uninitialized store should be reported after non-force admission"); + assert_eq!(err.code(), &S3ErrorCode::InternalError); + assert_eq!(err.message(), Some("Not init")); + } + + #[test] + fn delete_objects_audit_details_include_only_successful_request_entries() { + let requested = vec![ + ObjectIdentifier { + key: "first-key".to_string(), + version_id: None, + ..Default::default() + }, + ObjectIdentifier { + key: "denied-key".to_string(), + version_id: Some(Uuid::new_v4().to_string()), + ..Default::default() + }, + ObjectIdentifier { + key: "versioned-key".to_string(), + version_id: Some("requested-version".to_string()), + ..Default::default() + }, + ]; + + let objects = successful_delete_audit_objects( + &Delete { + objects: requested, + quiet: Some(true), + }, + [true, false, true], + ); + + assert_eq!( + objects, + vec![ + AuditObjectVersion::new("first-key".to_string(), None), + AuditObjectVersion::new("versioned-key".to_string(), Some("requested-version".to_string())), + ] + ); + } + + #[test] + fn delete_objects_audit_details_are_empty_when_every_entry_fails() { + let requested = vec![ObjectIdentifier { + key: "failed-key".to_string(), + version_id: None, + ..Default::default() + }]; + + assert!( + successful_delete_audit_objects( + &Delete { + objects: requested, + quiet: None, + }, + [false] + ) + .is_empty() + ); + } + + #[test] + fn normalize_delete_objects_version_id_preserves_explicit_null_marker() { + let (wire_version_id, internal_version_id) = + normalize_delete_objects_version_id(Some("null".to_string())).expect("null version marker should parse"); + + assert_eq!(wire_version_id.as_deref(), Some("null")); + assert_eq!(internal_version_id, Some(Uuid::nil())); + + let (wire_version_id, internal_version_id) = + normalize_delete_objects_version_id(Some(" \t ".to_string())).expect("empty version marker should normalize"); + assert_eq!(wire_version_id, None); + assert_eq!(internal_version_id, None); + } + + #[test] + fn delete_objects_treats_raw_io_not_found_as_idempotent() { + assert!(is_delete_objects_not_found(&StorageError::FileNotFound)); + assert!(is_delete_objects_not_found(&StorageError::Io(std::io::Error::from( + std::io::ErrorKind::NotFound, + )))); + assert!(!is_delete_objects_not_found(&StorageError::Io(std::io::Error::from( + std::io::ErrorKind::PermissionDenied, + )))); + assert!(!is_delete_objects_not_found(&StorageError::DiskNotFound)); + } + + #[test] + fn delete_objects_result_reducer_reports_raw_not_found_as_deleted() { + let object = ObjectToDelete { + object_name: "missing-key".to_string(), + ..Default::default() + }; + let deleted = StorageDeletedObject { + object_name: object.object_name.clone(), + ..Default::default() + }; + let error = StorageError::Io(std::io::Error::from(std::io::ErrorKind::NotFound)); + + let deleted = reduce_delete_objects_result(&object, &deleted, Some(&error), false) + .expect("raw not-found must produce a deleted result"); + assert_eq!(deleted.object_name, "missing-key"); + } + + #[test] + fn recursive_force_delete_requires_administrative_or_replica_context() { + let mut headers = HeaderMap::new(); + headers.insert("x-rustfs-force-delete", HeaderValue::from_static("true")); + + assert!(!recursive_force_delete_is_authorized(&headers, false, false)); + assert!(recursive_force_delete_is_authorized(&headers, true, false)); + assert!(recursive_force_delete_is_authorized(&headers, false, true)); + assert!(recursive_force_delete_is_authorized(&HeaderMap::new(), false, false)); + } + + #[tokio::test] + async fn execute_delete_object_rejects_untrusted_force_delete_before_store_access() { + let input = DeleteObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("prefix/object".to_string()) + .build() + .unwrap(); + let mut req = build_request(input, Method::DELETE); + req.headers.insert("x-rustfs-force-delete", HeaderValue::from_static("true")); + req.extensions.insert(crate::storage::access::ReqInfo::default()); + + let err = DefaultObjectUsecase::without_context() + .execute_delete_object(req) + .await + .expect_err("untrusted force-delete must be rejected before storage lookup"); + assert_eq!(err.code(), &S3ErrorCode::AccessDenied); + } + + #[tokio::test] + async fn execute_delete_objects_rejects_untrusted_force_delete_before_store_access() { + let input = DeleteObjectsInput::builder() + .bucket("test-bucket".to_string()) + .delete(Delete { + objects: vec![ObjectIdentifier { + key: "prefix/object".to_string(), + version_id: None, + ..Default::default() + }], + quiet: None, + }) + .build() + .unwrap(); + let mut req = build_request(input, Method::POST); + req.headers.insert("x-rustfs-force-delete", HeaderValue::from_static("true")); + req.extensions.insert(crate::storage::access::ReqInfo::default()); + + let err = DefaultObjectUsecase::without_context() + .execute_delete_objects(req) + .await + .expect_err("untrusted force-delete must be rejected before storage lookup"); + assert_eq!(err.code(), &S3ErrorCode::AccessDenied); + } + + // backlog#929 (HP-8): the pre-delete stat may only be skipped when every + // consumer of its result is provably idle. Each guard flips one condition + // to prove the skip is fenced on all four data dependencies. + fn delete_marker_creating_opts() -> ObjectOptions { + ObjectOptions { + version_id: None, + versioned: true, + version_suspended: false, + ..Default::default() + } + } + + #[test] + fn delete_objects_pre_stat_skippable_for_delete_marker_on_plain_bucket() { + assert!(can_skip_delete_objects_pre_stat(false, &delete_marker_creating_opts(), true)); + } + + #[test] + fn delete_objects_pre_stat_kept_for_object_lock_buckets() { + assert!(!can_skip_delete_objects_pre_stat(true, &delete_marker_creating_opts(), true)); + } + + #[test] + fn delete_objects_pre_stat_kept_for_explicit_version_deletes() { + let opts = ObjectOptions { + version_id: Some(Uuid::new_v4().to_string()), + versioned: true, + version_suspended: false, + ..Default::default() + }; + assert!(!can_skip_delete_objects_pre_stat(false, &opts, true)); + } + + #[test] + fn delete_objects_pre_stat_kept_for_unversioned_buckets() { + // Unversioned deletes remove the current object: usage accounting needs + // the object size and ILM tier cleanup needs the transition metadata. + let opts = ObjectOptions { + version_id: None, + versioned: false, + version_suspended: false, + ..Default::default() + }; + assert!(!can_skip_delete_objects_pre_stat(false, &opts, false)); + } + + #[test] + fn delete_objects_pre_stat_kept_for_suspended_versioning() { + let opts = ObjectOptions { + version_id: None, + versioned: true, + version_suspended: true, + ..Default::default() + }; + assert!(!can_skip_delete_objects_pre_stat(false, &opts, false)); + } + + #[test] + fn delete_objects_pre_stat_kept_when_accounting_snapshot_disagrees() { + // If the accounting-side versioning snapshot does not also classify the + // delete as a delete-marker creation, the stat must stay so usage + // accounting keeps its size input. + assert!(!can_skip_delete_objects_pre_stat(false, &delete_marker_creating_opts(), false)); + } + + #[test] + fn delete_accounting_recognizes_explicit_null_as_current_object() { + let opts = ObjectOptions { + version_id: Some(Uuid::nil().to_string()), + version_suspended: true, + ..Default::default() + }; + assert!(delete_removes_current_object(&opts)); + assert!(delete_request_targets_current(Some(Uuid::nil()))); + assert!(!delete_request_targets_current(Some(Uuid::new_v4()))); + assert!(!delete_removes_current_object(&ObjectOptions { + version_id: Some(Uuid::new_v4().to_string()), + ..Default::default() + })); + } + + #[test] + fn compressed_object_delete_restores_usage_baseline() { + let mut metadata = HashMap::new(); + insert_str(&mut metadata, SUFFIX_COMPRESSION, "klauspost/compress/s2".to_string()); + let object = ObjectInfo { + size: 400, + actual_size: 1000, + user_defined: Arc::new(metadata), + ..Default::default() + }; + let accounting_size = quota_object_size(&object).expect("logical compressed size should be canonical"); + + assert_eq!( + delete_memory_update(false, false, true, Some(accounting_size), true), + Some(DeleteMemoryUpdate::Object { + size: 1000, + removed_current_object: true, + }) + ); + } + + #[test] + fn invalid_accounting_metadata_is_reconciled_without_overflow() { + assert_eq!(delete_memory_update(false, false, true, None, true), None); + assert_eq!( + delete_memory_update(false, true, true, None, true), + Some(DeleteMemoryUpdate::DeleteMarker) + ); + } + + #[tokio::test] + #[serial_test::serial] + async fn compressed_delete_requests_update_observed_usage_without_releasing_quota_floor() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions}; + use crate::app::storage_api::test::data_usage::apply_bucket_usage_memory_overlay; + + async fn observed_bucket_usage(bucket: &str) -> Option { + let mut usage = rustfs_data_usage::DataUsageInfo::default(); + apply_bucket_usage_memory_overlay(&mut usage).await; + usage.buckets_usage.get(bucket).map(|value| value.size) + } + + let store = crate::app::gating_test_env::shared_gating_ecstore().await; + if current_app_context().is_none() { + crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; + } + let bucket = format!("compressed-delete-request-{}", Uuid::new_v4().simple()); + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("create compressed delete request bucket"); + + // Seed the process-local usage with the canonical logical bytes. The + // direct storage PUT below intentionally does not apply an app-layer + // usage delta; the two real DELETE requests must remove exactly this + // amount through their request-layer wiring. + crate::app::storage_api::test::data_usage::seed_bucket_usage_memory_for_test(&bucket, 2_000).await; + + for object in ["single", "batch"] { + let mut metadata = HashMap::new(); + insert_str(&mut metadata, SUFFIX_COMPRESSION, "klauspost/compress/s2".to_string()); + insert_str(&mut metadata, SUFFIX_ACTUAL_SIZE, "1000".to_string()); + let reader = HashReader::from_stream(std::io::Cursor::new(vec![0x5a; 400]), 400, 1000, None, None, false) + .expect("compressed fixture reader should be valid"); + let mut reader = PutObjReader::new(reader); + store + .put_object( + &bucket, + object, + &mut reader, + &ObjectOptions { + user_defined: metadata, + ..Default::default() + }, + ) + .await + .expect("compressed fixture object should be written"); + } + + let mut single_req = build_request( + DeleteObjectInput::builder() + .bucket(bucket.clone()) + .key("single".to_string()) + .build() + .expect("single delete input should build"), + Method::DELETE, + ); + single_req.extensions.insert(crate::storage::access::ReqInfo { + cred: Some(rustfs_credentials::Credentials::default()), + is_owner: true, + ..Default::default() + }); + DefaultObjectUsecase::from_global() + .execute_delete_object(single_req) + .await + .expect("single compressed delete should succeed"); + assert_eq!( + observed_bucket_usage(&bucket).await, + Some(1_000), + "single delete must subtract the logical accounting size" + ); + assert_eq!( + crate::app::storage_api::test::data_usage::get_bucket_usage_memory(&bucket).await, + Some(2_000), + "quota must retain the pre-delete floor until scanner reconciliation" + ); + + let mut batch_req = build_request( + DeleteObjectsInput::builder() + .bucket(bucket.clone()) + .delete(Delete { + objects: vec![ObjectIdentifier { + key: "batch".to_string(), + ..Default::default() + }], + quiet: None, + }) + .build() + .expect("batch delete input should build"), + Method::POST, + ); + batch_req.extensions.insert(crate::storage::access::ReqInfo { + cred: Some(rustfs_credentials::Credentials::default()), + is_owner: true, + ..Default::default() + }); + DefaultObjectUsecase::from_global() + .execute_delete_objects(batch_req) + .await + .expect("batch compressed delete should succeed"); + assert_eq!( + observed_bucket_usage(&bucket).await, + Some(0), + "batch delete must subtract the committed logical accounting size" + ); + assert_eq!( + crate::app::storage_api::test::data_usage::get_bucket_usage_memory(&bucket).await, + Some(2_000), + "quota must retain both pending deletes until scanner reconciliation" + ); + + store + .delete_bucket( + &bucket, + &DeleteBucketOptions { + force: true, + ..Default::default() + }, + ) + .await + .expect("clean up compressed delete request bucket"); + } + + #[test] + fn delete_replication_state_from_config_tracks_downstream_delete_marker_targets() { + let arn = "arn:aws:s3:::target-bucket".to_string(); + let config = ReplicationConfiguration { + role: arn.clone(), + rules: vec![ReplicationRule { + delete_marker_replication: Some(DeleteMarkerReplication { + status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)), + }), + delete_replication: None, + destination: Destination { + bucket: arn.clone(), + ..Default::default() + }, + existing_object_replication: Some(ExistingObjectReplication { + status: ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::ENABLED), + }), + filter: None, + id: Some("rule-1".to_string()), + prefix: Some("test/".to_string()), + 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), + }], + }; + let obj_info = ObjectInfo { + bucket: "bucket".to_string(), + name: "test/object.txt".to_string(), + delete_marker: true, + replication_status: ReplicationStatusType::Replica, + ..Default::default() + }; + + let state = delete_replication_state_from_config(&config, &obj_info, None, true) + .expect("replica delete marker should be forwarded to downstream targets"); + let pending = format!("{arn}=PENDING;"); + + assert_eq!(state.replication_status_internal.as_deref(), Some(pending.as_str())); + assert_eq!(state.replicate_decision_str, format!("{arn}=true;false;{arn};")); + assert!(state.targets.contains_key(&arn)); + } + + #[test] + fn delete_replication_state_from_config_skips_replica_delete_without_replica_modifications() { + let arn = "arn:aws:s3:::target-bucket".to_string(); + let config = ReplicationConfiguration { + role: arn.clone(), + rules: vec![ReplicationRule { + delete_marker_replication: Some(DeleteMarkerReplication { + status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)), + }), + delete_replication: None, + destination: Destination { + bucket: arn, + ..Default::default() + }, + existing_object_replication: Some(ExistingObjectReplication { + status: ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::ENABLED), + }), + filter: None, + id: Some("rule-1".to_string()), + prefix: Some("test/".to_string()), + priority: Some(1), + source_selection_criteria: None, + status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED), + }], + }; + let obj_info = ObjectInfo { + bucket: "bucket".to_string(), + name: "test/object.txt".to_string(), + delete_marker: true, + replication_status: ReplicationStatusType::Replica, + ..Default::default() + }; + + assert!( + delete_replication_state_from_config(&config, &obj_info, None, true).is_none(), + "replica deletes must only fan out when ReplicaModifications are enabled" + ); + } + + #[test] + fn delete_replication_state_from_config_requires_delete_switch_for_marker_version_purges() { + let arn = "arn:aws:s3:::target-bucket".to_string(); + let mut config = ReplicationConfiguration { + role: arn.clone(), + rules: vec![ReplicationRule { + delete_marker_replication: Some(DeleteMarkerReplication { + status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)), + }), + delete_replication: None, + destination: Destination { + bucket: arn.clone(), + ..Default::default() + }, + existing_object_replication: Some(ExistingObjectReplication { + status: ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::ENABLED), + }), + filter: None, + id: Some("rule-1".to_string()), + prefix: Some("test/".to_string()), + priority: Some(1), + source_selection_criteria: None, + status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED), + }], + }; + let obj_info = ObjectInfo { + bucket: "bucket".to_string(), + name: "test/object.txt".to_string(), + delete_marker: true, + replication_status: ReplicationStatusType::Completed, + ..Default::default() + }; + + let version_id = Some(Uuid::new_v4()); + assert!( + delete_replication_state_from_config(&config, &obj_info, version_id, false).is_none(), + "delete-marker version purge must not use DeleteMarkerReplication" + ); + + config.rules[0].delete_replication = Some(DeleteReplication { + status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED), + }); + let state = delete_replication_state_from_config(&config, &obj_info, version_id, false) + .expect("delete-marker version purge should honor DeleteReplication"); + let pending = format!("{arn}=PENDING;"); + + assert_eq!(state.version_purge_status_internal.as_deref(), Some(pending.as_str())); + assert_eq!(state.replicate_decision_str, format!("{arn}=true;false;{arn};")); + assert!(state.purge_targets.contains_key(&arn)); + } + + #[test] + fn replica_delete_enrichment_must_not_reuse_upstream_targets() { + let upstream_state = ReplicationState { + replicate_decision_str: "arn:aws:s3:::upstream=true;false;arn:aws:s3:::upstream;".to_string(), + replication_status_internal: Some("arn:aws:s3:::upstream=COMPLETED;".to_string()), + targets: replication_statuses_map("arn:aws:s3:::upstream=COMPLETED;"), + ..Default::default() + }; + let mut delete_object = StorageDeletedObject::default(); + set_deleted_object_replication_state(&mut delete_object, &upstream_state); + let obj_info = ObjectInfo { + replication_status: ReplicationStatusType::Replica, + ..Default::default() + }; + + let should_keep_existing = delete_object.replication_state.as_ref().is_some_and(|state| { + obj_info.replication_status != ReplicationStatusType::Replica + && !state.replicate_decision_str.is_empty() + && (!state.targets.is_empty() || !state.purge_targets.is_empty()) + }); + + assert!( + !should_keep_existing, + "replica fanout deletes must recompute targets from the local bucket config instead of reusing upstream replication state" + ); + } + + #[test] + fn delete_replication_version_id_uses_none_for_delete_marker_creation() { + let source = ObjectInfo { + delete_marker: true, + version_id: Some(Uuid::new_v4()), + ..Default::default() + }; + + assert_eq!( + delete_replication_version_id(&source, false), + None, + "delete-marker creation must stay on the delete-marker replication path" + ); + } + + #[test] + fn delete_replication_version_id_keeps_version_for_marker_purge() { + let version_id = Uuid::new_v4(); + let source = ObjectInfo { + delete_marker: true, + version_id: Some(version_id), + ..Default::default() + }; + + assert_eq!( + delete_replication_version_id(&source, true), + Some(version_id), + "delete-marker version purge must preserve the concrete version id for downstream purge replication" + ); + } + + #[test] + fn should_use_existing_delete_replication_info_ignores_replication_delete_marker_creation() { + let opts = ObjectOptions { + version_id: Some(Uuid::new_v4().to_string()), + delete_marker: true, + ..Default::default() + }; + + assert!( + !should_use_existing_delete_replication_info(&opts, true), + "replicated delete-marker creation carries a source version id header but must not be treated as a version purge" + ); + } + + #[test] + fn should_use_existing_delete_replication_info_keeps_version_delete_requests() { + let opts = ObjectOptions { + version_id: Some(Uuid::new_v4().to_string()), + ..Default::default() + }; + + assert!( + should_use_existing_delete_replication_info(&opts, true), + "true version-delete requests should keep using the pre-delete object info" + ); + } + + #[tokio::test] + #[serial_test::serial] + async fn durable_quota_reclaims_overwrites_and_deleted_bytes() { + let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("quota-delta-reconcile", 4096).await; + + for byte in [0x41, 0x42] { + let mut reader = PutObjReader::from_vec(vec![byte; 4096]); + store + .put_object(&bucket, "object", &mut reader, &ObjectOptions::default()) + .await + .expect("same-size overwrite must consume no additional quota"); + } + + store + .delete_object(&bucket, "object", ObjectOptions::default()) + .await + .expect("delete quota-tracked object"); + let mut replacement = PutObjReader::from_vec(vec![0x43; 4096]); + store + .put_object(&bucket, "replacement", &mut replacement, &ObjectOptions::default()) + .await + .expect("deleted bytes must be reclaimed before rejecting a replacement"); + + let mut excess = PutObjReader::from_vec(vec![0x44]); + let err = store + .put_object(&bucket, "excess", &mut excess, &ObjectOptions::default()) + .await + .expect_err("one byte beyond the reclaimed exact quota must be denied"); + assert!(matches!( + err, + StorageError::QuotaExceeded { + current: 4096, + limit: 4096 + } + )); + } +} diff --git a/rustfs/src/app/object/extract.rs b/rustfs/src/app/object/extract.rs new file mode 100644 index 000000000..1a4cbcc4c --- /dev/null +++ b/rustfs/src/app/object/extract.rs @@ -0,0 +1,1506 @@ +// 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. + +//! Snowball auto-extract (PutObject x-amz-meta-snowball-auto-extract) path. + +use super::*; + +fn ensure_legacy_archive_size_within_quota(result: &QuotaCheckResult, total_unpacked_size: u64) -> S3Result<()> { + if result.uses_durable_reservations { + return Ok(()); + } + let (Some(current_usage), Some(quota_limit)) = (result.current_usage, result.quota_limit) else { + return Ok(()); + }; + let expected_usage = current_usage + .checked_add(total_unpacked_size) + .ok_or_else(|| s3_error!(InvalidArgument, "Archive total size overflowed quota accounting"))?; + if expected_usage > quota_limit { + return Err(S3Error::with_message( + S3ErrorCode::InvalidRequest, + format!("Bucket quota exceeded. Current usage: {current_usage} bytes, limit: {quota_limit} bytes"), + )); + } + Ok(()) +} + +pin_project! { + struct ExtractArchiveEtagReader { + #[pin] + inner: R, + md5: Md5, + finished: bool, + etag: Arc>>, + } +} + +impl ExtractArchiveEtagReader { + fn new(inner: R, etag: Arc>>) -> Self { + Self { + inner, + md5: Md5::new(), + finished: false, + etag, + } + } +} + +impl AsyncRead for ExtractArchiveEtagReader { + fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + let this = self.project(); + let before = buf.filled().len(); + match this.inner.poll_read(cx, buf) { + Poll::Pending => Poll::Pending, + Poll::Ready(Ok(())) => { + let filled = &buf.filled()[before..]; + if !filled.is_empty() { + this.md5.update(filled); + } else if !*this.finished { + *this.finished = true; + if let Ok(mut etag) = this.etag.lock() { + *etag = Some(hex_simd::encode_to_string(this.md5.clone().finalize(), hex_simd::AsciiCase::Lower)); + } + } + Poll::Ready(Ok(())) + } + Poll::Ready(Err(err)) => Poll::Ready(Err(err)), + } + } +} + +const AMZ_SNOWBALL_EXTRACT_COMPAT: &str = "X-Amz-Snowball-Auto-Extract"; + +#[cfg(test)] +const AMZ_SNOWBALL_PREFIX_INTERNAL: &str = "X-Amz-Meta-Rustfs-Snowball-Prefix"; + +#[cfg(test)] +const AMZ_SNOWBALL_IGNORE_DIRS_INTERNAL: &str = "X-Amz-Meta-Rustfs-Snowball-Ignore-Dirs"; + +#[cfg(test)] +const AMZ_SNOWBALL_IGNORE_ERRORS_INTERNAL: &str = "X-Amz-Meta-Rustfs-Snowball-Ignore-Errors"; + +const AMZ_META_PREFIX_LOWER: &str = "x-amz-meta-"; + +const SNOWBALL_PREFIX_SUFFIX_LOWER: &str = "snowball-prefix"; + +const SNOWBALL_IGNORE_DIRS_SUFFIX_LOWER: &str = "snowball-ignore-dirs"; + +const SNOWBALL_IGNORE_ERRORS_SUFFIX_LOWER: &str = "snowball-ignore-errors"; + +const SNOWBALL_PREFIX_HEADER_KEYS: &[&str] = &[AMZ_MINIO_SNOWBALL_PREFIX, AMZ_SNOWBALL_PREFIX, AMZ_RUSTFS_SNOWBALL_PREFIX]; + +const SNOWBALL_IGNORE_DIRS_HEADER_KEYS: &[&str] = &[ + AMZ_MINIO_SNOWBALL_IGNORE_DIRS, + AMZ_SNOWBALL_IGNORE_DIRS, + AMZ_RUSTFS_SNOWBALL_IGNORE_DIRS, +]; + +const SNOWBALL_IGNORE_ERRORS_HEADER_KEYS: &[&str] = &[ + AMZ_MINIO_SNOWBALL_IGNORE_ERRORS, + AMZ_SNOWBALL_IGNORE_ERRORS, + AMZ_RUSTFS_SNOWBALL_IGNORE_ERRORS, +]; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct PutObjectExtractOptions { + prefix: Option, + ignore_dirs: bool, + ignore_errors: bool, +} + +fn header_value_is_true(headers: &HeaderMap, key: &str) -> bool { + headers + .get(key) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.trim().eq_ignore_ascii_case("true")) +} + +pub(super) fn is_put_object_extract_requested(headers: &HeaderMap) -> bool { + header_value_is_true(headers, AMZ_SNOWBALL_EXTRACT) || header_value_is_true(headers, AMZ_SNOWBALL_EXTRACT_COMPAT) +} + +fn trimmed_header_value(headers: &HeaderMap, key: &str) -> Option { + headers + .get(key) + .and_then(|value| value.to_str().ok()) + .map(|value| value.trim().to_string()) +} + +fn is_exact_snowball_meta_key(key: &str, exact_keys: &[&str]) -> bool { + exact_keys.iter().any(|exact_key| key.eq_ignore_ascii_case(exact_key)) +} + +fn snowball_meta_value_by_suffix(headers: &HeaderMap, suffix_lower: &str, exact_keys: &[&str]) -> Option { + for (name, value) in headers { + let key = name.as_str(); + if key.starts_with(AMZ_META_PREFIX_LOWER) + && key.ends_with(suffix_lower) + && !is_exact_snowball_meta_key(key, exact_keys) + && let Ok(parsed) = value.to_str() + { + return Some(parsed.trim().to_string()); + } + } + + None +} + +fn snowball_meta_value(headers: &HeaderMap, exact_keys: &[&str], suffix_lower: &str) -> Option { + for key in exact_keys { + if let Some(value) = trimmed_header_value(headers, key) { + return Some(value); + } + } + + snowball_meta_value_by_suffix(headers, suffix_lower, exact_keys) +} + +fn snowball_meta_flag(headers: &HeaderMap, exact_keys: &[&str], suffix_lower: &str) -> bool { + snowball_meta_value(headers, exact_keys, suffix_lower).is_some_and(|value| value.eq_ignore_ascii_case("true")) +} + +/// Validates that an archive entry path does not escape the target bucket. +/// +/// Delegates to [`rustfs_utils::path::validate_extract_relative_path`] and wraps +/// the result as an S3 error on failure. +pub fn validate_extract_relative_path(path: &str) -> S3Result<()> { + rustfs_utils::path::validate_extract_relative_path(path).map_err(|msg| s3_error!(InvalidArgument, "{msg}")) +} + +fn normalize_snowball_prefix(prefix: &str) -> S3Result> { + let normalized = prefix.trim().trim_matches('/'); + if normalized.is_empty() { + return Ok(None); + } + + validate_extract_relative_path(normalized)?; + + Ok(Some(normalized.to_string())) +} + +/// Normalizes an archive entry key by applying a prefix, trimming slashes, +/// and ensuring directory entries end with `/`. +/// +/// Delegates to [`rustfs_utils::path::normalize_extract_entry_key`] and wraps +/// the result as an S3 error on failure. +pub fn normalize_extract_entry_key(path: &str, prefix: Option<&str>, is_dir: bool) -> S3Result { + rustfs_utils::path::normalize_extract_entry_key(path, prefix, is_dir).map_err(|msg| s3_error!(InvalidArgument, "{msg}")) +} + +fn map_extract_archive_error(err: impl std::fmt::Display) -> S3Error { + s3_error!(InvalidArgument, "Failed to process archive entry: {}", err) +} + +#[derive(Debug, Default)] +struct ExtractEntryPaxAuthorization { + headers: HeaderMap, + object_lock_legal_hold_status: Option, + object_lock_mode: Option, + object_lock_retain_until_date: Option, +} + +async fn apply_extract_entry_pax_extensions( + entry: &mut tokio_tar::Entry>, + bucket: &str, + object_name: &str, + object_lock_config_state: &metadata_sys::ObjectLockConfigState, + metadata: &mut HashMap, + opts: &mut ObjectOptions, +) -> S3Result +where + R: AsyncRead + Send + Unpin + 'static, +{ + let Some(extensions) = entry.pax_extensions().await.map_err(map_extract_archive_error)? else { + return Ok(ExtractEntryPaxAuthorization::default()); + }; + + let mut pax_headers = HeaderMap::new(); + let mut pax_version_id = None; + for ext in extensions { + let ext = ext.map_err(map_extract_archive_error)?; + let key = ext.key().map_err(map_extract_archive_error)?; + let value = ext.value().map_err(map_extract_archive_error)?; + + if let Some(meta_key) = key.strip_prefix("minio.metadata.") { + if !meta_key.is_empty() { + let name = http::HeaderName::from_bytes(meta_key.as_bytes()) + .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball PAX metadata header"))?; + let header_value = HeaderValue::from_str(value) + .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball PAX metadata value"))?; + preserve_unclassified_user_metadata(metadata, name.as_str(), value); + pax_headers.insert(name, header_value); + } + continue; + } + + if key == "minio.versionId" && !value.is_empty() { + if Uuid::parse_str(value).is_err() { + return Err(s3_error!(InvalidArgument, "Invalid Snowball PAX version ID")); + } + pax_version_id = Some(value.to_string()); + } + } + + let has_replica_status = pax_headers.contains_key(AMZ_BUCKET_REPLICATION_STATUS); + if let Some(value) = pax_headers.get(AMZ_BUCKET_REPLICATION_STATUS) { + let status = value + .to_str() + .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball replication status"))?; + if !status.eq_ignore_ascii_case(ReplicationStatusType::Replica.as_str()) { + return Err(s3_error!(InvalidArgument, "Invalid Snowball replication status")); + } + pax_headers.insert(AMZ_BUCKET_REPLICATION_STATUS, HeaderValue::from_static("REPLICA")); + } + + let authorization_headers = pax_headers.clone(); + + if let Some(value) = pax_headers.remove("x-amz-tagging") { + let value = value + .to_str() + .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball object tagging value"))?; + metadata.insert(AMZ_OBJECT_TAGGING.to_owned(), value.to_owned()); + } + + let object_lock_mode = pax_headers + .remove(AMZ_OBJECT_LOCK_MODE_LOWER) + .map(|value| { + value + .to_str() + .map(|value| ObjectLockMode::from(value.to_string())) + .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball Object Lock mode")) + }) + .transpose()?; + let object_lock_retain_until_date = pax_headers + .remove(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER) + .map(|value| { + let value = value + .to_str() + .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball Object Lock retain-until date"))?; + OffsetDateTime::parse(value, &Rfc3339) + .map(Timestamp::from) + .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball Object Lock retain-until date")) + }) + .transpose()?; + let object_lock_legal_hold_status = pax_headers + .remove(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER) + .map(|value| { + value + .to_str() + .map(|value| ObjectLockLegalHoldStatus::from(value.to_string())) + .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball Object Lock legal-hold status")) + }) + .transpose()?; + opts.version_id = pax_version_id; + + extract_metadata_from_mime_with_object_name(&pax_headers, metadata, false, Some(object_name)); + if has_replica_status { + metadata.retain(|key, _| !key.eq_ignore_ascii_case(AMZ_BUCKET_REPLICATION_STATUS)); + metadata.insert( + AMZ_BUCKET_REPLICATION_STATUS.to_string(), + ReplicationStatusType::Replica.as_str().to_string(), + ); + } + if let Some(object_lock_metadata) = build_put_like_object_lock_metadata( + bucket, + object_lock_config_state, + object_lock_legal_hold_status.clone(), + object_lock_mode.clone(), + object_lock_retain_until_date.clone(), + )? { + metadata.extend(object_lock_metadata); + } + + Ok(ExtractEntryPaxAuthorization { + headers: authorization_headers, + object_lock_legal_hold_status, + object_lock_mode, + object_lock_retain_until_date, + }) +} + +fn resolve_put_object_extract_options(headers: &HeaderMap) -> S3Result { + let prefix = snowball_meta_value(headers, SNOWBALL_PREFIX_HEADER_KEYS, SNOWBALL_PREFIX_SUFFIX_LOWER) + .map(|value| normalize_snowball_prefix(&value)) + .transpose()? + .flatten(); + let ignore_dirs = snowball_meta_flag(headers, SNOWBALL_IGNORE_DIRS_HEADER_KEYS, SNOWBALL_IGNORE_DIRS_SUFFIX_LOWER); + let ignore_errors = snowball_meta_flag(headers, SNOWBALL_IGNORE_ERRORS_HEADER_KEYS, SNOWBALL_IGNORE_ERRORS_SUFFIX_LOWER); + + Ok(PutObjectExtractOptions { + prefix, + ignore_dirs, + ignore_errors, + }) +} + +fn put_object_extract_limits() -> ArchiveLimits { + ArchiveLimits::default() +} + +fn validate_put_object_extract_entry_count(count: usize, limits: ArchiveLimits) -> S3Result<()> { + if count > limits.max_entries { + return Err(s3_error!( + InvalidArgument, + "Archive entry count exceeds limit: count={}, limit={}", + count, + limits.max_entries + )); + } + Ok(()) +} + +fn validate_put_object_extract_entry_size(path: &str, size: u64, limits: ArchiveLimits) -> S3Result<()> { + if size > limits.max_entry_size { + return Err(s3_error!( + InvalidArgument, + "Archive entry size exceeds limit for {}: size={}, limit={}", + path, + size, + limits.max_entry_size + )); + } + Ok(()) +} + +fn validate_put_object_extract_total_size(total_size: u64, limits: ArchiveLimits) -> S3Result<()> { + if total_size > limits.max_total_unpacked_size { + return Err(s3_error!( + InvalidArgument, + "Archive total unpacked size exceeds limit: size={}, limit={}", + total_size, + limits.max_total_unpacked_size + )); + } + Ok(()) +} + +fn validate_put_object_extract_entry_path(path: &str, limits: ArchiveLimits) -> S3Result<()> { + if path.len() > limits.max_path_length { + return Err(s3_error!( + InvalidArgument, + "Archive entry path exceeds limit for {}: length={}, limit={}", + path, + path.len(), + limits.max_path_length + )); + } + Ok(()) +} + +impl DefaultObjectUsecase { + #[instrument(level = "debug", skip(self, req))] + #[hotpath::measure(impl_type = "DefaultObjectUsecase")] + pub async fn execute_put_object_extract(&self, req: S3Request) -> S3Result> { + self.execute_put_object_extract_boxed(req).await + } + + fn execute_put_object_extract_boxed( + &self, + req: S3Request, + ) -> impl std::future::Future>> + Send + '_ { + Box::pin(self.execute_put_object_extract_inner(req)) + } + + async fn execute_put_object_extract_inner(&self, req: S3Request) -> S3Result> { + let helper = OperationHelper::new(&req, EventName::ObjectCreatedPut, S3Operation::PutObject).suppress_event(); + let request_context = helper.request_context_or_from_request(&req); + let auth_method = req.method.clone(); + let auth_uri = req.uri.clone(); + let auth_headers = req.headers.clone(); + let auth_extensions = req.extensions.clone(); + let auth_credentials = req.credentials.clone(); + let auth_region = req.region.clone(); + let auth_service = req.service.clone(); + let auth_trailing_headers = req.trailing_headers.clone(); + // Extract uploads reject SSE-KMS before reaching the SSE layer, so the principal is + // only carried for the day that restriction lifts; the NotImplemented answer below + // deliberately stays ahead of any key authorization. + let extract_principal = SseKmsPrincipal::from_request(&req); + if is_sse_kms_requested(&req.input, &req.headers) { + return Err(s3_error!(NotImplemented, "SSE-KMS is not supported for extract uploads")); + } + let replication_authorized = replication_request_authorized(&req); + let mut bucket_generation_opts = ObjectOptions::default(); + apply_bucket_generation_guard(&req, &req.input.bucket, &mut bucket_generation_opts)?; + let expected_bucket_incarnation_id = bucket_generation_opts.expected_bucket_incarnation_id; + let input = req.input; + + let PutObjectInput { + body, + bucket, + key, + version_id, + cache_control, + content_disposition, + content_encoding, + content_length, + content_language, + content_type, + content_md5, + expires, + object_lock_legal_hold_status, + object_lock_mode, + object_lock_retain_until_date, + server_side_encryption, + sse_customer_algorithm, + sse_customer_key, + sse_customer_key_md5, + ssekms_key_id, + storage_class, + tagging, + website_redirect_location, + .. + } = input; + + let event_version_id = version_id; + let (h_algo, h_key, h_md5) = extract_ssec_params_from_headers(&req.headers)?; + let sse_customer_algorithm = sse_customer_algorithm.or(h_algo); + let sse_customer_key = sse_customer_key.or(h_key); + let sse_customer_key_md5 = sse_customer_key_md5.or(h_md5); + + let original_sse = server_side_encryption.or(extract_server_side_encryption_from_headers(&req.headers)?); + let bucket_sse_config = metadata_sys::get_sse_config(&bucket).await.ok(); + let (mut effective_sse, mut effective_kms_key_id) = resolve_bucket_default_sse( + bucket_sse_config.as_ref().map(|(config, _timestamp)| config), + original_sse, + ssekms_key_id, + false, + ); + if effective_sse + .as_ref() + .is_some_and(|sse| sse.as_str().eq_ignore_ascii_case(ServerSideEncryption::AWS_KMS)) + { + return Err(s3_error!(NotImplemented, "SSE-KMS is not supported for extract uploads")); + } + validate_sse_headers_for_write( + effective_sse.as_ref(), + effective_kms_key_id.as_ref(), + extract_ssekms_context_from_headers(&req.headers)?.as_ref(), + sse_customer_algorithm.as_ref(), + sse_customer_key.as_ref(), + sse_customer_key_md5.as_ref(), + true, + )?; + let Some(body) = body else { return Err(s3_error!(IncompleteBody)) }; + + let size = match content_length { + Some(c) => c, + None => { + if let Some(val) = req.headers.get(AMZ_DECODED_CONTENT_LENGTH) { + match atoi::atoi::(val.as_bytes()) { + Some(x) => x, + None => return Err(s3_error!(UnexpectedContent)), + } + } else { + return Err(s3_error!(UnexpectedContent)); + } + } + }; + if size < 0 { + return Err(s3_error!(UnexpectedContent)); + } + validate_object_key(&key, "PUT")?; + validate_table_catalog_object_mutation(&bucket, &key).await?; + let _ = self + .check_bucket_quota( + &bucket, + QuotaOperation::PutObject, + u64::try_from(size).map_err(|_| S3Error::new(S3ErrorCode::UnexpectedContent))?, + ) + .await?; + + // Apply adaptive buffer sizing based on file size for optimal streaming performance. + // Uses workload profile configuration (enabled by default) to select appropriate buffer size. + // Buffer sizes range from 32KB to 4MB depending on file size and configured workload profile. + let buffer_size = get_buffer_size_opt_in(size); + let body = + tokio::io::BufReader::with_capacity(buffer_size, StreamReader::new(body.map(|f| f.map_err(s3s_body_error_to_io)))); + + let Some(ext) = Path::new(&key).extension().and_then(|s| s.to_str()) else { + return Err(s3_error!(InvalidArgument, "key extension not found")); + }; + + let ext = ext.to_owned(); + + let md5hex = if let Some(base64_md5) = content_md5 { + let md5 = base64_simd::STANDARD + .decode_to_vec(base64_md5.as_bytes()) + .map_err(|e| ApiError::from(StorageError::other(format!("Invalid content MD5: {e}"))))?; + Some(hex_simd::encode_to_string(&md5, hex_simd::AsciiCase::Lower)) + } else { + None + }; + + let sha256hex = get_content_sha256_with_query(&req.headers, req.uri.query()); + let actual_size = size; + + let mut archive_reader = + HashReader::from_stream(body, size, actual_size, md5hex, sha256hex, false).map_err(ApiError::from)?; + + if let Err(err) = archive_reader.add_checksum_from_s3s(&req.headers, req.trailing_headers.clone(), false) { + return Err(ApiError::from(err).into()); + } + + let archive_etag = Arc::new(Mutex::new(None)); + let decoder = CompressionFormat::from_extension(&ext) + .get_decoder(ExtractArchiveEtagReader::new(archive_reader, archive_etag.clone())) + .map_err(|e| { + error!(error = ?e, "Archive decoder creation failed"); + s3_error!(InvalidArgument, "get_decoder err") + })?; + + let mut ar = Archive::new(decoder); + let mut entries = ar.entries().map_err(|e| { + error!(error = ?e, "Archive entry listing failed"); + s3_error!(InvalidArgument, "get entries err") + })?; + + let Some(store) = self.object_store() else { + return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); + }; + + let extract_options = resolve_put_object_extract_options(&req.headers)?; + let extract_limits = put_object_extract_limits(); + let extract_quota_check = if let Some(metadata_sys) = self.bucket_metadata_sys() { + let quota_checker = QuotaChecker::new(metadata_sys); + let check_result = + map_quota_check_outcome(&bucket, quota_checker.check_quota(&bucket, QuotaOperation::PutObject, 0).await)?; + Some(check_result) + } else { + None + }; + let extract_quota_enabled = extract_quota_check + .as_ref() + .is_some_and(|result| result.quota_limit.is_some()); + let version_id = match event_version_id { + Some(v) => v.to_string(), + None => String::new(), + }; + + let notify = current_notify_interface_for_context(self.context.as_deref()); + let req_params = rustfs_targets::extract_params_header(&req.headers); + let host = get_request_host(&req.headers); + let port = get_request_port(&req.headers); + let user_agent = get_request_user_agent(&req.headers); + let mut wrote_any_entry = false; + let mut extracted_entry_count = 0usize; + let mut total_unpacked_size = 0u64; + let object_lock_config_snapshot = store.object_lock_config_snapshot(&bucket).await.map_err(ApiError::from)?; + let object_lock_config_state = object_lock_config_snapshot.state(); + + while let Some(entry) = entries.next().await { + let mut f = match entry { + Ok(f) => f, + Err(e) => { + if extract_options.ignore_errors { + warn!(error = %e, "Archive entry read skipped due to ignore-errors"); + continue; + } + error!(error = %e, "Archive entry read failed"); + return Err(s3_error!(InvalidArgument, "Failed to read archive entry: {:?}", e)); + } + }; + extracted_entry_count = extracted_entry_count.saturating_add(1); + validate_put_object_extract_entry_count(extracted_entry_count, extract_limits)?; + + let fpath = match f.path() { + Ok(path) => path, + Err(e) => { + if extract_options.ignore_errors { + warn!(error = %e, "Archive path decode skipped due to ignore-errors"); + continue; + } + return Err(s3_error!(InvalidArgument, "Failed to decode archive entry path")); + } + }; + + let is_dir = f.header().entry_type().is_dir(); + let fpath = match normalize_extract_entry_key(&fpath.to_string_lossy(), extract_options.prefix.as_deref(), is_dir) { + Ok(fpath) => fpath, + Err(err) => { + if extract_options.ignore_errors { + warn!(error = %err, "Unsafe archive path skipped due to ignore-errors"); + continue; + } + return Err(err); + } + }; + validate_put_object_extract_entry_path(&fpath, extract_limits)?; + validate_table_catalog_object_mutation(&bucket, &fpath).await?; + + let mut auth_req = S3Request { + input: PutObjectInput::default(), + method: auth_method.clone(), + uri: auth_uri.clone(), + headers: auth_headers.clone(), + extensions: auth_extensions.clone(), + credentials: auth_credentials.clone(), + region: auth_region.clone(), + service: auth_service.clone(), + trailing_headers: auth_trailing_headers.clone(), + }; + { + let req_info = req_info_mut(&mut auth_req)?; + req_info.bucket = Some(bucket.clone()); + req_info.object = Some(fpath.clone()); + req_info.version_id = None; + } + let entry_size = f.header().size().unwrap_or_default(); + validate_put_object_extract_entry_size(&fpath, entry_size, extract_limits)?; + total_unpacked_size = total_unpacked_size + .checked_add(entry_size) + .ok_or_else(|| s3_error!(InvalidArgument, "Archive total unpacked size overflowed while processing entries"))?; + validate_put_object_extract_total_size(total_unpacked_size, extract_limits)?; + if let Some(quota_check) = extract_quota_check.as_ref() { + ensure_legacy_archive_size_within_quota(quota_check, total_unpacked_size)?; + } + let mut size = + i64::try_from(entry_size).map_err(|_| s3_error!(InvalidArgument, "Archive entry size does not fit into i64"))?; + // mtime 0 means "unset" in tar headers, and xl.meta cannot represent an + // epoch mod_time anyway (0 nanos decodes as no-mod_time, making the version + // unreadable — rustfs#4842), so fall back to the upload time instead. + let archive_entry_mod_time = f + .header() + .mtime() + .ok() + .filter(|&modified_at_secs| modified_at_secs > 0) + .and_then(|modified_at_secs| OffsetDateTime::from_unix_timestamp(modified_at_secs as i64).ok()); + let mut metadata = HashMap::new(); + let has_explicit_object_lock_retention = object_lock_mode.is_some() || object_lock_retain_until_date.is_some(); + apply_put_request_metadata( + &mut metadata, + &req.headers, + &fpath, + cache_control.clone(), + content_disposition.clone(), + content_encoding.clone(), + content_language.clone(), + content_type.clone(), + expires.clone(), + website_redirect_location.clone(), + tagging.clone(), + storage_class.clone(), + )?; + apply_bucket_default_lock_retention( + &bucket, + object_lock_config_state, + &mut metadata, + has_explicit_object_lock_retention, + )?; + let mut opts = put_opts_with_replication_authorization( + &bucket, + &fpath, + None, + &req.headers, + metadata.clone(), + replication_authorized, + ) + .await + .map_err(ApiError::from)?; + if let Some(quota_check) = extract_quota_check.as_ref() { + apply_quota_admission(&mut opts, quota_check)?; + } + opts.expected_bucket_incarnation_id = expected_bucket_incarnation_id; + opts.object_lock_config_snapshot = Some(Arc::clone(&object_lock_config_snapshot)); + let pax_authorization = + apply_extract_entry_pax_extensions(&mut f, &bucket, &fpath, object_lock_config_state, &mut metadata, &mut opts) + .await?; + for (name, value) in &pax_authorization.headers { + auth_req.headers.insert(name.clone(), value.clone()); + } + if let Some(version_id) = opts.version_id.as_ref() { + req_info_mut(&mut auth_req)?.version_id = Some(version_id.clone()); + } + authorize_request(&mut auth_req, Action::S3Action(S3Action::PutObjectAction)).await?; + if pax_authorization.object_lock_mode.is_some() || pax_authorization.object_lock_retain_until_date.is_some() { + authorize_request(&mut auth_req, Action::S3Action(S3Action::PutObjectRetentionAction)).await?; + } + if pax_authorization.object_lock_legal_hold_status.is_some() { + authorize_request(&mut auth_req, Action::S3Action(S3Action::PutObjectLegalHoldAction)).await?; + } + if opts.version_id.is_some() || pax_authorization.headers.contains_key(AMZ_BUCKET_REPLICATION_STATUS) { + authorize_request(&mut auth_req, Action::S3Action(S3Action::ReplicateObjectAction)).await?; + } + let effective_object_lock_legal_hold_status = pax_authorization + .object_lock_legal_hold_status + .clone() + .or_else(|| object_lock_legal_hold_status.clone()); + let (effective_object_lock_mode, effective_object_lock_retain_until_date) = + if pax_authorization.object_lock_mode.is_some() || pax_authorization.object_lock_retain_until_date.is_some() { + ( + pax_authorization.object_lock_mode.clone(), + pax_authorization.object_lock_retain_until_date.clone(), + ) + } else { + (object_lock_mode.clone(), object_lock_retain_until_date.clone()) + }; + if archive_entry_mod_time.is_some() { + opts.mod_time = archive_entry_mod_time; + } + + debug!("Extracting file: {}, size: {} bytes", fpath, size); + + if is_dir { + if extract_options.ignore_dirs { + debug!("Skipping directory entry during archive extract: {}", fpath); + continue; + } + size = 0; + } + + let actual_size = size; + + let should_compress = + !is_dir && is_disk_compressible(&HeaderMap::new(), &fpath) && size > MIN_DISK_COMPRESSIBLE_SIZE as i64; + + let mut write_plan = WritePlan::new(); + let mut hrd = if is_dir { + HashReader::from_stream(std::io::Cursor::new(Vec::new()), size, actual_size, None, None, false) + .map_err(ApiError::from)? + } else if should_compress { + let algorithm = CompressionAlgorithm::default(); + insert_str(&mut metadata, SUFFIX_COMPRESSION, compression_metadata_value(algorithm)); + insert_str(&mut metadata, SUFFIX_ACTUAL_SIZE, size.to_string()); + + let hrd = HashReader::from_stream(f, size, actual_size, None, None, false).map_err(ApiError::from)?; + write_plan = write_plan.with_compression(algorithm); + hrd + } else { + HashReader::from_stream(f, size, actual_size, None, None, false).map_err(ApiError::from)? + }; + apply_put_request_object_lock_opts( + &bucket, + object_lock_config_state, + effective_object_lock_legal_hold_status, + effective_object_lock_mode, + effective_object_lock_retain_until_date, + &mut opts, + )?; + if let Some(material) = sse_encryption(EncryptionRequest { + bucket: &bucket, + key: &fpath, + server_side_encryption: effective_sse.clone(), + ssekms_key_id: effective_kms_key_id.clone(), + ssekms_context: extract_ssekms_context_from_headers(&req.headers)?, + sse_customer_algorithm: sse_customer_algorithm.clone(), + sse_customer_key: sse_customer_key.clone(), + sse_customer_key_md5: sse_customer_key_md5.clone(), + content_size: actual_size, + principal: extract_principal.as_ref(), + }) + .await? + { + effective_sse = Some(material.server_side_encryption.clone()); + effective_kms_key_id = material.kms_key_id.clone(); + + write_plan = write_plan.with_encryption(material.write_encryption(None)); + + let encryption_metadata = encryption_material_to_metadata(&material)?; + metadata.extend(encryption_metadata.clone()); + opts.user_defined.extend(encryption_metadata); + } + hrd = write_plan.apply(hrd, actual_size).map_err(ApiError::from)?; + opts.user_defined.extend(metadata); + + // Each extracted member is an independent user write and joins + // bucket replication like a regular PUT (MinIO PutObjectExtract + // parity). One immutable decision drives both the pending metadata + // and the post-commit schedule below, same contract as the PUT path + // (https://github.com/rustfs/backlog/issues/1320); inbound replica + // writes are declined inside `must_replicate_object`. + let dsc = must_replicate_object( + &bucket, + &fpath, + &opts.user_defined, + "".to_string(), + opts.delete_marker_replication_status(), + opts.clone(), + ) + .await; + if dsc.replicate_any() { + insert_str(&mut opts.user_defined, SUFFIX_REPLICATION_TIMESTAMP, jiff::Zoned::now().to_string()); + insert_str( + &mut opts.user_defined, + SUFFIX_REPLICATION_STATUS, + dsc.pending_status().unwrap_or_default(), + ); + } + + let mut reader = PutObjReader::new(hrd); + let cache_adapter = self.object_data_cache(); + let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &fpath).await; + + let (obj_info, backfilled_old_current_size) = match store + .put_object_with_old_current_size(&bucket, &fpath, &mut reader, &opts) + .await + { + Ok(result) => result, + Err(e) => { + if extract_options.ignore_errors { + warn!(error = %e, "Archive object write skipped due to ignore-errors"); + continue; + } + return Err(ApiError::from(e).into()); + } + }; + let committed_size = quota_accounting_object_size(&obj_info, extract_quota_enabled)?; + let extract_versioned = BucketVersioningSys::prefix_enabled(&bucket, &fpath).await; + match previous_current_size_from_backfill(backfilled_old_current_size) { + Some(previous_current_size) => { + if extract_versioned { + record_bucket_object_version_write_memory(&bucket, previous_current_size, committed_size).await; + } else { + record_bucket_object_write_memory(&bucket, previous_current_size, committed_size).await; + } + } + None => { + record_bucket_object_write_unknown_previous_memory(&bucket, committed_size, extract_versioned).await; + } + } + let _ = invalidate_object_data_cache_after_put_success(&cache_adapter, &bucket, &fpath).await; + + // Reuse the per-entry pre-commit decision (see `dsc` above) so the + // persisted pending marker and the schedule always agree. + if dsc.replicate_any() { + schedule_object_replication(obj_info.clone(), store.clone(), dsc).await; + } + + if !wrote_any_entry { + rustfs_scanner::record_dirty_usage_bucket(&bucket); + wrote_any_entry = true; + } + + let _manager = get_concurrency_manager(); + let _fpath_clone = fpath.clone(); + let _bucket_clone = bucket.clone(); + let e_tag = obj_info.etag.clone().map(|etag| to_s3s_etag(&etag)); + + let output = PutObjectOutput { + e_tag, + ..Default::default() + }; + + let event_args = rustfs_notify::EventArgs { + event_name: put_event_name_for_post_object(false), + bucket_name: bucket.clone(), + object: convert_ecstore_object_info(obj_info.clone()), + req_params: req_params.clone(), + resp_elements: build_event_resp_elements(&S3Response::new(output.clone()), &request_context.request_id), + version_id: version_id.clone(), + host: host.clone(), + port, + user_agent: user_agent.clone(), + }; + + let notify = notify.clone(); + spawn_background_with_context(Some(request_context.clone()), async move { + notify.notify(event_args).await; + }); + } + + let mut checksums = PutObjectChecksums { + crc32: input.checksum_crc32, + crc32c: input.checksum_crc32c, + sha1: input.checksum_sha1, + sha256: input.checksum_sha256, + crc64nvme: input.checksum_crc64nvme, + }; + apply_trailing_checksums( + input.checksum_algorithm.as_ref().map(|a| a.as_str()), + &req.trailing_headers, + &mut checksums, + ); + + warn!( + "put object extract checksum_crc32={:?}, checksum_crc32c={:?}, checksum_sha1={:?}, checksum_sha256={:?}, checksum_crc64nvme={:?}", + checksums.crc32, checksums.crc32c, checksums.sha1, checksums.sha256, checksums.crc64nvme, + ); + + drop(entries); + let mut decoder = match ar.into_inner() { + Ok(decoder) => decoder, + Err(_) => return Err(s3_error!(InvalidArgument, "Failed to finalize archive reader")), + }; + tokio::io::copy(&mut decoder, &mut tokio::io::sink()) + .await + .map_err(map_extract_archive_error)?; + let archive_etag = archive_etag + .lock() + .ok() + .and_then(|etag| etag.clone()) + .map(|etag| to_s3s_etag(&etag)); + + let output = PutObjectOutput { + e_tag: archive_etag, + checksum_crc32: checksums.crc32, + checksum_crc32c: checksums.crc32c, + checksum_sha1: checksums.sha1, + checksum_sha256: checksums.sha256, + checksum_crc64nvme: checksums.crc64nvme, + ..Default::default() + }; + let result = Ok(S3Response::new(output)); + let _ = helper.complete(&result); + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http::{HeaderMap, HeaderName, HeaderValue}; + use s3s::dto::{ObjectLockConfiguration, ObjectLockEnabled}; + use tokio_tar::{Builder, EntryType, Header}; + + fn pax_record(key: &str, value: &[u8]) -> Vec { + let body_len = 1 + key.len() + 1 + value.len() + 1; + let mut len = body_len + 1; + loop { + let actual_len = len.to_string().len() + body_len; + if actual_len == len { + break; + } + len = actual_len; + } + + let mut record = format!("{len} {key}=").into_bytes(); + record.extend_from_slice(value); + record.push(b'\n'); + assert_eq!(record.len(), len); + record + } + + #[tokio::test] + async fn snowball_pax_rejects_unpaired_object_lock_retention() { + let record = pax_record("minio.metadata.x-amz-object-lock-mode", b"GOVERNANCE"); + let mut builder = Builder::new(Vec::new()); + let mut extension = Header::new_ustar(); + extension.set_size(record.len() as u64); + extension.set_entry_type(EntryType::XHeader); + builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); + + let mut file = Header::new_ustar(); + file.set_size(0); + builder.append_data(&mut file, "object", &b""[..]).await.unwrap(); + let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); + let mut entries = archive.entries().unwrap(); + let mut entry = entries.next().await.unwrap().unwrap(); + let mut metadata = HashMap::from([ + (AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), ObjectLockRetentionMode::COMPLIANCE.to_string()), + (AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), "2030-01-01T00:00:00Z".to_string()), + ]); + let mut opts = ObjectOptions::default(); + let state = metadata_sys::ObjectLockConfigState::Configured { + config: ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + rule: None, + }, + updated_at: OffsetDateTime::now_utc(), + }; + + let err = apply_extract_entry_pax_extensions(&mut entry, "bucket", "object", &state, &mut metadata, &mut opts) + .await + .unwrap_err(); + + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(metadata.get(AMZ_OBJECT_LOCK_MODE_LOWER).map(String::as_str), Some("COMPLIANCE")); + } + + #[tokio::test] + async fn snowball_pax_privileged_fields_require_independent_authorization() { + let mut retention = pax_record("minio.metadata.X-Amz-Object-Lock-Mode", b"GOVERNANCE"); + retention.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Retain-Until-Date", b"2099-01-01T00:00:00Z")); + let cases = [ + ("retention", retention, (true, false, false)), + ( + "legal-hold", + pax_record("minio.metadata.X-Amz-Object-Lock-Legal-Hold", b"ON"), + (false, true, false), + ), + ( + "version-id", + pax_record("minio.versionId", Uuid::nil().to_string().as_bytes()), + (false, false, true), + ), + ( + "replication-status", + pax_record("minio.metadata.x-amz-replication-status", b"REPLICA"), + (false, false, true), + ), + ]; + let state = metadata_sys::ObjectLockConfigState::Configured { + config: ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + rule: None, + }, + updated_at: OffsetDateTime::now_utc(), + }; + + for (case, record, expected) in cases { + let mut builder = Builder::new(Vec::new()); + let mut extension = Header::new_ustar(); + extension.set_size(record.len() as u64); + extension.set_entry_type(EntryType::XHeader); + builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); + let mut file = Header::new_ustar(); + file.set_size(0); + builder.append_data(&mut file, "object", &b""[..]).await.unwrap(); + let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); + let mut entries = archive.entries().unwrap(); + let mut entry = entries.next().await.unwrap().unwrap(); + + let mut opts = ObjectOptions::default(); + let authorization = + apply_extract_entry_pax_extensions(&mut entry, "bucket", "object", &state, &mut HashMap::new(), &mut opts) + .await + .unwrap(); + + assert_eq!( + ( + authorization.object_lock_mode.is_some() || authorization.object_lock_retain_until_date.is_some(), + authorization.object_lock_legal_hold_status.is_some(), + opts.version_id.is_some() || authorization.headers.contains_key(AMZ_BUCKET_REPLICATION_STATUS), + ), + expected, + "{case} must request only its own additional authorization" + ); + match case { + "retention" => { + assert!(authorization.headers.contains_key(AMZ_OBJECT_LOCK_MODE_LOWER)); + assert!(authorization.headers.contains_key(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER)); + } + "legal-hold" => assert!(authorization.headers.contains_key(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER)), + "version-id" => assert_eq!(opts.version_id.as_deref(), Some(Uuid::nil().to_string().as_str())), + "replication-status" => assert_eq!( + authorization + .headers + .get(AMZ_BUCKET_REPLICATION_STATUS) + .and_then(|value| value.to_str().ok()), + Some("REPLICA") + ), + _ => unreachable!(), + } + } + } + + #[tokio::test] + async fn snowball_pax_rejects_invalid_retention_and_replication_values() { + let mut invalid_mode = pax_record("minio.metadata.X-Amz-Object-Lock-Mode", b"INVALID"); + invalid_mode.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Retain-Until-Date", b"2099-01-01T00:00:00Z")); + let mut invalid_date = pax_record("minio.metadata.X-Amz-Object-Lock-Mode", b"COMPLIANCE"); + invalid_date.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Retain-Until-Date", b"not-a-date")); + let cases = [ + ("invalid-mode", invalid_mode), + ("invalid-date", invalid_date), + ( + "invalid-replication-status", + pax_record("minio.metadata.x-amz-replication-status", b"INVALID"), + ), + ("invalid-version-id", pax_record("minio.versionId", b"not-a-uuid")), + ]; + let state = metadata_sys::ObjectLockConfigState::Configured { + config: ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + rule: None, + }, + updated_at: OffsetDateTime::now_utc(), + }; + + for (case, record) in cases { + let mut builder = Builder::new(Vec::new()); + let mut extension = Header::new_ustar(); + extension.set_size(record.len() as u64); + extension.set_entry_type(EntryType::XHeader); + builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); + let mut file = Header::new_ustar(); + file.set_size(0); + builder.append_data(&mut file, "object", &b""[..]).await.unwrap(); + let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); + let mut entries = archive.entries().unwrap(); + let mut entry = entries.next().await.unwrap().unwrap(); + + let err = apply_extract_entry_pax_extensions( + &mut entry, + "bucket", + "object", + &state, + &mut HashMap::new(), + &mut ObjectOptions::default(), + ) + .await + .unwrap_err(); + + assert!( + err.code() == &S3ErrorCode::InvalidArgument || err.code() == &S3ErrorCode::MalformedXML, + "{case}" + ); + } + } + + #[tokio::test] + async fn snowball_pax_preserves_canonical_minio_metadata_and_valid_retention() { + let mut record = pax_record("minio.metadata.Content-Type", b"text/plain"); + record.extend(pax_record("minio.metadata.X-Amz-Meta-Owner", b"alice")); + record.extend(pax_record("minio.metadata.project", b"alpha-demo")); + record.extend(pax_record("minio.metadata.x-amz-tagging", b"classification=public")); + record.extend(pax_record("minio.versionId", Uuid::nil().to_string().as_bytes())); + record.extend(pax_record("minio.metadata.x-amz-replication-status", b"REPLICA")); + record.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Mode", b"GOVERNANCE")); + record.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Retain-Until-Date", b"2099-01-01T00:00:00Z")); + record.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Legal-Hold", b"ON")); + let mut builder = Builder::new(Vec::new()); + let mut extension = Header::new_ustar(); + extension.set_size(record.len() as u64); + extension.set_entry_type(EntryType::XHeader); + builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); + + let mut file = Header::new_ustar(); + file.set_size(0); + builder.append_data(&mut file, "object.txt", &b""[..]).await.unwrap(); + let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); + let mut entries = archive.entries().unwrap(); + let mut entry = entries.next().await.unwrap().unwrap(); + let mut metadata = HashMap::from([ + (AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), ObjectLockRetentionMode::COMPLIANCE.to_string()), + (AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), "2030-01-01T00:00:00Z".to_string()), + ]); + let mut opts = ObjectOptions::default(); + let state = metadata_sys::ObjectLockConfigState::Configured { + config: ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + rule: None, + }, + updated_at: OffsetDateTime::now_utc(), + }; + + let authorization = + apply_extract_entry_pax_extensions(&mut entry, "bucket", "object.txt", &state, &mut metadata, &mut opts) + .await + .unwrap(); + + assert_eq!(metadata.get("content-type").map(String::as_str), Some("text/plain")); + assert_eq!(metadata.get("owner").map(String::as_str), Some("alice")); + assert_eq!(metadata.get("project").map(String::as_str), Some("alpha-demo")); + assert_eq!(metadata.get(AMZ_OBJECT_TAGGING).map(String::as_str), Some("classification=public")); + assert!(!metadata.contains_key("x-amz-tagging")); + assert_eq!(metadata.get(AMZ_OBJECT_LOCK_MODE_LOWER).map(String::as_str), Some("GOVERNANCE")); + assert_eq!( + metadata.get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER).map(String::as_str), + Some("2099-01-01T00:00:00Z") + ); + assert_eq!(metadata.get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER).map(String::as_str), Some("ON")); + assert!(metadata.contains_key("x-rustfs-internal-objectlock-legalhold-timestamp")); + assert!(metadata.contains_key("x-minio-internal-objectlock-legalhold-timestamp")); + assert_eq!(metadata.get(AMZ_BUCKET_REPLICATION_STATUS).map(String::as_str), Some("REPLICA")); + assert_eq!(opts.version_id.as_deref(), Some("00000000-0000-0000-0000-000000000000")); + assert!(authorization.object_lock_mode.is_some()); + assert!(authorization.object_lock_retain_until_date.is_some()); + assert!(authorization.object_lock_legal_hold_status.is_some()); + assert!(opts.version_id.is_some()); + assert!(authorization.headers.contains_key(AMZ_BUCKET_REPLICATION_STATUS)); + } + + #[tokio::test] + async fn snowball_pax_rejects_legal_hold_without_bucket_object_lock() { + let record = pax_record("minio.metadata.X-Amz-Object-Lock-Legal-Hold", b"ON"); + let mut builder = Builder::new(Vec::new()); + let mut extension = Header::new_ustar(); + extension.set_size(record.len() as u64); + extension.set_entry_type(EntryType::XHeader); + builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); + + let mut file = Header::new_ustar(); + file.set_size(0); + builder.append_data(&mut file, "object", &b""[..]).await.unwrap(); + let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); + let mut entries = archive.entries().unwrap(); + let mut entry = entries.next().await.unwrap().unwrap(); + let mut metadata = HashMap::new(); + + let err = apply_extract_entry_pax_extensions( + &mut entry, + "bucket", + "object", + &metadata_sys::ObjectLockConfigState::ConfirmedAbsent, + &mut metadata, + &mut ObjectOptions::default(), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert!(!metadata.contains_key(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER)); + } + + #[tokio::test] + async fn snowball_pax_rejects_invalid_legal_hold_status() { + let record = pax_record("minio.metadata.X-Amz-Object-Lock-Legal-Hold", b"INVALID"); + let mut builder = Builder::new(Vec::new()); + let mut extension = Header::new_ustar(); + extension.set_size(record.len() as u64); + extension.set_entry_type(EntryType::XHeader); + builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); + + let mut file = Header::new_ustar(); + file.set_size(0); + builder.append_data(&mut file, "object", &b""[..]).await.unwrap(); + let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); + let mut entries = archive.entries().unwrap(); + let mut entry = entries.next().await.unwrap().unwrap(); + let mut metadata = HashMap::new(); + let state = metadata_sys::ObjectLockConfigState::Configured { + config: ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + rule: None, + }, + updated_at: OffsetDateTime::now_utc(), + }; + + let err = apply_extract_entry_pax_extensions( + &mut entry, + "bucket", + "object", + &state, + &mut metadata, + &mut ObjectOptions::default(), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), &S3ErrorCode::MalformedXML); + assert!(!metadata.contains_key(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER)); + } + + #[test] + fn is_put_object_extract_requested_accepts_meta_header() { + let mut headers = HeaderMap::new(); + headers.insert(AMZ_SNOWBALL_EXTRACT, HeaderValue::from_static("true")); + + assert!(is_put_object_extract_requested(&headers)); + } + + #[test] + fn is_put_object_extract_requested_accepts_compat_header_case_insensitive() { + let mut headers = HeaderMap::new(); + headers.insert(AMZ_SNOWBALL_EXTRACT_COMPAT, HeaderValue::from_static(" TRUE ")); + + assert!(is_put_object_extract_requested(&headers)); + } + + #[test] + fn is_put_object_extract_requested_rejects_missing_or_false_value() { + let mut headers = HeaderMap::new(); + assert!(!is_put_object_extract_requested(&headers)); + + headers.insert(AMZ_SNOWBALL_EXTRACT, HeaderValue::from_static("false")); + assert!(!is_put_object_extract_requested(&headers)); + } + + #[test] + fn normalize_snowball_prefix_trims_slashes_and_whitespace() { + assert_eq!( + normalize_snowball_prefix(" /batch/incoming/ ").unwrap(), + Some("batch/incoming".to_string()) + ); + assert_eq!(normalize_snowball_prefix("///").unwrap(), None); + } + + #[test] + fn normalize_snowball_prefix_rejects_parent_dir_components() { + assert!(normalize_snowball_prefix("../victim-bucket").is_err()); + assert!(normalize_snowball_prefix("safe/../../victim-bucket").is_err()); + assert!(normalize_snowball_prefix("safe\\..\\victim-bucket").is_err()); + } + + #[test] + fn normalize_extract_entry_key_applies_prefix_and_directory_suffix() { + assert_eq!( + normalize_extract_entry_key("nested/path.txt", Some("imports"), false).unwrap(), + "imports/nested/path.txt" + ); + assert_eq!( + normalize_extract_entry_key("nested/dir/", Some("imports"), true).unwrap(), + "imports/nested/dir/" + ); + assert_eq!(normalize_extract_entry_key("top-level", None, false).unwrap(), "top-level"); + } + + #[test] + fn normalize_extract_entry_key_rejects_bucket_escape_paths() { + assert!(normalize_extract_entry_key("../victim-bucket/evil.txt", None, false).is_err()); + assert!(normalize_extract_entry_key("safe/../../victim-bucket/evil.txt", None, false).is_err()); + assert!(normalize_extract_entry_key("safe\\..\\victim-bucket\\evil.txt", None, false).is_err()); + assert!(normalize_extract_entry_key("evil.txt", Some("../victim-bucket"), false).is_err()); + } + + #[test] + fn resolve_put_object_extract_options_defaults_when_headers_missing() { + let headers = HeaderMap::new(); + let options = resolve_put_object_extract_options(&headers).unwrap(); + assert_eq!( + options, + PutObjectExtractOptions { + prefix: None, + ignore_dirs: false, + ignore_errors: false + } + ); + } + + #[test] + fn resolve_put_object_extract_options_accepts_internal_headers() { + let mut headers = HeaderMap::new(); + headers.insert(AMZ_SNOWBALL_PREFIX_INTERNAL, HeaderValue::from_static("/internal/prefix/")); + headers.insert(AMZ_SNOWBALL_IGNORE_DIRS_INTERNAL, HeaderValue::from_static("true")); + headers.insert(AMZ_SNOWBALL_IGNORE_ERRORS_INTERNAL, HeaderValue::from_static("TRUE")); + + let options = resolve_put_object_extract_options(&headers).unwrap(); + assert_eq!(options.prefix.as_deref(), Some("internal/prefix")); + assert!(options.ignore_dirs); + assert!(options.ignore_errors); + } + + #[test] + fn resolve_put_object_extract_options_accepts_standard_headers() { + let mut headers = HeaderMap::new(); + headers.insert(AMZ_SNOWBALL_PREFIX, HeaderValue::from_static(" /standard/prefix/ ")); + headers.insert(AMZ_SNOWBALL_IGNORE_DIRS, HeaderValue::from_static(" true ")); + headers.insert(AMZ_SNOWBALL_IGNORE_ERRORS, HeaderValue::from_static("TRUE")); + + let options = resolve_put_object_extract_options(&headers).unwrap(); + assert_eq!(options.prefix.as_deref(), Some("standard/prefix")); + assert!(options.ignore_dirs); + assert!(options.ignore_errors); + } + + #[test] + fn resolve_put_object_extract_options_accepts_suffix_compatible_headers() { + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_static("x-amz-meta-acme-snowball-prefix"), + HeaderValue::from_static(" /partner/import "), + ); + headers.insert( + HeaderName::from_static("x-amz-meta-acme-snowball-ignore-dirs"), + HeaderValue::from_static(" true "), + ); + headers.insert( + HeaderName::from_static("x-amz-meta-acme-snowball-ignore-errors"), + HeaderValue::from_static("TRUE"), + ); + + let options = resolve_put_object_extract_options(&headers).unwrap(); + assert_eq!(options.prefix.as_deref(), Some("partner/import")); + assert!(options.ignore_dirs); + assert!(options.ignore_errors); + } + + #[test] + fn resolve_put_object_extract_options_prefers_exact_headers_over_suffix_fallback() { + let mut headers = HeaderMap::new(); + headers.insert("x-amz-meta-acme-snowball-prefix", HeaderValue::from_static("/fallback/prefix/")); + headers.insert(AMZ_RUSTFS_SNOWBALL_PREFIX, HeaderValue::from_static("/internal/prefix/")); + headers.insert(AMZ_SNOWBALL_PREFIX, HeaderValue::from_static("/standard/prefix/")); + headers.insert(AMZ_MINIO_SNOWBALL_PREFIX, HeaderValue::from_static("/minio/prefix/")); + + let options = resolve_put_object_extract_options(&headers).unwrap(); + assert_eq!(options.prefix.as_deref(), Some("minio/prefix")); + } + + #[test] + fn resolve_put_object_extract_options_exact_flags_override_suffix_fallback() { + let mut headers = HeaderMap::new(); + headers.insert(AMZ_SNOWBALL_IGNORE_DIRS, HeaderValue::from_static("false")); + headers.insert("x-amz-meta-acme-snowball-ignore-dirs", HeaderValue::from_static("true")); + headers.insert(AMZ_RUSTFS_SNOWBALL_IGNORE_ERRORS, HeaderValue::from_static("false")); + headers.insert("x-amz-meta-acme-snowball-ignore-errors", HeaderValue::from_static("true")); + + let options = resolve_put_object_extract_options(&headers).unwrap(); + assert!(!options.ignore_dirs); + assert!(!options.ignore_errors); + } + + #[test] + fn resolve_put_object_extract_options_rejects_unsafe_prefix_header() { + let mut headers = HeaderMap::new(); + headers.insert(AMZ_SNOWBALL_PREFIX, HeaderValue::from_static("../victim-bucket")); + + assert!(resolve_put_object_extract_options(&headers).is_err()); + } + + #[test] + fn validate_put_object_extract_entry_count_rejects_limit_overflow() { + let limits = ArchiveLimits { + max_entries: 1, + ..ArchiveLimits::default() + }; + + let err = validate_put_object_extract_entry_count(2, limits).unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + } + + #[test] + fn validate_put_object_extract_entry_size_rejects_oversized_entry() { + let limits = ArchiveLimits { + max_entry_size: 8, + ..ArchiveLimits::default() + }; + + let err = validate_put_object_extract_entry_size("payload.bin", 9, limits).unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + } + + #[test] + fn validate_put_object_extract_total_size_rejects_cumulative_overflow() { + let limits = ArchiveLimits { + max_total_unpacked_size: 16, + ..ArchiveLimits::default() + }; + + let err = validate_put_object_extract_total_size(17, limits).unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + } + + #[test] + fn validate_put_object_extract_entry_path_rejects_overlong_path() { + let limits = ArchiveLimits { + max_path_length: 8, + ..ArchiveLimits::default() + }; + + let err = validate_put_object_extract_entry_path("toolong-path", limits).unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + } + + #[test] + fn legacy_archive_quota_rejects_cumulative_size_and_overflow() { + let legacy = QuotaCheckResult { + allowed: true, + current_usage: Some(4), + quota_limit: Some(5), + operation_size: 0, + remaining: Some(1), + uses_durable_reservations: false, + }; + assert!(ensure_legacy_archive_size_within_quota(&legacy, 2).is_err()); + assert!(ensure_legacy_archive_size_within_quota(&legacy, 1).is_ok()); + + let maxed = QuotaCheckResult { + current_usage: Some(u64::MAX), + quota_limit: Some(u64::MAX), + ..legacy + }; + assert!(ensure_legacy_archive_size_within_quota(&maxed, 1).is_err()); + } +} diff --git a/rustfs/src/app/object/get.rs b/rustfs/src/app/object/get.rs new file mode 100644 index 000000000..809425b82 --- /dev/null +++ b/rustfs/src/app/object/get.rs @@ -0,0 +1,9311 @@ +// 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. + +//! GetObject / GetObjectAttributes read path: cold fill, resume, stream tuning. + +use super::*; + +struct ColdFillDiskPermitMetric { + owner: ColdFillDiskPermitOwner, + metric_recorded: bool, +} + +#[cfg(test)] +static COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST: AtomicU64 = AtomicU64::new(0); + +#[cfg(test)] +struct ColdFillPublicationBarrier { + reached: tokio::sync::Semaphore, + release: tokio::sync::Semaphore, +} + +#[cfg(test)] +type ColdFillPublicationBarrierState = Option<(rustfs_object_data_cache::ObjectDataCacheKey, Arc)>; + +#[cfg(test)] +static COLD_FILL_PUBLICATION_BARRIER: OnceLock> = OnceLock::new(); + +#[cfg(test)] +type ColdFillReaderOpenProbeState = Option<(rustfs_object_data_cache::ObjectDataCacheKey, Arc)>; + +#[cfg(test)] +static COLD_FILL_READER_OPEN_PROBE: OnceLock> = OnceLock::new(); + +fn adjust_cold_fill_disk_permit_metric(owner: ColdFillDiskPermitOwner, acquired: bool) { + macro_rules! adjust_gauge { + ($name:literal) => {{ + #[cfg(not(test))] + let gauge = { + static HANDLE: std::sync::LazyLock = std::sync::LazyLock::new(|| metrics::gauge!($name)); + &*HANDLE + }; + #[cfg(test)] + let gauge = metrics::gauge!($name); + if acquired { + gauge.increment(1.0); + } else { + gauge.decrement(1.0); + } + }}; + } + + match owner { + ColdFillDiskPermitOwner::Producer => { + adjust_gauge!("rustfs_object_data_cache_cold_fill_producer_disk_permits"); + } + ColdFillDiskPermitOwner::Follower => { + adjust_gauge!("rustfs_object_data_cache_cold_fill_follower_disk_permits"); + } + } +} + +#[cfg(test)] +async fn wait_cold_fill_publication_barrier(plan: &rustfs_object_data_cache::ObjectDataCacheGetPlan) { + let Some(key) = plan.key() else { + return; + }; + let barrier = COLD_FILL_PUBLICATION_BARRIER + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .filter(|(barrier_key, _)| barrier_key == key) + .map(|(_, barrier)| Arc::clone(barrier)); + if let Some(barrier) = barrier { + barrier.reached.add_permits(1); + if let Ok(permit) = barrier.release.acquire().await { + permit.forget(); + } + } +} + +#[cfg(test)] +fn record_cold_fill_reader_open_for_test(plan: &rustfs_object_data_cache::ObjectDataCacheGetPlan) { + let Some(key) = plan.key() else { + return; + }; + let probe = COLD_FILL_READER_OPEN_PROBE + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .filter(|(probe_key, _)| probe_key == key) + .map(|(_, count)| Arc::clone(count)); + if let Some(count) = probe { + count.fetch_add(1, Ordering::Relaxed); + } +} + +impl ColdFillDiskPermitMetric { + fn new(owner: ColdFillDiskPermitOwner) -> Self { + let metric_recorded = rustfs_io_metrics::metrics_enabled(); + if metric_recorded { + adjust_cold_fill_disk_permit_metric(owner, true); + } + #[cfg(test)] + if matches!(owner, ColdFillDiskPermitOwner::Follower) { + COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.fetch_add(1, Ordering::Relaxed); + } + Self { owner, metric_recorded } + } +} + +impl Drop for ColdFillDiskPermitMetric { + fn drop(&mut self) { + if self.metric_recorded { + adjust_cold_fill_disk_permit_metric(self.owner, false); + } + #[cfg(test)] + if matches!(self.owner, ColdFillDiskPermitOwner::Follower) { + COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.fetch_sub(1, Ordering::Relaxed); + } + } +} + +struct GetObjectDiskPermit { + permit: Option, + metric: Option, +} + +impl GetObjectDiskPermit { + fn new(permit: OwnedSemaphorePermit) -> Self { + Self { + permit: Some(permit), + metric: current_cold_fill_disk_permit_owner().map(ColdFillDiskPermitMetric::new), + } + } + + fn release(&mut self) { + self.permit.take(); + self.metric.take(); + } +} + +impl From for GetObjectDiskPermit { + fn from(permit: OwnedSemaphorePermit) -> Self { + Self::new(permit) + } +} + +impl Drop for GetObjectDiskPermit { + fn drop(&mut self) { + self.release(); + } +} + +const COLD_FILL_HARD_MAX_DURATION: Duration = Duration::from_secs(10 * 60); + +pub(crate) const MAX_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 64 * 1024 * 1024; + +const MEDIUM_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 8 * 1024 * 1024; + +const HIGH_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 4 * 1024 * 1024; + +const VERY_HIGH_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 1024 * 1024; + +const EVENT_GET_OBJECT_STREAM_BODY: &str = "get_object_stream_body"; + +const GET_OBJECT_STAGE_PATH_S3_HANDLER: &str = "s3_handler"; + +const GET_OBJECT_STAGE_REQUEST_INGRESS_TO_CONTEXT: &str = "request_ingress_to_context"; + +const GET_OBJECT_STAGE_OUTPUT_STRATEGY: &str = "output_strategy"; + +const GET_OBJECT_STAGE_BODY_BUILD: &str = "body_build"; + +const GET_OBJECT_STAGE_BODY_ENCRYPTED_BUFFER_READ: &str = "body_encrypted_buffer_read"; + +const GET_OBJECT_STAGE_BODY_MEMORY_BLOB: &str = "body_memory_blob"; + +const GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ: &str = "body_seek_buffer_read"; + +const GET_OBJECT_STAGE_BODY_STREAM_STRATEGY: &str = "body_stream_strategy"; + +const GET_OBJECT_STAGE_BODY_STREAMING_BLOB: &str = "body_streaming_blob"; + +const GET_OBJECT_STAGE_CHECKSUM_HEADERS: &str = "checksum_headers"; + +const GET_OBJECT_STAGE_LIFECYCLE_EXPIRATION: &str = "lifecycle_expiration"; + +const GET_OBJECT_STAGE_METADATA_FILTER: &str = "metadata_filter"; + +const GET_OBJECT_STREAM_WARN_THRESHOLD: Duration = Duration::from_secs(5); + +static GET_OBJECT_BUFFER_THRESHOLD_WARNED: AtomicBool = AtomicBool::new(false); + +fn record_get_object_s3_handler_stage_duration(stage: &'static str, start: Option) { + if let Some(start) = start { + rustfs_io_metrics::record_get_object_stage_duration( + GET_OBJECT_STAGE_PATH_S3_HANDLER, + stage, + start.elapsed().as_secs_f64(), + ); + } +} + +struct GetObjectBootstrap { + timeout_config: GetObjectTimeoutPolicy, + wrapper: RequestTimeoutWrapper, + request_start: std::time::Instant, + request_guard: GetObjectGuard, + _deadlock_request_guard: Option, + concurrent_requests: usize, +} + +struct GetObjectIoPlanning { + /// `None` when inline fast path skips disk I/O semaphore. + disk_permit: Option, + permit_wait_duration: Duration, + queue_status: concurrency::IoQueueStatus, + queue_utilization: f64, +} + +#[derive(Clone, Copy)] +struct GetObjectRequestTimeout<'a> { + wrapper: &'a RequestTimeoutWrapper, + policy: &'a GetObjectTimeoutPolicy, +} + +struct GetObjectRequestContext { + bucket: String, + key: String, + version_id_for_event: String, + part_number: Option, + rs: Option, + opts: ObjectOptions, +} + +/// Request fields that passed the cheap GET validations, ready for the +/// bucket-metadata work in [`DefaultObjectUsecase::prepare_get_object_request_context`]. +struct GetObjectValidatedRequest { + bucket: String, + key: String, + version_id: Option, + part_number: Option, + rs: Option, +} + +struct GetObjectReadSetup { + info: ObjectInfo, + final_stream: DynReader, + buffered_body: Option, + /// ODC-16: `buffered_body` is the body the ecstore cache hook served, so the + /// app layer serves it as the object-data-cache source without a re-lookup. + cache_hook_served: bool, + /// ODC-16: the cache hook probed this read (served or missed), so the app + /// layer must skip its own lookup. + cache_hook_probed: bool, + cache_fill_allowed: bool, + rs: Option, + content_type: Option, + last_modified: Option, + response_content_length: i64, + content_range: Option, + server_side_encryption: Option, + sse_customer_algorithm: Option, + sse_customer_key_md5: Option, + ssekms_key_id: Option, + encryption_applied: bool, + /// Resolved plaintext start offset of the committed response body + /// (`get_offset_length` output; 0 for a full-object read). Feeds the + /// mid-stream resume offset. + resume_range_start: i64, + /// Resolved inclusive plaintext end offset of the committed response body; + /// -1 when the committed body runs to the end of the object. + resume_range_end: i64, +} + +struct GetObjectPreparedRead { + io_planning: GetObjectIoPlanning, + read_setup: GetObjectReadSetup, +} + +struct GetObjectStrategyContext { + #[allow(dead_code, reason = "written but never read back (backlog#1823)")] + io_strategy: concurrency::IoStrategy, + optimal_buffer_size: usize, + enable_readahead: bool, +} + +struct GetObjectOutputContext { + output: GetObjectOutput, + event_info: Option, + response_content_length: i64, + optimal_buffer_size: usize, + extra_checksum_headers: Vec<(&'static str, String)>, +} + +enum GetObjectTimeoutStage { + BeforeProcessing, + DiskPermitWait { permit_wait_duration: Duration }, + BeforeRead, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum GetObjectStreamStrategy { + Standard, + LargeSequentialReadahead, +} + +impl GetObjectStreamStrategy { + fn as_str(self) -> &'static str { + match self { + Self::Standard => "standard", + Self::LargeSequentialReadahead => "large_sequential_readahead", + } + } +} + +const LARGE_SEQUENTIAL_GET_THRESHOLD_BYTES: i64 = 1024 * 1024 * 1024; + +const LARGE_SEQUENTIAL_GET_STREAM_BUFFER_CAP_BYTES: usize = 4 * MI_B; + +const LARGE_SEQUENTIAL_GET_READAHEAD_MULTIPLIER: usize = 2; + +const LARGE_BODY_READER_STREAM_BUFFER_FLOOR_BYTES: usize = MI_B; + +const LARGE_BODY_READER_STREAM_BUFFER_THRESHOLD_BYTES: i64 = 4 * MI_B as i64; + +const MID_BODY_READER_STREAM_BUFFER_FLOOR_BYTES: usize = 512 * 1024; + +const MID_BODY_READER_STREAM_BUFFER_THRESHOLD_BYTES: i64 = MI_B as i64; + +const ENV_RUSTFS_GET_SEEK_BUFFER_ENABLE: &str = "RUSTFS_GET_SEEK_BUFFER_ENABLE"; + +const ENV_RUSTFS_GET_READER_STREAM_BUFFER_SIZE: &str = "RUSTFS_GET_READER_STREAM_BUFFER_SIZE"; + +const ENV_RUSTFS_GET_OUTPUT_HANDOFF_ATTRIBUTION_ENABLE: &str = "RUSTFS_GET_OUTPUT_HANDOFF_ATTRIBUTION_ENABLE"; + +const ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE: &str = "RUSTFS_GET_SMALL_BODY_ONCE_ENABLE"; + +const GET_READER_STREAM_BUFFER_SOURCE_SELECTED: &str = "selected"; + +const GET_READER_STREAM_BUFFER_SOURCE_ENV_OVERRIDE: &str = "env_override"; + +const GET_READER_STREAM_POLL_PENDING: &str = "pending"; + +const GET_READER_STREAM_POLL_READY_DATA: &str = "ready_data"; + +const GET_READER_STREAM_POLL_READY_EMPTY: &str = "ready_empty"; + +const GET_READER_STREAM_POLL_READY_ERROR: &str = "ready_error"; + +const GET_STREAMING_BODY_FAILURE_STAGE_READER_STREAM: &str = "reader_stream"; + +const GET_STREAMING_BODY_FAILURE_REASON_READER_ERROR: &str = "reader_error"; + +const GET_STREAMING_BODY_FAILURE_REASON_SHORT_EOF: &str = "short_eof"; + +const GET_MEMORY_BODY_SOURCE_BUFFERED_BODY: &str = "buffered_body"; + +const GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE: &str = "object_data_cache"; + +const GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE_MATERIALIZED: &str = "object_data_cache_materialized"; + +const GET_MEMORY_BODY_SOURCE_SEEK_BUFFER: &str = "seek_buffer"; + +const GET_MEMORY_BODY_SOURCE_ENCRYPTED_BUFFER: &str = "encrypted_buffer"; + +const GET_OBJECT_STAGE_BODY_CACHE_MATERIALIZE_READ: &str = "body_cache_materialize_read"; + +fn get_reader_stream_buffer_size_override() -> Option { + static GET_READER_STREAM_BUFFER_SIZE_OVERRIDE: OnceLock> = OnceLock::new(); + *GET_READER_STREAM_BUFFER_SIZE_OVERRIDE.get_or_init(|| { + std::env::var(ENV_RUSTFS_GET_READER_STREAM_BUFFER_SIZE) + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + }) +} + +fn is_get_output_handoff_attribution_enabled() -> bool { + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| rustfs_utils::get_env_bool(ENV_RUSTFS_GET_OUTPUT_HANDOFF_ATTRIBUTION_ENABLE, false)) +} + +fn is_get_small_body_once_enabled() -> bool { + #[cfg(test)] + { + rustfs_utils::get_env_bool(ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE, false) + } + #[cfg(not(test))] + { + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| rustfs_utils::get_env_bool(ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE, false)) + } +} + +fn is_get_seek_buffer_enabled() -> bool { + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| rustfs_utils::get_env_bool(ENV_RUSTFS_GET_SEEK_BUFFER_ENABLE, false)) +} + +fn resolve_reader_stream_buffer_size(selected_size: usize, override_size: Option) -> (usize, &'static str) { + if let Some(override_size) = override_size.filter(|value| *value > 0) { + return (override_size, GET_READER_STREAM_BUFFER_SOURCE_ENV_OVERRIDE); + } + + (selected_size.max(1), GET_READER_STREAM_BUFFER_SOURCE_SELECTED) +} + +fn tune_reader_stream_buffer_size( + selected_size: usize, + response_content_length: i64, + stream_strategy: GetObjectStreamStrategy, +) -> usize { + if stream_strategy == GetObjectStreamStrategy::Standard + && response_content_length >= LARGE_BODY_READER_STREAM_BUFFER_THRESHOLD_BYTES + { + return selected_size.max(LARGE_BODY_READER_STREAM_BUFFER_FLOOR_BYTES); + } + + if stream_strategy == GetObjectStreamStrategy::Standard + && response_content_length >= MID_BODY_READER_STREAM_BUFFER_THRESHOLD_BYTES + { + return selected_size.max(MID_BODY_READER_STREAM_BUFFER_FLOOR_BYTES); + } + + selected_size +} + +fn get_object_stream_size_bucket(expected: usize) -> &'static str { + rustfs_io_metrics::get_object_size_bucket(i64::try_from(expected).unwrap_or(i64::MAX)) +} + +fn classify_get_object_stream_read_error(err: &std::io::Error) -> &'static str { + if let Some(inner) = err.get_ref() { + if inner.is::() { + return "short_eof"; + } + + if inner.is::() { + return "bitrot"; + } + + let error_msg = inner.to_string().to_lowercase(); + if error_msg.contains("bitrot") { + return "bitrot"; + } + if error_msg.contains("read quorum") || error_msg.contains("insufficient read quorum") || error_msg.contains("erasure") { + return "read_quorum"; + } + } + + match err.kind() { + std::io::ErrorKind::UnexpectedEof => "short_eof", + std::io::ErrorKind::TimedOut => "timeout", + std::io::ErrorKind::InvalidInput | std::io::ErrorKind::InvalidData => "range_or_length_invalid", + _ => "io", + } +} + +fn get_object_stream_failure_reason(error_class: &'static str) -> &'static str { + if error_class == "short_eof" { + GET_STREAMING_BODY_FAILURE_REASON_SHORT_EOF + } else { + GET_STREAMING_BODY_FAILURE_REASON_READER_ERROR + } +} + +fn record_get_object_reader_stream_failure( + reason: &'static str, + error_class: &'static str, + strategy: &'static str, + buffer_source: &'static str, + expected: usize, + emitted: usize, + remaining: usize, +) { + rustfs_io_metrics::record_get_object_streaming_body_failure(rustfs_io_metrics::GetObjectStreamingBodyFailure { + stage: GET_STREAMING_BODY_FAILURE_STAGE_READER_STREAM, + reason, + error_class, + strategy, + buffer_source, + size_bucket: get_object_stream_size_bucket(expected), + emitted_bytes: emitted, + remaining_bytes: remaining, + }); +} + +struct MemoryTrackedBytesStream { + bytes: Option, + emitted: bool, + completed: bool, + expected: usize, + /// Set when the materialized buffer length disagrees with the declared + /// content length. Such a body would be truncated (short) or over-long + /// relative to the already-committed `Content-Length`, so the stream must + /// surface an error instead of a clean short/over-long body. See #1324. + length_mismatch: bool, + started: std::time::Instant, + source: &'static str, + _guard: Option, + lifecycle: GetObjectBodyLifecycle, +} + +struct MemoryOnceBodyOwner { + bytes: Bytes, + _guard: Option, + // Body::Once has no poll hook, so this opt-in path only holds the request + // guard until the bytes are dropped; the result status remains unknown. + _lifecycle: GetObjectBodyLifecycle, +} + +impl MemoryOnceBodyOwner { + fn new(bytes: Bytes, guard: Option, lifecycle: GetObjectBodyLifecycle) -> Self { + Self { + bytes, + _guard: guard, + _lifecycle: lifecycle, + } + } +} + +impl AsRef<[u8]> for MemoryOnceBodyOwner { + fn as_ref(&self) -> &[u8] { + self.bytes.as_ref() + } +} + +#[derive(Default)] +struct GetObjectBodyLifecycle { + request_guard: Option, +} + +impl GetObjectBodyLifecycle { + fn tracked(request_guard: GetObjectGuard) -> Self { + Self { + request_guard: Some(request_guard), + } + } + + #[cfg(test)] + fn disabled() -> Self { + Self { request_guard: None } + } + + fn is_finished(&self) -> bool { + self.request_guard.is_none() + } + + fn finish_ok(&mut self) { + if let Some(mut request_guard) = self.request_guard.take() { + request_guard.finish_ok(); + } + } + + fn finish_err(&mut self) { + if let Some(mut request_guard) = self.request_guard.take() { + request_guard.finish_err(); + } + } +} + +pin_project! { + // Keep the disk-read admission permit tied to the response body. This is + // intentionally conservative backpressure: a streaming GET should occupy a + // read slot until the client drains or drops the body. + struct DiskReadPermitReader { + #[pin] + inner: R, + disk_permit: Option, + } +} + +impl DiskReadPermitReader { + fn new(inner: R, disk_permit: GetObjectDiskPermit) -> Self { + Self { + inner, + disk_permit: Some(disk_permit), + } + } +} + +impl AsyncRead for DiskReadPermitReader +where + R: AsyncRead, +{ + fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + let this = self.project(); + let had_capacity = buf.remaining() > 0; + let filled_before = buf.filled().len(); + let poll = this.inner.poll_read(cx, buf); + // EOF: no more disk reads can happen through this stream, so release + // the permit instead of holding it until the client drops the body. + if had_capacity + && matches!(poll, Poll::Ready(Ok(()))) + && buf.filled().len() == filled_before + && let Some(mut disk_permit) = this.disk_permit.take() + { + disk_permit.release(); + } + poll + } +} + +pin_project! { + struct GetObjectReaderStream { + #[pin] + reader: Option, + capacity: usize, + strategy: &'static str, + buffer_source: &'static str, + remaining: usize, + emitted: usize, + expected: usize, + // Diagnostic-only identity for the body this stream is serving. Unset in + // unit tests that drive the stream over a bare reader; every production + // body carries it via `with_diagnostics`. + diagnostics: GetObjectReaderStreamDiagnostics, + } +} + +/// Object identity carried alongside a streaming GET body purely so a +/// mid-stream failure names the object it happened on. +#[derive(Clone, Default)] +struct GetObjectReaderStreamDiagnostics { + bucket: String, + object: String, + request_id: String, +} + +impl MemoryTrackedBytesStream { + fn new( + bytes: Bytes, + expected: usize, + source: &'static str, + guard: Option, + lifecycle: GetObjectBodyLifecycle, + ) -> Self { + let length_mismatch = bytes.len() != expected; + Self { + bytes: Some(bytes), + emitted: false, + completed: !length_mismatch && expected == 0, + expected, + length_mismatch, + started: std::time::Instant::now(), + source, + _guard: guard, + lifecycle, + } + } + + fn finish_ok(&mut self) { + self.completed = true; + self.lifecycle.finish_ok(); + } + + fn finish_err(&mut self) { + self.lifecycle.finish_err(); + } +} + +impl GetObjectReaderStream +where + R: AsyncRead, +{ + fn new(reader: R, capacity: usize, remaining: usize, strategy: &'static str, buffer_source: &'static str) -> Self { + if is_get_output_handoff_attribution_enabled() { + rustfs_io_metrics::record_get_object_reader_stream_buffer_size(strategy, buffer_source, capacity); + } + Self { + reader: Some(reader), + capacity, + strategy, + buffer_source, + remaining, + emitted: 0, + expected: remaining, + diagnostics: GetObjectReaderStreamDiagnostics::default(), + } + } + + /// Attach the object identity a failed body should be reported against. + fn with_diagnostics(mut self, bucket: &str, object: &str, request_id: &str) -> Self { + self.diagnostics = GetObjectReaderStreamDiagnostics { + bucket: bucket.to_string(), + object: object.to_string(), + request_id: request_id.to_string(), + }; + self + } +} + +impl futures::Stream for MemoryTrackedBytesStream { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + let poll_start = is_get_output_handoff_attribution_enabled().then(std::time::Instant::now); + if this.emitted { + if let Some(poll_start) = poll_start { + rustfs_io_metrics::record_get_object_memory_body_stream_poll( + this.source, + GET_READER_STREAM_POLL_READY_EMPTY, + 0, + poll_start.elapsed().as_secs_f64(), + ); + } + return Poll::Ready(None); + } + + // Strict materialization guard (#1324): a body whose length disagrees + // with the declared content length must fail the transfer rather than be + // delivered as a clean short body (truncation) or an over-long body + // (protocol violation). The HTTP layer has already committed to + // `Content-Length == expected`, so there is no safe way to serve a + // differently sized body. This is a defense-in-depth backstop; the + // buffered/cache callers reject the mismatch before headers are sent. + if this.length_mismatch { + let actual = this.bytes.as_ref().map_or(0, Bytes::len); + this.emitted = true; + this.finish_err(); + return Poll::Ready(Some(Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("materialized GET body length mismatch: expected {}, got {}", this.expected, actual), + ) + .into()))); + } + + let Some(bytes) = this.bytes.take() else { + return Poll::Ready(None); + }; + let bytes_len = bytes.len(); + let first_byte_elapsed = (!bytes.is_empty()).then(|| this.started.elapsed()); + this.emitted = true; + if let Some(elapsed) = first_byte_elapsed { + rustfs_io_metrics::record_get_object_first_byte_latency(GET_OBJECT_STAGE_PATH_S3_HANDLER, elapsed.as_secs_f64()); + } + if bytes_len >= this.expected { + this.finish_ok(); + } + if let Some(poll_start) = poll_start { + rustfs_io_metrics::record_get_object_memory_body_stream_poll( + this.source, + GET_READER_STREAM_POLL_READY_DATA, + bytes_len, + poll_start.elapsed().as_secs_f64(), + ); + } + Poll::Ready(Some(Ok(bytes))) + } +} + +impl ByteStream for MemoryTrackedBytesStream { + fn remaining_length(&self) -> RemainingLength { + if self.emitted || self.bytes.is_none() { + RemainingLength::new_exact(0) + } else { + RemainingLength::new_exact(self.expected) + } + } +} + +impl Drop for MemoryTrackedBytesStream { + fn drop(&mut self) { + if self.lifecycle.is_finished() { + return; + } + + if self.completed { + self.finish_ok(); + } else { + self.finish_err(); + } + } +} + +/// Failure modes of strictly materializing an object body into memory (#1324). +#[derive(Debug)] +enum StrictMaterializeError { + /// The reader produced a different number of bytes than the declared content + /// length (short or over-long). The response has already committed to + /// `Content-Length == expected`, so any other length is an unrecoverable, + /// broken HTTP response and must fail before headers are sent. + LengthMismatch { expected: usize, actual: usize }, + /// A read error occurred after `consumed` bytes were already drained from the + /// reader. The caller MUST NOT fall back to streaming the same reader: the + /// drained prefix is gone, so streaming would ship a body missing its prefix + /// (the seek-buffer prefix-misalignment bug this issue closes). + Read { consumed: usize, source: std::io::Error }, +} + +impl std::fmt::Display for StrictMaterializeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::LengthMismatch { expected, actual, .. } => { + write!(f, "materialized length mismatch: expected {expected}, got {actual}") + } + Self::Read { consumed, source } => { + write!(f, "read failed after {consumed} bytes: {source}") + } + } + } +} + +impl StrictMaterializeError { + fn into_storage_error(self) -> StorageError { + match self { + Self::LengthMismatch { expected, actual, .. } if actual < expected => StorageError::LessData, + Self::LengthMismatch { .. } => StorageError::MoreData, + Self::Read { source, .. } if source.kind() == std::io::ErrorKind::TimedOut => StorageError::Timeout, + Self::Read { source, .. } => StorageError::Io(std::io::Error::new(source.kind(), "object body read failed")), + } + } + + fn into_s3_error(self, _response_content_length: i64) -> S3Error { + ApiError::from(self.into_storage_error()).into() + } +} + +/// Strictly materialize an object body into memory, enforcing an exact-length +/// contract (#1324). +/// +/// Reads at most `expected + 1` bytes so an over-long stream is detected without +/// buffering it unbounded, then requires `bytes_read == expected`. A short read +/// (clean EOF before `expected`), an over-long read, or a mid-stream read error +/// all return an error; only an exact-length read yields the buffer. Because the +/// HTTP response commits to `Content-Length == expected` before the body is +/// produced, this mirrors the streaming path (which already fails a short read +/// with `UnexpectedEof`) and the ODC materialize-fill path, closing the +/// warn-and-serve holes in the encrypted, seek, and cache memory branches. +/// +/// On error the reader has already been (partially) consumed, so callers must +/// propagate the error rather than fall back to streaming the same reader. +async fn strict_materialize_object_body( + reader: R, + expected: usize, + stage: &'static str, +) -> Result, StrictMaterializeError> +where + R: AsyncRead + Unpin, +{ + // Stop filling before the Vec reaches capacity. Calling `read_to_end` on a + // bounded reader can still reserve beyond `expected` before observing EOF. + // The over-long probe below stays outside this Vec so the admitted body + // allocation remains exactly `expected` bytes. + let mut buf = Vec::with_capacity(expected); + let mut reader = reader; + let read_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let read_result = loop { + if buf.len() == expected { + break Ok(()); + } + match tokio::io::AsyncReadExt::read_buf(&mut reader, &mut buf).await { + Ok(0) => break Ok(()), + Ok(_) => {} + Err(source) => break Err(source), + } + }; + let actual = buf.len(); + let probe_result = if read_result.is_ok() && actual == expected { + let mut probe = [0_u8; 1]; + tokio::io::AsyncReadExt::read(&mut reader, &mut probe).await + } else { + Ok(0) + }; + record_get_object_s3_handler_stage_duration(stage, read_start); + match (read_result, probe_result) { + (Ok(_), Ok(extra)) => { + let actual = actual.saturating_add(extra); + if actual == expected { + Ok(buf) + } else { + Err(StrictMaterializeError::LengthMismatch { expected, actual }) + } + } + (Err(source), _) | (_, Err(source)) => Err(StrictMaterializeError::Read { + consumed: actual, + source, + }), + } +} + +struct ColdFillProducerExecution { + expected: usize, + deadline: Option, + adapter: Arc, + engine_plan: rustfs_object_data_cache::ObjectDataCacheGetPlan, +} + +enum ColdFillStartupWaitError { + Cancelled, + DeadlineExceeded, +} + +async fn await_cold_fill_startup( + future: F, + cancellation: &tokio_util::sync::CancellationToken, + deadline: Option, +) -> Result +where + F: Future, +{ + tokio::pin!(future); + match deadline { + Some(deadline) => { + tokio::select! { + biased; + _ = cancellation.cancelled() => Err(ColdFillStartupWaitError::Cancelled), + result = tokio::time::timeout_at(deadline, &mut future) => { + result.map_err(|_| ColdFillStartupWaitError::DeadlineExceeded) + } + } + } + None => { + tokio::select! { + biased; + _ = cancellation.cancelled() => Err(ColdFillStartupWaitError::Cancelled), + result = &mut future => Ok(result), + } + } + } +} + +async fn start_cold_fill_producer( + producer: ColdFillProducer, + reservation: Option, + acquire_io: AcquireIo, + open_reader: OpenReader, + execution: ColdFillProducerExecution, +) where + AcquireIo: FnOnce() -> AcquireIoFuture, + AcquireIoFuture: Future>, + OpenReader: FnOnce() -> OpenReaderFuture, + OpenReaderFuture: Future>, +{ + let ColdFillProducerExecution { + expected, + deadline, + adapter, + engine_plan, + } = execution; + let hard_deadline = tokio::time::Instant::now() + COLD_FILL_HARD_MAX_DURATION; + let deadline = deadline.map_or(hard_deadline, |request_deadline| request_deadline.min(hard_deadline)); + let cancellation = producer.cancellation_token(); + let Some(reservation) = reservation else { + producer.bypass(); + return; + }; + let acquire = acquire_io(); + tokio::pin!(acquire); + let producer_io = tokio::select! { + _ = cancellation.cancelled() => { + producer.finish(Err(StorageError::OperationCanceled)); + return; + } + result = tokio::time::timeout_at(deadline, &mut acquire) => match result { + Ok(result) => result, + Err(_) => { + producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout)); + return; + } + } + }; + let producer_io = match producer_io { + Ok(io) => io, + Err(err) => { + producer.relinquish_or_finish(err); + return; + } + }; + + let open = open_reader(); + tokio::pin!(open); + let reader = match tokio::select! { + _ = cancellation.cancelled() => Err(StorageError::OperationCanceled), + result = tokio::time::timeout_at(deadline, &mut open) => { + result.unwrap_or(Err(StorageError::Timeout)) + } + } { + Ok(reader) => reader, + Err(err) => { + producer.relinquish_or_finish(ColdFillError::Storage(err)); + return; + } + }; + producer.mark_reader_started(); + let materialize = async move { + let GetObjectReader { + stream, buffered_body, .. + } = reader; + let body = if let Some(body) = buffered_body { + if body.len() == expected { + body + } else { + return Err(StorageError::other(format!( + "cold-fill buffered body length mismatch: expected {expected}, got {}", + body.len() + ))); + } + } else { + let stream = if let Some(permit) = producer_io.disk_permit { + wrap_reader(DiskReadPermitReader::new(stream, permit)) + } else { + stream + }; + Bytes::from( + strict_materialize_object_body(stream, expected, GET_OBJECT_STAGE_BODY_CACHE_MATERIALIZE_READ) + .await + .map_err(StrictMaterializeError::into_storage_error)?, + ) + }; + Ok::<_, StorageError>((body, reservation)) + }; + let materialized = tokio::select! { + _ = cancellation.cancelled() => Err(StorageError::OperationCanceled), + result = tokio::time::timeout_at(deadline, materialize) => { + result.unwrap_or(Err(StorageError::Timeout)) + } + }; + let result = match materialized { + Ok((body, reservation)) => { + if cancellation.is_cancelled() { + producer.finish(Err(StorageError::OperationCanceled)); + return; + } + if deadline <= tokio::time::Instant::now() { + producer.finish(Err(StorageError::Timeout)); + return; + } + let reserved = reservation.wrap_bytes(body); + let shared = reserved.bytes(); + let publish = async { + #[cfg(test)] + wait_cold_fill_publication_barrier(&engine_plan).await; + adapter.fill_reserved_body(&engine_plan, reserved).await + }; + tokio::pin!(publish); + tokio::select! { + _ = cancellation.cancelled() => Err(StorageError::OperationCanceled), + _ = tokio::time::sleep_until(deadline) => { + Err(StorageError::Timeout) + } + _ = &mut publish => Ok(shared), + } + } + Err(err) => Err(err), + }; + producer.finish(result); +} + +fn cold_fill_deadline( + wrapper: &RequestTimeoutWrapper, + timeout_config: &GetObjectTimeoutPolicy, + response_size: u64, +) -> Option { + if !timeout_config.is_timeout_enabled() { + return None; + } + Some(tokio::time::Instant::now() + wrapper.remaining_time_for_size(Some(response_size)).unwrap_or(Duration::ZERO)) +} + +fn cold_fill_producer_deadline(timeout_config: &GetObjectTimeoutPolicy, response_size: u64) -> tokio::time::Instant { + let now = tokio::time::Instant::now(); + let hard_deadline = now + COLD_FILL_HARD_MAX_DURATION; + if timeout_config.is_timeout_enabled() { + (now + timeout_config.calculate_timeout_for_size(response_size)).min(hard_deadline) + } else { + hard_deadline + } +} + +async fn lookup_cold_fill_second_chance( + adapter: &ObjectDataCacheAdapter, + plan: &rustfs_object_data_cache::ObjectDataCacheGetPlan, +) -> Option { + match adapter.peek_body_untracked(plan).await { + rustfs_object_data_cache::ObjectDataCacheLookup::Hit(body) => Some(body), + _ => None, + } +} + +fn retain_cold_fill_producer_for_matching_plan( + producer: ColdFillProducer, + current: &GetObjectBodyCachePlan, + expected: &rustfs_object_data_cache::ObjectDataCacheGetPlan, +) -> Option { + if current == &GetObjectBodyCachePlan::Cacheable(expected.clone()) { + Some(producer) + } else { + producer.bypass(); + None + } +} + +impl futures::Stream for GetObjectReaderStream +where + R: AsyncRead, +{ + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut this = self.project(); + if *this.remaining == 0 { + return Poll::Ready(None); + } + + let remaining_before = *this.remaining; + let attribution_enabled = is_get_output_handoff_attribution_enabled(); + let poll_start = attribution_enabled.then(std::time::Instant::now); + let reader = match this.reader.as_mut().as_pin_mut() { + Some(reader) => reader, + None => return Poll::Ready(None), + }; + let read_capacity = (*this.capacity).min(*this.remaining); + let mut buf = BytesMut::with_capacity(read_capacity); + let poll_read = poll_read_buf(reader, cx, &mut buf); + + let result: Poll> = match poll_read { + Poll::Ready(Ok(bytes_read)) if bytes_read > 0 => { + let bytes = buf.freeze(); + *this.remaining -= bytes.len(); + *this.emitted += bytes.len(); + #[cfg(feature = "tracing-chunk-debug")] + { + tracing::debug!( + emitted = *this.emitted, + expected = *this.expected, + chunk_len = bytes.len(), + "GetObject ReaderStream emitted bytes" + ); + } + if bytes.is_empty() { + Poll::Ready(None) + } else { + Poll::Ready(Some(Ok(bytes))) + } + } + Poll::Ready(Ok(_)) => { + this.reader.set(None); + let remaining = i64::try_from(*this.remaining).unwrap_or(i64::MAX); + let err = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, rustfs_rio::IncompleteBody { remaining }); + record_get_object_reader_stream_failure( + GET_STREAMING_BODY_FAILURE_REASON_SHORT_EOF, + "short_eof", + this.strategy, + this.buffer_source, + *this.expected, + *this.emitted, + *this.remaining, + ); + // The inner GetObjectStreamingReader is what normally reports a + // short body, so reaching this arm means the reader signalled a + // clean EOF while this layer still owed bytes against an + // already-committed Content-Length. That disagreement is a data + // plane fault, not chunk noise: log it unconditionally so the + // truncated object is named in the operator's log rather than + // only in a metric counter (issue #4784). + error!( + event = EVENT_GET_OBJECT_STREAM_BODY, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + bucket = %this.diagnostics.bucket, + object = %this.diagnostics.object, + request_id = %this.diagnostics.request_id, + size_bucket = get_object_stream_size_bucket(*this.expected), + expected = *this.expected, + emitted = *this.emitted, + remaining = *this.remaining, + strategy = this.strategy, + buffer_source = this.buffer_source, + state = "reader_stream_short_eof", + error = %err, + "GetObject reader stream ended before the committed content length" + ); + Poll::Ready(Some(Err(Box::new(err) as S3StdError))) + } + Poll::Ready(Err(err)) => { + this.reader.set(None); + let error_class = classify_get_object_stream_read_error(&err); + record_get_object_reader_stream_failure( + get_object_stream_failure_reason(error_class), + error_class, + this.strategy, + this.buffer_source, + *this.expected, + *this.emitted, + *this.remaining, + ); + // Deliberately not logged at warn here: every production body + // wraps a GetObjectStreamingReader, and that layer already + // reports this same error once with `state = "read_failed"` and + // the object identity. A second unconditional line per failed + // GET would read as two distinct faults. The chunk-debug build + // still gets this layer's view of the same error. + #[cfg(feature = "tracing-chunk-debug")] + tracing::error!( + emitted = *this.emitted, + expected = *this.expected, + error_class = error_class, + error = %err, + "GetObject ReaderStream returned error" + ); + Poll::Ready(Some(Err(Box::new(err) as S3StdError))) + } + Poll::Pending => Poll::Pending, + }; + + let emitted_bytes = match &result { + Poll::Ready(Some(Ok(bytes))) => bytes.len(), + _ => 0, + }; + let outcome = match &result { + Poll::Ready(Some(Ok(bytes))) if !bytes.is_empty() => GET_READER_STREAM_POLL_READY_DATA, + Poll::Ready(Some(Ok(_))) | Poll::Ready(None) => GET_READER_STREAM_POLL_READY_EMPTY, + Poll::Ready(Some(Err(_))) => GET_READER_STREAM_POLL_READY_ERROR, + Poll::Pending => GET_READER_STREAM_POLL_PENDING, + }; + if attribution_enabled { + rustfs_io_metrics::record_get_object_reader_stream_poll( + this.strategy, + this.buffer_source, + outcome, + remaining_before, + emitted_bytes, + poll_start.map_or(0.0, |start| start.elapsed().as_secs_f64()), + ); + } + + result + } + + fn size_hint(&self) -> (usize, Option) { + if self.remaining == 0 || self.reader.is_none() { + (0, Some(0)) + } else { + (1, None) + } + } +} + +impl ByteStream for GetObjectReaderStream +where + R: AsyncRead, +{ + fn remaining_length(&self) -> RemainingLength { + RemainingLength::new_exact(self.remaining) + } +} + +struct GetObjectStreamingReader { + inner: Option, + // bucket/object + request_id + optional content_range are only used for diagnostic + // correlation and failure bucketing; they do not alter stream behavior. The object + // identity is what turns a mid-stream failure into an actionable report: a request_id + // alone cannot tell an operator which object reads short (issue #4784). + bucket: String, + object: String, + request_id: String, + content_range: Option, + expected: usize, + emitted: usize, + timeout: Duration, + timer: Option>>, + started: std::time::Instant, + first_byte_reported: bool, + completed: bool, + lifecycle: GetObjectBodyLifecycle, + resume: Option>, + _foreground_read_guard: rustfs_scanner::ForegroundReadGuard, +} + +impl GetObjectStreamingReader { + #[allow(clippy::too_many_arguments)] + fn new( + inner: R, + bucket: &str, + key: &str, + request_id: &str, + content_range: Option, + expected: usize, + timeout: Duration, + lifecycle: GetObjectBodyLifecycle, + resume: Option>, + ) -> Self { + Self { + inner: Some(inner), + bucket: bucket.to_string(), + object: key.to_string(), + request_id: request_id.to_string(), + content_range, + expected, + emitted: 0, + timeout, + timer: None, + started: std::time::Instant::now(), + first_byte_reported: false, + completed: expected == 0, + lifecycle, + resume, + _foreground_read_guard: rustfs_scanner::ForegroundReadGuard::new(), + } + } + + fn elapsed(&self) -> Duration { + self.started.elapsed() + } + + // Classify transport/read failures before logging so operators can quickly + // distinguish truncated upstream bodies, corruption, quorum issues, and + // genuine downstream-close disconnects. + fn classify_read_error(err: &std::io::Error) -> &'static str { + classify_get_object_stream_read_error(err) + } + + fn finish_ok(&mut self) { + self.completed = true; + self.lifecycle.finish_ok(); + } + + fn finish_err(&mut self) { + self.lifecycle.finish_err(); + } + + fn resume_in_flight(&self) -> bool { + matches!( + self.resume.as_ref().map(|resume| &resume.stage), + Some(GetObjectResumeStage::Backoff | GetObjectResumeStage::Reopening(_)) + ) + } + + fn begin_resume(&mut self, error: std::io::Error) { + let Some(resume) = self.resume.as_mut() else { + return; + }; + self.inner.take(); + resume.begin(error); + } + + // Drive the armed resume flow: backoff ticks gate each reopen attempt, and + // a successful reopen swaps the failed stream out for the replacement. + fn poll_resume(&mut self, cx: &mut Context<'_>) -> GetObjectResumePoll { + let Some(mut resume) = self.resume.take() else { + // resume_in_flight guards every call site. + unreachable!("poll_resume requires an armed resume control"); + }; + let outcome = loop { + let stage = std::mem::replace(&mut resume.stage, GetObjectResumeStage::Idle); + match stage { + GetObjectResumeStage::Idle => unreachable!("resume control is only polled while armed"), + GetObjectResumeStage::Backoff => match Pin::new(&mut resume.timer).poll_next(cx) { + Poll::Ready(Some(())) => { + resume.attempts += 1; + resume.stage = GetObjectResumeStage::Reopening(Mutex::new((resume.reopen)(self.emitted))); + } + Poll::Ready(None) => { + let error = resume.take_trigger_error(); + break GetObjectResumePoll::Failed { + error, + attempts: resume.attempts, + }; + } + Poll::Pending => { + resume.stage = GetObjectResumeStage::Backoff; + break GetObjectResumePoll::Pending; + } + }, + GetObjectResumeStage::Reopening(reopening) => { + let poll = match reopening.try_lock() { + Ok(mut reopening) => reopening.as_mut().poll(cx), + // Only reachable when a poll of the reopen future + // panicked and poisoned the mutex: fail closed with the + // original trigger error instead of polling it again. + Err(_) => { + let error = resume.take_trigger_error(); + break GetObjectResumePoll::Failed { + error, + attempts: resume.attempts, + }; + } + }; + match poll { + Poll::Ready(Ok(reader)) => { + self.inner = Some(reader); + break GetObjectResumePoll::Resumed { + attempts: resume.attempts, + }; + } + Poll::Ready(Err(GetObjectResumeFailure::Retryable)) => { + resume.stage = GetObjectResumeStage::Backoff; + } + Poll::Ready(Err(GetObjectResumeFailure::Fatal)) => { + let error = resume.take_trigger_error(); + break GetObjectResumePoll::Failed { + error, + attempts: resume.attempts, + }; + } + Poll::Pending => { + resume.stage = GetObjectResumeStage::Reopening(reopening); + break GetObjectResumePoll::Pending; + } + } + } + } + }; + if matches!(outcome, GetObjectResumePoll::Resumed { .. } | GetObjectResumePoll::Pending) { + self.resume = Some(resume); + } + outcome + } + + fn poll_stall_timeout(&mut self, cx: &mut Context<'_>) -> Poll> { + if self.timeout.is_zero() { + return Poll::Pending; + } + + if self.timer.is_none() { + self.timer = Some(Box::pin(tokio::time::sleep(self.timeout))); + } + + if let Some(timer) = self.timer.as_mut() + && std::future::Future::poll(timer.as_mut(), cx).is_ready() + { + self.timer = None; + warn!( + event = EVENT_GET_OBJECT_STREAM_BODY, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + bucket = %self.bucket, + object = %self.object, + request_id = %self.request_id, + range = %self.content_range.as_deref().unwrap_or("full"), + size_bucket = get_object_stream_size_bucket(self.expected), + expected = self.expected, + emitted = self.emitted, + elapsed_ms = self.elapsed().as_millis(), + timeout_ms = self.timeout.as_millis(), + state = "stall_timeout", + "GetObject streaming body stalled" + ); + self.finish_err(); + return Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "get object streaming body stall timeout", + ))); + } + + Poll::Pending + } +} + +impl AsyncRead for GetObjectStreamingReader { + fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + let filled_before = buf.filled().len(); + + loop { + // An armed resume owns the reader until it swaps in a reopened + // stream or exhausts its budget; the failed inner stream is never + // polled again. + if self.resume_in_flight() { + match self.poll_resume(cx) { + GetObjectResumePoll::Resumed { attempts } => { + debug!( + event = EVENT_GET_OBJECT_STREAM_BODY, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + bucket = %self.bucket, + object = %self.object, + request_id = %self.request_id, + range = %self.content_range.as_deref().unwrap_or("full"), + size_bucket = get_object_stream_size_bucket(self.expected), + expected = self.expected, + emitted = self.emitted, + resume_attempts = attempts, + state = "resumed", + "GetObject streaming body resumed from a reopened object read" + ); + // The replacement stream starts a fresh stall window. + self.timer = None; + continue; + } + GetObjectResumePoll::Pending => return self.poll_stall_timeout(cx), + GetObjectResumePoll::Failed { error, attempts } => { + self.timer = None; + let failure_reason = Self::classify_read_error(&error); + self.finish_err(); + error!( + event = EVENT_GET_OBJECT_STREAM_BODY, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + bucket = %self.bucket, + object = %self.object, + request_id = %self.request_id, + range = %self.content_range.as_deref().unwrap_or("full"), + size_bucket = get_object_stream_size_bucket(self.expected), + expected = self.expected, + emitted = self.emitted, + elapsed_ms = self.elapsed().as_millis(), + state = "read_failed", + failure_reason = failure_reason, + resume_attempts = attempts, + error = %error, + "GetObject streaming body read failed; mid-stream resume did not recover" + ); + return Poll::Ready(Err(error)); + } + } + } + + let Some(inner) = self.inner.as_mut() else { + self.finish_err(); + return Poll::Ready(Err(std::io::Error::other( + "get object streaming reader lost its active read outside resume", + ))); + }; + match Pin::new(inner).poll_read(cx, buf) { + Poll::Ready(Ok(())) => { + self.timer = None; + let produced = buf.filled().len().saturating_sub(filled_before); + if produced > 0 { + self.emitted = self.emitted.saturating_add(produced); + if !self.first_byte_reported { + self.first_byte_reported = true; + let elapsed = self.elapsed(); + rustfs_io_metrics::record_get_object_first_byte_latency( + GET_OBJECT_STAGE_PATH_S3_HANDLER, + elapsed.as_secs_f64(), + ); + if elapsed >= GET_OBJECT_STREAM_WARN_THRESHOLD { + warn!( + event = EVENT_GET_OBJECT_STREAM_BODY, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + bucket = %self.bucket, + object = %self.object, + request_id = %self.request_id, + range = %self.content_range.as_deref().unwrap_or("full"), + size_bucket = get_object_stream_size_bucket(self.expected), + expected = self.expected, + emitted = self.emitted, + elapsed_ms = elapsed.as_millis(), + state = "first_byte_slow", + "GetObject streaming body first byte was slow" + ); + } + } + if self.emitted >= self.expected { + self.completed = true; + self.finish_ok(); + } + return Poll::Ready(Ok(())); + } + + if self.emitted < self.expected { + // The inner reader signalled a clean EOF before delivering the full + // Content-Length. Returning Ok here would hand the client a truncated body + // under a full Content-Length: the peer treats the short body as complete + // (e.g. `mc mirror` writes a short file and considers it done — the + // "incomplete data mirroring" in issue #2955). Surface an error instead so + // the transfer fails loudly and the client retries rather than persisting + // truncated data. + let error = std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + rustfs_rio::IncompleteBody { + remaining: self.expected.saturating_sub(self.emitted) as i64, + }, + ); + // A premature EOF is also how the legacy duplex read path + // surfaces the object data vanishing mid-stream (typed + // errors do not survive that pump), so arm the resume + // flow before failing loudly when one is attached. + if self.resume.is_some() { + self.begin_resume(error); + continue; + } + error!( + event = EVENT_GET_OBJECT_STREAM_BODY, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + bucket = %self.bucket, + object = %self.object, + request_id = %self.request_id, + range = %self.content_range.as_deref().unwrap_or("full"), + size_bucket = get_object_stream_size_bucket(self.expected), + expected = self.expected, + emitted = self.emitted, + elapsed_ms = self.elapsed().as_millis(), + state = "short_eof", + "GetObject streaming body ended before expected length" + ); + self.finish_err(); + return Poll::Ready(Err(error)); + } + + self.completed = true; + self.finish_ok(); + return Poll::Ready(Ok(())); + } + Poll::Ready(Err(err)) => { + // Typed relocation errors (the codec read path delivers them + // in-band) mean rebalance/decommission removed the pinned + // object data mid-stream: reopen and continue instead of + // failing the download. The error is only intercepted before + // the committed body length has been fully delivered. + if self.emitted < self.expected && is_object_relocation_error(&err) && self.resume.is_some() { + self.begin_resume(err); + continue; + } + let failure_reason = Self::classify_read_error(&err); + self.timer = None; + self.finish_err(); + error!( + event = EVENT_GET_OBJECT_STREAM_BODY, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + bucket = %self.bucket, + object = %self.object, + request_id = %self.request_id, + range = %self.content_range.as_deref().unwrap_or("full"), + size_bucket = get_object_stream_size_bucket(self.expected), + expected = self.expected, + emitted = self.emitted, + elapsed_ms = self.elapsed().as_millis(), + state = "read_failed", + failure_reason = failure_reason, + error = %err, + "GetObject streaming body read failed" + ); + return Poll::Ready(Err(err)); + } + Poll::Pending => return self.poll_stall_timeout(cx), + } + } + } +} + +impl Drop for GetObjectStreamingReader { + fn drop(&mut self) { + if self.lifecycle.is_finished() { + return; + } + + if self.expected == 0 || self.completed || self.emitted >= self.expected { + self.finish_ok(); + return; + } + + self.finish_err(); + warn!( + event = EVENT_GET_OBJECT_STREAM_BODY, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + bucket = %self.bucket, + object = %self.object, + request_id = %self.request_id, + range = %self.content_range.as_deref().unwrap_or("full"), + size_bucket = get_object_stream_size_bucket(self.expected), + expected = self.expected, + emitted = self.emitted, + elapsed_ms = self.elapsed().as_millis(), + state = "dropped_incomplete", + "GetObject streaming body dropped before expected length" + ); + } +} + +/// Reopen budget for a single GetObject body. Three attempts against the +/// jittered 200ms/400ms RetryTimer schedule (~600ms worst case) bound the +/// metadata fan-out a storm of relocated downloads can multiply. +const GET_OBJECT_RESUME_MAX_ATTEMPTS: i64 = 3; + +type GetObjectResumeFuture = Pin> + Send>>; + +type GetObjectReopen = Box GetObjectResumeFuture + Send + Sync>; + +enum GetObjectResumePoll { + Resumed { attempts: usize }, + Pending, + Failed { error: std::io::Error, attempts: usize }, +} + +/// Why a single resume attempt did not produce a replacement stream. +#[derive(Debug)] +enum GetObjectResumeFailure { + /// Reopen/admission failure that may clear on the next attempt. + Retryable, + /// The reopened object is not the version this response committed to (or + /// admission is permanently unavailable): continuing would splice two + /// versions into one 200 response, so fail with the original error. + Fatal, +} + +enum GetObjectResumeStage { + Idle, + Backoff, + // The store's boxed read futures are Send but not Sync, while the + // streaming body requires the reader to be Sync, so the in-flight reopen + // future is stored behind a mutex. It is only ever locked under `&mut + // self` in `poll_resume`, so the lock never contends. + Reopening(Mutex>), +} + +/// Mid-stream resume machinery for [`GetObjectStreamingReader`]: when the +/// pinned object data vanishes mid-body (rebalance/decommission copies the +/// version elsewhere, then deletes the source), reopen the object at the +/// emitted offset and continue instead of failing the download. +struct GetObjectResumeControl { + reopen: GetObjectReopen, + timer: RetryTimer, + stage: GetObjectResumeStage, + original_error: Option, + attempts: usize, +} + +impl GetObjectResumeControl { + fn new(reopen: GetObjectReopen, timer: RetryTimer) -> Self { + Self { + reopen, + timer, + stage: GetObjectResumeStage::Idle, + original_error: None, + attempts: 0, + } + } + + fn begin(&mut self, error: std::io::Error) { + self.original_error = Some(error); + self.stage = GetObjectResumeStage::Backoff; + } + + // The trigger error is always recorded by `begin`; the fallback is a + // fail-closed internal error, never a fabricated success. + fn take_trigger_error(&mut self) -> std::io::Error { + self.original_error + .take() + .unwrap_or_else(|| std::io::Error::other("get object resume lost its trigger error")) + } +} + +/// Object-version identity captured when the response committed to a body. A +/// resumed read must serve exactly this version; `data_dir` is deliberately +/// excluded because rebalance regenerates it for the same version. +struct GetObjectResumeIdentity { + version_id: Option, + mod_time: Option, + size: i64, + etag: Option, + // The store rewrites a read's `object_info.size` to the per-read delivered + // length for encrypted and compressed objects (readers.rs Encrypted / + // Compressed transforms), so a reopened subrange reports `size - emitted` + // while a plain read reports the range-invariant `oi.size`. The flag only + // chooses the comparison arithmetic; a transform change that no longer + // matches it fails the identity check, which is the closed direction. + range_dependent_size: bool, +} + +impl GetObjectResumeIdentity { + fn matches(&self, info: &ObjectInfo, emitted: usize) -> bool { + let expected_size = if self.range_dependent_size { + self.size - emitted as i64 + } else { + self.size + }; + self.version_id == info.version_id + && self.mod_time == info.mod_time + && expected_size == info.size + && self.etag == info.etag + } +} + +/// Reopen parameters for a mid-stream resume. Only the SSE-C headers the store +/// read path consumes are retained: the store-level `get_object_reader` spans +/// record their header argument at debug level, so retaining the full request +/// headers would re-log credentials on every attempt. +struct GetObjectResumeContext { + store: Arc, + bucket: String, + key: String, + opts: ObjectOptions, + ssec_headers: HeaderMap, + // Resolved plaintext offsets of the committed response body, captured + // after `HTTPRangeSpec::get_offset_length`: suffix ranges and partNumber + // GETs are already resolved to absolute offsets at that point, so the + // resume offset is `range_start + emitted` regardless of request shape. + range_start: i64, + range_end: i64, + identity: GetObjectResumeIdentity, +} + +impl GetObjectResumeContext { + #[allow(clippy::too_many_arguments)] + fn new( + store: Arc, + bucket: &str, + key: &str, + mut opts: ObjectOptions, + request_headers: &HeaderMap, + info: &ObjectInfo, + range_start: i64, + range_end: i64, + ) -> Self { + if opts.version_id.is_none() + && let Some(version_id) = info.version_id + { + opts.version_id = Some(version_id.to_string()); + } + // Store spans record their header argument at debug level. Retain only + // the SSE-C inputs needed to reopen the reader and keep them redacted. + let ssec_headers = project_ssec_transport_headers(request_headers); + Self { + store, + bucket: bucket.to_string(), + key: key.to_string(), + opts, + ssec_headers, + range_start, + range_end, + identity: GetObjectResumeIdentity { + version_id: info.version_id, + mod_time: info.mod_time, + size: info.size, + etag: info.etag.clone(), + range_dependent_size: info.is_encrypted() || info.is_compressed(), + }, + } + } + + fn resume_range(range_start: i64, range_end: i64, emitted: usize) -> Option { + let start = range_start + emitted as i64; + if start == 0 && range_end < 0 { + // Nothing was emitted from a full-object read: reopen without a + // range so the replacement stream keeps the codec fast path + // instead of the duplex fallback a synthesized range forces. + return None; + } + Some(HTTPRangeSpec { + is_suffix_length: false, + start, + end: range_end, + }) + } + + async fn reopen(&self, emitted: usize) -> Result { + #[cfg(test)] + GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.fetch_add(1, Ordering::Relaxed); + + // A resumed read must hold disk-read admission just like the initial + // read; otherwise recovery reads bypass the concurrency caps exactly + // while rebalance is stressing the pool. + let disk_permit = DefaultObjectUsecase::admit_get_object_disk_read(get_concurrency_manager(), &self.bucket, &self.key) + .await + .map_err(|err| { + if err.code() == &S3ErrorCode::SlowDown { + GetObjectResumeFailure::Retryable + } else { + GetObjectResumeFailure::Fatal + } + })?; + let range = Self::resume_range(self.range_start, self.range_end, emitted); + let reader = self + .store + .get_object_reader(&self.bucket, &self.key, range, self.ssec_headers.clone(), &self.opts) + .await + .map_err(|err| { + debug!( + bucket = %self.bucket, + object = %self.key, + error = %err, + "GetObject mid-stream resume reopen failed" + ); + GetObjectResumeFailure::Retryable + })?; + if !self.identity.matches(&reader.object_info, emitted) { + warn!( + bucket = %self.bucket, + object = %self.key, + "GetObject mid-stream resume resolved a different object version; refusing to splice content" + ); + return Err(GetObjectResumeFailure::Fatal); + } + let stream = wrap_reader(reader.stream); + Ok(match disk_permit { + Some(disk_permit) => wrap_reader(DiskReadPermitReader::new(stream, disk_permit)), + None => stream, + }) + } +} + +#[cfg(test)] +static GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST: AtomicUsize = AtomicUsize::new(0); + +fn get_object_resume_control(ctx: GetObjectResumeContext) -> GetObjectResumeControl { + use rand::RngExt as _; + let ctx = Arc::new(ctx); + let reopen: GetObjectReopen = Box::new(move |emitted| { + let ctx = Arc::clone(&ctx); + Box::pin(async move { ctx.reopen(emitted).await }) + }); + GetObjectResumeControl::new( + reopen, + RetryTimer::new( + GET_OBJECT_RESUME_MAX_ATTEMPTS, + DEFAULT_RETRY_UNIT, + DEFAULT_RETRY_CAP, + MAX_JITTER, + rand::rng().random_range(10..=50), + ), + ) +} + +/// Mid-stream errors that mean the pinned object data is gone (rebalance or +/// decommission removed it after copying the version elsewhere). Only typed +/// `StorageError`s qualify; generic I/O errors and string-matched "not enough +/// disks" failures keep the existing fail-loud behavior. +fn is_object_relocation_error(err: &std::io::Error) -> bool { + let Some(inner) = err.get_ref() else { return false }; + match inner.downcast_ref::() { + Some(StorageError::FileNotFound | StorageError::ObjectNotFound(..) | StorageError::InsufficientReadQuorum(..)) => true, + Some(StorageError::Io(source)) => source.kind() == std::io::ErrorKind::NotFound, + _ => false, + } +} + +pub(crate) fn object_seek_support_threshold() -> usize { + static OBJECT_SEEK_SUPPORT_THRESHOLD: OnceLock = OnceLock::new(); + *OBJECT_SEEK_SUPPORT_THRESHOLD.get_or_init(|| { + rustfs_utils::get_env_usize( + rustfs_config::ENV_OBJECT_SEEK_SUPPORT_THRESHOLD, + rustfs_config::DEFAULT_OBJECT_SEEK_SUPPORT_THRESHOLD, + ) + }) +} + +fn object_seek_support_concurrency_thresholds() -> (usize, usize) { + static OBJECT_SEEK_SUPPORT_CONCURRENCY_THRESHOLDS: OnceLock<(usize, usize)> = OnceLock::new(); + *OBJECT_SEEK_SUPPORT_CONCURRENCY_THRESHOLDS.get_or_init(|| { + let medium = rustfs_utils::get_env_usize( + rustfs_config::ENV_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD, + rustfs_config::DEFAULT_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD, + ) + .max(1); + let high = rustfs_utils::get_env_usize( + rustfs_config::ENV_OBJECT_HIGH_CONCURRENCY_THRESHOLD, + rustfs_config::DEFAULT_OBJECT_HIGH_CONCURRENCY_THRESHOLD, + ) + .max(medium + 1); + (medium, high) + }) +} + +fn concurrency_aware_seek_support_threshold(configured_threshold: i64, concurrent_requests: usize) -> i64 { + let (medium_threshold, high_threshold) = object_seek_support_concurrency_thresholds(); + let effective_threshold = configured_threshold.min(MAX_GET_OBJECT_MEMORY_BUFFER_BYTES); + + if concurrent_requests >= high_threshold.saturating_mul(2) { + return effective_threshold.min(VERY_HIGH_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES); + } + if concurrent_requests >= high_threshold { + return effective_threshold.min(HIGH_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES); + } + if concurrent_requests >= medium_threshold { + return effective_threshold.min(MEDIUM_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES); + } + + effective_threshold +} + +fn should_buffer_get_object_in_memory( + info: &ObjectInfo, + response_content_length: i64, + part_number: Option, + has_range: bool, + concurrent_requests: usize, +) -> bool { + let configured_threshold = object_seek_support_threshold() as i64; + should_buffer_get_object_in_memory_with_threshold( + info, + response_content_length, + part_number, + has_range, + configured_threshold, + concurrent_requests, + is_get_seek_buffer_enabled(), + ) +} + +fn should_materialize_get_object_body_for_cache( + info: &ObjectInfo, + response_content_length: i64, + part_number: Option, + has_range: bool, + concurrent_requests: usize, +) -> bool { + let configured_threshold = object_seek_support_threshold() as i64; + should_buffer_get_object_in_memory_with_threshold( + info, + response_content_length, + part_number, + has_range, + configured_threshold, + concurrent_requests, + true, + ) +} + +fn should_buffer_get_object_in_memory_with_threshold( + _info: &ObjectInfo, + response_content_length: i64, + part_number: Option, + has_range: bool, + configured_threshold: i64, + concurrent_requests: usize, + seek_buffer_enabled: bool, +) -> bool { + if !seek_buffer_enabled || part_number.is_some() || has_range || response_content_length <= 0 || configured_threshold <= 0 { + return false; + } + if usize::try_from(response_content_length).is_err() { + return false; + } + + let effective_threshold = concurrency_aware_seek_support_threshold(configured_threshold, concurrent_requests); + if configured_threshold > MAX_GET_OBJECT_MEMORY_BUFFER_BYTES + && GET_OBJECT_BUFFER_THRESHOLD_WARNED + .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + warn!( + configured_threshold_bytes = configured_threshold, + hard_limit_bytes = MAX_GET_OBJECT_MEMORY_BUFFER_BYTES, + "RUSTFS_OBJECT_SEEK_SUPPORT_THRESHOLD exceeds safety cap; using capped in-memory buffer threshold" + ); + } + + if response_content_length > effective_threshold { + return false; + } + + true +} + +impl DefaultObjectUsecase { + fn build_memory_bytes_blob( + bytes: Bytes, + response_content_length: i64, + source: &'static str, + lifecycle: GetObjectBodyLifecycle, + ) -> StreamingBlob { + let get_stage_metrics_enabled = rustfs_io_metrics::get_stage_metrics_enabled(); + let memory_blob_start = get_stage_metrics_enabled.then(std::time::Instant::now); + let handoff_start = get_stage_metrics_enabled.then(std::time::Instant::now); + let bytes_len = bytes.len(); + let guard = rustfs_io_metrics::track_get_object_buffered_bytes(bytes_len); + let remaining = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX); + let blob = if is_get_small_body_once_enabled() && bytes_len == remaining { + let owner = MemoryOnceBodyOwner::new(bytes, guard, lifecycle); + StreamingBlob::from_bytes(Bytes::from_owner(owner)) + } else { + StreamingBlob::new(MemoryTrackedBytesStream::new(bytes, remaining, source, guard, lifecycle)) + }; + if let Some(handoff_start) = handoff_start { + rustfs_io_metrics::record_get_object_response_handoff( + "single_chunk", + source, + bytes_len, + response_content_length, + handoff_start.elapsed().as_secs_f64(), + ); + } + record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_MEMORY_BLOB, memory_blob_start); + blob + } + + fn build_memory_blob( + buf: Vec, + response_content_length: i64, + source: &'static str, + lifecycle: GetObjectBodyLifecycle, + ) -> StreamingBlob { + Self::build_memory_bytes_blob(Bytes::from(buf), response_content_length, source, lifecycle) + } + + fn select_stream_buffer_strategy( + response_content_length: i64, + optimal_buffer_size: usize, + enable_readahead: bool, + has_range: bool, + ) -> (usize, GetObjectStreamStrategy) { + if enable_readahead && !has_range && response_content_length >= LARGE_SEQUENTIAL_GET_THRESHOLD_BYTES { + let expanded_buffer_size = optimal_buffer_size + .saturating_mul(LARGE_SEQUENTIAL_GET_READAHEAD_MULTIPLIER) + .min(LARGE_SEQUENTIAL_GET_STREAM_BUFFER_CAP_BYTES) + .max(optimal_buffer_size); + return (expanded_buffer_size, GetObjectStreamStrategy::LargeSequentialReadahead); + } + + (optimal_buffer_size, GetObjectStreamStrategy::Standard) + } + + #[allow(clippy::too_many_arguments)] + fn build_reader_blob( + reader: R, + response_content_length: i64, + request_id: &str, + content_range: Option<&str>, + stream_buffer_size: usize, + stream_strategy: GetObjectStreamStrategy, + bucket: &str, + key: &str, + lifecycle: GetObjectBodyLifecycle, + resume: Option>, + ) -> StreamingBlob + where + R: AsyncRead + Send + Sync + Unpin + 'static, + { + let streaming_blob_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let expected = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX); + let tuned_stream_buffer_size = + tune_reader_stream_buffer_size(stream_buffer_size, response_content_length, stream_strategy); + let (stream_buffer_size, buffer_source) = + resolve_reader_stream_buffer_size(tuned_stream_buffer_size, get_reader_stream_buffer_size_override()); + let get_stage_metrics_enabled = rustfs_io_metrics::get_stage_metrics_enabled(); + if get_stage_metrics_enabled { + rustfs_io_metrics::record_get_object_stream_strategy( + stream_strategy.as_str(), + stream_buffer_size, + response_content_length, + ); + } + let handoff_start = get_stage_metrics_enabled.then(std::time::Instant::now); + let reader = GetObjectStreamingReader::new( + reader, + bucket, + key, + request_id, + content_range.map(|content_range| content_range.to_string()), + expected, + get_object_disk_read_timeout(), + lifecycle, + resume, + ); + let stream = GetObjectReaderStream::new(reader, stream_buffer_size, expected, stream_strategy.as_str(), buffer_source) + .with_diagnostics(bucket, key, request_id); + let blob = StreamingBlob::new(stream); + if let Some(handoff_start) = handoff_start { + rustfs_io_metrics::record_get_object_response_handoff( + stream_strategy.as_str(), + buffer_source, + stream_buffer_size, + response_content_length, + handoff_start.elapsed().as_secs_f64(), + ); + } + record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_STREAMING_BLOB, streaming_blob_start); + blob + } + + fn init_get_object_bootstrap(&self, bucket: &str, key: &str, request_id: &str) -> S3Result { + #[cfg(test)] + let timeout_config = self + .get_object_timeout_policy + .clone() + .unwrap_or_else(GetObjectTimeoutPolicy::cached_from_env); + #[cfg(not(test))] + let timeout_config = GetObjectTimeoutPolicy::cached_from_env(); + let wrapper = RequestTimeoutWrapper::with_request_id(timeout_config.clone(), request_id.to_string()); + let request_start = std::time::Instant::now(); + let request_guard = ConcurrencyManager::track_request(); + let concurrent_requests = GetObjectGuard::concurrent_requests(); + + let deadlock_detector = deadlock_detector::get_deadlock_detector(); + let deadlock_request_guard = DeadlockRequestGuard::register_if_enabled(deadlock_detector, wrapper.request_id(), || { + format!("GetObject {bucket}/{key}") + }); + + Self::ensure_get_object_not_timed_out(&wrapper, &timeout_config, bucket, key, GetObjectTimeoutStage::BeforeProcessing)?; + + debug!( + "GetObject request started with {} concurrent requests, timeout={:?}", + concurrent_requests, timeout_config.get_object_timeout + ); + + Ok(GetObjectBootstrap { + timeout_config, + wrapper, + request_start, + request_guard, + _deadlock_request_guard: deadlock_request_guard, + concurrent_requests, + }) + } + + fn validate_get_object_part_number(part_number: Option, info: &ObjectInfo) -> S3Result<()> { + if let Some(part_number) = part_number + && part_number > 1 + && !info.parts.iter().any(|part| part.number == part_number) + { + return Err(s3_error!(InvalidPart)); + } + Ok(()) + } + + fn validate_get_object_before_cold_fill(headers: &HeaderMap, part_number: Option, info: &ObjectInfo) -> S3Result<()> { + check_preconditions(headers, info)?; + Self::validate_get_object_part_number(part_number, info) + } + + /// How long a GET waits for a disk read permit before degrading to a + /// permit-less read. Cached: consulted per GET. Zero disables the bound. + fn disk_permit_wait_timeout() -> Duration { + static CACHED: std::sync::OnceLock = std::sync::OnceLock::new(); + *CACHED.get_or_init(|| { + Duration::from_secs(rustfs_utils::get_env_u64( + rustfs_config::ENV_OBJECT_DISK_PERMIT_WAIT_TIMEOUT, + rustfs_config::DEFAULT_OBJECT_DISK_PERMIT_WAIT_TIMEOUT, + )) + }) + } + + async fn acquire_get_object_io_planning( + manager: &ConcurrencyManager, + request_timeout: Option>, + bucket: &str, + key: &str, + ) -> S3Result { + let permit_wait_start = std::time::Instant::now(); + let disk_permit = Self::admit_get_object_disk_read(manager, bucket, key).await?; + let permit_wait_duration = permit_wait_start.elapsed(); + + if let Some(timeout) = request_timeout { + Self::ensure_get_object_not_timed_out( + timeout.wrapper, + timeout.policy, + bucket, + key, + GetObjectTimeoutStage::DiskPermitWait { permit_wait_duration }, + )?; + } + + let queue_status = manager.io_queue_status(); + let queue_snapshot = GetObjectQueueSnapshot::from_available_permits( + queue_status.total_permits, + queue_status.total_permits.saturating_sub(queue_status.permits_in_use), + ); + let queue_utilization = queue_snapshot.utilization_percent(); + + if queue_snapshot.is_congested(80.0) { + // Metrics count every congested request; only the WARN is rate + // limited, because under saturation every GET crosses the + // threshold and per-request WARNs flood the log. + rustfs_io_metrics::record_io_queue_congestion(); + + if let Some(suppressed_warns) = IO_QUEUE_CONGESTION_WARN_THROTTLE.claim(IoQueueCongestionWarnThrottle::now_ms()) { + warn!( + bucket = %bucket, + key = %key, + queue_utilization = format!("{:.1}%", queue_utilization), + permits_in_use = queue_status.permits_in_use, + total_permits = queue_status.total_permits, + suppressed_warns, + "I/O queue congestion detected" + ); + } + } + + if let Some(timeout) = request_timeout { + Self::ensure_get_object_not_timed_out( + timeout.wrapper, + timeout.policy, + bucket, + key, + GetObjectTimeoutStage::BeforeRead, + )?; + } + + Ok(GetObjectIoPlanning { + disk_permit, + permit_wait_duration, + queue_status, + queue_utilization, + }) + } + + // Shared by the initial read path and the mid-stream resume reopen, which + // must hold the same admission token before touching disks. The permit + // wait inside is bounded by the primary-pool timeout. + async fn admit_get_object_disk_read( + manager: &ConcurrencyManager, + bucket: &str, + key: &str, + ) -> S3Result> { + let permit_wait_start = std::time::Instant::now(); + let permit_wait_timeout = Self::disk_permit_wait_timeout(); + // Permits are held for the whole body transfer, so slow clients can pin + // all of them while disks are idle. Bound the wait on the primary pool + // and, on timeout, admit from a bounded degraded overflow lane. Total + // concurrent disk-active GETs are hard-capped at + // `primary_cap + degraded_cap`; once that cap is reached we reject with + // `SlowDown` instead of reading without any admission token. Never + // proceed permit-less. + let disk_permit = match manager + .admit_disk_read(permit_wait_timeout) + .await + .map_err(|_| s3_error!(InternalError, "disk read semaphore closed"))? + { + DiskReadAdmission::Primary(permit) => Some(permit), + // Throttling disabled by config (primary cap 0): proceed without an + // admission token. Not a saturation bypass. + DiskReadAdmission::Unbounded => None, + DiskReadAdmission::Degraded(permit) => { + metrics::counter!("rustfs.get_object.disk_permit.degraded.total").increment(1); + warn!( + bucket = %bucket, + key = %key, + wait_ms = permit_wait_start.elapsed().as_millis() as u64, + "GetObject admitted into bounded degraded disk-read lane after primary pool saturation" + ); + Some(permit) + } + DiskReadAdmission::Rejected => { + metrics::counter!("rustfs.get_object.disk_permit.hard_reject.total").increment(1); + warn!( + bucket = %bucket, + key = %key, + wait_ms = permit_wait_start.elapsed().as_millis() as u64, + "GetObject rejected: disk-read hard concurrency cap reached" + ); + return Err(s3_error!( + SlowDown, + "disk read concurrency limit reached, please reduce your request rate" + )); + } + }; + Ok(disk_permit.map(GetObjectDiskPermit::new)) + } + + async fn acquire_cold_fill_io_planning( + manager: &'static ConcurrencyManager, + bucket: &str, + key: &str, + ) -> Result { + match Self::acquire_get_object_io_planning(manager, None, bucket, key).await { + Ok(io) => Ok(io), + Err(err) if err.code() == &S3ErrorCode::SlowDown => Err(ColdFillError::Storage(StorageError::SlowDown)), + Err(_) => Err(ColdFillError::DiskAdmissionClosed), + } + } + + fn get_object_io_planning_without_disk(manager: &ConcurrencyManager) -> GetObjectIoPlanning { + let queue_status = manager.io_queue_status(); + let queue_snapshot = GetObjectQueueSnapshot::from_available_permits( + queue_status.total_permits, + queue_status.total_permits.saturating_sub(queue_status.permits_in_use), + ); + GetObjectIoPlanning { + disk_permit: None, + permit_wait_duration: Duration::ZERO, + queue_utilization: queue_snapshot.utilization_percent(), + queue_status, + } + } + + /// Cheap request-shape validations, run before the bucket-existence store + /// lookup so invalid requests keep their InvalidArgument precedence. + fn validate_get_object_request(req: &S3Request) -> S3Result { + // Clone only the fields this path needs instead of the whole input. + let bucket = req.input.bucket.clone(); + let key = req.input.key.clone(); + let version_id = req.input.version_id.clone(); + let part_number = req.input.part_number; + let range = req.input.range; + + validate_object_key(&key, "GET")?; + + let part_number = parse_part_number_i32_to_usize(part_number, "GET")?; + + let rs = range.map(range_to_http_range_spec).transpose()?; + + if rs.is_some() && part_number.is_some() { + return Err(s3_error!(InvalidArgument, "range and part_number invalid")); + } + + Ok(GetObjectValidatedRequest { + bucket, + key, + version_id, + part_number, + rs, + }) + } + + async fn prepare_get_object_request_context( + validated: GetObjectValidatedRequest, + headers: &HeaderMap, + ) -> S3Result { + let GetObjectValidatedRequest { + bucket, + key, + version_id, + part_number, + rs, + } = validated; + + let opts: ObjectOptions = get_opts(&bucket, &key, version_id.clone(), part_number, headers) + .await + .map_err(ApiError::from)?; + + Ok(GetObjectRequestContext { + version_id_for_event: version_id.unwrap_or_default(), + bucket, + key, + part_number, + rs, + opts, + }) + } + + #[allow(clippy::too_many_arguments)] + async fn prepare_get_object_read_execution( + &self, + req: &S3Request, + manager: &'static ConcurrencyManager, + store: Arc, + wrapper: &RequestTimeoutWrapper, + timeout_config: &GetObjectTimeoutPolicy, + bucket: &str, + key: &str, + rs: Option, + opts: &ObjectOptions, + part_number: Option, + object_traffic_health: Option>, + ) -> S3Result { + let read_start = std::time::Instant::now(); + let read_stage_start = rustfs_io_metrics::get_stage_metrics_enabled().then_some(read_start); + let store_headers = project_ssec_transport_headers(&req.headers); + let cache_adapter = self.object_data_cache(); + if cache_adapter.is_disabled() || !cache_adapter.materialize_fill_enabled() { + let io_planning = Self::acquire_get_object_io_planning( + manager, + Some(GetObjectRequestTimeout { + wrapper, + policy: timeout_config, + }), + bucket, + key, + ) + .await?; + let reader = track_object_read_setup( + object_traffic_health.as_deref(), + store.get_object_reader(bucket, key, rs.clone(), store_headers, opts), + ) + .await + .map_err(map_get_object_reader_error)?; + let read_setup = + Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, true).await?; + return Ok(GetObjectPreparedRead { io_planning, read_setup }); + } + + // Preserve the legacy metadata-fanout bound without making followers + // hold a body-transfer permit while they wait on the cold-fill session. + let mut metadata_admission = Some( + Self::acquire_get_object_io_planning( + manager, + Some(GetObjectRequestTimeout { + wrapper, + policy: timeout_config, + }), + bucket, + key, + ) + .await?, + ); + let mut prepared = Some( + track_object_read_setup( + object_traffic_health.as_deref(), + store.prepare_get_object_reader(bucket, key, rs.clone(), HeaderMap::new(), opts), + ) + .await + .map_err(map_get_object_reader_error)?, + ); + let mut cache_fill_allowed = true; + let mut legacy_hook_missed = false; + 'snapshot: { + let info = prepared + .as_ref() + .ok_or_else(|| s3_error!(InternalError, "prepared metadata snapshot is unavailable"))? + .object_info(); + // Preconditions, cache planning, and the authoritative hook lookup all + // run against one namespace-locked metadata snapshot. Cacheable misses + // release both the lock and short admission before joining cold fill. + let Some(response_content_length) = get_object_body_cache_plaintext_len(&rs, opts, info) else { + break 'snapshot; + }; + let cache_plan = build_get_object_body_cache_plan( + &cache_adapter, + GetObjectBodyCacheRequest { + bucket, + key, + info, + response_content_length, + has_range: rs.is_some(), + part_number, + encryption_applied: info.is_encrypted(), + }, + ); + + // The legacy hook is evaluated once, before cold-fill coordination. + // In-session producer retries never re-enter this snapshot block. + let legacy_probe = lookup_preplanned_get_object_body_cache_hook( + Arc::clone(&cache_adapter), + cache_plan.clone(), + bucket, + key, + &rs, + opts, + info, + ) + .await; + if matches!(legacy_probe, GetObjectBodyCacheHookLookup::Ineligible) { + break 'snapshot; + } + Self::validate_get_object_before_cold_fill(&req.headers, part_number, info)?; + if let GetObjectBodyCacheHookLookup::Hit(body) = legacy_probe { + drop(metadata_admission.take()); + let info = prepared + .take() + .ok_or_else(|| s3_error!(InternalError, "prepared cache-hit reader is unavailable"))? + .into_object_info(); + let reader = GetObjectReader::from_cache_body(info, body).map_err(ApiError::from)?; + let read_setup = + Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, true).await?; + return Ok(GetObjectPreparedRead { + io_planning: Self::get_object_io_planning_without_disk(manager), + read_setup, + }); + } + if matches!(legacy_probe, GetObjectBodyCacheHookLookup::Miss) { + legacy_hook_missed = true; + } + if !legacy_hook_missed + && let GetObjectBodyCacheLookup::Hit(body) = lookup_get_object_body_cache_hit(&cache_adapter, &cache_plan).await + { + drop(metadata_admission.take()); + let info = prepared + .take() + .ok_or_else(|| s3_error!(InternalError, "prepared cache-hit reader is unavailable"))? + .into_object_info(); + let reader = GetObjectReader::from_cache_body(info, body).map_err(ApiError::from)?; + let read_setup = + Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, true).await?; + return Ok(GetObjectPreparedRead { + io_planning: Self::get_object_io_planning_without_disk(manager), + read_setup, + }); + } + + let GetObjectBodyCachePlan::Cacheable(engine_plan) = &cache_plan else { + break 'snapshot; + }; + let Some(cache_key) = cache_plan.key().cloned() else { + break 'snapshot; + }; + let expected = usize::try_from(response_content_length) + .map_err(|_| s3_error!(InternalError, "cold-fill body length is not representable"))?; + let response_size = u64::try_from(response_content_length) + .map_err(|_| s3_error!(InternalError, "cold-fill body length is negative"))?; + let waiter_deadline = cold_fill_deadline(wrapper, timeout_config, response_size); + let proposed_producer_deadline = cold_fill_producer_deadline(timeout_config, response_size); + let coordinator = cache_adapter.cold_fill_coordinator(); + let info = prepared + .take() + .ok_or_else(|| s3_error!(InternalError, "prepared cold-fill reader is unavailable"))? + .into_object_info(); + drop(metadata_admission.take()); + let outcome = coordinate_cold_fill(&coordinator, cache_key, waiter_deadline, Some(proposed_producer_deadline), { + let adapter = &cache_adapter; + let headers = &store_headers; + let store = &store; + let range = &rs; + let object_traffic_health = &object_traffic_health; + move |producer| { + let adapter = Arc::clone(adapter); + let engine_plan = engine_plan.clone(); + let h = headers.clone(); + let store = Arc::clone(store); + let range = range.clone(); + let bucket = bucket.to_owned(); + let key = key.to_owned(); + let opts = opts.clone(); + let object_traffic_health = object_traffic_health.as_ref().map(Arc::clone); + async move { + let producer_deadline = producer.deadline(); + let cancellation = producer.cancellation_token(); + let second_chance = match await_cold_fill_startup( + lookup_cold_fill_second_chance(&adapter, &engine_plan), + &cancellation, + producer_deadline, + ) + .await + { + Ok(body) => body, + Err(ColdFillStartupWaitError::Cancelled) => { + producer.finish(Err(StorageError::OperationCanceled)); + return; + } + Err(ColdFillStartupWaitError::DeadlineExceeded) => { + producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout)); + return; + } + }; + if let Some(body) = second_chance { + producer.finish_shared(Ok(body)); + return; + } + + let acquire = Self::acquire_cold_fill_io_planning(manager, &bucket, &key); + let producer_io = match await_cold_fill_startup(acquire, &cancellation, producer_deadline).await { + Ok(result) => result, + Err(ColdFillStartupWaitError::Cancelled) => { + producer.finish(Err(StorageError::OperationCanceled)); + return; + } + Err(ColdFillStartupWaitError::DeadlineExceeded) => { + producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout)); + return; + } + }; + let producer_io = match producer_io { + Ok(io) => io, + Err(err) => { + producer.finish_shared(Err(err)); + return; + } + }; + + let prepare = track_object_read_setup( + object_traffic_health.as_deref(), + store.prepare_get_object_reader(&bucket, &key, range.clone(), HeaderMap::new(), &opts), + ); + let prepared = match match await_cold_fill_startup(prepare, &cancellation, producer_deadline).await { + Ok(result) => result, + Err(ColdFillStartupWaitError::Cancelled) => { + producer.finish(Err(StorageError::OperationCanceled)); + return; + } + Err(ColdFillStartupWaitError::DeadlineExceeded) => { + producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout)); + return; + } + } { + Ok(prepared) => prepared, + Err(err) => { + producer.relinquish_or_finish(ColdFillError::Storage(err)); + return; + } + }; + let current_info = prepared.object_info(); + let current_length = match current_info.get_actual_size() { + Ok(length) => length, + Err(err) => { + let _ = err; + producer.finish_shared(Err(ColdFillError::Storage(StorageError::FileCorrupt))); + return; + } + }; + let current_plan = build_get_object_body_cache_plan_for_revalidation( + &adapter, + GetObjectBodyCacheRequest { + bucket: &bucket, + key: &key, + info: current_info, + response_content_length: current_length, + has_range: range.is_some(), + part_number, + encryption_applied: current_info.is_encrypted(), + }, + ); + let Some(producer) = retain_cold_fill_producer_for_matching_plan(producer, ¤t_plan, &engine_plan) + else { + return; + }; + + let reservation = adapter.reserve_body(&engine_plan); + #[cfg(test)] + let reader_open_plan = engine_plan.clone(); + start_cold_fill_producer( + producer, + reservation, + || async move { Ok(producer_io) }, + || { + #[cfg(test)] + record_cold_fill_reader_open_for_test(&reader_open_plan); + let open_reader = prepared.with_headers(h).into_reader(); + async move { track_object_read_setup(object_traffic_health.as_deref(), open_reader).await } + }, + ColdFillProducerExecution { + expected, + deadline: producer_deadline, + adapter, + engine_plan, + }, + ) + .await; + } + } + }) + .await; + + match outcome { + ColdFillCoordinateOutcome::Ready(result) => { + let body = match result { + Ok(body) => body, + Err(ColdFillError::Storage(err)) => return Err(map_get_object_reader_error(err).into()), + Err(ColdFillError::DiskAdmissionClosed) => { + return Err(s3_error!(InternalError, "disk read semaphore closed")); + } + }; + let reader = GetObjectReader::from_cache_body(info, body).map_err(ApiError::from)?; + let read_setup = + Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, true) + .await?; + return Ok(GetObjectPreparedRead { + io_planning: Self::get_object_io_planning_without_disk(manager), + read_setup, + }); + } + ColdFillCoordinateOutcome::Bypass => { + cache_fill_allowed = false; + break 'snapshot; + } + ColdFillCoordinateOutcome::Rejected => return Err(ApiError::from(StorageError::SlowDown).into()), + } + } + + let (io_planning, reader) = if let Some(prepared) = prepared.take() { + let io_planning = metadata_admission + .take() + .ok_or_else(|| s3_error!(InternalError, "prepared metadata admission is unavailable"))?; + let reader = + track_object_read_setup(object_traffic_health.as_deref(), prepared.with_headers(store_headers).into_reader()) + .await + .map_err(map_get_object_reader_error)?; + (io_planning, reader) + } else { + let io_planning = Self::acquire_get_object_io_planning( + manager, + Some(GetObjectRequestTimeout { + wrapper, + policy: timeout_config, + }), + bucket, + key, + ) + .await?; + let reader = if legacy_hook_missed { + let prepared = track_object_read_setup( + object_traffic_health.as_deref(), + store.prepare_get_object_reader(bucket, key, rs.clone(), HeaderMap::new(), opts), + ) + .await + .map_err(map_get_object_reader_error)?; + track_object_read_setup(object_traffic_health.as_deref(), prepared.with_headers(store_headers).into_reader()) + .await + .map_err(map_get_object_reader_error)? + } else { + track_object_read_setup( + object_traffic_health.as_deref(), + store.get_object_reader(bucket, key, rs.clone(), store_headers, opts), + ) + .await + .map_err(map_get_object_reader_error)? + }; + (io_planning, reader) + }; + let read_setup = + Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, cache_fill_allowed) + .await?; + if let Some(read_stage_start) = read_stage_start { + rustfs_io_metrics::record_get_object_stage_duration( + "s3_handler", + "store_reader_setup", + read_stage_start.elapsed().as_secs_f64(), + ); + } + Ok(GetObjectPreparedRead { io_planning, read_setup }) + } + + #[allow(clippy::too_many_arguments)] + async fn finish_get_object_read( + req: &S3Request, + manager: &ConcurrencyManager, + bucket: &str, + key: &str, + mut rs: Option, + part_number: Option, + read_start: std::time::Instant, + reader: GetObjectReader, + cache_fill_allowed: bool, + ) -> S3Result { + // ODC-16: capture whether the ecstore cache hook already probed this + // read, so the app layer does not repeat the lookup it ran after fresh + // metadata resolution. + let cache_hook_served = reader.is_cache_hook_served(); + let cache_hook_probed = reader.cache_hook_probed(); + let info = reader.object_info; + let stream = reader.stream; + let buffered_body = reader.buffered_body; + + let read_duration = read_start.elapsed(); + + // Conditional metrics recording to reduce overhead + if rustfs_io_metrics::get_stage_metrics_enabled() { + use rustfs_io_metrics::record_zero_copy_read; + record_zero_copy_read(info.size as usize, read_duration.as_secs_f64() * 1000.0); + manager.record_disk_operation(info.size as u64, read_duration, true).await; + } + + check_preconditions(&req.headers, &info)?; + Self::validate_get_object_part_number(part_number, &info)?; + + debug!(object_size = info.size, part_count = info.parts.len(), "GET object metadata snapshot"); + for part in info.parts.iter() { + debug!( + part_number = part.number, + part_size = part.size, + part_actual_size = part.actual_size, + "GET object part details" + ); + } + + let content_type = if let Some(content_type) = &info.content_type { + match ContentType::from_str(content_type) { + Ok(res) => Some(res), + Err(err) => { + error!(content_type, error = ?err, "GET object content-type parse failed"); + None + } + } + } else { + None + }; + let last_modified = info.mod_time.map(Timestamp::from); + + if let Some(part_number) = part_number + && rs.is_none() + { + rs = HTTPRangeSpec::from_part_sizes( + info.size, + part_number, + info.parts.iter().map(|part| { + if part.actual_size > 0 { + part.actual_size + } else { + i64::try_from(part.size).unwrap_or(i64::MAX) + } + }), + ); + } + + validate_sse_headers_for_read(&info.user_defined, &req.headers)?; + + let mut content_length = info.get_actual_size().map_err(ApiError::from)?; + let (resume_range_start, resume_range_end, content_range) = if let Some(rs) = &rs { + let total_size = content_length; + let (start, length) = rs.get_offset_length(total_size).map_err(ApiError::from)?; + content_length = length; + let start = start as i64; + // Inclusive end of the committed body; may precede `start` when a + // zero-length range was requested, in which case the body completes + // immediately and the resume range is never consulted. + ( + start, + start + length - 1, + Some(format!("bytes {}-{}/{}", start, start + length - 1, total_size)), + ) + } else { + (0, -1, None) + }; + + debug!( + "GET object metadata check: parts={}, provided_sse_key={:?}", + info.parts.len(), + req.input.sse_customer_key.is_some() + ); + + let read_principal = SseKmsPrincipal::from_request(req); + let decryption_request = DecryptionRequest { + bucket, + key, + metadata: &info.user_defined, + sse_customer_key: req.input.sse_customer_key.as_ref(), + sse_customer_key_md5: req.input.sse_customer_key_md5.as_ref(), + principal: read_principal.as_ref(), + }; + + let response_content_length = content_length; + + let ( + server_side_encryption, + sse_customer_algorithm, + sse_customer_key_md5, + ssekms_key_id, + encryption_applied, + final_stream, + buffered_body, + ) = match classify_sse_read_response(decryption_request).await? { + // The stream is already decrypted by the object layer's encryption + // resolver; only the response headers, authorization and audit + // summary are derived here, without a second KMS unwrap. + Some(headers) => ( + Some(headers.server_side_encryption), + headers.sse_customer_algorithm, + headers.sse_customer_key_md5, + headers.ssekms_key_id, + true, + wrap_reader(stream), + None, + ), + None => (None, None, None, None, false, wrap_reader(stream), buffered_body), + }; + + Ok(GetObjectReadSetup { + info, + final_stream, + buffered_body, + cache_hook_served, + cache_hook_probed, + cache_fill_allowed, + rs, + content_type, + last_modified, + response_content_length, + content_range, + server_side_encryption, + sse_customer_algorithm, + sse_customer_key_md5, + ssekms_key_id, + encryption_applied, + resume_range_start, + resume_range_end, + }) + } + + #[allow(clippy::too_many_arguments)] + fn finalize_get_object_strategy( + &self, + manager: &ConcurrencyManager, + bucket: &str, + key: &str, + info: &ObjectInfo, + rs: Option<&HTTPRangeSpec>, + response_content_length: i64, + permit_wait_duration: Duration, + queue_utilization: f64, + queue_status: &concurrency::IoQueueStatus, + concurrent_requests: usize, + ) -> GetObjectStrategyContext { + let base_buffer_size = if response_content_length > 0 { + get_buffer_size_opt_in(response_content_length) + } else { + self.base_buffer_size() + }; + + let is_sequential_hint = if rs.is_none() { + true + } else if let Some(range_spec) = rs { + range_spec.start == 0 && !range_spec.is_suffix_length + } else { + false + }; + + // Conditional metrics recording to reduce overhead + if rustfs_io_metrics::get_stage_metrics_enabled() { + if let Some(range_spec) = rs + && range_spec.start >= 0 + { + manager.record_access(range_spec.start as u64, response_content_length as u64); + } + + if response_content_length > 0 { + manager.record_transfer(response_content_length as u64, permit_wait_duration); + } + } + + let io_strategy = + manager.calculate_io_strategy_with_context(info.size, base_buffer_size, permit_wait_duration, is_sequential_hint); + + debug!( + wait_ms = permit_wait_duration.as_millis() as u64, + load_level = ?io_strategy.load_level, + buffer_size = io_strategy.buffer_size, + buffer_multiplier = io_strategy.buffer_multiplier, + readahead = io_strategy.enable_readahead, + storage_media = ?io_strategy.storage_media, + access_pattern = ?io_strategy.access_pattern, + bandwidth_tier = ?io_strategy.bandwidth_tier, + concurrent_requests = io_strategy.concurrent_requests, + file_size = info.size, + is_sequential = is_sequential_hint, + "Enhanced multi-factor I/O strategy calculated" + ); + + let io_priority = manager.get_io_priority(response_content_length); + + if manager.is_priority_scheduling_enabled() { + debug!( + bucket = %bucket, + key = %key, + priority = %io_priority, + request_size = response_content_length, + "I/O priority assigned (based on actual request size)" + ); + } + + rustfs_io_metrics::record_get_object_io_state( + permit_wait_duration.as_secs_f64(), + queue_utilization, + queue_status.permits_in_use, + queue_status.total_permits.saturating_sub(queue_status.permits_in_use), + io_strategy.load_level.as_str(), + io_strategy.buffer_multiplier, + ); + rustfs_io_metrics::record_io_priority_assignment(io_priority.as_str()); + + debug!( + actual_request_size = response_content_length, + priority = %io_priority.as_str(), + "I/O priority finalized with actual request size" + ); + + let optimal_buffer_size = if io_strategy.buffer_size > 0 { + io_strategy.buffer_size + } else { + get_concurrency_aware_buffer_size(response_content_length, base_buffer_size) + }; + + debug!( + "GetObject buffer sizing: file_size={}, base={}, optimal={}, concurrent_requests={}, io_strategy={:?}", + response_content_length, base_buffer_size, optimal_buffer_size, concurrent_requests, io_strategy.load_level + ); + let enable_readahead = io_strategy.enable_readahead; + + GetObjectStrategyContext { + io_strategy, + optimal_buffer_size, + enable_readahead, + } + } + + fn build_get_object_checksums( + info: &ObjectInfo, + headers: &HeaderMap, + part_number: Option, + rs: Option<&HTTPRangeSpec>, + ) -> S3Result { + if let Some(checksum_mode) = headers.get(AMZ_CHECKSUM_MODE) + && checksum_mode.to_str().unwrap_or_default() == "ENABLED" + && rs.is_none() + { + let (decrypted_checksums, is_multipart) = info.decrypt_checksums(part_number.unwrap_or(0), headers).map_err(|e| { + error!(error = %e, "GetObject checksum decryption failed"); + ApiError::from(e) + })?; + + return Ok(classify_response_checksums(decrypted_checksums, is_multipart)); + } + + Ok(ResponseChecksums::default()) + } + + #[allow(clippy::too_many_arguments)] + async fn build_get_object_body( + final_stream: R, + info: &ObjectInfo, + response_content_length: i64, + request_id: &str, + content_range: Option<&str>, + optimal_buffer_size: usize, + enable_readahead: bool, + concurrent_requests: usize, + part_number: Option, + has_range: bool, + encryption_applied: bool, + buffered_body: Option, + bucket: &str, + key: &str, + mut lifecycle: GetObjectBodyLifecycle, + resume: F, + ) -> S3Result + where + R: AsyncRead + Send + Sync + Unpin + 'static, + F: FnOnce(&ObjectInfo) -> Option>, + { + if encryption_applied { + let should_buffer_encrypted_object = + should_buffer_get_object_in_memory(info, response_content_length, part_number, has_range, concurrent_requests); + + if should_buffer_encrypted_object { + // Strict materialization (#1324): a decrypted body that is shorter + // or longer than the declared content length must hard-fail before + // headers, not warn-and-serve a truncated/over-long body. + let expected = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX); + match strict_materialize_object_body(final_stream, expected, GET_OBJECT_STAGE_BODY_ENCRYPTED_BUFFER_READ).await { + Ok(buf) => { + return Ok(Self::build_memory_blob( + buf, + response_content_length, + GET_MEMORY_BODY_SOURCE_ENCRYPTED_BUFFER, + lifecycle, + )); + } + Err(e) => { + lifecycle.finish_err(); + error!(error = %e, "GetObject decrypted object strict materialization failed"); + return Err(e.into_s3_error(response_content_length)); + } + } + } + + debug!(buffer_size = optimal_buffer_size, "Encrypted object uses streaming decrypt path"); + let stream_strategy_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let (stream_buffer_size, stream_strategy) = + Self::select_stream_buffer_strategy(response_content_length, optimal_buffer_size, enable_readahead, has_range); + record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_STREAM_STRATEGY, stream_strategy_start); + return Ok(Self::build_reader_blob( + final_stream, + response_content_length, + request_id, + content_range, + stream_buffer_size, + stream_strategy, + bucket, + key, + lifecycle, + resume(info), + )); + } + + if let Some(buffered_body) = buffered_body { + // Strict materialization (#1324): the buffered body is the exact + // response payload; a length disagreement means an upstream/cache bug + // and must hard-fail before headers rather than serve a body that does + // not match its committed Content-Length. + let expected = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX); + if buffered_body.len() != expected { + lifecycle.finish_err(); + error!( + expected = response_content_length, + actual = buffered_body.len(), + "Buffered GetObject body length mismatch" + ); + return Err(ApiError::from(StorageError::other(format!( + "Buffered GetObject body length mismatch: expected {response_content_length}, got {}", + buffered_body.len() + ))) + .into()); + } + + return Ok(Self::build_memory_bytes_blob( + buffered_body, + response_content_length, + GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, + lifecycle, + )); + } + + let should_provide_seek_support = + should_buffer_get_object_in_memory(info, response_content_length, part_number, has_range, concurrent_requests); + + if should_provide_seek_support { + // Strict materialization (#1324): the previous implementation only + // logged a warning on a length mismatch, and — most dangerously — on a read + // error it fell through to streaming the *same* reader after + // `read_to_end` had already drained K bytes, shipping a body missing + // its prefix (prefix-misaligned data). Both are now hard errors: an + // exact-length read is required, and any read error returns without + // reusing the partially consumed reader. + let expected = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX); + match strict_materialize_object_body(final_stream, expected, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ).await { + Ok(buf) => { + return Ok(Self::build_memory_blob( + buf, + response_content_length, + GET_MEMORY_BODY_SOURCE_SEEK_BUFFER, + lifecycle, + )); + } + Err(e) => { + lifecycle.finish_err(); + error!( + error = %e, + "GetObject seek-support strict materialization failed; refusing to reuse the partially consumed reader" + ); + return Err(e.into_s3_error(response_content_length)); + } + } + } + + let stream_strategy_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let (stream_buffer_size, stream_strategy) = + Self::select_stream_buffer_strategy(response_content_length, optimal_buffer_size, enable_readahead, has_range); + record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_STREAM_STRATEGY, stream_strategy_start); + Ok(Self::build_reader_blob( + final_stream, + response_content_length, + request_id, + content_range, + stream_buffer_size, + stream_strategy, + bucket, + key, + lifecycle, + resume(info), + )) + } + + #[allow(clippy::too_many_arguments)] + async fn build_get_object_body_with_cache( + cache_adapter: &ObjectDataCacheAdapter, + final_stream: R, + info: &ObjectInfo, + response_content_length: i64, + request_id: &str, + content_range: Option<&str>, + optimal_buffer_size: usize, + enable_readahead: bool, + concurrent_requests: usize, + part_number: Option, + has_range: bool, + encryption_applied: bool, + mut buffered_body: Option, + cache_hook_served: bool, + cache_hook_probed: bool, + cache_fill_allowed: bool, + bucket: &str, + key: &str, + mut lifecycle: GetObjectBodyLifecycle, + resume: F, + ) -> S3Result + where + R: AsyncRead + Send + Sync + Unpin + 'static, + F: FnOnce(&ObjectInfo) -> Option>, + { + // ODC-16 (backlog#1121): when the ecstore hook or shared cold fill + // already supplied this body, the request-level plan was built before + // the authoritative lookup. Serve it without planning a second time. + if cache_hook_served && let Some(bytes) = buffered_body.take() { + return Ok(Self::build_memory_bytes_blob( + bytes, + response_content_length, + GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE, + lifecycle, + )); + } + + if !cache_fill_allowed { + return Self::build_get_object_body( + final_stream, + info, + response_content_length, + request_id, + content_range, + optimal_buffer_size, + enable_readahead, + concurrent_requests, + part_number, + has_range, + encryption_applied, + buffered_body, + bucket, + key, + lifecycle, + resume, + ) + .await; + } + + let cache_request = GetObjectBodyCacheRequest { + bucket, + key, + info, + response_content_length, + has_range, + part_number, + encryption_applied, + }; + let cache_plan = build_get_object_body_cache_plan(cache_adapter, cache_request); + + // ODC-16: only look up when the hook did not probe this read. When it did + // probe (a served body handled above, or a miss), its result is + // authoritative because it ran after fresh metadata resolution, so the + // app layer skips its own lookup and only uses the plan to fill. + if !cache_hook_probed { + match lookup_get_object_body_cache_hit(cache_adapter, &cache_plan).await { + GetObjectBodyCacheLookup::Hit(bytes) => { + return Ok(Self::build_memory_bytes_blob( + bytes, + response_content_length, + GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE, + lifecycle, + )); + } + GetObjectBodyCacheLookup::Disabled | GetObjectBodyCacheLookup::Skip | GetObjectBodyCacheLookup::Miss => {} + } + } + + if let Some(buffered_body) = buffered_body { + // ODC-15: the body is already fully in hand, so keep the fill off the + // response's critical path. For a cacheable plan, run the fill in a + // detached task (Bytes is a cheap clone) and return immediately. For + // a non-cacheable plan the fill is a pure metric-only skip with no + // I/O, so record it inline to preserve observability. + if cache_fill_allowed && matches!(cache_plan, GetObjectBodyCachePlan::Cacheable(_)) { + let cache_adapter = cache_adapter.clone(); + let cache_plan = cache_plan.clone(); + let fill_bytes = buffered_body.clone(); + tokio::spawn(async move { + let _ = fill_get_object_body_cache_from_buffered_body(&cache_adapter, &cache_plan, &fill_bytes).await; + }); + } else if cache_fill_allowed { + let _ = fill_get_object_body_cache_from_buffered_body(cache_adapter, &cache_plan, &buffered_body).await; + } + + return Ok(Self::build_memory_bytes_blob( + buffered_body, + response_content_length, + GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, + lifecycle, + )); + } + + let should_materialize_for_cache = cache_adapter.materialize_fill_enabled() + && cache_fill_allowed + && matches!(cache_plan, GetObjectBodyCachePlan::Cacheable(_)) + && should_materialize_get_object_body_for_cache( + info, + response_content_length, + part_number, + has_range, + concurrent_requests, + ); + + if should_materialize_for_cache { + let Ok(materialized_capacity) = usize::try_from(response_content_length) else { + warn!( + expected = response_content_length, + "GetObject materialize-fill skipped because content length is not representable" + ); + return Self::build_get_object_body( + final_stream, + info, + response_content_length, + request_id, + content_range, + optimal_buffer_size, + enable_readahead, + concurrent_requests, + part_number, + has_range, + encryption_applied, + None, + bucket, + key, + lifecycle, + resume, + ) + .await; + }; + // ODC-07 / #1324: share the strict exact-length materialization gate + // with the encrypted and seek memory branches. The helper bounds the + // read to `capacity + 1` (so an over-long stream is detected without + // buffering it unbounded), rejects short and over-long reads, and on a + // partial-read error refuses to reuse the consumed reader. + match strict_materialize_object_body( + final_stream, + materialized_capacity, + GET_OBJECT_STAGE_BODY_CACHE_MATERIALIZE_READ, + ) + .await + { + Ok(buf) => { + let bytes = Bytes::from(buf); + // ODC-15: fill off the response's critical path (see the + // buffered-body branch above). + let cache_adapter = cache_adapter.clone(); + let cache_plan = cache_plan.clone(); + let fill_bytes = bytes.clone(); + tokio::spawn(async move { + let _ = fill_get_object_body_cache_from_materialized_body(&cache_adapter, &cache_plan, &fill_bytes).await; + }); + + return Ok(Self::build_memory_bytes_blob( + bytes, + response_content_length, + GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE_MATERIALIZED, + lifecycle, + )); + } + Err(e) => { + lifecycle.finish_err(); + error!(error = %e, "GetObject materialize-fill strict materialization failed"); + // A short/over-long body would ship a truncated or over-long + // response; a partial-read error leaves the stream consumed so + // falling back to streaming would send a prefix-misaligned + // body. Both fail the request. + return Err(e.into_s3_error(response_content_length)); + } + } + } + + Self::build_get_object_body( + final_stream, + info, + response_content_length, + request_id, + content_range, + optimal_buffer_size, + enable_readahead, + concurrent_requests, + part_number, + has_range, + encryption_applied, + None, + bucket, + key, + lifecycle, + resume, + ) + .await + } + + fn finalize_get_object_completion( + wrapper: &RequestTimeoutWrapper, + timeout_config: &GetObjectTimeoutPolicy, + total_duration: Duration, + response_content_length: i64, + optimal_buffer_size: usize, + ) { + rustfs_io_metrics::record_get_object_completion( + total_duration.as_secs_f64(), + response_content_length, + optimal_buffer_size, + ); + + rustfs_io_metrics::record_get_object(total_duration.as_millis() as f64, response_content_length); + + if wrapper.is_timeout() { + warn!( + "GetObject request exceeded timeout: duration={:?} timeout={:?}", + wrapper.elapsed(), + timeout_config.get_object_timeout + ); + rustfs_io_metrics::record_get_object_timeout(None, Some(wrapper.elapsed().as_secs_f64())); + } + + debug!( + "GetObject completed: size={} duration={:?} buffer={}", + response_content_length, total_duration, optimal_buffer_size + ); + } + + fn ensure_get_object_not_timed_out( + wrapper: &RequestTimeoutWrapper, + timeout_config: &GetObjectTimeoutPolicy, + bucket: &str, + key: &str, + stage: GetObjectTimeoutStage, + ) -> S3Result<()> { + if !wrapper.is_timeout() { + return Ok(()); + } + + let timeout_secs = timeout_config.get_object_timeout.as_secs(); + let elapsed_ms = wrapper.elapsed().as_millis(); + + match stage { + GetObjectTimeoutStage::BeforeProcessing => { + warn!( + bucket = %bucket, + key = %key, + timeout_secs, + elapsed_ms, + "GetObject request timed out before processing" + ); + Err(s3_error!(InternalError, "Request timeout before processing")) + } + GetObjectTimeoutStage::DiskPermitWait { permit_wait_duration } => { + warn!( + bucket = %bucket, + key = %key, + wait_ms = permit_wait_duration.as_millis(), + timeout_secs, + elapsed_ms, + "GetObject request timed out while waiting for disk permit" + ); + rustfs_io_metrics::record_get_object_timeout(Some("disk_permit"), Some(wrapper.elapsed().as_secs_f64())); + Err(s3_error!(InternalError, "Request timeout while waiting for disk permit")) + } + GetObjectTimeoutStage::BeforeRead => { + warn!( + bucket = %bucket, + key = %key, + timeout_secs, + elapsed_ms, + "GetObject request timed out before reading object" + ); + rustfs_io_metrics::record_get_object_timeout(Some("before_read"), Some(wrapper.elapsed().as_secs_f64())); + Err(s3_error!(InternalError, "Request timeout before reading object")) + } + } + } + + #[allow(clippy::too_many_arguments)] + async fn finalize_get_object_response( + helper: OperationHelper, + bucket: &str, + method: &hyper::Method, + headers: &HeaderMap, + event_info: Option, + version_id_for_event: String, + output: GetObjectOutput, + extra_checksum_headers: Vec<(&'static str, String)>, + ) -> S3Result> { + let helper = match event_info { + Some(event_info) => helper.object(event_info), + None => helper, + }; + let helper = helper.version_id(version_id_for_event); + let mut response = wrap_response_with_cors(bucket, method, headers, output).await; + inject_accept_ranges_header(&mut response.headers); + // Emit XXHash3/64/128 and SHA-512 checksums that s3s GetObjectOutput cannot + // carry (#1257). This is the download-side integrity path AWS SDKs verify. + inject_additional_checksum_headers(&mut response.headers, &extra_checksum_headers); + let result = Ok(response); + let _ = helper.complete(&result); + result + } + + #[allow(clippy::too_many_arguments)] + async fn build_get_object_output_context( + &self, + req: &S3Request, + manager: &ConcurrencyManager, + bucket: &str, + key: &str, + info: ObjectInfo, + event_info: Option, + final_stream: DynReader, + buffered_body: Option, + cache_hook_served: bool, + cache_hook_probed: bool, + cache_fill_allowed: bool, + rs: Option, + content_type: Option, + last_modified: Option, + response_content_length: i64, + content_range: Option, + request_id: &str, + server_side_encryption: Option, + sse_customer_algorithm: Option, + sse_customer_key_md5: Option, + ssekms_key_id: Option, + encryption_applied: bool, + permit_wait_duration: Duration, + queue_utilization: f64, + queue_status: &concurrency::IoQueueStatus, + concurrent_requests: usize, + part_number: Option, + versioned: bool, + lifecycle: GetObjectBodyLifecycle, + resume: F, + ) -> S3Result + where + F: FnOnce(&ObjectInfo) -> Option>, + { + let strategy_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let strategy = self.finalize_get_object_strategy( + manager, + bucket, + key, + &info, + rs.as_ref(), + response_content_length, + permit_wait_duration, + queue_utilization, + queue_status, + concurrent_requests, + ); + record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_OUTPUT_STRATEGY, strategy_start); + let GetObjectStrategyContext { + io_strategy: _, + optimal_buffer_size, + enable_readahead, + } = strategy; + let cache_adapter = self.object_data_cache(); + + let body_build_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let body = Self::build_get_object_body_with_cache( + &cache_adapter, + final_stream, + &info, + response_content_length, + request_id, + content_range.as_deref(), + optimal_buffer_size, + enable_readahead, + concurrent_requests, + part_number, + rs.is_some(), + encryption_applied, + buffered_body, + cache_hook_served, + cache_hook_probed, + cache_fill_allowed, + bucket, + key, + lifecycle, + resume, + ) + .await?; + record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_BUILD, body_build_start); + + let checksum_headers_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let checksums = Self::build_get_object_checksums(&info, &req.headers, part_number, rs.as_ref())?; + record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_CHECKSUM_HEADERS, checksum_headers_start); + + let output_version_id = if versioned { + info.version_id.map(|vid| { + if vid == Uuid::nil() { + "null".to_string() + } else { + vid.to_string() + } + }) + } else { + None + }; + + // x-amz-restore: extract from object metadata + let restore = info.user_defined.get(X_AMZ_RESTORE.as_str()).and_then(|v| { + let rs = parse_restore_obj_status(v).ok()?; + Some(rs.to_string2()) + }); + + // x-amz-expiration: predict from lifecycle configuration + let lifecycle_expiration_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let expiration = resolve_put_object_expiration(bucket, &info).await; + record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_LIFECYCLE_EXPIRATION, lifecycle_expiration_start); + let storage_class = response_storage_class(&info, &info.user_defined); + let cache_control = info.user_defined.get("cache-control").cloned(); + let content_disposition = info.user_defined.get("content-disposition").cloned(); + + let metadata_filter_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let metadata = filter_object_metadata(&info.user_defined); + record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_METADATA_FILTER, metadata_filter_start); + + let output = GetObjectOutput { + body: Some(body), + content_length: Some(response_content_length), + last_modified, + content_type, + content_encoding: info.content_encoding.clone(), + cache_control, + content_disposition, + content_range, + e_tag: info.etag.map(|etag| to_s3s_etag(&etag)), + metadata, + server_side_encryption, + sse_customer_algorithm, + sse_customer_key_md5, + ssekms_key_id, + checksum_crc32: checksums.crc32, + checksum_crc32c: checksums.crc32c, + checksum_sha1: checksums.sha1, + checksum_sha256: checksums.sha256, + checksum_crc64nvme: checksums.crc64nvme, + checksum_type: checksums.checksum_type, + version_id: output_version_id, + restore, + expiration, + storage_class, + ..Default::default() + }; + + Ok(GetObjectOutputContext { + output, + event_info, + response_content_length, + optimal_buffer_size, + extra_checksum_headers: checksums.extra, + }) + } + + /// Serve a GET whose local read failed with not-found by proxying to the + /// bucket's replication targets (MinIO `proxyGetToReplicationTarget`, + /// backlog#1675 P1-5). Returns None when no target can serve the object; + /// the caller then returns the original local error. + async fn proxy_get_object_to_replication_targets( + req: &S3Request, + bucket: &str, + key: &str, + opts: &ObjectOptions, + ) -> Option { + let targets = get_read_proxy_targets(bucket, key, opts).await; + if targets.is_empty() { + return None; + } + let extra_headers = Self::proxy_read_passthrough_headers(&req.headers); + let range = req + .headers + .get(http::header::RANGE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let part_number = req.input.part_number; + + for target in targets { + match target + .get_object( + &target.bucket, + key, + opts.version_id.clone(), + range.clone(), + part_number, + extra_headers.clone(), + ) + .await + { + Ok(remote) => { + // MinIO-aligned accounting: one total per proxy attempt + // (targets were available), one failed when no target + // served it — never per target. + record_replication_proxy(bucket, "GetObject", false).await; + return Some(Self::proxy_sdk_get_output_to_s3s(remote)); + } + Err(err) if Self::proxy_sdk_error_is_not_found(&err) => { + debug!(bucket, key, arn = %target.arn, "read proxy: target does not have the object"); + } + Err(err) => { + warn!(bucket, key, arn = %target.arn, error = %err, "read proxy: GET against replication target failed"); + } + } + } + record_replication_proxy(bucket, "GetObject", true).await; + None + } + + /// Translate a proxied SDK GET response into the s3s output, forwarding + /// the body as a stream (no buffering, no local persistence). + fn proxy_sdk_get_output_to_s3s(remote: aws_sdk_s3::operation::get_object::GetObjectOutput) -> GetObjectOutput { + let body = remote.body; + let body_stream = tokio_util::io::ReaderStream::with_capacity(body.into_async_read(), 64 * 1024); + GetObjectOutput { + body: Some(StreamingBlob::wrap(body_stream)), + content_length: remote.content_length, + content_range: remote.content_range, + content_type: remote.content_type.as_deref().and_then(|v| ContentType::from_str(v).ok()), + content_encoding: remote.content_encoding, + content_disposition: remote.content_disposition, + content_language: remote.content_language, + cache_control: remote.cache_control, + e_tag: remote.e_tag.as_deref().and_then(|v| ETag::from_str(v).ok()), + last_modified: remote + .last_modified + .and_then(|dt| OffsetDateTime::from_unix_timestamp_nanos(dt.as_nanos()).ok()) + .map(Timestamp::from), + metadata: remote.metadata, + version_id: remote.version_id, + server_side_encryption: remote + .server_side_encryption + .map(|sse| ServerSideEncryption::from(sse.as_str().to_string())), + sse_customer_algorithm: remote.sse_customer_algorithm, + sse_customer_key_md5: remote.sse_customer_key_md5, + ssekms_key_id: remote.ssekms_key_id, + parts_count: remote.parts_count, + tag_count: remote.tag_count, + storage_class: remote.storage_class.map(|sc| StorageClass::from(sc.as_str().to_string())), + expiration: remote.expiration, + restore: remote.restore, + checksum_crc32: remote.checksum_crc32, + checksum_crc32c: remote.checksum_crc32_c, + checksum_crc64nvme: remote.checksum_crc64_nvme, + checksum_sha1: remote.checksum_sha1, + checksum_sha256: remote.checksum_sha256, + checksum_type: remote.checksum_type.map(|ct| ChecksumType::from(ct.as_str().to_string())), + ..Default::default() + } + } + + #[instrument(name = "execute_get_object", level = "trace", skip(self, req))] + pub async fn execute_get_object(&self, req: S3Request) -> S3Result> { + self.execute_get_object_boxed(req).await + } + + fn execute_get_object_boxed( + &self, + req: S3Request, + ) -> impl std::future::Future>> + Send + '_ { + Box::pin(self.execute_get_object_inner(req)) + } + + async fn execute_get_object_inner(&self, req: S3Request) -> S3Result> { + if let Some(context) = &self.context { + let _ = context.object_store(); + } + + let inbound_request_context = req.extensions.get::(); + let request_id = inbound_request_context + .map(|ctx| ctx.request_id.clone()) + .unwrap_or_else(|| request_context::RequestContext::fallback().request_id); + if rustfs_io_metrics::get_stage_metrics_enabled() + && let Some(context) = inbound_request_context + { + rustfs_io_metrics::record_get_object_stage_duration( + GET_OBJECT_STAGE_PATH_S3_HANDLER, + GET_OBJECT_STAGE_REQUEST_INGRESS_TO_CONTEXT, + context.start_time.elapsed().as_secs_f64(), + ); + } + let bootstrap = self.init_get_object_bootstrap(&req.input.bucket, &req.input.key, &request_id)?; + let timeout_config = bootstrap.timeout_config; + let wrapper = bootstrap.wrapper; + let request_start = bootstrap.request_start; + let concurrent_requests = bootstrap.concurrent_requests; + let mut lifecycle = GetObjectBodyLifecycle::tracked(bootstrap.request_guard); + + let helper = OperationHelper::new(&req, EventName::ObjectAccessedGet, S3Operation::GetObject).suppress_event(); + // mc get 3 + + // Cheap request-shape validations run first so invalid requests keep + // their InvalidArgument precedence over bucket existence. + let validated = match Self::validate_get_object_request(&req) { + Ok(validated) => validated, + Err(err) => { + lifecycle.finish_err(); + return Err(err); + } + }; + + // SF05: Store lookup next (5s-TTL bucket-validation cache). Bucket + // existence is established before any bucket-metadata work, so requests + // naming nonexistent buckets fail before the versioning lookup in + // get_opts. The store comes from the request-bound server context + // (backlog#1052 S6), not the process-global handle. + let object_traffic_health = self.object_traffic_health(); + let object_metadata_progress = object_traffic_health + .as_deref() + .and_then(ObjectTrafficHealth::track_read_metadata); + let store_lookup_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let Some(store) = self.object_store() else { + lifecycle.finish_err(); + return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); + }; + if let Err(err) = validate_bucket_exists(&store, &req.input.bucket).await { + lifecycle.finish_err(); + return Err(err); + } + if let Some(store_lookup_start) = store_lookup_start { + rustfs_io_metrics::record_get_object_stage_duration( + "s3_handler", + "store_lookup", + store_lookup_start.elapsed().as_secs_f64(), + ); + } + + let request_context_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let request_context = match Self::prepare_get_object_request_context(validated, &req.headers).await { + Ok(request_context) => request_context, + Err(err) => { + lifecycle.finish_err(); + return Err(err); + } + }; + if let Some(request_context_start) = request_context_start { + rustfs_io_metrics::record_get_object_stage_duration( + "s3_handler", + "request_context", + request_context_start.elapsed().as_secs_f64(), + ); + } + let GetObjectRequestContext { + bucket, + key, + version_id_for_event, + part_number, + rs, + opts, + } = request_context; + drop(object_metadata_progress); + + let manager = get_concurrency_manager(); + + let prepared_read = match self + .prepare_get_object_read_execution( + &req, + manager, + store.clone(), + &wrapper, + &timeout_config, + &bucket, + &key, + rs, + &opts, + part_number, + object_traffic_health, + ) + .await + { + Ok(prepared_read) => prepared_read, + Err(err) => { + // Active-active replication lag window: an object missing + // locally (and only missing — other errors keep their + // semantics) may still be served by proxying the GET to a + // replication target (backlog#1675 P1-5). + if matches!(*err.code(), S3ErrorCode::NoSuchKey | S3ErrorCode::NoSuchVersion) + && let Some(output) = Self::proxy_get_object_to_replication_targets(&req, &bucket, &key, &opts).await + { + lifecycle.finish_ok(); + let mut response = wrap_response_with_cors(&bucket, &req.method, &req.headers, output).await; + inject_accept_ranges_header(&mut response.headers); + let result = Ok(response); + let _ = helper.version_id(version_id_for_event).complete(&result); + return result; + } + lifecycle.finish_err(); + return Err(err); + } + }; + let GetObjectPreparedRead { io_planning, read_setup } = prepared_read; + let GetObjectIoPlanning { + disk_permit, + permit_wait_duration, + queue_status, + queue_utilization, + } = io_planning; + + let GetObjectReadSetup { + info, + final_stream, + buffered_body, + cache_hook_served, + cache_hook_probed, + cache_fill_allowed, + rs, + content_type, + last_modified, + response_content_length, + content_range, + server_side_encryption, + sse_customer_algorithm, + sse_customer_key_md5, + ssekms_key_id, + encryption_applied, + resume_range_start, + resume_range_end, + } = read_setup; + let final_stream = if let Some(disk_permit) = disk_permit { + wrap_reader(DiskReadPermitReader::new(final_stream, disk_permit)) + } else { + final_stream + }; + + // Clone ObjectInfo for event notification only when an event will + // actually be built — the clone is expensive for multipart objects. + let event_info = helper.wants_object_info().then(|| info.clone()); + + let output_build_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); + let output_context = self + .build_get_object_output_context( + &req, + manager, + &bucket, + &key, + info, + event_info, + final_stream, + buffered_body, + cache_hook_served, + cache_hook_probed, + cache_fill_allowed, + rs, + content_type, + last_modified, + response_content_length, + content_range, + &request_id, + server_side_encryption, + sse_customer_algorithm, + sse_customer_key_md5, + ssekms_key_id, + encryption_applied, + permit_wait_duration, + queue_utilization, + &queue_status, + concurrent_requests, + part_number, + opts.versioned, + lifecycle, + |info| { + Some(get_object_resume_control(GetObjectResumeContext::new( + store, + &bucket, + &key, + opts, + &req.headers, + info, + resume_range_start, + resume_range_end, + ))) + }, + ) + .await; + let output_context = match output_context { + Ok(output_context) => output_context, + Err(err) => return Err(err), + }; + if let Some(output_build_start) = output_build_start { + rustfs_io_metrics::record_get_object_stage_duration( + "s3_handler", + "output_build", + output_build_start.elapsed().as_secs_f64(), + ); + } + let GetObjectOutputContext { + output, + event_info, + response_content_length, + optimal_buffer_size, + extra_checksum_headers, + } = output_context; + + let total_duration = request_start.elapsed(); + Self::finalize_get_object_completion( + &wrapper, + &timeout_config, + total_duration, + response_content_length, + optimal_buffer_size, + ); + + Self::finalize_get_object_response( + helper, + &bucket, + &req.method, + &req.headers, + event_info, + version_id_for_event, + output, + extra_checksum_headers, + ) + .await + } + + pub async fn execute_get_object_attributes( + &self, + req: S3Request, + ) -> S3Result> { + if let Some(context) = &self.context { + let _ = context.object_store(); + } + + let mut helper = + OperationHelper::new(&req, EventName::ObjectAccessedAttributes, S3Operation::GetObjectAttributes).suppress_event(); + let GetObjectAttributesInput { + bucket, + key, + max_parts, + object_attributes, + part_number_marker, + version_id, + sse_customer_key, + sse_customer_key_md5, + .. + } = req.input; + + let Some(store) = self.object_store() else { + return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); + }; + + let mut opts: ObjectOptions = get_opts(&bucket, &key, version_id.clone(), None, &req.headers) + .await + .map_err(ApiError::from)?; + opts.include_part_checksums = object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_PARTS); + + let info = match store.get_object_info(&bucket, &key, &opts).await { + Ok(info) => info, + Err(err) => { + if is_err_object_not_found(&err) || is_err_version_not_found(&err) { + if is_dir_object(&key) { + let has_children = match probe_prefix_has_children(store, &bucket, &key, false).await { + Ok(has_children) => has_children, + Err(e) => { + error!( + "Failed to probe children for object attributes (bucket: {}, key: {}): {}", + bucket, key, e + ); + false + } + }; + let msg = head_prefix_not_found_message(&bucket, &key, has_children); + return Err(S3Error::with_message(S3ErrorCode::NoSuchKey, msg)); + } + return Err(S3Error::new(S3ErrorCode::NoSuchKey)); + } + return Err(ApiError::from(err).into()); + } + }; + + if info.delete_marker { + if opts.version_id.is_none() { + return Err(S3Error::new(S3ErrorCode::NoSuchKey)); + } + return Err(S3Error::new(S3ErrorCode::MethodNotAllowed)); + } + + validate_ssec_for_read(&info.user_defined, sse_customer_key.as_ref(), sse_customer_key_md5.as_ref())?; + + let metadata_map = info.user_defined.clone(); + debug!( + "GetObjectAttributes raw object_attributes={:?}", + object_attributes.iter().map(|value| value.as_str()).collect::>() + ); + + let requested = |name: &'static str| -> bool { object_attributes_requested(&object_attributes, name) }; + let storage_class = + response_storage_class_for_object_attributes(&info, &metadata_map, requested(ObjectAttributes::STORAGE_CLASS)); + + let e_tag = if requested(ObjectAttributes::ETAG) { + info.etag.as_ref().map(|etag| to_s3s_etag(etag)) + } else { + None + }; + + let object_size = if requested(ObjectAttributes::OBJECT_SIZE) { + Some(info.get_actual_size().map_err(ApiError::from)?) + } else { + None + }; + + let checksum = if requested(ObjectAttributes::CHECKSUM) { + let (checksums, is_multipart) = info.decrypt_checksums(0, &req.headers).map_err(ApiError::from)?; + // GetObjectAttributes returns checksums in the XML body, and s3s's Checksum + // type has no field for the additional algorithms, so `extra` cannot be + // surfaced here (unlike the header-based GET/HEAD paths) — an s3s limitation + // tracked for when it gains typed fields. + let ResponseChecksums { + crc32: checksum_crc32, + crc32c: checksum_crc32c, + sha1: checksum_sha1, + sha256: checksum_sha256, + crc64nvme: checksum_crc64nvme, + checksum_type, + .. + } = classify_response_checksums(checksums, is_multipart); + + Some(Checksum { + checksum_crc32, + checksum_crc32c, + checksum_sha1, + checksum_sha256, + checksum_crc64nvme, + checksum_type, + ..Default::default() + }) + } else { + None + }; + let object_parts = if requested(ObjectAttributes::OBJECT_PARTS) && info.is_multipart() { + let params = parse_list_parts_params(part_number_marker, max_parts)?; + let mut parts = Vec::new(); + let mut marker = params.part_number_marker; + let max_parts = params.max_parts; + let mut start_at = 0usize; + + if let Some(marker_value) = marker { + if let Some(index) = info.parts.iter().position(|part| part.number == marker_value) { + start_at = index + 1; + } else { + marker = None; + } + } + + let max_parts: i32 = max_parts.try_into().map_err(|_| { + S3Error::with_message(S3ErrorCode::InvalidArgument, "max-parts value is out of range".to_string()) + })?; + let end = (start_at + params.max_parts).min(info.parts.len()); + let is_truncated = end < info.parts.len(); + + for part in &info.parts[start_at..end] { + let (checksums, is_multipart) = info.decrypt_checksums(part.number, &req.headers).map_err(ApiError::from)?; + // Additional algorithms cannot be surfaced in the ObjectPart XML body + // (s3s has no field); same limitation as the object-level attributes above. + let ResponseChecksums { + crc32: checksum_crc32, + crc32c: checksum_crc32c, + sha1: checksum_sha1, + sha256: checksum_sha256, + crc64nvme: checksum_crc64nvme, + .. + } = classify_response_checksums(checksums, is_multipart); + + let part_size = if part.actual_size > 0 { + part.actual_size + } else { + part.size.try_into().map_err(|_| { + S3Error::with_message(S3ErrorCode::InvalidArgument, "Part size value is out of range".to_string()) + })? + }; + + parts.push(ObjectPart { + checksum_crc32, + checksum_crc32c, + checksum_sha1, + checksum_sha256, + checksum_crc64nvme, + part_number: i32::try_from(part.number).ok(), + size: Some(part_size), + ..Default::default() + }); + } + + let part_number_marker = marker.and_then(|v| i32::try_from(v).ok()); + let next_part_number_marker = parts.last().and_then(|part| part.part_number); + + Some(GetObjectAttributesParts { + is_truncated: Some(is_truncated), + max_parts: Some(max_parts), + next_part_number_marker, + part_number_marker, + parts: Some(parts), + total_parts_count: Some(i32::try_from(info.parts.len()).map_err(|_| { + S3Error::with_message(S3ErrorCode::InvalidArgument, "Part count is out of range".to_string()) + })?), + }) + } else { + None + }; + + let version_id = if BucketVersioningSys::prefix_enabled(&bucket, &key).await { + info.version_id.map(|vid| { + if vid == Uuid::nil() { + "null".to_string() + } else { + vid.to_string() + } + }) + } else { + None + }; + + let output = GetObjectAttributesOutput { + checksum, + delete_marker: if info.delete_marker { Some(true) } else { None }, + e_tag, + last_modified: info.mod_time.map(Timestamp::from), + object_parts, + object_size, + storage_class, + version_id: version_id.clone(), + ..Default::default() + }; + + helper = helper.object(info).version_id(version_id.unwrap_or_default()); + + let result = Ok(S3Response::new(output)); + let _ = helper.complete(&result); + result + } +} + +fn object_attributes_requested(object_attributes: &[ObjectAttributes], name: &'static str) -> bool { + object_attributes.iter().any(|value| { + value.as_str().split(',').any(|part| { + part.trim_matches(|c: char| c.is_whitespace() || c == '"' || c == '\'') + .eq_ignore_ascii_case(name) + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use http::{HeaderMap, HeaderValue, Method}; + use std::pin::Pin; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; + use std::task::{Context, Poll}; + use tokio::io::{AsyncRead, ReadBuf}; + + #[tokio::test(start_paused = true)] + async fn cold_fill_disk_admission_preserves_slow_down() { + let manager = Box::leak(Box::new(ConcurrencyManager::with_disk_read_caps_for_test(1, 1))); + let primary = match manager.admit_disk_read(Duration::from_millis(1)).await.unwrap() { + DiskReadAdmission::Primary(permit) => permit, + other => panic!("expected primary admission, got {other:?}"), + }; + let degraded = match manager.admit_disk_read(Duration::from_millis(1)).await.unwrap() { + DiskReadAdmission::Degraded(permit) => permit, + other => panic!("expected degraded admission, got {other:?}"), + }; + + let result = DefaultObjectUsecase::acquire_cold_fill_io_planning(manager, "bucket", "object").await; + assert!(matches!(result, Err(ColdFillError::Storage(StorageError::SlowDown)))); + + drop(degraded); + drop(primary); + } + + #[tokio::test] + async fn cold_fill_closed_disk_admission_is_not_slow_down() { + let manager = Box::leak(Box::new(ConcurrencyManager::with_disk_read_caps_for_test(1, 1))); + manager.close_disk_read_admission_for_test(); + + let result = DefaultObjectUsecase::acquire_cold_fill_io_planning(manager, "bucket", "object").await; + assert!(matches!(result, Err(ColdFillError::DiskAdmissionClosed))); + } + + #[tokio::test] + async fn finalize_get_object_response_injects_accept_ranges_header() { + let req = build_request(GetObjectInput::default(), Method::GET); + let helper = OperationHelper::new(&req, EventName::ObjectAccessedGet, S3Operation::GetObject).suppress_event(); + let response = DefaultObjectUsecase::finalize_get_object_response( + helper, + "bucket", + &req.method, + &req.headers, + None, + String::new(), + GetObjectOutput::default(), + Vec::new(), + ) + .await + .expect("finalize response"); + + assert_eq!(response.headers.get(http::header::ACCEPT_RANGES).unwrap(), ACCEPT_RANGES_BYTES); + } + + #[test] + fn should_buffer_get_object_in_memory_respects_hard_safety_cap() { + let info = ObjectInfo::default(); + let configured_threshold = 20_i64 * 1024 * 1024 * 1024; + let response_len = 80_i64 * 1024 * 1024; + let should_buffer = + should_buffer_get_object_in_memory_with_threshold(&info, response_len, None, false, configured_threshold, 1, true); + + assert!( + !should_buffer, + "64MiB hard cap must force streaming when response exceeds cap even if configured threshold is much higher" + ); + } + + #[test] + fn should_buffer_get_object_in_memory_allows_small_non_range_requests() { + let info = ObjectInfo::default(); + let configured_threshold = 10_i64 * 1024 * 1024; + + assert!(should_buffer_get_object_in_memory_with_threshold( + &info, + 1024 * 1024, + None, + false, + configured_threshold, + 1, + true + )); + assert!(!should_buffer_get_object_in_memory_with_threshold( + &info, + 1024 * 1024, + Some(1), + false, + configured_threshold, + 1, + true + )); + assert!(!should_buffer_get_object_in_memory_with_threshold( + &info, + 1024 * 1024, + None, + true, + configured_threshold, + 1, + true + )); + } + + #[test] + fn should_buffer_get_object_in_memory_requires_seek_buffer_opt_in() { + let info = ObjectInfo::default(); + let configured_threshold = 10_i64 * 1024 * 1024; + + assert!(!should_buffer_get_object_in_memory_with_threshold( + &info, + 1024, + None, + false, + configured_threshold, + 1, + false + )); + } + + #[test] + fn should_buffer_get_object_in_memory_respects_configured_threshold_below_cap() { + let info = ObjectInfo::default(); + let configured_threshold = 10_i64 * 1024 * 1024; + + assert!(should_buffer_get_object_in_memory_with_threshold( + &info, + configured_threshold, + None, + false, + configured_threshold, + 1, + true + )); + assert!(!should_buffer_get_object_in_memory_with_threshold( + &info, + configured_threshold + 1, + None, + false, + configured_threshold, + 1, + true + )); + } + + #[test] + fn should_buffer_get_object_in_memory_rejects_unknown_lengths_and_disabled_thresholds() { + let info = ObjectInfo::default(); + let configured_threshold = 10_i64 * 1024 * 1024; + + assert!(!should_buffer_get_object_in_memory_with_threshold( + &info, + 0, + None, + false, + configured_threshold, + 1, + true + )); + assert!(!should_buffer_get_object_in_memory_with_threshold( + &info, + -1, + None, + false, + configured_threshold, + 1, + true + )); + assert!(!should_buffer_get_object_in_memory_with_threshold(&info, 1024, None, false, 0, 1, true)); + } + + #[test] + fn should_buffer_get_object_in_memory_reduces_threshold_under_concurrency() { + let info = ObjectInfo::default(); + let configured_threshold = 10_i64 * 1024 * 1024; + + assert!(should_buffer_get_object_in_memory_with_threshold( + &info, + configured_threshold, + None, + false, + configured_threshold, + 1, + true + )); + assert!(!should_buffer_get_object_in_memory_with_threshold( + &info, + configured_threshold, + None, + false, + configured_threshold, + 32, + true + )); + assert!(should_buffer_get_object_in_memory_with_threshold( + &info, + 4_i64 * 1024 * 1024, + None, + false, + configured_threshold, + rustfs_config::DEFAULT_OBJECT_HIGH_CONCURRENCY_THRESHOLD, + true + )); + } + + /// Polls the cache until the detached fill (ODC-15) populates the entry, so + /// a follow-up GET is a deterministic hit rather than racing the fill task. + async fn wait_for_cache_hit( + adapter: &crate::app::object_data_cache::ObjectDataCacheAdapter, + bucket: &str, + object: &str, + etag: &str, + size: u64, + ) { + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket, + object, + version_id: None, + etag, + size, + data_dir_u128: None, + mod_time_unix_nanos: 0, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + for _ in 0..400 { + if matches!(adapter.lookup_body(&plan).await, rustfs_object_data_cache::ObjectDataCacheLookup::Hit(_)) { + return; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + panic!("detached fill did not populate the cache within the timeout"); + } + + struct ReadProbeReader { + reads: Arc, + } + + impl AsyncRead for ReadProbeReader { + fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll> { + self.reads.fetch_add(1, AtomicOrdering::Relaxed); + Poll::Ready(Ok(())) + } + } + + struct DataProbeReader { + reads: Arc, + data: std::io::Cursor>, + } + + struct ColdFillMatrixReader { + inner: tokio::io::DuplexStream, + first_poll_recorded: bool, + completion_recorded: bool, + first_polls: Arc, + completed: Arc, + bytes_read: Arc, + } + + impl AsyncRead for ColdFillMatrixReader { + fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + if !self.first_poll_recorded { + self.first_poll_recorded = true; + self.first_polls.fetch_add(1, AtomicOrdering::Relaxed); + } + let before = buf.filled().len(); + match Pin::new(&mut self.inner).poll_read(cx, buf) { + Poll::Ready(Ok(())) => { + let read = buf.filled().len().saturating_sub(before); + self.bytes_read.fetch_add(read, AtomicOrdering::Relaxed); + if read == 0 && !self.completion_recorded { + self.completion_recorded = true; + self.completed.fetch_add(1, AtomicOrdering::Relaxed); + } + Poll::Ready(Ok(())) + } + other => other, + } + } + } + + impl AsyncRead for DataProbeReader { + fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + self.reads.fetch_add(1, AtomicOrdering::Relaxed); + + let remaining = buf.remaining(); + if remaining == 0 { + return Poll::Ready(Ok(())); + } + + let position = usize::try_from(self.data.position()).unwrap_or(usize::MAX); + let source = self.data.get_ref(); + if position >= source.len() { + return Poll::Ready(Ok(())); + } + + let end = position.saturating_add(remaining).min(source.len()); + buf.put_slice(&source[position..end]); + self.data.set_position(u64::try_from(end).unwrap_or(u64::MAX)); + Poll::Ready(Ok(())) + } + } + + struct PendingReader; + + impl AsyncRead for PendingReader { + fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll> { + Poll::Pending + } + } + + // Emits `fail_after` bytes from `data`, then returns a hard read error. Used + // to inject the "read K bytes then Err" partial-read case (#1324). + struct ErrAfterReader { + data: std::io::Cursor>, + fail_after: usize, + emitted: usize, + } + + impl AsyncRead for ErrAfterReader { + fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + if self.emitted >= self.fail_after { + return Poll::Ready(Err(std::io::Error::other("injected mid-stream read error"))); + } + let remaining = buf.remaining(); + if remaining == 0 { + return Poll::Ready(Ok(())); + } + let want = (self.fail_after - self.emitted).min(remaining); + let position = usize::try_from(self.data.position()).unwrap_or(usize::MAX); + let source = self.data.get_ref(); + let end = position.saturating_add(want).min(source.len()); + if end <= position { + return Poll::Ready(Err(std::io::Error::other("injected mid-stream read error"))); + } + let chunk_len = end - position; + buf.put_slice(&source[position..end]); + self.data.set_position(u64::try_from(end).unwrap_or(u64::MAX)); + self.emitted += chunk_len; + Poll::Ready(Ok(())) + } + } + + fn cursor_reader(bytes: &[u8]) -> std::io::Cursor> { + std::io::Cursor::new(bytes.to_vec()) + } + + // #1324: the strict materialization helper is the shared exact-length gate + // for the encrypted, seek, and cache memory branches. For a declared length N + // only an exact N-byte read succeeds; a short read (N-1), an over-long read + // (N+1), and a mid-stream read error all hard-fail. This is the reversal + // guard for every one of those sources at once: restoring WARN-and-serve or a + // partial fallback would flip the short/over-long/error assertions to Ok. + #[tokio::test] + async fn strict_materialize_object_body_requires_exact_length() { + // Exact length: the only accepted outcome. + let buf = strict_materialize_object_body(cursor_reader(b"hello"), 5, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ) + .await + .expect("exact-length read must materialize"); + assert_eq!(buf, b"hello"); + assert_eq!(buf.capacity(), 5, "exact materialization must allocate only the declared body length"); + + let exact_large = vec![7_u8; 64 * 1024]; + let buf = strict_materialize_object_body( + std::io::Cursor::new(exact_large.clone()), + exact_large.len(), + GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ, + ) + .await + .expect("64 KiB exact-length read must materialize"); + assert_eq!(buf.capacity(), exact_large.len()); + + let mut overlong_large = exact_large; + overlong_large.push(9); + let overlong = strict_materialize_object_body( + std::io::Cursor::new(overlong_large), + 64 * 1024, + GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ, + ) + .await; + assert!(matches!( + overlong, + Err(StrictMaterializeError::LengthMismatch { + expected: 65_536, + actual: 65_537 + }) + )); + + // Short read (actual = expected - 1): a clean EOF before the declared + // length must be a hard error, never a truncated served body. + let short = strict_materialize_object_body(cursor_reader(b"hell"), 5, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ).await; + assert!( + matches!( + short, + Err(StrictMaterializeError::LengthMismatch { + expected: 5, + actual: 4, + .. + }) + ), + "short read must fail with a length mismatch, got {short:?}", + short = short.as_ref().map(|b| b.len()) + ); + + // Over-long read (actual = expected + 1): must fail rather than silently + // truncate to the committed Content-Length. + let long = strict_materialize_object_body(cursor_reader(b"hello!"), 5, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ).await; + assert!( + matches!(long, Err(StrictMaterializeError::LengthMismatch { expected: 5, actual: 6 })), + "over-long read must fail with a length mismatch, got {long:?}", + long = long.as_ref().map(|b| b.len()) + ); + + // Read K bytes then Err: must surface the read error and never return the + // partially consumed buffer (which the caller could otherwise re-stream). + let reader = ErrAfterReader { + data: cursor_reader(b"hello"), + fail_after: 3, + emitted: 0, + }; + let errored = strict_materialize_object_body(reader, 5, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ).await; + assert!( + matches!(errored, Err(StrictMaterializeError::Read { consumed: 3, .. })), + "a mid-stream read error must be reported as a read failure" + ); + } + + #[test] + fn cold_fill_zero_timeout_policy_disables_deadline() { + let policy = GetObjectTimeoutPolicy { + get_object_timeout: Duration::ZERO, + ..GetObjectTimeoutPolicy::default() + }; + let wrapper = RequestTimeoutWrapper::with_request_id(policy.clone(), "cold-fill-zero-timeout"); + assert!(cold_fill_deadline(&wrapper, &policy, 1).is_none()); + } + + #[tokio::test(start_paused = true)] + async fn cold_fill_producer_deadline_is_capped_at_ten_minutes() { + let disabled = GetObjectTimeoutPolicy { + get_object_timeout: Duration::ZERO, + ..GetObjectTimeoutPolicy::default() + }; + let now = tokio::time::Instant::now(); + assert_eq!(cold_fill_producer_deadline(&disabled, 1) - now, Duration::from_secs(600)); + + let long = GetObjectTimeoutPolicy { + get_object_timeout: Duration::from_secs(3600), + enable_dynamic_timeout: false, + ..GetObjectTimeoutPolicy::default() + }; + let now = tokio::time::Instant::now(); + assert_eq!(cold_fill_producer_deadline(&long, 1) - now, Duration::from_secs(600)); + } + + #[tokio::test] + async fn cold_fill_startup_wait_stops_when_last_consumer_cancels() { + let cancellation = tokio_util::sync::CancellationToken::new(); + let waiting = tokio::spawn({ + let cancellation = cancellation.clone(); + async move { await_cold_fill_startup(std::future::pending::<()>(), &cancellation, None).await } + }); + tokio::task::yield_now().await; + + cancellation.cancel(); + + let result = tokio::time::timeout(Duration::from_secs(1), waiting) + .await + .expect("startup wait must observe cancellation") + .expect("startup wait task must not panic"); + assert!(matches!(result, Err(ColdFillStartupWaitError::Cancelled))); + } + + #[tokio::test(start_paused = true)] + async fn cold_fill_startup_wait_with_deadline_still_observes_cancellation() { + let cancellation = tokio_util::sync::CancellationToken::new(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(60); + let waiting = tokio::spawn({ + let cancellation = cancellation.clone(); + async move { await_cold_fill_startup(std::future::pending::<()>(), &cancellation, Some(deadline)).await } + }); + tokio::task::yield_now().await; + + cancellation.cancel(); + + let result = waiting.await.expect("startup wait task must not panic"); + assert!(matches!(result, Err(ColdFillStartupWaitError::Cancelled))); + } + + #[tokio::test(start_paused = true)] + async fn cold_fill_startup_wait_reports_deadline_exceeded() { + let cancellation = tokio_util::sync::CancellationToken::new(); + let deadline = tokio::time::Instant::now() + Duration::from_millis(1); + + let result = await_cold_fill_startup(std::future::pending::<()>(), &cancellation, Some(deadline)).await; + + assert!(matches!(result, Err(ColdFillStartupWaitError::DeadlineExceeded))); + } + + #[tokio::test] + async fn cold_fill_late_miss_second_chance_hits_without_reader() { + let adapter = ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 1024 * 1024, + max_memory_percent: 0, + max_entry_bytes: 1024, + min_free_memory_percent: 0, + fill_concurrency_max: 1, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("second-chance cache config must be valid"); + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "late-bucket", + object: "late-object", + version_id: None, + etag: "late-etag", + size: 4, + data_dir_u128: Some(1), + mod_time_unix_nanos: 1, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + assert!(matches!( + adapter.lookup_body(&plan).await, + rustfs_object_data_cache::ObjectDataCacheLookup::Miss + )); + let request_lookups = adapter.cache().stats().lookups; + assert_eq!(request_lookups, 1, "the authoritative request lookup must be counted once"); + + let reservation = adapter.reserve_body(&plan).expect("late producer must reserve"); + let reserved = reservation.wrap_bytes(Bytes::from_static(b"body")); + let _ = adapter.fill_reserved_body(&plan, reserved).await; + let coordinator = adapter.cold_fill_coordinator(); + let cache_key = plan.key().cloned().expect("late plan must be cacheable"); + let adapter = Arc::new(adapter); + let readers = Arc::new(AtomicUsize::new(0)); + let outcome = coordinate_cold_fill(&coordinator, cache_key, None, None, { + let adapter = Arc::clone(&adapter); + let readers = Arc::clone(&readers); + move |producer| { + let adapter = Arc::clone(&adapter); + let plan = plan.clone(); + let readers = Arc::clone(&readers); + async move { + if let Some(body) = lookup_cold_fill_second_chance(&adapter, &plan).await { + producer.finish_shared(Ok(body)); + return; + } + readers.fetch_add(1, AtomicOrdering::Relaxed); + producer.bypass(); + } + } + }) + .await; + let ColdFillCoordinateOutcome::Ready(Ok(body)) = outcome else { + panic!("late request must observe the completed fill, got {outcome:?}"); + }; + assert_eq!(body, Bytes::from_static(b"body")); + assert_eq!( + adapter.cache().stats().lookups, + request_lookups, + "the producer second chance must not count another request lookup" + ); + assert_eq!(readers.load(AtomicOrdering::Relaxed), 0); + } + + #[tokio::test] + async fn cold_fill_timeout_is_shared_and_releases_resources() { + let adapter = Arc::new( + ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 1024 * 1024, + max_memory_percent: 0, + max_entry_bytes: 1024, + min_free_memory_percent: 0, + fill_concurrency_max: 1, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("timeout cache config must be valid"), + ); + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "timeout-bucket", + object: "timeout-object", + version_id: None, + etag: "timeout-etag", + size: 1, + data_dir_u128: Some(1), + mod_time_unix_nanos: 1, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + let key = plan.key().cloned().expect("timeout body must be cacheable"); + let coordinator = adapter.cold_fill_coordinator(); + let ColdFillRole::Produce(mut producer) = coordinator.join(key.clone()) else { + panic!("first timeout request must produce"); + }; + let leader = producer.waiter(); + let reservation = adapter.reserve_body(&plan); + let disk_permits = Arc::new(tokio::sync::Semaphore::new(1)); + let disk_gate = Arc::clone(&disk_permits); + let readers = Arc::new(AtomicUsize::new(0)); + let reader_count = Arc::clone(&readers); + let producer_task = tokio::spawn(start_cold_fill_producer( + producer, + reservation, + move || async move { + let permit = disk_gate + .acquire_owned() + .await + .map_err(|_| ColdFillError::DiskAdmissionClosed)?; + let mut io = DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager()); + io.disk_permit = Some(permit.into()); + Ok(io) + }, + move || async move { + reader_count.fetch_add(1, AtomicOrdering::Relaxed); + Ok(GetObjectReader { + stream: Box::new(PendingReader), + object_info: ObjectInfo { + size: 1, + actual_size: 1, + ..Default::default() + }, + buffered_body: None, + body_source: GetObjectBodySource::HookMissed, + }) + }, + ColdFillProducerExecution { + expected: 1, + deadline: Some(tokio::time::Instant::now() + Duration::from_millis(20)), + adapter: Arc::clone(&adapter), + engine_plan: plan.clone(), + }, + )); + tokio::time::timeout(Duration::from_secs(1), async { + while readers.load(AtomicOrdering::Relaxed) == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("producer reader must open"); + let ColdFillRole::Wait(follower) = coordinator.join(key.clone()) else { + panic!("second timeout request must follow"); + }; + + let (leader_result, follower_result) = + tokio::time::timeout(Duration::from_secs(2), async { tokio::join!(leader.wait(), follower.wait()) }) + .await + .expect("typed timeout must wake all waiters"); + assert!(matches!( + leader_result, + ColdFillWaitOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout))) + )); + assert!(matches!( + follower_result, + ColdFillWaitOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout))) + )); + assert_eq!(readers.load(AtomicOrdering::Relaxed), 1); + assert_eq!(disk_permits.available_permits(), 1); + assert_eq!(coordinator.global_waiter_count_for_test(), 0); + assert_eq!(coordinator.active_session_count_for_test(), 0); + assert!(matches!( + adapter.lookup_body(&plan).await, + rustfs_object_data_cache::ObjectDataCacheLookup::Miss + )); + producer_task.await.expect("producer task must join"); + assert!(adapter.reserve_body(&plan).is_some(), "timeout must release the body reservation"); + let ColdFillRole::Produce(successor) = coordinator.join(key) else { + panic!("timeout must release the session for a successor"); + }; + drop(successor); + } + + #[tokio::test] + async fn cold_fill_survives_leader_request_cancellation_without_second_producer() { + let adapter = Arc::new( + ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 1024 * 1024, + max_memory_percent: 0, + max_entry_bytes: 1024, + min_free_memory_percent: 0, + fill_concurrency_max: 1, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("cancellation cache config must be valid"), + ); + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "cancel-bucket", + object: "cancel-object", + version_id: None, + etag: "cancel-etag", + size: 4, + data_dir_u128: Some(1), + mod_time_unix_nanos: 1, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + let key = plan.key().cloned().expect("cancellation body must be cacheable"); + let coordinator = adapter.cold_fill_coordinator(); + let ColdFillRole::Produce(mut producer) = coordinator.join(key.clone()) else { + panic!("first cancellation request must produce"); + }; + let leader = producer.waiter(); + let reservation = adapter.reserve_body(&plan); + let readers = Arc::new(AtomicUsize::new(0)); + let reader_count = Arc::clone(&readers); + let writer_slot = Arc::new(Mutex::new(None)); + let writer_output = Arc::clone(&writer_slot); + let producer_task = tokio::spawn(start_cold_fill_producer( + producer, + reservation, + || async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) }, + move || async move { + reader_count.fetch_add(1, AtomicOrdering::Relaxed); + let (writer, reader) = tokio::io::duplex(16); + *writer_output.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(writer); + Ok(GetObjectReader { + stream: Box::new(reader), + object_info: ObjectInfo { + size: 4, + actual_size: 4, + ..Default::default() + }, + buffered_body: None, + body_source: GetObjectBodySource::HookMissed, + }) + }, + ColdFillProducerExecution { + expected: 4, + deadline: None, + adapter: Arc::clone(&adapter), + engine_plan: plan.clone(), + }, + )); + tokio::time::timeout(Duration::from_secs(1), async { + while readers.load(AtomicOrdering::Relaxed) == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("cancellation producer reader must open"); + let ColdFillRole::Wait(follower) = coordinator.join(key.clone()) else { + panic!("second cancellation request must follow"); + }; + drop(leader); + assert_eq!(readers.load(AtomicOrdering::Relaxed), 1); + let ColdFillRole::Wait(late) = coordinator.join(key) else { + panic!("leader cancellation must not open a successor session"); + }; + drop(late); + + let mut writer = writer_slot + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + .expect("reader factory must publish writer"); + tokio::io::AsyncWriteExt::write_all(&mut writer, b"body") + .await + .expect("body write must succeed"); + tokio::io::AsyncWriteExt::shutdown(&mut writer) + .await + .expect("body writer must close"); + let ColdFillWaitOutcome::Ready(result) = follower.wait().await else { + panic!("follower must receive producer result"); + }; + assert_eq!(result.expect("surviving producer must succeed"), Bytes::from_static(b"body")); + producer_task.await.expect("producer task must join"); + assert_eq!(readers.load(AtomicOrdering::Relaxed), 1); + } + + #[tokio::test] + async fn cold_fill_reservation_rejection_streams_without_materializing() { + let coordinator = Arc::new(crate::app::object_data_cache::ColdFillCoordinator::default()); + let plan = rustfs_object_data_cache::ObjectDataCacheGetPlan::Disabled; + let ColdFillRole::Produce(mut producer) = coordinator.join(rustfs_object_data_cache::ObjectDataCacheKey::new( + "bucket", + "object", + None, + "etag", + 4, + rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + )) else { + panic!("first rejected reservation request must produce"); + }; + let leader = producer.waiter(); + let permits = Arc::new(AtomicUsize::new(0)); + let readers = Arc::new(AtomicUsize::new(0)); + let permit_count = Arc::clone(&permits); + let reader_count = Arc::clone(&readers); + start_cold_fill_producer( + producer, + None, + move || async move { + permit_count.fetch_add(1, AtomicOrdering::Relaxed); + Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) + }, + move || async move { + reader_count.fetch_add(1, AtomicOrdering::Relaxed); + Err(StorageError::other("reader must not open")) + }, + ColdFillProducerExecution { + expected: 4, + deadline: None, + adapter: Arc::new(ObjectDataCacheAdapter::disabled()), + engine_plan: plan, + }, + ) + .await; + assert!(matches!(leader.wait().await, ColdFillWaitOutcome::Bypass)); + assert_eq!(permits.load(AtomicOrdering::Relaxed), 0); + assert_eq!(readers.load(AtomicOrdering::Relaxed), 0); + + let fallback_reads = Arc::new(AtomicUsize::new(0)); + let fallback_reader = DataProbeReader { + reads: Arc::clone(&fallback_reads), + data: std::io::Cursor::new(b"body".to_vec()), + }; + let info = ObjectInfo { + size: 4, + actual_size: 4, + ..Default::default() + }; + let mut fallback_body = DefaultObjectUsecase::build_get_object_body( + fallback_reader, + &info, + 4, + "req-cold-fill", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + None, + "bucket", + "object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("reservation bypass must construct the normal streaming fallback"); + let chunk = fallback_body + .next() + .await + .expect("fallback stream must yield a body chunk") + .expect("fallback stream must not fail"); + assert_eq!(chunk, Bytes::from_static(b"body")); + assert!(fallback_reads.load(AtomicOrdering::Relaxed) > 0); + assert_eq!(readers.load(AtomicOrdering::Relaxed), 0, "cold-fill materialization must remain unopened"); + assert_eq!(coordinator.active_session_count_for_test(), 0); + } + + #[tokio::test] + async fn cold_fill_internal_movement_and_restore_reads_never_join_sessions() { + let coordinator = Arc::new(crate::app::object_data_cache::ColdFillCoordinator::default()); + let info = ObjectInfo { + size: 4, + actual_size: 4, + ..Default::default() + }; + let mut restore = ObjectOptions::default(); + restore.transition.restore_request.days = Some(1); + let cases = [ + ObjectOptions { + raw_data_movement_read: true, + ..Default::default() + }, + ObjectOptions { + data_movement: true, + ..Default::default() + }, + restore, + ]; + + for opts in &cases { + assert!(matches!( + lookup_get_object_body_cache_hook("bucket", "object", &None, opts, &info).await, + GetObjectBodyCacheHookLookup::Ineligible + )); + assert_eq!(coordinator.active_session_count_for_test(), 0); + } + + let delete_marker = ObjectInfo { + delete_marker: true, + etag: Some("delete-marker-etag".to_string()), + ..Default::default() + }; + let delete_marker_part = ObjectOptions { + part_number: Some(2), + ..Default::default() + }; + assert!(matches!( + lookup_get_object_body_cache_hook("bucket", "object", &None, &delete_marker_part, &delete_marker).await, + GetObjectBodyCacheHookLookup::Ineligible + )); + assert_eq!(coordinator.active_session_count_for_test(), 0); + } + + #[tokio::test] + async fn cold_fill_generation_change_bypasses_before_opening_body() { + let adapter = Arc::new( + ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 1024 * 1024, + max_memory_percent: 0, + max_entry_bytes: 1024, + min_free_memory_percent: 0, + fill_concurrency_max: 1, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("generation retry cache config must be valid"), + ); + let request = |data_dir_u128| rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "generation-bucket", + object: "generation-object", + version_id: None, + etag: "generation-etag", + size: 4, + data_dir_u128: Some(data_dir_u128), + mod_time_unix_nanos: 1, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }; + let initial_plan = adapter.plan_get(request(1)); + let changed_plan = GetObjectBodyCachePlan::Cacheable(adapter.plan_get(request(2))); + let cache_key = initial_plan.key().cloned().expect("initial generation must be cacheable"); + let coordinator = adapter.cold_fill_coordinator(); + let body_opens = Arc::new(AtomicUsize::new(0)); + let producer_attempts = Arc::new(AtomicUsize::new(0)); + + let outcome = coordinate_cold_fill(&coordinator, cache_key, None, None, { + let body_opens = Arc::clone(&body_opens); + let producer_attempts = Arc::clone(&producer_attempts); + move |producer| { + let body_opens = Arc::clone(&body_opens); + let producer_attempts = Arc::clone(&producer_attempts); + let changed_plan = changed_plan.clone(); + let initial_plan = initial_plan.clone(); + async move { + producer_attempts.fetch_add(1, AtomicOrdering::Relaxed); + let Some(producer) = retain_cold_fill_producer_for_matching_plan(producer, &changed_plan, &initial_plan) + else { + return; + }; + body_opens.fetch_add(1, AtomicOrdering::Relaxed); + producer.bypass(); + } + } + }) + .await; + + assert!(matches!(outcome, ColdFillCoordinateOutcome::Bypass)); + assert_eq!(producer_attempts.load(AtomicOrdering::Relaxed), 1); + assert_eq!(body_opens.load(AtomicOrdering::Relaxed), 0); + assert_eq!(coordinator.active_session_count_for_test(), 0); + } + + #[tokio::test] + #[serial_test::serial(body_cache_hook)] + async fn execute_get_object_rejects_conditions_before_joining_cold_fill() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; + + let (store, context) = real_cold_fill_test_context().await; + let bucket = format!("cold-condition-{}", Uuid::new_v4()); + let object = "object.bin"; + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("real cold-fill condition bucket must be created"); + let body = vec![b'a'; 1_300_000]; + let info = put_real_cold_fill_object(&store, &bucket, object, &body).await; + let adapter = context.object_data_cache(); + let plan = real_cold_fill_plan(&adapter, &bucket, object, &info); + let coordinator = adapter.cold_fill_coordinator(); + let ColdFillRole::Produce(producer) = + coordinator.join(plan.key().cloned().expect("real cold-fill plan must expose its key")) + else { + panic!("test must reserve the initial cold-fill producer"); + }; + + let input = GetObjectInput::builder() + .bucket(bucket) + .key(object.to_string()) + .build() + .expect("real cold-fill GET input must build"); + let mut req = build_request(input, Method::GET); + let etag = info.etag.expect("real cold-fill test object must have an ETag"); + req.headers.insert( + http::header::IF_NONE_MATCH, + HeaderValue::from_str(&format!("\"{etag}\"")).expect("ETag header must be valid"), + ); + let usecase = DefaultObjectUsecase::with_context(Some(context)); + let result = tokio::time::timeout(Duration::from_secs(2), usecase.execute_get_object(req)) + .await + .expect("conditional GET must not wait for the reserved cold-fill session") + .expect_err("matching If-None-Match must reject the GET"); + + assert_eq!(result.code(), &S3ErrorCode::NotModified); + assert_eq!(coordinator.global_waiter_count_for_test(), 0); + drop(producer); + assert_eq!(coordinator.active_session_count_for_test(), 0); + } + + #[tokio::test] + #[serial_test::serial(body_cache_hook)] + async fn execute_get_object_maps_cold_fill_session_rejection_to_slow_down_without_opening_reader() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; + + let (store, context) = real_cold_fill_test_context().await; + let bucket = format!("cold-rejected-{}", Uuid::new_v4()); + let object = "object.bin"; + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("real cold-fill rejection bucket must be created"); + let body = vec![b'a'; 1_300_000]; + let info = put_real_cold_fill_object(&store, &bucket, object, &body).await; + let adapter = context.object_data_cache(); + let plan = real_cold_fill_plan(&adapter, &bucket, object, &info); + let cache_key = plan.key().cloned().expect("real cold-fill plan must expose its key"); + let coordinator = adapter.cold_fill_coordinator(); + let mut held_producers = Vec::new(); + for index in 0..2048 { + let saturation_key = rustfs_object_data_cache::ObjectDataCacheKey::new( + "cold-fill-saturation", + format!("object-{index}"), + None, + "etag", + 4, + rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + ); + match coordinator.join(saturation_key) { + ColdFillRole::Produce(producer) => held_producers.push(producer), + ColdFillRole::Rejected => break, + ColdFillRole::Wait(_) | ColdFillRole::Bypass => panic!("unique saturation keys must produce or reject"), + } + } + assert_eq!(coordinator.active_session_count_for_test(), held_producers.len()); + assert!(!held_producers.is_empty(), "saturation must reserve cold-fill sessions"); + + let reader_opens = Arc::new(AtomicU64::new(0)); + *COLD_FILL_READER_OPEN_PROBE + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((cache_key, Arc::clone(&reader_opens))); + let input = GetObjectInput::builder() + .bucket(bucket) + .key(object.to_string()) + .build() + .expect("real cold-fill rejection GET input must build"); + let usecase = DefaultObjectUsecase::with_context(Some(context)); + let result = tokio::time::timeout(Duration::from_secs(2), usecase.execute_get_object(build_request(input, Method::GET))) + .await + .expect("rejected real GET must not wait for a cold-fill session") + .expect_err("rejected real GET must return an S3 error"); + *COLD_FILL_READER_OPEN_PROBE + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; + + assert_eq!(result.code(), &S3ErrorCode::SlowDown); + assert_eq!(reader_opens.load(Ordering::Relaxed), 0, "rejected GET must not open its body reader"); + assert_eq!(coordinator.active_session_count_for_test(), held_producers.len()); + drop(held_producers); + assert_eq!(coordinator.active_session_count_for_test(), 0); + } + + #[tokio::test] + #[serial_test::serial(body_cache_hook)] + async fn execute_get_object_generation_change_bypasses_old_cold_fill_plan() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; + + let (store, context) = real_cold_fill_test_context().await; + let bucket = format!("cold-generation-{}", Uuid::new_v4()); + let object = "object.bin"; + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("real cold-fill generation bucket must be created"); + let initial_body = vec![b'a'; 1_300_000]; + let changed_body = vec![b'b'; initial_body.len()]; + let initial_info = put_real_cold_fill_object(&store, &bucket, object, &initial_body).await; + let adapter = context.object_data_cache(); + let initial_plan = real_cold_fill_plan(&adapter, &bucket, object, &initial_info); + let coordinator = adapter.cold_fill_coordinator(); + let ColdFillRole::Produce(producer) = + coordinator.join(initial_plan.key().cloned().expect("real cold-fill plan must expose its key")) + else { + panic!("test must reserve the initial cold-fill producer"); + }; + + let input = GetObjectInput::builder() + .bucket(bucket.clone()) + .key(object.to_string()) + .build() + .expect("real cold-fill GET input must build"); + // The request is intentionally held behind the first producer while a + // 1.3 MiB replacement write changes its generation. Disable dynamic + // sizing for this test so runner I/O load cannot consume the five-second + // production minimum before the behavior under test is released. + let usecase = DefaultObjectUsecase::with_context_and_get_object_timeout_policy( + Some(context), + GetObjectTimeoutPolicy { + enable_dynamic_timeout: false, + ..GetObjectTimeoutPolicy::default() + }, + ); + let request = tokio::spawn(async move { usecase.execute_get_object(build_request(input, Method::GET)).await }); + tokio::time::timeout(Duration::from_secs(2), async { + while coordinator.global_waiter_count_for_test() != 1 { + tokio::task::yield_now().await; + } + }) + .await + .expect("real GET must join the reserved cold-fill session"); + + let changed_info = put_real_cold_fill_object(&store, &bucket, object, &changed_body).await; + assert_ne!(initial_info.etag, changed_info.etag); + producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout)); + + let mut response = tokio::time::timeout(Duration::from_secs(10), request) + .await + .expect("generation-changing GET must complete") + .expect("generation-changing GET task must join") + .expect("generation-changing GET must fall back successfully"); + let mut response_body = response.output.body.take().expect("GET response must include a body"); + let mut actual = Vec::with_capacity(changed_body.len()); + while let Some(chunk) = response_body.next().await { + actual.extend_from_slice(&chunk.expect("fallback body chunk must be readable")); + } + + assert_eq!(actual, changed_body); + assert!(matches!( + adapter.lookup_body(&initial_plan).await, + rustfs_object_data_cache::ObjectDataCacheLookup::Miss + )); + assert_eq!(coordinator.global_waiter_count_for_test(), 0); + assert_eq!(coordinator.active_session_count_for_test(), 0); + } + + #[tokio::test] + async fn cold_fill_open_error_retries_once_then_single_successor_succeeds() { + let adapter = Arc::new( + ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 1024 * 1024, + max_memory_percent: 0, + max_entry_bytes: 1024, + min_free_memory_percent: 0, + fill_concurrency_max: 1, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("open retry cache config must be valid"), + ); + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "open-retry-bucket", + object: "open-retry-object", + version_id: None, + etag: "open-retry-etag", + size: 4, + data_dir_u128: Some(1), + mod_time_unix_nanos: 1, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + let cache_key = plan.key().cloned().expect("open retry plan must be cacheable"); + let coordinator = adapter.cold_fill_coordinator(); + let open_attempts = Arc::new(AtomicUsize::new(0)); + let open_attempts_for_start = Arc::clone(&open_attempts); + + let outcome = coordinate_cold_fill(&coordinator, cache_key, None, None, move |producer| { + let reservation = adapter.reserve_body(&plan); + let adapter = Arc::clone(&adapter); + let plan = plan.clone(); + let open_attempts = Arc::clone(&open_attempts_for_start); + async move { + start_cold_fill_producer( + producer, + reservation, + || async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) }, + move || async move { + let attempt = open_attempts.fetch_add(1, AtomicOrdering::Relaxed); + if attempt == 0 { + return Err(StorageError::other("first open fails")); + } + Ok(GetObjectReader { + stream: Box::new(std::io::Cursor::new(Vec::::new())), + object_info: ObjectInfo { + size: 4, + actual_size: 4, + ..Default::default() + }, + buffered_body: Some(Bytes::from_static(b"body")), + body_source: GetObjectBodySource::HookMissed, + }) + }, + ColdFillProducerExecution { + expected: 4, + deadline: None, + adapter, + engine_plan: plan, + }, + ) + .await + } + }) + .await; + + let ColdFillCoordinateOutcome::Ready(Ok(body)) = outcome else { + panic!("the unique successor must publish the body"); + }; + assert_eq!(body, Bytes::from_static(b"body")); + assert_eq!(open_attempts.load(AtomicOrdering::Relaxed), 2); + assert_eq!(coordinator.active_session_count_for_test(), 0); + } + + #[tokio::test] + async fn cold_fill_open_timeout_retries_once_then_is_terminal() { + tokio::time::pause(); + let adapter = Arc::new( + ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 1024 * 1024, + max_memory_percent: 0, + max_entry_bytes: 1024, + min_free_memory_percent: 0, + fill_concurrency_max: 1, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("open timeout cache config must be valid"), + ); + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "open-timeout-bucket", + object: "open-timeout-object", + version_id: None, + etag: "open-timeout-etag", + size: 4, + data_dir_u128: Some(1), + mod_time_unix_nanos: 1, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + let cache_key = plan.key().cloned().expect("open timeout plan must be cacheable"); + let coordinator = adapter.cold_fill_coordinator(); + let open_attempts = Arc::new(AtomicUsize::new(0)); + + let deadline = tokio::time::Instant::now() + Duration::from_millis(10); + let task = tokio::spawn({ + let adapter = Arc::clone(&adapter); + let coordinator = Arc::clone(&coordinator); + let plan = plan.clone(); + let open_attempts = Arc::clone(&open_attempts); + async move { + coordinate_cold_fill(&coordinator, cache_key, None, Some(deadline), move |producer| { + let adapter = Arc::clone(&adapter); + let plan = plan.clone(); + let open_attempts = Arc::clone(&open_attempts); + let reservation = adapter.reserve_body(&plan); + let producer_deadline = producer.deadline(); + async move { + start_cold_fill_producer( + producer, + reservation, + || async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) }, + move || async move { + open_attempts.fetch_add(1, AtomicOrdering::Relaxed); + std::future::pending::>().await + }, + ColdFillProducerExecution { + expected: 4, + deadline: producer_deadline, + adapter, + engine_plan: plan, + }, + ) + .await + } + }) + .await + } + }); + while open_attempts.load(AtomicOrdering::Relaxed) == 0 { + tokio::task::yield_now().await; + } + tokio::time::advance(Duration::from_millis(11)).await; + let outcome = task.await.expect("open timeout task must join"); + assert!(matches!( + outcome, + ColdFillCoordinateOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout))) + )); + + assert_eq!(open_attempts.load(AtomicOrdering::Relaxed), 2); + assert_eq!(coordinator.active_session_count_for_test(), 0); + } + + #[tokio::test] + async fn cold_fill_pre_reader_failure_promotes_one_of_two_thousand_waiters() { + const REQUESTS: usize = 2000; + let adapter = Arc::new( + ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 1024 * 1024, + max_memory_percent: 0, + max_entry_bytes: 1024, + min_free_memory_percent: 0, + fill_concurrency_max: 1, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("successor cache config must be valid"), + ); + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "successor-bucket", + object: "successor-object", + version_id: None, + etag: "successor-etag", + size: 4, + data_dir_u128: Some(1), + mod_time_unix_nanos: 1, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + let cache_key = plan.key().cloned().expect("successor plan must be cacheable"); + let coordinator = adapter.cold_fill_coordinator(); + let admission_attempts = Arc::new(AtomicUsize::new(0)); + let open_attempts = Arc::new(AtomicUsize::new(0)); + let first_open_release = Arc::new(tokio::sync::Semaphore::new(0)); + let mut tasks = tokio::task::JoinSet::new(); + + for _ in 0..REQUESTS { + let adapter = Arc::clone(&adapter); + let coordinator = Arc::clone(&coordinator); + let cache_key = cache_key.clone(); + let plan = plan.clone(); + let admission_attempts = Arc::clone(&admission_attempts); + let open_attempts = Arc::clone(&open_attempts); + let first_open_release = Arc::clone(&first_open_release); + tasks.spawn(async move { + coordinate_cold_fill(&coordinator, cache_key, None, None, move |producer| { + let reservation = adapter.reserve_body(&plan); + let adapter = Arc::clone(&adapter); + let plan = plan.clone(); + let admission_attempts = Arc::clone(&admission_attempts); + let open_attempts = Arc::clone(&open_attempts); + let first_open_release = Arc::clone(&first_open_release); + async move { + start_cold_fill_producer( + producer, + reservation, + move || async move { + admission_attempts.fetch_add(1, AtomicOrdering::Relaxed); + Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) + }, + move || async move { + if open_attempts.fetch_add(1, AtomicOrdering::Relaxed) == 0 { + first_open_release + .acquire() + .await + .expect("first open release gate must remain open") + .forget(); + return Err(StorageError::other("first open fails")); + } + Ok(GetObjectReader { + stream: Box::new(std::io::Cursor::new(Vec::::new())), + object_info: ObjectInfo { + size: 4, + actual_size: 4, + ..Default::default() + }, + buffered_body: Some(Bytes::from_static(b"body")), + body_source: GetObjectBodySource::HookMissed, + }) + }, + ColdFillProducerExecution { + expected: 4, + deadline: None, + adapter, + engine_plan: plan, + }, + ) + .await + } + }) + .await + }); + } + + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if coordinator.global_waiter_count_for_test() == REQUESTS - 1 && open_attempts.load(AtomicOrdering::Relaxed) == 1 + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("all followers must join before the first open fails"); + first_open_release.add_permits(1); + + while let Some(result) = tasks.join_next().await { + let ColdFillCoordinateOutcome::Ready(Ok(body)) = result.expect("successor request task must join") else { + panic!("all followers must receive the successor body"); + }; + assert_eq!(body, Bytes::from_static(b"body")); + } + assert_eq!(admission_attempts.load(AtomicOrdering::Relaxed), 2); + assert_eq!(open_attempts.load(AtomicOrdering::Relaxed), 2); + assert_eq!(coordinator.global_waiter_count_for_test(), 0); + assert_eq!(coordinator.active_session_count_for_test(), 0); + } + + fn install_cold_fill_publication_barrier( + plan: &rustfs_object_data_cache::ObjectDataCacheGetPlan, + ) -> Arc { + let barrier = Arc::new(ColdFillPublicationBarrier { + reached: tokio::sync::Semaphore::new(0), + release: tokio::sync::Semaphore::new(0), + }); + let key = plan.key().cloned().expect("publication barrier plan must be cacheable"); + *COLD_FILL_PUBLICATION_BARRIER + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((key, Arc::clone(&barrier))); + barrier + } + + fn clear_cold_fill_publication_barrier() { + *COLD_FILL_PUBLICATION_BARRIER + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; + } + + fn publication_test_adapter() -> Arc { + Arc::new( + ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 1024 * 1024, + max_memory_percent: 0, + max_entry_bytes: 1024, + min_free_memory_percent: 0, + fill_concurrency_max: 1, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("publication cache config must be valid"), + ) + } + + fn publication_test_plan(adapter: &ObjectDataCacheAdapter, object: &str) -> rustfs_object_data_cache::ObjectDataCacheGetPlan { + adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "publication-bucket", + object, + version_id: None, + etag: "publication-etag", + size: 4, + data_dir_u128: Some(1), + mod_time_unix_nanos: 1, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }) + } + + #[tokio::test] + #[serial_test::serial(cold_fill_publication_barrier)] + async fn cold_fill_last_consumer_cancel_releases_session_before_publication_barrier() { + let adapter = publication_test_adapter(); + let plan = publication_test_plan(&adapter, "cancel"); + let barrier = install_cold_fill_publication_barrier(&plan); + let coordinator = adapter.cold_fill_coordinator(); + let key = plan.key().cloned().expect("publication plan must be cacheable"); + let ColdFillRole::Produce(mut producer) = coordinator.join(key) else { + panic!("publication request must produce"); + }; + let leader = producer.waiter(); + let reservation = adapter.reserve_body(&plan); + let disk_permits = Arc::new(tokio::sync::Semaphore::new(1)); + let disk_gate = Arc::clone(&disk_permits); + let producer_task = tokio::spawn(scope_cold_fill_disk_permit_owner_for_test( + ColdFillDiskPermitOwner::Producer, + start_cold_fill_producer( + producer, + reservation, + move || async move { + let permit = disk_gate + .acquire_owned() + .await + .map_err(|_| ColdFillError::DiskAdmissionClosed)?; + let mut io = DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager()); + io.disk_permit = Some(permit.into()); + Ok(io) + }, + || async { + Ok(GetObjectReader { + stream: Box::new(std::io::Cursor::new(b"body".to_vec())), + object_info: ObjectInfo { + size: 4, + actual_size: 4, + ..Default::default() + }, + buffered_body: None, + body_source: GetObjectBodySource::HookMissed, + }) + }, + ColdFillProducerExecution { + expected: 4, + deadline: None, + adapter: Arc::clone(&adapter), + engine_plan: plan.clone(), + }, + ), + )); + + let reached = barrier.reached.acquire().await.expect("publication barrier must remain open"); + reached.forget(); + assert_eq!( + disk_permits.available_permits(), + 1, + "the producer disk permit and its gauge guard must end before publication" + ); + let clear_adapter = Arc::clone(&adapter); + let clear = tokio::spawn(async move { + clear_adapter + .clear(rustfs_object_data_cache::ObjectDataCacheInvalidationReason::Manual) + .await + }); + tokio::task::yield_now().await; + assert!(!clear.is_finished(), "clear must wait while publication owns its reservation"); + drop(leader); + tokio::time::timeout(Duration::from_secs(1), async { + while coordinator.active_session_count_for_test() != 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("last-consumer cancellation must release the session immediately"); + tokio::time::timeout(Duration::from_secs(1), clear) + .await + .expect("clear must finish after publication cancellation") + .expect("clear task must join"); + producer_task.await.expect("producer task must join"); + + barrier.release.add_permits(1); + clear_cold_fill_publication_barrier(); + drop(adapter.reserve_body(&plan).expect("publication reservation must be released")); + } + + #[tokio::test(start_paused = true)] + #[serial_test::serial(cold_fill_publication_barrier)] + async fn cold_fill_hard_deadline_releases_session_at_publication_barrier() { + let adapter = publication_test_adapter(); + let plan = publication_test_plan(&adapter, "deadline"); + let barrier = install_cold_fill_publication_barrier(&plan); + let coordinator = adapter.cold_fill_coordinator(); + let key = plan.key().cloned().expect("publication plan must be cacheable"); + let ColdFillRole::Produce(mut producer) = coordinator.join(key) else { + panic!("publication request must produce"); + }; + let leader = producer.waiter(); + let reservation = adapter.reserve_body(&plan); + let deadline = tokio::time::Instant::now() + Duration::from_millis(20); + let producer_task = tokio::spawn(start_cold_fill_producer( + producer, + reservation, + || async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) }, + || async { + Ok(GetObjectReader { + stream: Box::new(std::io::Cursor::new(Vec::::new())), + object_info: ObjectInfo { + size: 4, + actual_size: 4, + ..Default::default() + }, + buffered_body: Some(Bytes::from_static(b"body")), + body_source: GetObjectBodySource::HookMissed, + }) + }, + ColdFillProducerExecution { + expected: 4, + deadline: Some(deadline), + adapter: Arc::clone(&adapter), + engine_plan: plan.clone(), + }, + )); + + let reached = barrier.reached.acquire().await.expect("publication barrier must remain open"); + reached.forget(); + tokio::time::advance(Duration::from_millis(20)).await; + assert!(matches!( + leader.wait().await, + ColdFillWaitOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout))) + )); + assert_eq!(coordinator.active_session_count_for_test(), 0); + producer_task.await.expect("producer task must join"); + + barrier.release.add_permits(1); + clear_cold_fill_publication_barrier(); + drop( + adapter + .reserve_body(&plan) + .expect("deadline must release the publication reservation"), + ); + tokio::time::timeout( + Duration::from_secs(1), + adapter.clear(rustfs_object_data_cache::ObjectDataCacheInvalidationReason::Manual), + ) + .await + .expect("clear must complete after publication deadline"); + } + + #[tokio::test(start_paused = true)] + async fn cold_fill_without_request_timeout_stops_at_ten_minute_hard_cap() { + let adapter = publication_test_adapter(); + let plan = publication_test_plan(&adapter, "hard-cap"); + let coordinator = adapter.cold_fill_coordinator(); + let key = plan.key().cloned().expect("hard-cap plan must be cacheable"); + let ColdFillRole::Produce(mut producer) = coordinator.join(key) else { + panic!("hard-cap request must produce"); + }; + let leader = producer.waiter(); + let reservation = adapter.reserve_body(&plan); + let producer_task = tokio::spawn(start_cold_fill_producer( + producer, + reservation, + || async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) }, + || async { + Ok(GetObjectReader { + stream: Box::new(PendingReader), + object_info: ObjectInfo { + size: 4, + actual_size: 4, + ..Default::default() + }, + buffered_body: None, + body_source: GetObjectBodySource::HookMissed, + }) + }, + ColdFillProducerExecution { + expected: 4, + deadline: None, + adapter: Arc::clone(&adapter), + engine_plan: plan.clone(), + }, + )); + let wait = tokio::spawn(async move { leader.wait().await }); + + tokio::time::advance(Duration::from_secs(599)).await; + tokio::task::yield_now().await; + assert!(!wait.is_finished(), "hard cap must not fire before 600 seconds"); + assert!(adapter.reserve_body(&plan).is_none(), "reservation must remain owned before the hard cap"); + + tokio::time::advance(Duration::from_secs(1)).await; + assert!(matches!( + wait.await.expect("hard-cap waiter must join"), + ColdFillWaitOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout))) + )); + producer_task.await.expect("producer task must join"); + assert_eq!(coordinator.active_session_count_for_test(), 0); + drop( + adapter + .reserve_body(&plan) + .expect("hard cap must release the body reservation"), + ); + } + + #[tokio::test] + async fn build_get_object_body_with_cache_same_key_cold_fill_consumes_one_reader() { + const REQUESTS: usize = 2000; + const BODY_BYTES: usize = 64 * 1024; + const BODY_BYTES_U64: u64 = 64 * 1024; + const BODY_BYTES_I64: i64 = 64 * 1024; + + for key_count in [1_usize, 4, 32] { + let adapter = Arc::new( + ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 128 * 1024 * 1024, + max_memory_percent: 0, + max_entry_bytes: 1024 * 1024, + min_free_memory_percent: 0, + fill_concurrency_per_cpu: 64, + fill_concurrency_max: 64, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("matrix cache config must be valid"), + ); + let coordinator = adapter.cold_fill_coordinator(); + let disk_permits = Arc::new(tokio::sync::Semaphore::new(key_count)); + let writers = Arc::new(tokio::sync::Mutex::new(Vec::with_capacity(key_count))); + let permit_acquires = Arc::new(AtomicUsize::new(0)); + let reader_factories = Arc::new(AtomicUsize::new(0)); + let first_polls = Arc::new(AtomicUsize::new(0)); + let completed = Arc::new(AtomicUsize::new(0)); + let bytes_read = Arc::new(AtomicUsize::new(0)); + let mut tasks = tokio::task::JoinSet::new(); + + for request in 0..REQUESTS { + let key_index = request % key_count; + let object = format!("matrix-object-{key_index}"); + let engine_plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "matrix-bucket", + object: &object, + version_id: None, + etag: "matrix-etag", + size: BODY_BYTES_U64, + data_dir_u128: Some(u128::try_from(key_index).unwrap_or(u128::MAX) + 1), + mod_time_unix_nanos: 1, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + let cache_key = engine_plan.key().cloned().expect("matrix body must be cacheable"); + let adapter = Arc::clone(&adapter); + let coordinator = Arc::clone(&coordinator); + let disk_permits = Arc::clone(&disk_permits); + let writers = Arc::clone(&writers); + let permit_acquires = Arc::clone(&permit_acquires); + let reader_factories = Arc::clone(&reader_factories); + let first_polls = Arc::clone(&first_polls); + let completed = Arc::clone(&completed); + let bytes_read = Arc::clone(&bytes_read); + tasks.spawn(async move { + let outcome = coordinate_cold_fill(&coordinator, cache_key, None, None, move |producer| { + let reservation = adapter.reserve_body(&engine_plan); + let adapter = Arc::clone(&adapter); + let disk_permits = Arc::clone(&disk_permits); + let writers = Arc::clone(&writers); + let permit_acquires = Arc::clone(&permit_acquires); + let reader_factories = Arc::clone(&reader_factories); + let first_polls = Arc::clone(&first_polls); + let completed = Arc::clone(&completed); + let bytes_read = Arc::clone(&bytes_read); + let fill_plan = engine_plan.clone(); + async move { + start_cold_fill_producer( + producer, + reservation, + || async move { + permit_acquires.fetch_add(1, AtomicOrdering::Relaxed); + let permit = disk_permits + .acquire_owned() + .await + .map_err(|_| ColdFillError::DiskAdmissionClosed)?; + let mut io = + DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager()); + io.disk_permit = Some(permit.into()); + Ok(io) + }, + || async move { + reader_factories.fetch_add(1, AtomicOrdering::Relaxed); + let (writer, reader) = tokio::io::duplex(BODY_BYTES * 2); + writers.lock().await.push(writer); + Ok(GetObjectReader { + stream: Box::new(ColdFillMatrixReader { + inner: reader, + first_poll_recorded: false, + completion_recorded: false, + first_polls, + completed, + bytes_read, + }), + object_info: ObjectInfo { + size: BODY_BYTES_I64, + actual_size: BODY_BYTES_I64, + ..Default::default() + }, + buffered_body: None, + body_source: GetObjectBodySource::HookMissed, + }) + }, + ColdFillProducerExecution { + expected: BODY_BYTES, + deadline: None, + adapter, + engine_plan: fill_plan, + }, + ) + .await + } + }) + .await; + let ColdFillCoordinateOutcome::Ready(Ok(body)) = outcome else { + panic!("matrix request must receive the shared body, got {outcome:?}"); + }; + assert_eq!(body.len(), BODY_BYTES); + assert!(body.iter().all(|byte| *byte == 7)); + (key_index, body.as_ptr() as usize) + }); + } + + tokio::time::timeout(Duration::from_secs(30), async { + loop { + if writers.lock().await.len() == key_count + && coordinator.global_waiter_count_for_test() == REQUESTS - key_count + && first_polls.load(AtomicOrdering::Relaxed) == key_count + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("all matrix followers must join before releasing bodies"); + + let mut body_writers = std::mem::take(&mut *writers.lock().await); + let body = vec![7_u8; BODY_BYTES]; + for writer in &mut body_writers { + tokio::io::AsyncWriteExt::write_all(writer, &body) + .await + .expect("matrix body write must succeed"); + tokio::io::AsyncWriteExt::shutdown(writer) + .await + .expect("matrix body writer must close"); + } + let mut backing_pointers = std::collections::HashMap::>::new(); + tokio::time::timeout(Duration::from_secs(30), async { + while let Some(result) = tasks.join_next().await { + let (key_index, body_pointer) = result.expect("matrix GET task must complete"); + backing_pointers.entry(key_index).or_default().insert(body_pointer); + } + }) + .await + .expect("matrix GET tasks must complete before the watchdog"); + + assert_eq!(permit_acquires.load(AtomicOrdering::Relaxed), key_count); + assert_eq!(reader_factories.load(AtomicOrdering::Relaxed), key_count); + assert_eq!(first_polls.load(AtomicOrdering::Relaxed), key_count); + assert_eq!(completed.load(AtomicOrdering::Relaxed), key_count); + assert_eq!(bytes_read.load(AtomicOrdering::Relaxed), key_count * BODY_BYTES); + assert_eq!(backing_pointers.len(), key_count); + assert!( + backing_pointers.values().all(|pointers| pointers.len() == 1), + "all followers of one key must share one backing allocation" + ); + assert_eq!( + backing_pointers + .values() + .flatten() + .copied() + .collect::>() + .len(), + key_count + ); + assert_eq!(coordinator.global_waiter_count_for_test(), 0); + assert_eq!(coordinator.active_session_count_for_test(), 0); + assert_eq!(disk_permits.available_permits(), key_count); + + for key_index in 0..key_count { + let object = format!("matrix-object-{key_index}"); + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "matrix-bucket", + object: &object, + version_id: None, + etag: "matrix-etag", + size: BODY_BYTES_U64, + data_dir_u128: Some(u128::try_from(key_index).unwrap_or(u128::MAX) + 1), + mod_time_unix_nanos: 1, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + assert!(matches!( + adapter.lookup_body(&plan).await, + rustfs_object_data_cache::ObjectDataCacheLookup::Hit(_) + )); + } + } + } + + // #1324: the in-memory (buffered/cache) source is guarded by + // MemoryTrackedBytesStream. A buffer whose length disagrees with the declared + // content length must yield a stream error on first poll instead of a clean + // short body or an over-long body. Reverting to the old warn-and-serve + // behavior would make these assertions observe Ok chunks. + #[tokio::test] + #[serial_test::serial] + async fn memory_tracked_bytes_stream_fails_short_body() { + let mut stream = MemoryTrackedBytesStream::new( + Bytes::from_static(b"test"), + 5, + GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, + None, + GetObjectBodyLifecycle::disabled(), + ); + let err = stream + .next() + .await + .expect("mismatched memory body must yield an item") + .expect_err("a short memory body must fail the stream instead of serving a truncated body"); + assert_eq!( + err.downcast_ref::().map(std::io::Error::kind), + Some(std::io::ErrorKind::InvalidData) + ); + assert!(stream.next().await.is_none(), "stream must terminate after the error"); + } + + #[tokio::test] + #[serial_test::serial] + async fn memory_tracked_bytes_stream_fails_over_long_body() { + let mut stream = MemoryTrackedBytesStream::new( + Bytes::from_static(b"hello!"), + 5, + GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, + None, + GetObjectBodyLifecycle::disabled(), + ); + let err = stream + .next() + .await + .expect("mismatched memory body must yield an item") + .expect_err("an over-long memory body must fail the stream instead of serving mismatched bytes"); + assert_eq!( + err.downcast_ref::().map(std::io::Error::kind), + Some(std::io::ErrorKind::InvalidData) + ); + } + + #[test] + fn memory_blob_preserves_exact_remaining_length() { + let blob = DefaultObjectUsecase::build_memory_bytes_blob( + Bytes::from_static(b"hello"), + 5, + GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, + GetObjectBodyLifecycle::disabled(), + ); + + assert_eq!(blob.remaining_length().exact(), Some(5)); + } + + #[test] + #[serial_test::serial] + fn memory_blob_once_fast_path_holds_guard_until_bytes_drop() { + temp_env::with_var(ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE, Some("true"), || { + let initial = GetObjectGuard::concurrent_count(); + let guard = GetObjectGuard::new(); + assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); + + let blob = DefaultObjectUsecase::build_memory_bytes_blob( + Bytes::from_static(b"hello"), + 5, + GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, + GetObjectBodyLifecycle::tracked(guard), + ); + let mut body = s3s::Body::from(blob); + let bytes = body.take_bytes().expect("opt-in exact memory body should stay on Body::Once"); + + assert_eq!(bytes, Bytes::from_static(b"hello")); + assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); + drop(bytes); + assert_eq!(GetObjectGuard::concurrent_count(), initial); + }); + } + + #[test] + #[serial_test::serial] + fn memory_blob_once_fast_path_rejects_length_mismatch() { + temp_env::with_var(ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE, Some("true"), || { + let blob = DefaultObjectUsecase::build_memory_bytes_blob( + Bytes::from_static(b"test"), + 5, + GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, + GetObjectBodyLifecycle::disabled(), + ); + let mut body = s3s::Body::from(blob); + + assert!(body.take_bytes().is_none(), "mismatched memory body must keep the guarded stream path"); + }); + } + + #[tokio::test] + async fn get_object_streaming_reader_times_out_when_body_stalls() { + let reader = GetObjectStreamingReader::new( + PendingReader, + "test-bucket", + "stalled-object", + "req-stalled-stream", + None, + 1, + Duration::from_millis(1), + GetObjectBodyLifecycle::disabled(), + None, + ); + let mut stream = ReaderStream::with_capacity(reader, 1024); + + let err = stream + .next() + .await + .expect("reader stream should yield timeout") + .expect_err("stalled reader should return an error"); + + assert_eq!(err.kind(), std::io::ErrorKind::TimedOut); + } + + #[tokio::test] + async fn get_object_streaming_reader_fails_closed_without_active_reader() { + use tokio::io::AsyncReadExt; + + let mut reader = GetObjectStreamingReader::new( + cursor_reader(b"x"), + "test-bucket", + "missing-reader-object", + "req-missing-reader", + None, + 1, + Duration::ZERO, + GetObjectBodyLifecycle::disabled(), + None, + ); + reader.inner.take(); + + let err = reader + .read_to_end(&mut Vec::new()) + .await + .expect_err("an impossible missing active reader must fail closed"); + + assert_eq!(err.kind(), std::io::ErrorKind::Other); + assert_eq!(err.to_string(), "get object streaming reader lost its active read outside resume"); + } + + #[tokio::test] + #[serial_test::serial] + async fn get_object_streaming_reader_holds_request_guard_until_eof() { + use tokio::io::AsyncReadExt; + + let initial = GetObjectGuard::concurrent_count(); + let guard = GetObjectGuard::new(); + assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); + + let mut reader = GetObjectStreamingReader::new( + std::io::Cursor::new(b"hello".to_vec()), + "test-bucket", + "complete-object", + "req-complete-stream", + None, + 5, + Duration::ZERO, + GetObjectBodyLifecycle::tracked(guard), + None, + ); + let mut out = Vec::new(); + + reader + .read_to_end(&mut out) + .await + .expect("complete streaming body should read successfully"); + + assert_eq!(out, b"hello"); + assert_eq!(GetObjectGuard::concurrent_count(), initial); + } + + #[tokio::test] + #[serial_test::serial] + async fn get_object_streaming_reader_errors_on_short_eof() { + use tokio::io::AsyncReadExt; + + // The inner reader delivers 5 bytes then a clean EOF, but the advertised + // Content-Length is 10. The reader must surface an error rather than a clean EOF, so + // the client sees a failed transfer instead of silently persisting a truncated body + // (the "incomplete data mirroring" of #2955). + let initial = GetObjectGuard::concurrent_count(); + let guard = GetObjectGuard::new(); + assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); + + let mut reader = GetObjectStreamingReader::new( + std::io::Cursor::new(b"short".to_vec()), + "test-bucket", + "truncated-object", + "req-short-eof", + None, + 10, + Duration::ZERO, + GetObjectBodyLifecycle::tracked(guard), + None, + ); + let mut out = Vec::new(); + let err = reader + .read_to_end(&mut out) + .await + .expect_err("short body under a larger Content-Length must fail the stream"); + assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof); + let incomplete_body = err + .get_ref() + .and_then(|inner| inner.downcast_ref::()) + .expect("short eof should include remaining bytes as IncompleteBody"); + assert_eq!(incomplete_body.remaining, 5); + assert_eq!(out, b"short", "bytes read before the short EOF are still delivered"); + + drop(reader); + assert_eq!(GetObjectGuard::concurrent_count(), initial); + } + + #[test] + #[serial_test::serial] + fn get_object_streaming_reader_releases_request_guard_when_dropped_incomplete() { + let initial = GetObjectGuard::concurrent_count(); + let guard = GetObjectGuard::new(); + assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); + + let reader = GetObjectStreamingReader::new( + std::io::Cursor::new(b"short".to_vec()), + "test-bucket", + "dropped-object", + "req-dropped-stream", + None, + 10, + Duration::ZERO, + GetObjectBodyLifecycle::tracked(guard), + None, + ); + drop(reader); + + assert_eq!(GetObjectGuard::concurrent_count(), initial); + } + + // Emits all of `data`, then either the injected error or a clean EOF. Drives + // the mid-stream resume state machine through its typed-error and + // premature-EOF triggers without a store. + struct FailAtEndReader { + data: std::io::Cursor>, + error: Option, + } + + impl FailAtEndReader { + fn new(data: &[u8], error: Option) -> Self { + Self { + data: std::io::Cursor::new(data.to_vec()), + error, + } + } + } + + impl AsyncRead for FailAtEndReader { + fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + let position = usize::try_from(self.data.position()).unwrap_or(usize::MAX); + let source_len = self.data.get_ref().len(); + if position >= source_len { + return match self.error.take() { + Some(error) => Poll::Ready(Err(error)), + None => Poll::Ready(Ok(())), + }; + } + let want = buf.remaining().min(source_len - position); + if want == 0 { + return Poll::Ready(Ok(())); + } + buf.put_slice(&self.data.get_ref()[position..position + want]); + self.data.set_position(u64::try_from(position + want).unwrap_or(u64::MAX)); + Poll::Ready(Ok(())) + } + } + + fn relocation_read_error() -> std::io::Error { + std::io::Error::other(StorageError::FileNotFound) + } + + fn counting_resume_control( + reopen_count: Arc, + mut reopen: impl FnMut(usize) -> Result + Send + Sync + 'static, + ) -> GetObjectResumeControl { + let reopen: GetObjectReopen = Box::new(move |emitted| { + reopen_count.fetch_add(1, Ordering::Relaxed); + let outcome = reopen(emitted); + Box::pin(async move { outcome }) + }); + GetObjectResumeControl::new( + reopen, + RetryTimer::new( + GET_OBJECT_RESUME_MAX_ATTEMPTS, + Duration::from_millis(1), + Duration::from_millis(2), + rustfs_utils::retry::NO_JITTER, + 0, + ), + ) + } + + #[tokio::test] + async fn get_object_streaming_reader_resumes_after_relocation_error() { + use tokio::io::AsyncReadExt; + + // Every typed relocation variant the codec read path can surface + // mid-body must arm the resume flow. + for variant in [ + StorageError::FileNotFound, + StorageError::ObjectNotFound("test-bucket".to_string(), "relocated-object".to_string()), + StorageError::InsufficientReadQuorum("test-bucket".to_string(), "relocated-object".to_string()), + StorageError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "relocated shard disappeared")), + ] { + let reopen_count = Arc::new(AtomicUsize::new(0)); + let control = counting_resume_control(Arc::clone(&reopen_count), |emitted| { + assert_eq!(emitted, 6, "resume must reopen at the emitted offset"); + Ok(FailAtEndReader::new(b"world", None)) + }); + let mut reader = GetObjectStreamingReader::new( + FailAtEndReader::new(b"hello ", Some(std::io::Error::other(variant))), + "test-bucket", + "relocated-object", + "req-resume-typed-error", + None, + 11, + Duration::ZERO, + GetObjectBodyLifecycle::disabled(), + Some(control), + ); + let mut out = Vec::new(); + reader + .read_to_end(&mut out) + .await + .expect("a resumed body must deliver the full committed content"); + + assert_eq!(out, b"hello world"); + assert_eq!(reopen_count.load(Ordering::Relaxed), 1); + } + } + + #[tokio::test(start_paused = true)] + async fn get_object_streaming_reader_releases_failed_disk_permit_before_reopen() { + use tokio::io::AsyncReadExt; + + let manager = Arc::new(ConcurrencyManager::with_disk_read_caps_for_test(1, 0)); + let initial_permit = match manager + .admit_disk_read(Duration::ZERO) + .await + .expect("test disk admission must remain open") + { + DiskReadAdmission::Primary(permit) => permit, + other => panic!("initial read must hold the only primary permit, got {other:?}"), + }; + let reopen_count = Arc::new(AtomicUsize::new(0)); + let reopen: GetObjectReopen> = Box::new({ + let manager = Arc::clone(&manager); + let reopen_count = Arc::clone(&reopen_count); + move |emitted| { + assert_eq!(emitted, 6, "resume must reopen at the emitted offset"); + reopen_count.fetch_add(1, Ordering::Relaxed); + let manager = Arc::clone(&manager); + Box::pin(async move { + match manager + .admit_disk_read(Duration::from_millis(1)) + .await + .map_err(|_| GetObjectResumeFailure::Fatal)? + { + DiskReadAdmission::Primary(permit) => { + Ok(DiskReadPermitReader::new(FailAtEndReader::new(b"world", None), permit.into())) + } + _ => Err(GetObjectResumeFailure::Retryable), + } + }) + } + }); + let control = GetObjectResumeControl::new( + reopen, + RetryTimer::new( + GET_OBJECT_RESUME_MAX_ATTEMPTS, + Duration::from_millis(1), + Duration::from_millis(2), + rustfs_utils::retry::NO_JITTER, + 0, + ), + ); + let initial_reader = + DiskReadPermitReader::new(FailAtEndReader::new(b"hello ", Some(relocation_read_error())), initial_permit.into()); + let mut reader = GetObjectStreamingReader::new( + initial_reader, + "test-bucket", + "relocated-object", + "req-resume-single-permit", + None, + 11, + Duration::ZERO, + GetObjectBodyLifecycle::disabled(), + Some(control), + ); + let mut out = Vec::new(); + + reader + .read_to_end(&mut out) + .await + .expect("resume must not wait on the failed reader's permit"); + + assert_eq!(out, b"hello world"); + assert_eq!(reopen_count.load(Ordering::Relaxed), 1); + assert_eq!( + manager.io_queue_status().permits_in_use, + 0, + "the replacement reader must release its permit at EOF" + ); + } + + #[tokio::test] + async fn get_object_streaming_reader_resumes_after_premature_eof() { + use tokio::io::AsyncReadExt; + + // The legacy duplex read path surfaces vanished object data as a clean + // EOF before the committed length; the resume flow must treat it like + // the typed relocation error. + let reopen_count = Arc::new(AtomicUsize::new(0)); + let control = counting_resume_control(Arc::clone(&reopen_count), |emitted| { + assert_eq!(emitted, 6, "resume must reopen at the emitted offset"); + Ok(FailAtEndReader::new(b"world", None)) + }); + let mut reader = GetObjectStreamingReader::new( + FailAtEndReader::new(b"hello ", None), + "test-bucket", + "truncated-object", + "req-resume-short-eof", + None, + 11, + Duration::ZERO, + GetObjectBodyLifecycle::disabled(), + Some(control), + ); + let mut out = Vec::new(); + reader + .read_to_end(&mut out) + .await + .expect("a resumed body must deliver the full committed content"); + + assert_eq!(out, b"hello world"); + assert_eq!(reopen_count.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn get_object_streaming_reader_clean_eof_does_not_resume() { + use tokio::io::AsyncReadExt; + + let reopen_count = Arc::new(AtomicUsize::new(0)); + let control = counting_resume_control(Arc::clone(&reopen_count), |_| { + panic!("a cleanly completed body must never reopen"); + }); + let mut reader = GetObjectStreamingReader::new( + FailAtEndReader::new(b"hello world", None), + "test-bucket", + "complete-object", + "req-resume-clean-eof", + None, + 11, + Duration::ZERO, + GetObjectBodyLifecycle::disabled(), + Some(control), + ); + let mut out = Vec::new(); + reader.read_to_end(&mut out).await.expect("complete body must read"); + + assert_eq!(out, b"hello world"); + assert_eq!(reopen_count.load(Ordering::Relaxed), 0); + } + + #[tokio::test] + async fn get_object_streaming_reader_fatal_resume_failure_returns_original_error() { + use tokio::io::AsyncReadExt; + + // A fatal reopen failure (the reopened object is a different version) + // must surface the original trigger error after exactly one attempt, + // with only the originally emitted prefix delivered. + let reopen_count = Arc::new(AtomicUsize::new(0)); + let control = counting_resume_control(Arc::clone(&reopen_count), |_| Err(GetObjectResumeFailure::Fatal)); + let mut reader = GetObjectStreamingReader::new( + FailAtEndReader::new(b"hello ", Some(relocation_read_error())), + "test-bucket", + "replaced-object", + "req-resume-fatal", + None, + 11, + Duration::ZERO, + GetObjectBodyLifecycle::disabled(), + Some(control), + ); + let mut out = Vec::new(); + let err = reader + .read_to_end(&mut out) + .await + .expect_err("a fatal resume failure must fail the body with the original error"); + + assert!( + err.get_ref().is_some_and(|inner| inner.is::()), + "the surfaced error must be the original typed trigger, got: {err}" + ); + assert_eq!(out, b"hello "); + assert_eq!( + reopen_count.load(Ordering::Relaxed), + 1, + "a fatal failure must short-circuit the retry budget" + ); + } + + #[tokio::test] + async fn get_object_streaming_reader_exhausts_resume_budget() { + use tokio::io::AsyncReadExt; + + let reopen_count = Arc::new(AtomicUsize::new(0)); + let control = counting_resume_control(Arc::clone(&reopen_count), |_| Err(GetObjectResumeFailure::Retryable)); + let mut reader = GetObjectStreamingReader::new( + FailAtEndReader::new(b"hello ", Some(relocation_read_error())), + "test-bucket", + "vanished-object", + "req-resume-budget", + None, + 11, + Duration::ZERO, + GetObjectBodyLifecycle::disabled(), + Some(control), + ); + let mut out = Vec::new(); + let err = reader + .read_to_end(&mut out) + .await + .expect_err("an exhausted resume budget must fail the body with the original error"); + + assert!( + err.get_ref().is_some_and(|inner| inner.is::()), + "the surfaced error must be the original typed trigger, got: {err}" + ); + assert_eq!(out, b"hello "); + assert_eq!( + reopen_count.load(Ordering::Relaxed), + usize::try_from(GET_OBJECT_RESUME_MAX_ATTEMPTS).expect("resume budget fits usize"), + "resume must stop after its reopen budget" + ); + } + + #[tokio::test] + async fn get_object_streaming_reader_rearms_resume_after_a_successful_resume() { + use tokio::io::AsyncReadExt; + + // A successful resume restores the armed state: a second mid-stream + // relocation error on the replacement stream must resume again. + let reopen_count = Arc::new(AtomicUsize::new(0)); + let control = counting_resume_control(Arc::clone(&reopen_count), |emitted| match emitted { + 6 => Ok(FailAtEndReader::new(b"wo", Some(relocation_read_error()))), + 8 => Ok(FailAtEndReader::new(b"rld", None)), + other => panic!("unexpected reopen offset {other}"), + }); + let mut reader = GetObjectStreamingReader::new( + FailAtEndReader::new(b"hello ", Some(relocation_read_error())), + "test-bucket", + "twice-relocated-object", + "req-resume-rearm", + None, + 11, + Duration::ZERO, + GetObjectBodyLifecycle::disabled(), + Some(control), + ); + let mut out = Vec::new(); + reader + .read_to_end(&mut out) + .await + .expect("a re-armed resume must deliver the full committed content"); + + assert_eq!(out, b"hello world"); + assert_eq!(reopen_count.load(Ordering::Relaxed), 2); + } + + #[tokio::test] + async fn get_object_streaming_reader_resume_budget_is_per_body() { + use tokio::io::AsyncReadExt; + + // The retry budget is consumed across the whole body, not reset per + // error: one successful resume plus two failed reopens exhausts it. + let reopen_count = Arc::new(AtomicUsize::new(0)); + let control = counting_resume_control(Arc::clone(&reopen_count), |emitted| match emitted { + 6 => Ok(FailAtEndReader::new(b"wo", Some(relocation_read_error()))), + _ => Err(GetObjectResumeFailure::Retryable), + }); + let mut reader = GetObjectStreamingReader::new( + FailAtEndReader::new(b"hello ", Some(relocation_read_error())), + "test-bucket", + "budget-shared-object", + "req-resume-budget-per-body", + None, + 11, + Duration::ZERO, + GetObjectBodyLifecycle::disabled(), + Some(control), + ); + let mut out = Vec::new(); + let err = reader + .read_to_end(&mut out) + .await + .expect_err("the shared budget must exhaust and surface the latest trigger error"); + + assert!( + err.get_ref().is_some_and(|inner| inner.is::()), + "the surfaced error must be the typed trigger, got: {err}" + ); + assert_eq!(out, b"hello wo"); + assert_eq!( + reopen_count.load(Ordering::Relaxed), + usize::try_from(GET_OBJECT_RESUME_MAX_ATTEMPTS).expect("resume budget fits usize"), + "the budget spans every resume of the same body" + ); + } + + #[tokio::test] + async fn get_object_streaming_reader_non_relocation_error_passes_through() { + use tokio::io::AsyncReadExt; + + let reopen_count = Arc::new(AtomicUsize::new(0)); + let control = counting_resume_control(Arc::clone(&reopen_count), |_| { + panic!("a non-relocation read error must not reopen"); + }); + let mut reader = GetObjectStreamingReader::new( + FailAtEndReader::new(b"hello ", Some(std::io::Error::new(std::io::ErrorKind::InvalidData, "corrupt"))), + "test-bucket", + "corrupt-object", + "req-resume-passthrough", + None, + 11, + Duration::ZERO, + GetObjectBodyLifecycle::disabled(), + Some(control), + ); + let mut out = Vec::new(); + let err = reader + .read_to_end(&mut out) + .await + .expect_err("a non-relocation error must fail the body unchanged"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + assert_eq!(out, b"hello "); + assert_eq!(reopen_count.load(Ordering::Relaxed), 0); + } + + #[tokio::test] + async fn get_object_streaming_reader_error_after_full_delivery_does_not_resume() { + use tokio::io::AsyncReadExt; + + // The committed length is already delivered when the inner stream + // errors, so the error must keep the existing fail-loud behavior + // instead of arming a resume. + let reopen_count = Arc::new(AtomicUsize::new(0)); + let control = counting_resume_control(Arc::clone(&reopen_count), |_| { + panic!("an error after full delivery must not reopen"); + }); + let mut reader = GetObjectStreamingReader::new( + FailAtEndReader::new(b"hello world", Some(relocation_read_error())), + "test-bucket", + "fully-delivered-object", + "req-resume-after-full", + None, + 11, + Duration::ZERO, + GetObjectBodyLifecycle::disabled(), + Some(control), + ); + let mut out = Vec::new(); + let err = reader + .read_to_end(&mut out) + .await + .expect_err("a post-completion inner error still surfaces instead of being swallowed"); + + assert!( + err.get_ref().is_some_and(|inner| inner.is::()), + "the surfaced error must be the inner typed error, got: {err}" + ); + assert_eq!(out, b"hello world"); + assert_eq!(reopen_count.load(Ordering::Relaxed), 0); + } + + #[test] + fn get_object_resume_identity_requires_same_version() { + let version_id = Uuid::from_u128(0x1234); + let mod_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp"); + let later_mod_time = OffsetDateTime::from_unix_timestamp(1_700_000_100).expect("valid timestamp"); + let identity = GetObjectResumeIdentity { + version_id: Some(version_id), + mod_time: Some(mod_time), + size: 11, + etag: Some("etag-a".to_string()), + range_dependent_size: false, + }; + let info = ObjectInfo { + version_id: Some(version_id), + mod_time: Some(mod_time), + size: 11, + etag: Some("etag-a".to_string()), + ..Default::default() + }; + assert!(identity.matches(&info, 0)); + assert!(identity.matches(&info, 6), "a plain read reports the range-invariant oi.size"); + // Rebalance regenerates data_dir for the same version: identity must + // still match so a relocated read can resume. + assert!(identity.matches( + &ObjectInfo { + data_dir: Some(Uuid::from_u128(0xbeef)), + ..info.clone() + }, + 0 + )); + assert!(!identity.matches( + &ObjectInfo { + version_id: Some(Uuid::from_u128(0x5678)), + ..info.clone() + }, + 0 + )); + assert!(!identity.matches( + &ObjectInfo { + version_id: None, + ..info.clone() + }, + 0 + )); + assert!(!identity.matches( + &ObjectInfo { + mod_time: Some(later_mod_time), + ..info.clone() + }, + 0 + )); + assert!(!identity.matches( + &ObjectInfo { + size: 12, + ..info.clone() + }, + 0 + )); + assert!(!identity.matches( + &ObjectInfo { + etag: Some("etag-b".to_string()), + ..info.clone() + }, + 0 + )); + assert!(!identity.matches(&ObjectInfo { etag: None, ..info }, 0)); + } + + #[test] + fn get_object_resume_identity_normalizes_range_dependent_size() { + // Encrypted and compressed reads report the per-read delivered length + // as object_info.size, so the reopened subrange reports size - emitted + // for the same version. + let version_id = Uuid::from_u128(0x1234); + let mod_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp"); + let identity = GetObjectResumeIdentity { + version_id: Some(version_id), + mod_time: Some(mod_time), + size: 11, + etag: Some("etag-a".to_string()), + range_dependent_size: true, + }; + let reopened = ObjectInfo { + version_id: Some(version_id), + mod_time: Some(mod_time), + size: 5, + etag: Some("etag-a".to_string()), + ..Default::default() + }; + assert!(identity.matches(&reopened, 6), "the reopened subrange reports size - emitted"); + assert!(identity.matches( + &ObjectInfo { + size: 11, + ..reopened.clone() + }, + 0 + )); + assert!( + !identity.matches( + &ObjectInfo { + size: 11, + ..reopened.clone() + }, + 6 + ), + "an unshrunk range-dependent size after emitted bytes is a different object" + ); + assert!(!identity.matches(&ObjectInfo { size: 4, ..reopened }, 6)); + } + + #[test] + fn get_object_resume_range_offsets() { + // A full-object read that emitted nothing reopens range-free so the + // replacement stream keeps the codec fast path. + assert!(GetObjectResumeContext::resume_range(0, -1, 0).is_none()); + + // Mid-stream full-object resume: open-ended from the emitted offset. + let range = GetObjectResumeContext::resume_range(0, -1, 6).expect("a mid-stream resume must carry a range"); + assert!(!range.is_suffix_length); + assert_eq!((range.start, range.end), (6, -1)); + + // Ranged reads resume at absolute offsets with the committed end + // preserved (suffix ranges and partNumber GETs are resolved to absolute + // offsets before these values are captured). + let range = GetObjectResumeContext::resume_range(10, 19, 0).expect("a ranged resume must carry a range"); + assert!(!range.is_suffix_length); + assert_eq!((range.start, range.end), (10, 19)); + let range = GetObjectResumeContext::resume_range(10, 19, 5).expect("a ranged resume must carry a range"); + assert_eq!((range.start, range.end), (15, 19)); + } + + async fn real_get_resume_test_context() -> (Vec, Arc, Arc) { + let (disk_paths, store) = crate::app::gating_test_env::shared_gating_ecstore_and_disk_paths().await; + if current_app_context().is_none() { + crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; + } + let ambient = current_app_context().expect("resume wiring tests require an ambient AppContext"); + let context = Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms())); + (disk_paths, store, context) + } + + // Uploads a real multipart object through the store and returns the + // concatenated body, so resume wiring tests can verify byte-exact delivery + // against on-disk part files. + async fn put_real_multipart_object( + store: &Arc, + bucket: &str, + object: &str, + part_size: usize, + part_count: usize, + fill: u8, + ) -> Vec { + use crate::app::storage_api::multipart_usecase::contract::multipart::{CompletePart, MultipartOperations as _}; + + let upload = store + .new_multipart_upload(bucket, object, &ObjectOptions::default()) + .await + .expect("create multipart upload"); + let mut parts = Vec::new(); + let mut body = Vec::with_capacity(part_size * part_count); + for part_id in 1..=part_count { + let part_fill = fill.wrapping_add(u8::try_from(part_id - 1).expect("test part index must fit u8")); + let part_body = vec![part_fill; part_size]; + body.extend_from_slice(&part_body); + let mut reader = PutObjReader::from_vec(part_body); + let part = store + .put_object_part(bucket, object, &upload.upload_id, part_id, &mut reader, &ObjectOptions::default()) + .await + .expect("upload multipart part"); + parts.push(CompletePart { + part_num: part_id, + etag: part.etag, + ..Default::default() + }); + } + store + .clone() + .complete_multipart_upload(bucket, object, &upload.upload_id, parts, &ObjectOptions::default()) + .await + .expect("complete multipart upload"); + body + } + + // An erasure-coded write returns once write-quorum disks commit, so a + // lagging disk can legally still be missing its xl.meta when the write + // call returns. Fixtures that iterate every disk of the owning pool must + // wait for full materialization first, or they race the trailing disk + // writes under CI load (issue #6703). Bounded so a genuinely failed disk + // write still surfaces as a test failure instead of a hang. + async fn wait_for_object_on_every_disk(disk_paths: &[std::path::PathBuf], bucket: &str, object: &str) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + if disk_paths + .iter() + .all(|path| path.join(bucket).join(object).join("xl.meta").is_file()) + { + return; + } + assert!( + std::time::Instant::now() < deadline, + "object {bucket}/{object} must materialize xl.meta on every pool disk within the readiness window" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + } + + // Deletes the given part files from every version data dir present on the + // disks, simulating rebalance removing the object data while xl.meta stays + // readable. Returns the number of version dirs visited and files removed. + fn delete_object_part_shards( + disk_paths: &[std::path::PathBuf], + bucket: &str, + object: &str, + part_numbers: &[usize], + ) -> (usize, usize) { + let mut version_dirs = 0; + let mut deleted = 0; + for disk_path in disk_paths { + let object_dir = disk_path.join(bucket).join(object); + let entries = match std::fs::read_dir(&object_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => panic!("object directory must be readable: {error}"), + }; + for entry in entries { + let entry = entry.expect("object directory entry must read"); + if !entry.file_type().expect("entry file type must read").is_dir() { + continue; + } + version_dirs += 1; + for part_number in part_numbers { + let part_file = entry.path().join(format!("part.{part_number}")); + if part_file.exists() { + std::fs::remove_file(&part_file).expect("part shard must be removable"); + deleted += 1; + } + } + } + } + (version_dirs, deleted) + } + + // The surfaced mid-stream failure must be the original trigger: a typed + // relocation StorageError from the codec read path, or an IncompleteBody + // (UnexpectedEof) from the duplex path. The resume flow must never + // fabricate a different error. + fn assert_original_trigger_error(error: &(dyn std::error::Error + Send + Sync + 'static)) { + let Some(io_error) = error.downcast_ref::() else { + panic!("body error must be an io::Error, got: {error}"); + }; + let is_trigger = io_error.kind() == std::io::ErrorKind::UnexpectedEof || is_object_relocation_error(io_error); + assert!(is_trigger, "body error must be the original relocation trigger, got: {error}"); + } + + #[tokio::test] + #[serial_test::serial] + // SAFETY: the test mutates one process env var before any use; nextest runs + // each test in its own process, so the mutation cannot race another test. + #[allow(unsafe_code)] + async fn execute_get_object_resume_exhausts_budget_when_object_data_vanishes() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; + + // The resume phase runs inside the body stall budget (default 10s), + // and three real reopen attempts against missing shards approach it on + // loaded CI disks; widen the budget so this test asserts the resume + // outcome instead of racing the stall timer. + unsafe { std::env::set_var(rustfs_config::ENV_OBJECT_DISK_READ_TIMEOUT, "120") }; + + let (disk_paths, store, context) = real_get_resume_test_context().await; + let bucket = format!("resume-vanish-{}", Uuid::new_v4()); + let object = "object.bin"; + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("create resume failure-path bucket"); + let part_size = 6 * 1024 * 1024; + let body = put_real_multipart_object(&store, &bucket, object, part_size, 3, 0xAA).await; + + // Remove the part.2/part.3 shards on every disk before the GET starts, + // so no file descriptor for them can exist: the stream must fail at the + // part-2 boundary, and every reopen resolves intact metadata whose data + // is gone, so the whole resume budget burns down. + let (version_dirs, deleted) = delete_object_part_shards(&disk_paths, &bucket, object, &[2, 3]); + assert!(version_dirs > 0, "the multipart object must have at least one version data directory"); + assert_eq!(deleted, version_dirs * 2); + + let input = GetObjectInput::builder() + .bucket(bucket) + .key(object.to_string()) + .build() + .expect("resume failure-path GET input must build"); + let usecase = DefaultObjectUsecase::with_context(Some(context)); + let attempts_before = GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed); + let mut response = usecase + .execute_get_object(build_request(input, Method::GET)) + .await + .expect("the GET commits a response; the body fails mid-stream"); + let mut response_body = response.output.body.take().expect("GET response must include a body"); + let mut collected = Vec::new(); + let mut stream_error = None; + while let Some(chunk) = response_body.next().await { + match chunk { + Ok(bytes) => collected.extend_from_slice(&bytes), + Err(error) => { + stream_error = Some(error); + break; + } + } + } + + assert_eq!( + collected, + &body[..part_size], + "only the first part can be delivered before the object data vanishes" + ); + assert_original_trigger_error( + stream_error + .as_deref() + .expect("the body stream must fail at the missing part"), + ); + let attempts = GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed) - attempts_before; + assert_eq!( + attempts, + usize::try_from(GET_OBJECT_RESUME_MAX_ATTEMPTS).expect("resume budget fits usize"), + "resume must exhaust its reopen budget before failing" + ); + } + + #[tokio::test] + #[serial_test::serial] + async fn execute_get_object_resumes_from_relocated_pool_without_splicing_body() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; + + let (temp_dir, pool_disk_paths, store) = crate::app::gating_test_env::isolated_multi_pool_ecstore().await; + if current_app_context().is_none() { + crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; + } + let ambient = current_app_context().expect("multi-pool resume test requires an ambient AppContext"); + let context = Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms())); + let bucket = format!("resume-relocate-{}", Uuid::new_v4()); + let object = "object.bin"; + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("create multi-pool resume bucket"); + let part_size = 24 * 1024 * 1024; + let body = put_real_multipart_object(&store, &bucket, object, part_size, 3, 0xA5).await; + let upload_pool = pool_disk_paths + .iter() + .position(|paths| { + paths + .iter() + .any(|path| path.join(&bucket).join(object).join("xl.meta").is_file()) + }) + .expect("multipart object must be placed in one source pool"); + wait_for_object_on_every_disk(&pool_disk_paths[upload_pool], &bucket, object).await; + if upload_pool != 0 { + let mut normalized_disks = 0; + for (source_disk, target_disk) in pool_disk_paths[upload_pool].iter().zip(&pool_disk_paths[0]) { + let source_object = source_disk.join(&bucket).join(object); + // The multipart commit succeeds on write quorum, so under suite + // IO load a lagging disk of the erasure set can legitimately + // hold no object directory (#6701). Normalize the disks that + // do hold it; the reader tolerates the same minority gap. + if !source_object.exists() { + continue; + } + let target_bucket = target_disk.join(&bucket); + std::fs::create_dir_all(&target_bucket).expect("create normalized target bucket directory"); + std::fs::rename(source_object, target_bucket.join(object)).expect("normalize the test object into the old pool"); + normalized_disks += 1; + } + assert!( + normalized_disks > pool_disk_paths[upload_pool].len() / 2, + "a write-quorum majority of the upload pool's disks must hold the multipart object to normalize" + ); + } + let source_pool = 0; + let target_pool = 1; + + let input = GetObjectInput::builder() + .bucket(bucket.clone()) + .key(object.to_string()) + .build() + .expect("multi-pool resume GET input must build"); + let usecase = DefaultObjectUsecase::with_context(Some(context)); + let mut response = usecase + .execute_get_object(build_request(input, Method::GET)) + .await + .expect("multi-pool GET must commit its response"); + let mut response_body = response + .output + .body + .take() + .expect("multi-pool GET response must include a body"); + + // Open the source reader before publishing the relocated object. Build + // each replica outside the bucket and rename it into place atomically so + // background maintenance never observes a metadata-less target object. + let mut staged_targets = Vec::with_capacity(pool_disk_paths[target_pool].len()); + for (source_disk, target_disk) in pool_disk_paths[source_pool].iter().zip(&pool_disk_paths[target_pool]) { + let source_dir = source_disk.join(&bucket).join(object); + // The same write-quorum minority gap tolerated above (#6701) can + // leave a lagging source-pool disk without the object; skip it and + // stage the replicas that exist — the reader tolerates the gap. + if !source_dir.join("xl.meta").is_file() { + continue; + } + let target_dir = target_disk.join(&bucket).join(object); + let staging_dir = temp_dir.path().join(format!("resume-relocate-{}", Uuid::new_v4())); + std::fs::create_dir_all(&staging_dir).expect("create relocated target staging directory"); + for entry in std::fs::read_dir(&source_dir).expect("read source object directory") { + let entry = entry.expect("read source object entry"); + if !entry.file_type().expect("read source object entry type").is_dir() { + continue; + } + let target_entry = staging_dir.join(entry.file_name()); + std::fs::create_dir_all(&target_entry).expect("create relocated target data directory"); + for child in std::fs::read_dir(entry.path()).expect("read source object data directory") { + let child = child.expect("read source object data entry"); + std::fs::copy(child.path(), target_entry.join(child.file_name())).expect("copy relocated object data entry"); + } + } + std::fs::copy(source_dir.join("xl.meta"), staging_dir.join("xl.meta")).expect("stage relocated object metadata"); + staged_targets.push((staging_dir, target_dir, source_dir.join("xl.meta"))); + } + assert!( + staged_targets.len() > pool_disk_paths[source_pool].len() / 2, + "a write-quorum majority of the source pool's disks must hold the object to stage the relocation" + ); + let (version_dirs, deleted) = delete_object_part_shards(&pool_disk_paths[source_pool], &bucket, object, &[2, 3]); + assert!(version_dirs > 0, "the source pool must have at least one version data directory"); + assert_eq!(deleted, version_dirs * 2); + + for (staging_dir, target_dir, source_meta) in staged_targets { + std::fs::rename(staging_dir, target_dir).expect("publish relocated target object"); + std::fs::remove_file(source_meta).expect("remove relocated source object metadata"); + } + store + .get_object_info(&bucket, object, &ObjectOptions::default()) + .await + .expect("the relocated object must resolve from the target pool"); + + let attempts_before = GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed); + let mut collected = Vec::new(); + while let Some(chunk) = response_body.next().await { + match chunk { + Ok(chunk) => collected.extend_from_slice(&chunk), + Err(err) => panic!( + "relocated GET from pool {source_pool} must resume from pool {target_pool} after {} attempts: {err:?}", + GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed) - attempts_before + ), + } + } + + assert_eq!(collected, body, "resumed production GET must preserve the complete body byte-for-byte"); + assert_eq!( + GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed) - attempts_before, + 1, + "the relocated body must reopen exactly once" + ); + } + + #[tokio::test] + #[serial_test::serial] + async fn get_object_resume_reopen_rejects_a_replaced_object_version() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; + use tokio::io::AsyncReadExt as _; + + let (_disk_paths, store, _context) = real_get_resume_test_context().await; + let bucket = format!("resume-identity-{}", Uuid::new_v4()); + let object = "object.bin"; + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("create resume identity bucket"); + let body = vec![0xAA; 1024 * 1024]; + put_real_cold_fill_object(&store, &bucket, object, &body).await; + let info = store + .get_object_info(&bucket, object, &ObjectOptions::default()) + .await + .expect("read the committed object metadata"); + + let ctx = GetObjectResumeContext::new( + Arc::clone(&store), + &bucket, + object, + ObjectOptions::default(), + &HeaderMap::new(), + &info, + 0, + -1, + ); + + // Positive control: the same version reopens and streams the body. + let manager = get_concurrency_manager(); + let permits_before = manager.io_queue_status().permits_in_use; + let mut reader = ctx.reopen(0).await.expect("reopening the same version must succeed"); + assert_eq!( + manager.io_queue_status().permits_in_use, + permits_before + 1, + "the resumed stream must hold disk-read admission like the initial read" + ); + let mut reopened_body = Vec::new(); + reader + .read_to_end(&mut reopened_body) + .await + .expect("the reopened reader must stream the body"); + assert_eq!(reopened_body, body); + // The reopened reader holds the object read lock; drop it before the + // delete below requests the write lock. + drop(reader); + assert_eq!( + manager.io_queue_status().permits_in_use, + permits_before, + "dropping the resumed stream must release its disk-read admission" + ); + + // A nonzero-offset reopen must splice the remaining bytes exactly. + let mut reader = ctx.reopen(1024).await.expect("reopening at a nonzero offset must succeed"); + let mut tail = Vec::new(); + reader + .read_to_end(&mut tail) + .await + .expect("the offset reader must stream the remaining body"); + assert_eq!(tail, body[1024..], "the resumed stream must continue from the emitted offset exactly"); + drop(reader); + + // Delete and re-PUT the key, then the stale context must refuse to + // splice the replacement version into the committed response. + store + .delete_object(&bucket, object, ObjectOptions::default()) + .await + .expect("delete the original object"); + let replacement_body = vec![0xBB; 2 * 1024 * 1024]; + put_real_cold_fill_object(&store, &bucket, object, &replacement_body).await; + let result = ctx.reopen(0).await; + assert!( + matches!(result, Err(GetObjectResumeFailure::Fatal)), + "reopening a replaced version must fail closed" + ); + } + + #[tokio::test] + #[serial_test::serial] + async fn get_object_resume_context_pins_latest_read_to_resolved_version() { + let (_disk_paths, store, _context) = real_get_resume_test_context().await; + let resolved_version = Uuid::new_v4(); + let info = ObjectInfo { + version_id: Some(resolved_version), + ..Default::default() + }; + + let ctx = GetObjectResumeContext::new( + Arc::clone(&store), + "bucket", + "object.bin", + ObjectOptions::default(), + &HeaderMap::new(), + &info, + 0, + -1, + ); + assert_eq!( + ctx.opts.version_id, + Some(resolved_version.to_string()), + "latest GET resume must reopen the initially resolved version, not the moving latest" + ); + + let explicit_version = Uuid::new_v4().to_string(); + let explicit_opts = ObjectOptions { + version_id: Some(explicit_version.clone()), + ..Default::default() + }; + let ctx = GetObjectResumeContext::new( + Arc::clone(&store), + "bucket", + "object.bin", + explicit_opts, + &HeaderMap::new(), + &info, + 0, + -1, + ); + assert_eq!( + ctx.opts.version_id.as_deref(), + Some(explicit_version.as_str()), + "an explicit request version must stay authoritative" + ); + + let unversioned_info = ObjectInfo::default(); + let ctx = GetObjectResumeContext::new( + Arc::clone(&store), + "bucket", + "object.bin", + ObjectOptions::default(), + &HeaderMap::new(), + &unversioned_info, + 0, + -1, + ); + assert_eq!(ctx.opts.version_id, None, "unversioned reads have no version to pin"); + } + + #[tokio::test] + #[serial_test::serial] + async fn get_object_resume_context_redacts_ssec_headers_and_flags_range_dependent_size() { + let (_disk_paths, store, _context) = real_get_resume_test_context().await; + + let mut request_headers = HeaderMap::new(); + request_headers.insert(SSEC_ALGORITHM_HEADER, HeaderValue::from_static("AES256")); + request_headers.insert(SSEC_KEY_HEADER, HeaderValue::from_static("dGVzdC1rZXk=")); + request_headers.insert(SSEC_KEY_MD5_HEADER, HeaderValue::from_static("bWQ1")); + request_headers.insert(http::header::AUTHORIZATION, HeaderValue::from_static("AWS4-HMAC-SHA256 Credential=test")); + request_headers.insert("x-amz-security-token", HeaderValue::from_static("session-token")); + let store_headers = project_ssec_transport_headers(&request_headers); + assert_eq!(store_headers.len(), 3, "only store-consumed SSE-C headers are forwarded"); + assert!(store_headers.values().all(HeaderValue::is_sensitive)); + assert!(store_headers.get(http::header::AUTHORIZATION).is_none()); + assert!(store_headers.get("x-amz-security-token").is_none()); + assert!(!format!("{store_headers:?}").contains("dGVzdC1rZXk=")); + let plain_info = ObjectInfo { + size: 11, + ..Default::default() + }; + let ctx = GetObjectResumeContext::new( + Arc::clone(&store), + "bucket", + "object.bin", + ObjectOptions::default(), + &request_headers, + &plain_info, + 0, + -1, + ); + for name in [SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER] { + let value = ctx.ssec_headers.get(name).expect("the SSE-C trio is retained"); + assert!(value.is_sensitive(), "store spans record headers at debug; {name} must be redacted there"); + } + assert_eq!( + ctx.ssec_headers.len(), + 3, + "only the SSE-C trio may be retained; credential headers must never be replayed into store spans" + ); + assert!(!ctx.identity.range_dependent_size, "plain reads report the range-invariant oi.size"); + + let encrypted_info = ObjectInfo { + size: 11, + user_defined: Arc::new( + [("x-amz-server-side-encryption".to_string(), "aws:kms".to_string())] + .into_iter() + .collect(), + ), + ..Default::default() + }; + let ctx = GetObjectResumeContext::new( + Arc::clone(&store), + "bucket", + "object.bin", + ObjectOptions::default(), + &HeaderMap::new(), + &encrypted_info, + 0, + -1, + ); + assert!(ctx.identity.range_dependent_size, "encrypted reads report the per-read delivered length"); + + let compressed_info = ObjectInfo { + size: 11, + user_defined: Arc::new( + [("x-rustfs-internal-compression".to_string(), "snappy".to_string())] + .into_iter() + .collect(), + ), + ..Default::default() + }; + let ctx = GetObjectResumeContext::new( + Arc::clone(&store), + "bucket", + "object.bin", + ObjectOptions::default(), + &HeaderMap::new(), + &compressed_info, + 0, + -1, + ); + assert!(ctx.identity.range_dependent_size, "compressed reads report the per-read delivered length"); + } + + #[tokio::test] + #[serial_test::serial] + async fn memory_tracked_bytes_stream_releases_request_guard_after_emit() { + let initial = GetObjectGuard::concurrent_count(); + let guard = GetObjectGuard::new(); + assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); + + let mut stream = MemoryTrackedBytesStream::new( + Bytes::from_static(b"hello"), + 5, + GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, + None, + GetObjectBodyLifecycle::tracked(guard), + ); + let chunk = stream + .next() + .await + .expect("memory body should emit one chunk") + .expect("memory body chunk should be readable"); + + assert_eq!(chunk.as_ref(), b"hello"); + assert_eq!(GetObjectGuard::concurrent_count(), initial); + } + + #[test] + #[serial_test::serial] + fn memory_tracked_bytes_stream_releases_request_guard_for_zero_length_without_poll() { + let initial = GetObjectGuard::concurrent_count(); + let guard = GetObjectGuard::new(); + assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); + + let stream = MemoryTrackedBytesStream::new( + Bytes::new(), + 0, + GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, + None, + GetObjectBodyLifecycle::tracked(guard), + ); + drop(stream); + + assert_eq!(GetObjectGuard::concurrent_count(), initial); + } + + #[tokio::test] + async fn disk_read_permit_reader_holds_permit_until_reader_is_dropped() { + let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); + let permit = semaphore + .clone() + .acquire_owned() + .await + .expect("test semaphore should grant owned permit"); + + let reader = DiskReadPermitReader::new(std::io::Cursor::new(Vec::::new()), permit.into()); + assert_eq!(semaphore.available_permits(), 0); + + drop(reader); + assert_eq!(semaphore.available_permits(), 1); + } + + #[tokio::test] + #[serial_test::serial(cold_fill_metrics_gate)] + async fn cold_fill_follower_disk_permit_metric_tracks_actual_permit_lifetime() { + COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.store(0, Ordering::Relaxed); + let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); + scope_cold_fill_disk_permit_owner_for_test(ColdFillDiskPermitOwner::Follower, async { + let permit = semaphore + .clone() + .acquire_owned() + .await + .expect("follower test semaphore must grant an owned permit"); + let tracked = GetObjectDiskPermit::new(permit); + assert_eq!(semaphore.available_permits(), 0); + assert_eq!(COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.load(Ordering::Relaxed), 1); + + drop(tracked); + assert_eq!(semaphore.available_permits(), 1); + assert_eq!(COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.load(Ordering::Relaxed), 0); + }) + .await; + } + + #[test] + #[serial_test::serial(cold_fill_metrics_gate)] + fn cold_fill_disk_permit_metrics_obey_gate_and_return_to_zero() { + use metrics_util::debugging::{DebugValue, DebuggingRecorder}; + + let metrics_was_enabled = rustfs_io_metrics::metrics_enabled(); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("metric test runtime must build"); + metrics::with_local_recorder(&recorder, || { + runtime.block_on(async { + rustfs_io_metrics::set_metrics_enabled(false); + let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); + scope_cold_fill_disk_permit_owner_for_test(ColdFillDiskPermitOwner::Follower, async { + let permit = semaphore + .clone() + .acquire_owned() + .await + .expect("metric test permit must be available"); + let tracked = GetObjectDiskPermit::new(permit); + rustfs_io_metrics::set_metrics_enabled(true); + drop(tracked); + }) + .await; + assert!( + snapshotter.snapshot().into_vec().into_iter().all(|(composite, _, _, _)| { + !composite.key().name().starts_with("rustfs_object_data_cache_cold_fill_") + }), + "a permit acquired while metrics were disabled must not record an unmatched decrement" + ); + + scope_cold_fill_disk_permit_owner_for_test(ColdFillDiskPermitOwner::Producer, async { + let permit = semaphore + .clone() + .acquire_owned() + .await + .expect("metric test permit must be available"); + let tracked = GetObjectDiskPermit::new(permit); + rustfs_io_metrics::set_metrics_enabled(false); + drop(tracked); + }) + .await; + rustfs_io_metrics::set_metrics_enabled(true); + scope_cold_fill_disk_permit_owner_for_test(ColdFillDiskPermitOwner::Follower, async { + let permit = semaphore.acquire_owned().await.expect("metric test permit must be available"); + let tracked = GetObjectDiskPermit::new(permit); + let _replacement = crate::app::object_data_cache::ColdFillCoordinator::default(); + drop(tracked); + }) + .await; + }); + }); + + let values = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter_map(|(composite, _unit, _description, value)| { + composite + .key() + .name() + .starts_with("rustfs_object_data_cache_cold_fill_") + .then_some((composite.key().name().to_string(), value)) + }) + .collect::>(); + assert_eq!(values.len(), 2); + for name in [ + "rustfs_object_data_cache_cold_fill_producer_disk_permits", + "rustfs_object_data_cache_cold_fill_follower_disk_permits", + ] { + let DebugValue::Gauge(value) = values.get(name).unwrap_or_else(|| panic!("missing {name} gauge")) else { + panic!("{name} must be a gauge"); + }; + assert_eq!(value.into_inner(), 0.0, "{name} must return to zero after permit drop"); + } + rustfs_io_metrics::set_metrics_enabled(metrics_was_enabled); + } + + #[tokio::test] + async fn build_get_object_body_keeps_large_objects_on_streaming_path_without_preread() { + let reads = Arc::new(AtomicUsize::new(0)); + let reader = ReadProbeReader { + reads: Arc::clone(&reads), + }; + let info = ObjectInfo { + size: 18_i64 * 1024 * 1024 * 1024, + ..Default::default() + }; + + let _body = DefaultObjectUsecase::build_get_object_body( + reader, + &info, + 18_i64 * 1024 * 1024 * 1024, + "req-large-object", + None, + 128 * 1024, + true, + 1, + None, + false, + false, + None, + "test-bucket", + "large-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("build_get_object_body should succeed for streaming path"); + + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "large-object response construction should not pre-read object data" + ); + } + + #[tokio::test] + async fn build_get_object_body_keeps_large_encrypted_objects_on_streaming_path_without_preread() { + let reads = Arc::new(AtomicUsize::new(0)); + let reader = ReadProbeReader { + reads: Arc::clone(&reads), + }; + let info = ObjectInfo { + size: 18_i64 * 1024 * 1024 * 1024, + ..Default::default() + }; + + let _body = DefaultObjectUsecase::build_get_object_body( + reader, + &info, + 18_i64 * 1024 * 1024 * 1024, + "req-large-encrypted-object", + None, + 128 * 1024, + true, + 1, + None, + false, + true, + None, + "test-bucket", + "large-encrypted-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("build_get_object_body should succeed for encrypted streaming path"); + + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "large encrypted object response construction should not pre-read object data" + ); + } + + #[tokio::test] + async fn build_get_object_body_uses_buffered_body_without_reader_preread() { + let reads = Arc::new(AtomicUsize::new(0)); + let reader = ReadProbeReader { + reads: Arc::clone(&reads), + }; + let info = ObjectInfo { + size: 4, + ..Default::default() + }; + + let _body = DefaultObjectUsecase::build_get_object_body( + reader, + &info, + 4, + "req-direct-memory-object", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + Some(Bytes::from_static(b"test")), + "test-bucket", + "direct-memory-object", + GetObjectBodyLifecycle::disabled(), + |_| panic!("a buffered body must not initialize streaming resume state"), + ) + .await + .expect("build_get_object_body should consume buffered body"); + + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "buffered GetObject body must not be read from the fallback reader" + ); + } + + #[tokio::test] + async fn build_get_object_body_with_cache_uses_cached_body_without_reader_preread() { + let reads = Arc::new(AtomicUsize::new(0)); + let reader = ReadProbeReader { + reads: Arc::clone(&reads), + }; + let info = ObjectInfo { + size: 5, + etag: Some("etag".to_string()), + ..Default::default() + }; + let adapter = + crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, + max_bytes: 8_388_608, + // Fill must not depend on the live memory reading (host vs container). + min_free_memory_percent: 0, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("fill-enabled cache adapter should initialize"); + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "test-bucket", + object: "cached-object", + version_id: None, + etag: "etag", + size: 5, + data_dir_u128: None, + mod_time_unix_nanos: 0, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + let fill = adapter.cache().fill_body(&plan, Bytes::from_static(b"hello")).await; + + assert_eq!(fill, rustfs_object_data_cache::ObjectDataCacheFillResult::Inserted); + + let _body = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + reader, + &info, + 5, + "req-cached-object", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + None, + false, + false, + true, + "test-bucket", + "cached-object", + GetObjectBodyLifecycle::disabled(), + |_| panic!("a cache hit must not initialize streaming resume state"), + ) + .await + .expect("cache hit body handoff should succeed"); + + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "cache hit body handoff must not read from the fallback reader" + ); + } + + #[tokio::test] + async fn build_get_object_body_with_cache_rejects_size_mismatch_fill() { + let reads = Arc::new(AtomicUsize::new(0)); + let reader = ReadProbeReader { + reads: Arc::clone(&reads), + }; + let info = ObjectInfo { + size: 5, + etag: Some("etag".to_string()), + ..Default::default() + }; + let adapter = + crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, + max_bytes: 8_388_608, + // Fill must not depend on the live memory reading (host vs container). + min_free_memory_percent: 0, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("fill-enabled cache adapter should initialize"); + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "test-bucket", + object: "cached-object", + version_id: None, + etag: "etag", + size: 5, + data_dir_u128: None, + mod_time_unix_nanos: 0, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + let fill = adapter.cache().fill_body(&plan, Bytes::from_static(b"oops")).await; + + let _body = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + reader, + &info, + 5, + "req-rejects-size-mismatch-fill", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + None, + false, + false, + true, + "test-bucket", + "cached-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("size-mismatched direct fill should not create a cache hit"); + let lookup_after_mismatch = adapter.lookup_body(&plan).await; + + assert_eq!(fill, rustfs_object_data_cache::ObjectDataCacheFillResult::SkippedSizeMismatch); + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "size-mismatched rejected fill should construct the fallback stream without pre-reading" + ); + assert!( + matches!(lookup_after_mismatch, rustfs_object_data_cache::ObjectDataCacheLookup::Miss), + "size-mismatched fill must not leave a reusable cache entry" + ); + } + + #[tokio::test] + async fn build_get_object_body_with_cache_fills_from_buffered_body_without_reader_preread() { + let first_reads = Arc::new(AtomicUsize::new(0)); + let first_reader = ReadProbeReader { + reads: Arc::clone(&first_reads), + }; + let second_reads = Arc::new(AtomicUsize::new(0)); + let second_reader = ReadProbeReader { + reads: Arc::clone(&second_reads), + }; + let info = ObjectInfo { + size: 5, + etag: Some("etag".to_string()), + ..Default::default() + }; + let adapter = + crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, + max_bytes: 8_388_608, + // Fill must not depend on the live memory reading (host vs container). + min_free_memory_percent: 0, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("fill-enabled cache adapter should initialize"); + + let _first_body = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + first_reader, + &info, + 5, + "req-cache-fill-first", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + Some(Bytes::from_static(b"hello")), + false, + false, + true, + "test-bucket", + "cached-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("buffered-body handoff should succeed"); + + // ODC-15: the fill is detached from the response path, so wait for it to + // populate the cache before the follow-up GET to keep the hit deterministic. + wait_for_cache_hit(&adapter, "test-bucket", "cached-object", "etag", 5).await; + + let _second_body = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + second_reader, + &info, + 5, + "req-cache-fill-second", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + None, + false, + false, + true, + "test-bucket", + "cached-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("follow-up cache hit should succeed"); + + assert_eq!( + first_reads.load(AtomicOrdering::Relaxed), + 0, + "buffered-body fill path must not read from the fallback reader" + ); + assert_eq!( + second_reads.load(AtomicOrdering::Relaxed), + 0, + "cache hit after buffered-body fill must not read from the fallback reader" + ); + } + + #[tokio::test] + async fn build_get_object_body_with_cache_skips_buffered_fill_on_size_mismatch() { + let reads = Arc::new(AtomicUsize::new(0)); + let reader = ReadProbeReader { + reads: Arc::clone(&reads), + }; + let info = ObjectInfo { + size: 5, + etag: Some("etag".to_string()), + ..Default::default() + }; + let adapter = + crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, + max_bytes: 8_388_608, + // Fill must not depend on the live memory reading (host vs container). + min_free_memory_percent: 0, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("fill-enabled cache adapter should initialize"); + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "test-bucket", + object: "cached-object", + version_id: None, + etag: "etag", + size: 5, + data_dir_u128: None, + mod_time_unix_nanos: 0, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + + let _body = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + reader, + &info, + 5, + "req-rejects-buffered-size-mismatch", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + Some(Bytes::from_static(b"oops")), + false, + false, + true, + "test-bucket", + "cached-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("size-mismatched buffered-body handoff should still return a response body"); + let lookup = adapter.lookup_body(&plan).await; + + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "buffered-body handoff must not read from the fallback reader" + ); + assert!( + matches!(lookup, rustfs_object_data_cache::ObjectDataCacheLookup::Miss), + "size-mismatched buffered body must not be filled into cache" + ); + } + + #[tokio::test] + async fn build_get_object_body_with_cache_hook_served_records_no_second_lookup() { + // ODC-16 (backlog#1121): a hook-served GET must record exactly one + // lookup — the ecstore hook's. The app layer, handed the cache body as + // buffered_body with cache_hook_served=true, must serve it directly + // without a second lookup (which would double the hits and hit_bytes). + let reads = Arc::new(AtomicUsize::new(0)); + let reader = ReadProbeReader { + reads: Arc::clone(&reads), + }; + let info = ObjectInfo { + size: 5, + etag: Some("etag".to_string()), + ..Default::default() + }; + let adapter = + crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, + max_bytes: 8_388_608, + min_free_memory_percent: 0, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("fill-enabled cache adapter should initialize"); + let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { + bucket: "test-bucket", + object: "hook-served", + version_id: None, + etag: "etag", + size: 5, + data_dir_u128: None, + mod_time_unix_nanos: 0, + body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, + }); + let hit_body = Bytes::from_static(b"hello"); + assert_eq!( + adapter.cache().fill_body(&plan, hit_body.clone()).await, + rustfs_object_data_cache::ObjectDataCacheFillResult::Inserted + ); + + // Simulate the ecstore hook: it performs exactly one lookup after fresh + // metadata resolution, hits, and hands the body forward as buffered_body. + assert!(matches!( + adapter.lookup_body(&plan).await, + rustfs_object_data_cache::ObjectDataCacheLookup::Hit(_) + )); + let lookups_after_hook = adapter.cache().stats().lookups; + assert_eq!(lookups_after_hook, 1, "the hook performs exactly one lookup"); + + let _body = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + reader, + &info, + 5, + "req-hook-served", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + Some(hit_body), + /* cache_hook_served */ true, + /* cache_hook_probed */ true, + /* cache_fill_allowed */ true, + "test-bucket", + "hook-served", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("hook-served body handoff should succeed"); + + assert_eq!( + adapter.cache().stats().lookups, + lookups_after_hook, + "a hook-served GET must not record a second lookup in the app layer" + ); + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "hook-served body handoff must not read from the fallback reader" + ); + } + + #[tokio::test] + async fn build_get_object_body_with_cache_hook_miss_skips_app_lookup() { + // ODC-16: when the hook probed and missed, its miss is authoritative + // (it ran after fresh metadata resolution), so the app layer must not + // run a second lookup — it only fills from the buffered body. + let reads = Arc::new(AtomicUsize::new(0)); + let reader = ReadProbeReader { + reads: Arc::clone(&reads), + }; + let info = ObjectInfo { + size: 5, + etag: Some("etag".to_string()), + ..Default::default() + }; + let adapter = + crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, + max_bytes: 8_388_608, + min_free_memory_percent: 0, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("fill-enabled cache adapter should initialize"); + + let lookups_before = adapter.cache().stats().lookups; + let _body = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + reader, + &info, + 5, + "req-hook-missed", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + Some(Bytes::from_static(b"hello")), + /* cache_hook_served */ false, + /* cache_hook_probed */ true, + /* cache_fill_allowed */ true, + "test-bucket", + "hook-missed", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("hook-miss buffered-body handoff should succeed"); + + assert_eq!( + adapter.cache().stats().lookups, + lookups_before, + "a hook-probed miss must not trigger an app-layer lookup" + ); + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "buffered-body handoff must not read from the fallback reader" + ); + } + + #[tokio::test] + async fn build_get_object_body_with_cache_materializes_once_and_hits_later() { + let first_reads = Arc::new(AtomicUsize::new(0)); + let first_reader = DataProbeReader { + reads: Arc::clone(&first_reads), + data: std::io::Cursor::new(b"hello".to_vec()), + }; + let second_reads = Arc::new(AtomicUsize::new(0)); + let second_reader = ReadProbeReader { + reads: Arc::clone(&second_reads), + }; + let info = ObjectInfo { + size: 5, + etag: Some("etag".to_string()), + ..Default::default() + }; + let adapter = + crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 8_388_608, + // Fill must not depend on the live memory reading (host vs container). + min_free_memory_percent: 0, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("materialize-fill cache adapter should initialize"); + + let _first_body = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + first_reader, + &info, + 5, + "req-materialize-first", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + None, + false, + false, + true, + "test-bucket", + "materialized-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("materialize-fill handoff should succeed"); + + // ODC-15: the fill is detached from the response path, so wait for it to + // populate the cache before the follow-up GET to keep the hit deterministic. + wait_for_cache_hit(&adapter, "test-bucket", "materialized-object", "etag", 5).await; + + let _second_body = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + second_reader, + &info, + 5, + "req-materialize-second", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + None, + false, + false, + true, + "test-bucket", + "materialized-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("follow-up cache hit should succeed"); + + assert_eq!( + first_reads.load(AtomicOrdering::Relaxed), + 2, + "materialize-fill path should read the source stream once to data and once for EOF" + ); + assert_eq!( + second_reads.load(AtomicOrdering::Relaxed), + 0, + "cache hit after materialize-fill must not read from the fallback reader" + ); + } + + // ODC-07: a materialize read that yields more than the declared content + // length must be a hard error, not a warn-and-serve, matching the + // direct-memory GET path. The bounded `take` reads one byte past capacity so + // the over-long stream is detected without buffering it unbounded. + #[tokio::test] + async fn build_get_object_body_with_cache_materialize_rejects_length_mismatch() { + let reads = Arc::new(AtomicUsize::new(0)); + // Declared content length is 5, but the stream yields 6 bytes. + let reader = DataProbeReader { + reads: Arc::clone(&reads), + data: std::io::Cursor::new(b"hello!".to_vec()), + }; + let info = ObjectInfo { + size: 5, + etag: Some("etag".to_string()), + ..Default::default() + }; + let adapter = + crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 8_388_608, + min_free_memory_percent: 0, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("materialize-fill cache adapter should initialize"); + + let result = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + reader, + &info, + 5, + "req-materialize-mismatch", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + None, + false, + false, + true, + "test-bucket", + "mismatch-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await; + + assert!( + result.is_err(), + "an over-long materialize read must be a hard error, not a truncated served body" + ); + } + + // #1324: a materialize-fill read that ends short of the declared content + // length (clean EOF at N-1 for a declared N) must hard-fail, matching the + // over-long case above. Reverting to warn-and-serve would return Ok with a + // truncated body. + #[tokio::test] + async fn build_get_object_body_with_cache_materialize_rejects_short_read() { + let reads = Arc::new(AtomicUsize::new(0)); + // Declared content length is 5, but the stream only yields 4 bytes. + let reader = DataProbeReader { + reads: Arc::clone(&reads), + data: std::io::Cursor::new(b"hell".to_vec()), + }; + let info = ObjectInfo { + size: 5, + etag: Some("etag".to_string()), + ..Default::default() + }; + let adapter = + crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 8_388_608, + min_free_memory_percent: 0, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("materialize-fill cache adapter should initialize"); + + let result = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + reader, + &info, + 5, + "req-materialize-short", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + None, + false, + false, + true, + "test-bucket", + "short-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await; + + assert!( + result.is_err(), + "a short materialize read must be a hard error, not a truncated served body" + ); + } + + // #1324: a materialize-fill read that fails after draining K bytes must + // propagate the read error and must NOT fall back to streaming the same + // (partially consumed) reader, which would ship a prefix-misaligned body. + #[tokio::test] + async fn build_get_object_body_with_cache_materialize_rejects_partial_read_error() { + let reader = ErrAfterReader { + data: std::io::Cursor::new(b"hello".to_vec()), + fail_after: 3, + emitted: 0, + }; + let info = ObjectInfo { + size: 5, + etag: Some("etag".to_string()), + ..Default::default() + }; + let adapter = + crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 8_388_608, + min_free_memory_percent: 0, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("materialize-fill cache adapter should initialize"); + + let result = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + reader, + &info, + 5, + "req-materialize-partial", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + None, + false, + false, + true, + "test-bucket", + "partial-read-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await; + + assert!( + result.is_err(), + "a partial-read error during materialization must fail the request, not stream a prefix-misaligned body" + ); + } + + // #1324: the buffered-body (direct-memory / cache-served) source must also + // enforce the exact-length contract. A buffered body shorter than the + // declared content length is a hard error before headers. + #[tokio::test] + async fn build_get_object_body_rejects_short_buffered_body() { + let reads = Arc::new(AtomicUsize::new(0)); + let reader = ReadProbeReader { + reads: Arc::clone(&reads), + }; + let info = ObjectInfo { + size: 5, + ..Default::default() + }; + + let result = DefaultObjectUsecase::build_get_object_body( + reader, + &info, + 5, + "req-short-buffered-object", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + // Declared length 5 but only 4 buffered bytes. + Some(Bytes::from_static(b"hell")), + "test-bucket", + "short-buffered-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await; + + assert!(result.is_err(), "a buffered body shorter than the declared content length must hard-fail"); + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "the mismatch must be caught without touching the fallback reader" + ); + } + + // #1324 compatibility boundary: a legacy/backfilled object whose decoded + // bytes exactly equal its declared content length must still serve cleanly. + // The strict contract keys off actual-vs-declared equality only, so it never + // flips a legitimate exact-length object into a hard failure — it only + // rejects genuine short/over-long/errored reads. + #[tokio::test] + async fn build_get_object_body_serves_exact_length_buffered_body() { + let reads = Arc::new(AtomicUsize::new(0)); + let reader = ReadProbeReader { + reads: Arc::clone(&reads), + }; + let info = ObjectInfo { + size: 5, + ..Default::default() + }; + + let _body = DefaultObjectUsecase::build_get_object_body( + reader, + &info, + 5, + "req-exact-buffered-object", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + Some(Bytes::from_static(b"hello")), + "test-bucket", + "exact-buffered-object", + GetObjectBodyLifecycle::disabled(), + |_| panic!("an exact-length buffered body must not initialize streaming resume state"), + ) + .await + .expect("an exact-length buffered body must serve without error"); + + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "an exact-length buffered body must not read from the fallback reader" + ); + } + + #[tokio::test] + async fn build_get_object_body_with_cache_skips_materialize_when_too_large_for_cache() { + let reads = Arc::new(AtomicUsize::new(0)); + let reader = DataProbeReader { + reads: Arc::clone(&reads), + data: std::io::Cursor::new(b"hello".to_vec()), + }; + let info = ObjectInfo { + size: 5, + etag: Some("etag".to_string()), + ..Default::default() + }; + let adapter = + crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { + mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, + max_bytes: 8_388_608, + max_entry_bytes: 4, + // Fill must not depend on the live memory reading (host vs container). + min_free_memory_percent: 0, + ..rustfs_object_data_cache::ObjectDataCacheConfig::default() + }) + .expect("materialize-fill cache adapter should initialize"); + + let _body = DefaultObjectUsecase::build_get_object_body_with_cache( + &adapter, + reader, + &info, + 5, + "req-materialize-too-large", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + None, + false, + false, + true, + "test-bucket", + "too-large-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("too-large cache candidate should use streaming fallback"); + + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "too-large materialize-fill candidate must not pre-read the fallback reader" + ); + } + + #[tokio::test] + async fn build_get_object_body_keeps_small_plain_objects_on_streaming_path_by_default() { + let reads = Arc::new(AtomicUsize::new(0)); + let reader = ReadProbeReader { + reads: Arc::clone(&reads), + }; + let info = ObjectInfo { + size: 4, + ..Default::default() + }; + + let _body = DefaultObjectUsecase::build_get_object_body( + reader, + &info, + 4, + "req-small-plain-object", + None, + 128 * 1024, + false, + 1, + None, + false, + false, + None, + "test-bucket", + "small-plain-object", + GetObjectBodyLifecycle::disabled(), + |_| None, + ) + .await + .expect("build_get_object_body should keep small plain object on streaming path"); + + assert_eq!( + reads.load(AtomicOrdering::Relaxed), + 0, + "default GetObject response construction should not pre-read small plain object data" + ); + } + + #[test] + fn select_stream_buffer_strategy_expands_large_sequential_gets() { + let (buffer_size, strategy) = + DefaultObjectUsecase::select_stream_buffer_strategy(2_i64 * 1024 * 1024 * 1024, 2 * MI_B, true, false); + + assert_eq!(strategy, GetObjectStreamStrategy::LargeSequentialReadahead); + assert_eq!(buffer_size, 4 * MI_B); + } + + #[test] + fn select_stream_buffer_strategy_keeps_ranges_and_small_gets_standard() { + let (range_buffer_size, range_strategy) = + DefaultObjectUsecase::select_stream_buffer_strategy(2_i64 * 1024 * 1024 * 1024, 2 * MI_B, true, true); + assert_eq!(range_strategy, GetObjectStreamStrategy::Standard); + assert_eq!(range_buffer_size, 2 * MI_B); + + let (small_buffer_size, small_strategy) = + DefaultObjectUsecase::select_stream_buffer_strategy(64 * 1024 * 1024, 512 * 1024, true, false); + assert_eq!(small_strategy, GetObjectStreamStrategy::Standard); + assert_eq!(small_buffer_size, 512 * 1024); + } + + #[test] + fn tune_reader_stream_buffer_size_raises_large_standard_streams_only() { + assert_eq!( + tune_reader_stream_buffer_size(128 * 1024, 10 * MI_B as i64, GetObjectStreamStrategy::Standard), + LARGE_BODY_READER_STREAM_BUFFER_FLOOR_BYTES + ); + assert_eq!( + tune_reader_stream_buffer_size(512 * 1024, 10 * MI_B as i64, GetObjectStreamStrategy::Standard), + LARGE_BODY_READER_STREAM_BUFFER_FLOOR_BYTES + ); + assert_eq!( + tune_reader_stream_buffer_size(2 * MI_B, 10 * MI_B as i64, GetObjectStreamStrategy::Standard), + 2 * MI_B + ); + assert_eq!( + tune_reader_stream_buffer_size(128 * 1024, MI_B as i64, GetObjectStreamStrategy::Standard), + MID_BODY_READER_STREAM_BUFFER_FLOOR_BYTES + ); + assert_eq!( + tune_reader_stream_buffer_size(256 * 1024, 2 * MI_B as i64, GetObjectStreamStrategy::Standard), + MID_BODY_READER_STREAM_BUFFER_FLOOR_BYTES + ); + assert_eq!( + tune_reader_stream_buffer_size(128 * 1024, 10 * MI_B as i64, GetObjectStreamStrategy::LargeSequentialReadahead), + 128 * 1024 + ); + } + + #[test] + fn resolve_reader_stream_buffer_size_keeps_selected_default() { + let (buffer_size, source) = resolve_reader_stream_buffer_size(128 * 1024, None); + + assert_eq!(buffer_size, 128 * 1024); + assert_eq!(source, GET_READER_STREAM_BUFFER_SOURCE_SELECTED); + } + + #[test] + fn resolve_reader_stream_buffer_size_applies_positive_override() { + let (buffer_size, source) = resolve_reader_stream_buffer_size(128 * 1024, Some(MI_B)); + + assert_eq!(buffer_size, MI_B); + assert_eq!(source, GET_READER_STREAM_BUFFER_SOURCE_ENV_OVERRIDE); + } + + #[test] + fn resolve_reader_stream_buffer_size_ignores_zero_override() { + let (buffer_size, source) = resolve_reader_stream_buffer_size(128 * 1024, Some(0)); + + assert_eq!(buffer_size, 128 * 1024); + assert_eq!(source, GET_READER_STREAM_BUFFER_SOURCE_SELECTED); + } + + #[tokio::test] + async fn get_object_reader_stream_tracks_remaining_length() { + let mut stream = GetObjectReaderStream::new( + std::io::Cursor::new(b"hello".to_vec()), + 2, + 5, + GetObjectStreamStrategy::Standard.as_str(), + GET_READER_STREAM_BUFFER_SOURCE_SELECTED, + ); + + assert_eq!(stream.remaining_length().exact(), Some(5)); + + let first = stream + .next() + .await + .expect("reader stream should emit first chunk") + .expect("first chunk should read"); + + assert_eq!(first.as_ref(), b"he"); + assert_eq!(stream.remaining_length().exact(), Some(3)); + } + + #[tokio::test] + async fn get_object_reader_stream_truncates_to_expected_length() { + let stream = GetObjectReaderStream::new( + std::io::Cursor::new(b"hello!".to_vec()), + 64, + 5, + GetObjectStreamStrategy::Standard.as_str(), + GET_READER_STREAM_BUFFER_SOURCE_SELECTED, + ); + + let chunks = stream + .collect::>() + .await + .into_iter() + .collect::, _>>() + .expect("reader stream should read"); + let body = chunks.into_iter().fold(Vec::new(), |mut acc, chunk| { + acc.extend_from_slice(&chunk); + acc + }); + + assert_eq!(body, b"hello"); + } + + #[tokio::test] + async fn get_object_reader_stream_bounds_read_buffer_to_remaining() { + struct RecordingReader { + data: &'static [u8], + pos: usize, + observed_remaining: Arc>>, + } + + impl AsyncRead for RecordingReader { + fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + let requested = buf.remaining(); + self.observed_remaining + .lock() + .expect("observed buffer sizes should not poison") + .push(requested); + let available = self.data.len().saturating_sub(self.pos); + let to_copy = requested.min(available); + if to_copy > 0 { + let end = self.pos + to_copy; + buf.put_slice(&self.data[self.pos..end]); + self.pos = end; + } + Poll::Ready(Ok(())) + } + } + + let observed_remaining = Arc::new(Mutex::new(Vec::new())); + let stream = GetObjectReaderStream::new( + RecordingReader { + data: b"hello", + pos: 0, + observed_remaining: Arc::clone(&observed_remaining), + }, + 64, + 5, + GetObjectStreamStrategy::Standard.as_str(), + GET_READER_STREAM_BUFFER_SOURCE_SELECTED, + ); + + let chunks = stream + .collect::>() + .await + .into_iter() + .collect::, _>>() + .expect("reader stream should read exact payload"); + assert_eq!(chunks, vec![Bytes::from_static(b"hello")]); + assert_eq!( + *observed_remaining.lock().expect("observed buffer sizes should not poison"), + vec![5], + "stream should not ask the reader for more bytes than the response has left" + ); + } + + #[tokio::test] + async fn get_object_reader_stream_bounds_multi_chunk_final_read() { + let stream = GetObjectReaderStream::new( + std::io::Cursor::new(vec![b'a'; 66]), + 64, + 65, + GetObjectStreamStrategy::Standard.as_str(), + GET_READER_STREAM_BUFFER_SOURCE_SELECTED, + ); + + let chunks = stream + .collect::>() + .await + .into_iter() + .collect::, _>>() + .expect("reader stream should ignore bytes past declared length"); + let chunk_lengths = chunks.iter().map(Bytes::len).collect::>(); + let body = chunks.into_iter().fold(Vec::new(), |mut acc, chunk| { + acc.extend_from_slice(&chunk); + acc + }); + + assert_eq!(chunk_lengths, vec![64, 1]); + assert_eq!(body, vec![b'a'; 65]); + } + + // Serial with the capture test below: both drive the same short-EOF log + // callsite, and `tracing` caches callsite interest process-wide. Running + // this one concurrently on a thread with no subscriber re-caches that + // callsite as "never interested" and blinds the capture. + #[tokio::test] + #[serial_test::serial] + async fn get_object_reader_stream_errors_on_short_eof() { + let stream = GetObjectReaderStream::new( + std::io::Cursor::new(b"he".to_vec()), + 64, + 5, + GetObjectStreamStrategy::Standard.as_str(), + GET_READER_STREAM_BUFFER_SOURCE_SELECTED, + ); + + let err = stream + .collect::>() + .await + .into_iter() + .collect::, _>>() + .expect_err("short reader should fail the streaming body"); + + assert_eq!( + err.downcast_ref::().map(std::io::Error::kind), + Some(std::io::ErrorKind::UnexpectedEof) + ); + } + + /// Collects the structured fields of every event emitted while installed, + /// so a test can assert what an operator would actually read in the log + /// rather than only that an error value was returned. + type CapturedFieldMap = std::collections::HashMap; + + type CapturedEventLog = Arc>>; + + struct CapturedEvents(CapturedEventLog); + + struct CapturedFields(CapturedFieldMap); + + impl tracing::field::Visit for CapturedFields { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + self.0.insert(field.name().to_string(), format!("{value:?}")); + } + + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + self.0.insert(field.name().to_string(), value.to_string()); + } + } + + impl tracing_subscriber::Layer for CapturedEvents { + fn on_event(&self, event: &tracing::Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) { + let mut fields = CapturedFields(CapturedFieldMap::new()); + event.record(&mut fields); + self.0.lock().expect("captured events should not poison").push(fields.0); + } + } + + fn capture_events() -> (CapturedEventLog, tracing::subscriber::DefaultGuard) { + use tracing_subscriber::{Registry, prelude::*}; + + let captured = Arc::new(Mutex::new(Vec::new())); + let subscriber = Registry::default().with(CapturedEvents(Arc::clone(&captured))); + let guard = tracing::subscriber::set_default(subscriber); + // `tracing` caches per-callsite interest process-wide, so a subscriber + // installed by a test running in parallel can leave the log sites below + // cached as "never interested" and this capture would silently see + // nothing. Force the callsites to re-ask the subscriber we just + // installed. + tracing::callsite::rebuild_interest_cache(); + (captured, guard) + } + + fn find_stream_body_event(captured: &CapturedEventLog, state: &str) -> CapturedFieldMap { + let events = captured.lock().expect("captured events should not poison"); + events + .iter() + .find(|fields| fields.get("state").is_some_and(|value| value == state)) + .unwrap_or_else(|| { + panic!( + "a `{state}` streaming body failure must be logged, not only counted in a metric. \ + Captured {} event(s): {:?}", + events.len(), + events + ) + }) + .clone() + } + + /// rustfs#4784: a GET body that ends short of its committed Content-Length + /// is the fault that breaks every downstream copier (replication, site + /// replication, `rclone sync`), yet this layer only fed a metric counter — + /// its log line was compiled out unless the `tracing-chunk-debug` feature + /// was on, so operators saw nothing on the source side. + #[tokio::test] + #[serial_test::serial] + async fn get_object_reader_stream_short_eof_names_the_object() { + let (captured, _guard) = capture_events(); + + let stream = GetObjectReaderStream::new( + std::io::Cursor::new(b"he".to_vec()), + 64, + 5, + GetObjectStreamStrategy::Standard.as_str(), + GET_READER_STREAM_BUFFER_SOURCE_SELECTED, + ) + .with_diagnostics("restic-paperless", "index/41b5a4c2344edb90", "req-reader-stream-short-eof"); + + stream + .collect::>() + .await + .into_iter() + .collect::, _>>() + .expect_err("short reader should fail the streaming body"); + + let event = find_stream_body_event(&captured, "reader_stream_short_eof"); + assert_eq!(event.get("bucket").map(String::as_str), Some("restic-paperless")); + assert_eq!(event.get("object").map(String::as_str), Some("index/41b5a4c2344edb90")); + assert_eq!(event.get("request_id").map(String::as_str), Some("req-reader-stream-short-eof")); + assert_eq!(event.get("expected").map(String::as_str), Some("5")); + assert_eq!(event.get("emitted").map(String::as_str), Some("2")); + assert_eq!(event.get("remaining").map(String::as_str), Some("3")); + } + + /// The inner reader already logged mid-stream failures, but only under a + /// request_id — which cannot be resolved back to an object once the request + /// is gone. Without the identity the report in #4784 was unactionable. + #[tokio::test] + #[serial_test::serial] + async fn get_object_streaming_reader_short_eof_names_the_object() { + use tokio::io::AsyncReadExt; + + let (captured, _guard) = capture_events(); + + let mut reader = GetObjectStreamingReader::new( + std::io::Cursor::new(b"short".to_vec()), + "restic-paperless", + "index/41b5a4c2344edb90", + "req-streaming-short-eof", + None, + 10, + Duration::ZERO, + GetObjectBodyLifecycle::tracked(GetObjectGuard::new()), + None, + ); + + let mut out = Vec::new(); + reader + .read_to_end(&mut out) + .await + .expect_err("short body under a larger Content-Length must fail the stream"); + + let event = find_stream_body_event(&captured, "short_eof"); + assert_eq!(event.get("bucket").map(String::as_str), Some("restic-paperless")); + assert_eq!(event.get("object").map(String::as_str), Some("index/41b5a4c2344edb90")); + assert_eq!(event.get("request_id").map(String::as_str), Some("req-streaming-short-eof")); + } + + #[test] + fn get_object_stream_failure_labels_are_low_cardinality() { + assert_eq!(get_object_stream_failure_reason("short_eof"), GET_STREAMING_BODY_FAILURE_REASON_SHORT_EOF); + assert_eq!( + get_object_stream_failure_reason("timeout"), + GET_STREAMING_BODY_FAILURE_REASON_READER_ERROR + ); + assert_eq!( + get_object_stream_size_bucket(4 * 1024 * 1024), + rustfs_io_metrics::GET_OBJECT_SIZE_BUCKET_GT_1_MIB + ); + } + + #[tokio::test] + async fn disk_read_permit_reader_releases_permit_at_eof() { + use tokio::io::AsyncReadExt; + + let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); + let permit = semaphore.clone().acquire_owned().await.expect("acquire permit"); + assert_eq!(semaphore.available_permits(), 0); + + let mut reader = DiskReadPermitReader::new(std::io::Cursor::new(b"hello".to_vec()), permit.into()); + let mut body = Vec::new(); + reader.read_to_end(&mut body).await.expect("read body"); + assert_eq!(body, b"hello"); + + // The reader is still alive (client hasn't dropped the body), but EOF + // was observed, so the permit must already be back in the semaphore. + assert_eq!(semaphore.available_permits(), 1); + drop(reader); + assert_eq!(semaphore.available_permits(), 1); + } + + #[tokio::test] + async fn build_get_object_output_context_returns_standard_headers() { + let mut metadata = HashMap::new(); + metadata.insert("cache-control".to_string(), "public, max-age=259200".to_string()); + metadata.insert("content-disposition".to_string(), "attachment; filename=\"demo.png\"".to_string()); + + let info = ObjectInfo { + bucket: "test-bucket".to_string(), + name: "path/raw".to_string(), + user_defined: Arc::new(metadata), + ..Default::default() + }; + + let input = GetObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("path/raw".to_string()) + .build() + .unwrap(); + let req = build_request(input, Method::GET); + let usecase = DefaultObjectUsecase::without_context(); + let queue_status = concurrency::IoQueueStatus::default(); + + let context = usecase + .build_get_object_output_context( + &req, + get_concurrency_manager(), + "test-bucket", + "path/raw", + info.clone(), + Some(info), + wrap_reader(tokio::io::empty()), + Some(Bytes::new()), + false, + false, + true, + None, + None, + None, + 0, + None, + "req-output-content-disposition", + None, + None, + None, + None, + false, + Duration::ZERO, + 0.0, + &queue_status, + 1, + None, + false, + GetObjectBodyLifecycle::disabled(), + |_| panic!("a buffered output must not initialize streaming resume state"), + ) + .await + .expect("get object output context"); + + assert_eq!(context.output.cache_control.as_deref(), Some("public, max-age=259200")); + assert_eq!(context.output.content_disposition.as_deref(), Some("attachment; filename=\"demo.png\"")); + assert!( + !context + .output + .metadata + .as_ref() + .is_some_and(|metadata| metadata.contains_key("cache-control")) + ); + assert!( + !context + .output + .metadata + .as_ref() + .is_some_and(|metadata| metadata.contains_key("content-disposition")) + ); + } + + #[tokio::test] + async fn execute_get_object_rejects_zero_part_number() { + let input = GetObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .part_number(Some(0)) + .build() + .unwrap(); + + let req = build_request(input, Method::GET); + let usecase = DefaultObjectUsecase::without_context(); + + let err = Box::pin(usecase.execute_get_object(req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + } + + #[test] + fn parse_get_object_part_number_rejects_above_s3_max() { + let err = parse_part_number_i32_to_usize(Some(10001), "GET").expect_err("partNumber above S3 max must fail"); + + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + assert_eq!(err.message(), Some("GET: partNumber must be between 1 and 10000")); + } + + #[test] + fn validate_get_object_part_number_rejects_missing_part() { + let info = ObjectInfo { + parts: Arc::new(vec![rustfs_filemeta::ObjectPartInfo { + number: 1, + ..Default::default() + }]), + ..Default::default() + }; + + let err = + DefaultObjectUsecase::validate_get_object_part_number(Some(2), &info).expect_err("missing requested part must fail"); + + assert_eq!(err.code(), &S3ErrorCode::InvalidPart); + assert!(DefaultObjectUsecase::validate_get_object_part_number(Some(1), &info).is_ok()); + } + + #[test] + fn cold_fill_conditions_fail_before_phase_probe_advances() { + fn run_phase_probe(headers: &HeaderMap, info: &ObjectInfo) -> (S3Result<()>, [usize; 3]) { + let coordination = AtomicUsize::new(0); + let permit = AtomicUsize::new(0); + let reader = AtomicUsize::new(0); + let result = DefaultObjectUsecase::validate_get_object_before_cold_fill(headers, None, info); + if result.is_ok() { + coordination.fetch_add(1, AtomicOrdering::Relaxed); + permit.fetch_add(1, AtomicOrdering::Relaxed); + reader.fetch_add(1, AtomicOrdering::Relaxed); + } + ( + result, + [ + coordination.load(AtomicOrdering::Relaxed), + permit.load(AtomicOrdering::Relaxed), + reader.load(AtomicOrdering::Relaxed), + ], + ) + } + + let info = ObjectInfo { + etag: Some("phase-etag".to_string()), + parts: Arc::new(vec![rustfs_filemeta::ObjectPartInfo { + number: 1, + ..Default::default() + }]), + ..Default::default() + }; + + let mut not_modified = HeaderMap::new(); + not_modified.insert(http::header::IF_NONE_MATCH, HeaderValue::from_static("\"phase-etag\"")); + let (result, phases) = run_phase_probe(¬_modified, &info); + assert_eq!(result.expect_err("matching If-None-Match must reject").code(), &S3ErrorCode::NotModified); + assert_eq!(phases, [0, 0, 0]); + + let mut precondition_failed = HeaderMap::new(); + precondition_failed.insert(http::header::IF_MATCH, HeaderValue::from_static("\"other-etag\"")); + let (result, phases) = run_phase_probe(&precondition_failed, &info); + assert_eq!( + result.expect_err("mismatched If-Match must reject").code(), + &S3ErrorCode::PreconditionFailed + ); + assert_eq!(phases, [0, 0, 0]); + } + + #[tokio::test] + async fn execute_get_object_rejects_range_with_part_number() { + let input = GetObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .part_number(Some(1)) + .range(Some(Range::Int { first: 0, last: Some(1) })) + .build() + .unwrap(); + + let req = build_request(input, Method::GET); + let usecase = DefaultObjectUsecase::without_context(); + + let err = Box::pin(usecase.execute_get_object(req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + } + + #[tokio::test] + async fn execute_get_object_attributes_returns_internal_error_when_store_uninitialized() { + let input = GetObjectAttributesInput::builder() + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .build() + .unwrap(); + + let req = build_request(input, Method::GET); + let usecase = DefaultObjectUsecase::without_context(); + + let err = usecase.execute_get_object_attributes(req).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InternalError); + } + + #[test] + fn object_attributes_requested_with_single_value() { + let object_attributes = vec![ObjectAttributes::from_static(ObjectAttributes::ETAG)]; + + assert!(object_attributes_requested(&object_attributes, ObjectAttributes::ETAG)); + assert!(!object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_SIZE)); + } + + #[test] + fn object_attributes_requested_with_comma_separated_values() { + let object_attributes = vec![ + ObjectAttributes::from_static("ObjectParts,etag"), + ObjectAttributes::from_static("StorageClass"), + ]; + + assert!(object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_PARTS)); + assert!(object_attributes_requested(&object_attributes, ObjectAttributes::ETAG)); + assert!(!object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_SIZE)); + } + + #[test] + fn object_attributes_requested_with_quotes_and_spaces() { + let object_attributes = vec![ObjectAttributes::from_static("'ObjectSize', \"Checksum\" , \"Etag\"")]; + + assert!(object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_SIZE)); + assert!(object_attributes_requested(&object_attributes, ObjectAttributes::CHECKSUM)); + assert!(object_attributes_requested(&object_attributes, ObjectAttributes::ETAG)); + } + + #[test] + fn object_attributes_requested_returns_false_for_missing_name() { + let object_attributes = vec![ObjectAttributes::from_static("Checksum")]; + + assert!(!object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_SIZE)); + } +} diff --git a/rustfs/src/app/object/head.rs b/rustfs/src/app/object/head.rs new file mode 100644 index 000000000..7c6055773 --- /dev/null +++ b/rustfs/src/app/object/head.rs @@ -0,0 +1,481 @@ +// 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. + +//! HeadObject path. + +use super::*; + +impl DefaultObjectUsecase { + /// Serve a HEAD whose local lookup failed with not-found by proxying to + /// the bucket's replication targets (MinIO `proxyHeadToRepTarget`). + async fn proxy_head_object_to_replication_targets( + req: &S3Request, + bucket: &str, + key: &str, + opts: &ObjectOptions, + ) -> Option { + let targets = get_read_proxy_targets(bucket, key, opts).await; + if targets.is_empty() { + return None; + } + let extra_headers = Self::proxy_read_passthrough_headers(&req.headers); + let range = req + .headers + .get(http::header::RANGE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let part_number = req.input.part_number; + + for target in targets { + match target + .head_object_for_proxy( + &target.bucket, + key, + opts.version_id.clone(), + range.clone(), + part_number, + extra_headers.clone(), + ) + .await + { + Ok(remote) => { + // MinIO-aligned accounting: one total per proxy attempt, + // one failed when no target served it. + record_replication_proxy(bucket, "HeadObject", false).await; + return Some(Self::proxy_sdk_head_output_to_s3s(remote)); + } + Err(err) if Self::proxy_sdk_error_is_not_found(&err) => { + debug!(bucket, key, arn = %target.arn, "read proxy: target does not have the object"); + } + Err(err) => { + warn!(bucket, key, arn = %target.arn, error = %err, "read proxy: HEAD against replication target failed"); + } + } + } + record_replication_proxy(bucket, "HeadObject", true).await; + None + } + + /// Translate a proxied SDK HEAD response into the s3s output. + /// + /// Known gaps: the SDK's HeadObjectOutput does not model 206/Content-Range + /// for a ranged HEAD (the SDK exposes no content_range member on HEAD), + /// and s3s' typed HeadObjectOutput has no tag_count field (the local path + /// injects x-amz-tagging-count as a raw header) — both are dropped for + /// proxied HEADs. + fn proxy_sdk_head_output_to_s3s(remote: aws_sdk_s3::operation::head_object::HeadObjectOutput) -> HeadObjectOutput { + HeadObjectOutput { + content_length: remote.content_length, + content_type: remote.content_type.as_deref().and_then(|v| ContentType::from_str(v).ok()), + content_encoding: remote.content_encoding, + content_disposition: remote.content_disposition, + content_language: remote.content_language, + cache_control: remote.cache_control, + accept_ranges: Some(ACCEPT_RANGES_BYTES.to_string()), + e_tag: remote.e_tag.as_deref().and_then(|v| ETag::from_str(v).ok()), + last_modified: remote + .last_modified + .and_then(|dt| OffsetDateTime::from_unix_timestamp_nanos(dt.as_nanos()).ok()) + .map(Timestamp::from), + metadata: remote.metadata, + version_id: remote.version_id, + server_side_encryption: remote + .server_side_encryption + .map(|sse| ServerSideEncryption::from(sse.as_str().to_string())), + sse_customer_algorithm: remote.sse_customer_algorithm, + sse_customer_key_md5: remote.sse_customer_key_md5, + ssekms_key_id: remote.ssekms_key_id, + parts_count: remote.parts_count, + storage_class: remote.storage_class.map(|sc| StorageClass::from(sc.as_str().to_string())), + expiration: remote.expiration, + restore: remote.restore, + checksum_crc32: remote.checksum_crc32, + checksum_crc32c: remote.checksum_crc32_c, + checksum_crc64nvme: remote.checksum_crc64_nvme, + checksum_sha1: remote.checksum_sha1, + checksum_sha256: remote.checksum_sha256, + checksum_type: remote.checksum_type.map(|ct| ChecksumType::from(ct.as_str().to_string())), + ..Default::default() + } + } + + #[instrument(level = "debug", skip(self, req))] + pub async fn execute_head_object(&self, req: S3Request) -> S3Result> { + if let Some(context) = &self.context { + let _ = context.object_store(); + } + + let mut helper = OperationHelper::new(&req, EventName::ObjectAccessedHead, S3Operation::HeadObject).suppress_event(); + // mc get 2 + let HeadObjectInput { + bucket, + key, + version_id, + part_number, + range, + if_none_match, + if_match, + if_modified_since, + if_unmodified_since, + .. + } = req.input.clone(); + + // Validate object key + validate_object_key(&key, "HEAD")?; + // Parse part number from Option to Option with validation + let part_number: Option = parse_part_number_i32_to_usize(part_number, "HEAD")?; + + let rs = range.map(range_to_http_range_spec).transpose()?; + + if rs.is_some() && part_number.is_some() { + return Err(s3_error!(InvalidArgument, "range and part_number invalid")); + } + + // Establish bucket existence before any bucket-metadata work (matches + // PUT/GET): nonexistent buckets fail here instead of paying the + // versioning lookup in get_opts first. Resolve the store through the + // request-bound server context (backlog#1052 S6), not the + // process-global handle. + let Some(store) = self.object_store() else { + return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); + }; + validate_bucket_exists(&store, &bucket).await?; + + let opts: ObjectOptions = get_opts(&bucket, &key, version_id, part_number, &req.headers) + .await + .map_err(ApiError::from)?; + + // Modification Points: Explicitly handles get_object_info errors, distinguishing between object absence and other errors + let info = match store.get_object_info(&bucket, &key, &opts).await { + Ok(info) => info, + Err(err) => { + // If the error indicates the object or its version was not found, return 404 (NoSuchKey) + if is_err_object_not_found(&err) || is_err_version_not_found(&err) { + if is_dir_object(&key) { + let has_children = match probe_prefix_has_children(store, &bucket, &key, false).await { + Ok(has_children) => has_children, + Err(e) => { + error!(bucket, key, error = %e, "Failed to probe children for prefix"); + false + } + }; + let msg = head_prefix_not_found_message(&bucket, &key, has_children); + return Err(S3Error::with_message(S3ErrorCode::NoSuchKey, msg)); + } + // Active-active replication lag window: an object missing + // locally may still be served by proxying the HEAD to a + // replication target (backlog#1675 P1-5). + if let Some(output) = Self::proxy_head_object_to_replication_targets(&req, &bucket, &key, &opts).await { + let response = wrap_response_with_cors(&bucket, &req.method, &req.headers, output).await; + let result = Ok(response); + let _ = helper + .version_id(req.input.version_id.clone().unwrap_or_default()) + .complete(&result); + return result; + } + return Err(S3Error::new(S3ErrorCode::NoSuchKey)); + } + // Other errors, such as insufficient permissions, still return the original error + return Err(ApiError::from(err).into()); + } + }; + if info.delete_marker { + if opts.version_id.is_none() { + return Err(S3Error::new(S3ErrorCode::NoSuchKey)); + } + return Err(S3Error::new(S3ErrorCode::MethodNotAllowed)); + } + if let Some(match_etag) = if_none_match + && let Some(strong_etag) = match_etag.into_etag() + && info + .etag + .as_ref() + .is_some_and(|etag| ETag::Strong(etag.clone()) == strong_etag) + { + return Err(S3Error::new(S3ErrorCode::NotModified)); + } + if let Some(modified_since) = if_modified_since { + // obj_time < givenTime + 1s + if info.mod_time.is_some_and(|mod_time| { + let give_time: OffsetDateTime = modified_since.into(); + mod_time < give_time.add(time::Duration::seconds(1)) + }) { + return Err(S3Error::new(S3ErrorCode::NotModified)); + } + } + if let Some(match_etag) = if_match { + if let Some(strong_etag) = match_etag.into_etag() + && info + .etag + .as_ref() + .is_some_and(|etag| ETag::Strong(etag.clone()) != strong_etag) + { + return Err(S3Error::new(S3ErrorCode::PreconditionFailed)); + } + } else if let Some(unmodified_since) = if_unmodified_since + && info.mod_time.is_some_and(|mod_time| { + let give_time: OffsetDateTime = unmodified_since.into(); + mod_time > give_time.add(time::Duration::seconds(1)) + }) + { + return Err(S3Error::new(S3ErrorCode::PreconditionFailed)); + } + // An authorized replication convergence check only needs etag/size/mtime + // to compare source and replica; it holds no customer key, so the SSE-C + // read validation is skipped for it (and only it). + let replication_check = replication_request_authorized(&req) + && get_header(&req.headers, SUFFIX_SOURCE_REPLICATION_CHECK).as_deref() == Some("true"); + if !replication_check { + validate_sse_headers_for_read(&info.user_defined, &req.headers)?; + + // Validate SSE-C: if the object was encrypted with a customer-provided key, + // the caller must supply the matching key even for HEAD requests (per S3 spec). + validate_ssec_for_read( + &info.user_defined, + req.input.sse_customer_key.as_ref(), + req.input.sse_customer_key_md5.as_ref(), + )?; + } + + // Compute x-amz-expiration header from lifecycle prediction (before info is partially moved) + let expiration_header = resolve_put_object_expiration(&bucket, &info).await; + // Clone ObjectInfo for event notification only when an event will + // actually be built — the clone is expensive for multipart objects. + let event_info = helper.wants_object_info().then(|| info.clone()); + let content_type = { + if let Some(content_type) = &info.content_type { + match ContentType::from_str(content_type) { + Ok(res) => Some(res), + Err(err) => { + error!(content_type = %content_type, error = ?err, "Archive content-type parse failed"); + // + None + } + } + } else { + None + } + }; + let last_modified = info.mod_time.map(Timestamp::from); + + let content_length = info.get_actual_size().map_err(|e| { + error!(error = %e, "Failed to resolve actual object size"); + ApiError::from(e) + })?; + + let metadata_map = info.user_defined.clone(); + let server_side_encryption = metadata_map + .get("x-amz-server-side-encryption") + .map(|v| ServerSideEncryption::from(v.clone())); + let sse_customer_algorithm = metadata_map + .get("x-amz-server-side-encryption-customer-algorithm") + .map(|v| SSECustomerAlgorithm::from(v.clone())); + let sse_customer_key_md5 = metadata_map.get("x-amz-server-side-encryption-customer-key-md5").cloned(); + let sse_kms_key_id = metadata_map.get("x-amz-server-side-encryption-aws-kms-key-id").cloned(); + let storage_class = response_storage_class(&info, &metadata_map); + // checksum: classify once; additional algorithms (XXHash3/64/128, SHA-512, MD5) + // land in `extra` and are emitted as raw headers below (s3s has no typed field). + let ResponseChecksums { + crc32: checksum_crc32, + crc32c: checksum_crc32c, + sha1: checksum_sha1, + sha256: checksum_sha256, + crc64nvme: checksum_crc64nvme, + checksum_type, + extra: extra_checksum_headers, + } = if let Some(checksum_mode) = req.headers.get(AMZ_CHECKSUM_MODE) + && checksum_mode.to_str().unwrap_or_default() == "ENABLED" + && rs.is_none() + { + let (checksums, is_multipart) = info + .decrypt_checksums(opts.part_number.unwrap_or(0), &req.headers) + .map_err(ApiError::from)?; + classify_response_checksums(checksums, is_multipart) + } else { + ResponseChecksums::default() + }; + // Extract standard HTTP headers from user_defined metadata + // Note: These headers are stored with lowercase keys by extract_metadata_from_mime + let cache_control = metadata_map.get("cache-control").cloned(); + let content_disposition = metadata_map.get("content-disposition").cloned(); + let content_language = metadata_map.get("content-language").cloned(); + let website_redirect_location = metadata_map.get(AMZ_WEBSITE_REDIRECT_LOCATION).cloned(); + let expires = info.expires.map(Timestamp::from); + + // Calculate tag count from user_tags already in ObjectInfo + // This avoids an additional API call since user_tags is already populated by get_object_info + let tag_count = if !info.user_tags.is_empty() { + let tag_set = decode_tags(&info.user_tags); + tag_set.len() + } else { + 0 + }; + let output = HeadObjectOutput { + content_length: Some(content_length), + content_type, + content_encoding: info.content_encoding.clone(), + cache_control, + content_disposition, + content_language, + accept_ranges: Some(ACCEPT_RANGES_BYTES.to_string()), + website_redirect_location, + expires, + last_modified, + e_tag: info.etag.map(|etag| to_s3s_etag(&etag)), + metadata: filter_object_metadata(&metadata_map), + version_id: info.version_id.map(|v| v.to_string()), + server_side_encryption, + sse_customer_algorithm, + sse_customer_key_md5, + ssekms_key_id: sse_kms_key_id, + checksum_crc32, + checksum_crc32c, + checksum_sha1, + checksum_sha256, + checksum_crc64nvme, + checksum_type, + storage_class, + // x-amz-restore from object metadata + restore: metadata_map.get(X_AMZ_RESTORE.as_str()).and_then(|v| { + let rs = parse_restore_obj_status(v).ok()?; + Some(rs.to_string2()) + }), + // x-amz-expiration from lifecycle prediction + expiration: expiration_header, + // metadata: object_metadata, + ..Default::default() + }; + + let version_id = req.input.version_id.clone().unwrap_or_default(); + if let Some(event_info) = event_info { + helper = helper.object(event_info); + } + helper = helper.version_id(version_id); + + // NOTE ON CORS: + // Bucket-level CORS headers are intentionally applied only for object retrieval + // operations (GET/HEAD) via `wrap_response_with_cors`. Other S3 operations that + // interact with objects (PUT/POST/DELETE/LIST, etc.) rely on the system-level + // CORS layer instead. In case both are applicable, this bucket-level CORS logic + // takes precedence for these read operations. + let mut response = wrap_response_with_cors(&bucket, &req.method, &req.headers, output).await; + + // Emit additional-checksum headers (XXHash3/64/128, SHA-512) that s3s cannot + // carry on the typed HeadObjectOutput (#1257). + inject_additional_checksum_headers(&mut response.headers, &extra_checksum_headers); + + // Add x-amz-tagging-count header if object has tags + // Per S3 API spec, this header should be present in HEAD object response when tags exist + if tag_count > 0 { + let header_name = http::HeaderName::from_static(AMZ_TAG_COUNT); + if let Ok(header_value) = tag_count.to_string().parse::() { + response.headers.insert(header_name, header_value); + } else { + warn!("Failed to parse x-amz-tagging-count header; skipping"); + } + } + if let Some(retain_date) = metadata_map + .get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER) + .or_else(|| metadata_map.get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE)) + && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.as_bytes()) + && let Ok(header_value) = HeaderValue::from_str(retain_date) + { + response.headers.insert(header_name, header_value); + } + if let Some(mode) = metadata_map + .get(AMZ_OBJECT_LOCK_MODE_LOWER) + .or_else(|| metadata_map.get(AMZ_OBJECT_LOCK_MODE)) + && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_OBJECT_LOCK_MODE_LOWER.as_bytes()) + && let Ok(header_value) = HeaderValue::from_str(mode) + { + response.headers.insert(header_name, header_value); + } + if let Some(legal_hold) = metadata_map + .get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER) + .or_else(|| metadata_map.get(AMZ_OBJECT_LOCK_LEGAL_HOLD)) + && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER.as_bytes()) + && let Ok(header_value) = HeaderValue::from_str(legal_hold) + { + response.headers.insert(header_name, header_value); + } + + if let Some(amz_restore) = metadata_map.get(X_AMZ_RESTORE.as_str()) { + let Ok(restore_status) = parse_restore_obj_status(amz_restore) else { + return Err(S3Error::with_message(S3ErrorCode::Custom("ErrMeta".into()), "parse amz_restore failed.")); + }; + if let Ok(header_value) = HeaderValue::from_str(restore_status.to_string2().as_str()) { + response.headers.insert(X_AMZ_RESTORE, header_value); + } + } + if let Some(amz_restore_request_date) = metadata_map.get(AMZ_RESTORE_REQUEST_DATE) + && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_RESTORE_REQUEST_DATE.as_bytes()) + { + let Ok(amz_restore_request_date) = OffsetDateTime::parse(amz_restore_request_date, &Rfc3339) else { + return Err(S3Error::with_message( + S3ErrorCode::Custom("ErrMeta".into()), + "parse amz_restore_request_date failed.", + )); + }; + let Ok(amz_restore_request_date) = amz_restore_request_date.format(&RFC1123) else { + return Err(S3Error::with_message( + S3ErrorCode::Custom("ErrMeta".into()), + "format amz_restore_request_date failed.", + )); + }; + if let Ok(header_value) = HeaderValue::from_str(&amz_restore_request_date) { + response.headers.insert(header_name, header_value); + } + } + if let Some(amz_restore_expiry_days) = metadata_map.get(AMZ_RESTORE_EXPIRY_DAYS) + && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_RESTORE_EXPIRY_DAYS.as_bytes()) + && let Ok(header_value) = HeaderValue::from_str(amz_restore_expiry_days) + { + response.headers.insert(header_name, header_value); + } + if info.replication_status != ReplicationStatusType::Empty + && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_BUCKET_REPLICATION_STATUS.to_ascii_lowercase().as_bytes()) + && let Ok(header_value) = HeaderValue::from_str(info.replication_status.as_str()) + { + response.headers.insert(header_name, header_value); + } + + let result = Ok(response); + let _ = helper.complete(&result); + + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http::Method; + + #[tokio::test] + async fn execute_head_object_rejects_range_with_part_number() { + let input = HeadObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .part_number(Some(1)) + .range(Some(Range::Int { first: 0, last: Some(1) })) + .build() + .unwrap(); + + let req = build_request(input, Method::HEAD); + let usecase = DefaultObjectUsecase::without_context(); + + let err = usecase.execute_head_object(req).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); + } +} diff --git a/rustfs/src/app/object/mod.rs b/rustfs/src/app/object/mod.rs new file mode 100644 index 000000000..cb30c01b9 --- /dev/null +++ b/rustfs/src/app/object/mod.rs @@ -0,0 +1,346 @@ +// 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. + +//! Object application use-case contracts. + +// Performance metrics recording (with zero-copy-metrics integration) +use rustfs_io_metrics::buffered_write; + +use crate::storage_api::table::get_bucket_metadata; + +use super::storage_api::object_usecase::access::{ + PostObjectRequestMarker, apply_bucket_generation_guard, apply_copy_source_bucket_generation_guard, authorize_request, + has_bypass_governance_header, load_bucket_generation_from_store, recursive_force_delete_is_authorized, + replication_request_authorized, req_info_mut, req_info_ref, +}; +#[cfg(test)] +use super::storage_api::object_usecase::bucket::quota::BucketQuota; +use super::storage_api::object_usecase::bucket::quota::checker::QuotaChecker; +#[cfg(test)] +use super::storage_api::object_usecase::bucket::replication::{ReplicationState, replication_statuses_map}; +use super::storage_api::object_usecase::bucket::{ + VersioningConfigExt as _, + lifecycle::{ + bucket_lifecycle_audit::LcEventSrc, + bucket_lifecycle_ops::{enqueue_transition_immediate, post_restore_opts}, + lifecycle::{self, TransitionOptions}, + }, + metadata_sys, + object_lock::{ + objectlock::{get_object_legalhold_meta, get_object_retention_meta}, + objectlock_sys::{check_object_lock_for_deletion, is_retention_active, replication_write_may_pass_worm_gate}, + types::RetentionMode, + }, + predict_lifecycle_expiration, + quota::{QuotaCheckResult, QuotaError, QuotaOperation}, + replication::{ + DeleteReplicationConfigSnapshot, REPLICATE_INCOMING_DELETE, ReplicationStatusType, commit_force_delete_intent, + delete_replication_state_from_config, delete_replication_version_id, deleted_object_has_pending_replication_delete, + force_delete_target_set, get_read_proxy_targets, has_active_delete_rule, load_delete_config_snapshot, + must_replicate_object, persist_force_delete_intent, record_replication_proxy, schedule_object_replication, + schedule_replication_delete, schedule_replication_deletes, set_deleted_object_replication_state, + should_schedule_delete_replication, should_use_existing_delete_replication_info, + }, + tagging::decode_tags, + validate_restore_request, + versioning_sys::BucketVersioningSys, +}; +use super::storage_api::object_usecase::compression::{MIN_DISK_COMPRESSIBLE_SIZE, is_disk_compressible}; +use super::storage_api::object_usecase::concurrency::{ + self, ConcurrencyManager, DiskReadAdmission, GetObjectGuard, PutObjectAdmission, PutObjectGuard, + get_concurrency_aware_buffer_size, get_concurrency_manager, get_put_concurrency_aware_buffer_size, +}; +#[cfg(test)] +use super::storage_api::object_usecase::contract::http::HTTPPreconditions; +use super::storage_api::object_usecase::contract::namespace::NamespaceLocking; +use super::storage_api::object_usecase::contract::object::{ObjectIO as _, ObjectOperations as _}; +use super::storage_api::object_usecase::contract::range::HTTPRangeSpec; +use super::storage_api::object_usecase::data_usage::{ + quota_object_size, record_bucket_delete_marker_memory, record_bucket_object_delete_memory, + record_bucket_object_version_write_memory, record_bucket_object_write_memory, + record_bucket_object_write_unknown_previous_memory, +}; +use super::storage_api::object_usecase::deadlock_detector; +use super::storage_api::object_usecase::ecfs::FS; +use super::storage_api::object_usecase::error::{ + Error as EcstoreError, StorageError, is_err_bucket_not_found, is_err_object_not_found, is_err_version_not_found, +}; +use super::storage_api::object_usecase::head_prefix::{head_prefix_not_found_message, probe_prefix_has_children}; +use super::storage_api::object_usecase::helper::{OperationHelper, build_event_resp_elements, spawn_background_with_context}; +use super::storage_api::object_usecase::io::{DynReader, HashReader, WritePlan, compression_metadata_value, wrap_reader}; +#[cfg(test)] +use super::storage_api::object_usecase::object_cache::GetObjectBodySource; +#[cfg(test)] +use super::storage_api::object_usecase::object_cache::lookup_get_object_body_cache_hook; +use super::storage_api::object_usecase::object_cache::{GetObjectBodyCacheHookLookup, get_object_body_cache_plaintext_len}; +use super::storage_api::object_usecase::object_utils::to_s3s_etag; +use super::storage_api::object_usecase::options::{ + copy_dst_opts_with_replication_authorization, copy_src_opts, del_opts_with_versioning, extract_metadata, + extract_metadata_from_mime_with_object_name, filter_object_metadata, get_content_sha256_with_query, get_opts, + has_replication_retention_update, namespace_reserved_user_metadata, normalize_content_encoding_for_storage, + preserve_unclassified_user_metadata, put_opts_with_replication_authorization, validate_archive_content_encoding, +}; +use super::storage_api::object_usecase::request_context::{self, spawn_traced, spawn_traced_join}; +use super::storage_api::object_usecase::s3_api::multipart::parse_list_parts_params; +use super::storage_api::object_usecase::set_disk::{ + get_lock_acquire_timeout, get_object_disk_read_timeout, is_valid_storage_class, +}; +use super::storage_api::object_usecase::sse::{ + DecryptionRequest, EncryptionRequest, SseKmsPrincipal, apply_bucket_default_lock_retention, authorize_sse_kms_object_read, + bucket_default_write_sse, build_ssec_read_headers, classify_sse_read_response, encryption_material_to_metadata, + extract_server_side_encryption_from_headers, extract_ssec_params_from_headers, extract_ssekms_context_from_headers, + get_buffer_size_opt_in, load_bucket_object_lock_config_state, map_get_object_reader_error, sse_encryption, + validate_bucket_object_lock_enabled_state, +}; +use super::storage_api::object_usecase::storage_class as storageclass; +use super::storage_api::object_usecase::timeout_wrapper::{GetObjectTimeoutPolicy, RequestTimeoutWrapper}; +use super::storage_api::object_usecase::{ECStore, OldCurrentSize}; +use super::storage_api::object_usecase::{ + RFC1123, check_preconditions, parse_object_lock_legal_hold, parse_object_lock_retention, parse_part_number_i32_to_usize, + remove_object_lock_metadata_for_copy, strip_managed_encryption_metadata, validate_bucket_exists, validate_object_key, + validate_sse_headers_for_read, validate_sse_headers_for_write, validate_ssec_for_read, wrap_response_with_cors, +}; +use crate::app::runtime_sources::{ + AppContext, current_app_context, current_notify_interface_for_context, current_object_data_cache_for_context, + current_object_store_handle_for_context, +}; +use crate::config::RustFSBufferConfig; +use crate::delete_tail_activity::{DeleteTailActivityGuard, DeleteTailStage}; +use crate::error::ApiError; +use crate::shared_types::convert_ecstore_object_info; +use crate::table_catalog; +use bytes::{BufMut as _, Bytes, BytesMut}; +use futures::{Stream, StreamExt, TryStreamExt}; +use http::{HeaderMap, HeaderValue, StatusCode}; +use md5::{Digest as Md5Digest, Md5}; +use metrics::{counter, histogram}; +use pin_project_lite::pin_project; +use rustfs_audit::ObjectVersion as AuditObjectVersion; +use rustfs_concurrency::GetObjectQueueSnapshot; +use rustfs_config::MI_B; +use rustfs_filemeta::{NULL_VERSION_ID, RestoreStatusOps, parse_restore_obj_status}; +use rustfs_io_core::{BytesPool, PooledBuffer}; +use rustfs_io_metrics; +use rustfs_lock::NamespaceLockGuard; +use rustfs_notify::EventArgsBuilder; +use rustfs_object_capacity::capacity_manager::get_capacity_manager; +use rustfs_policy::policy::action::{Action, S3Action}; +use rustfs_s3_ops::{S3Operation, delete_event_name_for_marker, put_event_name_for_post_object}; +use rustfs_targets::{EventName, get_request_host, get_request_port, get_request_user_agent}; +use rustfs_utils::CompressionAlgorithm; +#[cfg(test)] +use rustfs_utils::http::headers::{SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER}; +#[cfg(test)] +use rustfs_utils::http::insert_header; +use rustfs_utils::http::{ + AMZ_BUCKET_REPLICATION_STATUS, AMZ_CHECKSUM_MODE, AMZ_CHECKSUM_TYPE, AMZ_WEBSITE_REDIRECT_LOCATION, CONTENT_TYPE, + SUFFIX_ACTUAL_SIZE, SUFFIX_COMPRESSION, SUFFIX_COMPRESSION_SIZE, SUFFIX_REPLICA_STATUS, SUFFIX_REPLICA_TIMESTAMP, + SUFFIX_REPLICATION_STATUS, SUFFIX_REPLICATION_TIMESTAMP, SUFFIX_RESTORE_OPERATION_ID, SUFFIX_SOURCE_REPLICATION_CHECK, + SUFFIX_SOURCE_REPLICATION_REQUEST, get_header, + headers::{ + AMZ_CONTENT_SHA256, AMZ_DECODED_CONTENT_LENGTH, AMZ_MINIO_SNOWBALL_IGNORE_DIRS, AMZ_MINIO_SNOWBALL_IGNORE_ERRORS, + AMZ_MINIO_SNOWBALL_PREFIX, AMZ_OBJECT_LOCK_LEGAL_HOLD, AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE, + AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, + AMZ_OBJECT_TAGGING, AMZ_RESTORE_EXPIRY_DAYS, AMZ_RESTORE_REQUEST_DATE, AMZ_RUSTFS_SNOWBALL_IGNORE_DIRS, + AMZ_RUSTFS_SNOWBALL_IGNORE_ERRORS, AMZ_RUSTFS_SNOWBALL_PREFIX, AMZ_SERVER_SIDE_ENCRYPTION, + AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM, AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID, AMZ_SNOWBALL_EXTRACT, + AMZ_SNOWBALL_IGNORE_DIRS, AMZ_SNOWBALL_IGNORE_ERRORS, AMZ_SNOWBALL_PREFIX, AMZ_STORAGE_CLASS, AMZ_TAG_COUNT, + }, + insert_str, project_ssec_transport_headers, remove_str, +}; +use rustfs_utils::path::{encode_dir_object, is_dir_object, path_join_buf}; +use rustfs_utils::retry::{DEFAULT_RETRY_CAP, DEFAULT_RETRY_UNIT, MAX_JITTER, RetryTimer}; +use rustfs_zip::{ArchiveLimits, CompressionFormat}; +use s3s::StdError; +use s3s::dto::{ + CacheControl, Checksum, ChecksumAlgorithm, ChecksumType, ContentDisposition, ContentEncoding, ContentLanguage, ContentType, + CopyObjectInput, CopyObjectOutput, CopyObjectResult, CopySource, DeleteObjectInput, DeleteObjectOutput, DeleteObjectsInput, + DeleteObjectsOutput, DeletedObject, ETag, GetObjectAttributesInput, GetObjectAttributesOutput, GetObjectAttributesParts, + GetObjectInput, GetObjectOutput, HeadObjectInput, HeadObjectOutput, MetadataDirective, ObjectAttributes, ObjectLockLegalHold, + ObjectLockLegalHoldStatus, ObjectLockMode, ObjectLockRetention, ObjectLockRetentionMode, ObjectPart, PutObjectInput, + PutObjectOutput, Range, RequestCharged, RestoreObjectInput, RestoreObjectOutput, RestoreStatus, SSECustomerAlgorithm, + SSECustomerKeyMD5, SSEKMSKeyId, SelectObjectContentInput, SelectObjectContentOutput, ServerSideEncryption, + ServerSideEncryptionConfiguration, StorageClass, StreamingBlob, TaggingDirective, TaggingHeader, Timestamp, TimestampFormat, + WebsiteRedirectLocation, +}; +use s3s::header::{X_AMZ_RESTORE, X_AMZ_RESTORE_OUTPUT_PATH}; +use s3s::stream::{ByteStream, DynByteStream, RemainingLength}; +use s3s::{S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error}; + +mod copy; +mod delete; +mod extract; +mod get; +mod head; +mod put; +mod restore; +mod shared; +#[cfg(test)] +mod test_support; + +pub(crate) use self::copy::*; +#[cfg(test)] +pub(crate) use self::delete::*; +pub(crate) use self::extract::*; +pub(crate) use self::get::*; +use self::put::*; +pub(crate) use self::shared::*; +#[cfg(test)] +use self::test_support::*; + +use std::collections::HashMap; +use std::io; +use std::ops::Add; +use std::path::Path; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use std::str::FromStr; +#[cfg(test)] +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use tokio::io::{AsyncRead, ReadBuf}; +use tokio::sync::{OwnedSemaphorePermit, RwLock}; +use tokio_tar::Archive; +#[cfg(test)] +use tokio_util::io::ReaderStream; +use tokio_util::io::{StreamReader, poll_read_buf}; +use tracing::{debug, error, instrument, warn}; +use uuid::Uuid; + +use super::storage_api::object_usecase::{ + BUCKET_LIFECYCLE_LOCK_OBJECT, GetObjectReader, StorageDeletedObject, StorageObjectInfo as ObjectInfo, + StorageObjectLockDeleteOptions, StorageObjectOptions as ObjectOptions, StorageObjectToDelete as ObjectToDelete, + StoragePutObjReader as PutObjReader, +}; +use crate::app::object_data_cache::{ + ColdFillCoordinateOutcome, ColdFillDiskPermitOwner, ColdFillError, ColdFillProducer, GetObjectBodyCacheLookup, + GetObjectBodyCachePlan, GetObjectBodyCacheRequest, ObjectDataCacheAdapter, build_get_object_body_cache_plan, + build_get_object_body_cache_plan_for_revalidation, coordinate_cold_fill, current_cold_fill_disk_permit_owner, + fill_get_object_body_cache_from_buffered_body, fill_get_object_body_cache_from_materialized_body, + invalidate_object_data_cache_after_copy_success, invalidate_object_data_cache_after_delete_success, + invalidate_object_data_cache_after_put_success, invalidate_object_data_cache_before_mutation, + invalidate_object_data_cache_objects_after_delete_success, invalidate_object_data_cache_objects_before_mutation, + invalidate_object_data_cache_prefix_after_delete, invalidate_object_data_cache_prefix_before_mutation, + lookup_get_object_body_cache_hit, lookup_preplanned_get_object_body_cache_hook, +}; +#[cfg(test)] +use crate::app::object_data_cache::{ColdFillRole, ColdFillWaitOutcome, scope_cold_fill_disk_permit_owner_for_test}; +use crate::app::object_traffic_health::ObjectTrafficHealth; + +#[derive(Clone, Default)] +pub struct DefaultObjectUsecase { + context: Option>, + #[cfg(test)] + get_object_timeout_policy: Option, +} + +impl DefaultObjectUsecase { + #[cfg(test)] + pub fn without_context() -> Self { + Self { + context: None, + get_object_timeout_policy: None, + } + } + + pub fn from_global() -> Self { + Self { + context: current_app_context(), + #[cfg(test)] + get_object_timeout_policy: None, + } + } + + /// Build the use-case bound to an explicit application context + /// (backlog#1052 S6): the per-server request path passes its own context + /// so the use-case resolves that server's store; `None` falls back to the + /// ambient default. + pub fn with_context(context: Option>) -> Self { + Self { + context, + #[cfg(test)] + get_object_timeout_policy: None, + } + } + + #[cfg(test)] + fn with_context_and_get_object_timeout_policy( + context: Option>, + get_object_timeout_policy: GetObjectTimeoutPolicy, + ) -> Self { + Self { + context, + get_object_timeout_policy: Some(get_object_timeout_policy), + } + } + + fn bucket_metadata_sys(&self) -> Option>> { + self.context.as_ref().and_then(|context| context.bucket_metadata().handle()) + } + + fn object_store(&self) -> Option> { + current_object_store_handle_for_context(self.context.as_deref()) + } + + fn object_data_cache(&self) -> Arc { + current_object_data_cache_for_context(self.context.as_deref()) + } + + fn object_traffic_health(&self) -> Option> { + self.context + .as_ref() + .map(|context| context.object_traffic_health()) + .or_else(|| current_app_context().map(|context| context.object_traffic_health())) + } + + fn base_buffer_size(&self) -> usize { + self.context + .clone() + .or_else(current_app_context) + .map(|context| context.buffer_config().get().base_config.default_unknown) + .unwrap_or_else(|| RustFSBufferConfig::default().base_config.default_unknown) + } + + async fn check_bucket_quota(&self, bucket: &str, op: QuotaOperation, size: u64) -> S3Result> { + let Some(metadata_sys) = self.bucket_metadata_sys() else { + return Ok(None); + }; + let quota_checker = QuotaChecker::new(metadata_sys); + map_quota_check_outcome(bucket, quota_checker.check_quota(bucket, op, size).await).map(Some) + } + + #[hotpath::measure( + label = "rustfs::app::object_usecase::DefaultObjectUsecase::execute_put_object", + impl_type = "DefaultObjectUsecase" + )] + #[hotpath::measure( + label = "rustfs::app::object_usecase::DefaultObjectUsecase::execute_get_object", + impl_type = "DefaultObjectUsecase" + )] + #[instrument(level = "debug", skip(self, req))] + pub async fn execute_select_object_content( + &self, + req: S3Request, + ) -> S3Result> { + if let Some(context) = &self.context { + let _ = context.object_store(); + } + + crate::app::select_object::execute_select_object_content(req).await + } +} diff --git a/rustfs/src/app/object/put.rs b/rustfs/src/app/object/put.rs new file mode 100644 index 000000000..2638cea36 --- /dev/null +++ b/rustfs/src/app/object/put.rs @@ -0,0 +1,3315 @@ +// 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. + +//! PutObject write path: body admission, eager commit, zero-copy tuning. + +use super::*; + +use crate::auth::{RUSTFS_MAX_CONTENT_LENGTH_QUERY, VerifiedPresignedRequest, parse_presigned_put_max_content_length}; +use crate::error::UploadLimitExceeded; + +const DEFAULT_PUT_LARGE_CONCURRENCY_TUNING_MIN_SIZE_BYTES: i64 = 32 * 1024 * 1024; + +const ENV_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES: &str = "RUSTFS_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES"; + +const DEFAULT_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES: usize = 16 * 1024 * 1024; + +const PUT_EAGER_STATUS_ELIGIBLE: &str = "eligible"; + +const PUT_EAGER_STATUS_EXTRACT: &str = "extract"; + +const PUT_EAGER_STATUS_COMPRESSED: &str = "compressed"; + +const PUT_EAGER_STATUS_ENCRYPTED: &str = "encrypted"; + +const PUT_EAGER_STATUS_INVALID_SIZE: &str = "invalid_size"; + +const PUT_EAGER_STATUS_ABOVE_EAGER_MAX: &str = "above_eager_max"; + +const PUT_EAGER_STATUS_ZERO_COPY_INELIGIBLE: &str = "zero_copy_ineligible"; + +const PUT_EAGER_STATUS_AWS_CHUNKED_MISSING_DECODED_LENGTH: &str = "aws_chunked_missing_decoded_length"; + +static CACHED_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES: std::sync::OnceLock = std::sync::OnceLock::new(); + +const EVENT_PUT_OBJECT_STORE_INFLIGHT_SLOW: &str = "put_object_store_inflight_slow"; + +const EVENT_PUT_OBJECT_STORE_RETURNED: &str = "put_object_store_returned"; + +const EVENT_PUT_OBJECT_COMMIT_OWNER_DEADLINE: &str = "put_object_commit_owner_deadline"; + +const EVENT_PUT_OBJECT_BODY_READ_STALLED: &str = "put_object_body_read_stalled"; + +const PUT_OBJECT_STORE_WARN_THRESHOLD: Duration = Duration::from_secs(5); + +// Eager PUT bodies are fully materialized before the storage owner starts. On +// request cancellation, keep the commit/publication tail alive briefly, then +// request pre-commit rollback and await cleanup so its write-health guard is +// reaped without abandoning staged shards. +const EAGER_PUT_COMMIT_CANCELLATION_GRACE: Duration = + Duration::from_secs(rustfs_config::DEFAULT_DRIVE_MAX_TIMEOUT_DURATION_SECS * 4); + +/// Resolve the authoritative object length that bucket-quota admission (and downstream sizing) must use. +/// +/// `Content-Encoding: aws-chunked` alone only *declares* the encoding; whether the body actually arrived chunk-framed is signalled by a `STREAMING-*` `x-amz-content-sha256`, and the S3 auth layer both requires `x-amz-decoded-content-length` for those requests and hands the body down already de-framed. So when a decoded length is present it is authoritative (the wire `Content-Length` counts chunk framing and would overcount); a framed body without a decoded length is rejected rather than falling back to the framed wire length. A declared-only aws-chunked request (issue #1857 clients) carries an unframed body, so its wire `Content-Length` is the authoritative size, exactly as for a plain PUT. A negative or otherwise unknown length is rejected so it can never be reinterpreted as an enormous unsigned size downstream. +fn resolve_put_object_authoritative_size(headers: &HeaderMap, content_length: Option) -> S3Result { + let decoded_content_length = decoded_content_length_from_headers(headers)?; + let aws_chunked = request_uses_aws_chunked(headers) || request_body_is_aws_chunked_framed(headers); + let size = match (aws_chunked, decoded_content_length, content_length) { + (true, Some(decoded), _) => decoded, + // Declared aws-chunked without a streaming payload: the body is not framed (the auth + // layer only de-frames STREAMING-* payloads, which always carry a decoded length), so + // the wire Content-Length is the real object size. + (true, None, Some(raw)) if !request_body_is_aws_chunked_framed(headers) => raw, + (true, None, _) => return Err(s3_error!(UnexpectedContent)), + (false, _, Some(raw)) => raw, + (false, Some(decoded), None) => decoded, + (false, None, None) => return Err(s3_error!(UnexpectedContent)), + }; + + if size < 0 { + return Err(s3_error!(UnexpectedContent)); + } + + Ok(size) +} + +/// Resolve the S3 request-body inter-chunk read timeout from the environment. +/// +/// Returns `Duration::ZERO` when disabled (`RUSTFS_HTTP_REQUEST_BODY_READ_TIMEOUT=0`), +/// in which case [`guard_put_object_body_read_timeout`] passes the body through +/// untouched. +fn put_object_body_read_timeout() -> Duration { + Duration::from_secs(rustfs_utils::get_env_u64( + rustfs_config::ENV_HTTP_REQUEST_BODY_READ_TIMEOUT, + rustfs_config::DEFAULT_HTTP_REQUEST_BODY_READ_TIMEOUT, + )) +} + +/// A [`ByteStream`] decorator that aborts a request body whose peer stops +/// sending bytes without closing the connection. +/// +/// A well-behaved short body ends with EOF and is rejected promptly by the +/// eager/streaming readers. The failure this guards against is different: a +/// reverse proxy or CDN forwards a *partial* body and then goes silent while +/// holding the connection open, so the inner stream neither yields more bytes +/// nor reports EOF. Without a bound, RustFS would wait forever for bytes that +/// never arrive and the client eventually sees a hang/abort with no server-side +/// explanation (issue #3076). +/// +/// The timer resets on every chunk, so slow-but-progressing uploads are not +/// penalized; it only fires after `timeout` of complete silence. On timeout the +/// stall is logged with the received/expected byte counts and the read fails +/// with an `ErrorKind::TimedOut` error instead of hanging. +/// +/// `remaining_length` and `size_hint` are forwarded from the inner stream so +/// wrapping is transparent to length/content handling downstream. +struct RequestBodyReadTimeout { + inner: DynByteStream, + timeout: Duration, + timer: Option>>, + received: u64, + expected: Option, + bucket: String, + key: String, + request_id: String, + timed_out: bool, +} + +/// Enforces a maximum size on the decoded request entity while preserving the +/// streaming behavior of the underlying S3 body. +struct MaxContentLengthStream { + inner: StreamingBlob, + limit: u64, + received: u64, + exceeded: bool, +} + +impl Stream for MaxContentLengthStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.as_mut().get_mut(); + if this.exceeded { + return Poll::Ready(None); + } + + match Pin::new(&mut this.inner).poll_next(cx) { + Poll::Ready(Some(Ok(chunk))) => { + let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX); + let exceeds = this.received > this.limit || chunk_len > this.limit.saturating_sub(this.received); + if exceeds { + this.exceeded = true; + return Poll::Ready(Some(Err(Box::new(UploadLimitExceeded { limit: this.limit })))); + } + + this.received = this.received.saturating_add(chunk_len); + Poll::Ready(Some(Ok(chunk))) + } + other => other, + } + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = usize::try_from(self.limit.saturating_sub(self.received)).unwrap_or(usize::MAX); + let (lower, upper) = self.inner.size_hint(); + (lower.min(remaining), upper.map(|upper| upper.min(remaining))) + } +} + +impl ByteStream for MaxContentLengthStream { + fn remaining_length(&self) -> RemainingLength { + let remaining = usize::try_from(self.limit.saturating_sub(self.received)).unwrap_or(usize::MAX); + let inner = self.inner.remaining_length(); + inner + .exact() + .map(|exact| RemainingLength::new_exact(exact.min(remaining))) + .unwrap_or_else(RemainingLength::unknown) + } +} + +impl Stream for RequestBodyReadTimeout { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + + // Once we have surfaced a stall error, treat the stream as terminated so + // we never poll the abandoned inner stream again. + if this.timed_out { + return Poll::Ready(None); + } + + match Pin::new(&mut this.inner).poll_next(cx) { + Poll::Ready(Some(Ok(chunk))) => { + this.timer = None; + this.received = this.received.saturating_add(chunk.len() as u64); + Poll::Ready(Some(Ok(chunk))) + } + Poll::Ready(other) => { + this.timer = None; + Poll::Ready(other) + } + Poll::Pending => { + if this.timeout.is_zero() { + return Poll::Pending; + } + + if this.timer.is_none() { + this.timer = Some(Box::pin(tokio::time::sleep(this.timeout))); + } + + if let Some(timer) = this.timer.as_mut() + && std::future::Future::poll(timer.as_mut(), cx).is_ready() + { + this.timer = None; + this.timed_out = true; + let expected_display = this.expected.map(|v| v.to_string()).unwrap_or_else(|| "unknown".to_string()); + warn!( + target: "rustfs::app::object_usecase", + event = EVENT_PUT_OBJECT_BODY_READ_STALLED, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + request_id = %this.request_id, + bucket = %this.bucket, + key = %this.key, + received_bytes = this.received, + expected_bytes = %expected_display, + timeout_secs = this.timeout.as_secs(), + state = "stall_timeout", + "PutObject request body read stalled; aborting. A proxy/CDN likely forwarded a partial body without closing the connection." + ); + return Poll::Ready(Some(Err(Box::new(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!( + "request body read stalled: received {} of {} bytes, no data for {}s", + this.received, + expected_display, + this.timeout.as_secs() + ), + )) as StdError))); + } + + Poll::Pending + } + } + } + + fn size_hint(&self) -> (usize, Option) { + self.inner.size_hint() + } +} + +impl ByteStream for RequestBodyReadTimeout { + fn remaining_length(&self) -> RemainingLength { + self.inner.remaining_length() + } +} + +/// Wrap an incoming request body with [`RequestBodyReadTimeout`] unless the +/// feature is disabled (`timeout == 0`), in which case the body is returned +/// untouched. `remaining_length` is preserved via [`StreamingBlob::new`]. +fn guard_put_object_body_read_timeout( + body: StreamingBlob, + bucket: &str, + key: &str, + request_id: &str, + expected: Option, + timeout: Duration, +) -> StreamingBlob { + if timeout.is_zero() { + return body; + } + + StreamingBlob::new(RequestBodyReadTimeout { + inner: body.into(), + timeout, + timer: None, + received: 0, + expected: expected.and_then(|v| u64::try_from(v).ok()), + bucket: bucket.to_string(), + key: key.to_string(), + request_id: request_id.to_string(), + timed_out: false, + }) +} + +struct PooledBufferReader { + buffer: PooledBuffer, + len: usize, + pos: usize, +} + +impl PooledBufferReader { + fn new(buffer: PooledBuffer, len: usize) -> Self { + Self { buffer, len, pos: 0 } + } +} + +impl AsyncRead for PooledBufferReader { + fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + if self.pos >= self.len { + return Poll::Ready(Ok(())); + } + + let remaining = self.len - self.pos; + let to_read = remaining.min(buf.remaining()); + buf.put_slice(&self.buffer[self.pos..self.pos + to_read]); + self.pos += to_read; + + Poll::Ready(Ok(())) + } +} + +struct ChunkedBytesReader { + chunks: Vec, + chunk_index: usize, + chunk_offset: usize, +} + +impl ChunkedBytesReader { + fn new(chunks: Vec) -> Self { + Self { + chunks, + chunk_index: 0, + chunk_offset: 0, + } + } +} + +impl AsyncRead for ChunkedBytesReader { + fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + while self.chunk_index < self.chunks.len() { + let chunk = &self.chunks[self.chunk_index]; + if self.chunk_offset >= chunk.len() { + self.chunk_index += 1; + self.chunk_offset = 0; + continue; + } + + let remaining = &chunk[self.chunk_offset..]; + let to_read = remaining.len().min(buf.remaining()); + buf.put_slice(&remaining[..to_read]); + self.chunk_offset += to_read; + return Poll::Ready(Ok(())); + } + + Poll::Ready(Ok(())) + } +} + +/// Determine if zero-copy write should be used for this PutObject operation. +/// +/// Zero-copy is beneficial for large objects without encryption or compression. +/// +/// # Arguments +/// +/// * `size` - Object size in bytes +/// * `headers` - HTTP headers (to check for encryption/compression) +/// +/// # Returns +/// +/// `true` if zero-copy should be used, `false` otherwise +fn should_use_zero_copy(size: i64, headers: &HeaderMap) -> bool { + // Only use zero-copy for objects larger than 1MB + const ZERO_COPY_MIN_SIZE: i64 = 1024 * 1024; + + if size <= ZERO_COPY_MIN_SIZE { + return false; + } + + // Don't use zero-copy if encryption is requested + if headers.get(AMZ_SERVER_SIDE_ENCRYPTION).is_some() + || headers.get(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM).is_some() + || headers.get(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID).is_some() + { + return false; + } + + // Don't use zero-copy if compression is likely (compressible content types) + // The compression check happens later in the flow + if let Some(content_type) = headers.get(CONTENT_TYPE) + && let Ok(ct) = content_type.to_str() + { + // Skip zero-copy for easily compressible content types + // since compression will be applied + let compressible_types = [ + "text/plain", + "text/html", + "text/css", + "text/javascript", + "application/javascript", + "application/json", + "application/xml", + "text/xml", + ]; + for ct_type in compressible_types { + if ct.contains(ct_type) { + return false; + } + } + } + + true +} + +#[cfg(test)] +fn should_use_zero_copy_eager_put_path( + size: i64, + headers: &HeaderMap, + server_side_encryption_requested: bool, + should_compress: bool, + is_extract: bool, +) -> bool { + zero_copy_eager_put_path_status(size, headers, server_side_encryption_requested, should_compress, is_extract) + == PUT_EAGER_STATUS_ELIGIBLE +} + +fn zero_copy_eager_put_path_status( + size: i64, + headers: &HeaderMap, + server_side_encryption_requested: bool, + should_compress: bool, + is_extract: bool, +) -> &'static str { + zero_copy_eager_put_path_status_with_max_size( + size, + headers, + server_side_encryption_requested, + should_compress, + is_extract, + zero_copy_eager_put_max_size_bytes(), + ) +} + +fn zero_copy_eager_put_path_status_with_max_size( + size: i64, + headers: &HeaderMap, + server_side_encryption_requested: bool, + should_compress: bool, + is_extract: bool, + max_size: i64, +) -> &'static str { + if is_extract { + return PUT_EAGER_STATUS_EXTRACT; + } + if should_compress { + return PUT_EAGER_STATUS_COMPRESSED; + } + if server_side_encryption_requested { + return PUT_EAGER_STATUS_ENCRYPTED; + } + + if size <= 0 { + return PUT_EAGER_STATUS_INVALID_SIZE; + } + if size > max_size { + return PUT_EAGER_STATUS_ABOVE_EAGER_MAX; + } + + if !should_use_zero_copy(size, headers) { + return PUT_EAGER_STATUS_ZERO_COPY_INELIGIBLE; + } + + if request_uses_aws_chunked(headers) && decoded_content_length_from_headers(headers).ok().flatten().is_none() { + return PUT_EAGER_STATUS_AWS_CHUNKED_MISSING_DECODED_LENGTH; + } + + PUT_EAGER_STATUS_ELIGIBLE +} + +fn zero_copy_eager_put_max_size_bytes() -> i64 { + let configured = *CACHED_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES.get_or_init(|| { + rustfs_utils::get_env_usize(ENV_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES, DEFAULT_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES) + }); + i64::try_from(configured).unwrap_or(i64::MAX) +} + +fn should_use_small_eager_put_path( + size: i64, + headers: &HeaderMap, + server_side_encryption_requested: bool, + should_compress: bool, + is_extract: bool, +) -> bool { + const SMALL_EAGER_PUT_MAX_SIZE: i64 = 1024 * 1024; + + if is_extract || should_compress || server_side_encryption_requested { + return false; + } + + if size <= 0 || size > SMALL_EAGER_PUT_MAX_SIZE { + return false; + } + + if has_put_sse_request_headers(headers) { + return false; + } + + if request_uses_aws_chunked(headers) && decoded_content_length_from_headers(headers).ok().flatten().is_none() { + return false; + } + + true +} + +/// Objects at or below this size bypass BytesPool and use direct allocation. +/// This avoids Small-tier Mutex contention under high concurrency for tiny objects +/// where the allocation cost is negligible (≤4KiB memcpy). +const POOL_BYPASS_MAX_SIZE: usize = 4 * 1024; + +async fn read_small_put_body_into(body: &mut R, buf: &mut B, size: usize) -> S3Result<()> +where + R: AsyncRead + Unpin, + B: bytes::BufMut, +{ + let mut filled = 0; + + while filled < size { + let mut remaining = (&mut *buf).limit(size - filled); + let read = tokio::io::AsyncReadExt::read_buf(&mut *body, &mut remaining) + .await + .map_err(ApiError::from)?; + if read == 0 { + return Err(s3_error!(IncompleteBody)); + } + filled += read; + } + + let mut extra = [0u8; 1]; + let extra_read = tokio::io::AsyncReadExt::read(&mut *body, &mut extra) + .await + .map_err(ApiError::from)?; + if extra_read != 0 { + return Err(s3_error!(UnexpectedContent)); + } + + Ok(()) +} + +async fn read_small_put_body_exact_pooled(mut body: R, size: usize, pool: &BytesPool) -> S3Result +where + R: AsyncRead + Unpin, +{ + let mut buf = pool.acquire_buffer(size).await; + read_small_put_body_into(&mut body, &mut *buf, size).await?; + Ok(buf) +} + +/// Read small PUT body into a directly-allocated buffer, bypassing BytesPool. +/// Used for objects ≤4KiB where pool contention under high concurrency +/// outweighs the allocation cost. +async fn read_small_put_body_exact_direct(mut body: R, size: usize) -> S3Result>> +where + R: AsyncRead + Unpin, +{ + let mut buf = Vec::with_capacity(size); + read_small_put_body_into(&mut body, &mut buf, size).await?; + Ok(std::io::Cursor::new(buf)) +} + +async fn read_zero_copy_put_body_exact(mut body: S, size: usize) -> S3Result +where + S: futures::Stream> + Unpin, + E: Into, +{ + let mut chunks = Vec::new(); + let mut filled = 0usize; + + while filled < size { + let Some(chunk) = body.next().await else { + return Err(s3_error!(IncompleteBody)); + }; + let chunk = chunk.map_err(|err| ApiError::from(s3s_body_error_to_io(err.into())))?; + if chunk.is_empty() { + continue; + } + if filled.saturating_add(chunk.len()) > size { + return Err(s3_error!(UnexpectedContent)); + } + + rustfs_io_metrics::record_zero_copy_buffer_operation("put_chunk", chunk.len()); + filled += chunk.len(); + chunks.push(chunk); + } + + while let Some(chunk) = body.next().await { + let chunk = chunk.map_err(|err| ApiError::from(s3s_body_error_to_io(err.into())))?; + if !chunk.is_empty() { + return Err(s3_error!(UnexpectedContent)); + } + } + + Ok(ChunkedBytesReader::new(chunks)) +} + +#[derive(Default)] +pub(super) struct PutObjectChecksums { + pub(super) crc32: Option, + pub(super) crc32c: Option, + pub(super) sha1: Option, + pub(super) sha256: Option, + pub(super) crc64nvme: Option, +} + +struct PutObjectCommitResult { + obj_info: ObjectInfo, + put_versioned: bool, +} + +struct EagerPutCommitOwner { + task: Option>, + cancellation: tokio_util::sync::CancellationToken, + cancellation_grace: Duration, +} + +impl EagerPutCommitOwner { + fn new( + task: tokio::task::JoinHandle, + cancellation: tokio_util::sync::CancellationToken, + cancellation_grace: Duration, + ) -> Self { + Self { + task: Some(task), + cancellation, + cancellation_grace, + } + } + + async fn join(mut self) -> Result { + let result = self.task.as_mut().expect("eager PUT commit owner task must be present").await; + self.task = None; + result + } +} + +impl Drop for EagerPutCommitOwner { + fn drop(&mut self) { + let Some(mut task) = self.task.take() else { + return; + }; + if tokio::runtime::Handle::try_current().is_err() { + task.abort(); + return; + } + let cancellation = self.cancellation.clone(); + let cancellation_grace = self.cancellation_grace; + spawn_traced(async move { + if tokio::time::timeout(cancellation_grace, &mut task).await.is_err() { + cancellation.cancel(); + metrics::counter!("rustfs_put_commit_owner_deadline_total", "put_path" => "eager").increment(1); + warn!( + target: "rustfs::app::object_usecase", + event = EVENT_PUT_OBJECT_COMMIT_OWNER_DEADLINE, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + state = "cancellation_requested", + cancellation_grace_ms = cancellation_grace.as_millis() as u64, + "cancelled eager PutObject commit owner exceeded its grace period and requested storage cleanup" + ); + let _ = task.await; + } + }); + } +} + +#[cfg(test)] +type PutPostStoreTestHook = (String, Arc, Arc); + +#[cfg(test)] +static PUT_POST_STORE_TEST_HOOK: OnceLock>> = OnceLock::new(); + +#[cfg(test)] +fn install_put_post_store_test_hook(bucket: String, entered: Arc, resume: Arc) { + *PUT_POST_STORE_TEST_HOOK + .get_or_init(|| Mutex::new(None)) + .lock() + .expect("PUT post-store test hook lock should not be poisoned") = Some((bucket, entered, resume)); +} + +#[cfg(test)] +async fn wait_for_put_post_store_test_hook(bucket: &str) { + let hook = { + let mut slot = PUT_POST_STORE_TEST_HOOK + .get_or_init(|| Mutex::new(None)) + .lock() + .expect("PUT post-store test hook lock should not be poisoned"); + if slot.as_ref().is_some_and(|(expected_bucket, _, _)| expected_bucket == bucket) { + slot.take() + } else { + None + } + }; + if let Some((_bucket, entered, resume)) = hook { + entered.wait().await; + resume.wait().await; + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn apply_put_request_metadata( + metadata: &mut HashMap, + headers: &HeaderMap, + object_name: &str, + cache_control: Option, + content_disposition: Option, + content_encoding: Option, + content_language: Option, + content_type: Option, + expires: Option, + website_redirect_location: Option, + tagging: Option, + storage_class: Option, +) -> S3Result<()> { + namespace_reserved_user_metadata(metadata); + apply_standard_object_metadata( + metadata, + cache_control.as_deref(), + content_disposition.as_deref(), + content_encoding.as_deref(), + content_language.as_deref(), + content_type.as_deref(), + expires.as_ref(), + website_redirect_location.as_deref(), + )?; + if let Some(tags) = tagging { + metadata.insert(AMZ_OBJECT_TAGGING.to_owned(), tags); + } + if let Some(storage_class) = storage_class { + metadata.insert(AMZ_STORAGE_CLASS.to_string(), storage_class.as_str().to_string()); + } + + extract_metadata_from_mime_with_object_name(headers, metadata, true, Some(object_name)); + Ok(()) +} + +pub(super) fn apply_put_request_object_lock_opts( + bucket: &str, + object_lock_config_state: &metadata_sys::ObjectLockConfigState, + object_lock_legal_hold_status: Option, + object_lock_mode: Option, + object_lock_retain_until_date: Option, + opts: &mut ObjectOptions, +) -> S3Result<()> { + if let Some(eval_metadata) = build_put_like_object_lock_metadata( + bucket, + object_lock_config_state, + object_lock_legal_hold_status, + object_lock_mode, + object_lock_retain_until_date, + )? { + opts.eval_metadata = Some(eval_metadata); + } + + Ok(()) +} + +pub(super) fn is_sse_kms_requested(input: &PutObjectInput, headers: &HeaderMap) -> bool { + input + .server_side_encryption + .as_ref() + .is_some_and(|sse| sse.as_str().eq_ignore_ascii_case(ServerSideEncryption::AWS_KMS)) + || input.ssekms_key_id.is_some() + || headers + .get(AMZ_SERVER_SIDE_ENCRYPTION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.trim().eq_ignore_ascii_case(ServerSideEncryption::AWS_KMS)) + || headers.contains_key(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID) +} + +fn is_post_object_sse_kms_requested(input: &PutObjectInput, headers: &HeaderMap) -> bool { + is_sse_kms_requested(input, headers) +} + +impl DefaultObjectUsecase { + fn should_use_large_put_concurrency_tuning(size: i64) -> bool { + size >= DEFAULT_PUT_LARGE_CONCURRENCY_TUNING_MIN_SIZE_BYTES + } + + fn put_object_execution_context(req: &S3Request) -> (EventName, QuotaOperation, &'static str) { + if req.extensions.get::().is_some() { + (put_event_name_for_post_object(true), QuotaOperation::PostObject, "POST") + } else { + (put_event_name_for_post_object(false), QuotaOperation::PutObject, "PUT") + } + } + + #[instrument(name = "execute_put_object", level = "info", skip(self, _fs, req))] + pub async fn execute_put_object(&self, _fs: &FS, req: S3Request) -> S3Result> { + self.execute_put_object_boxed(_fs, req).await + } + + fn execute_put_object_boxed<'a>( + &'a self, + _fs: &'a FS, + req: S3Request, + ) -> impl std::future::Future>> + Send + 'a { + Box::pin(self.execute_put_object_inner(_fs, req)) + } + + async fn execute_put_object_inner(&self, _fs: &FS, req: S3Request) -> S3Result> { + let start_time = std::time::Instant::now(); + let mut req = req; + + if let Some(context) = &self.context { + let _ = context.object_store(); + } + + let (event_name, quota_operation, request_method_name) = Self::put_object_execution_context(&req); + let max_content_length = parse_presigned_put_max_content_length( + &req.headers, + req.uri.query(), + req.extensions.get::().is_some(), + )?; + if req.extensions.get::().is_some() && is_post_object_sse_kms_requested(&req.input, &req.headers) + { + return Err(s3_error!(NotImplemented, "SSE-KMS is not supported for POST object uploads")); + } + if let Some(ref storage_class) = req.input.storage_class + && !is_valid_storage_class(storage_class.as_str()) + { + return Err(s3_error!(InvalidStorageClass)); + } + // An authorized inbound replication PUT must store the replica verbatim. + // A snowball-extracted member object keeps `x-amz-meta-snowball-auto-extract` + // in its user metadata, and the replication client replays stored metadata + // as headers — re-dispatching that PUT into the extract path would try to + // untar the member's own bytes (failing replication for any non-archive + // member) instead of writing the replica. + let inbound_replication_put = replication_request_authorized(&req) + && get_header(&req.headers, SUFFIX_SOURCE_REPLICATION_REQUEST).as_deref() == Some("true"); + if max_content_length.is_some() && is_put_object_extract_requested(&req.headers) { + return Err(S3Error::with_message( + S3ErrorCode::InvalidRequest, + format!("{RUSTFS_MAX_CONTENT_LENGTH_QUERY} is not supported for archive extraction"), + )); + } + if is_put_object_extract_requested(&req.headers) && !inbound_replication_put { + return Box::pin(self.execute_put_object_extract(req)).await; + } + // SSE-C ciphertext passthrough (authorized replication only): the body + // is already ciphertext and must be stored verbatim — no compression, + // no bucket-default encryption. + let ciphertext_passthrough = + inbound_replication_put && rustfs_utils::http::ssec_transport_to_stored_metadata(&req.headers).is_some(); + + let input = std::mem::take(&mut req.input); + + let PutObjectInput { + body, + bucket, + cache_control, + key, + content_length, + content_disposition, + content_encoding, + content_language, + content_type, + expires, + tagging, + metadata, + version_id, + server_side_encryption, + sse_customer_algorithm, + sse_customer_key, + sse_customer_key_md5, + ssekms_key_id, + content_md5, + object_lock_legal_hold_status, + object_lock_mode, + object_lock_retain_until_date, + storage_class, + website_redirect_location, + .. + } = input; + + // Merge SSE-C params from headers (fallback when S3 layer does not populate input) + let (h_algo, h_key, h_md5) = extract_ssec_params_from_headers(&req.headers)?; + let sse_customer_algorithm = sse_customer_algorithm.or(h_algo); + let sse_customer_key = sse_customer_key.or(h_key); + let sse_customer_key_md5 = sse_customer_key_md5.or(h_md5); + + // Merge server_side_encryption from headers (fallback when S3 layer does not populate input) + let server_side_encryption = server_side_encryption.or(extract_server_side_encryption_from_headers(&req.headers)?); + + // Validate object key + validate_object_key(&key, request_method_name)?; + validate_table_catalog_object_mutation(&bucket, &key).await?; + + // Validate archive content encoding (reject when strict mode is enabled) + validate_archive_content_encoding( + &key, + req.headers.get("content-type").and_then(|value| value.to_str().ok()), + req.headers.get("content-encoding").and_then(|value| value.to_str().ok()), + )?; + + let Some(body) = body else { return Err(s3_error!(IncompleteBody)) }; + + // Guard against a proxy/CDN that forwards a partial body then goes silent + // without closing the connection: bound the inter-chunk wait so the read + // fails (with a diagnostic log) instead of hanging forever (issue #3076). + let body = { + let request_id = req + .extensions + .get::() + .map(|ctx| ctx.request_id.clone()) + .unwrap_or_default(); + guard_put_object_body_read_timeout(body, &bucket, &key, &request_id, content_length, put_object_body_read_timeout()) + }; + + let body = match max_content_length { + Some(limit) => StreamingBlob::new(MaxContentLengthStream { + inner: body, + limit, + received: 0, + exceeded: false, + }), + None => body, + }; + + // Resolve the authoritative decoded/plain object length (rejecting negative/unknown) before anything else consumes it. + let mut size = resolve_put_object_authoritative_size(&req.headers, content_length)?; + + if let Some(limit) = max_content_length + && u64::try_from(size).is_ok_and(|size| size > limit) + { + return Err(S3Error::new(S3ErrorCode::EntityTooLarge)); + } + + // The app check preserves the existing S3 error contract; the storage + // commit path reserves the exact net logical growth under its locks. + let quota_check = self + .check_bucket_quota( + &bucket, + quota_operation, + u64::try_from(size).map_err(|_| S3Error::new(S3ErrorCode::UnexpectedContent))?, + ) + .await?; + let quota_enabled = quota_check.as_ref().is_some_and(|result| result.quota_limit.is_some()); + if quota_enabled && ciphertext_passthrough { + return Err(S3Error::with_message( + S3ErrorCode::InvalidRequest, + "SSE-C ciphertext replication is unavailable for quota-enabled buckets".to_string(), + )); + } + + let put_stage_metrics_enabled = rustfs_io_metrics::put_stage_metrics_enabled(); + let ingress_stage_start = put_stage_metrics_enabled.then(Instant::now); + let should_compress = + is_disk_compressible(&req.headers, &key) && size > MIN_DISK_COMPRESSIBLE_SIZE as i64 && !ciphertext_passthrough; + let server_side_encryption_requested = + server_side_encryption.is_some() || sse_customer_algorithm.is_some() || ssekms_key_id.is_some(); + + // Resolve the store through the request-bound server context + // (backlog#1052 S6), not the process-global handle, so an embedded + // second server never writes into the first server's store. + let Some(store) = self.object_store() else { + return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); + }; + let bucket_validate_stage_start = put_stage_metrics_enabled.then(Instant::now); + validate_bucket_exists(&store, &bucket).await?; + rustfs_io_metrics::record_put_object_stage_duration_from("app_bucket_validate", bucket_validate_stage_start); + + let put_admission = match get_concurrency_manager() + .admit_put_object() + .await + .map_err(|_| s3_error!(InternalError, "foreground write admission closed"))? + { + PutObjectAdmission::Disabled => None, + PutObjectAdmission::Admitted(permit) => { + counter!("rustfs.put_object.foreground_admission.total", "result" => "admitted").increment(1); + Some(permit) + } + PutObjectAdmission::Rejected => { + counter!("rustfs.put_object.foreground_admission.total", "result" => "rejected").increment(1); + return Err(s3_error!( + SlowDown, + "foreground write concurrency limit reached, please reduce your request rate" + )); + } + }; + + let mut put_request_guard = PutObjectGuard::new(); + let concurrent_put_requests = PutObjectGuard::concurrent_requests(); + + // Apply adaptive buffer sizing based on file size for optimal streaming performance. + // Uses workload profile configuration (enabled by default) to select appropriate buffer size. + // Buffer sizes range from 32KB to 4MB depending on file size and configured workload profile. + // Concurrency-aware adjustment reduces buffer size under high PUT concurrency to lower memory pressure. + let base_buffer_size = get_buffer_size_opt_in(size); + let use_large_put_concurrency_tuning = Self::should_use_large_put_concurrency_tuning(size); + let buffer_size = if use_large_put_concurrency_tuning { + get_put_concurrency_aware_buffer_size(size, base_buffer_size) + } else { + base_buffer_size + }; + + // Detect zero-copy opportunity before encryption/compression decisions + // Zero-copy is beneficial for large unencrypted, uncompressed objects + let enable_zero_copy = should_use_zero_copy(size, &req.headers); + + if enable_zero_copy { + // Record zero-copy write attempt + counter!("rustfs_zero_copy_write_attempts_total").increment(1); + histogram!("rustfs_zero_copy_write_size_bytes").record(size as f64); + debug!("Zero-copy write enabled for {} byte object (bucket={}, key={})", size, bucket, key); + } + + let use_empty_or_small_eager_put_path = size == 0 + || should_use_small_eager_put_path(size, &req.headers, server_side_encryption_requested, should_compress, false); + let zero_copy_eager_put_path_status = + zero_copy_eager_put_path_status(size, &req.headers, server_side_encryption_requested, should_compress, false); + let use_zero_copy_eager_put_path = zero_copy_eager_put_path_status == PUT_EAGER_STATUS_ELIGIBLE; + if use_zero_copy_eager_put_path { + counter!(buffered_write::ATTEMPTS_TOTAL).increment(1); + histogram!(buffered_write::ATTEMPT_SIZE_BYTES).record(size as f64); + } + let put_path = if should_compress { + "stream_compressed" + } else if use_zero_copy_eager_put_path { + "zero_copy_eager" + } else if use_empty_or_small_eager_put_path { + "small_eager" + } else { + "streaming" + }; + rustfs_io_metrics::record_put_object_diagnostics( + put_path, + zero_copy_eager_put_path_status, + size, + buffer_size, + use_large_put_concurrency_tuning, + ); + + let sse_config_stage_start = put_stage_metrics_enabled.then(Instant::now); + let bucket_sse_config = metadata_sys::get_sse_config(&bucket).await.ok(); + rustfs_io_metrics::record_put_object_stage_duration_from("app_sse_config_lookup", sse_config_stage_start); + debug!( + target: "rustfs::app::object_usecase", + component = "app", + subsystem = "object", + event = "bucket_sse_config_lookup", + bucket = %bucket, + found = bucket_sse_config.is_some(), + "Bucket SSE configuration lookup completed" + ); + + let original_sse = server_side_encryption.clone(); + let (mut effective_sse, mut effective_kms_key_id) = resolve_bucket_default_sse( + bucket_sse_config.as_ref().map(|(config, _timestamp)| config), + server_side_encryption, + ssekms_key_id, + false, + ); + debug!( + target: "rustfs::app::object_usecase", + component = "app", + subsystem = "object", + event = "effective_sse_resolved", + bucket = %bucket, + requested = ?original_sse, + effective = ?effective_sse, + "Resolved effective SSE configuration" + ); + + if ciphertext_passthrough { + // The replica keeps the source's SSE-C metadata; the bucket + // default must not claim managed encryption on it. + effective_sse = None; + effective_kms_key_id = None; + } + + // Validate SSE-C headers early: reject partial/invalid combinations per S3 spec + validate_sse_headers_for_write( + effective_sse.as_ref(), + effective_kms_key_id.as_ref(), + extract_ssekms_context_from_headers(&req.headers)?.as_ref(), + sse_customer_algorithm.as_ref(), + sse_customer_key.as_ref(), + sse_customer_key_md5.as_ref(), + true, // PutObject requires all three: algorithm, key, key_md5 + )?; + + let mut metadata = metadata.unwrap_or_default(); + let has_explicit_object_lock_retention = object_lock_mode.is_some() + || object_lock_retain_until_date.is_some() + || has_replication_retention_update(&req.headers, inbound_replication_put); + let object_lock_config_stage_start = put_stage_metrics_enabled.then(Instant::now); + let object_lock_config_state = load_bucket_object_lock_config_state(&bucket).await?; + rustfs_io_metrics::record_put_object_stage_duration_from("app_object_lock_config_lookup", object_lock_config_stage_start); + apply_put_request_metadata( + &mut metadata, + &req.headers, + &key, + cache_control, + content_disposition, + content_encoding, + content_language, + content_type, + expires, + website_redirect_location, + tagging, + storage_class.clone(), + )?; + apply_bucket_default_lock_retention( + &bucket, + &object_lock_config_state, + &mut metadata, + has_explicit_object_lock_retention, + )?; + + let put_opts_stage_start = put_stage_metrics_enabled.then(Instant::now); + let mut opts: ObjectOptions = put_opts_with_replication_authorization( + &bucket, + &key, + version_id.clone(), + &req.headers, + metadata.clone(), + replication_request_authorized(&req), + ) + .await + .map_err(ApiError::from)?; + if let Some(quota_check) = quota_check.as_ref() { + apply_quota_admission(&mut opts, quota_check)?; + } + rustfs_io_metrics::record_put_object_stage_duration_from("app_put_opts_build", put_opts_stage_start); + apply_bucket_generation_guard(&req, &bucket, &mut opts)?; + apply_put_request_object_lock_opts( + &bucket, + &object_lock_config_state, + object_lock_legal_hold_status, + object_lock_mode, + object_lock_retain_until_date, + &mut opts, + )?; + let eager_put_commit_cancellation = + (use_zero_copy_eager_put_path || use_empty_or_small_eager_put_path).then(tokio_util::sync::CancellationToken::new); + opts.put_object_cancellation = eager_put_commit_cancellation.clone(); + + // rustfs/backlog#1009: the pre-PUT lookup has exactly two consumers — + // the existing-object WORM validation and usage accounting's + // previous_current_size. When the bucket has no object locking (WORM is + // a provable no-op; the gate fails closed on metadata errors) and the + // PUT targets the latest version (no explicit version_id from internal + // replication), the lookup is skipped and accounting is backfilled from + // the dst xl.meta that rename_data already reads, saving a full-disk + // metadata fanout per PUT. + let prelookup_required = version_id.is_some() || object_lock_checks_required_for_state(&object_lock_config_state); + // Outer None = prelookup skipped (accounting comes from the commit + // backfill); Some(inner) = the previous current size as observed by the + // lookup, with the pre-#1009 semantics kept bit-for-bit. + let prelookup_stage_start = (prelookup_required && put_stage_metrics_enabled).then(Instant::now); + let prelookup_previous_current_size: Option> = if prelookup_required { + let current_opts: ObjectOptions = internal_object_info_lookup_opts( + get_opts(&bucket, &key, version_id.clone(), None, &req.headers) + .await + .map_err(ApiError::from)?, + ); + let previous_current_info = { + crate::hp_guard!("S3::put_object_prelookup"); + store.get_object_info(&bucket, &key, ¤t_opts).await + }; + Some(match previous_current_info { + Ok(existing_obj_info) => { + validate_existing_object_lock_for_write(&object_lock_config_state, &existing_obj_info, &opts)?; + Some(if quota_enabled { + quota_object_size(&existing_obj_info).map_err(ApiError::from)? + } else { + existing_obj_info.size.max(0) as u64 + }) + } + Err(err) => { + if !is_err_object_not_found(&err) && !is_err_version_not_found(&err) { + return Err(ApiError::from(err).into()); + } + None + } + }) + } else { + None + }; + rustfs_io_metrics::record_put_object_stage_duration_from("app_prelookup", prelookup_stage_start); + + let actual_size = size; + if !ciphertext_passthrough && let Some(quota_check) = quota_check.as_ref() { + ensure_object_size_within_quota( + quota_check, + u64::try_from(actual_size).map_err(|_| S3Error::new(S3ErrorCode::UnexpectedContent))?, + )?; + } + + let mut md5hex = if let Some(base64_md5) = content_md5 { + let md5 = base64_simd::STANDARD + .decode_to_vec(base64_md5.as_bytes()) + .map_err(|e| ApiError::from(StorageError::other(format!("Invalid content MD5: {e}"))))?; + Some(hex_simd::encode_to_string(&md5, hex_simd::AsciiCase::Lower)) + } else { + None + }; + + let mut sha256hex = get_content_sha256_with_query(&req.headers, req.uri.query()); + + let mut write_plan = WritePlan::new(); + // Additional-checksum (XXHash3/64/128, SHA-512) values to echo on the PutObject + // response (#1256); captured at want_checksum set points before opts is moved. + let mut put_extra_checksum_headers: Vec<(&'static str, String)> = Vec::new(); + let mut reader = if should_compress { + let body = tokio::io::BufReader::with_capacity( + buffer_size, + StreamReader::new(body.map(|f| f.map_err(s3s_body_error_to_io))), + ); + let algorithm = CompressionAlgorithm::default(); + insert_str(&mut metadata, SUFFIX_COMPRESSION, compression_metadata_value(algorithm)); + insert_str(&mut metadata, SUFFIX_ACTUAL_SIZE, size.to_string()); + + let mut hrd = + HashReader::from_stream(body, size, size, md5hex.take(), sha256hex.take(), false).map_err(ApiError::from)?; + + if let Err(err) = hrd.add_checksum_from_s3s(&req.headers, req.trailing_headers.clone(), false) { + return Err(ApiError::from(err).into()); + } + + opts.want_checksum = hrd.checksum(); + put_extra_checksum_headers = additional_checksum_echo_pairs(&opts.want_checksum); + insert_str(&mut opts.user_defined, SUFFIX_COMPRESSION, compression_metadata_value(algorithm)); + insert_str(&mut opts.user_defined, SUFFIX_ACTUAL_SIZE, size.to_string()); + + size = HashReader::SIZE_PRESERVE_LAYER; + write_plan = write_plan.with_compression(algorithm); + hrd + } else { + if use_zero_copy_eager_put_path { + let zero_copy_start = std::time::Instant::now(); + let eager_body = read_zero_copy_put_body_exact(body, actual_size as usize).await?; + rustfs_io_metrics::record_zero_copy_write(actual_size as usize, zero_copy_start.elapsed().as_secs_f64() * 1000.0); + HashReader::from_stream(eager_body, size, actual_size, md5hex, sha256hex, false).map_err(ApiError::from)? + } else if use_empty_or_small_eager_put_path { + if (actual_size as usize) <= POOL_BYPASS_MAX_SIZE { + // Bypass BytesPool for very small objects to avoid Small-tier + // Mutex contention under high concurrency. Direct allocation + // for ≤4KiB is negligible cost. + let eager_body = read_small_put_body_exact_direct( + StreamReader::new(body.map(|f| f.map_err(s3s_body_error_to_io))), + actual_size as usize, + ) + .await?; + HashReader::from_stream(eager_body, size, actual_size, md5hex, sha256hex, false).map_err(ApiError::from)? + } else { + let pool = get_concurrency_manager().bytes_pool(); + let eager_body = read_small_put_body_exact_pooled( + StreamReader::new(body.map(|f| f.map_err(s3s_body_error_to_io))), + actual_size as usize, + pool.as_ref(), + ) + .await?; + let eager_reader = PooledBufferReader::new(eager_body, actual_size as usize); + HashReader::from_stream(eager_reader, size, actual_size, md5hex, sha256hex, false).map_err(ApiError::from)? + } + } else { + let body = tokio::io::BufReader::with_capacity( + buffer_size, + StreamReader::new(body.map(|f| f.map_err(s3s_body_error_to_io))), + ); + HashReader::from_stream(body, size, actual_size, md5hex, sha256hex, false).map_err(ApiError::from)? + } + }; + + if size >= 0 { + if let Err(err) = reader.add_checksum_from_s3s(&req.headers, req.trailing_headers.clone(), false) { + return Err(ApiError::from(err).into()); + } + + opts.want_checksum = reader.checksum(); + put_extra_checksum_headers = additional_checksum_echo_pairs(&opts.want_checksum); + } + rustfs_io_metrics::record_put_object_path(put_path); + rustfs_io_metrics::record_put_object_stage_duration_from("ingress_prepare", ingress_stage_start); + + let mut helper = OperationHelper::new(&req, event_name, S3Operation::PutObject); + let ssekms_context = extract_ssekms_context_from_headers(&req.headers)?; + + // Apply encryption using unified SSE API. + let encryption_stage_start = put_stage_metrics_enabled.then(Instant::now); + let write_principal = SseKmsPrincipal::from_request(&req); + let encryption_request = EncryptionRequest { + bucket: &bucket, + key: &key, + server_side_encryption: effective_sse.clone(), + ssekms_key_id: effective_kms_key_id.clone(), + ssekms_context, + sse_customer_algorithm: sse_customer_algorithm.clone(), + sse_customer_key, + sse_customer_key_md5: sse_customer_key_md5.clone(), + content_size: actual_size, + principal: write_principal.as_ref(), + }; + + // SSE-C ciphertext passthrough must skip sse_encryption entirely: an + // explicit guard is required because prepare_sse_configuration inside + // it falls back to the bucket default encryption config and would + // double-encrypt the already-encrypted body. + let encryption_material = if opts.preserve_ciphertext { + None + } else { + match sse_encryption(encryption_request).await { + Ok(material) => material, + Err(err) => { + let result = Err(err.into()); + let _ = helper.complete(&result); + return result; + } + } + }; + + if let Some(material) = encryption_material { + effective_sse = Some(material.server_side_encryption.clone()); + effective_kms_key_id = material.kms_key_id.clone(); + + write_plan = write_plan.with_encryption(material.write_encryption(None)); + + let encryption_metadata = encryption_material_to_metadata(&material)?; + metadata.extend(encryption_metadata.clone()); + opts.user_defined.extend(encryption_metadata); + } + + reader = write_plan.apply(reader, actual_size).map_err(ApiError::from)?; + rustfs_io_metrics::record_put_object_stage_duration_from("app_encryption_prepare", encryption_stage_start); + + let reader = PutObjReader::new(reader); + + let mt2 = metadata.clone(); + opts.user_defined.extend(metadata); + let request_context = req.extensions.get::().cloned(); + let request_id = request_context + .as_ref() + .map(|ctx| ctx.request_id.clone()) + .unwrap_or_else(|| request_context::RequestContext::fallback().request_id); + + // Compute the replication decision exactly once per PUT. The same + // immutable `dsc` drives both the pending metadata written below and the + // post-commit schedule (see the reuse site further down), so a + // replication-config hot update can no longer split the two phases + // (https://github.com/rustfs/backlog/issues/1320). + let replication_decision_stage_start = put_stage_metrics_enabled.then(Instant::now); + let dsc = + must_replicate_object(&bucket, &key, &mt2, "".to_string(), opts.delete_marker_replication_status(), opts.clone()) + .await; + rustfs_io_metrics::record_put_object_stage_duration_from("app_replication_decision", replication_decision_stage_start); + + if dsc.replicate_any() { + insert_str(&mut opts.user_defined, SUFFIX_REPLICATION_TIMESTAMP, jiff::Zoned::now().to_string()); + insert_str( + &mut opts.user_defined, + SUFFIX_REPLICATION_STATUS, + dsc.pending_status().unwrap_or_default(), + ); + } + + let cache_adapter = self.object_data_cache(); + let cache_invalidate_before_stage_start = put_stage_metrics_enabled.then(Instant::now); + let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &key).await; + rustfs_io_metrics::record_put_object_stage_duration_from( + "app_cache_invalidate_before", + cache_invalidate_before_stage_start, + ); + + let store_put_watchdog = tokio_util::sync::CancellationToken::new(); + spawn_traced({ + let store_put_watchdog = store_put_watchdog.clone(); + let request_id = request_id.clone(); + let bucket = bucket.clone(); + let key = key.clone(); + let put_path = put_path.to_string(); + async move { + tokio::select! { + _ = store_put_watchdog.cancelled() => {} + _ = tokio::time::sleep(PUT_OBJECT_STORE_WARN_THRESHOLD) => { + warn!( + target: "rustfs::app::object_usecase", + event = EVENT_PUT_OBJECT_STORE_INFLIGHT_SLOW, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + request_id = %request_id, + bucket = %bucket, + key = %key, + put_path = %put_path, + object_size = actual_size, + threshold_ms = PUT_OBJECT_STORE_WARN_THRESHOLD.as_millis() as u64, + state = "store_put_pending", + "PutObject store write remains in flight" + ); + } + } + } + }); + + let object_traffic_health = if use_zero_copy_eager_put_path || use_empty_or_small_eager_put_path { + self.object_traffic_health() + } else { + None + }; + let put_commit = spawn_traced_join({ + let store = Arc::clone(&store); + let bucket = bucket.clone(); + let key = key.clone(); + let opts = opts.clone(); + let cache_adapter = cache_adapter.clone(); + let request_id = request_id.clone(); + let put_path = put_path.to_string(); + let put_admission = put_admission; + async move { + let _put_admission = put_admission; + let object_traffic_progress = object_traffic_health + .as_deref() + .and_then(ObjectTrafficHealth::track_write_storage); + let mut reader = reader; + let store_put_stage_start = put_stage_metrics_enabled.then(Instant::now); + let (obj_info, backfilled_old_current_size) = match store + .put_object_with_old_current_size(&bucket, &key, &mut reader, &opts) + .await + .map_err(ApiError::from) + { + Ok(obj_info) => { + store_put_watchdog.cancel(); + debug!( + target: "rustfs::app::object_usecase", + event = EVENT_PUT_OBJECT_STORE_RETURNED, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + request_id = %request_id, + bucket = %bucket, + key = %key, + put_path = %put_path, + object_size = actual_size, + duration_ms = start_time.elapsed().as_millis() as u64, + result = "success", + "PutObject store write returned" + ); + obj_info + } + Err(err) => { + store_put_watchdog.cancel(); + rustfs_io_metrics::record_put_object_stage_duration_from("app_store_put", store_put_stage_start); + warn!( + target: "rustfs::app::object_usecase", + event = EVENT_PUT_OBJECT_STORE_RETURNED, + component = LOG_COMPONENT_APP, + subsystem = LOG_SUBSYSTEM_OBJECT, + request_id = %request_id, + bucket = %bucket, + key = %key, + put_path = %put_path, + object_size = actual_size, + duration_ms = start_time.elapsed().as_millis() as u64, + result = "error", + error = %err, + "PutObject store write returned" + ); + return Err(err.into()); + } + }; + rustfs_io_metrics::record_put_object_stage_duration_from("app_store_put", store_put_stage_start); + drop(_put_admission); + drop(object_traffic_progress); + #[cfg(test)] + wait_for_put_post_store_test_hook(&bucket).await; + + let post_store_stage_start = put_stage_metrics_enabled.then(Instant::now); + maybe_enqueue_transition_immediate(&obj_info, LcEventSrc::S3PutObject).await; + let _ = invalidate_object_data_cache_after_put_success(&cache_adapter, &bucket, &key).await; + + let put_versioned = BucketVersioningSys::prefix_enabled(&bucket, &key).await; + // Fast in-memory update for immediate quota and admin usage consistency. + // The previous current size comes from the prelookup when it ran, + // otherwise from the rename_data backfill (rustfs/backlog#1009); the + // backfill reproduces the lookup's observation bit for bit (latest + // version's ObjectInfo.size — 0 for a delete-marker latest — or + // not-found → None). + let committed_size = quota_accounting_object_size(&obj_info, quota_enabled)?; + match prelookup_previous_current_size.or_else(|| previous_current_size_from_backfill(backfilled_old_current_size)) + { + Some(previous_current_size) => { + if put_versioned { + record_bucket_object_version_write_memory(&bucket, previous_current_size, committed_size).await; + } else { + record_bucket_object_write_memory(&bucket, previous_current_size, committed_size).await; + } + } + None => { + // Neither source could determine the previous state (peers + // predating the backfill field during a rolling upgrade, or + // sub-quorum metadata divergence). Record the components that + // are correct regardless; the next authoritative scanner + // refresh replaces the in-memory numbers. + debug!( + target: "rustfs::app::object_usecase", + bucket = %bucket, + key = %key, + put_versioned, + "put_object old-size backfill unknown; recording degraded usage delta" + ); + record_bucket_object_write_unknown_previous_memory(&bucket, committed_size, put_versioned).await; + } + } + + if dsc.replicate_any() { + schedule_object_replication(obj_info.clone(), store, dsc).await; + } + + rustfs_scanner::record_dirty_usage_bucket(&bucket); + rustfs_io_metrics::record_put_object_stage_duration_from("app_post_store_bookkeeping", post_store_stage_start); + + let capacity_update_stage_start = put_stage_metrics_enabled.then(Instant::now); + let manager = get_capacity_manager(); + manager.record_write_operation().await; + rustfs_io_metrics::record_put_object_stage_duration_from("app_capacity_update", capacity_update_stage_start); + + Ok::<_, S3Error>(PutObjectCommitResult { obj_info, put_versioned }) + } + }); + let put_commit_result = if let Some(cancellation) = eager_put_commit_cancellation { + EagerPutCommitOwner::new(put_commit, cancellation, EAGER_PUT_COMMIT_CANCELLATION_GRACE) + .join() + .await + } else { + put_commit.await + }; + let PutObjectCommitResult { obj_info, put_versioned } = match put_commit_result { + Ok(Ok(result)) => result, + Ok(Err(err)) => { + let result: S3Result> = Err(err); + put_request_guard.finish_err(); + let _ = helper.complete(&result); + return result; + } + Err(err) => { + let result: S3Result> = Err(S3Error::with_message( + S3ErrorCode::InternalError, + format!("put object commit owner task failed: {err}"), + )); + put_request_guard.finish_err(); + let _ = helper.complete(&result); + return result; + } + }; + + let raw_version = obj_info.version_id.map(|v| v.to_string()); + + helper = helper.object(obj_info.clone()); + if let Some(version_id) = &raw_version { + helper = helper.version_id(version_id.clone()); + } + + let put_version = if put_versioned { raw_version } else { None }; + + let e_tag = obj_info.etag.clone().map(|etag| to_s3s_etag(&etag)); + + let expiration = resolve_put_object_expiration(&bucket, &obj_info).await; + + let mut checksums = PutObjectChecksums { + crc32: input.checksum_crc32, + crc32c: input.checksum_crc32c, + sha1: input.checksum_sha1, + sha256: input.checksum_sha256, + crc64nvme: input.checksum_crc64nvme, + }; + apply_trailing_checksums( + input.checksum_algorithm.as_ref().map(|a| a.as_str()), + &req.trailing_headers, + &mut checksums, + ); + + let output = PutObjectOutput { + e_tag, + server_side_encryption: effective_sse, + sse_customer_algorithm: sse_customer_algorithm.clone(), + sse_customer_key_md5: sse_customer_key_md5.clone(), + ssekms_key_id: effective_kms_key_id, + expiration, + checksum_crc32: checksums.crc32, + checksum_crc32c: checksums.crc32c, + checksum_sha1: checksums.sha1, + checksum_sha256: checksums.sha256, + checksum_crc64nvme: checksums.crc64nvme, + version_id: put_version, + ..Default::default() + }; + + // For browser-based POST uploads (multipart/form-data), response status/body handling + // is decided by s3s PostObject serializer (success_action_status / redirect semantics). + + let mut response = S3Response::new(output); + // Echo XXHash3/64/128 / SHA-512 checksums that s3s PutObjectOutput has no typed + // field for (#1256). + inject_additional_checksum_headers(&mut response.headers, &put_extra_checksum_headers); + let result = Ok(response); + let _ = helper.complete(&result); + + // Record PutObject metrics via zero-copy-metrics + { + let duration_ms = start_time.elapsed().as_millis() as f64; + rustfs_io_metrics::record_put_object( + duration_ms, + size, + enable_zero_copy, // Track if zero-copy was enabled + ); + } + + debug!( + target: "rustfs::app::object_usecase", + component = "app", + subsystem = "object", + bucket = %bucket, + key = %key, + concurrent_put_requests, + buffer_size, + "PutObject request completed" + ); + + put_request_guard.finish_ok(); + + result + } +} + +/// rustfs/backlog#1009: map the rename_data old-size backfill onto the +/// `previous_current_size` value the usage-accounting helpers expect. Outer +/// `None` = unknown (no quorum agreement, or a peer predates the field) — the +/// caller must fall back to the degraded accounting path. +pub(super) fn previous_current_size_from_backfill(backfill: Option) -> Option> { + backfill.map(|observation| match observation { + OldCurrentSize::Present(size) => Some(size.max(0) as u64), + OldCurrentSize::Absent => None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::StreamExt; + use http::{HeaderMap, HeaderName, HeaderValue, Method}; + use s3s::dto::{DefaultRetention, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRule}; + use std::pin::Pin; + use std::sync::Arc; + use std::task::{Context, Poll}; + use tokio::io::{AsyncRead, ReadBuf}; + + #[tokio::test] + async fn cancelled_eager_put_commit_owner_reaps_stalled_storage_task() { + let health = Arc::new(ObjectTrafficHealth::enabled_for_test(Duration::ZERO)); + let task_health = Arc::clone(&health); + let cancellation = tokio_util::sync::CancellationToken::new(); + let task_cancellation = cancellation.clone(); + let task = spawn_traced_join(async move { + let _progress = task_health.track_write_storage().expect("write tracking must be enabled"); + task_cancellation.cancelled().await; + }); + let owner = EagerPutCommitOwner::new(task, cancellation, Duration::from_millis(10)); + let request = spawn_traced_join(owner.join()); + + tokio::time::timeout(Duration::from_secs(2), async { + while !health.snapshot().write_stalled { + tokio::task::yield_now().await; + } + }) + .await + .expect("stalled owner must publish write-storage progress"); + + request.abort(); + let _ = request.await; + tokio::time::timeout(Duration::from_secs(2), async { + while health.snapshot().write_stalled { + tokio::task::yield_now().await; + } + }) + .await + .expect("cancelled owner must abort and reap the stalled storage task"); + } + + #[tokio::test] + async fn max_content_length_stream_rejects_the_first_chunk_over_limit() { + let inner = StreamingBlob::wrap(futures::stream::iter([ + Ok::(Bytes::from_static(b"1234")), + Ok::(Bytes::from_static(b"56")), + ])); + let mut limited = MaxContentLengthStream { + inner, + limit: 5, + received: 0, + exceeded: false, + }; + + assert_eq!(limited.next().await.unwrap().unwrap(), Bytes::from_static(b"1234")); + let error = limited.next().await.unwrap().unwrap_err(); + assert!(error.downcast_ref::().is_some()); + assert!(limited.next().await.is_none()); + } + + #[tokio::test] + async fn max_content_length_stream_allows_exact_limit() { + let inner = StreamingBlob::from_bytes(Bytes::from_static(b"12345")); + let mut limited = MaxContentLengthStream { + inner, + limit: 5, + received: 0, + exceeded: false, + }; + + assert_eq!(limited.next().await.unwrap().unwrap(), Bytes::from_static(b"12345")); + assert!(limited.next().await.is_none()); + } + + #[test] + fn put_request_user_metadata_cannot_suppress_bucket_default_retention() { + let mut metadata = + HashMap::from([(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), ObjectLockRetentionMode::GOVERNANCE.to_string())]); + apply_put_request_metadata( + &mut metadata, + &HeaderMap::new(), + "object", + None, + None, + None, + None, + None, + None, + None, + None, + None, + ) + .unwrap(); + + let state = metadata_sys::ObjectLockConfigState::Configured { + config: ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + rule: Some(ObjectLockRule { + default_retention: Some(DefaultRetention { + mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)), + days: Some(1), + years: None, + }), + }), + }, + updated_at: OffsetDateTime::now_utc(), + }; + apply_bucket_default_lock_retention("bucket", &state, &mut metadata, false).unwrap(); + + assert_eq!(metadata.get(AMZ_OBJECT_LOCK_MODE_LOWER).map(String::as_str), Some("COMPLIANCE")); + assert!(metadata.contains_key(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER)); + assert_eq!(metadata.get("x-amz-meta-x-amz-object-lock-mode").map(String::as_str), Some("GOVERNANCE")); + + let mut replication_headers = HeaderMap::new(); + insert_header(&mut replication_headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true"); + insert_header( + &mut replication_headers, + rustfs_utils::http::SUFFIX_SOURCE_REPLICATION_RETENTION_TIMESTAMP, + "2026-01-01T00:00:00Z", + ); + let mut replica_metadata = HashMap::new(); + let explicit_clear = has_replication_retention_update(&replication_headers, true); + apply_bucket_default_lock_retention("bucket", &state, &mut replica_metadata, explicit_clear).unwrap(); + assert!(!replica_metadata.contains_key(AMZ_OBJECT_LOCK_MODE_LOWER)); + assert!(!replica_metadata.contains_key(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER)); + } + + /// rustfs/backlog#1009: the backfill→accounting mapping must mirror the + /// prelookup exactly — a live latest version maps to `Some(size)` (clamped + /// at 0 like the prelookup's `.max(0)`), absent/delete-marker maps to + /// `None`, and an unknown backfill maps to outer `None` so the caller + /// takes the degraded path instead of fabricating "new object". + #[test] + fn previous_current_size_from_backfill_mirrors_prelookup_semantics() { + assert_eq!(previous_current_size_from_backfill(Some(OldCurrentSize::Present(42))), Some(Some(42))); + assert_eq!(previous_current_size_from_backfill(Some(OldCurrentSize::Present(-7))), Some(Some(0))); + assert_eq!(previous_current_size_from_backfill(Some(OldCurrentSize::Absent)), Some(None)); + assert_eq!(previous_current_size_from_backfill(None), None); + } + + #[test] + fn should_use_zero_copy_rejects_boundary_at_1mb() { + let headers = HeaderMap::new(); + + assert!(!should_use_zero_copy(1024 * 1024, &headers)); + } + + #[test] + fn should_use_zero_copy_rejects_small_objects() { + let headers = HeaderMap::new(); + + assert!(!should_use_zero_copy(1024 * 1024 - 1, &headers)); + } + + #[test] + fn should_use_zero_copy_rejects_one_megabyte() { + let headers = HeaderMap::new(); + + assert!(!should_use_zero_copy(1024 * 1024, &headers)); + } + + #[test] + fn should_use_zero_copy_rejects_encrypted_requests() { + let mut headers = HeaderMap::new(); + headers.insert(AMZ_SERVER_SIDE_ENCRYPTION, HeaderValue::from_static("AES256")); + + assert!(!should_use_zero_copy(2 * 1024 * 1024, &headers)); + } + + #[tokio::test] + #[serial_test::serial(body_cache_hook)] + async fn object_progress_tracks_real_get_and_small_put_lock_waits() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; + + let object_traffic_health = Arc::new(ObjectTrafficHealth::enabled_for_test(Duration::ZERO)); + let context = temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_DATA_CACHE_ENABLE, Some("false"))], async { + crate::app::gating_test_env::app_context_with_object_traffic_health(Arc::clone(&object_traffic_health)).await + }) + .await; + let store = context.object_store(); + let bucket = format!("object-progress-{}", Uuid::new_v4()); + let object = "object.bin"; + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("object progress bucket must be created"); + put_real_cold_fill_object(&store, &bucket, object, b"initial").await; + + let metadata_entered = Arc::new(tokio::sync::Barrier::new(2)); + let metadata_resume = Arc::new(tokio::sync::Barrier::new(2)); + crate::storage::options::install_versioning_config_test_hook( + bucket.clone(), + Arc::clone(&metadata_entered), + Arc::clone(&metadata_resume), + ); + let metadata_input = GetObjectInput::builder() + .bucket(bucket.clone()) + .key(object.to_string()) + .build() + .expect("metadata GET input must build"); + let metadata_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context))); + let metadata_get = tokio::spawn(async move { + metadata_usecase + .execute_get_object(build_request(metadata_input, Method::GET)) + .await + }); + tokio::time::timeout(Duration::from_secs(2), metadata_entered.wait()) + .await + .expect("GET must enter the bucket metadata stage"); + assert!(object_traffic_health.snapshot().read_stalled); + assert!(!metadata_get.is_finished(), "GET must still be waiting in bucket metadata"); + metadata_resume.wait().await; + let metadata_response = tokio::time::timeout(Duration::from_secs(10), metadata_get) + .await + .expect("metadata GET must finish after release") + .expect("metadata GET task must join") + .expect("metadata GET must succeed after release"); + assert!(!object_traffic_health.snapshot().read_stalled); + drop(metadata_response); + + let read_lock = store + .new_ns_lock(&bucket, object) + .await + .expect("read test namespace lock must be created") + .get_write_lock(Duration::from_secs(5)) + .await + .expect("read test namespace lock must be held"); + let get_input = GetObjectInput::builder() + .bucket(bucket.clone()) + .key(object.to_string()) + .build() + .expect("GET input must build"); + let get_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context))); + let get = tokio::spawn(async move { get_usecase.execute_get_object(build_request(get_input, Method::GET)).await }); + tokio::time::timeout(Duration::from_secs(2), async { + while !object_traffic_health.read_storage_stalled_for_test() { + tokio::task::yield_now().await; + } + }) + .await + .expect("blocked GET must publish a storage stall"); + assert!(!get.is_finished(), "GET must still be waiting for the held namespace lock"); + drop(read_lock); + let get_response = tokio::time::timeout(Duration::from_secs(10), get) + .await + .expect("GET must finish after releasing the lock") + .expect("GET task must join") + .expect("GET must succeed after releasing the lock"); + assert!(!object_traffic_health.snapshot().read_stalled); + drop(get_response); + + let write_lock = store + .new_ns_lock(&bucket, object) + .await + .expect("write test namespace lock must be created") + .get_write_lock(Duration::from_secs(5)) + .await + .expect("write test namespace lock must be held"); + let post_store_entered = Arc::new(tokio::sync::Barrier::new(2)); + let post_store_resume = Arc::new(tokio::sync::Barrier::new(2)); + install_put_post_store_test_hook(bucket.clone(), Arc::clone(&post_store_entered), Arc::clone(&post_store_resume)); + let payload = Bytes::from_static(b"replacement"); + let put_input = PutObjectInput::builder() + .bucket(bucket) + .key(object.to_string()) + .body(Some(StreamingBlob::from(s3s::Body::from(payload.clone())))) + .content_length(Some(i64::try_from(payload.len()).expect("test payload length must fit i64"))) + .build() + .expect("PUT input must build"); + let put_usecase = DefaultObjectUsecase::with_context(Some(context)); + let put = tokio::spawn(async move { + put_usecase + .execute_put_object(&FS::new(), build_request(put_input, Method::PUT)) + .await + }); + tokio::time::timeout(Duration::from_secs(2), async { + while !object_traffic_health.snapshot().write_stalled { + tokio::task::yield_now().await; + } + }) + .await + .expect("blocked small PUT must publish a storage stall"); + assert!(!put.is_finished(), "PUT must still be waiting for the held namespace lock"); + drop(write_lock); + tokio::time::timeout(Duration::from_secs(10), post_store_entered.wait()) + .await + .expect("PUT must reach the first post-store hook"); + assert!(!object_traffic_health.snapshot().write_stalled); + assert!(!put.is_finished(), "PUT must remain blocked after the store guard has ended"); + post_store_resume.wait().await; + tokio::time::timeout(Duration::from_secs(10), put) + .await + .expect("PUT must finish after releasing the lock") + .expect("PUT task must join") + .expect("PUT must succeed after releasing the lock"); + let recovered = object_traffic_health.snapshot(); + assert!(!recovered.read_stalled); + assert!(!recovered.write_stalled); + } + + #[tokio::test] + #[serial_test::serial(body_cache_hook)] + async fn cancelled_put_request_completes_post_commit_publication() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; + + let (store, context) = real_cold_fill_test_context().await; + let bucket = format!("put-owner-tail-{}", Uuid::new_v4()); + let object = "object.bin"; + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("PUT owner-tail bucket must be created"); + + let old_body = Bytes::from_static(b"old body that must be invalidated"); + let old_info = put_real_cold_fill_object(&store, &bucket, object, &old_body).await; + let adapter = context.object_data_cache(); + let old_plan = real_cold_fill_plan(&adapter, &bucket, object, &old_info); + + let post_store_entered = Arc::new(tokio::sync::Barrier::new(2)); + let post_store_resume = Arc::new(tokio::sync::Barrier::new(2)); + install_put_post_store_test_hook(bucket.clone(), Arc::clone(&post_store_entered), Arc::clone(&post_store_resume)); + + let payload = Bytes::from_static(b"published despite caller cancellation"); + let put_input = PutObjectInput::builder() + .bucket(bucket.clone()) + .key(object.to_string()) + .body(Some(StreamingBlob::from(s3s::Body::from(payload.clone())))) + .content_length(Some(i64::try_from(payload.len()).expect("test payload length must fit i64"))) + .build() + .expect("PUT input must build"); + let put_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context))); + let put = tokio::spawn(async move { + put_usecase + .execute_put_object(&FS::new(), build_request(put_input, Method::PUT)) + .await + }); + + tokio::time::timeout(Duration::from_secs(10), post_store_entered.wait()) + .await + .expect("PUT must reach the post-store owner-tail hook"); + assert_eq!( + adapter.fill_body(&old_plan, old_body.clone()).await, + rustfs_object_data_cache::ObjectDataCacheFillResult::Inserted, + "test must republish the old body while the owner tail is paused" + ); + put.abort(); + post_store_resume.wait().await; + let _ = put.await.expect_err("outer request task must be cancelled"); + + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if matches!( + adapter.lookup_body(&old_plan).await, + rustfs_object_data_cache::ObjectDataCacheLookup::Miss + ) { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("post-commit owner tail must invalidate stale body cache after caller cancellation"); + + let recovered = store + .get_object_info(&bucket, object, &ObjectOptions::default()) + .await + .expect("cancelled request's owned commit must still publish the object"); + assert_eq!(recovered.size, i64::try_from(payload.len()).expect("test payload length must fit i64")); + } + + #[tokio::test] + async fn object_progress_tracks_zero_byte_and_zero_copy_put_lock_waits() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; + + let object_traffic_health = Arc::new(ObjectTrafficHealth::enabled_for_test(Duration::ZERO)); + let context = + crate::app::gating_test_env::app_context_with_object_traffic_health(Arc::clone(&object_traffic_health)).await; + let store = context.object_store(); + let bucket = format!("progress-buffered-{}", Uuid::new_v4()); + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("buffered PUT progress bucket must be created"); + + let extra_body_object = "zero-byte-extra.bin"; + let extra_body_input = PutObjectInput::builder() + .bucket(bucket.clone()) + .key(extra_body_object.to_string()) + .body(Some(StreamingBlob::from(s3s::Body::from(Bytes::from_static(b"x"))))) + .content_length(Some(88)) + .build() + .expect("zero-byte extra-body PUT input must build"); + let extra_body_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context))); + let mut extra_body_request = build_request(extra_body_input, Method::PUT); + extra_body_request.headers = streaming_headers(Some("0")); + let extra_body_err = extra_body_usecase + .execute_put_object(&FS::new(), extra_body_request) + .await + .expect_err("decoded zero-byte PUT with body data must fail"); + assert_eq!(extra_body_err.code(), &S3ErrorCode::UnexpectedContent); + assert!(!object_traffic_health.snapshot().write_stalled); + let lookup_err = store + .get_object_info(&bucket, extra_body_object, &ObjectOptions::default()) + .await + .expect_err("rejected zero-byte PUT must not create an object"); + assert!(is_err_object_not_found(&lookup_err)); + + let zero_object = "zero-byte.bin"; + let zero_write_lock = store + .new_ns_lock(&bucket, zero_object) + .await + .expect("zero-byte PUT namespace lock must be created") + .get_write_lock(Duration::from_secs(30)) + .await + .expect("zero-byte PUT namespace lock must be held"); + let (body_polled_tx, body_polled_rx) = tokio::sync::oneshot::channel(); + let (body_release_tx, body_release_rx) = tokio::sync::oneshot::channel(); + let pending_zero_body = StreamingBlob::wrap(futures::stream::once(async move { + body_polled_tx.send(()).expect("zero-byte body poll signal must be received"); + body_release_rx.await.expect("zero-byte body EOF must be released"); + Ok::(Bytes::new()) + })); + let zero_input = PutObjectInput::builder() + .bucket(bucket.clone()) + .key(zero_object.to_string()) + .body(Some(pending_zero_body)) + .content_length(Some(87)) + .build() + .expect("zero-byte PUT input must build"); + let zero_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context))); + let mut zero_request = build_request(zero_input, Method::PUT); + zero_request.headers = streaming_headers(Some("0")); + let zero_put = tokio::spawn(async move { zero_usecase.execute_put_object(&FS::new(), zero_request).await }); + + tokio::time::timeout(Duration::from_secs(30), body_polled_rx) + .await + .expect("zero-byte PUT body must be polled for EOF") + .expect("zero-byte PUT body poll signal must be sent"); + assert!(!object_traffic_health.snapshot().write_stalled); + assert!(!zero_put.is_finished(), "zero-byte PUT must still be waiting for request EOF"); + + body_release_tx.send(()).expect("zero-byte PUT body EOF must be released"); + tokio::time::timeout(Duration::from_secs(30), async { + while !object_traffic_health.snapshot().write_stalled { + tokio::task::yield_now().await; + } + }) + .await + .expect("fully received zero-byte PUT must publish a storage stall"); + assert!(!zero_put.is_finished(), "zero-byte PUT must still be waiting for the held namespace lock"); + + drop(zero_write_lock); + tokio::time::timeout(Duration::from_secs(30), zero_put) + .await + .expect("zero-byte PUT must finish after releasing the lock") + .expect("zero-byte PUT task must join") + .expect("zero-byte PUT must succeed after releasing the lock"); + assert!(!object_traffic_health.snapshot().write_stalled); + + let zero_copy_object = "zero-copy-eager.jpg"; + let zero_copy_payload = Bytes::from(vec![b'z'; 1024 * 1024 + 1]); + let zero_copy_size = i64::try_from(zero_copy_payload.len()).expect("zero-copy payload length must fit i64"); + let zero_copy_headers = HeaderMap::new(); + assert!(!is_disk_compressible(&zero_copy_headers, zero_copy_object)); + assert_eq!( + zero_copy_eager_put_path_status(zero_copy_size, &zero_copy_headers, false, false, false), + PUT_EAGER_STATUS_ELIGIBLE, + "test payload must exercise the production zero-copy eager path", + ); + let zero_copy_write_lock = store + .new_ns_lock(&bucket, zero_copy_object) + .await + .expect("zero-copy PUT namespace lock must be created") + .get_write_lock(Duration::from_secs(30)) + .await + .expect("zero-copy PUT namespace lock must be held"); + let zero_copy_input = PutObjectInput::builder() + .bucket(bucket) + .key(zero_copy_object.to_string()) + .body(Some(StreamingBlob::from(s3s::Body::from(zero_copy_payload)))) + .content_length(Some(zero_copy_size)) + .build() + .expect("zero-copy PUT input must build"); + let zero_copy_usecase = DefaultObjectUsecase::with_context(Some(context)); + let zero_copy_put = tokio::spawn(async move { + zero_copy_usecase + .execute_put_object(&FS::new(), build_request(zero_copy_input, Method::PUT)) + .await + }); + + tokio::time::timeout(Duration::from_secs(30), async { + while !object_traffic_health.snapshot().write_stalled { + tokio::task::yield_now().await; + } + }) + .await + .expect("blocked zero-copy eager PUT must publish a storage stall"); + assert!( + !zero_copy_put.is_finished(), + "zero-copy PUT must still be waiting for the held namespace lock" + ); + + drop(zero_copy_write_lock); + tokio::time::timeout(Duration::from_secs(30), zero_copy_put) + .await + .expect("zero-copy PUT must finish after releasing the lock") + .expect("zero-copy PUT task must join") + .expect("zero-copy PUT must succeed after releasing the lock"); + assert!(!object_traffic_health.snapshot().write_stalled); + } + + #[tokio::test] + async fn put_object_body_read_timeout_guard_aborts_on_stall() { + // Inner stream never yields and never reports EOF (a proxy that forwarded + // a partial body then went silent while holding the connection open). + let inner = StreamingBlob::wrap(futures::stream::pending::>()); + let mut guarded = guard_put_object_body_read_timeout( + inner, + "test-bucket", + "stalled-object", + "req-1", + Some(1024), + Duration::from_millis(1), + ); + + let err = guarded + .next() + .await + .expect("guard should yield a stall error") + .expect_err("stalled body should return an error"); + let io_err = err + .downcast_ref::() + .expect("stall error should wrap an io::Error"); + assert_eq!(io_err.kind(), std::io::ErrorKind::TimedOut); + + // After a stall the guard terminates the stream instead of re-polling the + // abandoned inner stream. + assert!(guarded.next().await.is_none()); + } + + #[tokio::test] + async fn put_object_body_read_timeout_guard_preserves_length_and_passes_through() { + let body = StreamingBlob::from(s3s::Body::from(Bytes::from_static(b"hello world"))); + assert_eq!(body.remaining_length().exact(), Some(11)); + + let mut guarded = + guard_put_object_body_read_timeout(body, "test-bucket", "ok-object", "req-2", Some(11), Duration::from_secs(60)); + // remaining_length must be forwarded, not reset to unknown. + assert_eq!(guarded.remaining_length().exact(), Some(11)); + + let mut collected = Vec::new(); + while let Some(chunk) = guarded.next().await { + collected.extend_from_slice(&chunk.expect("chunk should read")); + } + assert_eq!(collected, b"hello world"); + } + + #[tokio::test] + async fn put_object_body_read_timeout_guard_disabled_passthrough() { + let body = StreamingBlob::from(s3s::Body::from(Bytes::from_static(b"data"))); + let mut guarded = guard_put_object_body_read_timeout(body, "test-bucket", "ok-object", "req-3", Some(4), Duration::ZERO); + + let mut collected = Vec::new(); + while let Some(chunk) = guarded.next().await { + collected.extend_from_slice(&chunk.expect("chunk should read")); + } + assert_eq!(collected, b"data"); + } + + #[test] + fn should_use_zero_copy_rejects_encrypted_requests_with_sse_customer_algorithm() { + let mut headers = HeaderMap::new(); + headers.insert(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM, HeaderValue::from_static("AES256")); + + assert!(!should_use_zero_copy(2 * 1024 * 1024, &headers)); + } + + #[test] + fn should_use_zero_copy_rejects_encrypted_requests_with_kms_key_id() { + let mut headers = HeaderMap::new(); + headers.insert(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID, HeaderValue::from_static("test-kms-key-id")); + + assert!(!should_use_zero_copy(2 * 1024 * 1024, &headers)); + } + + #[test] + fn should_use_zero_copy_rejects_compressible_content_types() { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json; charset=utf-8")); + + assert!(!should_use_zero_copy(2 * 1024 * 1024, &headers)); + } + + #[test] + fn should_use_small_eager_put_path_allows_up_to_1mb() { + let headers = HeaderMap::new(); + + assert!(should_use_small_eager_put_path(1024, &headers, false, false, false)); + assert!(should_use_small_eager_put_path(1024 * 1024, &headers, false, false, false)); + assert!(!should_use_small_eager_put_path(1024 * 1024 + 1, &headers, false, false, false)); + } + + #[test] + fn should_use_small_eager_put_path_rejects_sse_requests() { + let headers = HeaderMap::new(); + + assert!(!should_use_small_eager_put_path(1024, &headers, true, false, false)); + } + + #[test] + fn should_use_small_eager_put_path_rejects_compressible_objects() { + let headers = HeaderMap::new(); + + assert!(!should_use_small_eager_put_path(1024, &headers, false, true, false)); + } + + #[test] + fn should_use_small_eager_put_path_rejects_extract_requests() { + let headers = HeaderMap::new(); + + assert!(!should_use_small_eager_put_path(1024, &headers, false, false, true)); + } + + #[test] + fn should_use_small_eager_put_path_rejects_large_or_empty_objects() { + let headers = HeaderMap::new(); + + assert!(!should_use_small_eager_put_path(0, &headers, false, false, false)); + assert!(!should_use_small_eager_put_path(1024 * 1024 + 1, &headers, false, false, false)); + } + + #[test] + fn should_use_zero_copy_eager_put_path_allows_large_plain_objects_within_cap() { + let headers = HeaderMap::new(); + + assert!(should_use_zero_copy_eager_put_path(2 * 1024 * 1024, &headers, false, false, false)); + assert!(should_use_zero_copy_eager_put_path(16 * 1024 * 1024, &headers, false, false, false)); + assert!(!should_use_zero_copy_eager_put_path(16 * 1024 * 1024 + 1, &headers, false, false, false)); + assert_eq!( + zero_copy_eager_put_path_status(16 * 1024 * 1024, &headers, false, false, false), + PUT_EAGER_STATUS_ELIGIBLE + ); + assert_eq!( + zero_copy_eager_put_path_status(16 * 1024 * 1024 + 1, &headers, false, false, false), + PUT_EAGER_STATUS_ABOVE_EAGER_MAX + ); + } + + #[test] + fn zero_copy_eager_put_path_status_honors_configured_cap() { + let headers = HeaderMap::new(); + let max_size = 64 * 1024 * 1024; + + assert_eq!( + zero_copy_eager_put_path_status_with_max_size(33 * 1024 * 1024, &headers, false, false, false, max_size), + PUT_EAGER_STATUS_ELIGIBLE + ); + assert_eq!( + zero_copy_eager_put_path_status_with_max_size(65 * 1024 * 1024, &headers, false, false, false, max_size), + PUT_EAGER_STATUS_ABOVE_EAGER_MAX + ); + } + + #[test] + fn should_use_zero_copy_eager_put_path_rejects_compression_sse_and_extract() { + let headers = HeaderMap::new(); + + assert!(!should_use_zero_copy_eager_put_path(2 * 1024 * 1024, &headers, true, false, false)); + assert!(!should_use_zero_copy_eager_put_path(2 * 1024 * 1024, &headers, false, true, false)); + assert!(!should_use_zero_copy_eager_put_path(2 * 1024 * 1024, &headers, false, false, true)); + assert_eq!( + zero_copy_eager_put_path_status(2 * 1024 * 1024, &headers, true, false, false), + PUT_EAGER_STATUS_ENCRYPTED + ); + assert_eq!( + zero_copy_eager_put_path_status(2 * 1024 * 1024, &headers, false, true, false), + PUT_EAGER_STATUS_COMPRESSED + ); + assert_eq!( + zero_copy_eager_put_path_status(2 * 1024 * 1024, &headers, false, false, true), + PUT_EAGER_STATUS_EXTRACT + ); + } + + #[tokio::test] + async fn read_small_put_body_maps_upload_stream_sha256_mismatch_to_bad_digest() { + let body = StreamReader::new(futures::stream::iter(vec![Err::(s3s_body_error_to_io(Box::new( + MockUploadStreamSha256Mismatch, + )))])); + + let error = read_small_put_body_exact_direct(body, 1) + .await + .expect_err("SHA256 mismatch should reject the small PUT body"); + + assert_eq!(error.code(), &S3ErrorCode::BadDigest); + } + + #[tokio::test] + async fn read_zero_copy_put_body_maps_upload_stream_sha256_mismatch_to_bad_digest() { + let body = futures::stream::iter(vec![Err::(MockUploadStreamSha256Mismatch)]); + + let error = match read_zero_copy_put_body_exact(body, 1).await { + Ok(_) => panic!("SHA256 mismatch should reject the zero-copy PUT body"), + Err(error) => error, + }; + + assert_eq!(error.code(), &S3ErrorCode::BadDigest); + } + + struct FragmentedBody { + data: std::io::Cursor>, + } + + impl AsyncRead for FragmentedBody { + fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + let position = usize::try_from(self.data.position()).expect("test cursor position should fit usize"); + let remaining = &self.data.get_ref()[position..]; + let copied = remaining.len().min(buf.remaining()).min(2); + buf.put_slice(&remaining[..copied]); + self.data + .set_position(u64::try_from(position + copied).expect("test cursor position should fit u64")); + Poll::Ready(Ok(())) + } + } + + struct InitializedLengthProbe { + data: std::io::Cursor>, + initialized_lengths: Arc>>, + } + + impl AsyncRead for InitializedLengthProbe { + fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + self.initialized_lengths + .lock() + .expect("initialized-length probe lock should not poison") + .push(buf.initialized().len()); + let position = usize::try_from(self.data.position()).expect("test cursor position should fit usize"); + let remaining = &self.data.get_ref()[position..]; + let copied = remaining.len().min(buf.remaining()); + buf.put_slice(&remaining[..copied]); + self.data + .set_position(u64::try_from(position + copied).expect("test cursor position should fit u64")); + Poll::Ready(Ok(())) + } + } + + #[tokio::test] + async fn read_small_put_body_exact_pooled_reads_exact_bytes_without_prefill() { + let pool = get_concurrency_manager().bytes_pool(); + let initialized_lengths = Arc::new(Mutex::new(Vec::new())); + let body = InitializedLengthProbe { + data: std::io::Cursor::new(b"hello".to_vec()), + initialized_lengths: Arc::clone(&initialized_lengths), + }; + + let buffer = read_small_put_body_exact_pooled(body, 5, pool.as_ref()) + .await + .expect("pooled exact read should succeed"); + + assert_eq!(&buffer[..5], b"hello"); + assert_eq!(buffer.len(), 5); + assert_eq!( + initialized_lengths + .lock() + .expect("initialized-length probe lock should not poison")[0], + 0, + "the first pooled body read must use uninitialized spare capacity rather than a zero-filled slice" + ); + } + + #[tokio::test] + async fn read_small_put_body_exact_pooled_rejects_short_body() { + let pool = get_concurrency_manager().bytes_pool(); + let body = std::io::Cursor::new(b"hell".to_vec()); + + let err = match read_small_put_body_exact_pooled(body, 5, pool.as_ref()).await { + Ok(_) => panic!("short pooled body should fail"), + Err(err) => err, + }; + + assert_eq!(err.code(), &S3ErrorCode::IncompleteBody); + } + + #[tokio::test] + async fn read_small_put_body_exact_pooled_rejects_extra_body() { + let pool = get_concurrency_manager().bytes_pool(); + let body = std::io::Cursor::new(b"hello!".to_vec()); + + let err = match read_small_put_body_exact_pooled(body, 5, pool.as_ref()).await { + Ok(_) => panic!("extra pooled body should fail"), + Err(err) => err, + }; + + assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); + } + + #[tokio::test] + async fn read_small_put_body_exact_direct_reads_exact_bytes_without_prefill() { + let body = std::io::Cursor::new(b"hello".to_vec()); + let reader = read_small_put_body_exact_direct(body, 5) + .await + .expect("direct exact read should succeed"); + + assert_eq!(reader.get_ref().as_slice(), b"hello"); + assert_eq!(reader.get_ref().len(), 5); + } + + #[tokio::test] + async fn read_small_put_body_exact_direct_rejects_short_and_extra_bodies() { + let short = read_small_put_body_exact_direct(std::io::Cursor::new(b"hell".to_vec()), 5) + .await + .expect_err("short direct body should fail"); + assert_eq!(short.code(), &S3ErrorCode::IncompleteBody); + + let extra = read_small_put_body_exact_direct(std::io::Cursor::new(b"hello!".to_vec()), 5) + .await + .expect_err("extra direct body should fail"); + assert_eq!(extra.code(), &S3ErrorCode::UnexpectedContent); + } + + #[tokio::test] + async fn read_small_put_body_exact_direct_handles_empty_body_boundary() { + let empty = read_small_put_body_exact_direct(std::io::Cursor::new(Vec::::new()), 0) + .await + .expect("empty direct body should succeed"); + assert!(empty.get_ref().is_empty()); + + let extra = read_small_put_body_exact_direct(std::io::Cursor::new(vec![1u8]), 0) + .await + .expect_err("non-empty body declared as empty should fail"); + assert_eq!(extra.code(), &S3ErrorCode::UnexpectedContent); + } + + #[tokio::test] + async fn read_small_put_body_exact_direct_rejects_error_after_partial_read() { + struct PartialThenError { + delivered_prefix: bool, + } + + impl AsyncRead for PartialThenError { + fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + if self.delivered_prefix { + return Poll::Ready(Err(std::io::Error::other("body read failed"))); + } + + self.delivered_prefix = true; + buf.put_slice(b"he"); + Poll::Ready(Ok(())) + } + } + + let err = read_small_put_body_exact_direct(PartialThenError { delivered_prefix: false }, 5) + .await + .expect_err("a partial body followed by an I/O error must fail"); + + assert_eq!(err.code(), &S3ErrorCode::InternalError); + } + + #[tokio::test] + async fn read_small_put_body_exact_direct_accepts_fragmented_body() { + let reader = read_small_put_body_exact_direct( + FragmentedBody { + data: std::io::Cursor::new(b"hello".to_vec()), + }, + 5, + ) + .await + .expect("a fragmented exact-length body should succeed"); + + assert_eq!(reader.get_ref().as_slice(), b"hello"); + } + + #[tokio::test] + async fn read_small_put_body_exact_direct_rejects_fragmented_extra_body() { + let err = read_small_put_body_exact_direct( + FragmentedBody { + data: std::io::Cursor::new(b"hello!".to_vec()), + }, + 5, + ) + .await + .expect_err("a fragmented body longer than declared must fail"); + + assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); + } + + #[tokio::test] + async fn read_small_put_body_exact_direct_reads_into_uninitialized_spare_capacity() { + let initialized_lengths = Arc::new(Mutex::new(Vec::new())); + let body = InitializedLengthProbe { + data: std::io::Cursor::new(b"hello".to_vec()), + initialized_lengths: Arc::clone(&initialized_lengths), + }; + + let reader = read_small_put_body_exact_direct(body, 5) + .await + .expect("direct exact read should succeed"); + + assert_eq!(reader.get_ref().as_slice(), b"hello"); + assert_eq!( + initialized_lengths + .lock() + .expect("initialized-length probe lock should not poison")[0], + 0, + "the first body read must use uninitialized spare capacity rather than a zero-filled slice" + ); + } + + #[tokio::test] + async fn read_zero_copy_put_body_exact_reads_chunked_body() { + use tokio::io::AsyncReadExt; + + let body = futures::stream::iter(vec![ + Ok::(Bytes::from_static(b"hello ")), + Ok::(Bytes::from_static(b"world")), + ]); + + let mut reader = read_zero_copy_put_body_exact(body, 11) + .await + .expect("zero-copy eager body read should succeed"); + let mut out = Vec::new(); + reader + .read_to_end(&mut out) + .await + .expect("chunked bytes reader should be readable"); + + assert_eq!(out, b"hello world"); + } + + #[tokio::test] + async fn read_zero_copy_put_body_exact_rejects_extra_bytes() { + let body = futures::stream::iter(vec![ + Ok::(Bytes::from_static(b"hello")), + Ok::(Bytes::from_static(b"!")), + ]); + + let err = match read_zero_copy_put_body_exact(body, 5).await { + Ok(_) => panic!("extra bytes should fail"), + Err(err) => err, + }; + + assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); + } + + #[tokio::test] + async fn pooled_buffer_reader_keeps_buffer_alive_until_consumed() { + use tokio::io::AsyncReadExt; + + let pool = get_concurrency_manager().bytes_pool(); + let body = std::io::Cursor::new(b"hello".to_vec()); + let buffer = read_small_put_body_exact_pooled(body, 5, pool.as_ref()) + .await + .expect("pooled exact read should succeed"); + let mut reader = PooledBufferReader::new(buffer, 5); + let mut out = Vec::new(); + + reader.read_to_end(&mut out).await.expect("pooled reader should be readable"); + + assert_eq!(out, b"hello"); + } + + #[test] + fn should_use_zero_copy_allows_large_unencrypted_binary_objects() { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/octet-stream")); + + assert!(should_use_zero_copy(2 * 1024 * 1024, &headers)); + } + + #[tokio::test] + async fn execute_put_object_rejects_post_object_sse_kms_from_input() { + let input = PutObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .server_side_encryption(Some(ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS))) + .build() + .unwrap(); + + let mut req = build_request(input, Method::POST); + req.extensions.insert(PostObjectRequestMarker); + + let usecase = DefaultObjectUsecase::without_context(); + let fs = FS::new(); + + let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::NotImplemented); + } + + #[tokio::test] + async fn execute_put_object_rejects_extract_sse_kms() { + let input = PutObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("archive.tar".to_string()) + .server_side_encryption(Some(ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS))) + .build() + .unwrap(); + + let mut req = build_request(input, Method::PUT); + req.headers.insert(AMZ_SNOWBALL_EXTRACT, HeaderValue::from_static("true")); + + let usecase = DefaultObjectUsecase::without_context(); + let fs = FS::new(); + + let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::NotImplemented); + } + + #[tokio::test] + async fn execute_put_object_extract_rejects_invalid_storage_class() { + let input = PutObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("archive.tar".to_string()) + .storage_class(Some(StorageClass::from_static("INVALID"))) + .build() + .unwrap(); + + let mut req = build_request(input, Method::PUT); + req.headers.insert(AMZ_SNOWBALL_EXTRACT, HeaderValue::from_static("true")); + + let usecase = DefaultObjectUsecase::without_context(); + let fs = FS::new(); + + let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidStorageClass); + } + + #[tokio::test] + async fn execute_put_object_rejects_post_object_sse_kms_from_headers() { + let input = PutObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .build() + .unwrap(); + + let mut req = build_request(input, Method::POST); + req.extensions.insert(PostObjectRequestMarker); + req.headers + .insert(AMZ_SERVER_SIDE_ENCRYPTION, HeaderValue::from_static("aws:kms")); + + let usecase = DefaultObjectUsecase::without_context(); + let fs = FS::new(); + + let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::NotImplemented); + } + + #[tokio::test] + async fn execute_put_object_rejects_post_object_sse_kms_key_id_header() { + let input = PutObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .build() + .unwrap(); + + let mut req = build_request(input, Method::POST); + req.extensions.insert(PostObjectRequestMarker); + req.headers + .insert(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID, HeaderValue::from_static("test-kms-key-id")); + + let usecase = DefaultObjectUsecase::without_context(); + let fs = FS::new(); + + let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::NotImplemented); + } + + #[tokio::test] + async fn execute_put_object_rejects_invalid_storage_class() { + let input = PutObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .storage_class(Some(StorageClass::from_static("INVALID-STORAGE-CLASS"))) + .build() + .unwrap(); + + let req = build_request(input, Method::PUT); + let usecase = DefaultObjectUsecase::without_context(); + let fs = FS::new(); + + let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidStorageClass); + } + + // https://github.com/rustfs/backlog/issues/1311 — bucket-quota admission must run against the authoritative + // decoded/plain object length, never the aws-chunked wire Content-Length, and must reject negative/unknown lengths. + // https://github.com/rustfs/backlog/issues/1336 — but Content-Encoding: aws-chunked alone is only a declared + // encoding: without a STREAMING-* payload the body is unframed and the wire Content-Length is authoritative. + fn aws_chunked_headers(decoded_len: Option<&str>) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(http::header::CONTENT_ENCODING, HeaderValue::from_static("aws-chunked")); + if let Some(decoded) = decoded_len { + headers.insert( + HeaderName::from_bytes(AMZ_DECODED_CONTENT_LENGTH.as_bytes()).unwrap(), + HeaderValue::from_str(decoded).unwrap(), + ); + } + headers + } + + fn streaming_headers(decoded_len: Option<&str>) -> HeaderMap { + let mut headers = aws_chunked_headers(decoded_len); + headers.insert( + HeaderName::from_bytes(AMZ_CONTENT_SHA256.as_bytes()).unwrap(), + HeaderValue::from_static("STREAMING-AWS4-HMAC-SHA256-PAYLOAD"), + ); + headers + } + + #[test] + fn authoritative_size_prefers_aws_chunked_decoded_over_wire_content_length() { + // Wire Content-Length (chunk framing) differs from the decoded object length; the decoded length wins. + let headers = streaming_headers(Some("1000")); + let size = resolve_put_object_authoritative_size(&headers, Some(1088)).expect("decoded length is authoritative"); + assert_eq!( + size, 1000, + "aws-chunked admission must use the decoded object length, not the framed wire length" + ); + + // A declared-only aws-chunked request that still carries a decoded length behaves the same. + let headers = aws_chunked_headers(Some("1000")); + let size = resolve_put_object_authoritative_size(&headers, Some(1088)).expect("decoded length is authoritative"); + assert_eq!(size, 1000); + } + + #[test] + fn authoritative_size_streaming_without_content_encoding_uses_decoded_length() { + // A streaming payload signals framing via x-amz-content-sha256 alone; Content-Encoding is optional. + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_bytes(AMZ_CONTENT_SHA256.as_bytes()).unwrap(), + HeaderValue::from_static("STREAMING-UNSIGNED-PAYLOAD-TRAILER"), + ); + headers.insert( + HeaderName::from_bytes(AMZ_DECODED_CONTENT_LENGTH.as_bytes()).unwrap(), + HeaderValue::from_static("1000"), + ); + let size = resolve_put_object_authoritative_size(&headers, Some(1088)).expect("decoded length is authoritative"); + assert_eq!( + size, 1000, + "a streaming payload without Content-Encoding must still use the decoded length" + ); + } + + #[test] + fn authoritative_size_rejects_framed_body_without_decoded_length() { + // A genuinely framed upload without x-amz-decoded-content-length has no authoritative size; + // the framed wire length must NOT be a fallback. + let headers = streaming_headers(None); + let err = resolve_put_object_authoritative_size(&headers, Some(1088)) + .expect_err("framed upload without decoded length must be rejected"); + assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); + + // ... even when the wire Content-Length is also absent. + let err = + resolve_put_object_authoritative_size(&headers, None).expect_err("framed upload without any length must be rejected"); + assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); + } + + #[test] + fn authoritative_size_declared_aws_chunked_without_streaming_uses_wire_content_length() { + // backlog#1336: an SDK PUT that merely declares Content-Encoding: aws-chunked (issue #1857 + // clients) has an unframed body and no decoded length; the wire Content-Length is the real + // object size and the request must be admitted, not rejected with UnexpectedContent. + let headers = aws_chunked_headers(None); + let size = resolve_put_object_authoritative_size(&headers, Some(1088)) + .expect("declared-only aws-chunked must fall back to the wire Content-Length"); + assert_eq!(size, 1088); + + // Same for a combined declared encoding (aws-chunked,gzip). + let mut headers = HeaderMap::new(); + headers.insert(http::header::CONTENT_ENCODING, HeaderValue::from_static("aws-chunked,gzip")); + let size = resolve_put_object_authoritative_size(&headers, Some(2048)) + .expect("declared-only aws-chunked,gzip must fall back to the wire Content-Length"); + assert_eq!(size, 2048); + + // Without any length information it is still rejected. + let headers = aws_chunked_headers(None); + let err = resolve_put_object_authoritative_size(&headers, None) + .expect_err("declared-only aws-chunked with no length at all must be rejected"); + assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); + } + + #[test] + fn authoritative_size_plain_put_uses_content_length() { + let headers = HeaderMap::new(); + let size = resolve_put_object_authoritative_size(&headers, Some(4096)).expect("plain PUT uses Content-Length"); + assert_eq!(size, 4096); + } + + #[test] + fn authoritative_size_plain_put_falls_back_to_decoded_length() { + // Non-chunked request that only surfaced an explicit decoded length. + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_bytes(AMZ_DECODED_CONTENT_LENGTH.as_bytes()).unwrap(), + HeaderValue::from_static("2048"), + ); + let size = resolve_put_object_authoritative_size(&headers, None).expect("decoded length is the fallback"); + assert_eq!(size, 2048); + } + + #[test] + fn authoritative_size_rejects_unknown_length() { + let headers = HeaderMap::new(); + let err = resolve_put_object_authoritative_size(&headers, None).expect_err("no length information must be rejected"); + assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); + } + + #[test] + fn authoritative_size_rejects_negative_length() { + // A negative decoded length would wrap to an enormous unsigned size for quota/buffer sizing; reject it. + let headers = aws_chunked_headers(Some("-1")); + let err = + resolve_put_object_authoritative_size(&headers, Some(64)).expect_err("negative decoded length must be rejected"); + assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); + + let plain = HeaderMap::new(); + let err = + resolve_put_object_authoritative_size(&plain, Some(-100)).expect_err("negative Content-Length must be rejected"); + assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); + } + + #[test] + fn authoritative_size_accepts_exact_and_rejects_negative_boundary() { + // Exact zero-length object is admissible (the over-by-1/exact-limit boundary is enforced by the quota checker on this value). + let headers = aws_chunked_headers(Some("0")); + assert_eq!( + resolve_put_object_authoritative_size(&headers, Some(87)).expect("zero-length decoded is valid"), + 0 + ); + } + + #[tokio::test] + #[serial_test::serial] + async fn quota_rejects_ciphertext_replication_before_polling_the_body() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let (_store, bucket) = + crate::app::gating_test_env::durable_quota_test_bucket("ciphertext-replication-early-reject", 4096).await; + let body_polled = Arc::new(AtomicBool::new(false)); + let body_polled_in_stream = Arc::clone(&body_polled); + let body = StreamingBlob::wrap(futures::stream::once(async move { + body_polled_in_stream.store(true, Ordering::Release); + Ok::(Bytes::from_static(b"ciphertext")) + })); + let input = PutObjectInput::builder() + .bucket(bucket) + .key("object".to_string()) + .body(Some(body)) + .content_length(Some(10)) + .build() + .expect("ciphertext replication PUT input should build"); + let mut request = build_request(input, Method::PUT); + insert_header(&mut request.headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true"); + request + .headers + .insert(rustfs_utils::http::REPLICATION_SSEC_ALGORITHM_HEADER, HeaderValue::from_static("AES256")); + request.extensions.insert(crate::storage::access::ReqInfo { + replication_request_authorized: true, + ..Default::default() + }); + + let err = DefaultObjectUsecase::from_global() + .execute_put_object(&FS::new(), request) + .await + .expect_err("quota-enabled ciphertext replication should fail at ingress"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert!(!body_polled.load(Ordering::Acquire), "rejected ciphertext body must not be consumed"); + } + + #[tokio::test] + #[serial_test::serial] + async fn legacy_quota_rejects_full_put_before_polling_the_body() { + use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; + use std::sync::atomic::{AtomicBool, Ordering}; + + const GI_B: u64 = 1024 * 1024 * 1024; + let store = crate::app::gating_test_env::shared_gating_ecstore().await; + crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; + let bucket = format!("legacy-quota-{}", Uuid::new_v4().simple()); + store + .make_bucket(&bucket, &MakeBucketOptions::default()) + .await + .expect("create legacy quota test bucket"); + crate::app::storage_api::test::data_usage::seed_bucket_usage_memory_for_test(&bucket, 4 * GI_B).await; + let metadata_sys = DefaultObjectUsecase::from_global() + .bucket_metadata_sys() + .expect("test app context should expose bucket metadata"); + QuotaChecker::new(metadata_sys) + .set_quota_config( + &bucket, + BucketQuota { + quota: Some(5 * GI_B), + ..Default::default() + }, + ) + .await + .expect("configure legacy quota"); + + let body_polled = Arc::new(AtomicBool::new(false)); + let body_polled_in_stream = Arc::clone(&body_polled); + let body = StreamingBlob::wrap(futures::stream::once(async move { + body_polled_in_stream.store(true, Ordering::Release); + Ok::(Bytes::new()) + })); + let input = PutObjectInput::builder() + .bucket(bucket) + .key("object".to_string()) + .body(Some(body)) + .content_length(Some(i64::try_from(2 * GI_B).expect("test size should fit i64"))) + .build() + .expect("legacy quota PUT input should build"); + + let err = DefaultObjectUsecase::from_global() + .execute_put_object(&FS::new(), build_request(input, Method::PUT)) + .await + .expect_err("4 GiB used plus a 2 GiB PUT must exceed a 5 GiB legacy quota"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert!(!body_polled.load(Ordering::Acquire), "legacy quota rejection must not consume the body"); + } + + #[tokio::test] + #[serial_test::serial] + async fn concurrent_puts_share_durable_bucket_quota_reservations() { + let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("concurrent-put-quota", 6000).await; + + let first_opts = ObjectOptions::default(); + let second_opts = ObjectOptions::default(); + let first_store = Arc::clone(&store); + let first_bucket = bucket.clone(); + let first = tokio::spawn(async move { + let mut reader = PutObjReader::from_vec(vec![0x73; 4096]); + first_store.put_object(&first_bucket, "first", &mut reader, &first_opts).await + }); + let second = tokio::spawn(async move { + let mut reader = PutObjReader::from_vec(vec![0x74; 4096]); + store.put_object(&bucket, "second", &mut reader, &second_opts).await + }); + let (first, second) = tokio::join!(first, second); + let first = first.expect("first PUT task should not panic"); + let second = second.expect("second PUT task should not panic"); + + assert_eq!(usize::from(first.is_ok()) + usize::from(second.is_ok()), 1); + let denied = first.err().or_else(|| second.err()).expect("one PUT must be denied"); + assert!(matches!( + denied, + StorageError::QuotaExceeded { + current: 4096, + limit: 6000 + } + )); + } + + #[tokio::test] + #[serial_test::serial] + async fn concurrent_within_limit_puts_keep_independent_mutation_fences() { + use crate::app::storage_api::test::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause}; + + let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("concurrent-fence-quota", 8192).await; + let first_barrier = PutObjectCommitBarrier::install(&bucket, "first", PutObjectCommitPause::BeforeQuotaRename); + let second_barrier = PutObjectCommitBarrier::install(&bucket, "second", PutObjectCommitPause::BeforeQuotaRename); + + let first_store = Arc::clone(&store); + let first_bucket = bucket.clone(); + let first = tokio::spawn(async move { + let mut reader = PutObjReader::from_vec(vec![0x75; 4096]); + first_store + .put_object(&first_bucket, "first", &mut reader, &ObjectOptions::default()) + .await + }); + let second_store = Arc::clone(&store); + let second_bucket = bucket.clone(); + let second = tokio::spawn(async move { + let mut reader = PutObjReader::from_vec(vec![0x76; 4096]); + second_store + .put_object(&second_bucket, "second", &mut reader, &ObjectOptions::default()) + .await + }); + + first_barrier.wait_until_paused().await; + second_barrier.wait_until_paused().await; + first_barrier.release(); + second_barrier.release(); + + first + .await + .expect("first PUT task should not panic") + .expect("first within-limit PUT should commit"); + second + .await + .expect("second PUT task should not panic") + .expect("second within-limit PUT should commit"); + } + + #[tokio::test] + #[serial_test::serial] + async fn put_rejects_rotated_quota_capability_before_rename() { + use crate::app::storage_api::test::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause}; + + let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("rotated-proof-put-quota", 4096).await; + let barrier = PutObjectCommitBarrier::install(&bucket, "object", PutObjectCommitPause::BeforeQuotaRename); + let put_store = Arc::clone(&store); + let put_bucket = bucket.clone(); + let put = tokio::spawn(async move { + let mut reader = PutObjReader::from_vec(vec![0x77; 4096]); + put_store + .put_object(&put_bucket, "object", &mut reader, &ObjectOptions::default()) + .await + }); + barrier.wait_until_paused().await; + assert!( + crate::storage::storage_api::ecstore_notification::rotate_cross_pool_fence_fleet_proof_for_test(), + "the gating environment must have a current fleet proof" + ); + barrier.release(); + + let err = put + .await + .expect("PUT task should not panic") + .expect_err("a replaced fleet proof must fence the authoritative rename"); + assert!(matches!( + err, + StorageError::NamespaceLockQuorumUnavailable { + mode: "quota_reservation", + .. + } + )); + store + .get_object_info(&bucket, "object", &ObjectOptions::default()) + .await + .expect_err("proof rotation before rename must leave no committed object"); + } + + #[tokio::test] + #[serial_test::serial] + async fn data_movement_put_has_zero_quota_growth() { + let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("data-movement-put-quota", 0).await; + let mut reader = PutObjReader::from_vec(vec![0x79; 4096]); + let stored = store + .put_object( + &bucket, + "object", + &mut reader, + &ObjectOptions { + data_movement: true, + ..Default::default() + }, + ) + .await + .expect("moving an already-accounted object between pools must have zero quota growth"); + assert_eq!(stored.size, 4096); + } + + #[tokio::test] + #[serial_test::serial] + async fn cancelled_put_releases_durable_quota_reservation() { + use crate::app::storage_api::test::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause}; + + let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("cancelled-put-quota", 4096).await; + + let barrier = PutObjectCommitBarrier::install(&bucket, "cancelled", PutObjectCommitPause::AfterQuotaReservation); + let cancelled_store = Arc::clone(&store); + let cancelled_bucket = bucket.clone(); + let cancelled = tokio::spawn(async move { + let mut reader = PutObjReader::from_vec(vec![0x51; 4096]); + cancelled_store + .put_object(&cancelled_bucket, "cancelled", &mut reader, &ObjectOptions::default()) + .await + }); + barrier.wait_until_paused().await; + cancelled.abort(); + let cancelled_result = cancelled.await; + assert!(cancelled_result.is_err(), "the paused request must be cancelled"); + drop(barrier); + + let mut replacement = PutObjReader::from_vec(vec![0x52; 4096]); + store + .put_object(&bucket, "replacement", &mut replacement, &ObjectOptions::default()) + .await + .expect("cancelling before commit must release the complete reservation"); + } + + #[tokio::test] + #[serial_test::serial] + async fn cancelled_put_after_commit_marker_is_reconciled() { + use crate::app::storage_api::test::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause}; + + let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("cancelled-spawned-put-quota", 4096).await; + let commit_barrier = PutObjectCommitBarrier::install(&bucket, "object", PutObjectCommitPause::BeforeQuotaRename); + let first_store = Arc::clone(&store); + let first_bucket = bucket.clone(); + let first = tokio::spawn(async move { + let mut reader = PutObjReader::from_vec(vec![0x53; 4096]); + first_store + .put_object(&first_bucket, "object", &mut reader, &ObjectOptions::default()) + .await + }); + commit_barrier.wait_until_paused().await; + first.abort(); + assert!(first.await.is_err(), "the outer request task must be cancelled"); + drop(commit_barrier); + + store + .get_object_info(&bucket, "object", &ObjectOptions::default()) + .await + .expect_err("cancelling before rename must not commit the object"); + let mut replacement = PutObjReader::from_vec(vec![0x54; 4096]); + store + .put_object(&bucket, "replacement", &mut replacement, &ObjectOptions::default()) + .await + .expect("the next admission must reap the abandoned commit marker"); + } + + #[tokio::test] + #[serial_test::serial] + async fn committed_put_survives_quota_ledger_settlement_failure() { + use crate::app::storage_api::test::set_disk::{ + PutObjectCommitBarrier, PutObjectCommitPause, fail_next_quota_ledger_save_for_test, + }; + + let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("settlement-failure-quota", 4096).await; + let barrier = PutObjectCommitBarrier::install(&bucket, "object", PutObjectCommitPause::BeforeQuotaRename); + let put_store = Arc::clone(&store); + let put_bucket = bucket.clone(); + let put = tokio::spawn(async move { + let mut reader = PutObjReader::from_vec(vec![0x59; 4096]); + put_store + .put_object(&put_bucket, "object", &mut reader, &ObjectOptions::default()) + .await + }); + barrier.wait_until_paused().await; + fail_next_quota_ledger_save_for_test(); + barrier.release(); + put.await + .expect("PUT task should not panic") + .expect("a post-commit ledger failure must not change the successful write result"); + let stored = store + .get_object_info(&bucket, "object", &ObjectOptions::default()) + .await + .expect("the committed object must remain visible"); + assert_eq!(stored.size, 4096); + } + + #[tokio::test] + #[serial_test::serial] + async fn suspended_null_version_overwrite_uses_exact_quota_delta() { + let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("suspended-version-quota", 6200).await; + let mut versioned_reader = PutObjReader::from_vec(vec![0x61; 4096]); + store + .put_object( + &bucket, + "object", + &mut versioned_reader, + &ObjectOptions { + versioned: true, + ..Default::default() + }, + ) + .await + .expect("write UUID version"); + + for (size, byte) in [(1024, 0x62), (2048, 0x63)] { + let mut reader = PutObjReader::from_vec(vec![byte; size]); + store + .put_object( + &bucket, + "object", + &mut reader, + &ObjectOptions { + version_suspended: true, + ..Default::default() + }, + ) + .await + .expect("suspended write should replace only the exact null version"); + } + + let mut excess = PutObjReader::from_vec(vec![0x64; 57]); + let err = store + .put_object(&bucket, "excess", &mut excess, &ObjectOptions::default()) + .await + .expect_err("UUID plus replacement null version must consume 6144 bytes"); + assert!(matches!( + err, + StorageError::QuotaExceeded { + current: 6144, + limit: 6200 + } + )); + } + + #[tokio::test] + #[serial_test::serial] + async fn durable_quota_reservation_observes_lowered_config_revision() { + let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("lowered-quota-revision", 8192).await; + let mut initial = PutObjReader::from_vec(vec![0x71; 4096]); + store + .put_object(&bucket, "initial", &mut initial, &ObjectOptions::default()) + .await + .expect("write under original quota"); + + let metadata_sys = DefaultObjectUsecase::from_global() + .bucket_metadata_sys() + .expect("test app context should expose bucket metadata"); + QuotaChecker::new(metadata_sys) + .set_quota_config(&bucket, BucketQuota::new(Some(4096))) + .await + .expect("lower bucket quota"); + let mut excess = PutObjReader::from_vec(vec![0x72]); + let err = store + .put_object(&bucket, "excess", &mut excess, &ObjectOptions::default()) + .await + .expect_err("reservation must not use the stale larger quota revision"); + assert!(matches!( + err, + StorageError::QuotaExceeded { + current: 4096, + limit: 4096 + } + )); + } + + #[tokio::test] + #[serial_test::serial] + async fn quota_enable_waits_for_unlimited_commit() { + use crate::app::storage_api::test::metadata_sys::ConfigWriteLockProbe; + use crate::app::storage_api::test::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause}; + + let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("quota-config-fence", 8192).await; + let metadata_sys = DefaultObjectUsecase::from_global() + .bucket_metadata_sys() + .expect("test app context should expose bucket metadata"); + QuotaChecker::new(Arc::clone(&metadata_sys)) + .set_quota_config(&bucket, BucketQuota::new(None)) + .await + .expect("clear quota before the fenced write"); + let barrier = PutObjectCommitBarrier::install(&bucket, "object", PutObjectCommitPause::AfterQuotaReservation); + let put_store = Arc::clone(&store); + let put_bucket = bucket.clone(); + let put = tokio::spawn(async move { + let mut reader = PutObjReader::from_vec(vec![0x73; 4096]); + put_store + .put_object(&put_bucket, "object", &mut reader, &ObjectOptions::default()) + .await + }); + barrier.wait_until_paused().await; + + let update_probe = ConfigWriteLockProbe::install(&bucket); + let update_bucket = bucket.clone(); + let update = tokio::spawn(async move { + QuotaChecker::new(metadata_sys) + .set_quota_config(&update_bucket, BucketQuota::new(Some(0))) + .await + }); + update_probe.wait_until_attempted().await; + assert!( + !update.is_finished(), + "quota mutation must wait for the reservation's metadata transaction guard" + ); + + barrier.release(); + put.await + .expect("PUT task should not panic") + .expect("the write linearized before the quota update must commit"); + update + .await + .expect("quota update task should not panic") + .expect("quota update should proceed after commit"); + + let mut excess = PutObjReader::from_vec(vec![0x74]); + let err = store + .put_object(&bucket, "excess", &mut excess, &ObjectOptions::default()) + .await + .expect_err("writes after the zero-byte quota update must be denied"); + assert!(matches!(err, StorageError::QuotaExceeded { current: 4096, limit: 0 })); + } +} diff --git a/rustfs/src/app/object/restore.rs b/rustfs/src/app/object/restore.rs new file mode 100644 index 000000000..8246cb90a --- /dev/null +++ b/rustfs/src/app/object/restore.rs @@ -0,0 +1,355 @@ +// 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. + +//! RestoreObject path. + +use super::*; + +impl DefaultObjectUsecase { + #[instrument(level = "debug", skip(self, req))] + pub async fn execute_restore_object(&self, req: S3Request) -> S3Result> { + if let Some(context) = &self.context { + let _ = context.object_store(); + } + + let mut helper = OperationHelper::new(&req, EventName::ObjectRestorePost, S3Operation::RestoreObject); + let RestoreObjectInput { + bucket, + key: object, + restore_request: rreq, + version_id, + .. + } = req.input.clone(); + + validate_table_catalog_object_mutation(&bucket, &object).await?; + + let rreq = rreq.ok_or_else(|| { + S3Error::with_message(S3ErrorCode::Custom("ErrValidRestoreObject".into()), "restore request is required") + })?; + + let Some(store) = self.object_store() else { + return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); + }; + + let version_id_str = version_id.clone().unwrap_or_default(); + let mut opts = post_restore_opts(&version_id_str, &bucket, &object) + .await + .map_err(|_| S3Error::with_message(S3ErrorCode::Custom("ErrPostRestoreOpts".into()), "restore object failed."))?; + apply_bucket_generation_guard(&req, &bucket, &mut opts)?; + // `apply_bucket_generation_guard` deliberately tolerates a missing guard + // (only the S3 access layer installs one), so this must not hard-require + // it. Resolve the current generation instead, exactly as the copy path + // does. The fence is unaffected: the value is re-read from disk and + // compared below, before the restore is admitted. + let restore_bucket_incarnation_id = match opts.expected_bucket_incarnation_id { + Some(incarnation_id) => incarnation_id, + None => { + let incarnation_id = store.bucket_incarnation_id_from_disk(&bucket).await.map_err(ApiError::from)?; + opts.expected_bucket_incarnation_id = Some(incarnation_id); + incarnation_id + } + }; + + // SELECT-type restores skip both the ongoing check and the metadata + // write below, so the accept guard would protect nothing for them — + // they keep the plain (read-locked) accept path. + let is_select = rreq.type_.as_ref().is_some_and(|t| t.as_str() == "SELECT"); + + // Hold the restore-accept guard across the restore-status read, the + // ongoing/already-restored decision, and the metadata write below, so + // two concurrent (non-SELECT) POST ?restore cannot both observe + // ongoing=false and both start a copy-back (backlog#1304). Reads and + // writes inside this scope run with no_lock; the guard is dropped + // before the copy-back is spawned so it never blocks readers. + // Contention on the accept guard (e.g. a concurrent accept or an + // in-flight commit on the same object) is transient — answer 503 + // SlowDown so SDK clients back off and retry instead of treating it + // as a hard failure. + let restore_bucket_lifecycle_guard = Some(acquire_copy_bucket_lifecycle_lock(store.as_ref(), &bucket).await?); + if store.bucket_incarnation_id_from_disk(&bucket).await.map_err(ApiError::from)? != restore_bucket_incarnation_id { + return Err(ApiError::from(StorageError::BucketNotFound(bucket.clone())).into()); + } + let accept_guard = if is_select { + None + } else { + let guard = store + .acquire_restore_accept_guard(&bucket, &object) + .await + .map_err(|_| S3Error::with_message(S3ErrorCode::SlowDown, "restore object failed."))?; + opts.no_lock = true; + Some(guard) + }; + + let mut obj_info = store + .get_object_info(&bucket, &object, &opts) + .await + .map_err(|_| S3Error::with_message(S3ErrorCode::Custom("ErrInvalidObjectState".into()), "restore object failed."))?; + + // Check if object is in a transitioned state + if obj_info.transitioned_object.status != lifecycle::TRANSITION_COMPLETE { + return Err(S3Error::with_message( + S3ErrorCode::Custom("ErrInvalidTransitionedState".into()), + "restore object failed.", + )); + } + + // Validate restore request + if let Err(e) = validate_restore_request(&rreq, store.clone()) { + return Err(S3Error::with_message( + S3ErrorCode::Custom("ErrValidRestoreObject".into()), + format!("Restore object validation failed: {}", e), + )); + } + + // Check if restore is already in progress. AWS answers this with + // 409 RestoreAlreadyInProgress; a Custom code would serialize as a + // retryable 500 and make SDK clients retry the conflict (backlog#1304). + if obj_info.restore_ongoing && !is_select { + return Err(S3Error::with_message( + S3ErrorCode::RestoreAlreadyInProgress, + "Object restore is already in progress.", + )); + } + + let mut already_restored = false; + if let Some(restore_expires) = obj_info.restore_expires + && !obj_info.restore_ongoing + && restore_expires.unix_timestamp() != 0 + { + already_restored = true; + } + + let restore_expiry = lifecycle::expected_expiry_time(OffsetDateTime::now_utc(), *rreq.days.as_ref().unwrap_or(&1)); + let mut metadata = (*obj_info.user_defined).clone(); + let restore_operation_id = (!is_select && !already_restored).then(Uuid::new_v4); + + let mut header = HeaderMap::new(); + + let event_object_info = obj_info.clone(); + let obj_info_ = obj_info.clone(); + if !is_select { + obj_info.metadata_only = true; + metadata.insert(AMZ_RESTORE_EXPIRY_DAYS.to_string(), rreq.days.unwrap_or(1).to_string()); + let request_date = OffsetDateTime::now_utc().format(&Rfc3339).map_err(|e| { + S3Error::with_message(S3ErrorCode::InternalError, format!("format restore request date failed: {}", e)) + })?; + metadata.insert(AMZ_RESTORE_REQUEST_DATE.to_string(), request_date); + if already_restored { + metadata.insert( + X_AMZ_RESTORE.as_str().to_string(), + RestoreStatus { + is_restore_in_progress: Some(false), + restore_expiry_date: Some(Timestamp::from(restore_expiry)), + } + .to_string(), + ); + } else { + metadata.insert( + X_AMZ_RESTORE.as_str().to_string(), + RestoreStatus { + is_restore_in_progress: Some(true), + restore_expiry_date: Some(Timestamp::from(OffsetDateTime::now_utc())), + } + .to_string(), + ); + if let Some(id) = restore_operation_id { + insert_str(&mut metadata, SUFFIX_RESTORE_OPERATION_ID, id.to_string()); + } + } + obj_info.user_defined = Arc::new(metadata); + + // Fence the compare-and-set write: if the accept guard was lost + // (lock-service degradation), another node may have concurrently + // accepted this restore — back off instead of committing a second + // ongoing flag and double-starting the copy-back. + if accept_guard.as_ref().is_some_and(|g| g.is_lock_lost()) { + return Err(S3Error::with_message(S3ErrorCode::SlowDown, "restore object failed.")); + } + + let mut restore_dst_opts = ObjectOptions { + version_id: obj_info_.version_id.map(|v| v.to_string()), + mod_time: obj_info_.mod_time, + no_lock: true, + expected_bucket_incarnation_id: Some(restore_bucket_incarnation_id), + ..Default::default() + }; + if let Some(guard) = restore_bucket_lifecycle_guard.as_ref() { + restore_dst_opts.add_bucket_lifecycle_lock_guard(guard); + } + if let Some(guard) = accept_guard.as_ref() { + guard.add_namespace_lock_fence(&mut restore_dst_opts); + } + store + .clone() + .copy_object( + &bucket, + &object, + &bucket, + &object, + &mut obj_info, + &ObjectOptions { + version_id: obj_info_.version_id.map(|v| v.to_string()), + // Inside the accept-guard critical section (see above). + no_lock: true, + ..Default::default() + }, + &restore_dst_opts, + ) + .await + .map_err(|_| S3Error::with_message(S3ErrorCode::Custom("ErrCopyObject".into()), "restore object failed."))?; + rustfs_scanner::record_dirty_usage_bucket(&bucket); + + if already_restored { + let output = RestoreObjectOutput { + request_charged: Some(RequestCharged::from_static(RequestCharged::REQUESTER)), + restore_output_path: None, + }; + helper = helper + .object(event_object_info.clone()) + .version_id(version_id_str.clone()) + .suppress_event(); + let result = Ok(S3Response::new(output)); + let _ = helper.complete(&result); + return result; + } + } + + // The accept decision is committed; release the object write lock so + // the background copy-back and concurrent reads are never blocked on it. + drop(accept_guard); + drop(restore_bucket_lifecycle_guard); + + // Handle output location for SELECT requests + if let Some(output_location) = &rreq.output_location + && let Some(s3) = &output_location.s3 + && !s3.bucket_name.is_empty() + { + let restore_object = Uuid::new_v4().to_string(); + if let Ok(header_value) = format!("{}{}{}", s3.bucket_name, s3.prefix, restore_object).parse() { + header.insert(X_AMZ_RESTORE_OUTPUT_PATH, header_value); + } + } + + // Spawn restoration task in the background. Pin the copy-back to the + // version the accept resolved and flagged: with a versionless request + // on a versioned bucket, a PUT landing between the accept and the + // copy-back would otherwise re-resolve "latest" to the new version, + // fail (not transitioned), and strand the flagged version at + // ongoing=true forever (backlog#1304). + let store_clone = store.clone(); + let bucket_clone = bucket.clone(); + let object_clone = object.clone(); + let rreq_clone = rreq.clone(); + let version_id_clone = obj_info_ + .version_id + .map(|v| v.to_string()) + .or_else(|| (opts.versioned || opts.version_suspended).then(|| Uuid::nil().to_string())); + let versioned = opts.versioned; + let version_suspended = opts.version_suspended; + let mut restore_operation_metadata = HashMap::new(); + if let Some(id) = restore_operation_id { + insert_str(&mut restore_operation_metadata, SUFFIX_RESTORE_OPERATION_ID, id.to_string()); + } + + spawn_traced(async move { + let opts = ObjectOptions { + transition: TransitionOptions { + restore_request: rreq_clone, + restore_expiry, + ..Default::default() + }, + version_id: version_id_clone, + versioned, + version_suspended, + expected_bucket_incarnation_id: Some(restore_bucket_incarnation_id), + user_defined: restore_operation_metadata, + ..Default::default() + }; + + if let Err(err) = store_clone + .restore_transitioned_object(&bucket_clone, &object_clone, &opts) + .await + { + warn!( + "unable to restore transitioned bucket/object {}/{}: {}", + bucket_clone, + object_clone, + err.to_string() + ); + } else { + rustfs_scanner::record_dirty_usage_bucket(&bucket_clone); + debug!(bucket = %bucket_clone, object = %object_clone, "Transitioned object restored"); + } + }); + + let output = RestoreObjectOutput { + request_charged: Some(RequestCharged::from_static(RequestCharged::REQUESTER)), + restore_output_path: None, + }; + helper = helper.object(event_object_info).version_id(version_id_str); + let result = Ok(S3Response::with_headers(output, header)); + let _ = helper.complete(&result); + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http::Method; + use s3s::dto::RestoreRequest; + + #[tokio::test] + async fn execute_restore_object_rejects_missing_restore_request() { + let input = RestoreObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .build() + .unwrap(); + + let req = build_request(input, Method::POST); + let usecase = DefaultObjectUsecase::without_context(); + + let err = usecase.execute_restore_object(req).await.unwrap_err(); + match err.code() { + S3ErrorCode::Custom(code) => assert_eq!(code, "ErrValidRestoreObject"), + code => panic!("unexpected error code: {:?}", code), + } + } + + #[tokio::test] + async fn execute_restore_object_returns_internal_error_when_store_uninitialized() { + let restore_request = RestoreRequest { + days: Some(1), + description: None, + glacier_job_parameters: None, + output_location: None, + select_parameters: None, + tier: None, + type_: None, + }; + let input = RestoreObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("test-key".to_string()) + .restore_request(Some(restore_request)) + .build() + .unwrap(); + + let req = build_request(input, Method::POST); + let usecase = DefaultObjectUsecase::without_context(); + + let err = usecase.execute_restore_object(req).await.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InternalError); + } +} diff --git a/rustfs/src/app/object/shared.rs b/rustfs/src/app/object/shared.rs new file mode 100644 index 000000000..17f7b89a2 --- /dev/null +++ b/rustfs/src/app/object/shared.rs @@ -0,0 +1,1653 @@ +// 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. + +//! Cross-cutting helpers shared by the object use-case modules. + +use super::*; + +pub(super) const RUSTFS_EXPECTED_CURRENT_VERSION_ID: &str = "x-rustfs-expected-current-version-id"; + +pub(super) type S3StdError = Box; + +pub(crate) fn s3s_body_error_to_io(err: StdError) -> io::Error { + io::Error::other(err) +} + +pub(super) const ACCEPT_RANGES_BYTES: &str = "bytes"; + +pub(super) const LOG_COMPONENT_APP: &str = "app"; + +pub(super) const LOG_SUBSYSTEM_OBJECT: &str = "object"; + +pub(super) fn decoded_content_length_from_headers(headers: &HeaderMap) -> S3Result> { + let Some(val) = headers.get(AMZ_DECODED_CONTENT_LENGTH) else { + return Ok(None); + }; + + match atoi::atoi::(val.as_bytes()) { + Some(x) => Ok(Some(x)), + None => Err(s3_error!(UnexpectedContent)), + } +} + +/// Losslessly convert an s3s [`Range`] into the internal [`HTTPRangeSpec`]. +/// +/// Shared by GET and HEAD so both apply identical range semantics. s3s parses +/// `first`/`last` as `u64`, but its own parser already rejects any value greater +/// than `i64::MAX`, so the int branch is a checked cast that never truncates. +/// +/// The suffix length, however, is an unchecked `u64`. A naive `length as i64` +/// truncates deterministically: `bytes=-18446744073709551615` wraps to `-1` and +/// is then read as "last 1 byte", and `bytes=-0` yields a 0-length 206 instead +/// of a 416. This function instead mirrors s3s [`Range::check`] semantics: +/// * a zero-length suffix is rejected with `InvalidRange` (416), matching AWS +/// S3 and MinIO; +/// * a suffix larger than `i64::MAX` is clamped to `i64::MAX`. Object sizes in +/// this system are bounded by `i64::MAX`, so such a suffix always covers the +/// whole object, and [`HTTPRangeSpec::get_length`] clamps it to the real +/// size once the object is known. +pub(super) fn range_to_http_range_spec(range: Range) -> S3Result { + match range { + Range::Int { first, last } => { + let start = i64::try_from(first).map_err(|_| s3_error!(InvalidRange, "The requested range is not satisfiable"))?; + let end = match last { + Some(last) => { + i64::try_from(last).map_err(|_| s3_error!(InvalidRange, "The requested range is not satisfiable"))? + } + None => -1, + }; + Ok(HTTPRangeSpec { + is_suffix_length: false, + start, + end, + }) + } + Range::Suffix { length } => { + if length == 0 { + return Err(s3_error!(InvalidRange, "The requested range is not satisfiable")); + } + // Clamp to i64::MAX: any suffix >= object size returns the whole + // object, and object sizes never exceed i64::MAX. + let start = i64::try_from(length).unwrap_or(i64::MAX); + Ok(HTTPRangeSpec { + is_suffix_length: true, + start, + end: -1, + }) + } + } +} + +/// True when the request body actually arrived chunk-framed on the wire, i.e. the payload was +/// signed as a SigV4 streaming upload (`x-amz-content-sha256: STREAMING-*`). This is the only +/// case in which the auth layer de-frames the body; `Content-Encoding: aws-chunked` without a +/// streaming payload is just a declared encoding over an unframed body. +pub(super) fn request_body_is_aws_chunked_framed(headers: &HeaderMap) -> bool { + headers + .get(AMZ_CONTENT_SHA256) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.len() >= 10 && value[..10].eq_ignore_ascii_case("STREAMING-")) +} + +/// Map a bucket-quota checker outcome onto the S3 admission result. +/// +/// Hard is the only supported quota type, so a checker fault (bucket-config read, config parse, or usage lookup) must fail closed rather than admit the write: allowing it would silently bypass a configured hard quota. The no-quota happy path never reaches the error arm — `QuotaChecker::check_quota` returns `Ok(allowed)` via the zero-extra-I/O fast path when no quota is configured, so failing closed here cannot penalise buckets without a quota. A fault surfaces as a retryable `ServiceUnavailable` and is counted; the client-facing message stays generic so internal config/usage details are not leaked. +pub(crate) fn map_quota_check_outcome(bucket: &str, outcome: Result) -> S3Result { + match outcome { + Ok(result) if !result.allowed => Err(S3Error::with_message( + S3ErrorCode::InvalidRequest, + format!( + "Bucket quota exceeded. Current usage: {} bytes, limit: {} bytes", + result.current_usage.unwrap_or(0), + result.quota_limit.unwrap_or(0) + ), + )), + Err(e) => { + counter!("rustfs_bucket_quota_check_failed_total").increment(1); + if matches!(&e, QuotaError::UsageUnavailable { .. }) { + debug!(bucket, error = %e, state = "usage_pending", "Bucket quota check waiting for authoritative usage"); + } else { + warn!(bucket, error = %e, state = "checker_failed", "Bucket quota check failed closed"); + } + Err(S3Error::with_message( + S3ErrorCode::ServiceUnavailable, + "Bucket quota check temporarily unavailable, please retry".to_string(), + )) + } + Ok(result) => Ok(result), + } +} + +pub(crate) fn apply_quota_admission(opts: &mut ObjectOptions, result: &QuotaCheckResult) -> S3Result<()> { + if result.uses_durable_reservations { + return Ok(()); + } + let Some(quota_limit) = result.quota_limit else { + return Ok(()); + }; + let Some(current_usage) = result.current_usage else { + return Err(S3Error::with_message( + S3ErrorCode::ServiceUnavailable, + "Bucket quota check temporarily unavailable, please retry".to_string(), + )); + }; + if current_usage > quota_limit { + return Err(S3Error::with_message( + S3ErrorCode::InvalidRequest, + format!("Bucket quota exceeded. Current usage: {current_usage} bytes, limit: {quota_limit} bytes"), + )); + } + let _ = opts.set_quota_admission(current_usage, quota_limit); + Ok(()) +} + +pub(super) fn ensure_object_size_within_quota(result: &QuotaCheckResult, new_size: u64) -> S3Result<()> { + let (Some(current_usage), Some(quota_limit)) = (result.current_usage, result.quota_limit) else { + return Ok(()); + }; + if new_size > quota_limit { + return Err(S3Error::with_message( + S3ErrorCode::InvalidRequest, + format!("Bucket quota exceeded. Current usage: {current_usage} bytes, limit: {quota_limit} bytes"), + )); + } + Ok(()) +} + +pub(super) fn quota_accounting_object_size(info: &ObjectInfo, fail_closed: bool) -> S3Result { + match quota_object_size(info) { + Ok(size) => Ok(size), + Err(err) if fail_closed => Err(ApiError::from(err).into()), + Err(_) => Ok(info.size.max(0) as u64), + } +} + +pub(super) fn request_uses_aws_chunked(headers: &HeaderMap) -> bool { + let has_aws_chunked = |header_name: &str| { + headers + .get(header_name) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.split(',').any(|part| part.trim().eq_ignore_ascii_case("aws-chunked"))) + }; + + has_aws_chunked("content-encoding") || has_aws_chunked("transfer-encoding") +} + +pub(super) async fn validate_table_catalog_object_mutation(bucket: &str, key: &str) -> S3Result<()> { + table_catalog::validate_bucket_object_mutation(bucket, key) + .await + .map_err(|_| s3_error!(InvalidRequest, "{}", table_catalog::RESERVED_CATALOG_OBJECT_MESSAGE)) +} + +pub(super) struct DeadlockRequestGuard { + deadlock_detector: Arc, + request_id: String, +} + +impl DeadlockRequestGuard { + fn new(deadlock_detector: Arc, request_id: String) -> Self { + Self { + deadlock_detector, + request_id, + } + } + + pub(super) fn register_if_enabled( + deadlock_detector: Arc, + request_id: &str, + description: F, + ) -> Option + where + F: FnOnce() -> String, + { + if !deadlock_detector.is_enabled() { + return None; + } + + let request_id = request_id.to_string(); + deadlock_detector.register_request(&request_id, description()); + Some(Self::new(deadlock_detector, request_id)) + } +} + +impl Drop for DeadlockRequestGuard { + fn drop(&mut self) { + self.deadlock_detector.unregister_request(&self.request_id); + } +} + +pub(super) fn has_put_sse_request_headers(headers: &HeaderMap) -> bool { + headers.get(AMZ_SERVER_SIDE_ENCRYPTION).is_some() + || headers.get(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM).is_some() + || headers.get(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID).is_some() +} + +/// Resolve the effective server-side encryption for a write against the bucket's +/// default encryption configuration. +/// +/// A request-level value always wins; the bucket default only fills a gap, and +/// the unknown-algorithm fallback lives once in [`bucket_default_write_sse`]. +/// +/// `has_explicit_ssec` suppresses the default entirely. Only COPY passes `true` +/// today: its destination may carry SSE-C, which must not also be given managed +/// encryption. PUT and extract pass `false`, matching their current behaviour — +/// see backlog#1826 for the divergence that leaves. +/// +/// Callers layering further overrides (PUT's `ciphertext_passthrough`) apply +/// them to the returned pair. +pub(super) fn resolve_bucket_default_sse( + bucket_sse_config: Option<&ServerSideEncryptionConfiguration>, + requested_sse: Option, + requested_kms_key_id: Option, + has_explicit_ssec: bool, +) -> (Option, Option) { + let bucket_default = || { + if has_explicit_ssec { + return None; + } + bucket_sse_config + .and_then(|config| config.rules.first()) + .and_then(|rule| rule.apply_server_side_encryption_by_default.as_ref()) + }; + + let effective_sse = requested_sse.or_else(|| bucket_default().map(bucket_default_write_sse)); + let effective_kms_key_id = requested_kms_key_id.or_else(|| bucket_default().and_then(|sse| sse.kms_master_key_id.clone())); + (effective_sse, effective_kms_key_id) +} + +#[cfg(test)] +mod deadlock_request_guard_tests { + use super::DeadlockRequestGuard; + use crate::app::storage_api::object_usecase::deadlock_detector::{DeadlockDetector, RequestHangDetectionPolicy}; + use std::cell::Cell; + use std::rc::Rc; + use std::sync::Arc; + + #[test] + fn deadlock_request_guard_unregisters_on_drop() { + let detector = Arc::new(DeadlockDetector::new(RequestHangDetectionPolicy { + enabled: true, + ..RequestHangDetectionPolicy::default() + })); + let request_id = "test-request-id".to_string(); + + detector.register_request(&request_id, "test request"); + assert_eq!(detector.tracked_count(), 1); + + { + let _guard = DeadlockRequestGuard::new(Arc::clone(&detector), request_id); + // `_guard` is dropped at the end of this scope, which should unregister the request. + } + + assert_eq!(detector.tracked_count(), 0); + } + + #[test] + fn deadlock_request_guard_skips_disabled_detector() { + let detector = Arc::new(DeadlockDetector::new(RequestHangDetectionPolicy { + enabled: false, + ..RequestHangDetectionPolicy::default() + })); + let description_built = Rc::new(Cell::new(false)); + let description_built_for_closure = Rc::clone(&description_built); + + let guard = DeadlockRequestGuard::register_if_enabled(detector, "test-request-id", || { + description_built_for_closure.set(true); + "test request".to_string() + }); + + assert!(guard.is_none()); + assert!(!description_built.get()); + } +} + +pub(super) async fn maybe_enqueue_transition_immediate(obj_info: &ObjectInfo, src: LcEventSrc) { + enqueue_transition_immediate(obj_info, src).await; +} + +/// Inject additional-checksum response headers (XXHash3/64/128, SHA-512) that s3s +/// cannot carry on its typed `*Output` structs. Centralized so that when s3s gains +/// typed fields for these algorithms, only this one function changes (fill the typed +/// field, drop the header insert) — and there is exactly one place that could ever +/// emit a duplicate header. Header names come from `ChecksumType::key()`, so they are +/// known-valid static strings. +pub(crate) fn inject_additional_checksum_headers(headers: &mut HeaderMap, pairs: &[(&'static str, String)]) { + for (name, value) in pairs { + match HeaderValue::from_str(value) { + Ok(header_value) => { + headers.insert(http::HeaderName::from_static(name), header_value); + } + Err(_) => warn!("Failed to parse {name} checksum header value; skipping"), + } + } +} + +pub(super) fn inject_accept_ranges_header(headers: &mut HeaderMap) { + headers.insert(http::header::ACCEPT_RANGES, HeaderValue::from_static(ACCEPT_RANGES_BYTES)); +} + +/// Derive the response-header echo pairs for an additional-checksum algorithm +/// (XXHash3/64/128, SHA-512) from the server-computed content checksum, for +/// PutObject to echo back (#1256). Returns empty for the five s3s-typed algorithms +/// (they are echoed via typed fields) and when the value is not yet materialized +/// (e.g. a trailing checksum, whose value lands after the body — covered by e2e). +pub(crate) fn additional_checksum_echo_pairs(want: &Option) -> Vec<(&'static str, String)> { + let mut out = Vec::new(); + if let Some(cs) = want + && !cs.checksum_type.is_s3s_typed() + && !cs.encoded.is_empty() + && let Some(name) = cs.checksum_type.key() + { + out.push((name, cs.encoded.clone())); + } + out +} + +/// Extract trailing-header checksum values, overriding the corresponding input fields. +pub(super) fn apply_trailing_checksums( + algorithm: Option<&str>, + trailing_headers: &Option, + checksums: &mut PutObjectChecksums, +) { + let Some(alg) = algorithm else { return }; + let Some(checksum_str) = trailing_headers.as_ref().and_then(|trailer| { + let key = match alg { + ChecksumAlgorithm::CRC32 => rustfs_rio::ChecksumType::CRC32.key(), + ChecksumAlgorithm::CRC32C => rustfs_rio::ChecksumType::CRC32C.key(), + ChecksumAlgorithm::SHA1 => rustfs_rio::ChecksumType::SHA1.key(), + ChecksumAlgorithm::SHA256 => rustfs_rio::ChecksumType::SHA256.key(), + ChecksumAlgorithm::CRC64NVME => rustfs_rio::ChecksumType::CRC64_NVME.key(), + _ => return None, + }; + trailer.read(|headers| { + headers + .get(key.unwrap_or_default()) + .and_then(|value| value.to_str().ok().map(|s| s.to_string())) + }) + }) else { + return; + }; + + match alg { + ChecksumAlgorithm::CRC32 => checksums.crc32 = checksum_str, + ChecksumAlgorithm::CRC32C => checksums.crc32c = checksum_str, + ChecksumAlgorithm::SHA1 => checksums.sha1 = checksum_str, + ChecksumAlgorithm::SHA256 => checksums.sha256 = checksum_str, + ChecksumAlgorithm::CRC64NVME => checksums.crc64nvme = checksum_str, + _ => (), + } +} + +/// Checksums resolved from stored (decrypted) metadata for a response. The five +/// legacy algorithms fill named fields; the additional algorithms land in `extra` +/// for raw-header response paths and DTOs that expose their newer typed fields. +#[derive(Default)] +pub(crate) struct ResponseChecksums { + pub(crate) crc32: Option, + pub(crate) crc32c: Option, + pub(crate) sha1: Option, + pub(crate) sha256: Option, + pub(crate) crc64nvme: Option, + pub(crate) checksum_type: Option, + pub(crate) extra: Vec<(&'static str, String)>, +} + +/// Split decrypted checksum pairs into the five legacy fields and the additional +/// algorithm values. Single source of truth for every response +/// path (GetObject / HeadObject / GetObjectAttributes / CompleteMultipartUpload), +/// replacing what used to be five copies of this match loop. +pub(crate) fn classify_response_checksums(pairs: I, is_multipart: bool) -> ResponseChecksums +where + I: IntoIterator, +{ + let mut c = ResponseChecksums::default(); + for (key, checksum) in pairs { + if key == AMZ_CHECKSUM_TYPE { + c.checksum_type = Some(ChecksumType::from(checksum)); + continue; + } + let ct = rustfs_rio::ChecksumType::from_string(key.as_str()); + match ct.base() { + rustfs_rio::ChecksumType::CRC32 => c.crc32 = Some(checksum), + rustfs_rio::ChecksumType::CRC32C => c.crc32c = Some(checksum), + rustfs_rio::ChecksumType::SHA1 => c.sha1 = Some(checksum), + rustfs_rio::ChecksumType::SHA256 => c.sha256 = Some(checksum), + rustfs_rio::ChecksumType::CRC64_NVME => c.crc64nvme = Some(checksum), + _ => { + if let Some(name) = ct.key() { + c.extra.push((name, checksum)); + } + } + } + } + if is_multipart && c.checksum_type.is_none() { + c.checksum_type = Some(ChecksumType::from("COMPOSITE".to_string())); + } + c +} + +fn build_put_object_expiration_header(event: &lifecycle::Event) -> Option { + if !event.action.delete() { + return None; + } + + let expire_time = event.due?; + + if event.rule_id.is_empty() || expire_time == OffsetDateTime::UNIX_EPOCH { + return None; + } + + let expiry_date = expire_time.format(&Rfc3339).ok()?; + Some(format!("expiry-date=\"{}\", rule-id=\"{}\"", expiry_date, event.rule_id)) +} + +pub(super) fn internal_object_info_lookup_opts(mut opts: ObjectOptions) -> ObjectOptions { + opts.http_preconditions = None; + opts +} + +pub(super) fn expected_current_version_id(headers: &HeaderMap) -> S3Result> { + headers + .get(RUSTFS_EXPECTED_CURRENT_VERSION_ID) + .map(|value| { + let value = value + .to_str() + .map(str::trim) + .map_err(|_| s3_error!(InvalidArgument, "Invalid expected current version ID header"))?; + if value.eq_ignore_ascii_case("null") { + return Ok(Uuid::nil().to_string()); + } + Uuid::parse_str(value) + .map(|version| version.to_string()) + .map_err(|_| s3_error!(InvalidArgument, "Invalid expected current version ID header")) + }) + .transpose() +} + +pub(super) fn insert_expires_metadata(metadata: &mut HashMap, expires: Option<&Timestamp>) -> S3Result<()> { + if let Some(expires) = expires { + let mut formatted = Vec::new(); + expires + .format(TimestampFormat::HttpDate, &mut formatted) + .map_err(|e| ApiError::from(StorageError::other(format!("Invalid expires timestamp: {e}"))))?; + metadata.insert("expires".to_string(), String::from_utf8_lossy(&formatted).into_owned()); + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn apply_standard_object_metadata( + metadata: &mut HashMap, + cache_control: Option<&str>, + content_disposition: Option<&str>, + content_encoding: Option<&str>, + content_language: Option<&str>, + content_type: Option<&str>, + expires: Option<&Timestamp>, + website_redirect_location: Option<&str>, +) -> S3Result<()> { + if let Some(cache_control) = cache_control { + metadata.insert("cache-control".to_string(), cache_control.to_string()); + } + if let Some(content_disposition) = content_disposition { + metadata.insert("content-disposition".to_string(), content_disposition.to_string()); + } + if let Some(content_encoding) = content_encoding + && let Some(normalized_content_encoding) = normalize_content_encoding_for_storage(content_encoding) + { + metadata.insert("content-encoding".to_string(), normalized_content_encoding); + } + if let Some(content_language) = content_language { + metadata.insert("content-language".to_string(), content_language.to_string()); + } + if let Some(content_type) = content_type { + metadata.insert("content-type".to_string(), content_type.to_string()); + } + insert_expires_metadata(metadata, expires)?; + if let Some(website_redirect_location) = website_redirect_location { + metadata.insert(AMZ_WEBSITE_REDIRECT_LOCATION.to_string(), website_redirect_location.to_string()); + } + Ok(()) +} + +pub(super) fn response_storage_class(info: &ObjectInfo, metadata: &HashMap) -> Option { + let stored_class = info + .storage_class + .as_deref() + .or_else(|| metadata.get(AMZ_STORAGE_CLASS).map(String::as_str)); + let transitioned_tier = (info.transitioned_object.status == rustfs_filemeta::TRANSITION_COMPLETE + && !info.transitioned_object.tier.is_empty()) + .then_some(info.transitioned_object.tier.as_str()); + let effective_class = storageclass::effective_class(stored_class, transitioned_tier); + + (effective_class != storageclass::STANDARD).then(|| StorageClass::from(effective_class.to_string())) +} + +pub(super) fn response_storage_class_for_object_attributes( + info: &ObjectInfo, + metadata: &HashMap, + requested: bool, +) -> Option { + if !requested { + return None; + } + + let stored_class = info + .storage_class + .as_deref() + .or_else(|| metadata.get(AMZ_STORAGE_CLASS).map(String::as_str)); + let transitioned_tier = (info.transitioned_object.status == rustfs_filemeta::TRANSITION_COMPLETE + && !info.transitioned_object.tier.is_empty()) + .then_some(info.transitioned_object.tier.as_str()); + + Some(StorageClass::from( + storageclass::effective_class(stored_class, transitioned_tier).to_string(), + )) +} + +// Shared across Object Lock validation paths to keep the client-facing +// InvalidRequest message consistent. +pub(crate) const ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED: &str = + "x-amz-object-lock-retain-until-date and x-amz-object-lock-mode must both be supplied"; + +pub(crate) fn build_put_like_object_lock_metadata( + bucket: &str, + object_lock_config_state: &metadata_sys::ObjectLockConfigState, + object_lock_legal_hold_status: Option, + object_lock_mode: Option, + object_lock_retain_until_date: Option, +) -> S3Result>> { + if object_lock_legal_hold_status.is_none() && object_lock_mode.is_none() && object_lock_retain_until_date.is_none() { + return Ok(None); + } + + let retention = match (object_lock_mode, object_lock_retain_until_date) { + (Some(mode), Some(retain_until_date)) => Some(ObjectLockRetention { + mode: Some(ObjectLockRetentionMode::from(mode.as_str().to_string())), + retain_until_date: Some(retain_until_date), + }), + (Some(_), None) | (None, Some(_)) => { + return Err(S3Error::with_message( + S3ErrorCode::InvalidRequest, + ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED.to_string(), + )); + } + (None, None) => None, + }; + + validate_bucket_object_lock_enabled_state(bucket, object_lock_config_state)?; + + let mut eval_metadata = parse_object_lock_retention(retention)?; + eval_metadata.extend(parse_object_lock_legal_hold( + object_lock_legal_hold_status.map(|status| ObjectLockLegalHold { status: Some(status) }), + )?); + + if eval_metadata.is_empty() { + return Ok(None); + } + + Ok(Some(eval_metadata)) +} + +fn put_like_write_creates_new_version(opts: &ObjectOptions) -> bool { + opts.version_id.is_none() && opts.versioned && !opts.version_suspended +} + +pub(crate) fn validate_existing_object_lock_for_write( + object_lock_config_state: &metadata_sys::ObjectLockConfigState, + existing_obj_info: &ObjectInfo, + opts: &ObjectOptions, +) -> S3Result<()> { + if put_like_write_creates_new_version(opts) { + return Ok(()); + } + // An authorized replication write may replace the locked version only + // when the set layer's commit-lock LWW will judge every locking category, + // judged against the bucket's authoritative lock state (default retention + // included) exactly like the set-layer gate, which re-checks the same + // rule under the lock. A non-authoritative state or malformed lock + // metadata fails closed here. + if opts.replication_request { + let may_pass = replication_write_may_pass_worm_gate(object_lock_config_state, existing_obj_info, opts).map_err(|_| { + S3Error::with_message(S3ErrorCode::AccessDenied, "Object Lock state could not be verified.".to_string()) + })?; + return if may_pass { + Ok(()) + } else { + Err(S3Error::with_message( + S3ErrorCode::AccessDenied, + "Object is locked and the replication write carries no source lock decision for it.".to_string(), + )) + }; + } + + let legal_hold = get_object_legalhold_meta(&existing_obj_info.user_defined); + if legal_hold.is_on() { + return Err(S3Error::with_message( + S3ErrorCode::AccessDenied, + "Object has a legal hold and cannot be overwritten. Remove the legal hold first.".to_string(), + )); + } + + let retention = get_object_retention_meta(&existing_obj_info.user_defined); + if let Some(mode) = retention.mode + && mode == RetentionMode::Compliance + && is_retention_active(mode, retention.retain_until_date) + { + return Err(S3Error::with_message( + S3ErrorCode::AccessDenied, + "Object is under COMPLIANCE retention and cannot be overwritten.".to_string(), + )); + } + + Ok(()) +} + +pub(super) async fn resolve_put_object_expiration(bucket: &str, obj_info: &ObjectInfo) -> Option { + let Ok((lifecycle_config, _)) = metadata_sys::get_lifecycle_config(bucket).await else { + debug!(bucket, state = "config_missing", "PUT object expiration config missing"); + return None; + }; + + let obj_opts = lifecycle::object_opts_from_object_info(obj_info); + let event = predict_lifecycle_expiration(&lifecycle_config, &obj_opts).await; + debug!( + bucket, + action = ?event.action, + rule_id = %event.rule_id, + due = ?event.due, + "PUT object expiration resolved" + ); + build_put_object_expiration_header(&event) +} + +/// Cadence for the "I/O queue congestion detected" WARN. Under sustained +/// overload (client concurrency at or above the disk-read permit pool) every +/// GET observes >=80% utilization, so an unthrottled WARN floods the log +/// from the already saturated hot path; congestion metrics stay per-request. +const IO_QUEUE_CONGESTION_WARN_INTERVAL_MS: u64 = 5_000; + +/// At-most-one-WARN-per-interval limiter for the I/O queue congestion log. +/// Callers supply monotonic milliseconds so tests can drive the clock. +pub(super) struct IoQueueCongestionWarnThrottle { + /// Timestamp of the last emitted WARN; `u64::MAX` until the first one. + last_warn_ms: AtomicU64, + /// Congested requests left unlogged since the last emitted WARN. + suppressed: AtomicU64, +} + +impl IoQueueCongestionWarnThrottle { + const fn new() -> Self { + Self { + last_warn_ms: AtomicU64::new(u64::MAX), + suppressed: AtomicU64::new(0), + } + } + + /// Claim the right to emit one WARN. Returns the number of events + /// suppressed since the previous emission, or `None` while the interval + /// window is still closed (the event is counted, not logged). + pub(super) fn claim(&self, now_ms: u64) -> Option { + let last = self.last_warn_ms.load(Ordering::Relaxed); + let window_open = last == u64::MAX || now_ms.saturating_sub(last) >= IO_QUEUE_CONGESTION_WARN_INTERVAL_MS; + if window_open + && self + .last_warn_ms + .compare_exchange(last, now_ms, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + Some(self.suppressed.swap(0, Ordering::Relaxed)) + } else { + self.suppressed.fetch_add(1, Ordering::Relaxed); + None + } + } + + /// Monotonic milliseconds since the first call, for production callers. + pub(super) fn now_ms() -> u64 { + static ANCHOR: OnceLock = OnceLock::new(); + ANCHOR.get_or_init(std::time::Instant::now).elapsed().as_millis() as u64 + } +} + +pub(super) static IO_QUEUE_CONGESTION_WARN_THROTTLE: IoQueueCongestionWarnThrottle = IoQueueCongestionWarnThrottle::new(); + +pub(super) async fn track_object_read_setup(health: Option<&ObjectTrafficHealth>, future: F) -> F::Output +where + F: std::future::Future, +{ + let _progress = health.and_then(ObjectTrafficHealth::track_read_storage); + future.await +} + +impl DefaultObjectUsecase { + /// Headers a proxied read forwards verbatim to the replication target: + /// only the client's SSE-C key family, so the target performs the real + /// SSE-C decryption (never the replication-check exemption). HTTP + /// conditional headers (If-Match & co.) are deliberately NOT forwarded — + /// MinIO does not forward them either, and a remote 304/412 would leak a + /// conditional evaluation against a replica the local site never saw. + /// Range and part-number travel as typed SDK parameters instead. + pub(super) fn proxy_read_passthrough_headers(headers: &HeaderMap) -> HeaderMap { + const FORWARDED: &[&str] = &[ + "x-amz-server-side-encryption-customer-algorithm", + "x-amz-server-side-encryption-customer-key", + "x-amz-server-side-encryption-customer-key-md5", + ]; + let mut forwarded = HeaderMap::new(); + for name in FORWARDED { + if let Ok(header_name) = http::HeaderName::from_str(name) + && let Some(value) = headers.get(&header_name) + { + forwarded.insert(header_name, value.clone()); + } + } + forwarded + } + + /// True when a proxied SDK call failed because the target does not have + /// the object either (service-level not-found or a raw 404, which also + /// covers NoSuchVersion): the caller tries the next target silently. + pub(super) fn proxy_sdk_error_is_not_found(err: &aws_sdk_s3::error::SdkError) -> bool { + err.raw_response().is_some_and(|resp| resp.status().as_u16() == 404) + } +} + +/// Fail closed when deciding whether an object-lock-sensitive operation may +/// skip its existing-object lookup. +pub(crate) async fn object_lock_checks_required(bucket: &str) -> bool { + get_bucket_metadata(bucket) + .await + .map_or(true, |metadata| metadata.object_locking()) +} + +pub(super) fn object_lock_checks_required_for_state(state: &metadata_sys::ObjectLockConfigState) -> bool { + match state { + metadata_sys::ObjectLockConfigState::Configured { .. } | metadata_sys::ObjectLockConfigState::Fabricated => true, + metadata_sys::ObjectLockConfigState::ConfirmedAbsent => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http::{HeaderMap, HeaderValue}; + use s3s::dto::{ + ObjectLockConfiguration, ObjectLockEnabled, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, + ServerSideEncryptionRule, + }; + use std::sync::Arc; + + #[test] + fn io_queue_congestion_warn_throttle_emits_once_per_interval() { + let throttle = IoQueueCongestionWarnThrottle::new(); + // The first congested request logs immediately. + assert_eq!(throttle.claim(0), Some(0)); + // Requests inside the window are counted, not logged. + assert_eq!(throttle.claim(1), None); + assert_eq!(throttle.claim(IO_QUEUE_CONGESTION_WARN_INTERVAL_MS - 1), None); + // The next emission reports how many stayed silent. + assert_eq!(throttle.claim(IO_QUEUE_CONGESTION_WARN_INTERVAL_MS), Some(2)); + assert_eq!(throttle.claim(IO_QUEUE_CONGESTION_WARN_INTERVAL_MS + 1), None); + } + + // classify_response_checksums is the single point that splits decrypted checksum + // pairs into the five s3s-typed fields and the additional-algorithm `extra` + // headers, replacing five copies of the loop. Lock its behaviour (#1252). + #[test] + fn classify_response_checksums_splits_typed_and_extra() { + // Typed algorithms fill named fields; nothing spills into extra. + let c = classify_response_checksums( + vec![ + ("CRC32".to_string(), "AAAAAA==".to_string()), + ("SHA256".to_string(), "c2hhMjU2".to_string()), + ("CRC64NVME".to_string(), "Zm9vYmFyCg==".to_string()), + ], + false, + ); + assert_eq!(c.crc32.as_deref(), Some("AAAAAA==")); + assert_eq!(c.sha256.as_deref(), Some("c2hhMjU2")); + assert_eq!(c.crc64nvme.as_deref(), Some("Zm9vYmFyCg==")); + assert!(c.extra.is_empty(), "typed algorithms must not land in extra"); + + // Additional algorithms land in extra keyed by their response-header name. + let c = classify_response_checksums( + vec![ + ("XXHASH3".to_string(), "eHhoMw==".to_string()), + ("XXHASH64".to_string(), "eHhoNjQ=".to_string()), + ("XXHASH128".to_string(), "eHhoMTI4".to_string()), + ("SHA512".to_string(), "c2hhNTEy".to_string()), + ("MD5".to_string(), "bWQ1".to_string()), + ], + false, + ); + assert!(c.crc32.is_none() && c.sha256.is_none() && c.crc64nvme.is_none()); + let names: Vec<&str> = c.extra.iter().map(|(n, _)| *n).collect(); + for expected in [ + "x-amz-checksum-xxhash3", + "x-amz-checksum-xxhash64", + "x-amz-checksum-xxhash128", + "x-amz-checksum-sha512", + "x-amz-checksum-md5", + ] { + assert!(names.contains(&expected), "extra missing {expected}: {names:?}"); + } + assert_eq!(c.extra.len(), 5); + + // The checksum-type marker is captured as the type, not mistaken for an algorithm. + let c = classify_response_checksums(vec![(AMZ_CHECKSUM_TYPE.to_string(), "COMPOSITE".to_string())], false); + assert!(c.checksum_type.is_some()); + assert!(c.extra.is_empty() && c.crc32.is_none()); + + let c = classify_response_checksums(vec![("CRC32".to_string(), "AAAAAA==-2".to_string())], true); + assert_eq!(c.checksum_type.as_ref().map(ChecksumType::as_str), Some("COMPOSITE")); + + // Empty input yields an all-default result. + let c = classify_response_checksums(Vec::<(String, String)>::new(), false); + assert!(c.crc32.is_none() && c.extra.is_empty() && c.checksum_type.is_none()); + } + + // additional_checksum_echo_pairs derives the PutObject/UploadPart response echo for + // additional algorithms from the server-computed checksum, and nothing for the + // five typed ones (those go through typed output fields). + #[test] + fn additional_checksum_echo_pairs_only_for_new_algorithms() { + // Typed algorithm → no echo pair. + let sha256 = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::SHA256, b"data"); + assert!(additional_checksum_echo_pairs(&sha256).is_empty()); + + // Additional algorithm → exactly one (header, value) pair matching the digest. + let xxh3 = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::XXHASH3, b"data"); + let pairs = additional_checksum_echo_pairs(&xxh3); + assert_eq!(pairs.len(), 1); + assert_eq!(pairs[0].0, "x-amz-checksum-xxhash3"); + assert_eq!(pairs[0].1, xxh3.as_ref().unwrap().encoded); + + // MD5 additional checksum is echoed too. + let md5 = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::MD5, b"data"); + let pairs = additional_checksum_echo_pairs(&md5); + assert_eq!(pairs.len(), 1); + assert_eq!(pairs[0].0, "x-amz-checksum-md5"); + + // None → empty. + assert!(additional_checksum_echo_pairs(&None).is_empty()); + } + + #[test] + fn inject_additional_checksum_headers_writes_all_pairs() { + let mut headers = HeaderMap::new(); + inject_additional_checksum_headers( + &mut headers, + &[ + ("x-amz-checksum-xxhash3", "eHhoMw==".to_string()), + ("x-amz-checksum-md5", "bWQ1".to_string()), + ], + ); + assert_eq!(headers.get("x-amz-checksum-xxhash3").unwrap(), "eHhoMw=="); + assert_eq!(headers.get("x-amz-checksum-md5").unwrap(), "bWQ1"); + // Empty input is a no-op. + let mut empty = HeaderMap::new(); + inject_additional_checksum_headers(&mut empty, &[]); + assert!(empty.is_empty()); + } + + #[test] + fn inject_accept_ranges_header_writes_static_bytes_value() { + let mut headers = HeaderMap::new(); + inject_accept_ranges_header(&mut headers); + + assert_eq!(headers.get(http::header::ACCEPT_RANGES).unwrap(), ACCEPT_RANGES_BYTES); + } + + #[test] + fn internal_object_info_lookup_opts_drops_http_preconditions() { + let version_id = Uuid::new_v4().to_string(); + let opts = ObjectOptions { + version_id: Some(version_id.clone()), + no_lock: true, + http_preconditions: Some(HTTPPreconditions { + if_none_match: Some("\"etag\"".to_string()), + if_match: Some("\"other\"".to_string()), + ..Default::default() + }), + ..Default::default() + }; + + let lookup_opts = internal_object_info_lookup_opts(opts); + + assert!(lookup_opts.http_preconditions.is_none()); + assert_eq!(lookup_opts.version_id.as_deref(), Some(version_id.as_str())); + assert!(lookup_opts.no_lock); + } + + fn bucket_sse_config_with(algorithm: &str, kms_key_id: Option<&str>) -> ServerSideEncryptionConfiguration { + ServerSideEncryptionConfiguration { + rules: vec![ServerSideEncryptionRule { + apply_server_side_encryption_by_default: Some(ServerSideEncryptionByDefault { + sse_algorithm: ServerSideEncryption::from(String::from(algorithm)), + kms_master_key_id: kms_key_id.map(|id| SSEKMSKeyId::from(id.to_string())), + }), + bucket_key_enabled: None, + }], + } + } + + #[test] + fn resolve_bucket_default_sse_prefers_the_request_over_the_bucket_default() { + let config = bucket_sse_config_with(ServerSideEncryption::AWS_KMS, Some("bucket-key")); + + let (sse, kms_key_id) = resolve_bucket_default_sse( + Some(&config), + Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)), + Some(SSEKMSKeyId::from("request-key".to_string())), + false, + ); + + assert_eq!(sse.as_ref().map(|sse| sse.as_str()), Some(ServerSideEncryption::AES256)); + assert_eq!(kms_key_id.as_deref(), Some("request-key")); + } + + #[test] + fn resolve_bucket_default_sse_fills_gaps_from_the_bucket_default() { + let config = bucket_sse_config_with(ServerSideEncryption::AWS_KMS, Some("bucket-key")); + + let (sse, kms_key_id) = resolve_bucket_default_sse(Some(&config), None, None, false); + + assert_eq!(sse.as_ref().map(|sse| sse.as_str()), Some(ServerSideEncryption::AWS_KMS)); + assert_eq!(kms_key_id.as_deref(), Some("bucket-key")); + } + + #[test] + fn resolve_bucket_default_sse_falls_back_to_aes256_for_an_unknown_algorithm() { + // Reachable only through corrupt or hand-edited bucket metadata; + // PutBucketEncryption rejects unknown algorithms. All three call sites + // now share this single decision (backlog#1826). + let config = bucket_sse_config_with("garbage", None); + + let (sse, kms_key_id) = resolve_bucket_default_sse(Some(&config), None, None, false); + + assert_eq!(sse.as_ref().map(|sse| sse.as_str()), Some(ServerSideEncryption::AES256)); + assert!(kms_key_id.is_none()); + } + + #[test] + fn resolve_bucket_default_sse_suppresses_the_default_for_explicit_ssec() { + let config = bucket_sse_config_with(ServerSideEncryption::AES256, Some("bucket-key")); + + let (sse, kms_key_id) = resolve_bucket_default_sse(Some(&config), None, None, true); + + assert!(sse.is_none(), "an SSE-C destination must not also get managed encryption"); + assert!(kms_key_id.is_none()); + } + + #[test] + fn resolve_bucket_default_sse_returns_nothing_without_a_bucket_default() { + let (sse, kms_key_id) = resolve_bucket_default_sse(None, None, None, false); + + assert!(sse.is_none()); + assert!(kms_key_id.is_none()); + } + + #[test] + fn build_put_like_object_lock_metadata_rejects_mode_without_retain_until_date() { + let err = build_put_like_object_lock_metadata( + "test-bucket", + &metadata_sys::ObjectLockConfigState::ConfirmedAbsent, + None, + Some(ObjectLockMode::from_static(ObjectLockMode::GOVERNANCE)), + None, + ) + .unwrap_err(); + + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some(ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED)); + } + + #[test] + fn object_lock_checks_required_reuses_authoritative_state() { + assert!(!object_lock_checks_required_for_state( + &metadata_sys::ObjectLockConfigState::ConfirmedAbsent + )); + + let configured = metadata_sys::ObjectLockConfigState::Configured { + config: ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), + rule: None, + }, + updated_at: OffsetDateTime::now_utc(), + }; + assert!(object_lock_checks_required_for_state(&configured)); + assert!(object_lock_checks_required_for_state(&metadata_sys::ObjectLockConfigState::Fabricated)); + } + + #[test] + fn build_put_like_object_lock_metadata_rejects_retain_until_date_without_mode() { + let retain_until = Timestamp::from(OffsetDateTime::now_utc().add(time::Duration::days(1))); + let err = build_put_like_object_lock_metadata( + "test-bucket", + &metadata_sys::ObjectLockConfigState::ConfirmedAbsent, + None, + None, + Some(retain_until), + ) + .unwrap_err(); + + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(err.message(), Some(ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED)); + } + + const NO_BUCKET_LOCK: metadata_sys::ObjectLockConfigState = metadata_sys::ObjectLockConfigState::ConfirmedAbsent; + + fn bucket_default_retention_state(mode: &'static str) -> metadata_sys::ObjectLockConfigState { + metadata_sys::ObjectLockConfigState::Configured { + config: s3s::dto::ObjectLockConfiguration { + object_lock_enabled: Some(s3s::dto::ObjectLockEnabled::from_static(s3s::dto::ObjectLockEnabled::ENABLED)), + rule: Some(s3s::dto::ObjectLockRule { + default_retention: Some(s3s::dto::DefaultRetention { + mode: Some(ObjectLockRetentionMode::from_static(mode)), + days: Some(1), + years: None, + }), + }), + }, + updated_at: OffsetDateTime::now_utc(), + } + } + + fn object_info_with_lock_metadata(metadata: HashMap) -> ObjectInfo { + ObjectInfo { + user_defined: Arc::new(metadata), + mod_time: Some(OffsetDateTime::now_utc()), + ..Default::default() + } + } + + fn compliance_retained_object_info() -> ObjectInfo { + let mut metadata = HashMap::new(); + metadata.insert(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), ObjectLockRetentionMode::COMPLIANCE.to_string()); + metadata.insert(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), "2030-01-01T00:00:00Z".to_string()); + object_info_with_lock_metadata(metadata) + } + + fn legal_hold_object_info() -> ObjectInfo { + let mut metadata = HashMap::new(); + metadata.insert(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER.to_string(), ObjectLockLegalHoldStatus::ON.to_string()); + object_info_with_lock_metadata(metadata) + } + + #[test] + fn validate_existing_object_lock_allows_versioned_new_version_with_compliance_retention() { + let opts = ObjectOptions { + versioned: true, + version_id: None, + ..Default::default() + }; + + validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts) + .expect("versioned put should create a new version"); + } + + #[test] + fn validate_existing_object_lock_allows_versioned_new_version_with_legal_hold() { + let opts = ObjectOptions { + versioned: true, + version_id: None, + ..Default::default() + }; + + validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &legal_hold_object_info(), &opts) + .expect("versioned put should create a new version"); + } + + #[test] + fn validate_existing_object_lock_blocks_unversioned_compliance_overwrite() { + let err = validate_existing_object_lock_for_write( + &NO_BUCKET_LOCK, + &compliance_retained_object_info(), + &ObjectOptions::default(), + ) + .expect_err("unversioned overwrite should still be blocked"); + + assert_eq!(err.code(), &S3ErrorCode::AccessDenied); + } + + #[test] + fn validate_existing_object_lock_blocks_suspended_version_compliance_overwrite() { + let opts = ObjectOptions { + versioned: true, + version_suspended: true, + version_id: None, + ..Default::default() + }; + let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts) + .expect_err("suspended versioning overwrite should still be blocked"); + + assert_eq!(err.code(), &S3ErrorCode::AccessDenied); + } + + #[test] + fn validate_existing_object_lock_blocks_explicit_version_compliance_overwrite() { + let opts = ObjectOptions { + versioned: true, + version_id: Some(Uuid::new_v4().to_string()), + ..Default::default() + }; + let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts) + .expect_err("explicit version overwrite should still be blocked"); + + assert_eq!(err.code(), &S3ErrorCode::AccessDenied); + } + + /// The source's lock state governs the replica (rustfs/backlog#1953): + /// an authorized replication write carrying the locking category's source + /// timestamp may overwrite a locked version; the set layer's LWW then + /// decides per category. + #[test] + fn validate_existing_object_lock_allows_authorized_replication_overwrite() { + let opts = ObjectOptions { + versioned: true, + version_id: Some(Uuid::new_v4().to_string()), + replication_request: true, + replication_retention_timestamp: Some(OffsetDateTime::UNIX_EPOCH), + replication_legalhold_timestamp: Some(OffsetDateTime::UNIX_EPOCH), + ..Default::default() + }; + + validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts) + .expect("replication write must bypass the destination COMPLIANCE lock"); + validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &legal_hold_object_info(), &opts) + .expect("replication write must bypass the destination legal hold"); + } + + /// Without the locking category's source timestamp the LWW merge cannot + /// judge it, so the write stays rejected instead of lifting the lock. + #[test] + fn validate_existing_object_lock_rejects_replication_overwrite_without_lock_timestamp() { + let opts = ObjectOptions { + versioned: true, + version_id: Some(Uuid::new_v4().to_string()), + replication_request: true, + replication_tagging_timestamp: Some(OffsetDateTime::UNIX_EPOCH), + ..Default::default() + }; + + let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts) + .expect_err("COMPLIANCE lock must hold without a retention source timestamp"); + assert_eq!(err.code(), &S3ErrorCode::AccessDenied); + let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &legal_hold_object_info(), &opts) + .expect_err("legal hold must hold without a legal-hold source timestamp"); + assert_eq!(err.code(), &S3ErrorCode::AccessDenied); + } + + /// The bucket default retention locks a version without explicit + /// retention keys; the pre-check judges the same authoritative state as + /// the set-layer gate, so a tagging-only replication write is rejected + /// and one carrying the retention source timestamp passes to LWW. + #[test] + fn validate_existing_object_lock_judges_bucket_default_retention_for_replication_overwrite() { + let default_protected = object_info_with_lock_metadata(HashMap::new()); + let tagging_only = ObjectOptions { + versioned: true, + version_id: Some(Uuid::new_v4().to_string()), + replication_request: true, + replication_tagging_timestamp: Some(OffsetDateTime::UNIX_EPOCH), + ..Default::default() + }; + let with_retention_decision = ObjectOptions { + replication_retention_timestamp: Some(OffsetDateTime::UNIX_EPOCH), + ..tagging_only.clone() + }; + + for mode in [ObjectLockRetentionMode::COMPLIANCE, ObjectLockRetentionMode::GOVERNANCE] { + let state = bucket_default_retention_state(mode); + let err = validate_existing_object_lock_for_write(&state, &default_protected, &tagging_only) + .expect_err("bucket default retention must hold without a retention source timestamp"); + assert_eq!(err.code(), &S3ErrorCode::AccessDenied, "{mode}"); + validate_existing_object_lock_for_write(&state, &default_protected, &with_retention_decision) + .expect("the retention source timestamp hands the default retention to LWW"); + } + + // Without a bucket default the same version is simply unlocked. + validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &default_protected, &tagging_only) + .expect("no bucket default, no lock"); + } + + #[test] + fn aws_chunked_put_prefers_decoded_content_length() { + let mut headers = HeaderMap::new(); + headers.insert("content-encoding", HeaderValue::from_static("aws-chunked")); + headers.insert(AMZ_DECODED_CONTENT_LENGTH, HeaderValue::from_static("71680")); + + let decoded = decoded_content_length_from_headers(&headers).expect("decoded content length should parse"); + assert!(request_uses_aws_chunked(&headers)); + assert_eq!(decoded, Some(71680)); + + let resolved = match (request_uses_aws_chunked(&headers), decoded, Some(99999)) { + (true, Some(decoded), _) => decoded, + (_, _, Some(c)) => c, + (_, Some(decoded), None) => decoded, + _ => unreachable!("test provides a valid size source"), + }; + + assert_eq!(resolved, 71680); + } + + #[test] + fn s3s_body_error_to_io_preserves_upload_stream_error_source() { + let error = s3s_body_error_to_io(Box::new(MockUploadStreamSha256Mismatch)); + + assert!(matches!( + error + .get_ref() + .and_then(|source| source.downcast_ref::()), + Some(MockUploadStreamSha256Mismatch) + )); + } + + #[test] + fn response_storage_class_reports_effective_layout_and_preserves_transition_tier() { + let metadata = HashMap::new(); + let standard_info = ObjectInfo { + storage_class: Some(storageclass::STANDARD.to_string()), + user_defined: Arc::new(metadata.clone()), + ..Default::default() + }; + assert!(response_storage_class(&standard_info, &metadata).is_none()); + + let mut metadata = HashMap::new(); + metadata.insert(AMZ_STORAGE_CLASS.to_string(), storageclass::STANDARD_IA.to_string()); + let label_only_info = ObjectInfo { + storage_class: Some(storageclass::STANDARD_IA.to_string()), + user_defined: Arc::new(metadata.clone()), + ..Default::default() + }; + assert!( + response_storage_class(&label_only_info, &metadata).is_none(), + "historical STANDARD_IA labels must report the effective implicit STANDARD layout" + ); + + let rrs_info = ObjectInfo { + storage_class: Some(storageclass::RRS.to_string()), + ..Default::default() + }; + assert_eq!( + response_storage_class(&rrs_info, &HashMap::new()) + .as_ref() + .map(StorageClass::as_str), + Some(storageclass::RRS) + ); + + let mut transitioned_info = label_only_info; + transitioned_info.transitioned_object.tier = "WARM-TIER".to_string(); + assert!( + response_storage_class(&transitioned_info, &metadata).is_none(), + "a tier name without a completed transition must not override the effective local class" + ); + transitioned_info.transitioned_object.status = rustfs_filemeta::TRANSITION_COMPLETE.to_string(); + assert_eq!( + response_storage_class(&transitioned_info, &metadata) + .as_ref() + .map(StorageClass::as_str), + Some("WARM-TIER") + ); + + let mut metadata = HashMap::new(); + metadata.insert(AMZ_STORAGE_CLASS.to_string(), storageclass::STANDARD.to_string()); + let standard_metadata_info = ObjectInfo { + storage_class: None, + user_defined: Arc::new(metadata.clone()), + ..Default::default() + }; + assert!( + response_storage_class(&standard_metadata_info, &metadata).is_none(), + "STANDARD must be omitted even when it only arrives via metadata fallback" + ); + } + + #[test] + fn response_storage_class_for_object_attributes_defaults_to_standard_when_requested() { + let metadata = HashMap::new(); + let info = ObjectInfo { + storage_class: None, + user_defined: Arc::new(metadata.clone()), + ..Default::default() + }; + + assert_eq!( + response_storage_class_for_object_attributes(&info, &metadata, true) + .as_ref() + .map(StorageClass::as_str), + Some(storageclass::STANDARD) + ); + + let legacy_info = ObjectInfo { + storage_class: Some(storageclass::STANDARD_IA.to_string()), + ..Default::default() + }; + assert_eq!( + response_storage_class_for_object_attributes(&legacy_info, &HashMap::new(), true) + .as_ref() + .map(StorageClass::as_str), + Some(storageclass::STANDARD) + ); + } + + #[test] + fn response_storage_class_for_object_attributes_skips_value_when_not_requested() { + let metadata = HashMap::new(); + let info = ObjectInfo { + storage_class: Some(storageclass::STANDARD_IA.to_string()), + user_defined: Arc::new(metadata.clone()), + ..Default::default() + }; + + assert!( + response_storage_class_for_object_attributes(&info, &metadata, false).is_none(), + "StorageClass must only be returned when explicitly requested" + ); + } + + #[test] + fn expected_current_version_header_normalizes_uuid_and_null() { + let version = Uuid::new_v4(); + let mut headers = HeaderMap::new(); + headers.insert( + RUSTFS_EXPECTED_CURRENT_VERSION_ID, + HeaderValue::from_str(&version.to_string().to_uppercase()).unwrap(), + ); + assert_eq!(expected_current_version_id(&headers).unwrap(), Some(version.to_string())); + + headers.insert(RUSTFS_EXPECTED_CURRENT_VERSION_ID, HeaderValue::from_static(" null ")); + assert_eq!(expected_current_version_id(&headers).unwrap(), Some(Uuid::nil().to_string())); + } + + #[test] + fn expected_current_version_header_rejects_empty_and_malformed_values() { + for value in ["", "not-a-version"] { + let mut headers = HeaderMap::new(); + headers.insert(RUSTFS_EXPECTED_CURRENT_VERSION_ID, HeaderValue::from_str(value).unwrap()); + assert_eq!(expected_current_version_id(&headers).unwrap_err().code(), &S3ErrorCode::InvalidArgument); + } + } + + #[test] + fn build_put_object_expiration_header_returns_none_for_non_delete_events() { + let event = lifecycle::Event { + action: lifecycle::IlmAction::TransitionAction, + rule_id: "rule-1".to_string(), + due: Some(OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap()), + noncurrent_days: 0, + newer_noncurrent_versions: 0, + storage_class: String::new(), + }; + + assert!(build_put_object_expiration_header(&event).is_none()); + } + + #[test] + fn build_put_object_expiration_header_formats_expected_value() { + let expire_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(); + let event = lifecycle::Event { + action: lifecycle::IlmAction::DeleteAction, + rule_id: "rule-1".to_string(), + due: Some(expire_time), + noncurrent_days: 0, + newer_noncurrent_versions: 0, + storage_class: String::new(), + }; + + let expiry_date = expire_time.format(&Rfc3339).unwrap(); + let expected = format!("expiry-date=\"{}\", rule-id=\"rule-1\"", expiry_date); + assert_eq!(build_put_object_expiration_header(&event), Some(expected)); + } + + #[test] + fn build_put_object_expiration_header_requires_rule_id_and_due_time() { + let event = lifecycle::Event { + action: lifecycle::IlmAction::DeleteAction, + rule_id: String::new(), + due: Some(OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap()), + noncurrent_days: 0, + newer_noncurrent_versions: 0, + storage_class: String::new(), + }; + + assert!(build_put_object_expiration_header(&event).is_none()); + + let event = lifecycle::Event { + action: lifecycle::IlmAction::DeleteAction, + rule_id: "rule-1".to_string(), + due: Some(OffsetDateTime::UNIX_EPOCH), + noncurrent_days: 0, + newer_noncurrent_versions: 0, + storage_class: String::new(), + }; + + assert!(build_put_object_expiration_header(&event).is_none()); + } + + // -- Range: u64 -> i64 lossless conversion (issue rustfs/backlog#1322) -- + + const I64_MAX_AS_U64: u64 = i64::MAX as u64; + + /// The conversion itself: s3s `Range` (u64) -> internal `HTTPRangeSpec` + /// (i64). This directly guards the suffix truncation fix. Reverting to + /// `length as i64` regresses the zero-suffix, `i64::MAX + 1` and `u64::MAX` + /// rows below. + #[test] + fn range_to_http_range_spec_is_lossless() { + // Zero-length suffix (`bytes=-0`) is unsatisfiable -> InvalidRange (416), + // never a 0-length 206. + let zero_suffix = range_to_http_range_spec(Range::Suffix { length: 0 }); + assert_eq!( + zero_suffix.as_ref().err().map(|e| e.code()), + Some(&S3ErrorCode::InvalidRange), + "bytes=-0 must map to InvalidRange (416)" + ); + + // Suffix conversions: positive `start` holds the suffix length; values + // above i64::MAX clamp to i64::MAX (they always cover the whole object). + let suffix_cases = [ + (1_u64, 1_i64), + (I64_MAX_AS_U64, i64::MAX), + (I64_MAX_AS_U64 + 1, i64::MAX), // was i64::MIN under `as i64` -> checked_neg overflow + (u64::MAX, i64::MAX), // was -1 under `as i64` -> read as "last 1 byte" + ]; + for (length, expected_start) in suffix_cases { + let spec = range_to_http_range_spec(Range::Suffix { length }) + .unwrap_or_else(|_| panic!("suffix {length} must convert losslessly")); + assert!(spec.is_suffix_length, "suffix {length} must stay a suffix spec"); + assert_eq!(spec.start, expected_start, "suffix {length} start"); + assert_eq!(spec.end, -1, "suffix {length} end"); + } + + // Int ranges: s3s already rejects first/last > i64::MAX, so the checked + // cast never truncates. first-last and open-ended must not regress. + let int_first_last = range_to_http_range_spec(Range::Int { + first: 10, + last: Some(20), + }) + .expect("first-last converts"); + assert!(!int_first_last.is_suffix_length); + assert_eq!((int_first_last.start, int_first_last.end), (10, 20)); + + let int_open = range_to_http_range_spec(Range::Int { first: 5, last: None }).expect("open-ended converts"); + assert_eq!((int_open.start, int_open.end), (5, -1)); + + let int_max = range_to_http_range_spec(Range::Int { + first: I64_MAX_AS_U64, + last: Some(I64_MAX_AS_U64), + }) + .expect("i64::MAX int converts"); + assert_eq!((int_max.start, int_max.end), (i64::MAX, i64::MAX)); + } + + /// Observable end-to-end effect the GET/HEAD handlers derive from a range + /// spec: `HTTPRangeSpec::get_offset_length` yields the (offset, length) + /// that becomes `Content-Length` and `Content-Range`, or an error that + /// surfaces as 416. Covers empty / 1-byte / normal objects. + #[test] + fn range_suffix_offset_length_matches_s3_semantics() { + // Expected outcome for a satisfiable range, or `None` for 416. + #[derive(Debug, PartialEq)] + enum Outcome { + /// (offset, content_length, content_range) + Partial(usize, i64, String), + Unsatisfiable, + } + + fn derive(range: Range, size: i64) -> Outcome { + let spec = match range_to_http_range_spec(range) { + Ok(spec) => spec, + Err(_) => return Outcome::Unsatisfiable, + }; + match spec.get_offset_length(size) { + Ok((offset, len)) => { + let content_range = format!("bytes {}-{}/{}", offset, offset as i64 + len - 1, size); + Outcome::Partial(offset, len, content_range) + } + Err(_) => Outcome::Unsatisfiable, + } + } + + let suffix = |length: u64| Range::Suffix { length }; + + // size, range, expected + let normal = 100_i64; + let cases = [ + // Zero suffix is always 416, whatever the size. + (0_i64, suffix(0), Outcome::Unsatisfiable), + (1, suffix(0), Outcome::Unsatisfiable), + (normal, suffix(0), Outcome::Unsatisfiable), + // Suffix within the object returns the trailing bytes. + (normal, suffix(1), Outcome::Partial(99, 1, "bytes 99-99/100".into())), + (normal, suffix(normal as u64), Outcome::Partial(0, 100, "bytes 0-99/100".into())), + // Suffix >= size returns the whole object (never a truncated tail). + (normal, suffix(normal as u64 + 1), Outcome::Partial(0, 100, "bytes 0-99/100".into())), + (normal, suffix(I64_MAX_AS_U64), Outcome::Partial(0, 100, "bytes 0-99/100".into())), + (normal, suffix(I64_MAX_AS_U64 + 1), Outcome::Partial(0, 100, "bytes 0-99/100".into())), + (normal, suffix(u64::MAX), Outcome::Partial(0, 100, "bytes 0-99/100".into())), + // 1-byte object: any non-zero suffix returns that single byte. + (1, suffix(1), Outcome::Partial(0, 1, "bytes 0-0/1".into())), + (1, suffix(2), Outcome::Partial(0, 1, "bytes 0-0/1".into())), + (1, suffix(I64_MAX_AS_U64 + 1), Outcome::Partial(0, 1, "bytes 0-0/1".into())), + (1, suffix(u64::MAX), Outcome::Partial(0, 1, "bytes 0-0/1".into())), + // Normal first-last and open-ended int ranges must not regress. + ( + normal, + Range::Int { + first: 10, + last: Some(19), + }, + Outcome::Partial(10, 10, "bytes 10-19/100".into()), + ), + ( + normal, + Range::Int { first: 90, last: None }, + Outcome::Partial(90, 10, "bytes 90-99/100".into()), + ), + ]; + + for (size, range, expected) in cases { + let got = derive(range, size); + assert_eq!(got, expected, "size={size} range={range:?}"); + } + } + + fn quota_result(allowed: bool) -> QuotaCheckResult { + QuotaCheckResult { + allowed, + current_usage: Some(1024), + quota_limit: Some(2048), + operation_size: 512, + remaining: Some(512), + uses_durable_reservations: true, + } + } + + #[test] + fn quota_admission_allows_within_limit() { + let result = map_quota_check_outcome("bucket", Ok(quota_result(true))).expect("an allowed result admits the write"); + + assert_eq!(result.current_usage, Some(1024)); + assert_eq!(result.quota_limit, Some(2048)); + assert_eq!(result.operation_size, 512); + assert_eq!(result.remaining, Some(512)); + } + + #[test] + fn quota_admission_rejects_over_limit() { + let err = map_quota_check_outcome("bucket", Ok(quota_result(false))).expect_err("an over-limit result rejects the write"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + } + + #[test] + fn legacy_quota_admission_rejects_already_over_limit() { + let result = QuotaCheckResult { + allowed: true, + current_usage: Some(6), + quota_limit: Some(5), + operation_size: 0, + remaining: Some(0), + uses_durable_reservations: false, + }; + let mut opts = ObjectOptions::default(); + let err = + apply_quota_admission(&mut opts, &result).expect_err("legacy completion must not bypass an already exceeded quota"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + } + + #[test] + fn quota_admission_fails_closed_on_checker_error() { + // A configured hard quota must never be bypassed by an internal fault: a checker error becomes a retryable ServiceUnavailable, not a silent allow. + let err = map_quota_check_outcome( + "bucket", + Err(QuotaError::InvalidConfig { + reason: "corrupt quota config".to_string(), + }), + ) + .expect_err("a checker fault must fail closed"); + assert_eq!(err.code(), &S3ErrorCode::ServiceUnavailable); + } + + #[test] + fn early_quota_filter_rejects_only_an_individually_impossible_object() { + let stale_full_usage = QuotaCheckResult { + allowed: true, + current_usage: Some(4096), + quota_limit: Some(4096), + operation_size: 0, + remaining: Some(0), + uses_durable_reservations: true, + }; + + ensure_object_size_within_quota(&stale_full_usage, 4096) + .expect("commit-time ledger must decide whether stale usage was reclaimed"); + let err = ensure_object_size_within_quota(&stale_full_usage, 4097) + .expect_err("an object larger than the whole quota can never fit"); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + } + + #[test] + fn quota_admission_fails_closed_on_unknown_authoritative_usage() { + let err = map_quota_check_outcome( + "bucket", + Err(QuotaError::UsageUnavailable { + bucket: "bucket".to_string(), + }), + ) + .expect_err("unknown authoritative usage must not admit a quota-controlled write"); + assert_eq!(err.code(), &S3ErrorCode::ServiceUnavailable); + } +} diff --git a/rustfs/src/app/object/test_support.rs b/rustfs/src/app/object/test_support.rs new file mode 100644 index 000000000..61b368cf6 --- /dev/null +++ b/rustfs/src/app/object/test_support.rs @@ -0,0 +1,98 @@ +// 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. + +//! Test-only scaffolding shared by the object use-case test modules. + +use super::*; +use http::{Extensions, HeaderMap, Method, Uri}; +use std::sync::Arc; + +#[derive(Debug)] +pub(super) struct MockUploadStreamSha256Mismatch; + +impl std::fmt::Display for MockUploadStreamSha256Mismatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("UploadStreamError: Sha256Mismatch") + } +} + +impl std::error::Error for MockUploadStreamSha256Mismatch {} + +pub(super) fn build_request(input: T, method: Method) -> S3Request { + S3Request { + input, + method, + uri: Uri::from_static("/"), + headers: HeaderMap::new(), + extensions: Extensions::new(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + } +} + +pub(super) async fn real_cold_fill_test_context() -> (Arc, Arc) { + let store = crate::app::gating_test_env::shared_gating_ecstore().await; + if current_app_context().is_none() { + crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; + } + let ambient = current_app_context().expect("real cold-fill tests require an ambient AppContext"); + let context = temp_env::with_vars( + [ + (rustfs_config::ENV_OBJECT_DATA_CACHE_ENABLE, Some("true")), + (rustfs_config::ENV_OBJECT_DATA_CACHE_MODE, Some("fill_materialize_enabled")), + (rustfs_config::ENV_OBJECT_DATA_CACHE_MAX_BYTES, Some("8388608")), + (rustfs_config::ENV_OBJECT_DATA_CACHE_MAX_ENTRY_BYTES, Some("2097152")), + (rustfs_config::ENV_OBJECT_DATA_CACHE_MIN_FREE_MEMORY_PERCENT, Some("0")), + ], + || Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms())), + ); + assert!(context.object_data_cache().materialize_fill_enabled()); + (store, context) +} + +pub(super) async fn put_real_cold_fill_object(store: &Arc, bucket: &str, object: &str, body: &[u8]) -> ObjectInfo { + let mut reader = PutObjReader::from_vec(body.to_vec()); + store + .put_object(bucket, object, &mut reader, &ObjectOptions::default()) + .await + .expect("real cold-fill test object must be written") +} + +pub(super) fn real_cold_fill_plan( + adapter: &ObjectDataCacheAdapter, + bucket: &str, + object: &str, + info: &ObjectInfo, +) -> rustfs_object_data_cache::ObjectDataCacheGetPlan { + let length = info + .get_actual_size() + .expect("real cold-fill test metadata must expose plaintext size"); + let GetObjectBodyCachePlan::Cacheable(plan) = build_get_object_body_cache_plan( + adapter, + GetObjectBodyCacheRequest { + bucket, + key: object, + info, + response_content_length: length, + has_range: false, + part_number: None, + encryption_applied: false, + }, + ) else { + panic!("real cold-fill test object must be cacheable"); + }; + plan +} diff --git a/rustfs/src/app/object_usecase.rs b/rustfs/src/app/object_usecase.rs index 022951744..80857ef7d 100644 --- a/rustfs/src/app/object_usecase.rs +++ b/rustfs/src/app/object_usecase.rs @@ -13,19680 +13,9 @@ // limitations under the License. //! Object application use-case contracts. +//! +//! The implementation lives in [`crate::app::object`], split per S3 operation +//! (backlog#1841). This module stays as a thin re-export so existing +//! `crate::app::object_usecase::*` paths keep working. -// Performance metrics recording (with zero-copy-metrics integration) -use rustfs_io_metrics::buffered_write; - -use crate::storage_api::table::get_bucket_metadata; - -use super::storage_api::object_usecase::access::{ - PostObjectRequestMarker, apply_bucket_generation_guard, apply_copy_source_bucket_generation_guard, authorize_request, - has_bypass_governance_header, load_bucket_generation_from_store, recursive_force_delete_is_authorized, - replication_request_authorized, req_info_mut, req_info_ref, -}; -#[cfg(test)] -use super::storage_api::object_usecase::bucket::quota::BucketQuota; -use super::storage_api::object_usecase::bucket::quota::checker::QuotaChecker; -#[cfg(test)] -use super::storage_api::object_usecase::bucket::replication::{ReplicationState, replication_statuses_map}; -use super::storage_api::object_usecase::bucket::{ - VersioningConfigExt as _, - lifecycle::{ - bucket_lifecycle_audit::LcEventSrc, - bucket_lifecycle_ops::{enqueue_transition_immediate, post_restore_opts}, - lifecycle::{self, TransitionOptions}, - }, - metadata_sys, - object_lock::{ - objectlock::{get_object_legalhold_meta, get_object_retention_meta}, - objectlock_sys::{check_object_lock_for_deletion, is_retention_active, replication_write_may_pass_worm_gate}, - }, - predict_lifecycle_expiration, - quota::{QuotaCheckResult, QuotaError, QuotaOperation}, - replication::{ - DeleteReplicationConfigSnapshot, REPLICATE_INCOMING_DELETE, ReplicationStatusType, commit_force_delete_intent, - delete_replication_state_from_config, delete_replication_version_id, deleted_object_has_pending_replication_delete, - force_delete_target_set, get_read_proxy_targets, has_active_delete_rule, load_delete_config_snapshot, - must_replicate_object, persist_force_delete_intent, record_replication_proxy, schedule_object_replication, - schedule_replication_delete, schedule_replication_deletes, set_deleted_object_replication_state, - should_schedule_delete_replication, should_use_existing_delete_replication_info, - }, - tagging::decode_tags, - validate_restore_request, - versioning_sys::BucketVersioningSys, -}; -use super::storage_api::object_usecase::compression::{MIN_DISK_COMPRESSIBLE_SIZE, is_disk_compressible}; -use super::storage_api::object_usecase::concurrency::{ - self, ConcurrencyManager, DiskReadAdmission, GetObjectGuard, PutObjectAdmission, PutObjectGuard, - get_concurrency_aware_buffer_size, get_concurrency_manager, get_put_concurrency_aware_buffer_size, -}; -#[cfg(test)] -use super::storage_api::object_usecase::contract::http::HTTPPreconditions; -use super::storage_api::object_usecase::contract::namespace::NamespaceLocking; -use super::storage_api::object_usecase::contract::object::{ObjectIO as _, ObjectOperations as _}; -use super::storage_api::object_usecase::contract::range::HTTPRangeSpec; -use super::storage_api::object_usecase::data_usage::{ - quota_object_size, record_bucket_delete_marker_memory, record_bucket_object_delete_memory, - record_bucket_object_version_write_memory, record_bucket_object_write_memory, - record_bucket_object_write_unknown_previous_memory, -}; -use super::storage_api::object_usecase::deadlock_detector; -use super::storage_api::object_usecase::ecfs::FS; -use super::storage_api::object_usecase::error::{ - Error as EcstoreError, StorageError, is_err_bucket_not_found, is_err_object_not_found, is_err_version_not_found, -}; -use super::storage_api::object_usecase::head_prefix::{head_prefix_not_found_message, probe_prefix_has_children}; -use super::storage_api::object_usecase::helper::{OperationHelper, build_event_resp_elements, spawn_background_with_context}; -use super::storage_api::object_usecase::io::{DynReader, HashReader, WritePlan, compression_metadata_value, wrap_reader}; -#[cfg(test)] -use super::storage_api::object_usecase::object_cache::GetObjectBodySource; -#[cfg(test)] -use super::storage_api::object_usecase::object_cache::lookup_get_object_body_cache_hook; -use super::storage_api::object_usecase::object_cache::{GetObjectBodyCacheHookLookup, get_object_body_cache_plaintext_len}; -use super::storage_api::object_usecase::object_utils::to_s3s_etag; -use super::storage_api::object_usecase::options::{ - copy_dst_opts_with_replication_authorization, copy_src_opts, del_opts_with_versioning, extract_metadata, - extract_metadata_from_mime_with_object_name, filter_object_metadata, get_content_sha256_with_query, get_opts, - has_replication_retention_update, namespace_reserved_user_metadata, normalize_content_encoding_for_storage, - preserve_unclassified_user_metadata, put_opts_with_replication_authorization, validate_archive_content_encoding, -}; -use super::storage_api::object_usecase::request_context::{self, spawn_traced, spawn_traced_join}; -use super::storage_api::object_usecase::s3_api::multipart::parse_list_parts_params; -use super::storage_api::object_usecase::set_disk::{ - get_lock_acquire_timeout, get_object_disk_read_timeout, is_valid_storage_class, -}; -use super::storage_api::object_usecase::sse::{ - DecryptionRequest, EncryptionRequest, SseKmsPrincipal, apply_bucket_default_lock_retention, authorize_sse_kms_object_read, - bucket_default_write_sse, build_ssec_read_headers, classify_sse_read_response, encryption_material_to_metadata, - extract_server_side_encryption_from_headers, extract_ssec_params_from_headers, extract_ssekms_context_from_headers, - get_buffer_size_opt_in, load_bucket_object_lock_config_state, map_get_object_reader_error, sse_encryption, - validate_bucket_object_lock_enabled_state, -}; -use super::storage_api::object_usecase::storage_class as storageclass; -use super::storage_api::object_usecase::timeout_wrapper::{GetObjectTimeoutPolicy, RequestTimeoutWrapper}; -use super::storage_api::object_usecase::{ECStore, OldCurrentSize}; -use super::storage_api::object_usecase::{ - RFC1123, check_preconditions, parse_object_lock_legal_hold, parse_object_lock_retention, parse_part_number_i32_to_usize, - remove_object_lock_metadata_for_copy, strip_managed_encryption_metadata, validate_bucket_exists, validate_object_key, - validate_sse_headers_for_read, validate_sse_headers_for_write, validate_ssec_for_read, wrap_response_with_cors, -}; -use crate::app::runtime_sources::{ - AppContext, current_app_context, current_notify_interface_for_context, current_object_data_cache_for_context, - current_object_store_handle_for_context, -}; -use crate::config::RustFSBufferConfig; -use crate::delete_tail_activity::{DeleteTailActivityGuard, DeleteTailStage}; -use crate::error::ApiError; -use crate::shared_types::convert_ecstore_object_info; -use crate::table_catalog; -use bytes::{BufMut as _, Bytes, BytesMut}; -use futures::{Stream, StreamExt, TryStreamExt}; -use http::{HeaderMap, HeaderValue, StatusCode}; -use md5::{Digest as Md5Digest, Md5}; -use metrics::{counter, histogram}; -use pin_project_lite::pin_project; -use rustfs_audit::ObjectVersion as AuditObjectVersion; -use rustfs_concurrency::GetObjectQueueSnapshot; -use rustfs_config::MI_B; -use rustfs_filemeta::{NULL_VERSION_ID, RestoreStatusOps, parse_restore_obj_status}; -use rustfs_io_core::{BytesPool, PooledBuffer}; -use rustfs_io_metrics; -use rustfs_lock::NamespaceLockGuard; -use rustfs_notify::EventArgsBuilder; -use rustfs_object_capacity::capacity_manager::get_capacity_manager; -use rustfs_policy::policy::action::{Action, S3Action}; -use rustfs_s3_ops::{S3Operation, delete_event_name_for_marker, put_event_name_for_post_object}; -use rustfs_targets::{EventName, get_request_host, get_request_port, get_request_user_agent}; -use rustfs_utils::CompressionAlgorithm; -#[cfg(test)] -use rustfs_utils::http::headers::{SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER}; -#[cfg(test)] -use rustfs_utils::http::insert_header; -use rustfs_utils::http::{ - AMZ_BUCKET_REPLICATION_STATUS, AMZ_CHECKSUM_MODE, AMZ_CHECKSUM_TYPE, AMZ_WEBSITE_REDIRECT_LOCATION, CONTENT_TYPE, - SUFFIX_ACTUAL_SIZE, SUFFIX_COMPRESSION, SUFFIX_COMPRESSION_SIZE, SUFFIX_REPLICA_STATUS, SUFFIX_REPLICA_TIMESTAMP, - SUFFIX_REPLICATION_STATUS, SUFFIX_REPLICATION_TIMESTAMP, SUFFIX_RESTORE_OPERATION_ID, SUFFIX_SOURCE_REPLICATION_CHECK, - SUFFIX_SOURCE_REPLICATION_REQUEST, get_header, - headers::{ - AMZ_CONTENT_SHA256, AMZ_DECODED_CONTENT_LENGTH, AMZ_MINIO_SNOWBALL_IGNORE_DIRS, AMZ_MINIO_SNOWBALL_IGNORE_ERRORS, - AMZ_MINIO_SNOWBALL_PREFIX, AMZ_OBJECT_LOCK_LEGAL_HOLD, AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE, - AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, - AMZ_OBJECT_TAGGING, AMZ_RESTORE_EXPIRY_DAYS, AMZ_RESTORE_REQUEST_DATE, AMZ_RUSTFS_SNOWBALL_IGNORE_DIRS, - AMZ_RUSTFS_SNOWBALL_IGNORE_ERRORS, AMZ_RUSTFS_SNOWBALL_PREFIX, AMZ_SERVER_SIDE_ENCRYPTION, - AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM, AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID, AMZ_SNOWBALL_EXTRACT, - AMZ_SNOWBALL_IGNORE_DIRS, AMZ_SNOWBALL_IGNORE_ERRORS, AMZ_SNOWBALL_PREFIX, AMZ_STORAGE_CLASS, AMZ_TAG_COUNT, - }, - insert_str, project_ssec_transport_headers, remove_str, -}; -use rustfs_utils::path::{encode_dir_object, is_dir_object, path_join_buf}; -use rustfs_utils::retry::{DEFAULT_RETRY_CAP, DEFAULT_RETRY_UNIT, MAX_JITTER, RetryTimer}; -use rustfs_zip::{ArchiveLimits, CompressionFormat}; -use s3s::StdError; -use s3s::dto::{ - CacheControl, Checksum, ChecksumAlgorithm, ChecksumType, ContentDisposition, ContentEncoding, ContentLanguage, ContentType, - CopyObjectInput, CopyObjectOutput, CopyObjectResult, CopySource, DeleteObjectInput, DeleteObjectOutput, DeleteObjectsInput, - DeleteObjectsOutput, DeletedObject, ETag, GetObjectAttributesInput, GetObjectAttributesOutput, GetObjectAttributesParts, - GetObjectInput, GetObjectOutput, HeadObjectInput, HeadObjectOutput, MetadataDirective, ObjectAttributes, ObjectLockLegalHold, - ObjectLockLegalHoldStatus, ObjectLockMode, ObjectLockRetention, ObjectLockRetentionMode, ObjectPart, PutObjectInput, - PutObjectOutput, Range, RequestCharged, RestoreObjectInput, RestoreObjectOutput, RestoreStatus, SSECustomerAlgorithm, - SSECustomerKeyMD5, SSEKMSKeyId, SelectObjectContentInput, SelectObjectContentOutput, ServerSideEncryption, - ServerSideEncryptionConfiguration, StorageClass, StreamingBlob, TaggingDirective, TaggingHeader, Timestamp, TimestampFormat, - WebsiteRedirectLocation, -}; -use s3s::header::{X_AMZ_RESTORE, X_AMZ_RESTORE_OUTPUT_PATH}; -use s3s::stream::{ByteStream, DynByteStream, RemainingLength}; -use s3s::{S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error}; - -const DEFAULT_PUT_LARGE_CONCURRENCY_TUNING_MIN_SIZE_BYTES: i64 = 32 * 1024 * 1024; -const RUSTFS_EXPECTED_CURRENT_VERSION_ID: &str = "x-rustfs-expected-current-version-id"; -const ENV_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES: &str = "RUSTFS_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES"; -const DEFAULT_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES: usize = 16 * 1024 * 1024; -const PUT_EAGER_STATUS_ELIGIBLE: &str = "eligible"; -const PUT_EAGER_STATUS_EXTRACT: &str = "extract"; -const PUT_EAGER_STATUS_COMPRESSED: &str = "compressed"; -const PUT_EAGER_STATUS_ENCRYPTED: &str = "encrypted"; -const PUT_EAGER_STATUS_INVALID_SIZE: &str = "invalid_size"; -const PUT_EAGER_STATUS_ABOVE_EAGER_MAX: &str = "above_eager_max"; -const PUT_EAGER_STATUS_ZERO_COPY_INELIGIBLE: &str = "zero_copy_ineligible"; -const PUT_EAGER_STATUS_AWS_CHUNKED_MISSING_DECODED_LENGTH: &str = "aws_chunked_missing_decoded_length"; -static CACHED_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES: std::sync::OnceLock = std::sync::OnceLock::new(); -use std::collections::HashMap; -use std::io; -use std::ops::Add; -use std::path::Path; -use std::pin::Pin; -use std::task::{Context, Poll}; - -use std::str::FromStr; -#[cfg(test)] -use std::sync::atomic::AtomicUsize; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, OnceLock}; -use std::time::{Duration, Instant}; -use time::{OffsetDateTime, format_description::well_known::Rfc3339}; -use tokio::io::{AsyncRead, ReadBuf}; -use tokio::sync::{OwnedSemaphorePermit, RwLock}; -use tokio_tar::Archive; -#[cfg(test)] -use tokio_util::io::ReaderStream; -use tokio_util::io::{StreamReader, poll_read_buf}; -use tracing::{debug, error, instrument, warn}; -use uuid::Uuid; - -use super::storage_api::object_usecase::{ - BUCKET_LIFECYCLE_LOCK_OBJECT, GetObjectReader, StorageDeletedObject, StorageObjectInfo as ObjectInfo, - StorageObjectLockDeleteOptions, StorageObjectOptions as ObjectOptions, StorageObjectToDelete as ObjectToDelete, - StoragePutObjReader as PutObjReader, -}; -use crate::app::object_data_cache::{ - ColdFillCoordinateOutcome, ColdFillDiskPermitOwner, ColdFillError, ColdFillProducer, GetObjectBodyCacheLookup, - GetObjectBodyCachePlan, GetObjectBodyCacheRequest, ObjectDataCacheAdapter, build_get_object_body_cache_plan, - build_get_object_body_cache_plan_for_revalidation, coordinate_cold_fill, current_cold_fill_disk_permit_owner, - fill_get_object_body_cache_from_buffered_body, fill_get_object_body_cache_from_materialized_body, - invalidate_object_data_cache_after_copy_success, invalidate_object_data_cache_after_delete_success, - invalidate_object_data_cache_after_put_success, invalidate_object_data_cache_before_mutation, - invalidate_object_data_cache_objects_after_delete_success, invalidate_object_data_cache_objects_before_mutation, - invalidate_object_data_cache_prefix_after_delete, invalidate_object_data_cache_prefix_before_mutation, - lookup_get_object_body_cache_hit, lookup_preplanned_get_object_body_cache_hook, -}; -#[cfg(test)] -use crate::app::object_data_cache::{ColdFillRole, ColdFillWaitOutcome, scope_cold_fill_disk_permit_owner_for_test}; -use crate::app::object_traffic_health::ObjectTrafficHealth; - -type S3StdError = Box; - -pub(crate) fn s3s_body_error_to_io(err: StdError) -> io::Error { - io::Error::other(err) -} - -struct ColdFillDiskPermitMetric { - owner: ColdFillDiskPermitOwner, - metric_recorded: bool, -} - -#[cfg(test)] -static COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST: AtomicU64 = AtomicU64::new(0); - -#[cfg(test)] -struct ColdFillPublicationBarrier { - reached: tokio::sync::Semaphore, - release: tokio::sync::Semaphore, -} - -#[cfg(test)] -type ColdFillPublicationBarrierState = Option<(rustfs_object_data_cache::ObjectDataCacheKey, Arc)>; - -#[cfg(test)] -static COLD_FILL_PUBLICATION_BARRIER: OnceLock> = OnceLock::new(); - -#[cfg(test)] -type ColdFillReaderOpenProbeState = Option<(rustfs_object_data_cache::ObjectDataCacheKey, Arc)>; - -#[cfg(test)] -static COLD_FILL_READER_OPEN_PROBE: OnceLock> = OnceLock::new(); - -fn adjust_cold_fill_disk_permit_metric(owner: ColdFillDiskPermitOwner, acquired: bool) { - macro_rules! adjust_gauge { - ($name:literal) => {{ - #[cfg(not(test))] - let gauge = { - static HANDLE: std::sync::LazyLock = std::sync::LazyLock::new(|| metrics::gauge!($name)); - &*HANDLE - }; - #[cfg(test)] - let gauge = metrics::gauge!($name); - if acquired { - gauge.increment(1.0); - } else { - gauge.decrement(1.0); - } - }}; - } - - match owner { - ColdFillDiskPermitOwner::Producer => { - adjust_gauge!("rustfs_object_data_cache_cold_fill_producer_disk_permits"); - } - ColdFillDiskPermitOwner::Follower => { - adjust_gauge!("rustfs_object_data_cache_cold_fill_follower_disk_permits"); - } - } -} - -#[cfg(test)] -async fn wait_cold_fill_publication_barrier(plan: &rustfs_object_data_cache::ObjectDataCacheGetPlan) { - let Some(key) = plan.key() else { - return; - }; - let barrier = COLD_FILL_PUBLICATION_BARRIER - .get_or_init(|| Mutex::new(None)) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .as_ref() - .filter(|(barrier_key, _)| barrier_key == key) - .map(|(_, barrier)| Arc::clone(barrier)); - if let Some(barrier) = barrier { - barrier.reached.add_permits(1); - if let Ok(permit) = barrier.release.acquire().await { - permit.forget(); - } - } -} - -#[cfg(test)] -fn record_cold_fill_reader_open_for_test(plan: &rustfs_object_data_cache::ObjectDataCacheGetPlan) { - let Some(key) = plan.key() else { - return; - }; - let probe = COLD_FILL_READER_OPEN_PROBE - .get_or_init(|| Mutex::new(None)) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .as_ref() - .filter(|(probe_key, _)| probe_key == key) - .map(|(_, count)| Arc::clone(count)); - if let Some(count) = probe { - count.fetch_add(1, Ordering::Relaxed); - } -} - -impl ColdFillDiskPermitMetric { - fn new(owner: ColdFillDiskPermitOwner) -> Self { - let metric_recorded = rustfs_io_metrics::metrics_enabled(); - if metric_recorded { - adjust_cold_fill_disk_permit_metric(owner, true); - } - #[cfg(test)] - if matches!(owner, ColdFillDiskPermitOwner::Follower) { - COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.fetch_add(1, Ordering::Relaxed); - } - Self { owner, metric_recorded } - } -} - -impl Drop for ColdFillDiskPermitMetric { - fn drop(&mut self) { - if self.metric_recorded { - adjust_cold_fill_disk_permit_metric(self.owner, false); - } - #[cfg(test)] - if matches!(self.owner, ColdFillDiskPermitOwner::Follower) { - COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.fetch_sub(1, Ordering::Relaxed); - } - } -} - -struct GetObjectDiskPermit { - permit: Option, - metric: Option, -} - -impl GetObjectDiskPermit { - fn new(permit: OwnedSemaphorePermit) -> Self { - Self { - permit: Some(permit), - metric: current_cold_fill_disk_permit_owner().map(ColdFillDiskPermitMetric::new), - } - } - - fn release(&mut self) { - self.permit.take(); - self.metric.take(); - } -} - -impl From for GetObjectDiskPermit { - fn from(permit: OwnedSemaphorePermit) -> Self { - Self::new(permit) - } -} - -impl Drop for GetObjectDiskPermit { - fn drop(&mut self) { - self.release(); - } -} - -const ACCEPT_RANGES_BYTES: &str = "bytes"; -const COLD_FILL_HARD_MAX_DURATION: Duration = Duration::from_secs(10 * 60); -pub(crate) const MAX_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 64 * 1024 * 1024; -const MEDIUM_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 8 * 1024 * 1024; -const HIGH_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 4 * 1024 * 1024; -const VERY_HIGH_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 1024 * 1024; -const LOG_COMPONENT_APP: &str = "app"; -const LOG_SUBSYSTEM_OBJECT: &str = "object"; -const EVENT_PUT_OBJECT_STORE_INFLIGHT_SLOW: &str = "put_object_store_inflight_slow"; -const EVENT_PUT_OBJECT_STORE_RETURNED: &str = "put_object_store_returned"; -const EVENT_PUT_OBJECT_COMMIT_OWNER_DEADLINE: &str = "put_object_commit_owner_deadline"; -const EVENT_GET_OBJECT_STREAM_BODY: &str = "get_object_stream_body"; -const EVENT_PUT_OBJECT_BODY_READ_STALLED: &str = "put_object_body_read_stalled"; -const GET_OBJECT_STAGE_PATH_S3_HANDLER: &str = "s3_handler"; -const GET_OBJECT_STAGE_REQUEST_INGRESS_TO_CONTEXT: &str = "request_ingress_to_context"; -const GET_OBJECT_STAGE_OUTPUT_STRATEGY: &str = "output_strategy"; -const GET_OBJECT_STAGE_BODY_BUILD: &str = "body_build"; -const GET_OBJECT_STAGE_BODY_ENCRYPTED_BUFFER_READ: &str = "body_encrypted_buffer_read"; -const GET_OBJECT_STAGE_BODY_MEMORY_BLOB: &str = "body_memory_blob"; -const GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ: &str = "body_seek_buffer_read"; -const GET_OBJECT_STAGE_BODY_STREAM_STRATEGY: &str = "body_stream_strategy"; -const GET_OBJECT_STAGE_BODY_STREAMING_BLOB: &str = "body_streaming_blob"; -const GET_OBJECT_STAGE_CHECKSUM_HEADERS: &str = "checksum_headers"; -const GET_OBJECT_STAGE_LIFECYCLE_EXPIRATION: &str = "lifecycle_expiration"; -const GET_OBJECT_STAGE_METADATA_FILTER: &str = "metadata_filter"; -const PUT_OBJECT_STORE_WARN_THRESHOLD: Duration = Duration::from_secs(5); -// Eager PUT bodies are fully materialized before the storage owner starts. On -// request cancellation, keep the commit/publication tail alive briefly, then -// request pre-commit rollback and await cleanup so its write-health guard is -// reaped without abandoning staged shards. -const EAGER_PUT_COMMIT_CANCELLATION_GRACE: Duration = - Duration::from_secs(rustfs_config::DEFAULT_DRIVE_MAX_TIMEOUT_DURATION_SECS * 4); -const GET_OBJECT_STREAM_WARN_THRESHOLD: Duration = Duration::from_secs(5); -static GET_OBJECT_BUFFER_THRESHOLD_WARNED: AtomicBool = AtomicBool::new(false); - -fn record_get_object_s3_handler_stage_duration(stage: &'static str, start: Option) { - if let Some(start) = start { - rustfs_io_metrics::record_get_object_stage_duration( - GET_OBJECT_STAGE_PATH_S3_HANDLER, - stage, - start.elapsed().as_secs_f64(), - ); - } -} - -fn decoded_content_length_from_headers(headers: &HeaderMap) -> S3Result> { - let Some(val) = headers.get(AMZ_DECODED_CONTENT_LENGTH) else { - return Ok(None); - }; - - match atoi::atoi::(val.as_bytes()) { - Some(x) => Ok(Some(x)), - None => Err(s3_error!(UnexpectedContent)), - } -} - -/// Losslessly convert an s3s [`Range`] into the internal [`HTTPRangeSpec`]. -/// -/// Shared by GET and HEAD so both apply identical range semantics. s3s parses -/// `first`/`last` as `u64`, but its own parser already rejects any value greater -/// than `i64::MAX`, so the int branch is a checked cast that never truncates. -/// -/// The suffix length, however, is an unchecked `u64`. A naive `length as i64` -/// truncates deterministically: `bytes=-18446744073709551615` wraps to `-1` and -/// is then read as "last 1 byte", and `bytes=-0` yields a 0-length 206 instead -/// of a 416. This function instead mirrors s3s [`Range::check`] semantics: -/// * a zero-length suffix is rejected with `InvalidRange` (416), matching AWS -/// S3 and MinIO; -/// * a suffix larger than `i64::MAX` is clamped to `i64::MAX`. Object sizes in -/// this system are bounded by `i64::MAX`, so such a suffix always covers the -/// whole object, and [`HTTPRangeSpec::get_length`] clamps it to the real -/// size once the object is known. -fn range_to_http_range_spec(range: Range) -> S3Result { - match range { - Range::Int { first, last } => { - let start = i64::try_from(first).map_err(|_| s3_error!(InvalidRange, "The requested range is not satisfiable"))?; - let end = match last { - Some(last) => { - i64::try_from(last).map_err(|_| s3_error!(InvalidRange, "The requested range is not satisfiable"))? - } - None => -1, - }; - Ok(HTTPRangeSpec { - is_suffix_length: false, - start, - end, - }) - } - Range::Suffix { length } => { - if length == 0 { - return Err(s3_error!(InvalidRange, "The requested range is not satisfiable")); - } - // Clamp to i64::MAX: any suffix >= object size returns the whole - // object, and object sizes never exceed i64::MAX. - let start = i64::try_from(length).unwrap_or(i64::MAX); - Ok(HTTPRangeSpec { - is_suffix_length: true, - start, - end: -1, - }) - } - } -} - -/// Resolve the authoritative object length that bucket-quota admission (and downstream sizing) must use. -/// -/// `Content-Encoding: aws-chunked` alone only *declares* the encoding; whether the body actually arrived chunk-framed is signalled by a `STREAMING-*` `x-amz-content-sha256`, and the S3 auth layer both requires `x-amz-decoded-content-length` for those requests and hands the body down already de-framed. So when a decoded length is present it is authoritative (the wire `Content-Length` counts chunk framing and would overcount); a framed body without a decoded length is rejected rather than falling back to the framed wire length. A declared-only aws-chunked request (issue #1857 clients) carries an unframed body, so its wire `Content-Length` is the authoritative size, exactly as for a plain PUT. A negative or otherwise unknown length is rejected so it can never be reinterpreted as an enormous unsigned size downstream. -fn resolve_put_object_authoritative_size(headers: &HeaderMap, content_length: Option) -> S3Result { - let decoded_content_length = decoded_content_length_from_headers(headers)?; - let aws_chunked = request_uses_aws_chunked(headers) || request_body_is_aws_chunked_framed(headers); - let size = match (aws_chunked, decoded_content_length, content_length) { - (true, Some(decoded), _) => decoded, - // Declared aws-chunked without a streaming payload: the body is not framed (the auth - // layer only de-frames STREAMING-* payloads, which always carry a decoded length), so - // the wire Content-Length is the real object size. - (true, None, Some(raw)) if !request_body_is_aws_chunked_framed(headers) => raw, - (true, None, _) => return Err(s3_error!(UnexpectedContent)), - (false, _, Some(raw)) => raw, - (false, Some(decoded), None) => decoded, - (false, None, None) => return Err(s3_error!(UnexpectedContent)), - }; - - if size < 0 { - return Err(s3_error!(UnexpectedContent)); - } - - Ok(size) -} - -/// True when the request body actually arrived chunk-framed on the wire, i.e. the payload was -/// signed as a SigV4 streaming upload (`x-amz-content-sha256: STREAMING-*`). This is the only -/// case in which the auth layer de-frames the body; `Content-Encoding: aws-chunked` without a -/// streaming payload is just a declared encoding over an unframed body. -fn request_body_is_aws_chunked_framed(headers: &HeaderMap) -> bool { - headers - .get(AMZ_CONTENT_SHA256) - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.len() >= 10 && value[..10].eq_ignore_ascii_case("STREAMING-")) -} - -/// Map a bucket-quota checker outcome onto the S3 admission result. -/// -/// Hard is the only supported quota type, so a checker fault (bucket-config read, config parse, or usage lookup) must fail closed rather than admit the write: allowing it would silently bypass a configured hard quota. The no-quota happy path never reaches the error arm — `QuotaChecker::check_quota` returns `Ok(allowed)` via the zero-extra-I/O fast path when no quota is configured, so failing closed here cannot penalise buckets without a quota. A fault surfaces as a retryable `ServiceUnavailable` and is counted; the client-facing message stays generic so internal config/usage details are not leaked. -pub(super) fn map_quota_check_outcome(bucket: &str, outcome: Result) -> S3Result { - match outcome { - Ok(result) if !result.allowed => Err(S3Error::with_message( - S3ErrorCode::InvalidRequest, - format!( - "Bucket quota exceeded. Current usage: {} bytes, limit: {} bytes", - result.current_usage.unwrap_or(0), - result.quota_limit.unwrap_or(0) - ), - )), - Err(e) => { - counter!("rustfs_bucket_quota_check_failed_total").increment(1); - if matches!(&e, QuotaError::UsageUnavailable { .. }) { - debug!(bucket, error = %e, state = "usage_pending", "Bucket quota check waiting for authoritative usage"); - } else { - warn!(bucket, error = %e, state = "checker_failed", "Bucket quota check failed closed"); - } - Err(S3Error::with_message( - S3ErrorCode::ServiceUnavailable, - "Bucket quota check temporarily unavailable, please retry".to_string(), - )) - } - Ok(result) => Ok(result), - } -} - -pub(super) fn apply_quota_admission(opts: &mut ObjectOptions, result: &QuotaCheckResult) -> S3Result<()> { - if result.uses_durable_reservations { - return Ok(()); - } - let Some(quota_limit) = result.quota_limit else { - return Ok(()); - }; - let Some(current_usage) = result.current_usage else { - return Err(S3Error::with_message( - S3ErrorCode::ServiceUnavailable, - "Bucket quota check temporarily unavailable, please retry".to_string(), - )); - }; - if current_usage > quota_limit { - return Err(S3Error::with_message( - S3ErrorCode::InvalidRequest, - format!("Bucket quota exceeded. Current usage: {current_usage} bytes, limit: {quota_limit} bytes"), - )); - } - let _ = opts.set_quota_admission(current_usage, quota_limit); - Ok(()) -} - -fn ensure_object_size_within_quota(result: &QuotaCheckResult, new_size: u64) -> S3Result<()> { - let (Some(current_usage), Some(quota_limit)) = (result.current_usage, result.quota_limit) else { - return Ok(()); - }; - if new_size > quota_limit { - return Err(S3Error::with_message( - S3ErrorCode::InvalidRequest, - format!("Bucket quota exceeded. Current usage: {current_usage} bytes, limit: {quota_limit} bytes"), - )); - } - Ok(()) -} - -fn ensure_legacy_archive_size_within_quota(result: &QuotaCheckResult, total_unpacked_size: u64) -> S3Result<()> { - if result.uses_durable_reservations { - return Ok(()); - } - let (Some(current_usage), Some(quota_limit)) = (result.current_usage, result.quota_limit) else { - return Ok(()); - }; - let expected_usage = current_usage - .checked_add(total_unpacked_size) - .ok_or_else(|| s3_error!(InvalidArgument, "Archive total size overflowed quota accounting"))?; - if expected_usage > quota_limit { - return Err(S3Error::with_message( - S3ErrorCode::InvalidRequest, - format!("Bucket quota exceeded. Current usage: {current_usage} bytes, limit: {quota_limit} bytes"), - )); - } - Ok(()) -} - -fn quota_accounting_object_size(info: &ObjectInfo, fail_closed: bool) -> S3Result { - match quota_object_size(info) { - Ok(size) => Ok(size), - Err(err) if fail_closed => Err(ApiError::from(err).into()), - Err(_) => Ok(info.size.max(0) as u64), - } -} - -fn request_uses_aws_chunked(headers: &HeaderMap) -> bool { - let has_aws_chunked = |header_name: &str| { - headers - .get(header_name) - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.split(',').any(|part| part.trim().eq_ignore_ascii_case("aws-chunked"))) - }; - - has_aws_chunked("content-encoding") || has_aws_chunked("transfer-encoding") -} - -async fn validate_table_catalog_object_mutation(bucket: &str, key: &str) -> S3Result<()> { - table_catalog::validate_bucket_object_mutation(bucket, key) - .await - .map_err(|_| s3_error!(InvalidRequest, "{}", table_catalog::RESERVED_CATALOG_OBJECT_MESSAGE)) -} - -struct DeadlockRequestGuard { - deadlock_detector: Arc, - request_id: String, -} - -impl DeadlockRequestGuard { - fn new(deadlock_detector: Arc, request_id: String) -> Self { - Self { - deadlock_detector, - request_id, - } - } - - fn register_if_enabled( - deadlock_detector: Arc, - request_id: &str, - description: F, - ) -> Option - where - F: FnOnce() -> String, - { - if !deadlock_detector.is_enabled() { - return None; - } - - let request_id = request_id.to_string(); - deadlock_detector.register_request(&request_id, description()); - Some(Self::new(deadlock_detector, request_id)) - } -} - -impl Drop for DeadlockRequestGuard { - fn drop(&mut self) { - self.deadlock_detector.unregister_request(&self.request_id); - } -} - -struct GetObjectBootstrap { - timeout_config: GetObjectTimeoutPolicy, - wrapper: RequestTimeoutWrapper, - request_start: std::time::Instant, - request_guard: GetObjectGuard, - _deadlock_request_guard: Option, - concurrent_requests: usize, -} - -struct GetObjectIoPlanning { - /// `None` when inline fast path skips disk I/O semaphore. - disk_permit: Option, - permit_wait_duration: Duration, - queue_status: concurrency::IoQueueStatus, - queue_utilization: f64, -} - -#[derive(Clone, Copy)] -struct GetObjectRequestTimeout<'a> { - wrapper: &'a RequestTimeoutWrapper, - policy: &'a GetObjectTimeoutPolicy, -} - -struct GetObjectRequestContext { - bucket: String, - key: String, - version_id_for_event: String, - part_number: Option, - rs: Option, - opts: ObjectOptions, -} - -/// Request fields that passed the cheap GET validations, ready for the -/// bucket-metadata work in [`DefaultObjectUsecase::prepare_get_object_request_context`]. -struct GetObjectValidatedRequest { - bucket: String, - key: String, - version_id: Option, - part_number: Option, - rs: Option, -} - -struct GetObjectReadSetup { - info: ObjectInfo, - final_stream: DynReader, - buffered_body: Option, - /// ODC-16: `buffered_body` is the body the ecstore cache hook served, so the - /// app layer serves it as the object-data-cache source without a re-lookup. - cache_hook_served: bool, - /// ODC-16: the cache hook probed this read (served or missed), so the app - /// layer must skip its own lookup. - cache_hook_probed: bool, - cache_fill_allowed: bool, - rs: Option, - content_type: Option, - last_modified: Option, - response_content_length: i64, - content_range: Option, - server_side_encryption: Option, - sse_customer_algorithm: Option, - sse_customer_key_md5: Option, - ssekms_key_id: Option, - encryption_applied: bool, - /// Resolved plaintext start offset of the committed response body - /// (`get_offset_length` output; 0 for a full-object read). Feeds the - /// mid-stream resume offset. - resume_range_start: i64, - /// Resolved inclusive plaintext end offset of the committed response body; - /// -1 when the committed body runs to the end of the object. - resume_range_end: i64, -} - -struct GetObjectPreparedRead { - io_planning: GetObjectIoPlanning, - read_setup: GetObjectReadSetup, -} - -struct GetObjectStrategyContext { - #[allow(dead_code, reason = "written but never read back (backlog#1823)")] - io_strategy: concurrency::IoStrategy, - optimal_buffer_size: usize, - enable_readahead: bool, -} - -struct GetObjectOutputContext { - output: GetObjectOutput, - event_info: Option, - response_content_length: i64, - optimal_buffer_size: usize, - extra_checksum_headers: Vec<(&'static str, String)>, -} - -enum GetObjectTimeoutStage { - BeforeProcessing, - DiskPermitWait { permit_wait_duration: Duration }, - BeforeRead, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum GetObjectStreamStrategy { - Standard, - LargeSequentialReadahead, -} - -impl GetObjectStreamStrategy { - fn as_str(self) -> &'static str { - match self { - Self::Standard => "standard", - Self::LargeSequentialReadahead => "large_sequential_readahead", - } - } -} - -const LARGE_SEQUENTIAL_GET_THRESHOLD_BYTES: i64 = 1024 * 1024 * 1024; -const LARGE_SEQUENTIAL_GET_STREAM_BUFFER_CAP_BYTES: usize = 4 * MI_B; -const LARGE_SEQUENTIAL_GET_READAHEAD_MULTIPLIER: usize = 2; -const LARGE_BODY_READER_STREAM_BUFFER_FLOOR_BYTES: usize = MI_B; -const LARGE_BODY_READER_STREAM_BUFFER_THRESHOLD_BYTES: i64 = 4 * MI_B as i64; -const MID_BODY_READER_STREAM_BUFFER_FLOOR_BYTES: usize = 512 * 1024; -const MID_BODY_READER_STREAM_BUFFER_THRESHOLD_BYTES: i64 = MI_B as i64; -const ENV_RUSTFS_GET_SEEK_BUFFER_ENABLE: &str = "RUSTFS_GET_SEEK_BUFFER_ENABLE"; -const ENV_RUSTFS_GET_READER_STREAM_BUFFER_SIZE: &str = "RUSTFS_GET_READER_STREAM_BUFFER_SIZE"; -const ENV_RUSTFS_GET_OUTPUT_HANDOFF_ATTRIBUTION_ENABLE: &str = "RUSTFS_GET_OUTPUT_HANDOFF_ATTRIBUTION_ENABLE"; -const ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE: &str = "RUSTFS_GET_SMALL_BODY_ONCE_ENABLE"; -const GET_READER_STREAM_BUFFER_SOURCE_SELECTED: &str = "selected"; -const GET_READER_STREAM_BUFFER_SOURCE_ENV_OVERRIDE: &str = "env_override"; -const GET_READER_STREAM_POLL_PENDING: &str = "pending"; -const GET_READER_STREAM_POLL_READY_DATA: &str = "ready_data"; -const GET_READER_STREAM_POLL_READY_EMPTY: &str = "ready_empty"; -const GET_READER_STREAM_POLL_READY_ERROR: &str = "ready_error"; -const GET_STREAMING_BODY_FAILURE_STAGE_READER_STREAM: &str = "reader_stream"; -const GET_STREAMING_BODY_FAILURE_REASON_READER_ERROR: &str = "reader_error"; -const GET_STREAMING_BODY_FAILURE_REASON_SHORT_EOF: &str = "short_eof"; -const GET_MEMORY_BODY_SOURCE_BUFFERED_BODY: &str = "buffered_body"; -const GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE: &str = "object_data_cache"; -const GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE_MATERIALIZED: &str = "object_data_cache_materialized"; -const GET_MEMORY_BODY_SOURCE_SEEK_BUFFER: &str = "seek_buffer"; -const GET_MEMORY_BODY_SOURCE_ENCRYPTED_BUFFER: &str = "encrypted_buffer"; -const GET_OBJECT_STAGE_BODY_CACHE_MATERIALIZE_READ: &str = "body_cache_materialize_read"; - -fn get_reader_stream_buffer_size_override() -> Option { - static GET_READER_STREAM_BUFFER_SIZE_OVERRIDE: OnceLock> = OnceLock::new(); - *GET_READER_STREAM_BUFFER_SIZE_OVERRIDE.get_or_init(|| { - std::env::var(ENV_RUSTFS_GET_READER_STREAM_BUFFER_SIZE) - .ok() - .and_then(|value| value.parse::().ok()) - .filter(|value| *value > 0) - }) -} - -fn is_get_output_handoff_attribution_enabled() -> bool { - static ENABLED: OnceLock = OnceLock::new(); - *ENABLED.get_or_init(|| rustfs_utils::get_env_bool(ENV_RUSTFS_GET_OUTPUT_HANDOFF_ATTRIBUTION_ENABLE, false)) -} - -fn is_get_small_body_once_enabled() -> bool { - #[cfg(test)] - { - rustfs_utils::get_env_bool(ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE, false) - } - #[cfg(not(test))] - { - static ENABLED: OnceLock = OnceLock::new(); - *ENABLED.get_or_init(|| rustfs_utils::get_env_bool(ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE, false)) - } -} - -fn is_get_seek_buffer_enabled() -> bool { - static ENABLED: OnceLock = OnceLock::new(); - *ENABLED.get_or_init(|| rustfs_utils::get_env_bool(ENV_RUSTFS_GET_SEEK_BUFFER_ENABLE, false)) -} - -fn resolve_reader_stream_buffer_size(selected_size: usize, override_size: Option) -> (usize, &'static str) { - if let Some(override_size) = override_size.filter(|value| *value > 0) { - return (override_size, GET_READER_STREAM_BUFFER_SOURCE_ENV_OVERRIDE); - } - - (selected_size.max(1), GET_READER_STREAM_BUFFER_SOURCE_SELECTED) -} - -fn tune_reader_stream_buffer_size( - selected_size: usize, - response_content_length: i64, - stream_strategy: GetObjectStreamStrategy, -) -> usize { - if stream_strategy == GetObjectStreamStrategy::Standard - && response_content_length >= LARGE_BODY_READER_STREAM_BUFFER_THRESHOLD_BYTES - { - return selected_size.max(LARGE_BODY_READER_STREAM_BUFFER_FLOOR_BYTES); - } - - if stream_strategy == GetObjectStreamStrategy::Standard - && response_content_length >= MID_BODY_READER_STREAM_BUFFER_THRESHOLD_BYTES - { - return selected_size.max(MID_BODY_READER_STREAM_BUFFER_FLOOR_BYTES); - } - - selected_size -} - -fn get_object_stream_size_bucket(expected: usize) -> &'static str { - rustfs_io_metrics::get_object_size_bucket(i64::try_from(expected).unwrap_or(i64::MAX)) -} - -fn classify_get_object_stream_read_error(err: &std::io::Error) -> &'static str { - if let Some(inner) = err.get_ref() { - if inner.is::() { - return "short_eof"; - } - - if inner.is::() { - return "bitrot"; - } - - let error_msg = inner.to_string().to_lowercase(); - if error_msg.contains("bitrot") { - return "bitrot"; - } - if error_msg.contains("read quorum") || error_msg.contains("insufficient read quorum") || error_msg.contains("erasure") { - return "read_quorum"; - } - } - - match err.kind() { - std::io::ErrorKind::UnexpectedEof => "short_eof", - std::io::ErrorKind::TimedOut => "timeout", - std::io::ErrorKind::InvalidInput | std::io::ErrorKind::InvalidData => "range_or_length_invalid", - _ => "io", - } -} - -fn get_object_stream_failure_reason(error_class: &'static str) -> &'static str { - if error_class == "short_eof" { - GET_STREAMING_BODY_FAILURE_REASON_SHORT_EOF - } else { - GET_STREAMING_BODY_FAILURE_REASON_READER_ERROR - } -} - -fn record_get_object_reader_stream_failure( - reason: &'static str, - error_class: &'static str, - strategy: &'static str, - buffer_source: &'static str, - expected: usize, - emitted: usize, - remaining: usize, -) { - rustfs_io_metrics::record_get_object_streaming_body_failure(rustfs_io_metrics::GetObjectStreamingBodyFailure { - stage: GET_STREAMING_BODY_FAILURE_STAGE_READER_STREAM, - reason, - error_class, - strategy, - buffer_source, - size_bucket: get_object_stream_size_bucket(expected), - emitted_bytes: emitted, - remaining_bytes: remaining, - }); -} - -pin_project! { - struct ExtractArchiveEtagReader { - #[pin] - inner: R, - md5: Md5, - finished: bool, - etag: Arc>>, - } -} - -struct MemoryTrackedBytesStream { - bytes: Option, - emitted: bool, - completed: bool, - expected: usize, - /// Set when the materialized buffer length disagrees with the declared - /// content length. Such a body would be truncated (short) or over-long - /// relative to the already-committed `Content-Length`, so the stream must - /// surface an error instead of a clean short/over-long body. See #1324. - length_mismatch: bool, - started: std::time::Instant, - source: &'static str, - _guard: Option, - lifecycle: GetObjectBodyLifecycle, -} - -struct MemoryOnceBodyOwner { - bytes: Bytes, - _guard: Option, - // Body::Once has no poll hook, so this opt-in path only holds the request - // guard until the bytes are dropped; the result status remains unknown. - _lifecycle: GetObjectBodyLifecycle, -} - -impl MemoryOnceBodyOwner { - fn new(bytes: Bytes, guard: Option, lifecycle: GetObjectBodyLifecycle) -> Self { - Self { - bytes, - _guard: guard, - _lifecycle: lifecycle, - } - } -} - -impl AsRef<[u8]> for MemoryOnceBodyOwner { - fn as_ref(&self) -> &[u8] { - self.bytes.as_ref() - } -} - -#[derive(Default)] -struct GetObjectBodyLifecycle { - request_guard: Option, -} - -impl GetObjectBodyLifecycle { - fn tracked(request_guard: GetObjectGuard) -> Self { - Self { - request_guard: Some(request_guard), - } - } - - #[cfg(test)] - fn disabled() -> Self { - Self { request_guard: None } - } - - fn is_finished(&self) -> bool { - self.request_guard.is_none() - } - - fn finish_ok(&mut self) { - if let Some(mut request_guard) = self.request_guard.take() { - request_guard.finish_ok(); - } - } - - fn finish_err(&mut self) { - if let Some(mut request_guard) = self.request_guard.take() { - request_guard.finish_err(); - } - } -} - -pin_project! { - // Keep the disk-read admission permit tied to the response body. This is - // intentionally conservative backpressure: a streaming GET should occupy a - // read slot until the client drains or drops the body. - struct DiskReadPermitReader { - #[pin] - inner: R, - disk_permit: Option, - } -} - -impl DiskReadPermitReader { - fn new(inner: R, disk_permit: GetObjectDiskPermit) -> Self { - Self { - inner, - disk_permit: Some(disk_permit), - } - } -} - -impl AsyncRead for DiskReadPermitReader -where - R: AsyncRead, -{ - fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - let this = self.project(); - let had_capacity = buf.remaining() > 0; - let filled_before = buf.filled().len(); - let poll = this.inner.poll_read(cx, buf); - // EOF: no more disk reads can happen through this stream, so release - // the permit instead of holding it until the client drops the body. - if had_capacity - && matches!(poll, Poll::Ready(Ok(()))) - && buf.filled().len() == filled_before - && let Some(mut disk_permit) = this.disk_permit.take() - { - disk_permit.release(); - } - poll - } -} - -pin_project! { - struct GetObjectReaderStream { - #[pin] - reader: Option, - capacity: usize, - strategy: &'static str, - buffer_source: &'static str, - remaining: usize, - emitted: usize, - expected: usize, - // Diagnostic-only identity for the body this stream is serving. Unset in - // unit tests that drive the stream over a bare reader; every production - // body carries it via `with_diagnostics`. - diagnostics: GetObjectReaderStreamDiagnostics, - } -} - -/// Object identity carried alongside a streaming GET body purely so a -/// mid-stream failure names the object it happened on. -#[derive(Clone, Default)] -struct GetObjectReaderStreamDiagnostics { - bucket: String, - object: String, - request_id: String, -} - -impl MemoryTrackedBytesStream { - fn new( - bytes: Bytes, - expected: usize, - source: &'static str, - guard: Option, - lifecycle: GetObjectBodyLifecycle, - ) -> Self { - let length_mismatch = bytes.len() != expected; - Self { - bytes: Some(bytes), - emitted: false, - completed: !length_mismatch && expected == 0, - expected, - length_mismatch, - started: std::time::Instant::now(), - source, - _guard: guard, - lifecycle, - } - } - - fn finish_ok(&mut self) { - self.completed = true; - self.lifecycle.finish_ok(); - } - - fn finish_err(&mut self) { - self.lifecycle.finish_err(); - } -} - -impl GetObjectReaderStream -where - R: AsyncRead, -{ - fn new(reader: R, capacity: usize, remaining: usize, strategy: &'static str, buffer_source: &'static str) -> Self { - if is_get_output_handoff_attribution_enabled() { - rustfs_io_metrics::record_get_object_reader_stream_buffer_size(strategy, buffer_source, capacity); - } - Self { - reader: Some(reader), - capacity, - strategy, - buffer_source, - remaining, - emitted: 0, - expected: remaining, - diagnostics: GetObjectReaderStreamDiagnostics::default(), - } - } - - /// Attach the object identity a failed body should be reported against. - fn with_diagnostics(mut self, bucket: &str, object: &str, request_id: &str) -> Self { - self.diagnostics = GetObjectReaderStreamDiagnostics { - bucket: bucket.to_string(), - object: object.to_string(), - request_id: request_id.to_string(), - }; - self - } -} - -impl futures::Stream for MemoryTrackedBytesStream { - type Item = Result; - - fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - let this = self.get_mut(); - let poll_start = is_get_output_handoff_attribution_enabled().then(std::time::Instant::now); - if this.emitted { - if let Some(poll_start) = poll_start { - rustfs_io_metrics::record_get_object_memory_body_stream_poll( - this.source, - GET_READER_STREAM_POLL_READY_EMPTY, - 0, - poll_start.elapsed().as_secs_f64(), - ); - } - return Poll::Ready(None); - } - - // Strict materialization guard (#1324): a body whose length disagrees - // with the declared content length must fail the transfer rather than be - // delivered as a clean short body (truncation) or an over-long body - // (protocol violation). The HTTP layer has already committed to - // `Content-Length == expected`, so there is no safe way to serve a - // differently sized body. This is a defense-in-depth backstop; the - // buffered/cache callers reject the mismatch before headers are sent. - if this.length_mismatch { - let actual = this.bytes.as_ref().map_or(0, Bytes::len); - this.emitted = true; - this.finish_err(); - return Poll::Ready(Some(Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("materialized GET body length mismatch: expected {}, got {}", this.expected, actual), - ) - .into()))); - } - - let Some(bytes) = this.bytes.take() else { - return Poll::Ready(None); - }; - let bytes_len = bytes.len(); - let first_byte_elapsed = (!bytes.is_empty()).then(|| this.started.elapsed()); - this.emitted = true; - if let Some(elapsed) = first_byte_elapsed { - rustfs_io_metrics::record_get_object_first_byte_latency(GET_OBJECT_STAGE_PATH_S3_HANDLER, elapsed.as_secs_f64()); - } - if bytes_len >= this.expected { - this.finish_ok(); - } - if let Some(poll_start) = poll_start { - rustfs_io_metrics::record_get_object_memory_body_stream_poll( - this.source, - GET_READER_STREAM_POLL_READY_DATA, - bytes_len, - poll_start.elapsed().as_secs_f64(), - ); - } - Poll::Ready(Some(Ok(bytes))) - } -} - -impl ByteStream for MemoryTrackedBytesStream { - fn remaining_length(&self) -> RemainingLength { - if self.emitted || self.bytes.is_none() { - RemainingLength::new_exact(0) - } else { - RemainingLength::new_exact(self.expected) - } - } -} - -impl Drop for MemoryTrackedBytesStream { - fn drop(&mut self) { - if self.lifecycle.is_finished() { - return; - } - - if self.completed { - self.finish_ok(); - } else { - self.finish_err(); - } - } -} - -/// Failure modes of strictly materializing an object body into memory (#1324). -#[derive(Debug)] -enum StrictMaterializeError { - /// The reader produced a different number of bytes than the declared content - /// length (short or over-long). The response has already committed to - /// `Content-Length == expected`, so any other length is an unrecoverable, - /// broken HTTP response and must fail before headers are sent. - LengthMismatch { expected: usize, actual: usize }, - /// A read error occurred after `consumed` bytes were already drained from the - /// reader. The caller MUST NOT fall back to streaming the same reader: the - /// drained prefix is gone, so streaming would ship a body missing its prefix - /// (the seek-buffer prefix-misalignment bug this issue closes). - Read { consumed: usize, source: std::io::Error }, -} - -impl std::fmt::Display for StrictMaterializeError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::LengthMismatch { expected, actual, .. } => { - write!(f, "materialized length mismatch: expected {expected}, got {actual}") - } - Self::Read { consumed, source } => { - write!(f, "read failed after {consumed} bytes: {source}") - } - } - } -} - -impl StrictMaterializeError { - fn into_storage_error(self) -> StorageError { - match self { - Self::LengthMismatch { expected, actual, .. } if actual < expected => StorageError::LessData, - Self::LengthMismatch { .. } => StorageError::MoreData, - Self::Read { source, .. } if source.kind() == std::io::ErrorKind::TimedOut => StorageError::Timeout, - Self::Read { source, .. } => StorageError::Io(std::io::Error::new(source.kind(), "object body read failed")), - } - } - - fn into_s3_error(self, _response_content_length: i64) -> S3Error { - ApiError::from(self.into_storage_error()).into() - } -} - -/// Strictly materialize an object body into memory, enforcing an exact-length -/// contract (#1324). -/// -/// Reads at most `expected + 1` bytes so an over-long stream is detected without -/// buffering it unbounded, then requires `bytes_read == expected`. A short read -/// (clean EOF before `expected`), an over-long read, or a mid-stream read error -/// all return an error; only an exact-length read yields the buffer. Because the -/// HTTP response commits to `Content-Length == expected` before the body is -/// produced, this mirrors the streaming path (which already fails a short read -/// with `UnexpectedEof`) and the ODC materialize-fill path, closing the -/// warn-and-serve holes in the encrypted, seek, and cache memory branches. -/// -/// On error the reader has already been (partially) consumed, so callers must -/// propagate the error rather than fall back to streaming the same reader. -async fn strict_materialize_object_body( - reader: R, - expected: usize, - stage: &'static str, -) -> Result, StrictMaterializeError> -where - R: AsyncRead + Unpin, -{ - // Stop filling before the Vec reaches capacity. Calling `read_to_end` on a - // bounded reader can still reserve beyond `expected` before observing EOF. - // The over-long probe below stays outside this Vec so the admitted body - // allocation remains exactly `expected` bytes. - let mut buf = Vec::with_capacity(expected); - let mut reader = reader; - let read_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let read_result = loop { - if buf.len() == expected { - break Ok(()); - } - match tokio::io::AsyncReadExt::read_buf(&mut reader, &mut buf).await { - Ok(0) => break Ok(()), - Ok(_) => {} - Err(source) => break Err(source), - } - }; - let actual = buf.len(); - let probe_result = if read_result.is_ok() && actual == expected { - let mut probe = [0_u8; 1]; - tokio::io::AsyncReadExt::read(&mut reader, &mut probe).await - } else { - Ok(0) - }; - record_get_object_s3_handler_stage_duration(stage, read_start); - match (read_result, probe_result) { - (Ok(_), Ok(extra)) => { - let actual = actual.saturating_add(extra); - if actual == expected { - Ok(buf) - } else { - Err(StrictMaterializeError::LengthMismatch { expected, actual }) - } - } - (Err(source), _) | (_, Err(source)) => Err(StrictMaterializeError::Read { - consumed: actual, - source, - }), - } -} - -struct ColdFillProducerExecution { - expected: usize, - deadline: Option, - adapter: Arc, - engine_plan: rustfs_object_data_cache::ObjectDataCacheGetPlan, -} - -enum ColdFillStartupWaitError { - Cancelled, - DeadlineExceeded, -} - -async fn await_cold_fill_startup( - future: F, - cancellation: &tokio_util::sync::CancellationToken, - deadline: Option, -) -> Result -where - F: Future, -{ - tokio::pin!(future); - match deadline { - Some(deadline) => { - tokio::select! { - biased; - _ = cancellation.cancelled() => Err(ColdFillStartupWaitError::Cancelled), - result = tokio::time::timeout_at(deadline, &mut future) => { - result.map_err(|_| ColdFillStartupWaitError::DeadlineExceeded) - } - } - } - None => { - tokio::select! { - biased; - _ = cancellation.cancelled() => Err(ColdFillStartupWaitError::Cancelled), - result = &mut future => Ok(result), - } - } - } -} - -async fn start_cold_fill_producer( - producer: ColdFillProducer, - reservation: Option, - acquire_io: AcquireIo, - open_reader: OpenReader, - execution: ColdFillProducerExecution, -) where - AcquireIo: FnOnce() -> AcquireIoFuture, - AcquireIoFuture: Future>, - OpenReader: FnOnce() -> OpenReaderFuture, - OpenReaderFuture: Future>, -{ - let ColdFillProducerExecution { - expected, - deadline, - adapter, - engine_plan, - } = execution; - let hard_deadline = tokio::time::Instant::now() + COLD_FILL_HARD_MAX_DURATION; - let deadline = deadline.map_or(hard_deadline, |request_deadline| request_deadline.min(hard_deadline)); - let cancellation = producer.cancellation_token(); - let Some(reservation) = reservation else { - producer.bypass(); - return; - }; - let acquire = acquire_io(); - tokio::pin!(acquire); - let producer_io = tokio::select! { - _ = cancellation.cancelled() => { - producer.finish(Err(StorageError::OperationCanceled)); - return; - } - result = tokio::time::timeout_at(deadline, &mut acquire) => match result { - Ok(result) => result, - Err(_) => { - producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout)); - return; - } - } - }; - let producer_io = match producer_io { - Ok(io) => io, - Err(err) => { - producer.relinquish_or_finish(err); - return; - } - }; - - let open = open_reader(); - tokio::pin!(open); - let reader = match tokio::select! { - _ = cancellation.cancelled() => Err(StorageError::OperationCanceled), - result = tokio::time::timeout_at(deadline, &mut open) => { - result.unwrap_or(Err(StorageError::Timeout)) - } - } { - Ok(reader) => reader, - Err(err) => { - producer.relinquish_or_finish(ColdFillError::Storage(err)); - return; - } - }; - producer.mark_reader_started(); - let materialize = async move { - let GetObjectReader { - stream, buffered_body, .. - } = reader; - let body = if let Some(body) = buffered_body { - if body.len() == expected { - body - } else { - return Err(StorageError::other(format!( - "cold-fill buffered body length mismatch: expected {expected}, got {}", - body.len() - ))); - } - } else { - let stream = if let Some(permit) = producer_io.disk_permit { - wrap_reader(DiskReadPermitReader::new(stream, permit)) - } else { - stream - }; - Bytes::from( - strict_materialize_object_body(stream, expected, GET_OBJECT_STAGE_BODY_CACHE_MATERIALIZE_READ) - .await - .map_err(StrictMaterializeError::into_storage_error)?, - ) - }; - Ok::<_, StorageError>((body, reservation)) - }; - let materialized = tokio::select! { - _ = cancellation.cancelled() => Err(StorageError::OperationCanceled), - result = tokio::time::timeout_at(deadline, materialize) => { - result.unwrap_or(Err(StorageError::Timeout)) - } - }; - let result = match materialized { - Ok((body, reservation)) => { - if cancellation.is_cancelled() { - producer.finish(Err(StorageError::OperationCanceled)); - return; - } - if deadline <= tokio::time::Instant::now() { - producer.finish(Err(StorageError::Timeout)); - return; - } - let reserved = reservation.wrap_bytes(body); - let shared = reserved.bytes(); - let publish = async { - #[cfg(test)] - wait_cold_fill_publication_barrier(&engine_plan).await; - adapter.fill_reserved_body(&engine_plan, reserved).await - }; - tokio::pin!(publish); - tokio::select! { - _ = cancellation.cancelled() => Err(StorageError::OperationCanceled), - _ = tokio::time::sleep_until(deadline) => { - Err(StorageError::Timeout) - } - _ = &mut publish => Ok(shared), - } - } - Err(err) => Err(err), - }; - producer.finish(result); -} - -fn cold_fill_deadline( - wrapper: &RequestTimeoutWrapper, - timeout_config: &GetObjectTimeoutPolicy, - response_size: u64, -) -> Option { - if !timeout_config.is_timeout_enabled() { - return None; - } - Some(tokio::time::Instant::now() + wrapper.remaining_time_for_size(Some(response_size)).unwrap_or(Duration::ZERO)) -} - -fn cold_fill_producer_deadline(timeout_config: &GetObjectTimeoutPolicy, response_size: u64) -> tokio::time::Instant { - let now = tokio::time::Instant::now(); - let hard_deadline = now + COLD_FILL_HARD_MAX_DURATION; - if timeout_config.is_timeout_enabled() { - (now + timeout_config.calculate_timeout_for_size(response_size)).min(hard_deadline) - } else { - hard_deadline - } -} - -async fn lookup_cold_fill_second_chance( - adapter: &ObjectDataCacheAdapter, - plan: &rustfs_object_data_cache::ObjectDataCacheGetPlan, -) -> Option { - match adapter.peek_body_untracked(plan).await { - rustfs_object_data_cache::ObjectDataCacheLookup::Hit(body) => Some(body), - _ => None, - } -} - -fn retain_cold_fill_producer_for_matching_plan( - producer: ColdFillProducer, - current: &GetObjectBodyCachePlan, - expected: &rustfs_object_data_cache::ObjectDataCacheGetPlan, -) -> Option { - if current == &GetObjectBodyCachePlan::Cacheable(expected.clone()) { - Some(producer) - } else { - producer.bypass(); - None - } -} - -impl futures::Stream for GetObjectReaderStream -where - R: AsyncRead, -{ - type Item = Result; - - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let mut this = self.project(); - if *this.remaining == 0 { - return Poll::Ready(None); - } - - let remaining_before = *this.remaining; - let attribution_enabled = is_get_output_handoff_attribution_enabled(); - let poll_start = attribution_enabled.then(std::time::Instant::now); - let reader = match this.reader.as_mut().as_pin_mut() { - Some(reader) => reader, - None => return Poll::Ready(None), - }; - let read_capacity = (*this.capacity).min(*this.remaining); - let mut buf = BytesMut::with_capacity(read_capacity); - let poll_read = poll_read_buf(reader, cx, &mut buf); - - let result: Poll> = match poll_read { - Poll::Ready(Ok(bytes_read)) if bytes_read > 0 => { - let bytes = buf.freeze(); - *this.remaining -= bytes.len(); - *this.emitted += bytes.len(); - #[cfg(feature = "tracing-chunk-debug")] - { - tracing::debug!( - emitted = *this.emitted, - expected = *this.expected, - chunk_len = bytes.len(), - "GetObject ReaderStream emitted bytes" - ); - } - if bytes.is_empty() { - Poll::Ready(None) - } else { - Poll::Ready(Some(Ok(bytes))) - } - } - Poll::Ready(Ok(_)) => { - this.reader.set(None); - let remaining = i64::try_from(*this.remaining).unwrap_or(i64::MAX); - let err = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, rustfs_rio::IncompleteBody { remaining }); - record_get_object_reader_stream_failure( - GET_STREAMING_BODY_FAILURE_REASON_SHORT_EOF, - "short_eof", - this.strategy, - this.buffer_source, - *this.expected, - *this.emitted, - *this.remaining, - ); - // The inner GetObjectStreamingReader is what normally reports a - // short body, so reaching this arm means the reader signalled a - // clean EOF while this layer still owed bytes against an - // already-committed Content-Length. That disagreement is a data - // plane fault, not chunk noise: log it unconditionally so the - // truncated object is named in the operator's log rather than - // only in a metric counter (issue #4784). - error!( - event = EVENT_GET_OBJECT_STREAM_BODY, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - bucket = %this.diagnostics.bucket, - object = %this.diagnostics.object, - request_id = %this.diagnostics.request_id, - size_bucket = get_object_stream_size_bucket(*this.expected), - expected = *this.expected, - emitted = *this.emitted, - remaining = *this.remaining, - strategy = this.strategy, - buffer_source = this.buffer_source, - state = "reader_stream_short_eof", - error = %err, - "GetObject reader stream ended before the committed content length" - ); - Poll::Ready(Some(Err(Box::new(err) as S3StdError))) - } - Poll::Ready(Err(err)) => { - this.reader.set(None); - let error_class = classify_get_object_stream_read_error(&err); - record_get_object_reader_stream_failure( - get_object_stream_failure_reason(error_class), - error_class, - this.strategy, - this.buffer_source, - *this.expected, - *this.emitted, - *this.remaining, - ); - // Deliberately not logged at warn here: every production body - // wraps a GetObjectStreamingReader, and that layer already - // reports this same error once with `state = "read_failed"` and - // the object identity. A second unconditional line per failed - // GET would read as two distinct faults. The chunk-debug build - // still gets this layer's view of the same error. - #[cfg(feature = "tracing-chunk-debug")] - tracing::error!( - emitted = *this.emitted, - expected = *this.expected, - error_class = error_class, - error = %err, - "GetObject ReaderStream returned error" - ); - Poll::Ready(Some(Err(Box::new(err) as S3StdError))) - } - Poll::Pending => Poll::Pending, - }; - - let emitted_bytes = match &result { - Poll::Ready(Some(Ok(bytes))) => bytes.len(), - _ => 0, - }; - let outcome = match &result { - Poll::Ready(Some(Ok(bytes))) if !bytes.is_empty() => GET_READER_STREAM_POLL_READY_DATA, - Poll::Ready(Some(Ok(_))) | Poll::Ready(None) => GET_READER_STREAM_POLL_READY_EMPTY, - Poll::Ready(Some(Err(_))) => GET_READER_STREAM_POLL_READY_ERROR, - Poll::Pending => GET_READER_STREAM_POLL_PENDING, - }; - if attribution_enabled { - rustfs_io_metrics::record_get_object_reader_stream_poll( - this.strategy, - this.buffer_source, - outcome, - remaining_before, - emitted_bytes, - poll_start.map_or(0.0, |start| start.elapsed().as_secs_f64()), - ); - } - - result - } - - fn size_hint(&self) -> (usize, Option) { - if self.remaining == 0 || self.reader.is_none() { - (0, Some(0)) - } else { - (1, None) - } - } -} - -impl ByteStream for GetObjectReaderStream -where - R: AsyncRead, -{ - fn remaining_length(&self) -> RemainingLength { - RemainingLength::new_exact(self.remaining) - } -} - -struct GetObjectStreamingReader { - inner: Option, - // bucket/object + request_id + optional content_range are only used for diagnostic - // correlation and failure bucketing; they do not alter stream behavior. The object - // identity is what turns a mid-stream failure into an actionable report: a request_id - // alone cannot tell an operator which object reads short (issue #4784). - bucket: String, - object: String, - request_id: String, - content_range: Option, - expected: usize, - emitted: usize, - timeout: Duration, - timer: Option>>, - started: std::time::Instant, - first_byte_reported: bool, - completed: bool, - lifecycle: GetObjectBodyLifecycle, - resume: Option>, - _foreground_read_guard: rustfs_scanner::ForegroundReadGuard, -} - -impl GetObjectStreamingReader { - #[allow(clippy::too_many_arguments)] - fn new( - inner: R, - bucket: &str, - key: &str, - request_id: &str, - content_range: Option, - expected: usize, - timeout: Duration, - lifecycle: GetObjectBodyLifecycle, - resume: Option>, - ) -> Self { - Self { - inner: Some(inner), - bucket: bucket.to_string(), - object: key.to_string(), - request_id: request_id.to_string(), - content_range, - expected, - emitted: 0, - timeout, - timer: None, - started: std::time::Instant::now(), - first_byte_reported: false, - completed: expected == 0, - lifecycle, - resume, - _foreground_read_guard: rustfs_scanner::ForegroundReadGuard::new(), - } - } - - fn elapsed(&self) -> Duration { - self.started.elapsed() - } - - // Classify transport/read failures before logging so operators can quickly - // distinguish truncated upstream bodies, corruption, quorum issues, and - // genuine downstream-close disconnects. - fn classify_read_error(err: &std::io::Error) -> &'static str { - classify_get_object_stream_read_error(err) - } - - fn finish_ok(&mut self) { - self.completed = true; - self.lifecycle.finish_ok(); - } - - fn finish_err(&mut self) { - self.lifecycle.finish_err(); - } - - fn resume_in_flight(&self) -> bool { - matches!( - self.resume.as_ref().map(|resume| &resume.stage), - Some(GetObjectResumeStage::Backoff | GetObjectResumeStage::Reopening(_)) - ) - } - - fn begin_resume(&mut self, error: std::io::Error) { - let Some(resume) = self.resume.as_mut() else { - return; - }; - self.inner.take(); - resume.begin(error); - } - - // Drive the armed resume flow: backoff ticks gate each reopen attempt, and - // a successful reopen swaps the failed stream out for the replacement. - fn poll_resume(&mut self, cx: &mut Context<'_>) -> GetObjectResumePoll { - let Some(mut resume) = self.resume.take() else { - // resume_in_flight guards every call site. - unreachable!("poll_resume requires an armed resume control"); - }; - let outcome = loop { - let stage = std::mem::replace(&mut resume.stage, GetObjectResumeStage::Idle); - match stage { - GetObjectResumeStage::Idle => unreachable!("resume control is only polled while armed"), - GetObjectResumeStage::Backoff => match Pin::new(&mut resume.timer).poll_next(cx) { - Poll::Ready(Some(())) => { - resume.attempts += 1; - resume.stage = GetObjectResumeStage::Reopening(Mutex::new((resume.reopen)(self.emitted))); - } - Poll::Ready(None) => { - let error = resume.take_trigger_error(); - break GetObjectResumePoll::Failed { - error, - attempts: resume.attempts, - }; - } - Poll::Pending => { - resume.stage = GetObjectResumeStage::Backoff; - break GetObjectResumePoll::Pending; - } - }, - GetObjectResumeStage::Reopening(reopening) => { - let poll = match reopening.try_lock() { - Ok(mut reopening) => reopening.as_mut().poll(cx), - // Only reachable when a poll of the reopen future - // panicked and poisoned the mutex: fail closed with the - // original trigger error instead of polling it again. - Err(_) => { - let error = resume.take_trigger_error(); - break GetObjectResumePoll::Failed { - error, - attempts: resume.attempts, - }; - } - }; - match poll { - Poll::Ready(Ok(reader)) => { - self.inner = Some(reader); - break GetObjectResumePoll::Resumed { - attempts: resume.attempts, - }; - } - Poll::Ready(Err(GetObjectResumeFailure::Retryable)) => { - resume.stage = GetObjectResumeStage::Backoff; - } - Poll::Ready(Err(GetObjectResumeFailure::Fatal)) => { - let error = resume.take_trigger_error(); - break GetObjectResumePoll::Failed { - error, - attempts: resume.attempts, - }; - } - Poll::Pending => { - resume.stage = GetObjectResumeStage::Reopening(reopening); - break GetObjectResumePoll::Pending; - } - } - } - } - }; - if matches!(outcome, GetObjectResumePoll::Resumed { .. } | GetObjectResumePoll::Pending) { - self.resume = Some(resume); - } - outcome - } - - fn poll_stall_timeout(&mut self, cx: &mut Context<'_>) -> Poll> { - if self.timeout.is_zero() { - return Poll::Pending; - } - - if self.timer.is_none() { - self.timer = Some(Box::pin(tokio::time::sleep(self.timeout))); - } - - if let Some(timer) = self.timer.as_mut() - && std::future::Future::poll(timer.as_mut(), cx).is_ready() - { - self.timer = None; - warn!( - event = EVENT_GET_OBJECT_STREAM_BODY, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - bucket = %self.bucket, - object = %self.object, - request_id = %self.request_id, - range = %self.content_range.as_deref().unwrap_or("full"), - size_bucket = get_object_stream_size_bucket(self.expected), - expected = self.expected, - emitted = self.emitted, - elapsed_ms = self.elapsed().as_millis(), - timeout_ms = self.timeout.as_millis(), - state = "stall_timeout", - "GetObject streaming body stalled" - ); - self.finish_err(); - return Poll::Ready(Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - "get object streaming body stall timeout", - ))); - } - - Poll::Pending - } -} - -impl AsyncRead for GetObjectStreamingReader { - fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - let filled_before = buf.filled().len(); - - loop { - // An armed resume owns the reader until it swaps in a reopened - // stream or exhausts its budget; the failed inner stream is never - // polled again. - if self.resume_in_flight() { - match self.poll_resume(cx) { - GetObjectResumePoll::Resumed { attempts } => { - debug!( - event = EVENT_GET_OBJECT_STREAM_BODY, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - bucket = %self.bucket, - object = %self.object, - request_id = %self.request_id, - range = %self.content_range.as_deref().unwrap_or("full"), - size_bucket = get_object_stream_size_bucket(self.expected), - expected = self.expected, - emitted = self.emitted, - resume_attempts = attempts, - state = "resumed", - "GetObject streaming body resumed from a reopened object read" - ); - // The replacement stream starts a fresh stall window. - self.timer = None; - continue; - } - GetObjectResumePoll::Pending => return self.poll_stall_timeout(cx), - GetObjectResumePoll::Failed { error, attempts } => { - self.timer = None; - let failure_reason = Self::classify_read_error(&error); - self.finish_err(); - error!( - event = EVENT_GET_OBJECT_STREAM_BODY, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - bucket = %self.bucket, - object = %self.object, - request_id = %self.request_id, - range = %self.content_range.as_deref().unwrap_or("full"), - size_bucket = get_object_stream_size_bucket(self.expected), - expected = self.expected, - emitted = self.emitted, - elapsed_ms = self.elapsed().as_millis(), - state = "read_failed", - failure_reason = failure_reason, - resume_attempts = attempts, - error = %error, - "GetObject streaming body read failed; mid-stream resume did not recover" - ); - return Poll::Ready(Err(error)); - } - } - } - - let Some(inner) = self.inner.as_mut() else { - self.finish_err(); - return Poll::Ready(Err(std::io::Error::other( - "get object streaming reader lost its active read outside resume", - ))); - }; - match Pin::new(inner).poll_read(cx, buf) { - Poll::Ready(Ok(())) => { - self.timer = None; - let produced = buf.filled().len().saturating_sub(filled_before); - if produced > 0 { - self.emitted = self.emitted.saturating_add(produced); - if !self.first_byte_reported { - self.first_byte_reported = true; - let elapsed = self.elapsed(); - rustfs_io_metrics::record_get_object_first_byte_latency( - GET_OBJECT_STAGE_PATH_S3_HANDLER, - elapsed.as_secs_f64(), - ); - if elapsed >= GET_OBJECT_STREAM_WARN_THRESHOLD { - warn!( - event = EVENT_GET_OBJECT_STREAM_BODY, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - bucket = %self.bucket, - object = %self.object, - request_id = %self.request_id, - range = %self.content_range.as_deref().unwrap_or("full"), - size_bucket = get_object_stream_size_bucket(self.expected), - expected = self.expected, - emitted = self.emitted, - elapsed_ms = elapsed.as_millis(), - state = "first_byte_slow", - "GetObject streaming body first byte was slow" - ); - } - } - if self.emitted >= self.expected { - self.completed = true; - self.finish_ok(); - } - return Poll::Ready(Ok(())); - } - - if self.emitted < self.expected { - // The inner reader signalled a clean EOF before delivering the full - // Content-Length. Returning Ok here would hand the client a truncated body - // under a full Content-Length: the peer treats the short body as complete - // (e.g. `mc mirror` writes a short file and considers it done — the - // "incomplete data mirroring" in issue #2955). Surface an error instead so - // the transfer fails loudly and the client retries rather than persisting - // truncated data. - let error = std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - rustfs_rio::IncompleteBody { - remaining: self.expected.saturating_sub(self.emitted) as i64, - }, - ); - // A premature EOF is also how the legacy duplex read path - // surfaces the object data vanishing mid-stream (typed - // errors do not survive that pump), so arm the resume - // flow before failing loudly when one is attached. - if self.resume.is_some() { - self.begin_resume(error); - continue; - } - error!( - event = EVENT_GET_OBJECT_STREAM_BODY, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - bucket = %self.bucket, - object = %self.object, - request_id = %self.request_id, - range = %self.content_range.as_deref().unwrap_or("full"), - size_bucket = get_object_stream_size_bucket(self.expected), - expected = self.expected, - emitted = self.emitted, - elapsed_ms = self.elapsed().as_millis(), - state = "short_eof", - "GetObject streaming body ended before expected length" - ); - self.finish_err(); - return Poll::Ready(Err(error)); - } - - self.completed = true; - self.finish_ok(); - return Poll::Ready(Ok(())); - } - Poll::Ready(Err(err)) => { - // Typed relocation errors (the codec read path delivers them - // in-band) mean rebalance/decommission removed the pinned - // object data mid-stream: reopen and continue instead of - // failing the download. The error is only intercepted before - // the committed body length has been fully delivered. - if self.emitted < self.expected && is_object_relocation_error(&err) && self.resume.is_some() { - self.begin_resume(err); - continue; - } - let failure_reason = Self::classify_read_error(&err); - self.timer = None; - self.finish_err(); - error!( - event = EVENT_GET_OBJECT_STREAM_BODY, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - bucket = %self.bucket, - object = %self.object, - request_id = %self.request_id, - range = %self.content_range.as_deref().unwrap_or("full"), - size_bucket = get_object_stream_size_bucket(self.expected), - expected = self.expected, - emitted = self.emitted, - elapsed_ms = self.elapsed().as_millis(), - state = "read_failed", - failure_reason = failure_reason, - error = %err, - "GetObject streaming body read failed" - ); - return Poll::Ready(Err(err)); - } - Poll::Pending => return self.poll_stall_timeout(cx), - } - } - } -} - -impl Drop for GetObjectStreamingReader { - fn drop(&mut self) { - if self.lifecycle.is_finished() { - return; - } - - if self.expected == 0 || self.completed || self.emitted >= self.expected { - self.finish_ok(); - return; - } - - self.finish_err(); - warn!( - event = EVENT_GET_OBJECT_STREAM_BODY, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - bucket = %self.bucket, - object = %self.object, - request_id = %self.request_id, - range = %self.content_range.as_deref().unwrap_or("full"), - size_bucket = get_object_stream_size_bucket(self.expected), - expected = self.expected, - emitted = self.emitted, - elapsed_ms = self.elapsed().as_millis(), - state = "dropped_incomplete", - "GetObject streaming body dropped before expected length" - ); - } -} - -/// Reopen budget for a single GetObject body. Three attempts against the -/// jittered 200ms/400ms RetryTimer schedule (~600ms worst case) bound the -/// metadata fan-out a storm of relocated downloads can multiply. -const GET_OBJECT_RESUME_MAX_ATTEMPTS: i64 = 3; - -type GetObjectResumeFuture = Pin> + Send>>; -type GetObjectReopen = Box GetObjectResumeFuture + Send + Sync>; - -enum GetObjectResumePoll { - Resumed { attempts: usize }, - Pending, - Failed { error: std::io::Error, attempts: usize }, -} - -/// Why a single resume attempt did not produce a replacement stream. -#[derive(Debug)] -enum GetObjectResumeFailure { - /// Reopen/admission failure that may clear on the next attempt. - Retryable, - /// The reopened object is not the version this response committed to (or - /// admission is permanently unavailable): continuing would splice two - /// versions into one 200 response, so fail with the original error. - Fatal, -} - -enum GetObjectResumeStage { - Idle, - Backoff, - // The store's boxed read futures are Send but not Sync, while the - // streaming body requires the reader to be Sync, so the in-flight reopen - // future is stored behind a mutex. It is only ever locked under `&mut - // self` in `poll_resume`, so the lock never contends. - Reopening(Mutex>), -} - -/// Mid-stream resume machinery for [`GetObjectStreamingReader`]: when the -/// pinned object data vanishes mid-body (rebalance/decommission copies the -/// version elsewhere, then deletes the source), reopen the object at the -/// emitted offset and continue instead of failing the download. -struct GetObjectResumeControl { - reopen: GetObjectReopen, - timer: RetryTimer, - stage: GetObjectResumeStage, - original_error: Option, - attempts: usize, -} - -impl GetObjectResumeControl { - fn new(reopen: GetObjectReopen, timer: RetryTimer) -> Self { - Self { - reopen, - timer, - stage: GetObjectResumeStage::Idle, - original_error: None, - attempts: 0, - } - } - - fn begin(&mut self, error: std::io::Error) { - self.original_error = Some(error); - self.stage = GetObjectResumeStage::Backoff; - } - - // The trigger error is always recorded by `begin`; the fallback is a - // fail-closed internal error, never a fabricated success. - fn take_trigger_error(&mut self) -> std::io::Error { - self.original_error - .take() - .unwrap_or_else(|| std::io::Error::other("get object resume lost its trigger error")) - } -} - -/// Object-version identity captured when the response committed to a body. A -/// resumed read must serve exactly this version; `data_dir` is deliberately -/// excluded because rebalance regenerates it for the same version. -struct GetObjectResumeIdentity { - version_id: Option, - mod_time: Option, - size: i64, - etag: Option, - // The store rewrites a read's `object_info.size` to the per-read delivered - // length for encrypted and compressed objects (readers.rs Encrypted / - // Compressed transforms), so a reopened subrange reports `size - emitted` - // while a plain read reports the range-invariant `oi.size`. The flag only - // chooses the comparison arithmetic; a transform change that no longer - // matches it fails the identity check, which is the closed direction. - range_dependent_size: bool, -} - -impl GetObjectResumeIdentity { - fn matches(&self, info: &ObjectInfo, emitted: usize) -> bool { - let expected_size = if self.range_dependent_size { - self.size - emitted as i64 - } else { - self.size - }; - self.version_id == info.version_id - && self.mod_time == info.mod_time - && expected_size == info.size - && self.etag == info.etag - } -} - -/// Reopen parameters for a mid-stream resume. Only the SSE-C headers the store -/// read path consumes are retained: the store-level `get_object_reader` spans -/// record their header argument at debug level, so retaining the full request -/// headers would re-log credentials on every attempt. -struct GetObjectResumeContext { - store: Arc, - bucket: String, - key: String, - opts: ObjectOptions, - ssec_headers: HeaderMap, - // Resolved plaintext offsets of the committed response body, captured - // after `HTTPRangeSpec::get_offset_length`: suffix ranges and partNumber - // GETs are already resolved to absolute offsets at that point, so the - // resume offset is `range_start + emitted` regardless of request shape. - range_start: i64, - range_end: i64, - identity: GetObjectResumeIdentity, -} - -impl GetObjectResumeContext { - #[allow(clippy::too_many_arguments)] - fn new( - store: Arc, - bucket: &str, - key: &str, - mut opts: ObjectOptions, - request_headers: &HeaderMap, - info: &ObjectInfo, - range_start: i64, - range_end: i64, - ) -> Self { - if opts.version_id.is_none() - && let Some(version_id) = info.version_id - { - opts.version_id = Some(version_id.to_string()); - } - // Store spans record their header argument at debug level. Retain only - // the SSE-C inputs needed to reopen the reader and keep them redacted. - let ssec_headers = project_ssec_transport_headers(request_headers); - Self { - store, - bucket: bucket.to_string(), - key: key.to_string(), - opts, - ssec_headers, - range_start, - range_end, - identity: GetObjectResumeIdentity { - version_id: info.version_id, - mod_time: info.mod_time, - size: info.size, - etag: info.etag.clone(), - range_dependent_size: info.is_encrypted() || info.is_compressed(), - }, - } - } - - fn resume_range(range_start: i64, range_end: i64, emitted: usize) -> Option { - let start = range_start + emitted as i64; - if start == 0 && range_end < 0 { - // Nothing was emitted from a full-object read: reopen without a - // range so the replacement stream keeps the codec fast path - // instead of the duplex fallback a synthesized range forces. - return None; - } - Some(HTTPRangeSpec { - is_suffix_length: false, - start, - end: range_end, - }) - } - - async fn reopen(&self, emitted: usize) -> Result { - #[cfg(test)] - GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.fetch_add(1, Ordering::Relaxed); - - // A resumed read must hold disk-read admission just like the initial - // read; otherwise recovery reads bypass the concurrency caps exactly - // while rebalance is stressing the pool. - let disk_permit = DefaultObjectUsecase::admit_get_object_disk_read(get_concurrency_manager(), &self.bucket, &self.key) - .await - .map_err(|err| { - if err.code() == &S3ErrorCode::SlowDown { - GetObjectResumeFailure::Retryable - } else { - GetObjectResumeFailure::Fatal - } - })?; - let range = Self::resume_range(self.range_start, self.range_end, emitted); - let reader = self - .store - .get_object_reader(&self.bucket, &self.key, range, self.ssec_headers.clone(), &self.opts) - .await - .map_err(|err| { - debug!( - bucket = %self.bucket, - object = %self.key, - error = %err, - "GetObject mid-stream resume reopen failed" - ); - GetObjectResumeFailure::Retryable - })?; - if !self.identity.matches(&reader.object_info, emitted) { - warn!( - bucket = %self.bucket, - object = %self.key, - "GetObject mid-stream resume resolved a different object version; refusing to splice content" - ); - return Err(GetObjectResumeFailure::Fatal); - } - let stream = wrap_reader(reader.stream); - Ok(match disk_permit { - Some(disk_permit) => wrap_reader(DiskReadPermitReader::new(stream, disk_permit)), - None => stream, - }) - } -} - -#[cfg(test)] -static GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST: AtomicUsize = AtomicUsize::new(0); - -fn get_object_resume_control(ctx: GetObjectResumeContext) -> GetObjectResumeControl { - use rand::RngExt as _; - let ctx = Arc::new(ctx); - let reopen: GetObjectReopen = Box::new(move |emitted| { - let ctx = Arc::clone(&ctx); - Box::pin(async move { ctx.reopen(emitted).await }) - }); - GetObjectResumeControl::new( - reopen, - RetryTimer::new( - GET_OBJECT_RESUME_MAX_ATTEMPTS, - DEFAULT_RETRY_UNIT, - DEFAULT_RETRY_CAP, - MAX_JITTER, - rand::rng().random_range(10..=50), - ), - ) -} - -/// Mid-stream errors that mean the pinned object data is gone (rebalance or -/// decommission removed it after copying the version elsewhere). Only typed -/// `StorageError`s qualify; generic I/O errors and string-matched "not enough -/// disks" failures keep the existing fail-loud behavior. -fn is_object_relocation_error(err: &std::io::Error) -> bool { - let Some(inner) = err.get_ref() else { return false }; - match inner.downcast_ref::() { - Some(StorageError::FileNotFound | StorageError::ObjectNotFound(..) | StorageError::InsufficientReadQuorum(..)) => true, - Some(StorageError::Io(source)) => source.kind() == std::io::ErrorKind::NotFound, - _ => false, - } -} - -/// Resolve the S3 request-body inter-chunk read timeout from the environment. -/// -/// Returns `Duration::ZERO` when disabled (`RUSTFS_HTTP_REQUEST_BODY_READ_TIMEOUT=0`), -/// in which case [`guard_put_object_body_read_timeout`] passes the body through -/// untouched. -fn put_object_body_read_timeout() -> Duration { - Duration::from_secs(rustfs_utils::get_env_u64( - rustfs_config::ENV_HTTP_REQUEST_BODY_READ_TIMEOUT, - rustfs_config::DEFAULT_HTTP_REQUEST_BODY_READ_TIMEOUT, - )) -} - -/// A [`ByteStream`] decorator that aborts a request body whose peer stops -/// sending bytes without closing the connection. -/// -/// A well-behaved short body ends with EOF and is rejected promptly by the -/// eager/streaming readers. The failure this guards against is different: a -/// reverse proxy or CDN forwards a *partial* body and then goes silent while -/// holding the connection open, so the inner stream neither yields more bytes -/// nor reports EOF. Without a bound, RustFS would wait forever for bytes that -/// never arrive and the client eventually sees a hang/abort with no server-side -/// explanation (issue #3076). -/// -/// The timer resets on every chunk, so slow-but-progressing uploads are not -/// penalized; it only fires after `timeout` of complete silence. On timeout the -/// stall is logged with the received/expected byte counts and the read fails -/// with an `ErrorKind::TimedOut` error instead of hanging. -/// -/// `remaining_length` and `size_hint` are forwarded from the inner stream so -/// wrapping is transparent to length/content handling downstream. -struct RequestBodyReadTimeout { - inner: DynByteStream, - timeout: Duration, - timer: Option>>, - received: u64, - expected: Option, - bucket: String, - key: String, - request_id: String, - timed_out: bool, -} - -impl Stream for RequestBodyReadTimeout { - type Item = Result; - - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let this = self.get_mut(); - - // Once we have surfaced a stall error, treat the stream as terminated so - // we never poll the abandoned inner stream again. - if this.timed_out { - return Poll::Ready(None); - } - - match Pin::new(&mut this.inner).poll_next(cx) { - Poll::Ready(Some(Ok(chunk))) => { - this.timer = None; - this.received = this.received.saturating_add(chunk.len() as u64); - Poll::Ready(Some(Ok(chunk))) - } - Poll::Ready(other) => { - this.timer = None; - Poll::Ready(other) - } - Poll::Pending => { - if this.timeout.is_zero() { - return Poll::Pending; - } - - if this.timer.is_none() { - this.timer = Some(Box::pin(tokio::time::sleep(this.timeout))); - } - - if let Some(timer) = this.timer.as_mut() - && std::future::Future::poll(timer.as_mut(), cx).is_ready() - { - this.timer = None; - this.timed_out = true; - let expected_display = this.expected.map(|v| v.to_string()).unwrap_or_else(|| "unknown".to_string()); - warn!( - target: "rustfs::app::object_usecase", - event = EVENT_PUT_OBJECT_BODY_READ_STALLED, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - request_id = %this.request_id, - bucket = %this.bucket, - key = %this.key, - received_bytes = this.received, - expected_bytes = %expected_display, - timeout_secs = this.timeout.as_secs(), - state = "stall_timeout", - "PutObject request body read stalled; aborting. A proxy/CDN likely forwarded a partial body without closing the connection." - ); - return Poll::Ready(Some(Err(Box::new(std::io::Error::new( - std::io::ErrorKind::TimedOut, - format!( - "request body read stalled: received {} of {} bytes, no data for {}s", - this.received, - expected_display, - this.timeout.as_secs() - ), - )) as StdError))); - } - - Poll::Pending - } - } - } - - fn size_hint(&self) -> (usize, Option) { - self.inner.size_hint() - } -} - -impl ByteStream for RequestBodyReadTimeout { - fn remaining_length(&self) -> RemainingLength { - self.inner.remaining_length() - } -} - -/// Wrap an incoming request body with [`RequestBodyReadTimeout`] unless the -/// feature is disabled (`timeout == 0`), in which case the body is returned -/// untouched. `remaining_length` is preserved via [`StreamingBlob::new`]. -fn guard_put_object_body_read_timeout( - body: StreamingBlob, - bucket: &str, - key: &str, - request_id: &str, - expected: Option, - timeout: Duration, -) -> StreamingBlob { - if timeout.is_zero() { - return body; - } - - StreamingBlob::new(RequestBodyReadTimeout { - inner: body.into(), - timeout, - timer: None, - received: 0, - expected: expected.and_then(|v| u64::try_from(v).ok()), - bucket: bucket.to_string(), - key: key.to_string(), - request_id: request_id.to_string(), - timed_out: false, - }) -} - -impl ExtractArchiveEtagReader { - fn new(inner: R, etag: Arc>>) -> Self { - Self { - inner, - md5: Md5::new(), - finished: false, - etag, - } - } -} - -impl AsyncRead for ExtractArchiveEtagReader { - fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - let this = self.project(); - let before = buf.filled().len(); - match this.inner.poll_read(cx, buf) { - Poll::Pending => Poll::Pending, - Poll::Ready(Ok(())) => { - let filled = &buf.filled()[before..]; - if !filled.is_empty() { - this.md5.update(filled); - } else if !*this.finished { - *this.finished = true; - if let Ok(mut etag) = this.etag.lock() { - *etag = Some(hex_simd::encode_to_string(this.md5.clone().finalize(), hex_simd::AsciiCase::Lower)); - } - } - Poll::Ready(Ok(())) - } - Poll::Ready(Err(err)) => Poll::Ready(Err(err)), - } - } -} - -struct PooledBufferReader { - buffer: PooledBuffer, - len: usize, - pos: usize, -} - -impl PooledBufferReader { - fn new(buffer: PooledBuffer, len: usize) -> Self { - Self { buffer, len, pos: 0 } - } -} - -impl AsyncRead for PooledBufferReader { - fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - if self.pos >= self.len { - return Poll::Ready(Ok(())); - } - - let remaining = self.len - self.pos; - let to_read = remaining.min(buf.remaining()); - buf.put_slice(&self.buffer[self.pos..self.pos + to_read]); - self.pos += to_read; - - Poll::Ready(Ok(())) - } -} - -struct ChunkedBytesReader { - chunks: Vec, - chunk_index: usize, - chunk_offset: usize, -} - -impl ChunkedBytesReader { - fn new(chunks: Vec) -> Self { - Self { - chunks, - chunk_index: 0, - chunk_offset: 0, - } - } -} - -impl AsyncRead for ChunkedBytesReader { - fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - while self.chunk_index < self.chunks.len() { - let chunk = &self.chunks[self.chunk_index]; - if self.chunk_offset >= chunk.len() { - self.chunk_index += 1; - self.chunk_offset = 0; - continue; - } - - let remaining = &chunk[self.chunk_offset..]; - let to_read = remaining.len().min(buf.remaining()); - buf.put_slice(&remaining[..to_read]); - self.chunk_offset += to_read; - return Poll::Ready(Ok(())); - } - - Poll::Ready(Ok(())) - } -} - -/// Determine if zero-copy write should be used for this PutObject operation. -/// -/// Zero-copy is beneficial for large objects without encryption or compression. -/// -/// # Arguments -/// -/// * `size` - Object size in bytes -/// * `headers` - HTTP headers (to check for encryption/compression) -/// -/// # Returns -/// -/// `true` if zero-copy should be used, `false` otherwise -fn should_use_zero_copy(size: i64, headers: &HeaderMap) -> bool { - // Only use zero-copy for objects larger than 1MB - const ZERO_COPY_MIN_SIZE: i64 = 1024 * 1024; - - if size <= ZERO_COPY_MIN_SIZE { - return false; - } - - // Don't use zero-copy if encryption is requested - if headers.get(AMZ_SERVER_SIDE_ENCRYPTION).is_some() - || headers.get(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM).is_some() - || headers.get(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID).is_some() - { - return false; - } - - // Don't use zero-copy if compression is likely (compressible content types) - // The compression check happens later in the flow - if let Some(content_type) = headers.get(CONTENT_TYPE) - && let Ok(ct) = content_type.to_str() - { - // Skip zero-copy for easily compressible content types - // since compression will be applied - let compressible_types = [ - "text/plain", - "text/html", - "text/css", - "text/javascript", - "application/javascript", - "application/json", - "application/xml", - "text/xml", - ]; - for ct_type in compressible_types { - if ct.contains(ct_type) { - return false; - } - } - } - - true -} - -#[cfg(test)] -fn should_use_zero_copy_eager_put_path( - size: i64, - headers: &HeaderMap, - server_side_encryption_requested: bool, - should_compress: bool, - is_extract: bool, -) -> bool { - zero_copy_eager_put_path_status(size, headers, server_side_encryption_requested, should_compress, is_extract) - == PUT_EAGER_STATUS_ELIGIBLE -} - -fn zero_copy_eager_put_path_status( - size: i64, - headers: &HeaderMap, - server_side_encryption_requested: bool, - should_compress: bool, - is_extract: bool, -) -> &'static str { - zero_copy_eager_put_path_status_with_max_size( - size, - headers, - server_side_encryption_requested, - should_compress, - is_extract, - zero_copy_eager_put_max_size_bytes(), - ) -} - -fn zero_copy_eager_put_path_status_with_max_size( - size: i64, - headers: &HeaderMap, - server_side_encryption_requested: bool, - should_compress: bool, - is_extract: bool, - max_size: i64, -) -> &'static str { - if is_extract { - return PUT_EAGER_STATUS_EXTRACT; - } - if should_compress { - return PUT_EAGER_STATUS_COMPRESSED; - } - if server_side_encryption_requested { - return PUT_EAGER_STATUS_ENCRYPTED; - } - - if size <= 0 { - return PUT_EAGER_STATUS_INVALID_SIZE; - } - if size > max_size { - return PUT_EAGER_STATUS_ABOVE_EAGER_MAX; - } - - if !should_use_zero_copy(size, headers) { - return PUT_EAGER_STATUS_ZERO_COPY_INELIGIBLE; - } - - if request_uses_aws_chunked(headers) && decoded_content_length_from_headers(headers).ok().flatten().is_none() { - return PUT_EAGER_STATUS_AWS_CHUNKED_MISSING_DECODED_LENGTH; - } - - PUT_EAGER_STATUS_ELIGIBLE -} - -fn zero_copy_eager_put_max_size_bytes() -> i64 { - let configured = *CACHED_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES.get_or_init(|| { - rustfs_utils::get_env_usize(ENV_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES, DEFAULT_ZERO_COPY_EAGER_PUT_MAX_SIZE_BYTES) - }); - i64::try_from(configured).unwrap_or(i64::MAX) -} - -fn has_put_sse_request_headers(headers: &HeaderMap) -> bool { - headers.get(AMZ_SERVER_SIDE_ENCRYPTION).is_some() - || headers.get(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM).is_some() - || headers.get(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID).is_some() -} - -/// Resolve the effective server-side encryption for a write against the bucket's -/// default encryption configuration. -/// -/// A request-level value always wins; the bucket default only fills a gap, and -/// the unknown-algorithm fallback lives once in [`bucket_default_write_sse`]. -/// -/// `has_explicit_ssec` suppresses the default entirely. Only COPY passes `true` -/// today: its destination may carry SSE-C, which must not also be given managed -/// encryption. PUT and extract pass `false`, matching their current behaviour — -/// see backlog#1826 for the divergence that leaves. -/// -/// Callers layering further overrides (PUT's `ciphertext_passthrough`) apply -/// them to the returned pair. -fn resolve_bucket_default_sse( - bucket_sse_config: Option<&ServerSideEncryptionConfiguration>, - requested_sse: Option, - requested_kms_key_id: Option, - has_explicit_ssec: bool, -) -> (Option, Option) { - let bucket_default = || { - if has_explicit_ssec { - return None; - } - bucket_sse_config - .and_then(|config| config.rules.first()) - .and_then(|rule| rule.apply_server_side_encryption_by_default.as_ref()) - }; - - let effective_sse = requested_sse.or_else(|| bucket_default().map(bucket_default_write_sse)); - let effective_kms_key_id = requested_kms_key_id.or_else(|| bucket_default().and_then(|sse| sse.kms_master_key_id.clone())); - (effective_sse, effective_kms_key_id) -} - -fn should_use_small_eager_put_path( - size: i64, - headers: &HeaderMap, - server_side_encryption_requested: bool, - should_compress: bool, - is_extract: bool, -) -> bool { - const SMALL_EAGER_PUT_MAX_SIZE: i64 = 1024 * 1024; - - if is_extract || should_compress || server_side_encryption_requested { - return false; - } - - if size <= 0 || size > SMALL_EAGER_PUT_MAX_SIZE { - return false; - } - - if has_put_sse_request_headers(headers) { - return false; - } - - if request_uses_aws_chunked(headers) && decoded_content_length_from_headers(headers).ok().flatten().is_none() { - return false; - } - - true -} - -/// Objects at or below this size bypass BytesPool and use direct allocation. -/// This avoids Small-tier Mutex contention under high concurrency for tiny objects -/// where the allocation cost is negligible (≤4KiB memcpy). -const POOL_BYPASS_MAX_SIZE: usize = 4 * 1024; - -async fn read_small_put_body_into(body: &mut R, buf: &mut B, size: usize) -> S3Result<()> -where - R: AsyncRead + Unpin, - B: bytes::BufMut, -{ - let mut filled = 0; - - while filled < size { - let mut remaining = (&mut *buf).limit(size - filled); - let read = tokio::io::AsyncReadExt::read_buf(&mut *body, &mut remaining) - .await - .map_err(ApiError::from)?; - if read == 0 { - return Err(s3_error!(IncompleteBody)); - } - filled += read; - } - - let mut extra = [0u8; 1]; - let extra_read = tokio::io::AsyncReadExt::read(&mut *body, &mut extra) - .await - .map_err(ApiError::from)?; - if extra_read != 0 { - return Err(s3_error!(UnexpectedContent)); - } - - Ok(()) -} - -async fn read_small_put_body_exact_pooled(mut body: R, size: usize, pool: &BytesPool) -> S3Result -where - R: AsyncRead + Unpin, -{ - let mut buf = pool.acquire_buffer(size).await; - read_small_put_body_into(&mut body, &mut *buf, size).await?; - Ok(buf) -} - -/// Read small PUT body into a directly-allocated buffer, bypassing BytesPool. -/// Used for objects ≤4KiB where pool contention under high concurrency -/// outweighs the allocation cost. -async fn read_small_put_body_exact_direct(mut body: R, size: usize) -> S3Result>> -where - R: AsyncRead + Unpin, -{ - let mut buf = Vec::with_capacity(size); - read_small_put_body_into(&mut body, &mut buf, size).await?; - Ok(std::io::Cursor::new(buf)) -} - -async fn read_zero_copy_put_body_exact(mut body: S, size: usize) -> S3Result -where - S: futures::Stream> + Unpin, - E: Into, -{ - let mut chunks = Vec::new(); - let mut filled = 0usize; - - while filled < size { - let Some(chunk) = body.next().await else { - return Err(s3_error!(IncompleteBody)); - }; - let chunk = chunk.map_err(|err| ApiError::from(s3s_body_error_to_io(err.into())))?; - if chunk.is_empty() { - continue; - } - if filled.saturating_add(chunk.len()) > size { - return Err(s3_error!(UnexpectedContent)); - } - - rustfs_io_metrics::record_zero_copy_buffer_operation("put_chunk", chunk.len()); - filled += chunk.len(); - chunks.push(chunk); - } - - while let Some(chunk) = body.next().await { - let chunk = chunk.map_err(|err| ApiError::from(s3s_body_error_to_io(err.into())))?; - if !chunk.is_empty() { - return Err(s3_error!(UnexpectedContent)); - } - } - - Ok(ChunkedBytesReader::new(chunks)) -} - -pub(crate) fn object_seek_support_threshold() -> usize { - static OBJECT_SEEK_SUPPORT_THRESHOLD: OnceLock = OnceLock::new(); - *OBJECT_SEEK_SUPPORT_THRESHOLD.get_or_init(|| { - rustfs_utils::get_env_usize( - rustfs_config::ENV_OBJECT_SEEK_SUPPORT_THRESHOLD, - rustfs_config::DEFAULT_OBJECT_SEEK_SUPPORT_THRESHOLD, - ) - }) -} - -fn object_seek_support_concurrency_thresholds() -> (usize, usize) { - static OBJECT_SEEK_SUPPORT_CONCURRENCY_THRESHOLDS: OnceLock<(usize, usize)> = OnceLock::new(); - *OBJECT_SEEK_SUPPORT_CONCURRENCY_THRESHOLDS.get_or_init(|| { - let medium = rustfs_utils::get_env_usize( - rustfs_config::ENV_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD, - rustfs_config::DEFAULT_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD, - ) - .max(1); - let high = rustfs_utils::get_env_usize( - rustfs_config::ENV_OBJECT_HIGH_CONCURRENCY_THRESHOLD, - rustfs_config::DEFAULT_OBJECT_HIGH_CONCURRENCY_THRESHOLD, - ) - .max(medium + 1); - (medium, high) - }) -} - -fn concurrency_aware_seek_support_threshold(configured_threshold: i64, concurrent_requests: usize) -> i64 { - let (medium_threshold, high_threshold) = object_seek_support_concurrency_thresholds(); - let effective_threshold = configured_threshold.min(MAX_GET_OBJECT_MEMORY_BUFFER_BYTES); - - if concurrent_requests >= high_threshold.saturating_mul(2) { - return effective_threshold.min(VERY_HIGH_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES); - } - if concurrent_requests >= high_threshold { - return effective_threshold.min(HIGH_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES); - } - if concurrent_requests >= medium_threshold { - return effective_threshold.min(MEDIUM_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES); - } - - effective_threshold -} - -fn should_buffer_get_object_in_memory( - info: &ObjectInfo, - response_content_length: i64, - part_number: Option, - has_range: bool, - concurrent_requests: usize, -) -> bool { - let configured_threshold = object_seek_support_threshold() as i64; - should_buffer_get_object_in_memory_with_threshold( - info, - response_content_length, - part_number, - has_range, - configured_threshold, - concurrent_requests, - is_get_seek_buffer_enabled(), - ) -} - -fn should_materialize_get_object_body_for_cache( - info: &ObjectInfo, - response_content_length: i64, - part_number: Option, - has_range: bool, - concurrent_requests: usize, -) -> bool { - let configured_threshold = object_seek_support_threshold() as i64; - should_buffer_get_object_in_memory_with_threshold( - info, - response_content_length, - part_number, - has_range, - configured_threshold, - concurrent_requests, - true, - ) -} - -fn should_buffer_get_object_in_memory_with_threshold( - _info: &ObjectInfo, - response_content_length: i64, - part_number: Option, - has_range: bool, - configured_threshold: i64, - concurrent_requests: usize, - seek_buffer_enabled: bool, -) -> bool { - if !seek_buffer_enabled || part_number.is_some() || has_range || response_content_length <= 0 || configured_threshold <= 0 { - return false; - } - if usize::try_from(response_content_length).is_err() { - return false; - } - - let effective_threshold = concurrency_aware_seek_support_threshold(configured_threshold, concurrent_requests); - if configured_threshold > MAX_GET_OBJECT_MEMORY_BUFFER_BYTES - && GET_OBJECT_BUFFER_THRESHOLD_WARNED - .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - warn!( - configured_threshold_bytes = configured_threshold, - hard_limit_bytes = MAX_GET_OBJECT_MEMORY_BUFFER_BYTES, - "RUSTFS_OBJECT_SEEK_SUPPORT_THRESHOLD exceeds safety cap; using capped in-memory buffer threshold" - ); - } - - if response_content_length > effective_threshold { - return false; - } - - true -} - -#[cfg(test)] -mod deadlock_request_guard_tests { - use super::DeadlockRequestGuard; - use crate::app::storage_api::object_usecase::deadlock_detector::{DeadlockDetector, RequestHangDetectionPolicy}; - use std::cell::Cell; - use std::rc::Rc; - use std::sync::Arc; - - #[test] - fn deadlock_request_guard_unregisters_on_drop() { - let detector = Arc::new(DeadlockDetector::new(RequestHangDetectionPolicy { - enabled: true, - ..RequestHangDetectionPolicy::default() - })); - let request_id = "test-request-id".to_string(); - - detector.register_request(&request_id, "test request"); - assert_eq!(detector.tracked_count(), 1); - - { - let _guard = DeadlockRequestGuard::new(Arc::clone(&detector), request_id); - // `_guard` is dropped at the end of this scope, which should unregister the request. - } - - assert_eq!(detector.tracked_count(), 0); - } - - #[test] - fn deadlock_request_guard_skips_disabled_detector() { - let detector = Arc::new(DeadlockDetector::new(RequestHangDetectionPolicy { - enabled: false, - ..RequestHangDetectionPolicy::default() - })); - let description_built = Rc::new(Cell::new(false)); - let description_built_for_closure = Rc::clone(&description_built); - - let guard = DeadlockRequestGuard::register_if_enabled(detector, "test-request-id", || { - description_built_for_closure.set(true); - "test request".to_string() - }); - - assert!(guard.is_none()); - assert!(!description_built.get()); - } -} - -async fn maybe_enqueue_transition_immediate(obj_info: &ObjectInfo, src: LcEventSrc) { - enqueue_transition_immediate(obj_info, src).await; -} - -/// Inject additional-checksum response headers (XXHash3/64/128, SHA-512) that s3s -/// cannot carry on its typed `*Output` structs. Centralized so that when s3s gains -/// typed fields for these algorithms, only this one function changes (fill the typed -/// field, drop the header insert) — and there is exactly one place that could ever -/// emit a duplicate header. Header names come from `ChecksumType::key()`, so they are -/// known-valid static strings. -pub(crate) fn inject_additional_checksum_headers(headers: &mut HeaderMap, pairs: &[(&'static str, String)]) { - for (name, value) in pairs { - match HeaderValue::from_str(value) { - Ok(header_value) => { - headers.insert(http::HeaderName::from_static(name), header_value); - } - Err(_) => warn!("Failed to parse {name} checksum header value; skipping"), - } - } -} - -fn inject_accept_ranges_header(headers: &mut HeaderMap) { - headers.insert(http::header::ACCEPT_RANGES, HeaderValue::from_static(ACCEPT_RANGES_BYTES)); -} - -/// Derive the response-header echo pairs for an additional-checksum algorithm -/// (XXHash3/64/128, SHA-512) from the server-computed content checksum, for -/// PutObject to echo back (#1256). Returns empty for the five s3s-typed algorithms -/// (they are echoed via typed fields) and when the value is not yet materialized -/// (e.g. a trailing checksum, whose value lands after the body — covered by e2e). -pub(crate) fn additional_checksum_echo_pairs(want: &Option) -> Vec<(&'static str, String)> { - let mut out = Vec::new(); - if let Some(cs) = want - && !cs.checksum_type.is_s3s_typed() - && !cs.encoded.is_empty() - && let Some(name) = cs.checksum_type.key() - { - out.push((name, cs.encoded.clone())); - } - out -} - -/// Extract trailing-header checksum values, overriding the corresponding input fields. -fn apply_trailing_checksums( - algorithm: Option<&str>, - trailing_headers: &Option, - checksums: &mut PutObjectChecksums, -) { - let Some(alg) = algorithm else { return }; - let Some(checksum_str) = trailing_headers.as_ref().and_then(|trailer| { - let key = match alg { - ChecksumAlgorithm::CRC32 => rustfs_rio::ChecksumType::CRC32.key(), - ChecksumAlgorithm::CRC32C => rustfs_rio::ChecksumType::CRC32C.key(), - ChecksumAlgorithm::SHA1 => rustfs_rio::ChecksumType::SHA1.key(), - ChecksumAlgorithm::SHA256 => rustfs_rio::ChecksumType::SHA256.key(), - ChecksumAlgorithm::CRC64NVME => rustfs_rio::ChecksumType::CRC64_NVME.key(), - _ => return None, - }; - trailer.read(|headers| { - headers - .get(key.unwrap_or_default()) - .and_then(|value| value.to_str().ok().map(|s| s.to_string())) - }) - }) else { - return; - }; - - match alg { - ChecksumAlgorithm::CRC32 => checksums.crc32 = checksum_str, - ChecksumAlgorithm::CRC32C => checksums.crc32c = checksum_str, - ChecksumAlgorithm::SHA1 => checksums.sha1 = checksum_str, - ChecksumAlgorithm::SHA256 => checksums.sha256 = checksum_str, - ChecksumAlgorithm::CRC64NVME => checksums.crc64nvme = checksum_str, - _ => (), - } -} - -/// Checksums resolved from stored (decrypted) metadata for a response. The five -/// legacy algorithms fill named fields; the additional algorithms land in `extra` -/// for raw-header response paths and DTOs that expose their newer typed fields. -#[derive(Default)] -pub(crate) struct ResponseChecksums { - pub(crate) crc32: Option, - pub(crate) crc32c: Option, - pub(crate) sha1: Option, - pub(crate) sha256: Option, - pub(crate) crc64nvme: Option, - pub(crate) checksum_type: Option, - pub(crate) extra: Vec<(&'static str, String)>, -} - -/// Split decrypted checksum pairs into the five legacy fields and the additional -/// algorithm values. Single source of truth for every response -/// path (GetObject / HeadObject / GetObjectAttributes / CompleteMultipartUpload), -/// replacing what used to be five copies of this match loop. -pub(crate) fn classify_response_checksums(pairs: I, is_multipart: bool) -> ResponseChecksums -where - I: IntoIterator, -{ - let mut c = ResponseChecksums::default(); - for (key, checksum) in pairs { - if key == AMZ_CHECKSUM_TYPE { - c.checksum_type = Some(ChecksumType::from(checksum)); - continue; - } - let ct = rustfs_rio::ChecksumType::from_string(key.as_str()); - match ct.base() { - rustfs_rio::ChecksumType::CRC32 => c.crc32 = Some(checksum), - rustfs_rio::ChecksumType::CRC32C => c.crc32c = Some(checksum), - rustfs_rio::ChecksumType::SHA1 => c.sha1 = Some(checksum), - rustfs_rio::ChecksumType::SHA256 => c.sha256 = Some(checksum), - rustfs_rio::ChecksumType::CRC64_NVME => c.crc64nvme = Some(checksum), - _ => { - if let Some(name) = ct.key() { - c.extra.push((name, checksum)); - } - } - } - } - if is_multipart && c.checksum_type.is_none() { - c.checksum_type = Some(ChecksumType::from("COMPOSITE".to_string())); - } - c -} - -#[derive(Default)] -struct PutObjectChecksums { - crc32: Option, - crc32c: Option, - sha1: Option, - sha256: Option, - crc64nvme: Option, -} - -struct PutObjectCommitResult { - obj_info: ObjectInfo, - put_versioned: bool, -} - -struct EagerPutCommitOwner { - task: Option>, - cancellation: tokio_util::sync::CancellationToken, - cancellation_grace: Duration, -} - -impl EagerPutCommitOwner { - fn new( - task: tokio::task::JoinHandle, - cancellation: tokio_util::sync::CancellationToken, - cancellation_grace: Duration, - ) -> Self { - Self { - task: Some(task), - cancellation, - cancellation_grace, - } - } - - async fn join(mut self) -> Result { - let result = self.task.as_mut().expect("eager PUT commit owner task must be present").await; - self.task = None; - result - } -} - -impl Drop for EagerPutCommitOwner { - fn drop(&mut self) { - let Some(mut task) = self.task.take() else { - return; - }; - if tokio::runtime::Handle::try_current().is_err() { - task.abort(); - return; - } - let cancellation = self.cancellation.clone(); - let cancellation_grace = self.cancellation_grace; - spawn_traced(async move { - if tokio::time::timeout(cancellation_grace, &mut task).await.is_err() { - cancellation.cancel(); - metrics::counter!("rustfs_put_commit_owner_deadline_total", "put_path" => "eager").increment(1); - warn!( - target: "rustfs::app::object_usecase", - event = EVENT_PUT_OBJECT_COMMIT_OWNER_DEADLINE, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - state = "cancellation_requested", - cancellation_grace_ms = cancellation_grace.as_millis() as u64, - "cancelled eager PutObject commit owner exceeded its grace period and requested storage cleanup" - ); - let _ = task.await; - } - }); - } -} - -fn successful_delete_audit_objects( - delete: &s3s::dto::Delete, - successful_results: impl IntoIterator, -) -> Vec { - delete - .objects - .iter() - .zip(successful_results) - .filter(|(_, successful)| *successful) - .map(|(requested, _)| AuditObjectVersion::new(requested.key.clone(), requested.version_id.clone())) - .collect() -} - -fn normalize_delete_objects_version_id( - version_id: Option, -) -> std::result::Result<(Option, Option), String> { - let version_id = version_id.map(|v| v.trim().to_string()).filter(|v| !v.is_empty()); - match version_id { - Some(id) => { - if id.eq_ignore_ascii_case("null") { - Ok((Some("null".to_string()), Some(Uuid::nil()))) - } else { - let uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?; - Ok((Some(id), Some(uuid))) - } - } - None => Ok((None, None)), - } -} - -#[cfg(test)] -type DeleteSnapshotTestHook = (String, Arc, Arc); -#[cfg(test)] -type PutPostStoreTestHook = (String, Arc, Arc); - -#[cfg(test)] -static DELETE_SNAPSHOT_TEST_HOOK: OnceLock>> = OnceLock::new(); -#[cfg(test)] -static DELETE_SOURCE_TEST_HOOK: OnceLock>> = OnceLock::new(); -#[cfg(test)] -static DELETE_OBJECTS_AUTH_TEST_HOOK: OnceLock>> = OnceLock::new(); -#[cfg(test)] -static PUT_POST_STORE_TEST_HOOK: OnceLock>> = OnceLock::new(); - -#[cfg(test)] -pub(crate) fn install_delete_snapshot_test_hook( - bucket: String, - loaded: Arc, - resume: Arc, -) { - *DELETE_SNAPSHOT_TEST_HOOK - .get_or_init(|| Mutex::new(None)) - .lock() - .expect("delete snapshot test hook lock should not be poisoned") = Some((bucket, loaded, resume)); -} - -#[cfg(test)] -async fn wait_for_delete_snapshot_test_hook(bucket: &str) { - let hook = { - let mut slot = DELETE_SNAPSHOT_TEST_HOOK - .get_or_init(|| Mutex::new(None)) - .lock() - .expect("delete snapshot test hook lock should not be poisoned"); - if slot.as_ref().is_some_and(|(expected_bucket, _, _)| expected_bucket == bucket) { - slot.take() - } else { - None - } - }; - if let Some((_bucket, loaded, resume)) = hook { - loaded.wait().await; - resume.wait().await; - } -} - -#[cfg(test)] -pub(crate) fn install_delete_source_test_hook( - bucket: String, - loaded: Arc, - resume: Arc, -) { - *DELETE_SOURCE_TEST_HOOK - .get_or_init(|| Mutex::new(None)) - .lock() - .expect("delete source test hook lock should not be poisoned") = Some((bucket, loaded, resume)); -} - -#[cfg(test)] -async fn wait_for_delete_source_test_hook(bucket: &str) { - let hook = { - let mut slot = DELETE_SOURCE_TEST_HOOK - .get_or_init(|| Mutex::new(None)) - .lock() - .expect("delete source test hook lock should not be poisoned"); - if slot.as_ref().is_some_and(|(expected_bucket, _, _)| expected_bucket == bucket) { - slot.take() - } else { - None - } - }; - if let Some((_bucket, loaded, resume)) = hook { - loaded.wait().await; - resume.wait().await; - } -} - -#[cfg(test)] -pub(crate) fn install_delete_objects_auth_test_hook( - bucket: String, - loaded: Arc, - resume: Arc, -) { - *DELETE_OBJECTS_AUTH_TEST_HOOK - .get_or_init(|| Mutex::new(None)) - .lock() - .expect("delete objects auth test hook lock should not be poisoned") = Some((bucket, loaded, resume)); -} - -#[cfg(test)] -async fn wait_for_delete_objects_auth_test_hook(bucket: &str) { - let hook = { - let mut slot = DELETE_OBJECTS_AUTH_TEST_HOOK - .get_or_init(|| Mutex::new(None)) - .lock() - .expect("delete objects auth test hook lock should not be poisoned"); - if slot.as_ref().is_some_and(|(expected_bucket, _, _)| expected_bucket == bucket) { - slot.take() - } else { - None - } - }; - if let Some((_bucket, loaded, resume)) = hook { - loaded.wait().await; - resume.wait().await; - } -} - -#[cfg(test)] -fn install_put_post_store_test_hook(bucket: String, entered: Arc, resume: Arc) { - *PUT_POST_STORE_TEST_HOOK - .get_or_init(|| Mutex::new(None)) - .lock() - .expect("PUT post-store test hook lock should not be poisoned") = Some((bucket, entered, resume)); -} - -#[cfg(test)] -async fn wait_for_put_post_store_test_hook(bucket: &str) { - let hook = { - let mut slot = PUT_POST_STORE_TEST_HOOK - .get_or_init(|| Mutex::new(None)) - .lock() - .expect("PUT post-store test hook lock should not be poisoned"); - if slot.as_ref().is_some_and(|(expected_bucket, _, _)| expected_bucket == bucket) { - slot.take() - } else { - None - } - }; - if let Some((_bucket, entered, resume)) = hook { - entered.wait().await; - resume.wait().await; - } -} - -fn build_put_object_expiration_header(event: &lifecycle::Event) -> Option { - if !event.action.delete() { - return None; - } - - let expire_time = event.due?; - - if event.rule_id.is_empty() || expire_time == OffsetDateTime::UNIX_EPOCH { - return None; - } - - let expiry_date = expire_time.format(&Rfc3339).ok()?; - Some(format!("expiry-date=\"{}\", rule-id=\"{}\"", expiry_date, event.rule_id)) -} - -fn enrich_delete_replication_state_if_needed( - snapshot: &DeleteReplicationConfigSnapshot, - delete_object: &mut StorageDeletedObject, - obj_info: &ObjectInfo, -) { - let Some(replication_state) = delete_object.replication_state.as_ref() else { - return; - }; - if obj_info.replication_status != ReplicationStatusType::Replica - && !replication_state.replicate_decision_str.is_empty() - && (!replication_state.targets.is_empty() || !replication_state.purge_targets.is_empty()) - { - return; - } - - let Some(config) = snapshot.replication_config() else { - return; - }; - let version_id = if delete_object.delete_marker { - None - } else if delete_object.delete_marker_version_id.is_some() { - delete_object.delete_marker_version_id - } else { - delete_object.version_id - }; - if let Some(local_state) = delete_replication_state_from_config( - config, - obj_info, - version_id, - obj_info.replication_status == ReplicationStatusType::Replica, - ) { - set_deleted_object_replication_state(delete_object, &local_state); - } -} - -fn should_schedule_replica_delete_replication( - snapshot: &DeleteReplicationConfigSnapshot, - replication_source: &ObjectInfo, - version_id: Option, -) -> bool { - let Some(config) = snapshot.replication_config() else { - return false; - }; - - delete_replication_state_from_config(config, replication_source, version_id, true).is_some() -} - -fn internal_object_info_lookup_opts(mut opts: ObjectOptions) -> ObjectOptions { - opts.http_preconditions = None; - opts -} - -fn expected_current_version_id(headers: &HeaderMap) -> S3Result> { - headers - .get(RUSTFS_EXPECTED_CURRENT_VERSION_ID) - .map(|value| { - let value = value - .to_str() - .map(str::trim) - .map_err(|_| s3_error!(InvalidArgument, "Invalid expected current version ID header"))?; - if value.eq_ignore_ascii_case("null") { - return Ok(Uuid::nil().to_string()); - } - Uuid::parse_str(value) - .map(|version| version.to_string()) - .map_err(|_| s3_error!(InvalidArgument, "Invalid expected current version ID header")) - }) - .transpose() -} - -fn validate_undo_delete_version(expected: Option<&str>, requested: Option<&str>) -> S3Result<()> { - if expected.is_some() && expected != requested { - return Err(s3_error!(PreconditionFailed)); - } - Ok(()) -} - -fn copy_namespace_lock_error(bucket: &str, object: &str, mode: &'static str, err: rustfs_lock::LockError) -> StorageError { - match err { - rustfs_lock::LockError::QuorumNotReached { required, achieved } => StorageError::NamespaceLockQuorumUnavailable { - mode, - bucket: bucket.to_owned(), - object: object.to_owned(), - required, - achieved, - }, - other => StorageError::Lock(other), - } -} - -async fn acquire_self_copy_namespace_lock(store: &S, bucket: &str, object: &str) -> S3Result -where - S: NamespaceLocking + ?Sized, -{ - let object = encode_dir_object(object); - let lock = store.new_ns_lock(bucket, &object).await.map_err(ApiError::from)?; - lock.get_write_lock(get_lock_acquire_timeout()) - .await - .map_err(|err| ApiError::from(copy_namespace_lock_error(bucket, &object, "write", err)).into()) -} - -pub(crate) async fn acquire_copy_bucket_lifecycle_lock(store: &S, bucket: &str) -> S3Result -where - S: NamespaceLocking + ?Sized, -{ - let lock = store - .new_ns_lock(bucket, BUCKET_LIFECYCLE_LOCK_OBJECT) - .await - .map_err(ApiError::from)?; - lock.get_read_lock(get_lock_acquire_timeout()).await.map_err(|err| { - ApiError::from(copy_namespace_lock_error( - bucket, - BUCKET_LIFECYCLE_LOCK_OBJECT, - "bucket_lifecycle_read", - err, - )) - .into() - }) -} - -pub(crate) async fn acquire_copy_bucket_lifecycle_locks( - store: &S, - source_bucket: &str, - destination_bucket: &str, -) -> S3Result<(NamespaceLockGuard, Option)> -where - S: NamespaceLocking + ?Sized, -{ - if source_bucket == destination_bucket { - return Ok((acquire_copy_bucket_lifecycle_lock(store, source_bucket).await?, None)); - } - - if source_bucket < destination_bucket { - let source_guard = acquire_copy_bucket_lifecycle_lock(store, source_bucket).await?; - let destination_guard = acquire_copy_bucket_lifecycle_lock(store, destination_bucket).await?; - Ok((source_guard, Some(destination_guard))) - } else { - let destination_guard = acquire_copy_bucket_lifecycle_lock(store, destination_bucket).await?; - let source_guard = acquire_copy_bucket_lifecycle_lock(store, source_bucket).await?; - Ok((source_guard, Some(destination_guard))) - } -} - -const AMZ_SNOWBALL_EXTRACT_COMPAT: &str = "X-Amz-Snowball-Auto-Extract"; -#[cfg(test)] -const AMZ_SNOWBALL_PREFIX_INTERNAL: &str = "X-Amz-Meta-Rustfs-Snowball-Prefix"; -#[cfg(test)] -const AMZ_SNOWBALL_IGNORE_DIRS_INTERNAL: &str = "X-Amz-Meta-Rustfs-Snowball-Ignore-Dirs"; -#[cfg(test)] -const AMZ_SNOWBALL_IGNORE_ERRORS_INTERNAL: &str = "X-Amz-Meta-Rustfs-Snowball-Ignore-Errors"; -const AMZ_META_PREFIX_LOWER: &str = "x-amz-meta-"; -const SNOWBALL_PREFIX_SUFFIX_LOWER: &str = "snowball-prefix"; -const SNOWBALL_IGNORE_DIRS_SUFFIX_LOWER: &str = "snowball-ignore-dirs"; -const SNOWBALL_IGNORE_ERRORS_SUFFIX_LOWER: &str = "snowball-ignore-errors"; -const SNOWBALL_PREFIX_HEADER_KEYS: &[&str] = &[AMZ_MINIO_SNOWBALL_PREFIX, AMZ_SNOWBALL_PREFIX, AMZ_RUSTFS_SNOWBALL_PREFIX]; -const SNOWBALL_IGNORE_DIRS_HEADER_KEYS: &[&str] = &[ - AMZ_MINIO_SNOWBALL_IGNORE_DIRS, - AMZ_SNOWBALL_IGNORE_DIRS, - AMZ_RUSTFS_SNOWBALL_IGNORE_DIRS, -]; -const SNOWBALL_IGNORE_ERRORS_HEADER_KEYS: &[&str] = &[ - AMZ_MINIO_SNOWBALL_IGNORE_ERRORS, - AMZ_SNOWBALL_IGNORE_ERRORS, - AMZ_RUSTFS_SNOWBALL_IGNORE_ERRORS, -]; - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -struct PutObjectExtractOptions { - prefix: Option, - ignore_dirs: bool, - ignore_errors: bool, -} - -fn header_value_is_true(headers: &HeaderMap, key: &str) -> bool { - headers - .get(key) - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.trim().eq_ignore_ascii_case("true")) -} - -fn is_put_object_extract_requested(headers: &HeaderMap) -> bool { - header_value_is_true(headers, AMZ_SNOWBALL_EXTRACT) || header_value_is_true(headers, AMZ_SNOWBALL_EXTRACT_COMPAT) -} - -fn trimmed_header_value(headers: &HeaderMap, key: &str) -> Option { - headers - .get(key) - .and_then(|value| value.to_str().ok()) - .map(|value| value.trim().to_string()) -} - -fn is_exact_snowball_meta_key(key: &str, exact_keys: &[&str]) -> bool { - exact_keys.iter().any(|exact_key| key.eq_ignore_ascii_case(exact_key)) -} - -fn snowball_meta_value_by_suffix(headers: &HeaderMap, suffix_lower: &str, exact_keys: &[&str]) -> Option { - for (name, value) in headers { - let key = name.as_str(); - if key.starts_with(AMZ_META_PREFIX_LOWER) - && key.ends_with(suffix_lower) - && !is_exact_snowball_meta_key(key, exact_keys) - && let Ok(parsed) = value.to_str() - { - return Some(parsed.trim().to_string()); - } - } - - None -} - -fn snowball_meta_value(headers: &HeaderMap, exact_keys: &[&str], suffix_lower: &str) -> Option { - for key in exact_keys { - if let Some(value) = trimmed_header_value(headers, key) { - return Some(value); - } - } - - snowball_meta_value_by_suffix(headers, suffix_lower, exact_keys) -} - -fn snowball_meta_flag(headers: &HeaderMap, exact_keys: &[&str], suffix_lower: &str) -> bool { - snowball_meta_value(headers, exact_keys, suffix_lower).is_some_and(|value| value.eq_ignore_ascii_case("true")) -} - -/// Validates that an archive entry path does not escape the target bucket. -/// -/// Delegates to [`rustfs_utils::path::validate_extract_relative_path`] and wraps -/// the result as an S3 error on failure. -pub fn validate_extract_relative_path(path: &str) -> S3Result<()> { - rustfs_utils::path::validate_extract_relative_path(path).map_err(|msg| s3_error!(InvalidArgument, "{msg}")) -} - -fn normalize_snowball_prefix(prefix: &str) -> S3Result> { - let normalized = prefix.trim().trim_matches('/'); - if normalized.is_empty() { - return Ok(None); - } - - validate_extract_relative_path(normalized)?; - - Ok(Some(normalized.to_string())) -} - -/// Normalizes an archive entry key by applying a prefix, trimming slashes, -/// and ensuring directory entries end with `/`. -/// -/// Delegates to [`rustfs_utils::path::normalize_extract_entry_key`] and wraps -/// the result as an S3 error on failure. -pub fn normalize_extract_entry_key(path: &str, prefix: Option<&str>, is_dir: bool) -> S3Result { - rustfs_utils::path::normalize_extract_entry_key(path, prefix, is_dir).map_err(|msg| s3_error!(InvalidArgument, "{msg}")) -} - -fn map_extract_archive_error(err: impl std::fmt::Display) -> S3Error { - s3_error!(InvalidArgument, "Failed to process archive entry: {}", err) -} - -#[derive(Debug, Default)] -struct ExtractEntryPaxAuthorization { - headers: HeaderMap, - object_lock_legal_hold_status: Option, - object_lock_mode: Option, - object_lock_retain_until_date: Option, -} - -async fn apply_extract_entry_pax_extensions( - entry: &mut tokio_tar::Entry>, - bucket: &str, - object_name: &str, - object_lock_config_state: &metadata_sys::ObjectLockConfigState, - metadata: &mut HashMap, - opts: &mut ObjectOptions, -) -> S3Result -where - R: AsyncRead + Send + Unpin + 'static, -{ - let Some(extensions) = entry.pax_extensions().await.map_err(map_extract_archive_error)? else { - return Ok(ExtractEntryPaxAuthorization::default()); - }; - - let mut pax_headers = HeaderMap::new(); - let mut pax_version_id = None; - for ext in extensions { - let ext = ext.map_err(map_extract_archive_error)?; - let key = ext.key().map_err(map_extract_archive_error)?; - let value = ext.value().map_err(map_extract_archive_error)?; - - if let Some(meta_key) = key.strip_prefix("minio.metadata.") { - if !meta_key.is_empty() { - let name = http::HeaderName::from_bytes(meta_key.as_bytes()) - .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball PAX metadata header"))?; - let header_value = HeaderValue::from_str(value) - .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball PAX metadata value"))?; - preserve_unclassified_user_metadata(metadata, name.as_str(), value); - pax_headers.insert(name, header_value); - } - continue; - } - - if key == "minio.versionId" && !value.is_empty() { - if Uuid::parse_str(value).is_err() { - return Err(s3_error!(InvalidArgument, "Invalid Snowball PAX version ID")); - } - pax_version_id = Some(value.to_string()); - } - } - - let has_replica_status = pax_headers.contains_key(AMZ_BUCKET_REPLICATION_STATUS); - if let Some(value) = pax_headers.get(AMZ_BUCKET_REPLICATION_STATUS) { - let status = value - .to_str() - .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball replication status"))?; - if !status.eq_ignore_ascii_case(ReplicationStatusType::Replica.as_str()) { - return Err(s3_error!(InvalidArgument, "Invalid Snowball replication status")); - } - pax_headers.insert(AMZ_BUCKET_REPLICATION_STATUS, HeaderValue::from_static("REPLICA")); - } - - let authorization_headers = pax_headers.clone(); - - if let Some(value) = pax_headers.remove("x-amz-tagging") { - let value = value - .to_str() - .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball object tagging value"))?; - metadata.insert(AMZ_OBJECT_TAGGING.to_owned(), value.to_owned()); - } - - let object_lock_mode = pax_headers - .remove(AMZ_OBJECT_LOCK_MODE_LOWER) - .map(|value| { - value - .to_str() - .map(|value| ObjectLockMode::from(value.to_string())) - .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball Object Lock mode")) - }) - .transpose()?; - let object_lock_retain_until_date = pax_headers - .remove(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER) - .map(|value| { - let value = value - .to_str() - .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball Object Lock retain-until date"))?; - OffsetDateTime::parse(value, &Rfc3339) - .map(Timestamp::from) - .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball Object Lock retain-until date")) - }) - .transpose()?; - let object_lock_legal_hold_status = pax_headers - .remove(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER) - .map(|value| { - value - .to_str() - .map(|value| ObjectLockLegalHoldStatus::from(value.to_string())) - .map_err(|_| s3_error!(InvalidArgument, "Invalid Snowball Object Lock legal-hold status")) - }) - .transpose()?; - opts.version_id = pax_version_id; - - extract_metadata_from_mime_with_object_name(&pax_headers, metadata, false, Some(object_name)); - if has_replica_status { - metadata.retain(|key, _| !key.eq_ignore_ascii_case(AMZ_BUCKET_REPLICATION_STATUS)); - metadata.insert( - AMZ_BUCKET_REPLICATION_STATUS.to_string(), - ReplicationStatusType::Replica.as_str().to_string(), - ); - } - if let Some(object_lock_metadata) = build_put_like_object_lock_metadata( - bucket, - object_lock_config_state, - object_lock_legal_hold_status.clone(), - object_lock_mode.clone(), - object_lock_retain_until_date.clone(), - )? { - metadata.extend(object_lock_metadata); - } - - Ok(ExtractEntryPaxAuthorization { - headers: authorization_headers, - object_lock_legal_hold_status, - object_lock_mode, - object_lock_retain_until_date, - }) -} - -fn insert_expires_metadata(metadata: &mut HashMap, expires: Option<&Timestamp>) -> S3Result<()> { - if let Some(expires) = expires { - let mut formatted = Vec::new(); - expires - .format(TimestampFormat::HttpDate, &mut formatted) - .map_err(|e| ApiError::from(StorageError::other(format!("Invalid expires timestamp: {e}"))))?; - metadata.insert("expires".to_string(), String::from_utf8_lossy(&formatted).into_owned()); - } - Ok(()) -} - -#[allow(clippy::too_many_arguments)] -fn apply_standard_object_metadata( - metadata: &mut HashMap, - cache_control: Option<&str>, - content_disposition: Option<&str>, - content_encoding: Option<&str>, - content_language: Option<&str>, - content_type: Option<&str>, - expires: Option<&Timestamp>, - website_redirect_location: Option<&str>, -) -> S3Result<()> { - if let Some(cache_control) = cache_control { - metadata.insert("cache-control".to_string(), cache_control.to_string()); - } - if let Some(content_disposition) = content_disposition { - metadata.insert("content-disposition".to_string(), content_disposition.to_string()); - } - if let Some(content_encoding) = content_encoding - && let Some(normalized_content_encoding) = normalize_content_encoding_for_storage(content_encoding) - { - metadata.insert("content-encoding".to_string(), normalized_content_encoding); - } - if let Some(content_language) = content_language { - metadata.insert("content-language".to_string(), content_language.to_string()); - } - if let Some(content_type) = content_type { - metadata.insert("content-type".to_string(), content_type.to_string()); - } - insert_expires_metadata(metadata, expires)?; - if let Some(website_redirect_location) = website_redirect_location { - metadata.insert(AMZ_WEBSITE_REDIRECT_LOCATION.to_string(), website_redirect_location.to_string()); - } - Ok(()) -} - -#[allow(clippy::too_many_arguments)] -fn apply_put_request_metadata( - metadata: &mut HashMap, - headers: &HeaderMap, - object_name: &str, - cache_control: Option, - content_disposition: Option, - content_encoding: Option, - content_language: Option, - content_type: Option, - expires: Option, - website_redirect_location: Option, - tagging: Option, - storage_class: Option, -) -> S3Result<()> { - namespace_reserved_user_metadata(metadata); - apply_standard_object_metadata( - metadata, - cache_control.as_deref(), - content_disposition.as_deref(), - content_encoding.as_deref(), - content_language.as_deref(), - content_type.as_deref(), - expires.as_ref(), - website_redirect_location.as_deref(), - )?; - if let Some(tags) = tagging { - metadata.insert(AMZ_OBJECT_TAGGING.to_owned(), tags); - } - if let Some(storage_class) = storage_class { - metadata.insert(AMZ_STORAGE_CLASS.to_string(), storage_class.as_str().to_string()); - } - - extract_metadata_from_mime_with_object_name(headers, metadata, true, Some(object_name)); - Ok(()) -} - -fn response_storage_class(info: &ObjectInfo, metadata: &HashMap) -> Option { - let stored_class = info - .storage_class - .as_deref() - .or_else(|| metadata.get(AMZ_STORAGE_CLASS).map(String::as_str)); - let transitioned_tier = (info.transitioned_object.status == rustfs_filemeta::TRANSITION_COMPLETE - && !info.transitioned_object.tier.is_empty()) - .then_some(info.transitioned_object.tier.as_str()); - let effective_class = storageclass::effective_class(stored_class, transitioned_tier); - - (effective_class != storageclass::STANDARD).then(|| StorageClass::from(effective_class.to_string())) -} - -fn response_storage_class_for_object_attributes( - info: &ObjectInfo, - metadata: &HashMap, - requested: bool, -) -> Option { - if !requested { - return None; - } - - let stored_class = info - .storage_class - .as_deref() - .or_else(|| metadata.get(AMZ_STORAGE_CLASS).map(String::as_str)); - let transitioned_tier = (info.transitioned_object.status == rustfs_filemeta::TRANSITION_COMPLETE - && !info.transitioned_object.tier.is_empty()) - .then_some(info.transitioned_object.tier.as_str()); - - Some(StorageClass::from( - storageclass::effective_class(stored_class, transitioned_tier).to_string(), - )) -} - -fn apply_put_request_object_lock_opts( - bucket: &str, - object_lock_config_state: &metadata_sys::ObjectLockConfigState, - object_lock_legal_hold_status: Option, - object_lock_mode: Option, - object_lock_retain_until_date: Option, - opts: &mut ObjectOptions, -) -> S3Result<()> { - if let Some(eval_metadata) = build_put_like_object_lock_metadata( - bucket, - object_lock_config_state, - object_lock_legal_hold_status, - object_lock_mode, - object_lock_retain_until_date, - )? { - opts.eval_metadata = Some(eval_metadata); - } - - Ok(()) -} - -// Shared across Object Lock validation paths to keep the client-facing -// InvalidRequest message consistent. -pub(crate) const ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED: &str = - "x-amz-object-lock-retain-until-date and x-amz-object-lock-mode must both be supplied"; - -pub(crate) fn build_put_like_object_lock_metadata( - bucket: &str, - object_lock_config_state: &metadata_sys::ObjectLockConfigState, - object_lock_legal_hold_status: Option, - object_lock_mode: Option, - object_lock_retain_until_date: Option, -) -> S3Result>> { - if object_lock_legal_hold_status.is_none() && object_lock_mode.is_none() && object_lock_retain_until_date.is_none() { - return Ok(None); - } - - let retention = match (object_lock_mode, object_lock_retain_until_date) { - (Some(mode), Some(retain_until_date)) => Some(ObjectLockRetention { - mode: Some(ObjectLockRetentionMode::from(mode.as_str().to_string())), - retain_until_date: Some(retain_until_date), - }), - (Some(_), None) | (None, Some(_)) => { - return Err(S3Error::with_message( - S3ErrorCode::InvalidRequest, - ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED.to_string(), - )); - } - (None, None) => None, - }; - - validate_bucket_object_lock_enabled_state(bucket, object_lock_config_state)?; - - let mut eval_metadata = parse_object_lock_retention(retention)?; - eval_metadata.extend(parse_object_lock_legal_hold( - object_lock_legal_hold_status.map(|status| ObjectLockLegalHold { status: Some(status) }), - )?); - - if eval_metadata.is_empty() { - return Ok(None); - } - - Ok(Some(eval_metadata)) -} - -fn put_like_write_creates_new_version(opts: &ObjectOptions) -> bool { - opts.version_id.is_none() && opts.versioned && !opts.version_suspended -} - -pub(crate) fn validate_existing_object_lock_for_write( - object_lock_config_state: &metadata_sys::ObjectLockConfigState, - existing_obj_info: &ObjectInfo, - opts: &ObjectOptions, -) -> S3Result<()> { - if put_like_write_creates_new_version(opts) { - return Ok(()); - } - // An authorized replication write may replace the locked version only - // when the set layer's commit-lock LWW will judge every locking category, - // judged against the bucket's authoritative lock state (default retention - // included) exactly like the set-layer gate, which re-checks the same - // rule under the lock. A non-authoritative state or malformed lock - // metadata fails closed here. - if opts.replication_request { - let may_pass = replication_write_may_pass_worm_gate(object_lock_config_state, existing_obj_info, opts).map_err(|_| { - S3Error::with_message(S3ErrorCode::AccessDenied, "Object Lock state could not be verified.".to_string()) - })?; - return if may_pass { - Ok(()) - } else { - Err(S3Error::with_message( - S3ErrorCode::AccessDenied, - "Object is locked and the replication write carries no source lock decision for it.".to_string(), - )) - }; - } - - let legal_hold = get_object_legalhold_meta(&existing_obj_info.user_defined); - if legal_hold - .status - .as_ref() - .is_some_and(|status| status.as_str() == ObjectLockLegalHoldStatus::ON) - { - return Err(S3Error::with_message( - S3ErrorCode::AccessDenied, - "Object has a legal hold and cannot be overwritten. Remove the legal hold first.".to_string(), - )); - } - - let retention = get_object_retention_meta(&existing_obj_info.user_defined); - if let Some(mode) = retention.mode.as_ref() - && mode.as_str() == ObjectLockRetentionMode::COMPLIANCE - && is_retention_active(mode.as_str(), retention.retain_until_date.as_ref()) - { - return Err(S3Error::with_message( - S3ErrorCode::AccessDenied, - "Object is under COMPLIANCE retention and cannot be overwritten.".to_string(), - )); - } - - Ok(()) -} - -fn delete_creates_delete_marker(opts: &ObjectOptions) -> bool { - opts.version_id.is_none() && opts.versioned && !opts.version_suspended -} - -fn delete_removes_current_object(opts: &ObjectOptions) -> bool { - delete_request_targets_current( - opts.version_id - .as_deref() - .and_then(|version_id| Uuid::parse_str(version_id).ok()), - ) -} - -fn delete_request_targets_current(version_id: Option) -> bool { - version_id.is_none() || version_id.is_some_and(|version_id| version_id.is_nil()) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum DeleteMemoryUpdate { - DeleteMarker, - Object { size: u64, removed_current_object: bool }, -} - -fn delete_memory_update( - creates_delete_marker: bool, - committed_delete_marker: bool, - requested_current: bool, - accounting_size: Option, - removed_current_object: bool, -) -> Option { - if creates_delete_marker || (committed_delete_marker && requested_current) { - return Some(DeleteMemoryUpdate::DeleteMarker); - } - - (!committed_delete_marker) - .then_some(accounting_size) - .flatten() - .map(|size| DeleteMemoryUpdate::Object { - size, - removed_current_object, - }) -} - -async fn apply_delete_memory_update(bucket: &str, update: Option) { - match update { - Some(DeleteMemoryUpdate::DeleteMarker) => record_bucket_delete_marker_memory(bucket).await, - Some(DeleteMemoryUpdate::Object { - size, - removed_current_object, - }) => record_bucket_object_delete_memory(bucket, size, removed_current_object).await, - None => {} - } -} - -/// `DeleteObjects` is idempotent. A raw filesystem `NotFound` can cross the -/// distributed delete path instead of its usual typed missing-object error. -fn is_delete_objects_not_found(error: &EcstoreError) -> bool { - is_err_object_not_found(error) - || is_err_version_not_found(error) - || matches!(error, StorageError::Io(source) if source.kind() == std::io::ErrorKind::NotFound) -} - -/// Bounded concurrency for the per-object pre-delete stat fanout in -/// `execute_delete_objects` (backlog#929 / HP-8). Keeps the metadata reads for -/// a 1000-key batch from serializing while capping the disk fanout pressure. -const DELETE_OBJECTS_PRE_STAT_CONCURRENCY: usize = 16; - -/// backlog#929 (HP-8): whether the pre-delete `get_object_info` for one entry -/// of a DeleteObjects batch can be skipped without changing behavior. -/// -/// The stat result feeds four consumers, and each must be provably idle: -/// - the app-layer object-lock admission check never runs for deletes that -/// create a delete marker, and non-lock buckets cannot hold retention or -/// legal-hold metadata (`bucket_lock_enabled == false`); -/// - replication reads its authoritative source metadata later while the -/// SetDisks write lock is held, so it does not consume this advisory stat; -/// - usage accounting for delete-marker creation goes through -/// `record_bucket_delete_marker_memory` and never reads the object size -/// (`accounting_creates_delete_marker` is computed from the same versioning -/// snapshot the accounting branch uses); -/// - transitioned-object (ILM tier) cleanup journaling is a no-op for -/// delete-marker creation because no version is removed, so `ObjSweeper` -/// produces no journal entry regardless of the stat result. -/// -/// Object-lock enabled buckets always keep the stat, so their delete path is -/// byte-for-byte the pre-#929 one (see PR #4297). -fn can_skip_delete_objects_pre_stat( - bucket_lock_enabled: bool, - opts: &ObjectOptions, - accounting_creates_delete_marker: bool, -) -> bool { - !bucket_lock_enabled && delete_creates_delete_marker(opts) && accounting_creates_delete_marker -} - -fn complete_delete_noop( - helper: OperationHelper, - bucket: String, - key: String, - version_id: Option, -) -> (S3Result>, OperationHelper) { - let helper = helper - .event_name(EventName::ObjectRemovedNoOP) - .object(ObjectInfo { - name: key, - bucket, - ..Default::default() - }) - .version_id(version_id.unwrap_or_default()); - let result = Ok(S3Response::with_status(DeleteObjectOutput::default(), StatusCode::NO_CONTENT)); - let helper = helper.complete(&result); - (result, helper) -} - -fn delete_response_version_id(version_id: Option, synthetic_version_id: bool) -> Option { - if synthetic_version_id { - None - } else if version_id == Some(Uuid::nil()) { - Some(NULL_VERSION_ID.to_string()) - } else { - version_id.map(|version_id| version_id.to_string()) - } -} - -fn reduce_delete_objects_result<'a>( - object: &ObjectToDelete, - deleted: &'a StorageDeletedObject, - error: Option<&EcstoreError>, - synthetic_version_id: bool, -) -> Result<&'a StorageDeletedObject, s3s::dto::Error> { - match error { - None => Ok(deleted), - Some(error) if is_delete_objects_not_found(error) => Ok(deleted), - Some(error) => { - let api_error = ApiError::from(error.clone()); - Err(s3s::dto::Error { - code: Some(api_error.code.as_str().to_string()), - key: Some(object.object_name.clone()), - message: Some(api_error.message), - version_id: delete_response_version_id(object.version_id, synthetic_version_id), - }) - } - } -} - -fn resolve_put_object_extract_options(headers: &HeaderMap) -> S3Result { - let prefix = snowball_meta_value(headers, SNOWBALL_PREFIX_HEADER_KEYS, SNOWBALL_PREFIX_SUFFIX_LOWER) - .map(|value| normalize_snowball_prefix(&value)) - .transpose()? - .flatten(); - let ignore_dirs = snowball_meta_flag(headers, SNOWBALL_IGNORE_DIRS_HEADER_KEYS, SNOWBALL_IGNORE_DIRS_SUFFIX_LOWER); - let ignore_errors = snowball_meta_flag(headers, SNOWBALL_IGNORE_ERRORS_HEADER_KEYS, SNOWBALL_IGNORE_ERRORS_SUFFIX_LOWER); - - Ok(PutObjectExtractOptions { - prefix, - ignore_dirs, - ignore_errors, - }) -} - -fn put_object_extract_limits() -> ArchiveLimits { - ArchiveLimits::default() -} - -fn validate_put_object_extract_entry_count(count: usize, limits: ArchiveLimits) -> S3Result<()> { - if count > limits.max_entries { - return Err(s3_error!( - InvalidArgument, - "Archive entry count exceeds limit: count={}, limit={}", - count, - limits.max_entries - )); - } - Ok(()) -} - -fn validate_put_object_extract_entry_size(path: &str, size: u64, limits: ArchiveLimits) -> S3Result<()> { - if size > limits.max_entry_size { - return Err(s3_error!( - InvalidArgument, - "Archive entry size exceeds limit for {}: size={}, limit={}", - path, - size, - limits.max_entry_size - )); - } - Ok(()) -} - -fn validate_put_object_extract_total_size(total_size: u64, limits: ArchiveLimits) -> S3Result<()> { - if total_size > limits.max_total_unpacked_size { - return Err(s3_error!( - InvalidArgument, - "Archive total unpacked size exceeds limit: size={}, limit={}", - total_size, - limits.max_total_unpacked_size - )); - } - Ok(()) -} - -fn validate_put_object_extract_entry_path(path: &str, limits: ArchiveLimits) -> S3Result<()> { - if path.len() > limits.max_path_length { - return Err(s3_error!( - InvalidArgument, - "Archive entry path exceeds limit for {}: length={}, limit={}", - path, - path.len(), - limits.max_path_length - )); - } - Ok(()) -} - -fn is_sse_kms_requested(input: &PutObjectInput, headers: &HeaderMap) -> bool { - input - .server_side_encryption - .as_ref() - .is_some_and(|sse| sse.as_str().eq_ignore_ascii_case(ServerSideEncryption::AWS_KMS)) - || input.ssekms_key_id.is_some() - || headers - .get(AMZ_SERVER_SIDE_ENCRYPTION) - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.trim().eq_ignore_ascii_case(ServerSideEncryption::AWS_KMS)) - || headers.contains_key(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID) -} - -fn is_post_object_sse_kms_requested(input: &PutObjectInput, headers: &HeaderMap) -> bool { - is_sse_kms_requested(input, headers) -} - -async fn resolve_put_object_expiration(bucket: &str, obj_info: &ObjectInfo) -> Option { - let Ok((lifecycle_config, _)) = metadata_sys::get_lifecycle_config(bucket).await else { - debug!(bucket, state = "config_missing", "PUT object expiration config missing"); - return None; - }; - - let obj_opts = lifecycle::object_opts_from_object_info(obj_info); - let event = predict_lifecycle_expiration(&lifecycle_config, &obj_opts).await; - debug!( - bucket, - action = ?event.action, - rule_id = %event.rule_id, - due = ?event.due, - "PUT object expiration resolved" - ); - build_put_object_expiration_header(&event) -} - -/// Cadence for the "I/O queue congestion detected" WARN. Under sustained -/// overload (client concurrency at or above the disk-read permit pool) every -/// GET observes >=80% utilization, so an unthrottled WARN floods the log -/// from the already saturated hot path; congestion metrics stay per-request. -const IO_QUEUE_CONGESTION_WARN_INTERVAL_MS: u64 = 5_000; - -/// At-most-one-WARN-per-interval limiter for the I/O queue congestion log. -/// Callers supply monotonic milliseconds so tests can drive the clock. -struct IoQueueCongestionWarnThrottle { - /// Timestamp of the last emitted WARN; `u64::MAX` until the first one. - last_warn_ms: AtomicU64, - /// Congested requests left unlogged since the last emitted WARN. - suppressed: AtomicU64, -} - -impl IoQueueCongestionWarnThrottle { - const fn new() -> Self { - Self { - last_warn_ms: AtomicU64::new(u64::MAX), - suppressed: AtomicU64::new(0), - } - } - - /// Claim the right to emit one WARN. Returns the number of events - /// suppressed since the previous emission, or `None` while the interval - /// window is still closed (the event is counted, not logged). - fn claim(&self, now_ms: u64) -> Option { - let last = self.last_warn_ms.load(Ordering::Relaxed); - let window_open = last == u64::MAX || now_ms.saturating_sub(last) >= IO_QUEUE_CONGESTION_WARN_INTERVAL_MS; - if window_open - && self - .last_warn_ms - .compare_exchange(last, now_ms, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - Some(self.suppressed.swap(0, Ordering::Relaxed)) - } else { - self.suppressed.fetch_add(1, Ordering::Relaxed); - None - } - } - - /// Monotonic milliseconds since the first call, for production callers. - fn now_ms() -> u64 { - static ANCHOR: OnceLock = OnceLock::new(); - ANCHOR.get_or_init(std::time::Instant::now).elapsed().as_millis() as u64 - } -} - -static IO_QUEUE_CONGESTION_WARN_THROTTLE: IoQueueCongestionWarnThrottle = IoQueueCongestionWarnThrottle::new(); - -#[derive(Clone, Default)] -pub struct DefaultObjectUsecase { - context: Option>, - #[cfg(test)] - get_object_timeout_policy: Option, -} - -async fn track_object_read_setup(health: Option<&ObjectTrafficHealth>, future: F) -> F::Output -where - F: std::future::Future, -{ - let _progress = health.and_then(ObjectTrafficHealth::track_read_storage); - future.await -} - -impl DefaultObjectUsecase { - fn should_use_large_put_concurrency_tuning(size: i64) -> bool { - size >= DEFAULT_PUT_LARGE_CONCURRENCY_TUNING_MIN_SIZE_BYTES - } - - #[cfg(test)] - pub fn without_context() -> Self { - Self { - context: None, - get_object_timeout_policy: None, - } - } - - pub fn from_global() -> Self { - Self { - context: current_app_context(), - #[cfg(test)] - get_object_timeout_policy: None, - } - } - - /// Build the use-case bound to an explicit application context - /// (backlog#1052 S6): the per-server request path passes its own context - /// so the use-case resolves that server's store; `None` falls back to the - /// ambient default. - pub fn with_context(context: Option>) -> Self { - Self { - context, - #[cfg(test)] - get_object_timeout_policy: None, - } - } - - #[cfg(test)] - fn with_context_and_get_object_timeout_policy( - context: Option>, - get_object_timeout_policy: GetObjectTimeoutPolicy, - ) -> Self { - Self { - context, - get_object_timeout_policy: Some(get_object_timeout_policy), - } - } - - fn bucket_metadata_sys(&self) -> Option>> { - self.context.as_ref().and_then(|context| context.bucket_metadata().handle()) - } - - fn object_store(&self) -> Option> { - current_object_store_handle_for_context(self.context.as_deref()) - } - - fn object_data_cache(&self) -> Arc { - current_object_data_cache_for_context(self.context.as_deref()) - } - - fn object_traffic_health(&self) -> Option> { - self.context - .as_ref() - .map(|context| context.object_traffic_health()) - .or_else(|| current_app_context().map(|context| context.object_traffic_health())) - } - - fn base_buffer_size(&self) -> usize { - self.context - .clone() - .or_else(current_app_context) - .map(|context| context.buffer_config().get().base_config.default_unknown) - .unwrap_or_else(|| RustFSBufferConfig::default().base_config.default_unknown) - } - - async fn check_bucket_quota(&self, bucket: &str, op: QuotaOperation, size: u64) -> S3Result> { - let Some(metadata_sys) = self.bucket_metadata_sys() else { - return Ok(None); - }; - let quota_checker = QuotaChecker::new(metadata_sys); - map_quota_check_outcome(bucket, quota_checker.check_quota(bucket, op, size).await).map(Some) - } - - fn build_memory_bytes_blob( - bytes: Bytes, - response_content_length: i64, - source: &'static str, - lifecycle: GetObjectBodyLifecycle, - ) -> StreamingBlob { - let get_stage_metrics_enabled = rustfs_io_metrics::get_stage_metrics_enabled(); - let memory_blob_start = get_stage_metrics_enabled.then(std::time::Instant::now); - let handoff_start = get_stage_metrics_enabled.then(std::time::Instant::now); - let bytes_len = bytes.len(); - let guard = rustfs_io_metrics::track_get_object_buffered_bytes(bytes_len); - let remaining = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX); - let blob = if is_get_small_body_once_enabled() && bytes_len == remaining { - let owner = MemoryOnceBodyOwner::new(bytes, guard, lifecycle); - StreamingBlob::from_bytes(Bytes::from_owner(owner)) - } else { - StreamingBlob::new(MemoryTrackedBytesStream::new(bytes, remaining, source, guard, lifecycle)) - }; - if let Some(handoff_start) = handoff_start { - rustfs_io_metrics::record_get_object_response_handoff( - "single_chunk", - source, - bytes_len, - response_content_length, - handoff_start.elapsed().as_secs_f64(), - ); - } - record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_MEMORY_BLOB, memory_blob_start); - blob - } - - fn build_memory_blob( - buf: Vec, - response_content_length: i64, - source: &'static str, - lifecycle: GetObjectBodyLifecycle, - ) -> StreamingBlob { - Self::build_memory_bytes_blob(Bytes::from(buf), response_content_length, source, lifecycle) - } - - fn select_stream_buffer_strategy( - response_content_length: i64, - optimal_buffer_size: usize, - enable_readahead: bool, - has_range: bool, - ) -> (usize, GetObjectStreamStrategy) { - if enable_readahead && !has_range && response_content_length >= LARGE_SEQUENTIAL_GET_THRESHOLD_BYTES { - let expanded_buffer_size = optimal_buffer_size - .saturating_mul(LARGE_SEQUENTIAL_GET_READAHEAD_MULTIPLIER) - .min(LARGE_SEQUENTIAL_GET_STREAM_BUFFER_CAP_BYTES) - .max(optimal_buffer_size); - return (expanded_buffer_size, GetObjectStreamStrategy::LargeSequentialReadahead); - } - - (optimal_buffer_size, GetObjectStreamStrategy::Standard) - } - - #[allow(clippy::too_many_arguments)] - fn build_reader_blob( - reader: R, - response_content_length: i64, - request_id: &str, - content_range: Option<&str>, - stream_buffer_size: usize, - stream_strategy: GetObjectStreamStrategy, - bucket: &str, - key: &str, - lifecycle: GetObjectBodyLifecycle, - resume: Option>, - ) -> StreamingBlob - where - R: AsyncRead + Send + Sync + Unpin + 'static, - { - let streaming_blob_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let expected = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX); - let tuned_stream_buffer_size = - tune_reader_stream_buffer_size(stream_buffer_size, response_content_length, stream_strategy); - let (stream_buffer_size, buffer_source) = - resolve_reader_stream_buffer_size(tuned_stream_buffer_size, get_reader_stream_buffer_size_override()); - let get_stage_metrics_enabled = rustfs_io_metrics::get_stage_metrics_enabled(); - if get_stage_metrics_enabled { - rustfs_io_metrics::record_get_object_stream_strategy( - stream_strategy.as_str(), - stream_buffer_size, - response_content_length, - ); - } - let handoff_start = get_stage_metrics_enabled.then(std::time::Instant::now); - let reader = GetObjectStreamingReader::new( - reader, - bucket, - key, - request_id, - content_range.map(|content_range| content_range.to_string()), - expected, - get_object_disk_read_timeout(), - lifecycle, - resume, - ); - let stream = GetObjectReaderStream::new(reader, stream_buffer_size, expected, stream_strategy.as_str(), buffer_source) - .with_diagnostics(bucket, key, request_id); - let blob = StreamingBlob::new(stream); - if let Some(handoff_start) = handoff_start { - rustfs_io_metrics::record_get_object_response_handoff( - stream_strategy.as_str(), - buffer_source, - stream_buffer_size, - response_content_length, - handoff_start.elapsed().as_secs_f64(), - ); - } - record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_STREAMING_BLOB, streaming_blob_start); - blob - } - - fn init_get_object_bootstrap(&self, bucket: &str, key: &str, request_id: &str) -> S3Result { - #[cfg(test)] - let timeout_config = self - .get_object_timeout_policy - .clone() - .unwrap_or_else(GetObjectTimeoutPolicy::cached_from_env); - #[cfg(not(test))] - let timeout_config = GetObjectTimeoutPolicy::cached_from_env(); - let wrapper = RequestTimeoutWrapper::with_request_id(timeout_config.clone(), request_id.to_string()); - let request_start = std::time::Instant::now(); - let request_guard = ConcurrencyManager::track_request(); - let concurrent_requests = GetObjectGuard::concurrent_requests(); - - let deadlock_detector = deadlock_detector::get_deadlock_detector(); - let deadlock_request_guard = DeadlockRequestGuard::register_if_enabled(deadlock_detector, wrapper.request_id(), || { - format!("GetObject {bucket}/{key}") - }); - - Self::ensure_get_object_not_timed_out(&wrapper, &timeout_config, bucket, key, GetObjectTimeoutStage::BeforeProcessing)?; - - debug!( - "GetObject request started with {} concurrent requests, timeout={:?}", - concurrent_requests, timeout_config.get_object_timeout - ); - - Ok(GetObjectBootstrap { - timeout_config, - wrapper, - request_start, - request_guard, - _deadlock_request_guard: deadlock_request_guard, - concurrent_requests, - }) - } - - fn validate_get_object_part_number(part_number: Option, info: &ObjectInfo) -> S3Result<()> { - if let Some(part_number) = part_number - && part_number > 1 - && !info.parts.iter().any(|part| part.number == part_number) - { - return Err(s3_error!(InvalidPart)); - } - Ok(()) - } - - fn validate_get_object_before_cold_fill(headers: &HeaderMap, part_number: Option, info: &ObjectInfo) -> S3Result<()> { - check_preconditions(headers, info)?; - Self::validate_get_object_part_number(part_number, info) - } - - /// How long a GET waits for a disk read permit before degrading to a - /// permit-less read. Cached: consulted per GET. Zero disables the bound. - fn disk_permit_wait_timeout() -> Duration { - static CACHED: std::sync::OnceLock = std::sync::OnceLock::new(); - *CACHED.get_or_init(|| { - Duration::from_secs(rustfs_utils::get_env_u64( - rustfs_config::ENV_OBJECT_DISK_PERMIT_WAIT_TIMEOUT, - rustfs_config::DEFAULT_OBJECT_DISK_PERMIT_WAIT_TIMEOUT, - )) - }) - } - - async fn acquire_get_object_io_planning( - manager: &ConcurrencyManager, - request_timeout: Option>, - bucket: &str, - key: &str, - ) -> S3Result { - let permit_wait_start = std::time::Instant::now(); - let disk_permit = Self::admit_get_object_disk_read(manager, bucket, key).await?; - let permit_wait_duration = permit_wait_start.elapsed(); - - if let Some(timeout) = request_timeout { - Self::ensure_get_object_not_timed_out( - timeout.wrapper, - timeout.policy, - bucket, - key, - GetObjectTimeoutStage::DiskPermitWait { permit_wait_duration }, - )?; - } - - let queue_status = manager.io_queue_status(); - let queue_snapshot = GetObjectQueueSnapshot::from_available_permits( - queue_status.total_permits, - queue_status.total_permits.saturating_sub(queue_status.permits_in_use), - ); - let queue_utilization = queue_snapshot.utilization_percent(); - - if queue_snapshot.is_congested(80.0) { - // Metrics count every congested request; only the WARN is rate - // limited, because under saturation every GET crosses the - // threshold and per-request WARNs flood the log. - rustfs_io_metrics::record_io_queue_congestion(); - - if let Some(suppressed_warns) = IO_QUEUE_CONGESTION_WARN_THROTTLE.claim(IoQueueCongestionWarnThrottle::now_ms()) { - warn!( - bucket = %bucket, - key = %key, - queue_utilization = format!("{:.1}%", queue_utilization), - permits_in_use = queue_status.permits_in_use, - total_permits = queue_status.total_permits, - suppressed_warns, - "I/O queue congestion detected" - ); - } - } - - if let Some(timeout) = request_timeout { - Self::ensure_get_object_not_timed_out( - timeout.wrapper, - timeout.policy, - bucket, - key, - GetObjectTimeoutStage::BeforeRead, - )?; - } - - Ok(GetObjectIoPlanning { - disk_permit, - permit_wait_duration, - queue_status, - queue_utilization, - }) - } - - // Shared by the initial read path and the mid-stream resume reopen, which - // must hold the same admission token before touching disks. The permit - // wait inside is bounded by the primary-pool timeout. - async fn admit_get_object_disk_read( - manager: &ConcurrencyManager, - bucket: &str, - key: &str, - ) -> S3Result> { - let permit_wait_start = std::time::Instant::now(); - let permit_wait_timeout = Self::disk_permit_wait_timeout(); - // Permits are held for the whole body transfer, so slow clients can pin - // all of them while disks are idle. Bound the wait on the primary pool - // and, on timeout, admit from a bounded degraded overflow lane. Total - // concurrent disk-active GETs are hard-capped at - // `primary_cap + degraded_cap`; once that cap is reached we reject with - // `SlowDown` instead of reading without any admission token. Never - // proceed permit-less. - let disk_permit = match manager - .admit_disk_read(permit_wait_timeout) - .await - .map_err(|_| s3_error!(InternalError, "disk read semaphore closed"))? - { - DiskReadAdmission::Primary(permit) => Some(permit), - // Throttling disabled by config (primary cap 0): proceed without an - // admission token. Not a saturation bypass. - DiskReadAdmission::Unbounded => None, - DiskReadAdmission::Degraded(permit) => { - metrics::counter!("rustfs.get_object.disk_permit.degraded.total").increment(1); - warn!( - bucket = %bucket, - key = %key, - wait_ms = permit_wait_start.elapsed().as_millis() as u64, - "GetObject admitted into bounded degraded disk-read lane after primary pool saturation" - ); - Some(permit) - } - DiskReadAdmission::Rejected => { - metrics::counter!("rustfs.get_object.disk_permit.hard_reject.total").increment(1); - warn!( - bucket = %bucket, - key = %key, - wait_ms = permit_wait_start.elapsed().as_millis() as u64, - "GetObject rejected: disk-read hard concurrency cap reached" - ); - return Err(s3_error!( - SlowDown, - "disk read concurrency limit reached, please reduce your request rate" - )); - } - }; - Ok(disk_permit.map(GetObjectDiskPermit::new)) - } - - async fn acquire_cold_fill_io_planning( - manager: &'static ConcurrencyManager, - bucket: &str, - key: &str, - ) -> Result { - match Self::acquire_get_object_io_planning(manager, None, bucket, key).await { - Ok(io) => Ok(io), - Err(err) if err.code() == &S3ErrorCode::SlowDown => Err(ColdFillError::Storage(StorageError::SlowDown)), - Err(_) => Err(ColdFillError::DiskAdmissionClosed), - } - } - - fn get_object_io_planning_without_disk(manager: &ConcurrencyManager) -> GetObjectIoPlanning { - let queue_status = manager.io_queue_status(); - let queue_snapshot = GetObjectQueueSnapshot::from_available_permits( - queue_status.total_permits, - queue_status.total_permits.saturating_sub(queue_status.permits_in_use), - ); - GetObjectIoPlanning { - disk_permit: None, - permit_wait_duration: Duration::ZERO, - queue_utilization: queue_snapshot.utilization_percent(), - queue_status, - } - } - - /// Cheap request-shape validations, run before the bucket-existence store - /// lookup so invalid requests keep their InvalidArgument precedence. - fn validate_get_object_request(req: &S3Request) -> S3Result { - // Clone only the fields this path needs instead of the whole input. - let bucket = req.input.bucket.clone(); - let key = req.input.key.clone(); - let version_id = req.input.version_id.clone(); - let part_number = req.input.part_number; - let range = req.input.range; - - validate_object_key(&key, "GET")?; - - let part_number = parse_part_number_i32_to_usize(part_number, "GET")?; - - let rs = range.map(range_to_http_range_spec).transpose()?; - - if rs.is_some() && part_number.is_some() { - return Err(s3_error!(InvalidArgument, "range and part_number invalid")); - } - - Ok(GetObjectValidatedRequest { - bucket, - key, - version_id, - part_number, - rs, - }) - } - - async fn prepare_get_object_request_context( - validated: GetObjectValidatedRequest, - headers: &HeaderMap, - ) -> S3Result { - let GetObjectValidatedRequest { - bucket, - key, - version_id, - part_number, - rs, - } = validated; - - let opts: ObjectOptions = get_opts(&bucket, &key, version_id.clone(), part_number, headers) - .await - .map_err(ApiError::from)?; - - Ok(GetObjectRequestContext { - version_id_for_event: version_id.unwrap_or_default(), - bucket, - key, - part_number, - rs, - opts, - }) - } - #[allow(clippy::too_many_arguments)] - async fn prepare_get_object_read_execution( - &self, - req: &S3Request, - manager: &'static ConcurrencyManager, - store: Arc, - wrapper: &RequestTimeoutWrapper, - timeout_config: &GetObjectTimeoutPolicy, - bucket: &str, - key: &str, - rs: Option, - opts: &ObjectOptions, - part_number: Option, - object_traffic_health: Option>, - ) -> S3Result { - let read_start = std::time::Instant::now(); - let read_stage_start = rustfs_io_metrics::get_stage_metrics_enabled().then_some(read_start); - let store_headers = project_ssec_transport_headers(&req.headers); - let cache_adapter = self.object_data_cache(); - if cache_adapter.is_disabled() || !cache_adapter.materialize_fill_enabled() { - let io_planning = Self::acquire_get_object_io_planning( - manager, - Some(GetObjectRequestTimeout { - wrapper, - policy: timeout_config, - }), - bucket, - key, - ) - .await?; - let reader = track_object_read_setup( - object_traffic_health.as_deref(), - store.get_object_reader(bucket, key, rs.clone(), store_headers, opts), - ) - .await - .map_err(map_get_object_reader_error)?; - let read_setup = - Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, true).await?; - return Ok(GetObjectPreparedRead { io_planning, read_setup }); - } - - // Preserve the legacy metadata-fanout bound without making followers - // hold a body-transfer permit while they wait on the cold-fill session. - let mut metadata_admission = Some( - Self::acquire_get_object_io_planning( - manager, - Some(GetObjectRequestTimeout { - wrapper, - policy: timeout_config, - }), - bucket, - key, - ) - .await?, - ); - let mut prepared = Some( - track_object_read_setup( - object_traffic_health.as_deref(), - store.prepare_get_object_reader(bucket, key, rs.clone(), HeaderMap::new(), opts), - ) - .await - .map_err(map_get_object_reader_error)?, - ); - let mut cache_fill_allowed = true; - let mut legacy_hook_missed = false; - 'snapshot: { - let info = prepared - .as_ref() - .ok_or_else(|| s3_error!(InternalError, "prepared metadata snapshot is unavailable"))? - .object_info(); - // Preconditions, cache planning, and the authoritative hook lookup all - // run against one namespace-locked metadata snapshot. Cacheable misses - // release both the lock and short admission before joining cold fill. - let Some(response_content_length) = get_object_body_cache_plaintext_len(&rs, opts, info) else { - break 'snapshot; - }; - let cache_plan = build_get_object_body_cache_plan( - &cache_adapter, - GetObjectBodyCacheRequest { - bucket, - key, - info, - response_content_length, - has_range: rs.is_some(), - part_number, - encryption_applied: info.is_encrypted(), - }, - ); - - // The legacy hook is evaluated once, before cold-fill coordination. - // In-session producer retries never re-enter this snapshot block. - let legacy_probe = lookup_preplanned_get_object_body_cache_hook( - Arc::clone(&cache_adapter), - cache_plan.clone(), - bucket, - key, - &rs, - opts, - info, - ) - .await; - if matches!(legacy_probe, GetObjectBodyCacheHookLookup::Ineligible) { - break 'snapshot; - } - Self::validate_get_object_before_cold_fill(&req.headers, part_number, info)?; - if let GetObjectBodyCacheHookLookup::Hit(body) = legacy_probe { - drop(metadata_admission.take()); - let info = prepared - .take() - .ok_or_else(|| s3_error!(InternalError, "prepared cache-hit reader is unavailable"))? - .into_object_info(); - let reader = GetObjectReader::from_cache_body(info, body).map_err(ApiError::from)?; - let read_setup = - Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, true).await?; - return Ok(GetObjectPreparedRead { - io_planning: Self::get_object_io_planning_without_disk(manager), - read_setup, - }); - } - if matches!(legacy_probe, GetObjectBodyCacheHookLookup::Miss) { - legacy_hook_missed = true; - } - if !legacy_hook_missed - && let GetObjectBodyCacheLookup::Hit(body) = lookup_get_object_body_cache_hit(&cache_adapter, &cache_plan).await - { - drop(metadata_admission.take()); - let info = prepared - .take() - .ok_or_else(|| s3_error!(InternalError, "prepared cache-hit reader is unavailable"))? - .into_object_info(); - let reader = GetObjectReader::from_cache_body(info, body).map_err(ApiError::from)?; - let read_setup = - Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, true).await?; - return Ok(GetObjectPreparedRead { - io_planning: Self::get_object_io_planning_without_disk(manager), - read_setup, - }); - } - - let GetObjectBodyCachePlan::Cacheable(engine_plan) = &cache_plan else { - break 'snapshot; - }; - let Some(cache_key) = cache_plan.key().cloned() else { - break 'snapshot; - }; - let expected = usize::try_from(response_content_length) - .map_err(|_| s3_error!(InternalError, "cold-fill body length is not representable"))?; - let response_size = u64::try_from(response_content_length) - .map_err(|_| s3_error!(InternalError, "cold-fill body length is negative"))?; - let waiter_deadline = cold_fill_deadline(wrapper, timeout_config, response_size); - let proposed_producer_deadline = cold_fill_producer_deadline(timeout_config, response_size); - let coordinator = cache_adapter.cold_fill_coordinator(); - let info = prepared - .take() - .ok_or_else(|| s3_error!(InternalError, "prepared cold-fill reader is unavailable"))? - .into_object_info(); - drop(metadata_admission.take()); - let outcome = coordinate_cold_fill(&coordinator, cache_key, waiter_deadline, Some(proposed_producer_deadline), { - let adapter = &cache_adapter; - let headers = &store_headers; - let store = &store; - let range = &rs; - let object_traffic_health = &object_traffic_health; - move |producer| { - let adapter = Arc::clone(adapter); - let engine_plan = engine_plan.clone(); - let h = headers.clone(); - let store = Arc::clone(store); - let range = range.clone(); - let bucket = bucket.to_owned(); - let key = key.to_owned(); - let opts = opts.clone(); - let object_traffic_health = object_traffic_health.as_ref().map(Arc::clone); - async move { - let producer_deadline = producer.deadline(); - let cancellation = producer.cancellation_token(); - let second_chance = match await_cold_fill_startup( - lookup_cold_fill_second_chance(&adapter, &engine_plan), - &cancellation, - producer_deadline, - ) - .await - { - Ok(body) => body, - Err(ColdFillStartupWaitError::Cancelled) => { - producer.finish(Err(StorageError::OperationCanceled)); - return; - } - Err(ColdFillStartupWaitError::DeadlineExceeded) => { - producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout)); - return; - } - }; - if let Some(body) = second_chance { - producer.finish_shared(Ok(body)); - return; - } - - let acquire = Self::acquire_cold_fill_io_planning(manager, &bucket, &key); - let producer_io = match await_cold_fill_startup(acquire, &cancellation, producer_deadline).await { - Ok(result) => result, - Err(ColdFillStartupWaitError::Cancelled) => { - producer.finish(Err(StorageError::OperationCanceled)); - return; - } - Err(ColdFillStartupWaitError::DeadlineExceeded) => { - producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout)); - return; - } - }; - let producer_io = match producer_io { - Ok(io) => io, - Err(err) => { - producer.finish_shared(Err(err)); - return; - } - }; - - let prepare = track_object_read_setup( - object_traffic_health.as_deref(), - store.prepare_get_object_reader(&bucket, &key, range.clone(), HeaderMap::new(), &opts), - ); - let prepared = match match await_cold_fill_startup(prepare, &cancellation, producer_deadline).await { - Ok(result) => result, - Err(ColdFillStartupWaitError::Cancelled) => { - producer.finish(Err(StorageError::OperationCanceled)); - return; - } - Err(ColdFillStartupWaitError::DeadlineExceeded) => { - producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout)); - return; - } - } { - Ok(prepared) => prepared, - Err(err) => { - producer.relinquish_or_finish(ColdFillError::Storage(err)); - return; - } - }; - let current_info = prepared.object_info(); - let current_length = match current_info.get_actual_size() { - Ok(length) => length, - Err(err) => { - let _ = err; - producer.finish_shared(Err(ColdFillError::Storage(StorageError::FileCorrupt))); - return; - } - }; - let current_plan = build_get_object_body_cache_plan_for_revalidation( - &adapter, - GetObjectBodyCacheRequest { - bucket: &bucket, - key: &key, - info: current_info, - response_content_length: current_length, - has_range: range.is_some(), - part_number, - encryption_applied: current_info.is_encrypted(), - }, - ); - let Some(producer) = retain_cold_fill_producer_for_matching_plan(producer, ¤t_plan, &engine_plan) - else { - return; - }; - - let reservation = adapter.reserve_body(&engine_plan); - #[cfg(test)] - let reader_open_plan = engine_plan.clone(); - start_cold_fill_producer( - producer, - reservation, - || async move { Ok(producer_io) }, - || { - #[cfg(test)] - record_cold_fill_reader_open_for_test(&reader_open_plan); - let open_reader = prepared.with_headers(h).into_reader(); - async move { track_object_read_setup(object_traffic_health.as_deref(), open_reader).await } - }, - ColdFillProducerExecution { - expected, - deadline: producer_deadline, - adapter, - engine_plan, - }, - ) - .await; - } - } - }) - .await; - - match outcome { - ColdFillCoordinateOutcome::Ready(result) => { - let body = match result { - Ok(body) => body, - Err(ColdFillError::Storage(err)) => return Err(map_get_object_reader_error(err).into()), - Err(ColdFillError::DiskAdmissionClosed) => { - return Err(s3_error!(InternalError, "disk read semaphore closed")); - } - }; - let reader = GetObjectReader::from_cache_body(info, body).map_err(ApiError::from)?; - let read_setup = - Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, true) - .await?; - return Ok(GetObjectPreparedRead { - io_planning: Self::get_object_io_planning_without_disk(manager), - read_setup, - }); - } - ColdFillCoordinateOutcome::Bypass => { - cache_fill_allowed = false; - break 'snapshot; - } - ColdFillCoordinateOutcome::Rejected => return Err(ApiError::from(StorageError::SlowDown).into()), - } - } - - let (io_planning, reader) = if let Some(prepared) = prepared.take() { - let io_planning = metadata_admission - .take() - .ok_or_else(|| s3_error!(InternalError, "prepared metadata admission is unavailable"))?; - let reader = - track_object_read_setup(object_traffic_health.as_deref(), prepared.with_headers(store_headers).into_reader()) - .await - .map_err(map_get_object_reader_error)?; - (io_planning, reader) - } else { - let io_planning = Self::acquire_get_object_io_planning( - manager, - Some(GetObjectRequestTimeout { - wrapper, - policy: timeout_config, - }), - bucket, - key, - ) - .await?; - let reader = if legacy_hook_missed { - let prepared = track_object_read_setup( - object_traffic_health.as_deref(), - store.prepare_get_object_reader(bucket, key, rs.clone(), HeaderMap::new(), opts), - ) - .await - .map_err(map_get_object_reader_error)?; - track_object_read_setup(object_traffic_health.as_deref(), prepared.with_headers(store_headers).into_reader()) - .await - .map_err(map_get_object_reader_error)? - } else { - track_object_read_setup( - object_traffic_health.as_deref(), - store.get_object_reader(bucket, key, rs.clone(), store_headers, opts), - ) - .await - .map_err(map_get_object_reader_error)? - }; - (io_planning, reader) - }; - let read_setup = - Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, cache_fill_allowed) - .await?; - if let Some(read_stage_start) = read_stage_start { - rustfs_io_metrics::record_get_object_stage_duration( - "s3_handler", - "store_reader_setup", - read_stage_start.elapsed().as_secs_f64(), - ); - } - Ok(GetObjectPreparedRead { io_planning, read_setup }) - } - - #[allow(clippy::too_many_arguments)] - async fn finish_get_object_read( - req: &S3Request, - manager: &ConcurrencyManager, - bucket: &str, - key: &str, - mut rs: Option, - part_number: Option, - read_start: std::time::Instant, - reader: GetObjectReader, - cache_fill_allowed: bool, - ) -> S3Result { - // ODC-16: capture whether the ecstore cache hook already probed this - // read, so the app layer does not repeat the lookup it ran after fresh - // metadata resolution. - let cache_hook_served = reader.is_cache_hook_served(); - let cache_hook_probed = reader.cache_hook_probed(); - let info = reader.object_info; - let stream = reader.stream; - let buffered_body = reader.buffered_body; - - let read_duration = read_start.elapsed(); - - // Conditional metrics recording to reduce overhead - if rustfs_io_metrics::get_stage_metrics_enabled() { - use rustfs_io_metrics::record_zero_copy_read; - record_zero_copy_read(info.size as usize, read_duration.as_secs_f64() * 1000.0); - manager.record_disk_operation(info.size as u64, read_duration, true).await; - } - - check_preconditions(&req.headers, &info)?; - Self::validate_get_object_part_number(part_number, &info)?; - - debug!(object_size = info.size, part_count = info.parts.len(), "GET object metadata snapshot"); - for part in info.parts.iter() { - debug!( - part_number = part.number, - part_size = part.size, - part_actual_size = part.actual_size, - "GET object part details" - ); - } - - let content_type = if let Some(content_type) = &info.content_type { - match ContentType::from_str(content_type) { - Ok(res) => Some(res), - Err(err) => { - error!(content_type, error = ?err, "GET object content-type parse failed"); - None - } - } - } else { - None - }; - let last_modified = info.mod_time.map(Timestamp::from); - - if let Some(part_number) = part_number - && rs.is_none() - { - rs = HTTPRangeSpec::from_part_sizes( - info.size, - part_number, - info.parts.iter().map(|part| { - if part.actual_size > 0 { - part.actual_size - } else { - i64::try_from(part.size).unwrap_or(i64::MAX) - } - }), - ); - } - - validate_sse_headers_for_read(&info.user_defined, &req.headers)?; - - let mut content_length = info.get_actual_size().map_err(ApiError::from)?; - let (resume_range_start, resume_range_end, content_range) = if let Some(rs) = &rs { - let total_size = content_length; - let (start, length) = rs.get_offset_length(total_size).map_err(ApiError::from)?; - content_length = length; - let start = start as i64; - // Inclusive end of the committed body; may precede `start` when a - // zero-length range was requested, in which case the body completes - // immediately and the resume range is never consulted. - ( - start, - start + length - 1, - Some(format!("bytes {}-{}/{}", start, start + length - 1, total_size)), - ) - } else { - (0, -1, None) - }; - - debug!( - "GET object metadata check: parts={}, provided_sse_key={:?}", - info.parts.len(), - req.input.sse_customer_key.is_some() - ); - - let read_principal = SseKmsPrincipal::from_request(req); - let decryption_request = DecryptionRequest { - bucket, - key, - metadata: &info.user_defined, - sse_customer_key: req.input.sse_customer_key.as_ref(), - sse_customer_key_md5: req.input.sse_customer_key_md5.as_ref(), - principal: read_principal.as_ref(), - }; - - let response_content_length = content_length; - - let ( - server_side_encryption, - sse_customer_algorithm, - sse_customer_key_md5, - ssekms_key_id, - encryption_applied, - final_stream, - buffered_body, - ) = match classify_sse_read_response(decryption_request).await? { - // The stream is already decrypted by the object layer's encryption - // resolver; only the response headers, authorization and audit - // summary are derived here, without a second KMS unwrap. - Some(headers) => ( - Some(headers.server_side_encryption), - headers.sse_customer_algorithm, - headers.sse_customer_key_md5, - headers.ssekms_key_id, - true, - wrap_reader(stream), - None, - ), - None => (None, None, None, None, false, wrap_reader(stream), buffered_body), - }; - - Ok(GetObjectReadSetup { - info, - final_stream, - buffered_body, - cache_hook_served, - cache_hook_probed, - cache_fill_allowed, - rs, - content_type, - last_modified, - response_content_length, - content_range, - server_side_encryption, - sse_customer_algorithm, - sse_customer_key_md5, - ssekms_key_id, - encryption_applied, - resume_range_start, - resume_range_end, - }) - } - #[allow(clippy::too_many_arguments)] - fn finalize_get_object_strategy( - &self, - manager: &ConcurrencyManager, - bucket: &str, - key: &str, - info: &ObjectInfo, - rs: Option<&HTTPRangeSpec>, - response_content_length: i64, - permit_wait_duration: Duration, - queue_utilization: f64, - queue_status: &concurrency::IoQueueStatus, - concurrent_requests: usize, - ) -> GetObjectStrategyContext { - let base_buffer_size = if response_content_length > 0 { - get_buffer_size_opt_in(response_content_length) - } else { - self.base_buffer_size() - }; - - let is_sequential_hint = if rs.is_none() { - true - } else if let Some(range_spec) = rs { - range_spec.start == 0 && !range_spec.is_suffix_length - } else { - false - }; - - // Conditional metrics recording to reduce overhead - if rustfs_io_metrics::get_stage_metrics_enabled() { - if let Some(range_spec) = rs - && range_spec.start >= 0 - { - manager.record_access(range_spec.start as u64, response_content_length as u64); - } - - if response_content_length > 0 { - manager.record_transfer(response_content_length as u64, permit_wait_duration); - } - } - - let io_strategy = - manager.calculate_io_strategy_with_context(info.size, base_buffer_size, permit_wait_duration, is_sequential_hint); - - debug!( - wait_ms = permit_wait_duration.as_millis() as u64, - load_level = ?io_strategy.load_level, - buffer_size = io_strategy.buffer_size, - buffer_multiplier = io_strategy.buffer_multiplier, - readahead = io_strategy.enable_readahead, - storage_media = ?io_strategy.storage_media, - access_pattern = ?io_strategy.access_pattern, - bandwidth_tier = ?io_strategy.bandwidth_tier, - concurrent_requests = io_strategy.concurrent_requests, - file_size = info.size, - is_sequential = is_sequential_hint, - "Enhanced multi-factor I/O strategy calculated" - ); - - let io_priority = manager.get_io_priority(response_content_length); - - if manager.is_priority_scheduling_enabled() { - debug!( - bucket = %bucket, - key = %key, - priority = %io_priority, - request_size = response_content_length, - "I/O priority assigned (based on actual request size)" - ); - } - - rustfs_io_metrics::record_get_object_io_state( - permit_wait_duration.as_secs_f64(), - queue_utilization, - queue_status.permits_in_use, - queue_status.total_permits.saturating_sub(queue_status.permits_in_use), - io_strategy.load_level.as_str(), - io_strategy.buffer_multiplier, - ); - rustfs_io_metrics::record_io_priority_assignment(io_priority.as_str()); - - debug!( - actual_request_size = response_content_length, - priority = %io_priority.as_str(), - "I/O priority finalized with actual request size" - ); - - let optimal_buffer_size = if io_strategy.buffer_size > 0 { - io_strategy.buffer_size - } else { - get_concurrency_aware_buffer_size(response_content_length, base_buffer_size) - }; - - debug!( - "GetObject buffer sizing: file_size={}, base={}, optimal={}, concurrent_requests={}, io_strategy={:?}", - response_content_length, base_buffer_size, optimal_buffer_size, concurrent_requests, io_strategy.load_level - ); - let enable_readahead = io_strategy.enable_readahead; - - GetObjectStrategyContext { - io_strategy, - optimal_buffer_size, - enable_readahead, - } - } - - fn build_get_object_checksums( - info: &ObjectInfo, - headers: &HeaderMap, - part_number: Option, - rs: Option<&HTTPRangeSpec>, - ) -> S3Result { - if let Some(checksum_mode) = headers.get(AMZ_CHECKSUM_MODE) - && checksum_mode.to_str().unwrap_or_default() == "ENABLED" - && rs.is_none() - { - let (decrypted_checksums, is_multipart) = info.decrypt_checksums(part_number.unwrap_or(0), headers).map_err(|e| { - error!(error = %e, "GetObject checksum decryption failed"); - ApiError::from(e) - })?; - - return Ok(classify_response_checksums(decrypted_checksums, is_multipart)); - } - - Ok(ResponseChecksums::default()) - } - #[allow(clippy::too_many_arguments)] - async fn build_get_object_body( - final_stream: R, - info: &ObjectInfo, - response_content_length: i64, - request_id: &str, - content_range: Option<&str>, - optimal_buffer_size: usize, - enable_readahead: bool, - concurrent_requests: usize, - part_number: Option, - has_range: bool, - encryption_applied: bool, - buffered_body: Option, - bucket: &str, - key: &str, - mut lifecycle: GetObjectBodyLifecycle, - resume: F, - ) -> S3Result - where - R: AsyncRead + Send + Sync + Unpin + 'static, - F: FnOnce(&ObjectInfo) -> Option>, - { - if encryption_applied { - let should_buffer_encrypted_object = - should_buffer_get_object_in_memory(info, response_content_length, part_number, has_range, concurrent_requests); - - if should_buffer_encrypted_object { - // Strict materialization (#1324): a decrypted body that is shorter - // or longer than the declared content length must hard-fail before - // headers, not warn-and-serve a truncated/over-long body. - let expected = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX); - match strict_materialize_object_body(final_stream, expected, GET_OBJECT_STAGE_BODY_ENCRYPTED_BUFFER_READ).await { - Ok(buf) => { - return Ok(Self::build_memory_blob( - buf, - response_content_length, - GET_MEMORY_BODY_SOURCE_ENCRYPTED_BUFFER, - lifecycle, - )); - } - Err(e) => { - lifecycle.finish_err(); - error!(error = %e, "GetObject decrypted object strict materialization failed"); - return Err(e.into_s3_error(response_content_length)); - } - } - } - - debug!(buffer_size = optimal_buffer_size, "Encrypted object uses streaming decrypt path"); - let stream_strategy_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let (stream_buffer_size, stream_strategy) = - Self::select_stream_buffer_strategy(response_content_length, optimal_buffer_size, enable_readahead, has_range); - record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_STREAM_STRATEGY, stream_strategy_start); - return Ok(Self::build_reader_blob( - final_stream, - response_content_length, - request_id, - content_range, - stream_buffer_size, - stream_strategy, - bucket, - key, - lifecycle, - resume(info), - )); - } - - if let Some(buffered_body) = buffered_body { - // Strict materialization (#1324): the buffered body is the exact - // response payload; a length disagreement means an upstream/cache bug - // and must hard-fail before headers rather than serve a body that does - // not match its committed Content-Length. - let expected = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX); - if buffered_body.len() != expected { - lifecycle.finish_err(); - error!( - expected = response_content_length, - actual = buffered_body.len(), - "Buffered GetObject body length mismatch" - ); - return Err(ApiError::from(StorageError::other(format!( - "Buffered GetObject body length mismatch: expected {response_content_length}, got {}", - buffered_body.len() - ))) - .into()); - } - - return Ok(Self::build_memory_bytes_blob( - buffered_body, - response_content_length, - GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, - lifecycle, - )); - } - - let should_provide_seek_support = - should_buffer_get_object_in_memory(info, response_content_length, part_number, has_range, concurrent_requests); - - if should_provide_seek_support { - // Strict materialization (#1324): the previous implementation only - // logged a warning on a length mismatch, and — most dangerously — on a read - // error it fell through to streaming the *same* reader after - // `read_to_end` had already drained K bytes, shipping a body missing - // its prefix (prefix-misaligned data). Both are now hard errors: an - // exact-length read is required, and any read error returns without - // reusing the partially consumed reader. - let expected = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX); - match strict_materialize_object_body(final_stream, expected, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ).await { - Ok(buf) => { - return Ok(Self::build_memory_blob( - buf, - response_content_length, - GET_MEMORY_BODY_SOURCE_SEEK_BUFFER, - lifecycle, - )); - } - Err(e) => { - lifecycle.finish_err(); - error!( - error = %e, - "GetObject seek-support strict materialization failed; refusing to reuse the partially consumed reader" - ); - return Err(e.into_s3_error(response_content_length)); - } - } - } - - let stream_strategy_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let (stream_buffer_size, stream_strategy) = - Self::select_stream_buffer_strategy(response_content_length, optimal_buffer_size, enable_readahead, has_range); - record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_STREAM_STRATEGY, stream_strategy_start); - Ok(Self::build_reader_blob( - final_stream, - response_content_length, - request_id, - content_range, - stream_buffer_size, - stream_strategy, - bucket, - key, - lifecycle, - resume(info), - )) - } - - #[allow(clippy::too_many_arguments)] - async fn build_get_object_body_with_cache( - cache_adapter: &ObjectDataCacheAdapter, - final_stream: R, - info: &ObjectInfo, - response_content_length: i64, - request_id: &str, - content_range: Option<&str>, - optimal_buffer_size: usize, - enable_readahead: bool, - concurrent_requests: usize, - part_number: Option, - has_range: bool, - encryption_applied: bool, - mut buffered_body: Option, - cache_hook_served: bool, - cache_hook_probed: bool, - cache_fill_allowed: bool, - bucket: &str, - key: &str, - mut lifecycle: GetObjectBodyLifecycle, - resume: F, - ) -> S3Result - where - R: AsyncRead + Send + Sync + Unpin + 'static, - F: FnOnce(&ObjectInfo) -> Option>, - { - // ODC-16 (backlog#1121): when the ecstore hook or shared cold fill - // already supplied this body, the request-level plan was built before - // the authoritative lookup. Serve it without planning a second time. - if cache_hook_served && let Some(bytes) = buffered_body.take() { - return Ok(Self::build_memory_bytes_blob( - bytes, - response_content_length, - GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE, - lifecycle, - )); - } - - if !cache_fill_allowed { - return Self::build_get_object_body( - final_stream, - info, - response_content_length, - request_id, - content_range, - optimal_buffer_size, - enable_readahead, - concurrent_requests, - part_number, - has_range, - encryption_applied, - buffered_body, - bucket, - key, - lifecycle, - resume, - ) - .await; - } - - let cache_request = GetObjectBodyCacheRequest { - bucket, - key, - info, - response_content_length, - has_range, - part_number, - encryption_applied, - }; - let cache_plan = build_get_object_body_cache_plan(cache_adapter, cache_request); - - // ODC-16: only look up when the hook did not probe this read. When it did - // probe (a served body handled above, or a miss), its result is - // authoritative because it ran after fresh metadata resolution, so the - // app layer skips its own lookup and only uses the plan to fill. - if !cache_hook_probed { - match lookup_get_object_body_cache_hit(cache_adapter, &cache_plan).await { - GetObjectBodyCacheLookup::Hit(bytes) => { - return Ok(Self::build_memory_bytes_blob( - bytes, - response_content_length, - GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE, - lifecycle, - )); - } - GetObjectBodyCacheLookup::Disabled | GetObjectBodyCacheLookup::Skip | GetObjectBodyCacheLookup::Miss => {} - } - } - - if let Some(buffered_body) = buffered_body { - // ODC-15: the body is already fully in hand, so keep the fill off the - // response's critical path. For a cacheable plan, run the fill in a - // detached task (Bytes is a cheap clone) and return immediately. For - // a non-cacheable plan the fill is a pure metric-only skip with no - // I/O, so record it inline to preserve observability. - if cache_fill_allowed && matches!(cache_plan, GetObjectBodyCachePlan::Cacheable(_)) { - let cache_adapter = cache_adapter.clone(); - let cache_plan = cache_plan.clone(); - let fill_bytes = buffered_body.clone(); - tokio::spawn(async move { - let _ = fill_get_object_body_cache_from_buffered_body(&cache_adapter, &cache_plan, &fill_bytes).await; - }); - } else if cache_fill_allowed { - let _ = fill_get_object_body_cache_from_buffered_body(cache_adapter, &cache_plan, &buffered_body).await; - } - - return Ok(Self::build_memory_bytes_blob( - buffered_body, - response_content_length, - GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, - lifecycle, - )); - } - - let should_materialize_for_cache = cache_adapter.materialize_fill_enabled() - && cache_fill_allowed - && matches!(cache_plan, GetObjectBodyCachePlan::Cacheable(_)) - && should_materialize_get_object_body_for_cache( - info, - response_content_length, - part_number, - has_range, - concurrent_requests, - ); - - if should_materialize_for_cache { - let Ok(materialized_capacity) = usize::try_from(response_content_length) else { - warn!( - expected = response_content_length, - "GetObject materialize-fill skipped because content length is not representable" - ); - return Self::build_get_object_body( - final_stream, - info, - response_content_length, - request_id, - content_range, - optimal_buffer_size, - enable_readahead, - concurrent_requests, - part_number, - has_range, - encryption_applied, - None, - bucket, - key, - lifecycle, - resume, - ) - .await; - }; - // ODC-07 / #1324: share the strict exact-length materialization gate - // with the encrypted and seek memory branches. The helper bounds the - // read to `capacity + 1` (so an over-long stream is detected without - // buffering it unbounded), rejects short and over-long reads, and on a - // partial-read error refuses to reuse the consumed reader. - match strict_materialize_object_body( - final_stream, - materialized_capacity, - GET_OBJECT_STAGE_BODY_CACHE_MATERIALIZE_READ, - ) - .await - { - Ok(buf) => { - let bytes = Bytes::from(buf); - // ODC-15: fill off the response's critical path (see the - // buffered-body branch above). - let cache_adapter = cache_adapter.clone(); - let cache_plan = cache_plan.clone(); - let fill_bytes = bytes.clone(); - tokio::spawn(async move { - let _ = fill_get_object_body_cache_from_materialized_body(&cache_adapter, &cache_plan, &fill_bytes).await; - }); - - return Ok(Self::build_memory_bytes_blob( - bytes, - response_content_length, - GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE_MATERIALIZED, - lifecycle, - )); - } - Err(e) => { - lifecycle.finish_err(); - error!(error = %e, "GetObject materialize-fill strict materialization failed"); - // A short/over-long body would ship a truncated or over-long - // response; a partial-read error leaves the stream consumed so - // falling back to streaming would send a prefix-misaligned - // body. Both fail the request. - return Err(e.into_s3_error(response_content_length)); - } - } - } - - Self::build_get_object_body( - final_stream, - info, - response_content_length, - request_id, - content_range, - optimal_buffer_size, - enable_readahead, - concurrent_requests, - part_number, - has_range, - encryption_applied, - None, - bucket, - key, - lifecycle, - resume, - ) - .await - } - - fn put_object_execution_context(req: &S3Request) -> (EventName, QuotaOperation, &'static str) { - if req.extensions.get::().is_some() { - (put_event_name_for_post_object(true), QuotaOperation::PostObject, "POST") - } else { - (put_event_name_for_post_object(false), QuotaOperation::PutObject, "PUT") - } - } - - #[instrument(name = "execute_put_object", level = "info", skip(self, _fs, req))] - pub async fn execute_put_object(&self, _fs: &FS, req: S3Request) -> S3Result> { - self.execute_put_object_boxed(_fs, req).await - } - - fn execute_put_object_boxed<'a>( - &'a self, - _fs: &'a FS, - req: S3Request, - ) -> impl std::future::Future>> + Send + 'a { - Box::pin(self.execute_put_object_inner(_fs, req)) - } - - #[hotpath::measure( - label = "rustfs::app::object_usecase::DefaultObjectUsecase::execute_put_object", - impl_type = "DefaultObjectUsecase" - )] - async fn execute_put_object_inner(&self, _fs: &FS, req: S3Request) -> S3Result> { - let start_time = std::time::Instant::now(); - let mut req = req; - - if let Some(context) = &self.context { - let _ = context.object_store(); - } - - let (event_name, quota_operation, request_method_name) = Self::put_object_execution_context(&req); - if req.extensions.get::().is_some() && is_post_object_sse_kms_requested(&req.input, &req.headers) - { - return Err(s3_error!(NotImplemented, "SSE-KMS is not supported for POST object uploads")); - } - if let Some(ref storage_class) = req.input.storage_class - && !is_valid_storage_class(storage_class.as_str()) - { - return Err(s3_error!(InvalidStorageClass)); - } - // An authorized inbound replication PUT must store the replica verbatim. - // A snowball-extracted member object keeps `x-amz-meta-snowball-auto-extract` - // in its user metadata, and the replication client replays stored metadata - // as headers — re-dispatching that PUT into the extract path would try to - // untar the member's own bytes (failing replication for any non-archive - // member) instead of writing the replica. - let inbound_replication_put = replication_request_authorized(&req) - && get_header(&req.headers, SUFFIX_SOURCE_REPLICATION_REQUEST).as_deref() == Some("true"); - if is_put_object_extract_requested(&req.headers) && !inbound_replication_put { - return Box::pin(self.execute_put_object_extract(req)).await; - } - // SSE-C ciphertext passthrough (authorized replication only): the body - // is already ciphertext and must be stored verbatim — no compression, - // no bucket-default encryption. - let ciphertext_passthrough = - inbound_replication_put && rustfs_utils::http::ssec_transport_to_stored_metadata(&req.headers).is_some(); - - let input = std::mem::take(&mut req.input); - - let PutObjectInput { - body, - bucket, - cache_control, - key, - content_length, - content_disposition, - content_encoding, - content_language, - content_type, - expires, - tagging, - metadata, - version_id, - server_side_encryption, - sse_customer_algorithm, - sse_customer_key, - sse_customer_key_md5, - ssekms_key_id, - content_md5, - object_lock_legal_hold_status, - object_lock_mode, - object_lock_retain_until_date, - storage_class, - website_redirect_location, - .. - } = input; - - // Merge SSE-C params from headers (fallback when S3 layer does not populate input) - let (h_algo, h_key, h_md5) = extract_ssec_params_from_headers(&req.headers)?; - let sse_customer_algorithm = sse_customer_algorithm.or(h_algo); - let sse_customer_key = sse_customer_key.or(h_key); - let sse_customer_key_md5 = sse_customer_key_md5.or(h_md5); - - // Merge server_side_encryption from headers (fallback when S3 layer does not populate input) - let server_side_encryption = server_side_encryption.or(extract_server_side_encryption_from_headers(&req.headers)?); - - // Validate object key - validate_object_key(&key, request_method_name)?; - validate_table_catalog_object_mutation(&bucket, &key).await?; - - // Validate archive content encoding (reject when strict mode is enabled) - validate_archive_content_encoding( - &key, - req.headers.get("content-type").and_then(|value| value.to_str().ok()), - req.headers.get("content-encoding").and_then(|value| value.to_str().ok()), - )?; - - let Some(body) = body else { return Err(s3_error!(IncompleteBody)) }; - - // Guard against a proxy/CDN that forwards a partial body then goes silent - // without closing the connection: bound the inter-chunk wait so the read - // fails (with a diagnostic log) instead of hanging forever (issue #3076). - let body = { - let request_id = req - .extensions - .get::() - .map(|ctx| ctx.request_id.clone()) - .unwrap_or_default(); - guard_put_object_body_read_timeout(body, &bucket, &key, &request_id, content_length, put_object_body_read_timeout()) - }; - - // Resolve the authoritative decoded/plain object length (rejecting negative/unknown) before anything else consumes it. - let mut size = resolve_put_object_authoritative_size(&req.headers, content_length)?; - - // The app check preserves the existing S3 error contract; the storage - // commit path reserves the exact net logical growth under its locks. - let quota_check = self - .check_bucket_quota( - &bucket, - quota_operation, - u64::try_from(size).map_err(|_| S3Error::new(S3ErrorCode::UnexpectedContent))?, - ) - .await?; - let quota_enabled = quota_check.as_ref().is_some_and(|result| result.quota_limit.is_some()); - if quota_enabled && ciphertext_passthrough { - return Err(S3Error::with_message( - S3ErrorCode::InvalidRequest, - "SSE-C ciphertext replication is unavailable for quota-enabled buckets".to_string(), - )); - } - - let put_stage_metrics_enabled = rustfs_io_metrics::put_stage_metrics_enabled(); - let ingress_stage_start = put_stage_metrics_enabled.then(Instant::now); - let should_compress = - is_disk_compressible(&req.headers, &key) && size > MIN_DISK_COMPRESSIBLE_SIZE as i64 && !ciphertext_passthrough; - let server_side_encryption_requested = - server_side_encryption.is_some() || sse_customer_algorithm.is_some() || ssekms_key_id.is_some(); - - // Resolve the store through the request-bound server context - // (backlog#1052 S6), not the process-global handle, so an embedded - // second server never writes into the first server's store. - let Some(store) = self.object_store() else { - return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); - }; - let bucket_validate_stage_start = put_stage_metrics_enabled.then(Instant::now); - validate_bucket_exists(&store, &bucket).await?; - rustfs_io_metrics::record_put_object_stage_duration_from("app_bucket_validate", bucket_validate_stage_start); - - let put_admission = match get_concurrency_manager() - .admit_put_object() - .await - .map_err(|_| s3_error!(InternalError, "foreground write admission closed"))? - { - PutObjectAdmission::Disabled => None, - PutObjectAdmission::Admitted(permit) => { - counter!("rustfs.put_object.foreground_admission.total", "result" => "admitted").increment(1); - Some(permit) - } - PutObjectAdmission::Rejected => { - counter!("rustfs.put_object.foreground_admission.total", "result" => "rejected").increment(1); - return Err(s3_error!( - SlowDown, - "foreground write concurrency limit reached, please reduce your request rate" - )); - } - }; - - let mut put_request_guard = PutObjectGuard::new(); - let concurrent_put_requests = PutObjectGuard::concurrent_requests(); - - // Apply adaptive buffer sizing based on file size for optimal streaming performance. - // Uses workload profile configuration (enabled by default) to select appropriate buffer size. - // Buffer sizes range from 32KB to 4MB depending on file size and configured workload profile. - // Concurrency-aware adjustment reduces buffer size under high PUT concurrency to lower memory pressure. - let base_buffer_size = get_buffer_size_opt_in(size); - let use_large_put_concurrency_tuning = Self::should_use_large_put_concurrency_tuning(size); - let buffer_size = if use_large_put_concurrency_tuning { - get_put_concurrency_aware_buffer_size(size, base_buffer_size) - } else { - base_buffer_size - }; - - // Detect zero-copy opportunity before encryption/compression decisions - // Zero-copy is beneficial for large unencrypted, uncompressed objects - let enable_zero_copy = should_use_zero_copy(size, &req.headers); - - if enable_zero_copy { - // Record zero-copy write attempt - counter!("rustfs_zero_copy_write_attempts_total").increment(1); - histogram!("rustfs_zero_copy_write_size_bytes").record(size as f64); - debug!("Zero-copy write enabled for {} byte object (bucket={}, key={})", size, bucket, key); - } - - let use_empty_or_small_eager_put_path = size == 0 - || should_use_small_eager_put_path(size, &req.headers, server_side_encryption_requested, should_compress, false); - let zero_copy_eager_put_path_status = - zero_copy_eager_put_path_status(size, &req.headers, server_side_encryption_requested, should_compress, false); - let use_zero_copy_eager_put_path = zero_copy_eager_put_path_status == PUT_EAGER_STATUS_ELIGIBLE; - if use_zero_copy_eager_put_path { - counter!(buffered_write::ATTEMPTS_TOTAL).increment(1); - histogram!(buffered_write::ATTEMPT_SIZE_BYTES).record(size as f64); - } - let put_path = if should_compress { - "stream_compressed" - } else if use_zero_copy_eager_put_path { - "zero_copy_eager" - } else if use_empty_or_small_eager_put_path { - "small_eager" - } else { - "streaming" - }; - rustfs_io_metrics::record_put_object_diagnostics( - put_path, - zero_copy_eager_put_path_status, - size, - buffer_size, - use_large_put_concurrency_tuning, - ); - - let sse_config_stage_start = put_stage_metrics_enabled.then(Instant::now); - let bucket_sse_config = metadata_sys::get_sse_config(&bucket).await.ok(); - rustfs_io_metrics::record_put_object_stage_duration_from("app_sse_config_lookup", sse_config_stage_start); - debug!( - target: "rustfs::app::object_usecase", - component = "app", - subsystem = "object", - event = "bucket_sse_config_lookup", - bucket = %bucket, - found = bucket_sse_config.is_some(), - "Bucket SSE configuration lookup completed" - ); - - let original_sse = server_side_encryption.clone(); - let (mut effective_sse, mut effective_kms_key_id) = resolve_bucket_default_sse( - bucket_sse_config.as_ref().map(|(config, _timestamp)| config), - server_side_encryption, - ssekms_key_id, - false, - ); - debug!( - target: "rustfs::app::object_usecase", - component = "app", - subsystem = "object", - event = "effective_sse_resolved", - bucket = %bucket, - requested = ?original_sse, - effective = ?effective_sse, - "Resolved effective SSE configuration" - ); - - if ciphertext_passthrough { - // The replica keeps the source's SSE-C metadata; the bucket - // default must not claim managed encryption on it. - effective_sse = None; - effective_kms_key_id = None; - } - - // Validate SSE-C headers early: reject partial/invalid combinations per S3 spec - validate_sse_headers_for_write( - effective_sse.as_ref(), - effective_kms_key_id.as_ref(), - extract_ssekms_context_from_headers(&req.headers)?.as_ref(), - sse_customer_algorithm.as_ref(), - sse_customer_key.as_ref(), - sse_customer_key_md5.as_ref(), - true, // PutObject requires all three: algorithm, key, key_md5 - )?; - - let mut metadata = metadata.unwrap_or_default(); - let has_explicit_object_lock_retention = object_lock_mode.is_some() - || object_lock_retain_until_date.is_some() - || has_replication_retention_update(&req.headers, inbound_replication_put); - let object_lock_config_stage_start = put_stage_metrics_enabled.then(Instant::now); - let object_lock_config_state = load_bucket_object_lock_config_state(&bucket).await?; - rustfs_io_metrics::record_put_object_stage_duration_from("app_object_lock_config_lookup", object_lock_config_stage_start); - apply_put_request_metadata( - &mut metadata, - &req.headers, - &key, - cache_control, - content_disposition, - content_encoding, - content_language, - content_type, - expires, - website_redirect_location, - tagging, - storage_class.clone(), - )?; - apply_bucket_default_lock_retention( - &bucket, - &object_lock_config_state, - &mut metadata, - has_explicit_object_lock_retention, - )?; - - let put_opts_stage_start = put_stage_metrics_enabled.then(Instant::now); - let mut opts: ObjectOptions = put_opts_with_replication_authorization( - &bucket, - &key, - version_id.clone(), - &req.headers, - metadata.clone(), - replication_request_authorized(&req), - ) - .await - .map_err(ApiError::from)?; - if let Some(quota_check) = quota_check.as_ref() { - apply_quota_admission(&mut opts, quota_check)?; - } - rustfs_io_metrics::record_put_object_stage_duration_from("app_put_opts_build", put_opts_stage_start); - apply_bucket_generation_guard(&req, &bucket, &mut opts)?; - apply_put_request_object_lock_opts( - &bucket, - &object_lock_config_state, - object_lock_legal_hold_status, - object_lock_mode, - object_lock_retain_until_date, - &mut opts, - )?; - let eager_put_commit_cancellation = - (use_zero_copy_eager_put_path || use_empty_or_small_eager_put_path).then(tokio_util::sync::CancellationToken::new); - opts.put_object_cancellation = eager_put_commit_cancellation.clone(); - - // rustfs/backlog#1009: the pre-PUT lookup has exactly two consumers — - // the existing-object WORM validation and usage accounting's - // previous_current_size. When the bucket has no object locking (WORM is - // a provable no-op; the gate fails closed on metadata errors) and the - // PUT targets the latest version (no explicit version_id from internal - // replication), the lookup is skipped and accounting is backfilled from - // the dst xl.meta that rename_data already reads, saving a full-disk - // metadata fanout per PUT. - let prelookup_required = version_id.is_some() || object_lock_checks_required_for_state(&object_lock_config_state); - // Outer None = prelookup skipped (accounting comes from the commit - // backfill); Some(inner) = the previous current size as observed by the - // lookup, with the pre-#1009 semantics kept bit-for-bit. - let prelookup_stage_start = (prelookup_required && put_stage_metrics_enabled).then(Instant::now); - let prelookup_previous_current_size: Option> = if prelookup_required { - let current_opts: ObjectOptions = internal_object_info_lookup_opts( - get_opts(&bucket, &key, version_id.clone(), None, &req.headers) - .await - .map_err(ApiError::from)?, - ); - let previous_current_info = { - crate::hp_guard!("S3::put_object_prelookup"); - store.get_object_info(&bucket, &key, ¤t_opts).await - }; - Some(match previous_current_info { - Ok(existing_obj_info) => { - validate_existing_object_lock_for_write(&object_lock_config_state, &existing_obj_info, &opts)?; - Some(if quota_enabled { - quota_object_size(&existing_obj_info).map_err(ApiError::from)? - } else { - existing_obj_info.size.max(0) as u64 - }) - } - Err(err) => { - if !is_err_object_not_found(&err) && !is_err_version_not_found(&err) { - return Err(ApiError::from(err).into()); - } - None - } - }) - } else { - None - }; - rustfs_io_metrics::record_put_object_stage_duration_from("app_prelookup", prelookup_stage_start); - - let actual_size = size; - if !ciphertext_passthrough && let Some(quota_check) = quota_check.as_ref() { - ensure_object_size_within_quota( - quota_check, - u64::try_from(actual_size).map_err(|_| S3Error::new(S3ErrorCode::UnexpectedContent))?, - )?; - } - - let mut md5hex = if let Some(base64_md5) = content_md5 { - let md5 = base64_simd::STANDARD - .decode_to_vec(base64_md5.as_bytes()) - .map_err(|e| ApiError::from(StorageError::other(format!("Invalid content MD5: {e}"))))?; - Some(hex_simd::encode_to_string(&md5, hex_simd::AsciiCase::Lower)) - } else { - None - }; - - let mut sha256hex = get_content_sha256_with_query(&req.headers, req.uri.query()); - - let mut write_plan = WritePlan::new(); - // Additional-checksum (XXHash3/64/128, SHA-512) values to echo on the PutObject - // response (#1256); captured at want_checksum set points before opts is moved. - let mut put_extra_checksum_headers: Vec<(&'static str, String)> = Vec::new(); - let mut reader = if should_compress { - let body = tokio::io::BufReader::with_capacity( - buffer_size, - StreamReader::new(body.map(|f| f.map_err(s3s_body_error_to_io))), - ); - let algorithm = CompressionAlgorithm::default(); - insert_str(&mut metadata, SUFFIX_COMPRESSION, compression_metadata_value(algorithm)); - insert_str(&mut metadata, SUFFIX_ACTUAL_SIZE, size.to_string()); - - let mut hrd = - HashReader::from_stream(body, size, size, md5hex.take(), sha256hex.take(), false).map_err(ApiError::from)?; - - if let Err(err) = hrd.add_checksum_from_s3s(&req.headers, req.trailing_headers.clone(), false) { - return Err(ApiError::from(err).into()); - } - - opts.want_checksum = hrd.checksum(); - put_extra_checksum_headers = additional_checksum_echo_pairs(&opts.want_checksum); - insert_str(&mut opts.user_defined, SUFFIX_COMPRESSION, compression_metadata_value(algorithm)); - insert_str(&mut opts.user_defined, SUFFIX_ACTUAL_SIZE, size.to_string()); - - size = HashReader::SIZE_PRESERVE_LAYER; - write_plan = write_plan.with_compression(algorithm); - hrd - } else { - if use_zero_copy_eager_put_path { - let zero_copy_start = std::time::Instant::now(); - let eager_body = read_zero_copy_put_body_exact(body, actual_size as usize).await?; - rustfs_io_metrics::record_zero_copy_write(actual_size as usize, zero_copy_start.elapsed().as_secs_f64() * 1000.0); - HashReader::from_stream(eager_body, size, actual_size, md5hex, sha256hex, false).map_err(ApiError::from)? - } else if use_empty_or_small_eager_put_path { - if (actual_size as usize) <= POOL_BYPASS_MAX_SIZE { - // Bypass BytesPool for very small objects to avoid Small-tier - // Mutex contention under high concurrency. Direct allocation - // for ≤4KiB is negligible cost. - let eager_body = read_small_put_body_exact_direct( - StreamReader::new(body.map(|f| f.map_err(s3s_body_error_to_io))), - actual_size as usize, - ) - .await?; - HashReader::from_stream(eager_body, size, actual_size, md5hex, sha256hex, false).map_err(ApiError::from)? - } else { - let pool = get_concurrency_manager().bytes_pool(); - let eager_body = read_small_put_body_exact_pooled( - StreamReader::new(body.map(|f| f.map_err(s3s_body_error_to_io))), - actual_size as usize, - pool.as_ref(), - ) - .await?; - let eager_reader = PooledBufferReader::new(eager_body, actual_size as usize); - HashReader::from_stream(eager_reader, size, actual_size, md5hex, sha256hex, false).map_err(ApiError::from)? - } - } else { - let body = tokio::io::BufReader::with_capacity( - buffer_size, - StreamReader::new(body.map(|f| f.map_err(s3s_body_error_to_io))), - ); - HashReader::from_stream(body, size, actual_size, md5hex, sha256hex, false).map_err(ApiError::from)? - } - }; - - if size >= 0 { - if let Err(err) = reader.add_checksum_from_s3s(&req.headers, req.trailing_headers.clone(), false) { - return Err(ApiError::from(err).into()); - } - - opts.want_checksum = reader.checksum(); - put_extra_checksum_headers = additional_checksum_echo_pairs(&opts.want_checksum); - } - rustfs_io_metrics::record_put_object_path(put_path); - rustfs_io_metrics::record_put_object_stage_duration_from("ingress_prepare", ingress_stage_start); - - let mut helper = OperationHelper::new(&req, event_name, S3Operation::PutObject); - let ssekms_context = extract_ssekms_context_from_headers(&req.headers)?; - - // Apply encryption using unified SSE API. - let encryption_stage_start = put_stage_metrics_enabled.then(Instant::now); - let write_principal = SseKmsPrincipal::from_request(&req); - let encryption_request = EncryptionRequest { - bucket: &bucket, - key: &key, - server_side_encryption: effective_sse.clone(), - ssekms_key_id: effective_kms_key_id.clone(), - ssekms_context, - sse_customer_algorithm: sse_customer_algorithm.clone(), - sse_customer_key, - sse_customer_key_md5: sse_customer_key_md5.clone(), - content_size: actual_size, - principal: write_principal.as_ref(), - }; - - // SSE-C ciphertext passthrough must skip sse_encryption entirely: an - // explicit guard is required because prepare_sse_configuration inside - // it falls back to the bucket default encryption config and would - // double-encrypt the already-encrypted body. - let encryption_material = if opts.preserve_ciphertext { - None - } else { - match sse_encryption(encryption_request).await { - Ok(material) => material, - Err(err) => { - let result = Err(err.into()); - let _ = helper.complete(&result); - return result; - } - } - }; - - if let Some(material) = encryption_material { - effective_sse = Some(material.server_side_encryption.clone()); - effective_kms_key_id = material.kms_key_id.clone(); - - write_plan = write_plan.with_encryption(material.write_encryption(None)); - - let encryption_metadata = encryption_material_to_metadata(&material)?; - metadata.extend(encryption_metadata.clone()); - opts.user_defined.extend(encryption_metadata); - } - - reader = write_plan.apply(reader, actual_size).map_err(ApiError::from)?; - rustfs_io_metrics::record_put_object_stage_duration_from("app_encryption_prepare", encryption_stage_start); - - let reader = PutObjReader::new(reader); - - let mt2 = metadata.clone(); - opts.user_defined.extend(metadata); - let request_context = req.extensions.get::().cloned(); - let request_id = request_context - .as_ref() - .map(|ctx| ctx.request_id.clone()) - .unwrap_or_else(|| request_context::RequestContext::fallback().request_id); - - // Compute the replication decision exactly once per PUT. The same - // immutable `dsc` drives both the pending metadata written below and the - // post-commit schedule (see the reuse site further down), so a - // replication-config hot update can no longer split the two phases - // (https://github.com/rustfs/backlog/issues/1320). - let replication_decision_stage_start = put_stage_metrics_enabled.then(Instant::now); - let dsc = - must_replicate_object(&bucket, &key, &mt2, "".to_string(), opts.delete_marker_replication_status(), opts.clone()) - .await; - rustfs_io_metrics::record_put_object_stage_duration_from("app_replication_decision", replication_decision_stage_start); - - if dsc.replicate_any() { - insert_str(&mut opts.user_defined, SUFFIX_REPLICATION_TIMESTAMP, jiff::Zoned::now().to_string()); - insert_str( - &mut opts.user_defined, - SUFFIX_REPLICATION_STATUS, - dsc.pending_status().unwrap_or_default(), - ); - } - - let cache_adapter = self.object_data_cache(); - let cache_invalidate_before_stage_start = put_stage_metrics_enabled.then(Instant::now); - let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &key).await; - rustfs_io_metrics::record_put_object_stage_duration_from( - "app_cache_invalidate_before", - cache_invalidate_before_stage_start, - ); - - let store_put_watchdog = tokio_util::sync::CancellationToken::new(); - spawn_traced({ - let store_put_watchdog = store_put_watchdog.clone(); - let request_id = request_id.clone(); - let bucket = bucket.clone(); - let key = key.clone(); - let put_path = put_path.to_string(); - async move { - tokio::select! { - _ = store_put_watchdog.cancelled() => {} - _ = tokio::time::sleep(PUT_OBJECT_STORE_WARN_THRESHOLD) => { - warn!( - target: "rustfs::app::object_usecase", - event = EVENT_PUT_OBJECT_STORE_INFLIGHT_SLOW, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - request_id = %request_id, - bucket = %bucket, - key = %key, - put_path = %put_path, - object_size = actual_size, - threshold_ms = PUT_OBJECT_STORE_WARN_THRESHOLD.as_millis() as u64, - state = "store_put_pending", - "PutObject store write remains in flight" - ); - } - } - } - }); - - let object_traffic_health = if use_zero_copy_eager_put_path || use_empty_or_small_eager_put_path { - self.object_traffic_health() - } else { - None - }; - let put_commit = spawn_traced_join({ - let store = Arc::clone(&store); - let bucket = bucket.clone(); - let key = key.clone(); - let opts = opts.clone(); - let cache_adapter = cache_adapter.clone(); - let request_id = request_id.clone(); - let put_path = put_path.to_string(); - let put_admission = put_admission; - async move { - let _put_admission = put_admission; - let object_traffic_progress = object_traffic_health - .as_deref() - .and_then(ObjectTrafficHealth::track_write_storage); - let mut reader = reader; - let store_put_stage_start = put_stage_metrics_enabled.then(Instant::now); - let (obj_info, backfilled_old_current_size) = match store - .put_object_with_old_current_size(&bucket, &key, &mut reader, &opts) - .await - .map_err(ApiError::from) - { - Ok(obj_info) => { - store_put_watchdog.cancel(); - debug!( - target: "rustfs::app::object_usecase", - event = EVENT_PUT_OBJECT_STORE_RETURNED, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - request_id = %request_id, - bucket = %bucket, - key = %key, - put_path = %put_path, - object_size = actual_size, - duration_ms = start_time.elapsed().as_millis() as u64, - result = "success", - "PutObject store write returned" - ); - obj_info - } - Err(err) => { - store_put_watchdog.cancel(); - rustfs_io_metrics::record_put_object_stage_duration_from("app_store_put", store_put_stage_start); - warn!( - target: "rustfs::app::object_usecase", - event = EVENT_PUT_OBJECT_STORE_RETURNED, - component = LOG_COMPONENT_APP, - subsystem = LOG_SUBSYSTEM_OBJECT, - request_id = %request_id, - bucket = %bucket, - key = %key, - put_path = %put_path, - object_size = actual_size, - duration_ms = start_time.elapsed().as_millis() as u64, - result = "error", - error = %err, - "PutObject store write returned" - ); - return Err(err.into()); - } - }; - rustfs_io_metrics::record_put_object_stage_duration_from("app_store_put", store_put_stage_start); - drop(_put_admission); - drop(object_traffic_progress); - #[cfg(test)] - wait_for_put_post_store_test_hook(&bucket).await; - - let post_store_stage_start = put_stage_metrics_enabled.then(Instant::now); - maybe_enqueue_transition_immediate(&obj_info, LcEventSrc::S3PutObject).await; - let _ = invalidate_object_data_cache_after_put_success(&cache_adapter, &bucket, &key).await; - - let put_versioned = BucketVersioningSys::prefix_enabled(&bucket, &key).await; - // Fast in-memory update for immediate quota and admin usage consistency. - // The previous current size comes from the prelookup when it ran, - // otherwise from the rename_data backfill (rustfs/backlog#1009); the - // backfill reproduces the lookup's observation bit for bit (latest - // version's ObjectInfo.size — 0 for a delete-marker latest — or - // not-found → None). - let committed_size = quota_accounting_object_size(&obj_info, quota_enabled)?; - match prelookup_previous_current_size.or_else(|| previous_current_size_from_backfill(backfilled_old_current_size)) - { - Some(previous_current_size) => { - if put_versioned { - record_bucket_object_version_write_memory(&bucket, previous_current_size, committed_size).await; - } else { - record_bucket_object_write_memory(&bucket, previous_current_size, committed_size).await; - } - } - None => { - // Neither source could determine the previous state (peers - // predating the backfill field during a rolling upgrade, or - // sub-quorum metadata divergence). Record the components that - // are correct regardless; the next authoritative scanner - // refresh replaces the in-memory numbers. - debug!( - target: "rustfs::app::object_usecase", - bucket = %bucket, - key = %key, - put_versioned, - "put_object old-size backfill unknown; recording degraded usage delta" - ); - record_bucket_object_write_unknown_previous_memory(&bucket, committed_size, put_versioned).await; - } - } - - if dsc.replicate_any() { - schedule_object_replication(obj_info.clone(), store, dsc).await; - } - - rustfs_scanner::record_dirty_usage_bucket(&bucket); - rustfs_io_metrics::record_put_object_stage_duration_from("app_post_store_bookkeeping", post_store_stage_start); - - let capacity_update_stage_start = put_stage_metrics_enabled.then(Instant::now); - let manager = get_capacity_manager(); - manager.record_write_operation().await; - rustfs_io_metrics::record_put_object_stage_duration_from("app_capacity_update", capacity_update_stage_start); - - Ok::<_, S3Error>(PutObjectCommitResult { obj_info, put_versioned }) - } - }); - let put_commit_result = if let Some(cancellation) = eager_put_commit_cancellation { - EagerPutCommitOwner::new(put_commit, cancellation, EAGER_PUT_COMMIT_CANCELLATION_GRACE) - .join() - .await - } else { - put_commit.await - }; - let PutObjectCommitResult { obj_info, put_versioned } = match put_commit_result { - Ok(Ok(result)) => result, - Ok(Err(err)) => { - let result: S3Result> = Err(err); - put_request_guard.finish_err(); - let _ = helper.complete(&result); - return result; - } - Err(err) => { - let result: S3Result> = Err(S3Error::with_message( - S3ErrorCode::InternalError, - format!("put object commit owner task failed: {err}"), - )); - put_request_guard.finish_err(); - let _ = helper.complete(&result); - return result; - } - }; - - let raw_version = obj_info.version_id.map(|v| v.to_string()); - - helper = helper.object(obj_info.clone()); - if let Some(version_id) = &raw_version { - helper = helper.version_id(version_id.clone()); - } - - let put_version = if put_versioned { raw_version } else { None }; - - let e_tag = obj_info.etag.clone().map(|etag| to_s3s_etag(&etag)); - - let expiration = resolve_put_object_expiration(&bucket, &obj_info).await; - - let mut checksums = PutObjectChecksums { - crc32: input.checksum_crc32, - crc32c: input.checksum_crc32c, - sha1: input.checksum_sha1, - sha256: input.checksum_sha256, - crc64nvme: input.checksum_crc64nvme, - }; - apply_trailing_checksums( - input.checksum_algorithm.as_ref().map(|a| a.as_str()), - &req.trailing_headers, - &mut checksums, - ); - - let output = PutObjectOutput { - e_tag, - server_side_encryption: effective_sse, - sse_customer_algorithm: sse_customer_algorithm.clone(), - sse_customer_key_md5: sse_customer_key_md5.clone(), - ssekms_key_id: effective_kms_key_id, - expiration, - checksum_crc32: checksums.crc32, - checksum_crc32c: checksums.crc32c, - checksum_sha1: checksums.sha1, - checksum_sha256: checksums.sha256, - checksum_crc64nvme: checksums.crc64nvme, - version_id: put_version, - ..Default::default() - }; - - // For browser-based POST uploads (multipart/form-data), response status/body handling - // is decided by s3s PostObject serializer (success_action_status / redirect semantics). - - let mut response = S3Response::new(output); - // Echo XXHash3/64/128 / SHA-512 checksums that s3s PutObjectOutput has no typed - // field for (#1256). - inject_additional_checksum_headers(&mut response.headers, &put_extra_checksum_headers); - let result = Ok(response); - let _ = helper.complete(&result); - - // Record PutObject metrics via zero-copy-metrics - { - let duration_ms = start_time.elapsed().as_millis() as f64; - rustfs_io_metrics::record_put_object( - duration_ms, - size, - enable_zero_copy, // Track if zero-copy was enabled - ); - } - - debug!( - target: "rustfs::app::object_usecase", - component = "app", - subsystem = "object", - bucket = %bucket, - key = %key, - concurrent_put_requests, - buffer_size, - "PutObject request completed" - ); - - put_request_guard.finish_ok(); - - result - } - - fn finalize_get_object_completion( - wrapper: &RequestTimeoutWrapper, - timeout_config: &GetObjectTimeoutPolicy, - total_duration: Duration, - response_content_length: i64, - optimal_buffer_size: usize, - ) { - rustfs_io_metrics::record_get_object_completion( - total_duration.as_secs_f64(), - response_content_length, - optimal_buffer_size, - ); - - rustfs_io_metrics::record_get_object(total_duration.as_millis() as f64, response_content_length); - - if wrapper.is_timeout() { - warn!( - "GetObject request exceeded timeout: duration={:?} timeout={:?}", - wrapper.elapsed(), - timeout_config.get_object_timeout - ); - rustfs_io_metrics::record_get_object_timeout(None, Some(wrapper.elapsed().as_secs_f64())); - } - - debug!( - "GetObject completed: size={} duration={:?} buffer={}", - response_content_length, total_duration, optimal_buffer_size - ); - } - - fn ensure_get_object_not_timed_out( - wrapper: &RequestTimeoutWrapper, - timeout_config: &GetObjectTimeoutPolicy, - bucket: &str, - key: &str, - stage: GetObjectTimeoutStage, - ) -> S3Result<()> { - if !wrapper.is_timeout() { - return Ok(()); - } - - let timeout_secs = timeout_config.get_object_timeout.as_secs(); - let elapsed_ms = wrapper.elapsed().as_millis(); - - match stage { - GetObjectTimeoutStage::BeforeProcessing => { - warn!( - bucket = %bucket, - key = %key, - timeout_secs, - elapsed_ms, - "GetObject request timed out before processing" - ); - Err(s3_error!(InternalError, "Request timeout before processing")) - } - GetObjectTimeoutStage::DiskPermitWait { permit_wait_duration } => { - warn!( - bucket = %bucket, - key = %key, - wait_ms = permit_wait_duration.as_millis(), - timeout_secs, - elapsed_ms, - "GetObject request timed out while waiting for disk permit" - ); - rustfs_io_metrics::record_get_object_timeout(Some("disk_permit"), Some(wrapper.elapsed().as_secs_f64())); - Err(s3_error!(InternalError, "Request timeout while waiting for disk permit")) - } - GetObjectTimeoutStage::BeforeRead => { - warn!( - bucket = %bucket, - key = %key, - timeout_secs, - elapsed_ms, - "GetObject request timed out before reading object" - ); - rustfs_io_metrics::record_get_object_timeout(Some("before_read"), Some(wrapper.elapsed().as_secs_f64())); - Err(s3_error!(InternalError, "Request timeout before reading object")) - } - } - } - - #[allow(clippy::too_many_arguments)] - async fn finalize_get_object_response( - helper: OperationHelper, - bucket: &str, - method: &hyper::Method, - headers: &HeaderMap, - event_info: Option, - version_id_for_event: String, - output: GetObjectOutput, - extra_checksum_headers: Vec<(&'static str, String)>, - ) -> S3Result> { - let helper = match event_info { - Some(event_info) => helper.object(event_info), - None => helper, - }; - let helper = helper.version_id(version_id_for_event); - let mut response = wrap_response_with_cors(bucket, method, headers, output).await; - inject_accept_ranges_header(&mut response.headers); - // Emit XXHash3/64/128 and SHA-512 checksums that s3s GetObjectOutput cannot - // carry (#1257). This is the download-side integrity path AWS SDKs verify. - inject_additional_checksum_headers(&mut response.headers, &extra_checksum_headers); - let result = Ok(response); - let _ = helper.complete(&result); - result - } - #[allow(clippy::too_many_arguments)] - async fn build_get_object_output_context( - &self, - req: &S3Request, - manager: &ConcurrencyManager, - bucket: &str, - key: &str, - info: ObjectInfo, - event_info: Option, - final_stream: DynReader, - buffered_body: Option, - cache_hook_served: bool, - cache_hook_probed: bool, - cache_fill_allowed: bool, - rs: Option, - content_type: Option, - last_modified: Option, - response_content_length: i64, - content_range: Option, - request_id: &str, - server_side_encryption: Option, - sse_customer_algorithm: Option, - sse_customer_key_md5: Option, - ssekms_key_id: Option, - encryption_applied: bool, - permit_wait_duration: Duration, - queue_utilization: f64, - queue_status: &concurrency::IoQueueStatus, - concurrent_requests: usize, - part_number: Option, - versioned: bool, - lifecycle: GetObjectBodyLifecycle, - resume: F, - ) -> S3Result - where - F: FnOnce(&ObjectInfo) -> Option>, - { - let strategy_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let strategy = self.finalize_get_object_strategy( - manager, - bucket, - key, - &info, - rs.as_ref(), - response_content_length, - permit_wait_duration, - queue_utilization, - queue_status, - concurrent_requests, - ); - record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_OUTPUT_STRATEGY, strategy_start); - let GetObjectStrategyContext { - io_strategy: _, - optimal_buffer_size, - enable_readahead, - } = strategy; - let cache_adapter = self.object_data_cache(); - - let body_build_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let body = Self::build_get_object_body_with_cache( - &cache_adapter, - final_stream, - &info, - response_content_length, - request_id, - content_range.as_deref(), - optimal_buffer_size, - enable_readahead, - concurrent_requests, - part_number, - rs.is_some(), - encryption_applied, - buffered_body, - cache_hook_served, - cache_hook_probed, - cache_fill_allowed, - bucket, - key, - lifecycle, - resume, - ) - .await?; - record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_BUILD, body_build_start); - - let checksum_headers_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let checksums = Self::build_get_object_checksums(&info, &req.headers, part_number, rs.as_ref())?; - record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_CHECKSUM_HEADERS, checksum_headers_start); - - let output_version_id = if versioned { - info.version_id.map(|vid| { - if vid == Uuid::nil() { - "null".to_string() - } else { - vid.to_string() - } - }) - } else { - None - }; - - // x-amz-restore: extract from object metadata - let restore = info.user_defined.get(X_AMZ_RESTORE.as_str()).and_then(|v| { - let rs = parse_restore_obj_status(v).ok()?; - Some(rs.to_string2()) - }); - - // x-amz-expiration: predict from lifecycle configuration - let lifecycle_expiration_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let expiration = resolve_put_object_expiration(bucket, &info).await; - record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_LIFECYCLE_EXPIRATION, lifecycle_expiration_start); - let storage_class = response_storage_class(&info, &info.user_defined); - let cache_control = info.user_defined.get("cache-control").cloned(); - let content_disposition = info.user_defined.get("content-disposition").cloned(); - - let metadata_filter_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let metadata = filter_object_metadata(&info.user_defined); - record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_METADATA_FILTER, metadata_filter_start); - - let output = GetObjectOutput { - body: Some(body), - content_length: Some(response_content_length), - last_modified, - content_type, - content_encoding: info.content_encoding.clone(), - cache_control, - content_disposition, - content_range, - e_tag: info.etag.map(|etag| to_s3s_etag(&etag)), - metadata, - server_side_encryption, - sse_customer_algorithm, - sse_customer_key_md5, - ssekms_key_id, - checksum_crc32: checksums.crc32, - checksum_crc32c: checksums.crc32c, - checksum_sha1: checksums.sha1, - checksum_sha256: checksums.sha256, - checksum_crc64nvme: checksums.crc64nvme, - checksum_type: checksums.checksum_type, - version_id: output_version_id, - restore, - expiration, - storage_class, - ..Default::default() - }; - - Ok(GetObjectOutputContext { - output, - event_info, - response_content_length, - optimal_buffer_size, - extra_checksum_headers: checksums.extra, - }) - } - - /// Headers a proxied read forwards verbatim to the replication target: - /// only the client's SSE-C key family, so the target performs the real - /// SSE-C decryption (never the replication-check exemption). HTTP - /// conditional headers (If-Match & co.) are deliberately NOT forwarded — - /// MinIO does not forward them either, and a remote 304/412 would leak a - /// conditional evaluation against a replica the local site never saw. - /// Range and part-number travel as typed SDK parameters instead. - fn proxy_read_passthrough_headers(headers: &HeaderMap) -> HeaderMap { - const FORWARDED: &[&str] = &[ - "x-amz-server-side-encryption-customer-algorithm", - "x-amz-server-side-encryption-customer-key", - "x-amz-server-side-encryption-customer-key-md5", - ]; - let mut forwarded = HeaderMap::new(); - for name in FORWARDED { - if let Ok(header_name) = http::HeaderName::from_str(name) - && let Some(value) = headers.get(&header_name) - { - forwarded.insert(header_name, value.clone()); - } - } - forwarded - } - - /// True when a proxied SDK call failed because the target does not have - /// the object either (service-level not-found or a raw 404, which also - /// covers NoSuchVersion): the caller tries the next target silently. - fn proxy_sdk_error_is_not_found(err: &aws_sdk_s3::error::SdkError) -> bool { - err.raw_response().is_some_and(|resp| resp.status().as_u16() == 404) - } - - /// Serve a GET whose local read failed with not-found by proxying to the - /// bucket's replication targets (MinIO `proxyGetToReplicationTarget`, - /// backlog#1675 P1-5). Returns None when no target can serve the object; - /// the caller then returns the original local error. - async fn proxy_get_object_to_replication_targets( - req: &S3Request, - bucket: &str, - key: &str, - opts: &ObjectOptions, - ) -> Option { - let targets = get_read_proxy_targets(bucket, key, opts).await; - if targets.is_empty() { - return None; - } - let extra_headers = Self::proxy_read_passthrough_headers(&req.headers); - let range = req - .headers - .get(http::header::RANGE) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned); - let part_number = req.input.part_number; - - for target in targets { - match target - .get_object( - &target.bucket, - key, - opts.version_id.clone(), - range.clone(), - part_number, - extra_headers.clone(), - ) - .await - { - Ok(remote) => { - // MinIO-aligned accounting: one total per proxy attempt - // (targets were available), one failed when no target - // served it — never per target. - record_replication_proxy(bucket, "GetObject", false).await; - return Some(Self::proxy_sdk_get_output_to_s3s(remote)); - } - Err(err) if Self::proxy_sdk_error_is_not_found(&err) => { - debug!(bucket, key, arn = %target.arn, "read proxy: target does not have the object"); - } - Err(err) => { - warn!(bucket, key, arn = %target.arn, error = %err, "read proxy: GET against replication target failed"); - } - } - } - record_replication_proxy(bucket, "GetObject", true).await; - None - } - - /// Serve a HEAD whose local lookup failed with not-found by proxying to - /// the bucket's replication targets (MinIO `proxyHeadToRepTarget`). - async fn proxy_head_object_to_replication_targets( - req: &S3Request, - bucket: &str, - key: &str, - opts: &ObjectOptions, - ) -> Option { - let targets = get_read_proxy_targets(bucket, key, opts).await; - if targets.is_empty() { - return None; - } - let extra_headers = Self::proxy_read_passthrough_headers(&req.headers); - let range = req - .headers - .get(http::header::RANGE) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned); - let part_number = req.input.part_number; - - for target in targets { - match target - .head_object_for_proxy( - &target.bucket, - key, - opts.version_id.clone(), - range.clone(), - part_number, - extra_headers.clone(), - ) - .await - { - Ok(remote) => { - // MinIO-aligned accounting: one total per proxy attempt, - // one failed when no target served it. - record_replication_proxy(bucket, "HeadObject", false).await; - return Some(Self::proxy_sdk_head_output_to_s3s(remote)); - } - Err(err) if Self::proxy_sdk_error_is_not_found(&err) => { - debug!(bucket, key, arn = %target.arn, "read proxy: target does not have the object"); - } - Err(err) => { - warn!(bucket, key, arn = %target.arn, error = %err, "read proxy: HEAD against replication target failed"); - } - } - } - record_replication_proxy(bucket, "HeadObject", true).await; - None - } - - /// Translate a proxied SDK GET response into the s3s output, forwarding - /// the body as a stream (no buffering, no local persistence). - fn proxy_sdk_get_output_to_s3s(remote: aws_sdk_s3::operation::get_object::GetObjectOutput) -> GetObjectOutput { - let body = remote.body; - let body_stream = tokio_util::io::ReaderStream::with_capacity(body.into_async_read(), 64 * 1024); - GetObjectOutput { - body: Some(StreamingBlob::wrap(body_stream)), - content_length: remote.content_length, - content_range: remote.content_range, - content_type: remote.content_type.as_deref().and_then(|v| ContentType::from_str(v).ok()), - content_encoding: remote.content_encoding, - content_disposition: remote.content_disposition, - content_language: remote.content_language, - cache_control: remote.cache_control, - e_tag: remote.e_tag.as_deref().and_then(|v| ETag::from_str(v).ok()), - last_modified: remote - .last_modified - .and_then(|dt| OffsetDateTime::from_unix_timestamp_nanos(dt.as_nanos()).ok()) - .map(Timestamp::from), - metadata: remote.metadata, - version_id: remote.version_id, - server_side_encryption: remote - .server_side_encryption - .map(|sse| ServerSideEncryption::from(sse.as_str().to_string())), - sse_customer_algorithm: remote.sse_customer_algorithm, - sse_customer_key_md5: remote.sse_customer_key_md5, - ssekms_key_id: remote.ssekms_key_id, - parts_count: remote.parts_count, - tag_count: remote.tag_count, - storage_class: remote.storage_class.map(|sc| StorageClass::from(sc.as_str().to_string())), - expiration: remote.expiration, - restore: remote.restore, - checksum_crc32: remote.checksum_crc32, - checksum_crc32c: remote.checksum_crc32_c, - checksum_crc64nvme: remote.checksum_crc64_nvme, - checksum_sha1: remote.checksum_sha1, - checksum_sha256: remote.checksum_sha256, - checksum_type: remote.checksum_type.map(|ct| ChecksumType::from(ct.as_str().to_string())), - ..Default::default() - } - } - - /// Translate a proxied SDK HEAD response into the s3s output. - /// - /// Known gaps: the SDK's HeadObjectOutput does not model 206/Content-Range - /// for a ranged HEAD (the SDK exposes no content_range member on HEAD), - /// and s3s' typed HeadObjectOutput has no tag_count field (the local path - /// injects x-amz-tagging-count as a raw header) — both are dropped for - /// proxied HEADs. - fn proxy_sdk_head_output_to_s3s(remote: aws_sdk_s3::operation::head_object::HeadObjectOutput) -> HeadObjectOutput { - HeadObjectOutput { - content_length: remote.content_length, - content_type: remote.content_type.as_deref().and_then(|v| ContentType::from_str(v).ok()), - content_encoding: remote.content_encoding, - content_disposition: remote.content_disposition, - content_language: remote.content_language, - cache_control: remote.cache_control, - accept_ranges: Some(ACCEPT_RANGES_BYTES.to_string()), - e_tag: remote.e_tag.as_deref().and_then(|v| ETag::from_str(v).ok()), - last_modified: remote - .last_modified - .and_then(|dt| OffsetDateTime::from_unix_timestamp_nanos(dt.as_nanos()).ok()) - .map(Timestamp::from), - metadata: remote.metadata, - version_id: remote.version_id, - server_side_encryption: remote - .server_side_encryption - .map(|sse| ServerSideEncryption::from(sse.as_str().to_string())), - sse_customer_algorithm: remote.sse_customer_algorithm, - sse_customer_key_md5: remote.sse_customer_key_md5, - ssekms_key_id: remote.ssekms_key_id, - parts_count: remote.parts_count, - storage_class: remote.storage_class.map(|sc| StorageClass::from(sc.as_str().to_string())), - expiration: remote.expiration, - restore: remote.restore, - checksum_crc32: remote.checksum_crc32, - checksum_crc32c: remote.checksum_crc32_c, - checksum_crc64nvme: remote.checksum_crc64_nvme, - checksum_sha1: remote.checksum_sha1, - checksum_sha256: remote.checksum_sha256, - checksum_type: remote.checksum_type.map(|ct| ChecksumType::from(ct.as_str().to_string())), - ..Default::default() - } - } - - #[instrument(name = "execute_get_object", level = "trace", skip(self, req))] - pub async fn execute_get_object(&self, req: S3Request) -> S3Result> { - self.execute_get_object_boxed(req).await - } - - fn execute_get_object_boxed( - &self, - req: S3Request, - ) -> impl std::future::Future>> + Send + '_ { - Box::pin(self.execute_get_object_inner(req)) - } - - #[hotpath::measure( - label = "rustfs::app::object_usecase::DefaultObjectUsecase::execute_get_object", - impl_type = "DefaultObjectUsecase" - )] - async fn execute_get_object_inner(&self, req: S3Request) -> S3Result> { - if let Some(context) = &self.context { - let _ = context.object_store(); - } - - let inbound_request_context = req.extensions.get::(); - let request_id = inbound_request_context - .map(|ctx| ctx.request_id.clone()) - .unwrap_or_else(|| request_context::RequestContext::fallback().request_id); - if rustfs_io_metrics::get_stage_metrics_enabled() - && let Some(context) = inbound_request_context - { - rustfs_io_metrics::record_get_object_stage_duration( - GET_OBJECT_STAGE_PATH_S3_HANDLER, - GET_OBJECT_STAGE_REQUEST_INGRESS_TO_CONTEXT, - context.start_time.elapsed().as_secs_f64(), - ); - } - let bootstrap = self.init_get_object_bootstrap(&req.input.bucket, &req.input.key, &request_id)?; - let timeout_config = bootstrap.timeout_config; - let wrapper = bootstrap.wrapper; - let request_start = bootstrap.request_start; - let concurrent_requests = bootstrap.concurrent_requests; - let mut lifecycle = GetObjectBodyLifecycle::tracked(bootstrap.request_guard); - - let helper = OperationHelper::new(&req, EventName::ObjectAccessedGet, S3Operation::GetObject).suppress_event(); - // mc get 3 - - // Cheap request-shape validations run first so invalid requests keep - // their InvalidArgument precedence over bucket existence. - let validated = match Self::validate_get_object_request(&req) { - Ok(validated) => validated, - Err(err) => { - lifecycle.finish_err(); - return Err(err); - } - }; - - // SF05: Store lookup next (5s-TTL bucket-validation cache). Bucket - // existence is established before any bucket-metadata work, so requests - // naming nonexistent buckets fail before the versioning lookup in - // get_opts. The store comes from the request-bound server context - // (backlog#1052 S6), not the process-global handle. - let object_traffic_health = self.object_traffic_health(); - let object_metadata_progress = object_traffic_health - .as_deref() - .and_then(ObjectTrafficHealth::track_read_metadata); - let store_lookup_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let Some(store) = self.object_store() else { - lifecycle.finish_err(); - return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); - }; - if let Err(err) = validate_bucket_exists(&store, &req.input.bucket).await { - lifecycle.finish_err(); - return Err(err); - } - if let Some(store_lookup_start) = store_lookup_start { - rustfs_io_metrics::record_get_object_stage_duration( - "s3_handler", - "store_lookup", - store_lookup_start.elapsed().as_secs_f64(), - ); - } - - let request_context_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let request_context = match Self::prepare_get_object_request_context(validated, &req.headers).await { - Ok(request_context) => request_context, - Err(err) => { - lifecycle.finish_err(); - return Err(err); - } - }; - if let Some(request_context_start) = request_context_start { - rustfs_io_metrics::record_get_object_stage_duration( - "s3_handler", - "request_context", - request_context_start.elapsed().as_secs_f64(), - ); - } - let GetObjectRequestContext { - bucket, - key, - version_id_for_event, - part_number, - rs, - opts, - } = request_context; - drop(object_metadata_progress); - - let manager = get_concurrency_manager(); - - let prepared_read = match self - .prepare_get_object_read_execution( - &req, - manager, - store.clone(), - &wrapper, - &timeout_config, - &bucket, - &key, - rs, - &opts, - part_number, - object_traffic_health, - ) - .await - { - Ok(prepared_read) => prepared_read, - Err(err) => { - // Active-active replication lag window: an object missing - // locally (and only missing — other errors keep their - // semantics) may still be served by proxying the GET to a - // replication target (backlog#1675 P1-5). - if matches!(*err.code(), S3ErrorCode::NoSuchKey | S3ErrorCode::NoSuchVersion) - && let Some(output) = Self::proxy_get_object_to_replication_targets(&req, &bucket, &key, &opts).await - { - lifecycle.finish_ok(); - let mut response = wrap_response_with_cors(&bucket, &req.method, &req.headers, output).await; - inject_accept_ranges_header(&mut response.headers); - let result = Ok(response); - let _ = helper.version_id(version_id_for_event).complete(&result); - return result; - } - lifecycle.finish_err(); - return Err(err); - } - }; - let GetObjectPreparedRead { io_planning, read_setup } = prepared_read; - let GetObjectIoPlanning { - disk_permit, - permit_wait_duration, - queue_status, - queue_utilization, - } = io_planning; - - let GetObjectReadSetup { - info, - final_stream, - buffered_body, - cache_hook_served, - cache_hook_probed, - cache_fill_allowed, - rs, - content_type, - last_modified, - response_content_length, - content_range, - server_side_encryption, - sse_customer_algorithm, - sse_customer_key_md5, - ssekms_key_id, - encryption_applied, - resume_range_start, - resume_range_end, - } = read_setup; - let final_stream = if let Some(disk_permit) = disk_permit { - wrap_reader(DiskReadPermitReader::new(final_stream, disk_permit)) - } else { - final_stream - }; - - // Clone ObjectInfo for event notification only when an event will - // actually be built — the clone is expensive for multipart objects. - let event_info = helper.wants_object_info().then(|| info.clone()); - - let output_build_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now); - let output_context = self - .build_get_object_output_context( - &req, - manager, - &bucket, - &key, - info, - event_info, - final_stream, - buffered_body, - cache_hook_served, - cache_hook_probed, - cache_fill_allowed, - rs, - content_type, - last_modified, - response_content_length, - content_range, - &request_id, - server_side_encryption, - sse_customer_algorithm, - sse_customer_key_md5, - ssekms_key_id, - encryption_applied, - permit_wait_duration, - queue_utilization, - &queue_status, - concurrent_requests, - part_number, - opts.versioned, - lifecycle, - |info| { - Some(get_object_resume_control(GetObjectResumeContext::new( - store, - &bucket, - &key, - opts, - &req.headers, - info, - resume_range_start, - resume_range_end, - ))) - }, - ) - .await; - let output_context = match output_context { - Ok(output_context) => output_context, - Err(err) => return Err(err), - }; - if let Some(output_build_start) = output_build_start { - rustfs_io_metrics::record_get_object_stage_duration( - "s3_handler", - "output_build", - output_build_start.elapsed().as_secs_f64(), - ); - } - let GetObjectOutputContext { - output, - event_info, - response_content_length, - optimal_buffer_size, - extra_checksum_headers, - } = output_context; - - let total_duration = request_start.elapsed(); - Self::finalize_get_object_completion( - &wrapper, - &timeout_config, - total_duration, - response_content_length, - optimal_buffer_size, - ); - - Self::finalize_get_object_response( - helper, - &bucket, - &req.method, - &req.headers, - event_info, - version_id_for_event, - output, - extra_checksum_headers, - ) - .await - } - - pub async fn execute_get_object_attributes( - &self, - req: S3Request, - ) -> S3Result> { - if let Some(context) = &self.context { - let _ = context.object_store(); - } - - let mut helper = - OperationHelper::new(&req, EventName::ObjectAccessedAttributes, S3Operation::GetObjectAttributes).suppress_event(); - let GetObjectAttributesInput { - bucket, - key, - max_parts, - object_attributes, - part_number_marker, - version_id, - sse_customer_key, - sse_customer_key_md5, - .. - } = req.input; - - let Some(store) = self.object_store() else { - return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); - }; - - let mut opts: ObjectOptions = get_opts(&bucket, &key, version_id.clone(), None, &req.headers) - .await - .map_err(ApiError::from)?; - opts.include_part_checksums = object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_PARTS); - - let info = match store.get_object_info(&bucket, &key, &opts).await { - Ok(info) => info, - Err(err) => { - if is_err_object_not_found(&err) || is_err_version_not_found(&err) { - if is_dir_object(&key) { - let has_children = match probe_prefix_has_children(store, &bucket, &key, false).await { - Ok(has_children) => has_children, - Err(e) => { - error!( - "Failed to probe children for object attributes (bucket: {}, key: {}): {}", - bucket, key, e - ); - false - } - }; - let msg = head_prefix_not_found_message(&bucket, &key, has_children); - return Err(S3Error::with_message(S3ErrorCode::NoSuchKey, msg)); - } - return Err(S3Error::new(S3ErrorCode::NoSuchKey)); - } - return Err(ApiError::from(err).into()); - } - }; - - if info.delete_marker { - if opts.version_id.is_none() { - return Err(S3Error::new(S3ErrorCode::NoSuchKey)); - } - return Err(S3Error::new(S3ErrorCode::MethodNotAllowed)); - } - - validate_ssec_for_read(&info.user_defined, sse_customer_key.as_ref(), sse_customer_key_md5.as_ref())?; - - let metadata_map = info.user_defined.clone(); - debug!( - "GetObjectAttributes raw object_attributes={:?}", - object_attributes.iter().map(|value| value.as_str()).collect::>() - ); - - let requested = |name: &'static str| -> bool { object_attributes_requested(&object_attributes, name) }; - let storage_class = - response_storage_class_for_object_attributes(&info, &metadata_map, requested(ObjectAttributes::STORAGE_CLASS)); - - let e_tag = if requested(ObjectAttributes::ETAG) { - info.etag.as_ref().map(|etag| to_s3s_etag(etag)) - } else { - None - }; - - let object_size = if requested(ObjectAttributes::OBJECT_SIZE) { - Some(info.get_actual_size().map_err(ApiError::from)?) - } else { - None - }; - - let checksum = if requested(ObjectAttributes::CHECKSUM) { - let (checksums, is_multipart) = info.decrypt_checksums(0, &req.headers).map_err(ApiError::from)?; - // GetObjectAttributes returns checksums in the XML body, and s3s's Checksum - // type has no field for the additional algorithms, so `extra` cannot be - // surfaced here (unlike the header-based GET/HEAD paths) — an s3s limitation - // tracked for when it gains typed fields. - let ResponseChecksums { - crc32: checksum_crc32, - crc32c: checksum_crc32c, - sha1: checksum_sha1, - sha256: checksum_sha256, - crc64nvme: checksum_crc64nvme, - checksum_type, - .. - } = classify_response_checksums(checksums, is_multipart); - - Some(Checksum { - checksum_crc32, - checksum_crc32c, - checksum_sha1, - checksum_sha256, - checksum_crc64nvme, - checksum_type, - ..Default::default() - }) - } else { - None - }; - let object_parts = if requested(ObjectAttributes::OBJECT_PARTS) && info.is_multipart() { - let params = parse_list_parts_params(part_number_marker, max_parts)?; - let mut parts = Vec::new(); - let mut marker = params.part_number_marker; - let max_parts = params.max_parts; - let mut start_at = 0usize; - - if let Some(marker_value) = marker { - if let Some(index) = info.parts.iter().position(|part| part.number == marker_value) { - start_at = index + 1; - } else { - marker = None; - } - } - - let max_parts: i32 = max_parts.try_into().map_err(|_| { - S3Error::with_message(S3ErrorCode::InvalidArgument, "max-parts value is out of range".to_string()) - })?; - let end = (start_at + params.max_parts).min(info.parts.len()); - let is_truncated = end < info.parts.len(); - - for part in &info.parts[start_at..end] { - let (checksums, is_multipart) = info.decrypt_checksums(part.number, &req.headers).map_err(ApiError::from)?; - // Additional algorithms cannot be surfaced in the ObjectPart XML body - // (s3s has no field); same limitation as the object-level attributes above. - let ResponseChecksums { - crc32: checksum_crc32, - crc32c: checksum_crc32c, - sha1: checksum_sha1, - sha256: checksum_sha256, - crc64nvme: checksum_crc64nvme, - .. - } = classify_response_checksums(checksums, is_multipart); - - let part_size = if part.actual_size > 0 { - part.actual_size - } else { - part.size.try_into().map_err(|_| { - S3Error::with_message(S3ErrorCode::InvalidArgument, "Part size value is out of range".to_string()) - })? - }; - - parts.push(ObjectPart { - checksum_crc32, - checksum_crc32c, - checksum_sha1, - checksum_sha256, - checksum_crc64nvme, - part_number: i32::try_from(part.number).ok(), - size: Some(part_size), - ..Default::default() - }); - } - - let part_number_marker = marker.and_then(|v| i32::try_from(v).ok()); - let next_part_number_marker = parts.last().and_then(|part| part.part_number); - - Some(GetObjectAttributesParts { - is_truncated: Some(is_truncated), - max_parts: Some(max_parts), - next_part_number_marker, - part_number_marker, - parts: Some(parts), - total_parts_count: Some(i32::try_from(info.parts.len()).map_err(|_| { - S3Error::with_message(S3ErrorCode::InvalidArgument, "Part count is out of range".to_string()) - })?), - }) - } else { - None - }; - - let version_id = if BucketVersioningSys::prefix_enabled(&bucket, &key).await { - info.version_id.map(|vid| { - if vid == Uuid::nil() { - "null".to_string() - } else { - vid.to_string() - } - }) - } else { - None - }; - - let output = GetObjectAttributesOutput { - checksum, - delete_marker: if info.delete_marker { Some(true) } else { None }, - e_tag, - last_modified: info.mod_time.map(Timestamp::from), - object_parts, - object_size, - storage_class, - version_id: version_id.clone(), - ..Default::default() - }; - - helper = helper.object(info).version_id(version_id.unwrap_or_default()); - - let result = Ok(S3Response::new(output)); - let _ = helper.complete(&result); - result - } - - pub fn execute_copy_object( - &self, - req: S3Request, - ) -> impl std::future::Future>> + Send + '_ { - Box::pin(self.execute_copy_object_inner(req)) - } - - #[instrument(name = "execute_copy_object", level = "debug", skip(self, req))] - async fn execute_copy_object_inner(&self, req: S3Request) -> S3Result> { - if let Some(context) = &self.context { - let _ = context.object_store(); - } - - let mut helper = OperationHelper::new(&req, EventName::ObjectCreatedCopy, S3Operation::CopyObject); - let CopyObjectInput { - copy_source, - bucket, - key, - version_id: dest_version_id, - server_side_encryption: requested_sse, - ssekms_key_id: requested_kms_key_id, - sse_customer_algorithm, - sse_customer_key, - sse_customer_key_md5, - copy_source_sse_customer_algorithm, - copy_source_sse_customer_key, - copy_source_sse_customer_key_md5, - metadata_directive, - metadata, - tagging, - tagging_directive, - copy_source_if_match, - copy_source_if_none_match, - cache_control, - content_disposition, - content_encoding, - content_language, - content_type, - expires, - website_redirect_location, - object_lock_legal_hold_status, - object_lock_mode, - object_lock_retain_until_date, - storage_class, - checksum_algorithm, - .. - } = req.input.clone(); - let requested_checksum_type = checksum_algorithm - .as_ref() - .map(|algorithm| rustfs_rio::ChecksumType::from_string(algorithm.as_str())); - if requested_checksum_type.is_some_and(|checksum_type| !checksum_type.is_set()) { - return Err(s3_error!(InvalidArgument, "Unsupported checksum algorithm")); - } - let (src_bucket, src_key, version_id) = match copy_source { - CopySource::AccessPoint { .. } => return Err(s3_error!(NotImplemented)), - CopySource::Outpost { .. } => return Err(s3_error!(NotImplemented)), - CopySource::Bucket { - ref bucket, - ref key, - version_id, - } => (bucket.to_string(), key.to_string(), version_id.map(|v| v.to_string())), - }; - - // Normalize the copy-source version id like GET/HEAD do: trim, treat "null" as the - // nil UUID, and reject malformed ids up front (issue #4238). - let version_id = match version_id { - Some(v) => { - let trimmed = v.trim(); - if trimmed.eq_ignore_ascii_case("null") { - Some(Uuid::nil().to_string()) - } else if Uuid::parse_str(trimmed).is_ok() { - Some(trimmed.to_string()) - } else { - return Err(s3_error!(InvalidArgument, "Invalid version id specified in copy source")); - } - } - None => None, - }; - - if let Some(ref sc) = storage_class - && !is_valid_storage_class(sc.as_str()) - { - return Err(s3_error!(InvalidStorageClass)); - } - let ssekms_context = extract_ssekms_context_from_headers(&req.headers)?; - validate_sse_headers_for_write( - requested_sse.as_ref(), - requested_kms_key_id.as_ref(), - ssekms_context.as_ref(), - sse_customer_algorithm.as_ref(), - sse_customer_key.as_ref(), - sse_customer_key_md5.as_ref(), - true, - )?; - let has_explicit_ssec = sse_customer_algorithm.is_some() || sse_customer_key.is_some() || sse_customer_key_md5.is_some(); - - // Validate both source and destination keys - validate_object_key(&src_key, "COPY (source)")?; - validate_object_key(&key, "COPY (dest)")?; - validate_table_catalog_object_mutation(&bucket, &key).await?; - let replaces_metadata = match metadata_directive.as_ref().map(|directive| directive.as_str()) { - None | Some(MetadataDirective::COPY) => false, - Some(MetadataDirective::REPLACE) => true, - Some(_) => { - return Err(S3Error::with_message( - S3ErrorCode::InvalidArgument, - "The MetadataDirective header is invalid".to_string(), - )); - } - }; - let replacement_metadata = if replaces_metadata { - validate_archive_content_encoding(&key, content_type.as_deref(), content_encoding.as_deref())?; - let mut replacement_metadata = metadata.unwrap_or_default(); - namespace_reserved_user_metadata(&mut replacement_metadata); - apply_standard_object_metadata( - &mut replacement_metadata, - cache_control.as_deref(), - content_disposition.as_deref(), - content_encoding.as_deref(), - content_language.as_deref(), - content_type.as_deref(), - expires.as_ref(), - website_redirect_location.as_deref(), - )?; - Some(replacement_metadata) - } else { - None - }; - - // AWS S3 allows self-copy when metadata directive is REPLACE (used to update metadata in-place), - // when an explicit storage class change is requested, or when restoring a specific historical - // version onto the current key (source carries a versionId). Reject only a true no-op self-copy - // where none of these apply (issue #4238). - let replacement_tags = super::storage_api::object_usecase::s3_api::tagging::resolve_copy_object_tags( - tagging.as_deref(), - tagging_directive.as_ref(), - )?; - - if !replaces_metadata - && tagging_directive.as_ref().map(TaggingDirective::as_str) != Some(TaggingDirective::REPLACE) - && storage_class.is_none() - && version_id.is_none() - && src_bucket == bucket - && src_key == key - { - error!(bucket, key, "Rejected self-copy operation"); - return Err(s3_error!( - InvalidRequest, - "Cannot copy an object to itself. Source and destination must be different." - )); - } - - // warn!("copy_object {}/{}, to {}/{}", &src_bucket, &src_key, &bucket, &key); - - let mut src_opts = copy_src_opts(&src_bucket, &src_key, &req.headers).map_err(ApiError::from)?; - - src_opts.version_id = version_id.clone(); - - let mut src_get_opts = ObjectOptions { - version_id: src_opts.version_id.clone(), - versioned: src_opts.versioned, - version_suspended: src_opts.version_suspended, - ..Default::default() - }; - apply_copy_source_bucket_generation_guard(&req, &src_bucket, &mut src_get_opts)?; - - let mut dst_opts = copy_dst_opts_with_replication_authorization( - &bucket, - &key, - dest_version_id.clone(), - &req.headers, - HashMap::new(), - replication_request_authorized(&req), - ) - .await - .map_err(ApiError::from)?; - apply_bucket_generation_guard(&req, &bucket, &mut dst_opts)?; - - let cp_src_dst_same = path_join_buf(&[&src_bucket, &src_key]) == path_join_buf(&[&bucket, &key]); - let expected_current_version_id = expected_current_version_id(&req.headers)?; - if expected_current_version_id.is_some() - && (!cp_src_dst_same || version_id.is_none() || dest_version_id.is_some() || !dst_opts.versioned) - { - return Err(s3_error!( - InvalidRequest, - "Expected current version precondition requires a versioned same-object historical copy that creates a new version" - )); - } - - let Some(store) = self.object_store() else { - return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); - }; - let (source_bucket_lifecycle_guard, destination_bucket_lifecycle_guard_storage) = - acquire_copy_bucket_lifecycle_locks(store.as_ref(), &src_bucket, &bucket).await?; - let current_source_incarnation_id = store - .bucket_incarnation_id_from_disk(&src_bucket) - .await - .map_err(ApiError::from)?; - if src_get_opts - .expected_bucket_incarnation_id - .is_some_and(|expected| expected != current_source_incarnation_id) - { - return Err(ApiError::from(StorageError::BucketNotFound(src_bucket.clone())).into()); - } - let current_destination_incarnation_id = if src_bucket == bucket { - current_source_incarnation_id - } else { - store.bucket_incarnation_id_from_disk(&bucket).await.map_err(ApiError::from)? - }; - if dst_opts - .expected_bucket_incarnation_id - .is_some_and(|expected| expected != current_destination_incarnation_id) - { - return Err(ApiError::from(StorageError::BucketNotFound(bucket.clone())).into()); - } - let destination_bucket_lifecycle_guard = destination_bucket_lifecycle_guard_storage - .as_ref() - .unwrap_or(&source_bucket_lifecycle_guard); - if source_bucket_lifecycle_guard.is_lock_lost() || destination_bucket_lifecycle_guard.is_lock_lost() { - return Err(ApiError::from(StorageError::NamespaceLockQuorumUnavailable { - mode: "copy_bucket_generation", - bucket: bucket.clone(), - object: key.clone(), - required: 1, - achieved: 0, - }) - .into()); - } - src_get_opts.expected_bucket_incarnation_id = Some(current_source_incarnation_id); - dst_opts.expected_bucket_incarnation_id = Some(current_destination_incarnation_id); - if src_bucket != bucket { - dst_opts.add_bucket_lifecycle_lock_guard(&source_bucket_lifecycle_guard); - } - dst_opts.add_bucket_lifecycle_lock_guard(destination_bucket_lifecycle_guard); - - // Bucket metadata uses the bucket name as its namespace-lock key. Load - // every copy-time bucket snapshot before a same-object key can collide - // with that key (for example, copying `bucket/bucket` onto itself). - let bucket_sse_config = metadata_sys::get_sse_config(&bucket).await.ok(); - let object_lock_config_state = load_bucket_object_lock_config_state(&bucket).await?; - if cp_src_dst_same && key == bucket && expected_current_version_id.is_none() { - dst_opts.object_lock_config_snapshot = - Some(store.object_lock_config_snapshot(&bucket).await.map_err(ApiError::from)?); - } - let mut current_opts: ObjectOptions = internal_object_info_lookup_opts( - get_opts(&bucket, &key, dest_version_id.clone(), None, &req.headers) - .await - .map_err(ApiError::from)?, - ); - - let _self_copy_lock_guard = if cp_src_dst_same && expected_current_version_id.is_none() { - let guard = acquire_self_copy_namespace_lock(store.as_ref(), &bucket, &key).await?; - src_opts.no_lock = true; - src_get_opts.no_lock = true; - dst_opts.no_lock = true; - Some(guard) - } else { - None - }; - if let Some(guard) = _self_copy_lock_guard.as_ref() { - dst_opts.add_namespace_lock_guard(guard); - } - dst_opts.expected_current_version_id = expected_current_version_id.clone(); - - if _self_copy_lock_guard.is_some() { - current_opts.no_lock = true; - } - let previous_current_sizes = match store.get_object_info(&bucket, &key, ¤t_opts).await { - Ok(existing_obj_info) => { - validate_existing_object_lock_for_write(&object_lock_config_state, &existing_obj_info, &dst_opts)?; - if let Some(expected) = expected_current_version_id.as_deref() - && existing_obj_info.version_id.unwrap_or_default().to_string() != expected - { - return Err(s3_error!(PreconditionFailed)); - } - Some((existing_obj_info.size.max(0) as u64, quota_object_size(&existing_obj_info))) - } - Err(err) => { - if expected_current_version_id.is_some() { - return Err(s3_error!(PreconditionFailed)); - } - if !is_err_object_not_found(&err) && !is_err_version_not_found(&err) { - return Err(ApiError::from(err).into()); - } - None - } - }; - - let (mut effective_sse, mut effective_kms_key_id) = resolve_bucket_default_sse( - bucket_sse_config.as_ref().map(|(config, _)| config), - requested_sse, - requested_kms_key_id, - has_explicit_ssec, - ); - - let h = build_ssec_read_headers( - copy_source_sse_customer_algorithm.as_ref(), - copy_source_sse_customer_key.as_ref(), - copy_source_sse_customer_key_md5.as_ref(), - ); - - let copy_principal = SseKmsPrincipal::from_request(&req); - - if source_bucket_lifecycle_guard.is_lock_lost() { - return Err(ApiError::from(StorageError::NamespaceLockQuorumUnavailable { - mode: "copy_source_bucket_generation", - bucket: src_bucket.clone(), - object: src_key.clone(), - required: 1, - achieved: 0, - }) - .into()); - } - - let gr = store - .get_object_reader(&src_bucket, &src_key, None, h, &src_get_opts) - .await - .map_err(map_get_object_reader_error)?; - - let mut src_info = gr.object_info.clone(); - - // A copy reads the source plaintext, so it needs the source key's decrypt permission - // as well as the destination key's generate permission below. The source read resolves - // its material inside the object layer, which has no request identity, so the check - // happens here. - authorize_sse_kms_object_read(copy_principal.as_ref(), &src_info.user_defined).await?; - - // Capture the version actually read from the source before src_info is mutated/consumed - // below. This is the exact source version copied (issue #4976): the response must echo it - // via x-amz-copy-source-version-id, distinct from the destination version_id. - let src_resolved_version_id = src_info.version_id; - - // Source object's existing checksum, if any. When the copy does not request a new - // algorithm, AWS preserves the source object's checksum on the destination (#4996); the - // copy does not transform the plaintext, so we carry the stored value over unchanged - // rather than re-hashing every byte. - let src_checksum = src_info.checksum.as_ref().and_then(|bytes| { - let (pairs, _) = rustfs_rio::read_checksums(bytes.as_ref(), 0); - pairs - .into_iter() - .find_map(|(k, v)| rustfs_rio::Checksum::new_from_string(&k, &v)) - }); - - // Validate copy source conditions - if let Some(if_match) = copy_source_if_match { - if let Some(ref etag) = src_info.etag { - if let Some(strong_etag) = if_match.into_etag() { - if ETag::Strong(etag.clone()) != strong_etag { - return Err(s3_error!(PreconditionFailed)); - } - } else { - // Weak ETag or Any (*) in If-Match should fail per RFC 9110 - return Err(s3_error!(PreconditionFailed)); - } - } else { - return Err(s3_error!(PreconditionFailed)); - } - } - - if let Some(if_none_match) = copy_source_if_none_match - && let Some(ref etag) = src_info.etag - && let Some(strong_etag) = if_none_match.into_etag() - && ETag::Strong(etag.clone()) == strong_etag - { - return Err(s3_error!(PreconditionFailed)); - } - - // A same-name copy is normally serviced as a metadata-only update: the store layer - // rewrites xl.meta in place and leaves the data blocks alone. That shortcut is only sound - // when the destination's physical bytes are identical to the source's, and encryption - // breaks exactly that. The destination metadata is rebuilt from scratch below — - // `strip_managed_encryption_metadata` drops the source DEK and `sse_encryption` mints a - // fresh one — so reusing the stored ciphertext would leave a new DEK sitting beside bytes - // it cannot decrypt, permanently destroying the object (GET fails with an AEAD tag - // mismatch). The mirror case is worse because it is silent: an encrypted source copied - // without any destination SSE keeps its ciphertext while losing the key metadata, so GET - // hands back raw ciphertext as if it were plaintext. So whenever either side is - // encrypted, leave metadata_only = false and let the store layer do a full read/write - // rewrite through put_object, the same resolution the versioned historical-restore path - // uses (issue #4238, crates/ecstore/src/store/object.rs). - // - // This mirrors MinIO's `isSourceEncrypted || isTargetEncrypted -> metadataOnly = false` - // in CopyObjectHandler, with one deliberate difference: MinIO decides "target encrypted" - // from request headers alone, while `effective_sse` here also resolves the bucket default - // encryption rule. `sse_encryption` mints a DEK from that resolved value, so a - // header-only check would miss a self-copy under a bucket default rule. The source half - // deliberately reuses `ObjectInfo::is_encrypted` rather than naming individual headers, - // so a future encryption flavour is covered here the moment it is recognised there. - // - // The zero-copy shortcut is only recoverable for encrypted objects by *preserving* the - // DEK that sealed the bytes and re-wrapping it under a new master key (MinIO's - // `rotateKey` + `keyRotation` flag, which is why it may keep metadataOnly = true). RustFS - // has no such rewrap primitive today; adding one is backlog#1637, and it would enter here - // as an explicit exception rather than by relaxing this guard. - let copy_changes_encryption = src_info.is_encrypted() || effective_sse.is_some() || has_explicit_ssec; - if cp_src_dst_same && src_info.transitioned_object.tier.is_empty() && !copy_changes_encryption { - src_info.metadata_only = true; - } - - // Extract user_defined from Arc for mutation; it will be re-wrapped after all edits. - let mut user_defined = (*src_info.user_defined).clone(); - let effective_tags = replacement_tags.unwrap_or_else(|| (*src_info.user_tags).clone()); - if !replaces_metadata { - let source_expires = src_info.expires.map(Timestamp::from); - insert_expires_metadata(&mut user_defined, source_expires.as_ref())?; - } - - strip_managed_encryption_metadata(&mut user_defined); - - let destination_storage_class = storage_class - .as_ref() - .map(StorageClass::as_str) - .unwrap_or(storageclass::STANDARD); - src_info.storage_class = Some(destination_storage_class.to_string()); - - let actual_size = src_info.get_actual_size().map_err(ApiError::from)?; - - let length = actual_size; - - let mut compress_metadata = HashMap::new(); - - let should_compress = is_disk_compressible(&req.headers, &key) && actual_size > MIN_DISK_COMPRESSIBLE_SIZE as i64; - - if should_compress { - insert_str( - &mut compress_metadata, - SUFFIX_COMPRESSION, - compression_metadata_value(CompressionAlgorithm::default()), - ); - insert_str(&mut compress_metadata, SUFFIX_ACTUAL_SIZE, actual_size.to_string()); - } else { - remove_str(&mut user_defined, SUFFIX_COMPRESSION); - remove_str(&mut user_defined, SUFFIX_ACTUAL_SIZE); - remove_str(&mut user_defined, SUFFIX_COMPRESSION_SIZE); - } - - // Handle MetadataDirective REPLACE: replace user metadata while preserving system metadata. - // System metadata (compression, encryption) is added after this block to ensure - // it's not cleared by the REPLACE operation. - if let Some(replacement_metadata) = replacement_metadata { - user_defined = replacement_metadata; - src_info.content_type = content_type.clone(); - src_info.content_encoding = content_encoding.as_deref().and_then(normalize_content_encoding_for_storage); - src_info.expires = expires.map(OffsetDateTime::from); - } else if metadata_directive.is_some() || website_redirect_location.is_some() { - user_defined.retain(|key, _| !key.eq_ignore_ascii_case(AMZ_WEBSITE_REDIRECT_LOCATION)); - if let Some(website_redirect_location) = website_redirect_location { - user_defined.insert(AMZ_WEBSITE_REDIRECT_LOCATION.to_string(), website_redirect_location); - } - } - - user_defined.retain(|key, _| !key.eq_ignore_ascii_case(AMZ_STORAGE_CLASS)); - if destination_storage_class != storageclass::STANDARD { - user_defined.insert(AMZ_STORAGE_CLASS.to_string(), destination_storage_class.to_string()); - } - - user_defined.retain(|key, _| !key.eq_ignore_ascii_case(AMZ_OBJECT_TAGGING)); - if !effective_tags.is_empty() { - user_defined.insert(AMZ_OBJECT_TAGGING.to_string(), effective_tags.clone()); - } - src_info.user_tags = Arc::new(effective_tags); - - let has_explicit_object_lock_retention = object_lock_mode.is_some() || object_lock_retain_until_date.is_some(); - remove_object_lock_metadata_for_copy(&mut user_defined); - if let Some(object_lock_metadata) = build_put_like_object_lock_metadata( - &bucket, - &object_lock_config_state, - object_lock_legal_hold_status, - object_lock_mode, - object_lock_retain_until_date, - )? { - user_defined.extend(object_lock_metadata); - } - apply_bucket_default_lock_retention( - &bucket, - &object_lock_config_state, - &mut user_defined, - has_explicit_object_lock_retention, - )?; - - let mut write_plan = WritePlan::new(); - let mut reader = if should_compress { - let algorithm = CompressionAlgorithm::default(); - let hrd = HashReader::from_stream(gr.stream, length, actual_size, None, None, false).map_err(ApiError::from)?; - write_plan = write_plan.with_compression(algorithm); - hrd - } else { - HashReader::from_stream(gr.stream, length, actual_size, None, None, false).map_err(ApiError::from)? - }; - - // Give the destination object a checksum so CopyObject returns it and a later checksum-mode - // HEAD/GET matches (#4996). When the caller requests an algorithm, compute it fresh over the - // copied plaintext (the hasher sits on the innermost reader so it digests plaintext). When - // none is requested, carry the source object's stored checksum over unchanged — the copy - // does not alter the plaintext, so re-hashing would be wasted work and would flatten a - // multipart composite value. - match requested_checksum_type { - Some(checksum_type) => { - reader.add_calculated_checksum(checksum_type).map_err(ApiError::from)?; - } - None => { - if let Some(cs) = src_checksum { - reader.add_non_trailing_checksum(Some(cs), true).map_err(ApiError::from)?; - } - } - } - - let encryption_request = EncryptionRequest { - bucket: &bucket, - key: &key, - server_side_encryption: effective_sse.clone(), - ssekms_key_id: effective_kms_key_id.clone(), - ssekms_context, - sse_customer_algorithm: sse_customer_algorithm.clone(), - sse_customer_key, - sse_customer_key_md5: sse_customer_key_md5.clone(), - content_size: actual_size, - principal: copy_principal.as_ref(), - }; - - if let Some(material) = sse_encryption(encryption_request).await? { - effective_sse = Some(material.server_side_encryption.clone()); - effective_kms_key_id = material.kms_key_id.clone(); - - write_plan = write_plan.with_encryption(material.write_encryption(None)); - - user_defined.extend(encryption_material_to_metadata(&material)?); - } - - reader = write_plan.apply(reader, actual_size).map_err(ApiError::from)?; - - src_info.put_object_reader = Some(PutObjReader::new(reader)); - - // check quota - - for (k, v) in compress_metadata { - user_defined.insert(k, v); - } - - // The source object's replication bookkeeping (internal status/timestamp, - // replica state, and the surfaced x-amz-replication-status) describes the - // SOURCE's replication history; carried onto the destination it fakes a - // COMPLETED/REPLICA state for an object that never replicated (MinIO - // filterReplicationStatusMetadata parity). Inbound replica writes are - // exempt: the authorized replication request owns these keys (see - // copy_dst_opts_with_replication_authorization above). - if !dst_opts.replication_request { - user_defined.retain(|k, _| !k.eq_ignore_ascii_case(AMZ_BUCKET_REPLICATION_STATUS)); - remove_str(&mut user_defined, SUFFIX_REPLICATION_STATUS); - remove_str(&mut user_defined, SUFFIX_REPLICATION_TIMESTAMP); - remove_str(&mut user_defined, SUFFIX_REPLICA_STATUS); - remove_str(&mut user_defined, SUFFIX_REPLICA_TIMESTAMP); - } - - // Compute the replication decision exactly once per copy. The same - // immutable `dsc` drives both the pending metadata written below and the - // post-commit schedule (see the reuse site after copy_object), so a - // replication-config hot update cannot split the two phases — same - // contract as the PUT path (https://github.com/rustfs/backlog/issues/1320). - // `must_replicate_object` itself declines inbound replica writes - // (replication_request / REPLICA status), so replicas are never - // re-scheduled outbound. - let dsc = must_replicate_object( - &bucket, - &key, - &user_defined, - "".to_string(), - dst_opts.delete_marker_replication_status(), - dst_opts.clone(), - ) - .await; - if dsc.replicate_any() { - insert_str(&mut user_defined, SUFFIX_REPLICATION_TIMESTAMP, jiff::Zoned::now().to_string()); - insert_str(&mut user_defined, SUFFIX_REPLICATION_STATUS, dsc.pending_status().unwrap_or_default()); - } - - src_info.user_defined = Arc::new(user_defined); - - let quota_check = self - .check_bucket_quota( - &bucket, - QuotaOperation::CopyObject, - u64::try_from(actual_size).map_err(|_| S3Error::new(S3ErrorCode::UnexpectedContent))?, - ) - .await?; - let quota_enabled = quota_check.as_ref().is_some_and(|result| result.quota_limit.is_some()); - if let Some(quota_check) = quota_check.as_ref() { - apply_quota_admission(&mut dst_opts, quota_check)?; - } - let previous_current_size = match previous_current_sizes { - Some((_, Ok(logical_size))) if quota_enabled => Some(logical_size), - Some((_, Err(err))) if quota_enabled => return Err(ApiError::from(err).into()), - Some((physical_size, _)) => Some(physical_size), - None => None, - }; - if let Some(quota_check) = quota_check.as_ref() { - ensure_object_size_within_quota( - quota_check, - u64::try_from(actual_size).map_err(|_| S3Error::new(S3ErrorCode::UnexpectedContent))?, - )?; - } - let has_bucket_metadata = self.bucket_metadata_sys().is_some(); - let cache_adapter = self.object_data_cache(); - let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &key).await; - - let copy_commit = spawn_traced_join({ - let store = Arc::clone(&store); - let src_bucket = src_bucket.clone(); - let src_key = src_key.clone(); - let bucket = bucket.clone(); - let key = key.clone(); - let src_opts = src_opts.clone(); - let dst_opts = dst_opts.clone(); - async move { - let _source_bucket_lifecycle_guard = source_bucket_lifecycle_guard; - let _destination_bucket_lifecycle_guard_storage = destination_bucket_lifecycle_guard_storage; - let _self_copy_lock_guard = _self_copy_lock_guard; - - let oi = store - .copy_object(&src_bucket, &src_key, &bucket, &key, &mut src_info, &src_opts, &dst_opts) - .await - .map_err(ApiError::from)?; - - // Reuse the single pre-commit replication decision (see `dsc` above) so - // the persisted pending marker and the schedule always agree, mirroring - // the PUT path. - if dsc.replicate_any() { - schedule_object_replication(oi.clone(), Arc::clone(&store), dsc).await; - } - - maybe_enqueue_transition_immediate(&oi, LcEventSrc::S3CopyObject).await; - let _ = invalidate_object_data_cache_after_copy_success(&cache_adapter, &bucket, &key).await; - - let dest_versioned = BucketVersioningSys::prefix_enabled(&bucket, &key).await; - if has_bucket_metadata { - let committed_size = quota_accounting_object_size(&oi, quota_enabled)?; - if dest_versioned { - record_bucket_object_version_write_memory(&bucket, previous_current_size, committed_size).await; - } else { - record_bucket_object_write_memory(&bucket, previous_current_size, committed_size).await; - } - } - - rustfs_scanner::record_dirty_usage_bucket(&bucket); - Ok::<_, S3Error>((oi, dest_versioned)) - } - }); - let (oi, dest_versioned) = copy_commit.await.map_err(|err| { - S3Error::with_message(S3ErrorCode::InternalError, format!("copy object commit owner task failed: {err}")) - })??; - - let raw_dest_version = oi.version_id.map(|v| v.to_string()); - let dest_version = if dest_versioned { raw_dest_version } else { None }; - - // Echo the source version that was copied via x-amz-copy-source-version-id (issue #4976). - // AWS/MinIO return this whenever the source bucket carries versioning (enabled or - // suspended); render the null version as "null" like GET/HEAD do. This is the exact source - // version, kept distinct from the destination version_id above. - let src_versioned = BucketVersioningSys::prefix_enabled(&src_bucket, &src_key).await - || BucketVersioningSys::prefix_suspended(&src_bucket, &src_key).await; - let copy_source_version_id = if src_versioned { - src_resolved_version_id.map(|vid| { - if vid == Uuid::nil() { - "null".to_string() - } else { - vid.to_string() - } - }) - } else { - None - }; - - // Report the destination object's checksum in the response, decoded the same way GetObject - // / HeadObject do so the value is identical to a later checksum-mode HEAD/GET (#4996). - let response_checksums = oi - .decrypt_checksums(0, &req.headers) - .map(|(pairs, is_multipart)| classify_response_checksums(pairs, is_multipart)) - .unwrap_or_default(); - - // warn!("copy_object oi {:?}", &oi); - let object_info = oi.clone(); - let mut checksum_md5 = None; - let mut checksum_sha512 = None; - let mut checksum_xxhash3 = None; - let mut checksum_xxhash64 = None; - let mut checksum_xxhash128 = None; - for (name, value) in response_checksums.extra { - match name { - "x-amz-checksum-md5" => checksum_md5 = Some(value), - "x-amz-checksum-sha512" => checksum_sha512 = Some(value), - "x-amz-checksum-xxhash3" => checksum_xxhash3 = Some(value), - "x-amz-checksum-xxhash64" => checksum_xxhash64 = Some(value), - "x-amz-checksum-xxhash128" => checksum_xxhash128 = Some(value), - _ => {} - } - } - let copy_object_result = CopyObjectResult { - e_tag: oi.etag.as_ref().map(|etag| to_s3s_etag(etag)), - last_modified: oi.mod_time.map(Timestamp::from), - checksum_crc32: response_checksums.crc32, - checksum_crc32c: response_checksums.crc32c, - checksum_sha1: response_checksums.sha1, - checksum_sha256: response_checksums.sha256, - checksum_crc64nvme: response_checksums.crc64nvme, - checksum_md5, - checksum_sha512, - checksum_xxhash3, - checksum_xxhash64, - checksum_xxhash128, - checksum_type: response_checksums.checksum_type, - }; - - let output = CopyObjectOutput { - copy_object_result: Some(copy_object_result), - copy_source_version_id, - server_side_encryption: effective_sse, - ssekms_key_id: effective_kms_key_id, - sse_customer_algorithm, - sse_customer_key_md5, - version_id: dest_version, - ..Default::default() - }; - - let version_id = req.input.version_id.clone().unwrap_or_default(); - helper = helper.object(object_info).version_id(version_id); - - let result = Ok(S3Response::new(output)); - let _ = helper.complete(&result); - result - } - - #[instrument(level = "debug", skip(self, req))] - pub async fn execute_delete_objects( - &self, - mut req: S3Request, - ) -> S3Result> { - if let Some(context) = &self.context { - let _ = context.object_store(); - } - - let helper = OperationHelper::new(&req, EventName::ObjectRemovedDelete, S3Operation::DeleteObjects).suppress_event(); - let request_context = helper.request_context_or_from_request(&req); - let (bucket, delete) = { - let bucket = req.input.bucket.clone(); - let delete = req.input.delete.clone(); - (bucket, delete) - }; - - if delete.objects.is_empty() || delete.objects.len() > 1000 { - return Err(S3Error::with_message( - S3ErrorCode::InvalidArgument, - "No objects to delete or too many objects to delete".to_string(), - )); - } - - let is_owner = req_info_ref(&req).map(|info| info.is_owner).unwrap_or(false); - if !recursive_force_delete_is_authorized(&req.headers, is_owner, false) { - return Err(S3Error::with_message( - S3ErrorCode::AccessDenied, - "Recursive force-delete is restricted to administrative requests", - )); - } - - let Some(store) = self.object_store() else { - return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); - }; - // Capture the bucket generation before per-object authorization, but - // do not expose a bucket-state error unless at least one object is authorized. - let bucket_generation = load_bucket_generation_from_store(store.as_ref(), &req, &bucket).await; - - let bypass_governance = has_bypass_governance_header(&req.headers); - - #[derive(Default, Clone)] - struct DeleteResult { - delete_object: Option, - error: Option, - synthetic_version_id: bool, - } - - let mut delete_results = vec![DeleteResult::default(); delete.objects.len()]; - - struct AuthorizedDelete { - idx: usize, - object: ObjectToDelete, - } - - let mut authorized_deletes = Vec::with_capacity(delete.objects.len()); - // Issue #5740: keep the first per-key denial of this bulk request at - // warn and demote the rest to debug, so a denied 1000-key DeleteObjects - // cannot flood the log. - let mut bulk_denial_logged = false; - for (idx, obj_id) in delete.objects.iter().enumerate() { - let raw_version_id = obj_id.version_id.clone(); - let (version_id, version_uuid) = match normalize_delete_objects_version_id(raw_version_id.clone()) { - Ok(parsed) => parsed, - Err(err) => { - delete_results[idx].error = Some(s3s::dto::Error { - code: Some("NoSuchVersion".to_string()), - key: Some(obj_id.key.clone()), - message: Some(err), - version_id: raw_version_id, - }); - continue; - } - }; - - { - let req_info = req_info_mut(&mut req)?; - req_info.bucket = Some(bucket.clone()); - req_info.object = Some(obj_id.key.clone()); - req_info.version_id = version_id.clone(); - } - - let auth_res = authorize_request(&mut req, Action::S3Action(S3Action::DeleteObjectAction)).await; - if auth_res.is_err() { - if !bulk_denial_logged { - bulk_denial_logged = true; - req_info_mut(&mut req)?.suppress_denial_log = true; - } - delete_results[idx].error = Some(s3s::dto::Error { - code: Some("AccessDenied".to_string()), - key: Some(obj_id.key.clone()), - message: Some("Access Denied".to_string()), - version_id: version_id.clone(), - }); - continue; - } - - if bypass_governance { - let auth_res = authorize_request(&mut req, Action::S3Action(S3Action::BypassGovernanceRetentionAction)).await; - if auth_res.is_err() { - if !bulk_denial_logged { - bulk_denial_logged = true; - req_info_mut(&mut req)?.suppress_denial_log = true; - } - delete_results[idx].error = Some(s3s::dto::Error { - code: Some("AccessDenied".to_string()), - key: Some(obj_id.key.clone()), - message: Some("Access Denied".to_string()), - version_id: version_id.clone(), - }); - continue; - } - } - - if let Err(err) = validate_table_catalog_object_mutation(&bucket, &obj_id.key).await { - delete_results[idx].error = Some(s3s::dto::Error { - code: Some("InvalidRequest".to_string()), - key: Some(obj_id.key.clone()), - message: Some(err.to_string()), - version_id: version_id.clone(), - }); - continue; - } - - let synthetic_version_id = version_id.is_none() && is_dir_object(&obj_id.key); - let object = ObjectToDelete { - object_name: obj_id.key.clone(), - version_id: version_uuid, - synthetic_version_id, - ..Default::default() - }; - delete_results[idx].synthetic_version_id = synthetic_version_id; - - authorized_deletes.push(AuthorizedDelete { idx, object }); - } - - if authorized_deletes.is_empty() { - let output = DeleteObjectsOutput { - deleted: Some(Vec::new()), - errors: Some(delete_results.into_iter().filter_map(|result| result.error).collect()), - ..Default::default() - }; - let result = Ok(S3Response::new(output)); - let _ = helper.complete(&result); - return result; - } - #[cfg(test)] - wait_for_delete_objects_auth_test_hook(&bucket).await; - req.extensions.insert(bucket_generation?); - let bucket_lock_enabled = object_lock_checks_required(&bucket).await; - - let delete_config_snapshot = Arc::new( - load_delete_config_snapshot(store.as_ref(), &bucket) - .await - .map_err(ApiError::from)?, - ); - let version_cfg = delete_config_snapshot.versioning_config(); - let replicate_deletes = authorized_deletes - .iter() - .any(|authorized| has_active_delete_rule(&delete_config_snapshot, &authorized.object.object_name)); - - struct PreparedDelete { - idx: usize, - object: ObjectToDelete, - opts: ObjectOptions, - skip_stat: bool, - } - - // Phase 1 (serial): derive storage options from the request-scoped - // configuration after every candidate has passed authorization. - let mut prepared_deletes: Vec = Vec::with_capacity(authorized_deletes.len()); - for authorized in authorized_deletes { - let AuthorizedDelete { idx, object } = authorized; - - let metadata = extract_metadata(&req.headers); - let opts: ObjectOptions = del_opts_with_versioning( - &bucket, - &object.object_name, - object.version_id.map(|f| f.to_string()), - &req.headers, - metadata, - version_cfg, - false, - ) - .map_err(ApiError::from)?; - - // backlog#929 (HP-8): the accounting branch after the store delete - // decides delete-marker vs object-delete from this exact snapshot, - // so evaluate it here with the same inputs to keep the stat-skip - // decision and the accounting path provably consistent. - let accounting_creates_delete_marker = object.version_id.is_none() && opts.versioned && !opts.version_suspended; - let skip_stat = can_skip_delete_objects_pre_stat(bucket_lock_enabled, &opts, accounting_creates_delete_marker); - - prepared_deletes.push(PreparedDelete { - idx, - object, - opts, - skip_stat, - }); - } - - struct AdmittedDelete { - idx: usize, - object: ObjectToDelete, - versioned: bool, - version_suspended: bool, - } - - // Phase 2 (bounded concurrency, backlog#929 / HP-8): collect the - // metadata needed for accounting and tier cleanup. Entries are - // independent per key, and `buffered` preserves input order. Object - // Lock admission is enforced later in set_disk under the write lock. - let store_ref = &store; - let bucket_ref = bucket.as_str(); - let admitted_deletes: Vec = - futures::stream::iter(prepared_deletes.into_iter().map(|prepared| async move { - let PreparedDelete { - idx, - mut object, - opts, - skip_stat, - } = prepared; - let synthetic_version_id = object.version_id.is_none() && is_dir_object(&object.object_name); - if !skip_stat { - match store_ref.get_object_info(bucket_ref, &object.object_name, &opts).await { - Ok(_) => {} - Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {} - Err(err) => return Err(ApiError::from(err)), - } - } - - if synthetic_version_id { - object.version_id = Some(Uuid::nil()); - } - - Ok::<_, ApiError>(AdmittedDelete { - idx, - object, - versioned: opts.versioned, - version_suspended: opts.version_suspended, - }) - })) - .buffered(DELETE_OBJECTS_PRE_STAT_CONCURRENCY) - .try_collect() - .await?; - - // Phase 3 (serial): apply outcomes in the original request order so - // per-key success/failure reporting is unchanged. - let mut object_to_delete = Vec::new(); - let mut object_to_delete_idx = Vec::new(); - let mut object_versioning = Vec::new(); - for admitted in admitted_deletes { - object_to_delete_idx.push(admitted.idx); - object_versioning.push((admitted.versioned, admitted.version_suspended)); - object_to_delete.push(admitted.object); - } - let cache_adapter = self.object_data_cache(); - let cache_keys_before_delete = object_to_delete - .iter() - .map(|object| object.object_name.clone()) - .collect::>(); - invalidate_object_data_cache_objects_before_mutation(&cache_adapter, &bucket, cache_keys_before_delete.iter()).await; - - let mut storage_delete_opts = ObjectOptions { - versioned: version_cfg.enabled(), - version_suspended: version_cfg.suspended(), - delete_replication_config_snapshot: Some(Arc::clone(&delete_config_snapshot)), - object_lock_delete: Some(StorageObjectLockDeleteOptions { bypass_governance }), - ..Default::default() - }; - apply_bucket_generation_guard(&req, &bucket, &mut storage_delete_opts)?; - let (dobjs, errs, accounting) = store - .delete_objects_with_tier_delete_journal_and_accounting(&bucket, object_to_delete.clone(), storage_delete_opts) - .await; - - let _manager = get_concurrency_manager(); - let _bucket_clone = bucket.clone(); - let _deleted_objects = dobjs.clone(); - if !errs.is_empty() && errs.iter().all(|err| err.as_ref().is_some_and(is_err_bucket_not_found)) { - let result = Err(S3Error::with_message(S3ErrorCode::NoSuchBucket, "Bucket not found".to_string())); - let _ = helper.complete(&result); - return result; - } - - for (i, err) in errs.iter().enumerate() { - let didx = object_to_delete_idx[i]; - - match reduce_delete_objects_result( - &object_to_delete[i], - &dobjs[i], - err.as_ref(), - delete_results[didx].synthetic_version_id, - ) { - Ok(deleted_object) => { - delete_results[didx].delete_object = Some(deleted_object.clone()); - let (versioned, version_suspended) = object_versioning[i]; - let creates_delete_marker = object_to_delete[i].version_id.is_none() && versioned && !version_suspended; - let committed_delete_marker = dobjs[i].delete_marker; - let delete_accounting = accounting.get(i).and_then(Option::as_ref); - let update = delete_memory_update( - creates_delete_marker, - committed_delete_marker, - delete_request_targets_current(object_to_delete[i].version_id), - delete_accounting.and_then(|value| value.size), - delete_accounting.is_some_and(|value| value.removed_current_object), - ); - apply_delete_memory_update(&bucket, update).await; - } - Err(error) => { - delete_results[didx].error = Some(error); - } - } - } - - let deleted = delete_results - .iter() - .filter_map(|result| result.delete_object.as_ref().map(|object| (result, object))) - .map(|(result, object)| DeletedObject { - delete_marker: { if object.delete_marker { Some(true) } else { None } }, - delete_marker_version_id: delete_response_version_id( - object.delete_marker_version_id, - result.synthetic_version_id, - ), - key: Some(object.object_name.clone()), - version_id: delete_response_version_id(object.version_id, result.synthetic_version_id), - }) - .collect(); - let deleted_cache_keys = delete_results - .iter() - .filter_map(|result| result.delete_object.as_ref().map(|deleted| deleted.object_name.clone())) - .collect::>(); - invalidate_object_data_cache_objects_after_delete_success(&cache_adapter, &bucket, deleted_cache_keys.iter()).await; - - let errors = delete_results - .iter() - .filter_map(|v| v.error.clone()) - .collect::>(); - let output = DeleteObjectsOutput { - deleted: Some(deleted), - errors: Some(errors), - ..Default::default() - }; - let helper = if helper.wants_audit_object_info() { - let audit_objects = - successful_delete_audit_objects(&delete, delete_results.iter().map(|result| result.delete_object.is_some())); - helper.audit_objects(audit_objects) - } else { - helper - }; - - let replication_deletes = if replicate_deletes { - delete_results - .iter() - .filter_map(|result| result.delete_object.as_ref()) - .filter(|dobj| deleted_object_has_pending_replication_delete(dobj)) - .cloned() - .collect::>() - } else { - Vec::new() - }; - if !replication_deletes.is_empty() { - let bucket_for_replication = bucket.clone(); - let replication_task = tokio::spawn(async move { - let _activity_guard = DeleteTailActivityGuard::new(DeleteTailStage::Replication); - schedule_replication_deletes(replication_deletes, bucket_for_replication, REPLICATE_INCOMING_DELETE.to_string()) - .await; - }); - // The spawned task owns every locally committed delete. Dropping the - // join handle on request cancellation therefore cannot lose the tail. - let _ = replication_task.await; - } - - let req_headers = req.headers.clone(); - let notify = current_notify_interface_for_context(self.context.as_deref()); - let req_params = rustfs_targets::extract_params_header(&req_headers); - let resp_elements = - build_event_resp_elements(&S3Response::new(DeleteObjectsOutput::default()), &request_context.request_id); - let deleted_any = delete_results.iter().any(|result| result.delete_object.is_some()); - let notify_bucket = bucket.clone(); - spawn_background_with_context(Some(request_context), async move { - let _activity_guard = DeleteTailActivityGuard::new(DeleteTailStage::Notify); - for res in delete_results { - if let Some(dobj) = res.delete_object { - let event_name = delete_event_name_for_marker(dobj.delete_marker); - let event_args = EventArgsBuilder::new( - event_name, - notify_bucket.clone(), - convert_ecstore_object_info(ObjectInfo { - name: dobj.object_name.clone(), - bucket: notify_bucket.clone(), - ..Default::default() - }), - ) - .version_id(delete_response_version_id(dobj.version_id, res.synthetic_version_id).unwrap_or_default()) - .req_params(req_params.clone()) - .resp_elements(resp_elements.clone()) - .host(get_request_host(&req_headers)) - .user_agent(get_request_user_agent(&req_headers)) - .build(); - - notify.notify(event_args).await; - } - } - }); - - let result = Ok(S3Response::new(output)); - let _ = helper.complete(&result); - if deleted_any { - rustfs_scanner::record_dirty_usage_bucket(&bucket); - } - // Record write operation for capacity management (inline to avoid per-request tokio::spawn overhead) - let manager = get_capacity_manager(); - manager.record_write_operation().await; - result - } - - #[instrument(level = "info", skip(self, req))] - pub async fn execute_delete_object(&self, mut req: S3Request) -> S3Result> { - if let Some(context) = &self.context { - let _ = context.object_store(); - } - - let mut helper = OperationHelper::new(&req, EventName::ObjectRemovedDelete, S3Operation::DeleteObject); - let DeleteObjectInput { - bucket, key, version_id, .. - } = req.input.clone(); - - // Validate object key - validate_object_key(&key, "DELETE")?; - - let replica = req - .headers - .get(AMZ_BUCKET_REPLICATION_STATUS) - .map(|v| v.to_str().unwrap_or_default() == ReplicationStatusType::Replica.as_str()) - .unwrap_or_default(); - - if replica { - authorize_request(&mut req, Action::S3Action(S3Action::ReplicateDeleteAction)).await?; - } - - let is_owner = req_info_ref(&req).map(|info| info.is_owner).unwrap_or(false); - if !recursive_force_delete_is_authorized(&req.headers, is_owner, replica) { - return Err(S3Error::with_message( - S3ErrorCode::AccessDenied, - "Recursive force-delete is restricted to internal or administrative requests", - )); - } - validate_table_catalog_object_mutation(&bucket, &key).await?; - - // Establish bucket existence before any bucket-metadata work (matches - // PUT/GET): nonexistent buckets fail here instead of paying the - // versioning lookups in del_opts/get_opts first. Resolve the store - // through the request-bound server context (backlog#1052 S6), not the - // process-global handle. - let Some(store) = self.object_store() else { - return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); - }; - validate_bucket_exists(&store, &bucket).await?; - - let metadata = extract_metadata(&req.headers); - // Clone version_id before it's moved - let version_id_clone = version_id.clone(); - let synthetic_version_id = version_id_clone.is_none() && is_dir_object(&key); - - let delete_config_snapshot = Arc::new( - load_delete_config_snapshot(store.as_ref(), &bucket) - .await - .map_err(ApiError::from)?, - ); - #[cfg(test)] - wait_for_delete_snapshot_test_hook(&bucket).await; - let version_cfg = delete_config_snapshot.versioning_config(); - let mut opts: ObjectOptions = - del_opts_with_versioning(&bucket, &key, version_id, &req.headers, metadata, version_cfg, replica) - .map_err(ApiError::from)?; - opts.delete_replication_config_snapshot = Some(Arc::clone(&delete_config_snapshot)); - opts.object_lock_delete = Some(StorageObjectLockDeleteOptions { - bypass_governance: has_bypass_governance_header(&req.headers), - }); - apply_bucket_generation_guard(&req, &bucket, &mut opts)?; - let force_delete = opts.delete_prefix; - - // let mut vid = opts.version_id.clone(); - - if replica { - opts.set_replica_status(ReplicationStatusType::Replica); - - // if opts.version_purge_status().is_empty() { - // vid = None; - // } - } - - let expected_current_version_id = expected_current_version_id(&req.headers)?; - if expected_current_version_id.is_some() && (force_delete || !opts.versioned) { - return Err(s3_error!( - InvalidRequest, - "Expected current version precondition requires a version-specific delete in a versioned bucket" - )); - } - validate_undo_delete_version(expected_current_version_id.as_deref(), opts.version_id.as_deref())?; - opts.expected_current_version_id = expected_current_version_id.clone(); - - let replicate_force_delete = force_delete && !replica && has_active_delete_rule(&delete_config_snapshot, &key); - let mut force_delete_intent = None; - - let get_opts = opts.clone(); - let existing_object_info = match store.get_object_info(&bucket, &key, &get_opts).await { - Ok(obj_info) => Some(obj_info), - Err(err) => { - // If object not found, allow deletion to proceed (will return 204 No Content) - if !is_err_object_not_found(&err) && !is_err_version_not_found(&err) { - return Err(ApiError::from(err).into()); - } - None - } - }; - #[cfg(test)] - wait_for_delete_source_test_hook(&bucket).await; - - let cache_adapter = self.object_data_cache(); - // A force (delete_prefix) delete removes every object under `key` as a - // prefix, so invalidating only the exact key would strand every cached - // body beneath it. Use the prefix primitive in that branch (ODC-27). - if force_delete { - let _ = invalidate_object_data_cache_prefix_before_mutation(&cache_adapter, &bucket, &key).await; - } else { - let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &key).await; - } - - if replicate_force_delete - && let Some((target_arns, generation)) = force_delete_target_set(&delete_config_snapshot, &key) - && !target_arns.is_empty() - { - let operation_id = - persist_force_delete_intent(store.clone(), bucket.clone(), key.clone(), target_arns.clone(), generation) - .await - .map_err(ApiError::from)?; - force_delete_intent = Some((operation_id, target_arns, generation)); - } - - let obj_info = { - match store - .delete_object_with_tier_delete_journal(&bucket, &key, opts.clone()) - .await - { - Ok(obj) => obj, - Err(err) => { - if let Some((operation_id, _, _)) = force_delete_intent.as_ref() - && let Err(cleanup_error) = - crate::storage::storage_api::complete_force_delete_intent(store.clone(), *operation_id).await - { - warn!( - bucket = %bucket, - object = %key, - operation_id = %operation_id, - error = %cleanup_error, - "failed to remove uncommitted force-delete intent after local delete failure" - ); - } - if is_err_bucket_not_found(&err) { - return Err(S3Error::with_message(S3ErrorCode::NoSuchBucket, "Bucket not found".to_string())); - } - - if is_err_object_not_found(&err) || is_err_version_not_found(&err) { - let (result, _helper) = complete_delete_noop(helper, bucket, key, version_id_clone); - return result; - } - - if matches!(&err, StorageError::PrefixAccessDenied(_, _)) - && let Some(existing_object_info) = existing_object_info.as_ref() - && let Some(reason) = check_object_lock_for_deletion( - &bucket, - existing_object_info, - has_bypass_governance_header(&req.headers), - ) - .await - { - return Err(S3Error::with_message(S3ErrorCode::AccessDenied, reason.error_message())); - } - - return Err(ApiError::from(err).into()); - } - } - }; - - if force_delete { - let _ = invalidate_object_data_cache_prefix_after_delete(&cache_adapter, &bucket, &key).await; - } else { - let _ = invalidate_object_data_cache_after_delete_success(&cache_adapter, &bucket, &key).await; - } - - // Fast in-memory update for immediate quota and admin usage consistency. - // Prefix/force deletes and synthetic directory entries do not carry one - // committed object identity; leave their cache delta to reconciliation. - let update = if force_delete || obj_info.name.is_empty() || synthetic_version_id { - None - } else { - // The storage commit returns this object's metadata while its - // generation lock is held. Never fall back to a pre-delete stat: - // an overwrite can commit between that stat and this delete. - delete_memory_update( - delete_creates_delete_marker(&opts), - obj_info.delete_marker, - opts.version_id.is_none(), - quota_object_size(&obj_info).ok(), - delete_removes_current_object(&opts), - ) - }; - apply_delete_memory_update(&bucket, update).await; - - if obj_info.name.is_empty() { - if let Some((operation_id, target_arns, generation)) = force_delete_intent { - if let Err(error) = commit_force_delete_intent(store.clone(), operation_id).await { - warn!( - bucket = %bucket, - object = %key, - operation_id = %operation_id, - error = %error, - "failed to mark force-delete intent committed after local delete" - ); - } - let generation = i64::try_from(generation.unix_timestamp_nanos()).unwrap_or(i64::MAX); - schedule_replication_delete( - StorageDeletedObject { - object_name: key.clone(), - force_delete: true, - force_delete_id: Some(operation_id), - force_delete_target_arns: target_arns, - force_delete_generation: Some(generation), - ..Default::default() - }, - bucket.clone(), - REPLICATE_INCOMING_DELETE.to_string(), - ) - .await; - } else if replicate_force_delete { - let mut delete_object = StorageDeletedObject { - object_name: key.clone(), - force_delete: true, - ..Default::default() - }; - if let Some(replication_state) = delete_replication_state_from_config( - delete_config_snapshot - .replication_config() - .unwrap_or_else(|| unreachable!("force-delete requires a replication config")), - &ObjectInfo { - bucket: bucket.clone(), - name: key.clone(), - ..Default::default() - }, - None, - false, - ) { - set_deleted_object_replication_state(&mut delete_object, &replication_state); - } - schedule_replication_delete(delete_object, bucket.clone(), REPLICATE_INCOMING_DELETE.to_string()).await; - } - // Prefix/force-delete returns empty ObjectInfo; still emit bucket notification so webhooks match S3 DELETE. - helper = helper - .event_name(delete_event_name_for_marker(false)) - .object(ObjectInfo { - name: key.clone(), - bucket: bucket.clone(), - ..Default::default() - }) - .version_id(String::new()); - let result = Ok(S3Response::with_status(DeleteObjectOutput::default(), StatusCode::NO_CONTENT)); - // Match non-empty delete path: capacity manager write-op telemetry. - let manager = get_capacity_manager(); - manager.record_write_operation().await; - let _ = helper.complete(&result); - rustfs_scanner::record_dirty_usage_bucket(&bucket); - return result; - } - - let deleted_replication_info = existing_object_info - .as_ref() - .filter(|_| should_use_existing_delete_replication_info(&opts, opts.version_id.is_some())); - let _delete_tail_guard = DeleteTailActivityGuard::new(DeleteTailStage::Tail); - let deleted_object_source = deleted_replication_info.unwrap_or(&obj_info); - let replication_state_source = &obj_info; - let deleted_delete_marker_version = deleted_replication_info.is_some_and(|info| info.delete_marker); - - let delete_replication_version_id = delete_replication_version_id(deleted_object_source, deleted_delete_marker_version); - let schedule_delete_replication = if opts.replication_request && replica { - should_schedule_replica_delete_replication( - &delete_config_snapshot, - replication_state_source, - delete_replication_version_id, - ) - } else { - should_schedule_delete_replication( - &opts, - replication_state_source, - deleted_delete_marker_version, - opts.version_id.is_some(), - ) - }; - - if schedule_delete_replication { - let _activity_guard = DeleteTailActivityGuard::new(DeleteTailStage::Replication); - let mut deleted_object = StorageDeletedObject { - delete_marker: deleted_object_source.delete_marker && !deleted_delete_marker_version, - delete_marker_version_id: if deleted_object_source.delete_marker { - deleted_object_source.version_id - } else { - None - }, - object_name: key.clone(), - version_id: if deleted_object_source.delete_marker { - None - } else { - deleted_object_source.version_id - }, - delete_marker_mtime: deleted_object_source.mod_time, - replication_state: None, - ..Default::default() - }; - set_deleted_object_replication_state(&mut deleted_object, &replication_state_source.replication_state()); - enrich_delete_replication_state_if_needed(&delete_config_snapshot, &mut deleted_object, replication_state_source); - schedule_replication_delete(deleted_object, bucket.clone(), REPLICATE_INCOMING_DELETE.to_string()).await; - } - - let delete_marker = obj_info.delete_marker; - let version_id = obj_info.version_id; - let response_version_id = delete_response_version_id(version_id, synthetic_version_id); - - let output = DeleteObjectOutput { - delete_marker: Some(delete_marker), - version_id: response_version_id.clone(), - ..Default::default() - }; - - let event_name = delete_event_name_for_marker(delete_marker); - - helper = helper.event_name(event_name); - helper = helper.object(obj_info).version_id(response_version_id.unwrap_or_default()); - - let result = Ok(S3Response::new(output)); - // Record write operation for capacity management (inline to avoid per-request tokio::spawn overhead) - let manager = get_capacity_manager(); - manager.record_write_operation().await; - let _ = helper.complete(&result); - rustfs_scanner::record_dirty_usage_bucket(&bucket); - result - } - - #[instrument(level = "debug", skip(self, req))] - pub async fn execute_head_object(&self, req: S3Request) -> S3Result> { - if let Some(context) = &self.context { - let _ = context.object_store(); - } - - let mut helper = OperationHelper::new(&req, EventName::ObjectAccessedHead, S3Operation::HeadObject).suppress_event(); - // mc get 2 - let HeadObjectInput { - bucket, - key, - version_id, - part_number, - range, - if_none_match, - if_match, - if_modified_since, - if_unmodified_since, - .. - } = req.input.clone(); - - // Validate object key - validate_object_key(&key, "HEAD")?; - // Parse part number from Option to Option with validation - let part_number: Option = parse_part_number_i32_to_usize(part_number, "HEAD")?; - - let rs = range.map(range_to_http_range_spec).transpose()?; - - if rs.is_some() && part_number.is_some() { - return Err(s3_error!(InvalidArgument, "range and part_number invalid")); - } - - // Establish bucket existence before any bucket-metadata work (matches - // PUT/GET): nonexistent buckets fail here instead of paying the - // versioning lookup in get_opts first. Resolve the store through the - // request-bound server context (backlog#1052 S6), not the - // process-global handle. - let Some(store) = self.object_store() else { - return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); - }; - validate_bucket_exists(&store, &bucket).await?; - - let opts: ObjectOptions = get_opts(&bucket, &key, version_id, part_number, &req.headers) - .await - .map_err(ApiError::from)?; - - // Modification Points: Explicitly handles get_object_info errors, distinguishing between object absence and other errors - let info = match store.get_object_info(&bucket, &key, &opts).await { - Ok(info) => info, - Err(err) => { - // If the error indicates the object or its version was not found, return 404 (NoSuchKey) - if is_err_object_not_found(&err) || is_err_version_not_found(&err) { - if is_dir_object(&key) { - let has_children = match probe_prefix_has_children(store, &bucket, &key, false).await { - Ok(has_children) => has_children, - Err(e) => { - error!(bucket, key, error = %e, "Failed to probe children for prefix"); - false - } - }; - let msg = head_prefix_not_found_message(&bucket, &key, has_children); - return Err(S3Error::with_message(S3ErrorCode::NoSuchKey, msg)); - } - // Active-active replication lag window: an object missing - // locally may still be served by proxying the HEAD to a - // replication target (backlog#1675 P1-5). - if let Some(output) = Self::proxy_head_object_to_replication_targets(&req, &bucket, &key, &opts).await { - let response = wrap_response_with_cors(&bucket, &req.method, &req.headers, output).await; - let result = Ok(response); - let _ = helper - .version_id(req.input.version_id.clone().unwrap_or_default()) - .complete(&result); - return result; - } - return Err(S3Error::new(S3ErrorCode::NoSuchKey)); - } - // Other errors, such as insufficient permissions, still return the original error - return Err(ApiError::from(err).into()); - } - }; - if info.delete_marker { - if opts.version_id.is_none() { - return Err(S3Error::new(S3ErrorCode::NoSuchKey)); - } - return Err(S3Error::new(S3ErrorCode::MethodNotAllowed)); - } - if let Some(match_etag) = if_none_match - && let Some(strong_etag) = match_etag.into_etag() - && info - .etag - .as_ref() - .is_some_and(|etag| ETag::Strong(etag.clone()) == strong_etag) - { - return Err(S3Error::new(S3ErrorCode::NotModified)); - } - if let Some(modified_since) = if_modified_since { - // obj_time < givenTime + 1s - if info.mod_time.is_some_and(|mod_time| { - let give_time: OffsetDateTime = modified_since.into(); - mod_time < give_time.add(time::Duration::seconds(1)) - }) { - return Err(S3Error::new(S3ErrorCode::NotModified)); - } - } - if let Some(match_etag) = if_match { - if let Some(strong_etag) = match_etag.into_etag() - && info - .etag - .as_ref() - .is_some_and(|etag| ETag::Strong(etag.clone()) != strong_etag) - { - return Err(S3Error::new(S3ErrorCode::PreconditionFailed)); - } - } else if let Some(unmodified_since) = if_unmodified_since - && info.mod_time.is_some_and(|mod_time| { - let give_time: OffsetDateTime = unmodified_since.into(); - mod_time > give_time.add(time::Duration::seconds(1)) - }) - { - return Err(S3Error::new(S3ErrorCode::PreconditionFailed)); - } - // An authorized replication convergence check only needs etag/size/mtime - // to compare source and replica; it holds no customer key, so the SSE-C - // read validation is skipped for it (and only it). - let replication_check = replication_request_authorized(&req) - && get_header(&req.headers, SUFFIX_SOURCE_REPLICATION_CHECK).as_deref() == Some("true"); - if !replication_check { - validate_sse_headers_for_read(&info.user_defined, &req.headers)?; - - // Validate SSE-C: if the object was encrypted with a customer-provided key, - // the caller must supply the matching key even for HEAD requests (per S3 spec). - validate_ssec_for_read( - &info.user_defined, - req.input.sse_customer_key.as_ref(), - req.input.sse_customer_key_md5.as_ref(), - )?; - } - - // Compute x-amz-expiration header from lifecycle prediction (before info is partially moved) - let expiration_header = resolve_put_object_expiration(&bucket, &info).await; - // Clone ObjectInfo for event notification only when an event will - // actually be built — the clone is expensive for multipart objects. - let event_info = helper.wants_object_info().then(|| info.clone()); - let content_type = { - if let Some(content_type) = &info.content_type { - match ContentType::from_str(content_type) { - Ok(res) => Some(res), - Err(err) => { - error!(content_type = %content_type, error = ?err, "Archive content-type parse failed"); - // - None - } - } - } else { - None - } - }; - let last_modified = info.mod_time.map(Timestamp::from); - - let content_length = info.get_actual_size().map_err(|e| { - error!(error = %e, "Failed to resolve actual object size"); - ApiError::from(e) - })?; - - let metadata_map = info.user_defined.clone(); - let server_side_encryption = metadata_map - .get("x-amz-server-side-encryption") - .map(|v| ServerSideEncryption::from(v.clone())); - let sse_customer_algorithm = metadata_map - .get("x-amz-server-side-encryption-customer-algorithm") - .map(|v| SSECustomerAlgorithm::from(v.clone())); - let sse_customer_key_md5 = metadata_map.get("x-amz-server-side-encryption-customer-key-md5").cloned(); - let sse_kms_key_id = metadata_map.get("x-amz-server-side-encryption-aws-kms-key-id").cloned(); - let storage_class = response_storage_class(&info, &metadata_map); - // checksum: classify once; additional algorithms (XXHash3/64/128, SHA-512, MD5) - // land in `extra` and are emitted as raw headers below (s3s has no typed field). - let ResponseChecksums { - crc32: checksum_crc32, - crc32c: checksum_crc32c, - sha1: checksum_sha1, - sha256: checksum_sha256, - crc64nvme: checksum_crc64nvme, - checksum_type, - extra: extra_checksum_headers, - } = if let Some(checksum_mode) = req.headers.get(AMZ_CHECKSUM_MODE) - && checksum_mode.to_str().unwrap_or_default() == "ENABLED" - && rs.is_none() - { - let (checksums, is_multipart) = info - .decrypt_checksums(opts.part_number.unwrap_or(0), &req.headers) - .map_err(ApiError::from)?; - classify_response_checksums(checksums, is_multipart) - } else { - ResponseChecksums::default() - }; - // Extract standard HTTP headers from user_defined metadata - // Note: These headers are stored with lowercase keys by extract_metadata_from_mime - let cache_control = metadata_map.get("cache-control").cloned(); - let content_disposition = metadata_map.get("content-disposition").cloned(); - let content_language = metadata_map.get("content-language").cloned(); - let website_redirect_location = metadata_map.get(AMZ_WEBSITE_REDIRECT_LOCATION).cloned(); - let expires = info.expires.map(Timestamp::from); - - // Calculate tag count from user_tags already in ObjectInfo - // This avoids an additional API call since user_tags is already populated by get_object_info - let tag_count = if !info.user_tags.is_empty() { - let tag_set = decode_tags(&info.user_tags); - tag_set.len() - } else { - 0 - }; - let output = HeadObjectOutput { - content_length: Some(content_length), - content_type, - content_encoding: info.content_encoding.clone(), - cache_control, - content_disposition, - content_language, - accept_ranges: Some(ACCEPT_RANGES_BYTES.to_string()), - website_redirect_location, - expires, - last_modified, - e_tag: info.etag.map(|etag| to_s3s_etag(&etag)), - metadata: filter_object_metadata(&metadata_map), - version_id: info.version_id.map(|v| v.to_string()), - server_side_encryption, - sse_customer_algorithm, - sse_customer_key_md5, - ssekms_key_id: sse_kms_key_id, - checksum_crc32, - checksum_crc32c, - checksum_sha1, - checksum_sha256, - checksum_crc64nvme, - checksum_type, - storage_class, - // x-amz-restore from object metadata - restore: metadata_map.get(X_AMZ_RESTORE.as_str()).and_then(|v| { - let rs = parse_restore_obj_status(v).ok()?; - Some(rs.to_string2()) - }), - // x-amz-expiration from lifecycle prediction - expiration: expiration_header, - // metadata: object_metadata, - ..Default::default() - }; - - let version_id = req.input.version_id.clone().unwrap_or_default(); - if let Some(event_info) = event_info { - helper = helper.object(event_info); - } - helper = helper.version_id(version_id); - - // NOTE ON CORS: - // Bucket-level CORS headers are intentionally applied only for object retrieval - // operations (GET/HEAD) via `wrap_response_with_cors`. Other S3 operations that - // interact with objects (PUT/POST/DELETE/LIST, etc.) rely on the system-level - // CORS layer instead. In case both are applicable, this bucket-level CORS logic - // takes precedence for these read operations. - let mut response = wrap_response_with_cors(&bucket, &req.method, &req.headers, output).await; - - // Emit additional-checksum headers (XXHash3/64/128, SHA-512) that s3s cannot - // carry on the typed HeadObjectOutput (#1257). - inject_additional_checksum_headers(&mut response.headers, &extra_checksum_headers); - - // Add x-amz-tagging-count header if object has tags - // Per S3 API spec, this header should be present in HEAD object response when tags exist - if tag_count > 0 { - let header_name = http::HeaderName::from_static(AMZ_TAG_COUNT); - if let Ok(header_value) = tag_count.to_string().parse::() { - response.headers.insert(header_name, header_value); - } else { - warn!("Failed to parse x-amz-tagging-count header; skipping"); - } - } - if let Some(retain_date) = metadata_map - .get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER) - .or_else(|| metadata_map.get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE)) - && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.as_bytes()) - && let Ok(header_value) = HeaderValue::from_str(retain_date) - { - response.headers.insert(header_name, header_value); - } - if let Some(mode) = metadata_map - .get(AMZ_OBJECT_LOCK_MODE_LOWER) - .or_else(|| metadata_map.get(AMZ_OBJECT_LOCK_MODE)) - && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_OBJECT_LOCK_MODE_LOWER.as_bytes()) - && let Ok(header_value) = HeaderValue::from_str(mode) - { - response.headers.insert(header_name, header_value); - } - if let Some(legal_hold) = metadata_map - .get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER) - .or_else(|| metadata_map.get(AMZ_OBJECT_LOCK_LEGAL_HOLD)) - && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER.as_bytes()) - && let Ok(header_value) = HeaderValue::from_str(legal_hold) - { - response.headers.insert(header_name, header_value); - } - - if let Some(amz_restore) = metadata_map.get(X_AMZ_RESTORE.as_str()) { - let Ok(restore_status) = parse_restore_obj_status(amz_restore) else { - return Err(S3Error::with_message(S3ErrorCode::Custom("ErrMeta".into()), "parse amz_restore failed.")); - }; - if let Ok(header_value) = HeaderValue::from_str(restore_status.to_string2().as_str()) { - response.headers.insert(X_AMZ_RESTORE, header_value); - } - } - if let Some(amz_restore_request_date) = metadata_map.get(AMZ_RESTORE_REQUEST_DATE) - && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_RESTORE_REQUEST_DATE.as_bytes()) - { - let Ok(amz_restore_request_date) = OffsetDateTime::parse(amz_restore_request_date, &Rfc3339) else { - return Err(S3Error::with_message( - S3ErrorCode::Custom("ErrMeta".into()), - "parse amz_restore_request_date failed.", - )); - }; - let Ok(amz_restore_request_date) = amz_restore_request_date.format(&RFC1123) else { - return Err(S3Error::with_message( - S3ErrorCode::Custom("ErrMeta".into()), - "format amz_restore_request_date failed.", - )); - }; - if let Ok(header_value) = HeaderValue::from_str(&amz_restore_request_date) { - response.headers.insert(header_name, header_value); - } - } - if let Some(amz_restore_expiry_days) = metadata_map.get(AMZ_RESTORE_EXPIRY_DAYS) - && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_RESTORE_EXPIRY_DAYS.as_bytes()) - && let Ok(header_value) = HeaderValue::from_str(amz_restore_expiry_days) - { - response.headers.insert(header_name, header_value); - } - if info.replication_status != ReplicationStatusType::Empty - && let Ok(header_name) = http::HeaderName::from_bytes(AMZ_BUCKET_REPLICATION_STATUS.to_ascii_lowercase().as_bytes()) - && let Ok(header_value) = HeaderValue::from_str(info.replication_status.as_str()) - { - response.headers.insert(header_name, header_value); - } - - let result = Ok(response); - let _ = helper.complete(&result); - - result - } - - #[instrument(level = "debug", skip(self, req))] - pub async fn execute_restore_object(&self, req: S3Request) -> S3Result> { - if let Some(context) = &self.context { - let _ = context.object_store(); - } - - let mut helper = OperationHelper::new(&req, EventName::ObjectRestorePost, S3Operation::RestoreObject); - let RestoreObjectInput { - bucket, - key: object, - restore_request: rreq, - version_id, - .. - } = req.input.clone(); - - validate_table_catalog_object_mutation(&bucket, &object).await?; - - let rreq = rreq.ok_or_else(|| { - S3Error::with_message(S3ErrorCode::Custom("ErrValidRestoreObject".into()), "restore request is required") - })?; - - let Some(store) = self.object_store() else { - return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); - }; - - let version_id_str = version_id.clone().unwrap_or_default(); - let mut opts = post_restore_opts(&version_id_str, &bucket, &object) - .await - .map_err(|_| S3Error::with_message(S3ErrorCode::Custom("ErrPostRestoreOpts".into()), "restore object failed."))?; - apply_bucket_generation_guard(&req, &bucket, &mut opts)?; - // `apply_bucket_generation_guard` deliberately tolerates a missing guard - // (only the S3 access layer installs one), so this must not hard-require - // it. Resolve the current generation instead, exactly as the copy path - // does. The fence is unaffected: the value is re-read from disk and - // compared below, before the restore is admitted. - let restore_bucket_incarnation_id = match opts.expected_bucket_incarnation_id { - Some(incarnation_id) => incarnation_id, - None => { - let incarnation_id = store.bucket_incarnation_id_from_disk(&bucket).await.map_err(ApiError::from)?; - opts.expected_bucket_incarnation_id = Some(incarnation_id); - incarnation_id - } - }; - - // SELECT-type restores skip both the ongoing check and the metadata - // write below, so the accept guard would protect nothing for them — - // they keep the plain (read-locked) accept path. - let is_select = rreq.type_.as_ref().is_some_and(|t| t.as_str() == "SELECT"); - - // Hold the restore-accept guard across the restore-status read, the - // ongoing/already-restored decision, and the metadata write below, so - // two concurrent (non-SELECT) POST ?restore cannot both observe - // ongoing=false and both start a copy-back (backlog#1304). Reads and - // writes inside this scope run with no_lock; the guard is dropped - // before the copy-back is spawned so it never blocks readers. - // Contention on the accept guard (e.g. a concurrent accept or an - // in-flight commit on the same object) is transient — answer 503 - // SlowDown so SDK clients back off and retry instead of treating it - // as a hard failure. - let restore_bucket_lifecycle_guard = Some(acquire_copy_bucket_lifecycle_lock(store.as_ref(), &bucket).await?); - if store.bucket_incarnation_id_from_disk(&bucket).await.map_err(ApiError::from)? != restore_bucket_incarnation_id { - return Err(ApiError::from(StorageError::BucketNotFound(bucket.clone())).into()); - } - let accept_guard = if is_select { - None - } else { - let guard = store - .acquire_restore_accept_guard(&bucket, &object) - .await - .map_err(|_| S3Error::with_message(S3ErrorCode::SlowDown, "restore object failed."))?; - opts.no_lock = true; - Some(guard) - }; - - let mut obj_info = store - .get_object_info(&bucket, &object, &opts) - .await - .map_err(|_| S3Error::with_message(S3ErrorCode::Custom("ErrInvalidObjectState".into()), "restore object failed."))?; - - // Check if object is in a transitioned state - if obj_info.transitioned_object.status != lifecycle::TRANSITION_COMPLETE { - return Err(S3Error::with_message( - S3ErrorCode::Custom("ErrInvalidTransitionedState".into()), - "restore object failed.", - )); - } - - // Validate restore request - if let Err(e) = validate_restore_request(&rreq, store.clone()) { - return Err(S3Error::with_message( - S3ErrorCode::Custom("ErrValidRestoreObject".into()), - format!("Restore object validation failed: {}", e), - )); - } - - // Check if restore is already in progress. AWS answers this with - // 409 RestoreAlreadyInProgress; a Custom code would serialize as a - // retryable 500 and make SDK clients retry the conflict (backlog#1304). - if obj_info.restore_ongoing && !is_select { - return Err(S3Error::with_message( - S3ErrorCode::RestoreAlreadyInProgress, - "Object restore is already in progress.", - )); - } - - let mut already_restored = false; - if let Some(restore_expires) = obj_info.restore_expires - && !obj_info.restore_ongoing - && restore_expires.unix_timestamp() != 0 - { - already_restored = true; - } - - let restore_expiry = lifecycle::expected_expiry_time(OffsetDateTime::now_utc(), *rreq.days.as_ref().unwrap_or(&1)); - let mut metadata = (*obj_info.user_defined).clone(); - let restore_operation_id = (!is_select && !already_restored).then(Uuid::new_v4); - - let mut header = HeaderMap::new(); - - let event_object_info = obj_info.clone(); - let obj_info_ = obj_info.clone(); - if !is_select { - obj_info.metadata_only = true; - metadata.insert(AMZ_RESTORE_EXPIRY_DAYS.to_string(), rreq.days.unwrap_or(1).to_string()); - let request_date = OffsetDateTime::now_utc().format(&Rfc3339).map_err(|e| { - S3Error::with_message(S3ErrorCode::InternalError, format!("format restore request date failed: {}", e)) - })?; - metadata.insert(AMZ_RESTORE_REQUEST_DATE.to_string(), request_date); - if already_restored { - metadata.insert( - X_AMZ_RESTORE.as_str().to_string(), - RestoreStatus { - is_restore_in_progress: Some(false), - restore_expiry_date: Some(Timestamp::from(restore_expiry)), - } - .to_string(), - ); - } else { - metadata.insert( - X_AMZ_RESTORE.as_str().to_string(), - RestoreStatus { - is_restore_in_progress: Some(true), - restore_expiry_date: Some(Timestamp::from(OffsetDateTime::now_utc())), - } - .to_string(), - ); - if let Some(id) = restore_operation_id { - insert_str(&mut metadata, SUFFIX_RESTORE_OPERATION_ID, id.to_string()); - } - } - obj_info.user_defined = Arc::new(metadata); - - // Fence the compare-and-set write: if the accept guard was lost - // (lock-service degradation), another node may have concurrently - // accepted this restore — back off instead of committing a second - // ongoing flag and double-starting the copy-back. - if accept_guard.as_ref().is_some_and(|g| g.is_lock_lost()) { - return Err(S3Error::with_message(S3ErrorCode::SlowDown, "restore object failed.")); - } - - let mut restore_dst_opts = ObjectOptions { - version_id: obj_info_.version_id.map(|v| v.to_string()), - mod_time: obj_info_.mod_time, - no_lock: true, - expected_bucket_incarnation_id: Some(restore_bucket_incarnation_id), - ..Default::default() - }; - if let Some(guard) = restore_bucket_lifecycle_guard.as_ref() { - restore_dst_opts.add_bucket_lifecycle_lock_guard(guard); - } - if let Some(guard) = accept_guard.as_ref() { - guard.add_namespace_lock_fence(&mut restore_dst_opts); - } - store - .clone() - .copy_object( - &bucket, - &object, - &bucket, - &object, - &mut obj_info, - &ObjectOptions { - version_id: obj_info_.version_id.map(|v| v.to_string()), - // Inside the accept-guard critical section (see above). - no_lock: true, - ..Default::default() - }, - &restore_dst_opts, - ) - .await - .map_err(|_| S3Error::with_message(S3ErrorCode::Custom("ErrCopyObject".into()), "restore object failed."))?; - rustfs_scanner::record_dirty_usage_bucket(&bucket); - - if already_restored { - let output = RestoreObjectOutput { - request_charged: Some(RequestCharged::from_static(RequestCharged::REQUESTER)), - restore_output_path: None, - }; - helper = helper - .object(event_object_info.clone()) - .version_id(version_id_str.clone()) - .suppress_event(); - let result = Ok(S3Response::new(output)); - let _ = helper.complete(&result); - return result; - } - } - - // The accept decision is committed; release the object write lock so - // the background copy-back and concurrent reads are never blocked on it. - drop(accept_guard); - drop(restore_bucket_lifecycle_guard); - - // Handle output location for SELECT requests - if let Some(output_location) = &rreq.output_location - && let Some(s3) = &output_location.s3 - && !s3.bucket_name.is_empty() - { - let restore_object = Uuid::new_v4().to_string(); - if let Ok(header_value) = format!("{}{}{}", s3.bucket_name, s3.prefix, restore_object).parse() { - header.insert(X_AMZ_RESTORE_OUTPUT_PATH, header_value); - } - } - - // Spawn restoration task in the background. Pin the copy-back to the - // version the accept resolved and flagged: with a versionless request - // on a versioned bucket, a PUT landing between the accept and the - // copy-back would otherwise re-resolve "latest" to the new version, - // fail (not transitioned), and strand the flagged version at - // ongoing=true forever (backlog#1304). - let store_clone = store.clone(); - let bucket_clone = bucket.clone(); - let object_clone = object.clone(); - let rreq_clone = rreq.clone(); - let version_id_clone = obj_info_ - .version_id - .map(|v| v.to_string()) - .or_else(|| (opts.versioned || opts.version_suspended).then(|| Uuid::nil().to_string())); - let versioned = opts.versioned; - let version_suspended = opts.version_suspended; - let mut restore_operation_metadata = HashMap::new(); - if let Some(id) = restore_operation_id { - insert_str(&mut restore_operation_metadata, SUFFIX_RESTORE_OPERATION_ID, id.to_string()); - } - - spawn_traced(async move { - let opts = ObjectOptions { - transition: TransitionOptions { - restore_request: rreq_clone, - restore_expiry, - ..Default::default() - }, - version_id: version_id_clone, - versioned, - version_suspended, - expected_bucket_incarnation_id: Some(restore_bucket_incarnation_id), - user_defined: restore_operation_metadata, - ..Default::default() - }; - - if let Err(err) = store_clone - .restore_transitioned_object(&bucket_clone, &object_clone, &opts) - .await - { - warn!( - "unable to restore transitioned bucket/object {}/{}: {}", - bucket_clone, - object_clone, - err.to_string() - ); - } else { - rustfs_scanner::record_dirty_usage_bucket(&bucket_clone); - debug!(bucket = %bucket_clone, object = %object_clone, "Transitioned object restored"); - } - }); - - let output = RestoreObjectOutput { - request_charged: Some(RequestCharged::from_static(RequestCharged::REQUESTER)), - restore_output_path: None, - }; - helper = helper.object(event_object_info).version_id(version_id_str); - let result = Ok(S3Response::with_headers(output, header)); - let _ = helper.complete(&result); - result - } - - #[instrument(level = "debug", skip(self, req))] - pub async fn execute_select_object_content( - &self, - req: S3Request, - ) -> S3Result> { - if let Some(context) = &self.context { - let _ = context.object_store(); - } - - crate::app::select_object::execute_select_object_content(req).await - } - - #[instrument(level = "debug", skip(self, req))] - #[hotpath::measure(impl_type = "DefaultObjectUsecase")] - pub async fn execute_put_object_extract(&self, req: S3Request) -> S3Result> { - self.execute_put_object_extract_boxed(req).await - } - - fn execute_put_object_extract_boxed( - &self, - req: S3Request, - ) -> impl std::future::Future>> + Send + '_ { - Box::pin(self.execute_put_object_extract_inner(req)) - } - - async fn execute_put_object_extract_inner(&self, req: S3Request) -> S3Result> { - let helper = OperationHelper::new(&req, EventName::ObjectCreatedPut, S3Operation::PutObject).suppress_event(); - let request_context = helper.request_context_or_from_request(&req); - let auth_method = req.method.clone(); - let auth_uri = req.uri.clone(); - let auth_headers = req.headers.clone(); - let auth_extensions = req.extensions.clone(); - let auth_credentials = req.credentials.clone(); - let auth_region = req.region.clone(); - let auth_service = req.service.clone(); - let auth_trailing_headers = req.trailing_headers.clone(); - // Extract uploads reject SSE-KMS before reaching the SSE layer, so the principal is - // only carried for the day that restriction lifts; the NotImplemented answer below - // deliberately stays ahead of any key authorization. - let extract_principal = SseKmsPrincipal::from_request(&req); - if is_sse_kms_requested(&req.input, &req.headers) { - return Err(s3_error!(NotImplemented, "SSE-KMS is not supported for extract uploads")); - } - let replication_authorized = replication_request_authorized(&req); - let mut bucket_generation_opts = ObjectOptions::default(); - apply_bucket_generation_guard(&req, &req.input.bucket, &mut bucket_generation_opts)?; - let expected_bucket_incarnation_id = bucket_generation_opts.expected_bucket_incarnation_id; - let input = req.input; - - let PutObjectInput { - body, - bucket, - key, - version_id, - cache_control, - content_disposition, - content_encoding, - content_length, - content_language, - content_type, - content_md5, - expires, - object_lock_legal_hold_status, - object_lock_mode, - object_lock_retain_until_date, - server_side_encryption, - sse_customer_algorithm, - sse_customer_key, - sse_customer_key_md5, - ssekms_key_id, - storage_class, - tagging, - website_redirect_location, - .. - } = input; - - let event_version_id = version_id; - let (h_algo, h_key, h_md5) = extract_ssec_params_from_headers(&req.headers)?; - let sse_customer_algorithm = sse_customer_algorithm.or(h_algo); - let sse_customer_key = sse_customer_key.or(h_key); - let sse_customer_key_md5 = sse_customer_key_md5.or(h_md5); - - let original_sse = server_side_encryption.or(extract_server_side_encryption_from_headers(&req.headers)?); - let bucket_sse_config = metadata_sys::get_sse_config(&bucket).await.ok(); - let (mut effective_sse, mut effective_kms_key_id) = resolve_bucket_default_sse( - bucket_sse_config.as_ref().map(|(config, _timestamp)| config), - original_sse, - ssekms_key_id, - false, - ); - if effective_sse - .as_ref() - .is_some_and(|sse| sse.as_str().eq_ignore_ascii_case(ServerSideEncryption::AWS_KMS)) - { - return Err(s3_error!(NotImplemented, "SSE-KMS is not supported for extract uploads")); - } - validate_sse_headers_for_write( - effective_sse.as_ref(), - effective_kms_key_id.as_ref(), - extract_ssekms_context_from_headers(&req.headers)?.as_ref(), - sse_customer_algorithm.as_ref(), - sse_customer_key.as_ref(), - sse_customer_key_md5.as_ref(), - true, - )?; - let Some(body) = body else { return Err(s3_error!(IncompleteBody)) }; - - let size = match content_length { - Some(c) => c, - None => { - if let Some(val) = req.headers.get(AMZ_DECODED_CONTENT_LENGTH) { - match atoi::atoi::(val.as_bytes()) { - Some(x) => x, - None => return Err(s3_error!(UnexpectedContent)), - } - } else { - return Err(s3_error!(UnexpectedContent)); - } - } - }; - if size < 0 { - return Err(s3_error!(UnexpectedContent)); - } - validate_object_key(&key, "PUT")?; - validate_table_catalog_object_mutation(&bucket, &key).await?; - let _ = self - .check_bucket_quota( - &bucket, - QuotaOperation::PutObject, - u64::try_from(size).map_err(|_| S3Error::new(S3ErrorCode::UnexpectedContent))?, - ) - .await?; - - // Apply adaptive buffer sizing based on file size for optimal streaming performance. - // Uses workload profile configuration (enabled by default) to select appropriate buffer size. - // Buffer sizes range from 32KB to 4MB depending on file size and configured workload profile. - let buffer_size = get_buffer_size_opt_in(size); - let body = - tokio::io::BufReader::with_capacity(buffer_size, StreamReader::new(body.map(|f| f.map_err(s3s_body_error_to_io)))); - - let Some(ext) = Path::new(&key).extension().and_then(|s| s.to_str()) else { - return Err(s3_error!(InvalidArgument, "key extension not found")); - }; - - let ext = ext.to_owned(); - - let md5hex = if let Some(base64_md5) = content_md5 { - let md5 = base64_simd::STANDARD - .decode_to_vec(base64_md5.as_bytes()) - .map_err(|e| ApiError::from(StorageError::other(format!("Invalid content MD5: {e}"))))?; - Some(hex_simd::encode_to_string(&md5, hex_simd::AsciiCase::Lower)) - } else { - None - }; - - let sha256hex = get_content_sha256_with_query(&req.headers, req.uri.query()); - let actual_size = size; - - let mut archive_reader = - HashReader::from_stream(body, size, actual_size, md5hex, sha256hex, false).map_err(ApiError::from)?; - - if let Err(err) = archive_reader.add_checksum_from_s3s(&req.headers, req.trailing_headers.clone(), false) { - return Err(ApiError::from(err).into()); - } - - let archive_etag = Arc::new(Mutex::new(None)); - let decoder = CompressionFormat::from_extension(&ext) - .get_decoder(ExtractArchiveEtagReader::new(archive_reader, archive_etag.clone())) - .map_err(|e| { - error!(error = ?e, "Archive decoder creation failed"); - s3_error!(InvalidArgument, "get_decoder err") - })?; - - let mut ar = Archive::new(decoder); - let mut entries = ar.entries().map_err(|e| { - error!(error = ?e, "Archive entry listing failed"); - s3_error!(InvalidArgument, "get entries err") - })?; - - let Some(store) = self.object_store() else { - return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); - }; - - let extract_options = resolve_put_object_extract_options(&req.headers)?; - let extract_limits = put_object_extract_limits(); - let extract_quota_check = if let Some(metadata_sys) = self.bucket_metadata_sys() { - let quota_checker = QuotaChecker::new(metadata_sys); - let check_result = - map_quota_check_outcome(&bucket, quota_checker.check_quota(&bucket, QuotaOperation::PutObject, 0).await)?; - Some(check_result) - } else { - None - }; - let extract_quota_enabled = extract_quota_check - .as_ref() - .is_some_and(|result| result.quota_limit.is_some()); - let version_id = match event_version_id { - Some(v) => v.to_string(), - None => String::new(), - }; - - let notify = current_notify_interface_for_context(self.context.as_deref()); - let req_params = rustfs_targets::extract_params_header(&req.headers); - let host = get_request_host(&req.headers); - let port = get_request_port(&req.headers); - let user_agent = get_request_user_agent(&req.headers); - let mut wrote_any_entry = false; - let mut extracted_entry_count = 0usize; - let mut total_unpacked_size = 0u64; - let object_lock_config_snapshot = store.object_lock_config_snapshot(&bucket).await.map_err(ApiError::from)?; - let object_lock_config_state = object_lock_config_snapshot.state(); - - while let Some(entry) = entries.next().await { - let mut f = match entry { - Ok(f) => f, - Err(e) => { - if extract_options.ignore_errors { - warn!(error = %e, "Archive entry read skipped due to ignore-errors"); - continue; - } - error!(error = %e, "Archive entry read failed"); - return Err(s3_error!(InvalidArgument, "Failed to read archive entry: {:?}", e)); - } - }; - extracted_entry_count = extracted_entry_count.saturating_add(1); - validate_put_object_extract_entry_count(extracted_entry_count, extract_limits)?; - - let fpath = match f.path() { - Ok(path) => path, - Err(e) => { - if extract_options.ignore_errors { - warn!(error = %e, "Archive path decode skipped due to ignore-errors"); - continue; - } - return Err(s3_error!(InvalidArgument, "Failed to decode archive entry path")); - } - }; - - let is_dir = f.header().entry_type().is_dir(); - let fpath = match normalize_extract_entry_key(&fpath.to_string_lossy(), extract_options.prefix.as_deref(), is_dir) { - Ok(fpath) => fpath, - Err(err) => { - if extract_options.ignore_errors { - warn!(error = %err, "Unsafe archive path skipped due to ignore-errors"); - continue; - } - return Err(err); - } - }; - validate_put_object_extract_entry_path(&fpath, extract_limits)?; - validate_table_catalog_object_mutation(&bucket, &fpath).await?; - - let mut auth_req = S3Request { - input: PutObjectInput::default(), - method: auth_method.clone(), - uri: auth_uri.clone(), - headers: auth_headers.clone(), - extensions: auth_extensions.clone(), - credentials: auth_credentials.clone(), - region: auth_region.clone(), - service: auth_service.clone(), - trailing_headers: auth_trailing_headers.clone(), - }; - { - let req_info = req_info_mut(&mut auth_req)?; - req_info.bucket = Some(bucket.clone()); - req_info.object = Some(fpath.clone()); - req_info.version_id = None; - } - let entry_size = f.header().size().unwrap_or_default(); - validate_put_object_extract_entry_size(&fpath, entry_size, extract_limits)?; - total_unpacked_size = total_unpacked_size - .checked_add(entry_size) - .ok_or_else(|| s3_error!(InvalidArgument, "Archive total unpacked size overflowed while processing entries"))?; - validate_put_object_extract_total_size(total_unpacked_size, extract_limits)?; - if let Some(quota_check) = extract_quota_check.as_ref() { - ensure_legacy_archive_size_within_quota(quota_check, total_unpacked_size)?; - } - let mut size = - i64::try_from(entry_size).map_err(|_| s3_error!(InvalidArgument, "Archive entry size does not fit into i64"))?; - // mtime 0 means "unset" in tar headers, and xl.meta cannot represent an - // epoch mod_time anyway (0 nanos decodes as no-mod_time, making the version - // unreadable — rustfs#4842), so fall back to the upload time instead. - let archive_entry_mod_time = f - .header() - .mtime() - .ok() - .filter(|&modified_at_secs| modified_at_secs > 0) - .and_then(|modified_at_secs| OffsetDateTime::from_unix_timestamp(modified_at_secs as i64).ok()); - let mut metadata = HashMap::new(); - let has_explicit_object_lock_retention = object_lock_mode.is_some() || object_lock_retain_until_date.is_some(); - apply_put_request_metadata( - &mut metadata, - &req.headers, - &fpath, - cache_control.clone(), - content_disposition.clone(), - content_encoding.clone(), - content_language.clone(), - content_type.clone(), - expires.clone(), - website_redirect_location.clone(), - tagging.clone(), - storage_class.clone(), - )?; - apply_bucket_default_lock_retention( - &bucket, - object_lock_config_state, - &mut metadata, - has_explicit_object_lock_retention, - )?; - let mut opts = put_opts_with_replication_authorization( - &bucket, - &fpath, - None, - &req.headers, - metadata.clone(), - replication_authorized, - ) - .await - .map_err(ApiError::from)?; - if let Some(quota_check) = extract_quota_check.as_ref() { - apply_quota_admission(&mut opts, quota_check)?; - } - opts.expected_bucket_incarnation_id = expected_bucket_incarnation_id; - opts.object_lock_config_snapshot = Some(Arc::clone(&object_lock_config_snapshot)); - let pax_authorization = - apply_extract_entry_pax_extensions(&mut f, &bucket, &fpath, object_lock_config_state, &mut metadata, &mut opts) - .await?; - for (name, value) in &pax_authorization.headers { - auth_req.headers.insert(name.clone(), value.clone()); - } - if let Some(version_id) = opts.version_id.as_ref() { - req_info_mut(&mut auth_req)?.version_id = Some(version_id.clone()); - } - authorize_request(&mut auth_req, Action::S3Action(S3Action::PutObjectAction)).await?; - if pax_authorization.object_lock_mode.is_some() || pax_authorization.object_lock_retain_until_date.is_some() { - authorize_request(&mut auth_req, Action::S3Action(S3Action::PutObjectRetentionAction)).await?; - } - if pax_authorization.object_lock_legal_hold_status.is_some() { - authorize_request(&mut auth_req, Action::S3Action(S3Action::PutObjectLegalHoldAction)).await?; - } - if opts.version_id.is_some() || pax_authorization.headers.contains_key(AMZ_BUCKET_REPLICATION_STATUS) { - authorize_request(&mut auth_req, Action::S3Action(S3Action::ReplicateObjectAction)).await?; - } - let effective_object_lock_legal_hold_status = pax_authorization - .object_lock_legal_hold_status - .clone() - .or_else(|| object_lock_legal_hold_status.clone()); - let (effective_object_lock_mode, effective_object_lock_retain_until_date) = - if pax_authorization.object_lock_mode.is_some() || pax_authorization.object_lock_retain_until_date.is_some() { - ( - pax_authorization.object_lock_mode.clone(), - pax_authorization.object_lock_retain_until_date.clone(), - ) - } else { - (object_lock_mode.clone(), object_lock_retain_until_date.clone()) - }; - if archive_entry_mod_time.is_some() { - opts.mod_time = archive_entry_mod_time; - } - - debug!("Extracting file: {}, size: {} bytes", fpath, size); - - if is_dir { - if extract_options.ignore_dirs { - debug!("Skipping directory entry during archive extract: {}", fpath); - continue; - } - size = 0; - } - - let actual_size = size; - - let should_compress = - !is_dir && is_disk_compressible(&HeaderMap::new(), &fpath) && size > MIN_DISK_COMPRESSIBLE_SIZE as i64; - - let mut write_plan = WritePlan::new(); - let mut hrd = if is_dir { - HashReader::from_stream(std::io::Cursor::new(Vec::new()), size, actual_size, None, None, false) - .map_err(ApiError::from)? - } else if should_compress { - let algorithm = CompressionAlgorithm::default(); - insert_str(&mut metadata, SUFFIX_COMPRESSION, compression_metadata_value(algorithm)); - insert_str(&mut metadata, SUFFIX_ACTUAL_SIZE, size.to_string()); - - let hrd = HashReader::from_stream(f, size, actual_size, None, None, false).map_err(ApiError::from)?; - write_plan = write_plan.with_compression(algorithm); - hrd - } else { - HashReader::from_stream(f, size, actual_size, None, None, false).map_err(ApiError::from)? - }; - apply_put_request_object_lock_opts( - &bucket, - object_lock_config_state, - effective_object_lock_legal_hold_status, - effective_object_lock_mode, - effective_object_lock_retain_until_date, - &mut opts, - )?; - if let Some(material) = sse_encryption(EncryptionRequest { - bucket: &bucket, - key: &fpath, - server_side_encryption: effective_sse.clone(), - ssekms_key_id: effective_kms_key_id.clone(), - ssekms_context: extract_ssekms_context_from_headers(&req.headers)?, - sse_customer_algorithm: sse_customer_algorithm.clone(), - sse_customer_key: sse_customer_key.clone(), - sse_customer_key_md5: sse_customer_key_md5.clone(), - content_size: actual_size, - principal: extract_principal.as_ref(), - }) - .await? - { - effective_sse = Some(material.server_side_encryption.clone()); - effective_kms_key_id = material.kms_key_id.clone(); - - write_plan = write_plan.with_encryption(material.write_encryption(None)); - - let encryption_metadata = encryption_material_to_metadata(&material)?; - metadata.extend(encryption_metadata.clone()); - opts.user_defined.extend(encryption_metadata); - } - hrd = write_plan.apply(hrd, actual_size).map_err(ApiError::from)?; - opts.user_defined.extend(metadata); - - // Each extracted member is an independent user write and joins - // bucket replication like a regular PUT (MinIO PutObjectExtract - // parity). One immutable decision drives both the pending metadata - // and the post-commit schedule below, same contract as the PUT path - // (https://github.com/rustfs/backlog/issues/1320); inbound replica - // writes are declined inside `must_replicate_object`. - let dsc = must_replicate_object( - &bucket, - &fpath, - &opts.user_defined, - "".to_string(), - opts.delete_marker_replication_status(), - opts.clone(), - ) - .await; - if dsc.replicate_any() { - insert_str(&mut opts.user_defined, SUFFIX_REPLICATION_TIMESTAMP, jiff::Zoned::now().to_string()); - insert_str( - &mut opts.user_defined, - SUFFIX_REPLICATION_STATUS, - dsc.pending_status().unwrap_or_default(), - ); - } - - let mut reader = PutObjReader::new(hrd); - let cache_adapter = self.object_data_cache(); - let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &fpath).await; - - let (obj_info, backfilled_old_current_size) = match store - .put_object_with_old_current_size(&bucket, &fpath, &mut reader, &opts) - .await - { - Ok(result) => result, - Err(e) => { - if extract_options.ignore_errors { - warn!(error = %e, "Archive object write skipped due to ignore-errors"); - continue; - } - return Err(ApiError::from(e).into()); - } - }; - let committed_size = quota_accounting_object_size(&obj_info, extract_quota_enabled)?; - let extract_versioned = BucketVersioningSys::prefix_enabled(&bucket, &fpath).await; - match previous_current_size_from_backfill(backfilled_old_current_size) { - Some(previous_current_size) => { - if extract_versioned { - record_bucket_object_version_write_memory(&bucket, previous_current_size, committed_size).await; - } else { - record_bucket_object_write_memory(&bucket, previous_current_size, committed_size).await; - } - } - None => { - record_bucket_object_write_unknown_previous_memory(&bucket, committed_size, extract_versioned).await; - } - } - let _ = invalidate_object_data_cache_after_put_success(&cache_adapter, &bucket, &fpath).await; - - // Reuse the per-entry pre-commit decision (see `dsc` above) so the - // persisted pending marker and the schedule always agree. - if dsc.replicate_any() { - schedule_object_replication(obj_info.clone(), store.clone(), dsc).await; - } - - if !wrote_any_entry { - rustfs_scanner::record_dirty_usage_bucket(&bucket); - wrote_any_entry = true; - } - - let _manager = get_concurrency_manager(); - let _fpath_clone = fpath.clone(); - let _bucket_clone = bucket.clone(); - let e_tag = obj_info.etag.clone().map(|etag| to_s3s_etag(&etag)); - - let output = PutObjectOutput { - e_tag, - ..Default::default() - }; - - let event_args = rustfs_notify::EventArgs { - event_name: put_event_name_for_post_object(false), - bucket_name: bucket.clone(), - object: convert_ecstore_object_info(obj_info.clone()), - req_params: req_params.clone(), - resp_elements: build_event_resp_elements(&S3Response::new(output.clone()), &request_context.request_id), - version_id: version_id.clone(), - host: host.clone(), - port, - user_agent: user_agent.clone(), - }; - - let notify = notify.clone(); - spawn_background_with_context(Some(request_context.clone()), async move { - notify.notify(event_args).await; - }); - } - - let mut checksums = PutObjectChecksums { - crc32: input.checksum_crc32, - crc32c: input.checksum_crc32c, - sha1: input.checksum_sha1, - sha256: input.checksum_sha256, - crc64nvme: input.checksum_crc64nvme, - }; - apply_trailing_checksums( - input.checksum_algorithm.as_ref().map(|a| a.as_str()), - &req.trailing_headers, - &mut checksums, - ); - - warn!( - "put object extract checksum_crc32={:?}, checksum_crc32c={:?}, checksum_sha1={:?}, checksum_sha256={:?}, checksum_crc64nvme={:?}", - checksums.crc32, checksums.crc32c, checksums.sha1, checksums.sha256, checksums.crc64nvme, - ); - - drop(entries); - let mut decoder = match ar.into_inner() { - Ok(decoder) => decoder, - Err(_) => return Err(s3_error!(InvalidArgument, "Failed to finalize archive reader")), - }; - tokio::io::copy(&mut decoder, &mut tokio::io::sink()) - .await - .map_err(map_extract_archive_error)?; - let archive_etag = archive_etag - .lock() - .ok() - .and_then(|etag| etag.clone()) - .map(|etag| to_s3s_etag(&etag)); - - let output = PutObjectOutput { - e_tag: archive_etag, - checksum_crc32: checksums.crc32, - checksum_crc32c: checksums.crc32c, - checksum_sha1: checksums.sha1, - checksum_sha256: checksums.sha256, - checksum_crc64nvme: checksums.crc64nvme, - ..Default::default() - }; - let result = Ok(S3Response::new(output)); - let _ = helper.complete(&result); - result - } -} - -fn object_attributes_requested(object_attributes: &[ObjectAttributes], name: &'static str) -> bool { - object_attributes.iter().any(|value| { - value.as_str().split(',').any(|part| { - part.trim_matches(|c: char| c.is_whitespace() || c == '"' || c == '\'') - .eq_ignore_ascii_case(name) - }) - }) -} - -/// Fail closed when deciding whether an object-lock-sensitive operation may -/// skip its existing-object lookup. -pub(super) async fn object_lock_checks_required(bucket: &str) -> bool { - get_bucket_metadata(bucket) - .await - .map_or(true, |metadata| metadata.object_locking()) -} - -fn object_lock_checks_required_for_state(state: &metadata_sys::ObjectLockConfigState) -> bool { - match state { - metadata_sys::ObjectLockConfigState::Configured { .. } | metadata_sys::ObjectLockConfigState::Fabricated => true, - metadata_sys::ObjectLockConfigState::ConfirmedAbsent => false, - } -} - -/// rustfs/backlog#1009: map the rename_data old-size backfill onto the -/// `previous_current_size` value the usage-accounting helpers expect. Outer -/// `None` = unknown (no quorum agreement, or a peer predates the field) — the -/// caller must fall back to the degraded accounting path. -fn previous_current_size_from_backfill(backfill: Option) -> Option> { - backfill.map(|observation| match observation { - OldCurrentSize::Present(size) => Some(size.max(0) as u64), - OldCurrentSize::Absent => None, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use http::{Extensions, HeaderMap, HeaderName, HeaderValue, Method, Uri}; - use s3s::dto::{ - DefaultRetention, Delete, DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication, - DeleteReplicationStatus, Destination, ExistingObjectReplication, ExistingObjectReplicationStatus, ObjectIdentifier, - ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRule, ReplicaModifications, ReplicaModificationsStatus, - ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus, RestoreRequest, ServerSideEncryptionByDefault, - ServerSideEncryptionConfiguration, ServerSideEncryptionRule, SourceSelectionCriteria, - }; - use std::pin::Pin; - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; - use std::task::{Context, Poll}; - use tokio::io::{AsyncRead, ReadBuf}; - use tokio_tar::{Builder, EntryType, Header}; - - #[tokio::test] - async fn cancelled_eager_put_commit_owner_reaps_stalled_storage_task() { - let health = Arc::new(ObjectTrafficHealth::enabled_for_test(Duration::ZERO)); - let task_health = Arc::clone(&health); - let cancellation = tokio_util::sync::CancellationToken::new(); - let task_cancellation = cancellation.clone(); - let task = spawn_traced_join(async move { - let _progress = task_health.track_write_storage().expect("write tracking must be enabled"); - task_cancellation.cancelled().await; - }); - let owner = EagerPutCommitOwner::new(task, cancellation, Duration::from_millis(10)); - let request = spawn_traced_join(owner.join()); - - tokio::time::timeout(Duration::from_secs(2), async { - while !health.snapshot().write_stalled { - tokio::task::yield_now().await; - } - }) - .await - .expect("stalled owner must publish write-storage progress"); - - request.abort(); - let _ = request.await; - tokio::time::timeout(Duration::from_secs(2), async { - while health.snapshot().write_stalled { - tokio::task::yield_now().await; - } - }) - .await - .expect("cancelled owner must abort and reap the stalled storage task"); - } - - #[test] - fn delete_response_version_id_preserves_null_and_synthetic_semantics() { - let version_id = Uuid::new_v4(); - - assert_eq!(delete_response_version_id(Some(version_id), false), Some(version_id.to_string())); - assert_eq!(delete_response_version_id(Some(Uuid::nil()), false), Some("null".to_string())); - assert_eq!(delete_response_version_id(Some(Uuid::nil()), true), None); - assert_eq!(delete_response_version_id(None, false), None); - } - - #[test] - fn io_queue_congestion_warn_throttle_emits_once_per_interval() { - let throttle = IoQueueCongestionWarnThrottle::new(); - // The first congested request logs immediately. - assert_eq!(throttle.claim(0), Some(0)); - // Requests inside the window are counted, not logged. - assert_eq!(throttle.claim(1), None); - assert_eq!(throttle.claim(IO_QUEUE_CONGESTION_WARN_INTERVAL_MS - 1), None); - // The next emission reports how many stayed silent. - assert_eq!(throttle.claim(IO_QUEUE_CONGESTION_WARN_INTERVAL_MS), Some(2)); - assert_eq!(throttle.claim(IO_QUEUE_CONGESTION_WARN_INTERVAL_MS + 1), None); - } - - #[tokio::test(start_paused = true)] - async fn cold_fill_disk_admission_preserves_slow_down() { - let manager = Box::leak(Box::new(ConcurrencyManager::with_disk_read_caps_for_test(1, 1))); - let primary = match manager.admit_disk_read(Duration::from_millis(1)).await.unwrap() { - DiskReadAdmission::Primary(permit) => permit, - other => panic!("expected primary admission, got {other:?}"), - }; - let degraded = match manager.admit_disk_read(Duration::from_millis(1)).await.unwrap() { - DiskReadAdmission::Degraded(permit) => permit, - other => panic!("expected degraded admission, got {other:?}"), - }; - - let result = DefaultObjectUsecase::acquire_cold_fill_io_planning(manager, "bucket", "object").await; - assert!(matches!(result, Err(ColdFillError::Storage(StorageError::SlowDown)))); - - drop(degraded); - drop(primary); - } - - #[tokio::test] - async fn cold_fill_closed_disk_admission_is_not_slow_down() { - let manager = Box::leak(Box::new(ConcurrencyManager::with_disk_read_caps_for_test(1, 1))); - manager.close_disk_read_admission_for_test(); - - let result = DefaultObjectUsecase::acquire_cold_fill_io_planning(manager, "bucket", "object").await; - assert!(matches!(result, Err(ColdFillError::DiskAdmissionClosed))); - } - - // classify_response_checksums is the single point that splits decrypted checksum - // pairs into the five s3s-typed fields and the additional-algorithm `extra` - // headers, replacing five copies of the loop. Lock its behaviour (#1252). - #[test] - fn classify_response_checksums_splits_typed_and_extra() { - // Typed algorithms fill named fields; nothing spills into extra. - let c = classify_response_checksums( - vec![ - ("CRC32".to_string(), "AAAAAA==".to_string()), - ("SHA256".to_string(), "c2hhMjU2".to_string()), - ("CRC64NVME".to_string(), "Zm9vYmFyCg==".to_string()), - ], - false, - ); - assert_eq!(c.crc32.as_deref(), Some("AAAAAA==")); - assert_eq!(c.sha256.as_deref(), Some("c2hhMjU2")); - assert_eq!(c.crc64nvme.as_deref(), Some("Zm9vYmFyCg==")); - assert!(c.extra.is_empty(), "typed algorithms must not land in extra"); - - // Additional algorithms land in extra keyed by their response-header name. - let c = classify_response_checksums( - vec![ - ("XXHASH3".to_string(), "eHhoMw==".to_string()), - ("XXHASH64".to_string(), "eHhoNjQ=".to_string()), - ("XXHASH128".to_string(), "eHhoMTI4".to_string()), - ("SHA512".to_string(), "c2hhNTEy".to_string()), - ("MD5".to_string(), "bWQ1".to_string()), - ], - false, - ); - assert!(c.crc32.is_none() && c.sha256.is_none() && c.crc64nvme.is_none()); - let names: Vec<&str> = c.extra.iter().map(|(n, _)| *n).collect(); - for expected in [ - "x-amz-checksum-xxhash3", - "x-amz-checksum-xxhash64", - "x-amz-checksum-xxhash128", - "x-amz-checksum-sha512", - "x-amz-checksum-md5", - ] { - assert!(names.contains(&expected), "extra missing {expected}: {names:?}"); - } - assert_eq!(c.extra.len(), 5); - - // The checksum-type marker is captured as the type, not mistaken for an algorithm. - let c = classify_response_checksums(vec![(AMZ_CHECKSUM_TYPE.to_string(), "COMPOSITE".to_string())], false); - assert!(c.checksum_type.is_some()); - assert!(c.extra.is_empty() && c.crc32.is_none()); - - let c = classify_response_checksums(vec![("CRC32".to_string(), "AAAAAA==-2".to_string())], true); - assert_eq!(c.checksum_type.as_ref().map(ChecksumType::as_str), Some("COMPOSITE")); - - // Empty input yields an all-default result. - let c = classify_response_checksums(Vec::<(String, String)>::new(), false); - assert!(c.crc32.is_none() && c.extra.is_empty() && c.checksum_type.is_none()); - } - - // additional_checksum_echo_pairs derives the PutObject/UploadPart response echo for - // additional algorithms from the server-computed checksum, and nothing for the - // five typed ones (those go through typed output fields). - #[test] - fn additional_checksum_echo_pairs_only_for_new_algorithms() { - // Typed algorithm → no echo pair. - let sha256 = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::SHA256, b"data"); - assert!(additional_checksum_echo_pairs(&sha256).is_empty()); - - // Additional algorithm → exactly one (header, value) pair matching the digest. - let xxh3 = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::XXHASH3, b"data"); - let pairs = additional_checksum_echo_pairs(&xxh3); - assert_eq!(pairs.len(), 1); - assert_eq!(pairs[0].0, "x-amz-checksum-xxhash3"); - assert_eq!(pairs[0].1, xxh3.as_ref().unwrap().encoded); - - // MD5 additional checksum is echoed too. - let md5 = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::MD5, b"data"); - let pairs = additional_checksum_echo_pairs(&md5); - assert_eq!(pairs.len(), 1); - assert_eq!(pairs[0].0, "x-amz-checksum-md5"); - - // None → empty. - assert!(additional_checksum_echo_pairs(&None).is_empty()); - } - - #[test] - fn inject_additional_checksum_headers_writes_all_pairs() { - let mut headers = HeaderMap::new(); - inject_additional_checksum_headers( - &mut headers, - &[ - ("x-amz-checksum-xxhash3", "eHhoMw==".to_string()), - ("x-amz-checksum-md5", "bWQ1".to_string()), - ], - ); - assert_eq!(headers.get("x-amz-checksum-xxhash3").unwrap(), "eHhoMw=="); - assert_eq!(headers.get("x-amz-checksum-md5").unwrap(), "bWQ1"); - // Empty input is a no-op. - let mut empty = HeaderMap::new(); - inject_additional_checksum_headers(&mut empty, &[]); - assert!(empty.is_empty()); - } - - #[test] - fn inject_accept_ranges_header_writes_static_bytes_value() { - let mut headers = HeaderMap::new(); - inject_accept_ranges_header(&mut headers); - - assert_eq!(headers.get(http::header::ACCEPT_RANGES).unwrap(), ACCEPT_RANGES_BYTES); - } - - #[tokio::test] - async fn finalize_get_object_response_injects_accept_ranges_header() { - let req = build_request(GetObjectInput::default(), Method::GET); - let helper = OperationHelper::new(&req, EventName::ObjectAccessedGet, S3Operation::GetObject).suppress_event(); - let response = DefaultObjectUsecase::finalize_get_object_response( - helper, - "bucket", - &req.method, - &req.headers, - None, - String::new(), - GetObjectOutput::default(), - Vec::new(), - ) - .await - .expect("finalize response"); - - assert_eq!(response.headers.get(http::header::ACCEPT_RANGES).unwrap(), ACCEPT_RANGES_BYTES); - } - - fn build_request(input: T, method: Method) -> S3Request { - S3Request { - input, - method, - uri: Uri::from_static("/"), - headers: HeaderMap::new(), - extensions: Extensions::new(), - credentials: None, - region: None, - service: None, - trailing_headers: None, - } - } - - #[test] - fn internal_object_info_lookup_opts_drops_http_preconditions() { - let version_id = Uuid::new_v4().to_string(); - let opts = ObjectOptions { - version_id: Some(version_id.clone()), - no_lock: true, - http_preconditions: Some(HTTPPreconditions { - if_none_match: Some("\"etag\"".to_string()), - if_match: Some("\"other\"".to_string()), - ..Default::default() - }), - ..Default::default() - }; - - let lookup_opts = internal_object_info_lookup_opts(opts); - - assert!(lookup_opts.http_preconditions.is_none()); - assert_eq!(lookup_opts.version_id.as_deref(), Some(version_id.as_str())); - assert!(lookup_opts.no_lock); - } - - // A malformed bucket-default algorithm reaches this resolution only through - // corrupt or hand-edited bucket metadata (PutBucketEncryption validates the - // value), so the invariant is pinned here rather than end-to-end: the copy - // path must resolve managed AES256 exactly like PUT/extract. With an - // unencrypted same-name source and no SSE-C, the resolved default alone - // keeps `copy_changes_encryption` true, so the metadata-only shortcut stays - // off while `sse_encryption` mints a fresh DEK (backlog#1826). - #[test] - fn copy_bucket_default_unknown_sse_algorithm_falls_back_to_aes256() { - let config = ServerSideEncryptionConfiguration { - rules: vec![ServerSideEncryptionRule { - apply_server_side_encryption_by_default: Some(ServerSideEncryptionByDefault { - sse_algorithm: ServerSideEncryption::from(String::from("garbage")), - kms_master_key_id: None, - }), - bucket_key_enabled: None, - }], - }; - - let effective_sse = config - .rules - .first() - .and_then(|rule| rule.apply_server_side_encryption_by_default.as_ref()) - .map(bucket_default_write_sse); - - assert_eq!(effective_sse.as_ref().map(|sse| sse.as_str()), Some(ServerSideEncryption::AES256)); - - // Valid algorithms map to themselves, byte-identical to the PUT path. - for (configured, expected) in [ - (ServerSideEncryption::AES256, ServerSideEncryption::AES256), - (ServerSideEncryption::AWS_KMS, ServerSideEncryption::AWS_KMS), - ] { - let sse = ServerSideEncryptionByDefault { - sse_algorithm: ServerSideEncryption::from_static(configured), - kms_master_key_id: None, - }; - assert_eq!(bucket_default_write_sse(&sse).as_str(), expected); - } - } - - fn bucket_sse_config_with(algorithm: &str, kms_key_id: Option<&str>) -> ServerSideEncryptionConfiguration { - ServerSideEncryptionConfiguration { - rules: vec![ServerSideEncryptionRule { - apply_server_side_encryption_by_default: Some(ServerSideEncryptionByDefault { - sse_algorithm: ServerSideEncryption::from(String::from(algorithm)), - kms_master_key_id: kms_key_id.map(|id| SSEKMSKeyId::from(id.to_string())), - }), - bucket_key_enabled: None, - }], - } - } - - #[test] - fn resolve_bucket_default_sse_prefers_the_request_over_the_bucket_default() { - let config = bucket_sse_config_with(ServerSideEncryption::AWS_KMS, Some("bucket-key")); - - let (sse, kms_key_id) = resolve_bucket_default_sse( - Some(&config), - Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)), - Some(SSEKMSKeyId::from("request-key".to_string())), - false, - ); - - assert_eq!(sse.as_ref().map(|sse| sse.as_str()), Some(ServerSideEncryption::AES256)); - assert_eq!(kms_key_id.as_deref(), Some("request-key")); - } - - #[test] - fn resolve_bucket_default_sse_fills_gaps_from_the_bucket_default() { - let config = bucket_sse_config_with(ServerSideEncryption::AWS_KMS, Some("bucket-key")); - - let (sse, kms_key_id) = resolve_bucket_default_sse(Some(&config), None, None, false); - - assert_eq!(sse.as_ref().map(|sse| sse.as_str()), Some(ServerSideEncryption::AWS_KMS)); - assert_eq!(kms_key_id.as_deref(), Some("bucket-key")); - } - - #[test] - fn resolve_bucket_default_sse_falls_back_to_aes256_for_an_unknown_algorithm() { - // Reachable only through corrupt or hand-edited bucket metadata; - // PutBucketEncryption rejects unknown algorithms. All three call sites - // now share this single decision (backlog#1826). - let config = bucket_sse_config_with("garbage", None); - - let (sse, kms_key_id) = resolve_bucket_default_sse(Some(&config), None, None, false); - - assert_eq!(sse.as_ref().map(|sse| sse.as_str()), Some(ServerSideEncryption::AES256)); - assert!(kms_key_id.is_none()); - } - - #[test] - fn resolve_bucket_default_sse_suppresses_the_default_for_explicit_ssec() { - let config = bucket_sse_config_with(ServerSideEncryption::AES256, Some("bucket-key")); - - let (sse, kms_key_id) = resolve_bucket_default_sse(Some(&config), None, None, true); - - assert!(sse.is_none(), "an SSE-C destination must not also get managed encryption"); - assert!(kms_key_id.is_none()); - } - - #[test] - fn resolve_bucket_default_sse_returns_nothing_without_a_bucket_default() { - let (sse, kms_key_id) = resolve_bucket_default_sse(None, None, None, false); - - assert!(sse.is_none()); - assert!(kms_key_id.is_none()); - } - - #[test] - fn put_request_user_metadata_cannot_suppress_bucket_default_retention() { - let mut metadata = - HashMap::from([(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), ObjectLockRetentionMode::GOVERNANCE.to_string())]); - apply_put_request_metadata( - &mut metadata, - &HeaderMap::new(), - "object", - None, - None, - None, - None, - None, - None, - None, - None, - None, - ) - .unwrap(); - - let state = metadata_sys::ObjectLockConfigState::Configured { - config: ObjectLockConfiguration { - object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), - rule: Some(ObjectLockRule { - default_retention: Some(DefaultRetention { - mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)), - days: Some(1), - years: None, - }), - }), - }, - updated_at: OffsetDateTime::now_utc(), - }; - apply_bucket_default_lock_retention("bucket", &state, &mut metadata, false).unwrap(); - - assert_eq!(metadata.get(AMZ_OBJECT_LOCK_MODE_LOWER).map(String::as_str), Some("COMPLIANCE")); - assert!(metadata.contains_key(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER)); - assert_eq!(metadata.get("x-amz-meta-x-amz-object-lock-mode").map(String::as_str), Some("GOVERNANCE")); - - let mut replication_headers = HeaderMap::new(); - insert_header(&mut replication_headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true"); - insert_header( - &mut replication_headers, - rustfs_utils::http::SUFFIX_SOURCE_REPLICATION_RETENTION_TIMESTAMP, - "2026-01-01T00:00:00Z", - ); - let mut replica_metadata = HashMap::new(); - let explicit_clear = has_replication_retention_update(&replication_headers, true); - apply_bucket_default_lock_retention("bucket", &state, &mut replica_metadata, explicit_clear).unwrap(); - assert!(!replica_metadata.contains_key(AMZ_OBJECT_LOCK_MODE_LOWER)); - assert!(!replica_metadata.contains_key(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER)); - } - - fn pax_record(key: &str, value: &[u8]) -> Vec { - let body_len = 1 + key.len() + 1 + value.len() + 1; - let mut len = body_len + 1; - loop { - let actual_len = len.to_string().len() + body_len; - if actual_len == len { - break; - } - len = actual_len; - } - - let mut record = format!("{len} {key}=").into_bytes(); - record.extend_from_slice(value); - record.push(b'\n'); - assert_eq!(record.len(), len); - record - } - - #[tokio::test] - async fn snowball_pax_rejects_unpaired_object_lock_retention() { - let record = pax_record("minio.metadata.x-amz-object-lock-mode", b"GOVERNANCE"); - let mut builder = Builder::new(Vec::new()); - let mut extension = Header::new_ustar(); - extension.set_size(record.len() as u64); - extension.set_entry_type(EntryType::XHeader); - builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); - - let mut file = Header::new_ustar(); - file.set_size(0); - builder.append_data(&mut file, "object", &b""[..]).await.unwrap(); - let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); - let mut entries = archive.entries().unwrap(); - let mut entry = entries.next().await.unwrap().unwrap(); - let mut metadata = HashMap::from([ - (AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), ObjectLockRetentionMode::COMPLIANCE.to_string()), - (AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), "2030-01-01T00:00:00Z".to_string()), - ]); - let mut opts = ObjectOptions::default(); - let state = metadata_sys::ObjectLockConfigState::Configured { - config: ObjectLockConfiguration { - object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), - rule: None, - }, - updated_at: OffsetDateTime::now_utc(), - }; - - let err = apply_extract_entry_pax_extensions(&mut entry, "bucket", "object", &state, &mut metadata, &mut opts) - .await - .unwrap_err(); - - assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); - assert_eq!(metadata.get(AMZ_OBJECT_LOCK_MODE_LOWER).map(String::as_str), Some("COMPLIANCE")); - } - - #[tokio::test] - async fn snowball_pax_privileged_fields_require_independent_authorization() { - let mut retention = pax_record("minio.metadata.X-Amz-Object-Lock-Mode", b"GOVERNANCE"); - retention.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Retain-Until-Date", b"2099-01-01T00:00:00Z")); - let cases = [ - ("retention", retention, (true, false, false)), - ( - "legal-hold", - pax_record("minio.metadata.X-Amz-Object-Lock-Legal-Hold", b"ON"), - (false, true, false), - ), - ( - "version-id", - pax_record("minio.versionId", Uuid::nil().to_string().as_bytes()), - (false, false, true), - ), - ( - "replication-status", - pax_record("minio.metadata.x-amz-replication-status", b"REPLICA"), - (false, false, true), - ), - ]; - let state = metadata_sys::ObjectLockConfigState::Configured { - config: ObjectLockConfiguration { - object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), - rule: None, - }, - updated_at: OffsetDateTime::now_utc(), - }; - - for (case, record, expected) in cases { - let mut builder = Builder::new(Vec::new()); - let mut extension = Header::new_ustar(); - extension.set_size(record.len() as u64); - extension.set_entry_type(EntryType::XHeader); - builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); - let mut file = Header::new_ustar(); - file.set_size(0); - builder.append_data(&mut file, "object", &b""[..]).await.unwrap(); - let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); - let mut entries = archive.entries().unwrap(); - let mut entry = entries.next().await.unwrap().unwrap(); - - let mut opts = ObjectOptions::default(); - let authorization = - apply_extract_entry_pax_extensions(&mut entry, "bucket", "object", &state, &mut HashMap::new(), &mut opts) - .await - .unwrap(); - - assert_eq!( - ( - authorization.object_lock_mode.is_some() || authorization.object_lock_retain_until_date.is_some(), - authorization.object_lock_legal_hold_status.is_some(), - opts.version_id.is_some() || authorization.headers.contains_key(AMZ_BUCKET_REPLICATION_STATUS), - ), - expected, - "{case} must request only its own additional authorization" - ); - match case { - "retention" => { - assert!(authorization.headers.contains_key(AMZ_OBJECT_LOCK_MODE_LOWER)); - assert!(authorization.headers.contains_key(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER)); - } - "legal-hold" => assert!(authorization.headers.contains_key(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER)), - "version-id" => assert_eq!(opts.version_id.as_deref(), Some(Uuid::nil().to_string().as_str())), - "replication-status" => assert_eq!( - authorization - .headers - .get(AMZ_BUCKET_REPLICATION_STATUS) - .and_then(|value| value.to_str().ok()), - Some("REPLICA") - ), - _ => unreachable!(), - } - } - } - - #[tokio::test] - async fn snowball_pax_rejects_invalid_retention_and_replication_values() { - let mut invalid_mode = pax_record("minio.metadata.X-Amz-Object-Lock-Mode", b"INVALID"); - invalid_mode.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Retain-Until-Date", b"2099-01-01T00:00:00Z")); - let mut invalid_date = pax_record("minio.metadata.X-Amz-Object-Lock-Mode", b"COMPLIANCE"); - invalid_date.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Retain-Until-Date", b"not-a-date")); - let cases = [ - ("invalid-mode", invalid_mode), - ("invalid-date", invalid_date), - ( - "invalid-replication-status", - pax_record("minio.metadata.x-amz-replication-status", b"INVALID"), - ), - ("invalid-version-id", pax_record("minio.versionId", b"not-a-uuid")), - ]; - let state = metadata_sys::ObjectLockConfigState::Configured { - config: ObjectLockConfiguration { - object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), - rule: None, - }, - updated_at: OffsetDateTime::now_utc(), - }; - - for (case, record) in cases { - let mut builder = Builder::new(Vec::new()); - let mut extension = Header::new_ustar(); - extension.set_size(record.len() as u64); - extension.set_entry_type(EntryType::XHeader); - builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); - let mut file = Header::new_ustar(); - file.set_size(0); - builder.append_data(&mut file, "object", &b""[..]).await.unwrap(); - let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); - let mut entries = archive.entries().unwrap(); - let mut entry = entries.next().await.unwrap().unwrap(); - - let err = apply_extract_entry_pax_extensions( - &mut entry, - "bucket", - "object", - &state, - &mut HashMap::new(), - &mut ObjectOptions::default(), - ) - .await - .unwrap_err(); - - assert!( - err.code() == &S3ErrorCode::InvalidArgument || err.code() == &S3ErrorCode::MalformedXML, - "{case}" - ); - } - } - - #[tokio::test] - async fn snowball_pax_preserves_canonical_minio_metadata_and_valid_retention() { - let mut record = pax_record("minio.metadata.Content-Type", b"text/plain"); - record.extend(pax_record("minio.metadata.X-Amz-Meta-Owner", b"alice")); - record.extend(pax_record("minio.metadata.project", b"alpha-demo")); - record.extend(pax_record("minio.metadata.x-amz-tagging", b"classification=public")); - record.extend(pax_record("minio.versionId", Uuid::nil().to_string().as_bytes())); - record.extend(pax_record("minio.metadata.x-amz-replication-status", b"REPLICA")); - record.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Mode", b"GOVERNANCE")); - record.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Retain-Until-Date", b"2099-01-01T00:00:00Z")); - record.extend(pax_record("minio.metadata.X-Amz-Object-Lock-Legal-Hold", b"ON")); - let mut builder = Builder::new(Vec::new()); - let mut extension = Header::new_ustar(); - extension.set_size(record.len() as u64); - extension.set_entry_type(EntryType::XHeader); - builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); - - let mut file = Header::new_ustar(); - file.set_size(0); - builder.append_data(&mut file, "object.txt", &b""[..]).await.unwrap(); - let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); - let mut entries = archive.entries().unwrap(); - let mut entry = entries.next().await.unwrap().unwrap(); - let mut metadata = HashMap::from([ - (AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), ObjectLockRetentionMode::COMPLIANCE.to_string()), - (AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), "2030-01-01T00:00:00Z".to_string()), - ]); - let mut opts = ObjectOptions::default(); - let state = metadata_sys::ObjectLockConfigState::Configured { - config: ObjectLockConfiguration { - object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), - rule: None, - }, - updated_at: OffsetDateTime::now_utc(), - }; - - let authorization = - apply_extract_entry_pax_extensions(&mut entry, "bucket", "object.txt", &state, &mut metadata, &mut opts) - .await - .unwrap(); - - assert_eq!(metadata.get("content-type").map(String::as_str), Some("text/plain")); - assert_eq!(metadata.get("owner").map(String::as_str), Some("alice")); - assert_eq!(metadata.get("project").map(String::as_str), Some("alpha-demo")); - assert_eq!(metadata.get(AMZ_OBJECT_TAGGING).map(String::as_str), Some("classification=public")); - assert!(!metadata.contains_key("x-amz-tagging")); - assert_eq!(metadata.get(AMZ_OBJECT_LOCK_MODE_LOWER).map(String::as_str), Some("GOVERNANCE")); - assert_eq!( - metadata.get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER).map(String::as_str), - Some("2099-01-01T00:00:00Z") - ); - assert_eq!(metadata.get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER).map(String::as_str), Some("ON")); - assert!(metadata.contains_key("x-rustfs-internal-objectlock-legalhold-timestamp")); - assert!(metadata.contains_key("x-minio-internal-objectlock-legalhold-timestamp")); - assert_eq!(metadata.get(AMZ_BUCKET_REPLICATION_STATUS).map(String::as_str), Some("REPLICA")); - assert_eq!(opts.version_id.as_deref(), Some("00000000-0000-0000-0000-000000000000")); - assert!(authorization.object_lock_mode.is_some()); - assert!(authorization.object_lock_retain_until_date.is_some()); - assert!(authorization.object_lock_legal_hold_status.is_some()); - assert!(opts.version_id.is_some()); - assert!(authorization.headers.contains_key(AMZ_BUCKET_REPLICATION_STATUS)); - } - - #[tokio::test] - async fn snowball_pax_rejects_legal_hold_without_bucket_object_lock() { - let record = pax_record("minio.metadata.X-Amz-Object-Lock-Legal-Hold", b"ON"); - let mut builder = Builder::new(Vec::new()); - let mut extension = Header::new_ustar(); - extension.set_size(record.len() as u64); - extension.set_entry_type(EntryType::XHeader); - builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); - - let mut file = Header::new_ustar(); - file.set_size(0); - builder.append_data(&mut file, "object", &b""[..]).await.unwrap(); - let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); - let mut entries = archive.entries().unwrap(); - let mut entry = entries.next().await.unwrap().unwrap(); - let mut metadata = HashMap::new(); - - let err = apply_extract_entry_pax_extensions( - &mut entry, - "bucket", - "object", - &metadata_sys::ObjectLockConfigState::ConfirmedAbsent, - &mut metadata, - &mut ObjectOptions::default(), - ) - .await - .unwrap_err(); - - assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); - assert!(!metadata.contains_key(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER)); - } - - #[tokio::test] - async fn snowball_pax_rejects_invalid_legal_hold_status() { - let record = pax_record("minio.metadata.X-Amz-Object-Lock-Legal-Hold", b"INVALID"); - let mut builder = Builder::new(Vec::new()); - let mut extension = Header::new_ustar(); - extension.set_size(record.len() as u64); - extension.set_entry_type(EntryType::XHeader); - builder.append_data(&mut extension, "pax", &record[..]).await.unwrap(); - - let mut file = Header::new_ustar(); - file.set_size(0); - builder.append_data(&mut file, "object", &b""[..]).await.unwrap(); - let mut archive = Archive::new(std::io::Cursor::new(builder.into_inner().await.unwrap())); - let mut entries = archive.entries().unwrap(); - let mut entry = entries.next().await.unwrap().unwrap(); - let mut metadata = HashMap::new(); - let state = metadata_sys::ObjectLockConfigState::Configured { - config: ObjectLockConfiguration { - object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), - rule: None, - }, - updated_at: OffsetDateTime::now_utc(), - }; - - let err = apply_extract_entry_pax_extensions( - &mut entry, - "bucket", - "object", - &state, - &mut metadata, - &mut ObjectOptions::default(), - ) - .await - .unwrap_err(); - - assert_eq!(err.code(), &S3ErrorCode::MalformedXML); - assert!(!metadata.contains_key(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER)); - } - - #[test] - fn build_put_like_object_lock_metadata_rejects_mode_without_retain_until_date() { - let err = build_put_like_object_lock_metadata( - "test-bucket", - &metadata_sys::ObjectLockConfigState::ConfirmedAbsent, - None, - Some(ObjectLockMode::from_static(ObjectLockMode::GOVERNANCE)), - None, - ) - .unwrap_err(); - - assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); - assert_eq!(err.message(), Some(ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED)); - } - - #[test] - fn object_lock_checks_required_reuses_authoritative_state() { - assert!(!object_lock_checks_required_for_state( - &metadata_sys::ObjectLockConfigState::ConfirmedAbsent - )); - - let configured = metadata_sys::ObjectLockConfigState::Configured { - config: ObjectLockConfiguration { - object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), - rule: None, - }, - updated_at: OffsetDateTime::now_utc(), - }; - assert!(object_lock_checks_required_for_state(&configured)); - assert!(object_lock_checks_required_for_state(&metadata_sys::ObjectLockConfigState::Fabricated)); - } - - #[test] - fn build_put_like_object_lock_metadata_rejects_retain_until_date_without_mode() { - let retain_until = Timestamp::from(OffsetDateTime::now_utc().add(time::Duration::days(1))); - let err = build_put_like_object_lock_metadata( - "test-bucket", - &metadata_sys::ObjectLockConfigState::ConfirmedAbsent, - None, - None, - Some(retain_until), - ) - .unwrap_err(); - - assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); - assert_eq!(err.message(), Some(ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED)); - } - - const NO_BUCKET_LOCK: metadata_sys::ObjectLockConfigState = metadata_sys::ObjectLockConfigState::ConfirmedAbsent; - - fn bucket_default_retention_state(mode: &'static str) -> metadata_sys::ObjectLockConfigState { - metadata_sys::ObjectLockConfigState::Configured { - config: s3s::dto::ObjectLockConfiguration { - object_lock_enabled: Some(s3s::dto::ObjectLockEnabled::from_static(s3s::dto::ObjectLockEnabled::ENABLED)), - rule: Some(s3s::dto::ObjectLockRule { - default_retention: Some(s3s::dto::DefaultRetention { - mode: Some(ObjectLockRetentionMode::from_static(mode)), - days: Some(1), - years: None, - }), - }), - }, - updated_at: OffsetDateTime::now_utc(), - } - } - - fn object_info_with_lock_metadata(metadata: HashMap) -> ObjectInfo { - ObjectInfo { - user_defined: Arc::new(metadata), - mod_time: Some(OffsetDateTime::now_utc()), - ..Default::default() - } - } - - fn compliance_retained_object_info() -> ObjectInfo { - let mut metadata = HashMap::new(); - metadata.insert(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), ObjectLockRetentionMode::COMPLIANCE.to_string()); - metadata.insert(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), "2030-01-01T00:00:00Z".to_string()); - object_info_with_lock_metadata(metadata) - } - - fn legal_hold_object_info() -> ObjectInfo { - let mut metadata = HashMap::new(); - metadata.insert(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER.to_string(), ObjectLockLegalHoldStatus::ON.to_string()); - object_info_with_lock_metadata(metadata) - } - - /// rustfs/backlog#1009: the backfill→accounting mapping must mirror the - /// prelookup exactly — a live latest version maps to `Some(size)` (clamped - /// at 0 like the prelookup's `.max(0)`), absent/delete-marker maps to - /// `None`, and an unknown backfill maps to outer `None` so the caller - /// takes the degraded path instead of fabricating "new object". - #[test] - fn previous_current_size_from_backfill_mirrors_prelookup_semantics() { - assert_eq!(previous_current_size_from_backfill(Some(OldCurrentSize::Present(42))), Some(Some(42))); - assert_eq!(previous_current_size_from_backfill(Some(OldCurrentSize::Present(-7))), Some(Some(0))); - assert_eq!(previous_current_size_from_backfill(Some(OldCurrentSize::Absent)), Some(None)); - assert_eq!(previous_current_size_from_backfill(None), None); - } - - #[test] - fn validate_existing_object_lock_allows_versioned_new_version_with_compliance_retention() { - let opts = ObjectOptions { - versioned: true, - version_id: None, - ..Default::default() - }; - - validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts) - .expect("versioned put should create a new version"); - } - - #[test] - fn validate_existing_object_lock_allows_versioned_new_version_with_legal_hold() { - let opts = ObjectOptions { - versioned: true, - version_id: None, - ..Default::default() - }; - - validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &legal_hold_object_info(), &opts) - .expect("versioned put should create a new version"); - } - - #[test] - fn validate_existing_object_lock_blocks_unversioned_compliance_overwrite() { - let err = validate_existing_object_lock_for_write( - &NO_BUCKET_LOCK, - &compliance_retained_object_info(), - &ObjectOptions::default(), - ) - .expect_err("unversioned overwrite should still be blocked"); - - assert_eq!(err.code(), &S3ErrorCode::AccessDenied); - } - - #[test] - fn validate_existing_object_lock_blocks_suspended_version_compliance_overwrite() { - let opts = ObjectOptions { - versioned: true, - version_suspended: true, - version_id: None, - ..Default::default() - }; - let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts) - .expect_err("suspended versioning overwrite should still be blocked"); - - assert_eq!(err.code(), &S3ErrorCode::AccessDenied); - } - - #[test] - fn validate_existing_object_lock_blocks_explicit_version_compliance_overwrite() { - let opts = ObjectOptions { - versioned: true, - version_id: Some(Uuid::new_v4().to_string()), - ..Default::default() - }; - let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts) - .expect_err("explicit version overwrite should still be blocked"); - - assert_eq!(err.code(), &S3ErrorCode::AccessDenied); - } - - /// The source's lock state governs the replica (rustfs/backlog#1953): - /// an authorized replication write carrying the locking category's source - /// timestamp may overwrite a locked version; the set layer's LWW then - /// decides per category. - #[test] - fn validate_existing_object_lock_allows_authorized_replication_overwrite() { - let opts = ObjectOptions { - versioned: true, - version_id: Some(Uuid::new_v4().to_string()), - replication_request: true, - replication_retention_timestamp: Some(OffsetDateTime::UNIX_EPOCH), - replication_legalhold_timestamp: Some(OffsetDateTime::UNIX_EPOCH), - ..Default::default() - }; - - validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts) - .expect("replication write must bypass the destination COMPLIANCE lock"); - validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &legal_hold_object_info(), &opts) - .expect("replication write must bypass the destination legal hold"); - } - - /// Without the locking category's source timestamp the LWW merge cannot - /// judge it, so the write stays rejected instead of lifting the lock. - #[test] - fn validate_existing_object_lock_rejects_replication_overwrite_without_lock_timestamp() { - let opts = ObjectOptions { - versioned: true, - version_id: Some(Uuid::new_v4().to_string()), - replication_request: true, - replication_tagging_timestamp: Some(OffsetDateTime::UNIX_EPOCH), - ..Default::default() - }; - - let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts) - .expect_err("COMPLIANCE lock must hold without a retention source timestamp"); - assert_eq!(err.code(), &S3ErrorCode::AccessDenied); - let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &legal_hold_object_info(), &opts) - .expect_err("legal hold must hold without a legal-hold source timestamp"); - assert_eq!(err.code(), &S3ErrorCode::AccessDenied); - } - - /// The bucket default retention locks a version without explicit - /// retention keys; the pre-check judges the same authoritative state as - /// the set-layer gate, so a tagging-only replication write is rejected - /// and one carrying the retention source timestamp passes to LWW. - #[test] - fn validate_existing_object_lock_judges_bucket_default_retention_for_replication_overwrite() { - let default_protected = object_info_with_lock_metadata(HashMap::new()); - let tagging_only = ObjectOptions { - versioned: true, - version_id: Some(Uuid::new_v4().to_string()), - replication_request: true, - replication_tagging_timestamp: Some(OffsetDateTime::UNIX_EPOCH), - ..Default::default() - }; - let with_retention_decision = ObjectOptions { - replication_retention_timestamp: Some(OffsetDateTime::UNIX_EPOCH), - ..tagging_only.clone() - }; - - for mode in [ObjectLockRetentionMode::COMPLIANCE, ObjectLockRetentionMode::GOVERNANCE] { - let state = bucket_default_retention_state(mode); - let err = validate_existing_object_lock_for_write(&state, &default_protected, &tagging_only) - .expect_err("bucket default retention must hold without a retention source timestamp"); - assert_eq!(err.code(), &S3ErrorCode::AccessDenied, "{mode}"); - validate_existing_object_lock_for_write(&state, &default_protected, &with_retention_decision) - .expect("the retention source timestamp hands the default retention to LWW"); - } - - // Without a bucket default the same version is simply unlocked. - validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &default_protected, &tagging_only) - .expect("no bucket default, no lock"); - } - - #[test] - fn is_put_object_extract_requested_accepts_meta_header() { - let mut headers = HeaderMap::new(); - headers.insert(AMZ_SNOWBALL_EXTRACT, HeaderValue::from_static("true")); - - assert!(is_put_object_extract_requested(&headers)); - } - - #[test] - fn is_put_object_extract_requested_accepts_compat_header_case_insensitive() { - let mut headers = HeaderMap::new(); - headers.insert(AMZ_SNOWBALL_EXTRACT_COMPAT, HeaderValue::from_static(" TRUE ")); - - assert!(is_put_object_extract_requested(&headers)); - } - - #[test] - fn is_put_object_extract_requested_rejects_missing_or_false_value() { - let mut headers = HeaderMap::new(); - assert!(!is_put_object_extract_requested(&headers)); - - headers.insert(AMZ_SNOWBALL_EXTRACT, HeaderValue::from_static("false")); - assert!(!is_put_object_extract_requested(&headers)); - } - - #[test] - fn normalize_snowball_prefix_trims_slashes_and_whitespace() { - assert_eq!( - normalize_snowball_prefix(" /batch/incoming/ ").unwrap(), - Some("batch/incoming".to_string()) - ); - assert_eq!(normalize_snowball_prefix("///").unwrap(), None); - } - - #[test] - fn normalize_snowball_prefix_rejects_parent_dir_components() { - assert!(normalize_snowball_prefix("../victim-bucket").is_err()); - assert!(normalize_snowball_prefix("safe/../../victim-bucket").is_err()); - assert!(normalize_snowball_prefix("safe\\..\\victim-bucket").is_err()); - } - - #[test] - fn normalize_extract_entry_key_applies_prefix_and_directory_suffix() { - assert_eq!( - normalize_extract_entry_key("nested/path.txt", Some("imports"), false).unwrap(), - "imports/nested/path.txt" - ); - assert_eq!( - normalize_extract_entry_key("nested/dir/", Some("imports"), true).unwrap(), - "imports/nested/dir/" - ); - assert_eq!(normalize_extract_entry_key("top-level", None, false).unwrap(), "top-level"); - } - - #[test] - fn normalize_extract_entry_key_rejects_bucket_escape_paths() { - assert!(normalize_extract_entry_key("../victim-bucket/evil.txt", None, false).is_err()); - assert!(normalize_extract_entry_key("safe/../../victim-bucket/evil.txt", None, false).is_err()); - assert!(normalize_extract_entry_key("safe\\..\\victim-bucket\\evil.txt", None, false).is_err()); - assert!(normalize_extract_entry_key("evil.txt", Some("../victim-bucket"), false).is_err()); - } - - #[test] - fn should_use_zero_copy_rejects_boundary_at_1mb() { - let headers = HeaderMap::new(); - - assert!(!should_use_zero_copy(1024 * 1024, &headers)); - } - - #[test] - fn should_use_zero_copy_rejects_small_objects() { - let headers = HeaderMap::new(); - - assert!(!should_use_zero_copy(1024 * 1024 - 1, &headers)); - } - - #[test] - fn should_use_zero_copy_rejects_one_megabyte() { - let headers = HeaderMap::new(); - - assert!(!should_use_zero_copy(1024 * 1024, &headers)); - } - - #[test] - fn should_use_zero_copy_rejects_encrypted_requests() { - let mut headers = HeaderMap::new(); - headers.insert(AMZ_SERVER_SIDE_ENCRYPTION, HeaderValue::from_static("AES256")); - - assert!(!should_use_zero_copy(2 * 1024 * 1024, &headers)); - } - - #[test] - fn aws_chunked_put_prefers_decoded_content_length() { - let mut headers = HeaderMap::new(); - headers.insert("content-encoding", HeaderValue::from_static("aws-chunked")); - headers.insert(AMZ_DECODED_CONTENT_LENGTH, HeaderValue::from_static("71680")); - - let decoded = decoded_content_length_from_headers(&headers).expect("decoded content length should parse"); - assert!(request_uses_aws_chunked(&headers)); - assert_eq!(decoded, Some(71680)); - - let resolved = match (request_uses_aws_chunked(&headers), decoded, Some(99999)) { - (true, Some(decoded), _) => decoded, - (_, _, Some(c)) => c, - (_, Some(decoded), None) => decoded, - _ => unreachable!("test provides a valid size source"), - }; - - assert_eq!(resolved, 71680); - } - - #[test] - fn should_buffer_get_object_in_memory_respects_hard_safety_cap() { - let info = ObjectInfo::default(); - let configured_threshold = 20_i64 * 1024 * 1024 * 1024; - let response_len = 80_i64 * 1024 * 1024; - let should_buffer = - should_buffer_get_object_in_memory_with_threshold(&info, response_len, None, false, configured_threshold, 1, true); - - assert!( - !should_buffer, - "64MiB hard cap must force streaming when response exceeds cap even if configured threshold is much higher" - ); - } - - #[test] - fn should_buffer_get_object_in_memory_allows_small_non_range_requests() { - let info = ObjectInfo::default(); - let configured_threshold = 10_i64 * 1024 * 1024; - - assert!(should_buffer_get_object_in_memory_with_threshold( - &info, - 1024 * 1024, - None, - false, - configured_threshold, - 1, - true - )); - assert!(!should_buffer_get_object_in_memory_with_threshold( - &info, - 1024 * 1024, - Some(1), - false, - configured_threshold, - 1, - true - )); - assert!(!should_buffer_get_object_in_memory_with_threshold( - &info, - 1024 * 1024, - None, - true, - configured_threshold, - 1, - true - )); - } - - #[test] - fn should_buffer_get_object_in_memory_requires_seek_buffer_opt_in() { - let info = ObjectInfo::default(); - let configured_threshold = 10_i64 * 1024 * 1024; - - assert!(!should_buffer_get_object_in_memory_with_threshold( - &info, - 1024, - None, - false, - configured_threshold, - 1, - false - )); - } - - #[test] - fn should_buffer_get_object_in_memory_respects_configured_threshold_below_cap() { - let info = ObjectInfo::default(); - let configured_threshold = 10_i64 * 1024 * 1024; - - assert!(should_buffer_get_object_in_memory_with_threshold( - &info, - configured_threshold, - None, - false, - configured_threshold, - 1, - true - )); - assert!(!should_buffer_get_object_in_memory_with_threshold( - &info, - configured_threshold + 1, - None, - false, - configured_threshold, - 1, - true - )); - } - - #[test] - fn should_buffer_get_object_in_memory_rejects_unknown_lengths_and_disabled_thresholds() { - let info = ObjectInfo::default(); - let configured_threshold = 10_i64 * 1024 * 1024; - - assert!(!should_buffer_get_object_in_memory_with_threshold( - &info, - 0, - None, - false, - configured_threshold, - 1, - true - )); - assert!(!should_buffer_get_object_in_memory_with_threshold( - &info, - -1, - None, - false, - configured_threshold, - 1, - true - )); - assert!(!should_buffer_get_object_in_memory_with_threshold(&info, 1024, None, false, 0, 1, true)); - } - - #[test] - fn should_buffer_get_object_in_memory_reduces_threshold_under_concurrency() { - let info = ObjectInfo::default(); - let configured_threshold = 10_i64 * 1024 * 1024; - - assert!(should_buffer_get_object_in_memory_with_threshold( - &info, - configured_threshold, - None, - false, - configured_threshold, - 1, - true - )); - assert!(!should_buffer_get_object_in_memory_with_threshold( - &info, - configured_threshold, - None, - false, - configured_threshold, - 32, - true - )); - assert!(should_buffer_get_object_in_memory_with_threshold( - &info, - 4_i64 * 1024 * 1024, - None, - false, - configured_threshold, - rustfs_config::DEFAULT_OBJECT_HIGH_CONCURRENCY_THRESHOLD, - true - )); - } - - /// Polls the cache until the detached fill (ODC-15) populates the entry, so - /// a follow-up GET is a deterministic hit rather than racing the fill task. - async fn wait_for_cache_hit( - adapter: &crate::app::object_data_cache::ObjectDataCacheAdapter, - bucket: &str, - object: &str, - etag: &str, - size: u64, - ) { - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket, - object, - version_id: None, - etag, - size, - data_dir_u128: None, - mod_time_unix_nanos: 0, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - for _ in 0..400 { - if matches!(adapter.lookup_body(&plan).await, rustfs_object_data_cache::ObjectDataCacheLookup::Hit(_)) { - return; - } - tokio::time::sleep(Duration::from_millis(5)).await; - } - panic!("detached fill did not populate the cache within the timeout"); - } - - struct ReadProbeReader { - reads: Arc, - } - - impl AsyncRead for ReadProbeReader { - fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll> { - self.reads.fetch_add(1, AtomicOrdering::Relaxed); - Poll::Ready(Ok(())) - } - } - - struct DataProbeReader { - reads: Arc, - data: std::io::Cursor>, - } - - struct ColdFillMatrixReader { - inner: tokio::io::DuplexStream, - first_poll_recorded: bool, - completion_recorded: bool, - first_polls: Arc, - completed: Arc, - bytes_read: Arc, - } - - impl AsyncRead for ColdFillMatrixReader { - fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - if !self.first_poll_recorded { - self.first_poll_recorded = true; - self.first_polls.fetch_add(1, AtomicOrdering::Relaxed); - } - let before = buf.filled().len(); - match Pin::new(&mut self.inner).poll_read(cx, buf) { - Poll::Ready(Ok(())) => { - let read = buf.filled().len().saturating_sub(before); - self.bytes_read.fetch_add(read, AtomicOrdering::Relaxed); - if read == 0 && !self.completion_recorded { - self.completion_recorded = true; - self.completed.fetch_add(1, AtomicOrdering::Relaxed); - } - Poll::Ready(Ok(())) - } - other => other, - } - } - } - - impl AsyncRead for DataProbeReader { - fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - self.reads.fetch_add(1, AtomicOrdering::Relaxed); - - let remaining = buf.remaining(); - if remaining == 0 { - return Poll::Ready(Ok(())); - } - - let position = usize::try_from(self.data.position()).unwrap_or(usize::MAX); - let source = self.data.get_ref(); - if position >= source.len() { - return Poll::Ready(Ok(())); - } - - let end = position.saturating_add(remaining).min(source.len()); - buf.put_slice(&source[position..end]); - self.data.set_position(u64::try_from(end).unwrap_or(u64::MAX)); - Poll::Ready(Ok(())) - } - } - - struct PendingReader; - - impl AsyncRead for PendingReader { - fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll> { - Poll::Pending - } - } - - // Emits `fail_after` bytes from `data`, then returns a hard read error. Used - // to inject the "read K bytes then Err" partial-read case (#1324). - struct ErrAfterReader { - data: std::io::Cursor>, - fail_after: usize, - emitted: usize, - } - - impl AsyncRead for ErrAfterReader { - fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - if self.emitted >= self.fail_after { - return Poll::Ready(Err(std::io::Error::other("injected mid-stream read error"))); - } - let remaining = buf.remaining(); - if remaining == 0 { - return Poll::Ready(Ok(())); - } - let want = (self.fail_after - self.emitted).min(remaining); - let position = usize::try_from(self.data.position()).unwrap_or(usize::MAX); - let source = self.data.get_ref(); - let end = position.saturating_add(want).min(source.len()); - if end <= position { - return Poll::Ready(Err(std::io::Error::other("injected mid-stream read error"))); - } - let chunk_len = end - position; - buf.put_slice(&source[position..end]); - self.data.set_position(u64::try_from(end).unwrap_or(u64::MAX)); - self.emitted += chunk_len; - Poll::Ready(Ok(())) - } - } - - fn cursor_reader(bytes: &[u8]) -> std::io::Cursor> { - std::io::Cursor::new(bytes.to_vec()) - } - - // #1324: the strict materialization helper is the shared exact-length gate - // for the encrypted, seek, and cache memory branches. For a declared length N - // only an exact N-byte read succeeds; a short read (N-1), an over-long read - // (N+1), and a mid-stream read error all hard-fail. This is the reversal - // guard for every one of those sources at once: restoring WARN-and-serve or a - // partial fallback would flip the short/over-long/error assertions to Ok. - #[tokio::test] - async fn strict_materialize_object_body_requires_exact_length() { - // Exact length: the only accepted outcome. - let buf = strict_materialize_object_body(cursor_reader(b"hello"), 5, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ) - .await - .expect("exact-length read must materialize"); - assert_eq!(buf, b"hello"); - assert_eq!(buf.capacity(), 5, "exact materialization must allocate only the declared body length"); - - let exact_large = vec![7_u8; 64 * 1024]; - let buf = strict_materialize_object_body( - std::io::Cursor::new(exact_large.clone()), - exact_large.len(), - GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ, - ) - .await - .expect("64 KiB exact-length read must materialize"); - assert_eq!(buf.capacity(), exact_large.len()); - - let mut overlong_large = exact_large; - overlong_large.push(9); - let overlong = strict_materialize_object_body( - std::io::Cursor::new(overlong_large), - 64 * 1024, - GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ, - ) - .await; - assert!(matches!( - overlong, - Err(StrictMaterializeError::LengthMismatch { - expected: 65_536, - actual: 65_537 - }) - )); - - // Short read (actual = expected - 1): a clean EOF before the declared - // length must be a hard error, never a truncated served body. - let short = strict_materialize_object_body(cursor_reader(b"hell"), 5, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ).await; - assert!( - matches!( - short, - Err(StrictMaterializeError::LengthMismatch { - expected: 5, - actual: 4, - .. - }) - ), - "short read must fail with a length mismatch, got {short:?}", - short = short.as_ref().map(|b| b.len()) - ); - - // Over-long read (actual = expected + 1): must fail rather than silently - // truncate to the committed Content-Length. - let long = strict_materialize_object_body(cursor_reader(b"hello!"), 5, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ).await; - assert!( - matches!(long, Err(StrictMaterializeError::LengthMismatch { expected: 5, actual: 6 })), - "over-long read must fail with a length mismatch, got {long:?}", - long = long.as_ref().map(|b| b.len()) - ); - - // Read K bytes then Err: must surface the read error and never return the - // partially consumed buffer (which the caller could otherwise re-stream). - let reader = ErrAfterReader { - data: cursor_reader(b"hello"), - fail_after: 3, - emitted: 0, - }; - let errored = strict_materialize_object_body(reader, 5, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ).await; - assert!( - matches!(errored, Err(StrictMaterializeError::Read { consumed: 3, .. })), - "a mid-stream read error must be reported as a read failure" - ); - } - - #[test] - fn cold_fill_zero_timeout_policy_disables_deadline() { - let policy = GetObjectTimeoutPolicy { - get_object_timeout: Duration::ZERO, - ..GetObjectTimeoutPolicy::default() - }; - let wrapper = RequestTimeoutWrapper::with_request_id(policy.clone(), "cold-fill-zero-timeout"); - assert!(cold_fill_deadline(&wrapper, &policy, 1).is_none()); - } - - #[tokio::test(start_paused = true)] - async fn cold_fill_producer_deadline_is_capped_at_ten_minutes() { - let disabled = GetObjectTimeoutPolicy { - get_object_timeout: Duration::ZERO, - ..GetObjectTimeoutPolicy::default() - }; - let now = tokio::time::Instant::now(); - assert_eq!(cold_fill_producer_deadline(&disabled, 1) - now, Duration::from_secs(600)); - - let long = GetObjectTimeoutPolicy { - get_object_timeout: Duration::from_secs(3600), - enable_dynamic_timeout: false, - ..GetObjectTimeoutPolicy::default() - }; - let now = tokio::time::Instant::now(); - assert_eq!(cold_fill_producer_deadline(&long, 1) - now, Duration::from_secs(600)); - } - - #[tokio::test] - async fn cold_fill_startup_wait_stops_when_last_consumer_cancels() { - let cancellation = tokio_util::sync::CancellationToken::new(); - let waiting = tokio::spawn({ - let cancellation = cancellation.clone(); - async move { await_cold_fill_startup(std::future::pending::<()>(), &cancellation, None).await } - }); - tokio::task::yield_now().await; - - cancellation.cancel(); - - let result = tokio::time::timeout(Duration::from_secs(1), waiting) - .await - .expect("startup wait must observe cancellation") - .expect("startup wait task must not panic"); - assert!(matches!(result, Err(ColdFillStartupWaitError::Cancelled))); - } - - #[tokio::test(start_paused = true)] - async fn cold_fill_startup_wait_with_deadline_still_observes_cancellation() { - let cancellation = tokio_util::sync::CancellationToken::new(); - let deadline = tokio::time::Instant::now() + Duration::from_secs(60); - let waiting = tokio::spawn({ - let cancellation = cancellation.clone(); - async move { await_cold_fill_startup(std::future::pending::<()>(), &cancellation, Some(deadline)).await } - }); - tokio::task::yield_now().await; - - cancellation.cancel(); - - let result = waiting.await.expect("startup wait task must not panic"); - assert!(matches!(result, Err(ColdFillStartupWaitError::Cancelled))); - } - - #[tokio::test(start_paused = true)] - async fn cold_fill_startup_wait_reports_deadline_exceeded() { - let cancellation = tokio_util::sync::CancellationToken::new(); - let deadline = tokio::time::Instant::now() + Duration::from_millis(1); - - let result = await_cold_fill_startup(std::future::pending::<()>(), &cancellation, Some(deadline)).await; - - assert!(matches!(result, Err(ColdFillStartupWaitError::DeadlineExceeded))); - } - - #[tokio::test] - async fn cold_fill_late_miss_second_chance_hits_without_reader() { - let adapter = ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 1024 * 1024, - max_memory_percent: 0, - max_entry_bytes: 1024, - min_free_memory_percent: 0, - fill_concurrency_max: 1, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("second-chance cache config must be valid"); - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "late-bucket", - object: "late-object", - version_id: None, - etag: "late-etag", - size: 4, - data_dir_u128: Some(1), - mod_time_unix_nanos: 1, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - assert!(matches!( - adapter.lookup_body(&plan).await, - rustfs_object_data_cache::ObjectDataCacheLookup::Miss - )); - let request_lookups = adapter.cache().stats().lookups; - assert_eq!(request_lookups, 1, "the authoritative request lookup must be counted once"); - - let reservation = adapter.reserve_body(&plan).expect("late producer must reserve"); - let reserved = reservation.wrap_bytes(Bytes::from_static(b"body")); - let _ = adapter.fill_reserved_body(&plan, reserved).await; - let coordinator = adapter.cold_fill_coordinator(); - let cache_key = plan.key().cloned().expect("late plan must be cacheable"); - let adapter = Arc::new(adapter); - let readers = Arc::new(AtomicUsize::new(0)); - let outcome = coordinate_cold_fill(&coordinator, cache_key, None, None, { - let adapter = Arc::clone(&adapter); - let readers = Arc::clone(&readers); - move |producer| { - let adapter = Arc::clone(&adapter); - let plan = plan.clone(); - let readers = Arc::clone(&readers); - async move { - if let Some(body) = lookup_cold_fill_second_chance(&adapter, &plan).await { - producer.finish_shared(Ok(body)); - return; - } - readers.fetch_add(1, AtomicOrdering::Relaxed); - producer.bypass(); - } - } - }) - .await; - let ColdFillCoordinateOutcome::Ready(Ok(body)) = outcome else { - panic!("late request must observe the completed fill, got {outcome:?}"); - }; - assert_eq!(body, Bytes::from_static(b"body")); - assert_eq!( - adapter.cache().stats().lookups, - request_lookups, - "the producer second chance must not count another request lookup" - ); - assert_eq!(readers.load(AtomicOrdering::Relaxed), 0); - } - - #[tokio::test] - async fn cold_fill_timeout_is_shared_and_releases_resources() { - let adapter = Arc::new( - ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 1024 * 1024, - max_memory_percent: 0, - max_entry_bytes: 1024, - min_free_memory_percent: 0, - fill_concurrency_max: 1, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("timeout cache config must be valid"), - ); - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "timeout-bucket", - object: "timeout-object", - version_id: None, - etag: "timeout-etag", - size: 1, - data_dir_u128: Some(1), - mod_time_unix_nanos: 1, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - let key = plan.key().cloned().expect("timeout body must be cacheable"); - let coordinator = adapter.cold_fill_coordinator(); - let ColdFillRole::Produce(mut producer) = coordinator.join(key.clone()) else { - panic!("first timeout request must produce"); - }; - let leader = producer.waiter(); - let reservation = adapter.reserve_body(&plan); - let disk_permits = Arc::new(tokio::sync::Semaphore::new(1)); - let disk_gate = Arc::clone(&disk_permits); - let readers = Arc::new(AtomicUsize::new(0)); - let reader_count = Arc::clone(&readers); - let producer_task = tokio::spawn(start_cold_fill_producer( - producer, - reservation, - move || async move { - let permit = disk_gate - .acquire_owned() - .await - .map_err(|_| ColdFillError::DiskAdmissionClosed)?; - let mut io = DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager()); - io.disk_permit = Some(permit.into()); - Ok(io) - }, - move || async move { - reader_count.fetch_add(1, AtomicOrdering::Relaxed); - Ok(GetObjectReader { - stream: Box::new(PendingReader), - object_info: ObjectInfo { - size: 1, - actual_size: 1, - ..Default::default() - }, - buffered_body: None, - body_source: GetObjectBodySource::HookMissed, - }) - }, - ColdFillProducerExecution { - expected: 1, - deadline: Some(tokio::time::Instant::now() + Duration::from_millis(20)), - adapter: Arc::clone(&adapter), - engine_plan: plan.clone(), - }, - )); - tokio::time::timeout(Duration::from_secs(1), async { - while readers.load(AtomicOrdering::Relaxed) == 0 { - tokio::task::yield_now().await; - } - }) - .await - .expect("producer reader must open"); - let ColdFillRole::Wait(follower) = coordinator.join(key.clone()) else { - panic!("second timeout request must follow"); - }; - - let (leader_result, follower_result) = - tokio::time::timeout(Duration::from_secs(2), async { tokio::join!(leader.wait(), follower.wait()) }) - .await - .expect("typed timeout must wake all waiters"); - assert!(matches!( - leader_result, - ColdFillWaitOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout))) - )); - assert!(matches!( - follower_result, - ColdFillWaitOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout))) - )); - assert_eq!(readers.load(AtomicOrdering::Relaxed), 1); - assert_eq!(disk_permits.available_permits(), 1); - assert_eq!(coordinator.global_waiter_count_for_test(), 0); - assert_eq!(coordinator.active_session_count_for_test(), 0); - assert!(matches!( - adapter.lookup_body(&plan).await, - rustfs_object_data_cache::ObjectDataCacheLookup::Miss - )); - producer_task.await.expect("producer task must join"); - assert!(adapter.reserve_body(&plan).is_some(), "timeout must release the body reservation"); - let ColdFillRole::Produce(successor) = coordinator.join(key) else { - panic!("timeout must release the session for a successor"); - }; - drop(successor); - } - - #[tokio::test] - async fn cold_fill_survives_leader_request_cancellation_without_second_producer() { - let adapter = Arc::new( - ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 1024 * 1024, - max_memory_percent: 0, - max_entry_bytes: 1024, - min_free_memory_percent: 0, - fill_concurrency_max: 1, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("cancellation cache config must be valid"), - ); - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "cancel-bucket", - object: "cancel-object", - version_id: None, - etag: "cancel-etag", - size: 4, - data_dir_u128: Some(1), - mod_time_unix_nanos: 1, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - let key = plan.key().cloned().expect("cancellation body must be cacheable"); - let coordinator = adapter.cold_fill_coordinator(); - let ColdFillRole::Produce(mut producer) = coordinator.join(key.clone()) else { - panic!("first cancellation request must produce"); - }; - let leader = producer.waiter(); - let reservation = adapter.reserve_body(&plan); - let readers = Arc::new(AtomicUsize::new(0)); - let reader_count = Arc::clone(&readers); - let writer_slot = Arc::new(Mutex::new(None)); - let writer_output = Arc::clone(&writer_slot); - let producer_task = tokio::spawn(start_cold_fill_producer( - producer, - reservation, - || async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) }, - move || async move { - reader_count.fetch_add(1, AtomicOrdering::Relaxed); - let (writer, reader) = tokio::io::duplex(16); - *writer_output.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(writer); - Ok(GetObjectReader { - stream: Box::new(reader), - object_info: ObjectInfo { - size: 4, - actual_size: 4, - ..Default::default() - }, - buffered_body: None, - body_source: GetObjectBodySource::HookMissed, - }) - }, - ColdFillProducerExecution { - expected: 4, - deadline: None, - adapter: Arc::clone(&adapter), - engine_plan: plan.clone(), - }, - )); - tokio::time::timeout(Duration::from_secs(1), async { - while readers.load(AtomicOrdering::Relaxed) == 0 { - tokio::task::yield_now().await; - } - }) - .await - .expect("cancellation producer reader must open"); - let ColdFillRole::Wait(follower) = coordinator.join(key.clone()) else { - panic!("second cancellation request must follow"); - }; - drop(leader); - assert_eq!(readers.load(AtomicOrdering::Relaxed), 1); - let ColdFillRole::Wait(late) = coordinator.join(key) else { - panic!("leader cancellation must not open a successor session"); - }; - drop(late); - - let mut writer = writer_slot - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .take() - .expect("reader factory must publish writer"); - tokio::io::AsyncWriteExt::write_all(&mut writer, b"body") - .await - .expect("body write must succeed"); - tokio::io::AsyncWriteExt::shutdown(&mut writer) - .await - .expect("body writer must close"); - let ColdFillWaitOutcome::Ready(result) = follower.wait().await else { - panic!("follower must receive producer result"); - }; - assert_eq!(result.expect("surviving producer must succeed"), Bytes::from_static(b"body")); - producer_task.await.expect("producer task must join"); - assert_eq!(readers.load(AtomicOrdering::Relaxed), 1); - } - - #[tokio::test] - async fn cold_fill_reservation_rejection_streams_without_materializing() { - let coordinator = Arc::new(crate::app::object_data_cache::ColdFillCoordinator::default()); - let plan = rustfs_object_data_cache::ObjectDataCacheGetPlan::Disabled; - let ColdFillRole::Produce(mut producer) = coordinator.join(rustfs_object_data_cache::ObjectDataCacheKey::new( - "bucket", - "object", - None, - "etag", - 4, - rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - )) else { - panic!("first rejected reservation request must produce"); - }; - let leader = producer.waiter(); - let permits = Arc::new(AtomicUsize::new(0)); - let readers = Arc::new(AtomicUsize::new(0)); - let permit_count = Arc::clone(&permits); - let reader_count = Arc::clone(&readers); - start_cold_fill_producer( - producer, - None, - move || async move { - permit_count.fetch_add(1, AtomicOrdering::Relaxed); - Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) - }, - move || async move { - reader_count.fetch_add(1, AtomicOrdering::Relaxed); - Err(StorageError::other("reader must not open")) - }, - ColdFillProducerExecution { - expected: 4, - deadline: None, - adapter: Arc::new(ObjectDataCacheAdapter::disabled()), - engine_plan: plan, - }, - ) - .await; - assert!(matches!(leader.wait().await, ColdFillWaitOutcome::Bypass)); - assert_eq!(permits.load(AtomicOrdering::Relaxed), 0); - assert_eq!(readers.load(AtomicOrdering::Relaxed), 0); - - let fallback_reads = Arc::new(AtomicUsize::new(0)); - let fallback_reader = DataProbeReader { - reads: Arc::clone(&fallback_reads), - data: std::io::Cursor::new(b"body".to_vec()), - }; - let info = ObjectInfo { - size: 4, - actual_size: 4, - ..Default::default() - }; - let mut fallback_body = DefaultObjectUsecase::build_get_object_body( - fallback_reader, - &info, - 4, - "req-cold-fill", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - None, - "bucket", - "object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("reservation bypass must construct the normal streaming fallback"); - let chunk = fallback_body - .next() - .await - .expect("fallback stream must yield a body chunk") - .expect("fallback stream must not fail"); - assert_eq!(chunk, Bytes::from_static(b"body")); - assert!(fallback_reads.load(AtomicOrdering::Relaxed) > 0); - assert_eq!(readers.load(AtomicOrdering::Relaxed), 0, "cold-fill materialization must remain unopened"); - assert_eq!(coordinator.active_session_count_for_test(), 0); - } - - #[tokio::test] - async fn cold_fill_internal_movement_and_restore_reads_never_join_sessions() { - let coordinator = Arc::new(crate::app::object_data_cache::ColdFillCoordinator::default()); - let info = ObjectInfo { - size: 4, - actual_size: 4, - ..Default::default() - }; - let mut restore = ObjectOptions::default(); - restore.transition.restore_request.days = Some(1); - let cases = [ - ObjectOptions { - raw_data_movement_read: true, - ..Default::default() - }, - ObjectOptions { - data_movement: true, - ..Default::default() - }, - restore, - ]; - - for opts in &cases { - assert!(matches!( - lookup_get_object_body_cache_hook("bucket", "object", &None, opts, &info).await, - GetObjectBodyCacheHookLookup::Ineligible - )); - assert_eq!(coordinator.active_session_count_for_test(), 0); - } - - let delete_marker = ObjectInfo { - delete_marker: true, - etag: Some("delete-marker-etag".to_string()), - ..Default::default() - }; - let delete_marker_part = ObjectOptions { - part_number: Some(2), - ..Default::default() - }; - assert!(matches!( - lookup_get_object_body_cache_hook("bucket", "object", &None, &delete_marker_part, &delete_marker).await, - GetObjectBodyCacheHookLookup::Ineligible - )); - assert_eq!(coordinator.active_session_count_for_test(), 0); - } - - #[tokio::test] - async fn cold_fill_generation_change_bypasses_before_opening_body() { - let adapter = Arc::new( - ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 1024 * 1024, - max_memory_percent: 0, - max_entry_bytes: 1024, - min_free_memory_percent: 0, - fill_concurrency_max: 1, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("generation retry cache config must be valid"), - ); - let request = |data_dir_u128| rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "generation-bucket", - object: "generation-object", - version_id: None, - etag: "generation-etag", - size: 4, - data_dir_u128: Some(data_dir_u128), - mod_time_unix_nanos: 1, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }; - let initial_plan = adapter.plan_get(request(1)); - let changed_plan = GetObjectBodyCachePlan::Cacheable(adapter.plan_get(request(2))); - let cache_key = initial_plan.key().cloned().expect("initial generation must be cacheable"); - let coordinator = adapter.cold_fill_coordinator(); - let body_opens = Arc::new(AtomicUsize::new(0)); - let producer_attempts = Arc::new(AtomicUsize::new(0)); - - let outcome = coordinate_cold_fill(&coordinator, cache_key, None, None, { - let body_opens = Arc::clone(&body_opens); - let producer_attempts = Arc::clone(&producer_attempts); - move |producer| { - let body_opens = Arc::clone(&body_opens); - let producer_attempts = Arc::clone(&producer_attempts); - let changed_plan = changed_plan.clone(); - let initial_plan = initial_plan.clone(); - async move { - producer_attempts.fetch_add(1, AtomicOrdering::Relaxed); - let Some(producer) = retain_cold_fill_producer_for_matching_plan(producer, &changed_plan, &initial_plan) - else { - return; - }; - body_opens.fetch_add(1, AtomicOrdering::Relaxed); - producer.bypass(); - } - } - }) - .await; - - assert!(matches!(outcome, ColdFillCoordinateOutcome::Bypass)); - assert_eq!(producer_attempts.load(AtomicOrdering::Relaxed), 1); - assert_eq!(body_opens.load(AtomicOrdering::Relaxed), 0); - assert_eq!(coordinator.active_session_count_for_test(), 0); - } - - async fn real_cold_fill_test_context() -> (Arc, Arc) { - let store = crate::app::gating_test_env::shared_gating_ecstore().await; - if current_app_context().is_none() { - crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; - } - let ambient = current_app_context().expect("real cold-fill tests require an ambient AppContext"); - let context = temp_env::with_vars( - [ - (rustfs_config::ENV_OBJECT_DATA_CACHE_ENABLE, Some("true")), - (rustfs_config::ENV_OBJECT_DATA_CACHE_MODE, Some("fill_materialize_enabled")), - (rustfs_config::ENV_OBJECT_DATA_CACHE_MAX_BYTES, Some("8388608")), - (rustfs_config::ENV_OBJECT_DATA_CACHE_MAX_ENTRY_BYTES, Some("2097152")), - (rustfs_config::ENV_OBJECT_DATA_CACHE_MIN_FREE_MEMORY_PERCENT, Some("0")), - ], - || Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms())), - ); - assert!(context.object_data_cache().materialize_fill_enabled()); - (store, context) - } - - #[tokio::test] - #[serial_test::serial(body_cache_hook)] - async fn object_progress_tracks_real_get_and_small_put_lock_waits() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; - - let object_traffic_health = Arc::new(ObjectTrafficHealth::enabled_for_test(Duration::ZERO)); - let context = temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_DATA_CACHE_ENABLE, Some("false"))], async { - crate::app::gating_test_env::app_context_with_object_traffic_health(Arc::clone(&object_traffic_health)).await - }) - .await; - let store = context.object_store(); - let bucket = format!("object-progress-{}", Uuid::new_v4()); - let object = "object.bin"; - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("object progress bucket must be created"); - put_real_cold_fill_object(&store, &bucket, object, b"initial").await; - - let metadata_entered = Arc::new(tokio::sync::Barrier::new(2)); - let metadata_resume = Arc::new(tokio::sync::Barrier::new(2)); - crate::storage::options::install_versioning_config_test_hook( - bucket.clone(), - Arc::clone(&metadata_entered), - Arc::clone(&metadata_resume), - ); - let metadata_input = GetObjectInput::builder() - .bucket(bucket.clone()) - .key(object.to_string()) - .build() - .expect("metadata GET input must build"); - let metadata_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context))); - let metadata_get = tokio::spawn(async move { - metadata_usecase - .execute_get_object(build_request(metadata_input, Method::GET)) - .await - }); - tokio::time::timeout(Duration::from_secs(2), metadata_entered.wait()) - .await - .expect("GET must enter the bucket metadata stage"); - assert!(object_traffic_health.snapshot().read_stalled); - assert!(!metadata_get.is_finished(), "GET must still be waiting in bucket metadata"); - metadata_resume.wait().await; - let metadata_response = tokio::time::timeout(Duration::from_secs(10), metadata_get) - .await - .expect("metadata GET must finish after release") - .expect("metadata GET task must join") - .expect("metadata GET must succeed after release"); - assert!(!object_traffic_health.snapshot().read_stalled); - drop(metadata_response); - - let read_lock = store - .new_ns_lock(&bucket, object) - .await - .expect("read test namespace lock must be created") - .get_write_lock(Duration::from_secs(5)) - .await - .expect("read test namespace lock must be held"); - let get_input = GetObjectInput::builder() - .bucket(bucket.clone()) - .key(object.to_string()) - .build() - .expect("GET input must build"); - let get_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context))); - let get = tokio::spawn(async move { get_usecase.execute_get_object(build_request(get_input, Method::GET)).await }); - tokio::time::timeout(Duration::from_secs(2), async { - while !object_traffic_health.read_storage_stalled_for_test() { - tokio::task::yield_now().await; - } - }) - .await - .expect("blocked GET must publish a storage stall"); - assert!(!get.is_finished(), "GET must still be waiting for the held namespace lock"); - drop(read_lock); - let get_response = tokio::time::timeout(Duration::from_secs(10), get) - .await - .expect("GET must finish after releasing the lock") - .expect("GET task must join") - .expect("GET must succeed after releasing the lock"); - assert!(!object_traffic_health.snapshot().read_stalled); - drop(get_response); - - let write_lock = store - .new_ns_lock(&bucket, object) - .await - .expect("write test namespace lock must be created") - .get_write_lock(Duration::from_secs(5)) - .await - .expect("write test namespace lock must be held"); - let post_store_entered = Arc::new(tokio::sync::Barrier::new(2)); - let post_store_resume = Arc::new(tokio::sync::Barrier::new(2)); - install_put_post_store_test_hook(bucket.clone(), Arc::clone(&post_store_entered), Arc::clone(&post_store_resume)); - let payload = Bytes::from_static(b"replacement"); - let put_input = PutObjectInput::builder() - .bucket(bucket) - .key(object.to_string()) - .body(Some(StreamingBlob::from(s3s::Body::from(payload.clone())))) - .content_length(Some(i64::try_from(payload.len()).expect("test payload length must fit i64"))) - .build() - .expect("PUT input must build"); - let put_usecase = DefaultObjectUsecase::with_context(Some(context)); - let put = tokio::spawn(async move { - put_usecase - .execute_put_object(&FS::new(), build_request(put_input, Method::PUT)) - .await - }); - tokio::time::timeout(Duration::from_secs(2), async { - while !object_traffic_health.snapshot().write_stalled { - tokio::task::yield_now().await; - } - }) - .await - .expect("blocked small PUT must publish a storage stall"); - assert!(!put.is_finished(), "PUT must still be waiting for the held namespace lock"); - drop(write_lock); - tokio::time::timeout(Duration::from_secs(10), post_store_entered.wait()) - .await - .expect("PUT must reach the first post-store hook"); - assert!(!object_traffic_health.snapshot().write_stalled); - assert!(!put.is_finished(), "PUT must remain blocked after the store guard has ended"); - post_store_resume.wait().await; - tokio::time::timeout(Duration::from_secs(10), put) - .await - .expect("PUT must finish after releasing the lock") - .expect("PUT task must join") - .expect("PUT must succeed after releasing the lock"); - let recovered = object_traffic_health.snapshot(); - assert!(!recovered.read_stalled); - assert!(!recovered.write_stalled); - } - - #[tokio::test] - #[serial_test::serial(body_cache_hook)] - async fn cancelled_put_request_completes_post_commit_publication() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; - - let (store, context) = real_cold_fill_test_context().await; - let bucket = format!("put-owner-tail-{}", Uuid::new_v4()); - let object = "object.bin"; - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("PUT owner-tail bucket must be created"); - - let old_body = Bytes::from_static(b"old body that must be invalidated"); - let old_info = put_real_cold_fill_object(&store, &bucket, object, &old_body).await; - let adapter = context.object_data_cache(); - let old_plan = real_cold_fill_plan(&adapter, &bucket, object, &old_info); - - let post_store_entered = Arc::new(tokio::sync::Barrier::new(2)); - let post_store_resume = Arc::new(tokio::sync::Barrier::new(2)); - install_put_post_store_test_hook(bucket.clone(), Arc::clone(&post_store_entered), Arc::clone(&post_store_resume)); - - let payload = Bytes::from_static(b"published despite caller cancellation"); - let put_input = PutObjectInput::builder() - .bucket(bucket.clone()) - .key(object.to_string()) - .body(Some(StreamingBlob::from(s3s::Body::from(payload.clone())))) - .content_length(Some(i64::try_from(payload.len()).expect("test payload length must fit i64"))) - .build() - .expect("PUT input must build"); - let put_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context))); - let put = tokio::spawn(async move { - put_usecase - .execute_put_object(&FS::new(), build_request(put_input, Method::PUT)) - .await - }); - - tokio::time::timeout(Duration::from_secs(10), post_store_entered.wait()) - .await - .expect("PUT must reach the post-store owner-tail hook"); - assert_eq!( - adapter.fill_body(&old_plan, old_body.clone()).await, - rustfs_object_data_cache::ObjectDataCacheFillResult::Inserted, - "test must republish the old body while the owner tail is paused" - ); - put.abort(); - post_store_resume.wait().await; - let _ = put.await.expect_err("outer request task must be cancelled"); - - tokio::time::timeout(Duration::from_secs(10), async { - loop { - if matches!( - adapter.lookup_body(&old_plan).await, - rustfs_object_data_cache::ObjectDataCacheLookup::Miss - ) { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("post-commit owner tail must invalidate stale body cache after caller cancellation"); - - let recovered = store - .get_object_info(&bucket, object, &ObjectOptions::default()) - .await - .expect("cancelled request's owned commit must still publish the object"); - assert_eq!(recovered.size, i64::try_from(payload.len()).expect("test payload length must fit i64")); - } - - #[tokio::test] - async fn object_progress_tracks_zero_byte_and_zero_copy_put_lock_waits() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; - - let object_traffic_health = Arc::new(ObjectTrafficHealth::enabled_for_test(Duration::ZERO)); - let context = - crate::app::gating_test_env::app_context_with_object_traffic_health(Arc::clone(&object_traffic_health)).await; - let store = context.object_store(); - let bucket = format!("progress-buffered-{}", Uuid::new_v4()); - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("buffered PUT progress bucket must be created"); - - let extra_body_object = "zero-byte-extra.bin"; - let extra_body_input = PutObjectInput::builder() - .bucket(bucket.clone()) - .key(extra_body_object.to_string()) - .body(Some(StreamingBlob::from(s3s::Body::from(Bytes::from_static(b"x"))))) - .content_length(Some(88)) - .build() - .expect("zero-byte extra-body PUT input must build"); - let extra_body_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context))); - let mut extra_body_request = build_request(extra_body_input, Method::PUT); - extra_body_request.headers = streaming_headers(Some("0")); - let extra_body_err = extra_body_usecase - .execute_put_object(&FS::new(), extra_body_request) - .await - .expect_err("decoded zero-byte PUT with body data must fail"); - assert_eq!(extra_body_err.code(), &S3ErrorCode::UnexpectedContent); - assert!(!object_traffic_health.snapshot().write_stalled); - let lookup_err = store - .get_object_info(&bucket, extra_body_object, &ObjectOptions::default()) - .await - .expect_err("rejected zero-byte PUT must not create an object"); - assert!(is_err_object_not_found(&lookup_err)); - - let zero_object = "zero-byte.bin"; - let zero_write_lock = store - .new_ns_lock(&bucket, zero_object) - .await - .expect("zero-byte PUT namespace lock must be created") - .get_write_lock(Duration::from_secs(30)) - .await - .expect("zero-byte PUT namespace lock must be held"); - let (body_polled_tx, body_polled_rx) = tokio::sync::oneshot::channel(); - let (body_release_tx, body_release_rx) = tokio::sync::oneshot::channel(); - let pending_zero_body = StreamingBlob::wrap(futures::stream::once(async move { - body_polled_tx.send(()).expect("zero-byte body poll signal must be received"); - body_release_rx.await.expect("zero-byte body EOF must be released"); - Ok::(Bytes::new()) - })); - let zero_input = PutObjectInput::builder() - .bucket(bucket.clone()) - .key(zero_object.to_string()) - .body(Some(pending_zero_body)) - .content_length(Some(87)) - .build() - .expect("zero-byte PUT input must build"); - let zero_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context))); - let mut zero_request = build_request(zero_input, Method::PUT); - zero_request.headers = streaming_headers(Some("0")); - let zero_put = tokio::spawn(async move { zero_usecase.execute_put_object(&FS::new(), zero_request).await }); - - tokio::time::timeout(Duration::from_secs(30), body_polled_rx) - .await - .expect("zero-byte PUT body must be polled for EOF") - .expect("zero-byte PUT body poll signal must be sent"); - assert!(!object_traffic_health.snapshot().write_stalled); - assert!(!zero_put.is_finished(), "zero-byte PUT must still be waiting for request EOF"); - - body_release_tx.send(()).expect("zero-byte PUT body EOF must be released"); - tokio::time::timeout(Duration::from_secs(30), async { - while !object_traffic_health.snapshot().write_stalled { - tokio::task::yield_now().await; - } - }) - .await - .expect("fully received zero-byte PUT must publish a storage stall"); - assert!(!zero_put.is_finished(), "zero-byte PUT must still be waiting for the held namespace lock"); - - drop(zero_write_lock); - tokio::time::timeout(Duration::from_secs(30), zero_put) - .await - .expect("zero-byte PUT must finish after releasing the lock") - .expect("zero-byte PUT task must join") - .expect("zero-byte PUT must succeed after releasing the lock"); - assert!(!object_traffic_health.snapshot().write_stalled); - - let zero_copy_object = "zero-copy-eager.jpg"; - let zero_copy_payload = Bytes::from(vec![b'z'; 1024 * 1024 + 1]); - let zero_copy_size = i64::try_from(zero_copy_payload.len()).expect("zero-copy payload length must fit i64"); - let zero_copy_headers = HeaderMap::new(); - assert!(!is_disk_compressible(&zero_copy_headers, zero_copy_object)); - assert_eq!( - zero_copy_eager_put_path_status(zero_copy_size, &zero_copy_headers, false, false, false), - PUT_EAGER_STATUS_ELIGIBLE, - "test payload must exercise the production zero-copy eager path", - ); - let zero_copy_write_lock = store - .new_ns_lock(&bucket, zero_copy_object) - .await - .expect("zero-copy PUT namespace lock must be created") - .get_write_lock(Duration::from_secs(30)) - .await - .expect("zero-copy PUT namespace lock must be held"); - let zero_copy_input = PutObjectInput::builder() - .bucket(bucket) - .key(zero_copy_object.to_string()) - .body(Some(StreamingBlob::from(s3s::Body::from(zero_copy_payload)))) - .content_length(Some(zero_copy_size)) - .build() - .expect("zero-copy PUT input must build"); - let zero_copy_usecase = DefaultObjectUsecase::with_context(Some(context)); - let zero_copy_put = tokio::spawn(async move { - zero_copy_usecase - .execute_put_object(&FS::new(), build_request(zero_copy_input, Method::PUT)) - .await - }); - - tokio::time::timeout(Duration::from_secs(30), async { - while !object_traffic_health.snapshot().write_stalled { - tokio::task::yield_now().await; - } - }) - .await - .expect("blocked zero-copy eager PUT must publish a storage stall"); - assert!( - !zero_copy_put.is_finished(), - "zero-copy PUT must still be waiting for the held namespace lock" - ); - - drop(zero_copy_write_lock); - tokio::time::timeout(Duration::from_secs(30), zero_copy_put) - .await - .expect("zero-copy PUT must finish after releasing the lock") - .expect("zero-copy PUT task must join") - .expect("zero-copy PUT must succeed after releasing the lock"); - assert!(!object_traffic_health.snapshot().write_stalled); - } - - async fn put_real_cold_fill_object(store: &Arc, bucket: &str, object: &str, body: &[u8]) -> ObjectInfo { - let mut reader = PutObjReader::from_vec(body.to_vec()); - store - .put_object(bucket, object, &mut reader, &ObjectOptions::default()) - .await - .expect("real cold-fill test object must be written") - } - - fn real_cold_fill_plan( - adapter: &ObjectDataCacheAdapter, - bucket: &str, - object: &str, - info: &ObjectInfo, - ) -> rustfs_object_data_cache::ObjectDataCacheGetPlan { - let length = info - .get_actual_size() - .expect("real cold-fill test metadata must expose plaintext size"); - let GetObjectBodyCachePlan::Cacheable(plan) = build_get_object_body_cache_plan( - adapter, - GetObjectBodyCacheRequest { - bucket, - key: object, - info, - response_content_length: length, - has_range: false, - part_number: None, - encryption_applied: false, - }, - ) else { - panic!("real cold-fill test object must be cacheable"); - }; - plan - } - - #[tokio::test] - #[serial_test::serial(body_cache_hook)] - async fn execute_get_object_rejects_conditions_before_joining_cold_fill() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; - - let (store, context) = real_cold_fill_test_context().await; - let bucket = format!("cold-condition-{}", Uuid::new_v4()); - let object = "object.bin"; - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("real cold-fill condition bucket must be created"); - let body = vec![b'a'; 1_300_000]; - let info = put_real_cold_fill_object(&store, &bucket, object, &body).await; - let adapter = context.object_data_cache(); - let plan = real_cold_fill_plan(&adapter, &bucket, object, &info); - let coordinator = adapter.cold_fill_coordinator(); - let ColdFillRole::Produce(producer) = - coordinator.join(plan.key().cloned().expect("real cold-fill plan must expose its key")) - else { - panic!("test must reserve the initial cold-fill producer"); - }; - - let input = GetObjectInput::builder() - .bucket(bucket) - .key(object.to_string()) - .build() - .expect("real cold-fill GET input must build"); - let mut req = build_request(input, Method::GET); - let etag = info.etag.expect("real cold-fill test object must have an ETag"); - req.headers.insert( - http::header::IF_NONE_MATCH, - HeaderValue::from_str(&format!("\"{etag}\"")).expect("ETag header must be valid"), - ); - let usecase = DefaultObjectUsecase::with_context(Some(context)); - let result = tokio::time::timeout(Duration::from_secs(2), usecase.execute_get_object(req)) - .await - .expect("conditional GET must not wait for the reserved cold-fill session") - .expect_err("matching If-None-Match must reject the GET"); - - assert_eq!(result.code(), &S3ErrorCode::NotModified); - assert_eq!(coordinator.global_waiter_count_for_test(), 0); - drop(producer); - assert_eq!(coordinator.active_session_count_for_test(), 0); - } - - #[tokio::test] - #[serial_test::serial(body_cache_hook)] - async fn execute_get_object_maps_cold_fill_session_rejection_to_slow_down_without_opening_reader() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; - - let (store, context) = real_cold_fill_test_context().await; - let bucket = format!("cold-rejected-{}", Uuid::new_v4()); - let object = "object.bin"; - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("real cold-fill rejection bucket must be created"); - let body = vec![b'a'; 1_300_000]; - let info = put_real_cold_fill_object(&store, &bucket, object, &body).await; - let adapter = context.object_data_cache(); - let plan = real_cold_fill_plan(&adapter, &bucket, object, &info); - let cache_key = plan.key().cloned().expect("real cold-fill plan must expose its key"); - let coordinator = adapter.cold_fill_coordinator(); - let mut held_producers = Vec::new(); - for index in 0..2048 { - let saturation_key = rustfs_object_data_cache::ObjectDataCacheKey::new( - "cold-fill-saturation", - format!("object-{index}"), - None, - "etag", - 4, - rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - ); - match coordinator.join(saturation_key) { - ColdFillRole::Produce(producer) => held_producers.push(producer), - ColdFillRole::Rejected => break, - ColdFillRole::Wait(_) | ColdFillRole::Bypass => panic!("unique saturation keys must produce or reject"), - } - } - assert_eq!(coordinator.active_session_count_for_test(), held_producers.len()); - assert!(!held_producers.is_empty(), "saturation must reserve cold-fill sessions"); - - let reader_opens = Arc::new(AtomicU64::new(0)); - *COLD_FILL_READER_OPEN_PROBE - .get_or_init(|| Mutex::new(None)) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((cache_key, Arc::clone(&reader_opens))); - let input = GetObjectInput::builder() - .bucket(bucket) - .key(object.to_string()) - .build() - .expect("real cold-fill rejection GET input must build"); - let usecase = DefaultObjectUsecase::with_context(Some(context)); - let result = tokio::time::timeout(Duration::from_secs(2), usecase.execute_get_object(build_request(input, Method::GET))) - .await - .expect("rejected real GET must not wait for a cold-fill session") - .expect_err("rejected real GET must return an S3 error"); - *COLD_FILL_READER_OPEN_PROBE - .get_or_init(|| Mutex::new(None)) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; - - assert_eq!(result.code(), &S3ErrorCode::SlowDown); - assert_eq!(reader_opens.load(Ordering::Relaxed), 0, "rejected GET must not open its body reader"); - assert_eq!(coordinator.active_session_count_for_test(), held_producers.len()); - drop(held_producers); - assert_eq!(coordinator.active_session_count_for_test(), 0); - } - - #[tokio::test] - #[serial_test::serial(body_cache_hook)] - async fn execute_get_object_generation_change_bypasses_old_cold_fill_plan() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; - - let (store, context) = real_cold_fill_test_context().await; - let bucket = format!("cold-generation-{}", Uuid::new_v4()); - let object = "object.bin"; - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("real cold-fill generation bucket must be created"); - let initial_body = vec![b'a'; 1_300_000]; - let changed_body = vec![b'b'; initial_body.len()]; - let initial_info = put_real_cold_fill_object(&store, &bucket, object, &initial_body).await; - let adapter = context.object_data_cache(); - let initial_plan = real_cold_fill_plan(&adapter, &bucket, object, &initial_info); - let coordinator = adapter.cold_fill_coordinator(); - let ColdFillRole::Produce(producer) = - coordinator.join(initial_plan.key().cloned().expect("real cold-fill plan must expose its key")) - else { - panic!("test must reserve the initial cold-fill producer"); - }; - - let input = GetObjectInput::builder() - .bucket(bucket.clone()) - .key(object.to_string()) - .build() - .expect("real cold-fill GET input must build"); - // The request is intentionally held behind the first producer while a - // 1.3 MiB replacement write changes its generation. Disable dynamic - // sizing for this test so runner I/O load cannot consume the five-second - // production minimum before the behavior under test is released. - let usecase = DefaultObjectUsecase::with_context_and_get_object_timeout_policy( - Some(context), - GetObjectTimeoutPolicy { - enable_dynamic_timeout: false, - ..GetObjectTimeoutPolicy::default() - }, - ); - let request = tokio::spawn(async move { usecase.execute_get_object(build_request(input, Method::GET)).await }); - tokio::time::timeout(Duration::from_secs(2), async { - while coordinator.global_waiter_count_for_test() != 1 { - tokio::task::yield_now().await; - } - }) - .await - .expect("real GET must join the reserved cold-fill session"); - - let changed_info = put_real_cold_fill_object(&store, &bucket, object, &changed_body).await; - assert_ne!(initial_info.etag, changed_info.etag); - producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout)); - - let mut response = tokio::time::timeout(Duration::from_secs(10), request) - .await - .expect("generation-changing GET must complete") - .expect("generation-changing GET task must join") - .expect("generation-changing GET must fall back successfully"); - let mut response_body = response.output.body.take().expect("GET response must include a body"); - let mut actual = Vec::with_capacity(changed_body.len()); - while let Some(chunk) = response_body.next().await { - actual.extend_from_slice(&chunk.expect("fallback body chunk must be readable")); - } - - assert_eq!(actual, changed_body); - assert!(matches!( - adapter.lookup_body(&initial_plan).await, - rustfs_object_data_cache::ObjectDataCacheLookup::Miss - )); - assert_eq!(coordinator.global_waiter_count_for_test(), 0); - assert_eq!(coordinator.active_session_count_for_test(), 0); - } - - #[tokio::test] - async fn cold_fill_open_error_retries_once_then_single_successor_succeeds() { - let adapter = Arc::new( - ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 1024 * 1024, - max_memory_percent: 0, - max_entry_bytes: 1024, - min_free_memory_percent: 0, - fill_concurrency_max: 1, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("open retry cache config must be valid"), - ); - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "open-retry-bucket", - object: "open-retry-object", - version_id: None, - etag: "open-retry-etag", - size: 4, - data_dir_u128: Some(1), - mod_time_unix_nanos: 1, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - let cache_key = plan.key().cloned().expect("open retry plan must be cacheable"); - let coordinator = adapter.cold_fill_coordinator(); - let open_attempts = Arc::new(AtomicUsize::new(0)); - let open_attempts_for_start = Arc::clone(&open_attempts); - - let outcome = coordinate_cold_fill(&coordinator, cache_key, None, None, move |producer| { - let reservation = adapter.reserve_body(&plan); - let adapter = Arc::clone(&adapter); - let plan = plan.clone(); - let open_attempts = Arc::clone(&open_attempts_for_start); - async move { - start_cold_fill_producer( - producer, - reservation, - || async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) }, - move || async move { - let attempt = open_attempts.fetch_add(1, AtomicOrdering::Relaxed); - if attempt == 0 { - return Err(StorageError::other("first open fails")); - } - Ok(GetObjectReader { - stream: Box::new(std::io::Cursor::new(Vec::::new())), - object_info: ObjectInfo { - size: 4, - actual_size: 4, - ..Default::default() - }, - buffered_body: Some(Bytes::from_static(b"body")), - body_source: GetObjectBodySource::HookMissed, - }) - }, - ColdFillProducerExecution { - expected: 4, - deadline: None, - adapter, - engine_plan: plan, - }, - ) - .await - } - }) - .await; - - let ColdFillCoordinateOutcome::Ready(Ok(body)) = outcome else { - panic!("the unique successor must publish the body"); - }; - assert_eq!(body, Bytes::from_static(b"body")); - assert_eq!(open_attempts.load(AtomicOrdering::Relaxed), 2); - assert_eq!(coordinator.active_session_count_for_test(), 0); - } - - #[tokio::test] - async fn cold_fill_open_timeout_retries_once_then_is_terminal() { - tokio::time::pause(); - let adapter = Arc::new( - ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 1024 * 1024, - max_memory_percent: 0, - max_entry_bytes: 1024, - min_free_memory_percent: 0, - fill_concurrency_max: 1, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("open timeout cache config must be valid"), - ); - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "open-timeout-bucket", - object: "open-timeout-object", - version_id: None, - etag: "open-timeout-etag", - size: 4, - data_dir_u128: Some(1), - mod_time_unix_nanos: 1, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - let cache_key = plan.key().cloned().expect("open timeout plan must be cacheable"); - let coordinator = adapter.cold_fill_coordinator(); - let open_attempts = Arc::new(AtomicUsize::new(0)); - - let deadline = tokio::time::Instant::now() + Duration::from_millis(10); - let task = tokio::spawn({ - let adapter = Arc::clone(&adapter); - let coordinator = Arc::clone(&coordinator); - let plan = plan.clone(); - let open_attempts = Arc::clone(&open_attempts); - async move { - coordinate_cold_fill(&coordinator, cache_key, None, Some(deadline), move |producer| { - let adapter = Arc::clone(&adapter); - let plan = plan.clone(); - let open_attempts = Arc::clone(&open_attempts); - let reservation = adapter.reserve_body(&plan); - let producer_deadline = producer.deadline(); - async move { - start_cold_fill_producer( - producer, - reservation, - || async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) }, - move || async move { - open_attempts.fetch_add(1, AtomicOrdering::Relaxed); - std::future::pending::>().await - }, - ColdFillProducerExecution { - expected: 4, - deadline: producer_deadline, - adapter, - engine_plan: plan, - }, - ) - .await - } - }) - .await - } - }); - while open_attempts.load(AtomicOrdering::Relaxed) == 0 { - tokio::task::yield_now().await; - } - tokio::time::advance(Duration::from_millis(11)).await; - let outcome = task.await.expect("open timeout task must join"); - assert!(matches!( - outcome, - ColdFillCoordinateOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout))) - )); - - assert_eq!(open_attempts.load(AtomicOrdering::Relaxed), 2); - assert_eq!(coordinator.active_session_count_for_test(), 0); - } - - #[tokio::test] - async fn cold_fill_pre_reader_failure_promotes_one_of_two_thousand_waiters() { - const REQUESTS: usize = 2000; - let adapter = Arc::new( - ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 1024 * 1024, - max_memory_percent: 0, - max_entry_bytes: 1024, - min_free_memory_percent: 0, - fill_concurrency_max: 1, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("successor cache config must be valid"), - ); - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "successor-bucket", - object: "successor-object", - version_id: None, - etag: "successor-etag", - size: 4, - data_dir_u128: Some(1), - mod_time_unix_nanos: 1, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - let cache_key = plan.key().cloned().expect("successor plan must be cacheable"); - let coordinator = adapter.cold_fill_coordinator(); - let admission_attempts = Arc::new(AtomicUsize::new(0)); - let open_attempts = Arc::new(AtomicUsize::new(0)); - let first_open_release = Arc::new(tokio::sync::Semaphore::new(0)); - let mut tasks = tokio::task::JoinSet::new(); - - for _ in 0..REQUESTS { - let adapter = Arc::clone(&adapter); - let coordinator = Arc::clone(&coordinator); - let cache_key = cache_key.clone(); - let plan = plan.clone(); - let admission_attempts = Arc::clone(&admission_attempts); - let open_attempts = Arc::clone(&open_attempts); - let first_open_release = Arc::clone(&first_open_release); - tasks.spawn(async move { - coordinate_cold_fill(&coordinator, cache_key, None, None, move |producer| { - let reservation = adapter.reserve_body(&plan); - let adapter = Arc::clone(&adapter); - let plan = plan.clone(); - let admission_attempts = Arc::clone(&admission_attempts); - let open_attempts = Arc::clone(&open_attempts); - let first_open_release = Arc::clone(&first_open_release); - async move { - start_cold_fill_producer( - producer, - reservation, - move || async move { - admission_attempts.fetch_add(1, AtomicOrdering::Relaxed); - Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) - }, - move || async move { - if open_attempts.fetch_add(1, AtomicOrdering::Relaxed) == 0 { - first_open_release - .acquire() - .await - .expect("first open release gate must remain open") - .forget(); - return Err(StorageError::other("first open fails")); - } - Ok(GetObjectReader { - stream: Box::new(std::io::Cursor::new(Vec::::new())), - object_info: ObjectInfo { - size: 4, - actual_size: 4, - ..Default::default() - }, - buffered_body: Some(Bytes::from_static(b"body")), - body_source: GetObjectBodySource::HookMissed, - }) - }, - ColdFillProducerExecution { - expected: 4, - deadline: None, - adapter, - engine_plan: plan, - }, - ) - .await - } - }) - .await - }); - } - - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if coordinator.global_waiter_count_for_test() == REQUESTS - 1 && open_attempts.load(AtomicOrdering::Relaxed) == 1 - { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("all followers must join before the first open fails"); - first_open_release.add_permits(1); - - while let Some(result) = tasks.join_next().await { - let ColdFillCoordinateOutcome::Ready(Ok(body)) = result.expect("successor request task must join") else { - panic!("all followers must receive the successor body"); - }; - assert_eq!(body, Bytes::from_static(b"body")); - } - assert_eq!(admission_attempts.load(AtomicOrdering::Relaxed), 2); - assert_eq!(open_attempts.load(AtomicOrdering::Relaxed), 2); - assert_eq!(coordinator.global_waiter_count_for_test(), 0); - assert_eq!(coordinator.active_session_count_for_test(), 0); - } - - fn install_cold_fill_publication_barrier( - plan: &rustfs_object_data_cache::ObjectDataCacheGetPlan, - ) -> Arc { - let barrier = Arc::new(ColdFillPublicationBarrier { - reached: tokio::sync::Semaphore::new(0), - release: tokio::sync::Semaphore::new(0), - }); - let key = plan.key().cloned().expect("publication barrier plan must be cacheable"); - *COLD_FILL_PUBLICATION_BARRIER - .get_or_init(|| Mutex::new(None)) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((key, Arc::clone(&barrier))); - barrier - } - - fn clear_cold_fill_publication_barrier() { - *COLD_FILL_PUBLICATION_BARRIER - .get_or_init(|| Mutex::new(None)) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; - } - - fn publication_test_adapter() -> Arc { - Arc::new( - ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 1024 * 1024, - max_memory_percent: 0, - max_entry_bytes: 1024, - min_free_memory_percent: 0, - fill_concurrency_max: 1, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("publication cache config must be valid"), - ) - } - - fn publication_test_plan(adapter: &ObjectDataCacheAdapter, object: &str) -> rustfs_object_data_cache::ObjectDataCacheGetPlan { - adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "publication-bucket", - object, - version_id: None, - etag: "publication-etag", - size: 4, - data_dir_u128: Some(1), - mod_time_unix_nanos: 1, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }) - } - - #[tokio::test] - #[serial_test::serial(cold_fill_publication_barrier)] - async fn cold_fill_last_consumer_cancel_releases_session_before_publication_barrier() { - let adapter = publication_test_adapter(); - let plan = publication_test_plan(&adapter, "cancel"); - let barrier = install_cold_fill_publication_barrier(&plan); - let coordinator = adapter.cold_fill_coordinator(); - let key = plan.key().cloned().expect("publication plan must be cacheable"); - let ColdFillRole::Produce(mut producer) = coordinator.join(key) else { - panic!("publication request must produce"); - }; - let leader = producer.waiter(); - let reservation = adapter.reserve_body(&plan); - let disk_permits = Arc::new(tokio::sync::Semaphore::new(1)); - let disk_gate = Arc::clone(&disk_permits); - let producer_task = tokio::spawn(scope_cold_fill_disk_permit_owner_for_test( - ColdFillDiskPermitOwner::Producer, - start_cold_fill_producer( - producer, - reservation, - move || async move { - let permit = disk_gate - .acquire_owned() - .await - .map_err(|_| ColdFillError::DiskAdmissionClosed)?; - let mut io = DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager()); - io.disk_permit = Some(permit.into()); - Ok(io) - }, - || async { - Ok(GetObjectReader { - stream: Box::new(std::io::Cursor::new(b"body".to_vec())), - object_info: ObjectInfo { - size: 4, - actual_size: 4, - ..Default::default() - }, - buffered_body: None, - body_source: GetObjectBodySource::HookMissed, - }) - }, - ColdFillProducerExecution { - expected: 4, - deadline: None, - adapter: Arc::clone(&adapter), - engine_plan: plan.clone(), - }, - ), - )); - - let reached = barrier.reached.acquire().await.expect("publication barrier must remain open"); - reached.forget(); - assert_eq!( - disk_permits.available_permits(), - 1, - "the producer disk permit and its gauge guard must end before publication" - ); - let clear_adapter = Arc::clone(&adapter); - let clear = tokio::spawn(async move { - clear_adapter - .clear(rustfs_object_data_cache::ObjectDataCacheInvalidationReason::Manual) - .await - }); - tokio::task::yield_now().await; - assert!(!clear.is_finished(), "clear must wait while publication owns its reservation"); - drop(leader); - tokio::time::timeout(Duration::from_secs(1), async { - while coordinator.active_session_count_for_test() != 0 { - tokio::task::yield_now().await; - } - }) - .await - .expect("last-consumer cancellation must release the session immediately"); - tokio::time::timeout(Duration::from_secs(1), clear) - .await - .expect("clear must finish after publication cancellation") - .expect("clear task must join"); - producer_task.await.expect("producer task must join"); - - barrier.release.add_permits(1); - clear_cold_fill_publication_barrier(); - drop(adapter.reserve_body(&plan).expect("publication reservation must be released")); - } - - #[tokio::test(start_paused = true)] - #[serial_test::serial(cold_fill_publication_barrier)] - async fn cold_fill_hard_deadline_releases_session_at_publication_barrier() { - let adapter = publication_test_adapter(); - let plan = publication_test_plan(&adapter, "deadline"); - let barrier = install_cold_fill_publication_barrier(&plan); - let coordinator = adapter.cold_fill_coordinator(); - let key = plan.key().cloned().expect("publication plan must be cacheable"); - let ColdFillRole::Produce(mut producer) = coordinator.join(key) else { - panic!("publication request must produce"); - }; - let leader = producer.waiter(); - let reservation = adapter.reserve_body(&plan); - let deadline = tokio::time::Instant::now() + Duration::from_millis(20); - let producer_task = tokio::spawn(start_cold_fill_producer( - producer, - reservation, - || async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) }, - || async { - Ok(GetObjectReader { - stream: Box::new(std::io::Cursor::new(Vec::::new())), - object_info: ObjectInfo { - size: 4, - actual_size: 4, - ..Default::default() - }, - buffered_body: Some(Bytes::from_static(b"body")), - body_source: GetObjectBodySource::HookMissed, - }) - }, - ColdFillProducerExecution { - expected: 4, - deadline: Some(deadline), - adapter: Arc::clone(&adapter), - engine_plan: plan.clone(), - }, - )); - - let reached = barrier.reached.acquire().await.expect("publication barrier must remain open"); - reached.forget(); - tokio::time::advance(Duration::from_millis(20)).await; - assert!(matches!( - leader.wait().await, - ColdFillWaitOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout))) - )); - assert_eq!(coordinator.active_session_count_for_test(), 0); - producer_task.await.expect("producer task must join"); - - barrier.release.add_permits(1); - clear_cold_fill_publication_barrier(); - drop( - adapter - .reserve_body(&plan) - .expect("deadline must release the publication reservation"), - ); - tokio::time::timeout( - Duration::from_secs(1), - adapter.clear(rustfs_object_data_cache::ObjectDataCacheInvalidationReason::Manual), - ) - .await - .expect("clear must complete after publication deadline"); - } - - #[tokio::test(start_paused = true)] - async fn cold_fill_without_request_timeout_stops_at_ten_minute_hard_cap() { - let adapter = publication_test_adapter(); - let plan = publication_test_plan(&adapter, "hard-cap"); - let coordinator = adapter.cold_fill_coordinator(); - let key = plan.key().cloned().expect("hard-cap plan must be cacheable"); - let ColdFillRole::Produce(mut producer) = coordinator.join(key) else { - panic!("hard-cap request must produce"); - }; - let leader = producer.waiter(); - let reservation = adapter.reserve_body(&plan); - let producer_task = tokio::spawn(start_cold_fill_producer( - producer, - reservation, - || async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) }, - || async { - Ok(GetObjectReader { - stream: Box::new(PendingReader), - object_info: ObjectInfo { - size: 4, - actual_size: 4, - ..Default::default() - }, - buffered_body: None, - body_source: GetObjectBodySource::HookMissed, - }) - }, - ColdFillProducerExecution { - expected: 4, - deadline: None, - adapter: Arc::clone(&adapter), - engine_plan: plan.clone(), - }, - )); - let wait = tokio::spawn(async move { leader.wait().await }); - - tokio::time::advance(Duration::from_secs(599)).await; - tokio::task::yield_now().await; - assert!(!wait.is_finished(), "hard cap must not fire before 600 seconds"); - assert!(adapter.reserve_body(&plan).is_none(), "reservation must remain owned before the hard cap"); - - tokio::time::advance(Duration::from_secs(1)).await; - assert!(matches!( - wait.await.expect("hard-cap waiter must join"), - ColdFillWaitOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout))) - )); - producer_task.await.expect("producer task must join"); - assert_eq!(coordinator.active_session_count_for_test(), 0); - drop( - adapter - .reserve_body(&plan) - .expect("hard cap must release the body reservation"), - ); - } - - #[tokio::test] - async fn build_get_object_body_with_cache_same_key_cold_fill_consumes_one_reader() { - const REQUESTS: usize = 2000; - const BODY_BYTES: usize = 64 * 1024; - const BODY_BYTES_U64: u64 = 64 * 1024; - const BODY_BYTES_I64: i64 = 64 * 1024; - - for key_count in [1_usize, 4, 32] { - let adapter = Arc::new( - ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 128 * 1024 * 1024, - max_memory_percent: 0, - max_entry_bytes: 1024 * 1024, - min_free_memory_percent: 0, - fill_concurrency_per_cpu: 64, - fill_concurrency_max: 64, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("matrix cache config must be valid"), - ); - let coordinator = adapter.cold_fill_coordinator(); - let disk_permits = Arc::new(tokio::sync::Semaphore::new(key_count)); - let writers = Arc::new(tokio::sync::Mutex::new(Vec::with_capacity(key_count))); - let permit_acquires = Arc::new(AtomicUsize::new(0)); - let reader_factories = Arc::new(AtomicUsize::new(0)); - let first_polls = Arc::new(AtomicUsize::new(0)); - let completed = Arc::new(AtomicUsize::new(0)); - let bytes_read = Arc::new(AtomicUsize::new(0)); - let mut tasks = tokio::task::JoinSet::new(); - - for request in 0..REQUESTS { - let key_index = request % key_count; - let object = format!("matrix-object-{key_index}"); - let engine_plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "matrix-bucket", - object: &object, - version_id: None, - etag: "matrix-etag", - size: BODY_BYTES_U64, - data_dir_u128: Some(u128::try_from(key_index).unwrap_or(u128::MAX) + 1), - mod_time_unix_nanos: 1, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - let cache_key = engine_plan.key().cloned().expect("matrix body must be cacheable"); - let adapter = Arc::clone(&adapter); - let coordinator = Arc::clone(&coordinator); - let disk_permits = Arc::clone(&disk_permits); - let writers = Arc::clone(&writers); - let permit_acquires = Arc::clone(&permit_acquires); - let reader_factories = Arc::clone(&reader_factories); - let first_polls = Arc::clone(&first_polls); - let completed = Arc::clone(&completed); - let bytes_read = Arc::clone(&bytes_read); - tasks.spawn(async move { - let outcome = coordinate_cold_fill(&coordinator, cache_key, None, None, move |producer| { - let reservation = adapter.reserve_body(&engine_plan); - let adapter = Arc::clone(&adapter); - let disk_permits = Arc::clone(&disk_permits); - let writers = Arc::clone(&writers); - let permit_acquires = Arc::clone(&permit_acquires); - let reader_factories = Arc::clone(&reader_factories); - let first_polls = Arc::clone(&first_polls); - let completed = Arc::clone(&completed); - let bytes_read = Arc::clone(&bytes_read); - let fill_plan = engine_plan.clone(); - async move { - start_cold_fill_producer( - producer, - reservation, - || async move { - permit_acquires.fetch_add(1, AtomicOrdering::Relaxed); - let permit = disk_permits - .acquire_owned() - .await - .map_err(|_| ColdFillError::DiskAdmissionClosed)?; - let mut io = - DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager()); - io.disk_permit = Some(permit.into()); - Ok(io) - }, - || async move { - reader_factories.fetch_add(1, AtomicOrdering::Relaxed); - let (writer, reader) = tokio::io::duplex(BODY_BYTES * 2); - writers.lock().await.push(writer); - Ok(GetObjectReader { - stream: Box::new(ColdFillMatrixReader { - inner: reader, - first_poll_recorded: false, - completion_recorded: false, - first_polls, - completed, - bytes_read, - }), - object_info: ObjectInfo { - size: BODY_BYTES_I64, - actual_size: BODY_BYTES_I64, - ..Default::default() - }, - buffered_body: None, - body_source: GetObjectBodySource::HookMissed, - }) - }, - ColdFillProducerExecution { - expected: BODY_BYTES, - deadline: None, - adapter, - engine_plan: fill_plan, - }, - ) - .await - } - }) - .await; - let ColdFillCoordinateOutcome::Ready(Ok(body)) = outcome else { - panic!("matrix request must receive the shared body, got {outcome:?}"); - }; - assert_eq!(body.len(), BODY_BYTES); - assert!(body.iter().all(|byte| *byte == 7)); - (key_index, body.as_ptr() as usize) - }); - } - - tokio::time::timeout(Duration::from_secs(30), async { - loop { - if writers.lock().await.len() == key_count - && coordinator.global_waiter_count_for_test() == REQUESTS - key_count - && first_polls.load(AtomicOrdering::Relaxed) == key_count - { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("all matrix followers must join before releasing bodies"); - - let mut body_writers = std::mem::take(&mut *writers.lock().await); - let body = vec![7_u8; BODY_BYTES]; - for writer in &mut body_writers { - tokio::io::AsyncWriteExt::write_all(writer, &body) - .await - .expect("matrix body write must succeed"); - tokio::io::AsyncWriteExt::shutdown(writer) - .await - .expect("matrix body writer must close"); - } - let mut backing_pointers = std::collections::HashMap::>::new(); - tokio::time::timeout(Duration::from_secs(30), async { - while let Some(result) = tasks.join_next().await { - let (key_index, body_pointer) = result.expect("matrix GET task must complete"); - backing_pointers.entry(key_index).or_default().insert(body_pointer); - } - }) - .await - .expect("matrix GET tasks must complete before the watchdog"); - - assert_eq!(permit_acquires.load(AtomicOrdering::Relaxed), key_count); - assert_eq!(reader_factories.load(AtomicOrdering::Relaxed), key_count); - assert_eq!(first_polls.load(AtomicOrdering::Relaxed), key_count); - assert_eq!(completed.load(AtomicOrdering::Relaxed), key_count); - assert_eq!(bytes_read.load(AtomicOrdering::Relaxed), key_count * BODY_BYTES); - assert_eq!(backing_pointers.len(), key_count); - assert!( - backing_pointers.values().all(|pointers| pointers.len() == 1), - "all followers of one key must share one backing allocation" - ); - assert_eq!( - backing_pointers - .values() - .flatten() - .copied() - .collect::>() - .len(), - key_count - ); - assert_eq!(coordinator.global_waiter_count_for_test(), 0); - assert_eq!(coordinator.active_session_count_for_test(), 0); - assert_eq!(disk_permits.available_permits(), key_count); - - for key_index in 0..key_count { - let object = format!("matrix-object-{key_index}"); - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "matrix-bucket", - object: &object, - version_id: None, - etag: "matrix-etag", - size: BODY_BYTES_U64, - data_dir_u128: Some(u128::try_from(key_index).unwrap_or(u128::MAX) + 1), - mod_time_unix_nanos: 1, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - assert!(matches!( - adapter.lookup_body(&plan).await, - rustfs_object_data_cache::ObjectDataCacheLookup::Hit(_) - )); - } - } - } - - // #1324: the in-memory (buffered/cache) source is guarded by - // MemoryTrackedBytesStream. A buffer whose length disagrees with the declared - // content length must yield a stream error on first poll instead of a clean - // short body or an over-long body. Reverting to the old warn-and-serve - // behavior would make these assertions observe Ok chunks. - #[tokio::test] - #[serial_test::serial] - async fn memory_tracked_bytes_stream_fails_short_body() { - let mut stream = MemoryTrackedBytesStream::new( - Bytes::from_static(b"test"), - 5, - GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, - None, - GetObjectBodyLifecycle::disabled(), - ); - let err = stream - .next() - .await - .expect("mismatched memory body must yield an item") - .expect_err("a short memory body must fail the stream instead of serving a truncated body"); - assert_eq!( - err.downcast_ref::().map(std::io::Error::kind), - Some(std::io::ErrorKind::InvalidData) - ); - assert!(stream.next().await.is_none(), "stream must terminate after the error"); - } - - #[tokio::test] - #[serial_test::serial] - async fn memory_tracked_bytes_stream_fails_over_long_body() { - let mut stream = MemoryTrackedBytesStream::new( - Bytes::from_static(b"hello!"), - 5, - GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, - None, - GetObjectBodyLifecycle::disabled(), - ); - let err = stream - .next() - .await - .expect("mismatched memory body must yield an item") - .expect_err("an over-long memory body must fail the stream instead of serving mismatched bytes"); - assert_eq!( - err.downcast_ref::().map(std::io::Error::kind), - Some(std::io::ErrorKind::InvalidData) - ); - } - - #[test] - fn memory_blob_preserves_exact_remaining_length() { - let blob = DefaultObjectUsecase::build_memory_bytes_blob( - Bytes::from_static(b"hello"), - 5, - GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, - GetObjectBodyLifecycle::disabled(), - ); - - assert_eq!(blob.remaining_length().exact(), Some(5)); - } - - #[test] - #[serial_test::serial] - fn memory_blob_once_fast_path_holds_guard_until_bytes_drop() { - temp_env::with_var(ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE, Some("true"), || { - let initial = GetObjectGuard::concurrent_count(); - let guard = GetObjectGuard::new(); - assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); - - let blob = DefaultObjectUsecase::build_memory_bytes_blob( - Bytes::from_static(b"hello"), - 5, - GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, - GetObjectBodyLifecycle::tracked(guard), - ); - let mut body = s3s::Body::from(blob); - let bytes = body.take_bytes().expect("opt-in exact memory body should stay on Body::Once"); - - assert_eq!(bytes, Bytes::from_static(b"hello")); - assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); - drop(bytes); - assert_eq!(GetObjectGuard::concurrent_count(), initial); - }); - } - - #[test] - #[serial_test::serial] - fn memory_blob_once_fast_path_rejects_length_mismatch() { - temp_env::with_var(ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE, Some("true"), || { - let blob = DefaultObjectUsecase::build_memory_bytes_blob( - Bytes::from_static(b"test"), - 5, - GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, - GetObjectBodyLifecycle::disabled(), - ); - let mut body = s3s::Body::from(blob); - - assert!(body.take_bytes().is_none(), "mismatched memory body must keep the guarded stream path"); - }); - } - - #[tokio::test] - async fn get_object_streaming_reader_times_out_when_body_stalls() { - let reader = GetObjectStreamingReader::new( - PendingReader, - "test-bucket", - "stalled-object", - "req-stalled-stream", - None, - 1, - Duration::from_millis(1), - GetObjectBodyLifecycle::disabled(), - None, - ); - let mut stream = ReaderStream::with_capacity(reader, 1024); - - let err = stream - .next() - .await - .expect("reader stream should yield timeout") - .expect_err("stalled reader should return an error"); - - assert_eq!(err.kind(), std::io::ErrorKind::TimedOut); - } - - #[tokio::test] - async fn get_object_streaming_reader_fails_closed_without_active_reader() { - use tokio::io::AsyncReadExt; - - let mut reader = GetObjectStreamingReader::new( - cursor_reader(b"x"), - "test-bucket", - "missing-reader-object", - "req-missing-reader", - None, - 1, - Duration::ZERO, - GetObjectBodyLifecycle::disabled(), - None, - ); - reader.inner.take(); - - let err = reader - .read_to_end(&mut Vec::new()) - .await - .expect_err("an impossible missing active reader must fail closed"); - - assert_eq!(err.kind(), std::io::ErrorKind::Other); - assert_eq!(err.to_string(), "get object streaming reader lost its active read outside resume"); - } - - #[tokio::test] - async fn put_object_body_read_timeout_guard_aborts_on_stall() { - // Inner stream never yields and never reports EOF (a proxy that forwarded - // a partial body then went silent while holding the connection open). - let inner = StreamingBlob::wrap(futures::stream::pending::>()); - let mut guarded = guard_put_object_body_read_timeout( - inner, - "test-bucket", - "stalled-object", - "req-1", - Some(1024), - Duration::from_millis(1), - ); - - let err = guarded - .next() - .await - .expect("guard should yield a stall error") - .expect_err("stalled body should return an error"); - let io_err = err - .downcast_ref::() - .expect("stall error should wrap an io::Error"); - assert_eq!(io_err.kind(), std::io::ErrorKind::TimedOut); - - // After a stall the guard terminates the stream instead of re-polling the - // abandoned inner stream. - assert!(guarded.next().await.is_none()); - } - - #[tokio::test] - async fn put_object_body_read_timeout_guard_preserves_length_and_passes_through() { - let body = StreamingBlob::from(s3s::Body::from(Bytes::from_static(b"hello world"))); - assert_eq!(body.remaining_length().exact(), Some(11)); - - let mut guarded = - guard_put_object_body_read_timeout(body, "test-bucket", "ok-object", "req-2", Some(11), Duration::from_secs(60)); - // remaining_length must be forwarded, not reset to unknown. - assert_eq!(guarded.remaining_length().exact(), Some(11)); - - let mut collected = Vec::new(); - while let Some(chunk) = guarded.next().await { - collected.extend_from_slice(&chunk.expect("chunk should read")); - } - assert_eq!(collected, b"hello world"); - } - - #[tokio::test] - async fn put_object_body_read_timeout_guard_disabled_passthrough() { - let body = StreamingBlob::from(s3s::Body::from(Bytes::from_static(b"data"))); - let mut guarded = guard_put_object_body_read_timeout(body, "test-bucket", "ok-object", "req-3", Some(4), Duration::ZERO); - - let mut collected = Vec::new(); - while let Some(chunk) = guarded.next().await { - collected.extend_from_slice(&chunk.expect("chunk should read")); - } - assert_eq!(collected, b"data"); - } - - #[tokio::test] - #[serial_test::serial] - async fn get_object_streaming_reader_holds_request_guard_until_eof() { - use tokio::io::AsyncReadExt; - - let initial = GetObjectGuard::concurrent_count(); - let guard = GetObjectGuard::new(); - assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); - - let mut reader = GetObjectStreamingReader::new( - std::io::Cursor::new(b"hello".to_vec()), - "test-bucket", - "complete-object", - "req-complete-stream", - None, - 5, - Duration::ZERO, - GetObjectBodyLifecycle::tracked(guard), - None, - ); - let mut out = Vec::new(); - - reader - .read_to_end(&mut out) - .await - .expect("complete streaming body should read successfully"); - - assert_eq!(out, b"hello"); - assert_eq!(GetObjectGuard::concurrent_count(), initial); - } - - #[tokio::test] - #[serial_test::serial] - async fn get_object_streaming_reader_errors_on_short_eof() { - use tokio::io::AsyncReadExt; - - // The inner reader delivers 5 bytes then a clean EOF, but the advertised - // Content-Length is 10. The reader must surface an error rather than a clean EOF, so - // the client sees a failed transfer instead of silently persisting a truncated body - // (the "incomplete data mirroring" of #2955). - let initial = GetObjectGuard::concurrent_count(); - let guard = GetObjectGuard::new(); - assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); - - let mut reader = GetObjectStreamingReader::new( - std::io::Cursor::new(b"short".to_vec()), - "test-bucket", - "truncated-object", - "req-short-eof", - None, - 10, - Duration::ZERO, - GetObjectBodyLifecycle::tracked(guard), - None, - ); - let mut out = Vec::new(); - let err = reader - .read_to_end(&mut out) - .await - .expect_err("short body under a larger Content-Length must fail the stream"); - assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof); - let incomplete_body = err - .get_ref() - .and_then(|inner| inner.downcast_ref::()) - .expect("short eof should include remaining bytes as IncompleteBody"); - assert_eq!(incomplete_body.remaining, 5); - assert_eq!(out, b"short", "bytes read before the short EOF are still delivered"); - - drop(reader); - assert_eq!(GetObjectGuard::concurrent_count(), initial); - } - - #[test] - #[serial_test::serial] - fn get_object_streaming_reader_releases_request_guard_when_dropped_incomplete() { - let initial = GetObjectGuard::concurrent_count(); - let guard = GetObjectGuard::new(); - assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); - - let reader = GetObjectStreamingReader::new( - std::io::Cursor::new(b"short".to_vec()), - "test-bucket", - "dropped-object", - "req-dropped-stream", - None, - 10, - Duration::ZERO, - GetObjectBodyLifecycle::tracked(guard), - None, - ); - drop(reader); - - assert_eq!(GetObjectGuard::concurrent_count(), initial); - } - - // Emits all of `data`, then either the injected error or a clean EOF. Drives - // the mid-stream resume state machine through its typed-error and - // premature-EOF triggers without a store. - struct FailAtEndReader { - data: std::io::Cursor>, - error: Option, - } - - impl FailAtEndReader { - fn new(data: &[u8], error: Option) -> Self { - Self { - data: std::io::Cursor::new(data.to_vec()), - error, - } - } - } - - impl AsyncRead for FailAtEndReader { - fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - let position = usize::try_from(self.data.position()).unwrap_or(usize::MAX); - let source_len = self.data.get_ref().len(); - if position >= source_len { - return match self.error.take() { - Some(error) => Poll::Ready(Err(error)), - None => Poll::Ready(Ok(())), - }; - } - let want = buf.remaining().min(source_len - position); - if want == 0 { - return Poll::Ready(Ok(())); - } - buf.put_slice(&self.data.get_ref()[position..position + want]); - self.data.set_position(u64::try_from(position + want).unwrap_or(u64::MAX)); - Poll::Ready(Ok(())) - } - } - - fn relocation_read_error() -> std::io::Error { - std::io::Error::other(StorageError::FileNotFound) - } - - fn counting_resume_control( - reopen_count: Arc, - mut reopen: impl FnMut(usize) -> Result + Send + Sync + 'static, - ) -> GetObjectResumeControl { - let reopen: GetObjectReopen = Box::new(move |emitted| { - reopen_count.fetch_add(1, Ordering::Relaxed); - let outcome = reopen(emitted); - Box::pin(async move { outcome }) - }); - GetObjectResumeControl::new( - reopen, - RetryTimer::new( - GET_OBJECT_RESUME_MAX_ATTEMPTS, - Duration::from_millis(1), - Duration::from_millis(2), - rustfs_utils::retry::NO_JITTER, - 0, - ), - ) - } - - #[tokio::test] - async fn get_object_streaming_reader_resumes_after_relocation_error() { - use tokio::io::AsyncReadExt; - - // Every typed relocation variant the codec read path can surface - // mid-body must arm the resume flow. - for variant in [ - StorageError::FileNotFound, - StorageError::ObjectNotFound("test-bucket".to_string(), "relocated-object".to_string()), - StorageError::InsufficientReadQuorum("test-bucket".to_string(), "relocated-object".to_string()), - StorageError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "relocated shard disappeared")), - ] { - let reopen_count = Arc::new(AtomicUsize::new(0)); - let control = counting_resume_control(Arc::clone(&reopen_count), |emitted| { - assert_eq!(emitted, 6, "resume must reopen at the emitted offset"); - Ok(FailAtEndReader::new(b"world", None)) - }); - let mut reader = GetObjectStreamingReader::new( - FailAtEndReader::new(b"hello ", Some(std::io::Error::other(variant))), - "test-bucket", - "relocated-object", - "req-resume-typed-error", - None, - 11, - Duration::ZERO, - GetObjectBodyLifecycle::disabled(), - Some(control), - ); - let mut out = Vec::new(); - reader - .read_to_end(&mut out) - .await - .expect("a resumed body must deliver the full committed content"); - - assert_eq!(out, b"hello world"); - assert_eq!(reopen_count.load(Ordering::Relaxed), 1); - } - } - - #[tokio::test(start_paused = true)] - async fn get_object_streaming_reader_releases_failed_disk_permit_before_reopen() { - use tokio::io::AsyncReadExt; - - let manager = Arc::new(ConcurrencyManager::with_disk_read_caps_for_test(1, 0)); - let initial_permit = match manager - .admit_disk_read(Duration::ZERO) - .await - .expect("test disk admission must remain open") - { - DiskReadAdmission::Primary(permit) => permit, - other => panic!("initial read must hold the only primary permit, got {other:?}"), - }; - let reopen_count = Arc::new(AtomicUsize::new(0)); - let reopen: GetObjectReopen> = Box::new({ - let manager = Arc::clone(&manager); - let reopen_count = Arc::clone(&reopen_count); - move |emitted| { - assert_eq!(emitted, 6, "resume must reopen at the emitted offset"); - reopen_count.fetch_add(1, Ordering::Relaxed); - let manager = Arc::clone(&manager); - Box::pin(async move { - match manager - .admit_disk_read(Duration::from_millis(1)) - .await - .map_err(|_| GetObjectResumeFailure::Fatal)? - { - DiskReadAdmission::Primary(permit) => { - Ok(DiskReadPermitReader::new(FailAtEndReader::new(b"world", None), permit.into())) - } - _ => Err(GetObjectResumeFailure::Retryable), - } - }) - } - }); - let control = GetObjectResumeControl::new( - reopen, - RetryTimer::new( - GET_OBJECT_RESUME_MAX_ATTEMPTS, - Duration::from_millis(1), - Duration::from_millis(2), - rustfs_utils::retry::NO_JITTER, - 0, - ), - ); - let initial_reader = - DiskReadPermitReader::new(FailAtEndReader::new(b"hello ", Some(relocation_read_error())), initial_permit.into()); - let mut reader = GetObjectStreamingReader::new( - initial_reader, - "test-bucket", - "relocated-object", - "req-resume-single-permit", - None, - 11, - Duration::ZERO, - GetObjectBodyLifecycle::disabled(), - Some(control), - ); - let mut out = Vec::new(); - - reader - .read_to_end(&mut out) - .await - .expect("resume must not wait on the failed reader's permit"); - - assert_eq!(out, b"hello world"); - assert_eq!(reopen_count.load(Ordering::Relaxed), 1); - assert_eq!( - manager.io_queue_status().permits_in_use, - 0, - "the replacement reader must release its permit at EOF" - ); - } - - #[tokio::test] - async fn get_object_streaming_reader_resumes_after_premature_eof() { - use tokio::io::AsyncReadExt; - - // The legacy duplex read path surfaces vanished object data as a clean - // EOF before the committed length; the resume flow must treat it like - // the typed relocation error. - let reopen_count = Arc::new(AtomicUsize::new(0)); - let control = counting_resume_control(Arc::clone(&reopen_count), |emitted| { - assert_eq!(emitted, 6, "resume must reopen at the emitted offset"); - Ok(FailAtEndReader::new(b"world", None)) - }); - let mut reader = GetObjectStreamingReader::new( - FailAtEndReader::new(b"hello ", None), - "test-bucket", - "truncated-object", - "req-resume-short-eof", - None, - 11, - Duration::ZERO, - GetObjectBodyLifecycle::disabled(), - Some(control), - ); - let mut out = Vec::new(); - reader - .read_to_end(&mut out) - .await - .expect("a resumed body must deliver the full committed content"); - - assert_eq!(out, b"hello world"); - assert_eq!(reopen_count.load(Ordering::Relaxed), 1); - } - - #[tokio::test] - async fn get_object_streaming_reader_clean_eof_does_not_resume() { - use tokio::io::AsyncReadExt; - - let reopen_count = Arc::new(AtomicUsize::new(0)); - let control = counting_resume_control(Arc::clone(&reopen_count), |_| { - panic!("a cleanly completed body must never reopen"); - }); - let mut reader = GetObjectStreamingReader::new( - FailAtEndReader::new(b"hello world", None), - "test-bucket", - "complete-object", - "req-resume-clean-eof", - None, - 11, - Duration::ZERO, - GetObjectBodyLifecycle::disabled(), - Some(control), - ); - let mut out = Vec::new(); - reader.read_to_end(&mut out).await.expect("complete body must read"); - - assert_eq!(out, b"hello world"); - assert_eq!(reopen_count.load(Ordering::Relaxed), 0); - } - - #[tokio::test] - async fn get_object_streaming_reader_fatal_resume_failure_returns_original_error() { - use tokio::io::AsyncReadExt; - - // A fatal reopen failure (the reopened object is a different version) - // must surface the original trigger error after exactly one attempt, - // with only the originally emitted prefix delivered. - let reopen_count = Arc::new(AtomicUsize::new(0)); - let control = counting_resume_control(Arc::clone(&reopen_count), |_| Err(GetObjectResumeFailure::Fatal)); - let mut reader = GetObjectStreamingReader::new( - FailAtEndReader::new(b"hello ", Some(relocation_read_error())), - "test-bucket", - "replaced-object", - "req-resume-fatal", - None, - 11, - Duration::ZERO, - GetObjectBodyLifecycle::disabled(), - Some(control), - ); - let mut out = Vec::new(); - let err = reader - .read_to_end(&mut out) - .await - .expect_err("a fatal resume failure must fail the body with the original error"); - - assert!( - err.get_ref().is_some_and(|inner| inner.is::()), - "the surfaced error must be the original typed trigger, got: {err}" - ); - assert_eq!(out, b"hello "); - assert_eq!( - reopen_count.load(Ordering::Relaxed), - 1, - "a fatal failure must short-circuit the retry budget" - ); - } - - #[tokio::test] - async fn get_object_streaming_reader_exhausts_resume_budget() { - use tokio::io::AsyncReadExt; - - let reopen_count = Arc::new(AtomicUsize::new(0)); - let control = counting_resume_control(Arc::clone(&reopen_count), |_| Err(GetObjectResumeFailure::Retryable)); - let mut reader = GetObjectStreamingReader::new( - FailAtEndReader::new(b"hello ", Some(relocation_read_error())), - "test-bucket", - "vanished-object", - "req-resume-budget", - None, - 11, - Duration::ZERO, - GetObjectBodyLifecycle::disabled(), - Some(control), - ); - let mut out = Vec::new(); - let err = reader - .read_to_end(&mut out) - .await - .expect_err("an exhausted resume budget must fail the body with the original error"); - - assert!( - err.get_ref().is_some_and(|inner| inner.is::()), - "the surfaced error must be the original typed trigger, got: {err}" - ); - assert_eq!(out, b"hello "); - assert_eq!( - reopen_count.load(Ordering::Relaxed), - usize::try_from(GET_OBJECT_RESUME_MAX_ATTEMPTS).expect("resume budget fits usize"), - "resume must stop after its reopen budget" - ); - } - - #[tokio::test] - async fn get_object_streaming_reader_rearms_resume_after_a_successful_resume() { - use tokio::io::AsyncReadExt; - - // A successful resume restores the armed state: a second mid-stream - // relocation error on the replacement stream must resume again. - let reopen_count = Arc::new(AtomicUsize::new(0)); - let control = counting_resume_control(Arc::clone(&reopen_count), |emitted| match emitted { - 6 => Ok(FailAtEndReader::new(b"wo", Some(relocation_read_error()))), - 8 => Ok(FailAtEndReader::new(b"rld", None)), - other => panic!("unexpected reopen offset {other}"), - }); - let mut reader = GetObjectStreamingReader::new( - FailAtEndReader::new(b"hello ", Some(relocation_read_error())), - "test-bucket", - "twice-relocated-object", - "req-resume-rearm", - None, - 11, - Duration::ZERO, - GetObjectBodyLifecycle::disabled(), - Some(control), - ); - let mut out = Vec::new(); - reader - .read_to_end(&mut out) - .await - .expect("a re-armed resume must deliver the full committed content"); - - assert_eq!(out, b"hello world"); - assert_eq!(reopen_count.load(Ordering::Relaxed), 2); - } - - #[tokio::test] - async fn get_object_streaming_reader_resume_budget_is_per_body() { - use tokio::io::AsyncReadExt; - - // The retry budget is consumed across the whole body, not reset per - // error: one successful resume plus two failed reopens exhausts it. - let reopen_count = Arc::new(AtomicUsize::new(0)); - let control = counting_resume_control(Arc::clone(&reopen_count), |emitted| match emitted { - 6 => Ok(FailAtEndReader::new(b"wo", Some(relocation_read_error()))), - _ => Err(GetObjectResumeFailure::Retryable), - }); - let mut reader = GetObjectStreamingReader::new( - FailAtEndReader::new(b"hello ", Some(relocation_read_error())), - "test-bucket", - "budget-shared-object", - "req-resume-budget-per-body", - None, - 11, - Duration::ZERO, - GetObjectBodyLifecycle::disabled(), - Some(control), - ); - let mut out = Vec::new(); - let err = reader - .read_to_end(&mut out) - .await - .expect_err("the shared budget must exhaust and surface the latest trigger error"); - - assert!( - err.get_ref().is_some_and(|inner| inner.is::()), - "the surfaced error must be the typed trigger, got: {err}" - ); - assert_eq!(out, b"hello wo"); - assert_eq!( - reopen_count.load(Ordering::Relaxed), - usize::try_from(GET_OBJECT_RESUME_MAX_ATTEMPTS).expect("resume budget fits usize"), - "the budget spans every resume of the same body" - ); - } - - #[tokio::test] - async fn get_object_streaming_reader_non_relocation_error_passes_through() { - use tokio::io::AsyncReadExt; - - let reopen_count = Arc::new(AtomicUsize::new(0)); - let control = counting_resume_control(Arc::clone(&reopen_count), |_| { - panic!("a non-relocation read error must not reopen"); - }); - let mut reader = GetObjectStreamingReader::new( - FailAtEndReader::new(b"hello ", Some(std::io::Error::new(std::io::ErrorKind::InvalidData, "corrupt"))), - "test-bucket", - "corrupt-object", - "req-resume-passthrough", - None, - 11, - Duration::ZERO, - GetObjectBodyLifecycle::disabled(), - Some(control), - ); - let mut out = Vec::new(); - let err = reader - .read_to_end(&mut out) - .await - .expect_err("a non-relocation error must fail the body unchanged"); - - assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); - assert_eq!(out, b"hello "); - assert_eq!(reopen_count.load(Ordering::Relaxed), 0); - } - - #[tokio::test] - async fn get_object_streaming_reader_error_after_full_delivery_does_not_resume() { - use tokio::io::AsyncReadExt; - - // The committed length is already delivered when the inner stream - // errors, so the error must keep the existing fail-loud behavior - // instead of arming a resume. - let reopen_count = Arc::new(AtomicUsize::new(0)); - let control = counting_resume_control(Arc::clone(&reopen_count), |_| { - panic!("an error after full delivery must not reopen"); - }); - let mut reader = GetObjectStreamingReader::new( - FailAtEndReader::new(b"hello world", Some(relocation_read_error())), - "test-bucket", - "fully-delivered-object", - "req-resume-after-full", - None, - 11, - Duration::ZERO, - GetObjectBodyLifecycle::disabled(), - Some(control), - ); - let mut out = Vec::new(); - let err = reader - .read_to_end(&mut out) - .await - .expect_err("a post-completion inner error still surfaces instead of being swallowed"); - - assert!( - err.get_ref().is_some_and(|inner| inner.is::()), - "the surfaced error must be the inner typed error, got: {err}" - ); - assert_eq!(out, b"hello world"); - assert_eq!(reopen_count.load(Ordering::Relaxed), 0); - } - - #[test] - fn get_object_resume_identity_requires_same_version() { - let version_id = Uuid::from_u128(0x1234); - let mod_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp"); - let later_mod_time = OffsetDateTime::from_unix_timestamp(1_700_000_100).expect("valid timestamp"); - let identity = GetObjectResumeIdentity { - version_id: Some(version_id), - mod_time: Some(mod_time), - size: 11, - etag: Some("etag-a".to_string()), - range_dependent_size: false, - }; - let info = ObjectInfo { - version_id: Some(version_id), - mod_time: Some(mod_time), - size: 11, - etag: Some("etag-a".to_string()), - ..Default::default() - }; - assert!(identity.matches(&info, 0)); - assert!(identity.matches(&info, 6), "a plain read reports the range-invariant oi.size"); - // Rebalance regenerates data_dir for the same version: identity must - // still match so a relocated read can resume. - assert!(identity.matches( - &ObjectInfo { - data_dir: Some(Uuid::from_u128(0xbeef)), - ..info.clone() - }, - 0 - )); - assert!(!identity.matches( - &ObjectInfo { - version_id: Some(Uuid::from_u128(0x5678)), - ..info.clone() - }, - 0 - )); - assert!(!identity.matches( - &ObjectInfo { - version_id: None, - ..info.clone() - }, - 0 - )); - assert!(!identity.matches( - &ObjectInfo { - mod_time: Some(later_mod_time), - ..info.clone() - }, - 0 - )); - assert!(!identity.matches( - &ObjectInfo { - size: 12, - ..info.clone() - }, - 0 - )); - assert!(!identity.matches( - &ObjectInfo { - etag: Some("etag-b".to_string()), - ..info.clone() - }, - 0 - )); - assert!(!identity.matches(&ObjectInfo { etag: None, ..info }, 0)); - } - - #[test] - fn get_object_resume_identity_normalizes_range_dependent_size() { - // Encrypted and compressed reads report the per-read delivered length - // as object_info.size, so the reopened subrange reports size - emitted - // for the same version. - let version_id = Uuid::from_u128(0x1234); - let mod_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp"); - let identity = GetObjectResumeIdentity { - version_id: Some(version_id), - mod_time: Some(mod_time), - size: 11, - etag: Some("etag-a".to_string()), - range_dependent_size: true, - }; - let reopened = ObjectInfo { - version_id: Some(version_id), - mod_time: Some(mod_time), - size: 5, - etag: Some("etag-a".to_string()), - ..Default::default() - }; - assert!(identity.matches(&reopened, 6), "the reopened subrange reports size - emitted"); - assert!(identity.matches( - &ObjectInfo { - size: 11, - ..reopened.clone() - }, - 0 - )); - assert!( - !identity.matches( - &ObjectInfo { - size: 11, - ..reopened.clone() - }, - 6 - ), - "an unshrunk range-dependent size after emitted bytes is a different object" - ); - assert!(!identity.matches(&ObjectInfo { size: 4, ..reopened }, 6)); - } - - #[test] - fn get_object_resume_range_offsets() { - // A full-object read that emitted nothing reopens range-free so the - // replacement stream keeps the codec fast path. - assert!(GetObjectResumeContext::resume_range(0, -1, 0).is_none()); - - // Mid-stream full-object resume: open-ended from the emitted offset. - let range = GetObjectResumeContext::resume_range(0, -1, 6).expect("a mid-stream resume must carry a range"); - assert!(!range.is_suffix_length); - assert_eq!((range.start, range.end), (6, -1)); - - // Ranged reads resume at absolute offsets with the committed end - // preserved (suffix ranges and partNumber GETs are resolved to absolute - // offsets before these values are captured). - let range = GetObjectResumeContext::resume_range(10, 19, 0).expect("a ranged resume must carry a range"); - assert!(!range.is_suffix_length); - assert_eq!((range.start, range.end), (10, 19)); - let range = GetObjectResumeContext::resume_range(10, 19, 5).expect("a ranged resume must carry a range"); - assert_eq!((range.start, range.end), (15, 19)); - } - - async fn real_get_resume_test_context() -> (Vec, Arc, Arc) { - let (disk_paths, store) = crate::app::gating_test_env::shared_gating_ecstore_and_disk_paths().await; - if current_app_context().is_none() { - crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; - } - let ambient = current_app_context().expect("resume wiring tests require an ambient AppContext"); - let context = Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms())); - (disk_paths, store, context) - } - - // Uploads a real multipart object through the store and returns the - // concatenated body, so resume wiring tests can verify byte-exact delivery - // against on-disk part files. - async fn put_real_multipart_object( - store: &Arc, - bucket: &str, - object: &str, - part_size: usize, - part_count: usize, - fill: u8, - ) -> Vec { - use crate::app::storage_api::multipart_usecase::contract::multipart::{CompletePart, MultipartOperations as _}; - - let upload = store - .new_multipart_upload(bucket, object, &ObjectOptions::default()) - .await - .expect("create multipart upload"); - let mut parts = Vec::new(); - let mut body = Vec::with_capacity(part_size * part_count); - for part_id in 1..=part_count { - let part_fill = fill.wrapping_add(u8::try_from(part_id - 1).expect("test part index must fit u8")); - let part_body = vec![part_fill; part_size]; - body.extend_from_slice(&part_body); - let mut reader = PutObjReader::from_vec(part_body); - let part = store - .put_object_part(bucket, object, &upload.upload_id, part_id, &mut reader, &ObjectOptions::default()) - .await - .expect("upload multipart part"); - parts.push(CompletePart { - part_num: part_id, - etag: part.etag, - ..Default::default() - }); - } - store - .clone() - .complete_multipart_upload(bucket, object, &upload.upload_id, parts, &ObjectOptions::default()) - .await - .expect("complete multipart upload"); - body - } - - // Deletes the given part files from every version data dir present on the - // disks, simulating rebalance removing the object data while xl.meta stays - // readable. Returns the number of version dirs visited and files removed. - fn delete_object_part_shards( - disk_paths: &[std::path::PathBuf], - bucket: &str, - object: &str, - part_numbers: &[usize], - ) -> (usize, usize) { - let mut version_dirs = 0; - let mut deleted = 0; - for disk_path in disk_paths { - let object_dir = disk_path.join(bucket).join(object); - let entries = match std::fs::read_dir(&object_dir) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(error) => panic!("object directory must be readable: {error}"), - }; - for entry in entries { - let entry = entry.expect("object directory entry must read"); - if !entry.file_type().expect("entry file type must read").is_dir() { - continue; - } - version_dirs += 1; - for part_number in part_numbers { - let part_file = entry.path().join(format!("part.{part_number}")); - if part_file.exists() { - std::fs::remove_file(&part_file).expect("part shard must be removable"); - deleted += 1; - } - } - } - } - (version_dirs, deleted) - } - - // The surfaced mid-stream failure must be the original trigger: a typed - // relocation StorageError from the codec read path, or an IncompleteBody - // (UnexpectedEof) from the duplex path. The resume flow must never - // fabricate a different error. - fn assert_original_trigger_error(error: &(dyn std::error::Error + Send + Sync + 'static)) { - let Some(io_error) = error.downcast_ref::() else { - panic!("body error must be an io::Error, got: {error}"); - }; - let is_trigger = io_error.kind() == std::io::ErrorKind::UnexpectedEof || is_object_relocation_error(io_error); - assert!(is_trigger, "body error must be the original relocation trigger, got: {error}"); - } - - #[tokio::test] - #[serial_test::serial] - // SAFETY: the test mutates one process env var before any use; nextest runs - // each test in its own process, so the mutation cannot race another test. - #[allow(unsafe_code)] - async fn execute_get_object_resume_exhausts_budget_when_object_data_vanishes() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; - - // The resume phase runs inside the body stall budget (default 10s), - // and three real reopen attempts against missing shards approach it on - // loaded CI disks; widen the budget so this test asserts the resume - // outcome instead of racing the stall timer. - unsafe { std::env::set_var(rustfs_config::ENV_OBJECT_DISK_READ_TIMEOUT, "120") }; - - let (disk_paths, store, context) = real_get_resume_test_context().await; - let bucket = format!("resume-vanish-{}", Uuid::new_v4()); - let object = "object.bin"; - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("create resume failure-path bucket"); - let part_size = 6 * 1024 * 1024; - let body = put_real_multipart_object(&store, &bucket, object, part_size, 3, 0xAA).await; - - // Remove the part.2/part.3 shards on every disk before the GET starts, - // so no file descriptor for them can exist: the stream must fail at the - // part-2 boundary, and every reopen resolves intact metadata whose data - // is gone, so the whole resume budget burns down. - let (version_dirs, deleted) = delete_object_part_shards(&disk_paths, &bucket, object, &[2, 3]); - assert!(version_dirs > 0, "the multipart object must have at least one version data directory"); - assert_eq!(deleted, version_dirs * 2); - - let input = GetObjectInput::builder() - .bucket(bucket) - .key(object.to_string()) - .build() - .expect("resume failure-path GET input must build"); - let usecase = DefaultObjectUsecase::with_context(Some(context)); - let attempts_before = GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed); - let mut response = usecase - .execute_get_object(build_request(input, Method::GET)) - .await - .expect("the GET commits a response; the body fails mid-stream"); - let mut response_body = response.output.body.take().expect("GET response must include a body"); - let mut collected = Vec::new(); - let mut stream_error = None; - while let Some(chunk) = response_body.next().await { - match chunk { - Ok(bytes) => collected.extend_from_slice(&bytes), - Err(error) => { - stream_error = Some(error); - break; - } - } - } - - assert_eq!( - collected, - &body[..part_size], - "only the first part can be delivered before the object data vanishes" - ); - assert_original_trigger_error( - stream_error - .as_deref() - .expect("the body stream must fail at the missing part"), - ); - let attempts = GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed) - attempts_before; - assert_eq!( - attempts, - usize::try_from(GET_OBJECT_RESUME_MAX_ATTEMPTS).expect("resume budget fits usize"), - "resume must exhaust its reopen budget before failing" - ); - } - - #[tokio::test] - #[serial_test::serial] - async fn execute_get_object_resumes_from_relocated_pool_without_splicing_body() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; - - let (temp_dir, pool_disk_paths, store) = crate::app::gating_test_env::isolated_multi_pool_ecstore().await; - if current_app_context().is_none() { - crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; - } - let ambient = current_app_context().expect("multi-pool resume test requires an ambient AppContext"); - let context = Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms())); - let bucket = format!("resume-relocate-{}", Uuid::new_v4()); - let object = "object.bin"; - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("create multi-pool resume bucket"); - let part_size = 24 * 1024 * 1024; - let body = put_real_multipart_object(&store, &bucket, object, part_size, 3, 0xA5).await; - let upload_pool = pool_disk_paths - .iter() - .position(|paths| { - paths - .iter() - .any(|path| path.join(&bucket).join(object).join("xl.meta").is_file()) - }) - .expect("multipart object must be placed in one source pool"); - if upload_pool != 0 { - for (source_disk, target_disk) in pool_disk_paths[upload_pool].iter().zip(&pool_disk_paths[0]) { - let source_object = source_disk.join(&bucket).join(object); - let target_bucket = target_disk.join(&bucket); - std::fs::create_dir_all(&target_bucket).expect("create normalized target bucket directory"); - std::fs::rename(source_object, target_bucket.join(object)).expect("normalize the test object into the old pool"); - } - } - let source_pool = 0; - let target_pool = 1; - - let input = GetObjectInput::builder() - .bucket(bucket.clone()) - .key(object.to_string()) - .build() - .expect("multi-pool resume GET input must build"); - let usecase = DefaultObjectUsecase::with_context(Some(context)); - let mut response = usecase - .execute_get_object(build_request(input, Method::GET)) - .await - .expect("multi-pool GET must commit its response"); - let mut response_body = response - .output - .body - .take() - .expect("multi-pool GET response must include a body"); - - // Open the source reader before publishing the relocated object. Build - // each replica outside the bucket and rename it into place atomically so - // background maintenance never observes a metadata-less target object. - let mut staged_targets = Vec::with_capacity(pool_disk_paths[target_pool].len()); - for (source_disk, target_disk) in pool_disk_paths[source_pool].iter().zip(&pool_disk_paths[target_pool]) { - let source_dir = source_disk.join(&bucket).join(object); - let target_dir = target_disk.join(&bucket).join(object); - let staging_dir = temp_dir.path().join(format!("resume-relocate-{}", Uuid::new_v4())); - std::fs::create_dir_all(&staging_dir).expect("create relocated target staging directory"); - for entry in std::fs::read_dir(&source_dir).expect("read source object directory") { - let entry = entry.expect("read source object entry"); - if !entry.file_type().expect("read source object entry type").is_dir() { - continue; - } - let target_entry = staging_dir.join(entry.file_name()); - std::fs::create_dir_all(&target_entry).expect("create relocated target data directory"); - for child in std::fs::read_dir(entry.path()).expect("read source object data directory") { - let child = child.expect("read source object data entry"); - std::fs::copy(child.path(), target_entry.join(child.file_name())).expect("copy relocated object data entry"); - } - } - std::fs::copy(source_dir.join("xl.meta"), staging_dir.join("xl.meta")).expect("stage relocated object metadata"); - staged_targets.push((staging_dir, target_dir)); - } - let (version_dirs, deleted) = delete_object_part_shards(&pool_disk_paths[source_pool], &bucket, object, &[2, 3]); - assert!(version_dirs > 0, "the source pool must have at least one version data directory"); - assert_eq!(deleted, version_dirs * 2); - - for ((staging_dir, target_dir), source_disk) in staged_targets.into_iter().zip(&pool_disk_paths[source_pool]) { - std::fs::rename(staging_dir, target_dir).expect("publish relocated target object"); - let source_meta = source_disk.join(&bucket).join(object).join("xl.meta"); - std::fs::remove_file(source_meta).expect("remove relocated source object metadata"); - } - store - .get_object_info(&bucket, object, &ObjectOptions::default()) - .await - .expect("the relocated object must resolve from the target pool"); - - let attempts_before = GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed); - let mut collected = Vec::new(); - while let Some(chunk) = response_body.next().await { - match chunk { - Ok(chunk) => collected.extend_from_slice(&chunk), - Err(err) => panic!( - "relocated GET from pool {source_pool} must resume from pool {target_pool} after {} attempts: {err:?}", - GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed) - attempts_before - ), - } - } - - assert_eq!(collected, body, "resumed production GET must preserve the complete body byte-for-byte"); - assert_eq!( - GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed) - attempts_before, - 1, - "the relocated body must reopen exactly once" - ); - } - - #[tokio::test] - #[serial_test::serial] - async fn get_object_resume_reopen_rejects_a_replaced_object_version() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; - use tokio::io::AsyncReadExt as _; - - let (_disk_paths, store, _context) = real_get_resume_test_context().await; - let bucket = format!("resume-identity-{}", Uuid::new_v4()); - let object = "object.bin"; - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("create resume identity bucket"); - let body = vec![0xAA; 1024 * 1024]; - put_real_cold_fill_object(&store, &bucket, object, &body).await; - let info = store - .get_object_info(&bucket, object, &ObjectOptions::default()) - .await - .expect("read the committed object metadata"); - - let ctx = GetObjectResumeContext::new( - Arc::clone(&store), - &bucket, - object, - ObjectOptions::default(), - &HeaderMap::new(), - &info, - 0, - -1, - ); - - // Positive control: the same version reopens and streams the body. - let manager = get_concurrency_manager(); - let permits_before = manager.io_queue_status().permits_in_use; - let mut reader = ctx.reopen(0).await.expect("reopening the same version must succeed"); - assert_eq!( - manager.io_queue_status().permits_in_use, - permits_before + 1, - "the resumed stream must hold disk-read admission like the initial read" - ); - let mut reopened_body = Vec::new(); - reader - .read_to_end(&mut reopened_body) - .await - .expect("the reopened reader must stream the body"); - assert_eq!(reopened_body, body); - // The reopened reader holds the object read lock; drop it before the - // delete below requests the write lock. - drop(reader); - assert_eq!( - manager.io_queue_status().permits_in_use, - permits_before, - "dropping the resumed stream must release its disk-read admission" - ); - - // A nonzero-offset reopen must splice the remaining bytes exactly. - let mut reader = ctx.reopen(1024).await.expect("reopening at a nonzero offset must succeed"); - let mut tail = Vec::new(); - reader - .read_to_end(&mut tail) - .await - .expect("the offset reader must stream the remaining body"); - assert_eq!(tail, body[1024..], "the resumed stream must continue from the emitted offset exactly"); - drop(reader); - - // Delete and re-PUT the key, then the stale context must refuse to - // splice the replacement version into the committed response. - store - .delete_object(&bucket, object, ObjectOptions::default()) - .await - .expect("delete the original object"); - let replacement_body = vec![0xBB; 2 * 1024 * 1024]; - put_real_cold_fill_object(&store, &bucket, object, &replacement_body).await; - let result = ctx.reopen(0).await; - assert!( - matches!(result, Err(GetObjectResumeFailure::Fatal)), - "reopening a replaced version must fail closed" - ); - } - - #[tokio::test] - #[serial_test::serial] - async fn get_object_resume_context_pins_latest_read_to_resolved_version() { - let (_disk_paths, store, _context) = real_get_resume_test_context().await; - let resolved_version = Uuid::new_v4(); - let info = ObjectInfo { - version_id: Some(resolved_version), - ..Default::default() - }; - - let ctx = GetObjectResumeContext::new( - Arc::clone(&store), - "bucket", - "object.bin", - ObjectOptions::default(), - &HeaderMap::new(), - &info, - 0, - -1, - ); - assert_eq!( - ctx.opts.version_id, - Some(resolved_version.to_string()), - "latest GET resume must reopen the initially resolved version, not the moving latest" - ); - - let explicit_version = Uuid::new_v4().to_string(); - let explicit_opts = ObjectOptions { - version_id: Some(explicit_version.clone()), - ..Default::default() - }; - let ctx = GetObjectResumeContext::new( - Arc::clone(&store), - "bucket", - "object.bin", - explicit_opts, - &HeaderMap::new(), - &info, - 0, - -1, - ); - assert_eq!( - ctx.opts.version_id.as_deref(), - Some(explicit_version.as_str()), - "an explicit request version must stay authoritative" - ); - - let unversioned_info = ObjectInfo::default(); - let ctx = GetObjectResumeContext::new( - Arc::clone(&store), - "bucket", - "object.bin", - ObjectOptions::default(), - &HeaderMap::new(), - &unversioned_info, - 0, - -1, - ); - assert_eq!(ctx.opts.version_id, None, "unversioned reads have no version to pin"); - } - - #[tokio::test] - #[serial_test::serial] - async fn get_object_resume_context_redacts_ssec_headers_and_flags_range_dependent_size() { - let (_disk_paths, store, _context) = real_get_resume_test_context().await; - - let mut request_headers = HeaderMap::new(); - request_headers.insert(SSEC_ALGORITHM_HEADER, HeaderValue::from_static("AES256")); - request_headers.insert(SSEC_KEY_HEADER, HeaderValue::from_static("dGVzdC1rZXk=")); - request_headers.insert(SSEC_KEY_MD5_HEADER, HeaderValue::from_static("bWQ1")); - request_headers.insert(http::header::AUTHORIZATION, HeaderValue::from_static("AWS4-HMAC-SHA256 Credential=test")); - request_headers.insert("x-amz-security-token", HeaderValue::from_static("session-token")); - let store_headers = project_ssec_transport_headers(&request_headers); - assert_eq!(store_headers.len(), 3, "only store-consumed SSE-C headers are forwarded"); - assert!(store_headers.values().all(HeaderValue::is_sensitive)); - assert!(store_headers.get(http::header::AUTHORIZATION).is_none()); - assert!(store_headers.get("x-amz-security-token").is_none()); - assert!(!format!("{store_headers:?}").contains("dGVzdC1rZXk=")); - let plain_info = ObjectInfo { - size: 11, - ..Default::default() - }; - let ctx = GetObjectResumeContext::new( - Arc::clone(&store), - "bucket", - "object.bin", - ObjectOptions::default(), - &request_headers, - &plain_info, - 0, - -1, - ); - for name in [SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER] { - let value = ctx.ssec_headers.get(name).expect("the SSE-C trio is retained"); - assert!(value.is_sensitive(), "store spans record headers at debug; {name} must be redacted there"); - } - assert_eq!( - ctx.ssec_headers.len(), - 3, - "only the SSE-C trio may be retained; credential headers must never be replayed into store spans" - ); - assert!(!ctx.identity.range_dependent_size, "plain reads report the range-invariant oi.size"); - - let encrypted_info = ObjectInfo { - size: 11, - user_defined: Arc::new( - [("x-amz-server-side-encryption".to_string(), "aws:kms".to_string())] - .into_iter() - .collect(), - ), - ..Default::default() - }; - let ctx = GetObjectResumeContext::new( - Arc::clone(&store), - "bucket", - "object.bin", - ObjectOptions::default(), - &HeaderMap::new(), - &encrypted_info, - 0, - -1, - ); - assert!(ctx.identity.range_dependent_size, "encrypted reads report the per-read delivered length"); - - let compressed_info = ObjectInfo { - size: 11, - user_defined: Arc::new( - [("x-rustfs-internal-compression".to_string(), "snappy".to_string())] - .into_iter() - .collect(), - ), - ..Default::default() - }; - let ctx = GetObjectResumeContext::new( - Arc::clone(&store), - "bucket", - "object.bin", - ObjectOptions::default(), - &HeaderMap::new(), - &compressed_info, - 0, - -1, - ); - assert!(ctx.identity.range_dependent_size, "compressed reads report the per-read delivered length"); - } - - #[tokio::test] - #[serial_test::serial] - async fn memory_tracked_bytes_stream_releases_request_guard_after_emit() { - let initial = GetObjectGuard::concurrent_count(); - let guard = GetObjectGuard::new(); - assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); - - let mut stream = MemoryTrackedBytesStream::new( - Bytes::from_static(b"hello"), - 5, - GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, - None, - GetObjectBodyLifecycle::tracked(guard), - ); - let chunk = stream - .next() - .await - .expect("memory body should emit one chunk") - .expect("memory body chunk should be readable"); - - assert_eq!(chunk.as_ref(), b"hello"); - assert_eq!(GetObjectGuard::concurrent_count(), initial); - } - - #[test] - #[serial_test::serial] - fn memory_tracked_bytes_stream_releases_request_guard_for_zero_length_without_poll() { - let initial = GetObjectGuard::concurrent_count(); - let guard = GetObjectGuard::new(); - assert_eq!(GetObjectGuard::concurrent_count(), initial + 1); - - let stream = MemoryTrackedBytesStream::new( - Bytes::new(), - 0, - GET_MEMORY_BODY_SOURCE_BUFFERED_BODY, - None, - GetObjectBodyLifecycle::tracked(guard), - ); - drop(stream); - - assert_eq!(GetObjectGuard::concurrent_count(), initial); - } - - #[tokio::test] - async fn disk_read_permit_reader_holds_permit_until_reader_is_dropped() { - let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); - let permit = semaphore - .clone() - .acquire_owned() - .await - .expect("test semaphore should grant owned permit"); - - let reader = DiskReadPermitReader::new(std::io::Cursor::new(Vec::::new()), permit.into()); - assert_eq!(semaphore.available_permits(), 0); - - drop(reader); - assert_eq!(semaphore.available_permits(), 1); - } - - #[tokio::test] - #[serial_test::serial(cold_fill_metrics_gate)] - async fn cold_fill_follower_disk_permit_metric_tracks_actual_permit_lifetime() { - COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.store(0, Ordering::Relaxed); - let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); - scope_cold_fill_disk_permit_owner_for_test(ColdFillDiskPermitOwner::Follower, async { - let permit = semaphore - .clone() - .acquire_owned() - .await - .expect("follower test semaphore must grant an owned permit"); - let tracked = GetObjectDiskPermit::new(permit); - assert_eq!(semaphore.available_permits(), 0); - assert_eq!(COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.load(Ordering::Relaxed), 1); - - drop(tracked); - assert_eq!(semaphore.available_permits(), 1); - assert_eq!(COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.load(Ordering::Relaxed), 0); - }) - .await; - } - - #[test] - #[serial_test::serial(cold_fill_metrics_gate)] - fn cold_fill_disk_permit_metrics_obey_gate_and_return_to_zero() { - use metrics_util::debugging::{DebugValue, DebuggingRecorder}; - - let metrics_was_enabled = rustfs_io_metrics::metrics_enabled(); - let recorder = DebuggingRecorder::new(); - let snapshotter = recorder.snapshotter(); - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("metric test runtime must build"); - metrics::with_local_recorder(&recorder, || { - runtime.block_on(async { - rustfs_io_metrics::set_metrics_enabled(false); - let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); - scope_cold_fill_disk_permit_owner_for_test(ColdFillDiskPermitOwner::Follower, async { - let permit = semaphore - .clone() - .acquire_owned() - .await - .expect("metric test permit must be available"); - let tracked = GetObjectDiskPermit::new(permit); - rustfs_io_metrics::set_metrics_enabled(true); - drop(tracked); - }) - .await; - assert!( - snapshotter.snapshot().into_vec().into_iter().all(|(composite, _, _, _)| { - !composite.key().name().starts_with("rustfs_object_data_cache_cold_fill_") - }), - "a permit acquired while metrics were disabled must not record an unmatched decrement" - ); - - scope_cold_fill_disk_permit_owner_for_test(ColdFillDiskPermitOwner::Producer, async { - let permit = semaphore - .clone() - .acquire_owned() - .await - .expect("metric test permit must be available"); - let tracked = GetObjectDiskPermit::new(permit); - rustfs_io_metrics::set_metrics_enabled(false); - drop(tracked); - }) - .await; - rustfs_io_metrics::set_metrics_enabled(true); - scope_cold_fill_disk_permit_owner_for_test(ColdFillDiskPermitOwner::Follower, async { - let permit = semaphore.acquire_owned().await.expect("metric test permit must be available"); - let tracked = GetObjectDiskPermit::new(permit); - let _replacement = crate::app::object_data_cache::ColdFillCoordinator::default(); - drop(tracked); - }) - .await; - }); - }); - - let values = snapshotter - .snapshot() - .into_vec() - .into_iter() - .filter_map(|(composite, _unit, _description, value)| { - composite - .key() - .name() - .starts_with("rustfs_object_data_cache_cold_fill_") - .then_some((composite.key().name().to_string(), value)) - }) - .collect::>(); - assert_eq!(values.len(), 2); - for name in [ - "rustfs_object_data_cache_cold_fill_producer_disk_permits", - "rustfs_object_data_cache_cold_fill_follower_disk_permits", - ] { - let DebugValue::Gauge(value) = values.get(name).unwrap_or_else(|| panic!("missing {name} gauge")) else { - panic!("{name} must be a gauge"); - }; - assert_eq!(value.into_inner(), 0.0, "{name} must return to zero after permit drop"); - } - rustfs_io_metrics::set_metrics_enabled(metrics_was_enabled); - } - - #[tokio::test] - async fn build_get_object_body_keeps_large_objects_on_streaming_path_without_preread() { - let reads = Arc::new(AtomicUsize::new(0)); - let reader = ReadProbeReader { - reads: Arc::clone(&reads), - }; - let info = ObjectInfo { - size: 18_i64 * 1024 * 1024 * 1024, - ..Default::default() - }; - - let _body = DefaultObjectUsecase::build_get_object_body( - reader, - &info, - 18_i64 * 1024 * 1024 * 1024, - "req-large-object", - None, - 128 * 1024, - true, - 1, - None, - false, - false, - None, - "test-bucket", - "large-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("build_get_object_body should succeed for streaming path"); - - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "large-object response construction should not pre-read object data" - ); - } - - #[tokio::test] - async fn build_get_object_body_keeps_large_encrypted_objects_on_streaming_path_without_preread() { - let reads = Arc::new(AtomicUsize::new(0)); - let reader = ReadProbeReader { - reads: Arc::clone(&reads), - }; - let info = ObjectInfo { - size: 18_i64 * 1024 * 1024 * 1024, - ..Default::default() - }; - - let _body = DefaultObjectUsecase::build_get_object_body( - reader, - &info, - 18_i64 * 1024 * 1024 * 1024, - "req-large-encrypted-object", - None, - 128 * 1024, - true, - 1, - None, - false, - true, - None, - "test-bucket", - "large-encrypted-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("build_get_object_body should succeed for encrypted streaming path"); - - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "large encrypted object response construction should not pre-read object data" - ); - } - - #[tokio::test] - async fn build_get_object_body_uses_buffered_body_without_reader_preread() { - let reads = Arc::new(AtomicUsize::new(0)); - let reader = ReadProbeReader { - reads: Arc::clone(&reads), - }; - let info = ObjectInfo { - size: 4, - ..Default::default() - }; - - let _body = DefaultObjectUsecase::build_get_object_body( - reader, - &info, - 4, - "req-direct-memory-object", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - Some(Bytes::from_static(b"test")), - "test-bucket", - "direct-memory-object", - GetObjectBodyLifecycle::disabled(), - |_| panic!("a buffered body must not initialize streaming resume state"), - ) - .await - .expect("build_get_object_body should consume buffered body"); - - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "buffered GetObject body must not be read from the fallback reader" - ); - } - - #[tokio::test] - async fn build_get_object_body_with_cache_uses_cached_body_without_reader_preread() { - let reads = Arc::new(AtomicUsize::new(0)); - let reader = ReadProbeReader { - reads: Arc::clone(&reads), - }; - let info = ObjectInfo { - size: 5, - etag: Some("etag".to_string()), - ..Default::default() - }; - let adapter = - crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, - max_bytes: 8_388_608, - // Fill must not depend on the live memory reading (host vs container). - min_free_memory_percent: 0, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("fill-enabled cache adapter should initialize"); - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "test-bucket", - object: "cached-object", - version_id: None, - etag: "etag", - size: 5, - data_dir_u128: None, - mod_time_unix_nanos: 0, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - let fill = adapter.cache().fill_body(&plan, Bytes::from_static(b"hello")).await; - - assert_eq!(fill, rustfs_object_data_cache::ObjectDataCacheFillResult::Inserted); - - let _body = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - reader, - &info, - 5, - "req-cached-object", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - None, - false, - false, - true, - "test-bucket", - "cached-object", - GetObjectBodyLifecycle::disabled(), - |_| panic!("a cache hit must not initialize streaming resume state"), - ) - .await - .expect("cache hit body handoff should succeed"); - - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "cache hit body handoff must not read from the fallback reader" - ); - } - - #[tokio::test] - async fn build_get_object_body_with_cache_rejects_size_mismatch_fill() { - let reads = Arc::new(AtomicUsize::new(0)); - let reader = ReadProbeReader { - reads: Arc::clone(&reads), - }; - let info = ObjectInfo { - size: 5, - etag: Some("etag".to_string()), - ..Default::default() - }; - let adapter = - crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, - max_bytes: 8_388_608, - // Fill must not depend on the live memory reading (host vs container). - min_free_memory_percent: 0, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("fill-enabled cache adapter should initialize"); - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "test-bucket", - object: "cached-object", - version_id: None, - etag: "etag", - size: 5, - data_dir_u128: None, - mod_time_unix_nanos: 0, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - let fill = adapter.cache().fill_body(&plan, Bytes::from_static(b"oops")).await; - - let _body = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - reader, - &info, - 5, - "req-rejects-size-mismatch-fill", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - None, - false, - false, - true, - "test-bucket", - "cached-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("size-mismatched direct fill should not create a cache hit"); - let lookup_after_mismatch = adapter.lookup_body(&plan).await; - - assert_eq!(fill, rustfs_object_data_cache::ObjectDataCacheFillResult::SkippedSizeMismatch); - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "size-mismatched rejected fill should construct the fallback stream without pre-reading" - ); - assert!( - matches!(lookup_after_mismatch, rustfs_object_data_cache::ObjectDataCacheLookup::Miss), - "size-mismatched fill must not leave a reusable cache entry" - ); - } - - #[tokio::test] - async fn build_get_object_body_with_cache_fills_from_buffered_body_without_reader_preread() { - let first_reads = Arc::new(AtomicUsize::new(0)); - let first_reader = ReadProbeReader { - reads: Arc::clone(&first_reads), - }; - let second_reads = Arc::new(AtomicUsize::new(0)); - let second_reader = ReadProbeReader { - reads: Arc::clone(&second_reads), - }; - let info = ObjectInfo { - size: 5, - etag: Some("etag".to_string()), - ..Default::default() - }; - let adapter = - crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, - max_bytes: 8_388_608, - // Fill must not depend on the live memory reading (host vs container). - min_free_memory_percent: 0, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("fill-enabled cache adapter should initialize"); - - let _first_body = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - first_reader, - &info, - 5, - "req-cache-fill-first", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - Some(Bytes::from_static(b"hello")), - false, - false, - true, - "test-bucket", - "cached-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("buffered-body handoff should succeed"); - - // ODC-15: the fill is detached from the response path, so wait for it to - // populate the cache before the follow-up GET to keep the hit deterministic. - wait_for_cache_hit(&adapter, "test-bucket", "cached-object", "etag", 5).await; - - let _second_body = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - second_reader, - &info, - 5, - "req-cache-fill-second", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - None, - false, - false, - true, - "test-bucket", - "cached-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("follow-up cache hit should succeed"); - - assert_eq!( - first_reads.load(AtomicOrdering::Relaxed), - 0, - "buffered-body fill path must not read from the fallback reader" - ); - assert_eq!( - second_reads.load(AtomicOrdering::Relaxed), - 0, - "cache hit after buffered-body fill must not read from the fallback reader" - ); - } - - #[tokio::test] - async fn build_get_object_body_with_cache_skips_buffered_fill_on_size_mismatch() { - let reads = Arc::new(AtomicUsize::new(0)); - let reader = ReadProbeReader { - reads: Arc::clone(&reads), - }; - let info = ObjectInfo { - size: 5, - etag: Some("etag".to_string()), - ..Default::default() - }; - let adapter = - crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, - max_bytes: 8_388_608, - // Fill must not depend on the live memory reading (host vs container). - min_free_memory_percent: 0, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("fill-enabled cache adapter should initialize"); - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "test-bucket", - object: "cached-object", - version_id: None, - etag: "etag", - size: 5, - data_dir_u128: None, - mod_time_unix_nanos: 0, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - - let _body = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - reader, - &info, - 5, - "req-rejects-buffered-size-mismatch", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - Some(Bytes::from_static(b"oops")), - false, - false, - true, - "test-bucket", - "cached-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("size-mismatched buffered-body handoff should still return a response body"); - let lookup = adapter.lookup_body(&plan).await; - - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "buffered-body handoff must not read from the fallback reader" - ); - assert!( - matches!(lookup, rustfs_object_data_cache::ObjectDataCacheLookup::Miss), - "size-mismatched buffered body must not be filled into cache" - ); - } - - #[tokio::test] - async fn build_get_object_body_with_cache_hook_served_records_no_second_lookup() { - // ODC-16 (backlog#1121): a hook-served GET must record exactly one - // lookup — the ecstore hook's. The app layer, handed the cache body as - // buffered_body with cache_hook_served=true, must serve it directly - // without a second lookup (which would double the hits and hit_bytes). - let reads = Arc::new(AtomicUsize::new(0)); - let reader = ReadProbeReader { - reads: Arc::clone(&reads), - }; - let info = ObjectInfo { - size: 5, - etag: Some("etag".to_string()), - ..Default::default() - }; - let adapter = - crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, - max_bytes: 8_388_608, - min_free_memory_percent: 0, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("fill-enabled cache adapter should initialize"); - let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest { - bucket: "test-bucket", - object: "hook-served", - version_id: None, - etag: "etag", - size: 5, - data_dir_u128: None, - mod_time_unix_nanos: 0, - body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1, - }); - let hit_body = Bytes::from_static(b"hello"); - assert_eq!( - adapter.cache().fill_body(&plan, hit_body.clone()).await, - rustfs_object_data_cache::ObjectDataCacheFillResult::Inserted - ); - - // Simulate the ecstore hook: it performs exactly one lookup after fresh - // metadata resolution, hits, and hands the body forward as buffered_body. - assert!(matches!( - adapter.lookup_body(&plan).await, - rustfs_object_data_cache::ObjectDataCacheLookup::Hit(_) - )); - let lookups_after_hook = adapter.cache().stats().lookups; - assert_eq!(lookups_after_hook, 1, "the hook performs exactly one lookup"); - - let _body = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - reader, - &info, - 5, - "req-hook-served", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - Some(hit_body), - /* cache_hook_served */ true, - /* cache_hook_probed */ true, - /* cache_fill_allowed */ true, - "test-bucket", - "hook-served", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("hook-served body handoff should succeed"); - - assert_eq!( - adapter.cache().stats().lookups, - lookups_after_hook, - "a hook-served GET must not record a second lookup in the app layer" - ); - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "hook-served body handoff must not read from the fallback reader" - ); - } - - #[tokio::test] - async fn build_get_object_body_with_cache_hook_miss_skips_app_lookup() { - // ODC-16: when the hook probed and missed, its miss is authoritative - // (it ran after fresh metadata resolution), so the app layer must not - // run a second lookup — it only fills from the buffered body. - let reads = Arc::new(AtomicUsize::new(0)); - let reader = ReadProbeReader { - reads: Arc::clone(&reads), - }; - let info = ObjectInfo { - size: 5, - etag: Some("etag".to_string()), - ..Default::default() - }; - let adapter = - crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly, - max_bytes: 8_388_608, - min_free_memory_percent: 0, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("fill-enabled cache adapter should initialize"); - - let lookups_before = adapter.cache().stats().lookups; - let _body = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - reader, - &info, - 5, - "req-hook-missed", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - Some(Bytes::from_static(b"hello")), - /* cache_hook_served */ false, - /* cache_hook_probed */ true, - /* cache_fill_allowed */ true, - "test-bucket", - "hook-missed", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("hook-miss buffered-body handoff should succeed"); - - assert_eq!( - adapter.cache().stats().lookups, - lookups_before, - "a hook-probed miss must not trigger an app-layer lookup" - ); - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "buffered-body handoff must not read from the fallback reader" - ); - } - - #[tokio::test] - async fn build_get_object_body_with_cache_materializes_once_and_hits_later() { - let first_reads = Arc::new(AtomicUsize::new(0)); - let first_reader = DataProbeReader { - reads: Arc::clone(&first_reads), - data: std::io::Cursor::new(b"hello".to_vec()), - }; - let second_reads = Arc::new(AtomicUsize::new(0)); - let second_reader = ReadProbeReader { - reads: Arc::clone(&second_reads), - }; - let info = ObjectInfo { - size: 5, - etag: Some("etag".to_string()), - ..Default::default() - }; - let adapter = - crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 8_388_608, - // Fill must not depend on the live memory reading (host vs container). - min_free_memory_percent: 0, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("materialize-fill cache adapter should initialize"); - - let _first_body = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - first_reader, - &info, - 5, - "req-materialize-first", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - None, - false, - false, - true, - "test-bucket", - "materialized-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("materialize-fill handoff should succeed"); - - // ODC-15: the fill is detached from the response path, so wait for it to - // populate the cache before the follow-up GET to keep the hit deterministic. - wait_for_cache_hit(&adapter, "test-bucket", "materialized-object", "etag", 5).await; - - let _second_body = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - second_reader, - &info, - 5, - "req-materialize-second", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - None, - false, - false, - true, - "test-bucket", - "materialized-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("follow-up cache hit should succeed"); - - assert_eq!( - first_reads.load(AtomicOrdering::Relaxed), - 2, - "materialize-fill path should read the source stream once to data and once for EOF" - ); - assert_eq!( - second_reads.load(AtomicOrdering::Relaxed), - 0, - "cache hit after materialize-fill must not read from the fallback reader" - ); - } - - // ODC-07: a materialize read that yields more than the declared content - // length must be a hard error, not a warn-and-serve, matching the - // direct-memory GET path. The bounded `take` reads one byte past capacity so - // the over-long stream is detected without buffering it unbounded. - #[tokio::test] - async fn build_get_object_body_with_cache_materialize_rejects_length_mismatch() { - let reads = Arc::new(AtomicUsize::new(0)); - // Declared content length is 5, but the stream yields 6 bytes. - let reader = DataProbeReader { - reads: Arc::clone(&reads), - data: std::io::Cursor::new(b"hello!".to_vec()), - }; - let info = ObjectInfo { - size: 5, - etag: Some("etag".to_string()), - ..Default::default() - }; - let adapter = - crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 8_388_608, - min_free_memory_percent: 0, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("materialize-fill cache adapter should initialize"); - - let result = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - reader, - &info, - 5, - "req-materialize-mismatch", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - None, - false, - false, - true, - "test-bucket", - "mismatch-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await; - - assert!( - result.is_err(), - "an over-long materialize read must be a hard error, not a truncated served body" - ); - } - - // #1324: a materialize-fill read that ends short of the declared content - // length (clean EOF at N-1 for a declared N) must hard-fail, matching the - // over-long case above. Reverting to warn-and-serve would return Ok with a - // truncated body. - #[tokio::test] - async fn build_get_object_body_with_cache_materialize_rejects_short_read() { - let reads = Arc::new(AtomicUsize::new(0)); - // Declared content length is 5, but the stream only yields 4 bytes. - let reader = DataProbeReader { - reads: Arc::clone(&reads), - data: std::io::Cursor::new(b"hell".to_vec()), - }; - let info = ObjectInfo { - size: 5, - etag: Some("etag".to_string()), - ..Default::default() - }; - let adapter = - crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 8_388_608, - min_free_memory_percent: 0, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("materialize-fill cache adapter should initialize"); - - let result = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - reader, - &info, - 5, - "req-materialize-short", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - None, - false, - false, - true, - "test-bucket", - "short-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await; - - assert!( - result.is_err(), - "a short materialize read must be a hard error, not a truncated served body" - ); - } - - // #1324: a materialize-fill read that fails after draining K bytes must - // propagate the read error and must NOT fall back to streaming the same - // (partially consumed) reader, which would ship a prefix-misaligned body. - #[tokio::test] - async fn build_get_object_body_with_cache_materialize_rejects_partial_read_error() { - let reader = ErrAfterReader { - data: std::io::Cursor::new(b"hello".to_vec()), - fail_after: 3, - emitted: 0, - }; - let info = ObjectInfo { - size: 5, - etag: Some("etag".to_string()), - ..Default::default() - }; - let adapter = - crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 8_388_608, - min_free_memory_percent: 0, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("materialize-fill cache adapter should initialize"); - - let result = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - reader, - &info, - 5, - "req-materialize-partial", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - None, - false, - false, - true, - "test-bucket", - "partial-read-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await; - - assert!( - result.is_err(), - "a partial-read error during materialization must fail the request, not stream a prefix-misaligned body" - ); - } - - // #1324: the buffered-body (direct-memory / cache-served) source must also - // enforce the exact-length contract. A buffered body shorter than the - // declared content length is a hard error before headers. - #[tokio::test] - async fn build_get_object_body_rejects_short_buffered_body() { - let reads = Arc::new(AtomicUsize::new(0)); - let reader = ReadProbeReader { - reads: Arc::clone(&reads), - }; - let info = ObjectInfo { - size: 5, - ..Default::default() - }; - - let result = DefaultObjectUsecase::build_get_object_body( - reader, - &info, - 5, - "req-short-buffered-object", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - // Declared length 5 but only 4 buffered bytes. - Some(Bytes::from_static(b"hell")), - "test-bucket", - "short-buffered-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await; - - assert!(result.is_err(), "a buffered body shorter than the declared content length must hard-fail"); - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "the mismatch must be caught without touching the fallback reader" - ); - } - - // #1324 compatibility boundary: a legacy/backfilled object whose decoded - // bytes exactly equal its declared content length must still serve cleanly. - // The strict contract keys off actual-vs-declared equality only, so it never - // flips a legitimate exact-length object into a hard failure — it only - // rejects genuine short/over-long/errored reads. - #[tokio::test] - async fn build_get_object_body_serves_exact_length_buffered_body() { - let reads = Arc::new(AtomicUsize::new(0)); - let reader = ReadProbeReader { - reads: Arc::clone(&reads), - }; - let info = ObjectInfo { - size: 5, - ..Default::default() - }; - - let _body = DefaultObjectUsecase::build_get_object_body( - reader, - &info, - 5, - "req-exact-buffered-object", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - Some(Bytes::from_static(b"hello")), - "test-bucket", - "exact-buffered-object", - GetObjectBodyLifecycle::disabled(), - |_| panic!("an exact-length buffered body must not initialize streaming resume state"), - ) - .await - .expect("an exact-length buffered body must serve without error"); - - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "an exact-length buffered body must not read from the fallback reader" - ); - } - - #[tokio::test] - async fn build_get_object_body_with_cache_skips_materialize_when_too_large_for_cache() { - let reads = Arc::new(AtomicUsize::new(0)); - let reader = DataProbeReader { - reads: Arc::clone(&reads), - data: std::io::Cursor::new(b"hello".to_vec()), - }; - let info = ObjectInfo { - size: 5, - etag: Some("etag".to_string()), - ..Default::default() - }; - let adapter = - crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig { - mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled, - max_bytes: 8_388_608, - max_entry_bytes: 4, - // Fill must not depend on the live memory reading (host vs container). - min_free_memory_percent: 0, - ..rustfs_object_data_cache::ObjectDataCacheConfig::default() - }) - .expect("materialize-fill cache adapter should initialize"); - - let _body = DefaultObjectUsecase::build_get_object_body_with_cache( - &adapter, - reader, - &info, - 5, - "req-materialize-too-large", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - None, - false, - false, - true, - "test-bucket", - "too-large-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("too-large cache candidate should use streaming fallback"); - - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "too-large materialize-fill candidate must not pre-read the fallback reader" - ); - } - - #[tokio::test] - async fn build_get_object_body_keeps_small_plain_objects_on_streaming_path_by_default() { - let reads = Arc::new(AtomicUsize::new(0)); - let reader = ReadProbeReader { - reads: Arc::clone(&reads), - }; - let info = ObjectInfo { - size: 4, - ..Default::default() - }; - - let _body = DefaultObjectUsecase::build_get_object_body( - reader, - &info, - 4, - "req-small-plain-object", - None, - 128 * 1024, - false, - 1, - None, - false, - false, - None, - "test-bucket", - "small-plain-object", - GetObjectBodyLifecycle::disabled(), - |_| None, - ) - .await - .expect("build_get_object_body should keep small plain object on streaming path"); - - assert_eq!( - reads.load(AtomicOrdering::Relaxed), - 0, - "default GetObject response construction should not pre-read small plain object data" - ); - } - - #[test] - fn select_stream_buffer_strategy_expands_large_sequential_gets() { - let (buffer_size, strategy) = - DefaultObjectUsecase::select_stream_buffer_strategy(2_i64 * 1024 * 1024 * 1024, 2 * MI_B, true, false); - - assert_eq!(strategy, GetObjectStreamStrategy::LargeSequentialReadahead); - assert_eq!(buffer_size, 4 * MI_B); - } - - #[test] - fn select_stream_buffer_strategy_keeps_ranges_and_small_gets_standard() { - let (range_buffer_size, range_strategy) = - DefaultObjectUsecase::select_stream_buffer_strategy(2_i64 * 1024 * 1024 * 1024, 2 * MI_B, true, true); - assert_eq!(range_strategy, GetObjectStreamStrategy::Standard); - assert_eq!(range_buffer_size, 2 * MI_B); - - let (small_buffer_size, small_strategy) = - DefaultObjectUsecase::select_stream_buffer_strategy(64 * 1024 * 1024, 512 * 1024, true, false); - assert_eq!(small_strategy, GetObjectStreamStrategy::Standard); - assert_eq!(small_buffer_size, 512 * 1024); - } - - #[test] - fn tune_reader_stream_buffer_size_raises_large_standard_streams_only() { - assert_eq!( - tune_reader_stream_buffer_size(128 * 1024, 10 * MI_B as i64, GetObjectStreamStrategy::Standard), - LARGE_BODY_READER_STREAM_BUFFER_FLOOR_BYTES - ); - assert_eq!( - tune_reader_stream_buffer_size(512 * 1024, 10 * MI_B as i64, GetObjectStreamStrategy::Standard), - LARGE_BODY_READER_STREAM_BUFFER_FLOOR_BYTES - ); - assert_eq!( - tune_reader_stream_buffer_size(2 * MI_B, 10 * MI_B as i64, GetObjectStreamStrategy::Standard), - 2 * MI_B - ); - assert_eq!( - tune_reader_stream_buffer_size(128 * 1024, MI_B as i64, GetObjectStreamStrategy::Standard), - MID_BODY_READER_STREAM_BUFFER_FLOOR_BYTES - ); - assert_eq!( - tune_reader_stream_buffer_size(256 * 1024, 2 * MI_B as i64, GetObjectStreamStrategy::Standard), - MID_BODY_READER_STREAM_BUFFER_FLOOR_BYTES - ); - assert_eq!( - tune_reader_stream_buffer_size(128 * 1024, 10 * MI_B as i64, GetObjectStreamStrategy::LargeSequentialReadahead), - 128 * 1024 - ); - } - - #[test] - fn resolve_reader_stream_buffer_size_keeps_selected_default() { - let (buffer_size, source) = resolve_reader_stream_buffer_size(128 * 1024, None); - - assert_eq!(buffer_size, 128 * 1024); - assert_eq!(source, GET_READER_STREAM_BUFFER_SOURCE_SELECTED); - } - - #[test] - fn resolve_reader_stream_buffer_size_applies_positive_override() { - let (buffer_size, source) = resolve_reader_stream_buffer_size(128 * 1024, Some(MI_B)); - - assert_eq!(buffer_size, MI_B); - assert_eq!(source, GET_READER_STREAM_BUFFER_SOURCE_ENV_OVERRIDE); - } - - #[test] - fn resolve_reader_stream_buffer_size_ignores_zero_override() { - let (buffer_size, source) = resolve_reader_stream_buffer_size(128 * 1024, Some(0)); - - assert_eq!(buffer_size, 128 * 1024); - assert_eq!(source, GET_READER_STREAM_BUFFER_SOURCE_SELECTED); - } - - #[test] - fn should_use_zero_copy_rejects_encrypted_requests_with_sse_customer_algorithm() { - let mut headers = HeaderMap::new(); - headers.insert(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM, HeaderValue::from_static("AES256")); - - assert!(!should_use_zero_copy(2 * 1024 * 1024, &headers)); - } - - #[test] - fn should_use_zero_copy_rejects_encrypted_requests_with_kms_key_id() { - let mut headers = HeaderMap::new(); - headers.insert(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID, HeaderValue::from_static("test-kms-key-id")); - - assert!(!should_use_zero_copy(2 * 1024 * 1024, &headers)); - } - - #[test] - fn should_use_zero_copy_rejects_compressible_content_types() { - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json; charset=utf-8")); - - assert!(!should_use_zero_copy(2 * 1024 * 1024, &headers)); - } - - #[test] - fn should_use_small_eager_put_path_allows_up_to_1mb() { - let headers = HeaderMap::new(); - - assert!(should_use_small_eager_put_path(1024, &headers, false, false, false)); - assert!(should_use_small_eager_put_path(1024 * 1024, &headers, false, false, false)); - assert!(!should_use_small_eager_put_path(1024 * 1024 + 1, &headers, false, false, false)); - } - - #[test] - fn should_use_small_eager_put_path_rejects_sse_requests() { - let headers = HeaderMap::new(); - - assert!(!should_use_small_eager_put_path(1024, &headers, true, false, false)); - } - - #[test] - fn should_use_small_eager_put_path_rejects_compressible_objects() { - let headers = HeaderMap::new(); - - assert!(!should_use_small_eager_put_path(1024, &headers, false, true, false)); - } - - #[test] - fn should_use_small_eager_put_path_rejects_extract_requests() { - let headers = HeaderMap::new(); - - assert!(!should_use_small_eager_put_path(1024, &headers, false, false, true)); - } - - #[test] - fn should_use_small_eager_put_path_rejects_large_or_empty_objects() { - let headers = HeaderMap::new(); - - assert!(!should_use_small_eager_put_path(0, &headers, false, false, false)); - assert!(!should_use_small_eager_put_path(1024 * 1024 + 1, &headers, false, false, false)); - } - - #[test] - fn should_use_zero_copy_eager_put_path_allows_large_plain_objects_within_cap() { - let headers = HeaderMap::new(); - - assert!(should_use_zero_copy_eager_put_path(2 * 1024 * 1024, &headers, false, false, false)); - assert!(should_use_zero_copy_eager_put_path(16 * 1024 * 1024, &headers, false, false, false)); - assert!(!should_use_zero_copy_eager_put_path(16 * 1024 * 1024 + 1, &headers, false, false, false)); - assert_eq!( - zero_copy_eager_put_path_status(16 * 1024 * 1024, &headers, false, false, false), - PUT_EAGER_STATUS_ELIGIBLE - ); - assert_eq!( - zero_copy_eager_put_path_status(16 * 1024 * 1024 + 1, &headers, false, false, false), - PUT_EAGER_STATUS_ABOVE_EAGER_MAX - ); - } - - #[test] - fn zero_copy_eager_put_path_status_honors_configured_cap() { - let headers = HeaderMap::new(); - let max_size = 64 * 1024 * 1024; - - assert_eq!( - zero_copy_eager_put_path_status_with_max_size(33 * 1024 * 1024, &headers, false, false, false, max_size), - PUT_EAGER_STATUS_ELIGIBLE - ); - assert_eq!( - zero_copy_eager_put_path_status_with_max_size(65 * 1024 * 1024, &headers, false, false, false, max_size), - PUT_EAGER_STATUS_ABOVE_EAGER_MAX - ); - } - - #[test] - fn should_use_zero_copy_eager_put_path_rejects_compression_sse_and_extract() { - let headers = HeaderMap::new(); - - assert!(!should_use_zero_copy_eager_put_path(2 * 1024 * 1024, &headers, true, false, false)); - assert!(!should_use_zero_copy_eager_put_path(2 * 1024 * 1024, &headers, false, true, false)); - assert!(!should_use_zero_copy_eager_put_path(2 * 1024 * 1024, &headers, false, false, true)); - assert_eq!( - zero_copy_eager_put_path_status(2 * 1024 * 1024, &headers, true, false, false), - PUT_EAGER_STATUS_ENCRYPTED - ); - assert_eq!( - zero_copy_eager_put_path_status(2 * 1024 * 1024, &headers, false, true, false), - PUT_EAGER_STATUS_COMPRESSED - ); - assert_eq!( - zero_copy_eager_put_path_status(2 * 1024 * 1024, &headers, false, false, true), - PUT_EAGER_STATUS_EXTRACT - ); - } - - #[test] - fn s3s_body_error_to_io_preserves_upload_stream_error_source() { - let error = s3s_body_error_to_io(Box::new(s3s::UploadStreamError::Sha256Mismatch)); - - assert!(matches!( - error - .get_ref() - .and_then(|source| source.downcast_ref::()), - Some(s3s::UploadStreamError::Sha256Mismatch) - )); - } - - #[tokio::test] - async fn read_small_put_body_maps_upload_stream_sha256_mismatch_to_bad_digest() { - let body = StreamReader::new(futures::stream::iter(vec![Err::(s3s_body_error_to_io(Box::new( - s3s::UploadStreamError::Sha256Mismatch, - )))])); - - let error = read_small_put_body_exact_direct(body, 1) - .await - .expect_err("SHA256 mismatch should reject the small PUT body"); - - assert_eq!(error.code(), &S3ErrorCode::BadDigest); - } - - #[tokio::test] - async fn read_zero_copy_put_body_maps_upload_stream_sha256_mismatch_to_bad_digest() { - let body = futures::stream::iter(vec![Err::(s3s::UploadStreamError::Sha256Mismatch)]); - - let error = match read_zero_copy_put_body_exact(body, 1).await { - Ok(_) => panic!("SHA256 mismatch should reject the zero-copy PUT body"), - Err(error) => error, - }; - - assert_eq!(error.code(), &S3ErrorCode::BadDigest); - } - - struct FragmentedBody { - data: std::io::Cursor>, - } - - impl AsyncRead for FragmentedBody { - fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - let position = usize::try_from(self.data.position()).expect("test cursor position should fit usize"); - let remaining = &self.data.get_ref()[position..]; - let copied = remaining.len().min(buf.remaining()).min(2); - buf.put_slice(&remaining[..copied]); - self.data - .set_position(u64::try_from(position + copied).expect("test cursor position should fit u64")); - Poll::Ready(Ok(())) - } - } - - struct InitializedLengthProbe { - data: std::io::Cursor>, - initialized_lengths: Arc>>, - } - - impl AsyncRead for InitializedLengthProbe { - fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - self.initialized_lengths - .lock() - .expect("initialized-length probe lock should not poison") - .push(buf.initialized().len()); - let position = usize::try_from(self.data.position()).expect("test cursor position should fit usize"); - let remaining = &self.data.get_ref()[position..]; - let copied = remaining.len().min(buf.remaining()); - buf.put_slice(&remaining[..copied]); - self.data - .set_position(u64::try_from(position + copied).expect("test cursor position should fit u64")); - Poll::Ready(Ok(())) - } - } - - #[tokio::test] - async fn read_small_put_body_exact_pooled_reads_exact_bytes_without_prefill() { - let pool = get_concurrency_manager().bytes_pool(); - let initialized_lengths = Arc::new(Mutex::new(Vec::new())); - let body = InitializedLengthProbe { - data: std::io::Cursor::new(b"hello".to_vec()), - initialized_lengths: Arc::clone(&initialized_lengths), - }; - - let buffer = read_small_put_body_exact_pooled(body, 5, pool.as_ref()) - .await - .expect("pooled exact read should succeed"); - - assert_eq!(&buffer[..5], b"hello"); - assert_eq!(buffer.len(), 5); - assert_eq!( - initialized_lengths - .lock() - .expect("initialized-length probe lock should not poison")[0], - 0, - "the first pooled body read must use uninitialized spare capacity rather than a zero-filled slice" - ); - } - - #[tokio::test] - async fn read_small_put_body_exact_pooled_rejects_short_body() { - let pool = get_concurrency_manager().bytes_pool(); - let body = std::io::Cursor::new(b"hell".to_vec()); - - let err = match read_small_put_body_exact_pooled(body, 5, pool.as_ref()).await { - Ok(_) => panic!("short pooled body should fail"), - Err(err) => err, - }; - - assert_eq!(err.code(), &S3ErrorCode::IncompleteBody); - } - - #[tokio::test] - async fn read_small_put_body_exact_pooled_rejects_extra_body() { - let pool = get_concurrency_manager().bytes_pool(); - let body = std::io::Cursor::new(b"hello!".to_vec()); - - let err = match read_small_put_body_exact_pooled(body, 5, pool.as_ref()).await { - Ok(_) => panic!("extra pooled body should fail"), - Err(err) => err, - }; - - assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); - } - - #[tokio::test] - async fn read_small_put_body_exact_direct_reads_exact_bytes_without_prefill() { - let body = std::io::Cursor::new(b"hello".to_vec()); - let reader = read_small_put_body_exact_direct(body, 5) - .await - .expect("direct exact read should succeed"); - - assert_eq!(reader.get_ref().as_slice(), b"hello"); - assert_eq!(reader.get_ref().len(), 5); - } - - #[tokio::test] - async fn read_small_put_body_exact_direct_rejects_short_and_extra_bodies() { - let short = read_small_put_body_exact_direct(std::io::Cursor::new(b"hell".to_vec()), 5) - .await - .expect_err("short direct body should fail"); - assert_eq!(short.code(), &S3ErrorCode::IncompleteBody); - - let extra = read_small_put_body_exact_direct(std::io::Cursor::new(b"hello!".to_vec()), 5) - .await - .expect_err("extra direct body should fail"); - assert_eq!(extra.code(), &S3ErrorCode::UnexpectedContent); - } - - #[tokio::test] - async fn read_small_put_body_exact_direct_handles_empty_body_boundary() { - let empty = read_small_put_body_exact_direct(std::io::Cursor::new(Vec::::new()), 0) - .await - .expect("empty direct body should succeed"); - assert!(empty.get_ref().is_empty()); - - let extra = read_small_put_body_exact_direct(std::io::Cursor::new(vec![1u8]), 0) - .await - .expect_err("non-empty body declared as empty should fail"); - assert_eq!(extra.code(), &S3ErrorCode::UnexpectedContent); - } - - #[tokio::test] - async fn read_small_put_body_exact_direct_rejects_error_after_partial_read() { - struct PartialThenError { - delivered_prefix: bool, - } - - impl AsyncRead for PartialThenError { - fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - if self.delivered_prefix { - return Poll::Ready(Err(std::io::Error::other("body read failed"))); - } - - self.delivered_prefix = true; - buf.put_slice(b"he"); - Poll::Ready(Ok(())) - } - } - - let err = read_small_put_body_exact_direct(PartialThenError { delivered_prefix: false }, 5) - .await - .expect_err("a partial body followed by an I/O error must fail"); - - assert_eq!(err.code(), &S3ErrorCode::InternalError); - } - - #[tokio::test] - async fn read_small_put_body_exact_direct_accepts_fragmented_body() { - let reader = read_small_put_body_exact_direct( - FragmentedBody { - data: std::io::Cursor::new(b"hello".to_vec()), - }, - 5, - ) - .await - .expect("a fragmented exact-length body should succeed"); - - assert_eq!(reader.get_ref().as_slice(), b"hello"); - } - - #[tokio::test] - async fn read_small_put_body_exact_direct_rejects_fragmented_extra_body() { - let err = read_small_put_body_exact_direct( - FragmentedBody { - data: std::io::Cursor::new(b"hello!".to_vec()), - }, - 5, - ) - .await - .expect_err("a fragmented body longer than declared must fail"); - - assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); - } - - #[tokio::test] - async fn read_small_put_body_exact_direct_reads_into_uninitialized_spare_capacity() { - let initialized_lengths = Arc::new(Mutex::new(Vec::new())); - let body = InitializedLengthProbe { - data: std::io::Cursor::new(b"hello".to_vec()), - initialized_lengths: Arc::clone(&initialized_lengths), - }; - - let reader = read_small_put_body_exact_direct(body, 5) - .await - .expect("direct exact read should succeed"); - - assert_eq!(reader.get_ref().as_slice(), b"hello"); - assert_eq!( - initialized_lengths - .lock() - .expect("initialized-length probe lock should not poison")[0], - 0, - "the first body read must use uninitialized spare capacity rather than a zero-filled slice" - ); - } - - #[tokio::test] - async fn read_zero_copy_put_body_exact_reads_chunked_body() { - use tokio::io::AsyncReadExt; - - let body = futures::stream::iter(vec![ - Ok::(Bytes::from_static(b"hello ")), - Ok::(Bytes::from_static(b"world")), - ]); - - let mut reader = read_zero_copy_put_body_exact(body, 11) - .await - .expect("zero-copy eager body read should succeed"); - let mut out = Vec::new(); - reader - .read_to_end(&mut out) - .await - .expect("chunked bytes reader should be readable"); - - assert_eq!(out, b"hello world"); - } - - #[tokio::test] - async fn read_zero_copy_put_body_exact_rejects_extra_bytes() { - let body = futures::stream::iter(vec![ - Ok::(Bytes::from_static(b"hello")), - Ok::(Bytes::from_static(b"!")), - ]); - - let err = match read_zero_copy_put_body_exact(body, 5).await { - Ok(_) => panic!("extra bytes should fail"), - Err(err) => err, - }; - - assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); - } - - #[tokio::test] - async fn get_object_reader_stream_tracks_remaining_length() { - let mut stream = GetObjectReaderStream::new( - std::io::Cursor::new(b"hello".to_vec()), - 2, - 5, - GetObjectStreamStrategy::Standard.as_str(), - GET_READER_STREAM_BUFFER_SOURCE_SELECTED, - ); - - assert_eq!(stream.remaining_length().exact(), Some(5)); - - let first = stream - .next() - .await - .expect("reader stream should emit first chunk") - .expect("first chunk should read"); - - assert_eq!(first.as_ref(), b"he"); - assert_eq!(stream.remaining_length().exact(), Some(3)); - } - - #[tokio::test] - async fn get_object_reader_stream_truncates_to_expected_length() { - let stream = GetObjectReaderStream::new( - std::io::Cursor::new(b"hello!".to_vec()), - 64, - 5, - GetObjectStreamStrategy::Standard.as_str(), - GET_READER_STREAM_BUFFER_SOURCE_SELECTED, - ); - - let chunks = stream - .collect::>() - .await - .into_iter() - .collect::, _>>() - .expect("reader stream should read"); - let body = chunks.into_iter().fold(Vec::new(), |mut acc, chunk| { - acc.extend_from_slice(&chunk); - acc - }); - - assert_eq!(body, b"hello"); - } - - #[tokio::test] - async fn get_object_reader_stream_bounds_read_buffer_to_remaining() { - struct RecordingReader { - data: &'static [u8], - pos: usize, - observed_remaining: Arc>>, - } - - impl AsyncRead for RecordingReader { - fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - let requested = buf.remaining(); - self.observed_remaining - .lock() - .expect("observed buffer sizes should not poison") - .push(requested); - let available = self.data.len().saturating_sub(self.pos); - let to_copy = requested.min(available); - if to_copy > 0 { - let end = self.pos + to_copy; - buf.put_slice(&self.data[self.pos..end]); - self.pos = end; - } - Poll::Ready(Ok(())) - } - } - - let observed_remaining = Arc::new(Mutex::new(Vec::new())); - let stream = GetObjectReaderStream::new( - RecordingReader { - data: b"hello", - pos: 0, - observed_remaining: Arc::clone(&observed_remaining), - }, - 64, - 5, - GetObjectStreamStrategy::Standard.as_str(), - GET_READER_STREAM_BUFFER_SOURCE_SELECTED, - ); - - let chunks = stream - .collect::>() - .await - .into_iter() - .collect::, _>>() - .expect("reader stream should read exact payload"); - assert_eq!(chunks, vec![Bytes::from_static(b"hello")]); - assert_eq!( - *observed_remaining.lock().expect("observed buffer sizes should not poison"), - vec![5], - "stream should not ask the reader for more bytes than the response has left" - ); - } - - #[tokio::test] - async fn get_object_reader_stream_bounds_multi_chunk_final_read() { - let stream = GetObjectReaderStream::new( - std::io::Cursor::new(vec![b'a'; 66]), - 64, - 65, - GetObjectStreamStrategy::Standard.as_str(), - GET_READER_STREAM_BUFFER_SOURCE_SELECTED, - ); - - let chunks = stream - .collect::>() - .await - .into_iter() - .collect::, _>>() - .expect("reader stream should ignore bytes past declared length"); - let chunk_lengths = chunks.iter().map(Bytes::len).collect::>(); - let body = chunks.into_iter().fold(Vec::new(), |mut acc, chunk| { - acc.extend_from_slice(&chunk); - acc - }); - - assert_eq!(chunk_lengths, vec![64, 1]); - assert_eq!(body, vec![b'a'; 65]); - } - - // Serial with the capture test below: both drive the same short-EOF log - // callsite, and `tracing` caches callsite interest process-wide. Running - // this one concurrently on a thread with no subscriber re-caches that - // callsite as "never interested" and blinds the capture. - #[tokio::test] - #[serial_test::serial] - async fn get_object_reader_stream_errors_on_short_eof() { - let stream = GetObjectReaderStream::new( - std::io::Cursor::new(b"he".to_vec()), - 64, - 5, - GetObjectStreamStrategy::Standard.as_str(), - GET_READER_STREAM_BUFFER_SOURCE_SELECTED, - ); - - let err = stream - .collect::>() - .await - .into_iter() - .collect::, _>>() - .expect_err("short reader should fail the streaming body"); - - assert_eq!( - err.downcast_ref::().map(std::io::Error::kind), - Some(std::io::ErrorKind::UnexpectedEof) - ); - } - - /// Collects the structured fields of every event emitted while installed, - /// so a test can assert what an operator would actually read in the log - /// rather than only that an error value was returned. - type CapturedFieldMap = std::collections::HashMap; - type CapturedEventLog = Arc>>; - - struct CapturedEvents(CapturedEventLog); - - struct CapturedFields(CapturedFieldMap); - - impl tracing::field::Visit for CapturedFields { - fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { - self.0.insert(field.name().to_string(), format!("{value:?}")); - } - - fn record_str(&mut self, field: &tracing::field::Field, value: &str) { - self.0.insert(field.name().to_string(), value.to_string()); - } - } - - impl tracing_subscriber::Layer for CapturedEvents { - fn on_event(&self, event: &tracing::Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) { - let mut fields = CapturedFields(CapturedFieldMap::new()); - event.record(&mut fields); - self.0.lock().expect("captured events should not poison").push(fields.0); - } - } - - fn capture_events() -> (CapturedEventLog, tracing::subscriber::DefaultGuard) { - use tracing_subscriber::{Registry, prelude::*}; - - let captured = Arc::new(Mutex::new(Vec::new())); - let subscriber = Registry::default().with(CapturedEvents(Arc::clone(&captured))); - let guard = tracing::subscriber::set_default(subscriber); - // `tracing` caches per-callsite interest process-wide, so a subscriber - // installed by a test running in parallel can leave the log sites below - // cached as "never interested" and this capture would silently see - // nothing. Force the callsites to re-ask the subscriber we just - // installed. - tracing::callsite::rebuild_interest_cache(); - (captured, guard) - } - - fn find_stream_body_event(captured: &CapturedEventLog, state: &str) -> CapturedFieldMap { - let events = captured.lock().expect("captured events should not poison"); - events - .iter() - .find(|fields| fields.get("state").is_some_and(|value| value == state)) - .unwrap_or_else(|| { - panic!( - "a `{state}` streaming body failure must be logged, not only counted in a metric. \ - Captured {} event(s): {:?}", - events.len(), - events - ) - }) - .clone() - } - - /// rustfs#4784: a GET body that ends short of its committed Content-Length - /// is the fault that breaks every downstream copier (replication, site - /// replication, `rclone sync`), yet this layer only fed a metric counter — - /// its log line was compiled out unless the `tracing-chunk-debug` feature - /// was on, so operators saw nothing on the source side. - #[tokio::test] - #[serial_test::serial] - async fn get_object_reader_stream_short_eof_names_the_object() { - let (captured, _guard) = capture_events(); - - let stream = GetObjectReaderStream::new( - std::io::Cursor::new(b"he".to_vec()), - 64, - 5, - GetObjectStreamStrategy::Standard.as_str(), - GET_READER_STREAM_BUFFER_SOURCE_SELECTED, - ) - .with_diagnostics("restic-paperless", "index/41b5a4c2344edb90", "req-reader-stream-short-eof"); - - stream - .collect::>() - .await - .into_iter() - .collect::, _>>() - .expect_err("short reader should fail the streaming body"); - - let event = find_stream_body_event(&captured, "reader_stream_short_eof"); - assert_eq!(event.get("bucket").map(String::as_str), Some("restic-paperless")); - assert_eq!(event.get("object").map(String::as_str), Some("index/41b5a4c2344edb90")); - assert_eq!(event.get("request_id").map(String::as_str), Some("req-reader-stream-short-eof")); - assert_eq!(event.get("expected").map(String::as_str), Some("5")); - assert_eq!(event.get("emitted").map(String::as_str), Some("2")); - assert_eq!(event.get("remaining").map(String::as_str), Some("3")); - } - - /// The inner reader already logged mid-stream failures, but only under a - /// request_id — which cannot be resolved back to an object once the request - /// is gone. Without the identity the report in #4784 was unactionable. - #[tokio::test] - #[serial_test::serial] - async fn get_object_streaming_reader_short_eof_names_the_object() { - use tokio::io::AsyncReadExt; - - let (captured, _guard) = capture_events(); - - let mut reader = GetObjectStreamingReader::new( - std::io::Cursor::new(b"short".to_vec()), - "restic-paperless", - "index/41b5a4c2344edb90", - "req-streaming-short-eof", - None, - 10, - Duration::ZERO, - GetObjectBodyLifecycle::tracked(GetObjectGuard::new()), - None, - ); - - let mut out = Vec::new(); - reader - .read_to_end(&mut out) - .await - .expect_err("short body under a larger Content-Length must fail the stream"); - - let event = find_stream_body_event(&captured, "short_eof"); - assert_eq!(event.get("bucket").map(String::as_str), Some("restic-paperless")); - assert_eq!(event.get("object").map(String::as_str), Some("index/41b5a4c2344edb90")); - assert_eq!(event.get("request_id").map(String::as_str), Some("req-streaming-short-eof")); - } - - #[test] - fn get_object_stream_failure_labels_are_low_cardinality() { - assert_eq!(get_object_stream_failure_reason("short_eof"), GET_STREAMING_BODY_FAILURE_REASON_SHORT_EOF); - assert_eq!( - get_object_stream_failure_reason("timeout"), - GET_STREAMING_BODY_FAILURE_REASON_READER_ERROR - ); - assert_eq!( - get_object_stream_size_bucket(4 * 1024 * 1024), - rustfs_io_metrics::GET_OBJECT_SIZE_BUCKET_GT_1_MIB - ); - } - - #[tokio::test] - async fn disk_read_permit_reader_releases_permit_at_eof() { - use tokio::io::AsyncReadExt; - - let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); - let permit = semaphore.clone().acquire_owned().await.expect("acquire permit"); - assert_eq!(semaphore.available_permits(), 0); - - let mut reader = DiskReadPermitReader::new(std::io::Cursor::new(b"hello".to_vec()), permit.into()); - let mut body = Vec::new(); - reader.read_to_end(&mut body).await.expect("read body"); - assert_eq!(body, b"hello"); - - // The reader is still alive (client hasn't dropped the body), but EOF - // was observed, so the permit must already be back in the semaphore. - assert_eq!(semaphore.available_permits(), 1); - drop(reader); - assert_eq!(semaphore.available_permits(), 1); - } - - #[tokio::test] - async fn pooled_buffer_reader_keeps_buffer_alive_until_consumed() { - use tokio::io::AsyncReadExt; - - let pool = get_concurrency_manager().bytes_pool(); - let body = std::io::Cursor::new(b"hello".to_vec()); - let buffer = read_small_put_body_exact_pooled(body, 5, pool.as_ref()) - .await - .expect("pooled exact read should succeed"); - let mut reader = PooledBufferReader::new(buffer, 5); - let mut out = Vec::new(); - - reader.read_to_end(&mut out).await.expect("pooled reader should be readable"); - - assert_eq!(out, b"hello"); - } - - #[test] - fn should_use_zero_copy_allows_large_unencrypted_binary_objects() { - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/octet-stream")); - - assert!(should_use_zero_copy(2 * 1024 * 1024, &headers)); - } - - #[test] - fn resolve_put_object_extract_options_defaults_when_headers_missing() { - let headers = HeaderMap::new(); - let options = resolve_put_object_extract_options(&headers).unwrap(); - assert_eq!( - options, - PutObjectExtractOptions { - prefix: None, - ignore_dirs: false, - ignore_errors: false - } - ); - } - - #[test] - fn resolve_put_object_extract_options_accepts_internal_headers() { - let mut headers = HeaderMap::new(); - headers.insert(AMZ_SNOWBALL_PREFIX_INTERNAL, HeaderValue::from_static("/internal/prefix/")); - headers.insert(AMZ_SNOWBALL_IGNORE_DIRS_INTERNAL, HeaderValue::from_static("true")); - headers.insert(AMZ_SNOWBALL_IGNORE_ERRORS_INTERNAL, HeaderValue::from_static("TRUE")); - - let options = resolve_put_object_extract_options(&headers).unwrap(); - assert_eq!(options.prefix.as_deref(), Some("internal/prefix")); - assert!(options.ignore_dirs); - assert!(options.ignore_errors); - } - - #[test] - fn resolve_put_object_extract_options_accepts_standard_headers() { - let mut headers = HeaderMap::new(); - headers.insert(AMZ_SNOWBALL_PREFIX, HeaderValue::from_static(" /standard/prefix/ ")); - headers.insert(AMZ_SNOWBALL_IGNORE_DIRS, HeaderValue::from_static(" true ")); - headers.insert(AMZ_SNOWBALL_IGNORE_ERRORS, HeaderValue::from_static("TRUE")); - - let options = resolve_put_object_extract_options(&headers).unwrap(); - assert_eq!(options.prefix.as_deref(), Some("standard/prefix")); - assert!(options.ignore_dirs); - assert!(options.ignore_errors); - } - - #[test] - fn resolve_put_object_extract_options_accepts_suffix_compatible_headers() { - let mut headers = HeaderMap::new(); - headers.insert( - HeaderName::from_static("x-amz-meta-acme-snowball-prefix"), - HeaderValue::from_static(" /partner/import "), - ); - headers.insert( - HeaderName::from_static("x-amz-meta-acme-snowball-ignore-dirs"), - HeaderValue::from_static(" true "), - ); - headers.insert( - HeaderName::from_static("x-amz-meta-acme-snowball-ignore-errors"), - HeaderValue::from_static("TRUE"), - ); - - let options = resolve_put_object_extract_options(&headers).unwrap(); - assert_eq!(options.prefix.as_deref(), Some("partner/import")); - assert!(options.ignore_dirs); - assert!(options.ignore_errors); - } - - #[test] - fn resolve_put_object_extract_options_prefers_exact_headers_over_suffix_fallback() { - let mut headers = HeaderMap::new(); - headers.insert("x-amz-meta-acme-snowball-prefix", HeaderValue::from_static("/fallback/prefix/")); - headers.insert(AMZ_RUSTFS_SNOWBALL_PREFIX, HeaderValue::from_static("/internal/prefix/")); - headers.insert(AMZ_SNOWBALL_PREFIX, HeaderValue::from_static("/standard/prefix/")); - headers.insert(AMZ_MINIO_SNOWBALL_PREFIX, HeaderValue::from_static("/minio/prefix/")); - - let options = resolve_put_object_extract_options(&headers).unwrap(); - assert_eq!(options.prefix.as_deref(), Some("minio/prefix")); - } - - #[test] - fn resolve_put_object_extract_options_exact_flags_override_suffix_fallback() { - let mut headers = HeaderMap::new(); - headers.insert(AMZ_SNOWBALL_IGNORE_DIRS, HeaderValue::from_static("false")); - headers.insert("x-amz-meta-acme-snowball-ignore-dirs", HeaderValue::from_static("true")); - headers.insert(AMZ_RUSTFS_SNOWBALL_IGNORE_ERRORS, HeaderValue::from_static("false")); - headers.insert("x-amz-meta-acme-snowball-ignore-errors", HeaderValue::from_static("true")); - - let options = resolve_put_object_extract_options(&headers).unwrap(); - assert!(!options.ignore_dirs); - assert!(!options.ignore_errors); - } - - #[test] - fn resolve_put_object_extract_options_rejects_unsafe_prefix_header() { - let mut headers = HeaderMap::new(); - headers.insert(AMZ_SNOWBALL_PREFIX, HeaderValue::from_static("../victim-bucket")); - - assert!(resolve_put_object_extract_options(&headers).is_err()); - } - - #[test] - fn validate_put_object_extract_entry_count_rejects_limit_overflow() { - let limits = ArchiveLimits { - max_entries: 1, - ..ArchiveLimits::default() - }; - - let err = validate_put_object_extract_entry_count(2, limits).unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - } - - #[test] - fn validate_put_object_extract_entry_size_rejects_oversized_entry() { - let limits = ArchiveLimits { - max_entry_size: 8, - ..ArchiveLimits::default() - }; - - let err = validate_put_object_extract_entry_size("payload.bin", 9, limits).unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - } - - #[test] - fn validate_put_object_extract_total_size_rejects_cumulative_overflow() { - let limits = ArchiveLimits { - max_total_unpacked_size: 16, - ..ArchiveLimits::default() - }; - - let err = validate_put_object_extract_total_size(17, limits).unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - } - - #[test] - fn validate_put_object_extract_entry_path_rejects_overlong_path() { - let limits = ArchiveLimits { - max_path_length: 8, - ..ArchiveLimits::default() - }; - - let err = validate_put_object_extract_entry_path("toolong-path", limits).unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - } - - #[tokio::test] - async fn execute_put_object_rejects_post_object_sse_kms_from_input() { - let input = PutObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .server_side_encryption(Some(ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS))) - .build() - .unwrap(); - - let mut req = build_request(input, Method::POST); - req.extensions.insert(PostObjectRequestMarker); - - let usecase = DefaultObjectUsecase::without_context(); - let fs = FS::new(); - - let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::NotImplemented); - } - - #[tokio::test] - async fn execute_put_object_rejects_extract_sse_kms() { - let input = PutObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("archive.tar".to_string()) - .server_side_encryption(Some(ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS))) - .build() - .unwrap(); - - let mut req = build_request(input, Method::PUT); - req.headers.insert(AMZ_SNOWBALL_EXTRACT, HeaderValue::from_static("true")); - - let usecase = DefaultObjectUsecase::without_context(); - let fs = FS::new(); - - let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::NotImplemented); - } - - #[tokio::test] - async fn execute_put_object_extract_rejects_invalid_storage_class() { - let input = PutObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("archive.tar".to_string()) - .storage_class(Some(StorageClass::from_static("INVALID"))) - .build() - .unwrap(); - - let mut req = build_request(input, Method::PUT); - req.headers.insert(AMZ_SNOWBALL_EXTRACT, HeaderValue::from_static("true")); - - let usecase = DefaultObjectUsecase::without_context(); - let fs = FS::new(); - - let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidStorageClass); - } - - #[tokio::test] - async fn execute_put_object_rejects_post_object_sse_kms_from_headers() { - let input = PutObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .build() - .unwrap(); - - let mut req = build_request(input, Method::POST); - req.extensions.insert(PostObjectRequestMarker); - req.headers - .insert(AMZ_SERVER_SIDE_ENCRYPTION, HeaderValue::from_static("aws:kms")); - - let usecase = DefaultObjectUsecase::without_context(); - let fs = FS::new(); - - let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::NotImplemented); - } - - #[tokio::test] - async fn execute_put_object_rejects_post_object_sse_kms_key_id_header() { - let input = PutObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .build() - .unwrap(); - - let mut req = build_request(input, Method::POST); - req.extensions.insert(PostObjectRequestMarker); - req.headers - .insert(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID, HeaderValue::from_static("test-kms-key-id")); - - let usecase = DefaultObjectUsecase::without_context(); - let fs = FS::new(); - - let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::NotImplemented); - } - - #[tokio::test] - async fn execute_put_object_rejects_invalid_storage_class() { - let input = PutObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .storage_class(Some(StorageClass::from_static("INVALID-STORAGE-CLASS"))) - .build() - .unwrap(); - - let req = build_request(input, Method::PUT); - let usecase = DefaultObjectUsecase::without_context(); - let fs = FS::new(); - - let err = Box::pin(usecase.execute_put_object(&fs, req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidStorageClass); - } - - #[test] - fn response_storage_class_reports_effective_layout_and_preserves_transition_tier() { - let metadata = HashMap::new(); - let standard_info = ObjectInfo { - storage_class: Some(storageclass::STANDARD.to_string()), - user_defined: Arc::new(metadata.clone()), - ..Default::default() - }; - assert!(response_storage_class(&standard_info, &metadata).is_none()); - - let mut metadata = HashMap::new(); - metadata.insert(AMZ_STORAGE_CLASS.to_string(), storageclass::STANDARD_IA.to_string()); - let label_only_info = ObjectInfo { - storage_class: Some(storageclass::STANDARD_IA.to_string()), - user_defined: Arc::new(metadata.clone()), - ..Default::default() - }; - assert!( - response_storage_class(&label_only_info, &metadata).is_none(), - "historical STANDARD_IA labels must report the effective implicit STANDARD layout" - ); - - let rrs_info = ObjectInfo { - storage_class: Some(storageclass::RRS.to_string()), - ..Default::default() - }; - assert_eq!( - response_storage_class(&rrs_info, &HashMap::new()) - .as_ref() - .map(StorageClass::as_str), - Some(storageclass::RRS) - ); - - let mut transitioned_info = label_only_info; - transitioned_info.transitioned_object.tier = "WARM-TIER".to_string(); - assert!( - response_storage_class(&transitioned_info, &metadata).is_none(), - "a tier name without a completed transition must not override the effective local class" - ); - transitioned_info.transitioned_object.status = rustfs_filemeta::TRANSITION_COMPLETE.to_string(); - assert_eq!( - response_storage_class(&transitioned_info, &metadata) - .as_ref() - .map(StorageClass::as_str), - Some("WARM-TIER") - ); - - let mut metadata = HashMap::new(); - metadata.insert(AMZ_STORAGE_CLASS.to_string(), storageclass::STANDARD.to_string()); - let standard_metadata_info = ObjectInfo { - storage_class: None, - user_defined: Arc::new(metadata.clone()), - ..Default::default() - }; - assert!( - response_storage_class(&standard_metadata_info, &metadata).is_none(), - "STANDARD must be omitted even when it only arrives via metadata fallback" - ); - } - - #[test] - fn response_storage_class_for_object_attributes_defaults_to_standard_when_requested() { - let metadata = HashMap::new(); - let info = ObjectInfo { - storage_class: None, - user_defined: Arc::new(metadata.clone()), - ..Default::default() - }; - - assert_eq!( - response_storage_class_for_object_attributes(&info, &metadata, true) - .as_ref() - .map(StorageClass::as_str), - Some(storageclass::STANDARD) - ); - - let legacy_info = ObjectInfo { - storage_class: Some(storageclass::STANDARD_IA.to_string()), - ..Default::default() - }; - assert_eq!( - response_storage_class_for_object_attributes(&legacy_info, &HashMap::new(), true) - .as_ref() - .map(StorageClass::as_str), - Some(storageclass::STANDARD) - ); - } - - #[test] - fn response_storage_class_for_object_attributes_skips_value_when_not_requested() { - let metadata = HashMap::new(); - let info = ObjectInfo { - storage_class: Some(storageclass::STANDARD_IA.to_string()), - user_defined: Arc::new(metadata.clone()), - ..Default::default() - }; - - assert!( - response_storage_class_for_object_attributes(&info, &metadata, false).is_none(), - "StorageClass must only be returned when explicitly requested" - ); - } - - #[tokio::test] - async fn build_get_object_output_context_returns_standard_headers() { - let mut metadata = HashMap::new(); - metadata.insert("cache-control".to_string(), "public, max-age=259200".to_string()); - metadata.insert("content-disposition".to_string(), "attachment; filename=\"demo.png\"".to_string()); - - let info = ObjectInfo { - bucket: "test-bucket".to_string(), - name: "path/raw".to_string(), - user_defined: Arc::new(metadata), - ..Default::default() - }; - - let input = GetObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("path/raw".to_string()) - .build() - .unwrap(); - let req = build_request(input, Method::GET); - let usecase = DefaultObjectUsecase::without_context(); - let queue_status = concurrency::IoQueueStatus::default(); - - let context = usecase - .build_get_object_output_context( - &req, - get_concurrency_manager(), - "test-bucket", - "path/raw", - info.clone(), - Some(info), - wrap_reader(tokio::io::empty()), - Some(Bytes::new()), - false, - false, - true, - None, - None, - None, - 0, - None, - "req-output-content-disposition", - None, - None, - None, - None, - false, - Duration::ZERO, - 0.0, - &queue_status, - 1, - None, - false, - GetObjectBodyLifecycle::disabled(), - |_| panic!("a buffered output must not initialize streaming resume state"), - ) - .await - .expect("get object output context"); - - assert_eq!(context.output.cache_control.as_deref(), Some("public, max-age=259200")); - assert_eq!(context.output.content_disposition.as_deref(), Some("attachment; filename=\"demo.png\"")); - assert!( - !context - .output - .metadata - .as_ref() - .is_some_and(|metadata| metadata.contains_key("cache-control")) - ); - assert!( - !context - .output - .metadata - .as_ref() - .is_some_and(|metadata| metadata.contains_key("content-disposition")) - ); - } - - #[tokio::test] - async fn execute_get_object_rejects_zero_part_number() { - let input = GetObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .part_number(Some(0)) - .build() - .unwrap(); - - let req = build_request(input, Method::GET); - let usecase = DefaultObjectUsecase::without_context(); - - let err = Box::pin(usecase.execute_get_object(req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - } - - #[test] - fn parse_get_object_part_number_rejects_above_s3_max() { - let err = parse_part_number_i32_to_usize(Some(10001), "GET").expect_err("partNumber above S3 max must fail"); - - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - assert_eq!(err.message(), Some("GET: partNumber must be between 1 and 10000")); - } - - #[test] - fn validate_get_object_part_number_rejects_missing_part() { - let info = ObjectInfo { - parts: Arc::new(vec![rustfs_filemeta::ObjectPartInfo { - number: 1, - ..Default::default() - }]), - ..Default::default() - }; - - let err = - DefaultObjectUsecase::validate_get_object_part_number(Some(2), &info).expect_err("missing requested part must fail"); - - assert_eq!(err.code(), &S3ErrorCode::InvalidPart); - assert!(DefaultObjectUsecase::validate_get_object_part_number(Some(1), &info).is_ok()); - } - - #[test] - fn cold_fill_conditions_fail_before_phase_probe_advances() { - fn run_phase_probe(headers: &HeaderMap, info: &ObjectInfo) -> (S3Result<()>, [usize; 3]) { - let coordination = AtomicUsize::new(0); - let permit = AtomicUsize::new(0); - let reader = AtomicUsize::new(0); - let result = DefaultObjectUsecase::validate_get_object_before_cold_fill(headers, None, info); - if result.is_ok() { - coordination.fetch_add(1, AtomicOrdering::Relaxed); - permit.fetch_add(1, AtomicOrdering::Relaxed); - reader.fetch_add(1, AtomicOrdering::Relaxed); - } - ( - result, - [ - coordination.load(AtomicOrdering::Relaxed), - permit.load(AtomicOrdering::Relaxed), - reader.load(AtomicOrdering::Relaxed), - ], - ) - } - - let info = ObjectInfo { - etag: Some("phase-etag".to_string()), - parts: Arc::new(vec![rustfs_filemeta::ObjectPartInfo { - number: 1, - ..Default::default() - }]), - ..Default::default() - }; - - let mut not_modified = HeaderMap::new(); - not_modified.insert(http::header::IF_NONE_MATCH, HeaderValue::from_static("\"phase-etag\"")); - let (result, phases) = run_phase_probe(¬_modified, &info); - assert_eq!(result.expect_err("matching If-None-Match must reject").code(), &S3ErrorCode::NotModified); - assert_eq!(phases, [0, 0, 0]); - - let mut precondition_failed = HeaderMap::new(); - precondition_failed.insert(http::header::IF_MATCH, HeaderValue::from_static("\"other-etag\"")); - let (result, phases) = run_phase_probe(&precondition_failed, &info); - assert_eq!( - result.expect_err("mismatched If-Match must reject").code(), - &S3ErrorCode::PreconditionFailed - ); - assert_eq!(phases, [0, 0, 0]); - } - - #[tokio::test] - async fn execute_get_object_rejects_range_with_part_number() { - let input = GetObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .part_number(Some(1)) - .range(Some(Range::Int { first: 0, last: Some(1) })) - .build() - .unwrap(); - - let req = build_request(input, Method::GET); - let usecase = DefaultObjectUsecase::without_context(); - - let err = Box::pin(usecase.execute_get_object(req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - } - - #[tokio::test] - async fn execute_copy_object_rejects_self_copy_without_replace_directive() { - let input = CopyObjectInput::builder() - .copy_source(CopySource::Bucket { - bucket: "test-bucket".into(), - key: "test-key".into(), - version_id: None, - }) - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .build() - .unwrap(); - - let req = build_request(input, Method::PUT); - let usecase = DefaultObjectUsecase::without_context(); - - let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); - } - - #[tokio::test] - async fn execute_copy_object_rejects_invalid_storage_class() { - let input = CopyObjectInput::builder() - .copy_source(CopySource::Bucket { - bucket: "src-bucket".into(), - key: "src-key".into(), - version_id: None, - }) - .bucket("dst-bucket".to_string()) - .key("dst-key".to_string()) - .storage_class(Some(StorageClass::from_static("INVALID"))) - .build() - .unwrap(); - - let req = build_request(input, Method::PUT); - let usecase = DefaultObjectUsecase::without_context(); - - let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidStorageClass); - } - - #[tokio::test] - async fn execute_copy_object_allows_self_copy_with_storage_class_change() { - let input = CopyObjectInput::builder() - .copy_source(CopySource::Bucket { - bucket: "test-bucket".into(), - key: "test-key".into(), - version_id: None, - }) - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .storage_class(Some(StorageClass::from_static(storageclass::RRS))) - .build() - .unwrap(); - - let req = build_request(input, Method::PUT); - let usecase = DefaultObjectUsecase::without_context(); - - let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); - // Self-copy with explicit storage class change must pass the self-copy guard. - assert_ne!(err.code(), &S3ErrorCode::InvalidRequest); - } - - #[tokio::test] - #[serial_test::serial] - async fn execute_self_copy_when_object_name_equals_bucket_observes_lock_order() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions}; - use s3s::access::S3Access as _; - - let store = crate::app::gating_test_env::shared_gating_ecstore().await; - if current_app_context().is_none() { - crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; - } - let ambient = current_app_context().expect("self-copy lock-order test requires an AppContext"); - let context = Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms())); - let server_ctx = crate::app::runtime_sources::ServerContextSlot::new(); - assert!(server_ctx.install(Arc::clone(&context))); - let fs = FS::with_server_ctx(server_ctx); - - let bucket = format!("self-copy-lock-order-{}", Uuid::new_v4()); - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("create self-copy test bucket"); - let payload = b"object whose key equals its bucket".to_vec(); - let mut reader = PutObjReader::from_vec(payload.clone()); - let setup_opts = ObjectOptions { - no_lock: true, - ..Default::default() - }; - store - .put_object(&bucket, &bucket, &mut reader, &setup_opts) - .await - .expect("put object whose key equals its bucket"); - - let policy_json = format!( - r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"*"}},"Action":["s3:GetObject","s3:PutObject"],"Resource":["arn:aws:s3:::{bucket}/*"]}}]}}"# - ); - let mut bucket_metadata = (*crate::storage::get_bucket_metadata(&bucket) - .await - .expect("self-copy bucket metadata should be cached")) - .clone(); - bucket_metadata.policy_config = Some(serde_json::from_str(&policy_json).expect("self-copy policy should parse")); - bucket_metadata.policy_config_json = policy_json.into_bytes(); - crate::storage::storage_api::set_bucket_metadata(bucket.clone(), bucket_metadata) - .await - .expect("publish self-copy test policy"); - - let input = CopyObjectInput::builder() - .copy_source(CopySource::Bucket { - bucket: bucket.clone().into(), - key: bucket.clone().into(), - version_id: None, - }) - .bucket(bucket.clone()) - .key(bucket.clone()) - .metadata_directive(Some(MetadataDirective::from_static(MetadataDirective::REPLACE))) - .metadata(Some(HashMap::from([("lock-order".to_string(), "verified".to_string())]))) - .build() - .expect("self-copy input should build"); - let mut req = build_request(input, Method::PUT); - req.extensions.insert(crate::storage::access::ReqInfo::default()); - fs.copy_object(&mut req) - .await - .expect("authorize self-copy whose object key equals its bucket"); - - let response = tokio::time::timeout( - std::time::Duration::from_secs(30), - DefaultObjectUsecase::with_context(Some(context)).execute_copy_object(req), - ) - .await - .expect("lifecycle, authority, and exact object locks must not deadlock") - .expect("self-copy whose object key equals its bucket should succeed"); - assert!(response.output.copy_object_result.is_some()); - let info = store - .get_object_info(&bucket, &bucket, &ObjectOptions::default()) - .await - .expect("self-copied object should remain readable"); - assert_eq!(info.size, payload.len() as i64); - - store - .delete_bucket( - &bucket, - &DeleteBucketOptions { - force: true, - ..Default::default() - }, - ) - .await - .expect("clean up self-copy test bucket"); - } - - #[tokio::test] - async fn execute_copy_object_allows_tiered_self_copy_with_storage_class_change() { - let input = CopyObjectInput::builder() - .copy_source(CopySource::Bucket { - bucket: "test-bucket".into(), - key: "test-key".into(), - version_id: None, - }) - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .storage_class(Some(StorageClass::from_static(storageclass::STANDARD))) - .metadata_directive(Some(MetadataDirective::from_static(MetadataDirective::REPLACE))) - .build() - .unwrap(); - - let req = build_request(input, Method::PUT); - let usecase = DefaultObjectUsecase::without_context(); - - let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); - // Tiered self-copy with STANDARD storage class must pass all validation checks. - // The call fails at store init (no store in unit tests), not at validation. - assert_ne!(err.code(), &S3ErrorCode::InvalidRequest); - assert_ne!(err.code(), &S3ErrorCode::NotImplemented); - } - - #[tokio::test] - async fn execute_copy_object_allows_self_copy_of_historical_version() { - // Restoring a specific historical version onto the current key (same bucket/key with a - // source versionId, default COPY directive) must pass the self-copy guard (issue #4238). - let input = CopyObjectInput::builder() - .copy_source(CopySource::Bucket { - bucket: "test-bucket".into(), - key: "test-key".into(), - version_id: Some("11111111-1111-1111-1111-111111111111".into()), - }) - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .build() - .unwrap(); - - let req = build_request(input, Method::PUT); - let usecase = DefaultObjectUsecase::without_context(); - - let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); - // Must not be rejected by the self-copy guard; it fails later at store init instead. - assert_ne!(err.code(), &S3ErrorCode::InvalidRequest); - } - - #[tokio::test] - async fn execute_copy_object_allows_self_copy_of_null_version() { - // A "null" source version id is a restore of the null version, not a no-op self-copy. - let input = CopyObjectInput::builder() - .copy_source(CopySource::Bucket { - bucket: "test-bucket".into(), - key: "test-key".into(), - version_id: Some("null".into()), - }) - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .build() - .unwrap(); - - let req = build_request(input, Method::PUT); - let usecase = DefaultObjectUsecase::without_context(); - - let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); - assert_ne!(err.code(), &S3ErrorCode::InvalidRequest); - } - - #[tokio::test] - async fn execute_copy_object_rejects_malformed_copy_source_version_id() { - // A malformed (non-null, non-UUID) source version id is rejected up front. - let input = CopyObjectInput::builder() - .copy_source(CopySource::Bucket { - bucket: "src-bucket".into(), - key: "src-key".into(), - version_id: Some("not-a-uuid".into()), - }) - .bucket("dst-bucket".to_string()) - .key("dst-key".to_string()) - .build() - .unwrap(); - - let req = build_request(input, Method::PUT); - let usecase = DefaultObjectUsecase::without_context(); - - let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - } - - #[tokio::test] - async fn execute_delete_object_rejects_invalid_object_key() { - let input = DeleteObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("bad\0key".to_string()) - .build() - .unwrap(); - - let req = build_request(input, Method::DELETE); - let usecase = DefaultObjectUsecase::without_context(); - - let err = Box::pin(usecase.execute_delete_object(req)).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - } - - #[test] - fn delete_not_found_completes_noop_event_with_version_context() { - temp_env::with_var(rustfs_config::ENV_NOTIFY_ENABLE, Some("true"), || { - crate::server::refresh_notify_module_enabled(); - for (version_id, expected_version) in [(None, ""), (Some("requested-version".to_string()), "requested-version")] { - let input = DeleteObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("missing-key".to_string()) - .version_id(version_id.clone()) - .build() - .expect("delete input should build"); - let mut req = build_request(input, Method::DELETE); - req.extensions.insert(crate::storage::access::ReqInfo { - bucket: Some("test-bucket".to_string()), - object: Some("missing-key".to_string()), - version_id: version_id.clone(), - ..Default::default() - }); - let helper = OperationHelper::new(&req, EventName::ObjectRemovedDelete, S3Operation::DeleteObject); - - let (result, helper) = - complete_delete_noop(helper, "test-bucket".to_string(), "missing-key".to_string(), version_id); - let event = helper.event_args().expect("successful no-op delete should retain an event"); - - assert_eq!(result.expect("no-op delete should succeed").status, Some(StatusCode::NO_CONTENT)); - assert_eq!(event.event_name, EventName::ObjectRemovedNoOP); - assert_eq!(event.bucket_name, "test-bucket"); - assert_eq!(event.object.name, "missing-key"); - assert_eq!(event.version_id, expected_version); - } - }); - crate::server::refresh_notify_module_enabled(); - } - - #[test] - fn expected_current_version_header_normalizes_uuid_and_null() { - let version = Uuid::new_v4(); - let mut headers = HeaderMap::new(); - headers.insert( - RUSTFS_EXPECTED_CURRENT_VERSION_ID, - HeaderValue::from_str(&version.to_string().to_uppercase()).unwrap(), - ); - assert_eq!(expected_current_version_id(&headers).unwrap(), Some(version.to_string())); - - headers.insert(RUSTFS_EXPECTED_CURRENT_VERSION_ID, HeaderValue::from_static(" null ")); - assert_eq!(expected_current_version_id(&headers).unwrap(), Some(Uuid::nil().to_string())); - } - - #[test] - fn expected_current_version_header_rejects_empty_and_malformed_values() { - for value in ["", "not-a-version"] { - let mut headers = HeaderMap::new(); - headers.insert(RUSTFS_EXPECTED_CURRENT_VERSION_ID, HeaderValue::from_str(value).unwrap()); - assert_eq!(expected_current_version_id(&headers).unwrap_err().code(), &S3ErrorCode::InvalidArgument); - } - } - - #[tokio::test] - async fn execute_copy_object_rejects_expected_version_for_different_destination() { - let input = CopyObjectInput::builder() - .copy_source(CopySource::Bucket { - bucket: "test-bucket".into(), - key: "source-key".into(), - version_id: Some(Uuid::new_v4().to_string().into()), - }) - .bucket("test-bucket".to_string()) - .key("destination-key".to_string()) - .build() - .unwrap(); - let mut req = build_request(input, Method::PUT); - req.headers.insert( - RUSTFS_EXPECTED_CURRENT_VERSION_ID, - HeaderValue::from_str(&Uuid::new_v4().to_string()).unwrap(), - ); - - let err = Box::pin(DefaultObjectUsecase::without_context().execute_copy_object(req)) - .await - .unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); - } - - #[test] - fn undo_delete_requires_version_id_to_match_expected_current() { - let expected = Uuid::new_v4().to_string(); - assert!(validate_undo_delete_version(Some(&expected), Some(&expected)).is_ok()); - assert_eq!( - validate_undo_delete_version(Some(&expected), Some(&Uuid::new_v4().to_string())) - .unwrap_err() - .code(), - &S3ErrorCode::PreconditionFailed - ); - assert_eq!( - validate_undo_delete_version(Some(&expected), None).unwrap_err().code(), - &S3ErrorCode::PreconditionFailed - ); - assert!(validate_undo_delete_version(None, None).is_ok()); - } - - #[tokio::test] - async fn execute_delete_objects_rejects_empty_object_list() { - let input = DeleteObjectsInput::builder() - .bucket("test-bucket".to_string()) - .delete(Delete { - objects: vec![], - quiet: None, - }) - .build() - .unwrap(); - - let req = build_request(input, Method::POST); - let usecase = DefaultObjectUsecase::without_context(); - - let err = usecase.execute_delete_objects(req).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - } - - #[tokio::test] - async fn execute_delete_objects_rejects_more_than_one_thousand_objects_before_store_lookup() { - let objects = (0..1001) - .map(|idx| ObjectIdentifier { - key: format!("test-key-{idx}"), - version_id: None, - ..Default::default() - }) - .collect(); - let input = DeleteObjectsInput::builder() - .bucket("test-bucket".to_string()) - .delete(Delete { objects, quiet: None }) - .build() - .expect("delete objects input should build"); - - let req = build_request(input, Method::POST); - let usecase = DefaultObjectUsecase::without_context(); - - let err = usecase.execute_delete_objects(req).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - } - - #[tokio::test] - async fn execute_delete_objects_returns_internal_error_when_store_uninitialized() { - let input = DeleteObjectsInput::builder() - .bucket("test-bucket".to_string()) - .delete(Delete { - objects: vec![ObjectIdentifier { - key: "test-key".to_string(), - version_id: None, - ..Default::default() - }], - quiet: None, - }) - .build() - .unwrap(); - - let req = build_request(input, Method::POST); - let usecase = DefaultObjectUsecase::without_context(); - - let err = usecase.execute_delete_objects(req).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InternalError); - assert_eq!(err.message(), Some("Not init")); - } - - #[tokio::test] - #[serial_test::serial] - async fn execute_delete_objects_rejects_bucket_recreated_after_authorization() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions}; - - let store = crate::app::gating_test_env::shared_gating_ecstore().await; - if current_app_context().is_none() { - crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; - } - let context = current_app_context().expect("delete objects generation test requires an AppContext"); - let bucket = format!("delete-objects-generation-{}", Uuid::new_v4()); - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("create authorized bucket generation"); - let mut reader = PutObjReader::from_vec(b"old generation".to_vec()); - store - .put_object(&bucket, "object", &mut reader, &ObjectOptions::default()) - .await - .expect("put old-generation object"); - - let policy_json = format!( - r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"*"}},"Action":["s3:DeleteObject"],"Resource":["arn:aws:s3:::{bucket}/*"]}}]}}"# - ); - let mut metadata = (*crate::storage::get_bucket_metadata(&bucket) - .await - .expect("authorized bucket metadata should be cached")) - .clone(); - metadata.policy_config = Some(serde_json::from_str(&policy_json).expect("test policy should parse")); - metadata.policy_config_json = policy_json.into_bytes(); - crate::storage::storage_api::set_bucket_metadata(bucket.clone(), metadata) - .await - .expect("publish test bucket policy"); - - let input = DeleteObjectsInput::builder() - .bucket(bucket.clone()) - .delete(Delete { - objects: vec![ObjectIdentifier { - key: "object".to_string(), - version_id: None, - ..Default::default() - }], - quiet: None, - }) - .build() - .expect("delete objects input should build"); - let mut req = build_request(input, Method::POST); - req.extensions.insert(crate::storage::access::ReqInfo::default()); - let loaded = Arc::new(tokio::sync::Barrier::new(2)); - let resume = Arc::new(tokio::sync::Barrier::new(2)); - install_delete_objects_auth_test_hook(bucket.clone(), Arc::clone(&loaded), Arc::clone(&resume)); - - let usecase = DefaultObjectUsecase::with_context(Some(context)); - let delete = tokio::spawn(async move { usecase.execute_delete_objects(req).await }); - loaded.wait().await; - - store - .delete_bucket( - &bucket, - &DeleteBucketOptions { - force: true, - ..Default::default() - }, - ) - .await - .expect("delete authorized bucket generation"); - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("recreate same bucket name"); - let mut reader = PutObjReader::from_vec(b"new generation".to_vec()); - store - .put_object(&bucket, "object", &mut reader, &ObjectOptions::default()) - .await - .expect("put new-generation object"); - resume.wait().await; - - let err = delete - .await - .expect("delete objects task should join") - .expect_err("old authorization must not delete from the recreated bucket"); - assert_eq!(err.code(), &S3ErrorCode::NoSuchBucket); - store - .get_object_info(&bucket, "object", &ObjectOptions::default()) - .await - .expect("new-generation object must survive the stale batch request"); - } - - #[tokio::test] - async fn execute_delete_object_allows_non_force_request_without_req_info_until_store_lookup() { - let input = DeleteObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .build() - .unwrap(); - - let err = DefaultObjectUsecase::without_context() - .execute_delete_object(build_request(input, Method::DELETE)) - .await - .expect_err("an uninitialized store should be reported after non-force admission"); - assert_eq!(err.code(), &S3ErrorCode::InternalError); - assert_eq!(err.message(), Some("Not init")); - } - - #[test] - fn delete_objects_audit_details_include_only_successful_request_entries() { - let requested = vec![ - ObjectIdentifier { - key: "first-key".to_string(), - version_id: None, - ..Default::default() - }, - ObjectIdentifier { - key: "denied-key".to_string(), - version_id: Some(Uuid::new_v4().to_string()), - ..Default::default() - }, - ObjectIdentifier { - key: "versioned-key".to_string(), - version_id: Some("requested-version".to_string()), - ..Default::default() - }, - ]; - - let objects = successful_delete_audit_objects( - &Delete { - objects: requested, - quiet: Some(true), - }, - [true, false, true], - ); - - assert_eq!( - objects, - vec![ - AuditObjectVersion::new("first-key".to_string(), None), - AuditObjectVersion::new("versioned-key".to_string(), Some("requested-version".to_string())), - ] - ); - } - - #[test] - fn delete_objects_audit_details_are_empty_when_every_entry_fails() { - let requested = vec![ObjectIdentifier { - key: "failed-key".to_string(), - version_id: None, - ..Default::default() - }]; - - assert!( - successful_delete_audit_objects( - &Delete { - objects: requested, - quiet: None, - }, - [false] - ) - .is_empty() - ); - } - - #[test] - fn normalize_delete_objects_version_id_preserves_explicit_null_marker() { - let (wire_version_id, internal_version_id) = - normalize_delete_objects_version_id(Some("null".to_string())).expect("null version marker should parse"); - - assert_eq!(wire_version_id.as_deref(), Some("null")); - assert_eq!(internal_version_id, Some(Uuid::nil())); - - let (wire_version_id, internal_version_id) = - normalize_delete_objects_version_id(Some(" \t ".to_string())).expect("empty version marker should normalize"); - assert_eq!(wire_version_id, None); - assert_eq!(internal_version_id, None); - } - - #[test] - fn delete_objects_treats_raw_io_not_found_as_idempotent() { - assert!(is_delete_objects_not_found(&StorageError::FileNotFound)); - assert!(is_delete_objects_not_found(&StorageError::Io(std::io::Error::from( - std::io::ErrorKind::NotFound, - )))); - assert!(!is_delete_objects_not_found(&StorageError::Io(std::io::Error::from( - std::io::ErrorKind::PermissionDenied, - )))); - assert!(!is_delete_objects_not_found(&StorageError::DiskNotFound)); - } - - #[test] - fn delete_objects_result_reducer_reports_raw_not_found_as_deleted() { - let object = ObjectToDelete { - object_name: "missing-key".to_string(), - ..Default::default() - }; - let deleted = StorageDeletedObject { - object_name: object.object_name.clone(), - ..Default::default() - }; - let error = StorageError::Io(std::io::Error::from(std::io::ErrorKind::NotFound)); - - let deleted = reduce_delete_objects_result(&object, &deleted, Some(&error), false) - .expect("raw not-found must produce a deleted result"); - assert_eq!(deleted.object_name, "missing-key"); - } - - #[test] - fn recursive_force_delete_requires_administrative_or_replica_context() { - let mut headers = HeaderMap::new(); - headers.insert("x-rustfs-force-delete", HeaderValue::from_static("true")); - - assert!(!recursive_force_delete_is_authorized(&headers, false, false)); - assert!(recursive_force_delete_is_authorized(&headers, true, false)); - assert!(recursive_force_delete_is_authorized(&headers, false, true)); - assert!(recursive_force_delete_is_authorized(&HeaderMap::new(), false, false)); - } - - #[tokio::test] - async fn execute_delete_object_rejects_untrusted_force_delete_before_store_access() { - let input = DeleteObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("prefix/object".to_string()) - .build() - .unwrap(); - let mut req = build_request(input, Method::DELETE); - req.headers.insert("x-rustfs-force-delete", HeaderValue::from_static("true")); - req.extensions.insert(crate::storage::access::ReqInfo::default()); - - let err = DefaultObjectUsecase::without_context() - .execute_delete_object(req) - .await - .expect_err("untrusted force-delete must be rejected before storage lookup"); - assert_eq!(err.code(), &S3ErrorCode::AccessDenied); - } - - #[tokio::test] - async fn execute_delete_objects_rejects_untrusted_force_delete_before_store_access() { - let input = DeleteObjectsInput::builder() - .bucket("test-bucket".to_string()) - .delete(Delete { - objects: vec![ObjectIdentifier { - key: "prefix/object".to_string(), - version_id: None, - ..Default::default() - }], - quiet: None, - }) - .build() - .unwrap(); - let mut req = build_request(input, Method::POST); - req.headers.insert("x-rustfs-force-delete", HeaderValue::from_static("true")); - req.extensions.insert(crate::storage::access::ReqInfo::default()); - - let err = DefaultObjectUsecase::without_context() - .execute_delete_objects(req) - .await - .expect_err("untrusted force-delete must be rejected before storage lookup"); - assert_eq!(err.code(), &S3ErrorCode::AccessDenied); - } - - // backlog#929 (HP-8): the pre-delete stat may only be skipped when every - // consumer of its result is provably idle. Each guard flips one condition - // to prove the skip is fenced on all four data dependencies. - fn delete_marker_creating_opts() -> ObjectOptions { - ObjectOptions { - version_id: None, - versioned: true, - version_suspended: false, - ..Default::default() - } - } - - #[test] - fn delete_objects_pre_stat_skippable_for_delete_marker_on_plain_bucket() { - assert!(can_skip_delete_objects_pre_stat(false, &delete_marker_creating_opts(), true)); - } - - #[test] - fn delete_objects_pre_stat_kept_for_object_lock_buckets() { - assert!(!can_skip_delete_objects_pre_stat(true, &delete_marker_creating_opts(), true)); - } - - #[test] - fn delete_objects_pre_stat_kept_for_explicit_version_deletes() { - let opts = ObjectOptions { - version_id: Some(Uuid::new_v4().to_string()), - versioned: true, - version_suspended: false, - ..Default::default() - }; - assert!(!can_skip_delete_objects_pre_stat(false, &opts, true)); - } - - #[test] - fn delete_objects_pre_stat_kept_for_unversioned_buckets() { - // Unversioned deletes remove the current object: usage accounting needs - // the object size and ILM tier cleanup needs the transition metadata. - let opts = ObjectOptions { - version_id: None, - versioned: false, - version_suspended: false, - ..Default::default() - }; - assert!(!can_skip_delete_objects_pre_stat(false, &opts, false)); - } - - #[test] - fn delete_objects_pre_stat_kept_for_suspended_versioning() { - let opts = ObjectOptions { - version_id: None, - versioned: true, - version_suspended: true, - ..Default::default() - }; - assert!(!can_skip_delete_objects_pre_stat(false, &opts, false)); - } - - #[test] - fn delete_objects_pre_stat_kept_when_accounting_snapshot_disagrees() { - // If the accounting-side versioning snapshot does not also classify the - // delete as a delete-marker creation, the stat must stay so usage - // accounting keeps its size input. - assert!(!can_skip_delete_objects_pre_stat(false, &delete_marker_creating_opts(), false)); - } - - #[test] - fn delete_accounting_recognizes_explicit_null_as_current_object() { - let opts = ObjectOptions { - version_id: Some(Uuid::nil().to_string()), - version_suspended: true, - ..Default::default() - }; - assert!(delete_removes_current_object(&opts)); - assert!(delete_request_targets_current(Some(Uuid::nil()))); - assert!(!delete_request_targets_current(Some(Uuid::new_v4()))); - assert!(!delete_removes_current_object(&ObjectOptions { - version_id: Some(Uuid::new_v4().to_string()), - ..Default::default() - })); - } - - #[test] - fn compressed_object_delete_restores_usage_baseline() { - let mut metadata = HashMap::new(); - insert_str(&mut metadata, SUFFIX_COMPRESSION, "klauspost/compress/s2".to_string()); - let object = ObjectInfo { - size: 400, - actual_size: 1000, - user_defined: Arc::new(metadata), - ..Default::default() - }; - let accounting_size = quota_object_size(&object).expect("logical compressed size should be canonical"); - - assert_eq!( - delete_memory_update(false, false, true, Some(accounting_size), true), - Some(DeleteMemoryUpdate::Object { - size: 1000, - removed_current_object: true, - }) - ); - } - - #[test] - fn invalid_accounting_metadata_is_reconciled_without_overflow() { - assert_eq!(delete_memory_update(false, false, true, None, true), None); - assert_eq!( - delete_memory_update(false, true, true, None, true), - Some(DeleteMemoryUpdate::DeleteMarker) - ); - } - - #[tokio::test] - #[serial_test::serial] - async fn compressed_delete_requests_update_observed_usage_without_releasing_quota_floor() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions}; - use crate::app::storage_api::test::data_usage::apply_bucket_usage_memory_overlay; - - async fn observed_bucket_usage(bucket: &str) -> Option { - let mut usage = rustfs_data_usage::DataUsageInfo::default(); - apply_bucket_usage_memory_overlay(&mut usage).await; - usage.buckets_usage.get(bucket).map(|value| value.size) - } - - let store = crate::app::gating_test_env::shared_gating_ecstore().await; - if current_app_context().is_none() { - crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; - } - let bucket = format!("compressed-delete-request-{}", Uuid::new_v4().simple()); - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("create compressed delete request bucket"); - - // Seed the process-local usage with the canonical logical bytes. The - // direct storage PUT below intentionally does not apply an app-layer - // usage delta; the two real DELETE requests must remove exactly this - // amount through their request-layer wiring. - crate::app::storage_api::test::data_usage::seed_bucket_usage_memory_for_test(&bucket, 2_000).await; - - for object in ["single", "batch"] { - let mut metadata = HashMap::new(); - insert_str(&mut metadata, SUFFIX_COMPRESSION, "klauspost/compress/s2".to_string()); - insert_str(&mut metadata, SUFFIX_ACTUAL_SIZE, "1000".to_string()); - let reader = HashReader::from_stream(std::io::Cursor::new(vec![0x5a; 400]), 400, 1000, None, None, false) - .expect("compressed fixture reader should be valid"); - let mut reader = PutObjReader::new(reader); - store - .put_object( - &bucket, - object, - &mut reader, - &ObjectOptions { - user_defined: metadata, - ..Default::default() - }, - ) - .await - .expect("compressed fixture object should be written"); - } - - let mut single_req = build_request( - DeleteObjectInput::builder() - .bucket(bucket.clone()) - .key("single".to_string()) - .build() - .expect("single delete input should build"), - Method::DELETE, - ); - single_req.extensions.insert(crate::storage::access::ReqInfo { - cred: Some(rustfs_credentials::Credentials::default()), - is_owner: true, - ..Default::default() - }); - DefaultObjectUsecase::from_global() - .execute_delete_object(single_req) - .await - .expect("single compressed delete should succeed"); - assert_eq!( - observed_bucket_usage(&bucket).await, - Some(1_000), - "single delete must subtract the logical accounting size" - ); - assert_eq!( - crate::app::storage_api::test::data_usage::get_bucket_usage_memory(&bucket).await, - Some(2_000), - "quota must retain the pre-delete floor until scanner reconciliation" - ); - - let mut batch_req = build_request( - DeleteObjectsInput::builder() - .bucket(bucket.clone()) - .delete(Delete { - objects: vec![ObjectIdentifier { - key: "batch".to_string(), - ..Default::default() - }], - quiet: None, - }) - .build() - .expect("batch delete input should build"), - Method::POST, - ); - batch_req.extensions.insert(crate::storage::access::ReqInfo { - cred: Some(rustfs_credentials::Credentials::default()), - is_owner: true, - ..Default::default() - }); - DefaultObjectUsecase::from_global() - .execute_delete_objects(batch_req) - .await - .expect("batch compressed delete should succeed"); - assert_eq!( - observed_bucket_usage(&bucket).await, - Some(0), - "batch delete must subtract the committed logical accounting size" - ); - assert_eq!( - crate::app::storage_api::test::data_usage::get_bucket_usage_memory(&bucket).await, - Some(2_000), - "quota must retain both pending deletes until scanner reconciliation" - ); - - store - .delete_bucket( - &bucket, - &DeleteBucketOptions { - force: true, - ..Default::default() - }, - ) - .await - .expect("clean up compressed delete request bucket"); - } - - #[tokio::test] - async fn execute_get_object_attributes_returns_internal_error_when_store_uninitialized() { - let input = GetObjectAttributesInput::builder() - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .build() - .unwrap(); - - let req = build_request(input, Method::GET); - let usecase = DefaultObjectUsecase::without_context(); - - let err = usecase.execute_get_object_attributes(req).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InternalError); - } - - #[test] - fn object_attributes_requested_with_single_value() { - let object_attributes = vec![ObjectAttributes::from_static(ObjectAttributes::ETAG)]; - - assert!(object_attributes_requested(&object_attributes, ObjectAttributes::ETAG)); - assert!(!object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_SIZE)); - } - - #[test] - fn object_attributes_requested_with_comma_separated_values() { - let object_attributes = vec![ - ObjectAttributes::from_static("ObjectParts,etag"), - ObjectAttributes::from_static("StorageClass"), - ]; - - assert!(object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_PARTS)); - assert!(object_attributes_requested(&object_attributes, ObjectAttributes::ETAG)); - assert!(!object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_SIZE)); - } - - #[test] - fn object_attributes_requested_with_quotes_and_spaces() { - let object_attributes = vec![ObjectAttributes::from_static("'ObjectSize', \"Checksum\" , \"Etag\"")]; - - assert!(object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_SIZE)); - assert!(object_attributes_requested(&object_attributes, ObjectAttributes::CHECKSUM)); - assert!(object_attributes_requested(&object_attributes, ObjectAttributes::ETAG)); - } - - #[test] - fn object_attributes_requested_returns_false_for_missing_name() { - let object_attributes = vec![ObjectAttributes::from_static("Checksum")]; - - assert!(!object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_SIZE)); - } - - #[test] - fn build_put_object_expiration_header_returns_none_for_non_delete_events() { - let event = lifecycle::Event { - action: lifecycle::IlmAction::TransitionAction, - rule_id: "rule-1".to_string(), - due: Some(OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap()), - noncurrent_days: 0, - newer_noncurrent_versions: 0, - storage_class: String::new(), - }; - - assert!(build_put_object_expiration_header(&event).is_none()); - } - - #[test] - fn build_put_object_expiration_header_formats_expected_value() { - let expire_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(); - let event = lifecycle::Event { - action: lifecycle::IlmAction::DeleteAction, - rule_id: "rule-1".to_string(), - due: Some(expire_time), - noncurrent_days: 0, - newer_noncurrent_versions: 0, - storage_class: String::new(), - }; - - let expiry_date = expire_time.format(&Rfc3339).unwrap(); - let expected = format!("expiry-date=\"{}\", rule-id=\"rule-1\"", expiry_date); - assert_eq!(build_put_object_expiration_header(&event), Some(expected)); - } - - #[test] - fn build_put_object_expiration_header_requires_rule_id_and_due_time() { - let event = lifecycle::Event { - action: lifecycle::IlmAction::DeleteAction, - rule_id: String::new(), - due: Some(OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap()), - noncurrent_days: 0, - newer_noncurrent_versions: 0, - storage_class: String::new(), - }; - - assert!(build_put_object_expiration_header(&event).is_none()); - - let event = lifecycle::Event { - action: lifecycle::IlmAction::DeleteAction, - rule_id: "rule-1".to_string(), - due: Some(OffsetDateTime::UNIX_EPOCH), - noncurrent_days: 0, - newer_noncurrent_versions: 0, - storage_class: String::new(), - }; - - assert!(build_put_object_expiration_header(&event).is_none()); - } - - #[tokio::test] - async fn execute_head_object_rejects_range_with_part_number() { - let input = HeadObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .part_number(Some(1)) - .range(Some(Range::Int { first: 0, last: Some(1) })) - .build() - .unwrap(); - - let req = build_request(input, Method::HEAD); - let usecase = DefaultObjectUsecase::without_context(); - - let err = usecase.execute_head_object(req).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InvalidArgument); - } - - #[tokio::test] - async fn execute_restore_object_rejects_missing_restore_request() { - let input = RestoreObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .build() - .unwrap(); - - let req = build_request(input, Method::POST); - let usecase = DefaultObjectUsecase::without_context(); - - let err = usecase.execute_restore_object(req).await.unwrap_err(); - match err.code() { - S3ErrorCode::Custom(code) => assert_eq!(code, "ErrValidRestoreObject"), - code => panic!("unexpected error code: {:?}", code), - } - } - - #[tokio::test] - async fn execute_restore_object_returns_internal_error_when_store_uninitialized() { - let restore_request = RestoreRequest { - days: Some(1), - description: None, - glacier_job_parameters: None, - output_location: None, - select_parameters: None, - tier: None, - type_: None, - }; - let input = RestoreObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("test-key".to_string()) - .restore_request(Some(restore_request)) - .build() - .unwrap(); - - let req = build_request(input, Method::POST); - let usecase = DefaultObjectUsecase::without_context(); - - let err = usecase.execute_restore_object(req).await.unwrap_err(); - assert_eq!(err.code(), &S3ErrorCode::InternalError); - } - - #[test] - fn delete_replication_state_from_config_tracks_downstream_delete_marker_targets() { - let arn = "arn:aws:s3:::target-bucket".to_string(); - let config = ReplicationConfiguration { - role: arn.clone(), - rules: vec![ReplicationRule { - delete_marker_replication: Some(DeleteMarkerReplication { - status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)), - }), - delete_replication: None, - destination: Destination { - bucket: arn.clone(), - ..Default::default() - }, - existing_object_replication: Some(ExistingObjectReplication { - status: ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::ENABLED), - }), - filter: None, - id: Some("rule-1".to_string()), - prefix: Some("test/".to_string()), - 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), - }], - }; - let obj_info = ObjectInfo { - bucket: "bucket".to_string(), - name: "test/object.txt".to_string(), - delete_marker: true, - replication_status: ReplicationStatusType::Replica, - ..Default::default() - }; - - let state = delete_replication_state_from_config(&config, &obj_info, None, true) - .expect("replica delete marker should be forwarded to downstream targets"); - let pending = format!("{arn}=PENDING;"); - - assert_eq!(state.replication_status_internal.as_deref(), Some(pending.as_str())); - assert_eq!(state.replicate_decision_str, format!("{arn}=true;false;{arn};")); - assert!(state.targets.contains_key(&arn)); - } - - #[test] - fn delete_replication_state_from_config_skips_replica_delete_without_replica_modifications() { - let arn = "arn:aws:s3:::target-bucket".to_string(); - let config = ReplicationConfiguration { - role: arn.clone(), - rules: vec![ReplicationRule { - delete_marker_replication: Some(DeleteMarkerReplication { - status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)), - }), - delete_replication: None, - destination: Destination { - bucket: arn, - ..Default::default() - }, - existing_object_replication: Some(ExistingObjectReplication { - status: ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::ENABLED), - }), - filter: None, - id: Some("rule-1".to_string()), - prefix: Some("test/".to_string()), - priority: Some(1), - source_selection_criteria: None, - status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED), - }], - }; - let obj_info = ObjectInfo { - bucket: "bucket".to_string(), - name: "test/object.txt".to_string(), - delete_marker: true, - replication_status: ReplicationStatusType::Replica, - ..Default::default() - }; - - assert!( - delete_replication_state_from_config(&config, &obj_info, None, true).is_none(), - "replica deletes must only fan out when ReplicaModifications are enabled" - ); - } - - #[test] - fn delete_replication_state_from_config_requires_delete_switch_for_marker_version_purges() { - let arn = "arn:aws:s3:::target-bucket".to_string(); - let mut config = ReplicationConfiguration { - role: arn.clone(), - rules: vec![ReplicationRule { - delete_marker_replication: Some(DeleteMarkerReplication { - status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)), - }), - delete_replication: None, - destination: Destination { - bucket: arn.clone(), - ..Default::default() - }, - existing_object_replication: Some(ExistingObjectReplication { - status: ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::ENABLED), - }), - filter: None, - id: Some("rule-1".to_string()), - prefix: Some("test/".to_string()), - priority: Some(1), - source_selection_criteria: None, - status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED), - }], - }; - let obj_info = ObjectInfo { - bucket: "bucket".to_string(), - name: "test/object.txt".to_string(), - delete_marker: true, - replication_status: ReplicationStatusType::Completed, - ..Default::default() - }; - - let version_id = Some(Uuid::new_v4()); - assert!( - delete_replication_state_from_config(&config, &obj_info, version_id, false).is_none(), - "delete-marker version purge must not use DeleteMarkerReplication" - ); - - config.rules[0].delete_replication = Some(DeleteReplication { - status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED), - }); - let state = delete_replication_state_from_config(&config, &obj_info, version_id, false) - .expect("delete-marker version purge should honor DeleteReplication"); - let pending = format!("{arn}=PENDING;"); - - assert_eq!(state.version_purge_status_internal.as_deref(), Some(pending.as_str())); - assert_eq!(state.replicate_decision_str, format!("{arn}=true;false;{arn};")); - assert!(state.purge_targets.contains_key(&arn)); - } - - #[test] - fn replica_delete_enrichment_must_not_reuse_upstream_targets() { - let upstream_state = ReplicationState { - replicate_decision_str: "arn:aws:s3:::upstream=true;false;arn:aws:s3:::upstream;".to_string(), - replication_status_internal: Some("arn:aws:s3:::upstream=COMPLETED;".to_string()), - targets: replication_statuses_map("arn:aws:s3:::upstream=COMPLETED;"), - ..Default::default() - }; - let mut delete_object = StorageDeletedObject::default(); - set_deleted_object_replication_state(&mut delete_object, &upstream_state); - let obj_info = ObjectInfo { - replication_status: ReplicationStatusType::Replica, - ..Default::default() - }; - - let should_keep_existing = delete_object.replication_state.as_ref().is_some_and(|state| { - obj_info.replication_status != ReplicationStatusType::Replica - && !state.replicate_decision_str.is_empty() - && (!state.targets.is_empty() || !state.purge_targets.is_empty()) - }); - - assert!( - !should_keep_existing, - "replica fanout deletes must recompute targets from the local bucket config instead of reusing upstream replication state" - ); - } - - #[test] - fn delete_replication_version_id_uses_none_for_delete_marker_creation() { - let source = ObjectInfo { - delete_marker: true, - version_id: Some(Uuid::new_v4()), - ..Default::default() - }; - - assert_eq!( - delete_replication_version_id(&source, false), - None, - "delete-marker creation must stay on the delete-marker replication path" - ); - } - - #[test] - fn delete_replication_version_id_keeps_version_for_marker_purge() { - let version_id = Uuid::new_v4(); - let source = ObjectInfo { - delete_marker: true, - version_id: Some(version_id), - ..Default::default() - }; - - assert_eq!( - delete_replication_version_id(&source, true), - Some(version_id), - "delete-marker version purge must preserve the concrete version id for downstream purge replication" - ); - } - - #[test] - fn should_use_existing_delete_replication_info_ignores_replication_delete_marker_creation() { - let opts = ObjectOptions { - version_id: Some(Uuid::new_v4().to_string()), - delete_marker: true, - ..Default::default() - }; - - assert!( - !should_use_existing_delete_replication_info(&opts, true), - "replicated delete-marker creation carries a source version id header but must not be treated as a version purge" - ); - } - - #[test] - fn should_use_existing_delete_replication_info_keeps_version_delete_requests() { - let opts = ObjectOptions { - version_id: Some(Uuid::new_v4().to_string()), - ..Default::default() - }; - - assert!( - should_use_existing_delete_replication_info(&opts, true), - "true version-delete requests should keep using the pre-delete object info" - ); - } - - // -- Range: u64 -> i64 lossless conversion (issue rustfs/backlog#1322) -- - - const I64_MAX_AS_U64: u64 = i64::MAX as u64; - - /// The conversion itself: s3s `Range` (u64) -> internal `HTTPRangeSpec` - /// (i64). This directly guards the suffix truncation fix. Reverting to - /// `length as i64` regresses the zero-suffix, `i64::MAX + 1` and `u64::MAX` - /// rows below. - #[test] - fn range_to_http_range_spec_is_lossless() { - // Zero-length suffix (`bytes=-0`) is unsatisfiable -> InvalidRange (416), - // never a 0-length 206. - let zero_suffix = range_to_http_range_spec(Range::Suffix { length: 0 }); - assert_eq!( - zero_suffix.as_ref().err().map(|e| e.code()), - Some(&S3ErrorCode::InvalidRange), - "bytes=-0 must map to InvalidRange (416)" - ); - - // Suffix conversions: positive `start` holds the suffix length; values - // above i64::MAX clamp to i64::MAX (they always cover the whole object). - let suffix_cases = [ - (1_u64, 1_i64), - (I64_MAX_AS_U64, i64::MAX), - (I64_MAX_AS_U64 + 1, i64::MAX), // was i64::MIN under `as i64` -> checked_neg overflow - (u64::MAX, i64::MAX), // was -1 under `as i64` -> read as "last 1 byte" - ]; - for (length, expected_start) in suffix_cases { - let spec = range_to_http_range_spec(Range::Suffix { length }) - .unwrap_or_else(|_| panic!("suffix {length} must convert losslessly")); - assert!(spec.is_suffix_length, "suffix {length} must stay a suffix spec"); - assert_eq!(spec.start, expected_start, "suffix {length} start"); - assert_eq!(spec.end, -1, "suffix {length} end"); - } - - // Int ranges: s3s already rejects first/last > i64::MAX, so the checked - // cast never truncates. first-last and open-ended must not regress. - let int_first_last = range_to_http_range_spec(Range::Int { - first: 10, - last: Some(20), - }) - .expect("first-last converts"); - assert!(!int_first_last.is_suffix_length); - assert_eq!((int_first_last.start, int_first_last.end), (10, 20)); - - let int_open = range_to_http_range_spec(Range::Int { first: 5, last: None }).expect("open-ended converts"); - assert_eq!((int_open.start, int_open.end), (5, -1)); - - let int_max = range_to_http_range_spec(Range::Int { - first: I64_MAX_AS_U64, - last: Some(I64_MAX_AS_U64), - }) - .expect("i64::MAX int converts"); - assert_eq!((int_max.start, int_max.end), (i64::MAX, i64::MAX)); - } - - /// Observable end-to-end effect the GET/HEAD handlers derive from a range - /// spec: `HTTPRangeSpec::get_offset_length` yields the (offset, length) - /// that becomes `Content-Length` and `Content-Range`, or an error that - /// surfaces as 416. Covers empty / 1-byte / normal objects. - #[test] - fn range_suffix_offset_length_matches_s3_semantics() { - // Expected outcome for a satisfiable range, or `None` for 416. - #[derive(Debug, PartialEq)] - enum Outcome { - /// (offset, content_length, content_range) - Partial(usize, i64, String), - Unsatisfiable, - } - - fn derive(range: Range, size: i64) -> Outcome { - let spec = match range_to_http_range_spec(range) { - Ok(spec) => spec, - Err(_) => return Outcome::Unsatisfiable, - }; - match spec.get_offset_length(size) { - Ok((offset, len)) => { - let content_range = format!("bytes {}-{}/{}", offset, offset as i64 + len - 1, size); - Outcome::Partial(offset, len, content_range) - } - Err(_) => Outcome::Unsatisfiable, - } - } - - let suffix = |length: u64| Range::Suffix { length }; - - // size, range, expected - let normal = 100_i64; - let cases = [ - // Zero suffix is always 416, whatever the size. - (0_i64, suffix(0), Outcome::Unsatisfiable), - (1, suffix(0), Outcome::Unsatisfiable), - (normal, suffix(0), Outcome::Unsatisfiable), - // Suffix within the object returns the trailing bytes. - (normal, suffix(1), Outcome::Partial(99, 1, "bytes 99-99/100".into())), - (normal, suffix(normal as u64), Outcome::Partial(0, 100, "bytes 0-99/100".into())), - // Suffix >= size returns the whole object (never a truncated tail). - (normal, suffix(normal as u64 + 1), Outcome::Partial(0, 100, "bytes 0-99/100".into())), - (normal, suffix(I64_MAX_AS_U64), Outcome::Partial(0, 100, "bytes 0-99/100".into())), - (normal, suffix(I64_MAX_AS_U64 + 1), Outcome::Partial(0, 100, "bytes 0-99/100".into())), - (normal, suffix(u64::MAX), Outcome::Partial(0, 100, "bytes 0-99/100".into())), - // 1-byte object: any non-zero suffix returns that single byte. - (1, suffix(1), Outcome::Partial(0, 1, "bytes 0-0/1".into())), - (1, suffix(2), Outcome::Partial(0, 1, "bytes 0-0/1".into())), - (1, suffix(I64_MAX_AS_U64 + 1), Outcome::Partial(0, 1, "bytes 0-0/1".into())), - (1, suffix(u64::MAX), Outcome::Partial(0, 1, "bytes 0-0/1".into())), - // Normal first-last and open-ended int ranges must not regress. - ( - normal, - Range::Int { - first: 10, - last: Some(19), - }, - Outcome::Partial(10, 10, "bytes 10-19/100".into()), - ), - ( - normal, - Range::Int { first: 90, last: None }, - Outcome::Partial(90, 10, "bytes 90-99/100".into()), - ), - ]; - - for (size, range, expected) in cases { - let got = derive(range, size); - assert_eq!(got, expected, "size={size} range={range:?}"); - } - } - - // https://github.com/rustfs/backlog/issues/1311 — bucket-quota admission must run against the authoritative - // decoded/plain object length, never the aws-chunked wire Content-Length, and must reject negative/unknown lengths. - // https://github.com/rustfs/backlog/issues/1336 — but Content-Encoding: aws-chunked alone is only a declared - // encoding: without a STREAMING-* payload the body is unframed and the wire Content-Length is authoritative. - fn aws_chunked_headers(decoded_len: Option<&str>) -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert(http::header::CONTENT_ENCODING, HeaderValue::from_static("aws-chunked")); - if let Some(decoded) = decoded_len { - headers.insert( - HeaderName::from_bytes(AMZ_DECODED_CONTENT_LENGTH.as_bytes()).unwrap(), - HeaderValue::from_str(decoded).unwrap(), - ); - } - headers - } - - fn streaming_headers(decoded_len: Option<&str>) -> HeaderMap { - let mut headers = aws_chunked_headers(decoded_len); - headers.insert( - HeaderName::from_bytes(AMZ_CONTENT_SHA256.as_bytes()).unwrap(), - HeaderValue::from_static("STREAMING-AWS4-HMAC-SHA256-PAYLOAD"), - ); - headers - } - - #[test] - fn authoritative_size_prefers_aws_chunked_decoded_over_wire_content_length() { - // Wire Content-Length (chunk framing) differs from the decoded object length; the decoded length wins. - let headers = streaming_headers(Some("1000")); - let size = resolve_put_object_authoritative_size(&headers, Some(1088)).expect("decoded length is authoritative"); - assert_eq!( - size, 1000, - "aws-chunked admission must use the decoded object length, not the framed wire length" - ); - - // A declared-only aws-chunked request that still carries a decoded length behaves the same. - let headers = aws_chunked_headers(Some("1000")); - let size = resolve_put_object_authoritative_size(&headers, Some(1088)).expect("decoded length is authoritative"); - assert_eq!(size, 1000); - } - - #[test] - fn authoritative_size_streaming_without_content_encoding_uses_decoded_length() { - // A streaming payload signals framing via x-amz-content-sha256 alone; Content-Encoding is optional. - let mut headers = HeaderMap::new(); - headers.insert( - HeaderName::from_bytes(AMZ_CONTENT_SHA256.as_bytes()).unwrap(), - HeaderValue::from_static("STREAMING-UNSIGNED-PAYLOAD-TRAILER"), - ); - headers.insert( - HeaderName::from_bytes(AMZ_DECODED_CONTENT_LENGTH.as_bytes()).unwrap(), - HeaderValue::from_static("1000"), - ); - let size = resolve_put_object_authoritative_size(&headers, Some(1088)).expect("decoded length is authoritative"); - assert_eq!( - size, 1000, - "a streaming payload without Content-Encoding must still use the decoded length" - ); - } - - #[test] - fn authoritative_size_rejects_framed_body_without_decoded_length() { - // A genuinely framed upload without x-amz-decoded-content-length has no authoritative size; - // the framed wire length must NOT be a fallback. - let headers = streaming_headers(None); - let err = resolve_put_object_authoritative_size(&headers, Some(1088)) - .expect_err("framed upload without decoded length must be rejected"); - assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); - - // ... even when the wire Content-Length is also absent. - let err = - resolve_put_object_authoritative_size(&headers, None).expect_err("framed upload without any length must be rejected"); - assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); - } - - #[test] - fn authoritative_size_declared_aws_chunked_without_streaming_uses_wire_content_length() { - // backlog#1336: an SDK PUT that merely declares Content-Encoding: aws-chunked (issue #1857 - // clients) has an unframed body and no decoded length; the wire Content-Length is the real - // object size and the request must be admitted, not rejected with UnexpectedContent. - let headers = aws_chunked_headers(None); - let size = resolve_put_object_authoritative_size(&headers, Some(1088)) - .expect("declared-only aws-chunked must fall back to the wire Content-Length"); - assert_eq!(size, 1088); - - // Same for a combined declared encoding (aws-chunked,gzip). - let mut headers = HeaderMap::new(); - headers.insert(http::header::CONTENT_ENCODING, HeaderValue::from_static("aws-chunked,gzip")); - let size = resolve_put_object_authoritative_size(&headers, Some(2048)) - .expect("declared-only aws-chunked,gzip must fall back to the wire Content-Length"); - assert_eq!(size, 2048); - - // Without any length information it is still rejected. - let headers = aws_chunked_headers(None); - let err = resolve_put_object_authoritative_size(&headers, None) - .expect_err("declared-only aws-chunked with no length at all must be rejected"); - assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); - } - - #[test] - fn authoritative_size_plain_put_uses_content_length() { - let headers = HeaderMap::new(); - let size = resolve_put_object_authoritative_size(&headers, Some(4096)).expect("plain PUT uses Content-Length"); - assert_eq!(size, 4096); - } - - #[test] - fn authoritative_size_plain_put_falls_back_to_decoded_length() { - // Non-chunked request that only surfaced an explicit decoded length. - let mut headers = HeaderMap::new(); - headers.insert( - HeaderName::from_bytes(AMZ_DECODED_CONTENT_LENGTH.as_bytes()).unwrap(), - HeaderValue::from_static("2048"), - ); - let size = resolve_put_object_authoritative_size(&headers, None).expect("decoded length is the fallback"); - assert_eq!(size, 2048); - } - - #[test] - fn authoritative_size_rejects_unknown_length() { - let headers = HeaderMap::new(); - let err = resolve_put_object_authoritative_size(&headers, None).expect_err("no length information must be rejected"); - assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); - } - - #[test] - fn authoritative_size_rejects_negative_length() { - // A negative decoded length would wrap to an enormous unsigned size for quota/buffer sizing; reject it. - let headers = aws_chunked_headers(Some("-1")); - let err = - resolve_put_object_authoritative_size(&headers, Some(64)).expect_err("negative decoded length must be rejected"); - assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); - - let plain = HeaderMap::new(); - let err = - resolve_put_object_authoritative_size(&plain, Some(-100)).expect_err("negative Content-Length must be rejected"); - assert_eq!(err.code(), &S3ErrorCode::UnexpectedContent); - } - - #[test] - fn authoritative_size_accepts_exact_and_rejects_negative_boundary() { - // Exact zero-length object is admissible (the over-by-1/exact-limit boundary is enforced by the quota checker on this value). - let headers = aws_chunked_headers(Some("0")); - assert_eq!( - resolve_put_object_authoritative_size(&headers, Some(87)).expect("zero-length decoded is valid"), - 0 - ); - } - - fn quota_result(allowed: bool) -> QuotaCheckResult { - QuotaCheckResult { - allowed, - current_usage: Some(1024), - quota_limit: Some(2048), - operation_size: 512, - remaining: Some(512), - uses_durable_reservations: true, - } - } - - #[tokio::test] - #[serial_test::serial] - async fn quota_rejects_ciphertext_replication_before_polling_the_body() { - use std::sync::atomic::{AtomicBool, Ordering}; - - let (_store, bucket) = - crate::app::gating_test_env::durable_quota_test_bucket("ciphertext-replication-early-reject", 4096).await; - let body_polled = Arc::new(AtomicBool::new(false)); - let body_polled_in_stream = Arc::clone(&body_polled); - let body = StreamingBlob::wrap(futures::stream::once(async move { - body_polled_in_stream.store(true, Ordering::Release); - Ok::(Bytes::from_static(b"ciphertext")) - })); - let input = PutObjectInput::builder() - .bucket(bucket) - .key("object".to_string()) - .body(Some(body)) - .content_length(Some(10)) - .build() - .expect("ciphertext replication PUT input should build"); - let mut request = build_request(input, Method::PUT); - insert_header(&mut request.headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true"); - request - .headers - .insert(rustfs_utils::http::REPLICATION_SSEC_ALGORITHM_HEADER, HeaderValue::from_static("AES256")); - request.extensions.insert(crate::storage::access::ReqInfo { - replication_request_authorized: true, - ..Default::default() - }); - - let err = DefaultObjectUsecase::from_global() - .execute_put_object(&FS::new(), request) - .await - .expect_err("quota-enabled ciphertext replication should fail at ingress"); - assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); - assert!(!body_polled.load(Ordering::Acquire), "rejected ciphertext body must not be consumed"); - } - - #[tokio::test] - #[serial_test::serial] - async fn legacy_quota_rejects_full_put_before_polling_the_body() { - use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions}; - use std::sync::atomic::{AtomicBool, Ordering}; - - const GI_B: u64 = 1024 * 1024 * 1024; - let store = crate::app::gating_test_env::shared_gating_ecstore().await; - crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await; - let bucket = format!("legacy-quota-{}", Uuid::new_v4().simple()); - store - .make_bucket(&bucket, &MakeBucketOptions::default()) - .await - .expect("create legacy quota test bucket"); - crate::app::storage_api::test::data_usage::seed_bucket_usage_memory_for_test(&bucket, 4 * GI_B).await; - let metadata_sys = DefaultObjectUsecase::from_global() - .bucket_metadata_sys() - .expect("test app context should expose bucket metadata"); - QuotaChecker::new(metadata_sys) - .set_quota_config( - &bucket, - BucketQuota { - quota: Some(5 * GI_B), - ..Default::default() - }, - ) - .await - .expect("configure legacy quota"); - - let body_polled = Arc::new(AtomicBool::new(false)); - let body_polled_in_stream = Arc::clone(&body_polled); - let body = StreamingBlob::wrap(futures::stream::once(async move { - body_polled_in_stream.store(true, Ordering::Release); - Ok::(Bytes::new()) - })); - let input = PutObjectInput::builder() - .bucket(bucket) - .key("object".to_string()) - .body(Some(body)) - .content_length(Some(i64::try_from(2 * GI_B).expect("test size should fit i64"))) - .build() - .expect("legacy quota PUT input should build"); - - let err = DefaultObjectUsecase::from_global() - .execute_put_object(&FS::new(), build_request(input, Method::PUT)) - .await - .expect_err("4 GiB used plus a 2 GiB PUT must exceed a 5 GiB legacy quota"); - assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); - assert!(!body_polled.load(Ordering::Acquire), "legacy quota rejection must not consume the body"); - } - - #[test] - fn quota_admission_allows_within_limit() { - let result = map_quota_check_outcome("bucket", Ok(quota_result(true))).expect("an allowed result admits the write"); - - assert_eq!(result.current_usage, Some(1024)); - assert_eq!(result.quota_limit, Some(2048)); - assert_eq!(result.operation_size, 512); - assert_eq!(result.remaining, Some(512)); - } - - #[tokio::test] - #[serial_test::serial] - async fn concurrent_puts_share_durable_bucket_quota_reservations() { - let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("concurrent-put-quota", 6000).await; - - let first_opts = ObjectOptions::default(); - let second_opts = ObjectOptions::default(); - let first_store = Arc::clone(&store); - let first_bucket = bucket.clone(); - let first = tokio::spawn(async move { - let mut reader = PutObjReader::from_vec(vec![0x73; 4096]); - first_store.put_object(&first_bucket, "first", &mut reader, &first_opts).await - }); - let second = tokio::spawn(async move { - let mut reader = PutObjReader::from_vec(vec![0x74; 4096]); - store.put_object(&bucket, "second", &mut reader, &second_opts).await - }); - let (first, second) = tokio::join!(first, second); - let first = first.expect("first PUT task should not panic"); - let second = second.expect("second PUT task should not panic"); - - assert_eq!(usize::from(first.is_ok()) + usize::from(second.is_ok()), 1); - let denied = first.err().or_else(|| second.err()).expect("one PUT must be denied"); - assert!(matches!( - denied, - StorageError::QuotaExceeded { - current: 4096, - limit: 6000 - } - )); - } - - #[tokio::test] - #[serial_test::serial] - async fn concurrent_within_limit_puts_keep_independent_mutation_fences() { - use crate::app::storage_api::test::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause}; - - let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("concurrent-fence-quota", 8192).await; - let first_barrier = PutObjectCommitBarrier::install(&bucket, "first", PutObjectCommitPause::BeforeQuotaRename); - let second_barrier = PutObjectCommitBarrier::install(&bucket, "second", PutObjectCommitPause::BeforeQuotaRename); - - let first_store = Arc::clone(&store); - let first_bucket = bucket.clone(); - let first = tokio::spawn(async move { - let mut reader = PutObjReader::from_vec(vec![0x75; 4096]); - first_store - .put_object(&first_bucket, "first", &mut reader, &ObjectOptions::default()) - .await - }); - let second_store = Arc::clone(&store); - let second_bucket = bucket.clone(); - let second = tokio::spawn(async move { - let mut reader = PutObjReader::from_vec(vec![0x76; 4096]); - second_store - .put_object(&second_bucket, "second", &mut reader, &ObjectOptions::default()) - .await - }); - - first_barrier.wait_until_paused().await; - second_barrier.wait_until_paused().await; - first_barrier.release(); - second_barrier.release(); - - first - .await - .expect("first PUT task should not panic") - .expect("first within-limit PUT should commit"); - second - .await - .expect("second PUT task should not panic") - .expect("second within-limit PUT should commit"); - } - - #[tokio::test] - #[serial_test::serial] - async fn put_rejects_rotated_quota_capability_before_rename() { - use crate::app::storage_api::test::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause}; - - let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("rotated-proof-put-quota", 4096).await; - let barrier = PutObjectCommitBarrier::install(&bucket, "object", PutObjectCommitPause::BeforeQuotaRename); - let put_store = Arc::clone(&store); - let put_bucket = bucket.clone(); - let put = tokio::spawn(async move { - let mut reader = PutObjReader::from_vec(vec![0x77; 4096]); - put_store - .put_object(&put_bucket, "object", &mut reader, &ObjectOptions::default()) - .await - }); - barrier.wait_until_paused().await; - assert!( - crate::storage::storage_api::ecstore_notification::rotate_cross_pool_fence_fleet_proof_for_test(), - "the gating environment must have a current fleet proof" - ); - barrier.release(); - - let err = put - .await - .expect("PUT task should not panic") - .expect_err("a replaced fleet proof must fence the authoritative rename"); - assert!(matches!( - err, - StorageError::NamespaceLockQuorumUnavailable { - mode: "quota_reservation", - .. - } - )); - store - .get_object_info(&bucket, "object", &ObjectOptions::default()) - .await - .expect_err("proof rotation before rename must leave no committed object"); - } - - #[tokio::test] - #[serial_test::serial] - async fn durable_quota_reclaims_overwrites_and_deleted_bytes() { - let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("quota-delta-reconcile", 4096).await; - - for byte in [0x41, 0x42] { - let mut reader = PutObjReader::from_vec(vec![byte; 4096]); - store - .put_object(&bucket, "object", &mut reader, &ObjectOptions::default()) - .await - .expect("same-size overwrite must consume no additional quota"); - } - - store - .delete_object(&bucket, "object", ObjectOptions::default()) - .await - .expect("delete quota-tracked object"); - let mut replacement = PutObjReader::from_vec(vec![0x43; 4096]); - store - .put_object(&bucket, "replacement", &mut replacement, &ObjectOptions::default()) - .await - .expect("deleted bytes must be reclaimed before rejecting a replacement"); - - let mut excess = PutObjReader::from_vec(vec![0x44]); - let err = store - .put_object(&bucket, "excess", &mut excess, &ObjectOptions::default()) - .await - .expect_err("one byte beyond the reclaimed exact quota must be denied"); - assert!(matches!( - err, - StorageError::QuotaExceeded { - current: 4096, - limit: 4096 - } - )); - } - - #[tokio::test] - #[serial_test::serial] - async fn data_movement_put_has_zero_quota_growth() { - let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("data-movement-put-quota", 0).await; - let mut reader = PutObjReader::from_vec(vec![0x79; 4096]); - let stored = store - .put_object( - &bucket, - "object", - &mut reader, - &ObjectOptions { - data_movement: true, - ..Default::default() - }, - ) - .await - .expect("moving an already-accounted object between pools must have zero quota growth"); - assert_eq!(stored.size, 4096); - } - - #[tokio::test] - #[serial_test::serial] - async fn cancelled_put_releases_durable_quota_reservation() { - use crate::app::storage_api::test::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause}; - - let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("cancelled-put-quota", 4096).await; - - let barrier = PutObjectCommitBarrier::install(&bucket, "cancelled", PutObjectCommitPause::AfterQuotaReservation); - let cancelled_store = Arc::clone(&store); - let cancelled_bucket = bucket.clone(); - let cancelled = tokio::spawn(async move { - let mut reader = PutObjReader::from_vec(vec![0x51; 4096]); - cancelled_store - .put_object(&cancelled_bucket, "cancelled", &mut reader, &ObjectOptions::default()) - .await - }); - barrier.wait_until_paused().await; - cancelled.abort(); - let cancelled_result = cancelled.await; - assert!(cancelled_result.is_err(), "the paused request must be cancelled"); - drop(barrier); - - let mut replacement = PutObjReader::from_vec(vec![0x52; 4096]); - store - .put_object(&bucket, "replacement", &mut replacement, &ObjectOptions::default()) - .await - .expect("cancelling before commit must release the complete reservation"); - } - - #[tokio::test] - #[serial_test::serial] - async fn cancelled_put_after_commit_marker_is_reconciled() { - use crate::app::storage_api::test::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause}; - - let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("cancelled-spawned-put-quota", 4096).await; - let commit_barrier = PutObjectCommitBarrier::install(&bucket, "object", PutObjectCommitPause::BeforeQuotaRename); - let first_store = Arc::clone(&store); - let first_bucket = bucket.clone(); - let first = tokio::spawn(async move { - let mut reader = PutObjReader::from_vec(vec![0x53; 4096]); - first_store - .put_object(&first_bucket, "object", &mut reader, &ObjectOptions::default()) - .await - }); - commit_barrier.wait_until_paused().await; - first.abort(); - assert!(first.await.is_err(), "the outer request task must be cancelled"); - drop(commit_barrier); - - store - .get_object_info(&bucket, "object", &ObjectOptions::default()) - .await - .expect_err("cancelling before rename must not commit the object"); - let mut replacement = PutObjReader::from_vec(vec![0x54; 4096]); - store - .put_object(&bucket, "replacement", &mut replacement, &ObjectOptions::default()) - .await - .expect("the next admission must reap the abandoned commit marker"); - } - - #[tokio::test] - #[serial_test::serial] - async fn committed_put_survives_quota_ledger_settlement_failure() { - use crate::app::storage_api::test::set_disk::{ - PutObjectCommitBarrier, PutObjectCommitPause, fail_next_quota_ledger_save_for_test, - }; - - let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("settlement-failure-quota", 4096).await; - let barrier = PutObjectCommitBarrier::install(&bucket, "object", PutObjectCommitPause::BeforeQuotaRename); - let put_store = Arc::clone(&store); - let put_bucket = bucket.clone(); - let put = tokio::spawn(async move { - let mut reader = PutObjReader::from_vec(vec![0x59; 4096]); - put_store - .put_object(&put_bucket, "object", &mut reader, &ObjectOptions::default()) - .await - }); - barrier.wait_until_paused().await; - fail_next_quota_ledger_save_for_test(); - barrier.release(); - put.await - .expect("PUT task should not panic") - .expect("a post-commit ledger failure must not change the successful write result"); - let stored = store - .get_object_info(&bucket, "object", &ObjectOptions::default()) - .await - .expect("the committed object must remain visible"); - assert_eq!(stored.size, 4096); - } - - #[tokio::test] - #[serial_test::serial] - async fn suspended_null_version_overwrite_uses_exact_quota_delta() { - let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("suspended-version-quota", 6200).await; - let mut versioned_reader = PutObjReader::from_vec(vec![0x61; 4096]); - store - .put_object( - &bucket, - "object", - &mut versioned_reader, - &ObjectOptions { - versioned: true, - ..Default::default() - }, - ) - .await - .expect("write UUID version"); - - for (size, byte) in [(1024, 0x62), (2048, 0x63)] { - let mut reader = PutObjReader::from_vec(vec![byte; size]); - store - .put_object( - &bucket, - "object", - &mut reader, - &ObjectOptions { - version_suspended: true, - ..Default::default() - }, - ) - .await - .expect("suspended write should replace only the exact null version"); - } - - let mut excess = PutObjReader::from_vec(vec![0x64; 57]); - let err = store - .put_object(&bucket, "excess", &mut excess, &ObjectOptions::default()) - .await - .expect_err("UUID plus replacement null version must consume 6144 bytes"); - assert!(matches!( - err, - StorageError::QuotaExceeded { - current: 6144, - limit: 6200 - } - )); - } - - #[tokio::test] - #[serial_test::serial] - async fn durable_quota_reservation_observes_lowered_config_revision() { - let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("lowered-quota-revision", 8192).await; - let mut initial = PutObjReader::from_vec(vec![0x71; 4096]); - store - .put_object(&bucket, "initial", &mut initial, &ObjectOptions::default()) - .await - .expect("write under original quota"); - - let metadata_sys = DefaultObjectUsecase::from_global() - .bucket_metadata_sys() - .expect("test app context should expose bucket metadata"); - QuotaChecker::new(metadata_sys) - .set_quota_config(&bucket, BucketQuota::new(Some(4096))) - .await - .expect("lower bucket quota"); - let mut excess = PutObjReader::from_vec(vec![0x72]); - let err = store - .put_object(&bucket, "excess", &mut excess, &ObjectOptions::default()) - .await - .expect_err("reservation must not use the stale larger quota revision"); - assert!(matches!( - err, - StorageError::QuotaExceeded { - current: 4096, - limit: 4096 - } - )); - } - - #[tokio::test] - #[serial_test::serial] - async fn quota_enable_waits_for_unlimited_commit() { - use crate::app::storage_api::test::metadata_sys::ConfigWriteLockProbe; - use crate::app::storage_api::test::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause}; - - let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("quota-config-fence", 8192).await; - let metadata_sys = DefaultObjectUsecase::from_global() - .bucket_metadata_sys() - .expect("test app context should expose bucket metadata"); - QuotaChecker::new(Arc::clone(&metadata_sys)) - .set_quota_config(&bucket, BucketQuota::new(None)) - .await - .expect("clear quota before the fenced write"); - let barrier = PutObjectCommitBarrier::install(&bucket, "object", PutObjectCommitPause::AfterQuotaReservation); - let put_store = Arc::clone(&store); - let put_bucket = bucket.clone(); - let put = tokio::spawn(async move { - let mut reader = PutObjReader::from_vec(vec![0x73; 4096]); - put_store - .put_object(&put_bucket, "object", &mut reader, &ObjectOptions::default()) - .await - }); - barrier.wait_until_paused().await; - - let update_probe = ConfigWriteLockProbe::install(&bucket); - let update_bucket = bucket.clone(); - let update = tokio::spawn(async move { - QuotaChecker::new(metadata_sys) - .set_quota_config(&update_bucket, BucketQuota::new(Some(0))) - .await - }); - update_probe.wait_until_attempted().await; - assert!( - !update.is_finished(), - "quota mutation must wait for the reservation's metadata transaction guard" - ); - - barrier.release(); - put.await - .expect("PUT task should not panic") - .expect("the write linearized before the quota update must commit"); - update - .await - .expect("quota update task should not panic") - .expect("quota update should proceed after commit"); - - let mut excess = PutObjReader::from_vec(vec![0x74]); - let err = store - .put_object(&bucket, "excess", &mut excess, &ObjectOptions::default()) - .await - .expect_err("writes after the zero-byte quota update must be denied"); - assert!(matches!(err, StorageError::QuotaExceeded { current: 4096, limit: 0 })); - } - - #[test] - fn quota_admission_rejects_over_limit() { - let err = map_quota_check_outcome("bucket", Ok(quota_result(false))).expect_err("an over-limit result rejects the write"); - assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); - } - - #[test] - fn legacy_quota_admission_rejects_already_over_limit() { - let result = QuotaCheckResult { - allowed: true, - current_usage: Some(6), - quota_limit: Some(5), - operation_size: 0, - remaining: Some(0), - uses_durable_reservations: false, - }; - let mut opts = ObjectOptions::default(); - let err = - apply_quota_admission(&mut opts, &result).expect_err("legacy completion must not bypass an already exceeded quota"); - assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); - } - - #[test] - fn quota_admission_fails_closed_on_checker_error() { - // A configured hard quota must never be bypassed by an internal fault: a checker error becomes a retryable ServiceUnavailable, not a silent allow. - let err = map_quota_check_outcome( - "bucket", - Err(QuotaError::InvalidConfig { - reason: "corrupt quota config".to_string(), - }), - ) - .expect_err("a checker fault must fail closed"); - assert_eq!(err.code(), &S3ErrorCode::ServiceUnavailable); - } - - #[test] - fn legacy_archive_quota_rejects_cumulative_size_and_overflow() { - let legacy = QuotaCheckResult { - allowed: true, - current_usage: Some(4), - quota_limit: Some(5), - operation_size: 0, - remaining: Some(1), - uses_durable_reservations: false, - }; - assert!(ensure_legacy_archive_size_within_quota(&legacy, 2).is_err()); - assert!(ensure_legacy_archive_size_within_quota(&legacy, 1).is_ok()); - - let maxed = QuotaCheckResult { - current_usage: Some(u64::MAX), - quota_limit: Some(u64::MAX), - ..legacy - }; - assert!(ensure_legacy_archive_size_within_quota(&maxed, 1).is_err()); - } - - #[test] - fn early_quota_filter_rejects_only_an_individually_impossible_object() { - let stale_full_usage = QuotaCheckResult { - allowed: true, - current_usage: Some(4096), - quota_limit: Some(4096), - operation_size: 0, - remaining: Some(0), - uses_durable_reservations: true, - }; - - ensure_object_size_within_quota(&stale_full_usage, 4096) - .expect("commit-time ledger must decide whether stale usage was reclaimed"); - let err = ensure_object_size_within_quota(&stale_full_usage, 4097) - .expect_err("an object larger than the whole quota can never fit"); - assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); - } - - #[test] - fn quota_admission_fails_closed_on_unknown_authoritative_usage() { - let err = map_quota_check_outcome( - "bucket", - Err(QuotaError::UsageUnavailable { - bucket: "bucket".to_string(), - }), - ) - .expect_err("unknown authoritative usage must not admit a quota-controlled write"); - assert_eq!(err.code(), &S3ErrorCode::ServiceUnavailable); - } -} +pub use super::object::*; diff --git a/rustfs/src/app/storage_api.rs b/rustfs/src/app/storage_api.rs index f11b10c38..85967ab55 100644 --- a/rustfs/src/app/storage_api.rs +++ b/rustfs/src/app/storage_api.rs @@ -137,7 +137,7 @@ pub(crate) mod runtime { pub(crate) type NotificationSys = crate::storage::storage_api::NotificationSys; pub(crate) type ObjectStoreResolver = crate::storage::storage_api::ObjectStoreResolver; pub(crate) type ReplicationStats = crate::storage::storage_api::ReplicationStats; - pub(crate) type ScannerMetricsReport = rustfs_common::metrics::ScannerMetricsReport; + pub(crate) type ScannerMetricsReport = rustfs_scanner_contracts::metrics::ScannerMetricsReport; pub(crate) type StorageClassConfig = crate::storage::storage_api::ecstore_config::storageclass::Config; pub(crate) type TierConfigMgr = crate::storage::storage_api::TierConfigMgr; pub(crate) type TransitionState = crate::storage::storage_api::TransitionState; @@ -213,7 +213,7 @@ pub(crate) mod runtime { } pub(crate) async fn collect_scanner_metrics_report() -> ScannerMetricsReport { - rustfs_common::metrics::global_metrics().report().await + rustfs_scanner_contracts::metrics::global_metrics().report().await } #[cfg(test)] @@ -559,16 +559,20 @@ pub(crate) mod bucket { } pub(crate) mod object_lock { + pub(crate) mod types { + pub(crate) use crate::storage::storage_api::ecstore_bucket::object_lock::types::RetentionMode; + } + pub(crate) mod objectlock { pub(crate) fn get_object_legalhold_meta( meta: &std::collections::HashMap, - ) -> s3s::dto::ObjectLockLegalHold { + ) -> crate::storage::storage_api::ecstore_bucket::object_lock::types::ObjectLegalHold { crate::storage::storage_api::ecstore_bucket::object_lock::objectlock::get_object_legalhold_meta(meta) } pub(crate) fn get_object_retention_meta( meta: &std::collections::HashMap, - ) -> s3s::dto::ObjectLockRetention { + ) -> crate::storage::storage_api::ecstore_bucket::object_lock::types::ObjectRetention { crate::storage::storage_api::ecstore_bucket::object_lock::objectlock::get_object_retention_meta(meta) } } @@ -587,7 +591,10 @@ pub(crate) mod bucket { .await } - pub(crate) fn is_retention_active(mode: &str, retain_until_date: Option<&s3s::dto::Date>) -> bool { + pub(crate) fn is_retention_active( + mode: crate::storage::storage_api::ecstore_bucket::object_lock::types::RetentionMode, + retain_until_date: Option, + ) -> bool { crate::storage::storage_api::ecstore_bucket::object_lock::objectlock_sys::is_retention_active( mode, retain_until_date, @@ -1185,7 +1192,9 @@ pub(crate) mod multipart_usecase { } pub(crate) mod object { - pub(crate) use super::super::super::storage_contracts::{ObjectIO, ObjectOperations}; + #[cfg(test)] + pub(crate) use super::super::super::storage_contracts::ObjectIO; + pub(crate) use super::super::super::storage_contracts::ObjectOperations; } pub(crate) mod range { diff --git a/rustfs/src/auth.rs b/rustfs/src/auth.rs index 73c869c6c..ed20d6e1f 100644 --- a/rustfs/src/auth.rs +++ b/rustfs/src/auth.rs @@ -38,6 +38,7 @@ use subtle::ConstantTimeEq; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; use tracing::{debug, trace, warn}; +use url::form_urlencoded; const LOG_COMPONENT_AUTH: &str = "auth"; const LOG_SUBSYSTEM_CREDENTIALS: &str = "credentials"; @@ -50,6 +51,15 @@ const EVENT_KEYSTONE_CREDENTIALS_VALIDATED: &str = "keystone_credentials_validat const EVENT_KEYSTONE_CONTEXT_MISSING: &str = "keystone_context_missing"; const EVENT_SESSION_TOKEN_EXTRACTION: &str = "session_token_extraction"; +/// RustFS-specific query capability for a single presigned PutObject request. +pub(crate) const RUSTFS_MAX_CONTENT_LENGTH_QUERY: &str = "x-rustfs-max-content-length"; + +/// Inserted by the S3 access boundary after the upstream verifier accepts a +/// request as SigV4 presigned. Downstream capability parsing must require this +/// marker instead of treating query syntax as proof of authentication. +#[derive(Debug, Clone, Copy)] +pub(crate) struct VerifiedPresignedRequest; + /// Performs constant-time string comparison to prevent timing attacks. /// /// This function should be used when comparing sensitive values like passwords, @@ -913,9 +923,11 @@ pub(crate) fn is_request_presigned_signature_v4_with_query(header: &HeaderMap, q if let Some(credential) = header.get(AMZ_CREDENTIAL) { return !credential.to_str().unwrap_or("").is_empty(); } - query - .and_then(|query| get_query_param(query, "x-amz-credential")) - .is_some_and(|credential| !credential.is_empty()) + query.is_some_and(|query| { + form_urlencoded::parse(query.as_bytes()) + .find(|(name, _)| name.eq_ignore_ascii_case("x-amz-credential")) + .is_some_and(|(_, credential)| !credential.is_empty()) + }) } /// Verify request has AWS PreSign Version '2' @@ -1007,6 +1019,98 @@ pub fn get_query_param<'a>(query: &'a str, param_name: &str) -> Option<&'a str> None } +/// Parse the RustFS presigned PutObject size capability after authentication. +/// +/// The query value is covered by SigV4 when it is present before presigning, but +/// the signature does not assign any semantics to the extension. Keep parsing +/// strict and only enable the capability for a verified SigV4 presigned request. +pub(crate) fn parse_presigned_put_max_content_length( + header: &HeaderMap, + query: Option<&str>, + verified_presigned: bool, +) -> S3Result> { + let Some(query) = query else { + return Ok(None); + }; + + let mut value = None; + let mut decoded_query = Vec::new(); + for (name, candidate) in form_urlencoded::parse(query.as_bytes()) { + decoded_query.push((name.to_string(), candidate.to_string())); + if name == RUSTFS_MAX_CONTENT_LENGTH_QUERY { + if value.is_some() { + return Err(S3Error::with_message( + S3ErrorCode::InvalidRequest, + format!("{RUSTFS_MAX_CONTENT_LENGTH_QUERY} must appear exactly once"), + )); + } + value = Some(candidate.into_owned()); + } else if name.eq_ignore_ascii_case(RUSTFS_MAX_CONTENT_LENGTH_QUERY) { + return Err(S3Error::with_message( + S3ErrorCode::InvalidRequest, + format!("query parameter name must be exactly {RUSTFS_MAX_CONTENT_LENGTH_QUERY}"), + )); + } + } + + let Some(value) = value else { + return Ok(None); + }; + + let query_value = |wanted: &str| { + decoded_query + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(wanted)) + .map(|(_, value)| value.as_str()) + }; + let is_complete_sigv4_query = [ + ("x-amz-algorithm", "AWS4-HMAC-SHA256"), + ("x-amz-date", ""), + ("x-amz-expires", ""), + ("x-amz-signedheaders", ""), + ("x-amz-credential", ""), + ("x-amz-signature", ""), + ] + .into_iter() + .all(|(name, expected)| { + query_value(name).is_some_and(|value| !value.is_empty() && (expected.is_empty() || value == expected)) + }); + if !verified_presigned + || !is_complete_sigv4_query + || !matches!(get_request_auth_type_with_query(header, Some(query)), AuthType::Presigned) + { + return Err(S3Error::with_message( + S3ErrorCode::InvalidRequest, + format!("{RUSTFS_MAX_CONTENT_LENGTH_QUERY} requires a SigV4 presigned request"), + )); + } + + let limit = value.parse::().map_err(|_| { + S3Error::with_message( + S3ErrorCode::InvalidRequest, + format!("{RUSTFS_MAX_CONTENT_LENGTH_QUERY} must be a non-negative 64-bit integer"), + ) + })?; + + Ok(Some(limit)) +} + +/// Reject the PutObject-only size capability when it appears on another +/// operation. Callers must invoke this after request authentication has run. +pub(crate) fn reject_presigned_put_max_content_length_for_other_operation( + header: &HeaderMap, + query: Option<&str>, + verified_presigned: bool, +) -> S3Result<()> { + if parse_presigned_put_max_content_length(header, query, verified_presigned)?.is_some() { + return Err(S3Error::with_message( + S3ErrorCode::InvalidRequest, + format!("{RUSTFS_MAX_CONTENT_LENGTH_QUERY} is only supported for presigned PutObject"), + )); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -1651,6 +1755,67 @@ mod tests { assert_eq!(result, Some("value=with=equals")); } + #[test] + fn presigned_put_max_content_length_requires_exactly_one_signed_query_value() { + let headers = HeaderMap::new(); + let signed_prefix = "X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260827T000000Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host&X-Amz-Credential=test/20260827/us-east-1/s3/aws4_request&X-Amz-Signature=signature"; + + let query = format!("{signed_prefix}&x-rustfs-max-content-length=104857600"); + assert_eq!( + parse_presigned_put_max_content_length(&headers, Some(&query), true).unwrap(), + Some(104_857_600) + ); + + let encoded_credential = query.replacen("X-Amz-Credential", "X%2DAmz-Credential", 1); + assert_eq!( + parse_presigned_put_max_content_length(&headers, Some(&encoded_credential), true).unwrap(), + Some(104_857_600) + ); + + let duplicate = format!("{query}&x-rustfs-max-content-length=1"); + assert_eq!( + parse_presigned_put_max_content_length(&headers, Some(&duplicate), true) + .unwrap_err() + .code(), + &S3ErrorCode::InvalidRequest + ); + + let wrong_case = format!("{signed_prefix}&X-RustFS-Max-Content-Length=1"); + assert_eq!( + parse_presigned_put_max_content_length(&headers, Some(&wrong_case), true) + .unwrap_err() + .code(), + &S3ErrorCode::InvalidRequest + ); + + assert_eq!( + reject_presigned_put_max_content_length_for_other_operation(&headers, Some(&query), true) + .unwrap_err() + .code(), + &S3ErrorCode::InvalidRequest + ); + } + + #[test] + fn presigned_put_max_content_length_rejects_unsigned_or_invalid_values() { + let headers = HeaderMap::new(); + let forged = "X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260827T000000Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host&X-Amz-Credential=test/credential&X-Amz-Signature=fake&x-rustfs-max-content-length=1"; + assert_eq!( + parse_presigned_put_max_content_length(&headers, Some(forged), false) + .unwrap_err() + .code(), + &S3ErrorCode::InvalidRequest + ); + for query in [ + "x-rustfs-max-content-length=1", + "X-Amz-Credential=test/credential&x-rustfs-max-content-length=-1", + "X-Amz-Credential=test/credential&x-rustfs-max-content-length=18446744073709551616", + ] { + let error = parse_presigned_put_max_content_length(&headers, Some(query), true).unwrap_err(); + assert_eq!(error.code(), &S3ErrorCode::InvalidRequest); + } + } + #[test] fn test_credentials_is_expired() { let mut cred = create_test_credentials(); diff --git a/rustfs/src/cluster_snapshot.rs b/rustfs/src/cluster_snapshot.rs index e7535eb5d..f061fa981 100644 --- a/rustfs/src/cluster_snapshot.rs +++ b/rustfs/src/cluster_snapshot.rs @@ -23,9 +23,9 @@ use crate::storage_api::cluster::control_plane::{ ClusterPeerHealthSnapshot, ClusterPoolStateSnapshot, ClusterRpcBoundarySnapshot, }; use crate::workload_admission::workload_admission_registry_snapshot; -use rustfs_common::metrics::{ScannerMetricsReport, global_metrics}; use rustfs_concurrency::{AdmissionState, WorkloadAdmissionRegistrySnapshot}; use rustfs_io_metrics::internode_metrics::{InternodeMetricsSnapshot, global_internode_metrics}; +use rustfs_scanner_contracts::metrics::{ScannerMetricsReport, global_metrics}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ClusterReadOnlySnapshot { diff --git a/rustfs/src/config/info.rs b/rustfs/src/config/info.rs index 95289581c..9468bc1dc 100644 --- a/rustfs/src/config/info.rs +++ b/rustfs/src/config/info.rs @@ -591,12 +591,19 @@ struct FeatureSpec { default_enabled: bool, } -fn feature_specs() -> [FeatureSpec; 8] { - [ +fn feature_specs() -> &'static [FeatureSpec] { + &[ + FeatureSpec { + name: "default", + enabled: cfg!(feature = "default"), + description: "Default feature set", + dependencies: "ftps + webdav", + default_enabled: true, + }, FeatureSpec { name: "metrics-gpu", enabled: cfg!(feature = "metrics-gpu"), - description: "Metrics GPU support", + description: "GPU metrics support", dependencies: "rustfs-obs/gpu", default_enabled: false, }, @@ -610,7 +617,7 @@ fn feature_specs() -> [FeatureSpec; 8] { FeatureSpec { name: "swift", enabled: cfg!(feature = "swift"), - description: "Swift storage backend", + description: "OpenStack Swift protocol support", dependencies: "rustfs-protocols/swift", default_enabled: false, }, @@ -621,6 +628,13 @@ fn feature_specs() -> [FeatureSpec; 8] { dependencies: "rustfs-protocols/webdav", default_enabled: true, }, + FeatureSpec { + name: "sftp", + enabled: cfg!(feature = "sftp"), + description: "SFTP protocol support", + dependencies: "rustfs-protocols/sftp", + default_enabled: false, + }, FeatureSpec { name: "license", enabled: cfg!(feature = "license"), @@ -636,17 +650,80 @@ fn feature_specs() -> [FeatureSpec; 8] { default_enabled: false, }, FeatureSpec { - name: "manual-test-runners", - enabled: cfg!(feature = "manual-test-runners"), - description: "Enable manual test binaries", + name: "tracing-chunk-debug", + enabled: cfg!(feature = "tracing-chunk-debug"), + description: "Per-chunk data-plane tracing", dependencies: "(none)", default_enabled: false, }, FeatureSpec { name: "full", enabled: cfg!(feature = "full"), - description: "All features enabled", - dependencies: "metrics-gpu + ftps + swift + webdav", + description: "Full protocol and observability bundle", + dependencies: "metrics-gpu + ftps + swift + webdav + sftp + pyroscope", + default_enabled: false, + }, + FeatureSpec { + name: "e2e-test-hooks", + enabled: cfg!(feature = "e2e-test-hooks"), + description: "End-to-end test hooks", + dependencies: "(none)", + default_enabled: false, + }, + FeatureSpec { + name: "connect-e2e-short-credentials", + enabled: cfg!(feature = "connect-e2e-short-credentials"), + description: "Short-lived Connect credentials for debug E2E builds", + dependencies: "(none)", + default_enabled: false, + }, + FeatureSpec { + name: "offline-enrollment-e2e-root", + enabled: cfg!(feature = "offline-enrollment-e2e-root"), + description: "Dedicated offline enrollment E2E root", + dependencies: "(none)", + default_enabled: false, + }, + FeatureSpec { + name: "rio-v2", + enabled: cfg!(feature = "rio-v2"), + description: "RIO v2 storage path support", + dependencies: "rustfs-ecstore/rio-v2", + default_enabled: false, + }, + FeatureSpec { + name: "pyroscope", + enabled: cfg!(feature = "pyroscope"), + description: "Pyroscope profiling support", + dependencies: "rustfs-obs/pyroscope", + default_enabled: false, + }, + FeatureSpec { + name: "dial9", + enabled: cfg!(feature = "dial9"), + description: "Tokio runtime telemetry", + dependencies: "rustfs-obs/dial9", + default_enabled: false, + }, + FeatureSpec { + name: "hotpath", + enabled: cfg!(feature = "hotpath"), + description: "Hotpath instrumentation", + dependencies: "hotpath + RustFS crate hotpath features", + default_enabled: false, + }, + FeatureSpec { + name: "hotpath-alloc", + enabled: cfg!(feature = "hotpath-alloc"), + description: "Hotpath allocation diagnostics", + dependencies: "hotpath + hotpath/hotpath-alloc + RustFS crate hotpath-alloc features", + default_enabled: false, + }, + FeatureSpec { + name: "hotpath-cpu", + enabled: cfg!(feature = "hotpath-cpu"), + description: "Hotpath CPU attribution", + dependencies: "hotpath + hotpath/hotpath-cpu + RustFS crate hotpath-cpu features", default_enabled: false, }, ] @@ -662,7 +739,7 @@ struct DepsInfoJson { fn collect_deps_info_json() -> DepsInfoJson { let features: Vec = feature_specs() - .into_iter() + .iter() .map(|feature| FeatureInfoJson { name: feature.name, enabled: feature.enabled, @@ -908,7 +985,7 @@ fn format_deps_info() -> String { output.push_str("### Feature Status\n\n"); output.push_str("| Feature | Status | Description |\n"); output.push_str("|---------|--------|-------------|\n"); - for feature in &features { + for feature in features { let status = if feature.enabled { "✓" } else { "✗" }; output.push_str(&format!("| {} | {} | {} |\n", feature.name, status, feature.description)); } @@ -923,7 +1000,7 @@ fn format_deps_info() -> String { output.push_str("\n### Feature Dependencies\n\n"); output.push_str("| Feature | Dependencies |\n"); output.push_str("|---------|-------------|\n"); - for feature in &features { + for feature in features { output.push_str(&format!("| {} | {} |\n", feature.name, feature.dependencies)); } @@ -1008,11 +1085,23 @@ mod tests { let info = collect_deps_info_json(); let feature_names: Vec<_> = info.features.iter().map(|feature| feature.name).collect(); - assert_eq!(info.total_count, 8); - assert_eq!(info.features.len(), 8); + assert_eq!(info.total_count, 19); + assert_eq!(info.features.len(), 19); + assert!(feature_names.contains(&"default")); assert!(feature_names.contains(&"metrics-gpu")); + assert!(feature_names.contains(&"sftp")); assert!(feature_names.contains(&"io-scheduler-debug")); - assert!(feature_names.contains(&"manual-test-runners")); + assert!(feature_names.contains(&"tracing-chunk-debug")); + assert!(feature_names.contains(&"e2e-test-hooks")); + assert!(feature_names.contains(&"connect-e2e-short-credentials")); + assert!(feature_names.contains(&"offline-enrollment-e2e-root")); + assert!(feature_names.contains(&"rio-v2")); + assert!(feature_names.contains(&"pyroscope")); + assert!(feature_names.contains(&"dial9")); + assert!(feature_names.contains(&"hotpath")); + assert!(feature_names.contains(&"hotpath-alloc")); + assert!(feature_names.contains(&"hotpath-cpu")); + assert!(!feature_names.contains(&"manual-test-runners")); assert!(!feature_names.contains(&"metrics")); assert!(!feature_names.contains(&"direct-io")); } @@ -1023,10 +1112,16 @@ mod tests { assert!(output.contains("| metrics-gpu |")); assert!(output.contains("| io-scheduler-debug |")); - assert!(output.contains("| manual-test-runners |")); + assert!(output.contains("| tracing-chunk-debug |")); + assert!(output.contains("| sftp |")); + assert!(output.contains("| rio-v2 |")); + assert!(output.contains("| dial9 |")); + assert!(output.contains("| hotpath-cpu |")); + assert!(output.contains("| default | enabled by default |")); + assert!(!output.contains("| manual-test-runners |")); assert!(output.contains("| ftps | enabled by default |")); assert!(output.contains("| webdav | enabled by default |")); - assert!(output.contains("| full | metrics-gpu + ftps + swift + webdav |")); + assert!(output.contains("| full | metrics-gpu + ftps + swift + webdav + sftp + pyroscope |")); assert!(!output.contains("| direct-io |")); } diff --git a/rustfs/src/connect/client.rs b/rustfs/src/connect/client.rs index 114f1d742..9d283460f 100644 --- a/rustfs/src/connect/client.rs +++ b/rustfs/src/connect/client.rs @@ -14,7 +14,6 @@ use std::time::Duration; -use base64::Engine as _; use chrono::{DateTime, Utc}; use reqwest::{Client, StatusCode, Url, header}; use rustls::RootCertStore; @@ -37,6 +36,9 @@ use super::registration::{ const MAX_ATTEMPTS: usize = 3; const MAX_RESPONSE_BYTES: usize = 1024 * 1024; +#[cfg(feature = "connect-e2e-short-credentials")] +const ROTATION_THRESHOLD_SECONDS: i64 = 120; +#[cfg(not(feature = "connect-e2e-short-credentials"))] const ROTATION_THRESHOLD_SECONDS: i64 = 8 * 60 * 60; const PENDING_REGISTRATION_STATE_DOMAIN: &[u8] = b"RUSTFS-CONNECT-PENDING-REGISTRATION-V1"; @@ -225,8 +227,8 @@ impl ConnectClient { pending: &PendingRegistration, identity: &super::identity::DeviceIdentity, ) -> Result { - let csr_der = base64::engine::general_purpose::STANDARD - .decode(&pending.certificate_request) + let csr_der = base64_simd::STANDARD + .decode_to_vec(&pending.certificate_request) .map_err(|_| ClientError::PendingRegistration)?; let transcript = RegistrationTranscript::build( &token.registration_token_uid, diff --git a/rustfs/src/connect/identity.rs b/rustfs/src/connect/identity.rs index e3028c770..8ce7bb890 100644 --- a/rustfs/src/connect/identity.rs +++ b/rustfs/src/connect/identity.rs @@ -20,8 +20,7 @@ //! module produces, so any divergence is a protocol break rather than a //! local behaviour change. -use base64::Engine as _; -use base64::engine::general_purpose::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD as BASE64_URL_NO_PAD}; +use base64_simd::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD as BASE64_URL_NO_PAD}; use p256::ecdsa::signature::{Signer as _, Verifier as _}; use p256::ecdsa::{Signature, SigningKey}; use p256::elliptic_curve::Generate as _; @@ -112,7 +111,7 @@ impl RegistrationTranscript { } let expiry = expires_unix.to_string(); - let csr_digest = BASE64_URL_NO_PAD.encode(Sha256::digest(certificate_request)); + let csr_digest = BASE64_URL_NO_PAD.encode_to_string(Sha256::digest(certificate_request)); let fields: [(&'static str, &str); FIELD_COUNT] = [ ("registrationTokenUid", registration_token_uid), @@ -238,7 +237,7 @@ impl DeviceIdentity { /// Standard padded base64 of the certificate request, as the body carries it. pub fn certificate_request_base64(&self) -> Result { - Ok(BASE64_STANDARD.encode(self.certificate_request_der()?)) + Ok(BASE64_STANDARD.encode_to_string(self.certificate_request_der()?)) } /// Sign a transcript, producing the low-S fixed-width proof. @@ -252,20 +251,20 @@ impl DeviceIdentity { RegistrationProof { algorithm: PROOF_ALGORITHM.to_string(), - value: BASE64_URL_NO_PAD.encode(canonical.to_bytes()), + value: BASE64_URL_NO_PAD.encode_to_string(canonical.to_bytes()), } } pub(crate) fn sign_pending_registration_state(&self, state: &[u8]) -> String { let signature: Signature = self.signing_key.sign(state); - BASE64_URL_NO_PAD.encode(signature.normalize_s().to_bytes()) + BASE64_URL_NO_PAD.encode_to_string(signature.normalize_s().to_bytes()) } pub(crate) fn verifies_pending_registration_state(&self, state: &[u8], proof: &str) -> bool { - let Ok(octets) = BASE64_URL_NO_PAD.decode(proof) else { + let Ok(octets) = BASE64_URL_NO_PAD.decode_to_vec(proof) else { return false; }; - if BASE64_URL_NO_PAD.encode(&octets) != proof { + if BASE64_URL_NO_PAD.encode_to_string(&octets) != proof { return false; } let Ok(signature) = Signature::from_slice(&octets) else { diff --git a/rustfs/src/connect/offline/bundle_writer.rs b/rustfs/src/connect/offline/bundle_writer.rs index e46eb1a43..aea3194c8 100644 --- a/rustfs/src/connect/offline/bundle_writer.rs +++ b/rustfs/src/connect/offline/bundle_writer.rs @@ -25,7 +25,7 @@ use std::path::Path; use std::path::PathBuf; #[cfg(target_os = "linux")] -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use base64_simd::URL_SAFE_NO_PAD; #[cfg(target_os = "linux")] use p256::ecdsa::{Signature, SigningKey, signature::Signer as _}; #[cfg(target_os = "linux")] @@ -163,7 +163,7 @@ fn write_offline_bundle_unix( let produced_at = OffsetDateTime::from_unix_timestamp(context.produced_at_unix).map_err(|_| BundleError::InvalidMetadata)?; let produced_at = produced_at.format(&Rfc3339).map_err(|_| BundleError::InvalidMetadata)?; - let nonce = URL_SAFE_NO_PAD.encode(context.nonce); + let nonce = URL_SAFE_NO_PAD.encode_to_string(context.nonce); let device_key_id = hex_lower(&Sha256::digest(key.public_key_der())); let manifest_entries = entries .iter() @@ -397,7 +397,7 @@ fn sign(key: &DeviceIdentity, manifest: &[u8]) -> Result { input.push(0); input.extend_from_slice(manifest); let signature: Signature = signing_key.sign(&input); - Ok(URL_SAFE_NO_PAD.encode(signature.normalize_s().to_bytes())) + Ok(URL_SAFE_NO_PAD.encode_to_string(signature.normalize_s().to_bytes())) } #[cfg(target_os = "linux")] diff --git a/rustfs/src/connect/offline/enrollment.rs b/rustfs/src/connect/offline/enrollment.rs index a6c7c69d1..ef36ce4cd 100644 --- a/rustfs/src/connect/offline/enrollment.rs +++ b/rustfs/src/connect/offline/enrollment.rs @@ -32,8 +32,7 @@ //! are frozen beside it. Reordering the checks changes which reason a given //! artifact produces, which is itself part of the contract. -use base64::Engine as _; -use base64::engine::general_purpose::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD as BASE64_URL_NO_PAD}; +use base64_simd::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD as BASE64_URL_NO_PAD}; use p256::ecdsa::signature::{Signer as _, Verifier as _}; use p256::ecdsa::{Signature, SigningKey, VerifyingKey}; use p256::pkcs8::DecodePrivateKey as _; @@ -367,7 +366,7 @@ impl OfflineEnrollment { // The octets that were transmitted. They are never re-serialised: every // later step signs and parses this same buffer. let bytes = BASE64_STANDARD - .decode(envelope.bytes.as_bytes()) + .decode_to_vec(envelope.bytes.as_bytes()) .map_err(|_| EnrollmentError::MalformedDocument)?; // Step 2: routing only. @@ -440,8 +439,8 @@ impl OfflineEnrollment { challenge_nonce: &challenge.nonce, challenge_proof: &challenge.challenge_proof, device_key_id: key_id(&point), - device_public_key: BASE64_URL_NO_PAD.encode(point), - device_nonce: BASE64_URL_NO_PAD.encode(device_nonce), + device_public_key: BASE64_URL_NO_PAD.encode_to_string(point), + device_nonce: BASE64_URL_NO_PAD.encode_to_string(device_nonce), produced_at, }; @@ -451,7 +450,7 @@ impl OfflineEnrollment { let signature = sign(key, TAG_RESPONSE, &bytes)?; let envelope = SignedDocument { - bytes: BASE64_STANDARD.encode(&bytes), + bytes: BASE64_STANDARD.encode_to_string(&bytes), signature: DocumentSignature { algorithm: SIGNATURE_ALGORITHM.to_owned(), key_id: document.device_key_id, @@ -537,7 +536,7 @@ fn verify_trust_chain( /// checked against these, never against a re-encoding of the parsed link. fn decode_trust_link(entry: &SignedDocument) -> Result<(TrustLink, Vec), EnrollmentError> { let bytes = BASE64_STANDARD - .decode(entry.bytes.as_bytes()) + .decode_to_vec(entry.bytes.as_bytes()) .map_err(|_| EnrollmentError::MalformedDocument)?; let link = serde_json::from_slice(&bytes).map_err(|_| EnrollmentError::TrustChainInvalid)?; Ok((link, bytes)) @@ -559,7 +558,7 @@ fn decode_signature(signature: &DocumentSignature) -> Result Result Option<(VerifyingKey, [u8; PUBLIC_KEY_OCTET return None; } - let point: [u8; PUBLIC_KEY_OCTETS] = BASE64_URL_NO_PAD.decode(value).ok()?.try_into().ok()?; + let point: [u8; PUBLIC_KEY_OCTETS] = BASE64_URL_NO_PAD.decode_to_vec(value).ok()?.try_into().ok()?; if point[0] != UNCOMPRESSED_POINT { return None; } diff --git a/rustfs/src/connect/registration.rs b/rustfs/src/connect/registration.rs index 3be43c4ac..829002c9b 100644 --- a/rustfs/src/connect/registration.rs +++ b/rustfs/src/connect/registration.rs @@ -15,8 +15,7 @@ use std::io::Read; use std::sync::Arc; -use base64::Engine as _; -use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL_NO_PAD; +use base64_simd::URL_SAFE_NO_PAD as BASE64_URL_NO_PAD; use p256::ecdsa::signature::Signer as _; use p256::ecdsa::{Signature, SigningKey}; use p256::pkcs8::DecodePrivateKey as _; @@ -38,6 +37,12 @@ use super::identity::{DeviceIdentity, RegistrationProof}; pub const PROTOCOL_VERSION: &str = "v1"; +#[cfg(all(feature = "connect-e2e-short-credentials", not(debug_assertions)))] +compile_error!("connect-e2e-short-credentials is restricted to debug builds"); + +#[cfg(feature = "connect-e2e-short-credentials")] +const CERTIFICATE_LIFETIME_SECONDS: i64 = 300; +#[cfg(not(feature = "connect-e2e-short-credentials"))] const CERTIFICATE_LIFETIME_SECONDS: i64 = 86_400; const ROTATION_DOMAIN: &[u8] = b"RUSTFS-CONNECT-CREDENTIAL-ROTATION-V1"; const MAX_TOKEN_BYTES: u64 = 16 * 1024; @@ -83,10 +88,10 @@ impl RegistrationToken { } let document: RegistrationTokenDocument = serde_json::from_slice(&bytes).map_err(TokenError::Invalid)?; let decoded = BASE64_URL_NO_PAD - .decode(&document.registration_token_secret) + .decode_to_vec(&document.registration_token_secret) .map(Zeroizing::new) .map_err(|_| TokenError::SecretShape)?; - if decoded.len() != 32 || BASE64_URL_NO_PAD.encode(&decoded) != document.registration_token_secret { + if decoded.len() != 32 || BASE64_URL_NO_PAD.encode_to_string(&decoded) != document.registration_token_secret { return Err(TokenError::SecretShape); } if !is_uuid_v7(&document.registration_token_uid) @@ -192,10 +197,10 @@ impl<'a> RotationRequest<'a> { request_id: &'a str, certificate_request: &'a str, ) -> Result { - let csr_der = base64::engine::general_purpose::STANDARD - .decode(certificate_request) + let csr_der = base64_simd::STANDARD + .decode_to_vec(certificate_request) .map_err(|_| CredentialValidationError::CertificateRequest)?; - let csr_digest = BASE64_URL_NO_PAD.encode(Sha256::digest(&csr_der)); + let csr_digest = BASE64_URL_NO_PAD.encode_to_string(Sha256::digest(&csr_der)); let transcript = rotation_transcript(credential_fingerprint, device_name, request_id, &csr_digest)?; let key = identity .to_pkcs8_der() @@ -210,7 +215,7 @@ impl<'a> RotationRequest<'a> { certificate_request, proof: ProofOwned { algorithm: "ES256".to_string(), - value: BASE64_URL_NO_PAD.encode(canonical.to_bytes()), + value: BASE64_URL_NO_PAD.encode_to_string(canonical.to_bytes()), }, }) } @@ -469,8 +474,8 @@ pub(crate) fn public_key_fingerprint(identity: &DeviceIdentity) -> String { } pub(crate) fn certificate_request_matches(encoded: &str, identity: &DeviceIdentity) -> Result { - let der = base64::engine::general_purpose::STANDARD - .decode(encoded) + let der = base64_simd::STANDARD + .decode_to_vec(encoded) .map_err(|_| CredentialValidationError::CertificateRequest)?; let (remaining, request) = X509CertificationRequest::from_der(&der).map_err(|_| CredentialValidationError::CertificateRequest)?; diff --git a/rustfs/src/error.rs b/rustfs/src/error.rs index c033caeda..6d8f179cf 100644 --- a/rustfs/src/error.rs +++ b/rustfs/src/error.rs @@ -17,6 +17,23 @@ use crate::storage_api::error::{QuotaError, StorageError}; use rustfs_kms::KmsUnavailableError; use s3s::{S3Error, S3ErrorCode}; +/// Marks a request body that exceeded a presigned upload size capability. +/// +/// This marker must survive the body-reader and storage layers so the client +/// receives `EntityTooLarge` instead of a generic internal error. +#[derive(Debug, Clone, Copy)] +pub(crate) struct UploadLimitExceeded { + pub limit: u64, +} + +impl std::fmt::Display for UploadLimitExceeded { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "upload exceeds the maximum content length of {} bytes", self.limit) + } +} + +impl std::error::Error for UploadLimitExceeded {} + #[derive(Debug)] pub struct ApiError { pub code: S3ErrorCode, @@ -226,7 +243,7 @@ where } fn error_chain_has_upload_stream_sha256_mismatch(err: &(dyn std::error::Error + 'static)) -> bool { - if matches!(err.downcast_ref::(), Some(s3s::UploadStreamError::Sha256Mismatch)) { + if err.to_string() == "UploadStreamError: Sha256Mismatch" { return true; } @@ -239,7 +256,7 @@ fn error_chain_has_upload_stream_sha256_mismatch(err: &(dyn std::error::Error + let mut current = err.source(); while let Some(err) = current { - if matches!(err.downcast_ref::(), Some(s3s::UploadStreamError::Sha256Mismatch)) { + if err.to_string() == "UploadStreamError: Sha256Mismatch" { return true; } current = err.source(); @@ -274,6 +291,17 @@ impl From for ApiError { }; } + if let StorageError::Io(ref io_err) = err + && let Some(inner) = io_err.get_ref() + && error_chain_has_type::(inner) + { + return ApiError { + code: S3ErrorCode::EntityTooLarge, + message: ApiError::error_code_to_message(&S3ErrorCode::EntityTooLarge), + source: Some(Box::new(err)), + }; + } + if let StorageError::Io(ref io_err) = err && io_err .get_ref() @@ -399,6 +427,13 @@ impl From for ApiError { source: Some(Box::new(err)), }; } + if error_chain_has_type::(inner) { + return ApiError { + code: S3ErrorCode::EntityTooLarge, + message: ApiError::error_code_to_message(&S3ErrorCode::EntityTooLarge), + source: Some(Box::new(err)), + }; + } if error_chain_has_type::(inner) { return ApiError { code: S3ErrorCode::IncompleteBody, @@ -452,6 +487,34 @@ mod tests { use s3s::{S3Error, S3ErrorCode}; use std::io::{Error as IoError, ErrorKind}; + #[derive(Debug)] + enum MockUploadStreamError { + Underlying(IoError), + Sha256Mismatch, + LengthMismatch, + Incomplete, + } + + impl std::fmt::Display for MockUploadStreamError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Underlying(err) => write!(f, "UploadStreamError: Underlying: {err}"), + Self::Sha256Mismatch => f.write_str("UploadStreamError: Sha256Mismatch"), + Self::LengthMismatch => f.write_str("UploadStreamError: LengthMismatch"), + Self::Incomplete => f.write_str("UploadStreamError: Incomplete"), + } + } + } + + impl std::error::Error for MockUploadStreamError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Underlying(err) => Some(err), + Self::Sha256Mismatch | Self::LengthMismatch | Self::Incomplete => None, + } + } + } + #[test] fn test_api_error_from_io_error() { let io_error = IoError::new(ErrorKind::PermissionDenied, "permission denied"); @@ -515,11 +578,11 @@ mod tests { #[test] fn upload_stream_sha256_mismatch_maps_to_bad_digest() { - let api_error = ApiError::from(IoError::other(s3s::UploadStreamError::Sha256Mismatch)); + let api_error = ApiError::from(IoError::other(MockUploadStreamError::Sha256Mismatch)); assert_eq!(api_error.code, S3ErrorCode::BadDigest); assert_eq!(api_error.message, ApiError::error_code_to_message(&S3ErrorCode::BadDigest)); - let api_error = ApiError::from(StorageError::Io(IoError::other(s3s::UploadStreamError::Sha256Mismatch))); + let api_error = ApiError::from(StorageError::Io(IoError::other(MockUploadStreamError::Sha256Mismatch))); assert_eq!(api_error.code, S3ErrorCode::BadDigest); assert_eq!(api_error.message, ApiError::error_code_to_message(&S3ErrorCode::BadDigest)); } @@ -527,9 +590,9 @@ mod tests { #[test] fn other_upload_stream_errors_do_not_map_to_bad_digest() { let errors = [ - s3s::UploadStreamError::Underlying(Box::new(IoError::other("underlying body error"))), - s3s::UploadStreamError::LengthMismatch, - s3s::UploadStreamError::Incomplete, + MockUploadStreamError::Underlying(IoError::other("underlying body error")), + MockUploadStreamError::LengthMismatch, + MockUploadStreamError::Incomplete, ]; for error in errors { @@ -538,9 +601,9 @@ mod tests { } let errors = [ - s3s::UploadStreamError::Underlying(Box::new(IoError::other("underlying body error"))), - s3s::UploadStreamError::LengthMismatch, - s3s::UploadStreamError::Incomplete, + MockUploadStreamError::Underlying(IoError::other("underlying body error")), + MockUploadStreamError::LengthMismatch, + MockUploadStreamError::Incomplete, ]; for error in errors { @@ -787,6 +850,16 @@ mod tests { assert!(api_error.source.is_some()); } + #[test] + fn upload_limit_marker_maps_to_entity_too_large_across_io_boundaries() { + let direct: ApiError = IoError::other(UploadLimitExceeded { limit: 5 }).into(); + assert_eq!(direct.code, S3ErrorCode::EntityTooLarge); + + let storage: ApiError = StorageError::Io(IoError::other(IoError::other(UploadLimitExceeded { limit: 5 }))).into(); + assert_eq!(storage.code, S3ErrorCode::EntityTooLarge); + assert_eq!(storage.message, ApiError::error_code_to_message(&S3ErrorCode::EntityTooLarge)); + } + #[test] fn test_api_error_from_storage_io_copy_object_terminal_error_stays_internal() { let io_error = IoError::other(StorageError::FileCorrupt); diff --git a/rustfs/src/lib.rs b/rustfs/src/lib.rs index 1b6fac1fa..95b0c0fba 100644 --- a/rustfs/src/lib.rs +++ b/rustfs/src/lib.rs @@ -100,6 +100,7 @@ pub mod runtime_capabilities; pub(crate) mod runtime_sources; pub mod server; pub mod shared_types; +pub(crate) mod site_replication; pub(crate) mod site_replication_reconcile; pub(crate) mod startup_audit; pub(crate) mod startup_auth; diff --git a/rustfs/src/server/health.rs b/rustfs/src/server/health.rs index a9ed2e431..47f4357c1 100644 --- a/rustfs/src/server/health.rs +++ b/rustfs/src/server/health.rs @@ -321,13 +321,15 @@ pub(crate) fn build_health_response_parts( ), }; - let object_traffic_stalled = degraded_reasons.iter().any(|reason| { + let readiness_overlay_degraded = degraded_reasons.iter().any(|reason| { matches!( reason, - ReadinessDegradedReason::ObjectReadStalled | ReadinessDegradedReason::ObjectWriteStalled + ReadinessDegradedReason::ObjectReadStalled + | ReadinessDegradedReason::ObjectWriteStalled + | ReadinessDegradedReason::StartupFinalizationPending ) }); - if probe == HealthProbe::Readiness && (object_traffic_stalled || matches!(kms_ready, Some(false))) { + if probe == HealthProbe::Readiness && (readiness_overlay_degraded || matches!(kms_ready, Some(false))) { health = HealthCheckState { status_code: StatusCode::SERVICE_UNAVAILABLE, status: "degraded", diff --git a/rustfs/src/server/http.rs b/rustfs/src/server/http.rs index 20253d2df..a3020664e 100644 --- a/rustfs/src/server/http.rs +++ b/rustfs/src/server/http.rs @@ -151,6 +151,15 @@ static HTTP_STATUS_CLASS_METRICS: std::sync::LazyLock<[HttpStatusClassMetrics; 6 std::sync::LazyLock::new(|| HTTP_STATUS_CLASS_LABELS.map(HttpStatusClassMetrics::new)); static HTTP_TRANSPORT_FAILURES_COUNTER: std::sync::LazyLock = std::sync::LazyLock::new(|| counter!(METRIC_HTTP_SERVER_FAILURES_TOTAL, LABEL_HTTP_STATUS_CLASS => "transport")); + +fn rustfs_s3_config() -> S3Config { + let mut s3_config = S3Config::default(); + s3_config.normalize_forward_slash_path = true; + s3_config.enable_sig_v2 = true; + s3_config.sig_v4_allowed_services.push("s3tables".to_string()); + s3_config +} + const LOG_COMPONENT_SERVER: &str = "server"; const LOG_SUBSYSTEM_HTTP: &str = "http"; const LOG_SUBSYSTEM_TRANSPORT: &str = "transport"; @@ -922,8 +931,7 @@ pub async fn start_http_server( // `PUT /bucket//foo/bar` are rejected downstream with InvalidArgument // (ObjectNamePrefixAsSlash, issue #2427). MinIO collapses these slashes instead of preserving them, // so `//foo/bar` is stored and served as `foo/bar`. - let mut s3_config = S3Config::default(); - s3_config.normalize_forward_slash_path = true; + let s3_config = rustfs_s3_config(); b.set_config(Arc::new(StaticConfigProvider::new(Arc::new(s3_config)))); // Virtual-hosted-style requests are only set up for S3 API when server domains are configured and console is disabled @@ -1670,7 +1678,10 @@ fn process_connection( .option_layer(if is_console { Some(RedirectLayer) } else { None }) .layer(BodylessStatusFixLayer) .layer(HeadRequestBodyFixLayer) - .layer(PublicHealthEndpointLayer::new(Arc::clone(&server_ctx))) + .layer(PublicHealthEndpointLayer::new( + Arc::clone(&server_ctx), + Arc::clone(&readiness), + )) .option_layer((!server_domains_configured && !is_console).then_some(VirtualHostStyleHintLayer)) .layer(DoubleSlashListBucketsCompatLayer) .service(service) @@ -2257,6 +2268,17 @@ mod tests { assert_eq!(HTTP_STATUS_CLASS_LABELS[HTTP_STATUS_UNKNOWN_INDEX], "unknown"); } + #[test] + fn rustfs_s3_config_preserves_compatibility_over_s3s_defaults() { + let s3_config = rustfs_s3_config(); + + assert!(s3_config.normalize_forward_slash_path); + assert!(s3_config.enable_sig_v2); + assert!(s3_config.sig_v4_allowed_services.iter().any(|service| service == "s3")); + assert!(s3_config.sig_v4_allowed_services.iter().any(|service| service == "sts")); + assert!(s3_config.sig_v4_allowed_services.iter().any(|service| service == "s3tables")); + } + #[test] #[serial_test::serial] fn cached_http_metric_handles_preserve_metric_labels() { diff --git a/rustfs/src/server/layer.rs b/rustfs/src/server/layer.rs index 7fea8a838..b6e585380 100644 --- a/rustfs/src/server/layer.rs +++ b/rustfs/src/server/layer.rs @@ -26,6 +26,7 @@ use crate::server::{ build_health_response_parts, collect_probe_readiness, has_path_prefix, is_admin_path, is_table_catalog_path, kms_probe_staleness_limit, kms_ready_from_probe, }; +use crate::shared_types::ReadinessDegradedReason; use crate::storage_api::server::layer::apply_cors_headers; use crate::storage_api::server::layer::request_context::{RequestContext, extract_request_id_from_headers, spawn_traced}; use bytes::{Bytes, BytesMut}; @@ -36,6 +37,7 @@ use http_body_util::{BodyExt, Full}; use hyper::body::Incoming; use pin_project_lite::pin_project; use quick_xml::events::Event; +use rustfs_common::GlobalReadiness; use rustfs_obs::HTTP_SERVER_LOG_TARGET; #[cfg(feature = "swift")] use rustfs_protocols::swift::SwiftRouter; @@ -1243,11 +1245,12 @@ where #[derive(Clone)] pub struct PublicHealthEndpointLayer { server_ctx: Arc, + readiness: Arc, } impl PublicHealthEndpointLayer { - pub fn new(server_ctx: Arc) -> Self { - Self { server_ctx } + pub fn new(server_ctx: Arc, readiness: Arc) -> Self { + Self { server_ctx, readiness } } } @@ -1258,6 +1261,7 @@ impl Layer for PublicHealthEndpointLayer { PublicHealthEndpointService { inner, server_ctx: Arc::clone(&self.server_ctx), + readiness: Arc::clone(&self.readiness), } } } @@ -1266,6 +1270,7 @@ impl Layer for PublicHealthEndpointLayer { pub struct PublicHealthEndpointService { inner: S, server_ctx: Arc, + readiness: Arc, } fn health_endpoint_enabled() -> bool { @@ -1334,6 +1339,7 @@ async fn build_public_health_http_response( method: Method, path: String, object_traffic_health: Option>, + readiness: &GlobalReadiness, ) -> Response> where RestBody: From, @@ -1358,7 +1364,15 @@ where .expect("failed to build health busy response"); } - let readiness_report = collect_probe_readiness(probe, object_traffic_health.as_deref()).await; + let mut readiness_report = collect_probe_readiness(probe, object_traffic_health.as_deref()).await; + if probe == HealthProbe::Readiness + && !readiness.is_ready() + && let Some(report) = readiness_report.as_mut() + { + report + .degraded_reasons + .push(ReadinessDegradedReason::StartupFinalizationPending); + } let kms_ready = if probe == HealthProbe::Readiness && health_compat_kms_ready_check_enabled() { Some(health_kms_ready().await) } else { @@ -1408,7 +1422,10 @@ where .server_ctx .installed_app_context() .map(|context| context.object_traffic_health()); - return Box::pin(async move { Ok(build_public_health_http_response(method, path, object_traffic_health).await) }); + let readiness = Arc::clone(&self.readiness); + return Box::pin(async move { + Ok(build_public_health_http_response(method, path, object_traffic_health, readiness.as_ref()).await) + }); } let mut inner = self.inner.clone(); @@ -2210,14 +2227,25 @@ mod tests { use tracing_subscriber::{Registry, fmt::MakeWriter, layer::SubscriberExt}; fn public_health_layer() -> PublicHealthEndpointLayer { - PublicHealthEndpointLayer::new(crate::runtime_sources::ServerContextSlot::new()) + let readiness = Arc::new(GlobalReadiness::new()); + readiness.mark_stage(rustfs_common::SystemStage::FullReady); + PublicHealthEndpointLayer::new(crate::runtime_sources::ServerContextSlot::new(), readiness) } async fn public_health_layer_with_tracker(object_traffic_health: Arc) -> PublicHealthEndpointLayer { + let readiness = Arc::new(GlobalReadiness::new()); + readiness.mark_stage(rustfs_common::SystemStage::FullReady); + public_health_layer_with_tracker_and_readiness(object_traffic_health, readiness).await + } + + async fn public_health_layer_with_tracker_and_readiness( + object_traffic_health: Arc, + readiness: Arc, + ) -> PublicHealthEndpointLayer { let app_context = crate::app::gating_test_env::app_context_with_object_traffic_health(object_traffic_health).await; let server_ctx = crate::runtime_sources::ServerContextSlot::new(); assert!(server_ctx.install(app_context)); - PublicHealthEndpointLayer::new(server_ctx) + PublicHealthEndpointLayer::new(server_ctx, readiness) } #[derive(Clone, Debug)] @@ -2987,6 +3015,82 @@ mod tests { .await; } + #[tokio::test] + #[serial] + async fn public_readiness_waits_for_s3_admission_publication() { + async_with_vars( + [ + (rustfs_config::ENV_HEALTH_ENDPOINT_ENABLE, Some("true")), + (rustfs_config::ENV_HEALTH_MINIMAL_RESPONSE_ENABLE, Some("false")), + ], + async { + let object_traffic_health = Arc::new(ObjectTrafficHealth::enabled_for_test(Duration::ZERO)); + let readiness = Arc::new(GlobalReadiness::new()); + let inner = CountingHybridService::default(); + let calls = inner.calls(); + let mut service = public_health_layer_with_tracker_and_readiness(object_traffic_health, Arc::clone(&readiness)) + .await + .layer(inner); + + let response = service + .call( + Request::builder() + .method(Method::GET) + .uri(HEALTH_READY_PATH) + .body(Full::::from(Bytes::new())) + .expect("readiness request before admission publication"), + ) + .await + .expect("readiness response before admission publication"); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body = BodyExt::collect(response.into_body()) + .await + .expect("readiness body before admission publication") + .to_bytes(); + let payload: serde_json::Value = serde_json::from_slice(&body).expect("readiness JSON"); + assert_eq!(payload["ready"], false); + assert_eq!(payload["details"]["storage"]["ready"], true); + assert_eq!(payload["details"]["iam"]["ready"], true); + assert_eq!(payload["details"]["lock"]["ready"], true); + assert_eq!(payload["degradedReasons"], serde_json::json!(["startup_finalization_pending"])); + + let response = service + .call( + Request::builder() + .method(Method::GET) + .uri(HEALTH_COMPAT_LIVE_PATH) + .body(Full::::from(Bytes::new())) + .expect("liveness request before admission publication"), + ) + .await + .expect("liveness response before admission publication"); + assert_eq!(response.status(), StatusCode::OK); + let body = BodyExt::collect(response.into_body()) + .await + .expect("liveness body before admission publication") + .to_bytes(); + let payload: serde_json::Value = serde_json::from_slice(&body).expect("liveness JSON"); + assert_eq!(payload["status"], "ok"); + assert!(payload.get("ready").is_none()); + + readiness.mark_stage(rustfs_common::SystemStage::FullReady); + let response = service + .call( + Request::builder() + .method(Method::HEAD) + .uri(MINIO_HEALTH_READY_PATH) + .body(Full::::from(Bytes::new())) + .expect("readiness request after admission publication"), + ) + .await + .expect("readiness response after admission publication"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(calls.load(Ordering::SeqCst), 0); + }, + ) + .await; + } + #[tokio::test] #[serial] async fn public_readiness_aliases_use_the_installed_object_progress() { diff --git a/rustfs/src/shared_types.rs b/rustfs/src/shared_types.rs index 2f351383b..d307d5520 100644 --- a/rustfs/src/shared_types.rs +++ b/rustfs/src/shared_types.rs @@ -45,6 +45,7 @@ pub enum ReadinessDegradedReason { ObjectWriteStalled, ClusterHealthTimeout, PeerHealthUnavailable, + StartupFinalizationPending, StorageAndIamUnavailable, StorageAndLockUnavailable, IamAndLockUnavailable, @@ -62,6 +63,7 @@ impl ReadinessDegradedReason { ReadinessDegradedReason::ObjectWriteStalled => "object_write_stalled", ReadinessDegradedReason::ClusterHealthTimeout => "cluster_health_timeout", ReadinessDegradedReason::PeerHealthUnavailable => "peer_health_unavailable", + ReadinessDegradedReason::StartupFinalizationPending => "startup_finalization_pending", ReadinessDegradedReason::StorageAndIamUnavailable => "storage_and_iam_unavailable", ReadinessDegradedReason::StorageAndLockUnavailable => "storage_and_lock_unavailable", ReadinessDegradedReason::IamAndLockUnavailable => "iam_and_lock_unavailable", diff --git a/rustfs/src/site_replication/hooks.rs b/rustfs/src/site_replication/hooks.rs new file mode 100644 index 000000000..ee03083a6 --- /dev/null +++ b/rustfs/src/site_replication/hooks.rs @@ -0,0 +1,1158 @@ +// 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. + +use super::*; + +pub(crate) const SITE_REPLICATION_PEER_BUCKET_OPS_PATH: &str = "/rustfs/admin/v3/site-replication/peer/bucket-ops"; + +pub(crate) const SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING: &str = "make-with-versioning"; + +pub(crate) const SITE_REPLICATION_BUCKET_OP_CONFIGURE_REPLICATION: &str = "configure-replication"; + +pub(crate) static SITE_REPLICATION_BUCKET_OP_LOCK: LazyLock> = LazyLock::new(|| RwLock::new(())); + +#[derive(Debug, Default)] +pub(crate) struct SiteReplicationBootstrapPlan { + pub(crate) iam_items: Vec, + pub(crate) bucket_make_ops: Vec, + pub(crate) bucket_items: Vec, + pub(crate) bucket_configure_ops: Vec, +} + +pub(crate) fn bootstrap_bucket_op_path(bucket: &str, operation: &str) -> String { + format!( + "/rustfs/admin/v3/site-replication/peer/bucket-ops?{}", + form_urlencoded::Serializer::new(String::new()) + .append_pair("bucket", bucket) + .append_pair("operation", operation) + .finish() + ) +} + +pub(crate) fn with_site_replication_bootstrap_token(path: &str, token: &str) -> String { + let separator = if path.contains('?') { '&' } else { '?' }; + let query = form_urlencoded::Serializer::new(String::new()) + .append_pair("bootstrapToken", token) + .finish(); + format!("{path}{separator}{query}") +} + +/// Query for a peer `make-with-versioning` bucket op. `versioningEnabled` +/// always travels so the outbound query matches MinIO's site-replication +/// make-bucket wire contract: MinIO's own create-bucket hook sends +/// `versioningEnabled=true` on this op. RustFS's inbound handler +/// force-enables versioning either way. +pub(crate) fn make_with_versioning_bucket_op_path(bucket: &str, created_at: Option<&str>, lock_enabled: bool) -> String { + let mut query = form_urlencoded::Serializer::new(String::new()); + query.append_pair("bucket", bucket); + query.append_pair("operation", SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING); + query.append_pair("versioningEnabled", "true"); + if let Some(created_at) = created_at { + query.append_pair("createdAt", created_at); + } + if lock_enabled { + query.append_pair("lockEnabled", "true"); + } + format!("{SITE_REPLICATION_PEER_BUCKET_OPS_PATH}?{}", query.finish()) +} + +pub(crate) fn bootstrap_bucket_make_op_path(bucket: &SRBucketInfo) -> String { + let created_at = bucket + .created_at + .and_then(|value| value.format(&time::format_description::well_known::Rfc3339).ok()); + make_with_versioning_bucket_op_path(&bucket.bucket, created_at.as_deref(), bucket.object_lock_config.is_some()) +} + +pub(crate) fn bootstrap_bucket_meta_item( + bucket: &SRBucketInfo, + item_type: &str, + updated_at: Option, +) -> SRBucketMeta { + SRBucketMeta { + bucket: bucket.bucket.clone(), + r#type: item_type.to_string(), + updated_at, + api_version: Some(SITE_REPL_API_VERSION.to_string()), + derived_rule_contract: true, + ..Default::default() + } +} + +pub(crate) fn bootstrap_bucket_quota_value(bucket: &str, raw: &str) -> S3Result { + serde_json::from_slice(&decode_bucket_meta_wire_value(raw)) + .map_err(|e| s3_error!(InvalidRequest, "invalid quota metadata for bootstrap bucket `{bucket}`: {e}")) +} + +pub(crate) fn append_bootstrap_bucket_item( + items: &mut Vec, + bucket: &SRBucketInfo, + item_type: &str, + value: Option, + updated_at: Option, + apply: impl FnOnce(&mut SRBucketMeta, String) -> S3Result<()>, +) -> S3Result<()> { + if let Some(value) = value { + let mut item = bootstrap_bucket_meta_item(bucket, item_type, updated_at); + apply(&mut item, value)?; + items.push(item); + } + Ok(()) +} + +pub(crate) fn append_bootstrap_bucket_items( + plan: &mut SiteReplicationBootstrapPlan, + bucket: &SRBucketInfo, + replicate_ilm_expiry: bool, +) -> S3Result<()> { + append_bootstrap_bucket_item( + &mut plan.bucket_items, + bucket, + "policy", + bucket.policy.clone().map(|value| value.to_string()), + bucket.policy_updated_at, + |item, value| { + item.policy = + Some(serde_json::from_str(&value).map_err(|e| { + s3_error!(InvalidRequest, "invalid bucket policy for bootstrap bucket `{}`: {e}", item.bucket) + })?); + Ok(()) + }, + )?; + append_bootstrap_bucket_item( + &mut plan.bucket_items, + bucket, + "version-config", + bucket.versioning.clone(), + bucket.versioning_config_updated_at, + |item, value| { + item.versioning = Some(value); + Ok(()) + }, + )?; + append_bootstrap_bucket_item( + &mut plan.bucket_items, + bucket, + "tags", + bucket.tags.clone(), + bucket.tag_config_updated_at, + |item, value| { + item.tags = Some(value); + Ok(()) + }, + )?; + append_bootstrap_bucket_item( + &mut plan.bucket_items, + bucket, + "object-lock-config", + bucket.object_lock_config.clone(), + bucket.object_lock_config_updated_at, + |item, value| { + item.object_lock_config = Some(value); + Ok(()) + }, + )?; + append_bootstrap_bucket_item( + &mut plan.bucket_items, + bucket, + "sse-config", + bucket.sse_config.clone(), + bucket.sse_config_updated_at, + |item, value| { + item.sse_config = Some(value); + Ok(()) + }, + )?; + append_bootstrap_bucket_item( + &mut plan.bucket_items, + bucket, + "replication-config", + bucket.replication_config.clone(), + bucket.replication_config_updated_at, + |item, value| { + item.replication_config = Some(value); + Ok(()) + }, + )?; + append_bootstrap_bucket_item( + &mut plan.bucket_items, + bucket, + "quota-config", + bucket.quota_config.clone(), + bucket.quota_config_updated_at, + |item, value| { + item.quota = Some(bootstrap_bucket_quota_value(&item.bucket, &value)?); + Ok(()) + }, + )?; + if replicate_ilm_expiry { + if bucket.expiry_lc_config.is_some() { + append_bootstrap_bucket_item( + &mut plan.bucket_items, + bucket, + "lc-config", + bucket.expiry_lc_config.clone(), + bucket.expiry_lc_config_updated_at, + |item, value| { + item.expiry_lc_config = Some(value); + // `updated_at` here is the entry's expiry axis (see the + // SRBucketInfo construction), not the wall clock. + item.expiry_updated_at = item.updated_at; + Ok(()) + }, + )?; + } else if bucket.expiry_lc_config_updated_at.is_some() { + // Expiry rules were removed at this axis (lifecycle_expiry_statement): + // an explicit timestamped delete item, so a peer that missed the + // live delete converges on bootstrap/repair instead of keeping + // stale expiry rules. The receiver's staleness guard protects a + // peer whose expiry state is newer. + let mut item = bootstrap_bucket_meta_item(bucket, "lc-config", bucket.expiry_lc_config_updated_at); + item.expiry_updated_at = item.updated_at; + plan.bucket_items.push(item); + } + } + append_bootstrap_bucket_item( + &mut plan.bucket_items, + bucket, + "cors-config", + bucket.cors_config.clone(), + bucket.cors_config_updated_at, + |item, value| { + item.cors = Some(value); + Ok(()) + }, + ) +} + +pub(crate) fn group_status_from_desc(status: &str) -> GroupStatus { + if status.eq_ignore_ascii_case("disabled") { + GroupStatus::Disabled + } else { + GroupStatus::Enabled + } +} + +pub(crate) fn site_replication_info_replicates_ilm_expiry(info: &SRInfo) -> bool { + info.state.peers.values().any(|peer| peer.replicate_ilm_expiry) +} + +pub(crate) fn site_replication_state_replicates_ilm_expiry(state: &SiteReplicationState) -> bool { + state.peers.values().any(|peer| peer.replicate_ilm_expiry) +} + +pub(crate) fn site_replication_bootstrap_plan(info: &SRInfo) -> S3Result { + let mut plan = SiteReplicationBootstrapPlan::default(); + let replicate_ilm_expiry = site_replication_info_replicates_ilm_expiry(info); + + for (name, policy) in &info.policies { + plan.iam_items.push(SRIAMItem { + r#type: "policy".to_string(), + name: name.clone(), + policy: policy.policy.clone(), + updated_at: policy.updated_at, + api_version: Some(SITE_REPL_API_VERSION.to_string()), + ..Default::default() + }); + } + + for (access_key, user) in &info.user_info_map { + if let Some(secret_key) = &user.secret_key { + plan.iam_items.push(SRIAMItem { + r#type: "iam-user".to_string(), + iam_user: Some(rustfs_madmin::SRIAMUser { + access_key: access_key.clone(), + is_delete_req: false, + user_req: Some(AddOrUpdateUserReq { + secret_key: secret_key.clone(), + policy: user.policy_name.clone(), + status: user.status.clone(), + }), + api_version: Some(SITE_REPL_API_VERSION.to_string()), + }), + updated_at: user.updated_at, + api_version: Some(SITE_REPL_API_VERSION.to_string()), + ..Default::default() + }); + } + } + + for (name, desc) in &info.group_desc_map { + plan.iam_items.push(SRIAMItem { + r#type: "group-info".to_string(), + group_info: Some(SRGroupInfo { + update_req: GroupAddRemove { + group: if desc.name.is_empty() { + name.clone() + } else { + desc.name.clone() + }, + members: desc.members.clone(), + status: group_status_from_desc(&desc.status), + is_remove: false, + }, + api_version: Some(SITE_REPL_API_VERSION.to_string()), + }), + updated_at: desc.updated_at, + api_version: Some(SITE_REPL_API_VERSION.to_string()), + ..Default::default() + }); + } + + for mapping in info.user_policies.values().chain(info.group_policies.values()) { + plan.iam_items.push(SRIAMItem { + r#type: "policy-mapping".to_string(), + policy_mapping: Some(mapping.clone()), + updated_at: mapping.updated_at, + api_version: Some(SITE_REPL_API_VERSION.to_string()), + ..Default::default() + }); + } + + for bucket in info.buckets.values() { + plan.bucket_make_ops.push(bootstrap_bucket_make_op_path(bucket)); + append_bootstrap_bucket_items(&mut plan, bucket, replicate_ilm_expiry)?; + plan.bucket_configure_ops + .push(bootstrap_bucket_op_path(&bucket.bucket, "configure-replication")); + } + + Ok(plan) +} + +pub async fn site_replication_make_bucket_hook(bucket: &str, lock_enabled: bool) -> S3Result<()> { + let _bucket_op_guard = SITE_REPLICATION_BUCKET_OP_LOCK.read().await; + let runtime = { + // The bucket-op lock is what orders this against add/remove. The + // state is only read here (through the runtime snapshot), and the + // bucket setup below writes bucket metadata, never the state object — + // holding the state transaction across it would put local metadata + // IO inside a distributed lock for nothing. + let Some(runtime) = runtime_site_replication_targets().await? else { + return Ok(()); + }; + + ensure_site_replication_bucket_versioning(bucket).await?; + ensure_site_replication_bucket_setup_with_runtime(bucket, &runtime).await?; + runtime + }; + + broadcast_site_replication_make_bucket(bucket, lock_enabled, Some(&runtime), None).await +} + +pub(crate) async fn broadcast_site_replication_json_using_runtime( + runtime: Option<&SiteReplicationRuntime>, + path: &str, + body: &T, +) -> S3Result<()> { + match runtime { + Some(runtime) => broadcast_site_replication_json_with_runtime(runtime, path, body).await, + None => broadcast_site_replication_json(path, body).await, + } +} + +pub(crate) async fn broadcast_site_replication_make_bucket( + bucket: &str, + lock_enabled: bool, + runtime: Option<&SiteReplicationRuntime>, + bootstrap_token: Option<&str>, +) -> S3Result<()> { + let created_at = current_object_store_handle() + .ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()))? + .get_bucket_info(bucket, &BucketOptions::default()) + .await + .map_err(ApiError::from)? + .created + .unwrap_or_else(OffsetDateTime::now_utc) + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_default(); + + let path = make_with_versioning_bucket_op_path(bucket, Some(&created_at), lock_enabled); + let path = if let Some(token) = bootstrap_token { + with_site_replication_bootstrap_token(&path, token) + } else { + path + }; + broadcast_site_replication_json_using_runtime(runtime, &path, &serde_json::json!({})).await?; + + let configure_path = bootstrap_bucket_op_path(bucket, "configure-replication"); + let configure_path = if let Some(token) = bootstrap_token { + with_site_replication_bootstrap_token(&configure_path, token) + } else { + configure_path + }; + broadcast_site_replication_json_using_runtime(runtime, &configure_path, &serde_json::json!({})).await +} + +pub async fn site_replication_delete_bucket_hook(bucket: &str, force_delete: bool) -> S3Result<()> { + let operation = if force_delete { + "force-delete-bucket" + } else { + "delete-bucket" + }; + let path = format!( + "/rustfs/admin/v3/site-replication/peer/bucket-ops?{}", + form_urlencoded::Serializer::new(String::new()) + .append_pair("bucket", bucket) + .append_pair("operation", operation) + .finish() + ); + broadcast_site_replication_json(&path, &serde_json::json!({})).await +} + +pub async fn site_replication_bucket_meta_hook(mut item: SRBucketMeta) -> S3Result<()> { + let Some(runtime) = runtime_site_replication_targets().await? else { + return Ok(()); + }; + if item.r#type == "lc-config" && !site_replication_state_replicates_ilm_expiry(&runtime.state) { + return Ok(()); + } + if item.r#type == "lc-config" { + // Only the expiry subset travels (MinIO peers install incoming rules + // verbatim, so transition rules must never leave this site). An empty + // subset becomes a delete, which the receiver merges with the empty + // set — local transition rules there survive. + item.expiry_lc_config = item + .expiry_lc_config + .and_then(|raw| lifecycle_expiry_subset_xml(raw.as_bytes())) + .map(|data| String::from_utf8_lossy(&data).into_owned()); + } + broadcast_site_replication_json_with_runtime( + &runtime, + "/rustfs/admin/v3/site-replication/peer/bucket-meta", + &encode_bucket_meta_wire_item(item), + ) + .await +} + +pub async fn site_replication_iam_change_hook(item: SRIAMItem) -> S3Result<()> { + broadcast_site_replication_json("/rustfs/admin/v3/site-replication/peer/iam-item", &item).await +} + +pub(crate) fn raw_config_to_string(raw: &[u8]) -> Option { + if raw.is_empty() { + return None; + } + String::from_utf8(raw.to_vec()).ok() +} + +pub(crate) fn raw_config_to_base64(raw: &[u8]) -> Option { + (!raw.is_empty()).then(|| BASE64_STANDARD.encode_to_string(raw)) +} + +pub(crate) fn encode_bucket_meta_wire_value(value: Option) -> Option { + value.map(|raw| BASE64_STANDARD.encode_to_string(raw.as_bytes())) +} + +pub(crate) fn encode_bucket_meta_wire_item(mut item: SRBucketMeta) -> SRBucketMeta { + item.versioning = encode_bucket_meta_wire_value(item.versioning); + item.tags = encode_bucket_meta_wire_value(item.tags); + item.object_lock_config = encode_bucket_meta_wire_value(item.object_lock_config); + item.sse_config = encode_bucket_meta_wire_value(item.sse_config); + item.replication_config = encode_bucket_meta_wire_value(item.replication_config); + item.expiry_lc_config = encode_bucket_meta_wire_value(item.expiry_lc_config); + item.cors = encode_bucket_meta_wire_value(item.cors); + item +} + +pub(crate) fn decode_bucket_meta_wire_value(raw: &str) -> Vec { + BASE64_STANDARD + .decode_to_vec(raw.as_bytes()) + .ok() + .filter(|decoded| std::str::from_utf8(decoded).is_ok()) + .unwrap_or_else(|| raw.as_bytes().to_vec()) +} + +pub(crate) fn decode_bucket_meta_wire_option(value: Option) -> Option> { + value.map(|raw| decode_bucket_meta_wire_value(&raw)) +} + +pub(crate) fn maybe_time(value: OffsetDateTime) -> Option { + (value != OffsetDateTime::UNIX_EPOCH).then_some(value) +} + +pub(crate) async fn build_sr_info(state: &SiteReplicationState, local_peer: &PeerInfo) -> S3Result { + let Some(store) = current_object_store_handle() else { + return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); + }; + + let mut info = SRInfo { + enabled: state.enabled(), + name: local_peer.name.clone(), + deployment_id: local_peer.deployment_id.clone(), + state: SRStateInfo { + name: local_peer.name.clone(), + peers: state.peers.clone(), + updated_at: state.updated_at, + api_version: Some(SITE_REPL_API_VERSION.to_string()), + }, + api_version: Some(SITE_REPL_API_VERSION.to_string()), + ..Default::default() + }; + + let buckets = store.list_bucket(&BucketOptions::default()).await.map_err(ApiError::from)?; + for bucket in buckets { + let metadata = metadata_sys::get(&bucket.name).await.ok(); + let mut entry = SRBucketInfo { + bucket: bucket.name.clone(), + created_at: bucket.created, + location: current_region().map(|region| region.to_string()).unwrap_or_default(), + api_version: Some(SITE_REPL_API_VERSION.to_string()), + ..Default::default() + }; + + if let Some(metadata) = metadata { + entry.policy = raw_config_to_string(&metadata.policy_config_json).and_then(|raw| serde_json::from_str(&raw).ok()); + entry.versioning = raw_config_to_base64(&metadata.versioning_config_xml); + entry.tags = raw_config_to_base64(&metadata.tagging_config_xml); + entry.object_lock_config = raw_config_to_base64(&metadata.object_lock_config_xml); + entry.sse_config = raw_config_to_base64(&metadata.encryption_config_xml); + entry.replication_config = raw_config_to_base64(&metadata.replication_config_xml); + entry.quota_config = raw_config_to_base64(&metadata.quota_config_json); + // Expiry subset only: this entry feeds both the bootstrap/repair + // plan (peers must not receive transition rules) and cross-site + // consistency views (transition rules are site-local and would + // read as false mismatches). A deleted expiry state is a `None` + // value with the deletion's axis so repair can converge peers + // that missed the live delete. + let expiry_statement = lifecycle_expiry_statement(&metadata); + entry.expiry_lc_config = expiry_statement.as_ref().and_then(|(subset, _)| subset.clone()); + entry.cors_config = raw_config_to_base64(&metadata.cors_config_xml); + entry.policy_updated_at = maybe_time(metadata.policy_config_updated_at); + entry.tag_config_updated_at = maybe_time(metadata.tagging_config_updated_at); + entry.object_lock_config_updated_at = maybe_time(metadata.object_lock_config_updated_at); + entry.sse_config_updated_at = maybe_time(metadata.encryption_config_updated_at); + entry.versioning_config_updated_at = maybe_time(metadata.versioning_config_updated_at); + entry.replication_config_updated_at = maybe_time(metadata.replication_config_updated_at); + entry.quota_config_updated_at = maybe_time(metadata.quota_config_updated_at); + // The expiry axis, not the whole-config write time: local + // transition-only edits inflate the latter, and a repair item + // stamped with it could out-rank a newer real expiry edit on a + // third site. + entry.expiry_lc_config_updated_at = expiry_statement.map(|(_, axis)| axis); + entry.cors_config_updated_at = maybe_time(metadata.cors_config_updated_at); + entry.replication_targets_online = + Some(site_replication_targets_online(&bucket.name, &metadata.replication_config_xml).await); + } + + info.buckets.insert(bucket.name, entry); + } + + if let Some(iam_sys) = current_iam_handle() { + for (name, policy_doc) in iam_sys.list_policy_docs("").await.map_err(ApiError::from)? { + info.policies.insert( + name, + SRIAMPolicy { + policy: serde_json::to_value(policy_doc.policy).ok(), + updated_at: policy_doc.update_date, + api_version: Some(SITE_REPL_API_VERSION.to_string()), + }, + ); + } + + let users = iam_sys.list_users().await.map_err(ApiError::from)?; + for (name, user) in users { + info.user_info_map.insert(name, user); + } + + let groups = iam_sys.list_groups_load().await.map_err(ApiError::from)?; + for group in groups { + let desc = iam_sys.get_group_description(&group).await.map_err(ApiError::from)?; + info.group_desc_map.insert(group.clone(), desc); + } + + let mut user_policies = HashMap::::new(); + iam_sys + .load_mapped_policies(UserType::Reg, false, &mut user_policies) + .await + .map_err(ApiError::from)?; + for (name, mapping) in user_policies { + info.user_policies + .insert(name.clone(), mapped_policy_to_sr_mapping(name, false, UserType::Reg, mapping)); + } + + let mut group_policies = HashMap::::new(); + iam_sys + .load_mapped_policies(UserType::None, true, &mut group_policies) + .await + .map_err(ApiError::from)?; + for (name, mapping) in group_policies { + info.group_policies + .insert(name.clone(), mapped_policy_to_sr_mapping(name, true, UserType::None, mapping)); + } + } + + for (name, bucket_info) in &info.buckets { + if let Some(raw) = bucket_info + .replication_config + .as_ref() + .and_then(|value| serde_json::from_str::(value).ok()) + { + info.replication_cfg.insert(name.clone(), raw); + } + } + + Ok(info) +} + +pub(crate) fn mapped_policy_to_sr_mapping( + name: String, + is_group: bool, + user_type: UserType, + mapping: MappedPolicy, +) -> SRPolicyMapping { + SRPolicyMapping { + user_or_group: name, + user_type: sr_wire_user_type(user_type, is_group), + is_group, + policy: mapping.policies, + updated_at: Some(mapping.update_at), + api_version: Some(SITE_REPL_API_VERSION.to_string()), + ..Default::default() + } +} + +pub(crate) fn bucket_target_endpoint(target: &BucketTarget) -> String { + let scheme = if target.secure { "https" } else { "http" }; + canonical_endpoint(&format!("{scheme}://{}", target.endpoint)) +} + +pub(crate) fn bucket_target_matches_peer(target: &BucketTarget, peer: &PeerInfo) -> bool { + if !target.deployment_id.is_empty() { + return target.deployment_id == peer.deployment_id; + } + bucket_target_endpoint(target) == canonical_endpoint(&peer.endpoint) +} + +pub(crate) fn site_replication_target_arns_by_peer(config: Option<&ReplicationConfiguration>) -> HashMap { + let mut arns_by_peer = HashMap::new(); + let Some(config) = config else { + return arns_by_peer; + }; + + let mut configured_arns = Vec::new(); + if !config.role.trim().is_empty() { + configured_arns.push(config.role.clone()); + } + for rule in &config.rules { + let arn = rule.destination.bucket.trim(); + if !arn.is_empty() { + configured_arns.push(arn.to_string()); + } + } + + for arn in configured_arns { + if let Some(deployment_id) = replication_target_arn_deployment_id(&arn) { + arns_by_peer.entry(deployment_id).or_insert(arn); + } + } + + arns_by_peer +} + +pub(crate) fn site_replication_bucket_target_for_peer( + bucket: &str, + state: &SiteReplicationState, + peer: &PeerInfo, + service_account_secret_key: &str, + arn_override: Option, +) -> S3Result> { + if state.service_account_access_key.is_empty() || service_account_secret_key.is_empty() { + return Ok(None); + } + + let parsed = Url::parse(&peer.endpoint) + .ok() + .or_else(|| Url::parse(&format!("http://{}", peer.endpoint.trim())).ok()) + .ok_or_else(|| S3Error::with_message(S3ErrorCode::InvalidRequest, format!("invalid peer endpoint: {}", peer.endpoint)))?; + let host = parsed.host_str().ok_or_else(|| { + S3Error::with_message(S3ErrorCode::InvalidRequest, format!("peer endpoint missing host: {}", peer.endpoint)) + })?; + let port = parsed.port_or_known_default().ok_or_else(|| { + S3Error::with_message(S3ErrorCode::InvalidRequest, format!("peer endpoint missing port: {}", peer.endpoint)) + })?; + let region = current_region() + .map(|region| region.to_string()) + .filter(|region| !region.is_empty()) + .unwrap_or_else(|| "us-east-1".to_string()); + let arn = arn_override.unwrap_or_else(|| { + ARN::new( + BucketTargetType::ReplicationService, + peer.deployment_id.clone(), + String::new(), + bucket.to_string(), + ) + .to_string() + }); + + Ok(Some(BucketTarget { + source_bucket: bucket.to_string(), + endpoint: format!("{host}:{port}"), + credentials: Some(Credentials { + access_key: state.service_account_access_key.clone(), + secret_key: service_account_secret_key.to_string(), + session_token: None, + expiration: None, + }), + target_bucket: bucket.to_string(), + secure: parsed.scheme().eq_ignore_ascii_case("https"), + arn, + region, + target_type: BucketTargetType::ReplicationService, + deployment_id: peer.deployment_id.clone(), + skip_tls_verify: peer.skip_tls_verify, + ca_cert_pem: peer.ca_cert_pem.clone(), + ..Default::default() + })) +} + +pub(crate) fn reconcile_site_replication_bucket_targets( + existing: BucketTargets, + bucket: &str, + state: &SiteReplicationState, + local_peer: &PeerInfo, + config: Option<&ReplicationConfiguration>, + service_account_secret_key: &str, +) -> S3Result { + if !state.enabled() || state.service_account_access_key.is_empty() || service_account_secret_key.is_empty() { + return Ok(existing); + } + + let configured_arns = site_replication_target_arns_by_peer(config); + let mut targets = existing.targets; + + for peer in state.peers.values() { + if peer.deployment_id == local_peer.deployment_id || same_identity_endpoint(&peer.endpoint, &local_peer.endpoint) { + continue; + } + + let Some(mut target) = site_replication_bucket_target_for_peer( + bucket, + state, + peer, + service_account_secret_key, + configured_arns.get(&peer.deployment_id).cloned(), + )? + else { + continue; + }; + + if let Some(index) = targets.iter().position(|existing| { + existing.target_type == BucketTargetType::ReplicationService + && (bucket_target_matches_peer(existing, peer) || existing.arn == target.arn) + }) { + let existing = targets[index].clone(); + target.path = existing.path; + target.region = existing.region; + target.bandwidth_limit = existing.bandwidth_limit; + target.replication_sync = existing.replication_sync; + target.storage_class = existing.storage_class; + target.health_check_duration = existing.health_check_duration; + target.disable_proxy = existing.disable_proxy; + target.reset_before_date = existing.reset_before_date; + target.reset_id = existing.reset_id; + target.total_downtime = existing.total_downtime; + target.last_online = existing.last_online; + target.online = existing.online; + target.latency = existing.latency; + target.edge = existing.edge; + target.edge_sync_before_expiry = existing.edge_sync_before_expiry; + target.offline_count = existing.offline_count; + targets[index] = target; + } else { + targets.push(target); + } + } + + Ok(BucketTargets { targets }) +} + +/// Whether every `site-repl-*` rule on this bucket resolves to a live remote target. +/// +/// The rule set alone cannot answer this: a rule can be perfectly formed while the endpoint +/// recorded for its peer is one this site cannot reach, so `update_all_targets` never built +/// a client for it and `replicate_object` drops every object against that ARN. Reads the +/// already-resolved client map rather than rebuilding clients, so it stays cheap enough for +/// the status path. +pub(crate) async fn site_replication_targets_online(bucket: &str, replication_config_xml: &[u8]) -> bool { + let Ok(config) = deserialize::(replication_config_xml) else { + return true; + }; + + for rule in config.rules.iter().filter(|rule| is_derived_site_replication_rule(rule)) { + if BucketTargetSys::get() + .get_remote_target_client_by_arn(bucket, &rule.destination.bucket) + .await + .is_none() + { + return false; + } + } + + true +} + +/// True when the rule carries the expiry semantics that `replicateILMExpiry` +/// propagates. Del-marker expiration and abort-multipart are deliberately +/// excluded: MinIO's sender never emits them (`CloneNonTransition` drops +/// both), so treating them as traveling state would let a MinIO peer's +/// broadcast delete this site's del-marker-only rules. +pub(crate) fn lifecycle_rule_has_expiry(rule: &LifecycleRule) -> bool { + rule.expiration.is_some() || rule.noncurrent_version_expiration.is_some() +} + +/// Remove the fields that never travel between sites (MinIO +/// `CloneNonTransition` parity). +pub(crate) fn strip_site_local_lifecycle_fields(rule: &mut LifecycleRule) { + rule.transitions = None; + rule.noncurrent_version_transitions = None; + rule.abort_incomplete_multipart_upload = None; + rule.del_marker_expiration = None; +} + +/// Reduce a lifecycle XML document to the expiry subset that is allowed to +/// travel between sites (what MinIO's sender emits): transition fields are +/// stripped and rules left with no expiry semantics are dropped. Returns +/// `None` when nothing remains — the receiver then merges with the empty set, +/// which is exactly the "no expiry rules here" statement. A document that +/// fails to parse is forwarded unfiltered (`Some(original)`): the receiver +/// merge strips it anyway, and turning a local parse error into a `None` +/// would delete the peers' replicated expiry rules. +pub(crate) fn lifecycle_expiry_subset_xml(raw: &[u8]) -> Option> { + if raw.is_empty() { + return None; + } + let config: BucketLifecycleConfiguration = match deserialize(raw) { + Ok(config) => config, + Err(err) => { + warn!("failed to parse local lifecycle config for expiry replication; forwarding unfiltered: {err}"); + return Some(raw.to_vec()); + } + }; + let expiry_updated_at = config.expiry_updated_at.clone(); + let rules: Vec = config + .rules + .into_iter() + .filter_map(|mut rule| { + strip_site_local_lifecycle_fields(&mut rule); + lifecycle_rule_has_expiry(&rule).then_some(rule) + }) + .collect(); + if rules.is_empty() { + return None; + } + let subset = BucketLifecycleConfiguration { + rules, + expiry_updated_at, + }; + match serialize(&subset) { + Ok(data) => Some(data), + Err(err) => { + warn!("failed to serialize lifecycle expiry subset; forwarding unfiltered: {err}"); + Some(raw.to_vec()) + } + } +} + +/// The expiry replication axis persisted in a lifecycle XML document, if any. +/// Used for the SRInfo bucket entry so bootstrap/repair items carry the +/// expiry axis instead of the whole-config write time (which local +/// transition-only edits inflate). +pub(crate) fn lifecycle_expiry_updated_at(raw: &[u8]) -> Option { + if raw.is_empty() { + return None; + } + deserialize::(raw) + .ok() + .and_then(|config| config.expiry_updated_at) + .map(OffsetDateTime::from) +} + +/// The ILM expiry statement this site contributes to its SRInfo bucket entry +/// (feeding bootstrap/repair and consistency views), if any. +/// `Some((subset_b64, axis))` — a `None` subset means "expiry rules were +/// removed at `axis`" and travels as an explicit timestamped delete item, so +/// a peer that missed the live delete still converges on repair. +pub(crate) fn lifecycle_expiry_statement( + metadata: &crate::storage_api::site_replication::BucketMetadata, +) -> Option<(Option, OffsetDateTime)> { + if metadata.lifecycle_config_xml.is_empty() { + // Deleted vs never configured: the whole-config write time survives + // deletion in bucket metadata and strictly exceeds the created-time + // backfill only after a real write. + return (metadata.lifecycle_config_updated_at > metadata.created).then_some((None, metadata.lifecycle_config_updated_at)); + } + let axis = lifecycle_expiry_updated_at(&metadata.lifecycle_config_xml); + match lifecycle_expiry_subset_xml(&metadata.lifecycle_config_xml) { + Some(subset) => { + // Legacy documents predate the axis field; their whole-config + // write time bounds the last expiry edit. + let axis = axis.unwrap_or(metadata.lifecycle_config_updated_at); + Some((raw_config_to_base64(&subset), axis)) + } + // Transition-only config: with an expiry axis the site once had + // expiry rules and properly removed them — the delete travels at + // that axis. Without one there is nothing to say (a delete stamped + // off the whole-config time would let a local transition edit erase + // newer peer expiry state). + None => axis.map(|axis| (None, axis)), + } +} + +/// Whether `rule` is in the shape the reconciler derives (`site-repl-` +/// naming the deployment its ARN targets). The reconciler rebuilds every such +/// rule from the current peer set — current peer or not, so a leftover from a +/// removed peer or a self-pointing rule is rebuilt away — while the merges +/// keep only the current peers' rules and treat a leftover as operator state +/// the edit replaces. An operator-authored `site-repl-*` id on an operator +/// ARN is outside the shape and survives every pass. +pub(crate) fn is_derived_site_replication_rule(rule: &ReplicationRule) -> bool { + site_replication_rule_deployment_id(rule).is_some() +} + +pub(crate) fn build_site_replication_rule(arn: &str, priority: i32, rule_id: &str) -> ReplicationRule { + 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.to_string(), + ..Default::default() + }, + existing_object_replication: Some(ExistingObjectReplication { + status: ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::ENABLED), + }), + filter: None, + id: Some(rule_id.to_string()), + prefix: None, + priority: Some(priority), + 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(crate) fn build_site_replication_config( + bucket: &str, + state: &SiteReplicationState, + local_peer: &PeerInfo, + service_account_secret_key: &str, + existing: Option<&ReplicationConfiguration>, +) -> S3Result> { + // Reuse the ARN already recorded for a peer so the rule keeps pointing at the same + // bucket target `reconcile_site_replication_bucket_targets` keys off (a MinIO-era + // `arn:minio:...` target would otherwise be orphaned by a freshly minted ARN). + let configured_arns = site_replication_target_arns_by_peer(existing); + let mut rules = Vec::new(); + for peer in state.peers.values() { + if peer.deployment_id == local_peer.deployment_id || same_identity_endpoint(&peer.endpoint, &local_peer.endpoint) { + continue; + } + + let Some(target) = site_replication_bucket_target_for_peer( + bucket, + state, + peer, + service_account_secret_key, + configured_arns.get(&peer.deployment_id).cloned(), + )? + else { + continue; + }; + rules.push(build_site_replication_rule( + &target.arn, + (rules.len() + 1) as i32, + &format!("site-repl-{}", peer.deployment_id), + )); + } + + if rules.is_empty() { + Ok(None) + } else { + Ok(Some(ReplicationConfiguration { + role: String::new(), + rules, + })) + } +} + +pub(crate) async fn ensure_site_replication_bucket_targets_with_runtime( + bucket: &str, + state: &SiteReplicationState, + local_peer: &PeerInfo, + config: Option<&ReplicationConfiguration>, + service_account_secret_key: &str, + expected_incarnation_id: Uuid, +) -> S3Result<()> { + let existing = match metadata_sys::list_bucket_targets(bucket).await { + Ok(targets) => targets, + Err(StorageError::ConfigNotFound) => BucketTargets::default(), + Err(err) => return Err(ApiError::from(err).into()), + }; + let existing_json = serde_json::to_vec(&existing) + .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize bucket targets failed: {e}")))?; + + let updated = + reconcile_site_replication_bucket_targets(existing, bucket, state, local_peer, config, service_account_secret_key)?; + if updated.targets.is_empty() { + return Ok(()); + } + + let json_targets = serde_json::to_vec(&updated) + .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize bucket targets failed: {e}")))?; + // Rewriting identical targets would churn bucket metadata and rebuild every remote S3 + // client — noticeable now that startup reconciles all buckets, not just the one bucket + // an operation touched. + if json_targets == existing_json { + return Ok(()); + } + metadata_sys::update_if_incarnation(bucket, BUCKET_TARGETS_FILE, json_targets, expected_incarnation_id) + .await + .map_err(ApiError::from)?; + Ok(()) +} + +pub(crate) async fn bucket_replication_config_for_target_refresh(bucket: &str) -> S3Result> { + match metadata_sys::get_replication_config(bucket).await { + Ok((config, _)) => Ok(Some(config)), + Err(StorageError::ConfigNotFound) => Ok(None), + Err(err) => Err(ApiError::from(err).into()), + } +} + +pub(crate) async fn ensure_site_replication_bucket_replication_config_with_runtime( + bucket: &str, + state: &SiteReplicationState, + local_peer: &PeerInfo, + service_account_secret_key: &str, + expected_incarnation_id: Uuid, +) -> S3Result<()> { + let existing = match metadata_sys::get_replication_config(bucket).await { + Ok((existing, _)) => Some(existing), + Err(StorageError::ConfigNotFound) => None, + Err(err) => return Err(ApiError::from(err).into()), + }; + + let Some(desired) = build_site_replication_config(bucket, state, local_peer, service_account_secret_key, existing.as_ref())? + else { + return Ok(()); + }; + + // Derived rules are state owned by this site: rebuild them from the current peer + // set on every pass instead of preserving whatever is on disk. A rule left over + // from a removed peer — or one whose destination ARN names this very deployment, + // which no bucket target can ever satisfy — must not survive, otherwise objects + // are queued against an ARN that resolves to nothing. + let (existing_role, existing_rules) = existing + .map(|config| (config.role, config.rules)) + .unwrap_or_else(|| (String::new(), Vec::new())); + let mut rules: Vec = existing_rules + .iter() + .filter(|rule| !is_derived_site_replication_rule(rule)) + .cloned() + .collect(); + rules.extend(desired.rules); + // Operator priorities are the operator's policy; only the derived rules + // take free slots, by the same function as the config merges so a merged + // write and this pass agree byte for byte. + assign_site_replication_rule_priorities(&mut rules, is_derived_site_replication_rule); + + // Only a `role` naming a current peer is ours to drop — an operator-authored role is + // part of the bucket's S3-visible configuration, and repairing a reverse rule must not + // quietly rewrite it. Same rule as `merge_incoming_replication_config`. + let role = if is_site_replication_role(&existing_role, &remote_peer_deployment_ids(state, local_peer)) { + String::new() + } else { + existing_role.clone() + }; + + if rules == existing_rules && role == existing_role { + return Ok(()); + } + + let data = serialize(&ReplicationConfiguration { role, rules }) + .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize replication failed: {e}")))?; + metadata_sys::update_if_incarnation(bucket, BUCKET_REPLICATION_CONFIG, data, expected_incarnation_id) + .await + .map_err(ApiError::from)?; + + Ok(()) +} + +pub(crate) async fn ensure_site_replication_bucket_setup_with_runtime( + bucket: &str, + runtime: &SiteReplicationRuntime, +) -> S3Result<()> { + let expected_incarnation_id = metadata_sys::capture_bucket_metadata_incarnation(bucket) + .await + .map_err(ApiError::from)?; + ensure_site_replication_bucket_setup_with_runtime_for_incarnation(bucket, runtime, expected_incarnation_id).await +} + +pub(crate) async fn ensure_site_replication_bucket_setup_with_runtime_for_incarnation( + bucket: &str, + runtime: &SiteReplicationRuntime, + expected_incarnation_id: Uuid, +) -> S3Result<()> { + let _targets_guard = lock_bucket_targets_metadata(bucket).await; + let config = bucket_replication_config_for_target_refresh(bucket).await?; + ensure_site_replication_bucket_targets_with_runtime( + bucket, + &runtime.state, + &runtime.local_peer, + config.as_ref(), + &runtime.service_account_secret_key, + expected_incarnation_id, + ) + .await?; + ensure_site_replication_bucket_replication_config_with_runtime( + bucket, + &runtime.state, + &runtime.local_peer, + &runtime.service_account_secret_key, + expected_incarnation_id, + ) + .await?; + Ok(()) +} + +pub(crate) fn bucket_versioning_xml() -> S3Result> { + let config = VersioningConfiguration { + status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)), + ..Default::default() + }; + serialize(&config).map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize versioning failed: {e}"))) +} + +pub(crate) async fn ensure_site_replication_bucket_versioning(bucket: &str) -> S3Result<()> { + let expected_incarnation_id = metadata_sys::capture_bucket_metadata_incarnation(bucket) + .await + .map_err(ApiError::from)?; + match metadata_sys::get_versioning_config(bucket).await { + Ok((config, _)) if config.enabled() => return Ok(()), + Ok(_) | Err(StorageError::ConfigNotFound) => {} + Err(err) => return Err(ApiError::from(err).into()), + } + + metadata_sys::update_if_incarnation(bucket, BUCKET_VERSIONING_CONFIG, bucket_versioning_xml()?, expected_incarnation_id) + .await + .map_err(ApiError::from)?; + + Ok(()) +} diff --git a/rustfs/src/admin/site_replication_identity.rs b/rustfs/src/site_replication/identity.rs similarity index 99% rename from rustfs/src/admin/site_replication_identity.rs rename to rustfs/src/site_replication/identity.rs index 24784160b..2a4738ebf 100644 --- a/rustfs/src/admin/site_replication_identity.rs +++ b/rustfs/src/site_replication/identity.rs @@ -86,7 +86,7 @@ pub(crate) fn mark_unknown_peer_sync_enabled(peers: &mut BTreeMap bool { +pub(crate) fn is_https_endpoint(endpoint: &str) -> bool { canonical_endpoint(endpoint).starts_with("https://") } diff --git a/rustfs/src/site_replication/mod.rs b/rustfs/src/site_replication/mod.rs new file mode 100644 index 000000000..4c3ad07d9 --- /dev/null +++ b/rustfs/src/site_replication/mod.rs @@ -0,0 +1,166 @@ +// 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. + +//! Site-replication service subsystem (backlog#1840). +//! +//! The parts of site replication that storage-side flows call into — the +//! persisted cluster state and its RMW transaction, the peer HTTP transport, +//! the retry queue, the repair state machine, and the bucket/IAM broadcast +//! hooks — live here in the infra layer. The admin HTTP handlers stay in +//! `crate::admin::handlers::site_replication` and call down into this module; +//! that file re-exports these items so existing paths keep resolving. +//! +//! Storage access goes through the root facade (`crate::storage_api`) and +//! never through the admin or storage interface layers — this module sits +//! below the interface layer and must not import upward. + +pub(crate) mod identity; +pub(crate) mod state_lock; + +pub(crate) mod hooks; +pub(crate) mod repair; +pub(crate) mod retry; +pub(crate) mod state; +pub(crate) mod transport; + +#[cfg(test)] +mod tests; + +pub(crate) use self::hooks::*; +pub(crate) use self::repair::*; +pub(crate) use self::retry::*; +pub(crate) use self::state::*; +pub(crate) use self::transport::*; + +use self::identity::{ + canonical_endpoint, deployment_id_for_endpoint, mark_unknown_peer_sync_enabled, normalize_peer_map_by_identity_with, + same_identity_endpoint, +}; +use self::state_lock::{SITE_REPLICATION_STATE_PATH, with_site_replication_state_lock}; +use crate::auth::constant_time_eq; +use crate::config::get_config_snapshot; +use crate::error::ApiError; +use crate::runtime_sources::{ + current_deployment_id, current_endpoints_handle, current_iam_handle, current_object_store_handle, current_region, +}; +use crate::storage_api::site_replication::s3::{ + Body, BucketLifecycleConfiguration, BucketVersioningStatus, DeleteMarkerReplication, DeleteMarkerReplicationStatus, + DeleteReplication, DeleteReplicationStatus, Destination, ExistingObjectReplication, ExistingObjectReplicationStatus, + LifecycleRule, ReplicaModifications, ReplicaModificationsStatus, ReplicationConfiguration, ReplicationRule, + ReplicationRuleStatus, S3Error, S3ErrorCode, S3Response, S3Result, SourceSelectionCriteria, VersioningConfiguration, + s3_error, +}; +#[cfg(test)] +use crate::storage_api::site_replication::save_config as save_admin_config; +use crate::storage_api::site_replication::{ + ARN, BUCKET_REPLICATION_CONFIG, BUCKET_TARGETS_FILE, BUCKET_VERSIONING_CONFIG, BucketOperations, BucketOptions, BucketTarget, + BucketTargetSys, BucketTargetType, BucketTargets, Credentials, ECStore, OperatorRuleContract, StorageError, + VersioningApi as _, assign_site_replication_rule_priorities, delete_config_no_lock, deserialize, is_site_replication_role, + lock_bucket_targets_metadata, metadata_sys, read_config as read_admin_config, read_config_no_lock, + replication_target_arn_deployment_id, save_config_no_lock, serialize, site_replication_rule_deployment_id, + with_config_object_read_lock, with_config_object_write_lock, +}; +use base64_simd::STANDARD as BASE64_STANDARD; +use base64_simd::URL_SAFE_NO_PAD; +use hmac::{Hmac, Mac}; +use http::header::{CONTENT_TYPE, HOST}; +use http::{HeaderMap, HeaderValue, Uri}; +use hyper::{Method, StatusCode}; +use rustfs_config::{DEFAULT_CONSOLE_ADDRESS, DEFAULT_RUSTFS_TLS_PATH, ENV_RUSTFS_CONSOLE_ADDRESS, ENV_RUSTFS_TLS_PATH}; +use rustfs_iam::store::{MappedPolicy, UserType, sr_wire_user_type}; +use rustfs_iam::sys::SITE_REPLICATOR_SERVICE_ACCOUNT; +use rustfs_madmin::{ + AddOrUpdateUserReq, GroupAddRemove, GroupStatus, PeerInfo, PeerSite, ReplicateEditStatus, SITE_REPL_API_VERSION, + SRBucketInfo, SRBucketMeta, SRGroupInfo, SRIAMItem, SRIAMPolicy, SRInfo, SRPolicyMapping, SRRemoveReq, SRResyncOpStatus, + SRRetryStats, SRStateInfo, SyncStatus, +}; +use rustfs_signer::constants::UNSIGNED_PAYLOAD; +use rustfs_signer::sign_v4; +use rustfs_tls_runtime::{GlobalPublishedOutboundTlsState, TlsGeneration}; +use rustfs_utils::egress::{OutboundUrlError, validate_outbound_url}; +use rustfs_utils::http::get_source_scheme; +use rustls_pki_types::pem::PemObject; +use serde::Deserialize; +use serde::Serialize; +use serde::de::IgnoredAny; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::net::{IpAddr, SocketAddr}; +use std::sync::{Arc, LazyLock}; +use std::time::Duration; +use time::OffsetDateTime; +use tokio::sync::{Mutex, RwLock}; +use tracing::{info, warn}; +use url::{Url, form_urlencoded}; +use uuid::Uuid; + +pub(crate) const LOG_COMPONENT_ADMIN: &str = "admin"; + +pub(crate) const LOG_SUBSYSTEM_SITE_REPLICATION: &str = "site_replication"; + +pub(crate) const EVENT_ADMIN_SITE_REPLICATION_STATE: &str = "admin_site_replication_state"; + +/// Layer-local mirror of `crate::admin::utils::json_response` (the repair +/// executor answers the admin HTTP surface but must not import upward from +/// the infra layer). +fn json_response(status: StatusCode, value: &T) -> S3Result> { + let data = serde_json::to_vec(value) + .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("failed to serialize response: {e}")))?; + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + Ok(S3Response::with_headers((status, Body::from(data)), headers)) +} + +// The admin layer's runtime-source wrappers apply fallbacks on top of +// `crate::runtime_sources`; this module reproduces the same fallbacks locally +// (verbatim from `crate::admin::runtime_sources`) so it never imports upward +// into the interface layer. + +#[cfg(test)] +static TEST_OUTBOUND_TLS_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +#[cfg(test)] +pub(crate) fn set_test_outbound_tls_generation(generation: u64) { + crate::runtime_sources::set_test_outbound_tls_generation(generation); + TEST_OUTBOUND_TLS_GENERATION.store(generation, std::sync::atomic::Ordering::Relaxed); +} + +fn current_outbound_tls_generation() -> TlsGeneration { + crate::runtime_sources::current_outbound_tls_generation().unwrap_or_else(empty_outbound_tls_generation) +} + +#[cfg(test)] +fn empty_outbound_tls_generation() -> TlsGeneration { + TlsGeneration(TEST_OUTBOUND_TLS_GENERATION.load(std::sync::atomic::Ordering::Relaxed)) +} + +#[cfg(not(test))] +fn empty_outbound_tls_generation() -> TlsGeneration { + TlsGeneration(0) +} + +async fn current_outbound_tls_state() -> GlobalPublishedOutboundTlsState { + if let Some(state) = crate::runtime_sources::current_outbound_tls_state().await { + return state; + } + + crate::runtime_sources::fallback_outbound_tls_runtime_interface() + .state() + .await +} + +fn current_runtime_port() -> u16 { + crate::runtime_sources::current_runtime_port().unwrap_or(rustfs_config::DEFAULT_PORT) +} diff --git a/rustfs/src/site_replication/repair.rs b/rustfs/src/site_replication/repair.rs new file mode 100644 index 000000000..f2c220c47 --- /dev/null +++ b/rustfs/src/site_replication/repair.rs @@ -0,0 +1,830 @@ +// 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. + +use super::*; + +pub(crate) const SITE_REPLICATION_REPAIR_STATE_PATH: &str = "config/site-replication/repair-state.json"; + +pub(crate) const SITE_REPLICATION_REPAIR_EXECUTION_LOCK_PATH: &str = "config/site-replication/repair-execution.lock"; + +pub(crate) const SITE_REPLICATION_REPAIR_OPERATION_LIMIT: usize = 32; + +pub(crate) const SITE_REPLICATION_REPAIR_IAM_FAMILY: &str = "iam"; + +pub(crate) const SITE_REPLICATION_REPAIR_BUCKET_FAMILY: &str = "bucket"; + +pub(crate) const SITE_REPLICATION_REPAIR_BUCKET_METADATA_FAMILY: &str = "bucket-metadata"; + +pub(crate) const SITE_REPLICATION_REPAIR_REPLICATION_FAMILY: &str = "replication"; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SiteReplicationRepairState { + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub(crate) operations: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SiteReplicationRepairOperation { + pub(crate) operation_id: String, + pub(crate) preflight_token: String, + pub(crate) plan_token: String, + pub(crate) status: String, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub(crate) sites: BTreeMap, + #[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")] + pub(crate) created_at: Option, + #[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")] + pub(crate) updated_at: Option, + #[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")] + pub(crate) completed_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SiteReplicationRepairSiteStatus { + pub(crate) deployment_id: String, + pub(crate) name: String, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub(crate) families: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SiteReplicationRepairFamilyStatus { + pub(crate) planned: usize, + pub(crate) succeeded: usize, + pub(crate) failed: usize, + #[serde(default)] + pub(crate) retry_events: usize, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) tasks: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) errors: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SiteReplicationRepairTaskStatus { + pub(crate) task_id: String, + pub(crate) status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) error: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct SiteReplicationRepairRequest { + pub(crate) mode: SiteReplicationRepairMode, + #[serde(default)] + pub(crate) preflight_token: Option, + #[serde(default)] + pub(crate) operation_id: Option, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum SiteReplicationRepairMode { + DryRun, + Execute, +} + +pub(crate) struct SiteReplicationRepairExecutionRequest { + pub(crate) local_peer: PeerInfo, + pub(crate) preflight_token: String, + pub(crate) operation_id: String, + pub(crate) signing_key: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SiteReplicationRepairPreflight { + pub(crate) mode: &'static str, + pub(crate) status: &'static str, + pub(crate) preflight_token: String, + pub(crate) retry_events: usize, + pub(crate) sites: BTreeMap, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SiteReplicationRepairOperationResponse { + pub(crate) mode: &'static str, + pub(crate) operation_id: String, + pub(crate) status: String, + pub(crate) sites: BTreeMap, + #[serde(with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")] + pub(crate) created_at: Option, + #[serde(with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")] + pub(crate) updated_at: Option, + #[serde(with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")] + pub(crate) completed_at: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SiteReplicationRepairSiteResponse { + pub(crate) deployment_id: String, + pub(crate) name: String, + pub(crate) families: BTreeMap, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SiteReplicationRepairFamilyResponse { + pub(crate) planned: usize, + pub(crate) succeeded: usize, + pub(crate) failed: usize, + pub(crate) retry_events: usize, + pub(crate) tasks: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) errors: Vec, +} + +pub(crate) async fn load_site_replication_repair_state_from_store(store: Arc) -> S3Result { + match read_config_no_lock(store, SITE_REPLICATION_REPAIR_STATE_PATH).await { + Ok(data) => serde_json::from_slice(&data).map_err(|e| { + S3Error::with_message(S3ErrorCode::InternalError, format!("invalid site replication repair state: {e}")) + }), + Err(StorageError::ConfigNotFound) => Ok(SiteReplicationRepairState::default()), + Err(err) => Err(S3Error::with_message( + S3ErrorCode::InternalError, + format!("failed to load site replication repair state: {err}"), + )), + } +} + +pub(crate) async fn save_site_replication_repair_state_to_store( + store: Arc, + state: &SiteReplicationRepairState, +) -> S3Result<()> { + let data = serde_json::to_vec(state) + .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize repair state failed: {e}")))?; + save_config_no_lock(store, SITE_REPLICATION_REPAIR_STATE_PATH, data) + .await + .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("save repair state failed: {e}"))) +} + +pub(crate) async fn read_site_replication_repair_state() -> S3Result { + let store = + current_object_store_handle().ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()))?; + let read_store = store.clone(); + with_config_object_read_lock(store, SITE_REPLICATION_REPAIR_STATE_PATH.to_string(), move || async move { + load_site_replication_repair_state_from_store(read_store).await + }) + .await + .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("lock repair state failed: {e}")))? +} + +pub(crate) async fn update_site_replication_repair_state(update: F) -> S3Result +where + T: Send + 'static, + F: FnOnce(&mut SiteReplicationRepairState) -> S3Result + Send + 'static, +{ + let store = + current_object_store_handle().ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()))?; + let read_store = store.clone(); + let save_store = store.clone(); + with_config_object_write_lock(store, SITE_REPLICATION_REPAIR_STATE_PATH.to_string(), move || async move { + let mut state = load_site_replication_repair_state_from_store(read_store).await?; + let result = update(&mut state)?; + save_site_replication_repair_state_to_store(save_store, &state).await?; + Ok(result) + }) + .await + .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("lock repair state failed: {e}")))? +} + +pub(crate) enum SiteReplicationRepairTask<'a> { + Iam(&'a SRIAMItem), + BucketMake(&'a str), + BucketMetadata(&'a SRBucketMeta), + Replication(&'a str), +} + +impl SiteReplicationRepairTask<'_> { + pub(crate) fn family(&self) -> &'static str { + match self { + Self::Iam(_) => SITE_REPLICATION_REPAIR_IAM_FAMILY, + Self::BucketMake(_) => SITE_REPLICATION_REPAIR_BUCKET_FAMILY, + Self::BucketMetadata(_) => SITE_REPLICATION_REPAIR_BUCKET_METADATA_FAMILY, + Self::Replication(_) => SITE_REPLICATION_REPAIR_REPLICATION_FAMILY, + } + } + + pub(crate) fn path(&self) -> &str { + match self { + Self::Iam(_) => "/rustfs/admin/v3/site-replication/peer/iam-item", + Self::BucketMake(path) | Self::Replication(path) => path, + Self::BucketMetadata(_) => "/rustfs/admin/v3/site-replication/peer/bucket-meta", + } + } + + pub(crate) fn id(&self) -> S3Result { + let payload = match self { + Self::Iam(item) => serde_json::to_vec(item), + Self::BucketMake(_) | Self::Replication(_) => serde_json::to_vec(&serde_json::json!({})), + Self::BucketMetadata(item) => serde_json::to_vec(item), + } + .map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize repair task failed: {err}")))?; + let mut digest = Sha256::new(); + digest.update(self.family().as_bytes()); + digest.update([0]); + digest.update(self.path().as_bytes()); + digest.update([0]); + digest.update(payload); + Ok(URL_SAFE_NO_PAD.encode_to_string(digest.finalize())) + } + + pub(crate) async fn send(&self, transport: &PeerTransport, access_key: &str, secret_key: &str) -> S3Result> { + match self { + Self::Iam(item) => { + PeerAdminRequest::put(&transport.connection, self.path(), access_key) + .with_client(&transport.client) + .send(secret_key, item) + .await + } + Self::BucketMetadata(item) => { + PeerAdminRequest::put(&transport.connection, self.path(), access_key) + .with_client(&transport.client) + .send(secret_key, item) + .await + } + Self::BucketMake(_) | Self::Replication(_) => { + PeerAdminRequest::put(&transport.connection, self.path(), access_key) + .with_client(&transport.client) + .send(secret_key, &serde_json::json!({})) + .await + } + } + } +} + +pub(crate) fn site_replication_repair_tasks(plan: &SiteReplicationBootstrapPlan) -> Vec<(usize, SiteReplicationRepairTask<'_>)> { + let mut tasks = Vec::with_capacity( + plan.iam_items.len() + plan.bucket_make_ops.len() + plan.bucket_items.len() + plan.bucket_configure_ops.len(), + ); + tasks.extend( + plan.iam_items + .iter() + .enumerate() + .map(|(index, item)| (index, SiteReplicationRepairTask::Iam(item))), + ); + tasks.extend( + plan.bucket_make_ops + .iter() + .enumerate() + .map(|(index, path)| (index, SiteReplicationRepairTask::BucketMake(path))), + ); + tasks.extend( + plan.bucket_items + .iter() + .enumerate() + .map(|(index, item)| (index, SiteReplicationRepairTask::BucketMetadata(item))), + ); + tasks.extend( + plan.bucket_configure_ops + .iter() + .enumerate() + .map(|(index, path)| (index, SiteReplicationRepairTask::Replication(path))), + ); + tasks +} + +pub(crate) fn site_replication_repair_plan_token( + state: &SiteReplicationState, + plan: &SiteReplicationBootstrapPlan, +) -> S3Result { + let mut digest = Sha256::new(); + let snapshot = serde_json::to_vec(&( + &state.name, + &state.service_account_access_key, + &state.peers, + state.updated_at, + state.sync_state_initialized, + )) + .map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize repair snapshot failed: {err}")))?; + digest.update(snapshot); + for (_, task) in site_replication_repair_tasks(plan) { + digest.update(task.id()?.as_bytes()); + } + Ok(URL_SAFE_NO_PAD.encode_to_string(digest.finalize())) +} + +pub(crate) fn site_replication_repair_preflight_token( + state: &SiteReplicationState, + plan: &SiteReplicationBootstrapPlan, + signing_key: &[u8], +) -> S3Result { + if signing_key.is_empty() { + return Err(S3Error::with_message( + S3ErrorCode::InternalError, + "repair signing key is empty".to_string(), + )); + } + let mut digest = as hmac::digest::KeyInit>::new_from_slice(signing_key) + .map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "invalid repair signing key".to_string()))?; + digest.update(b"rustfs:site-replication:repair-preflight:v1\0"); + digest.update(site_replication_repair_plan_token(state, plan)?.as_bytes()); + for event in state + .retry_queue + .iter() + .filter(|event| retry_event_replayed_by_bootstrap(event)) + { + digest.update(event.id.as_bytes()); + digest.update(&[0]); + digest.update(event.peer_deployment_id.as_bytes()); + digest.update(&[0]); + digest.update(event.path.as_bytes()); + digest.update(&[0]); + } + Ok(URL_SAFE_NO_PAD.encode_to_string(digest.finalize().into_bytes())) +} + +pub(crate) fn site_replication_repair_task_checkpoint_id( + signing_key: &[u8], + peer_deployment_id: &str, + task: &SiteReplicationRepairTask<'_>, +) -> S3Result { + let mut digest = as hmac::digest::KeyInit>::new_from_slice(signing_key) + .map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "invalid repair signing key".to_string()))?; + digest.update(b"rustfs:site-replication:repair-task:v1\0"); + digest.update(peer_deployment_id.as_bytes()); + digest.update(&[0]); + digest.update(task.id()?.as_bytes()); + Ok(URL_SAFE_NO_PAD.encode_to_string(digest.finalize().into_bytes())) +} + +pub(crate) fn site_replication_repair_sites( + state: &SiteReplicationState, + local_peer: &PeerInfo, + plan: &SiteReplicationBootstrapPlan, + signing_key: &[u8], +) -> S3Result> { + let mut planned = BTreeMap::new(); + let mut family_paths = BTreeMap::>::new(); + for (_, task) in site_replication_repair_tasks(plan) { + let family = task.family().to_string(); + let family_status = planned + .entry(task.family().to_string()) + .or_insert_with(SiteReplicationRepairFamilyStatus::default); + family_status.planned += 1; + family_paths.entry(family).or_default().insert(task.path().to_string()); + } + + let mut sites = BTreeMap::new(); + for peer in state.peers.values().filter(|peer| { + peer.deployment_id != local_peer.deployment_id && !same_identity_endpoint(&peer.endpoint, &local_peer.endpoint) + }) { + let mut families = planned.clone(); + for (_, task) in site_replication_repair_tasks(plan) { + let family = families + .get_mut(task.family()) + .ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair task family is missing".to_string()))?; + family.tasks.push(SiteReplicationRepairTaskStatus { + task_id: site_replication_repair_task_checkpoint_id(signing_key, &peer.deployment_id, &task)?, + status: "planned".to_string(), + error: None, + }); + } + for (family, status) in &mut families { + status.retry_events = state + .retry_queue + .iter() + .filter(|event| { + event.peer_deployment_id == peer.deployment_id + && retry_event_replayed_by_bootstrap(event) + && family_paths.get(family).is_some_and(|paths| paths.contains(&event.path)) + }) + .count(); + } + sites.insert( + peer.deployment_id.clone(), + SiteReplicationRepairSiteStatus { + deployment_id: peer.deployment_id.clone(), + name: peer.name.clone(), + families, + }, + ); + } + Ok(sites) +} + +pub(crate) fn update_site_replication_repair_task( + operation: &mut SiteReplicationRepairOperation, + deployment_id: &str, + family: &str, + family_index: usize, + result: Result<(), &str>, +) -> S3Result<()> { + let site = operation + .sites + .get_mut(deployment_id) + .ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair operation site is missing".to_string()))?; + let family_status = site + .families + .get_mut(family) + .ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair operation family is missing".to_string()))?; + if family_status.succeeded != family_index { + return Err(S3Error::with_message( + S3ErrorCode::InternalError, + "repair operation task checkpoint is invalid".to_string(), + )); + } + let task_status = family_status.tasks.get_mut(family_index).ok_or_else(|| { + S3Error::with_message(S3ErrorCode::InternalError, "repair operation task checkpoint is missing".to_string()) + })?; + family_status.failed = 0; + family_status.errors.clear(); + match result { + Ok(()) => { + family_status.succeeded = family_status.succeeded.saturating_add(1); + task_status.status = "succeeded".to_string(); + task_status.error = None; + } + Err(error) => { + let error = classify_site_replication_repair_error(error).to_string(); + family_status.failed = 1; + family_status.errors.push(error.clone()); + task_status.status = "failed".to_string(); + task_status.error = Some(error); + } + } + Ok(()) +} + +pub(crate) fn site_replication_repair_task_pending( + operation: &SiteReplicationRepairOperation, + deployment_id: &str, + family: &str, + family_index: usize, +) -> S3Result { + let site = operation + .sites + .get(deployment_id) + .ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair operation site is missing".to_string()))?; + let family = site + .families + .get(family) + .ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair operation family is missing".to_string()))?; + if family.succeeded > family_index { + return Ok(false); + } + if family.succeeded < family_index { + return Ok(false); + } + Ok(family.failed == 0) +} + +pub(crate) fn prepare_site_replication_repair_retry(operation: &mut SiteReplicationRepairOperation) { + for family in operation.sites.values_mut().flat_map(|site| site.families.values_mut()) { + family.failed = 0; + family.errors.clear(); + for task in &mut family.tasks { + match task.status.as_str() { + "succeeded" => task.status = "skipped".to_string(), + "failed" => { + task.status = "planned".to_string(); + task.error = None; + } + _ => {} + } + } + } +} + +pub(crate) fn classify_site_replication_repair_error(error: &str) -> &'static str { + let error = error.to_ascii_lowercase(); + if error.contains("accessdenied") + || error.contains("signaturedoesnotmatch") + || error.contains("unauthorized") + || error.contains("forbidden") + || error.contains("401") + || error.contains("403") + { + "authorization-failed" + } else if error.contains("timeout") { + "remote-timeout" + } else if error.contains("dns") { + "remote-dns-failed" + } else if error.contains("tls") || error.contains("certificate") { + "remote-tls-failed" + } else if error.contains("connect") { + "remote-connect-failed" + } else { + "remote-operation-failed" + } +} + +pub(crate) fn summarize_site_replication_repair_operation(operation: &mut SiteReplicationRepairOperation) { + let failed = operation + .sites + .values() + .flat_map(|site| site.families.values()) + .any(|family| family.failed > 0); + let complete = operation + .sites + .values() + .all(|site| site.families.values().all(|family| family.succeeded == family.planned)); + operation.status = if complete { + "success" + } else if failed { + "partial" + } else { + "running" + } + .to_string(); + operation.updated_at = Some(OffsetDateTime::now_utc()); + operation.completed_at = complete.then_some(OffsetDateTime::now_utc()); +} + +pub(crate) fn site_replication_repair_operation_response( + operation: &SiteReplicationRepairOperation, +) -> SiteReplicationRepairOperationResponse { + SiteReplicationRepairOperationResponse { + mode: "execute", + operation_id: operation.operation_id.clone(), + status: operation.status.clone(), + sites: operation + .sites + .iter() + .map(|(deployment_id, site)| { + ( + deployment_id.clone(), + SiteReplicationRepairSiteResponse { + deployment_id: site.deployment_id.clone(), + name: site.name.clone(), + families: site + .families + .iter() + .map(|(family, status)| { + ( + family.clone(), + SiteReplicationRepairFamilyResponse { + planned: status.planned, + succeeded: status.succeeded, + failed: status.failed, + retry_events: status.retry_events, + tasks: status.tasks.clone(), + errors: status.errors.clone(), + }, + ) + }) + .collect(), + }, + ) + }) + .collect(), + created_at: operation.created_at, + updated_at: operation.updated_at, + completed_at: operation.completed_at, + } +} + +pub(crate) fn prune_site_replication_repair_operations(operations: &mut BTreeMap) { + while operations.len() > SITE_REPLICATION_REPAIR_OPERATION_LIMIT { + let Some(oldest) = operations + .iter() + .filter(|(_, operation)| operation.status == "success") + .min_by_key(|(_, operation)| operation.created_at) + .map(|(id, _)| id.clone()) + else { + break; + }; + operations.remove(&oldest); + } +} + +pub(crate) async fn persist_site_replication_repair_operation(operation: &SiteReplicationRepairOperation) -> S3Result<()> { + let operation = operation.clone(); + update_site_replication_repair_state(move |state| { + if let Some(existing) = state.operations.get(&operation.operation_id) + && !constant_time_eq(&existing.preflight_token, &operation.preflight_token) + { + return Err(S3Error::with_message( + S3ErrorCode::ClientTokenConflict, + "repair operation ID is already bound to a different preflight".to_string(), + )); + } + state.operations.insert(operation.operation_id.clone(), operation); + prune_site_replication_repair_operations(&mut state.operations); + Ok(()) + }) + .await +} + +pub(crate) async fn persist_site_replication_repair_task( + operation: &SiteReplicationRepairOperation, + peer: &PeerInfo, + family: &str, + path: &str, +) -> S3Result<()> { + persist_site_replication_repair_operation(operation).await?; + + let family_status = operation + .sites + .get(&peer.deployment_id) + .and_then(|site| site.families.get(family)) + .ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair task status is missing".to_string()))?; + let failure = (family_status.failed > 0).then(|| { + family_status + .errors + .first() + .cloned() + .unwrap_or_else(|| "remote-operation-failed".to_string()) + }); + let peer = peer.clone(); + let path = path.to_string(); + update_site_replication_state(move |state| { + match failure.as_deref() { + Some(error) => upsert_site_replication_retry_event(&mut state.retry_queue, &peer, &path, error, None), + None => { + dequeue_site_replication_retry_events_including_escalated(&mut state.retry_queue, &peer, &path); + } + } + Ok(()) + }) + .await +} + +pub(crate) fn admit_site_replication_repair_operation( + repair_state: &mut SiteReplicationRepairState, + operation_id: String, + supplied_token: &str, + candidate: SiteReplicationRepairOperation, +) -> S3Result { + if let Some(existing) = repair_state.operations.get(&operation_id) { + if !constant_time_eq(&existing.preflight_token, supplied_token) { + return Err(S3Error::with_message( + S3ErrorCode::ClientTokenConflict, + "repair operation ID is already bound to a different preflight".to_string(), + )); + } + if !constant_time_eq(&existing.plan_token, &candidate.plan_token) { + return Err(S3Error::with_message( + S3ErrorCode::PreconditionFailed, + "site replication repair plan changed after partial execution".to_string(), + )); + } + return Ok(existing.clone()); + } + if repair_state + .operations + .values() + .any(|operation| operation.status == "running") + { + return Err(S3Error::with_message( + S3ErrorCode::ClientTokenConflict, + "another site replication repair is active".to_string(), + )); + } + repair_state.operations.insert(operation_id, candidate.clone()); + prune_site_replication_repair_operations(&mut repair_state.operations); + Ok(candidate) +} + +pub(crate) async fn execute_site_replication_repair( + request: SiteReplicationRepairExecutionRequest, +) -> S3Result> { + let store = + current_object_store_handle().ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()))?; + with_config_object_write_lock(store, SITE_REPLICATION_REPAIR_EXECUTION_LOCK_PATH.to_string(), move || async move { + execute_site_replication_repair_locked(request).await + }) + .await + .map_err(|_| { + S3Error::with_message(S3ErrorCode::ClientTokenConflict, "another site replication repair is active".to_string()) + })? +} + +pub(crate) async fn execute_site_replication_repair_locked( + request: SiteReplicationRepairExecutionRequest, +) -> S3Result> { + let state = load_site_replication_state().await?; + if !state.enabled() || state.service_account_access_key.is_empty() { + return Err(s3_error!(InvalidRequest, "site replication is not configured")); + } + let info = build_sr_info(&state, &request.local_peer).await?; + let plan = site_replication_bootstrap_plan(&info)?; + let plan_token = site_replication_repair_plan_token(&state, &plan)?; + let preflight_token = site_replication_repair_preflight_token(&state, &plan, request.signing_key.as_bytes())?; + let sites = site_replication_repair_sites(&state, &request.local_peer, &plan, request.signing_key.as_bytes())?; + + let repair_state = read_site_replication_repair_state().await?; + if let Some(existing) = repair_state.operations.get(&request.operation_id) { + if !constant_time_eq(&existing.preflight_token, &request.preflight_token) { + return Err(S3Error::with_message( + S3ErrorCode::ClientTokenConflict, + "repair operation ID is already bound to a different preflight".to_string(), + )); + } + if existing.status == "success" { + return json_response(StatusCode::OK, &site_replication_repair_operation_response(existing)); + } + if !constant_time_eq(&existing.plan_token, &plan_token) { + return Err(S3Error::with_message( + S3ErrorCode::PreconditionFailed, + "site replication repair plan changed after partial execution".to_string(), + )); + } + } else if !constant_time_eq(&request.preflight_token, &preflight_token) { + return Err(S3Error::with_message( + S3ErrorCode::PreconditionFailed, + "site replication repair preflight is stale".to_string(), + )); + } + + let now = OffsetDateTime::now_utc(); + let candidate = SiteReplicationRepairOperation { + operation_id: request.operation_id.clone(), + preflight_token, + plan_token, + status: "running".to_string(), + sites, + created_at: Some(now), + updated_at: Some(now), + completed_at: None, + }; + let supplied_token = request.preflight_token; + let operation_id = request.operation_id; + let mut operation = update_site_replication_repair_state(move |repair_state| { + admit_site_replication_repair_operation(repair_state, operation_id, &supplied_token, candidate) + }) + .await?; + if operation.status == "success" { + return json_response(StatusCode::OK, &site_replication_repair_operation_response(&operation)); + } + + let service_account_secret_key = site_replicator_service_account_secret(&state.service_account_access_key).await?; + prepare_site_replication_repair_retry(&mut operation); + operation.status = "running".to_string(); + operation.completed_at = None; + operation.updated_at = Some(OffsetDateTime::now_utc()); + persist_site_replication_repair_operation(&operation).await?; + + let tasks = site_replication_repair_tasks(&plan); + for peer in state.peers.values().filter(|peer| { + peer.deployment_id != request.local_peer.deployment_id + && !same_identity_endpoint(&peer.endpoint, &request.local_peer.endpoint) + }) { + let transport = match PeerTransport::for_runtime_peer(peer).await { + Ok(transport) => transport, + Err(err) => { + let error = err.to_string(); + for (family_index, task) in &tasks { + if !site_replication_repair_task_pending(&operation, &peer.deployment_id, task.family(), *family_index)? { + continue; + } + update_site_replication_repair_task( + &mut operation, + &peer.deployment_id, + task.family(), + *family_index, + Err(&error), + )?; + summarize_site_replication_repair_operation(&mut operation); + persist_site_replication_repair_task(&operation, peer, task.family(), task.path()).await?; + } + continue; + } + }; + + for (family_index, task) in &tasks { + if !site_replication_repair_task_pending(&operation, &peer.deployment_id, task.family(), *family_index)? { + continue; + } + let result = task + .send(&transport, &state.service_account_access_key, &service_account_secret_key) + .await; + let error = result.err().map(|err| err.to_string()); + update_site_replication_repair_task( + &mut operation, + &peer.deployment_id, + task.family(), + *family_index, + match error.as_deref() { + Some(error) => Err(error), + None => Ok(()), + }, + )?; + summarize_site_replication_repair_operation(&mut operation); + persist_site_replication_repair_task(&operation, peer, task.family(), task.path()).await?; + } + } + + summarize_site_replication_repair_operation(&mut operation); + persist_site_replication_repair_operation(&operation).await?; + json_response(StatusCode::OK, &site_replication_repair_operation_response(&operation)) +} diff --git a/rustfs/src/site_replication/retry.rs b/rustfs/src/site_replication/retry.rs new file mode 100644 index 000000000..ef12c9786 --- /dev/null +++ b/rustfs/src/site_replication/retry.rs @@ -0,0 +1,923 @@ +// 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. + +use super::*; + +pub(crate) const SITE_REPLICATION_RETRY_QUEUE_LIMIT: usize = 256; + +pub(crate) const SITE_REPLICATION_RETRY_FAILED_AFTER: u32 = 3; + +pub(crate) const SITE_REPLICATION_ENDPOINT_REFRESH_RETRY_PATH: &str = "internal:endpoint-target-refresh"; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub(crate) struct SiteReplicationRetryEvent { + pub(crate) id: String, + pub(crate) peer_deployment_id: String, + pub(crate) peer_endpoint: String, + pub(crate) path: String, + pub(crate) retry_count: u32, + pub(crate) failed: bool, + pub(crate) last_error: String, + #[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")] + pub(crate) updated_at: Option, + /// Peer-edit generation whose delivery failed, when the failing send + /// carried one. Settling a *later* success for the same (peer, path) must + /// not erase a failure recorded for a NEWER generation — see + /// [`settle_site_replication_retry_events`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) edit_generation: Option, +} + +pub(crate) fn retry_event_matches(event: &SiteReplicationRetryEvent, peer: &PeerInfo, path: &str) -> bool { + (event.peer_deployment_id == peer.deployment_id || event.peer_endpoint == peer.endpoint) && event.path == path +} + +pub(crate) const SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH: &str = "internal:retry-snapshot:iam"; + +pub(crate) const SITE_REPLICATION_RETRY_BUCKET_METADATA_SNAPSHOT_PATH: &str = "internal:retry-snapshot:bucket-metadata"; + +pub(crate) fn collapsed_retry_queue_path(path: &str) -> Option<&'static str> { + let base_path = path.split_once('?').map(|(base, _)| base).unwrap_or(path); + match base_path { + "/rustfs/admin/v3/site-replication/peer/iam-item" | SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH => { + Some(SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH) + } + "/rustfs/admin/v3/site-replication/peer/bucket-meta" | SITE_REPLICATION_RETRY_BUCKET_METADATA_SNAPSHOT_PATH => { + Some(SITE_REPLICATION_RETRY_BUCKET_METADATA_SNAPSHOT_PATH) + } + _ => None, + } +} + +pub(crate) fn normalize_collapsed_retry_queue_paths(queue: &mut Vec) -> bool { + let mut changed = false; + let mut normalized: Vec = Vec::with_capacity(queue.len()); + for mut event in queue.drain(..) { + if let Some(path) = collapsed_retry_queue_path(&event.path) + && event.path != path + { + event.path = path.to_string(); + changed = true; + } + + let duplicate = normalized.iter().position(|existing| { + existing.path == event.path + && (existing.peer_deployment_id == event.peer_deployment_id || existing.peer_endpoint == event.peer_endpoint) + }); + let Some(index) = duplicate else { + normalized.push(event); + continue; + }; + + changed = true; + let existing = &mut normalized[index]; + let event_is_newer = match (event.updated_at, existing.updated_at) { + (Some(event), Some(existing)) => event >= existing, + (Some(_), None) => true, + _ => false, + }; + if event_is_newer { + let retry_count = existing.retry_count.max(event.retry_count); + *existing = event; + existing.retry_count = retry_count; + } else { + existing.retry_count = existing.retry_count.max(event.retry_count); + } + existing.failed = existing.retry_count >= SITE_REPLICATION_RETRY_FAILED_AFTER; + } + *queue = normalized; + changed +} + +pub(crate) async fn migrate_collapsed_retry_queue_paths() -> S3Result<()> { + update_site_replication_state_when_changed(|state| { + Ok(if normalize_collapsed_retry_queue_paths(&mut state.retry_queue) { + StateCommit::Changed(()) + } else { + StateCommit::Unchanged(()) + }) + }) + .await +} + +#[cfg(test)] +pub(crate) fn dequeue_site_replication_retry_events( + queue: &mut Vec, + peer: &PeerInfo, + path: &str, +) -> usize { + settle_site_replication_retry_events(queue, peer, path, None) +} + +/// Repair-path settlement: also clears snapshot-escalated entries. Running a +/// repair is the operator's explicit accountability transfer for the +/// possibly-unreplayed deletion the marker records; ordinary delivery +/// successes must not clear it (see [`settle_site_replication_retry_events`]). +pub(crate) fn dequeue_site_replication_retry_events_including_escalated( + queue: &mut Vec, + peer: &PeerInfo, + path: &str, +) -> usize { + let before = queue.len(); + let collapsed_path = collapsed_retry_queue_path(path); + queue.retain(|event| { + !retry_event_matches(event, peer, path) + && !collapsed_path.is_some_and(|collapsed_path| retry_event_matches(event, peer, collapsed_path)) + }); + before.saturating_sub(queue.len()) +} + +/// Remove the retry events for (peer, path) that `generation` is entitled to +/// settle. A successful delivery only proves the peer reached the state the +/// delivery carried: while it was in flight another edit can commit, fail its +/// own delivery, and enqueue for the same (peer, path). Erasing that event +/// would leave the peer on the older edit with no retry left, so an event +/// stamped with a NEWER generation survives. `None` settles unconditionally — +/// the broadcast paths that carry no generation, whose retry events live under +/// their own paths and never collide with peer-edit deliveries. +pub(crate) fn settle_site_replication_retry_events( + queue: &mut Vec, + peer: &PeerInfo, + path: &str, + generation: Option, +) -> usize { + let before = queue.len(); + let collapsed_path = collapsed_retry_queue_path(path); + queue.retain(|event| { + if !retry_event_matches(event, peer, path) { + return true; + } + // A wire-path success identifies no IAM or bucket-metadata entity. + // This also protects legacy rows until the startup migration moves + // them under their internal snapshot path. + if collapsed_path.is_some() { + return true; + } + // A snapshot-escalated entry records a possibly-unreplayed deletion. + // Collapsed paths are shared by every entity, so a later successful + // delivery of a DIFFERENT item proves nothing about the deleted one — + // only a repair settles it (dequeue_..._including_escalated). + if event.last_error == SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER { + return true; + } + match (generation, event.edit_generation) { + (Some(settled), Some(failed)) => failed > settled, + _ => false, + } + }); + before.saturating_sub(queue.len()) +} + +pub(crate) fn upsert_site_replication_retry_event( + queue: &mut Vec, + peer: &PeerInfo, + path: &str, + error: &str, + generation: Option, +) { + let path = collapsed_retry_queue_path(path).unwrap_or(path); + let now = OffsetDateTime::now_utc(); + let detail = summarize_peer_error_detail(error); + if let Some(event) = queue.iter_mut().find(|event| retry_event_matches(event, peer, path)) { + event.retry_count = event.retry_count.saturating_add(1); + event.failed = event.retry_count >= SITE_REPLICATION_RETRY_FAILED_AFTER; + event.last_error = detail; + event.updated_at = Some(now); + // Keep the newest generation: an older delivery that fails afterwards + // must not lower the fence and let its own success settle the event. + event.edit_generation = event.edit_generation.max(generation); + return; + } + + queue.push(SiteReplicationRetryEvent { + id: Uuid::new_v4().to_string(), + peer_deployment_id: peer.deployment_id.clone(), + peer_endpoint: peer.endpoint.clone(), + path: path.to_string(), + retry_count: 1, + failed: false, + last_error: detail, + updated_at: Some(now), + edit_generation: generation, + }); + if queue.len() > SITE_REPLICATION_RETRY_QUEUE_LIMIT { + let overflow = queue.len() - SITE_REPLICATION_RETRY_QUEUE_LIMIT; + queue.drain(0..overflow); + } +} + +pub(crate) fn retry_stats_for_state(state: &SiteReplicationState) -> Option { + if state.retry_queue.is_empty() { + return None; + } + + Some(SRRetryStats { + pending: state.retry_queue.iter().filter(|event| !event.failed).count(), + failed: state.retry_queue.iter().filter(|event| event.failed).count(), + last_error: state + .retry_queue + .iter() + .rev() + .find_map(|event| (!event.last_error.is_empty()).then(|| event.last_error.clone())) + .unwrap_or_default(), + api_version: Some(SITE_REPL_API_VERSION.to_string()), + }) +} + +pub(crate) async fn enqueue_site_replication_retry_event(peer: &PeerInfo, path: &str, error: &S3Error) { + enqueue_site_replication_retry_event_for_generation(peer, path, error, None).await +} + +pub(crate) async fn enqueue_site_replication_retry_event_for_generation( + peer: &PeerInfo, + path: &str, + error: &S3Error, + generation: Option, +) { + let peer_owned = peer.clone(); + let path_owned = path.to_string(); + let error_text = error.to_string(); + let result = update_site_replication_state(move |state| { + // A peer that left the state can never drain its entries again + // (remove_sites already pruned them); recording a late failure for it + // would only pollute retry_stats until the queue cap evicts it. + if state.peers.contains_key(&peer_owned.deployment_id) { + upsert_site_replication_retry_event(&mut state.retry_queue, &peer_owned, &path_owned, &error_text, generation); + } + Ok(()) + }) + .await; + + if let Err(err) = result { + warn!( + component = LOG_COMPONENT_ADMIN, + subsystem = LOG_SUBSYSTEM_SITE_REPLICATION, + event = EVENT_ADMIN_SITE_REPLICATION_STATE, + peer = %peer.endpoint, + path, + error = ?err, + "failed to persist site replication retry event" + ); + } +} + +pub(crate) fn retry_bucket_operation(path: &str) -> Option { + let (base_path, query) = path.split_once('?')?; + if base_path != SITE_REPLICATION_PEER_BUCKET_OPS_PATH { + return None; + } + + form_urlencoded::parse(query.as_bytes()).find_map(|(key, value)| (key == "operation").then(|| value.into_owned())) +} + +pub(crate) fn retry_event_replayed_by_bootstrap(event: &SiteReplicationRetryEvent) -> bool { + matches!( + retry_bucket_operation(&event.path).as_deref(), + Some(SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING | SITE_REPLICATION_BUCKET_OP_CONFIGURE_REPLICATION) + ) +} + +/// Exponential backoff base for the background retry drain, aligned with the +/// reconcile cadence (`site_replication_reconcile::RECONCILE_INTERVAL`). +pub(crate) const SITE_REPLICATION_RETRY_DRAIN_BASE_BACKOFF_SECS: i64 = 600; + +/// Backoff ceiling: a permanently failed peer is still probed daily. +pub(crate) const SITE_REPLICATION_RETRY_DRAIN_MAX_BACKOFF_SECS: i64 = 86_400; + +/// What the background drain may do for one retry event. Everything not +/// representable here is operator territory (manual repair). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum RetryDrainAction { + /// Constant-path IAM item deliveries collapse into one queue entry per + /// peer and their bodies are not persisted; the only faithful replay is + /// the current IAM snapshot from the bootstrap plan. + IamSnapshot, + /// Same collapse for bucket-meta deliveries: replay the bucket metadata + /// snapshot from the bootstrap plan. + BucketMetadataSnapshot, + /// A self-contained bucket op the bootstrap plan can re-derive for its + /// bucket (`make-with-versioning` / `configure-replication`). + BucketOpReplay { operation: String, bucket: String }, + /// Re-send the current peer records under a fresh edit generation. + PeerEdit, +} + +#[derive(Clone)] +pub(crate) enum RetrySnapshot { + Iam(Vec), + BucketMetadata(Vec), +} + +impl RetrySnapshot { + pub(crate) fn from_plan(action: &RetryDrainAction, plan: &SiteReplicationBootstrapPlan) -> Option { + match action { + RetryDrainAction::IamSnapshot => Some(Self::Iam(plan.iam_items.clone())), + RetryDrainAction::BucketMetadataSnapshot => Some(Self::BucketMetadata(plan.bucket_items.clone())), + _ => None, + } + } + + pub(crate) fn fingerprint(&self) -> S3Result>> { + let mut payloads = match self { + Self::Iam(items) => items.iter().map(serde_json::to_vec).collect::, _>>(), + Self::BucketMetadata(items) => items.iter().map(serde_json::to_vec).collect::, _>>(), + } + .map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize retry snapshot failed: {err}")))?; + payloads.sort_unstable(); + Ok(payloads) + } + + pub(crate) fn replay_after_change(previous: &Self, fresh: &Self, observed_at: OffsetDateTime) -> Self { + match (previous, fresh) { + (Self::Iam(previous), Self::Iam(fresh)) => { + let fresh_keys: HashSet = fresh.iter().filter_map(iam_snapshot_key).collect(); + let mut replay = fresh.clone(); + for item in previous { + if iam_snapshot_key(item).is_some_and(|key| !fresh_keys.contains(&key)) { + replay.extend(iam_snapshot_tombstones(item, observed_at)); + } + } + Self::Iam(replay) + } + (Self::BucketMetadata(previous), Self::BucketMetadata(fresh)) => { + let fresh_keys: HashSet<(&str, &str)> = fresh + .iter() + .map(|item| (item.bucket.as_str(), item.r#type.as_str())) + .collect(); + let mut replay = fresh.clone(); + for item in previous { + if !fresh_keys.contains(&(item.bucket.as_str(), item.r#type.as_str())) { + replay.push(bucket_metadata_snapshot_tombstone(item, observed_at)); + } + } + Self::BucketMetadata(replay) + } + _ => fresh.clone(), + } + } + + pub(crate) async fn send(&self, transport: &PeerTransport, access_key: &str, secret_key: &str) -> S3Result<()> { + match self { + Self::Iam(items) => { + for item in items { + SiteReplicationRepairTask::Iam(item) + .send(transport, access_key, secret_key) + .await?; + } + } + Self::BucketMetadata(items) => { + for item in items { + SiteReplicationRepairTask::BucketMetadata(item) + .send(transport, access_key, secret_key) + .await?; + } + } + } + Ok(()) + } +} + +#[derive(Hash, PartialEq, Eq)] +pub(crate) enum IamSnapshotKey { + Policy(String), + User(String), + Group(String), + PolicyMapping { target: String, user_type: i64, is_group: bool }, +} + +pub(crate) fn iam_snapshot_key(item: &SRIAMItem) -> Option { + match item.r#type.as_str() { + "policy" => Some(IamSnapshotKey::Policy(item.name.clone())), + "iam-user" => item + .iam_user + .as_ref() + .map(|user| IamSnapshotKey::User(user.access_key.clone())), + "group-info" => item + .group_info + .as_ref() + .map(|group| IamSnapshotKey::Group(group.update_req.group.clone())), + "policy-mapping" => item.policy_mapping.as_ref().map(|mapping| IamSnapshotKey::PolicyMapping { + target: mapping.user_or_group.clone(), + user_type: mapping.user_type, + is_group: mapping.is_group, + }), + _ => None, + } +} + +pub(crate) fn iam_snapshot_tombstones(item: &SRIAMItem, observed_at: OffsetDateTime) -> Vec { + let mut tombstone = item.clone(); + tombstone.updated_at = Some(observed_at); + match item.r#type.as_str() { + "policy" => tombstone.policy = None, + "iam-user" => { + if let Some(user) = tombstone.iam_user.as_mut() { + user.is_delete_req = true; + user.user_req = None; + } + } + "group-info" => { + let Some(group) = tombstone.group_info.as_mut() else { + return Vec::new(); + }; + group.update_req.is_remove = true; + if group.update_req.members.is_empty() { + return vec![tombstone]; + } + let mut delete = tombstone.clone(); + if let Some(group) = delete.group_info.as_mut() { + group.update_req.members.clear(); + } + return vec![tombstone, delete]; + } + "policy-mapping" => { + if let Some(mapping) = tombstone.policy_mapping.as_mut() { + mapping.policy.clear(); + } + } + _ => return Vec::new(), + } + vec![tombstone] +} + +pub(crate) fn bucket_metadata_snapshot_tombstone(item: &SRBucketMeta, observed_at: OffsetDateTime) -> SRBucketMeta { + SRBucketMeta { + r#type: item.r#type.clone(), + bucket: item.bucket.clone(), + updated_at: Some(observed_at), + expiry_updated_at: Some(observed_at), + api_version: item.api_version.clone(), + derived_rule_contract: item.derived_rule_contract, + ..Default::default() + } +} + +pub(crate) const SITE_REPLICATION_RETRY_SNAPSHOT_STABILITY_ATTEMPTS: usize = 3; + +pub(crate) fn classify_site_replication_retry_event(event: &SiteReplicationRetryEvent) -> Option { + let snapshot_action = match event.path.as_str() { + SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH => Some(RetryDrainAction::IamSnapshot), + SITE_REPLICATION_RETRY_BUCKET_METADATA_SNAPSHOT_PATH => Some(RetryDrainAction::BucketMetadataSnapshot), + _ => None, + }; + if snapshot_action.is_some() && event.last_error != SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER { + return snapshot_action; + } + if event.path.starts_with("internal:") { + // Marker records store payloads in `last_error` (legacy + // pending-endpoint-refresh backup and snapshot liabilities); they are + // not drainable delivery failures. + return None; + } + if event.last_error == SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER { + // Already snapshot-replayed once for this failure episode; a possible + // deletion cannot be replayed from a snapshot, so re-sending daily + // proves nothing. A new hook failure overwrites the marker. + return None; + } + let base_path = event.path.split_once('?').map(|(base, _)| base).unwrap_or(&event.path); + match base_path { + "/rustfs/admin/v3/site-replication/peer/iam-item" => Some(RetryDrainAction::IamSnapshot), + "/rustfs/admin/v3/site-replication/peer/bucket-meta" => Some(RetryDrainAction::BucketMetadataSnapshot), + SITE_REPLICATION_PEER_EDIT_PATH => Some(RetryDrainAction::PeerEdit), + SITE_REPLICATION_PEER_BUCKET_OPS_PATH => { + let operation = retry_bucket_operation(&event.path)?; + if !matches!( + operation.as_str(), + SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING | SITE_REPLICATION_BUCKET_OP_CONFIGURE_REPLICATION + ) { + // Destructive ops (delete-bucket / force-delete-bucket) are + // operator territory: replaying them against a peer whose + // bucket was since recreated is irreversible. + return None; + } + let bucket = retry_bucket_name(&event.path)?; + Some(RetryDrainAction::BucketOpReplay { operation, bucket }) + } + _ => None, + } +} + +pub(crate) fn retry_bucket_name(path: &str) -> Option { + let (_, query) = path.split_once('?')?; + form_urlencoded::parse(query.as_bytes()) + .find_map(|(key, value)| (key == "bucket" && !value.is_empty()).then(|| value.into_owned())) +} + +/// A collapsed retry event after a stable snapshot resend is escalated with +/// this marker instead of being cleared: the snapshot contains no task for a +/// failed deletion, so remote absence remains operator-visible. Collapsed +/// failures use an internal queue path so ordinary successes and older nodes +/// cannot settle an unrelated entity's liability. +pub(crate) const SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER: &str = "snapshot replayed; a failed deletion cannot be replayed from a snapshot — run site replication repair or re-deliver to settle"; + +/// Escalate a collapsed retry event after its snapshot resend succeeded, +/// unless a newer failure was recorded after `snapshot_updated_at` (that +/// failure belongs to a newer local commit the snapshot did not contain and +/// must keep the entry drain-eligible). +pub(crate) fn escalate_site_replication_retry_events_up_to( + queue: &mut Vec, + peer: &PeerInfo, + path: &str, + snapshot_updated_at: Option, +) -> usize { + let Some(marker_path) = collapsed_retry_queue_path(path) else { + return 0; + }; + + if path != marker_path { + queue.retain(|event| { + if !retry_event_matches(event, peer, path) { + return true; + } + matches!((event.updated_at, snapshot_updated_at), (Some(current), Some(seen)) if current > seen) + || matches!((event.updated_at, snapshot_updated_at), (Some(_), None)) + }); + } + + let marker_index = queue.iter().position(|event| retry_event_matches(event, peer, marker_path)); + let marker_index = marker_index.unwrap_or_else(|| { + queue.push(SiteReplicationRetryEvent { + id: Uuid::new_v4().to_string(), + peer_deployment_id: peer.deployment_id.clone(), + peer_endpoint: peer.endpoint.clone(), + path: marker_path.to_string(), + updated_at: snapshot_updated_at, + ..Default::default() + }); + queue.len() - 1 + }); + let event = &mut queue[marker_index]; + let newer_failure_recorded = match (event.updated_at, snapshot_updated_at) { + (Some(current), Some(seen)) => current > seen, + (Some(_), None) => true, + (None, _) => false, + }; + if newer_failure_recorded && event.last_error != SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER { + return 0; + } + event.failed = true; + event.retry_count = event.retry_count.max(SITE_REPLICATION_RETRY_FAILED_AFTER); + event.last_error = SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER.to_string(); + event.updated_at = Some(OffsetDateTime::now_utc()); + 1 +} + +pub(crate) async fn escalate_site_replication_retry_event_up_to( + peer: &PeerInfo, + path: &str, + snapshot_updated_at: Option, +) { + let peer_owned = peer.clone(); + let path_owned = path.to_string(); + let result = update_site_replication_state(move |state| { + escalate_site_replication_retry_events_up_to(&mut state.retry_queue, &peer_owned, &path_owned, snapshot_updated_at); + Ok(()) + }) + .await; + + if let Err(err) = result { + warn!( + component = LOG_COMPONENT_ADMIN, + subsystem = LOG_SUBSYSTEM_SITE_REPLICATION, + event = EVENT_ADMIN_SITE_REPLICATION_STATE, + peer = %peer.endpoint, + deployment_id = %peer.deployment_id, + path, + error = ?err, + "failed to escalate site replication retry event" + ); + } +} + +/// Whether the drain may attempt this event now. +pub(crate) fn site_replication_retry_backoff_elapsed(event: &SiteReplicationRetryEvent, now: OffsetDateTime) -> bool { + let Some(updated_at) = event.updated_at else { + return true; + }; + // 600 * 2^8 already exceeds the daily ceiling; capping the shift keeps + // the arithmetic overflow-free for any persisted retry_count. + let exponent = event.retry_count.saturating_sub(1).min(8); + let delay = (SITE_REPLICATION_RETRY_DRAIN_BASE_BACKOFF_SECS << exponent).min(SITE_REPLICATION_RETRY_DRAIN_MAX_BACKOFF_SECS); + now.unix_timestamp().saturating_sub(updated_at.unix_timestamp()) >= delay +} + +/// The subset of the retry queue the background drain is allowed to touch. +pub(crate) fn actionable_site_replication_retry_events( + state: &SiteReplicationState, + now: OffsetDateTime, +) -> Vec { + state + .retry_queue + .iter() + .filter(|event| classify_site_replication_retry_event(event).is_some()) + .filter(|event| state.peers.contains_key(&event.peer_deployment_id)) + .filter(|event| site_replication_retry_backoff_elapsed(event, now)) + .cloned() + .collect() +} + +/// Background consumer for the retry queue, run from the reconcile tick. +/// +/// Scope: this settles "delivered once and failed" entries whose replay is +/// faithful (bucket ops, peer edits). Collapsed iam-item / bucket-meta +/// entries are snapshot-resent and then *escalated*, not cleared — a failed +/// deletion leaves no task in the snapshot, so remote absence stays unproven +/// until a later delivery or a manual repair. A hook that never fired (crash +/// between the local commit and the send) leaves no entry at all, so the +/// drain is not a full cross-site diff-heal; manual repair remains the +/// authoritative catch-all. +pub(crate) async fn drain_site_replication_retry_queue() { + if let Err(err) = drain_site_replication_retry_queue_inner().await { + warn!( + event = EVENT_ADMIN_SITE_REPLICATION_STATE, + component = LOG_COMPONENT_ADMIN, + subsystem = LOG_SUBSYSTEM_SITE_REPLICATION, + result = "retry_drain_failed", + error = ?err, + "admin site replication state" + ); + } +} + +pub(crate) async fn drain_site_replication_retry_queue_inner() -> S3Result<()> { + let Some(runtime) = runtime_site_replication_targets().await? else { + return Ok(()); + }; + let actionable = actionable_site_replication_retry_events(&runtime.state, OffsetDateTime::now_utc()); + if actionable.is_empty() { + return Ok(()); + } + let Some(store) = current_object_store_handle() else { + return Ok(()); + }; + if runtime.state.pending_endpoint_refresh.is_some() + || runtime.state.pending_remove.is_some() + || runtime.state.pending_rotation.is_some() + { + // The tick-level gate ran before the reconcilers; a multi-step flow + // (endpoint refresh commits its pending marker without the lifecycle + // guard) may have started since. Re-check on the fresh state. + return Ok(()); + } + // Serialize against operator repair execution. This does NOT close the + // dry-run -> execute window (dry-run takes no lock): a drain settling a + // replayable bucket-op entry in that window changes the preflight token + // and execute fails safe with "preflight is stale" — the operator + // re-runs the dry-run. Lock order matches repair: lifecycle guard (held + // by the reconcile tick) -> repair execution lock -> state object lock + // inside the send bookkeeping. An operator repair holding the lock makes + // this tick skip after the lock-acquire timeout. + with_config_object_write_lock(store, SITE_REPLICATION_REPAIR_EXECUTION_LOCK_PATH.to_string(), move || async move { + drain_site_replication_retry_queue_locked(runtime, actionable).await + }) + .await + .map_err(ApiError::from)? +} + +pub(crate) async fn drain_site_replication_retry_queue_locked( + runtime: SiteReplicationRuntime, + events: Vec, +) -> S3Result<()> { + let needs_plan = events + .iter() + .any(|event| !matches!(classify_site_replication_retry_event(event), Some(RetryDrainAction::PeerEdit))); + // The plan is a full local snapshot (buckets + IAM); build it once per + // tick and only when a snapshot resend is actually due. + let plan = if needs_plan { + let info = build_sr_info(&runtime.state, &runtime.local_peer).await?; + Some(site_replication_bootstrap_plan(&info)?) + } else { + None + }; + + let mut events_by_peer: BTreeMap> = BTreeMap::new(); + for event in events { + events_by_peer + .entry(event.peer_deployment_id.clone()) + .or_default() + .push(event); + } + + let mut settled = 0usize; + let mut failures = 0usize; + for (deployment_id, peer_events) in events_by_peer { + let Some(peer) = runtime.state.peers.get(&deployment_id) else { + continue; + }; + if deployment_id == runtime.local_peer.deployment_id + || same_identity_endpoint(&peer.endpoint, &runtime.local_peer.endpoint) + { + continue; + } + let transport = match PeerTransport::for_runtime_peer(peer).await { + Ok(transport) => transport, + Err(err) => { + // Record the attempt so backoff advances for an unreachable + // peer instead of re-dialing it every tick. + for event in &peer_events { + enqueue_site_replication_retry_event(peer, &event.path, &err).await; + } + failures += peer_events.len(); + continue; + } + }; + for event in peer_events { + let Some(action) = classify_site_replication_retry_event(&event) else { + continue; + }; + match drain_one_site_replication_retry_event(&runtime, peer, &transport, &event, action, plan.as_ref()).await { + Ok(true) => settled += 1, + Ok(false) => {} + Err(_) => failures += 1, + } + } + } + + if settled > 0 || failures > 0 { + info!( + event = EVENT_ADMIN_SITE_REPLICATION_STATE, + component = LOG_COMPONENT_ADMIN, + subsystem = LOG_SUBSYSTEM_SITE_REPLICATION, + result = "retry_drain_settled", + settled, + failures, + "admin site replication state" + ); + } + Ok(()) +} + +/// Replay one retry event against its peer. Returns `Ok(true)` when the +/// event was settled (delivered, or provably stale), `Ok(false)` when it was +/// skipped, and `Err` after a failed delivery (already re-queued with an +/// incremented retry count). +pub(crate) async fn drain_one_site_replication_retry_event( + runtime: &SiteReplicationRuntime, + peer: &PeerInfo, + transport: &PeerTransport, + event: &SiteReplicationRetryEvent, + action: RetryDrainAction, + plan: Option<&SiteReplicationBootstrapPlan>, +) -> S3Result { + let access_key = &runtime.state.service_account_access_key; + let secret_key = &runtime.service_account_secret_key; + match action.clone() { + RetryDrainAction::IamSnapshot | RetryDrainAction::BucketMetadataSnapshot => { + let Some(plan) = plan else { + return Ok(false); + }; + let mut current_snapshot = RetrySnapshot::from_plan(&action, plan).expect("snapshot action has a snapshot"); + let mut replay = current_snapshot.clone(); + for _ in 0..SITE_REPLICATION_RETRY_SNAPSHOT_STABILITY_ATTEMPTS { + let current_fingerprint = current_snapshot.fingerprint()?; + if let Err(err) = replay.send(transport, access_key, secret_key).await { + enqueue_site_replication_retry_event(peer, &event.path, &err).await; + return Err(err); + } + let fresh_info = build_sr_info(&runtime.state, &runtime.local_peer).await?; + let fresh_plan = site_replication_bootstrap_plan(&fresh_info)?; + let fresh_snapshot = RetrySnapshot::from_plan(&action, &fresh_plan).expect("snapshot action has a snapshot"); + if fresh_snapshot.fingerprint()? == current_fingerprint { + escalate_site_replication_retry_event_up_to(peer, &event.path, event.updated_at).await; + return Ok(true); + } + replay = RetrySnapshot::replay_after_change(¤t_snapshot, &fresh_snapshot, OffsetDateTime::now_utc()); + current_snapshot = fresh_snapshot; + } + Ok(false) + } + RetryDrainAction::BucketOpReplay { operation, bucket } => { + let Some(plan) = plan else { + return Ok(false); + }; + // Replay from the CURRENT plan, never the recorded path: the + // recorded query can carry an expired one-shot bootstrap token or + // a stale createdAt. + let make_op = operation == SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING; + let paths = if make_op { + &plan.bucket_make_ops + } else { + &plan.bucket_configure_ops + }; + let tasks: Vec> = paths + .iter() + .filter(|path| retry_bucket_name(path).as_deref() == Some(bucket.as_str())) + .map(|path| { + if make_op { + SiteReplicationRepairTask::BucketMake(path) + } else { + SiteReplicationRepairTask::Replication(path) + } + }) + .collect(); + if tasks.is_empty() { + // The bucket left the plan (deleted, or replication no longer + // configured): the recorded intent is stale, settle it. + dequeue_site_replication_retry_event(peer, &event.path).await; + return Ok(true); + } + for task in &tasks { + if let Err(err) = task.send(transport, access_key, secret_key).await { + enqueue_site_replication_retry_event(peer, &event.path, &err).await; + return Err(err); + } + } + dequeue_site_replication_retry_event(peer, &event.path).await; + Ok(true) + } + RetryDrainAction::PeerEdit => { + // The recorded generation is stale by definition — the receiver + // fences it. Allocate a fresh generation and re-send the current + // peer records (a superset of the failed body; the receiver + // upserts), all inside one state transaction so the fence and the + // bodies agree. + let target_id = peer.deployment_id.clone(); + let (generation, bodies) = update_site_replication_state(move |state| { + if !state.peers.contains_key(&target_id) { + return Ok((None, Vec::new())); + } + Ok((Some(next_peer_edit_generation(state)), state.peers.values().cloned().collect::>())) + }) + .await?; + let Some(generation) = generation else { + // Peer left between the snapshot and now; the queue entry was + // already pruned by remove_sites. + return Ok(false); + }; + let local_deployment_id = Some(runtime.local_peer.deployment_id.as_str()).filter(|id| !id.is_empty()); + let edit_path = peer_edit_path_with_fence(local_deployment_id, generation); + let delivery_fence = local_deployment_id.is_some().then_some(generation); + for body in &bodies { + if let Err(err) = PeerAdminRequest::put(&transport.connection, &edit_path, access_key) + .with_client(&transport.client) + .send(secret_key, body) + .await + { + enqueue_site_replication_retry_event_for_generation( + peer, + SITE_REPLICATION_PEER_EDIT_PATH, + &err, + delivery_fence, + ) + .await; + return Err(err); + } + } + dequeue_site_replication_retry_event_for_generation(peer, SITE_REPLICATION_PEER_EDIT_PATH, delivery_fence).await; + Ok(true) + } + } +} + +/// Remove a retry event for (peer, path) from the queue on successful delivery. +/// This is a no-op (load + no-op persist skipped) when no matching entry exists, +/// avoiding unnecessary I/O on the common path. +pub(crate) async fn dequeue_site_replication_retry_event(peer: &PeerInfo, path: &str) { + dequeue_site_replication_retry_event_for_generation(peer, path, None).await +} + +pub(crate) async fn dequeue_site_replication_retry_event_for_generation(peer: &PeerInfo, path: &str, generation: Option) { + let result = async { + // Fast path: this sits on every successful hook broadcast, so probe + // with a plain read first and only enter the locked RMW on a hit + // (the transaction re-checks under the lock). + let mut probe = load_site_replication_state().await?; + if settle_site_replication_retry_events(&mut probe.retry_queue, peer, path, generation) == 0 { + return Ok(()); + } + let peer_owned = peer.clone(); + let path_owned = path.to_string(); + update_site_replication_state(move |state| { + settle_site_replication_retry_events(&mut state.retry_queue, &peer_owned, &path_owned, generation); + Ok(()) + }) + .await?; + Ok::<_, S3Error>(()) + } + .await; + + if let Err(err) = result { + warn!( + component = LOG_COMPONENT_ADMIN, + subsystem = LOG_SUBSYSTEM_SITE_REPLICATION, + event = EVENT_ADMIN_SITE_REPLICATION_STATE, + peer = %peer.endpoint, + deployment_id = %peer.deployment_id, + path, + error = ?err, + "failed to dequeue site replication retry event" + ); + } +} diff --git a/rustfs/src/site_replication/state.rs b/rustfs/src/site_replication/state.rs new file mode 100644 index 000000000..3d2c5c74f --- /dev/null +++ b/rustfs/src/site_replication/state.rs @@ -0,0 +1,592 @@ +// 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. + +use super::*; + +pub(crate) const SITE_REPLICATION_PEER_EDIT_PATH: &str = "/rustfs/admin/v3/site-replication/peer/edit"; + +/// Peer-edit fencing token, carried as query parameters so a peer that predates +/// the fence simply ignores them (unknown query keys are dropped) and keeps the +/// previous last-writer-wins behaviour. +pub(crate) const SITE_REPLICATION_EDIT_ORIGIN_QUERY: &str = "editOrigin"; + +pub(crate) const SITE_REPLICATION_EDIT_GENERATION_QUERY: &str = "editGeneration"; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub(crate) struct SiteReplicationState { + pub(crate) name: String, + pub(crate) service_account_access_key: String, + #[serde(default, skip_serializing)] + pub(crate) service_account_secret_key: String, + pub(crate) service_account_parent: String, + pub(crate) peers: BTreeMap, + pub(crate) updated_at: Option, + pub(crate) resync_status: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) pending_rotation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) pending_remove: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) pending_endpoint_refresh: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) retry_queue: Vec, + #[serde(default)] + pub(crate) sync_state_initialized: bool, + /// Fencing token for peer-edit delivery, allocated inside the state + /// transaction (the distributed state-object lock). Two nodes of THIS + /// site that accept admin edits concurrently therefore get strictly + /// ordered generations, and a delivery that stalls can be recognised as + /// stale by the receiving site. + #[serde(default)] + pub(crate) edit_generation: u64, + /// Per-origin high-water mark of the peer edits already applied here, + /// keyed by the origin site's deployment id. A delivery whose generation + /// is not above the mark arrived out of order and must not overwrite the + /// newer edit that already landed. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub(crate) applied_edit_generations: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub(crate) struct PendingEndpointRefresh { + pub(crate) id: String, + pub(crate) peer: PeerInfo, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub(crate) remote_peers: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub(crate) acked_deployment_ids: BTreeSet, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub(crate) struct PendingRotation { + pub(crate) id: String, + pub(crate) access_key: String, + pub(crate) parent: String, + pub(crate) new_secret_key: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) secret_candidates: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub(crate) peers: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub(crate) acked_deployment_ids: BTreeSet, + #[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")] + pub(crate) updated_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub(crate) struct PendingRemove { + pub(crate) id: String, + pub(crate) req: SRRemoveReq, + pub(crate) service_account_access_key: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) secret_candidates: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub(crate) original_peers: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub(crate) acked_deployment_ids: BTreeSet, + #[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")] + pub(crate) updated_at: Option, +} + +impl SiteReplicationState { + pub(crate) fn enabled(&self) -> bool { + self.peers.len() > 1 + } +} + +pub(crate) fn parse_site_replication_state(data: &[u8]) -> S3Result { + let mut state: SiteReplicationState = serde_json::from_slice(data) + .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("invalid site replication state: {e}")))?; + state.peers = normalize_peer_map_by_identity(state.peers); + // A peer-edit high-water mark only fences a CURRENT peer. A site that + // leaves drops below two peers, which clears its own state object and + // restarts its generation counter — a mark left over from the previous + // membership must not reject the edits it sends after it rejoins. This + // pruning covers departures THIS site observed; an origin removed + // unilaterally elsewhere stays in this peer map with its mark, and the + // wall-clock floor in `next_peer_edit_generation` is what lifts its + // restarted counter over that mark. Dropping departed origins on load + // also keeps the map bounded. + state + .applied_edit_generations + .retain(|origin, _| state.peers.contains_key(origin)); + if !state.sync_state_initialized { + if state.enabled() { + mark_unknown_peer_sync_enabled(&mut state.peers); + } + state.sync_state_initialized = true; + } + Ok(state) +} + +pub(crate) async fn load_site_replication_state() -> S3Result { + let Some(store) = current_object_store_handle() else { + return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); + }; + + match read_admin_config(store, SITE_REPLICATION_STATE_PATH).await { + Ok(data) => parse_site_replication_state(&data), + Err(StorageError::ConfigNotFound) => Ok(SiteReplicationState::default()), + Err(err) => Err(S3Error::with_message( + S3ErrorCode::InternalError, + format!("failed to load site replication state: {err}"), + )), + } +} + +/// Whether this deployment participates in site replication (two or more +/// peers in the persisted state). Read by the S3 interface layer to gate +/// replication-config edits (MinIO `ErrReplicationDenyEditError` semantics, +/// issue #1948); a state-read failure propagates so the gate fails closed. +pub(crate) async fn site_replication_enabled() -> S3Result { + Ok(load_site_replication_state().await?.enabled()) +} + +/// Deployment ids of the remote peers the reconciler derives a +/// `site-repl-` rule for on every bucket (the same peer filter as +/// `build_site_replication_config`); empty when site replication is not +/// enabled. Read by the bucket usecase so an S3 replication-config edit keeps +/// exactly the reconciler-owned rules (issue #1948); a state-read failure +/// propagates so the edit fails closed. +pub(crate) async fn site_replication_edit_context() -> S3Result<(HashSet, OperatorRuleContract)> { + let Some(runtime) = runtime_site_replication_targets().await? else { + // Enabled without a service account is a state this site cannot + // broadcast from either; the peers are still the reconciler's. + let state = load_site_replication_state().await?; + if !state.enabled() { + return Ok((HashSet::new(), OperatorRuleContract::Derived)); + } + let peers = remote_peer_deployment_ids(&state, ¤t_local_runtime_peer(&state)); + return Ok((peers, OperatorRuleContract::Legacy)); + }; + let peers = remote_peer_deployment_ids(&runtime.state, &runtime.local_peer); + let contract = site_replication_operator_rule_contract(&runtime).await; + Ok((peers, contract)) +} + +/// Whether every remote peer merges replication configs under the derived +/// contract, probed through the peer capability endpoint. A peer that does +/// not (or cannot be asked) pins the cluster to [`OperatorRuleContract::Legacy`] +/// for this edit: consistency across sites wins over keeping the operator's +/// priority values, and the legacy merge keeps their order anyway. +pub(crate) async fn site_replication_operator_rule_contract(runtime: &SiteReplicationRuntime) -> OperatorRuleContract { + let remote_peers: Vec<&PeerInfo> = runtime + .state + .peers + .values() + .filter(|peer| { + peer.deployment_id != runtime.local_peer.deployment_id + && !same_identity_endpoint(&peer.endpoint, &runtime.local_peer.endpoint) + }) + .collect(); + let probes = futures::future::join_all(remote_peers.iter().map(|peer| async move { + let transport = PeerTransport::for_runtime_peer(peer).await?; + let (status, body) = PeerAdminRequest::put( + &transport.connection, + SITE_REPLICATION_PEER_DERIVED_RULE_CONTRACT_CAPABILITY_PATH, + &runtime.state.service_account_access_key, + ) + .with_client(&transport.client) + .send_raw(&runtime.service_account_secret_key, Some(&())) + .await?; + peer_capability_response_supported(peer, status, &body) + })) + .await; + operator_rule_contract_from_probes(remote_peers.into_iter().zip(probes)) +} + +pub(crate) fn operator_rule_contract_from_probes<'a>( + probes: impl IntoIterator)>, +) -> OperatorRuleContract { + for (peer, probe) in probes { + match probe { + Ok(true) => {} + Ok(false) => return OperatorRuleContract::Legacy, + Err(err) => { + warn!( + event = EVENT_ADMIN_SITE_REPLICATION_STATE, + component = LOG_COMPONENT_ADMIN, + subsystem = LOG_SUBSYSTEM_SITE_REPLICATION, + result = "derived_rule_contract_probe_failed", + peer = %peer.endpoint, + error = %err, + "admin site replication state" + ); + return OperatorRuleContract::Legacy; + } + } + } + OperatorRuleContract::Derived +} + +pub(crate) fn remote_peer_deployment_ids(state: &SiteReplicationState, local_peer: &PeerInfo) -> HashSet { + state + .peers + .values() + .filter(|peer| { + peer.deployment_id != local_peer.deployment_id && !same_identity_endpoint(&peer.endpoint, &local_peer.endpoint) + }) + .map(|peer| peer.deployment_id.clone()) + .collect() +} + +/// Deployment ids of every site in the cluster, this one included: the set +/// a peer's derived rules can name (its rule towards this site carries this +/// site's id). Empty when site replication is not enabled. +pub(crate) async fn site_replication_deployment_ids() -> S3Result> { + let state = load_site_replication_state().await?; + if !state.enabled() { + return Ok(HashSet::new()); + } + Ok(state.peers.values().map(|peer| peer.deployment_id.clone()).collect()) +} + +pub(crate) async fn load_site_replication_state_no_lock(store: Arc) -> S3Result { + match read_config_no_lock(store, SITE_REPLICATION_STATE_PATH).await { + Ok(data) => parse_site_replication_state(&data), + Err(StorageError::ConfigNotFound) => Ok(SiteReplicationState::default()), + Err(err) => Err(S3Error::with_message( + S3ErrorCode::InternalError, + format!("failed to load site replication state: {err}"), + )), + } +} + +/// Persist-or-clear under an already-held state object lock. Normalizes the +/// peer map exactly once (the historical persist path normalized twice with +/// two full clones — P2-22). +pub(crate) async fn persist_site_replication_state_no_lock(store: Arc, mut state: SiteReplicationState) -> S3Result<()> { + state.peers = normalize_peer_map_by_identity(state.peers); + if state.peers.len() <= 1 && state.pending_rotation.is_none() && state.pending_remove.is_none() { + match delete_config_no_lock(store, SITE_REPLICATION_STATE_PATH).await { + Ok(()) | Err(StorageError::ConfigNotFound) => Ok(()), + Err(err) => Err(S3Error::with_message(S3ErrorCode::InternalError, format!("clear state failed: {err}"))), + } + } else { + let data = serde_json::to_vec(&state) + .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize state failed: {e}")))?; + save_config_no_lock(store, SITE_REPLICATION_STATE_PATH, data) + .await + .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("save state failed: {e}"))) + } +} + +/// What a state transaction closure decided to do with the state it was +/// handed. `Unchanged` skips the write entirely: the ack markers and the +/// pending-clearing paths run on every retry and mostly find their pending id +/// already gone, and the retry queue shares this object — rewriting it byte +/// for byte only makes those misses contend with the writers that do have +/// something to say. +pub(crate) enum StateCommit { + Changed(T), + Unchanged(T), +} + +/// The site-replication state RMW transaction: load, mutate, persist — all +/// under the distributed state-object write lock (see +/// crate::site_replication::state_lock). No peer network calls and no other +/// config locks inside `update`; anything that has to talk to a peer belongs +/// between two transactions, with the precondition re-checked inside the +/// second one. +pub(crate) async fn update_site_replication_state(update: F) -> S3Result +where + T: Send + 'static, + F: FnOnce(&mut SiteReplicationState) -> S3Result + Send + 'static, +{ + update_site_replication_state_when_changed(move |state| update(state).map(StateCommit::Changed)).await +} + +/// [`update_site_replication_state`] for closures that may find nothing to +/// do — see [`StateCommit`]. +pub(crate) async fn update_site_replication_state_when_changed(update: F) -> S3Result +where + T: Send + 'static, + F: FnOnce(&mut SiteReplicationState) -> S3Result> + Send + 'static, +{ + with_site_replication_state_lock(move || async move { + let store = current_object_store_handle() + .ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()))?; + let mut state = load_site_replication_state_no_lock(store.clone()).await?; + match update(&mut state)? { + StateCommit::Changed(result) => { + persist_site_replication_state_no_lock(store, state).await?; + Ok(result) + } + StateCommit::Unchanged(result) => Ok(result), + } + }) + .await +} + +/// Test-only seeding of the state object. Every production write goes through +/// [`update_site_replication_state`] — this helper is `cfg(test)` so a new +/// call site cannot reintroduce the pre-P1-15 shape (load through one object +/// lock, save through another, with the mutation in between unprotected). +#[cfg(test)] +pub(crate) async fn save_site_replication_state(state: &SiteReplicationState) -> S3Result<()> { + let Some(store) = current_object_store_handle() else { + return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string())); + }; + + let mut normalized = state.clone(); + normalized.peers = normalize_peer_map_by_identity(normalized.peers); + + let data = serde_json::to_vec(&normalized) + .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize state failed: {e}")))?; + save_admin_config(store, SITE_REPLICATION_STATE_PATH, data) + .await + .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("save state failed: {e}")))?; + Ok(()) +} + +pub(crate) fn request_endpoint(uri: &Uri, headers: &HeaderMap) -> String { + let scheme = get_source_scheme(headers) + .and_then(|value| { + value + .split(',') + .next() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_ascii_lowercase) + }) + .or_else(|| uri.scheme_str().map(str::to_ascii_lowercase)) + .unwrap_or_else(|| { + if runtime_tls_enabled() { + "https".to_string() + } else { + "http".to_string() + } + }); + + let host = headers + .get(http::header::HOST) + .and_then(|value| value.to_str().ok()) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| uri.authority().map(|value| value.as_str().to_string())) + .or_else(|| { + current_endpoints_handle().and_then(|endpoints| { + endpoints + .as_ref() + .iter() + .flat_map(|pool| pool.endpoints.as_ref().iter()) + .find(|endpoint| endpoint.is_local) + .map(|endpoint| endpoint.host_port()) + }) + }) + .unwrap_or_else(|| format!("127.0.0.1:{}", current_runtime_port())); + + format!("{scheme}://{host}") +} + +pub(crate) fn runtime_console_port() -> Option { + let console_address = get_config_snapshot() + .map(|snapshot| snapshot.console_address.clone()) + .unwrap_or_else(|| rustfs_utils::get_env_str(ENV_RUSTFS_CONSOLE_ADDRESS, DEFAULT_CONSOLE_ADDRESS)); + + let parse_target = if console_address.starts_with(':') { + format!("127.0.0.1{console_address}") + } else { + console_address + }; + + Url::parse(&format!("http://{parse_target}")) + .ok() + .and_then(|parsed| parsed.port_or_known_default()) +} + +pub(crate) fn site_replication_local_endpoint(uri: &Uri, headers: &HeaderMap) -> String { + let endpoint = request_endpoint(uri, headers); + match Url::parse(&endpoint) { + Ok(mut parsed) => { + if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() { + return request_endpoint(&Uri::from_static("/"), &HeaderMap::new()); + } + if parsed.port_or_known_default() == runtime_console_port() && parsed.set_port(Some(current_runtime_port())).is_ok() { + parsed.to_string().trim_end_matches('/').to_string() + } else { + endpoint + } + } + Err(_) => request_endpoint(&Uri::from_static("/"), &HeaderMap::new()), + } +} + +pub(crate) fn current_local_runtime_endpoint() -> String { + site_replication_local_endpoint(&Uri::from_static("/"), &HeaderMap::new()) +} + +pub(crate) fn infer_site_name(endpoint: &str) -> String { + endpoint + .trim_start_matches("http://") + .trim_start_matches("https://") + .split('/') + .next() + .unwrap_or_default() + .split(':') + .next() + .unwrap_or_default() + .to_string() +} + +pub(crate) fn stored_peer_tls_settings(stored_peer: Option<&PeerInfo>) -> (bool, String) { + stored_peer + .map(|peer| (peer.skip_tls_verify, peer.ca_cert_pem.clone())) + .unwrap_or_default() +} + +/// The local peer record as the given state describes it. Split out of +/// [`current_local_peer`] so a state transaction can rebuild it against the +/// state it just loaded: the request the endpoint came from cannot cross into +/// the transaction closure, but the endpoint itself can. +pub(crate) fn local_peer_at_endpoint(endpoint: String, state: &SiteReplicationState) -> PeerInfo { + let deployment_id = current_deployment_id().unwrap_or_else(|| deployment_id_for_endpoint(&endpoint)); + let stored_peer = state.peers.get(&deployment_id); + let (skip_tls_verify, ca_cert_pem) = stored_peer_tls_settings(stored_peer); + + PeerInfo { + endpoint: endpoint.clone(), + name: if state.name.is_empty() { + stored_peer + .map(|peer| peer.name.clone()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| infer_site_name(&endpoint)) + } else { + state.name.clone() + }, + deployment_id, + sync_state: stored_peer.map(|peer| peer.sync_state.clone()).unwrap_or(SyncStatus::Unknown), + default_bandwidth: stored_peer.map(|peer| peer.default_bandwidth.clone()).unwrap_or_default(), + replicate_ilm_expiry: stored_peer.is_some_and(|peer| peer.replicate_ilm_expiry), + object_naming_mode: stored_peer.map(|peer| peer.object_naming_mode.clone()).unwrap_or_default(), + skip_tls_verify, + ca_cert_pem, + api_version: Some(SITE_REPL_API_VERSION.to_string()), + } +} + +pub(crate) fn current_local_runtime_peer(state: &SiteReplicationState) -> PeerInfo { + local_peer_at_endpoint(current_local_runtime_endpoint(), state) +} + +pub(crate) fn normalize_peer_map_by_identity(peers: BTreeMap) -> BTreeMap { + normalize_peer_map_by_identity_with(peers, normalize_peer_info) +} + +pub(crate) fn normalize_peer_info(mut peer: PeerInfo) -> PeerInfo { + if peer.deployment_id.is_empty() { + peer.deployment_id = deployment_id_for_endpoint(&peer.endpoint); + } + if peer.name.is_empty() { + peer.name = infer_site_name(&peer.endpoint); + } + if peer.api_version.is_none() { + peer.api_version = Some(SITE_REPL_API_VERSION.to_string()); + } + peer +} + +pub(crate) async fn site_replicator_service_account_secret(access_key: &str) -> S3Result { + let Some(iam_sys) = current_iam_handle() else { + return Err(s3_error!(InvalidRequest, "iam not init")); + }; + + iam_sys + .get_site_replicator_service_account_secret(access_key) + .await + .map_err(ApiError::from) + .map_err(Into::into) +} + +pub(crate) fn legacy_site_replicator_state_secret(state: &SiteReplicationState) -> Option { + (state.service_account_access_key == SITE_REPLICATOR_SERVICE_ACCOUNT && !state.service_account_secret_key.is_empty()) + .then(|| state.service_account_secret_key.clone()) +} + +pub(crate) fn pending_endpoint_refresh(state: &SiteReplicationState) -> Option { + state.pending_endpoint_refresh.clone().or_else(|| { + state + .retry_queue + .iter() + .find(|event| event.path == SITE_REPLICATION_ENDPOINT_REFRESH_RETRY_PATH) + .and_then(|event| serde_json::from_str(&event.last_error).ok()) + }) +} + +/// The wall clock in unix nanoseconds, clamped into u64. A pre-1970 (or +/// post-2554) clock yields 0, which makes the hybrid allocation below +/// degrade to the plain `previous + 1` counter — monotone, never panicking. +pub(crate) fn edit_generation_wall_clock() -> u64 { + u64::try_from(OffsetDateTime::now_utc().unix_timestamp_nanos()).unwrap_or(0) +} + +/// Allocate the next peer-edit generation as a hybrid logical clock: +/// `max(wall clock in unix nanoseconds, previous + 1)`. Called inside the +/// state transaction, so the value is handed out under the distributed +/// state-object lock and two nodes of this site can never take the same one +/// (`previous + 1` keeps the sequence strictly increasing even when two +/// allocations land in one clock tick, and keeps it monotone on a node +/// whose clock stepped backwards mid-lifetime). +/// +/// The wall-clock floor is what survives the counter's death. A site +/// removed while unreachable — the receiver never dropped it from its peer +/// map, so the load-time mark pruning in `parse_site_replication_state` +/// never fired — that later rejoins recreates its state object with the +/// counter back at zero. A plain counter would then hand out generations +/// below the receiver's stale high-water mark and every delivery would be +/// silently fenced until the counter caught up. Jumping to wall time clears +/// that mark: every value the deleted lifetime handed out was capped by the +/// wall clock at its own allocation (or by a prior lifetime's cap, applied +/// inductively), so the recreated lifetime's first allocation exceeds them +/// all — while a pre-removal delivery still in flight stays below the new +/// floor and remains correctly fenced. Marks recorded by pre-hybrid +/// receivers (small plain-counter values) sit far below any wall-clock +/// value, so a restarted origin passes those too — the fix needs only the +/// sender upgraded, nothing on the wire or in the receiver changed. +/// +/// A wall clock that regresses across a delete/recreate (the recreating +/// node's clock behind the clock that fed the previous lifetime) mints +/// below the stale mark and the origin stays fenced — but only until real +/// time passes the previous lifetime's last allocation, because every later +/// allocation takes the wall-clock floor again (and never longer than +/// [`PEER_EDIT_FENCE_STALENESS_WINDOW_NANOS`]: a regression past the window +/// leaves the mark implausibly distant and the origin runs unfenced +/// immediately). Bounded by the skew, +/// self-healing, and no rollback window beyond the plain counter's: a +/// delivery applies only at or above the receiver's mark, so the one +/// cross-lifetime interleaving that can apply stale content — a +/// pre-removal delivery whose generation lands above everything the +/// regressed new lifetime has minted — required the same straggler landing +/// above the mark under the plain counter, where the recreated counter's +/// low restart made it strictly easier to hit. +pub(crate) fn next_peer_edit_generation(state: &mut SiteReplicationState) -> u64 { + state.edit_generation = edit_generation_wall_clock().max(state.edit_generation.saturating_add(1)); + state.edit_generation +} + +/// Build the peer-edit request path carrying the fencing token. The bare +/// constant stays the retry-queue key: the query only fences the wire +/// delivery, and a per-generation key would make every retry event unique. +/// Without a local deployment id there is nothing to fence against, so the +/// unstamped path is sent and the receiver keeps its pre-fence behaviour. +pub(crate) fn peer_edit_path_with_fence(origin: Option<&str>, generation: u64) -> String { + let Some(origin) = origin.filter(|origin| !origin.is_empty()) else { + return SITE_REPLICATION_PEER_EDIT_PATH.to_string(); + }; + let query = form_urlencoded::Serializer::new(String::new()) + .append_pair(SITE_REPLICATION_EDIT_ORIGIN_QUERY, origin) + .append_pair(SITE_REPLICATION_EDIT_GENERATION_QUERY, &generation.to_string()) + .finish(); + format!("{SITE_REPLICATION_PEER_EDIT_PATH}?{query}") +} diff --git a/rustfs/src/admin/site_replication_state.rs b/rustfs/src/site_replication/state_lock.rs similarity index 93% rename from rustfs/src/admin/site_replication_state.rs rename to rustfs/src/site_replication/state_lock.rs index d39618a2b..6f9cc7bfd 100644 --- a/rustfs/src/admin/site_replication_state.rs +++ b/rustfs/src/site_replication/state_lock.rs @@ -34,12 +34,11 @@ //! Lock order: lifecycle -> bucket operation -> repair admission //! -> state object lock -> per-bucket metadata. -use crate::admin::storage_api::runtime::ECStore; -use crate::admin::storage_api::s3::{S3Error, S3ErrorCode, S3Result}; -use crate::storage::storage_api::with_config_object_write_lock; +use super::{S3Error, S3ErrorCode, S3Result}; +use crate::storage_api::site_replication::{ECStore, with_config_object_write_lock}; use std::sync::Arc; -use super::runtime_sources::current_object_store_handle; +use crate::runtime_sources::current_object_store_handle; /// Config object holding the whole site-replication state, including the /// retry-event queue. Shared by the typed handler-side accessors and the diff --git a/rustfs/src/site_replication/tests.rs b/rustfs/src/site_replication/tests.rs new file mode 100644 index 000000000..720fd6e2a --- /dev/null +++ b/rustfs/src/site_replication/tests.rs @@ -0,0 +1,2332 @@ +// 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. + +//! Business-logic tests that moved with the site-replication service +//! subsystem (backlog#1840 PR5). Tests exercising the admin handlers, the +//! apply/reconcile paths, and the status/resync builders stay with that code +//! in `crate::admin::handlers::site_replication`; a few small fixtures exist +//! on both sides rather than coupling the two test modules. + +use super::*; + +use super::identity::site_identity_key; +use crate::storage_api::site_replication::merge_incoming_replication_config; +use crate::storage_api::site_replication::s3::{ + ExpirationStatus, LifecycleExpiration, Timestamp, Transition, TransitionStorageClass, +}; +use crate::storage_api::site_replication::{Endpoint, EndpointServerPools, Endpoints, PoolEndpoints}; +use rustfs_madmin::{BucketBandwidth, SiteReplicationInfo}; +use serial_test::serial; +use std::sync::atomic::{AtomicBool, Ordering}; +use temp_env::with_var; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +fn valid_test_ca_pem(name: &str) -> String { + rcgen::generate_simple_self_signed(vec![name.to_string()]) + .expect("generate test CA") + .cert + .pem() +} + +fn empty_outbound_tls_state() -> GlobalPublishedOutboundTlsState { + GlobalPublishedOutboundTlsState { + generation: rustfs_tls_runtime::TlsGeneration(0), + root_ca_pem: None, + mtls_identity: None, + } +} + +async fn spawn_test_tls_server() -> (String, String, tokio::task::JoinHandle) { + spawn_test_tls_server_with_response(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok").await +} + +async fn spawn_test_tls_server_with_response(response: &'static [u8]) -> (String, String, tokio::task::JoinHandle) { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let certified = rcgen::generate_simple_self_signed(vec!["127.0.0.1".to_string()]).expect("generate TLS server certificate"); + let ca_pem = certified.cert.pem(); + let private_key = + rustls_pki_types::PrivateKeyDer::try_from(certified.signing_key.serialize_der()).expect("convert TLS server private key"); + let config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![certified.cert.der().clone()], private_key) + .expect("build TLS server config"); + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(config)); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind TLS test server"); + let endpoint = format!("https://{}", listener.local_addr().expect("TLS test server address")); + let task = tokio::spawn(async move { + let Ok((stream, _)) = listener.accept().await else { + return false; + }; + let Ok(mut stream) = acceptor.accept(stream).await else { + return false; + }; + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let Ok(read) = stream.read(&mut buffer).await else { + return false; + }; + if read == 0 { + return false; + } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + stream.write_all(response).await.is_ok() + }); + (endpoint, ca_pem, task) +} + +#[test] +fn peer_connection_validation_accepts_supported_combinations() { + let ca = valid_test_ca_pem("peer.example.com"); + + assert!(validate_peer_connection_inner("http://10.0.0.5:9000", false, "", false).is_ok()); + assert!(validate_peer_connection_inner("https://peer.example.com", false, "", false).is_ok()); + assert!(validate_peer_connection_inner("https://peer.example.com", true, "", false).is_ok()); + assert!(validate_peer_connection_inner("https://peer.example.com", false, &ca, false).is_ok()); +} + +#[test] +fn peer_connection_validation_rejects_invalid_tls_combinations() { + let ca = valid_test_ca_pem("peer.example.com"); + + for (endpoint, skip_tls_verify, ca_cert_pem) in [ + ("http://10.0.0.5:9000", true, ""), + ("http://10.0.0.5:9000", false, ca.as_str()), + ("https://peer.example.com", true, ca.as_str()), + ] { + assert!(validate_peer_connection_inner(endpoint, skip_tls_verify, ca_cert_pem, false).is_err()); + } +} + +#[test] +fn peer_connection_validation_requires_pure_origin() { + for endpoint in [ + "ftp://peer.example.com", + "https://user@peer.example.com", + "https://peer.example.com/admin", + "https://peer.example.com/?query=1", + "https://peer.example.com/#fragment", + ] { + assert!( + validate_peer_connection_inner(endpoint, false, "", false).is_err(), + "endpoint should be rejected: {endpoint}" + ); + } + assert!(validate_peer_connection_inner("https://peer.example.com/", false, "", false).is_ok()); +} + +#[test] +fn peer_connection_validation_matches_replication_egress_policy() { + assert!(validate_peer_connection_inner("http://10.0.0.5:9000", false, "", false).is_ok()); + assert!(validate_peer_connection_inner("http://127.0.0.1:9000", false, "", false).is_err()); + assert!(validate_peer_connection_inner("http://127.0.0.1:9000", false, "", true).is_ok()); + assert!(validate_peer_connection_inner("http://[::1]:9000", false, "", true).is_ok()); + assert!(validate_peer_connection_inner("http://localhost:9000", false, "", true).is_ok()); + + for endpoint in [ + "http://169.254.169.254", + "http://[fe80::1]:9000", + "http://0.0.0.0:9000", + "http://[::ffff:127.0.0.1]:9000", + "http://[::127.0.0.1]:9000", + "http://[::ffff:169.254.169.254]:9000", + ] { + assert!( + validate_peer_connection_inner(endpoint, false, "", true).is_err(), + "endpoint should remain forbidden with loopback opt-in: {endpoint}" + ); + } +} + +#[test] +fn peer_connection_validation_accepts_multi_cert_ca_and_rejects_unsafe_pem() { + let multi_cert = format!("{}{}", valid_test_ca_pem("one.example.com"), valid_test_ca_pem("two.example.com")); + assert!(validate_peer_connection_inner("https://peer.example.com", false, &multi_cert, false).is_ok()); + + for pem in [ + "not a certificate", + "-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----", + "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----", + "-----BEGIN RSA PRIVATE KEY-----\nsecret\n-----END RSA PRIVATE KEY-----", + ] { + assert!(validate_peer_connection_inner("https://peer.example.com", false, pem, false).is_err()); + } + + let oversized = "x".repeat(MAX_PEER_CA_CERT_PEM_SIZE + 1); + assert!(validate_peer_connection_inner("https://peer.example.com", false, &oversized, false).is_err()); +} + +#[tokio::test] +async fn peer_dns_resolver_filters_forbidden_addresses_and_reqwest_cannot_bypass() { + let resolver = PeerDnsResolver::with_overrides( + true, + HashMap::from([ + ("public.test".to_string(), vec!["8.8.8.8".parse().expect("public IP")]), + ("private.test".to_string(), vec!["10.0.0.5".parse().expect("private IP")]), + ("metadata.test".to_string(), vec!["169.254.169.254".parse().expect("metadata IP")]), + ("alias.test".to_string(), vec!["127.0.0.1".parse().expect("loopback IP")]), + ("mapped.test".to_string(), vec!["::ffff:127.0.0.1".parse().expect("mapped loopback IP")]), + ("localhost".to_string(), vec!["127.0.0.1".parse().expect("localhost IP")]), + ]), + ); + + for host in ["public.test", "private.test", "localhost"] { + let address_count = reqwest::dns::Resolve::resolve(&resolver, host.parse().expect("resolver test hostname")) + .await + .expect("allowed resolver result") + .count(); + assert_eq!(address_count, 1, "expected one allowed address for {host}"); + } + for host in ["metadata.test", "alias.test", "mapped.test"] { + assert!( + reqwest::dns::Resolve::resolve(&resolver, host.parse().expect("resolver test hostname")) + .await + .is_err(), + "resolver must reject {host}" + ); + } + + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind resolver bypass listener"); + let port = listener.local_addr().expect("resolver bypass listener address").port(); + let accepted = Arc::new(AtomicBool::new(false)); + let accepted_by_server = accepted.clone(); + let server = tokio::spawn(async move { + if listener.accept().await.is_ok() { + accepted_by_server.store(true, Ordering::SeqCst); + } + }); + let client = reqwest::Client::builder() + .no_proxy() + .dns_resolver(resolver) + .build() + .expect("resolver bypass client"); + assert!(client.get(format!("http://alias.test:{port}/")).send().await.is_err()); + assert!(!accepted.load(Ordering::SeqCst)); + server.abort(); +} + +#[tokio::test] +#[serial] +async fn production_peer_clients_ignore_environment_proxies_before_dns_filtering() { + let proxy_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind observable proxy listener"); + let proxy_url = format!("http://{}", proxy_listener.local_addr().expect("observable proxy listener address")); + let (proxy_hit_tx, mut proxy_hit_rx) = tokio::sync::mpsc::unbounded_channel(); + let proxy = tokio::spawn(async move { + while let Ok((_stream, _address)) = proxy_listener.accept().await { + if proxy_hit_tx.send(()).is_err() { + break; + } + } + }); + + temp_env::async_with_vars( + [ + ("HTTP_PROXY", Some(proxy_url.as_str())), + ("HTTPS_PROXY", Some(proxy_url.as_str())), + ("ALL_PROXY", Some(proxy_url.as_str())), + ("http_proxy", Some(proxy_url.as_str())), + ("https_proxy", Some(proxy_url.as_str())), + ("all_proxy", Some(proxy_url.as_str())), + ("NO_PROXY", Some("")), + ("no_proxy", Some("")), + ], + async { + let resolver = PeerDnsResolver::with_overrides( + false, + HashMap::from([("metadata.test".to_string(), vec!["169.254.169.254".parse().expect("metadata IP")])]), + ); + let outbound_tls = empty_outbound_tls_state(); + let default_connection = + validate_peer_connection_inner("http://metadata.test", false, "", false).expect("default peer connection"); + let custom_connection = + validate_peer_connection_inner("https://metadata.test", true, "", false).expect("custom peer connection"); + let default_client = build_site_replication_peer_client_with_resolver(&outbound_tls, resolver.clone()) + .expect("default production peer client"); + let custom_client = + build_custom_site_replication_peer_client_with_resolver(&outbound_tls, &custom_connection, resolver) + .expect("custom production peer client"); + + for (client, connection) in [(&default_client, &default_connection), (&custom_client, &custom_connection)] { + let result = PeerAdminRequest::get(connection, "/rustfs/admin/v3/site-replication/metainfo", "access-key") + .with_client(client) + .send_get("secret-key") + .await; + assert!(result.is_err(), "forbidden DNS result must fail closed"); + } + }, + ) + .await; + + assert!( + tokio::time::timeout(Duration::from_millis(100), proxy_hit_rx.recv()) + .await + .is_err(), + "site-replication peer traffic must never reach an environment proxy" + ); + proxy.abort(); +} + +#[test] +fn peer_url_join_preserves_wire_path_and_query_encoding() { + let connection = + validate_peer_connection_inner("https://peer.example.com", false, "", false).expect("peer connection for URL join"); + let url = site_replication_peer_url( + &connection, + "/minio/admin/v3/site-replication/peer/bucket-ops?bucket=a%2Fb&operation=configure-replication", + ) + .expect("join peer wire URL"); + + assert_eq!( + url.as_str(), + "https://peer.example.com/minio/admin/v3/site-replication/peer/bucket-ops?bucket=a%2Fb&operation=configure-replication" + ); +} + +#[tokio::test] +async fn peer_clients_isolate_skip_and_custom_ca_trust() { + let outbound_tls = empty_outbound_tls_state(); + + let (ca_endpoint, ca_pem, ca_server) = spawn_test_tls_server().await; + let ca_connection = validate_peer_connection_inner(&ca_endpoint, false, &ca_pem, true).expect("custom CA peer connection"); + let ca_client = build_custom_site_replication_peer_client(&outbound_tls, &ca_connection).expect("custom CA peer client"); + assert_eq!( + ca_client.get(&ca_endpoint).send().await.expect("custom CA request").status(), + StatusCode::OK + ); + assert!(ca_server.await.expect("custom CA server task")); + + let (untrusted_endpoint, _untrusted_ca, untrusted_server) = spawn_test_tls_server().await; + assert!(ca_client.get(&untrusted_endpoint).send().await.is_err()); + assert!(!untrusted_server.await.expect("untrusted TLS server task")); + + let (other_endpoint, other_ca, other_server) = spawn_test_tls_server().await; + let other_connection = + validate_peer_connection_inner(&other_endpoint, false, &other_ca, true).expect("second custom CA peer connection"); + let other_client = + build_custom_site_replication_peer_client(&outbound_tls, &other_connection).expect("second custom CA peer client"); + assert_eq!( + other_client + .get(&other_endpoint) + .send() + .await + .expect("second custom CA request") + .status(), + StatusCode::OK + ); + assert!(other_server.await.expect("second custom CA server task")); + + let (skip_endpoint, _skip_ca, skip_server) = spawn_test_tls_server().await; + let skip_connection = validate_peer_connection_inner(&skip_endpoint, true, "", true).expect("skip-verify peer connection"); + let skip_client = + build_custom_site_replication_peer_client(&outbound_tls, &skip_connection).expect("skip-verify peer client"); + assert_eq!( + skip_client + .get(&skip_endpoint) + .send() + .await + .expect("skip-verify request") + .status(), + StatusCode::OK + ); + assert!(skip_server.await.expect("skip-verify server task")); +} + +#[tokio::test] +async fn peer_clients_do_not_follow_redirects() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind redirect test server"); + let endpoint = format!("http://{}", listener.local_addr().expect("redirect test server address")); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept redirect test request"); + let mut request = [0_u8; 1024]; + let read = stream.read(&mut request).await.expect("read redirect test request"); + assert!(read > 0); + stream + .write_all(b"HTTP/1.1 302 Found\r\nlocation: /followed\r\ncontent-length: 0\r\nconnection: close\r\n\r\n") + .await + .expect("write redirect response"); + }); + + let client = build_site_replication_peer_client(&empty_outbound_tls_state()).expect("default peer client"); + let response = client.get(&endpoint).send().await.expect("redirect test request"); + assert_eq!(response.status(), StatusCode::FOUND); + server.await.expect("redirect test server task"); + + let (tls_endpoint, _tls_ca, tls_server) = spawn_test_tls_server_with_response( + b"HTTP/1.1 302 Found\r\nlocation: /followed\r\ncontent-length: 0\r\nconnection: close\r\n\r\n", + ) + .await; + let connection = validate_peer_connection_inner(&tls_endpoint, true, "", true).expect("custom redirect peer connection"); + let client = + build_custom_site_replication_peer_client(&empty_outbound_tls_state(), &connection).expect("custom redirect peer client"); + let response = client.get(&tls_endpoint).send().await.expect("custom redirect test request"); + assert_eq!(response.status(), StatusCode::FOUND); + assert!(tls_server.await.expect("custom redirect TLS server task")); +} + +fn peer(name: &str, endpoint: &str) -> PeerInfo { + PeerInfo { + name: name.to_string(), + endpoint: endpoint.to_string(), + deployment_id: String::new(), + sync_state: SyncStatus::Unknown, + default_bandwidth: BucketBandwidth::default(), + replicate_ilm_expiry: false, + object_naming_mode: String::new(), + skip_tls_verify: false, + ca_cert_pem: String::new(), + api_version: Some(SITE_REPL_API_VERSION.to_string()), + } +} + +#[test] +fn test_stored_peer_tls_settings_preserve_configured_values() { + let stored_peer = PeerInfo { + skip_tls_verify: true, + ca_cert_pem: "custom-ca".to_string(), + ..peer("local", "https://local.example.com") + }; + + assert_eq!(stored_peer_tls_settings(Some(&stored_peer)), (true, "custom-ca".to_string())); + assert_eq!(stored_peer_tls_settings(None), (false, String::new())); +} + +fn drain_event(peer: &str, path: &str, retry_count: u32, updated_at: Option) -> SiteReplicationRetryEvent { + SiteReplicationRetryEvent { + id: format!("evt-{peer}"), + peer_deployment_id: peer.to_string(), + peer_endpoint: format!("https://{peer}.example.com"), + path: path.to_string(), + retry_count, + failed: retry_count >= SITE_REPLICATION_RETRY_FAILED_AFTER, + last_error: "remote-operation-failed".to_string(), + updated_at, + edit_generation: None, + } +} + +/// P1-3 red-light: the drain must only ever act on deliveries it can +/// replay faithfully. IAM / bucket-meta entries collapse per (peer, path) +/// with no body persisted — only a snapshot resend is truthful; bucket +/// makes/replication configs are re-derivable; destructive bucket ops and +/// unrelated `internal:` marker records are never background-replayed. +#[test] +fn test_classify_site_replication_retry_event_actions() { + let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("timestamp"); + let classify = |path: &str| classify_site_replication_retry_event(&drain_event("remote", path, 1, Some(now))); + + assert_eq!( + classify("/rustfs/admin/v3/site-replication/peer/iam-item"), + Some(RetryDrainAction::IamSnapshot) + ); + assert_eq!( + classify("/rustfs/admin/v3/site-replication/peer/bucket-meta"), + Some(RetryDrainAction::BucketMetadataSnapshot) + ); + assert_eq!(classify(SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH), Some(RetryDrainAction::IamSnapshot)); + assert_eq!( + classify(SITE_REPLICATION_RETRY_BUCKET_METADATA_SNAPSHOT_PATH), + Some(RetryDrainAction::BucketMetadataSnapshot) + ); + assert_eq!(classify(SITE_REPLICATION_PEER_EDIT_PATH), Some(RetryDrainAction::PeerEdit)); + assert_eq!( + classify("/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=make-with-versioning&createdAt=1"), + Some(RetryDrainAction::BucketOpReplay { + operation: SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING.to_string(), + bucket: "photos".to_string(), + }) + ); + assert_eq!( + classify("/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=configure-replication"), + Some(RetryDrainAction::BucketOpReplay { + operation: SITE_REPLICATION_BUCKET_OP_CONFIGURE_REPLICATION.to_string(), + bucket: "photos".to_string(), + }) + ); + // Destructive ops are operator territory: replaying a bucket delete + // against a peer whose bucket was since recreated is irreversible. + assert_eq!( + classify("/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=delete-bucket"), + None + ); + assert_eq!( + classify("/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=force-delete-bucket"), + None + ); + // `internal:` records store payloads in `last_error`, not failures. + assert_eq!(classify(SITE_REPLICATION_ENDPOINT_REFRESH_RETRY_PATH), None); + assert_eq!(classify("internal:some-future-marker"), None); + assert_eq!(classify("/rustfs/admin/v3/site-replication/peer/unknown"), None); +} + +#[test] +fn test_retry_snapshot_fingerprint_detects_concurrent_iam_change() { + let old = SRIAMItem { + r#type: "policy".to_string(), + name: "readwrite".to_string(), + updated_at: Some(OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("timestamp")), + ..Default::default() + }; + let mut new = old.clone(); + new.updated_at = Some(OffsetDateTime::from_unix_timestamp(1_700_000_001).expect("timestamp")); + + let sent = RetrySnapshot::Iam(vec![old]); + let changed = RetrySnapshot::Iam(vec![new]); + assert_ne!(sent.fingerprint().unwrap(), changed.fingerprint().unwrap()); +} + +#[test] +fn test_retry_snapshot_replays_a_concurrent_deletion_as_a_tombstone() { + let observed_at = OffsetDateTime::from_unix_timestamp(1_700_000_010).expect("timestamp"); + let policy = SRIAMItem { + r#type: "policy".to_string(), + name: "readwrite".to_string(), + policy: Some(serde_json::json!({"Version": "2012-10-17"})), + ..Default::default() + }; + let replay = + RetrySnapshot::replay_after_change(&RetrySnapshot::Iam(vec![policy]), &RetrySnapshot::Iam(Vec::new()), observed_at); + let RetrySnapshot::Iam(items) = replay else { + panic!("IAM snapshot expected"); + }; + assert_eq!(items.len(), 1); + assert_eq!(items[0].name, "readwrite"); + assert!(items[0].policy.is_none()); + assert_eq!(items[0].updated_at, Some(observed_at)); + + let bucket = SRBucketMeta { + r#type: "tags".to_string(), + bucket: "photos".to_string(), + tags: Some("encoded-tags".to_string()), + ..Default::default() + }; + let replay = RetrySnapshot::replay_after_change( + &RetrySnapshot::BucketMetadata(vec![bucket]), + &RetrySnapshot::BucketMetadata(Vec::new()), + observed_at, + ); + let RetrySnapshot::BucketMetadata(items) = replay else { + panic!("bucket metadata snapshot expected"); + }; + assert_eq!(items.len(), 1); + assert_eq!(items[0].bucket, "photos"); + assert_eq!(items[0].r#type, "tags"); + assert!(items[0].tags.is_none()); + assert_eq!(items[0].updated_at, Some(observed_at)); +} + +/// Exponential backoff gates every attempt: without it a dead peer's +/// entries hit `failed` (retry_count >= 3) within 30 minutes of reconcile +/// ticks and the retry stats lose their signal. +#[test] +fn test_site_replication_retry_backoff_schedule() { + let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("timestamp"); + let at = |secs_ago: i64| Some(now - time::Duration::seconds(secs_ago)); + let elapsed = |retry_count: u32, secs_ago: i64| { + site_replication_retry_backoff_elapsed(&drain_event("remote", "/p", retry_count, at(secs_ago)), now) + }; + + // No record of when it failed: attempt now. + assert!(site_replication_retry_backoff_elapsed(&drain_event("remote", "/p", 1, None), now)); + // First failure: one reconcile interval. + assert!(!elapsed(1, 599)); + assert!(elapsed(1, 601)); + // Third failure: 600 * 2^2 = 2400s. + assert!(!elapsed(3, 1200)); + assert!(elapsed(3, 2401)); + // Ceiling: a long-dead peer is still probed daily, never less often. + assert!(!elapsed(30, 86_000)); + assert!(elapsed(30, 86_401)); +} + +/// The actionable subset respects classification, peer membership and +/// backoff; everything else stays untouched in the queue. +#[test] +fn test_actionable_site_replication_retry_events_filters() { + let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("timestamp"); + let old = Some(now - time::Duration::seconds(700)); + let mut state = SiteReplicationState::default(); + state + .peers + .insert("remote".to_string(), peer("remote", "https://remote.example.com")); + + state.retry_queue = vec![ + // Eligible: known peer, replayable, past backoff. + drain_event("remote", SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH, 1, old), + // Not yet due. + drain_event("remote", "/rustfs/admin/v3/site-replication/peer/bucket-meta", 2, Some(now)), + // Unknown peer (removed since the failure was recorded). + drain_event("gone", "/rustfs/admin/v3/site-replication/peer/iam-item", 1, old), + // Marker record, not a delivery failure. + drain_event("remote", SITE_REPLICATION_ENDPOINT_REFRESH_RETRY_PATH, 0, old), + // Destructive op: operator-only. + drain_event( + "remote", + "/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=delete-bucket", + 1, + old, + ), + ]; + + let actionable = actionable_site_replication_retry_events(&state, now); + assert_eq!(actionable.len(), 1, "only the due, replayable, known-peer event is actionable"); + assert_eq!(actionable[0].path, SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH); +} + +/// The drain settles a peer-edit success under a freshly allocated +/// generation; legacy queue entries carry `edit_generation: None` and +/// must be cleared by that generation-scoped settlement (`(Some, None)` +/// falls through to removal), or the drain would spin on them forever. +#[test] +fn test_settle_clears_legacy_none_generation_event_for_generation_scoped_success() { + let target = peer("remote", "https://remote.example.com"); + let mut queue = vec![drain_event("remote", SITE_REPLICATION_PEER_EDIT_PATH, 1, None)]; + assert!(queue[0].edit_generation.is_none()); + + let settled = settle_site_replication_retry_events(&mut queue, &target, SITE_REPLICATION_PEER_EDIT_PATH, Some(42)); + + assert_eq!(settled, 1, "a legacy None-generation event must settle under a newer generation"); + assert!(queue.is_empty()); +} + +/// A successful snapshot resend cannot prove a failed *deletion* was +/// replayed, so the collapsed entry is escalated (operator-visible, +/// drain-idle) instead of cleared — unless a newer failure was stamped +/// during the delivery window, which keeps the entry drain-eligible. +#[test] +fn test_escalate_up_to_marks_snapshot_replayed_and_keeps_newer_failures() { + let target = peer("remote", "https://remote.example.com"); + let path = "/rustfs/admin/v3/site-replication/peer/iam-item"; + let snapshot_at = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("timestamp"); + + // Failure re-stamped after the snapshot: untouched, still eligible. + let mut queue = vec![drain_event( + "remote", + SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH, + 2, + Some(snapshot_at + time::Duration::seconds(5)), + )]; + assert_eq!( + escalate_site_replication_retry_events_up_to( + &mut queue, + &target, + SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH, + Some(snapshot_at), + ), + 0 + ); + assert!(!queue[0].failed); + assert!( + classify_site_replication_retry_event(&queue[0]).is_some(), + "a newer failure must stay drain-eligible" + ); + + // Unchanged since the snapshot: escalated, kept, drain-idle. + let mut queue = vec![drain_event( + "remote", + SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH, + 2, + Some(snapshot_at), + )]; + assert_eq!( + escalate_site_replication_retry_events_up_to(&mut queue, &target, path, Some(snapshot_at)), + 1 + ); + assert_eq!(queue.len(), 1, "the entry must survive until remote absence is proven"); + assert_eq!(queue[0].path, SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH); + assert!(queue[0].failed); + assert_eq!(queue[0].last_error, SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER); + assert!( + classify_site_replication_retry_event(&queue[0]).is_none(), + "a snapshot-replayed entry must not be re-sent daily" + ); + // Ordinary success dequeues must not clear the marker: collapsed + // paths are shared by every entity, so a successful Bob update + // proves nothing about a failed Alice deletion (second review + // round). + assert_eq!(dequeue_site_replication_retry_events(&mut queue, &target, path), 0); + assert_eq!(queue.len(), 1, "an escalated entry must survive an ordinary delivery success"); + // Only a repair — the operator's accountability transfer — settles it. + assert_eq!(dequeue_site_replication_retry_events_including_escalated(&mut queue, &target, path), 1); + assert!(queue.is_empty()); + + // A failed Alice deletion is stored under the internal path, so a + // successful Bob update on the shared wire path cannot erase it even + // before the drain runs. + let mut queue = Vec::new(); + upsert_site_replication_retry_event(&mut queue, &target, path, "alice delete failed", None); + assert_eq!(queue[0].path, SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH); + assert_eq!(dequeue_site_replication_retry_events(&mut queue, &target, path), 0); + assert_eq!(queue.len(), 1); + assert_eq!(queue[0].path, SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH); + + // A later hook failure overwrites the marker and re-arms the drain. + let mut queue = vec![drain_event("remote", path, 2, Some(snapshot_at))]; + escalate_site_replication_retry_events_up_to(&mut queue, &target, path, Some(snapshot_at)); + upsert_site_replication_retry_event(&mut queue, &target, path, "peer offline", None); + assert!(classify_site_replication_retry_event(&queue[0]).is_some()); + + // Legacy entry without a timestamp: escalated. + let mut queue = vec![drain_event("remote", path, 2, None)]; + assert_eq!( + escalate_site_replication_retry_events_up_to(&mut queue, &target, path, Some(snapshot_at)), + 1 + ); + + // A cloned event can disappear during replay; escalation recreates + // the internal liability while leaving another peer's row untouched. + let mut queue = vec![drain_event("other", path, 2, Some(snapshot_at))]; + assert_eq!( + escalate_site_replication_retry_events_up_to(&mut queue, &target, path, Some(snapshot_at)), + 1 + ); + assert!(!queue[0].failed); + assert_eq!(queue.len(), 2); + assert_eq!(queue[1].peer_deployment_id, target.deployment_id); + assert_eq!(queue[1].path, SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH); +} + +#[test] +fn test_collapsed_retry_queue_migration_preserves_legacy_liability() { + let peer = PeerInfo { + deployment_id: "remote-dep".to_string(), + ..peer("remote", "https://remote.example.com") + }; + let wire_path = "/rustfs/admin/v3/site-replication/peer/iam-item"; + let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("timestamp"); + let mut queue = vec![drain_event("remote-dep", wire_path, 2, Some(now))]; + + assert_eq!(dequeue_site_replication_retry_events(&mut queue, &peer, wire_path), 0); + assert!(normalize_collapsed_retry_queue_paths(&mut queue)); + assert_eq!(queue.len(), 1); + assert_eq!(queue[0].path, SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH); + assert!(!normalize_collapsed_retry_queue_paths(&mut queue)); +} + +#[test] +fn test_legacy_pending_retry_json_remains_readable() { + let legacy = PendingEndpointRefresh { + id: "legacy-refresh".to_string(), + peer: PeerInfo { + deployment_id: "remote".to_string(), + ..peer("remote", "https://remote.example.com") + }, + ..Default::default() + }; + let state = SiteReplicationState { + retry_queue: vec![SiteReplicationRetryEvent { + path: SITE_REPLICATION_ENDPOINT_REFRESH_RETRY_PATH.to_string(), + last_error: serde_json::to_string(&legacy).expect("serialize legacy pending"), + ..Default::default() + }], + ..Default::default() + }; + + assert_eq!( + pending_endpoint_refresh(&state).map(|pending| pending.id).as_deref(), + Some("legacy-refresh") + ); +} + +#[test] +fn test_site_replication_bucket_target_replaces_tls_and_preserves_operational_fields() { + let local = PeerInfo { + deployment_id: "local".to_string(), + ..peer("local", "https://local.example.com") + }; + let remote = PeerInfo { + deployment_id: "remote".to_string(), + skip_tls_verify: true, + ..peer("remote", "https://remote.example.com:9443") + }; + let state = SiteReplicationState { + service_account_access_key: "svc".to_string(), + peers: BTreeMap::from([("local".to_string(), local.clone()), ("remote".to_string(), remote.clone())]), + ..Default::default() + }; + let generated = site_replication_bucket_target_for_peer("photos", &state, &remote, "secret", None) + .expect("build target") + .expect("target exists"); + assert!(generated.skip_tls_verify); + assert_eq!(generated.ca_cert_pem, ""); + + let existing = BucketTarget { + arn: generated.arn, + endpoint: "remote.example.com:9443".to_string(), + secure: true, + target_type: BucketTargetType::ReplicationService, + deployment_id: "remote".to_string(), + skip_tls_verify: false, + ca_cert_pem: "old-ca".to_string(), + bandwidth_limit: 42, + disable_proxy: true, + ..Default::default() + }; + let reconciled = reconcile_site_replication_bucket_targets( + BucketTargets { targets: vec![existing] }, + "photos", + &state, + &local, + None, + "secret", + ) + .expect("reconcile targets"); + let target = reconciled.targets.first().expect("reconciled target"); + assert!(target.skip_tls_verify); + assert_eq!(target.ca_cert_pem, ""); + assert_eq!(target.bandwidth_limit, 42); + assert!(target.disable_proxy); +} + +#[test] +fn test_bucket_versioning_xml_enables_versioning() { + let data = bucket_versioning_xml().expect("versioning XML should serialize"); + let config: VersioningConfiguration = deserialize(&data).expect("versioning XML should deserialize"); + + assert!(config.enabled()); +} + +/// A3 red-light: `versioningEnabled` must travel on every outbound +/// make-with-versioning bucket op so the query matches MinIO's +/// site-replication make-bucket wire contract (MinIO's own hook sends +/// `versioningEnabled=true` on this op). +#[test] +fn test_make_with_versioning_op_paths_send_versioning_enabled() { + let bucket = SRBucketInfo { + bucket: "photos".to_string(), + created_at: Some(OffsetDateTime::UNIX_EPOCH), + object_lock_config: Some(BASE64_STANDARD.encode_to_string("")), + ..Default::default() + }; + let bootstrap = bootstrap_bucket_make_op_path(&bucket); + assert!(bootstrap.contains("operation=make-with-versioning"), "{bootstrap}"); + assert!(bootstrap.contains("versioningEnabled=true"), "{bootstrap}"); + assert!(bootstrap.contains("createdAt="), "{bootstrap}"); + assert!(bootstrap.contains("lockEnabled=true"), "{bootstrap}"); + + // The broadcast path (create-bucket hook) shares the same builder. + let broadcast = make_with_versioning_bucket_op_path("photos", Some("1970-01-01T00:00:00Z"), false); + assert!(broadcast.contains("versioningEnabled=true"), "{broadcast}"); + assert!(!broadcast.contains("lockEnabled"), "{broadcast}"); +} + +#[test] +fn test_site_replication_bootstrap_plan_includes_replayable_snapshot_items() { + let mut info = SRInfo::default(); + info.state.peers.insert( + "remote".to_string(), + PeerInfo { + replicate_ilm_expiry: true, + ..peer("remote", "https://remote.example.com") + }, + ); + info.policies.insert( + "readwrite".to_string(), + SRIAMPolicy { + policy: Some(serde_json::json!({"Version": "2012-10-17", "Statement": []})), + updated_at: Some(OffsetDateTime::UNIX_EPOCH), + api_version: Some(SITE_REPL_API_VERSION.to_string()), + }, + ); + info.user_info_map.insert( + "alice".to_string(), + rustfs_madmin::UserInfo { + secret_key: Some("alice-secret".to_string()), + policy_name: Some("readwrite".to_string()), + status: rustfs_madmin::AccountStatus::Enabled, + updated_at: Some(OffsetDateTime::UNIX_EPOCH), + ..Default::default() + }, + ); + info.user_info_map.insert( + "external".to_string(), + rustfs_madmin::UserInfo { + secret_key: None, + status: rustfs_madmin::AccountStatus::Enabled, + ..Default::default() + }, + ); + info.group_desc_map.insert( + "devs".to_string(), + rustfs_madmin::GroupDesc { + name: "devs".to_string(), + status: "enabled".to_string(), + members: vec!["alice".to_string()], + policy: String::new(), + updated_at: Some(OffsetDateTime::UNIX_EPOCH), + }, + ); + info.user_policies.insert( + "alice".to_string(), + SRPolicyMapping { + user_or_group: "alice".to_string(), + user_type: sr_wire_user_type(UserType::Reg, false), + policy: "readwrite".to_string(), + updated_at: Some(OffsetDateTime::UNIX_EPOCH), + ..Default::default() + }, + ); + info.buckets.insert( + "photos".to_string(), + SRBucketInfo { + bucket: "photos".to_string(), + policy: Some(serde_json::json!({"Statement": []})), + versioning: Some(BASE64_STANDARD.encode_to_string("")), + quota_config: Some(BASE64_STANDARD.encode_to_string(r#"{"quota":1024}"#)), + expiry_lc_config: Some(BASE64_STANDARD.encode_to_string("")), + object_lock_config: Some(BASE64_STANDARD.encode_to_string("")), + created_at: Some(OffsetDateTime::UNIX_EPOCH), + api_version: Some(SITE_REPL_API_VERSION.to_string()), + ..Default::default() + }, + ); + + let plan = site_replication_bootstrap_plan(&info).expect("bootstrap plan should build"); + + assert_eq!(plan.iam_items.iter().map(|item| item.r#type.as_str()).collect::>(), { + vec!["policy", "iam-user", "group-info", "policy-mapping"] + }); + assert_eq!(plan.bucket_make_ops.len(), 1); + assert!(plan.bucket_make_ops[0].contains("operation=make-with-versioning")); + assert!(plan.bucket_make_ops[0].contains("lockEnabled=true")); + assert_eq!(plan.bucket_configure_ops.len(), 1); + assert!(plan.bucket_configure_ops[0].contains("operation=configure-replication")); + + let bucket_types = plan.bucket_items.iter().map(|item| item.r#type.as_str()).collect::>(); + assert_eq!( + bucket_types, + vec!["policy", "version-config", "object-lock-config", "quota-config", "lc-config"] + ); + let quota = plan + .bucket_items + .iter() + .find(|item| item.r#type == "quota-config") + .and_then(|item| item.quota.as_ref()) + .expect("quota item should exist"); + assert_eq!(quota["quota"], 1024); +} + +#[test] +fn test_site_replication_bootstrap_plan_skips_lifecycle_by_default() { + let mut info = SRInfo::default(); + info.buckets.insert( + "photos".to_string(), + SRBucketInfo { + bucket: "photos".to_string(), + expiry_lc_config: Some(BASE64_STANDARD.encode_to_string("")), + api_version: Some(SITE_REPL_API_VERSION.to_string()), + ..Default::default() + }, + ); + + let plan = site_replication_bootstrap_plan(&info).expect("bootstrap plan should build"); + + assert!(!plan.bucket_items.iter().any(|item| item.r#type == "lc-config")); +} + +/// A deleted expiry state (entry value None, axis set) must travel as an +/// explicit timestamped delete item — a peer that missed the live delete +/// otherwise keeps stale expiry rules through every repair (review +/// finding). +#[test] +fn test_site_replication_bootstrap_plan_emits_timestamped_lifecycle_delete() { + let deleted_at = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("timestamp"); + let mut info = SRInfo::default(); + info.state.peers.insert( + "remote-dep".to_string(), + PeerInfo { + replicate_ilm_expiry: true, + ..peer("remote", "https://remote.example.com") + }, + ); + info.buckets.insert( + "photos".to_string(), + SRBucketInfo { + bucket: "photos".to_string(), + expiry_lc_config: None, + expiry_lc_config_updated_at: Some(deleted_at), + api_version: Some(SITE_REPL_API_VERSION.to_string()), + ..Default::default() + }, + ); + + let plan = site_replication_bootstrap_plan(&info).expect("bootstrap plan should build"); + + let item = plan + .bucket_items + .iter() + .find(|item| item.r#type == "lc-config") + .expect("a deleted expiry state must produce an lc-config delete item"); + assert!(item.expiry_lc_config.is_none(), "delete items carry no config body"); + assert_eq!(item.expiry_updated_at, Some(deleted_at)); + assert_eq!(item.updated_at, Some(deleted_at)); +} + +/// What each local lifecycle state contributes to the SRInfo entry: +/// deletions are timestamped statements, never-configured buckets and +/// transition-only configs without an expiry axis say nothing. +#[test] +fn test_lifecycle_expiry_statement_matrix() { + let created = OffsetDateTime::from_unix_timestamp(1_600_000_000).expect("timestamp"); + let mut meta = crate::storage_api::site_replication::BucketMetadata::new("photos"); + meta.created = created; + // Never configured: load backfills the write time to `created`. + meta.lifecycle_config_updated_at = created; + assert!(lifecycle_expiry_statement(&meta).is_none()); + + // Deleted: the write time survives deletion and exceeds creation. + let deleted_at = created + time::Duration::seconds(100); + meta.lifecycle_config_updated_at = deleted_at; + let (subset, axis) = lifecycle_expiry_statement(&meta).expect("deletion is a statement"); + assert!(subset.is_none()); + assert_eq!(axis, deleted_at); + + // Present with expiry rules and the axis: subset + axis travel. + let expiry_axis = created + time::Duration::seconds(50); + let mut config = lc_config(vec![lc_rule("e1", Some(7), None)]); + config.expiry_updated_at = Some(Timestamp::from(expiry_axis)); + meta.lifecycle_config_xml = serialize(&config).expect("serialize config"); + let (subset, axis) = lifecycle_expiry_statement(&meta).expect("expiry config is a statement"); + assert!(subset.is_some()); + assert_eq!(axis.unix_timestamp(), expiry_axis.unix_timestamp()); + + // Transition-only without an axis: nothing to say (a delete stamped + // off the whole-config time would erase newer peer expiry state). + meta.lifecycle_config_xml = serialize(&lc_config(vec![lc_rule("t1", None, Some(30))])).expect("serialize config"); + assert!(lifecycle_expiry_statement(&meta).is_none()); + + // Transition-only WITH an axis: expiry rules were properly removed — + // the delete travels at that axis. + let mut transition_only = lc_config(vec![lc_rule("t1", None, Some(30))]); + transition_only.expiry_updated_at = Some(Timestamp::from(expiry_axis)); + meta.lifecycle_config_xml = serialize(&transition_only).expect("serialize config"); + let (subset, axis) = lifecycle_expiry_statement(&meta).expect("removed expiry state is a statement"); + assert!(subset.is_none()); + assert_eq!(axis.unix_timestamp(), expiry_axis.unix_timestamp()); +} + +#[test] +fn test_site_replication_repair_request_is_strict_and_requires_explicit_mode() { + assert!(serde_json::from_str::(r#"{"mode":"dry-run"}"#).is_ok()); + assert!(serde_json::from_str::(r#"{"mode":"execute"}"#).is_ok()); + assert!(serde_json::from_str::(r#"{}"#).is_err()); + assert!(serde_json::from_str::(r#"{"mode":"dry-run","secret":"leak"}"#).is_err()); +} + +#[test] +fn test_site_replication_repair_dry_run_plan_is_non_mutating_and_redacted() { + let state = SiteReplicationState { + name: "local".to_string(), + service_account_access_key: "site-replicator-0".to_string(), + service_account_secret_key: "state-secret".to_string(), + peers: BTreeMap::from([ + ( + "local-dep".to_string(), + PeerInfo { + deployment_id: "local-dep".to_string(), + ..peer("local", "https://local.example.com") + }, + ), + ( + "remote-dep".to_string(), + PeerInfo { + deployment_id: "remote-dep".to_string(), + ..peer("remote", "https://remote.example.com") + }, + ), + ]), + retry_queue: vec![SiteReplicationRetryEvent { + peer_deployment_id: "remote-dep".to_string(), + path: format!( + "{SITE_REPLICATION_PEER_BUCKET_OPS_PATH}?bucket=photos&operation={SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING}" + ), + last_error: "credential=retry-secret".to_string(), + ..Default::default() + }], + ..Default::default() + }; + let plan = SiteReplicationBootstrapPlan { + iam_items: vec![SRIAMItem { + r#type: "iam-user".to_string(), + iam_user: Some(rustfs_madmin::SRIAMUser { + access_key: "alice".to_string(), + user_req: Some(AddOrUpdateUserReq { + secret_key: "iam-secret".to_string(), + policy: None, + status: rustfs_madmin::AccountStatus::Enabled, + }), + ..Default::default() + }), + ..Default::default() + }], + bucket_make_ops: vec![format!( + "{SITE_REPLICATION_PEER_BUCKET_OPS_PATH}?bucket=photos&operation={SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING}" + )], + ..Default::default() + }; + let before = serde_json::to_vec(&state).expect("serialize state before planning"); + let local = state.peers.get("local-dep").expect("local peer"); + + let response = SiteReplicationRepairPreflight { + mode: "dry-run", + status: "planned", + preflight_token: site_replication_repair_preflight_token(&state, &plan, b"test-signing-key").expect("preflight token"), + retry_events: state.retry_queue.len(), + sites: site_replication_repair_sites(&state, local, &plan, b"test-signing-key").expect("repair sites"), + }; + let encoded = serde_json::to_string(&response).expect("serialize preflight"); + + assert_eq!(serde_json::to_vec(&state).expect("serialize state after planning"), before); + assert!(!encoded.contains("state-secret")); + assert!(!encoded.contains("iam-secret")); + assert!(!encoded.contains("retry-secret")); + assert!(!encoded.contains("remote.example.com")); + assert_eq!(response.sites["remote-dep"].families[SITE_REPLICATION_REPAIR_IAM_FAMILY].planned, 1); + let bucket_family = &response.sites["remote-dep"].families[SITE_REPLICATION_REPAIR_BUCKET_FAMILY]; + assert_eq!(bucket_family.retry_events, 1); + let task_id = &bucket_family.tasks[0].task_id; + assert_eq!(task_id.len(), 43); + assert!( + task_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + ); + assert!(!task_id.contains("bucket")); + assert!(!task_id.contains("photos")); + assert!(!task_id.contains("remote-dep")); + assert_eq!(bucket_family.tasks[0].status, "planned"); + let repeated = site_replication_repair_sites(&state, local, &plan, b"test-signing-key").expect("repeat repair sites"); + assert_eq!( + task_id, + &repeated["remote-dep"].families[SITE_REPLICATION_REPAIR_BUCKET_FAMILY].tasks[0].task_id + ); + let rotated = site_replication_repair_sites(&state, local, &plan, b"rotated-signing-key").expect("rotated repair sites"); + assert_ne!( + task_id, + &rotated["remote-dep"].families[SITE_REPLICATION_REPAIR_BUCKET_FAMILY].tasks[0].task_id + ); +} + +#[test] +fn test_site_replication_repair_preflight_detects_stale_snapshot() { + let mut state = SiteReplicationState { + name: "local".to_string(), + service_account_access_key: "site-replicator-0".to_string(), + peers: BTreeMap::from([( + "remote-dep".to_string(), + PeerInfo { + deployment_id: "remote-dep".to_string(), + ..peer("remote", "https://remote.example.com") + }, + )]), + ..Default::default() + }; + let plan = SiteReplicationBootstrapPlan { + bucket_make_ops: vec![ + "/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=make-with-versioning".to_string(), + ], + ..Default::default() + }; + let original = site_replication_repair_preflight_token(&state, &plan, b"test-signing-key").expect("original token"); + let original_plan = site_replication_repair_plan_token(&state, &plan).expect("original plan token"); + + state.updated_at = Some(OffsetDateTime::UNIX_EPOCH); + let changed = site_replication_repair_preflight_token(&state, &plan, b"test-signing-key").expect("changed token"); + let changed_plan = site_replication_repair_plan_token(&state, &plan).expect("changed plan token"); + + assert_ne!(original, changed); + assert_eq!(original.len(), 43); + assert!( + original + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + ); + assert_ne!( + changed, + site_replication_repair_preflight_token(&state, &plan, b"different-signing-key").expect("differently signed token") + ); + assert!(site_replication_repair_preflight_token(&state, &plan, b"").is_err()); + + state.retry_queue.push(SiteReplicationRetryEvent { + id: "retry-1".to_string(), + peer_deployment_id: "remote-dep".to_string(), + path: "/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=make-with-versioning".to_string(), + ..Default::default() + }); + let retry_changed = site_replication_repair_preflight_token(&state, &plan, b"test-signing-key").expect("retry-aware token"); + assert_ne!(changed, retry_changed); + assert_eq!( + changed_plan, + site_replication_repair_plan_token(&state, &plan).expect("retry-stable plan token") + ); + assert_ne!(original_plan, changed_plan, "updated_at changes the plan token"); +} + +#[test] +fn test_site_replication_repair_partial_retry_skips_completed_tasks_and_survives_restart() { + let local = PeerInfo { + deployment_id: "local-dep".to_string(), + ..peer("local", "https://local.example.com") + }; + let remote = PeerInfo { + deployment_id: "remote-dep".to_string(), + ..peer("remote", "https://remote.example.com") + }; + let state = SiteReplicationState { + peers: BTreeMap::from([ + (local.deployment_id.clone(), local.clone()), + (remote.deployment_id.clone(), remote.clone()), + ]), + ..Default::default() + }; + let plan = SiteReplicationBootstrapPlan { + iam_items: vec![SRIAMItem { + r#type: "policy".to_string(), + name: "readwrite".to_string(), + ..Default::default() + }], + bucket_make_ops: vec![ + "/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=make-with-versioning".to_string(), + ], + ..Default::default() + }; + let tasks = site_replication_repair_tasks(&plan); + let (first_index, first_task) = &tasks[0]; + let (second_index, second_task) = &tasks[1]; + let now = OffsetDateTime::UNIX_EPOCH; + let mut operation = SiteReplicationRepairOperation { + operation_id: Uuid::new_v4().to_string(), + preflight_token: site_replication_repair_preflight_token(&state, &plan, b"test-signing-key").expect("preflight token"), + plan_token: site_replication_repair_plan_token(&state, &plan).expect("plan token"), + status: "running".to_string(), + sites: site_replication_repair_sites(&state, &local, &plan, b"test-signing-key").expect("repair sites"), + created_at: Some(now), + updated_at: Some(now), + completed_at: None, + }; + + update_site_replication_repair_task(&mut operation, &remote.deployment_id, first_task.family(), *first_index, Ok(())) + .expect("record first success"); + update_site_replication_repair_task( + &mut operation, + &remote.deployment_id, + second_task.family(), + *second_index, + Err("peer response included secret=must-not-leak"), + ) + .expect("record injected failure"); + summarize_site_replication_repair_operation(&mut operation); + assert_eq!(operation.status, "partial"); + assert_eq!( + operation.sites["remote-dep"].families[SITE_REPLICATION_REPAIR_IAM_FAMILY].tasks[0].status, + "succeeded" + ); + assert_eq!( + operation.sites["remote-dep"].families[SITE_REPLICATION_REPAIR_BUCKET_FAMILY].tasks[0].status, + "failed" + ); + assert!( + !site_replication_repair_task_pending(&operation, &remote.deployment_id, first_task.family(), *first_index) + .expect("first task state") + ); + assert!( + !site_replication_repair_task_pending(&operation, &remote.deployment_id, second_task.family(), *second_index) + .expect("failed task waits for retry") + ); + let response = serde_json::to_string(&site_replication_repair_operation_response(&operation)) + .expect("serialize public operation response"); + assert!(!response.contains(&operation.preflight_token)); + assert!(!response.contains(&operation.plan_token)); + + let persisted_state = SiteReplicationRepairState { + operations: BTreeMap::from([(operation.operation_id.clone(), operation)]), + }; + let encoded = serde_json::to_vec(&persisted_state).expect("persist state"); + let recovered_state: SiteReplicationRepairState = serde_json::from_slice(&encoded).expect("load state after restart"); + let mut recovered = recovered_state + .operations + .into_values() + .next() + .expect("recover operation after restart"); + assert_eq!(recovered.sites["remote-dep"].families[SITE_REPLICATION_REPAIR_IAM_FAMILY].succeeded, 1); + assert!(!String::from_utf8(encoded).expect("operation JSON").contains("must-not-leak")); + + prepare_site_replication_repair_retry(&mut recovered); + assert_eq!( + recovered.sites["remote-dep"].families[SITE_REPLICATION_REPAIR_IAM_FAMILY].tasks[0].status, + "skipped" + ); + assert_eq!( + recovered.sites["remote-dep"].families[SITE_REPLICATION_REPAIR_BUCKET_FAMILY].tasks[0].status, + "planned" + ); + assert!( + site_replication_repair_task_pending(&recovered, &remote.deployment_id, second_task.family(), *second_index) + .expect("failed task becomes retryable") + ); + update_site_replication_repair_task(&mut recovered, &remote.deployment_id, second_task.family(), *second_index, Ok(())) + .expect("retry failed task"); + assert!( + !site_replication_repair_task_pending(&recovered, &remote.deployment_id, first_task.family(), *first_index) + .expect("completed task remains skipped") + ); + summarize_site_replication_repair_operation(&mut recovered); + + assert_eq!(recovered.status, "success"); + assert_eq!(recovered.sites["remote-dep"].families[SITE_REPLICATION_REPAIR_IAM_FAMILY].succeeded, 1); + assert_eq!(recovered.sites["remote-dep"].families[SITE_REPLICATION_REPAIR_BUCKET_FAMILY].succeeded, 1); +} + +#[test] +fn test_site_replication_repair_error_classification_is_redacted() { + assert_eq!( + classify_site_replication_repair_error("peer request to https://user:secret@example.com failed with 403: token=private"), + "authorization-failed" + ); + assert_eq!( + classify_site_replication_repair_error("peer request body contained secret=private"), + "remote-operation-failed" + ); +} + +#[test] +fn test_site_replication_repair_admission_resumes_same_id_and_rejects_conflicts() { + let existing = SiteReplicationRepairOperation { + operation_id: "operation-a".to_string(), + preflight_token: "preflight-a".to_string(), + plan_token: "plan-a".to_string(), + status: "running".to_string(), + ..Default::default() + }; + let mut state = SiteReplicationRepairState { + operations: BTreeMap::from([(existing.operation_id.clone(), existing.clone())]), + }; + + let resumed = admit_site_replication_repair_operation( + &mut state, + existing.operation_id.clone(), + &existing.preflight_token, + existing.clone(), + ) + .expect("same operation ID and preflight should resume"); + assert_eq!(resumed.operation_id, existing.operation_id); + + let conflicting_operation = SiteReplicationRepairOperation { + operation_id: "operation-b".to_string(), + preflight_token: "preflight-b".to_string(), + plan_token: "plan-b".to_string(), + status: "running".to_string(), + ..Default::default() + }; + let conflicting_preflight = conflicting_operation.preflight_token.clone(); + let err = admit_site_replication_repair_operation( + &mut state, + conflicting_operation.operation_id.clone(), + &conflicting_preflight, + conflicting_operation, + ) + .expect_err("a different operation must not pass a persisted running operation"); + assert_eq!(err.code(), &S3ErrorCode::ClientTokenConflict); + + let stale_candidate = SiteReplicationRepairOperation { + plan_token: "plan-changed".to_string(), + ..existing.clone() + }; + let err = admit_site_replication_repair_operation( + &mut state, + existing.operation_id.clone(), + &existing.preflight_token, + stale_candidate, + ) + .expect_err("a resumed operation must remain bound to its original plan"); + assert_eq!(err.code(), &S3ErrorCode::PreconditionFailed); + + let err = admit_site_replication_repair_operation(&mut state, existing.operation_id.clone(), "different-preflight", existing) + .expect_err("an operation ID must remain bound to its original preflight"); + assert_eq!(err.code(), &S3ErrorCode::ClientTokenConflict); +} + +#[test] +fn test_site_replication_repair_history_never_prunes_retriable_operations() { + let mut operations = (0..=SITE_REPLICATION_REPAIR_OPERATION_LIMIT) + .map(|index| { + ( + format!("success-{index}"), + SiteReplicationRepairOperation { + operation_id: format!("success-{index}"), + status: "success".to_string(), + created_at: OffsetDateTime::from_unix_timestamp(i64::try_from(index).expect("small test index")).ok(), + ..Default::default() + }, + ) + }) + .collect::>(); + operations.insert( + "partial".to_string(), + SiteReplicationRepairOperation { + operation_id: "partial".to_string(), + status: "partial".to_string(), + created_at: Some(OffsetDateTime::UNIX_EPOCH), + ..Default::default() + }, + ); + + prune_site_replication_repair_operations(&mut operations); + + assert!(operations.contains_key("partial")); + assert_eq!(operations.len(), SITE_REPLICATION_REPAIR_OPERATION_LIMIT); + assert!(!operations.contains_key("success-0")); + assert!(!operations.contains_key("success-1")); +} + +#[test] +fn test_site_replication_state_replicates_ilm_expiry_detects_enabled_peer() { + let mut state = SiteReplicationState::default(); + state.peers.insert( + "remote".to_string(), + PeerInfo { + replicate_ilm_expiry: true, + ..peer("remote", "https://remote.example.com") + }, + ); + + assert!(site_replication_state_replicates_ilm_expiry(&state)); +} + +#[test] +fn test_retry_event_upsert_marks_repeated_failures() { + let peer = PeerInfo { + deployment_id: "remote-dep".to_string(), + ..peer("remote", "https://remote.example.com") + }; + let mut queue = Vec::new(); + + upsert_site_replication_retry_event(&mut queue, &peer, "/rustfs/admin/v3/site-replication/peer/iam-item", "first", None); + upsert_site_replication_retry_event(&mut queue, &peer, "/rustfs/admin/v3/site-replication/peer/iam-item", "second", None); + upsert_site_replication_retry_event(&mut queue, &peer, "/rustfs/admin/v3/site-replication/peer/iam-item", "third", None); + + assert_eq!(queue.len(), 1); + assert_eq!(queue[0].path, SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH); + assert_eq!(queue[0].retry_count, SITE_REPLICATION_RETRY_FAILED_AFTER); + assert!(queue[0].failed); + assert_eq!(queue[0].last_error, "third"); +} + +/// P1-15 review follow-up: a successful peer-edit delivery only proves the +/// peer reached the state THAT delivery carried. Settling it must not +/// erase a retry event a newer edit left behind, or the local site sits on +/// edit B, the peer on edit A, and nothing is queued to converge them. +#[test] +fn retry_settlement_must_not_erase_a_newer_generation_failure() { + let peer = PeerInfo { + deployment_id: "remote-dep".to_string(), + ..peer("remote", "https://remote.example.com") + }; + let mut queue = Vec::new(); + + // Edit A (generation 5) delivered successfully and is stalled before + // settling. Edit B (generation 6) commits meanwhile, fails delivery to + // the same peer, and enqueues. + upsert_site_replication_retry_event(&mut queue, &peer, SITE_REPLICATION_PEER_EDIT_PATH, "peer offline", Some(6)); + + // A resumes: its own settlement must leave B's retry alone. + assert_eq!( + settle_site_replication_retry_events(&mut queue, &peer, SITE_REPLICATION_PEER_EDIT_PATH, Some(5)), + 0 + ); + assert_eq!(queue.len(), 1, "the newer edit's retry event was erased by an older success"); + assert_eq!(queue[0].edit_generation, Some(6)); + + // An even older delivery failing afterwards must not lower the fence. + upsert_site_replication_retry_event(&mut queue, &peer, SITE_REPLICATION_PEER_EDIT_PATH, "still offline", Some(4)); + assert_eq!(queue[0].edit_generation, Some(6)); + + // B's own delivery succeeding is what clears it. + assert_eq!( + settle_site_replication_retry_events(&mut queue, &peer, SITE_REPLICATION_PEER_EDIT_PATH, Some(6)), + 1 + ); + assert!(queue.is_empty()); + + // Collapsed broadcast failures live under an internal snapshot path; + // an unrelated success on their shared wire path cannot settle them. + let iam_path = "/rustfs/admin/v3/site-replication/peer/iam-item"; + upsert_site_replication_retry_event(&mut queue, &peer, iam_path, "peer offline", None); + assert_eq!(dequeue_site_replication_retry_events(&mut queue, &peer, iam_path), 0); + assert_eq!(queue[0].path, SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH); +} + +/// The `previous + 1` half of the hybrid clock: allocations stay strictly +/// increasing even when the wall clock cannot move them forward — two +/// allocations inside one clock tick, or a clock that stepped backwards +/// mid-lifetime (a counter already ahead of the wall clock advances by +/// exactly one per allocation instead of jumping back). Dropping the +/// `previous + 1` half (allocating bare wall time) turns this red. +#[test] +fn hybrid_generation_is_strictly_increasing_when_the_clock_stalls() { + let mut state = SiteReplicationState { + // A counter far ahead of any wall clock this test will see. + edit_generation: u64::MAX / 2, + ..Default::default() + }; + assert_eq!(next_peer_edit_generation(&mut state), u64::MAX / 2 + 1); + assert_eq!(next_peer_edit_generation(&mut state), u64::MAX / 2 + 2); + // Saturation pins at the ceiling instead of wrapping; the equal-value + // escape (`applied > generation` is false for equal) keeps deliveries + // applying rather than fencing the origin out. + state.edit_generation = u64::MAX; + assert_eq!(next_peer_edit_generation(&mut state), u64::MAX); +} + +#[test] +fn test_retry_stats_for_state_counts_pending_and_failed() { + let state = SiteReplicationState { + retry_queue: vec![ + SiteReplicationRetryEvent { + failed: false, + last_error: "pending".to_string(), + ..Default::default() + }, + SiteReplicationRetryEvent { + failed: true, + last_error: "failed".to_string(), + ..Default::default() + }, + ], + ..Default::default() + }; + + let stats = retry_stats_for_state(&state).expect("retry stats should be present"); + + assert_eq!(stats.pending, 1); + assert_eq!(stats.failed, 1); + assert_eq!(stats.last_error, "failed"); +} + +#[test] +fn test_retry_event_dequeue_matches_deployment_id_or_endpoint() { + let peer = PeerInfo { + deployment_id: "current-dep".to_string(), + ..peer("remote", "https://remote.example.com") + }; + let path = SITE_REPLICATION_PEER_EDIT_PATH; + let mut queue = vec![ + SiteReplicationRetryEvent { + id: "same-endpoint".to_string(), + peer_deployment_id: "old-dep".to_string(), + peer_endpoint: "https://remote.example.com".to_string(), + path: path.to_string(), + ..Default::default() + }, + SiteReplicationRetryEvent { + id: "different-path".to_string(), + peer_deployment_id: "old-dep".to_string(), + peer_endpoint: "https://remote.example.com".to_string(), + path: "/rustfs/admin/v3/site-replication/peer/bucket-meta".to_string(), + ..Default::default() + }, + ]; + + let removed = dequeue_site_replication_retry_events(&mut queue, &peer, path); + + assert_eq!(removed, 1); + assert_eq!(queue.len(), 1); + assert_eq!(queue[0].id, "different-path"); +} + +#[test] +fn test_retry_event_replayed_by_bootstrap_only_clears_replayable_bucket_ops() { + let retry_event = |id: &str, path: &str| SiteReplicationRetryEvent { + id: id.to_string(), + path: path.to_string(), + ..Default::default() + }; + let mut queue = vec![ + retry_event( + "make", + "/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=make-with-versioning", + ), + retry_event( + "configure", + "/rustfs/admin/v3/site-replication/peer/bucket-ops?operation=configure-replication&bucket=photos", + ), + retry_event( + "delete", + "/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=delete-bucket", + ), + retry_event( + "force-delete", + "/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=force-delete-bucket", + ), + retry_event( + "purge", + "/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=purge-deleted-bucket", + ), + retry_event( + "unknown", + "/rustfs/admin/v3/site-replication/peer/bucket-ops?bucket=photos&operation=custom", + ), + retry_event("iam", "/rustfs/admin/v3/site-replication/peer/iam-item"), + retry_event("bucket-meta", "/rustfs/admin/v3/site-replication/peer/bucket-meta"), + ]; + + queue.retain(|event| !retry_event_replayed_by_bootstrap(event)); + + let retained_ids = queue.iter().map(|event| event.id.as_str()).collect::>(); + assert_eq!(retained_ids, vec!["delete", "force-delete", "purge", "unknown", "iam", "bucket-meta"]); +} + +#[test] +fn test_site_identity_key_deduplicates_scheme_drift_on_same_host_port() { + assert_eq!( + site_identity_key("https://node-a.example.com:9000"), + site_identity_key("http://NODE-A.example.com:9000/"), + ); +} + +#[test] +fn test_normalize_peer_map_by_identity_prefers_https_endpoint() { + let peers = BTreeMap::from([ + ( + "peer-http".to_string(), + PeerInfo { + deployment_id: "peer-http".to_string(), + ..peer("peer", "http://node-a.example.com:9000") + }, + ), + ( + "peer-https".to_string(), + PeerInfo { + deployment_id: "peer-https".to_string(), + ..peer("peer", "https://node-a.example.com:9000") + }, + ), + ]); + + let normalized = normalize_peer_map_by_identity(peers); + assert_eq!(normalized.len(), 1); + let normalized_peer = normalized.values().next().expect("normalized peer"); + assert!(normalized_peer.endpoint.starts_with("https://")); +} + +#[test] +fn test_request_endpoint_prefers_forwarded_proto() { + let uri: Uri = "/rustfs/admin/v3/site-replication/status".parse().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-scheme", HeaderValue::from_static("http")); + headers.insert("x-forwarded-proto", HeaderValue::from_static("https")); + headers.insert("host", HeaderValue::from_static("node-a.example.com:9000")); + + let endpoint = request_endpoint(&uri, &headers); + + assert_eq!(endpoint, "https://node-a.example.com:9000"); +} + +#[test] +fn test_request_endpoint_uses_absolute_uri_without_host_header() { + let uri: Uri = "https://node-a.example.com:9443/rustfs/admin/v3/site-replication/status" + .parse() + .unwrap(); + let headers = HeaderMap::new(); + + let endpoint = request_endpoint(&uri, &headers); + + assert_eq!(endpoint, "https://node-a.example.com:9443"); +} + +#[test] +fn test_request_endpoint_falls_back_to_https_when_tls_path_is_configured() { + with_var(ENV_RUSTFS_TLS_PATH, Some("/tmp/tls"), || { + let uri: Uri = "/rustfs/admin/v3/site-replication/status".parse().unwrap(); + let headers = HeaderMap::new(); + + let endpoint = request_endpoint(&uri, &headers); + + assert!(endpoint.starts_with("https://")); + }); +} + +#[test] +fn test_site_replication_local_endpoint_uses_api_port_for_console_host_header() { + let uri: Uri = "/rustfs/admin/v3/site-replication/status".parse().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-proto", HeaderValue::from_static("https")); + headers.insert("host", HeaderValue::from_static("node-a.example.com:9001")); + + let endpoint = site_replication_local_endpoint(&uri, &headers); + + assert_eq!(endpoint, "https://node-a.example.com:9000"); +} + +#[test] +fn test_site_replication_local_endpoint_preserves_ipv6_host() { + let uri: Uri = "/rustfs/admin/v3/site-replication/status".parse().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-proto", HeaderValue::from_static("https")); + headers.insert("host", HeaderValue::from_static("[::1]:9001")); + + let endpoint = site_replication_local_endpoint(&uri, &headers); + + assert_eq!(endpoint, "https://[::1]:9000"); +} + +#[test] +fn test_site_replication_local_endpoint_preserves_non_console_port() { + let uri: Uri = "/rustfs/admin/v3/site-replication/status".parse().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-proto", HeaderValue::from_static("https")); + headers.insert("host", HeaderValue::from_static("lb.example.com:9443")); + + let endpoint = site_replication_local_endpoint(&uri, &headers); + + assert_eq!(endpoint, "https://lb.example.com:9443"); +} + +#[test] +fn test_site_replication_local_endpoint_rejects_forwarded_non_http_scheme() { + let uri: Uri = "/rustfs/admin/v3/site-replication/status".parse().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-proto", HeaderValue::from_static("ftp")); + headers.insert("host", HeaderValue::from_static("node-a.example.com:9000")); + + let endpoint = site_replication_local_endpoint(&uri, &headers); + + assert!(!endpoint.starts_with("ftp://")); +} + +#[test] +fn test_runtime_tls_enabled_prefers_explicit_tls_over_http_runtime_endpoint() { + let endpoints = EndpointServerPools::from(vec![PoolEndpoints { + legacy: false, + set_count: 1, + drives_per_set: 1, + endpoints: Endpoints::from(vec![Endpoint { + url: Url::parse("http://127.0.0.1:9000/tmp").unwrap(), + is_local: true, + pool_idx: 0, + set_idx: 0, + disk_idx: 0, + }]), + cmd_line: String::new(), + platform: String::new(), + }]); + + with_var(ENV_RUSTFS_TLS_PATH, Some("/tmp/tls"), || { + assert!(runtime_tls_enabled_with(Some(&endpoints))); + }); +} + +#[test] +fn test_site_replication_state_requires_remote_peer_to_be_enabled() { + let mut state = SiteReplicationState::default(); + state.peers.insert( + "local".to_string(), + PeerInfo { + deployment_id: "local".to_string(), + ..peer("local", "https://local.example.com") + }, + ); + + assert!(!state.enabled()); +} + +#[test] +fn test_sr_remove_req_accepts_null_sites() { + let req: SRRemoveReq = serde_json::from_str(r#"{"all":true,"sites":null}"#).expect("parse remove req"); + + assert!(req.remove_all); + assert!(req.site_names.is_empty()); +} + +#[test] +fn test_bucket_target_matches_peer_by_deployment_id() { + let target = BucketTarget { + deployment_id: "remote-dep".to_string(), + endpoint: "other-host:9000".to_string(), + target_type: BucketTargetType::ReplicationService, + ..Default::default() + }; + let mut remote = peer("remote", "https://remote.example.com"); + remote.deployment_id = "remote-dep".to_string(); + + assert!(bucket_target_matches_peer(&target, &remote)); +} + +#[test] +fn test_bucket_target_matches_peer_by_endpoint() { + let target = BucketTarget { + endpoint: "remote.example.com:443".to_string(), + secure: true, + target_type: BucketTargetType::ReplicationService, + ..Default::default() + }; + let remote = peer("remote", "https://remote.example.com/"); + + assert!(bucket_target_matches_peer(&target, &remote)); +} + +fn home_office() -> HashSet { + HashSet::from(["home".to_string(), "office".to_string()]) +} + +fn site_repl_config(peer: &str) -> ReplicationConfiguration { + ReplicationConfiguration { + role: String::new(), + rules: vec![build_site_replication_rule( + &format!("arn:rustfs:replication::{peer}:photos"), + 1, + &format!("site-repl-{peer}"), + )], + } +} + +fn operator_rule(id: &str) -> ReplicationRule { + ReplicationRule { + id: Some(id.to_string()), + ..build_site_replication_rule("arn:aws:s3:::backup", 1, id) + } +} + +// The one-directional bug: the joined site applied the initiator's replication config +// verbatim, so its own `site-repl-` rule was replaced by a rule pointing at +// itself. No bucket target backs that ARN, so every object was dropped without a log. +#[test] +fn test_merge_incoming_replication_config_keeps_local_reverse_rule() { + let merged = merge_incoming_replication_config( + Some(site_repl_config("home")), + Some(site_repl_config("office")), + &home_office(), + OperatorRuleContract::Derived, + ) + .expect("merge should keep the local rule"); + + assert_eq!(merged.rules.len(), 1); + assert_eq!(merged.rules[0].id.as_deref(), Some("site-repl-office")); + assert_eq!(merged.rules[0].destination.bucket, "arn:rustfs:replication::office:photos"); +} + +// A peer deleting its replication config must not delete the receiver's reverse rule +// either — the delete travels as `replication-config` with no payload. +#[test] +fn test_merge_incoming_replication_config_survives_peer_delete() { + let merged = + merge_incoming_replication_config(None, Some(site_repl_config("office")), &home_office(), OperatorRuleContract::Derived) + .expect("local site rules must survive a peer delete"); + + assert_eq!(merged.rules.len(), 1); + assert_eq!(merged.rules[0].id.as_deref(), Some("site-repl-office")); +} + +#[test] +fn test_merge_incoming_replication_config_replicates_operator_rules() { + let mut incoming = site_repl_config("home"); + incoming.rules.push(operator_rule("nightly-backup")); + incoming.role = "arn:rustfs:replication::home:photos".to_string(); + + let merged = merge_incoming_replication_config( + Some(incoming), + Some(site_repl_config("office")), + &home_office(), + OperatorRuleContract::Derived, + ) + .expect("merge should produce rules"); + + let ids: Vec<_> = merged.rules.iter().filter_map(|rule| rule.id.as_deref()).collect(); + assert_eq!(ids, vec!["nightly-backup", "site-repl-office"]); + assert_eq!(merged.rules[0].priority, Some(1)); + assert_eq!(merged.rules[1].priority, Some(2)); + assert!( + merged.role.is_empty(), + "a site-replication ARN in `role` belongs to the sender and must not be adopted" + ); +} + +#[test] +fn test_merge_incoming_replication_config_returns_none_when_nothing_remains() { + assert!( + merge_incoming_replication_config(Some(site_repl_config("home")), None, &home_office(), OperatorRuleContract::Derived) + .is_none() + ); +} + +fn lc_rule(id: &str, expiry_days: Option, transition_days: Option) -> LifecycleRule { + LifecycleRule { + id: Some(id.to_string()), + status: ExpirationStatus::from_static(ExpirationStatus::ENABLED), + prefix: Some(String::new()), + expiration: expiry_days.map(|days| LifecycleExpiration { + days: Some(days), + ..Default::default() + }), + transitions: transition_days.map(|days| { + vec![Transition { + days: Some(days), + storage_class: Some(TransitionStorageClass::from_static(TransitionStorageClass::GLACIER)), + date: None, + }] + }), + abort_incomplete_multipart_upload: None, + del_marker_expiration: None, + filter: None, + noncurrent_version_expiration: None, + noncurrent_version_transitions: None, + } +} + +fn lc_config(rules: Vec) -> BucketLifecycleConfiguration { + BucketLifecycleConfiguration { + rules, + expiry_updated_at: None, + } +} + +fn rule_ids(config: &BucketLifecycleConfiguration) -> Vec<&str> { + config.rules.iter().filter_map(|rule| rule.id.as_deref()).collect() +} + +/// Sender-side filter: only the expiry subset leaves this site. MinIO +/// peers install incoming rules verbatim, so a full document would plant +/// this site's transition rules there. +#[test] +fn test_lifecycle_expiry_subset_xml_strips_transitions() { + let full = serialize(&lc_config(vec![lc_rule("mixed", Some(1), Some(30)), lc_rule("t-only", None, Some(7))])) + .expect("serialize full config"); + + let subset = lifecycle_expiry_subset_xml(&full).expect("expiry subset should remain"); + let parsed: BucketLifecycleConfiguration = deserialize(&subset).expect("subset should parse"); + assert_eq!(rule_ids(&parsed), vec!["mixed"]); + assert!(parsed.rules[0].transitions.is_none(), "transition side must not travel"); + + let transition_only = + serialize(&lc_config(vec![lc_rule("t-only", None, Some(7))])).expect("serialize transition-only config"); + assert!( + lifecycle_expiry_subset_xml(&transition_only).is_none(), + "a transition-only config states 'no expiry rules' (delete semantics)" + ); + assert!(lifecycle_expiry_subset_xml(b"").is_none()); +} + +/// A local parse failure must forward the document unfiltered — mapping +/// it to `None` would delete the peers' replicated expiry rules. +#[test] +fn test_lifecycle_expiry_subset_xml_forwards_unparseable_config() { + let garbage = b""; + assert_eq!(lifecycle_expiry_subset_xml(garbage).as_deref(), Some(garbage.as_slice())); +} + +// `role` is part of the bucket's S3-visible configuration. Repairing a reverse rule must +// drop only a role naming a current peer, never an operator's own role — an IAM role or +// a remote target whose ARN carries an empty region — the same rule the merge path +// applies, so both paths agree on what is ours to rewrite. +#[test] +fn test_replication_role_is_only_cleared_when_it_names_a_peer() { + let sites = home_office(); + assert!(!is_site_replication_role("arn:aws:iam::123456789012:role/replication", &sites)); + assert!(!is_site_replication_role("arn:minio:replication::operator-dep:photos", &sites)); + assert!(is_site_replication_role("arn:rustfs:replication::home:photos", &sites)); + + for operator_role in [ + "arn:aws:iam::123456789012:role/replication", + "arn:minio:replication::operator-dep:photos", + ] { + let mut incoming = site_repl_config("home"); + incoming.role = operator_role.to_string(); + let merged = merge_incoming_replication_config( + Some(incoming), + Some(site_repl_config("office")), + &sites, + OperatorRuleContract::Derived, + ) + .expect("merge should produce rules"); + assert_eq!(merged.role, operator_role, "operator role must survive the merge"); + } +} + +// Rules and targets are keyed off the same ARN. Minting a fresh one while +// `reconcile_site_replication_bucket_targets` preserves a MinIO-era `arn:minio:...` +// target would leave the rule pointing at an ARN no target satisfies. +#[test] +fn test_build_site_replication_config_reuses_configured_arn() { + let mut state = SiteReplicationState { + service_account_access_key: "site-replicator-0".to_string(), + ..Default::default() + }; + state.peers.insert( + "local".to_string(), + PeerInfo { + deployment_id: "local".to_string(), + ..peer("local", "https://local.example.com") + }, + ); + state.peers.insert( + "remote".to_string(), + PeerInfo { + deployment_id: "remote".to_string(), + ..peer("remote", "http://remote.example.com:9000") + }, + ); + let existing = ReplicationConfiguration { + role: String::new(), + rules: vec![build_site_replication_rule( + "arn:minio:replication::remote:photos", + 1, + "site-repl-remote", + )], + }; + + let config = build_site_replication_config( + "photos", + &state, + &PeerInfo { + deployment_id: "local".to_string(), + ..peer("local", "https://local.example.com") + }, + "runtime-iam-secret", + Some(&existing), + ) + .expect("build site replication config") + .expect("a remote peer yields one rule"); + + assert_eq!(config.rules.len(), 1); + assert_eq!(config.rules[0].destination.bucket, "arn:minio:replication::remote:photos"); +} + +// Issue #1948 review: one pre-contract peer pins an S3 edit to the legacy +// merge; only a cluster where every remote peer answered the probe moves +// to the derived contract. A probe error counts as a pre-contract peer. +#[test] +fn test_operator_rule_contract_requires_every_remote_peer() { + let home = normalize_peer_info(PeerInfo { + endpoint: "https://home.example.com".to_string(), + ..Default::default() + }); + let office = normalize_peer_info(PeerInfo { + endpoint: "https://office.example.com".to_string(), + ..Default::default() + }); + + assert_eq!(operator_rule_contract_from_probes([]), OperatorRuleContract::Derived); + assert_eq!( + operator_rule_contract_from_probes([(&home, Ok(true)), (&office, Ok(true))]), + OperatorRuleContract::Derived + ); + assert_eq!( + operator_rule_contract_from_probes([(&home, Ok(true)), (&office, Ok(false))]), + OperatorRuleContract::Legacy + ); + assert_eq!( + operator_rule_contract_from_probes([(&home, Err(s3_error!(InternalError, "unreachable"))), (&office, Ok(true))]), + OperatorRuleContract::Legacy + ); +} + +// The contract travels with the payload: a pre-contract sender's item has +// no marker and is merged the legacy way; every item this site sends is +// marked, bootstrap snapshots included, so a preserved config is never +// renumbered by a peer on the derived contract. +#[test] +fn test_bucket_meta_items_carry_the_derived_rule_contract() { + let legacy: SRBucketMeta = serde_json::from_str(r#"{"type":"replication-config","bucket":"photos"}"#).expect("item"); + assert!(!legacy.derived_rule_contract); + + let bucket = SRBucketInfo { + bucket: "photos".to_string(), + ..Default::default() + }; + let item = bootstrap_bucket_meta_item(&bucket, "replication-config", None); + assert!(item.derived_rule_contract); + let wire = serde_json::to_value(&item).expect("json"); + assert_eq!(wire["derivedRuleContract"], serde_json::Value::Bool(true)); + assert!(bucket_metadata_snapshot_tombstone(&item, OffsetDateTime::now_utc()).derived_rule_contract); +} + +#[test] +fn test_site_replication_state_does_not_serialize_service_account_secret() { + let state = SiteReplicationState { + service_account_access_key: "site-replicator-0".to_string(), + service_account_secret_key: "do-not-persist".to_string(), + ..Default::default() + }; + + let json = serde_json::to_value(&state).expect("serialize state"); + + assert!(json.get("service_account_secret_key").is_none()); + assert!(json.get("service_account_access_key").is_some()); +} + +#[test] +fn test_pending_rotation_serializes_temporary_secret_until_cleanup() { + let state = SiteReplicationState { + service_account_access_key: SITE_REPLICATOR_SERVICE_ACCOUNT.to_string(), + service_account_secret_key: "do-not-persist".to_string(), + pending_rotation: Some(PendingRotation { + id: "rotation-id".to_string(), + access_key: SITE_REPLICATOR_SERVICE_ACCOUNT.to_string(), + parent: "root".to_string(), + new_secret_key: "temporary-new-secret".to_string(), + secret_candidates: vec!["temporary-old-secret".to_string()], + ..Default::default() + }), + ..Default::default() + }; + + let json = serde_json::to_value(&state).expect("serialize state"); + + assert!(json.get("service_account_secret_key").is_none()); + let pending = json.get("pending_rotation").expect("pending rotation should serialize"); + assert_eq!(pending.get("new_secret_key").and_then(Value::as_str), Some("temporary-new-secret")); + assert!(pending.get("secret_candidates").is_some()); +} + +#[test] +fn test_site_replication_peer_payload_encryption_matches_minio_contract() { + assert!(site_replication_peer_payload_encrypted("/minio/admin/v3/site-replication/peer/join")); + assert!(site_replication_peer_payload_encrypted( + "/minio/admin/v3/site-replication/peer/join?bootstrapToken=token" + )); + // The outbound rewrite no longer produces the legacy `/site-replication/join` + // path; it must not be treated as an encrypted MinIO route. + assert!(!site_replication_peer_payload_encrypted("/minio/admin/v3/site-replication/join")); + assert!(!site_replication_peer_payload_encrypted( + "/minio/admin/v3/site-replication/peer/bucket-meta" + )); + assert!(!site_replication_peer_payload_encrypted("/minio/admin/v3/site-replication/peer/iam-item")); +} + +#[test] +fn test_secret_candidate_retry_only_for_auth_errors() { + assert!(peer_error_may_be_secret_mismatch( + "peer request failed with 403 Forbidden: SignatureDoesNotMatch" + )); + assert!(peer_error_may_be_secret_mismatch("AccessDenied")); + assert!(!peer_error_may_be_secret_mismatch("peer request failed (timeout): deadline elapsed")); + assert!(!peer_error_may_be_secret_mismatch("peer request failed (tls handshake): bad certificate")); +} + +#[test] +fn test_bucket_meta_wire_values_are_base64_encoded_and_legacy_raw_decodes() { + let raw = ""; + let item = encode_bucket_meta_wire_item(SRBucketMeta { + r#type: "version-config".to_string(), + bucket: "photos".to_string(), + versioning: Some(raw.to_string()), + ..Default::default() + }); + + let encoded = item.versioning.expect("encoded versioning config"); + + assert_eq!(decode_bucket_meta_wire_value(&encoded), raw.as_bytes()); + assert_eq!(decode_bucket_meta_wire_value(raw), raw.as_bytes()); + assert_ne!(encoded, raw); +} + +#[test] +fn test_metainfo_bucket_config_values_are_base64_encoded() { + let raw = br#""#; + + assert_eq!(raw_config_to_base64(raw), Some(BASE64_STANDARD.encode_to_string(raw))); + assert_ne!(raw_config_to_base64(raw), raw_config_to_string(raw)); + assert_eq!(raw_config_to_base64(&[]), None); +} + +#[test] +fn test_reconcile_site_replication_bucket_targets_allows_peer_on_same_port_as_local_console() { + with_var("RUSTFS_CONSOLE_ADDRESS", Some(":9001"), || { + let mut state = SiteReplicationState { + service_account_access_key: "site-replicator-0".to_string(), + service_account_secret_key: "secret".to_string(), + ..Default::default() + }; + state.peers.insert( + "local".to_string(), + PeerInfo { + deployment_id: "local".to_string(), + ..peer("local", "https://local.example.com:9000") + }, + ); + state.peers.insert( + "remote".to_string(), + PeerInfo { + deployment_id: "remote".to_string(), + ..peer("remote", "https://remote.example.com:9001") + }, + ); + + let targets = reconcile_site_replication_bucket_targets( + BucketTargets::default(), + "photos", + &state, + &PeerInfo { + deployment_id: "local".to_string(), + ..peer("local", "https://local.example.com:9000") + }, + None, + "secret", + ) + .expect("peer using same numeric port as local console should remain valid"); + + assert_eq!(targets.targets.len(), 1); + let target = &targets.targets[0]; + assert_eq!(target.endpoint, "remote.example.com:9001"); + assert!(target.secure); + }); +} + +#[test] +fn test_hash_client_secret_matches_minio_style_base64url_sha256() { + assert_eq!(hash_client_secret(Some("secret")), "K7gNU3sdo-OL0wNhqoVWhr3g6s1xYv72ol_pe_Unols"); +} + +#[test] +fn test_site_replication_peer_client_cache_hit_generation_mismatch_returns_none() { + let cache = Some(SiteReplicationPeerClientCache { + generation: 7, + entry: SiteReplicationPeerClientCacheEntry::Failed("cached error".to_string()), + }); + + assert!(site_replication_peer_client_cache_hit(&cache, 8).is_none()); +} + +#[test] +fn test_site_replication_peer_client_cache_hit_returns_cached_ready_client() { + let cache = Some(SiteReplicationPeerClientCache { + generation: 7, + entry: SiteReplicationPeerClientCacheEntry::Ready(reqwest::Client::new()), + }); + + site_replication_peer_client_cache_hit(&cache, 7) + .expect("cache hit expected") + .expect("ready cache entry should return cached client"); +} + +#[test] +fn test_site_replication_peer_client_cache_hit_returns_cached_error() { + let cache = Some(SiteReplicationPeerClientCache { + generation: 7, + entry: SiteReplicationPeerClientCacheEntry::Failed("cached error".to_string()), + }); + + let err = site_replication_peer_client_cache_hit(&cache, 7) + .expect("cache hit expected") + .expect_err("error cache entry should return error"); + assert!(err.to_string().contains("cached error"), "expected cached error detail, got: {}", err); +} + +// BUG1: an explicit Disable is a meaningful state and must survive the Unknown -> Enable promotion. +#[test] +fn test_mark_peers_sync_enabled_preserves_disable() { + let mut peers = BTreeMap::new(); + peers.insert( + "a".to_string(), + PeerInfo { + deployment_id: "a".to_string(), + sync_state: SyncStatus::Unknown, + ..peer("a", "https://a.example.com") + }, + ); + peers.insert( + "b".to_string(), + PeerInfo { + deployment_id: "b".to_string(), + sync_state: SyncStatus::Disable, + ..peer("b", "https://b.example.com") + }, + ); + mark_unknown_peer_sync_enabled(&mut peers); + assert_eq!(peers["a"].sync_state, SyncStatus::Enable, "Unknown must be promoted to Enable"); + assert_eq!(peers["b"].sync_state, SyncStatus::Disable, "explicit Disable must be preserved"); +} + +/// rustfs/rustfs#5963: `replicate info` reported a healthy cluster while +/// every peer operation was failing. The health it used to omit now rides +/// along, and a healthy site still serializes without the new fields. +#[test] +fn site_replication_info_health_fields_are_absent_when_healthy() { + let healthy = SiteReplicationInfo { + enabled: true, + name: "site-a".to_string(), + sites: vec![peer("site-a", "https://site-a.example.com")], + service_account_access_key: SITE_REPLICATOR_SERVICE_ACCOUNT.to_string(), + api_version: Some(SITE_REPL_API_VERSION.to_string()), + retry_stats: None, + pending_operation: None, + }; + let value = serde_json::to_value(&healthy).expect("serialize info"); + assert!(value.get("retryStats").is_none(), "a healthy site must not grow fields: {value}"); + assert!(value.get("pendingOperation").is_none(), "a healthy site must not grow fields: {value}"); + + let degraded = SiteReplicationInfo { + retry_stats: Some(SRRetryStats { + pending: 1, + failed: 4, + last_error: "site replication is not enabled".to_string(), + api_version: Some(SITE_REPL_API_VERSION.to_string()), + }), + ..healthy + }; + let value = serde_json::to_value(°raded).expect("serialize info"); + assert_eq!( + value.pointer("/retryStats/failed").and_then(Value::as_u64), + Some(4), + "a source site whose peer rejects everything must say so in `info`" + ); + assert_eq!( + value.pointer("/retryStats/lastError").and_then(Value::as_str), + Some("site replication is not enabled") + ); +} + +// Fix 6: ensure_site_replication_bucket_replication_config must reconcile rather than +// early-return so that a bucket propagated to the second site gets a rule back to the first. +#[test] +fn test_reconcile_adds_missing_peer_rules_to_existing_config() { + // Start with a config that has only rule for dep-b (first site's initial config) + let rule_b = build_site_replication_rule("arn:rustfs:replication::dep-b:bucket", 1, "site-repl-dep-b"); + let rule_c = build_site_replication_rule("arn:rustfs:replication::dep-c:bucket", 2, "site-repl-dep-c"); + + let mut existing_rules = vec![rule_b.clone()]; + + // Desired config has rules for both dep-b and dep-c (3-site setup) + let desired_rules = vec![rule_b, rule_c]; + + // Simulate the reconcile: collect existing site-repl rule IDs + let existing_ids: std::collections::HashSet = existing_rules + .iter() + .filter_map(|r| r.id.as_deref()) + .filter(|id| id.starts_with("site-repl-")) + .map(String::from) + .collect(); + + let mut added = false; + for rule in &desired_rules { + let rid = rule.id.as_deref().unwrap_or(""); + if !existing_ids.contains(rid) { + existing_rules.push(rule.clone()); + added = true; + } + } + + assert!(added, "missing rule should have been added"); + assert_eq!(existing_rules.len(), 2, "should now have rules for both peers"); + + let rule_ids: Vec<&str> = existing_rules.iter().filter_map(|r| r.id.as_deref()).collect(); + assert!(rule_ids.contains(&"site-repl-dep-b")); + assert!(rule_ids.contains(&"site-repl-dep-c")); +} diff --git a/rustfs/src/site_replication/transport.rs b/rustfs/src/site_replication/transport.rs new file mode 100644 index 000000000..bbdb31c77 --- /dev/null +++ b/rustfs/src/site_replication/transport.rs @@ -0,0 +1,966 @@ +// 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. + +use super::*; + +pub(crate) const SITE_REPLICATION_PEER_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); + +pub(crate) const SITE_REPLICATION_PEER_CONNECT_TIMEOUT: Duration = Duration::from_secs(3); + +pub(crate) const SITE_REPLICATION_PEER_ERROR_DETAIL_LIMIT: usize = 256; + +pub(crate) const MAX_PEER_CA_CERT_PEM_SIZE: usize = 256 * 1024; + +pub(crate) const ALLOW_LOOPBACK_REPLICATION_TARGET_ENV: &str = "RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET"; + +pub(crate) const SITE_REPLICATION_PEER_DERIVED_RULE_CONTRACT_CAPABILITY_PATH: &str = + "/rustfs/admin/v3/site-replication/peer/edit-capabilities?capability=derived-rule-contract"; + +pub(crate) const RUSTFS_ADMIN_V3_PREFIX: &str = "/rustfs/admin/v3"; + +pub(crate) const MINIO_ADMIN_V3_PREFIX: &str = "/minio/admin/v3"; + +pub(crate) const MINIO_SITE_REPLICATION_PEER_JOIN_PATH: &str = "/minio/admin/v3/site-replication/peer/join"; + +#[derive(Clone)] +pub(crate) enum SiteReplicationPeerClientCacheEntry { + Ready(reqwest::Client), + Failed(String), +} + +#[derive(Clone)] +pub(crate) struct SiteReplicationPeerClientCache { + pub(crate) generation: u64, + pub(crate) entry: SiteReplicationPeerClientCacheEntry, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PeerConnection { + pub(crate) endpoint: Url, + pub(crate) skip_tls_verify: bool, + pub(crate) ca_cert_pem: String, +} + +#[derive(Deserialize, Default)] +pub(crate) struct PeerTlsFieldPresence { + #[serde(rename = "skipTlsVerify")] + pub(crate) skip_tls_verify: Option, + #[serde(rename = "caCertPem")] + pub(crate) ca_cert_pem: Option, +} + +impl PeerTlsFieldPresence { + pub(crate) fn has_skip_tls_verify(&self) -> bool { + self.skip_tls_verify.is_some() + } + + pub(crate) fn has_ca_cert_pem(&self) -> bool { + self.ca_cert_pem.is_some() + } +} + +#[derive(Clone)] +pub(crate) struct PeerDnsResolver { + pub(crate) allow_loopback: bool, + #[cfg(test)] + pub(crate) overrides: Option>>>, +} + +impl PeerDnsResolver { + pub(crate) fn new(allow_loopback: bool) -> Self { + Self { + allow_loopback, + #[cfg(test)] + overrides: None, + } + } + + #[cfg(test)] + pub(crate) fn with_overrides(allow_loopback: bool, overrides: HashMap>) -> Self { + Self { + allow_loopback, + overrides: Some(Arc::new(overrides)), + } + } +} + +impl reqwest::dns::Resolve for PeerDnsResolver { + fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving { + let host = name.as_str().to_string(); + let allow_loopback = self.allow_loopback; + #[cfg(test)] + let overrides = self.overrides.clone(); + Box::pin(async move { + #[cfg(test)] + let overridden = overrides.as_ref().and_then(|entries| entries.get(&host)).cloned(); + #[cfg(not(test))] + let overridden: Option> = None; + + let ips = if let Some(ips) = overridden { + ips + } else { + tokio::net::lookup_host((host.as_str(), 0)) + .await? + .map(|addr| addr.ip()) + .collect() + }; + let addrs = ips + .into_iter() + .filter(|ip| resolved_peer_ip_allowed(&host, *ip, allow_loopback)) + .map(|ip| SocketAddr::new(ip, 0)) + .collect::>(); + if addrs.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!("site replication DNS resolution for `{host}` returned no allowed addresses"), + ) + .into()); + } + Ok(Box::new(addrs.into_iter()) as reqwest::dns::Addrs) + }) + } +} + +impl PeerConnection { + pub(crate) fn new(endpoint: &str, skip_tls_verify: bool, ca_cert_pem: &str) -> S3Result { + validate_peer_connection_inner(endpoint, skip_tls_verify, ca_cert_pem, loopback_replication_targets_allowed()) + } + + pub(crate) fn endpoint(&self) -> &str { + self.endpoint.as_str().trim_end_matches('/') + } + + pub(crate) fn uses_default_tls(&self) -> bool { + !self.skip_tls_verify && self.ca_cert_pem.is_empty() + } +} + +impl TryFrom<&PeerInfo> for PeerConnection { + type Error = S3Error; + + fn try_from(peer: &PeerInfo) -> Result { + Self::new(&peer.endpoint, peer.skip_tls_verify, &peer.ca_cert_pem) + } +} + +impl TryFrom<&PeerSite> for PeerConnection { + type Error = S3Error; + + fn try_from(site: &PeerSite) -> Result { + Self::new(&site.endpoint, site.skip_tls_verify, &site.ca_cert_pem) + } +} + +static SITE_REPLICATION_PEER_CLIENT: LazyLock>> = LazyLock::new(|| Mutex::new(None)); + +pub(crate) fn site_replication_peer_client_cache_hit( + cache: &Option, + generation: u64, +) -> Option> { + let cached = cache.as_ref()?; + if cached.generation != generation { + return None; + } + Some(match &cached.entry { + SiteReplicationPeerClientCacheEntry::Ready(client) => Ok(client.clone()), + SiteReplicationPeerClientCacheEntry::Failed(err) => Err(S3Error::with_message( + S3ErrorCode::InternalError, + format!("initialize site replication peer client failed: {err}"), + )), + }) +} + +pub(crate) struct SiteReplicationRuntime { + pub(crate) state: SiteReplicationState, + pub(crate) local_peer: PeerInfo, + pub(crate) service_account_secret_key: String, +} + +pub(crate) fn build_site_replication_peer_client(outbound_tls: &GlobalPublishedOutboundTlsState) -> S3Result { + build_site_replication_peer_client_with_resolver(outbound_tls, PeerDnsResolver::new(loopback_replication_targets_allowed())) +} + +pub(crate) fn build_site_replication_peer_client_with_resolver( + outbound_tls: &GlobalPublishedOutboundTlsState, + resolver: PeerDnsResolver, +) -> S3Result { + let mut builder = reqwest::Client::builder() + .no_proxy() + .timeout(SITE_REPLICATION_PEER_REQUEST_TIMEOUT) + .connect_timeout(SITE_REPLICATION_PEER_CONNECT_TIMEOUT) + .pool_idle_timeout(Some(Duration::from_secs(60))) + .redirect(reqwest::redirect::Policy::none()) + .dns_resolver(resolver); + + if let Some(root_ca_pem) = outbound_tls.root_ca_pem.as_ref() { + let mut reader = std::io::BufReader::new(root_ca_pem.as_slice()); + let certs_der = rustls_pki_types::CertificateDer::pem_reader_iter(&mut reader) + .collect::, _>>() + .map_err(|e| { + S3Error::with_message( + S3ErrorCode::InternalError, + format!("failed to parse published site-replication CA certs: {e}"), + ) + })?; + + for cert_der in certs_der { + let cert = reqwest::Certificate::from_der(cert_der.as_ref()).map_err(|e| { + S3Error::with_message( + S3ErrorCode::InternalError, + format!("failed to load published site-replication CA cert: {e}"), + ) + })?; + builder = builder.add_root_certificate(cert); + } + } + + builder + .build() + .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("build site replication peer client failed: {e}"))) +} + +pub(crate) fn build_custom_site_replication_peer_client( + outbound_tls: &GlobalPublishedOutboundTlsState, + connection: &PeerConnection, +) -> S3Result { + build_custom_site_replication_peer_client_with_resolver( + outbound_tls, + connection, + PeerDnsResolver::new(loopback_replication_targets_allowed()), + ) +} + +pub(crate) fn build_custom_site_replication_peer_client_with_resolver( + outbound_tls: &GlobalPublishedOutboundTlsState, + connection: &PeerConnection, + resolver: PeerDnsResolver, +) -> S3Result { + let mut builder = reqwest::Client::builder() + .no_proxy() + .timeout(SITE_REPLICATION_PEER_REQUEST_TIMEOUT) + .connect_timeout(SITE_REPLICATION_PEER_CONNECT_TIMEOUT) + .pool_idle_timeout(Some(Duration::from_secs(60))) + .redirect(reqwest::redirect::Policy::none()) + .dns_resolver(resolver) + .danger_accept_invalid_certs(connection.skip_tls_verify); + + if let Some(root_ca_pem) = outbound_tls.root_ca_pem.as_ref() { + let mut reader = std::io::BufReader::new(root_ca_pem.as_slice()); + let certs_der = rustls_pki_types::CertificateDer::pem_reader_iter(&mut reader) + .collect::, _>>() + .map_err(|e| { + S3Error::with_message( + S3ErrorCode::InternalError, + format!("failed to parse published site-replication CA certs: {e}"), + ) + })?; + for cert_der in certs_der { + let cert = reqwest::Certificate::from_der(cert_der.as_ref()).map_err(|e| { + S3Error::with_message( + S3ErrorCode::InternalError, + format!("failed to load published site-replication CA cert: {e}"), + ) + })?; + builder = builder.add_root_certificate(cert); + } + } + if !connection.ca_cert_pem.is_empty() { + for cert in parse_peer_ca_certificates(&connection.ca_cert_pem)? { + builder = builder.add_root_certificate(cert); + } + } + + builder + .build() + .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("build site replication peer client failed: {e}"))) +} + +pub(crate) async fn site_replication_peer_client() -> S3Result { + let generation = current_outbound_tls_generation().0; + let cache = SITE_REPLICATION_PEER_CLIENT.lock().await; + if let Some(hit) = site_replication_peer_client_cache_hit(&cache, generation) { + return hit; + } + drop(cache); + + let outbound_tls = current_outbound_tls_state().await; + let built = build_site_replication_peer_client(&outbound_tls); + let cache_entry = match &built { + Ok(client) => SiteReplicationPeerClientCacheEntry::Ready(client.clone()), + Err(err) => SiteReplicationPeerClientCacheEntry::Failed(err.to_string()), + }; + + let mut cache = SITE_REPLICATION_PEER_CLIENT.lock().await; + if cache.as_ref().is_none_or(|cached| cached.generation <= generation) { + *cache = Some(SiteReplicationPeerClientCache { + generation, + entry: cache_entry, + }); + } + + built +} + +pub(crate) async fn site_replication_client_for(connection: &PeerConnection) -> S3Result { + // Revalidate at the client boundary so callers cannot bypass endpoint/TLS policy. + let connection = PeerConnection::new(connection.endpoint(), connection.skip_tls_verify, &connection.ca_cert_pem)?; + if connection.uses_default_tls() { + return site_replication_peer_client().await; + } + let outbound_tls = current_outbound_tls_state().await; + build_custom_site_replication_peer_client(&outbound_tls, &connection) +} + +pub(crate) fn runtime_peer_connection(peer: &PeerInfo) -> S3Result { + PeerConnection::try_from(peer).map_err(|err| { + S3Error::with_message( + S3ErrorCode::InternalError, + format!("invalid persisted site replication peer `{}`: {err}", peer.endpoint), + ) + }) +} + +pub(crate) struct PeerTransport { + pub(crate) connection: PeerConnection, + pub(crate) client: reqwest::Client, +} + +impl PeerTransport { + pub(crate) async fn for_runtime_peer(peer: &PeerInfo) -> S3Result { + let connection = runtime_peer_connection(peer)?; + let client = site_replication_client_for(&connection).await.map_err(|err| { + S3Error::with_message( + S3ErrorCode::InternalError, + format!("initialize persisted site replication peer `{}` transport failed: {err}", peer.endpoint), + ) + })?; + Ok(Self { connection, client }) + } +} + +pub(crate) fn runtime_tls_enabled_with(endpoints: Option<&crate::storage_api::site_replication::EndpointServerPools>) -> bool { + if !rustfs_utils::get_env_str(ENV_RUSTFS_TLS_PATH, DEFAULT_RUSTFS_TLS_PATH).is_empty() { + return true; + } + + if let Some(tls_enabled) = endpoints.and_then(|endpoints| { + endpoints + .as_ref() + .iter() + .flat_map(|pool| pool.endpoints.as_ref().iter()) + .find(|endpoint| endpoint.is_local) + .map(|endpoint| endpoint.url.scheme().eq_ignore_ascii_case("https")) + }) { + return tls_enabled; + } + + false +} + +pub(crate) fn runtime_tls_enabled() -> bool { + let endpoints = current_endpoints_handle(); + runtime_tls_enabled_with(endpoints.as_ref()) +} + +pub(crate) fn hash_client_secret(secret: Option<&str>) -> String { + let Some(secret) = secret.filter(|secret| !secret.is_empty()) else { + return String::new(); + }; + + let mut hasher = Sha256::new(); + hasher.update(secret.as_bytes()); + URL_SAFE_NO_PAD.encode_to_string(hasher.finalize()) +} + +pub(crate) fn loopback_replication_targets_allowed() -> bool { + std::env::var(ALLOW_LOOPBACK_REPLICATION_TARGET_ENV) + .map(|value| value.eq_ignore_ascii_case("true") || value == "1") + .unwrap_or(false) +} + +pub(crate) fn validate_peer_egress(url: &Url, allow_loopback: bool) -> Result<(), OutboundUrlError> { + match validate_outbound_url(url) { + Ok(()) => Ok(()), + Err(OutboundUrlError::ForbiddenHost { + reason: "private address", + .. + }) => Ok(()), + Err(OutboundUrlError::ForbiddenHost { + reason: "loopback address" | "loopback host", + .. + }) if allow_loopback && peer_url_has_canonical_loopback_host(url) => Ok(()), + Err(err) => Err(err), + } +} + +pub(crate) fn peer_url_has_canonical_loopback_host(url: &Url) -> bool { + match url.host() { + Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), + Some(url::Host::Ipv4(ip)) => ip == std::net::Ipv4Addr::LOCALHOST, + Some(url::Host::Ipv6(ip)) => ip == std::net::Ipv6Addr::LOCALHOST, + None => false, + } +} + +pub(crate) fn resolved_peer_ip_allowed(host: &str, ip: IpAddr, allow_loopback: bool) -> bool { + let Ok(ip_url) = (match ip { + IpAddr::V4(ip) => Url::parse(&format!("http://{ip}")), + IpAddr::V6(ip) => Url::parse(&format!("http://[{ip}]")), + }) else { + return false; + }; + match validate_outbound_url(&ip_url) { + Ok(()) => true, + Err(OutboundUrlError::ForbiddenHost { + reason: "private address", + .. + }) => true, + Err(OutboundUrlError::ForbiddenHost { + reason: "loopback address", + .. + }) => { + allow_loopback + && host.eq_ignore_ascii_case("localhost") + && matches!(ip, IpAddr::V4(std::net::Ipv4Addr::LOCALHOST) | IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)) + } + Err(_) => false, + } +} + +pub(crate) fn parse_peer_ca_certificates(ca_cert_pem: &str) -> S3Result> { + if ca_cert_pem.len() > MAX_PEER_CA_CERT_PEM_SIZE { + return Err(s3_error!(InvalidRequest, "site replication CA certificate exceeds 256 KiB")); + } + if ca_cert_pem.contains("PRIVATE KEY-----") { + return Err(s3_error!( + InvalidRequest, + "site replication CA certificate must not contain a private key" + )); + } + + let mut reader = std::io::BufReader::new(ca_cert_pem.as_bytes()); + let certs_der = rustls_pki_types::CertificateDer::pem_reader_iter(&mut reader) + .collect::, _>>() + .map_err(|e| { + S3Error::with_message(S3ErrorCode::InvalidRequest, format!("invalid site replication CA certificate: {e}")) + })?; + if certs_der.is_empty() { + return Err(s3_error!( + InvalidRequest, + "site replication CA certificate must contain at least one certificate" + )); + } + + let mut root_store = rustls::RootCertStore::empty(); + certs_der + .into_iter() + .map(|cert| { + root_store.add(cert.clone()).map_err(|e| { + S3Error::with_message(S3ErrorCode::InvalidRequest, format!("invalid site replication CA certificate: {e}")) + })?; + reqwest::Certificate::from_der(cert.as_ref()).map_err(|e| { + S3Error::with_message(S3ErrorCode::InvalidRequest, format!("invalid site replication CA certificate: {e}")) + }) + }) + .collect() +} + +pub(crate) fn validate_peer_connection_inner( + endpoint: &str, + skip_tls_verify: bool, + ca_cert_pem: &str, + allow_loopback: bool, +) -> S3Result { + let parsed = Url::parse(endpoint) + .map_err(|e| S3Error::with_message(S3ErrorCode::InvalidRequest, format!("invalid site endpoint `{endpoint}`: {e}")))?; + match parsed.scheme() { + "http" | "https" => {} + scheme => { + return Err(S3Error::with_message( + S3ErrorCode::InvalidRequest, + format!("invalid site endpoint `{endpoint}`: unsupported scheme `{scheme}`"), + )); + } + } + if parsed.host_str().is_none() { + return Err(S3Error::with_message( + S3ErrorCode::InvalidRequest, + format!("invalid site endpoint `{endpoint}`: missing host"), + )); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(s3_error!(InvalidRequest, "invalid site endpoint `{endpoint}`: userinfo is not allowed")); + } + if parsed.path() != "/" || parsed.query().is_some() || parsed.fragment().is_some() { + return Err(s3_error!( + InvalidRequest, + "invalid site endpoint `{endpoint}`: endpoint must be an origin" + )); + } + validate_peer_egress(&parsed, allow_loopback) + .map_err(|e| S3Error::with_message(S3ErrorCode::InvalidRequest, format!("invalid site endpoint `{endpoint}`: {e}")))?; + + if ca_cert_pem.len() > MAX_PEER_CA_CERT_PEM_SIZE { + return Err(s3_error!(InvalidRequest, "site replication CA certificate exceeds 256 KiB")); + } + let ca_cert_pem = ca_cert_pem.trim(); + if parsed.scheme() != "https" && (skip_tls_verify || !ca_cert_pem.is_empty()) { + return Err(s3_error!(InvalidRequest, "site replication TLS settings require an HTTPS endpoint")); + } + if skip_tls_verify && !ca_cert_pem.is_empty() { + return Err(s3_error!(InvalidRequest, "skipTLSVerify and caCertPem are mutually exclusive")); + } + if !ca_cert_pem.is_empty() { + parse_peer_ca_certificates(ca_cert_pem)?; + } + + Ok(PeerConnection { + endpoint: parsed, + skip_tls_verify, + ca_cert_pem: ca_cert_pem.to_string(), + }) +} + +pub(crate) fn site_replication_peer_wire_path(path: &str) -> String { + let (path_only, query) = path + .split_once('?') + .map(|(path, query)| (path, Some(query))) + .unwrap_or((path, None)); + let wire_path = if let Some(suffix) = path_only.strip_prefix(RUSTFS_ADMIN_V3_PREFIX) { + format!("{MINIO_ADMIN_V3_PREFIX}{suffix}") + } else { + path_only.to_string() + }; + + match query { + Some(query) => format!("{wire_path}?{query}"), + None => wire_path, + } +} + +pub(crate) fn site_replication_peer_payload_encrypted(wire_path: &str) -> bool { + // MinIO's SRPeerJoin handler force-decrypts the request body, so the + // peer/join payload must always travel encrypted. + wire_path.split_once('?').map(|(path, _)| path).unwrap_or(wire_path) == MINIO_SITE_REPLICATION_PEER_JOIN_PATH +} + +pub(crate) fn site_replication_peer_payload(path: &str, secret_key: &str, payload: Vec) -> S3Result<(Vec, &'static str)> { + if site_replication_peer_payload_encrypted(path) { + // The encrypted branch fires only for the `/minio/admin/...` peer-join + // wire path, where `crate::admin::utils::encode_compatible_admin_payload` + // unconditionally takes its compat-encryption arm — inlined here so + // this module does not import the interface layer. + let encrypted = rustfs_crypto::encrypt_stream_io(secret_key.as_bytes(), &payload) + .map_err(|e| s3_error!(InternalError, "failed to encrypt MinIO admin payload: {}", e))?; + Ok((encrypted, "application/octet-stream")) + } else { + Ok((payload, "application/json")) + } +} + +pub(crate) fn site_replication_peer_url(connection: &PeerConnection, wire_path: &str) -> S3Result { + let path = wire_path.split_once('?').map_or(wire_path, |(path, _)| path); + if !path.starts_with('/') || path.starts_with("//") { + return Err(s3_error!(InvalidRequest, "invalid site replication peer path")); + } + connection + .endpoint + .join(wire_path) + .map_err(|e| S3Error::with_message(S3ErrorCode::InvalidRequest, format!("invalid site replication peer path: {e}"))) +} + +/// One peer admin request, collapsing the formerly-duplicated PUT/GET +/// dispatch bodies (backlog#1840 PR2): wire-path mapping, URL/authority +/// derivation, payload wire-encoding, SigV4 signing, transport-error +/// classification, and the response read live here once. The option axes are +/// the method, an optional pre-resolved client, the service-account secret +/// candidates, and the retry-event bookkeeping. +pub(crate) struct PeerAdminRequest<'a> { + connection: &'a PeerConnection, + path: &'a str, + access_key: &'a str, + method: Method, + client: Option<&'a reqwest::Client>, +} + +impl<'a> PeerAdminRequest<'a> { + pub(crate) fn put(connection: &'a PeerConnection, path: &'a str, access_key: &'a str) -> Self { + Self { + connection, + path, + access_key, + method: Method::PUT, + client: None, + } + } + + pub(crate) fn get(connection: &'a PeerConnection, path: &'a str, access_key: &'a str) -> Self { + Self { + method: Method::GET, + ..Self::put(connection, path, access_key) + } + } + + pub(crate) fn with_client(mut self, client: &'a reqwest::Client) -> Self { + self.client = Some(client); + self + } + + async fn resolved_client(&self) -> S3Result { + match self.client { + Some(client) => Ok(client.clone()), + None => site_replication_client_for(self.connection).await, + } + } + + /// Send and return the raw status/body without a success check. `body` is + /// the JSON payload of a `PUT` (serialized and wire-encoded here, which + /// is where the peer-join encryption applies); `None` sends a bodiless + /// request (the `GET` flavor). + pub(crate) async fn send_raw(&self, secret_key: &str, body: Option<&T>) -> S3Result<(StatusCode, Vec)> { + let client = self.resolved_client().await?; + let path = site_replication_peer_wire_path(self.path); + let url = site_replication_peer_url(self.connection, &path)?; + let uri = url + .as_str() + .parse::() + .map_err(|e| S3Error::with_message(S3ErrorCode::InvalidRequest, format!("invalid peer endpoint: {e}")))?; + let authority = uri + .authority() + .ok_or_else(|| S3Error::with_message(S3ErrorCode::InvalidRequest, "peer endpoint missing authority".to_string()))? + .to_string(); + let payload = body + .map(|body| { + let payload = serde_json::to_vec(body).map_err(|e| { + S3Error::with_message(S3ErrorCode::InternalError, format!("serialize peer request failed: {e}")) + })?; + site_replication_peer_payload(&path, secret_key, payload) + }) + .transpose()?; + + let mut request = http::Request::builder() + .method(self.method.clone()) + .uri(uri) + .header(HOST, authority) + .header("x-amz-content-sha256", UNSIGNED_PAYLOAD); + if let Some((_, content_type)) = &payload { + request = request.header(CONTENT_TYPE, *content_type); + } + let signed = sign_v4( + request + .body(Body::empty()) + .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("build peer request failed: {e}")))?, + payload.as_ref().map(|(payload, _)| payload.len() as i64).unwrap_or(0), + self.access_key, + secret_key, + "", + current_region() + .map(|region| region.to_string()) + .as_deref() + .unwrap_or("us-east-1"), + ); + + let mut req = client.request(self.method.clone(), url.clone()); + for (name, value) in signed.headers() { + req = req.header(name, value); + } + if let Some((payload, _)) = payload { + req = req.body(payload); + } + + let response = req.send().await.map_err(|e| { + let classify = if e.is_timeout() { + "timeout" + } else if e.is_connect() && e.to_string().to_ascii_lowercase().contains("dns") { + "dns resolution" + } else if e.to_string().to_ascii_lowercase().contains("certificate") + || e.to_string().to_ascii_lowercase().contains("tls") + { + "tls handshake" + } else if e.is_connect() { + "connect" + } else { + "request" + }; + S3Error::with_message(S3ErrorCode::InternalError, format!("peer request to {url} failed ({classify}): {e}")) + })?; + + let status = response.status(); + let body = response + .bytes() + .await + .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("read peer response failed: {e}")))?; + + Ok((status, body.to_vec())) + } + + /// `PUT` with a success check; a non-success status becomes an error + /// naming the peer endpoint and the caller's path. + pub(crate) async fn send(&self, secret_key: &str, body: &T) -> S3Result> { + let (status, body) = self.send_raw(secret_key, Some(body)).await?; + if status.is_success() { + return Ok(body); + } + + let detail = String::from_utf8_lossy(&body).into_owned(); + Err(S3Error::with_message( + S3ErrorCode::InternalError, + format!( + "peer request to {}{} failed with {status}: {detail}", + self.connection.endpoint(), + self.path + ), + )) + } + + /// Bodiless `GET` with a success check; a non-success status becomes an + /// error naming the resolved wire URL. + pub(crate) async fn send_get(&self, secret_key: &str) -> S3Result> { + let (status, body) = self.send_raw::<()>(secret_key, None).await?; + if !status.is_success() { + let url = site_replication_peer_url(self.connection, &site_replication_peer_wire_path(self.path))?; + let detail = String::from_utf8_lossy(&body).into_owned(); + return Err(S3Error::with_message( + S3ErrorCode::InternalError, + format!("peer request to {url} failed with {status}: {detail}"), + )); + } + + Ok(body) + } + + /// Try each distinct non-empty service-account secret until one is + /// accepted; stop early on an error that cannot be a secret mismatch. + pub(crate) async fn send_with_secret_candidates( + &self, + secret_candidates: &[String], + body: &T, + ) -> S3Result> { + let client = self.resolved_client().await?; + let request = PeerAdminRequest { + connection: self.connection, + path: self.path, + access_key: self.access_key, + method: self.method.clone(), + client: Some(&client), + }; + let mut tried = HashSet::new(); + let mut errors = Vec::new(); + + for secret_key in secret_candidates.iter().filter(|secret_key| !secret_key.is_empty()) { + if !tried.insert(secret_key.as_str()) { + continue; + } + + match request.send(secret_key, body).await { + Ok(body) => return Ok(body), + Err(err) => { + let detail = format!("{err}"); + let may_retry_with_next_secret = peer_error_may_be_secret_mismatch(&detail); + errors.push(summarize_peer_error_detail(&detail)); + if !may_retry_with_next_secret { + break; + } + } + } + } + + Err(S3Error::with_message( + S3ErrorCode::InternalError, + format!( + "peer request to {}{} failed with all service-account secrets: {}", + self.connection.endpoint(), + self.path, + errors.join("; ") + ), + )) + } + + /// [`Self::send`] plus the retry-queue bookkeeping: a success settles the + /// peer/path's queued event, a failure enqueues one. + pub(crate) async fn send_with_retry_event( + &self, + peer: &PeerInfo, + secret_key: &str, + body: &T, + ) -> S3Result> { + match self.send(secret_key, body).await { + Ok(body) => { + dequeue_site_replication_retry_event(peer, self.path).await; + Ok(body) + } + Err(err) => { + enqueue_site_replication_retry_event(peer, self.path, &err).await; + Err(err) + } + } + } +} + +pub(crate) fn peer_error_may_be_secret_mismatch(detail: &str) -> bool { + let detail = detail.to_ascii_lowercase(); + detail.contains("signaturedoesnotmatch") + || detail.contains("accessdenied") + || detail.contains("forbidden") + || detail.contains("401") + || detail.contains("403") +} + +pub(crate) async fn runtime_site_replication_targets() -> S3Result> { + let state = load_site_replication_state().await?; + if !state.enabled() || state.service_account_access_key.is_empty() { + return Ok(None); + } + + let service_account_secret_key = match site_replicator_service_account_secret(&state.service_account_access_key).await { + Ok(secret) => secret, + Err(err) => { + let Some(secret) = legacy_site_replicator_state_secret(&state) else { + return Err(err); + }; + warn!( + event = EVENT_ADMIN_SITE_REPLICATION_STATE, + component = LOG_COMPONENT_ADMIN, + subsystem = LOG_SUBSYSTEM_SITE_REPLICATION, + result = "legacy_state_service_account_secret_fallback", + error = ?err, + "admin site replication state" + ); + secret + } + }; + let local_peer = current_local_runtime_peer(&state); + Ok(Some(SiteReplicationRuntime { + state, + local_peer, + service_account_secret_key, + })) +} + +pub(crate) async fn broadcast_site_replication_json(path: &str, body: &T) -> S3Result<()> { + let Some(runtime) = runtime_site_replication_targets().await? else { + return Ok(()); + }; + broadcast_site_replication_json_with_runtime(&runtime, path, body).await +} + +pub(crate) async fn broadcast_site_replication_json_with_runtime( + runtime: &SiteReplicationRuntime, + path: &str, + body: &T, +) -> S3Result<()> { + let state = &runtime.state; + let local_peer = &runtime.local_peer; + + for peer in state.peers.values() { + if peer.deployment_id == local_peer.deployment_id || same_identity_endpoint(&peer.endpoint, &local_peer.endpoint) { + continue; + } + + let transport = PeerTransport::for_runtime_peer(peer).await?; + PeerAdminRequest::put(&transport.connection, path, &state.service_account_access_key) + .with_client(&transport.client) + .send_with_retry_event(peer, &runtime.service_account_secret_key, body) + .await?; + } + + Ok(()) +} + +pub(crate) fn parse_endpoint_refresh_status(peer: &PeerInfo, body: &[u8]) -> S3Result<()> { + let status: ReplicateEditStatus = serde_json::from_slice(body).map_err(|_| { + S3Error::with_message( + S3ErrorCode::InternalError, + format!("peer {} does not support endpoint target refresh", peer.endpoint), + ) + })?; + if status.success { + Ok(()) + } else { + Err(S3Error::with_message( + S3ErrorCode::InternalError, + format!("peer {} failed endpoint target refresh: {}", peer.endpoint, status.err_detail), + )) + } +} + +pub(crate) fn peer_capability_response_supported(peer: &PeerInfo, status: StatusCode, body: &[u8]) -> S3Result { + if status.is_success() { + return Ok(parse_endpoint_refresh_status(peer, body).is_ok()); + } + if matches!(status, StatusCode::BAD_REQUEST | StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED) { + return Ok(false); + } + + Err(S3Error::with_message( + S3ErrorCode::InternalError, + format!("probe site replication capability on peer {} failed with {status}", peer.endpoint), + )) +} + +pub(crate) fn summarize_peer_error_detail(detail: &str) -> String { + let detail = detail.trim(); + let detail_chars = detail.chars().count(); + if detail_chars <= SITE_REPLICATION_PEER_ERROR_DETAIL_LIMIT { + return detail.to_string(); + } + + let suffix = "... (truncated)"; + let take_chars = SITE_REPLICATION_PEER_ERROR_DETAIL_LIMIT.saturating_sub(suffix.chars().count()); + let mut summary: String = detail.chars().take(take_chars).collect(); + summary.push_str(suffix); + summary +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[tokio::test] + #[serial] + async fn test_site_replication_peer_client_rebuilds_when_generation_changes() { + let previous_generation = current_outbound_tls_generation().0; + let previous_cache = { + let mut cache = SITE_REPLICATION_PEER_CLIENT.lock().await; + let snapshot = cache.clone(); + *cache = None; + snapshot + }; + + set_test_outbound_tls_generation(101); + site_replication_peer_client() + .await + .expect("initial client build should succeed"); + let cache = SITE_REPLICATION_PEER_CLIENT.lock().await; + let cached = cache.as_ref().expect("cache should be populated"); + assert_eq!(cached.generation, 101); + assert!(matches!(cached.entry, SiteReplicationPeerClientCacheEntry::Ready(_))); + drop(cache); + + set_test_outbound_tls_generation(102); + site_replication_peer_client() + .await + .expect("new generation should rebuild client"); + let cache = SITE_REPLICATION_PEER_CLIENT.lock().await; + let cached = cache.as_ref().expect("cache should be populated"); + assert_eq!(cached.generation, 102); + assert!(matches!(cached.entry, SiteReplicationPeerClientCacheEntry::Ready(_))); + + drop(cache); + set_test_outbound_tls_generation(previous_generation); + let mut cache = SITE_REPLICATION_PEER_CLIENT.lock().await; + *cache = previous_cache; + } +} diff --git a/rustfs/src/startup_bucket_metadata.rs b/rustfs/src/startup_bucket_metadata.rs index 33e90cb1b..dec3f50c1 100644 --- a/rustfs/src/startup_bucket_metadata.rs +++ b/rustfs/src/startup_bucket_metadata.rs @@ -20,12 +20,31 @@ use crate::storage_api::startup::bucket_metadata::{ use std::{ io::{Error as IoError, Result as IoResult}, sync::Arc, + time::{Duration, Instant}, }; use tokio_util::sync::CancellationToken; +const EVENT_REPLICATION_RESYNC_STARTUP_BACKGROUND_CANCELED: &str = "replication_resync_startup_background_canceled"; +const EVENT_REPLICATION_RESYNC_STARTUP_BACKGROUND_COMPLETED: &str = "replication_resync_startup_background_completed"; const EVENT_REPLICATION_RESYNC_STARTUP_BACKGROUND_FAILED: &str = "replication_resync_startup_background_failed"; +const EVENT_REPLICATION_RESYNC_STARTUP_BACKGROUND_STARTED: &str = "replication_resync_startup_background_started"; const LOG_COMPONENT_STARTUP_BUCKET_METADATA: &str = "startup_bucket_metadata"; const LOG_SUBSYSTEM_REPLICATION: &str = "replication"; +const METRIC_REPLICATION_RESYNC_STARTUP_BACKGROUND_DURATION_SECONDS: &str = + "rustfs_replication_resync_startup_background_duration_seconds"; +const METRIC_REPLICATION_RESYNC_STARTUP_BACKGROUND_EVENTS_TOTAL: &str = + "rustfs_replication_resync_startup_background_events_total"; +const METRIC_REPLICATION_RESYNC_STARTUP_BACKGROUND_STATUS: &str = "rustfs_replication_resync_startup_background_status"; +const STARTUP_BACKGROUND_MODE_EMBEDDED: &str = "embedded"; +const STARTUP_BACKGROUND_MODE_SERVER: &str = "server"; +const STARTUP_BACKGROUND_OUTCOME_CANCELED: &str = "canceled"; +const STARTUP_BACKGROUND_OUTCOME_FAILED: &str = "failed"; +const STARTUP_BACKGROUND_OUTCOME_STARTED: &str = "started"; +const STARTUP_BACKGROUND_OUTCOME_SUCCEEDED: &str = "succeeded"; +const STARTUP_BACKGROUND_STATUS_FAILED: f64 = 0.0; +const STARTUP_BACKGROUND_STATUS_SUCCEEDED: f64 = 1.0; +const STARTUP_BACKGROUND_STATUS_RUNNING: f64 = 2.0; +const STARTUP_BACKGROUND_STATUS_CANCELED: f64 = 3.0; pub(crate) async fn init_embedded_bucket_metadata_runtime(store: Arc, ctx: &CancellationToken) -> IoResult> { let buckets_list = store @@ -68,23 +87,127 @@ pub(crate) async fn init_bucket_metadata_runtime(store: Arc, ctx: Cance fn spawn_bucket_resync_startup_reconcile(buckets: Vec, ctx: CancellationToken, init_resync_after_reconcile: bool) { tokio::spawn(async move { + describe_bucket_resync_startup_background_metrics(); + let bucket_count = buckets.len(); + let mode = bucket_resync_startup_background_mode(init_resync_after_reconcile); + let started = Instant::now(); + + record_bucket_resync_startup_background_started(mode); + tracing::info!( + event = EVENT_REPLICATION_RESYNC_STARTUP_BACKGROUND_STARTED, + component = LOG_COMPONENT_STARTUP_BUCKET_METADATA, + subsystem = LOG_SUBSYSTEM_REPLICATION, + state = STARTUP_BACKGROUND_OUTCOME_STARTED, + mode, + bucket_count, + init_resync_after_reconcile, + "Bucket metadata startup resync reconcile started in background" + ); + if let Err(error) = run_bucket_resync_startup_reconcile(buckets, ctx, init_resync_after_reconcile).await { if !report_bucket_resync_startup_background_error(&error) { + record_bucket_resync_startup_background_finished(mode, STARTUP_BACKGROUND_OUTCOME_CANCELED, started.elapsed()); + tracing::debug!( + event = EVENT_REPLICATION_RESYNC_STARTUP_BACKGROUND_CANCELED, + component = LOG_COMPONENT_STARTUP_BUCKET_METADATA, + subsystem = LOG_SUBSYSTEM_REPLICATION, + result = STARTUP_BACKGROUND_OUTCOME_CANCELED, + mode, + bucket_count, + init_resync_after_reconcile, + duration_ms = started.elapsed().as_millis() as u64, + "Bucket metadata startup resync reconcile canceled during shutdown" + ); return; } + record_bucket_resync_startup_background_finished(mode, STARTUP_BACKGROUND_OUTCOME_FAILED, started.elapsed()); tracing::error!( event = EVENT_REPLICATION_RESYNC_STARTUP_BACKGROUND_FAILED, component = LOG_COMPONENT_STARTUP_BUCKET_METADATA, subsystem = LOG_SUBSYSTEM_REPLICATION, result = "failed", + mode, + bucket_count, init_resync_after_reconcile, + duration_ms = started.elapsed().as_millis() as u64, error = %error, "Bucket metadata startup resync reconcile failed in background" ); + return; } + + record_bucket_resync_startup_background_finished(mode, STARTUP_BACKGROUND_OUTCOME_SUCCEEDED, started.elapsed()); + tracing::info!( + event = EVENT_REPLICATION_RESYNC_STARTUP_BACKGROUND_COMPLETED, + component = LOG_COMPONENT_STARTUP_BUCKET_METADATA, + subsystem = LOG_SUBSYSTEM_REPLICATION, + result = "ok", + mode, + bucket_count, + init_resync_after_reconcile, + duration_ms = started.elapsed().as_millis() as u64, + "Bucket metadata startup resync reconcile completed in background" + ); }); } +fn describe_bucket_resync_startup_background_metrics() { + static DESCRIBE: std::sync::Once = std::sync::Once::new(); + DESCRIBE.call_once(|| { + metrics::describe_counter!( + METRIC_REPLICATION_RESYNC_STARTUP_BACKGROUND_EVENTS_TOTAL, + "Bucket metadata startup resync background task events, by fixed mode and outcome" + ); + metrics::describe_histogram!( + METRIC_REPLICATION_RESYNC_STARTUP_BACKGROUND_DURATION_SECONDS, + "Bucket metadata startup resync background task duration in seconds, by fixed mode and outcome" + ); + metrics::describe_gauge!( + METRIC_REPLICATION_RESYNC_STARTUP_BACKGROUND_STATUS, + "Latest bucket metadata startup resync background task status by fixed mode: 0=failed, 1=succeeded, 2=running, 3=canceled" + ); + }); +} + +fn bucket_resync_startup_background_mode(init_resync_after_reconcile: bool) -> &'static str { + if init_resync_after_reconcile { + STARTUP_BACKGROUND_MODE_SERVER + } else { + STARTUP_BACKGROUND_MODE_EMBEDDED + } +} + +fn record_bucket_resync_startup_background_started(mode: &'static str) { + metrics::counter!( + METRIC_REPLICATION_RESYNC_STARTUP_BACKGROUND_EVENTS_TOTAL, + "mode" => mode, + "outcome" => STARTUP_BACKGROUND_OUTCOME_STARTED + ) + .increment(1); + metrics::gauge!(METRIC_REPLICATION_RESYNC_STARTUP_BACKGROUND_STATUS, "mode" => mode).set(STARTUP_BACKGROUND_STATUS_RUNNING); +} + +fn record_bucket_resync_startup_background_finished(mode: &'static str, outcome: &'static str, duration: Duration) { + let status = match outcome { + STARTUP_BACKGROUND_OUTCOME_SUCCEEDED => STARTUP_BACKGROUND_STATUS_SUCCEEDED, + STARTUP_BACKGROUND_OUTCOME_CANCELED => STARTUP_BACKGROUND_STATUS_CANCELED, + _ => STARTUP_BACKGROUND_STATUS_FAILED, + }; + metrics::counter!( + METRIC_REPLICATION_RESYNC_STARTUP_BACKGROUND_EVENTS_TOTAL, + "mode" => mode, + "outcome" => outcome + ) + .increment(1); + metrics::histogram!( + METRIC_REPLICATION_RESYNC_STARTUP_BACKGROUND_DURATION_SECONDS, + "mode" => mode, + "outcome" => outcome + ) + .record(duration.as_secs_f64()); + metrics::gauge!(METRIC_REPLICATION_RESYNC_STARTUP_BACKGROUND_STATUS, "mode" => mode).set(status); +} + async fn run_bucket_resync_startup_reconcile( buckets: Vec, ctx: CancellationToken, @@ -117,4 +240,14 @@ mod tests { "replication pool is not initialized" ))); } + + #[test] + fn startup_resync_background_observability_uses_fixed_modes_and_statuses() { + assert_eq!(bucket_resync_startup_background_mode(true), STARTUP_BACKGROUND_MODE_SERVER); + assert_eq!(bucket_resync_startup_background_mode(false), STARTUP_BACKGROUND_MODE_EMBEDDED); + assert_eq!(STARTUP_BACKGROUND_STATUS_FAILED, 0.0); + assert_eq!(STARTUP_BACKGROUND_STATUS_SUCCEEDED, 1.0); + assert_eq!(STARTUP_BACKGROUND_STATUS_RUNNING, 2.0); + assert_eq!(STARTUP_BACKGROUND_STATUS_CANCELED, 3.0); + } } diff --git a/rustfs/src/startup_runtime_sources.rs b/rustfs/src/startup_runtime_sources.rs index 8b6e33115..ab63777b3 100644 --- a/rustfs/src/startup_runtime_sources.rs +++ b/rustfs/src/startup_runtime_sources.rs @@ -55,7 +55,7 @@ pub(crate) async fn publish_server_addr(addr: &str) { } pub(crate) async fn publish_init_time_now() { - rustfs_common::set_global_init_time_now().await; + rustfs_scanner_contracts::set_global_init_time_now().await; } pub(crate) fn init_kms_service_manager() -> Arc { diff --git a/rustfs/src/storage/access.rs b/rustfs/src/storage/access.rs index 690c2abdd..bbee97eca 100644 --- a/rustfs/src/storage/access.rs +++ b/rustfs/src/storage/access.rs @@ -16,8 +16,9 @@ use super::ObjectOptions; use super::ecfs::FS; use super::{ECStore, PolicySys, ReplicationStatusType, StorageError, get_lock_acquire_timeout, is_err_bucket_not_found}; use crate::auth::{ - check_key_valid_with_context, get_condition_values_with_client_info, get_condition_values_with_query_and_client_info, - get_session_token, + AuthType, RUSTFS_MAX_CONTENT_LENGTH_QUERY, VerifiedPresignedRequest, check_key_valid_with_context, + get_condition_values_with_client_info, get_condition_values_with_query_and_client_info, get_request_auth_type_with_query, + get_session_token, parse_presigned_put_max_content_length, }; use crate::error::ApiError; use crate::license::license_check; @@ -1770,9 +1771,28 @@ impl S3Access for FS { // Publish this server's context slot so downstream data-plane handlers // resolve the same store (backlog#1052 S6). - let ext = cx.extensions_mut(); - ext.insert(self.server_ctx().clone()); - ext.insert(req_info); + let verified_presigned = matches!(get_request_auth_type_with_query(cx.headers(), cx.uri().query()), AuthType::Presigned); + { + let ext = cx.extensions_mut(); + ext.insert(self.server_ctx().clone()); + ext.insert(req_info); + if verified_presigned { + ext.insert(VerifiedPresignedRequest); + } + } + + // The size capability is intentionally scoped to the single-object + // PutObject operation. Validate this at the operation-aware access + // boundary so unsupported GET/HEAD/DELETE/bucket routes cannot silently + // ignore a signed capability query. + if parse_presigned_put_max_content_length(cx.headers(), cx.uri().query(), verified_presigned)?.is_some() + && cx.s3_op().name() != "PutObject" + { + return Err(S3Error::with_message( + S3ErrorCode::InvalidRequest, + format!("{RUSTFS_MAX_CONTENT_LENGTH_QUERY} is only supported for presigned PutObject"), + )); + } license_check().map_err(|er| match er.kind() { std::io::ErrorKind::PermissionDenied => s3_error!(AccessDenied, "{er}"), _ => { diff --git a/rustfs/src/storage/helper.rs b/rustfs/src/storage/helper.rs index 20104dac5..5b6a2f88e 100644 --- a/rustfs/src/storage/helper.rs +++ b/rustfs/src/storage/helper.rs @@ -446,7 +446,6 @@ mod tests { use crate::server::{refresh_audit_module_enabled, refresh_notify_module_enabled}; use crate::storage::access::ReqInfo; use crate::storage::request_context::RequestContext; - use base64::Engine as _; use http::{Extensions, HeaderMap, HeaderValue, Method, Uri}; use metrics::{Counter, CounterFn, Gauge, GaugeFn, Histogram, HistogramFn, Key, KeyName, Metadata, SharedString, Unit}; use rustfs_audit::ObjectVersion; @@ -765,10 +764,7 @@ mod tests { std::collections::HashMap::from([ ("x-amz-server-side-encryption".to_string(), "aws:kms".to_string()), ("x-rustfs-encryption-key-id".to_string(), "finance-key".to_string()), - ( - "x-rustfs-encryption-key".to_string(), - base64::engine::general_purpose::STANDARD.encode([7u8; 48]), - ), + ("x-rustfs-encryption-key".to_string(), base64_simd::STANDARD.encode_to_string([7u8; 48])), ("x-rustfs-encryption-algorithm".to_string(), "aws:kms".to_string()), ]) } @@ -840,7 +836,7 @@ mod tests { let rendered = serde_json::to_string(&tags).expect("audit tags serialize"); assert!( - !rendered.contains(&base64::engine::general_purpose::STANDARD.encode([7u8; 48])), + !rendered.contains(&base64_simd::STANDARD.encode_to_string([7u8; 48])), "the audit entry must not carry the wrapped data key: {rendered}" ); }, diff --git a/rustfs/src/storage/rpc/node_service.rs b/rustfs/src/storage/rpc/node_service.rs index c73cf0f32..b9dfac111 100644 --- a/rustfs/src/storage/rpc/node_service.rs +++ b/rustfs/src/storage/rpc/node_service.rs @@ -195,8 +195,8 @@ fn heal_control_remaining(expires_at_unix_ms: i64, now_unix_ms: i64) -> Result Result<(), Status> { - if request.source != rustfs_common::heal_channel::HealRequestSource::Admin { +fn validate_admin_heal_control_start(request: &rustfs_heal_contracts::heal_channel::HealChannelRequest) -> Result<(), Status> { + if request.source != rustfs_heal_contracts::heal_channel::HealRequestSource::Admin { return Err(Status::permission_denied("heal control start source must be admin")); } if request.pool_index.is_some() != request.set_index.is_some() { @@ -385,10 +385,12 @@ fn background_rebalance_start_error_message(result: StorageResult<()>) -> Option fn stop_rebalance_response(result: StorageResult<()>) -> StopRebalanceResponse { match result { Ok(_) => StopRebalanceResponse { + error_code: None, success: true, error_info: None, }, Err(err) => StopRebalanceResponse { + error_code: None, success: false, error_info: Some(err.to_string()), }, @@ -1498,6 +1500,7 @@ impl Node for NodeService { let policy = request.policy_name; if policy.is_empty() { return Ok(Response::new(DeletePolicyResponse { + error_code: None, success: false, error_info: Some("policy name is missing".to_string()), })); @@ -1507,17 +1510,20 @@ impl Node for NodeService { return Ok(Response::new(DeletePolicyResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; let resp = iam_sys.delete_policy(&policy, false).await; if let Err(err) = resp { return Ok(Response::new(DeletePolicyResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })); } Ok(Response::new(DeletePolicyResponse { + error_code: None, success: true, error_info: None, })) @@ -1529,6 +1535,7 @@ impl Node for NodeService { let policy = request.policy_name; if policy.is_empty() { return Ok(Response::new(LoadPolicyResponse { + error_code: None, success: false, error_info: Some("policy name is missing".to_string()), })); @@ -1537,17 +1544,20 @@ impl Node for NodeService { return Ok(Response::new(LoadPolicyResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; let resp = iam_sys.load_policy(&policy).await; if let Err(err) = resp { return Ok(Response::new(LoadPolicyResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })); } Ok(Response::new(LoadPolicyResponse { + error_code: None, success: true, error_info: None, })) @@ -1562,12 +1572,14 @@ impl Node for NodeService { let user_or_group = request.user_or_group; if user_or_group.is_empty() { return Ok(Response::new(LoadPolicyMappingResponse { + error_code: None, success: false, error_info: Some("user_or_group name is missing".to_string()), })); } let Some(user_type) = UserType::from_u64(request.user_type) else { return Ok(Response::new(LoadPolicyMappingResponse { + error_code: None, success: false, error_info: Some("invalid user type".to_string()), })); @@ -1577,16 +1589,19 @@ impl Node for NodeService { return Ok(Response::new(LoadPolicyMappingResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; let resp = iam_sys.load_policy_mapping(&user_or_group, user_type, is_group).await; if let Err(err) = resp { return Ok(Response::new(LoadPolicyMappingResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })); } Ok(Response::new(LoadPolicyMappingResponse { + error_code: None, success: true, error_info: None, })) @@ -1598,6 +1613,7 @@ impl Node for NodeService { let access_key = request.access_key; if access_key.is_empty() { return Ok(Response::new(DeleteUserResponse { + error_code: None, success: false, error_info: Some("access_key name is missing".to_string()), })); @@ -1606,17 +1622,20 @@ impl Node for NodeService { return Ok(Response::new(DeleteUserResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; let resp = iam_sys.delete_user(&access_key, false).await; if let Err(err) = resp { return Ok(Response::new(DeleteUserResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })); } Ok(Response::new(DeleteUserResponse { + error_code: None, success: true, error_info: None, })) @@ -1631,6 +1650,7 @@ impl Node for NodeService { let access_key = request.access_key; if access_key.is_empty() { return Ok(Response::new(DeleteServiceAccountResponse { + error_code: None, success: false, error_info: Some("access_key name is missing".to_string()), })); @@ -1644,6 +1664,7 @@ impl Node for NodeService { return Ok(Response::new(DeleteServiceAccountResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; // This legacy RPC is a cache notification. Reloading shared state keeps a @@ -1651,11 +1672,13 @@ impl Node for NodeService { let resp = iam_sys.load_service_account(&access_key).await; if let Err(err) = resp { return Ok(Response::new(DeleteServiceAccountResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })); } Ok(Response::new(DeleteServiceAccountResponse { + error_code: None, success: true, error_info: None, })) @@ -1668,6 +1691,7 @@ impl Node for NodeService { let temp = request.temp; if access_key.is_empty() { return Ok(Response::new(LoadUserResponse { + error_code: None, success: false, error_info: Some("access_key name is missing".to_string()), })); @@ -1677,6 +1701,7 @@ impl Node for NodeService { return Ok(Response::new(LoadUserResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; @@ -1685,12 +1710,14 @@ impl Node for NodeService { let resp = iam_sys.load_user(&access_key, user_type).await; if let Err(err) = resp { return Ok(Response::new(LoadUserResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })); } Ok(Response::new(LoadUserResponse { + error_code: None, success: true, error_info: None, })) @@ -1705,6 +1732,7 @@ impl Node for NodeService { let access_key = request.access_key; if access_key.is_empty() { return Ok(Response::new(LoadServiceAccountResponse { + error_code: None, success: false, error_info: Some("access_key name is missing".to_string()), })); @@ -1714,18 +1742,21 @@ impl Node for NodeService { return Ok(Response::new(LoadServiceAccountResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; let resp = iam_sys.load_service_account(&access_key).await; if let Err(err) = resp { return Ok(Response::new(LoadServiceAccountResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })); } Ok(Response::new(LoadServiceAccountResponse { + error_code: None, success: true, error_info: None, })) @@ -1737,6 +1768,7 @@ impl Node for NodeService { let group = request.group; if group.is_empty() { return Ok(Response::new(LoadGroupResponse { + error_code: None, success: false, error_info: Some("group name is missing".to_string()), })); @@ -1746,17 +1778,20 @@ impl Node for NodeService { return Ok(Response::new(LoadGroupResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; let resp = iam_sys.load_group(&group).await; if let Err(err) = resp { return Ok(Response::new(LoadGroupResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })); } Ok(Response::new(LoadGroupResponse { + error_code: None, success: true, error_info: None, })) @@ -1771,14 +1806,17 @@ impl Node for NodeService { return Ok(Response::new(ReloadSiteReplicationConfigResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; match reload_site_replication_runtime_state().await { Ok(()) => Ok(Response::new(ReloadSiteReplicationConfigResponse { + error_code: None, success: true, error_info: None, })), Err(err) => Ok(Response::new(ReloadSiteReplicationConfigResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })), @@ -2098,6 +2136,7 @@ impl Node for NodeService { success: false, bg_heal_state: Bytes::new(), error_info: Some("storage layer not initialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); } let snapshot = heal::capture_node_heal_status(rustfs_scanner::scanner::BackgroundHealInfo::default()).await; @@ -2106,11 +2145,13 @@ impl Node for NodeService { success: true, bg_heal_state: bg_heal_state.into(), error_info: None, + error_code: None, })), Err(err) => Ok(Response::new(BackgroundHealStatusResponse { success: false, bg_heal_state: Bytes::new(), error_info: Some(err), + error_code: None, })), } } @@ -2124,16 +2165,19 @@ impl Node for NodeService { success: false, recovery_status: Bytes::new(), error_info: Some("storage layer not initialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); } let snapshot = heal::capture_node_replacement_recovery_status().await; match heal::encode_node_replacement_recovery_status(&snapshot) { Ok(recovery_status) => Ok(Response::new(ReplacementRecoveryStatusResponse { + error_code: None, success: true, recovery_status: recovery_status.into(), error_info: None, })), Err(err) => Ok(Response::new(ReplacementRecoveryStatusResponse { + error_code: None, success: false, recovery_status: Bytes::new(), error_info: Some(err), @@ -2164,6 +2208,7 @@ impl Node for NodeService { return Ok(Response::new(ReloadPoolMetaResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; // Recover missing workers only after the reload merged newer state; a @@ -2171,19 +2216,23 @@ impl Node for NodeService { match store.reload_pool_meta().await { Ok(true) => match store.spawn_missing_local_decommission_routines().await { Ok(_) => Ok(Response::new(ReloadPoolMetaResponse { + error_code: None, success: true, error_info: None, })), Err(err) => Ok(Response::new(ReloadPoolMetaResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })), }, Ok(false) => Ok(Response::new(ReloadPoolMetaResponse { + error_code: None, success: true, error_info: None, })), Err(err) => Ok(Response::new(ReloadPoolMetaResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })), @@ -2196,6 +2245,7 @@ impl Node for NodeService { return Ok(Response::new(StopRebalanceResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; @@ -2219,6 +2269,7 @@ impl Node for NodeService { return Ok(Response::new(LoadRebalanceMetaResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; @@ -2242,6 +2293,7 @@ impl Node for NodeService { "node rpc background task failed" ); return Ok(Response::new(LoadRebalanceMetaResponse { + error_code: None, success: false, error_info: Some(message), })); @@ -2249,6 +2301,7 @@ impl Node for NodeService { } Ok(Response::new(LoadRebalanceMetaResponse { + error_code: None, success: true, error_info: None, })) @@ -2263,6 +2316,7 @@ impl Node for NodeService { return Ok(Response::new(StartDecommissionResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; @@ -2276,10 +2330,12 @@ impl Node for NodeService { match store.decommission(CancellationToken::new(), indices).await { Ok(()) => Ok(Response::new(StartDecommissionResponse { + error_code: None, success: true, error_info: None, })), Err(err) => Ok(Response::new(StartDecommissionResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })), @@ -2295,6 +2351,7 @@ impl Node for NodeService { return Ok(Response::new(CancelDecommissionResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; @@ -2302,6 +2359,7 @@ impl Node for NodeService { .map_err(|_| Status::invalid_argument("decommission pool index exceeds local range"))?; if let Err(err) = ensure_rpc_decommission_local_leader(&store, idx) { return Ok(Response::new(CancelDecommissionResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })); @@ -2309,10 +2367,12 @@ impl Node for NodeService { match store.decommission_cancel(idx).await { Ok(()) => Ok(Response::new(CancelDecommissionResponse { + error_code: None, success: true, error_info: None, })), Err(err) => Ok(Response::new(CancelDecommissionResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })), @@ -2328,6 +2388,7 @@ impl Node for NodeService { return Ok(Response::new(ClearDecommissionResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; @@ -2335,6 +2396,7 @@ impl Node for NodeService { .map_err(|_| Status::invalid_argument("decommission pool index exceeds local range"))?; if let Err(err) = ensure_rpc_decommission_local_leader(&store, idx) { return Ok(Response::new(ClearDecommissionResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })); @@ -2342,10 +2404,12 @@ impl Node for NodeService { match store.clear_decommission(idx).await { Ok(()) => Ok(Response::new(ClearDecommissionResponse { + error_code: None, success: true, error_info: None, })), Err(err) => Ok(Response::new(ClearDecommissionResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })), @@ -2361,15 +2425,18 @@ impl Node for NodeService { return Ok(Response::new(LoadTransitionTierConfigResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; match reload_transition_tier_config(store).await { Ok(_) => Ok(Response::new(LoadTransitionTierConfigResponse { + error_code: None, success: true, error_info: None, })), Err(err) => Ok(Response::new(LoadTransitionTierConfigResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })), @@ -2542,7 +2609,7 @@ mod tests { _bucket: &str, _object: &str, _version_id: Option<&str>, - _opts: &rustfs_common::heal_channel::HealOpts, + _opts: &rustfs_heal_contracts::heal_channel::HealOpts, ) -> rustfs_heal::Result<(rustfs_madmin::heal_commands::HealResultItem, Option)> { Ok((rustfs_madmin::heal_commands::HealResultItem::default(), None)) } @@ -2550,7 +2617,7 @@ mod tests { async fn heal_bucket( &self, _bucket: &str, - _opts: &rustfs_common::heal_channel::HealOpts, + _opts: &rustfs_heal_contracts::heal_channel::HealOpts, ) -> rustfs_heal::Result { Ok(rustfs_madmin::heal_commands::HealResultItem::default()) } @@ -2618,10 +2685,14 @@ mod tests { let now = i64::try_from(now).expect("test clock should fit in i64"); let metadata = || rustfs_protos::heal_control::RequestMetadata::new(rand::random(), now, now + 30_000, coordinator_epoch); let start = |request_id: String| { - let mut request = - rustfs_common::heal_channel::create_heal_request("bucket".to_string(), Some("prefix".to_string()), false, None); + let mut request = rustfs_heal_contracts::heal_channel::create_heal_request( + "bucket".to_string(), + Some("prefix".to_string()), + false, + None, + ); request.id = request_id; - request.source = rustfs_common::heal_channel::HealRequestSource::Admin; + request.source = rustfs_heal_contracts::heal_channel::HealRequestSource::Admin; request }; @@ -3618,7 +3689,7 @@ mod tests { request } - let expired_request = rustfs_common::heal_channel::create_heal_request("bucket".to_string(), None, false, None); + let expired_request = rustfs_heal_contracts::heal_channel::create_heal_request("bucket".to_string(), None, false, None); let expired = rustfs_protos::heal_control::Envelope::start( expired_request, rustfs_protos::heal_control::RequestMetadata::new([1; 16], 1, 2, coordinator_epoch), @@ -3631,8 +3702,9 @@ mod tests { .expect_err("expired commands must fail before admission"); assert_eq!(expired.code(), tonic::Code::FailedPrecondition); - let mut non_admin_request = rustfs_common::heal_channel::create_heal_request("bucket".to_string(), None, false, None); - non_admin_request.source = rustfs_common::heal_channel::HealRequestSource::Scanner; + let mut non_admin_request = + rustfs_heal_contracts::heal_channel::create_heal_request("bucket".to_string(), None, false, None); + non_admin_request.source = rustfs_heal_contracts::heal_channel::HealRequestSource::Scanner; let now = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000; let now = i64::try_from(now).expect("test clock should fit in i64"); let non_admin = rustfs_protos::heal_control::Envelope::start( diff --git a/rustfs/src/storage/rpc/node_service/bucket.rs b/rustfs/src/storage/rpc/node_service/bucket.rs index 45e5ee248..60d69c119 100644 --- a/rustfs/src/storage/rpc/node_service/bucket.rs +++ b/rustfs/src/storage/rpc/node_service/bucket.rs @@ -60,6 +60,7 @@ impl NodeService { let bucket = request.bucket; if bucket.is_empty() { return Ok(Response::new(LoadBucketMetadataResponse { + error_code: None, success: false, error_info: Some("bucket name is missing".to_string()), })); @@ -69,6 +70,7 @@ impl NodeService { return Ok(Response::new(LoadBucketMetadataResponse { success: false, error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; @@ -78,11 +80,13 @@ impl NodeService { rustfs_scanner::record_scanner_maintenance_change(&bucket); } Ok(Response::new(LoadBucketMetadataResponse { + error_code: None, success: true, error_info: None, })) } Err(err) => Ok(Response::new(LoadBucketMetadataResponse { + error_code: None, success: false, error_info: Some(err.to_string()), })), diff --git a/rustfs/src/storage/rpc/node_service/heal.rs b/rustfs/src/storage/rpc/node_service/heal.rs index 8bbd27572..e5aaec78b 100644 --- a/rustfs/src/storage/rpc/node_service/heal.rs +++ b/rustfs/src/storage/rpc/node_service/heal.rs @@ -16,8 +16,8 @@ use crate::module_switches::{heal_enabled_from_env, scanner_enabled_from_env}; use crate::storage::storage_api::runtime_sources_consumer::EndpointServerPools; use jiff::Timestamp; use rmp_serde::Deserializer; -use rustfs_common::heal_channel::HealScanMode; use rustfs_heal::HealOperationsSnapshot; +use rustfs_heal_contracts::heal_channel::HealScanMode; use rustfs_scanner::scanner::BackgroundHealInfo; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -770,7 +770,7 @@ mod tests { BackgroundHealInfo { bitrot_start_time: Some(started_at), bitrot_start_cycle: 9, - current_scan_mode: rustfs_common::heal_channel::HealScanMode::Deep, + current_scan_mode: rustfs_heal_contracts::heal_channel::HealScanMode::Deep, }, HealOperationsSnapshot::default(), None, diff --git a/rustfs/src/storage/rpc/node_service/health.rs b/rustfs/src/storage/rpc/node_service/health.rs index b6b10b0d1..6bd2e4430 100644 --- a/rustfs/src/storage/rpc/node_service/health.rs +++ b/rustfs/src/storage/rpc/node_service/health.rs @@ -228,17 +228,20 @@ impl NodeService { success: false, storage_info: Bytes::new(), error_info: Some("errServerNotInitialized".to_string()), + error_code: Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32), })); }; let info = StorageAdminApi::local_storage_info(store.as_ref()).await; match encode_msgpack_map(&info) { Ok(buf) => Ok(Response::new(LocalStorageInfoResponse { + error_code: None, success: true, storage_info: buf.into(), error_info: None, })), Err(err) => Ok(Response::new(LocalStorageInfoResponse { + error_code: None, success: false, storage_info: Bytes::new(), error_info: Some(err.to_string()), diff --git a/rustfs/src/storage/sse.rs b/rustfs/src/storage/sse.rs index 6da76d0b2..97a116d93 100644 --- a/rustfs/src/storage/sse.rs +++ b/rustfs/src/storage/sse.rs @@ -83,7 +83,7 @@ use aes_gcm::{ aead::{Aead, KeyInit}, }; use async_trait::async_trait; -use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use base64_simd::STANDARD as BASE64_STANDARD; #[cfg(feature = "rio-v2")] use chacha20poly1305::ChaCha20Poly1305; #[cfg(feature = "rio-v2")] @@ -153,7 +153,7 @@ fn md5_bytes(input: impl AsRef<[u8]>) -> [u8; 16] { } fn md5_base64(input: impl AsRef<[u8]>) -> String { - BASE64_STANDARD.encode(md5_bytes(input)) + BASE64_STANDARD.encode_to_string(md5_bytes(input)) } use super::Error; @@ -562,7 +562,7 @@ pub(crate) fn extract_ssekms_context_from_headers(headers: &HeaderMap) -> Result let value = v .to_str() .map_err(|_| sse_invalid_argument("The x-amz-server-side-encryption-context header must be valid UTF-8."))?; - let decoded = BASE64_STANDARD.decode(value).map_err(|_| { + let decoded = BASE64_STANDARD.decode_to_vec(value).map_err(|_| { sse_invalid_argument("The x-amz-server-side-encryption-context header must be valid base64-encoded JSON.") })?; @@ -1320,7 +1320,7 @@ fn stored_envelope_master_key_version(metadata: &HashMap) -> Opt // this lookup never reads, so the normalized result is identical without it. let encoded = normalize_managed_metadata(metadata, None); let encoded = encoded.get(INTERNAL_ENCRYPTION_KEY_HEADER)?; - let envelope = BASE64_STANDARD.decode(encoded).ok()?; + let envelope = BASE64_STANDARD.decode_to_vec(encoded).ok()?; envelope_master_key_version(&envelope) } @@ -1516,7 +1516,7 @@ fn build_object_encryption_context( fn encode_minio_kms_context(context: &HashMap) -> Result { let encoded = serde_json::to_vec(context) .map_err(|e| ApiError::from(StorageError::other(format!("Failed to serialize KMS context: {e}"))))?; - Ok(BASE64_STANDARD.encode(encoded)) + Ok(BASE64_STANDARD.encode_to_string(encoded)) } fn decode_minio_kms_context(metadata: &HashMap) -> Result>, ApiError> { @@ -1524,7 +1524,7 @@ fn decode_minio_kms_context(metadata: &HashMap) -> Result