Compare commits

...

67 Commits

Author SHA1 Message Date
overtrue 5983594da9 feat(capture): add explicit schema-bound checkpoint capabilities 2026-10-11 21:28:05 +08:00
overtrue dfa5d50565 feat(capture): persist first-decision provenance 2026-10-11 18:58:12 +08:00
overtrue a902886737 feat(capture): atomically migrate legacy Created history 2026-10-11 16:39:10 +08:00
overtrue 8e53b87421 test(capture): specify atomic populated native migration 2026-10-11 16:39:10 +08:00
overtrue f7631e91b1 feat(capture): stream an owned coherent snapshot view 2026-10-11 16:30:12 +08:00
overtrue f9bc4e921f test(capture): specify coherent snapshot view ownership 2026-10-11 16:30:12 +08:00
overtrue 781584307a feat(capture): apply committed native entries on the ordered worker 2026-10-11 15:35:02 +08:00
overtrue 59d641a5fe docs(capture): define recoverable checkpoint and purge contracts 2026-10-11 15:00:38 +08:00
overtrue c618b57870 feat(capture): stream snapshot rows with a permanent failure fence 2026-10-11 14:44:05 +08:00
overtrue bbe1d54098 feat(capture): add bounded streaming snapshot codec 2026-10-11 14:21:30 +08:00
overtrue 9e8ea4dcdb feat(capture-authority): serialize native async writes 2026-10-11 13:35:15 +08:00
overtrue 63cf8e2f2f feat(capture-authority): add native OpenRaft log reader 2026-10-11 13:01:43 +08:00
overtrue b7fe40f4b3 feat(capture): support complete native log range bounds 2026-10-11 12:28:23 +08:00
overtrue 5b872b8e56 feat(capture): apply native entries atomically with durable replay 2026-10-11 11:50:32 +08:00
overtrue ca5be1ef95 feat(capture): persist native Raft entries in explicitly enabled stores 2026-10-11 05:49:17 +08:00
overtrue 9373e8fa3e Bind capture commands and responses to native Raft entries 2026-10-11 03:45:17 +08:00
overtrue cb32965522 feat(capture): preserve exact raft values and membership codec 2026-10-11 02:04:47 +08:00
overtrue fb6cf49a67 feat(capture): validate peer metadata and CNOD codec 2026-10-11 01:25:21 +08:00
overtrue 8c97049e68 build(capture): pin OpenRaft and guard resolved features 2026-10-11 00:56:30 +08:00
overtrue 17d4fe27cc feat(capture): apply committed Created decisions atomically 2026-10-10 23:35:52 +08:00
overtrue fb0c7972b2 feat(capture): persist committed log boundary 2026-10-10 23:10:28 +08:00
overtrue eba7967755 feat(capture): persist atomic log suffix truncation 2026-10-10 22:59:26 +08:00
overtrue 451c1c31ad feat(capture): add durable local authority storage 2026-10-10 22:33:44 +08:00
overtrue 8780dd670e test(ecstore): verify publication across process kills 2026-10-10 20:19:46 +08:00
overtrue f29bf2c902 fix(iam): preserve empty identity read failures 2026-10-10 11:37:07 +08:00
overtrue fd03436090 feat(ecstore): expose unbounded preserve-empty config reads 2026-10-10 11:10:27 +08:00
overtrue 1f1e7b1c97 fix(iam): preserve identity decryption errors 2026-10-10 10:37:15 +08:00
overtrue 45a952609e test(iam): add real identity storage fixtures 2026-10-10 10:01:03 +08:00
overtrue 323190a95a test(test-utils): support caller-owned instance contexts 2026-10-10 09:43:01 +08:00
overtrue 38cdda9292 fix(ecstore): expose config reader without private bounds 2026-10-10 09:11:47 +08:00
overtrue 85c84fa73d feat(policy): add dormant principal incarnation records 2026-10-10 08:12:14 +08:00
overtrue 51402087c5 feat(ecstore): expose bounded config snapshot reader 2026-10-10 08:03:27 +08:00
overtrue a994984fe2 build(policy): prepare identity incarnation UUID dependency 2026-10-10 06:57:51 +08:00
overtrue 48eb0e402c test(iam): use identity constructor in existing fixtures 2026-10-10 06:52:04 +08:00
GatewayJ 446479791e refactor(iam): remove legacy OIDC federation paths (#8433)
Co-authored-by: Hauser <housemecn@gmail.com>
2026-10-09 07:11:50 -07:00
Chris 99af59a914 Merge commit from fork 2026-10-09 21:43:53 +08:00
Chris b77bd26874 Merge commit from fork 2026-10-09 21:43:08 +08:00
Chris 956c1cf027 Merge commit from fork 2026-10-09 21:40:59 +08:00
Chris 8b9d44110e Merge commit from fork 2026-10-09 21:40:08 +08:00
Chris 44ce8d114f Merge commit from fork 2026-10-09 21:39:18 +08:00
Chris 769b0d7578 Merge commit from fork 2026-10-09 21:28:19 +08:00
Chris 03e775947d Merge commit from fork 2026-10-09 21:18:27 +08:00
Hauser 25e73d3bd6 fix(zip): statically link liblzma on macOS (#8434)
* fix(zip): statically link liblzma on macOS

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(zip): pin macOS liblzma minimum to 0.4.8

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(admin): route bucket recovery errors through facade

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(ecstore): group version pagination markers

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(heal): avoid cloning copy purge identity

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-09 16:25:24 +08:00
Hauser a464b45367 fix(rustfs): keep recovery handler within S3 gateway (#8429)
* fix(rustfs): route recovery errors through S3 gateway

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(ecstore): satisfy strict Clippy pagination checks

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(heal): avoid cloning Copy purge identity

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test: restore one-second drive request budgets

* fix(ecstore): resume pending lock bucket creation

Allow retries to reuse an uncommitted Object Lock bucket intent when the same incarnation remains unretired and all physical volumes are absent. Isolate the recovery entrypoint test from the shared production leader lock.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test: provision stack for deep catalog test futures

Apply the existing 4 MiB test-thread stack setup to table-catalog handler/store tests and the relocated-pool GET regression in both local and CI nextest profiles. These cases overflowed libtest's 2 MiB default stack on Linux.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-10-09 14:08:12 +08:00
overtrue 5972a09cc3 test: restore one-second drive request budgets 2026-10-08 23:55:57 +08:00
Chris 8a5e730bc9 fix(storage): warn on stalled local disk operations (#8395) 2026-10-08 21:50:31 +08:00
Hauser 1df874b724 fix(obs): pass RustFS build version to telemetry (#8425)
fix(obs): align telemetry version with build identity

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-08 21:27:24 +08:00
eye-gu 68dcad8387 fix(listing): continue versioned delimiter listings from raw key (#8424)
* fix(listing): continue versioned delimiter listings from raw key

Versioned listings with a delimiter could end a page with
IsTruncated=false while keys remained past the folded scan window.
Continue from the last raw scanned key; a folded prefix cannot
advance forward_past on versioned walks.

Fixes #8423

* fix(listing): filter resumed version prefixes

Apply the ListObjectVersions CommonPrefixes key-marker boundary before
page truncation and resume at the last raw key represented by the final
returned prefix.

Refs rustfs/backlog#2791

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: RustFS <hello@rustfs.com>
Co-authored-by: Hauser <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-08 21:26:57 +08:00
Hauser 8474feaa26 fix: reconcile bucket generations and observe lock failures (#8421)
* fix: reconcile bucket generations and observe lock failures

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix: fence bucket recovery through cancellation and quota reuse

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(heal): route tests through storage API boundary

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(ecstore): use typed lock errors in recovery

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-08 21:26:12 +08:00
cxymds d386f7741d fix(lifecycle): coordinate tier free-version recovery scans (#8387)
* fix(lifecycle): coordinate tier free-version recovery scans

* fix(lifecycle): remove obsolete recovery enqueue entry point

* test(upgrade): wait for writers after each rolling replacement
2026-10-08 21:24:34 +08:00
GatewayJ 5b4432620e refactor(iam): separate OIDC authentication mapping (#8416) 2026-10-08 21:21:22 +08:00
Chris 1a450bf228 ci(e2e): require fresh distributed JUnit reports (#8384)
* ci(e2e): require fresh distributed JUnit reports

* test(e2e): emit JUnit from successful workflow mocks
2026-10-08 06:21:19 -07:00
Chris 371241afcb test(connect): restore subscriber readiness deadlines (#8285) 2026-10-08 06:21:11 -07:00
Chris 61b31e7b45 test(connect): reject unknown health flags (#8142) 2026-10-08 06:21:03 -07:00
Chris 0d1d1487f3 test(ecstore): cover mixed metadata arrival orders (#8385) 2026-10-08 06:05:23 -07:00
Chris 3824779520 test(ecstore): cover late GET object generation changes (#8383) 2026-10-08 06:05:03 -07:00
cxymds a9670ebb4d fix(ci): preserve heal quorum retries and synchronize background tests (#8422) 2026-10-08 05:03:33 -07:00
cppudge 99ae115731 fix(ecstore): make deletes cancellation-safe and reclaim their residue (#8378)
* fix(ecstore): keep object deletes running when the caller is dropped

DeleteObject and DeleteObjects commit on every disk and then run a
separate cleanup pass, both inside the request future. When the client
disconnects, hyper drops that future and the delete stops between the
two passes: xl.meta is gone, but the rollback backup and the marked data
dir stay on disk, hidden from listings and never reclaimed (#6898).

Run the ECStore delete entry points on a detached task tracked by the
instance context. The task owns the object locks, both disk passes and
any rollback, so dropping the caller only stops the wait. Server
shutdown waits up to 30s for detached deletes after the HTTP drain.

Two smaller leaks of the same kind: a cancelled data-dir delete no
longer leaves its snapshot-lease entry marked as deleting forever, and
DiskStore::delete_versions releases its waiting slot through a guard.

Signed-off-by: Petr Slavyagin <disident47@gmail.com>

* fix(ecstore): finish committed delete residue on repeated deletes

Residue that an older build stranded (#6898) has no xl.meta, so deleting
the key again reports it missing and leaves everything in place. On a
disk, a delete-data.<T> marker in one of the object's data dirs proves
that transaction T removed the metadata there and committed: the data it
marked can no longer be read, healed or rolled back on that disk.

When delete_versions or delete_version finds no xl.meta, remove what the
cleanup pass of every transaction proven that way would have removed,
under the object's metadata lease. The rollback backup goes before the
marked data dirs, so an attempt cut short still leaves the marker for
the next one. Reserved markers, rollback backups without a committed
marker and directories that also hold xl.meta or other keys are left
alone. The disk result stays FileNotFound; the extra cost is one
directory read per missing key inside the disk call that already runs,
with no new RPC, and the read stops at the first child that is not a
data dir. Finished transactions are counted in
rustfs_disk_delete_residue_finished_total instead of logged per object.

A single DeleteObject in an unversioned bucket answers a missing key
from the metadata read quorum without reaching the disks, so existing
residue is reclaimed by DeleteObjects or by the empty-listing purge.

Signed-off-by: Petr Slavyagin <disident47@gmail.com>

* fix(ecstore): purge the rollback backup of a committed delete on listing

After an empty listing, the orphan purge (#7955, #8261) already removes
data dirs that carry a committed delete-data.<T> marker, but it treated
the <object>/<T>/xl.meta.bkp rollback backup of the same delete as data
and blocked the whole object directory and its ancestors. Residue left
by an abandoned delete (#6898) therefore survived the listing, and
DeleteBucket kept failing with BucketNotEmpty.

Treat a UUID dir that holds only xl.meta.bkp as purgeable when a
purgeable data dir of the same object on the same disk carries the
committed marker of that transaction. A backup without such a marker
still blocks as before, since it may be needed to roll the delete back.
The other purge gates are unchanged: every disk of the set must be
online, and the backoff applies. xl.meta or unmarked data on any disk
blocks that directory and its ancestors on every disk. Like a marked
data dir before, a backup below such a directory is still removed from
a disk that holds no xl.meta there and proves the transaction with its
own marker.

Signed-off-by: Petr Slavyagin <disident47@gmail.com>

* fix(ecstore): move caller-held delete locks into the detached delete

A force (recursive) delete takes the bucket lifecycle write lock in the
S3 handler and passes only its fence down, so the store does not take
that lock again. Since the delete runs on a detached task, dropping the
request released the lock while the delete kept running: a PUT under
the same prefix could succeed and then be removed by the old delete.
Lifecycle expiry had the same shape with its table-bucket publication
read lock, of which only the lock-lost signal reaches the delete.

Add ECStore::delete_object_with_tier_delete_journal_and_guards. It
moves the caller's guards into the detached task and releases them
after the delete and its listing bookkeeping. The force-delete handler
and lifecycle expiry hand their guards over; DeleteObjects holds none.

Signed-off-by: Petr Slavyagin <disident47@gmail.com>

* fix(startup): keep unclean-shutdown markers while deletes still run

Shutdown waits up to 30s for detached deletes, but it cleared the
unclean-shutdown markers even when that wait timed out. A delete then
cut short by the runtime teardown went unnoticed: the next startup
skipped the erasure-set heal that the marker triggers.

Clear the markers only when the heal handoff completed and every
detached mutation finished, and log that they are kept otherwise. Wait
after the FTP, FTPS, SFTP and WebDAV servers have stopped as well,
since their deletes take the same detached path.

Signed-off-by: Petr Slavyagin <disident47@gmail.com>

* test(ecstore): simulate tier-delete crashes below the detached delete

The chunk crash-boundary tests model a crash by aborting the task that
runs delete_object_with_tier_delete_journal while it is paused at a
TierDeleteChunkTestBarrier. That entry point now runs the delete on a
detached task, so aborting the caller no longer stops it: the delete
resumed on the old store once the barrier was dropped, and building the
restarted store on the same disks tripped the tier-delete journal proof
assertion (tier_delete_chunk_crash_boundaries_resume_without_skipping_sources
failed in CI).

Run the delete through handle_delete_object_with_journal, the body
below the detached task, so the abort stops it at the barrier as before.
tier_delete_chunk_missing_child_with_journals_fails_closed used the same
pattern; its resumed delete only failed fast because the tier rejects
removes, so it gets the same change.

Signed-off-by: Petr Slavyagin <disident47@gmail.com>

---------

Signed-off-by: Petr Slavyagin <disident47@gmail.com>
2026-10-08 02:58:14 -07:00
cxymds 545e794945 fix(admin): resolve event destination subscription target types (#8386) 2026-10-08 02:56:49 -07:00
cxymds 855586255f feat(auth): allow custom secret keys beyond 40 bytes (#8419)
Reuse the existing UTF-8 byte minimum for credential creation so long service account secrets remain intact. Keep generated secrets at 40 bytes and document older receiver and import constraints.
2026-10-08 17:37:15 +08:00
Hauser bf47a919ac feat(quota): add sharded durable reservation protocol (#8402)
* feat(quota): add sharded durable reservation protocol

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(quota): preserve typed task error context

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(quota): initialize all durable reservation shards

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(quota): persist bootstrap shard eligibility

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* perf(quota): reuse allocator bootstrap state

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(connect): widen inventory startup wait

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(quota): reserve protocol v3 for unknown fixtures

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(quota): reload allocator after concurrent credit refills

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-10-08 16:58:32 +08:00
GatewayJ bdd23da034 refactor(iam): separate federated provider views (#8415) 2026-10-08 16:53:17 +08:00
Valentin Belyakov 89a277c3d9 feat: support object lock remaining retention days (#8407) 2026-10-08 16:52:27 +08:00
Henry Guo 3e87004032 fix(table-catalog): reuse held entry locks in warehouse scans (#8418) 2026-10-08 01:01:56 -07:00
Henry Guo 76386572f6 fix(table-catalog): drop migrated tables after rename (#8417) 2026-10-08 00:07:24 -07:00
Hauser 34917c7947 fix: resume stalled GET bodies and classify transient storage failures (#8414)
* fix: resume stalled GET bodies

Retry a stalled source through the existing identity-fenced resume budget so transient stalls do not truncate committed responses.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix: preserve data usage read quorum errors

Apply the existing system-volume error mapping to snapshot body reads so a metadata-volume outage remains a retryable quorum failure.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix: handle transient heal lock failures

Align erasure-set heal classification with the shared lock taxonomy and avoid duplicate error logging for retried scanner journal writes.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-07 23:11:36 -07:00
GatewayJ f0b2080e0c refactor(iam): add verified federated identity model (#8413) 2026-10-07 23:10:34 -07:00
159 changed files with 21486 additions and 1391 deletions
+10
View File
@@ -161,6 +161,11 @@
"suite": "rustfs-ecstore",
"name": "set_disk::core::metadata_quorum::tests::metadata_observation_all_four_slot_states_and_arrival_orders_preserve_reduction"
},
{
"invariant": "metadata-observation-mixed-current-identity-arrivals-preserve-selection",
"suite": "rustfs-ecstore",
"name": "set_disk::core::metadata_quorum::tests::metadata_observation_mixed_current_identity_arrivals_preserve_selection"
},
{
"invariant": "metadata-observation-pending-newer-version-and-same-time-different-directory-force-full-wait",
"suite": "rustfs-ecstore",
@@ -186,6 +191,11 @@
"suite": "rustfs-ecstore",
"name": "set_disk::read::metadata_cache_tests::late_metadata_refresh_rejects_changed_generation_and_mapped_shards"
},
{
"invariant": "late-full-get-rejects-changed-object-generation",
"suite": "rustfs-ecstore",
"name": "set_disk::prepared_get_object_metadata_tests::non_inline_two_phase_get_rejects_generation_change_before_late_refresh"
},
{
"invariant": "list-objects-shared-corpus-static-namespace",
"suite": "rustfs-ecstore",
+10
View File
@@ -92,6 +92,12 @@ setup = 'ecstore-large-stack'
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
setup = 'ecstore-base-stack'
# These handler/store fixtures and the relocated-pool GET compose deep storage
# futures in libtest threads; on Linux they overflow the default 2 MiB stack.
[[profile.default.scripts]]
filter = 'package(rustfs) & (test(/^admin::handlers::table_catalog::tests::/) | test(/^table_catalog::store::tests::/) | test(=app::object::get::tests::execute_get_object_resumes_from_relocated_pool_without_splicing_body))'
setup = 'ecstore-base-stack'
[[profile.default.scripts]]
filter = 'binary(lifecycle_integration_test) | (package(rustfs) & test(/^app::lifecycle_transition_api_test::/))'
setup = 'lifecycle-large-stack'
@@ -337,6 +343,10 @@ setup = 'ecstore-large-stack'
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
setup = 'ecstore-base-stack'
[[profile.ci.scripts]]
filter = 'package(rustfs) & (test(/^admin::handlers::table_catalog::tests::/) | test(/^table_catalog::store::tests::/) | test(=app::object::get::tests::execute_get_object_resumes_from_relocated_pool_without_splicing_body))'
setup = 'ecstore-base-stack'
[[profile.ci.scripts]]
filter = 'binary(lifecycle_integration_test) | (package(rustfs) & test(/^app::lifecycle_transition_api_test::/))'
setup = 'lifecycle-large-stack'
+11
View File
@@ -440,6 +440,17 @@ jobs:
fi
done
- name: Verify macOS binary has no dynamic liblzma dependency
if: matrix.platform == 'macos'
shell: bash
run: |
binary="target/${{ matrix.target }}/release/rustfs"
if otool -L "$binary" | grep -E 'liblzma.*\.dylib'; then
echo "macOS release binary must not depend on a dynamic liblzma library" >&2
exit 1
fi
"$binary" --version
- name: Generate the CPU symbol catalog
if: endsWith(matrix.target, '-linux-gnu') && matrix.features == 'pyroscope'
shell: bash
+2
View File
@@ -168,11 +168,13 @@ jobs:
FILTER: ${{ inputs.filter }}
run: |
set -euo pipefail
rm -f target/nextest/e2e-distributed/junit.xml
if [ -n "${FILTER}" ]; then
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
else
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
fi
test -s target/nextest/e2e-distributed/junit.xml
- name: Upload distributed e2e diagnostics
if: always()
Generated
+180 -3
View File
@@ -275,6 +275,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anyerror"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71add24cc141a1e8326f249b74c41cfd217aeb2a67c9c6cf9134d175469afd49"
dependencies = [
"serde",
]
[[package]]
name = "anyhow"
version = "1.0.104"
@@ -1706,6 +1715,30 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "borsh"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
dependencies = [
"borsh-derive",
"bytes",
"cfg_aliases",
]
[[package]]
name = "borsh-derive"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"
dependencies = [
"once_cell",
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "brotli"
version = "8.0.4"
@@ -1772,6 +1805,18 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "byte-unit"
version = "5.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c719d56f7e96194cfc53460976d3ba51c85719747c9c62ed99981847b551152b"
dependencies = [
"rust_decimal",
"schemars 1.2.2",
"serde",
"utf8-width",
]
[[package]]
name = "bytemuck"
version = "1.25.2"
@@ -3724,13 +3769,34 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "derive_more"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05"
dependencies = [
"derive_more-impl 1.0.0",
]
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
"derive_more-impl 2.1.1",
]
[[package]]
name = "derive_more-impl"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"unicode-xid",
]
[[package]]
@@ -6234,7 +6300,7 @@ dependencies = [
"bytes",
"chrono",
"dashmap",
"derive_more",
"derive_more 2.1.1",
"futures-util",
"getrandom 0.4.3",
"lazy_static",
@@ -6388,6 +6454,12 @@ dependencies = [
"quote",
]
[[package]]
name = "maplit"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "matchers"
version = "0.2.0"
@@ -7329,6 +7401,42 @@ dependencies = [
"url",
]
[[package]]
name = "openraft"
version = "0.9.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a97014fb78acb77be3a40ac2da305f6dd3a6b243f3a908ace87d29b3972eaafd"
dependencies = [
"anyerror",
"byte-unit",
"chrono",
"clap",
"derive_more 1.0.0",
"futures",
"maplit",
"openraft-macros",
"rand 0.8.8",
"serde",
"thiserror 1.0.69",
"tokio",
"tracing",
"tracing-futures",
"validit",
]
[[package]]
name = "openraft-macros"
version = "0.9.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55417d79f46a787dd88c043d831d8b7a0713f7c69d1925f9de51cc42c437250f"
dependencies = [
"chrono",
"proc-macro2",
"quote",
"semver",
"syn 2.0.119",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
@@ -8958,6 +9066,15 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "redb"
version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb338a6c67830a61bed824b78c2bb034ab1ff401a20616bd7957524f4fdc2f22"
dependencies = [
"libc",
]
[[package]]
name = "redis"
version = "1.7.1"
@@ -9464,6 +9581,23 @@ dependencies = [
"walkdir",
]
[[package]]
name = "rust_decimal"
version = "1.43.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a"
dependencies = [
"arrayvec",
"borsh",
"bytes",
"num-traits",
"rand 0.8.8",
"rand 0.9.5",
"serde",
"serde_json",
"wasm-bindgen",
]
[[package]]
name = "rustc-demangle"
version = "0.1.28"
@@ -9666,6 +9800,21 @@ dependencies = [
"url",
]
[[package]]
name = "rustfs-capture-authority"
version = "1.0.1"
dependencies = [
"openraft",
"redb",
"serde",
"serde_json",
"sha2 0.11.0",
"tempfile",
"thiserror 2.0.21",
"tokio",
"url",
]
[[package]]
name = "rustfs-checksums"
version = "1.0.1"
@@ -10473,6 +10622,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
@@ -10594,6 +10744,7 @@ dependencies = [
"faster-hex",
"futures",
"hex-simd",
"hmac 0.13.0",
"hotpath",
"http 1.5.0",
"http-body-util",
@@ -11060,6 +11211,7 @@ dependencies = [
"async-compression",
"futures",
"hotpath",
"liblzma",
"rustfs-rio",
"rustfs-tokio-tar",
"serde",
@@ -13044,6 +13196,16 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "tracing-futures"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2"
dependencies = [
"pin-project",
"tracing",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
@@ -13194,7 +13356,7 @@ checksum = "79acf4b257aa56dd7f00466a81693d6c79c362c55092b3137909d6e76249ad80"
dependencies = [
"async-trait",
"chrono",
"derive_more",
"derive_more 2.1.1",
"libc",
"md-5 0.10.6",
"thiserror 2.0.21",
@@ -13294,6 +13456,12 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf8-width"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "159a7cadce548703edd50d24069bc294c5415ecab0a480e0cd1ca06d112dc94a"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -13320,6 +13488,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "validit"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4417835826b78aa6265724624d424df2b1e6e71a33329227d8b04a88950780dc"
dependencies = [
"anyerror",
]
[[package]]
name = "valuable"
version = "0.1.1"
+3
View File
@@ -18,6 +18,7 @@ members = [
"crates/audit", # Audit target management system with multi-target fan-out
"crates/checksums", # client checksums
"crates/common", # Shared utilities and data structures
"crates/capture-authority", # Durable capture authority storage
"crates/data-usage", # Shared data usage models and algorithms
"crates/config", # Configuration management
"crates/connect-symbol-catalog", # Release-only CPU profile symbol catalog generator
@@ -314,6 +315,8 @@ ratelimit = "2.0.0"
rayon = "1.12.0"
rustfs-erasure-codec = { version = "9.0.0" }
reed-solomon-simd = "3.1.0"
openraft = { version = "=0.9.25", default-features = false, features = ["serde"] }
redb = "=4.3.0"
regex = { version = "1.13.1" }
rumqttc = { package = "rumqttc-next", version = "0.34.0" }
redis = { version = "1.7.1" }
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "rustfs-capture-authority"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
description = "Durable local storage for capture authority"
[dependencies]
openraft = { workspace = true }
redb = { workspace = true }
sha2 = { workspace = true }
serde = { workspace = true, features = ["derive"] }
thiserror = { workspace = true }
tokio = { workspace = true }
url = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
tempfile = { workspace = true }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,531 @@
use super::*;
use crate::application::{APPLY_RESULTS, EVENTS, OPERATIONS, VERSIONS};
use crate::tests::CrashBackend;
use redb::ReadableTableMetadata;
use std::{
io::{BufRead, BufReader, Write},
process::{Command, Stdio},
sync::mpsc,
time::Duration,
};
fn identity() -> NodeIdentity {
NodeIdentity {
cluster_id: [7; 16],
node_id: 0,
}
}
fn binding() -> CaptureBinding {
CaptureBinding {
revision: 9,
binding_id: [8; 16],
target_id: [9; 16],
}
}
fn command() -> DecideCreated {
DecideCreated {
operation_id: [1; 16],
object: ObjectIdentity {
bucket_incarnation: [2; 16],
key: vec![0, 255, 3],
},
expected_head: None,
prepared: PreparedIdentity {
version_id: [3; 16],
preparation_id: [4; 16],
content_digest: [5; 32],
content_length: 123,
},
binding_revision: 9,
}
}
fn id(index: u64) -> LogId {
LogId {
term: 8,
leader_node: 0,
index,
}
}
fn append(store: &mut Store, index: u64, c: &DecideCreated) {
store
.append(&[LogEntry {
id: id(index),
payload: c.encode().expect("encode command"),
}])
.expect("append command");
}
fn apply(store: &mut Store, index: u64, c: &DecideCreated) -> CreatedResult {
append(store, index, c);
store.save_committed(id(index)).expect("commit log");
store.apply_next_created(id(index)).expect("apply command")
}
fn success(c: &DecideCreated, index: u64) -> CreatedResult {
CreatedResult::Created {
version_id: c.prepared.version_id,
event_id: c.operation_id,
decision_log_id: id(index),
}
}
fn complete(store: &Store) {
let c = command();
assert_eq!(store.capture_binding().expect("binding"), Some(binding()));
assert_eq!(
store.created_head(&c.object).expect("head"),
Some(c.prepared.version_id),
"U11 acknowledged head lost"
);
assert_eq!(
store.created_version(&c.object, c.prepared.version_id).expect("version"),
Some(CreatedVersion {
prepared: c.prepared.clone(),
decision_log_id: id(0),
binding_revision: 9
})
);
assert_eq!(
store.created_event(c.operation_id).expect("event"),
Some(CreatedEvent {
object: c.object.clone(),
prepared: c.prepared.clone(),
decision_log_id: id(0),
binding: binding()
}),
"U11 complete event missing"
);
assert_eq!(
store.created_operation(c.operation_id).expect("operation"),
Some(CreatedOperation {
canonical_command: c.encode().expect("encode"),
result: success(&c, 0)
})
);
assert_eq!(
store.created_apply_result(0).expect("result"),
Some(CreatedApplyResult {
log_id: id(0),
result: success(&c, 0)
})
);
assert_eq!(store.last_applied().expect("last applied"), Some(id(0)));
}
fn fixture() -> (tempfile::TempDir, Store) {
let dir = tempfile::tempdir().expect("tempdir");
let mut store = Store::initialize(dir.path().join("db"), identity()).expect("initialize");
store.pin_capture_binding(binding()).expect("pin");
(dir, store)
}
fn reopen(path: &std::path::Path) -> Store {
let result = Store::open(path, identity());
assert!(result.is_ok(), "U11 complete reopen: {:?}", result.as_ref().err());
result.expect("asserted successful reopen")
}
#[test]
fn created_codec_is_canonical_and_strict() {
let c = command();
let b = c.encode().expect("encode");
let mut golden = b"CRTD\x01".to_vec();
golden.extend([1; 16]);
golden.extend([2; 16]);
golden.extend(3u32.to_be_bytes());
golden.extend([0, 255, 3]);
golden.push(0);
golden.extend([3; 16]);
golden.extend([4; 16]);
golden.extend([5; 32]);
golden.extend(123u64.to_be_bytes());
golden.extend(9u64.to_be_bytes());
assert_eq!(b, golden);
assert_eq!(DecideCreated::decode(&b).expect("decode"), c);
let mut variants = Vec::new();
let mut v = c.clone();
v.operation_id = [6; 16];
variants.push(v);
let mut v = c.clone();
v.object.bucket_incarnation = [6; 16];
variants.push(v);
let mut v = c.clone();
v.object.key.push(4);
variants.push(v);
let mut v = c.clone();
v.expected_head = Some([6; 16]);
variants.push(v);
let mut v = c.clone();
v.prepared.version_id = [6; 16];
variants.push(v);
let mut v = c.clone();
v.prepared.preparation_id = [6; 16];
variants.push(v);
let mut v = c.clone();
v.prepared.content_digest = [6; 32];
variants.push(v);
let mut v = c.clone();
v.prepared.content_length += 1;
variants.push(v);
let mut v = c.clone();
v.binding_revision += 1;
variants.push(v);
for v in variants {
assert_ne!(v.encode().expect("variant"), b)
}
for n in 0..b.len() {
assert!(DecideCreated::decode(&b[..n]).is_err())
}
let mut bad = b.clone();
bad[4] = 2;
assert!(DecideCreated::decode(&bad).is_err());
let mut bad = b.clone();
bad[44] = 2;
assert!(DecideCreated::decode(&bad).is_err());
let mut bad = b.clone();
bad.push(0);
assert!(DecideCreated::decode(&bad).is_err());
let mut bad = b;
bad[37..41].copy_from_slice(&4097u32.to_be_bytes());
assert!(DecideCreated::decode(&bad).is_err());
let mut bad = c.clone();
bad.operation_id = [0; 16];
assert!(bad.encode().is_err());
bad = c;
bad.object.key = vec![0; MAX_OBJECT_KEY_LENGTH + 1];
assert!(bad.encode().is_err());
}
#[test]
fn created_committed_order_and_identity_are_enforced() {
let (_dir, mut s) = fixture();
let c = command();
append(&mut s, 0, &c);
append(&mut s, 1, &c);
assert!(s.apply_next_created(id(0)).is_err());
assert_eq!(s.last_applied().expect("no apply"), None);
s.save_committed(id(1)).expect("committed");
assert!(matches!(s.apply_next_created(id(1)), Err(StoreError::InvalidApplySequence)));
let mut wrong = id(0);
wrong.term += 1;
assert!(s.apply_next_created(wrong).is_err());
wrong = id(0);
wrong.leader_node += 1;
assert!(s.apply_next_created(wrong).is_err());
assert_eq!(s.apply_next_created(id(0)).expect("first"), success(&c, 0));
complete(&s);
assert_eq!(s.apply_next_created(id(0)).expect("retry"), success(&c, 0));
complete(&s);
assert!(s.apply_next_created(wrong).is_err());
assert_eq!(s.apply_next_created(id(1)).expect("duplicate log"), success(&c, 0));
assert_eq!(s.last_applied().expect("last"), Some(id(1)));
assert_eq!(
s.created_apply_result(1).expect("result"),
Some(CreatedApplyResult {
log_id: id(1),
result: success(&c, 0)
})
);
let tx = s.database.begin_read().expect("read");
assert_eq!(tx.open_table(EVENTS).expect("events").len().expect("len"), 1);
}
#[test]
fn created_head_version_event_and_dedup_reopen_together() {
let (dir, mut s) = fixture();
let c = command();
assert_eq!(apply(&mut s, 0, &c), success(&c, 0));
drop(s);
let mut s = reopen(&dir.path().join("db"));
complete(&s);
assert_eq!(apply(&mut s, 1, &c), success(&c, 0));
let mut changed = c.clone();
changed.prepared.content_digest = [6; 32];
assert_eq!(
apply(&mut s, 2, &changed),
CreatedResult::OperationConflict,
"U11 full canonical operation conflict"
);
assert_eq!(
s.created_operation(c.operation_id)
.expect("original")
.expect("operation")
.result,
success(&c, 0)
);
let mut stale = c.clone();
stale.operation_id = [6; 16];
stale.prepared.version_id = [6; 16];
assert_eq!(
apply(&mut s, 3, &stale),
CreatedResult::HeadMismatch {
actual_head: Some(c.prepared.version_id)
}
);
let mut reuse = c.clone();
reuse.operation_id = [7; 16];
reuse.expected_head = Some(c.prepared.version_id);
assert_eq!(apply(&mut s, 4, &reuse), CreatedResult::VersionConflict);
let mut replacement = stale.clone();
replacement.operation_id = [10; 16];
replacement.expected_head = Some(c.prepared.version_id);
assert_eq!(apply(&mut s, 5, &replacement), success(&replacement, 5));
assert_eq!(s.created_head(&c.object).expect("head"), Some(replacement.prepared.version_id));
assert_eq!(
s.created_version(&c.object, c.prepared.version_id)
.expect("old version")
.expect("version")
.decision_log_id,
id(0)
);
assert!(s.created_event(c.operation_id).expect("old event").is_some());
assert!(s.created_event(replacement.operation_id).expect("new event").is_some());
assert!(s.created_event(stale.operation_id).expect("rejection event").is_none());
assert_eq!(
apply(&mut s, 6, &stale),
CreatedResult::HeadMismatch {
actual_head: Some(c.prepared.version_id)
}
);
drop(s);
let s = reopen(&dir.path().join("db"));
assert_eq!(s.last_applied().expect("last"), Some(id(6)));
let tx = s.database.begin_read().expect("read");
assert_eq!(tx.open_table(EVENTS).expect("events").len().expect("len"), 2);
}
#[test]
fn created_binding_is_pinned_and_not_caller_selected() {
let (dir, mut s) = fixture();
let mut bad = command();
bad.binding_revision = 10;
assert_eq!(apply(&mut s, 0, &bad), CreatedResult::BindingMismatch);
assert!(s.created_head(&bad.object).expect("head").is_none());
assert!(s.created_event(bad.operation_id).expect("event").is_none());
assert_eq!(
s.created_operation(bad.operation_id)
.expect("operation")
.expect("saved")
.result,
CreatedResult::BindingMismatch
);
s.pin_capture_binding(binding()).expect("idempotent pin");
let mut changed = binding();
changed.target_id = [10; 16];
assert!(matches!(s.pin_capture_binding(changed), Err(StoreError::BindingConflict)));
drop(s);
let s = reopen(&dir.path().join("db"));
assert_eq!(s.capture_binding().expect("binding"), Some(binding()));
}
#[test]
fn created_schema_compatibility_and_corruption_are_checked() {
let dir = tempfile::tempdir().expect("dir");
let path = dir.path().join("db");
let s = Store::initialize(&path, identity()).expect("schema1");
assert_eq!(s.capture_binding().expect("unmigrated"), None);
drop(s);
let mut s = reopen(&path);
assert_eq!(s.last_applied().expect("no apply"), None);
append(&mut s, 0, &command());
s.save_committed(id(0)).expect("schema2");
drop(s);
let mut s = reopen(&path);
assert_eq!(s.capture_binding().expect("schema2 unchanged"), None);
s.pin_capture_binding(binding()).expect("explicit upgrade");
drop(s);
let mut s = reopen(&path);
assert_eq!(s.capture_binding().expect("schema3"), Some(binding()));
s.apply_next_created(id(0)).expect("apply");
drop(s);
let db = Database::open(&path).expect("raw db");
let tx = db.begin_write().expect("write corruption");
tx.open_table(EVENTS)
.expect("events")
.remove(command().operation_id.as_slice())
.expect("remove reference");
tx.commit().expect("commit corruption");
drop(db);
let result = Store::open(&path, identity());
assert!(
matches!(result, Err(StoreError::CorruptRecord)),
"U11 missing event rejected before expect"
);
let (dir, mut s) = fixture();
assert_eq!(s.read_committed().expect("empty committed"), None);
append(&mut s, 0, &command());
s.save_committed(id(0)).expect("committed schema3");
assert_eq!(s.capture_binding().expect("no downgrade"), Some(binding()));
drop(s);
let path = dir.path().join("db");
let db = Database::open(&path).expect("raw db");
let tx = db.begin_write().expect("write");
let mut marker = vec![1];
marker.extend(encode_id(id(0)));
tx.open_table(META)
.expect("meta")
.insert("last_applied", marker.as_slice())
.expect("corrupt applied");
tx.commit().expect("commit");
drop(db);
assert!(
matches!(Store::open(&path, identity()), Err(StoreError::CorruptRecord)),
"U11 last applied missing result rejected"
);
let (dir, s) = fixture();
drop(s);
let path = dir.path().join("db");
let db = Database::open(&path).expect("raw pinned db");
let tx = db.begin_write().expect("missing binding write");
tx.open_table(META)
.expect("meta")
.remove("capture_binding")
.expect("remove binding");
tx.commit().expect("binding corruption commit");
drop(db);
assert!(
matches!(Store::open(&path, identity()), Err(StoreError::CorruptRecord)),
"U11 missing pinned binding rejected"
);
let (dir, mut s) = fixture();
let c = command();
assert_eq!(apply(&mut s, 0, &c), success(&c, 0));
assert_eq!(apply(&mut s, 1, &c), success(&c, 0));
drop(s);
let path = dir.path().join("db");
let db = Database::open(&path).expect("raw duplicate db");
let tx = db.begin_write().expect("retarget decision write");
let mut key = created::object_bytes(&c.object).expect("object key");
key.extend(c.prepared.version_id);
let mut version = Vec::new();
created::put_prepared(&mut version, &c.prepared);
version.extend(encode_id(id(1)));
version.extend(binding().revision.to_be_bytes());
tx.open_table(VERSIONS)
.expect("versions")
.insert(key.as_slice(), version.as_slice())
.expect("retarget version");
let mut event = created::object_bytes(&c.object).expect("event object");
created::put_prepared(&mut event, &c.prepared);
event.extend(encode_id(id(1)));
event.extend(created::binding_bytes(&binding()).expect("binding bytes"));
tx.open_table(EVENTS)
.expect("events")
.insert(c.operation_id.as_slice(), event.as_slice())
.expect("retarget event");
let canonical = c.encode().expect("canonical");
let mut operation = u64::try_from(canonical.len()).expect("length").to_be_bytes().to_vec();
operation.extend(canonical);
operation.extend(created::result_bytes(&success(&c, 1)));
tx.open_table(OPERATIONS)
.expect("operations")
.insert(c.operation_id.as_slice(), operation.as_slice())
.expect("retarget original response");
for index in 0..=1 {
let mut result = encode_id(id(index));
result.extend(created::result_bytes(&success(&c, 1)));
tx.open_table(APPLY_RESULTS)
.expect("results")
.insert(index, result.as_slice())
.expect("retarget duplicate response");
}
tx.commit().expect("coherent retarget commit");
drop(db);
assert!(
matches!(Store::open(&path, identity()), Err(StoreError::CorruptRecord)),
"U11 original decision cannot retarget duplicate log"
);
}
#[test]
fn created_process_child() {
if let Some(path) = std::env::var_os("U11_CHILD_DB") {
let mut s = Store::initialize(path, identity()).expect("child init");
s.pin_capture_binding(binding()).expect("child pin");
assert_eq!(apply(&mut s, 0, &command()), success(&command(), 0));
complete(&s);
println!("U11_ACK");
std::io::stdout().flush().expect("flush ACK");
loop {
std::thread::park()
}
}
let (_dir, mut s) = fixture();
assert_eq!(apply(&mut s, 0, &command()), success(&command(), 0));
complete(&s);
}
#[test]
fn created_acknowledged_apply_survives_child_kill() {
let dir = tempfile::tempdir().expect("dir");
let path = dir.path().join("db");
let mut child = Command::new(std::env::current_exe().expect("test executable"))
.args(["--exact", "application_tests::created_process_child", "--nocapture"])
.env("U11_CHILD_DB", &path)
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.expect("spawn");
let stdout = child.stdout.take().expect("stdout");
let (tx, rx) = mpsc::channel();
let reader = std::thread::spawn(move || {
for line in BufReader::new(stdout).lines() {
let line = line.expect("child line");
if line.ends_with("U11_ACK") {
let _ = tx.send(());
break;
}
}
});
let acknowledged = rx.recv_timeout(Duration::from_secs(20));
child.kill().expect("kill child");
child.wait().expect("wait child");
reader.join().expect("reader cleanup");
assert!(acknowledged.is_ok(), "U11 bounded child ACK missing");
complete(&reopen(&path));
}
#[test]
fn created_power_cut_before_and_after_apply_flush() {
let (dir, mut s) = fixture();
append(&mut s, 0, &command());
s.save_committed(id(0)).expect("committed");
drop(s);
let path = dir.path().join("db");
let backend = CrashBackend::new(&path);
let mut s = Store::from_database(backend.database(), identity()).expect("backend");
assert_eq!(s.apply_next_created(id(0)).expect("apply ACK"), success(&command(), 0));
backend.cut();
drop(s);
complete(&reopen(&path));
for sync in 1..=2 {
for after in [false, true] {
let (dir, mut s) = fixture();
append(&mut s, 0, &command());
s.save_committed(id(0)).expect("committed");
drop(s);
let path = dir.path().join("db");
let backend = CrashBackend::new(&path);
let mut s = Store::from_database(backend.database(), identity()).expect("backend");
let (entered, resume) = backend.pause_sync(sync, after);
let writer = std::thread::spawn(move || {
let result = s.apply_next_created(id(0));
(s, result)
});
entered.wait();
backend.cut();
resume.wait();
let (s, result) = writer.join().expect("writer");
drop(s);
let reopened = Store::open(&path, identity());
assert!(
reopened.is_ok(),
"U11 apply cut must reopen whole old/new state sync={sync} after={after}: {:?}",
reopened.as_ref().err()
);
let s = reopened.expect("asserted reopen");
if s.last_applied().expect("last").is_some() {
complete(&s)
} else {
assert!(result.is_err());
assert_eq!(s.created_head(&command().object).expect("old head"), None);
assert_eq!(
s.created_version(&command().object, command().prepared.version_id)
.expect("old version"),
None
);
assert_eq!(s.created_event(command().operation_id).expect("old event"), None);
assert_eq!(s.created_operation(command().operation_id).expect("old op"), None);
assert_eq!(s.created_apply_result(0).expect("old result"), None)
}
eprintln!("U11 cut boundary sync={sync} after={after}");
}
}
}
+63
View File
@@ -0,0 +1,63 @@
use crate::{LOGS, LogId, META, Store, StoreError, decode_id, decode_log, encode_id, number};
use redb::{ReadableDatabase, ReadableTable};
pub(crate) fn validate_committed(
meta: &impl ReadableTable<&'static str, &'static [u8]>,
logs: &impl ReadableTable<u64, &'static [u8]>,
) -> Result<Option<LogId>, StoreError> {
let schema = number(meta.get("schema")?.ok_or(StoreError::CorruptRecord)?.value())?;
let marker = meta.get("committed")?;
match (schema, marker) {
(1 | 3 | 4 | 5 | 6, None) => {
if matches!(schema, 3..=6) && meta.get("last_applied")?.is_none_or(|v| v.value() != [0]) {
return Err(StoreError::CorruptRecord);
}
Ok(None)
}
(2 | 3 | 4 | 5 | 6, Some(marker)) => {
let id = decode_id(marker.value())?;
let record = logs.get(id.index)?.ok_or(StoreError::CorruptRecord)?;
if decode_log(record.value())?.id != id {
return Err(StoreError::CorruptRecord);
}
Ok(Some(id))
}
(1 | 2, _) => Err(StoreError::CorruptRecord),
_ => Err(StoreError::UnsupportedSchema),
}
}
impl Store {
/// Read the exact committed boundary, validating it against the stored log.
pub fn read_committed(&self) -> Result<Option<LogId>, StoreError> {
let tx = self.database.begin_read()?;
validate_committed(&tx.open_table(META)?, &tx.open_table(LOGS)?)
}
/// Persist a monotonic committed boundary supplied by the authority owner.
pub fn save_committed(&mut self, id: LogId) -> Result<(), StoreError> {
let tx = self.write_transaction()?;
{
let mut meta = tx.open_table(META)?;
let logs = tx.open_table(LOGS)?;
let previous = validate_committed(&meta, &logs)?;
let record = logs.get(id.index)?.ok_or(StoreError::InvalidCommittedLog)?;
if decode_log(record.value())?.id != id {
return Err(StoreError::InvalidCommittedLog);
}
if let Some(previous) = previous {
if id.index < previous.index {
return Err(StoreError::CommittedRegression);
}
if id == previous {
return Ok(());
}
}
meta.insert("committed", encode_id(id).as_slice())?;
if previous.is_none() && !matches!(number(meta.get("schema")?.ok_or(StoreError::CorruptRecord)?.value())?, 3..=6) {
meta.insert("schema", 2u64.to_be_bytes().as_slice())?;
}
}
self.commit(tx)
}
}
+247
View File
@@ -0,0 +1,247 @@
use crate::{LogId, StoreError, decode_id, encode_id};
pub const MAX_OBJECT_KEY_LENGTH: usize = 4096;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObjectIdentity {
pub bucket_incarnation: [u8; 16],
pub key: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreparedIdentity {
pub version_id: [u8; 16],
pub preparation_id: [u8; 16],
pub content_digest: [u8; 32],
pub content_length: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecideCreated {
pub operation_id: [u8; 16],
pub object: ObjectIdentity,
pub expected_head: Option<[u8; 16]>,
pub prepared: PreparedIdentity,
pub binding_revision: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CaptureBinding {
pub revision: u64,
pub binding_id: [u8; 16],
pub target_id: [u8; 16],
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CreatedResult {
Created {
version_id: [u8; 16],
event_id: [u8; 16],
decision_log_id: LogId,
},
HeadMismatch {
actual_head: Option<[u8; 16]>,
},
OperationConflict,
BindingMismatch,
VersionConflict,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreatedVersion {
pub prepared: PreparedIdentity,
pub decision_log_id: LogId,
pub binding_revision: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreatedEvent {
pub object: ObjectIdentity,
pub prepared: PreparedIdentity,
pub decision_log_id: LogId,
pub binding: CaptureBinding,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreatedOperation {
pub canonical_command: Vec<u8>,
pub result: CreatedResult,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreatedOperationProvenance {
pub operation: CreatedOperation,
pub first_decision_log_id: LogId,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreatedApplyResult {
pub log_id: LogId,
pub result: CreatedResult,
}
pub(crate) struct Decoder<'a> {
bytes: &'a [u8],
}
impl<'a> Decoder<'a> {
pub(crate) fn new(bytes: &'a [u8]) -> Self {
Self { bytes }
}
pub(crate) fn take<const N: usize>(&mut self) -> Result<[u8; N], StoreError> {
let a = self
.bytes
.get(..N)
.ok_or(StoreError::CorruptRecord)?
.try_into()
.map_err(|_| StoreError::CorruptRecord)?;
self.bytes = &self.bytes[N..];
Ok(a)
}
pub(crate) fn u64(&mut self) -> Result<u64, StoreError> {
Ok(u64::from_be_bytes(self.take()?))
}
pub(crate) fn uuid(&mut self) -> Result<[u8; 16], StoreError> {
let id = self.take()?;
if id == [0; 16] {
return Err(StoreError::CorruptRecord);
}
Ok(id)
}
pub(crate) fn option(&mut self) -> Result<Option<[u8; 16]>, StoreError> {
match self.take::<1>()?[0] {
0 => Ok(None),
1 => Ok(Some(self.uuid()?)),
_ => Err(StoreError::CorruptRecord),
}
}
pub(crate) fn object(&mut self) -> Result<ObjectIdentity, StoreError> {
let bucket_incarnation = self.uuid()?;
let len = usize::try_from(u32::from_be_bytes(self.take()?)).map_err(|_| StoreError::CorruptRecord)?;
if len > MAX_OBJECT_KEY_LENGTH {
return Err(StoreError::CorruptRecord);
}
let key = self.bytes.get(..len).ok_or(StoreError::CorruptRecord)?.to_vec();
self.bytes = &self.bytes[len..];
Ok(ObjectIdentity { bucket_incarnation, key })
}
pub(crate) fn prepared(&mut self) -> Result<PreparedIdentity, StoreError> {
Ok(PreparedIdentity {
version_id: self.uuid()?,
preparation_id: self.uuid()?,
content_digest: self.take()?,
content_length: self.u64()?,
})
}
pub(crate) fn id(&mut self) -> Result<LogId, StoreError> {
decode_id(&self.take::<24>()?)
}
pub(crate) fn binding(&mut self) -> Result<CaptureBinding, StoreError> {
Ok(CaptureBinding {
revision: self.u64()?,
binding_id: self.uuid()?,
target_id: self.uuid()?,
})
}
pub(crate) fn finish(self) -> Result<(), StoreError> {
if self.bytes.is_empty() {
Ok(())
} else {
Err(StoreError::CorruptRecord)
}
}
pub(crate) fn result(&mut self) -> Result<CreatedResult, StoreError> {
match self.take::<1>()?[0] {
0 => Ok(CreatedResult::Created {
version_id: self.uuid()?,
event_id: self.uuid()?,
decision_log_id: self.id()?,
}),
1 => Ok(CreatedResult::HeadMismatch {
actual_head: self.option()?,
}),
2 => Ok(CreatedResult::OperationConflict),
3 => Ok(CreatedResult::BindingMismatch),
4 => Ok(CreatedResult::VersionConflict),
_ => Err(StoreError::CorruptRecord),
}
}
}
pub(crate) fn put_option(b: &mut Vec<u8>, id: Option<[u8; 16]>) {
b.push(u8::from(id.is_some()));
if let Some(id) = id {
b.extend(id)
}
}
pub(crate) fn object_bytes(o: &ObjectIdentity) -> Result<Vec<u8>, StoreError> {
if o.bucket_incarnation == [0; 16] || o.key.len() > MAX_OBJECT_KEY_LENGTH {
return Err(StoreError::CorruptRecord);
}
let capacity = 20usize.checked_add(o.key.len()).ok_or(StoreError::CorruptRecord)?;
let mut b = Vec::with_capacity(capacity);
b.extend(o.bucket_incarnation);
b.extend(
u32::try_from(o.key.len())
.map_err(|_| StoreError::CorruptRecord)?
.to_be_bytes(),
);
b.extend(&o.key);
Ok(b)
}
pub(crate) fn put_prepared(b: &mut Vec<u8>, p: &PreparedIdentity) {
b.extend(p.version_id);
b.extend(p.preparation_id);
b.extend(p.content_digest);
b.extend(p.content_length.to_be_bytes())
}
pub(crate) fn binding_bytes(v: &CaptureBinding) -> Result<Vec<u8>, StoreError> {
let mut b = v.revision.to_be_bytes().to_vec();
b.extend(v.binding_id);
b.extend(v.target_id);
let mut d = Decoder::new(&b);
d.binding()?;
d.finish()?;
Ok(b)
}
pub(crate) fn result_bytes(r: &CreatedResult) -> Vec<u8> {
let mut b = Vec::new();
match r {
CreatedResult::Created {
version_id,
event_id,
decision_log_id,
} => {
b.push(0);
b.extend(version_id);
b.extend(event_id);
b.extend(encode_id(*decision_log_id))
}
CreatedResult::HeadMismatch { actual_head } => {
b.push(1);
put_option(&mut b, *actual_head)
}
CreatedResult::OperationConflict => b.push(2),
CreatedResult::BindingMismatch => b.push(3),
CreatedResult::VersionConflict => b.push(4),
}
b
}
impl DecideCreated {
pub fn encode(&self) -> Result<Vec<u8>, StoreError> {
let object = object_bytes(&self.object)?;
let capacity = object.len().checked_add(134).ok_or(StoreError::CorruptRecord)?;
let mut b = Vec::with_capacity(capacity);
b.extend(b"CRTD\x01");
b.extend(self.operation_id);
b.extend(object);
put_option(&mut b, self.expected_head);
put_prepared(&mut b, &self.prepared);
b.extend(self.binding_revision.to_be_bytes());
Self::decode(&b)?;
Ok(b)
}
pub fn decode(bytes: &[u8]) -> Result<Self, StoreError> {
let mut d = Decoder::new(bytes);
if d.take::<5>()? != *b"CRTD\x01" {
return Err(StoreError::CorruptRecord);
}
let c = Self {
operation_id: d.uuid()?,
object: d.object()?,
expected_head: d.option()?,
prepared: d.prepared()?,
binding_revision: d.u64()?,
};
d.finish()?;
Ok(c)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,118 @@
//! Owned consistent native application snapshot view.
use crate::{Store, StoreError, application, snapshot};
use redb::{ReadTransaction, ReadableDatabase, ReadableTable, ReadableTableMetadata};
use std::{
io::Write,
sync::{Arc, Mutex},
};
#[derive(Debug, thiserror::Error)]
pub enum NativeSnapshotError {
#[error(transparent)]
Store(#[from] StoreError),
#[error(transparent)]
Snapshot(#[from] snapshot::SnapshotError),
#[error("native snapshot Store lock poisoned")]
Poisoned,
#[error("native snapshot worker failed")]
Worker(#[source] tokio::task::JoinError),
}
pub struct NativeSnapshotView {
// Drop the transaction before the owner so the Database stays open throughout the view.
transaction: ReadTransaction,
header: snapshot::Header,
counts: [u64; 4],
schema: u64,
_owner: Arc<Mutex<Store>>,
}
impl NativeSnapshotView {
pub(crate) fn acquire(owner: Arc<Mutex<Store>>) -> Result<Self, NativeSnapshotError> {
let transaction = {
let store = owner.lock().map_err(|_| NativeSnapshotError::Poisoned)?;
if !store.writable {
return Err(StoreError::WriteDisabled.into());
}
store.database.begin_read().map_err(StoreError::from)?
};
let (applied, membership) = application::native_application_state(&transaction)?;
let (header, schema) = {
let meta = transaction.open_table(crate::META).map_err(StoreError::from)?;
let identity = meta
.get("identity")
.map_err(StoreError::from)?
.ok_or(StoreError::CorruptRecord)?;
let schema = crate::number(
meta.get("schema")
.map_err(StoreError::from)?
.ok_or(StoreError::CorruptRecord)?
.value(),
)?;
(
snapshot::Header {
cluster_id: crate::decode_identity(identity.value())?.cluster_id,
applied,
membership,
binding: application::read_binding(&meta)?,
},
schema,
)
};
let mut counts = [0; 4];
for (index, definition) in [
application::HEADS,
application::VERSIONS,
application::EVENTS,
application::OPERATIONS,
]
.into_iter()
.enumerate()
{
counts[index] = transaction
.open_table(definition)
.map_err(StoreError::from)?
.len()
.map_err(StoreError::from)?;
}
Ok(Self {
transaction,
header,
counts,
schema,
_owner: owner,
})
}
pub fn stream_capability_artifact<W: Write>(
self,
writer: W,
limits: snapshot::Limits,
) -> Result<snapshot::CapabilityArtifact<W>, NativeSnapshotError> {
if self.schema != 6 {
return Err(StoreError::UnsupportedSchema.into());
}
let output = snapshot::CapabilityOutput::new(writer, limits.total_bytes);
Ok(self.stream(output, limits)?.finish())
}
pub fn stream<W: Write>(self, writer: W, limits: snapshot::Limits) -> Result<W, NativeSnapshotError> {
let mut encoder = snapshot::Encoder::new(writer, &self.header, self.counts, limits)?;
for (definition, section) in [
(application::HEADS, snapshot::Section::Heads),
(application::VERSIONS, snapshot::Section::Versions),
(application::EVENTS, snapshot::Section::Events),
(application::OPERATIONS, snapshot::Section::Operations),
] {
let table = self.transaction.open_table(definition).map_err(StoreError::from)?;
for row in table.iter().map_err(StoreError::from)? {
let (key, value) = row.map_err(StoreError::from)?;
encoder.row(snapshot::Row {
section,
key: key.value(),
value: value.value(),
})?;
}
}
Ok(encoder.finish()?)
}
}
+371
View File
@@ -0,0 +1,371 @@
//! Validated capture peer metadata and its independent CNOD v1 codec.
use serde::{Deserialize, Deserializer, Serialize, Serializer, ser::SerializeStruct};
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CaptureNode {
rpc_uri: String,
}
#[derive(Debug, thiserror::Error)]
pub enum CaptureNodeError {
#[error("invalid RPC URI")]
InvalidRpcUri,
#[error("invalid RPC URI")]
InvalidRpcUriParse(#[source] url::ParseError),
#[error("RPC URI too long")]
RpcUriTooLong,
#[error("invalid node magic")]
InvalidNodeMagic,
#[error("unsupported node version")]
UnsupportedNodeVersion,
#[error("invalid node length")]
InvalidNodeLength,
#[error("truncated node record")]
TruncatedNodeRecord,
#[error("invalid node UTF-8")]
InvalidNodeUtf8(#[source] std::str::Utf8Error),
#[error("trailing node bytes")]
TrailingNodeBytes,
}
impl CaptureNodeError {
pub fn code(&self) -> &'static str {
match self {
Self::InvalidRpcUri | Self::InvalidRpcUriParse(_) => "invalid_rpc_uri",
Self::RpcUriTooLong => "rpc_uri_too_long",
Self::InvalidNodeMagic => "invalid_node_magic",
Self::UnsupportedNodeVersion => "unsupported_node_version",
Self::InvalidNodeLength => "invalid_node_length",
Self::TruncatedNodeRecord => "truncated_node_record",
Self::InvalidNodeUtf8(_) => "invalid_node_utf8",
Self::TrailingNodeBytes => "trailing_node_bytes",
}
}
}
impl CaptureNode {
pub fn new(rpc_uri: String) -> Result<Self, CaptureNodeError> {
let node = Self { rpc_uri };
node.validate()?;
Ok(node)
}
pub fn rpc_uri(&self) -> &str {
&self.rpc_uri
}
pub fn validate(&self) -> Result<(), CaptureNodeError> {
let uri = &self.rpc_uri;
if uri.len() > 4096 {
return Err(CaptureNodeError::RpcUriTooLong);
}
if uri.is_empty() || uri.chars().any(|c| c.is_whitespace() || c.is_control() || c == '\\') {
return Err(CaptureNodeError::InvalidRpcUri);
}
let rest = uri
.strip_prefix("http://")
.or_else(|| uri.strip_prefix("https://"))
.ok_or(CaptureNodeError::InvalidRpcUri)?;
if uri.contains('?') || uri.contains('#') {
return Err(CaptureNodeError::InvalidRpcUri);
}
let (authority, path) = rest.split_once('/').map_or((rest, ""), |(a, p)| (a, p));
// An empty suffix after the first slash is the only allowed path.
if !path.is_empty() || authority.contains('@') {
return Err(CaptureNodeError::InvalidRpcUri);
}
let (host, port) = if authority.starts_with('[') {
let end = authority.find(']').ok_or(CaptureNodeError::InvalidRpcUri)?;
let port = authority
.get(end + 1..)
.and_then(|suffix| suffix.strip_prefix(':'))
.ok_or(CaptureNodeError::InvalidRpcUri)?;
(&authority[..=end], port)
} else {
authority.split_once(':').ok_or(CaptureNodeError::InvalidRpcUri)?
};
if host.is_empty() || port.is_empty() || !port.bytes().all(|b| b.is_ascii_digit()) {
return Err(CaptureNodeError::InvalidRpcUri);
}
let port = port.parse::<u16>().map_err(|_| CaptureNodeError::InvalidRpcUri)?;
if port == 0 {
return Err(CaptureNodeError::InvalidRpcUri);
}
let parsed_url = url::Url::parse(uri).map_err(CaptureNodeError::InvalidRpcUriParse)?;
if parsed_url.host().is_none() {
return Err(CaptureNodeError::InvalidRpcUri);
}
Ok(())
}
pub fn encode(&self) -> Result<Vec<u8>, CaptureNodeError> {
self.validate()?;
let length = u32::try_from(self.rpc_uri.len()).map_err(|_| CaptureNodeError::InvalidNodeLength)?;
let mut bytes = b"CNOD\x01".to_vec();
bytes.extend_from_slice(&length.to_be_bytes());
bytes.extend_from_slice(self.rpc_uri.as_bytes());
Ok(bytes)
}
pub fn decode(bytes: &[u8]) -> Result<Self, CaptureNodeError> {
if bytes.len() < 9 {
return Err(CaptureNodeError::TruncatedNodeRecord);
}
if &bytes[..4] != b"CNOD" {
return Err(CaptureNodeError::InvalidNodeMagic);
}
if bytes[4] != 1 {
return Err(CaptureNodeError::UnsupportedNodeVersion);
}
let length = u32::from_be_bytes(bytes[5..9].try_into().map_err(|_| CaptureNodeError::InvalidNodeLength)?);
if !(1..=4096).contains(&length) {
return Err(CaptureNodeError::InvalidNodeLength);
}
let length = usize::try_from(length).map_err(|_| CaptureNodeError::InvalidNodeLength)?;
let end = 9_usize.checked_add(length).ok_or(CaptureNodeError::InvalidNodeLength)?;
if bytes.len() < end {
return Err(CaptureNodeError::TruncatedNodeRecord);
}
if bytes.len() != end {
return Err(CaptureNodeError::TrailingNodeBytes);
}
let uri = std::str::from_utf8(&bytes[9..end]).map_err(CaptureNodeError::InvalidNodeUtf8)?;
Self::new(uri.to_owned())
}
}
impl Serialize for CaptureNode {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.validate().map_err(serde::ser::Error::custom)?;
let mut state = serializer.serialize_struct("CaptureNode", 1)?;
state.serialize_field("rpc_uri", &self.rpc_uri)?;
state.end()
}
}
impl<'de> Deserialize<'de> for CaptureNode {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Fields {
rpc_uri: String,
}
let fields = Fields::deserialize(deserializer).map_err(|_| serde::de::Error::custom("invalid capture node fields"))?;
Self::new(fields.rpc_uri).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error;
fn record(payload: &[u8]) -> Vec<u8> {
let mut bytes = b"CNOD\x01".to_vec();
bytes.extend_from_slice(&u32::try_from(payload.len()).expect("test payload length").to_be_bytes());
bytes.extend_from_slice(payload);
bytes
}
fn reject_uri(uri: &str) {
let result = CaptureNode::new(uri.to_owned());
assert!(result.is_err(), "URI should reject: {uri:?}");
assert_eq!(result.expect_err("rejected URI").code(), "invalid_rpc_uri");
}
fn reject_record(bytes: &[u8], code: &str) {
let result = CaptureNode::decode(bytes);
assert!(result.is_err(), "record should reject with {code}");
assert_eq!(result.expect_err("rejected record").code(), code);
}
#[test]
fn actual_openraft_node_bound() {
fn require_node<T: openraft::Node>() {}
require_node::<CaptureNode>();
assert_eq!(CaptureNode::default().rpc_uri(), "");
}
#[test]
fn default_and_empty_rejected_at_every_boundary() {
let node = CaptureNode::default();
assert_eq!(node.rpc_uri(), "");
reject_uri("");
assert_eq!(node.validate().expect_err("empty validation").code(), "invalid_rpc_uri");
assert!(serde_json::to_string(&node).is_err());
assert!(serde_json::from_str::<CaptureNode>(r#"{"rpc_uri":""}"#).is_err());
assert_eq!(node.encode().expect_err("empty encoding").code(), "invalid_rpc_uri");
reject_record(&record(b""), "invalid_node_length");
}
#[test]
fn accepted_uris_preserve_exact_bytes() {
for uri in [
"http://node:80",
"https://node:443",
"http://node:1",
"https://node:65535/",
"http://127.0.0.1:9000",
"https://[::1]:443/",
"http://[2001:db8::1]:1",
"http://NoDe:00080/",
] {
let result = CaptureNode::new(uri.to_owned());
assert!(result.is_ok(), "valid URI construction: {uri}");
let node = result.expect("valid URI");
assert_eq!(node.rpc_uri(), uri, "preserve explicit default port and raw URI");
assert_eq!(node.encode().expect("encode URI"), record(uri.as_bytes()));
assert_eq!(CaptureNode::decode(&record(uri.as_bytes())).expect("decode URI"), node);
}
}
#[test]
fn golden_cnod_bytes() {
let node = CaptureNode {
rpc_uri: "http://node:80".to_owned(),
};
let golden = b"CNOD\x01\x00\x00\x00\x0ehttp://node:80";
assert_eq!(node.encode().ok().as_deref(), Some(golden.as_slice()), "golden CNOD bytes");
}
#[test]
fn valid_cnod_decode() {
let golden = b"CNOD\x01\x00\x00\x00\x0ehttp://node:80";
let decoded = CaptureNode::decode(golden);
assert!(decoded.is_ok(), "golden decode succeeds");
assert_eq!(decoded.expect("golden decoded node").rpc_uri(), "http://node:80");
}
#[test]
fn serde_shape_roundtrip_and_strict_fields() {
let node = CaptureNode::new("https://node:443/".to_owned()).expect("serde node");
let json = r#"{"rpc_uri":"https://node:443/"}"#;
assert_eq!(serde_json::to_string(&node).expect("serialize node"), json);
assert_eq!(serde_json::from_str::<CaptureNode>(json).expect("deserialize node"), node);
for json in [
r#"{}"#,
r#"{"rpc_uri":1}"#,
r#"{"rpc_uri":null}"#,
r#"{"rpc_uri":"http://node:1","extra":0}"#,
r#"{"rpc_uri":"http://node:1","rpc_uri":"http://node:2"}"#,
] {
assert!(serde_json::from_str::<CaptureNode>(json).is_err(), "strict fields: {json}");
}
}
#[test]
fn credentials_and_raw_uri_shapes_reject() {
for uri in [
"http://user@node:1",
"http://user:password@node:1",
"http://@node:1",
"HTTP://node:1",
"Https://node:1",
"http://node",
"http://node:",
"http://node:0",
"http://node:-1",
"http://node:+1",
"http://node:65536",
"http://node:999999999999999999999",
"http://[::1:1",
"http://[::1]extra:1",
"http://[invalid]:1",
"http://::1:1",
"http://:1",
"/disk/path",
"file:///disk/path",
"http://node:1/./",
"http://node:1/../",
"http://node:1/%2e/",
"http://node:1/%2E%2e/",
"http://node:1/path",
"http://node:1\\",
" http://node:1",
"http://node:1\n",
"http://no\tde:1",
"http://node:1\u{00a0}",
"http://node:1\u{0085}",
"http://node:1\0",
] {
reject_uri(uri);
}
}
#[test]
fn query_and_fragment_markers_reject() {
for uri in [
"http://node:1?",
"http://node:1?x=y",
"http://node:1/?",
"http://node:1#",
"http://node:1#fragment",
"http://node:1/#",
] {
reject_uri(uri);
}
}
#[test]
fn utf8_byte_length_boundaries() {
for length in [4095, 4096] {
let prefix = "http://node:";
let uri = format!("{prefix}{}1", "0".repeat(length - prefix.len() - 1));
assert_eq!(uri.len(), length);
assert!(url::Url::parse(&uri).is_ok(), "boundary fixture accepted by actual URL parser");
let node = CaptureNode::new(uri.clone()).expect("boundary node");
assert_eq!(node.rpc_uri(), uri);
assert_eq!(
CaptureNode::decode(&node.encode().expect("boundary encoding")).expect("boundary decoding"),
node
);
}
let uri = format!("http://node:{}1", "0".repeat(4097 - "http://node:".len() - 1));
assert_eq!(CaptureNode::new(uri).expect_err("oversize URI").code(), "rpc_uri_too_long");
let unicode = format!("http://{}:1", "é".repeat(2044));
assert!(unicode.chars().count() < 4096);
assert!(unicode.len() > 4096);
assert_eq!(CaptureNode::new(unicode).expect_err("UTF-8 byte limit").code(), "rpc_uri_too_long");
}
#[test]
fn cnod_headers_and_lengths_reject_exact_codes() {
let valid = record(b"http://node:80");
for end in 0..9 {
reject_record(&valid[..end], "truncated_node_record");
}
let mut bad = valid.clone();
bad[0] = b'X';
reject_record(&bad, "invalid_node_magic");
bad = valid.clone();
bad[4] = 2;
reject_record(&bad, "unsupported_node_version");
for length in [0_u32, 4097, u32::MAX] {
bad = b"CNOD\x01".to_vec();
bad.extend_from_slice(&length.to_be_bytes());
reject_record(&bad, "invalid_node_length");
}
for end in 9..valid.len() {
reject_record(&valid[..end], "truncated_node_record");
}
reject_record(&record(&[0xff]), "invalid_node_utf8");
reject_record(&record(&[0xc3, 0x28]), "invalid_node_utf8");
reject_record(&record(b"invalid URI"), "invalid_rpc_uri");
}
#[test]
fn trailing_bytes_reject() {
for tail in [&b"x"[..], &b"xyz"[..]] {
let mut bytes = record(b"http://node:80");
bytes.extend_from_slice(tail);
reject_record(&bytes, "trailing_node_bytes");
}
}
#[test]
fn errors_and_sources_redact_input() {
let sentinel = "http://SECRET_USER:SECRET_PASSWORD@node:1";
let error = CaptureNode::new(sentinel.to_owned()).expect_err("credentials reject");
let mut current: &dyn Error = &error;
loop {
let rendered = format!("{current} {current:?}");
assert!(!rendered.contains("SECRET"));
match current.source() {
Some(source) => current = source,
None => break,
}
}
let json = format!(r#"{{"rpc_uri":"{sentinel}"}}"#);
let error = serde_json::from_str::<CaptureNode>(&json).expect_err("serde credentials reject");
assert!(!format!("{error} {error:?}").contains("SECRET"));
let json = format!(r#"{{"{sentinel}":0}}"#);
let error = serde_json::from_str::<CaptureNode>(&json).expect_err("unknown fields reject");
assert!(!format!("{error} {error:?}").contains("SECRET"));
let error = CaptureNode::new("http://[bad]:1".to_owned()).expect_err("parser rejection");
assert!(error.source().is_some(), "retain URL parser source");
let error = CaptureNode::decode(&record(&[0xff])).expect_err("UTF-8 rejection");
assert!(error.source().is_some(), "retain UTF-8 source");
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
use crate::{LOGS, META, Store, StoreError, decode_id, decode_log, encode_id};
use redb::ReadableTable;
impl Store {
/// Atomically remove every log entry at or after `from`.
pub fn truncate_suffix(&mut self, from: u64) -> Result<(), StoreError> {
let tx = self.write_transaction()?;
{
let mut meta = tx.open_table(META)?;
let logs = tx.open_table(LOGS)?;
if crate::committed::validate_committed(&meta, &logs)?.is_some_and(|id| from <= id.index) {
return Err(StoreError::CommittedLogProtected);
}
drop(logs);
let tail = meta.get("tail")?.map(|v| decode_id(v.value())).transpose()?;
if tail.is_none_or(|id| from > id.index) {
return Ok(());
}
let mut logs = tx.open_table(LOGS)?;
let new_tail = if from == 0 {
None
} else {
let index = from - 1;
let record = logs.get(index)?.ok_or(StoreError::InvalidLogSequence)?;
let id = decode_log(record.value())?.id;
if id.index != index {
return Err(StoreError::CorruptRecord);
}
Some(id)
};
logs.retain(|index, _| index < from)?;
match new_tail {
Some(id) => {
meta.insert("tail", encode_id(id).as_slice())?;
}
None => {
meta.remove("tail")?;
}
}
}
self.commit(tx)
}
}
+3 -13
View File
@@ -18,10 +18,8 @@ use const_str::concat;
/// Default value: RustFS
/// Environment variable: RUSTFS_APP_NAME
pub const APP_NAME: &str = "RustFS";
/// Application version
/// Default value: 1.0.0
/// Environment variable: RUSTFS_VERSION
pub const VERSION: &str = "1.0.0";
/// Cargo package version for this workspace member.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Default configuration logger level
/// Default value: error
@@ -39,12 +37,6 @@ pub const SAMPLE_RATIO: f64 = 1.0;
/// Default value: 30
pub const METER_INTERVAL: u64 = 30;
/// Default configuration service version
/// Default value: 1.0.0
/// Environment variable: RUSTFS_OBS_SERVICE_VERSION
/// Uses the same value as VERSION constant
pub const SERVICE_VERSION: &str = "1.0.0";
/// Default configuration environment
/// Default value: production
pub const ENVIRONMENT: &str = "production";
@@ -410,7 +402,6 @@ mod tests {
APP_NAME,
VERSION,
DEFAULT_LOG_LEVEL,
SERVICE_VERSION,
ENVIRONMENT,
RUSTFS_TLS_KEY,
RUSTFS_TLS_CERT,
@@ -446,8 +437,7 @@ mod tests {
fn test_configuration_consistency() {
// Test configuration consistency
// Version consistency
assert_eq!(VERSION, SERVICE_VERSION, "Application version should match service version");
assert_eq!(VERSION, env!("CARGO_PKG_VERSION"));
// Port conflict check
let ports = [DEFAULT_PORT, DEFAULT_CONSOLE_PORT];
@@ -31,6 +31,7 @@ use super::harness::{
use crate::common::{
AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via, init_logging,
};
use crate::upgrade_compatibility_test::wait_for_upgrade_write_readiness;
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use std::path::{Path, PathBuf};
@@ -400,6 +401,19 @@ async fn four_node_direct_upgrade_preserves_history_and_iam_credentials() -> Tes
Ok(())
}
async fn replace_node_and_wait_for_write_readiness(dist: &mut DistCluster, seed: &UpgradeSeed, node_idx: usize) -> TestResult {
dist.replace_node_with_current_binary(node_idx).await?;
// Node readiness proves reads can succeed; a rolling writer must also
// recover before replacing the next peer or checking upgrade contracts.
wait_for_upgrade_write_readiness(
&dist.clients()?,
&seed.history_bucket,
&format!("replaced-node-{node_idx}"),
CREDENTIAL_TIMEOUT,
)
.await
}
#[tokio::test]
async fn four_node_rolling_upgrade_preserves_history_and_iam_credentials() -> TestResult {
init_logging();
@@ -410,15 +424,15 @@ async fn four_node_rolling_upgrade_preserves_history_and_iam_credentials() -> Te
let seed = seed_history_and_iam(&dist).await?;
dist.replace_node_with_current_binary(0).await?;
replace_node_and_wait_for_write_readiness(&mut dist, &seed, 0).await?;
assert_history_and_iam(&dist, &seed, "one-current-node").await?;
for node_idx in [1, 2] {
dist.replace_node_with_current_binary(node_idx).await?;
replace_node_and_wait_for_write_readiness(&mut dist, &seed, node_idx).await?;
}
assert_history_and_iam(&dist, &seed, "one-previous-node").await?;
dist.replace_node_with_current_binary(3).await?;
replace_node_and_wait_for_write_readiness(&mut dist, &seed, 3).await?;
assert_history_and_iam(&dist, &seed, "homogeneous-current").await?;
Ok(())
}
@@ -578,7 +578,7 @@ async fn exercise_mixed_cluster(
previous_node: usize,
) -> TestResult {
let clients = cluster.create_all_clients()?;
wait_for_upgrade_write_readiness(&clients, phase, LISTING_CONVERGENCE_TIMEOUT).await?;
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, phase, LISTING_CONVERGENCE_TIMEOUT).await?;
let current_client = &clients[current_node];
let previous_client = &clients[previous_node];
@@ -646,7 +646,12 @@ fn upgrade_probe_client(client: &Client) -> Client {
)
}
async fn wait_for_upgrade_write_readiness(clients: &[Client], phase: &str, budget: Duration) -> TestResult {
pub(crate) async fn wait_for_upgrade_write_readiness(
clients: &[Client],
bucket: &str,
phase: &str,
budget: Duration,
) -> TestResult {
// ListBuckets can succeed before peers recover a restarted disk. Cluster
// health also accepts Returning disks whose write health is still FAULTY.
// Probe every writer outside the asserted phase prefix; compatibility
@@ -666,7 +671,7 @@ async fn wait_for_upgrade_write_readiness(clients: &[Client], phase: &str, budge
deadline,
client
.put_object()
.bucket(MIXED_BUCKET)
.bucket(bucket)
.key(&key)
.body(ByteStream::from_static(UPGRADE_READINESS_BODY))
.send(),
@@ -701,19 +706,20 @@ async fn prepare_previous_release_baseline(cluster: &mut RustFSTestClusterEnviro
// waits for pool metadata (#7473). First prove the elected writer can
// persist data, then restart each old process once with three peers still
// readable. This preparation ends before any current binary is started.
wait_for_upgrade_write_readiness(&clients[..1], "previous-seed", LISTING_CONVERGENCE_TIMEOUT).await?;
wait_for_upgrade_write_readiness(&clients[..1], MIXED_BUCKET, "previous-seed", LISTING_CONVERGENCE_TIMEOUT).await?;
for node in [1, 2, 3, 0] {
cluster.stop_node(node)?;
cluster.start_node_from_binary(node, previous_binary).await?;
wait_for_upgrade_write_readiness(
std::slice::from_ref(&clients[node]),
MIXED_BUCKET,
&format!("previous-restart-{node}"),
LISTING_CONVERGENCE_TIMEOUT,
)
.await?;
}
wait_for_upgrade_write_readiness(&clients, "previous-baseline", LISTING_CONVERGENCE_TIMEOUT).await?;
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, "previous-baseline", LISTING_CONVERGENCE_TIMEOUT).await?;
for (reader, client) in clients.iter().enumerate() {
assert_eq!(
read_object(client, MIXED_BUCKET, PREVIOUS_RELEASE_SEED_KEY, None).await?.1,
@@ -740,9 +746,10 @@ mod upgrade_write_readiness_tests {
#[tokio::test]
async fn waits_for_each_writer_after_metadata_is_ready() -> TestResult {
let target = FakeS3Target::start().await?;
target.create_bucket(MIXED_BUCKET);
let bucket = "distributed-upgrade-history";
target.create_bucket(bucket);
let client = fake_source_client(&target);
client.head_bucket().bucket(MIXED_BUCKET).send().await?;
client.head_bucket().bucket(bucket).send().await?;
let phase = "one-previous-node";
let first_key = format!(".upgrade-readiness/{phase}/node-0");
@@ -762,7 +769,7 @@ mod upgrade_write_readiness_tests {
// Metadata readiness does not prove that a data write can succeed.
let premature = client
.put_object()
.bucket(MIXED_BUCKET)
.bucket(bucket)
.key(&first_key)
.body(ByteStream::from_static(b"upgrade write readiness"))
.send()
@@ -770,15 +777,15 @@ mod upgrade_write_readiness_tests {
.expect_err("metadata readiness does not prove write readiness");
assert_eq!(premature.raw_response().map(|response| response.status().as_u16()), Some(503));
wait_for_upgrade_write_readiness(&[client.clone(), client.clone()], phase, Duration::from_secs(5)).await?;
wait_for_upgrade_write_readiness(&[client.clone(), client.clone()], bucket, phase, Duration::from_secs(5)).await?;
assert_eq!(target.count_requests(FakeTargetOperation::PutObject, &first_key), 3);
assert_eq!(target.count_requests(FakeTargetOperation::PutObject, &second_key), 2);
assert!(target.has_object(MIXED_BUCKET, &first_key));
assert!(target.has_object(MIXED_BUCKET, &second_key));
assert!(target.has_object(bucket, &first_key));
assert!(target.has_object(bucket, &second_key));
assert!(
client
.list_objects_v2()
.bucket(MIXED_BUCKET)
.bucket(bucket)
.prefix(format!("{phase}/"))
.send()
.await?
@@ -808,9 +815,10 @@ mod upgrade_write_readiness_tests {
let phase = format!("permanent-{}", status.as_u16());
let key = format!(".upgrade-readiness/{phase}/node-0");
target.inject_for_key(FakeTargetOperation::PutObject, &key, FaultAction::Status(status), 1);
let error = wait_for_upgrade_write_readiness(std::slice::from_ref(&client), &phase, Duration::from_secs(5))
.await
.expect_err("a permanent error must not be retried into success");
let error =
wait_for_upgrade_write_readiness(std::slice::from_ref(&client), MIXED_BUCKET, &phase, Duration::from_secs(5))
.await
.expect_err("a permanent error must not be retried into success");
assert!(error.to_string().contains("node 0 write readiness failed"), "{error}");
assert_eq!(target.count_requests(FakeTargetOperation::PutObject, &key), 1);
assert!(!target.has_object(MIXED_BUCKET, &key));
@@ -833,7 +841,7 @@ mod upgrade_write_readiness_tests {
FaultAction::Status(StatusCode::SERVICE_UNAVAILABLE),
10,
);
let error = wait_for_upgrade_write_readiness(&[client], phase, Duration::from_secs(1))
let error = wait_for_upgrade_write_readiness(&[client], MIXED_BUCKET, phase, Duration::from_secs(1))
.await
.expect_err("persistent unavailability must exhaust the shared deadline");
// Transport scheduling consumes the same budget; do not require a
@@ -852,7 +860,7 @@ mod upgrade_write_readiness_tests {
let phase = "stalled";
let key = format!(".upgrade-readiness/{phase}/node-0");
target.inject_for_key(FakeTargetOperation::PutObject, &key, FaultAction::Stall(Duration::from_secs(30)), 1);
let error = wait_for_upgrade_write_readiness(std::slice::from_ref(&client), phase, Duration::from_secs(1))
let error = wait_for_upgrade_write_readiness(std::slice::from_ref(&client), MIXED_BUCKET, phase, Duration::from_secs(1))
.await
.expect_err("a stalled request must not outlive the readiness deadline");
assert!(error.to_string().contains("deadline exceeded during PutObject"), "{error}");
+28 -6
View File
@@ -240,7 +240,10 @@ pub mod bucket {
}
pub mod quota {
pub use crate::bucket::quota::{BucketQuota, QuotaCheckResult, QuotaError, QuotaOperation};
pub use crate::bucket::quota::{
BucketQuota, QUOTA_RESERVATION_PROTOCOL_V1, QUOTA_RESERVATION_PROTOCOL_V2, QuotaCheckResult, QuotaError,
QuotaOperation,
};
pub mod checker {
pub use crate::bucket::quota::checker::QuotaChecker;
@@ -362,11 +365,30 @@ pub mod config {
ServerConfigCorruptError, ServerConfigSaveResult, ServerConfigSnapshot, delete_config, delete_config_no_lock,
is_server_config_corrupt_error, lookup_configs, read_config, read_config_no_lock, read_config_with_metadata,
read_config_without_migrate, read_config_without_migrate_no_lock, read_existing_server_config_no_lock,
read_server_config_snapshot, save_config, save_config_no_lock, save_config_with_opts, save_server_config,
save_server_config_no_lock, save_server_config_snapshot, save_server_config_snapshot_with_generation,
server_config_path, try_migrate_server_config, with_config_object_read_lock, with_config_object_write_lock,
with_server_config_read_lock, with_server_config_write_lock,
read_server_config_snapshot, save_config, save_config_no_lock, save_config_with_opts, save_config_with_opts_quiet,
save_server_config, save_server_config_no_lock, save_server_config_snapshot,
save_server_config_snapshot_with_generation, server_config_path, try_migrate_server_config,
with_config_object_read_lock, with_config_object_write_lock, with_server_config_read_lock,
with_server_config_write_lock,
};
/// Read config bytes and metadata without treating an empty payload as absent.
pub async fn read_config_preserve_empty_with_metadata_opts(
api: std::sync::Arc<crate::store::ECStore>,
file: &str,
opts: &crate::object_api::ObjectOptions,
) -> crate::error::Result<(Vec<u8>, crate::object_api::ObjectInfo)> {
crate::config::com::read_config_preserve_empty_with_metadata_opts(api, file, opts).await
}
pub async fn read_config_limited_preserve_empty_with_metadata_opts(
api: std::sync::Arc<crate::store::ECStore>,
file: &str,
opts: &crate::object_api::ObjectOptions,
max_bytes: usize,
) -> crate::error::Result<(Vec<u8>, crate::object_api::ObjectInfo)> {
crate::config::com::read_config_limited_preserve_empty_with_metadata_opts(api, file, opts, max_bytes).await
}
}
pub mod storageclass {
@@ -468,7 +490,7 @@ pub mod event {
pub mod global {
pub use crate::runtime::global::{
set_global_endpoints, set_global_region, set_global_rustfs_port, set_object_store_resolver, shutdown_background_services,
update_erasure_type,
update_erasure_type, wait_for_detached_mutations,
};
}
@@ -87,6 +87,7 @@ use rustfs_filemeta::{
FileInfo, FileInfoOpts, NULL_VERSION_ID, RestoreStatus, RestoreStatusOps, TRANSITION_COMPLETE, get_file_info,
is_restored_object_on_disk,
};
use rustfs_lock::{NamespaceLockGuard, error::LockError};
use rustfs_scanner_metrics::metrics::{
IlmAction, Metrics, ScannerLifecycleExpiryStateUpdate, ScannerLifecycleTransitionStateUpdate, global_metrics,
};
@@ -190,6 +191,8 @@ const TIER_FREE_VERSION_RECOVERY_BASE_INTERVAL: StdDuration = StdDuration::from_
// before another full sweep can discover work persisted by another node.
const TIER_FREE_VERSION_RECOVERY_MAX_IDLE_INTERVAL: StdDuration = StdDuration::from_secs(10 * 60);
const TIER_FREE_VERSION_RECOVERY_JITTER_PERCENT: u64 = 10;
const TIER_FREE_VERSION_RECOVERY_LEADER_LOCK: &str = "tier-free-version-recovery/leader.lock";
const TIER_FREE_VERSION_RECOVERY_LOCK_TIMEOUT: StdDuration = StdDuration::from_secs(1);
const DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS: i64 = 5;
const EXPIRY_WORKER_QUEUE_CAPACITY: usize = 1000;
const DEFAULT_MANUAL_TRANSITION_JOB_RECOVERY_LIMIT: usize = 100;
@@ -1441,13 +1444,24 @@ pub(crate) async fn enqueue_committed_free_versions(api: &ECStore, free_versions
queued
}
async fn enqueue_recovered_free_version_with_state(state: &Arc<RwLock<ExpiryState>>, oi: ObjectInfo) -> bool {
async fn enqueue_recovered_free_version_with_state(
state: &Arc<RwLock<ExpiryState>>,
oi: ObjectInfo,
cancel: &CancellationToken,
) -> bool {
let task = FreeVersionTask(oi);
let hash = task.op_hash();
let (wrkr, stats) = {
let state = state.read().await;
let state = select! {
biased;
_ = cancel.cancelled() => return false,
state = state.read() => state,
};
(state.get_worker_ch(hash), Arc::clone(&state.stats))
};
if cancel.is_cancelled() {
return false;
}
let Some(wrkr) = wrkr else {
stats.increment_missed_freevers_tasks();
stats.record_scanner_expiry_state();
@@ -1497,9 +1511,13 @@ fn set_recovered_free_version_enqueue_observer(
RecoveredFreeVersionEnqueueObserverGuard
}
pub async fn enqueue_recovered_free_version(api: &ECStore, oi: ObjectInfo) -> bool {
pub(super) async fn enqueue_recovered_free_version_with_cancel(
api: &ECStore,
oi: ObjectInfo,
cancel: &CancellationToken,
) -> bool {
let expiry_state = api.ctx.expiry_state();
let queued = enqueue_recovered_free_version_with_state(&expiry_state, oi).await;
let queued = enqueue_recovered_free_version_with_state(&expiry_state, oi, cancel).await;
#[cfg(test)]
if let Some(observer) = RECOVERED_FREE_VERSION_ENQUEUE_OBSERVER
@@ -2921,6 +2939,20 @@ fn resolve_tier_free_version_recovery_enabled(value: Result<String, env::VarErro
}
fn spawn_tier_free_version_recovery_once(api: Arc<ECStore>, started: &OnceLock<()>) -> Option<JoinHandle<()>> {
spawn_tier_free_version_recovery_once_with_options(
api,
started,
TIER_FREE_VERSION_RECOVERY_LEADER_LOCK.to_string(),
tier_free_version_recovery_leader_retry_delay,
)
}
fn spawn_tier_free_version_recovery_once_with_options(
api: Arc<ECStore>,
started: &OnceLock<()>,
leader_lock_name: String,
retry_delay: fn() -> StdDuration,
) -> Option<JoinHandle<()>> {
if !tier_free_version_recovery_enabled() {
warn!(
event = EVENT_LIFECYCLE_WORKER_STATE,
@@ -2937,20 +2969,43 @@ fn spawn_tier_free_version_recovery_once(api: Arc<ECStore>, started: &OnceLock<(
Some(tokio::spawn(async move {
let cancel_token = api.ctx.background_cancel_token().unwrap_or_default();
let expiry_state = api.ctx.expiry_state();
run_tier_free_version_recovery_loop(
let acquire_api = Arc::clone(&api);
let acquire_lock_name = Arc::<str>::from(leader_lock_name);
run_tier_free_version_recovery_coordinator(
cancel_token,
expiry_state,
jitter_tier_free_version_recovery_delay,
move |bucket_marker, object_marker, recovery_cancel| {
retry_delay,
move || {
let api = Arc::clone(&acquire_api);
let lock_name = Arc::clone(&acquire_lock_name);
async move {
let lock = api.new_ns_lock(RUSTFS_META_BUCKET, &lock_name).await?;
match lock.get_write_lock_quiet(TIER_FREE_VERSION_RECOVERY_LOCK_TIMEOUT).await {
Ok(guard) => Ok(Some(guard)),
Err(LockError::Timeout { .. }) => Ok(None),
Err(err) => Err(err.into()),
}
}
},
move |leader_cancel| {
let api = Arc::clone(&api);
async move {
recover_tier_free_versions_with_cancel(
api,
DEFAULT_FREE_VERSION_RECOVERY_LIMIT,
bucket_marker,
object_marker,
recovery_cancel,
run_tier_free_version_recovery_loop(
leader_cancel,
api.ctx.expiry_state(),
jitter_tier_free_version_recovery_delay,
move |bucket_marker, object_marker, recovery_cancel| {
let api = Arc::clone(&api);
async move {
recover_tier_free_versions_with_cancel(
api,
DEFAULT_FREE_VERSION_RECOVERY_LIMIT,
bucket_marker,
object_marker,
recovery_cancel,
)
.await
}
},
)
.await
}
@@ -2960,6 +3015,86 @@ fn spawn_tier_free_version_recovery_once(api: Arc<ECStore>, started: &OnceLock<(
}))
}
fn tier_free_version_recovery_leader_retry_delay() -> StdDuration {
StdDuration::from_secs(rand::rng().random_range(15..=30))
}
async fn run_tier_free_version_recovery_coordinator<A, AF, R, RF>(
cancel: CancellationToken,
retry_delay: fn() -> StdDuration,
mut acquire: A,
mut recover: R,
) where
A: FnMut() -> AF,
AF: Future<Output = crate::error::Result<Option<NamespaceLockGuard>>>,
R: FnMut(CancellationToken) -> RF,
RF: Future<Output = ()>,
{
loop {
let acquired = select! {
biased;
_ = cancel.cancelled() => return,
result = acquire() => result,
};
match acquired {
Ok(Some(mut guard)) => {
let leader_cancel = cancel.child_token();
// Hold ownership across every page, cooldown and idle wait. A
// follower taking over starts a fresh sweep of persisted markers.
let recovery = recover(leader_cancel.clone());
tokio::pin!(recovery);
debug!(
event = EVENT_LIFECYCLE_WORKER_STATE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
state = "free_version_recovery_leader_acquired",
lock_name = TIER_FREE_VERSION_RECOVERY_LEADER_LOCK,
"Tier free-version recovery leader acquired"
);
select! {
biased;
_ = cancel.cancelled() => {
leader_cancel.cancel();
recovery.await;
}
_ = guard.lock_lost_notified() => {
debug!(
event = EVENT_LIFECYCLE_WORKER_STATE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
state = "free_version_recovery_leader_lost",
lock_name = TIER_FREE_VERSION_RECOVERY_LEADER_LOCK,
"Tier free-version recovery leader lost"
);
// Dropping the recovery future would detach its active
// walk. Cancel it and poll the same future through cleanup.
leader_cancel.cancel();
recovery.await;
}
_ = &mut recovery => {}
}
leader_cancel.cancel();
guard.release();
}
Ok(None) => {}
Err(err) => warn!(
event = EVENT_LIFECYCLE_WORKER_STATE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
state = "free_version_recovery_leader_acquire_failed",
lock_name = TIER_FREE_VERSION_RECOVERY_LEADER_LOCK,
error = %err,
"Tier free-version recovery leader acquisition failed"
),
}
select! {
biased;
_ = cancel.cancelled() => return,
_ = tokio::time::sleep(retry_delay()) => {}
}
}
}
async fn run_tier_free_version_recovery_loop<F, Fut>(
cancel_token: CancellationToken,
expiry_state: Arc<RwLock<ExpiryState>>,
@@ -2969,7 +3104,11 @@ async fn run_tier_free_version_recovery_loop<F, Fut>(
F: FnMut(Option<String>, Option<String>, CancellationToken) -> Fut,
Fut: Future<Output = crate::error::Result<FreeVersionRecoveryStats>>,
{
let recovery_notify = Arc::clone(&expiry_state.read().await.recovery_notify);
let recovery_notify = select! {
biased;
_ = cancel_token.cancelled() => return,
state = expiry_state.read() => Arc::clone(&state.recovery_notify),
};
let mut schedule = TierFreeVersionRecoverySchedule::default();
loop {
@@ -2992,7 +3131,11 @@ async fn run_tier_free_version_recovery_loop<F, Fut>(
schedule.record_success(&stats);
rustfs_io_metrics::record_stage_duration("lifecycle_free_version_recovery", elapsed.as_secs_f64() * 1000.0);
let (pending_tasks, active_tasks) = {
let state = expiry_state.read().await;
let state = select! {
biased;
_ = cancel_token.cancelled() => return,
state = expiry_state.read() => state,
};
(state.pending_tasks(), state.stats.active_tasks())
};
debug!(
@@ -5652,8 +5795,10 @@ async fn apply_expiry_on_non_transitioned_objects_with_lock_lost_signal(
} else {
None
};
// The delete owns the publication guard from here on, so a cancelled
// expiry worker cannot let table-bucket publication overtake it.
let mut dobj = match api
.delete_object_with_tier_delete_journal(&oi.bucket, &encode_dir_object(&oi.name), opts)
.delete_object_with_tier_delete_journal_and_guards(&oi.bucket, &encode_dir_object(&oi.name), opts, [publication_guard])
.await
{
Ok(dobj) => dobj,
@@ -5904,10 +6049,10 @@ mod tests {
merge_stale_multipart_candidate, persist_manual_transition_job_progress_if_owned,
persist_manual_transition_page_checkpoint, recover_manual_transition_job, recover_manual_transition_jobs,
resolve_tier_free_version_recovery_enabled, resolve_transition_queue_capacity, resolve_transition_queue_send_timeout,
resolve_transition_worker_count, resolve_transition_workers_absolute_max, run_tier_free_version_recovery_loop,
select_restore_s3_location, set_lifecycle_observability_observer, set_recovered_free_version_enqueue_observer,
should_defer_date_expiry_for_recent_config_update, transitioned_cleanup_tuple, transitioned_object_delete_opts,
wait_for_tier_free_version_recovery,
resolve_transition_worker_count, resolve_transition_workers_absolute_max, run_tier_free_version_recovery_coordinator,
run_tier_free_version_recovery_loop, select_restore_s3_location, set_lifecycle_observability_observer,
set_recovered_free_version_enqueue_observer, should_defer_date_expiry_for_recent_config_update,
transitioned_cleanup_tuple, transitioned_object_delete_opts, wait_for_tier_free_version_recovery,
};
#[cfg(feature = "test-util")]
use super::{delete_free_version_remote_object_then, encode_dir_object, get_transitioned_object_reader_with_tier_manager};
@@ -6001,6 +6146,231 @@ mod tests {
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
fn recovery_test_lock() -> (Arc<rustfs_lock::NamespaceLock>, Vec<Arc<dyn rustfs_lock::LockClient>>) {
let clients: Vec<Arc<dyn rustfs_lock::LockClient>> = (0..3)
.map(|_| {
Arc::new(rustfs_lock::LocalClient::with_manager(Arc::new(rustfs_lock::GlobalLockManager::new())))
as Arc<dyn rustfs_lock::LockClient>
})
.collect();
let lock =
rustfs_lock::NamespaceLock::with_clients_and_quorum(format!("recovery-test-{}", Uuid::new_v4()), clients.clone(), 2);
(Arc::new(lock), clients)
}
async fn acquire_recovery_test_lock(
lock: Arc<rustfs_lock::NamespaceLock>,
owner: String,
) -> crate::error::Result<Option<rustfs_lock::NamespaceLockGuard>> {
// Keep this lease longer than the virtual-time cooldown/idle advances;
// production uses the namespace wrapper's renewable 30-second lease.
lock.lock_guard(
rustfs_lock::ObjectKey::new(crate::disk::RUSTFS_META_BUCKET, super::TIER_FREE_VERSION_RECOVERY_LEADER_LOCK),
&owner,
StdDuration::from_millis(5),
StdDuration::from_secs(3600),
)
.await
.map_err(Into::into)
}
fn recovery_test_retry_delay() -> StdDuration {
StdDuration::from_secs(15)
}
#[tokio::test(start_paused = true)]
#[serial]
async fn tier_free_version_recovery_eight_coordinators_hold_one_leader_and_take_over() {
let (lock, _clients) = recovery_test_lock();
let (pages_tx, mut pages_rx) = tokio::sync::mpsc::unbounded_channel();
let mut tasks = Vec::new();
let mut cancels = Vec::new();
for node in 0..8 {
let cancel = CancellationToken::new();
cancels.push(cancel.clone());
let lock = Arc::clone(&lock);
let pages_tx = pages_tx.clone();
tasks.push(tokio::spawn(async move {
run_tier_free_version_recovery_coordinator(
cancel,
recovery_test_retry_delay,
move || acquire_recovery_test_lock(Arc::clone(&lock), format!("node-{node}")),
move |leader_cancel| {
let pages_tx = pages_tx.clone();
async move {
let mut pages = 0;
run_tier_free_version_recovery_loop(
leader_cancel,
ExpiryState::new(),
std::convert::identity,
move |bucket, object, _| {
pages += 1;
pages_tx.send((node, bucket, object)).expect("page observer should be open");
std::future::ready(Ok(free_version_recovery_stats(0, 0, pages == 1)))
},
)
.await;
}
},
)
.await;
}));
}
let (leader, bucket, object) = pages_rx.recv().await.expect("one coordinator should start recovery");
assert!(bucket.is_none() && object.is_none());
// The same leader must survive both a truncated page's cooldown and
// complete idle sweeps. All other coordinators keep probing the lock.
let mut observed_pages = 1;
for _ in 0..27 {
tokio::time::advance(StdDuration::from_secs(60)).await;
for _ in 0..100 {
tokio::task::yield_now().await;
}
while let Ok((node, bucket, object)) = pages_rx.try_recv() {
assert_eq!(node, leader, "followers must not repeat a namespace sweep");
if observed_pages == 1 {
assert_eq!(bucket.as_deref(), Some("bucket"));
assert_eq!(object.as_deref(), Some("object"));
} else {
assert!(bucket.is_none() && object.is_none(), "complete sweeps should reset their cursors");
}
observed_pages += 1;
}
}
assert!(observed_pages >= 7, "the leader should finish a full ten-minute idle wait");
cancels[leader].cancel();
let (successor, bucket, object) = tokio::time::timeout(StdDuration::from_secs(60), pages_rx.recv())
.await
.expect("a follower should take over after leader shutdown")
.expect("page observer should remain open");
assert_ne!(successor, leader);
assert!(bucket.is_none() && object.is_none(), "a new leader must restart discovery safely");
for cancel in cancels {
cancel.cancel();
}
for task in tasks {
task.await.expect("all coordinators should shut down cleanly");
}
}
#[tokio::test(start_paused = true)]
#[serial]
async fn tier_free_version_recovery_lock_loss_waits_for_active_page_cleanup() {
let (lock, _clients) = recovery_test_lock();
let cancel = CancellationToken::new();
let loop_cancel = cancel.clone();
let (page_tx, mut page_rx) = tokio::sync::mpsc::unbounded_channel();
let cleanup_started = Arc::new(tokio::sync::Notify::new());
let finish_cleanup = Arc::new(tokio::sync::Notify::new());
let observed_cleanup = Arc::clone(&cleanup_started);
let release_cleanup = Arc::clone(&finish_cleanup);
let task = tokio::spawn(async move {
run_tier_free_version_recovery_coordinator(
loop_cancel,
recovery_test_retry_delay,
move || acquire_recovery_test_lock(Arc::clone(&lock), "old-leader".to_string()),
move |leader_cancel| {
let page_tx = page_tx.clone();
let cleanup_started = Arc::clone(&cleanup_started);
let finish_cleanup = Arc::clone(&finish_cleanup);
async move {
run_tier_free_version_recovery_loop(
leader_cancel,
ExpiryState::new(),
std::convert::identity,
move |_, _, page_cancel| {
let cleanup_started = Arc::clone(&cleanup_started);
let finish_cleanup = Arc::clone(&finish_cleanup);
page_tx.send(()).expect("page observer should remain open");
async move {
page_cancel.cancelled().await;
cleanup_started.notify_one();
finish_cleanup.notified().await;
Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "cancelled page").into())
}
},
)
.await;
}
},
)
.await;
});
page_rx.recv().await.expect("leader should start an active page");
// Let authoritative backend leases expire before the next heartbeat
// can renew them. A failed refresh must cancel the active page.
tokio::time::advance(StdDuration::from_secs(3600)).await;
tokio::time::timeout(StdDuration::from_secs(1), observed_cleanup.notified())
.await
.expect("expired backend leases must trigger recovery cancellation");
assert!(!task.is_finished(), "lock loss must wait for the active page's cleanup");
assert!(page_rx.try_recv().is_err(), "the old leader must not start another page");
cancel.cancel();
release_cleanup.notify_one();
task.await.expect("coordinator should finish cleanup before exiting");
}
#[tokio::test(start_paused = true)]
async fn tier_free_version_recovery_coordinator_retries_without_scanning_after_lock_error() {
let cancel = CancellationToken::new();
let loop_cancel = cancel.clone();
let attempts = Arc::new(AtomicUsize::new(0));
let observed_attempts = Arc::clone(&attempts);
let task = tokio::spawn(async move {
run_tier_free_version_recovery_coordinator(
loop_cancel,
recovery_test_retry_delay,
move || {
observed_attempts.fetch_add(1, Ordering::SeqCst);
std::future::ready(Err(rustfs_lock::LockError::InsufficientNodes {
required: 2,
available: 1,
}
.into()))
},
|_| async { panic!("a node without ownership must never start discovery") },
)
.await;
});
tokio::task::yield_now().await;
assert_eq!(attempts.load(Ordering::SeqCst), 1);
tokio::time::advance(StdDuration::from_secs(14)).await;
assert_eq!(attempts.load(Ordering::SeqCst), 1, "lock errors must not cause a tight retry loop");
tokio::time::advance(StdDuration::from_secs(1)).await;
tokio::task::yield_now().await;
assert_eq!(attempts.load(Ordering::SeqCst), 2);
cancel.cancel();
task.await.expect("a follower should respond to shutdown during retry wait");
}
#[tokio::test]
async fn tier_free_version_recovery_enqueue_cancels_while_waiting_for_state_lock() {
let state = ExpiryState::new();
let (tx, mut rx) = tokio::sync::mpsc::channel(1);
let mut held = state.write().await;
held.tasks_tx.push(tx);
let cancel = CancellationToken::new();
let enqueue_cancel = cancel.clone();
let enqueue_state = Arc::clone(&state);
let enqueue = tokio::spawn(async move {
enqueue_recovered_free_version_with_state(&enqueue_state, ObjectInfo::default(), &enqueue_cancel).await
});
tokio::task::yield_now().await;
assert!(!enqueue.is_finished(), "enqueue should be blocked on the state lock");
cancel.cancel();
let queued = tokio::time::timeout(StdDuration::from_secs(1), enqueue)
.await
.expect("cancellation must not wait for the state write guard")
.expect("enqueue should return cleanly");
assert!(!queued);
assert_eq!(held.pending_tasks(), 0);
assert_eq!(held.stats.missed_free_vers_tasks(), 0);
assert!(rx.try_recv().is_err(), "a cancelled recovery must not enqueue a task");
drop(held);
assert!(!enqueue_recovered_free_version_with_state(&state, ObjectInfo::default(), &cancel).await);
assert!(rx.try_recv().is_err(), "cancellation must win even when the lock is available");
}
fn free_version_recovery_stats(enqueued: usize, failed: usize, truncated: bool) -> FreeVersionRecoveryStats {
FreeVersionRecoveryStats {
scanned: enqueued.saturating_add(failed),
@@ -7680,7 +8050,7 @@ mod tests {
assert_eq!(state.read().await.stats.pending_tasks(), 1);
// The single-slot queue is full for both enqueue paths.
assert!(!enqueue_recovered_free_version_with_state(&state, oi.clone()).await);
assert!(!enqueue_recovered_free_version_with_state(&state, oi.clone(), &CancellationToken::new()).await);
assert_eq!(state.read().await.stats.pending_tasks(), 1);
assert_eq!(state.read().await.stats.missed_free_vers_tasks(), 1);
@@ -7712,7 +8082,7 @@ mod tests {
..Default::default()
};
let queued = enqueue_recovered_free_version_with_state(&state, oi).await;
let queued = enqueue_recovered_free_version_with_state(&state, oi, &CancellationToken::new()).await;
let state = state.read().await;
assert!(!queued);
@@ -7841,8 +8211,8 @@ mod tests {
..Default::default()
};
let first = enqueue_recovered_free_version_with_state(&state, oi.clone()).await;
let second = enqueue_recovered_free_version_with_state(&state, oi).await;
let first = enqueue_recovered_free_version_with_state(&state, oi.clone(), &CancellationToken::new()).await;
let second = enqueue_recovered_free_version_with_state(&state, oi, &CancellationToken::new()).await;
let state = state.read().await;
assert!(first);
@@ -7943,7 +8313,7 @@ mod tests {
};
assert!(
super::enqueue_recovered_free_version(&ecstore, oi).await,
super::enqueue_recovered_free_version_with_cancel(&ecstore, oi, &CancellationToken::new()).await,
"the resized production worker queue should accept the task"
);
stop_tx.send(None).await.expect("worker stop signal should be delivered");
@@ -13837,6 +14207,10 @@ mod tests {
.await;
}
fn fast_tier_free_version_recovery_leader_retry_delay() -> StdDuration {
StdDuration::from_millis(10)
}
#[tokio::test]
#[serial]
async fn tier_free_version_recovery_production_entrypoint_enqueues_seeded_item() {
@@ -13852,8 +14226,13 @@ mod tests {
seed_recoverable_free_version(&disk_paths, &bucket, object, None, None).await;
let started = OnceLock::new();
let recovery = super::spawn_tier_free_version_recovery_once(Arc::clone(&ecstore), &started)
.expect("production recovery entrypoint should start once");
let recovery = super::spawn_tier_free_version_recovery_once_with_options(
Arc::clone(&ecstore),
&started,
format!("tier-free-version-recovery/test/{}", Uuid::new_v4()),
fast_tier_free_version_recovery_leader_retry_delay,
)
.expect("production recovery entrypoint should start once");
let task = tokio::time::timeout(StdDuration::from_secs(30), async { recovery_rx.lock().await.recv().await })
.await
.expect("production recovery entrypoint should enqueue the seeded item")
@@ -13896,13 +14275,14 @@ mod tests {
};
let mut recovery_rx = recovery_rx.lock().await;
assert!(
super::enqueue_recovered_free_version(
super::enqueue_recovered_free_version_with_cancel(
&ecstore,
ObjectInfo {
bucket: "prefill".to_string(),
name: "prefill".to_string(),
..Default::default()
},
&CancellationToken::new(),
)
.await,
"the production recovery queue should accept its first task"
@@ -20,7 +20,7 @@ use std::time::Duration;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::bucket::lifecycle::bucket_lifecycle_ops::enqueue_recovered_free_version;
use crate::bucket::lifecycle::bucket_lifecycle_ops::enqueue_recovered_free_version_with_cancel;
use crate::disk::RUSTFS_META_BUCKET;
use crate::error::Result;
use crate::object_api::ObjectInfo;
@@ -201,7 +201,11 @@ pub(super) async fn recover_tier_free_versions_with_cancel(
return Err(tier_free_version_recovery_cancelled());
}
retry_cursor.visit(&oi);
if !record_recovered_free_version_enqueue(&mut stats, enqueue_recovered_free_version(&api, oi).await) {
let queued = enqueue_recovered_free_version_with_cancel(&api, oi, &cancel_token).await;
if cancel_token.is_cancelled() {
return Err(tier_free_version_recovery_cancelled());
}
if !record_recovered_free_version_enqueue(&mut stats, queued) {
let (bucket_marker, object_marker) = retry_cursor.retry_markers();
stats.truncated = true;
stats.next_bucket_marker = bucket_marker;
+25 -5
View File
@@ -1153,7 +1153,7 @@ async fn acquire_transaction_lock_with_sys(
/// agree on, so renaming it would leave a mixed-version cluster with two
/// disjoint keys — and old and new nodes would stop excluding each other on
/// the very writes that are serialized today.
fn bucket_metadata_transaction_lock_key(bucket: &str) -> String {
pub(crate) fn bucket_metadata_transaction_lock_key(bucket: &str) -> String {
format!("bucket-targets/{bucket}/transaction.lock")
}
@@ -1292,14 +1292,34 @@ pub(crate) async fn get_object_lock_config_state_in(
pub(crate) async fn get_object_lock_config_and_incarnation_from_disk_in(
ctx: &crate::runtime::instance::InstanceContext,
bucket: &str,
) -> Result<(ObjectLockConfigState, Uuid, OffsetDateTime)> {
get_object_lock_config_and_incarnation_from_disk_with_guard_in(ctx, bucket, None).await
}
/// Bucket deletion already owns the namespace/lifecycle fences. Reuse its
/// live guard rather than recursively acquiring the bucket namespace lock.
/// The caller must also hold the metadata transaction read lock.
pub(crate) async fn get_object_lock_config_and_incarnation_from_disk_with_guard_in(
ctx: &crate::runtime::instance::InstanceContext,
bucket: &str,
guard: Option<&rustfs_lock::NamespaceLockGuard>,
) -> Result<(ObjectLockConfigState, Uuid, OffsetDateTime)> {
let bucket_meta_sys_lock = bucket_metadata_sys_of(ctx)?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await.clone();
match bucket_meta_sys
.read_authoritative_metadata_from_disk_under_transaction_lock(bucket)
.await?
{
let authority = match guard {
Some(guard) => {
bucket_meta_sys
.read_authoritative_metadata_from_disk_under_guard(bucket, guard)
.await?
}
None => {
bucket_meta_sys
.read_authoritative_metadata_from_disk_under_transaction_lock(bucket)
.await?
}
};
match authority {
BucketMetadataAuthority::Authoritative(metadata)
if metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() =>
{
+40 -6
View File
@@ -32,7 +32,10 @@ pub enum QuotaType {
Hard,
}
pub(crate) const QUOTA_RESERVATION_PROTOCOL_V1: u32 = 1;
pub const QUOTA_RESERVATION_PROTOCOL_V1: u32 = 1;
/// Sharded durable reservations. Older nodes reject this protocol instead of
/// silently falling back to the single-ledger protocol.
pub const QUOTA_RESERVATION_PROTOCOL_V2: u32 = 2;
/// Bucket quota configuration. quota_type defaults to Hard when omitted.
#[derive(Debug, Default, Clone, PartialEq)]
@@ -89,7 +92,10 @@ impl<'de> Deserialize<'de> for BucketQuota {
D: Deserializer<'de>,
{
let wire = BucketQuotaWire::deserialize(deserializer)?;
let quota = if wire.reservation_protocol == Some(QUOTA_RESERVATION_PROTOCOL_V1) {
let quota = if matches!(
wire.reservation_protocol,
Some(QUOTA_RESERVATION_PROTOCOL_V1) | Some(QUOTA_RESERVATION_PROTOCOL_V2)
) {
Some(
wire.reservation_quota
.ok_or_else(|| D::Error::custom("reservation_quota is required for reservation protocol v1"))?,
@@ -129,17 +135,33 @@ impl BucketQuota {
}
}
/// Creates a quota using the sharded durable reservation protocol.
/// Deployment must complete the mixed-version rollout before enabling it;
/// older nodes fail closed when they see protocol v2.
pub fn new_sharded(quota: u64) -> Self {
let mut config = Self::new(Some(quota));
config.reservation_protocol = Some(QUOTA_RESERVATION_PROTOCOL_V2);
config
}
pub fn get_quota_limit(&self) -> Option<u64> {
self.quota
}
pub fn uses_durable_reservations(&self) -> bool {
self.reservation_protocol == Some(QUOTA_RESERVATION_PROTOCOL_V1)
matches!(
self.reservation_protocol,
Some(QUOTA_RESERVATION_PROTOCOL_V1) | Some(QUOTA_RESERVATION_PROTOCOL_V2)
)
}
pub fn uses_sharded_reservations(&self) -> bool {
self.reservation_protocol == Some(QUOTA_RESERVATION_PROTOCOL_V2)
}
pub fn has_unsupported_reservation_protocol(&self) -> bool {
self.reservation_protocol
.is_some_and(|version| version != QUOTA_RESERVATION_PROTOCOL_V1)
.is_some_and(|version| !matches!(version, QUOTA_RESERVATION_PROTOCOL_V1 | QUOTA_RESERVATION_PROTOCOL_V2))
}
pub fn check_operation_allowed(&self, current_usage: u64, operation_size: u64) -> bool {
@@ -322,9 +344,21 @@ mod tests {
}
#[test]
fn unknown_reservation_protocol_does_not_activate_v1() {
fn sharded_quota_roundtrips_with_authoritative_reservation_quota() {
let quota = BucketQuota::new_sharded(4096);
let json = serde_json::to_value(&quota).expect("sharded quota should serialize");
assert_eq!(json["quota"], 0);
assert_eq!(json["reservation_protocol"], QUOTA_RESERVATION_PROTOCOL_V2);
assert_eq!(json["reservation_quota"], 4096);
let restored: BucketQuota = serde_json::from_value(json).expect("sharded quota should parse");
assert_eq!(restored.quota, Some(4096));
assert!(restored.uses_sharded_reservations());
}
#[test]
fn unknown_reservation_protocol_does_not_activate_any_known_protocol() {
let quota: BucketQuota =
serde_json::from_str(r#"{"quota":0,"quota_type":"Hard","reservation_protocol":2,"reservation_quota":2048}"#)
serde_json::from_str(r#"{"quota":0,"quota_type":"Hard","reservation_protocol":3,"reservation_quota":2048}"#)
.expect("future protocol should remain parseable");
assert!(!quota.uses_durable_reservations());
File diff suppressed because it is too large Load Diff
+76 -8
View File
@@ -495,10 +495,22 @@ pub(crate) async fn read_config_preserve_empty<S>(api: Arc<S>, file: &str) -> Re
where
S: EcstoreObjectIO,
{
let (data, _obj) = read_config_with_metadata_inner(api, file, &ObjectOptions::default(), true, None).await?;
let (data, _obj) = read_config_preserve_empty_with_metadata_opts(api, file, &ObjectOptions::default()).await?;
Ok(data)
}
/// Preserve an empty config payload, its metadata, and caller-supplied read options.
pub(crate) async fn read_config_preserve_empty_with_metadata_opts<S>(
api: Arc<S>,
file: &str,
opts: &ObjectOptions,
) -> Result<(Vec<u8>, ObjectInfo)>
where
S: EcstoreObjectIO,
{
read_config_with_metadata_inner(api, file, opts, true, None).await
}
pub async fn read_config_no_lock<S>(api: Arc<S>, file: &str) -> Result<Vec<u8>>
where
S: ObjectIO<
@@ -527,15 +539,13 @@ pub(crate) async fn read_config_no_lock_preserve_empty_with_metadata<S>(api: Arc
where
S: EcstoreObjectIO,
{
read_config_with_metadata_inner(
read_config_preserve_empty_with_metadata_opts(
api,
file,
&ObjectOptions {
no_lock: true,
..Default::default()
},
true,
None,
)
.await
}
@@ -2815,9 +2825,10 @@ mod tests {
config_task_join_error, configs_semantically_equal, decode_server_config_blob, encode_server_config_blob,
heal_config_descriptor, is_standard_object_server_config, lookup_configs, map_system_metadata_write_error,
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_config_with_opts_inner,
save_server_config, save_server_config_snapshot, save_server_config_snapshot_with_generation,
server_config_transaction_lock_path, should_warn_ignored_scalar_section, storage_class_kvs_mut,
read_config_preserve_empty_with_metadata_opts, read_config_with_metadata, read_config_without_migrate,
read_server_config_snapshot, save_config_with_opts_inner, save_server_config, save_server_config_snapshot,
save_server_config_snapshot_with_generation, 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::{RUSTFS_META_BUCKET, endpoint::Endpoint};
@@ -5065,6 +5076,7 @@ mod tests {
heal_replacement: Option<Vec<u8>>,
heal_calls: AtomicUsize,
write_calls: AtomicUsize,
last_get_no_lock: AtomicBool,
last_put_no_lock: AtomicBool,
last_put_preconditions: Mutex<Option<HTTPPreconditions>>,
revision: AtomicUsize,
@@ -5080,6 +5092,7 @@ mod tests {
heal_replacement,
heal_calls: AtomicUsize::new(0),
write_calls: AtomicUsize::new(0),
last_get_no_lock: AtomicBool::new(false),
last_put_no_lock: AtomicBool::new(false),
last_put_preconditions: Mutex::new(None),
revision: AtomicUsize::new(1),
@@ -5135,8 +5148,9 @@ mod tests {
_object: &str,
_range: Option<HTTPRangeSpec>,
_h: HeaderMap,
_opts: &ObjectOptions,
opts: &ObjectOptions,
) -> Result<GetObjectReader> {
self.last_get_no_lock.store(opts.no_lock, Ordering::SeqCst);
let data = match &*self.state.lock().expect("state lock poisoned") {
RecoveryReadState::Missing => return Err(Error::ConfigNotFound),
RecoveryReadState::Blob(data) => data.clone(),
@@ -5431,6 +5445,60 @@ mod tests {
assert!(data.is_empty());
}
#[tokio::test]
async fn read_config_preserve_empty_metadata_keeps_empty_and_options() {
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(Vec::new()), None));
for no_lock in [false, true] {
let opts = ObjectOptions {
no_lock,
..Default::default()
};
let (data, info) = read_config_preserve_empty_with_metadata_opts(store.clone(), "config/empty.json", &opts)
.await
.expect("an existing empty config must retain its payload and metadata");
assert!(data.is_empty());
assert_eq!(info.size, 0);
assert_eq!(info.actual_size, 0);
assert_eq!(info.etag.as_deref(), Some("config-1"));
assert_eq!(info.data_dir, Some(uuid::Uuid::from_u128(1)));
assert_eq!(store.last_get_no_lock.load(Ordering::SeqCst), no_lock);
}
let err = read_config(store, "config/empty.json")
.await
.expect_err("the existing reader must still treat empty configs as missing");
assert!(matches!(err, Error::ConfigNotFound));
}
#[tokio::test]
async fn read_config_preserve_empty_metadata_keeps_payload_and_metadata() {
let payload = b"original config bytes".to_vec();
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(payload.clone()), None));
let (data, info) =
read_config_preserve_empty_with_metadata_opts(store, "config/nonempty.json", &ObjectOptions::default())
.await
.expect("a nonempty config must retain its payload and metadata");
assert_eq!(data, payload);
let size = i64::try_from(payload.len()).expect("test payload size fits i64");
assert_eq!(info.size, size);
assert_eq!(info.actual_size, size);
assert_eq!(info.etag.as_deref(), Some("config-1"));
assert_eq!(info.data_dir, Some(uuid::Uuid::from_u128(1)));
}
#[tokio::test]
async fn read_config_preserve_empty_metadata_preserves_read_errors() {
let missing = Arc::new(RecoveryMockStore::new(RecoveryReadState::Missing, None));
let err = read_config_preserve_empty_with_metadata_opts(missing, "config/missing.json", &ObjectOptions::default())
.await
.expect_err("physical absence must remain ConfigNotFound");
assert!(matches!(err, Error::ConfigNotFound));
let quorum = Arc::new(RecoveryMockStore::new(RecoveryReadState::QuorumError, None));
let err = read_config_preserve_empty_with_metadata_opts(quorum, "config/quorum.json", &ObjectOptions::default())
.await
.expect_err("a quorum failure must retain its typed read error");
assert!(matches!(err, Error::ErasureReadQuorum));
}
#[async_trait::async_trait]
impl StorageAdminApi for RecoveryMockStore {
type BackendInfo = rustfs_madmin::BackendInfo;
+75 -1
View File
@@ -738,7 +738,12 @@ where
.filter(|etag| !etag.is_empty())
.map(str::to_owned)
.ok_or_else(|| Error::other("data usage snapshot has no ETag"))?;
let mut data_usage_info = parse_usage_snapshot(&reader.read_all().await?)?;
let mut data_usage_info = parse_usage_snapshot(
&reader
.read_all()
.await
.map_err(|err| map_data_usage_metadata_read_error(err, object))?,
)?;
populate_backward_compatible_usage_maps(&mut data_usage_info);
validate_complete_usage_snapshot(&mut data_usage_info);
Ok(Some((data_usage_info, revision)))
@@ -2997,6 +3002,61 @@ mod tests {
}
}
#[derive(Debug)]
struct UsageSnapshotReadFailureStore;
struct UsageSnapshotFailureReader;
impl tokio::io::AsyncRead for UsageSnapshotFailureReader {
fn poll_read(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
_buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Err(std::io::Error::other(Error::VolumeNotFound)))
}
}
#[async_trait::async_trait]
impl crate::storage_api_contracts::object::ObjectIO for UsageSnapshotReadFailureStore {
type Error = Error;
type RangeSpec = crate::storage_api_contracts::range::HTTPRangeSpec;
type HeaderMap = http::HeaderMap;
type ObjectOptions = ObjectOptions;
type ObjectInfo = ObjectInfo;
type GetObjectReader = crate::object_api::GetObjectReader;
type PutObjectReader = PutObjReader;
async fn get_object_reader(
&self,
_bucket: &str,
_object: &str,
_range: Option<Self::RangeSpec>,
_headers: Self::HeaderMap,
_opts: &Self::ObjectOptions,
) -> Result<Self::GetObjectReader, Self::Error> {
Ok(crate::object_api::GetObjectReader {
stream: Box::new(UsageSnapshotFailureReader),
object_info: ObjectInfo {
etag: Some("usage-revision".to_string()),
..Default::default()
},
buffered_body: None,
body_source: Default::default(),
})
}
async fn put_object(
&self,
_bucket: &str,
_object: &str,
_data: &mut Self::PutObjectReader,
_opts: &Self::ObjectOptions,
) -> Result<Self::ObjectInfo, Self::Error> {
unimplemented!("the read-failure fixture does not write")
}
}
/// Minimal ObjectIO backing `load_data_usage_cache` tests: records the keys
/// read and fails the first N reads with a transient (non-absence) error.
#[derive(Debug, Default)]
@@ -3161,6 +3221,20 @@ mod tests {
}
}
#[tokio::test]
async fn data_usage_removal_maps_missing_system_volume_during_snapshot_stream_read() {
let store = UsageSnapshotReadFailureStore;
let error = load_data_usage_for_bucket_removal(&store, "bucket-metadata/.usage.json")
.await
.expect_err("a system volume disappearing during body read must remain a retryable storage error");
assert_eq!(
error,
Error::InsufficientReadQuorum(RUSTFS_META_BUCKET.to_string(), "bucket-metadata/.usage.json".to_string())
);
}
#[tokio::test]
async fn load_data_usage_cache_retries_a_transient_failure() {
let name = "usage-cache";
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use rustfs_io_metrics::record_object_lock_acquire_failure;
use rustfs_lock::{LockError, NamespaceLockGuard, NamespaceLockWrapper};
use std::time::{Duration, Instant};
use tracing::Span;
@@ -21,6 +22,26 @@ const EVENT_OBJECT_NAMESPACE_LOCK: &str = "object_namespace_lock";
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_OBJECT_LOCK: &str = "object_lock";
pub(crate) fn record_lock_acquire_failure(op: &'static str, bucket: &str, object: &str, mode: &'static str, error: &LockError) {
let resource_class = if bucket == crate::disk::RUSTFS_META_MULTIPART_BUCKET {
"multipart_metadata"
} else if bucket == crate::disk::RUSTFS_META_BUCKET || bucket == ".minio.sys" {
"system_metadata"
} else if object.is_empty() || object == bucket {
"bucket_namespace"
} else {
"object_namespace"
};
let reason = match error {
LockError::Timeout { .. } => "timeout",
LockError::AlreadyLocked { .. } => "contention",
LockError::QuorumNotReached { .. } | LockError::InsufficientNodes { .. } => "quorum",
LockError::Network { .. } | LockError::Internal { .. } => "transport",
_ => "other",
};
record_object_lock_acquire_failure(op, mode, resource_class, reason);
}
/// Diagnostic lifetime only; it never owns or changes a namespace lock.
/// Keep it alongside the real guard when ownership moves to a commit tail.
#[derive(Default)]
+432 -15
View File
@@ -39,13 +39,15 @@ use std::sync::atomic::AtomicBool;
use std::{
collections::HashMap,
path::PathBuf,
pin::Pin,
sync::{
Arc, LazyLock, RwLock as StdRwLock,
atomic::{AtomicI64, AtomicU32, AtomicU64, Ordering},
},
task::{Context, Poll},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use tokio::{sync::RwLock, time};
use tokio::{io::AsyncWrite, sync::RwLock, time};
use tokio_util::sync::CancellationToken;
use tracing::{info, warn};
use uuid::Uuid;
@@ -58,6 +60,121 @@ const LOG_SUBSYSTEM_DISK: &str = "disk";
const EVENT_DISK_HEALTH_CHECK_FAILED: &str = "disk_health_check_failed";
const EVENT_DISK_RECOVERY_PROBE_STATE: &str = "disk_recovery_probe_state";
const EVENT_DISK_TIMEOUT_POLICY_FALLBACK: &str = "disk_timeout_policy_fallback";
const EVENT_DISK_OPERATION_SLOW: &str = "disk_operation_slow";
const SLOW_DISK_OPERATION_THRESHOLD: Duration = Duration::from_secs(5);
fn warn_slow_disk_operation(endpoint: &Endpoint, op: &'static str, elapsed: Duration) {
warn!(
event = EVENT_DISK_OPERATION_SLOW,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DISK,
state = "pending",
endpoint = %endpoint,
op,
elapsed_ms = elapsed.as_millis(),
threshold_ms = SLOW_DISK_OPERATION_THRESHOLD.as_millis(),
"Disk operation is still pending"
);
}
async fn observe_disk_operation<F: std::future::Future>(
endpoint: impl FnOnce() -> Endpoint,
op: &'static str,
operation: F,
) -> F::Output {
if !tracing::enabled!(tracing::Level::WARN) {
return operation.await;
}
let started = time::Instant::now();
tokio::pin!(operation);
tokio::select! {
biased;
result = &mut operation => result,
_ = time::sleep(SLOW_DISK_OPERATION_THRESHOLD) => {
warn_slow_disk_operation(&endpoint(), op, started.elapsed());
operation.await
}
}
}
struct SlowDiskWriter {
inner: crate::disk::FileWriter,
endpoint: Endpoint,
pending: Option<(&'static str, time::Instant)>,
timer: Option<Pin<Box<time::Sleep>>>,
warned: bool,
}
impl SlowDiskWriter {
fn new(inner: crate::disk::FileWriter, endpoint: Endpoint) -> Self {
Self {
inner,
endpoint,
pending: None,
timer: None,
warned: false,
}
}
fn observe<T>(&mut self, cx: &mut Context<'_>, op: &'static str, result: Poll<T>) -> Poll<T> {
if result.is_ready() {
self.pending = None;
self.warned = false;
} else if tracing::enabled!(tracing::Level::WARN) {
if self.pending.is_none_or(|(pending_op, _)| pending_op != op) {
let started = time::Instant::now();
self.pending = Some((op, started));
self.warned = false;
let deadline = started + SLOW_DISK_OPERATION_THRESHOLD;
// Reuse one timer per stream; ready writes need no timer allocation.
if let Some(timer) = &mut self.timer {
timer.as_mut().reset(deadline);
} else {
self.timer = Some(Box::pin(time::sleep_until(deadline)));
}
}
if !self.warned
&& let Some(timer) = &mut self.timer
&& std::future::Future::poll(timer.as_mut(), cx).is_ready()
&& let Some((_, started)) = self.pending
{
warn_slow_disk_operation(&self.endpoint, op, started.elapsed());
self.warned = true;
}
}
result
}
}
impl AsyncWrite for SlowDiskWriter {
fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll<std::io::Result<usize>> {
let result = Pin::new(&mut self.inner).poll_write(cx, buf);
self.observe(cx, "write", result)
}
fn poll_write_vectored(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
bufs: &[std::io::IoSlice<'_>],
) -> Poll<std::io::Result<usize>> {
let result = Pin::new(&mut self.inner).poll_write_vectored(cx, bufs);
self.observe(cx, "write_vectored", result)
}
fn is_write_vectored(&self) -> bool {
self.inner.is_write_vectored()
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
let result = Pin::new(&mut self.inner).poll_flush(cx);
self.observe(cx, "flush", result)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
let result = Pin::new(&mut self.inner).poll_shutdown(cx);
self.observe(cx, "shutdown", result)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TimeoutHealthAction {
@@ -2019,9 +2136,10 @@ impl LocalDiskWrapper {
let _waiting_guard = self.health.waiting_guard();
let _metric_waiting_guard = self.metrics.waiting_guard();
let started = Instant::now();
let operation = observe_disk_operation(|| self.endpoint(), op, operation(io_permit));
if timeout_duration == Duration::ZERO {
let result = operation(io_permit).await;
let result = operation.await;
self.metrics.record_operation_latency(op, started.elapsed());
self.record_result_error_metrics(&result);
if result.is_ok() {
@@ -2031,7 +2149,7 @@ impl LocalDiskWrapper {
return result;
}
// Execute the operation with timeout
let result = tokio::time::timeout(timeout_duration, operation(io_permit)).await;
let result = tokio::time::timeout(timeout_duration, operation).await;
match result {
Ok(operation_result) => {
@@ -2311,7 +2429,7 @@ impl DiskAPI for LocalDiskWrapper {
// Record operation start
self.health.last_started.store(current_unix_nanos(), Ordering::Relaxed);
self.health.increment_waiting();
let waiting_guard = self.health.waiting_guard();
let metric_waiting_guard = self.metrics.waiting_guard();
let started = Instant::now();
@@ -2320,7 +2438,7 @@ impl DiskAPI for LocalDiskWrapper {
self.metrics.record_operation_latency("delete_versions", started.elapsed());
self.record_batch_delete_error_metrics(&result);
self.health.decrement_waiting();
drop(waiting_guard);
drop(metric_waiting_guard);
let has_err = result.iter().any(|e| e.is_some());
if !has_err {
@@ -2545,18 +2663,22 @@ impl DiskAPI for LocalDiskWrapper {
}
async fn append_file(&self, volume: &str, path: &str) -> Result<crate::disk::FileWriter> {
self.track_disk_health_with_op("append_file", || async { self.disk.append_file(volume, path).await }, Duration::ZERO)
.await
let writer = self
.track_disk_health_with_op("append_file", || async { self.disk.append_file(volume, path).await }, Duration::ZERO)
.await?;
Ok(Box::new(SlowDiskWriter::new(writer, self.endpoint())))
}
async fn create_file(&self, origvolume: &str, volume: &str, path: &str, file_size: i64) -> Result<crate::disk::FileWriter> {
self.track_disk_health_mutation(
"create_file",
DiskMetricMutation::Write,
|| async { self.disk.create_file(origvolume, volume, path, file_size).await },
Duration::ZERO,
)
.await
let writer = self
.track_disk_health_mutation(
"create_file",
DiskMetricMutation::Write,
|| async { self.disk.create_file(origvolume, volume, path, file_size).await },
Duration::ZERO,
)
.await?;
Ok(Box::new(SlowDiskWriter::new(writer, self.endpoint())))
}
async fn rename_file(&self, src_volume: &str, src_path: &str, dst_volume: &str, dst_path: &str) -> Result<()> {
@@ -2709,7 +2831,268 @@ mod tests {
pin::Pin,
task::{Context, Poll},
};
use tokio::io::AsyncWrite;
use tokio::io::AsyncWriteExt;
use tracing_subscriber::fmt::MakeWriter;
#[derive(Clone, Default)]
struct SlowOperationLogs(Arc<std::sync::Mutex<Vec<u8>>>);
impl SlowOperationLogs {
fn contents(&self) -> String {
String::from_utf8(self.0.lock().expect("log buffer should not be poisoned").clone()).expect("logs should be UTF-8")
}
}
impl io::Write for SlowOperationLogs {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0
.lock()
.expect("log buffer should not be poisoned")
.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl<'a> MakeWriter<'a> for SlowOperationLogs {
type Writer = Self;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
#[tokio::test(start_paused = true)]
async fn slow_disk_operation_warns_once_while_pending_and_preserves_result() {
let dir = tempfile::tempdir().expect("temp dir should be created");
let endpoint = Endpoint::try_from(dir.path().to_str().expect("path should be UTF-8")).expect("endpoint should parse");
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("local disk should be created"));
let wrapper = LocalDiskWrapper::new(disk, false);
let logs = SlowOperationLogs::default();
let subscriber = tracing_subscriber::fmt()
.with_writer(logs.clone())
.with_ansi(false)
.without_time()
.finish();
let _subscriber = tracing::subscriber::set_default(subscriber);
let _interest = crate::test_tracing::pin_callsite_interest_for_test();
let (release, wait) = tokio::sync::oneshot::channel();
let operation = wrapper.track_disk_health_with_op(
"rename_data",
|| async { wait.await.expect("operation should be released") },
Duration::ZERO,
);
tokio::pin!(operation);
assert!(futures::poll!(&mut operation).is_pending());
time::advance(SLOW_DISK_OPERATION_THRESHOLD - Duration::from_millis(1)).await;
assert!(futures::poll!(&mut operation).is_pending());
assert!(logs.contents().is_empty(), "normal disk latency should not warn");
time::advance(Duration::from_millis(1)).await;
assert!(futures::poll!(&mut operation).is_pending());
let warning = logs.contents();
assert!(warning.contains("WARN"));
assert!(warning.contains("event=\"disk_operation_slow\""));
assert!(warning.contains("state=\"pending\""));
assert!(warning.contains(&format!("endpoint={endpoint}")));
assert!(warning.contains("op=\"rename_data\""));
assert!(warning.contains("elapsed_ms=5000"));
time::advance(Duration::from_secs(120)).await;
assert!(futures::poll!(&mut operation).is_pending(), "warning must not cancel the disk operation");
assert_eq!(logs.contents(), warning, "a pending operation should warn only once");
release
.send(Err::<(), _>(DiskError::FileAccessDenied))
.expect("operation should still be alive");
assert_eq!(operation.await, Err(DiskError::FileAccessDenied));
assert_eq!(wrapper.runtime_state(), RuntimeDriveHealthState::Online);
assert_eq!(wrapper.metrics_snapshot().total_waiting, 0);
let timed = wrapper.track_disk_health_with_op(
"write_all",
std::future::pending::<Result<()>>,
SLOW_DISK_OPERATION_THRESHOLD + Duration::from_secs(1),
);
tokio::pin!(timed);
assert!(futures::poll!(&mut timed).is_pending());
time::advance(SLOW_DISK_OPERATION_THRESHOLD).await;
assert!(futures::poll!(&mut timed).is_pending());
assert_eq!(logs.contents().matches(EVENT_DISK_OPERATION_SLOW).count(), 2);
assert_eq!(wrapper.runtime_state(), RuntimeDriveHealthState::Online);
time::advance(Duration::from_secs(1)).await;
assert_eq!(
timed.await,
Err(DiskError::Timeout),
"existing timeout must still apply after the warning"
);
assert_eq!(wrapper.metrics_snapshot().total_errors_timeout, 1);
assert_eq!(wrapper.metrics_snapshot().total_waiting, 0);
}
#[tokio::test(start_paused = true)]
async fn slow_disk_operation_fast_path_is_silent_and_does_not_resolve_endpoint() {
let logs = SlowOperationLogs::default();
let subscriber = tracing_subscriber::fmt()
.with_writer(logs.clone())
.with_ansi(false)
.without_time()
.finish();
let _subscriber = tracing::subscriber::set_default(subscriber);
let _interest = crate::test_tracing::pin_callsite_interest_for_test();
for delay in [Duration::ZERO, SLOW_DISK_OPERATION_THRESHOLD] {
let result =
observe_disk_operation(|| panic!("completed operation should not clone its endpoint"), "write_all", async {
if !delay.is_zero() {
time::sleep(delay).await;
}
Ok::<_, DiskError>(17)
})
.await;
assert_eq!(result, Ok(17));
}
assert!(logs.contents().is_empty());
}
#[tokio::test(start_paused = true)]
async fn slow_disk_diagnostics_are_bypassed_when_warnings_are_disabled() {
let _subscriber = tracing::subscriber::set_default(tracing::subscriber::NoSubscriber::default());
let endpoint = Endpoint::try_from("/slow-disk").expect("endpoint should parse");
let mut writer = SlowDiskWriter::new(Box::new(PendingWriter), endpoint);
{
let write = writer.write(b"bytes");
tokio::pin!(write);
assert!(futures::poll!(&mut write).is_pending());
}
assert!(writer.timer.is_none(), "disabled warnings should not allocate a stream timer");
let result = observe_disk_operation(|| panic!("disabled warnings should not resolve endpoints"), "write_all", async {
time::sleep(SLOW_DISK_OPERATION_THRESHOLD * 2).await;
17
})
.await;
assert_eq!(result, 17);
}
#[tokio::test(start_paused = true)]
async fn slow_disk_writer_preserves_vectored_bytes_and_warns_once_while_blocked() {
let endpoint = Endpoint::try_from("/slow-disk").expect("endpoint should parse");
let logs = SlowOperationLogs::default();
let subscriber = tracing_subscriber::fmt()
.with_writer(logs.clone())
.with_ansi(false)
.without_time()
.finish();
let _subscriber = tracing::subscriber::set_default(subscriber);
let _interest = crate::test_tracing::pin_callsite_interest_for_test();
let (stream, mut receiver) = tokio::io::duplex(1);
let mut writer = SlowDiskWriter::new(Box::new(stream), endpoint.clone());
writer.write_all(b"a").await.expect("first byte should fit in the buffer");
assert!(writer.timer.is_none(), "ready writes should not allocate a timer");
assert!(logs.contents().is_empty());
let mut received = [0; 1];
{
let bufs = [io::IoSlice::new(b"b"), io::IoSlice::new(b"c")];
let write = writer.write_vectored(&bufs);
tokio::pin!(write);
assert!(futures::poll!(&mut write).is_pending());
time::advance(SLOW_DISK_OPERATION_THRESHOLD).await;
assert!(futures::poll!(&mut write).is_pending());
assert!(logs.contents().contains("op=\"write_vectored\""));
time::advance(SLOW_DISK_OPERATION_THRESHOLD).await;
assert!(futures::poll!(&mut write).is_pending());
assert_eq!(logs.contents().matches(EVENT_DISK_OPERATION_SLOW).count(), 1);
tokio::io::AsyncReadExt::read_exact(&mut receiver, &mut received)
.await
.expect("buffered byte should be readable");
assert_eq!(received, *b"a");
assert_eq!(write.await.expect("blocked write should finish when capacity returns"), 1);
}
tokio::io::AsyncReadExt::read_exact(&mut receiver, &mut received)
.await
.expect("written byte should be readable");
assert_eq!(received, *b"b");
drop(receiver);
let err = writer.write_all(b"d").await.expect_err("closed receiver should fail writes");
assert_eq!(err.kind(), io::ErrorKind::BrokenPipe);
}
#[tokio::test]
async fn slow_disk_writer_preserves_full_vectored_write_and_capability() {
let endpoint = Endpoint::try_from("/slow-disk").expect("endpoint should parse");
let (stream, mut receiver) = tokio::io::duplex(64);
let vectored = stream.is_write_vectored();
let mut writer = SlowDiskWriter::new(Box::new(stream), endpoint);
assert_eq!(writer.is_write_vectored(), vectored);
let bufs = [io::IoSlice::new(b"hash"), io::IoSlice::new(b"data")];
assert_eq!(writer.write_vectored(&bufs).await.expect("vectored write should succeed"), 8);
writer.shutdown().await.expect("shutdown should succeed");
let mut received = Vec::new();
tokio::io::AsyncReadExt::read_to_end(&mut receiver, &mut received)
.await
.expect("all bytes should be readable");
assert_eq!(received, b"hashdata");
assert!(writer.timer.is_none());
}
struct StalledWriter(Arc<AtomicBool>);
impl AsyncWrite for StalledWriter {
fn poll_write(self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
if self.0.load(Ordering::Relaxed) {
Poll::Ready(Ok(buf.len()))
} else {
Poll::Pending
}
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
if self.0.load(Ordering::Relaxed) {
Poll::Ready(Ok(()))
} else {
Poll::Pending
}
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
self.poll_flush(cx)
}
}
#[tokio::test(start_paused = true)]
async fn slow_disk_writer_warns_for_write_flush_and_shutdown_and_rearms_after_progress() {
let endpoint = Endpoint::try_from("/slow-disk").expect("endpoint should parse");
let logs = SlowOperationLogs::default();
let subscriber = tracing_subscriber::fmt()
.with_writer(logs.clone())
.with_ansi(false)
.without_time()
.finish();
let _subscriber = tracing::subscriber::set_default(subscriber);
let _interest = crate::test_tracing::pin_callsite_interest_for_test();
let ready = Arc::new(AtomicBool::new(false));
let mut writer = SlowDiskWriter::new(Box::new(StalledWriter(Arc::clone(&ready))), endpoint);
for (index, op) in ["write", "flush", "shutdown"].into_iter().enumerate() {
ready.store(false, Ordering::Relaxed);
let operation = async {
match op {
"write" => writer.write_all(b"bytes").await,
"flush" => writer.flush().await,
"shutdown" => writer.shutdown().await,
_ => unreachable!("only write operations are tested"),
}
};
tokio::pin!(operation);
assert!(futures::poll!(&mut operation).is_pending());
time::advance(SLOW_DISK_OPERATION_THRESHOLD).await;
assert!(futures::poll!(&mut operation).is_pending());
assert!(logs.contents().contains(&format!("op=\"{op}\"")));
assert_eq!(logs.contents().matches(EVENT_DISK_OPERATION_SLOW).count(), index + 1);
ready.store(true, Ordering::Relaxed);
operation.await.expect("warning should not prevent operation completion");
}
assert!(writer.pending.is_none());
}
struct DropProbe(Arc<std::sync::atomic::AtomicUsize>);
@@ -3229,6 +3612,40 @@ mod tests {
assert_eq!(snapshot.total_deletes, 1);
}
// A batch delete dropped while the disk call is in flight (for example by a
// cancelled caller) must not leave the drive looking busy forever.
#[tokio::test]
async fn delete_versions_releases_waiting_slot_when_dropped() {
let dir = tempfile::tempdir().expect("temp dir should be created");
let endpoint =
Endpoint::try_from(dir.path().to_str().expect("temp dir should be valid UTF-8")).expect("endpoint should parse");
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("local disk should be created"));
let wrapper = LocalDiskWrapper::new(Arc::clone(&disk), false);
wrapper.make_volume("bucket").await.expect("volume should be created");
let object_path = disk.get_object_path("bucket", "object").expect("object path");
// Holding the object's metadata lease parks the disk call before it mutates anything.
let metadata_lease = crate::disk::os::acquire_metadata_mutation_lease(&object_path, None).await;
let mut delete = Box::pin(wrapper.delete_versions(
"bucket",
vec![FileInfoVersions {
name: "object".to_string(),
versions: vec![FileInfo::default()],
..Default::default()
}],
DeleteOptions::default(),
));
assert!(
futures::poll!(delete.as_mut()).is_pending(),
"the disk call should wait for the metadata lease"
);
assert_eq!(wrapper.health.waiting_count(), 1);
drop(delete);
assert_eq!(wrapper.health.waiting_count(), 0, "a dropped delete must release its waiting slot");
drop(metadata_lease);
}
#[tokio::test]
async fn delete_versions_counts_faulty_drive_availability_rejection() {
let dir = tempfile::tempdir().expect("temp dir should be created");
File diff suppressed because it is too large Load Diff
+53 -10
View File
@@ -442,12 +442,19 @@ impl WritePlan {
}
if let Some(encryption) = self.encryption {
let etag_nonce = match encryption.mode {
WriteEncryptionMode::Singlepart { base_nonce } | WriteEncryptionMode::MultipartLegacy { base_nonce, .. } => {
base_nonce
}
WriteEncryptionMode::SinglepartObjectKey | WriteEncryptionMode::MultipartObjectKey { .. } => [0u8; 12],
};
let protected_reader = rustfs_rio::EtagReader::new_encrypted(reader, encryption.key_bytes, etag_nonce)?;
reader = match encryption.mode {
WriteEncryptionMode::SinglepartObjectKey => HashReader::from_reader(
#[cfg(feature = "rio-v2")]
EncryptReader::new_with_object_key(reader, encryption.key_bytes),
EncryptReader::new_with_object_key(protected_reader, encryption.key_bytes),
#[cfg(not(feature = "rio-v2"))]
EncryptReader::new(reader, encryption.key_bytes, [0u8; 12]),
EncryptReader::new(protected_reader, encryption.key_bytes, [0u8; 12]),
HashReader::SIZE_PRESERVE_LAYER,
actual_size,
None,
@@ -457,12 +464,12 @@ impl WritePlan {
WriteEncryptionMode::Singlepart { base_nonce } => {
#[cfg(not(feature = "rio-v2"))]
let encrypt_reader = if encryption_frame_v2_enabled() {
EncryptReader::new_v2(reader, encryption.key_bytes, base_nonce)
EncryptReader::new_v2(protected_reader, encryption.key_bytes, base_nonce)
} else {
EncryptReader::new(reader, encryption.key_bytes, base_nonce)
EncryptReader::new(protected_reader, encryption.key_bytes, base_nonce)
};
#[cfg(feature = "rio-v2")]
let encrypt_reader = EncryptReader::new(reader, encryption.key_bytes, base_nonce);
let encrypt_reader = EncryptReader::new(protected_reader, encryption.key_bytes, base_nonce);
HashReader::from_reader(encrypt_reader, HashReader::SIZE_PRESERVE_LAYER, actual_size, None, None, false)?
}
WriteEncryptionMode::MultipartLegacy {
@@ -471,20 +478,25 @@ impl WritePlan {
} => {
#[cfg(not(feature = "rio-v2"))]
let encrypt_reader = if encryption_frame_v2_enabled() {
EncryptReader::new_multipart_v2(reader, encryption.key_bytes, base_nonce, multipart_part_number)
EncryptReader::new_multipart_v2(protected_reader, encryption.key_bytes, base_nonce, multipart_part_number)
} else {
EncryptReader::new_multipart(reader, encryption.key_bytes, base_nonce, multipart_part_number)
EncryptReader::new_multipart(protected_reader, encryption.key_bytes, base_nonce, multipart_part_number)
};
#[cfg(feature = "rio-v2")]
let encrypt_reader =
EncryptReader::new_multipart(reader, encryption.key_bytes, base_nonce, multipart_part_number);
EncryptReader::new_multipart(protected_reader, encryption.key_bytes, base_nonce, multipart_part_number);
HashReader::from_reader(encrypt_reader, HashReader::SIZE_PRESERVE_LAYER, actual_size, None, None, false)?
}
WriteEncryptionMode::MultipartObjectKey { multipart_part_number } => HashReader::from_reader(
#[cfg(feature = "rio-v2")]
EncryptReader::new_multipart_with_object_key(reader, encryption.key_bytes, multipart_part_number),
EncryptReader::new_multipart_with_object_key(protected_reader, encryption.key_bytes, multipart_part_number),
#[cfg(not(feature = "rio-v2"))]
EncryptReader::new_multipart(reader, encryption.key_bytes, [0u8; 12], multipart_part_number as usize),
EncryptReader::new_multipart(
protected_reader,
encryption.key_bytes,
[0u8; 12],
multipart_part_number as usize,
),
HashReader::SIZE_PRESERVE_LAYER,
actual_size,
None,
@@ -497,6 +509,7 @@ impl WritePlan {
// `ignore_value` deliberately avoids a second hasher over compressed or
// encrypted bytes. The inner reader still validates the plaintext request
// checksum while this outer reader exposes the request checksum context.
reader.encrypted_etag = encrypted;
reader.add_non_trailing_checksum(checksum, true)?;
reader.set_trailer(trailer);
@@ -513,6 +526,36 @@ mod tests {
use std::io::Cursor;
use tokio::io::AsyncReadExt;
#[tokio::test]
async fn write_plan_encryption_persists_protected_etags_for_every_mode() {
let data = b"Salary 2026: 85000 EUR";
let size = i64::try_from(data.len()).expect("test data size");
let mut control =
HashReader::from_stream(Cursor::new(data), size, size, None, None, false).expect("create control reader");
control.read_to_end(&mut Vec::new()).await.expect("read control plaintext");
let md5 = control.try_resolve_etag().expect("control MD5");
for encryption in [
WriteEncryption::singlepart([1; 32], [1; 12]),
WriteEncryption::singlepart_object_key([1; 32]),
WriteEncryption::multipart([1; 32], [1; 12], 1),
WriteEncryption::multipart_object_key([1; 32], 1),
] {
let plaintext = HashReader::from_stream(Cursor::new(data), size, size, Some(md5.clone()), None, false)
.expect("create checked reader");
let mut reader = WritePlan::new()
.with_encryption(encryption)
.apply(plaintext, size)
.expect("apply encryption");
let mut ciphertext = Vec::new();
reader.read_to_end(&mut ciphertext).await.expect("stream ciphertext");
assert_ne!(ciphertext, data);
let tag = reader.try_resolve_etag().expect("resolve persisted encrypted ETag");
assert_ne!(tag, md5, "stored ETag must not reveal the plaintext MD5");
assert_eq!(tag.len(), 32, "multipart ETags require a 16-byte hexadecimal part tag");
assert_eq!(reader.try_resolve_etag().as_ref(), Some(&tag));
}
}
async fn assert_non_trailing_checksum_survives(plan: WritePlan) {
let plaintext = b"checksum-context-through-write-plan".repeat(256);
let actual_size = plaintext.len() as i64;
+7
View File
@@ -307,6 +307,13 @@ pub fn shutdown_background_services() {
}
}
/// Wait up to `timeout` for object mutations that outlived their callers
/// (see `InstanceContext::wait_for_detached_mutations`). Returns `false` when
/// some are still running at the deadline.
pub async fn wait_for_detached_mutations(timeout: std::time::Duration) -> bool {
current_ctx().wait_for_detached_mutations(timeout).await
}
/// Set the global lock client (first LocalClient created)
///
/// # Arguments
+59
View File
@@ -59,6 +59,8 @@ use std::sync::{
use tokio::sync::{Mutex, Notify, OnceCell, OwnedRwLockReadGuard, RwLock};
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
use tracing::Instrument as _;
use uuid::Uuid;
const SCANNER_PUBLICATION_STATE_UNKNOWN: u8 = 0;
@@ -243,6 +245,10 @@ pub struct InstanceContext {
suppress_tier_delete_journal_recovery: bool,
transition_transaction_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
tier_delete_journal_recovery_wakeup: tokio::sync::Notify,
/// Object mutations that run on their own task so that dropping the
/// caller (for example, an S3 client disconnect) cannot stop them between
/// their on-disk steps. Shutdown waits for them after the HTTP drain.
detached_mutations: TaskTracker,
}
impl InstanceContext {
@@ -293,6 +299,7 @@ impl InstanceContext {
suppress_tier_delete_journal_recovery: false,
transition_transaction_recovery_stores: std::sync::Mutex::new(HashSet::new()),
tier_delete_journal_recovery_wakeup: tokio::sync::Notify::new(),
detached_mutations: TaskTracker::new(),
}
}
@@ -306,6 +313,31 @@ impl InstanceContext {
self.lock_manager.clone()
}
/// Run a multi-step mutation on its own task and wait for its output.
///
/// Dropping the returned future leaves the task running to completion, so
/// the mutation, together with the locks and guards it owns, never stops
/// between two of its on-disk steps because its caller went away.
pub(crate) async fn run_detached_mutation<F>(&self, mutation: F) -> Result<F::Output, tokio::task::JoinError>
where
F: std::future::Future + Send + 'static,
F::Output: Send + 'static,
{
self.detached_mutations.spawn(mutation.in_current_span()).await
}
/// Wait up to `timeout` for every detached mutation to finish. Returns
/// `false` when some are still running at the deadline.
pub async fn wait_for_detached_mutations(&self, timeout: std::time::Duration) -> bool {
self.detached_mutations.close();
tokio::time::timeout(timeout, self.detached_mutations.wait()).await.is_ok()
}
#[cfg(test)]
pub(crate) fn detached_mutation_count(&self) -> usize {
self.detached_mutations.len()
}
pub(crate) fn data_movement_operation_gate(&self) -> Arc<RwLock<()>> {
Arc::clone(&self.data_movement_operation_gate)
}
@@ -920,6 +952,33 @@ mod tests {
}
}
// A detached mutation keeps running after its caller is dropped, and
// shutdown waits for it instead of tearing it down mid-way.
#[tokio::test]
async fn detached_mutation_outlives_dropped_caller_and_is_awaited() {
let ctx = InstanceContext::new();
let finished = Arc::new(AtomicBool::new(false));
let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
let mut caller = Box::pin(ctx.run_detached_mutation({
let finished = Arc::clone(&finished);
async move {
let _ = release_rx.await;
finished.store(true, Ordering::Release);
}
}));
assert!(futures::poll!(caller.as_mut()).is_pending());
drop(caller);
assert_eq!(ctx.detached_mutation_count(), 1, "dropping the caller must not cancel the mutation");
assert!(
!ctx.wait_for_detached_mutations(std::time::Duration::from_millis(50)).await,
"the wait must report a mutation that is still running"
);
release_tx.send(()).expect("the detached mutation should still be waiting");
assert!(ctx.wait_for_detached_mutations(std::time::Duration::from_secs(5)).await);
assert!(finished.load(Ordering::Acquire));
}
// A fresh context (before any update) reflects the initial all-false state.
#[tokio::test]
async fn fresh_context_is_all_false() {
@@ -2340,7 +2340,8 @@ enum OrphanDirScan {
/// under taking parents, root included when anything under it is blocked);
/// `dirs` is the pre-order list of every directory reached that is not
/// blocked, and `committed_files` the erasure data and committed delete
/// markers found in the unblocked UUID data dirs among them.
/// markers found in the unblocked UUID data dirs among them, plus the
/// rollback backups those markers prove.
Scanned {
blocked: HashSet<String>,
dirs: Vec<String>,
@@ -2382,10 +2383,15 @@ fn is_safe_orphan_dir_entry(entry: &str) -> bool {
&& !component.contains('\\')
}
fn is_committed_delete_marker(entry: &str) -> bool {
fn committed_delete_marker_transaction(entry: &str) -> Option<Uuid> {
entry
.strip_prefix(DELETE_DATA_DIR_MARKER_PREFIX)
.is_some_and(|transaction| Uuid::parse_str(transaction).is_ok_and(|uuid| !uuid.is_nil()))
.and_then(|transaction| Uuid::parse_str(transaction).ok())
.filter(|uuid| !uuid.is_nil())
}
fn is_committed_delete_marker(entry: &str) -> bool {
committed_delete_marker_transaction(entry).is_some()
}
/// Outcome of a *post-quorum* `rename_data` commit, classifying whether the
@@ -5149,8 +5155,9 @@ impl SetDisks {
}
/// Scan a single disk's copy of `prefix` and classify every directory
/// under it. Only empty directories and UUID data directories with valid
/// committed delete markers are purgeable; anything else blocks its whole
/// under it. Only empty directories, UUID data directories with valid
/// committed delete markers, and the rollback backup of a delete whose
/// marker sits beside it are purgeable; anything else blocks its whole
/// ancestor chain while sibling subtrees stay purgeable, so committed
/// residue is still reclaimed when it shares an ancestor with residue from
/// an older build that never wrote markers (#6898).
@@ -5163,6 +5170,12 @@ impl SetDisks {
let mut committed_files: Vec<String> = Vec::new();
let mut blocked: HashSet<String> = HashSet::new();
let mut existed = false;
// `<object>/<T>` directories holding only a delete's rollback backup,
// and the `(object, T)` pairs a committed `delete-data.<T>` marker in a
// purgeable data dir of that object proves. Both are resolved after the
// walk because siblings are visited in no particular order.
let mut rollback_backup_dirs: Vec<String> = Vec::new();
let mut committed_transactions: HashSet<(String, Uuid)> = HashSet::new();
while let Some(dir) = stack.pop() {
let entries = match disk.list_dir("", bucket, &dir, 0).await {
@@ -5196,10 +5209,24 @@ impl SetDisks {
}
if !has_data && !files.is_empty() {
let data_dir_name = dir.rsplit(SLASH_SEPARATOR).next().unwrap_or_default();
let (parent, data_dir_name) = dir.rsplit_once(SLASH_SEPARATOR).unwrap_or(("", dir.as_str()));
let is_uuid_data_dir = Uuid::parse_str(data_dir_name).is_ok_and(|uuid| !uuid.is_nil());
let has_committed_delete = files.iter().any(|entry| is_committed_delete_marker(entry));
has_data = !is_uuid_data_dir || !has_committed_delete || files.iter().any(|entry| entry == STORAGE_FORMAT_FILE);
let committed = files
.iter()
.filter_map(|entry| committed_delete_marker_transaction(entry))
.collect::<Vec<_>>();
if is_uuid_data_dir
&& dir != root
&& child_dirs.is_empty()
&& matches!(files.as_slice(), [entry] if entry == STORAGE_FORMAT_FILE_BACKUP)
{
rollback_backup_dirs.push(dir);
continue;
}
has_data = !is_uuid_data_dir || committed.is_empty() || files.iter().any(|entry| entry == STORAGE_FORMAT_FILE);
if !has_data {
committed_transactions.extend(committed.into_iter().map(|transaction| (parent.to_string(), transaction)));
}
}
if has_data {
@@ -5214,6 +5241,22 @@ impl SetDisks {
stack.extend(child_dirs);
}
// A rollback backup is purgeable only when a data dir of the same
// object proves its transaction committed on this disk; otherwise it may
// still be needed to roll the delete back, so it blocks as before.
for dir in rollback_backup_dirs {
let proven = dir.rsplit_once(SLASH_SEPARATOR).is_some_and(|(parent, transaction)| {
Uuid::parse_str(transaction)
.is_ok_and(|transaction| committed_transactions.contains(&(parent.to_string(), transaction)))
});
if proven {
committed_files.push(format!("{dir}{SLASH_SEPARATOR}{STORAGE_FORMAT_FILE_BACKUP}"));
dirs.push(dir);
} else {
block_orphan_dir_chain(&mut blocked, &root, &dir);
}
}
if existed {
OrphanDirScan::Scanned {
blocked,
@@ -6177,6 +6220,7 @@ pub(crate) mod rename_fanout_barrier {
ReadVersion,
MetadataSlowtailFault,
NonInlineHedgeTimer,
LateMetadataRefresh,
}
impl From<&'static str> for Phase {
@@ -6188,6 +6232,7 @@ pub(crate) mod rename_fanout_barrier {
super::rename_fanout_barrier_phase::READ_VERSION => Self::ReadVersion,
"metadata_slowtail_fault" => Self::MetadataSlowtailFault,
"non_inline_hedge_timer" => Self::NonInlineHedgeTimer,
"late_metadata_refresh" => Self::LateMetadataRefresh,
_ => panic!("unknown commit fault phase: {label}"),
}
}
@@ -6202,6 +6247,9 @@ pub(crate) mod rename_fanout_barrier {
/// Object-scoped hedge timer checkpoint; slot zero identifies the timer, not a disk.
pub const PHASE_NON_INLINE_HEDGE_TIMER: Phase = Phase::NonInlineHedgeTimer;
/// Object-scoped retry boundary; slot zero identifies the refresh, not a disk.
pub const PHASE_LATE_METADATA_REFRESH: Phase = Phase::LateMetadataRefresh;
/// One armed barrier: the fan-out task matching `(disk_index, phase)` pauses.
struct Armed {
/// Signalled (task -> test) when the target task reaches the checkpoint.
@@ -514,6 +514,102 @@ mod tests {
}
}
#[test]
fn metadata_observation_mixed_current_identity_arrivals_preserve_selection() {
let permutations = (0..4)
.flat_map(|a| (0..4).filter(move |b| *b != a).map(move |b| (a, b)))
.flat_map(|(a, b)| (0..4).filter(move |c| *c != a && *c != b).map(move |c| (a, b, c)))
.map(|(a, b, c)| [a, b, c, 6 - a - b - c])
.collect::<Vec<_>>();
assert_eq!(permutations.len(), 24);
for (case, newer_version) in [("newer-current-version", true), ("same-time-different-directory", false)] {
let metadata = std::array::from_fn::<_, 4, _>(|index| {
let mut file_info = payload(index);
file_info.version_id = Some(Uuid::from_u128(if index > 0 && newer_version { 11 } else { 10 }));
file_info.is_latest = true;
if index > 0 {
file_info.data_dir = Some(Uuid::from_u128(2));
if newer_version {
file_info.mod_time = Some(OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(1));
}
}
assert!(file_info_is_valid_for_metadata(&file_info), "case={case}, disk={index}");
file_info
});
let expected_current = &metadata[1];
let expected_hash = SetDisks::file_info_quorum_hash(expected_current);
assert_ne!(SetDisks::file_info_quorum_hash(&metadata[0]), expected_hash);
for enabled in [false, true] {
for order in &permutations {
let mut slots = std::array::from_fn::<_, 4, _>(|index| observation(index, 4));
let mut typed = MetadataQuorumAccumulator::new(4, 2, enabled);
let mut legacy = MetadataQuorumAccumulator::new(4, 2, enabled);
for slot in &slots {
typed.observe(slot);
observe_legacy(&mut legacy, slot);
}
assert_same_reduction(&typed, &legacy);
assert_eq!(typed.valid_responses, 0, "pending slots cannot supply votes");
let mut old_seen = false;
let mut current_votes = 0;
for (prefix, &index) in order.iter().enumerate() {
slots[index].result = Some(Ok(metadata[index].clone()));
typed.observe(&slots[index]);
observe_legacy(&mut legacy, &slots[index]);
assert_same_reduction(&typed, &legacy);
if index == 0 {
old_seen = true;
} else {
current_votes += 1;
}
// A+B+B must wait for pending B; three B responses may
// finish early only before the conflicting A arrives.
let expected_decision = if enabled && !old_seen && current_votes == 3 {
Some(MetadataEarlyStopDecision {
reason: GET_METADATA_EARLY_STOP_REASON_VALID_QUORUM,
})
} else {
None
};
assert_eq!(
typed.early_stop_decision(),
expected_decision,
"case={case}, enabled={enabled}, order={order:?}, prefix={prefix}"
);
assert_eq!(typed.valid_responses, prefix + 1);
assert_eq!(typed.conflicting_metadata, old_seen && current_votes > 0);
assert_eq!(typed.version_early_stop_decision(), None, "current reads do not request a version ID");
if expected_decision.is_some() {
assert_eq!(
SetDisks::file_info_quorum_hash(
typed.candidate.as_ref().expect("three B votes need a candidate")
),
expected_hash
);
}
}
let completed_metadata = slots
.iter()
.map(|slot| slot.file_info().expect("every disk must have arrived").clone())
.collect::<Vec<_>>();
let (_, selected, selection_quorum) =
SetDisks::select_valid_fileinfo(&vec![None; 4], &completed_metadata, &vec![None; 4], "", 2, 3)
.expect("three matching B disks must select the current identity");
assert_eq!(selection_quorum, 3);
assert_eq!(selected.version_id, expected_current.version_id);
assert_eq!(selected.mod_time, expected_current.mod_time);
assert_eq!(selected.data_dir, expected_current.data_dir);
assert_eq!(SetDisks::file_info_quorum_hash(&selected), expected_hash);
}
}
}
}
#[test]
fn metadata_observation_pending_newer_version_and_same_time_different_directory_force_full_wait() {
let mut accumulator = MetadataQuorumAccumulator::new(4, 2, true);
+313 -3
View File
@@ -62,7 +62,7 @@ use crate::diagnostics::get::{
classify_storage_error, get_stage_timer_if_enabled, record_get_object_pipeline_failure,
record_get_object_pipeline_failure_for_path, record_get_stage_duration_if_enabled,
};
use crate::diagnostics::object_lock::ObjectLockAttempt;
use crate::diagnostics::object_lock::{ObjectLockAttempt, record_lock_acquire_failure};
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,
@@ -885,8 +885,6 @@ pub(crate) use ops::hermetic_set_disks_isolated;
pub(crate) use ops::multipart::NewMultipartUploadCommitObservation;
#[cfg(any(test, feature = "test-util"))]
pub use ops::multipart::{MultipartCommitBarrier, MultipartCommitPause};
#[cfg(test)]
pub(crate) use ops::object::DeleteObjectCommitBarrier;
#[cfg(feature = "test-util")]
pub(crate) use ops::object::TransitionCleanupStoreBarrier as SetDiskTransitionCleanupStoreBarrier;
#[cfg(all(test, feature = "test-util"))]
@@ -894,6 +892,8 @@ pub(crate) use ops::object::TransitionUploadedCommitBarrier as SetDiskTransition
pub(crate) use ops::object::body_cache_plaintext_len;
#[cfg(all(test, feature = "test-util"))]
pub(crate) use ops::object::cleanup_rejected_transition_upload_durably;
#[cfg(test)]
pub(crate) use ops::object::{DeleteCleanupBarrier, DeleteObjectCommitBarrier};
#[cfg(any(test, feature = "test-util"))]
pub use ops::object::{PutObjectCommitBarrier, PutObjectCommitPause};
#[cfg(all(test, feature = "test-util"))]
@@ -1441,6 +1441,172 @@ mod prepared_get_object_metadata_tests {
.await;
}
#[tokio::test]
#[serial_test::serial(body_cache_hook)]
async fn non_inline_two_phase_get_rejects_generation_change_before_late_refresh() {
use crate::object_api::WriteCompletion;
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
let (dirs, set_disks) = crate::ecstore_validation_blackbox::make_local_set_disks_with_ctx(4, 2, ctx).await;
let bucket = "non-inline-late-generation";
let object = object_with_initial_data_shards(bucket, "late-generation-object");
let generation_a_body = vec![0x5a; 1024 * 1024];
let generation_b_body = vec![0xa5; generation_a_body.len()];
// This unlocked, real-disk fixture exercises the internal generation
// boundary, not concurrent HTTP writes through a namespace read lock.
let opts = ObjectOptions {
no_lock: true,
suppress_read_repair: true,
write_completion: WriteCompletion::TailDrained,
..Default::default()
};
set_disks
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create late generation bucket");
set_disks
.put_object(bucket, &object, &mut PutObjReader::from_vec(generation_a_body), &opts)
.await
.expect("commit every generation A shard before GET");
let disks = set_disks.get_disks_internal().await;
let original = SetDisks::read_metadata_observed(&disks, "", bucket, &object, "", true, false, false, false, 2)
.await
.expect("read every generation A disk slot");
assert!(original.is_complete());
let (original_metadata, original_errors, _) = original.into_legacy();
assert!(original_errors.iter().all(Option::is_none));
let (_, generation_a, _) = SetDisks::select_valid_fileinfo(&disks, &original_metadata, &original_errors, "", 2, 3)
.expect("generation A must have real metadata quorum");
assert!(!generation_a.inline_data(), "fixture must use non-inline shard files");
let order = bounded_metadata_fanout_order(bucket, &object, 4, 2);
for disk_index in order.iter().take(2) {
let data_dir = original_metadata[*disk_index]
.data_dir
.expect("generation A metadata must name its real shard directory");
let part_path = dirs[*disk_index]
.path()
.join(bucket)
.join(&object)
.join(data_dir.to_string())
.join("part.1");
let mut shard = std::fs::read(&part_path).expect("selected generation A shard must exist");
*shard.first_mut().expect("real shard must contain a checksum frame") ^= 0xff;
std::fs::write(part_path, shard).expect("corrupt selected shard without changing generation A metadata");
}
temp_env::async_with_vars(
[
("RUSTFS_GET_METADATA_TWO_PHASE_READ_PLAN_ENABLE", Some("true")),
("RUSTFS_GET_METADATA_EARLY_STOP_ENABLE", Some("true")),
("RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT", Some("true")),
],
async {
let _hedge_timer = rename_fanout_barrier::arm(&object, 0, rename_fanout_barrier::PHASE_NON_INLINE_HEDGE_TIMER);
let refresh = rename_fanout_barrier::arm(&object, 0, rename_fanout_barrier::PHASE_LATE_METADATA_REFRESH);
let calls = disk_call_counters::observe(&object);
let get = set_disks.get_object_reader(bucket, &object, None, HeaderMap::new(), &opts);
tokio::pin!(get);
tokio::time::timeout(READ_VERSION_BARRIER_GUARD, async {
tokio::select! {
result = &mut get => panic!("GET returned before the late refresh boundary: {:?}", result.err()),
() = refresh.wait_until_paused() => {}
}
})
.await
.expect("failed generation A decode must reach the real late refresh boundary");
assert!(refresh.is_paused());
assert_eq!(
calls.total(disk_call_counters::KIND_READ_VERSION),
3,
"A must be an early metadata candidate"
);
assert_eq!(
calls.for_disk(disk_call_counters::KIND_READ_VERSION, order[3]),
0,
"late refresh must not have read the omitted disk before replacement"
);
set_disks
.put_object(bucket, &object, &mut PutObjReader::from_vec(generation_b_body.clone()), &opts)
.await
.expect("commit every healthy generation B shard while the late GET is paused");
let replacement = SetDisks::read_metadata_observed(&disks, "", bucket, &object, "", true, false, false, false, 2)
.await
.expect("read every committed generation B disk slot");
assert!(replacement.is_complete());
let (replacement_metadata, replacement_errors, _) = replacement.into_legacy();
assert!(replacement_errors.iter().all(Option::is_none));
let (online, generation_b, _) =
SetDisks::select_valid_fileinfo(&disks, &replacement_metadata, &replacement_errors, "", 2, 3)
.expect("replacement must have healthy real metadata quorum");
assert_eq!(online.iter().flatten().count(), 4);
assert_ne!(generation_a.data_dir, generation_b.data_dir);
assert_ne!(
SetDisks::file_info_quorum_hash(&generation_a),
SetDisks::file_info_quorum_hash(&generation_b)
);
assert_eq!(generation_a.size, generation_b.size);
assert_eq!(generation_a.erasure.distribution, generation_b.erasure.distribution);
let before_late = (0..4)
.map(|index| calls.for_disk(disk_call_counters::KIND_READ_VERSION, index))
.collect::<Vec<_>>();
refresh.release();
let result = tokio::time::timeout(READ_VERSION_BARRIER_GUARD, &mut get)
.await
.expect("late GET must finish after its refresh boundary is released");
for (index, before) in before_late.into_iter().enumerate() {
assert_eq!(
calls.for_disk(disk_call_counters::KIND_READ_VERSION, index) - before,
1,
"late refresh must read each real disk exactly once"
);
}
match result {
Err(Error::InsufficientReadQuorum(failed_bucket, failed_object)) => {
assert_eq!(failed_bucket, bucket);
assert_eq!(failed_object, object);
}
Err(error) => panic!("changed late generation must return InsufficientReadQuorum, got {error:?}"),
Ok(mut reader) => {
assert_eq!(reader.object_info.size, i64::try_from(generation_b_body.len()).expect("fixture size"));
let mut body = Vec::new();
reader
.stream
.read_to_end(&mut body)
.await
.expect("unexpected replacement body must be readable");
assert_eq!(body.len(), generation_b_body.len());
assert_eq!(body, generation_b_body);
println!(
"late generation oracle exposed complete B: initial_reads=3, late_reads=4, bytes={}",
body.len()
);
panic!("late GET must reject a changed object generation before exposing a body");
}
}
drop(refresh);
let mut reader = set_disks
.get_object_reader(bucket, &object, None, HeaderMap::new(), &opts)
.await
.expect("fresh GET must open the healthy replacement generation");
assert_eq!(reader.object_info.size, i64::try_from(generation_b_body.len()).expect("fixture size"));
let mut body = Vec::new();
reader
.stream
.read_to_end(&mut body)
.await
.expect("fresh generation B must stream completely");
assert_eq!(body.len(), generation_b_body.len());
assert_eq!(body, generation_b_body);
println!(
"late generation oracle rejected crossing: initial_reads=3, late_per_disk=[1,1,1,1], fresh_bytes={}",
body.len()
);
},
)
.await;
}
#[tokio::test]
#[serial_test::serial(body_cache_hook)]
async fn four_data_two_parity_two_phase_read_recovers_one_failed_data_shard() {
@@ -4549,6 +4715,9 @@ impl SetDisks {
let timeout = get_lock_acquire_timeout();
let mut attempt = ObjectLockAttempt::start(op, bucket, object, None, &ns_lock, "read", timeout);
let result = ns_lock.get_read_lock(timeout).await;
if let Err(error) = &result {
record_lock_acquire_failure(op, bucket, object, "read", error);
}
attempt.observe(&result);
let guard = result.map_err(|e| self.map_namespace_lock_error(bucket, object, "read", e))?;
let owner = diag_enabled.then(|| ns_lock.owner().to_string());
@@ -4607,6 +4776,9 @@ impl SetDisks {
let acquire_timeout = get_put_object_commit_lock_acquire_timeout(op);
let mut attempt = ObjectLockAttempt::start(op, bucket, object, None, &ns_lock, "write", acquire_timeout);
let result = ns_lock.get_write_lock(acquire_timeout).await;
if let Err(error) = &result {
record_lock_acquire_failure(op, bucket, object, "write", error);
}
attempt.observe(&result);
let guard = resolve_put_object_commit_lock_acquire_result(self, op, bucket, object, result)?;
Self::record_put_object_commit_namespace_lock_wait(op, acquire_start);
@@ -4659,6 +4831,9 @@ impl SetDisks {
std::task::Poll::Ready(result) => std::task::Poll::Ready(result),
})
.await;
if let Err(error) = &result {
record_lock_acquire_failure(op, bucket, object, "write", error);
}
attempt.observe(&result);
let guard = resolve_put_object_commit_lock_acquire_result(self, op, bucket, object, result)?;
Self::record_put_object_commit_namespace_lock_wait(op, acquire_start);
@@ -9655,6 +9830,141 @@ mod tests {
assert!(nested.join(STORAGE_FORMAT_FILE).exists());
}
/// The residue a delete leaves once it committed on a disk but before its
/// cleanup pass: a marked data dir and the rollback backup of the same
/// transaction. Returns `(data dir, rollback backup)`.
async fn write_committed_delete_with_rollback_backup(
object_dir: &std::path::Path,
backup_transaction: Uuid,
marker_transaction: Option<Uuid>,
) -> (std::path::PathBuf, std::path::PathBuf) {
let data_dir = object_dir.join(Uuid::new_v4().to_string());
fs::create_dir_all(&data_dir).await.expect("data dir should be created");
fs::write(data_dir.join("part.1"), b"stale")
.await
.expect("stale part should be written");
if let Some(transaction) = marker_transaction {
fs::write(
data_dir.join(format!("{}{}", crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX, transaction)),
[],
)
.await
.expect("committed delete marker should be written");
}
let backup = object_dir
.join(backup_transaction.to_string())
.join(STORAGE_FORMAT_FILE_BACKUP);
fs::create_dir_all(backup.parent().expect("backup parent"))
.await
.expect("rollback dir should be created");
fs::write(&backup, b"rollback metadata")
.await
.expect("rollback backup should be written");
(data_dir, backup)
}
// #6898: the rollback backup of a delete whose committed marker sits in a
// data dir of the same object is residue too, so the empty listing can
// reclaim the whole object directory.
#[tokio::test]
async fn purge_orphan_dir_object_removes_rollback_backup_of_committed_delete() {
let (dir, disk) = make_single_local_disk().await;
let transaction = Uuid::new_v4();
let object_dir = dir.path().join("bucket/pfx/obj");
write_committed_delete_with_rollback_backup(&object_dir, transaction, Some(transaction)).await;
let set = make_set_disks_with(vec![Some(disk)]).await;
let purged = set
.purge_orphan_dir_object("bucket", "pfx/")
.await
.expect("purge should succeed");
assert!(purged);
assert!(!dir.path().join("bucket/pfx").exists(), "the whole committed residue tree must go");
}
#[tokio::test]
async fn purge_orphan_dir_object_preserves_rollback_backup_of_other_transaction() {
let (dir, disk) = make_single_local_disk().await;
let object_dir = dir.path().join("bucket/pfx/obj");
let (data_dir, backup) =
write_committed_delete_with_rollback_backup(&object_dir, Uuid::new_v4(), Some(Uuid::new_v4())).await;
let set = make_set_disks_with(vec![Some(disk)]).await;
set.purge_orphan_dir_object("bucket", "pfx/")
.await
.expect("scan should succeed");
assert!(!data_dir.exists(), "the committed data dir is still reclaimed");
assert!(backup.exists(), "a backup whose own transaction is unproven must remain");
}
#[tokio::test]
async fn purge_orphan_dir_object_preserves_rollback_backup_without_marker() {
let (dir, disk) = make_single_local_disk().await;
let object_dir = dir.path().join("bucket/pfx/obj");
let backup = object_dir.join(Uuid::new_v4().to_string()).join(STORAGE_FORMAT_FILE_BACKUP);
fs::create_dir_all(backup.parent().expect("backup parent"))
.await
.expect("rollback dir should be created");
fs::write(&backup, b"rollback metadata")
.await
.expect("rollback backup should be written");
let set = make_set_disks_with(vec![Some(disk)]).await;
let purged = set
.purge_orphan_dir_object("bucket", "pfx/")
.await
.expect("scan should succeed");
assert!(!purged, "a lone rollback backup may still be needed to roll a delete back");
assert!(backup.exists());
}
// Live metadata on one disk blocks the object there and keeps it intact;
// on the other disks only what each disk's own committed marker proves is
// reclaimed, the same rule that already applies to a marked data dir.
#[tokio::test]
async fn purge_orphan_dir_object_keeps_object_with_metadata_on_another_disk() {
let mut dirs = Vec::new();
let mut disks = Vec::new();
for _ in 0..4 {
let (dir, disk) = make_single_local_disk().await;
dirs.push(dir);
disks.push(Some(disk));
}
let transaction = Uuid::new_v4();
let mut residue = Vec::new();
for dir in &dirs[..3] {
residue.push(
write_committed_delete_with_rollback_backup(&dir.path().join("bucket/pfx/obj"), transaction, Some(transaction))
.await,
);
}
let live_object = dirs[3].path().join("bucket/pfx/obj");
let live_part = live_object.join(Uuid::new_v4().to_string()).join("part.1");
fs::create_dir_all(live_part.parent().expect("data dir"))
.await
.expect("live data dir should be created");
fs::write(&live_part, b"live").await.expect("live part should be written");
fs::write(live_object.join(STORAGE_FORMAT_FILE), b"meta")
.await
.expect("live metadata should be written");
let set = make_set_disks_with(disks).await;
set.purge_orphan_dir_object("bucket", "pfx/")
.await
.expect("scan should succeed");
assert!(
live_object.join(STORAGE_FORMAT_FILE).exists() && live_part.exists(),
"the object with metadata on another disk must stay intact there"
);
for (data_dir, backup) in &residue {
assert!(!data_dir.exists() && !backup.exists(), "this disk's committed residue is reclaimed");
}
}
// An unreadable directory anywhere under the prefix aborts the purge on
// every disk before anything is deleted.
#[cfg(unix)]
+35
View File
@@ -34,6 +34,41 @@ pub(crate) enum BucketInfoQuorum {
}
impl SetDisks {
/// Require proof from every configured disk before retiring a bucket
/// generation whose visible volume was removed outside the storage API.
pub(crate) async fn ensure_bucket_volume_absent_on_every_disk(&self, bucket: &str) -> Result<()> {
let disks = self.disk_inventory().await;
if disks.is_empty() {
return Err(Error::ErasureReadQuorum);
}
let futures = disks.into_iter().map(|disk| async move {
match disk {
Some(disk) => disk.stat_volume(bucket).await.map(|_| ()),
None => Err(DiskError::DiskNotFound),
}
});
let results = join_all(futures).await;
let mut volume_present = false;
let mut first_unknown = None;
for result in results {
match result {
Ok(()) => volume_present = true,
Err(DiskError::VolumeNotFound) => {}
Err(error) if first_unknown.is_none() => first_unknown = Some(error),
Err(_) => {}
}
}
if let Some(error) = first_unknown {
return Err(error.into());
}
if volume_present {
return Err(Error::BucketExists(bucket.to_owned()));
}
Ok(())
}
pub(crate) async fn stat_bucket_with_quorum(&self, bucket: &str, quorum: BucketInfoQuorum) -> Result<BucketInfo> {
let disks = self.disk_inventory().await;
let disk_count = disks.len();
@@ -3004,6 +3004,10 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
// etag
let etag = resolve_complete_etag(opts, &uploaded_parts);
// An upload can contain parts from an older writer or a peer. Until
// every part's format is attested, keep its ETag out of keyless lists.
rustfs_utils::http::metadata_compat::remove_str(&mut fi.metadata, rustfs_utils::http::SUFFIX_OPAQUE_ENCRYPTED_ETAG);
fi.metadata.insert("etag".to_owned(), etag);
let persist_encryption_original_size = should_persist_encryption_original_size(&fi.metadata);
+156
View File
@@ -2788,6 +2788,13 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
)
.await;
if prepare_late_materialized_retry(&initial_result, &mut output, object_size) {
#[cfg(test)]
super::super::rename_fanout_barrier::checkpoint(
object,
0,
super::super::rename_fanout_barrier::PHASE_LATE_METADATA_REFRESH,
)
.await;
let (full_fi, full_parts_metadata, full_online_disks) = Self::refresh_late_metadata_fanout(
&late_metadata_fanout_disks,
bucket,
@@ -3840,6 +3847,16 @@ impl SetDisks {
.map(crate::io_support::rio::compression_index_storage_bytes);
let mut etag = data.stream.try_resolve_etag().unwrap_or_default();
if data.stream.encrypted_etag {
if opts.preserve_etag.as_ref().is_none_or(|preserved| preserved == &etag) {
insert_str(&mut user_defined, rustfs_utils::http::SUFFIX_OPAQUE_ENCRYPTED_ETAG, "v1".to_owned());
} else {
rustfs_utils::http::metadata_compat::remove_str(
&mut user_defined,
rustfs_utils::http::SUFFIX_OPAQUE_ENCRYPTED_ETAG,
);
}
}
if let Some(ref tag) = opts.preserve_etag {
etag = tag.clone();
}
@@ -7078,6 +7095,82 @@ async fn pause_delete_object_commit_after_publish(bucket: &str, object: &str) {
}
}
/// Pauses a delete after its per-disk commit and before the cleanup or rollback
/// pass, the window in which an abandoned delete used to leave metadata-less
/// residue behind.
#[cfg(test)]
struct DeleteCleanupBarrierState {
bucket: String,
arrived: tokio::sync::Notify,
release: tokio::sync::Notify,
}
#[cfg(test)]
pub(crate) struct DeleteCleanupBarrier {
state: Arc<DeleteCleanupBarrierState>,
}
#[cfg(test)]
static DELETE_CLEANUP_BARRIER: std::sync::OnceLock<std::sync::Mutex<Option<Arc<DeleteCleanupBarrierState>>>> =
std::sync::OnceLock::new();
#[cfg(test)]
impl DeleteCleanupBarrier {
pub(crate) fn install(bucket: &str) -> Self {
let state = Arc::new(DeleteCleanupBarrierState {
bucket: bucket.to_string(),
arrived: tokio::sync::Notify::new(),
release: tokio::sync::Notify::new(),
});
let mut slot = DELETE_CLEANUP_BARRIER
.get_or_init(|| std::sync::Mutex::new(None))
.lock()
.expect("delete cleanup barrier mutex should not poison");
assert!(slot.is_none(), "delete cleanup barrier must be unique");
*slot = Some(Arc::clone(&state));
Self { state }
}
pub(crate) async fn wait_until_paused(&self) {
tokio::time::timeout(Duration::from_secs(30), self.state.arrived.notified())
.await
.expect("delete should reach the cleanup barrier");
}
pub(crate) fn release(&self) {
self.state.release.notify_one();
}
}
#[cfg(test)]
impl Drop for DeleteCleanupBarrier {
fn drop(&mut self) {
self.state.release.notify_one();
let mut slot = DELETE_CLEANUP_BARRIER
.get_or_init(|| std::sync::Mutex::new(None))
.lock()
.expect("delete cleanup barrier mutex should not poison");
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
*slot = None;
}
}
}
#[cfg(test)]
async fn pause_delete_before_cleanup(bucket: &str) {
let barrier = DELETE_CLEANUP_BARRIER
.get_or_init(|| std::sync::Mutex::new(None))
.lock()
.expect("delete cleanup barrier mutex should not poison")
.as_ref()
.filter(|barrier| barrier.bucket == bucket)
.cloned();
if let Some(barrier) = barrier {
barrier.arrived.notify_one();
barrier.release.notified().await;
}
}
fn persisted_transition_version_with_gate(
remote_version: &str,
remote_version_state_writer_enabled: bool,
@@ -7658,6 +7751,8 @@ impl SetDisks {
let quorum_result = resolve_tiered_decommission_write_quorum_result(&errs, write_quorum, bucket, object);
let should_rollback = quorum_result.is_err();
#[cfg(test)]
pause_delete_before_cleanup(bucket).await;
let mut rollback_futures = Vec::new();
for (index, err) in errs.iter().enumerate() {
// backlog#1158: when rolling back, fan the idempotent undo out to every
@@ -8667,6 +8762,8 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
}
self.record_capacity_scope_if_needed(opts.capacity_scope_token, &disks);
#[cfg(test)]
pause_delete_before_cleanup(bucket).await;
let mut rollback_futures = Vec::new();
let committed_receipt_indices =
@@ -10523,6 +10620,65 @@ mod object_encryption_resolver_wiring_tests {
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
#[tokio::test]
async fn encrypted_put_attests_only_etags_produced_by_its_encryption_reader() {
use super::hermetic_set_disks_support::hermetic_set_disks_isolated;
use crate::io_support::rio::{WriteEncryption, WritePlan};
use crate::storage_api_contracts::object::ObjectIO as _;
use tokio::io::AsyncReadExt;
let (_temp_dirs, disks, set_disks) = hermetic_set_disks_isolated(4).await;
let bucket = "encrypted-etag-proof";
for disk in &disks {
disk.make_volume(bucket).await.expect("create bucket volume");
}
let plaintext = b"Salary 2026: 85000 EUR";
let size = plaintext.len() as i64;
let mut control = HashReader::from_stream(Cursor::new(plaintext), size, size, None, None, false).expect("control reader");
control.read_to_end(&mut Vec::new()).await.expect("read control plaintext");
let plaintext_md5 = control.try_resolve_etag().expect("control MD5");
for (object, preserved) in [("new", None), ("preserved", Some(plaintext_md5.clone()))] {
let reader =
HashReader::from_stream(Cursor::new(plaintext), size, size, None, None, false).expect("plaintext reader");
let reader = WritePlan::new()
.with_encryption(WriteEncryption::singlepart([1; 32], [2; 12]))
.apply(reader, size)
.expect("encrypted reader");
let mut opts = ObjectOptions {
preserve_etag: preserved.clone(),
user_defined: HashMap::from([
("x-amz-server-side-encryption-customer-algorithm".to_owned(), "AES256".to_owned()),
(rustfs_utils::http::SSEC_ORIGINAL_SIZE_HEADER.to_owned(), size.to_string()),
]),
..Default::default()
};
// A copied producer marker cannot attest to a preserved plaintext tag.
insert_str(&mut opts.user_defined, rustfs_utils::http::SUFFIX_OPAQUE_ENCRYPTED_ETAG, "v1".to_owned());
set_disks
.put_object(bucket, object, &mut PutObjReader::new(reader), &opts)
.await
.expect("store encrypted bytes");
let stored = set_disks
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("read persisted metadata");
if let Some(preserved) = preserved {
assert_eq!(stored.etag.as_deref(), Some(preserved.as_str()));
assert!(!rustfs_utils::http::etag_can_be_listed(&stored.user_defined));
} else {
assert_ne!(stored.etag.as_ref(), Some(&plaintext_md5));
assert_eq!(
rustfs_utils::http::get_consistent_str(
&stored.user_defined,
rustfs_utils::http::SUFFIX_OPAQUE_ENCRYPTED_ETAG
),
Some("v1")
);
assert!(rustfs_utils::http::etag_can_be_listed(&stored.user_defined));
}
}
}
#[derive(Clone, Default)]
struct CapturedLogs(Arc<Mutex<Vec<u8>>>);
+459 -10
View File
@@ -204,6 +204,52 @@ where
.await
}
fn bucket_recovery_lock_unavailable(bucket: &str, object: &str) -> StorageError {
StorageError::NamespaceLockQuorumUnavailable {
mode: "write",
bucket: bucket.to_string(),
object: object.to_string(),
required: 1,
achieved: 0,
}
}
async fn await_orphan_bucket_recovery<T, F>(
bucket: &str,
publication_guard: &rustfs_lock::NamespaceLockGuard,
lifecycle_guard: &rustfs_lock::NamespaceLockGuard,
metadata_guard: &rustfs_lock::NamespaceLockGuard,
namespace_guard: &rustfs_lock::NamespaceLockGuard,
future: F,
) -> Result<T>
where
F: Future<Output = Result<T>>,
{
let metadata_lock_object = crate::bucket::metadata_sys::bucket_metadata_transaction_lock_key(bucket);
for ((lock_bucket, lock_object), guard) in [
(
(bucket, rustfs_common::table_catalog::TABLE_BUCKET_PUBLICATION_LOCK_PATH),
publication_guard,
),
((bucket, BUCKET_LIFECYCLE_LOCK_OBJECT), lifecycle_guard),
((RUSTFS_META_BUCKET, metadata_lock_object.as_str()), metadata_guard),
((bucket, bucket), namespace_guard),
] {
if guard.is_lock_lost() {
return Err(bucket_recovery_lock_unavailable(lock_bucket, lock_object));
}
}
tokio::select! {
biased;
_ = publication_guard.lock_lost_notified() => Err(bucket_recovery_lock_unavailable(bucket, rustfs_common::table_catalog::TABLE_BUCKET_PUBLICATION_LOCK_PATH)),
_ = lifecycle_guard.lock_lost_notified() => Err(bucket_recovery_lock_unavailable(bucket, BUCKET_LIFECYCLE_LOCK_OBJECT)),
_ = metadata_guard.lock_lost_notified() => Err(bucket_recovery_lock_unavailable(RUSTFS_META_BUCKET, &metadata_lock_object)),
_ = namespace_guard.lock_lost_notified() => Err(bucket_recovery_lock_unavailable(bucket, bucket)),
result = future => result,
}
}
async fn run_bucket_usage_cleanup<F>(guard: Option<&rustfs_lock::NamespaceLockGuard>, bucket: &str, future: F) -> Result<()>
where
F: Future<Output = Result<()>>,
@@ -272,6 +318,20 @@ async fn bucket_delete_local_blocker(
}
impl ECStore {
/// Run a multi-step storage mutation on the instance task tracker so its
/// caller may stop waiting without releasing guards before the mutation
/// finishes.
pub async fn run_detached_mutation<F>(&self, mutation: F) -> Result<F::Output>
where
F: Future + Send + 'static,
F::Output: Send + 'static,
{
self.ctx
.run_detached_mutation(mutation)
.await
.map_err(|error| StorageError::other_with_context("detached ECStore mutation failed", error))
}
fn bucket_sets(&self) -> impl Iterator<Item = (usize, usize, Arc<crate::set_disk::SetDisks>)> + '_ {
self.pools.iter().flat_map(|pool| {
pool.disk_set
@@ -280,6 +340,113 @@ impl ECStore {
})
}
async fn ensure_bucket_volume_absent_on_all_sets(&self, bucket: &str) -> Result<()> {
let sets: Vec<_> = self.bucket_sets().map(|(_, _, set)| set).collect();
if sets.is_empty() {
return Err(StorageError::ErasureReadQuorum);
}
let results = join_all(sets.iter().map(|set| set.ensure_bucket_volume_absent_on_every_disk(bucket))).await;
for result in results {
result?;
}
Ok(())
}
/// Return whether an old bucket generation has a durable retirement record.
/// A live matching incarnation is never considered retired, even if a stale
/// record exists from a previous generation.
pub async fn is_bucket_incarnation_retired(&self, bucket: &str, expected: Uuid) -> Result<bool> {
if expected.is_nil() {
return Ok(false);
}
let retirement_store = metadata_sys::object_store_if_initialized_in(&self.ctx).await.ok_or_else(|| {
StorageError::InsufficientReadQuorum(RUSTFS_META_BUCKET.to_string(), "bucket-retirements".to_string())
})?;
if crate::bucket::metadata::load_bucket_incarnation(retirement_store.clone(), bucket).await? == Some(expected) {
return Ok(false);
}
crate::bucket::retirement::is_retired(retirement_store, bucket, expected).await
}
/// Reconcile bucket metadata after an operator removed all physical volume
/// directories. The expected generation, all-disk absence proof and durable
/// retirement record make retries safe after partial metadata cleanup.
pub fn recover_orphaned_bucket<'a>(&'a self, bucket: &'a str, expected: Uuid) -> futures::future::BoxFuture<'a, Result<()>> {
Box::pin(async move { Box::pin(self.recover_orphaned_bucket_inner(bucket, expected)).await })
}
async fn recover_orphaned_bucket_inner(&self, bucket: &str, expected: Uuid) -> Result<()> {
if let Err(error) = check_valid_bucket_name_strict(bucket) {
return Err(StorageError::BucketNameInvalid(error.to_string()));
}
if is_meta_bucketname(bucket) || expected.is_nil() {
return Err(StorageError::InvalidArgument(
"RecoverOrphanedBucket".to_string(),
"bucket".to_string(),
"a user bucket and non-nil incarnation are required".to_string(),
));
}
// Lock order matches bucket deletion: publication -> lifecycle ->
// metadata transaction -> exact namespace.
let publication_guard = self.acquire_bucket_publication_write_lock(bucket).await?;
let lifecycle_guard = self.acquire_bucket_lifecycle_write_lock(bucket).await?;
let metadata_guard = metadata_sys::acquire_bucket_metadata_transaction_lock_in(&self.ctx, bucket).await?;
let ns_lock = self.new_ns_lock(bucket, bucket).await?;
let ns_guard = ns_lock
.get_write_lock(get_lock_acquire_timeout())
.await
.map_err(|error| match error {
rustfs_lock::LockError::QuorumNotReached { required, achieved } => StorageError::NamespaceLockQuorumUnavailable {
mode: "write",
bucket: bucket.to_string(),
object: bucket.to_string(),
required,
achieved,
},
other => StorageError::Lock(other),
})?;
let recover = Box::pin(async {
self.ensure_bucket_volume_absent_on_all_sets(bucket).await?;
let retirement_store = metadata_sys::object_store_if_initialized_in(&self.ctx).await.ok_or_else(|| {
StorageError::InsufficientReadQuorum(RUSTFS_META_BUCKET.to_string(), "bucket-retirements".to_string())
})?;
let current_incarnation = crate::bucket::metadata::load_bucket_incarnation(retirement_store.clone(), bucket).await?;
if current_incarnation.is_some_and(|current| current != expected) {
return Err(StorageError::PreconditionFailed);
}
let retired = crate::bucket::retirement::is_retired(retirement_store.clone(), bucket, expected).await?;
if current_incarnation.is_none() && !retired {
return Err(StorageError::PreconditionFailed);
}
if !retired {
let mut record_opts = ObjectOptions {
max_parity: true,
..Default::default()
};
record_opts.add_bucket_lifecycle_lock_guard(&lifecycle_guard);
record_opts.add_namespace_lock_guard(&ns_guard);
let publish_retirement = Box::pin(crate::bucket::retirement::commit_retirement(
retirement_store.clone(),
bucket,
expected,
&record_opts,
));
publish_retirement.await?;
}
crate::bucket::quota::reservation::cleanup_retired_bucket_ledger(retirement_store.clone(), bucket, expected).await?;
crate::data_usage::prepare_bucket_usage_for_namespace_change(bucket, Some(&ns_guard)).await?;
crate::data_usage::remove_bucket_usage_from_backend_with_guard_fenced(self, bucket, Some(&ns_guard)).await?;
self.cleanup_deleted_bucket_metadata_strict(bucket, true).await?;
crate::store::list_objects::observe_scanner_namespace_mutations(bucket, 1);
Ok(())
});
await_orphan_bucket_recovery(bucket, &publication_guard, &lifecycle_guard, &metadata_guard, &ns_guard, recover).await
}
pub async fn get_bucket_metadata(&self, bucket: &str) -> Result<Arc<BucketMetadata>> {
let sys = metadata_sys::require_bucket_metadata_sys_in(&self.ctx)?;
sys.read().await.get(bucket).await
@@ -604,6 +771,25 @@ impl ECStore {
Ok(())
}
async fn cleanup_deleted_bucket_metadata_strict(&self, bucket: &str, include_deleted_marker: bool) -> Result<()> {
let options = ObjectOptions {
delete_prefix_object: true,
..Default::default()
};
for prefix in bucket_delete_metadata_cleanup_prefixes(bucket) {
self.delete_prefix(RUSTFS_META_BUCKET, &prefix, &options).await?;
}
if include_deleted_marker {
let marker_prefix = bucket_deleted_marker_prefix(bucket);
self.delete_prefix(RUSTFS_META_BUCKET, &marker_prefix, &options).await?;
}
metadata_sys::remove_bucket_metadata_in(&self.ctx, bucket).await?;
runtime_sources::delete_bucket_monitor_entry(bucket);
Ok(())
}
async fn cleanup_bucket_usage(&self, bucket: &str, guard: Option<&rustfs_lock::NamespaceLockGuard>) -> Result<()> {
run_bucket_usage_cleanup(guard, bucket, async {
crate::data_usage::prepare_bucket_usage_for_namespace_change(bucket, guard).await?;
@@ -715,7 +901,46 @@ impl ECStore {
}
};
let confirmed_missing = existing_bucket_info.is_none();
let existing_metadata = if opts.force_create && !confirmed_missing && !is_meta_bucketname(bucket) {
let mut pending_creation_metadata = None;
if confirmed_missing
&& !opts.no_lock
&& !is_meta_bucketname(bucket)
&& let Some(metadata_store) = metadata_sys::object_store_if_initialized_in(&self.ctx).await
&& let Some(old_incarnation) = crate::bucket::metadata::load_bucket_incarnation(metadata_store, bucket).await?
{
// A quorum-level absence is not proof that every disk lost the old
// generation. Require the explicit recovery path to verify all disks
// and retire this incarnation before a same-name create can mutate
// any volume.
self.ensure_bucket_volume_absent_on_all_sets(bucket)
.await
.map_err(|error| match error {
StorageError::BucketExists(_) => StorageError::InvalidArgument(
"CreateBucket".to_string(),
"bucket".to_string(),
"an old bucket volume remains on disk; administrator recovery refused".to_string(),
),
other => other,
})?;
let (metadata, persisted) = metadata_sys::get_config_from_disk_with_presence_in(&self.ctx, bucket).await?;
let metadata_store = metadata_sys::object_store_in(&self.ctx).await?;
let pending_intent = persisted
&& metadata.bucket_incarnation_id == old_incarnation
&& metadata.needs_bucket_creation_commit()
&& !crate::bucket::retirement::is_retired(metadata_store, bucket, old_incarnation).await?;
if pending_intent {
pending_creation_metadata = Some(metadata);
} else {
return Err(StorageError::InvalidArgument(
"CreateBucket".to_string(),
"bucket".to_string(),
format!("orphaned bucket generation {old_incarnation} requires administrator recovery before recreation"),
));
}
}
let existing_metadata = if let Some(metadata) = pending_creation_metadata {
Some(metadata)
} else if opts.force_create && !confirmed_missing && !is_meta_bucketname(bucket) {
let (mut metadata, persisted) = metadata_sys::get_config_from_disk_with_presence_in(&self.ctx, bucket).await?;
if !persisted {
metadata = BucketMetadata::new(bucket);
@@ -1135,6 +1360,23 @@ impl ECStore {
} else {
None
};
// Match metadata migration: lifecycle -> metadata transaction ->
// bucket namespace. Holding the transaction while waiting for the
// namespace prevents a migration writer and force delete from
// acquiring these two locks in opposite orders.
let object_lock_metadata_transaction = if opts.force && !is_meta_bucketname(bucket) {
Some(
await_bucket_namespace_operation(
bucket_lifecycle_guard.as_ref(),
bucket,
"Object Lock metadata transaction acquisition",
crate::bucket::metadata_sys::acquire_bucket_metadata_transaction_read_lock_in(&self.ctx, bucket),
)
.await?,
)
} else {
None
};
let ns_guard = if !opts.no_lock {
let ns_lock = self.new_ns_lock(bucket, bucket).await?;
Some(
@@ -1190,6 +1432,29 @@ impl ECStore {
}
}
// Keep configuration changes fenced until data deletion finishes;
// a force delete cannot bypass any Object Lock protection.
let object_lock_metadata_guard = if bucket_exists && let Some(guard) = object_lock_metadata_transaction {
let (state, incarnation, _) =
crate::bucket::metadata_sys::get_object_lock_config_and_incarnation_from_disk_with_guard_in(
&self.ctx,
bucket,
Some(ns_guard.as_ref().unwrap_or(&guard)),
)
.await?;
match state {
crate::bucket::metadata_sys::ObjectLockConfigState::ConfirmedAbsent => Some((guard, incarnation)),
crate::bucket::metadata_sys::ObjectLockConfigState::Configured { .. } => {
return Err(StorageError::PrefixAccessDenied(bucket.to_owned(), String::new()));
}
crate::bucket::metadata_sys::ObjectLockConfigState::Fabricated => {
return Err(StorageError::FileCorrupt);
}
}
} else {
None
};
if sr_delete && !bucket_exists {
delete_opts.force_if_empty = true;
}
@@ -1203,7 +1468,12 @@ impl ECStore {
ns_guard.as_ref(),
bucket,
"bucket delete marker creation",
self.mark_bucket_deleted(bucket),
await_bucket_namespace_operation(
object_lock_metadata_guard.as_ref().map(|(guard, _)| guard),
bucket,
"Object Lock configuration fence during bucket retirement",
self.mark_bucket_deleted(bucket),
),
)
.await?;
}
@@ -1212,9 +1482,13 @@ impl ECStore {
// Legacy buckets without a stamp cannot produce retirement authority.
let retirement = if bucket_exists && bucket_lifecycle_guard.is_some() && !is_meta_bucketname(bucket) {
if let Some(store) = metadata_sys::object_store_if_initialized_in(&self.ctx).await {
crate::bucket::metadata::load_bucket_incarnation(store.clone(), bucket)
.await?
.map(|incarnation| (store, incarnation))
if let Some((_, incarnation)) = object_lock_metadata_guard.as_ref() {
Some((store, *incarnation))
} else {
crate::bucket::metadata::load_bucket_incarnation(store.clone(), bucket)
.await?
.map(|incarnation| (store, incarnation))
}
} else {
None
}
@@ -1226,13 +1500,19 @@ impl ECStore {
bucket_lifecycle_guard.as_ref(),
bucket,
"physical bucket deletion",
run_physical_bucket_deletion(ns_guard.as_ref(), bucket, async {
self.delete_bucket_on_sets(bucket, &delete_opts)
.await
.map_err(|err| to_object_err(err, vec![bucket]))
}),
await_bucket_namespace_operation(
object_lock_metadata_guard.as_ref().map(|(guard, _)| guard),
bucket,
"Object Lock configuration fence during bucket deletion",
run_physical_bucket_deletion(ns_guard.as_ref(), bucket, async {
self.delete_bucket_on_sets(bucket, &delete_opts)
.await
.map_err(|err| to_object_err(err, vec![bucket]))
}),
),
)
.await;
drop(object_lock_metadata_guard);
if let Err(err) = delete_result
&& (!sr_delete || !is_err_strict_volume_not_found(&err))
{
@@ -1610,6 +1890,65 @@ mod tests {
setup_bucket_quorum_test_env(&[4, 4], None).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn orphaned_bucket_recovery_requires_every_volume_absent_and_is_retryable() {
let (temp_dir, store) = setup_multi_pool_bucket_test_env().await;
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("orphan-recovery-{}", Uuid::new_v4().simple());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create bucket before simulated operator removal");
let incarnation = store
.bucket_incarnation_id_from_disk(&bucket)
.await
.expect("persisted bucket generation");
let first_disk_volume = temp_dir.path().join("pool0-disk0").join(&bucket);
tokio::fs::remove_dir_all(&first_disk_volume)
.await
.expect("simulate operator removal on one disk");
let blocked = store
.recover_orphaned_bucket(&bucket, incarnation)
.await
.expect_err("recovery must refuse while any configured disk still has the volume");
assert!(matches!(blocked, StorageError::BucketExists(_)), "unexpected error: {blocked}");
assert_eq!(
crate::bucket::metadata::load_bucket_incarnation(store.clone(), &bucket)
.await
.expect("generation sidecar remains readable"),
Some(incarnation),
"a refused recovery must preserve the old generation identity"
);
for disk_index in 1..4 {
tokio::fs::remove_dir_all(temp_dir.path().join(format!("pool0-disk{disk_index}")).join(&bucket))
.await
.expect("remove remaining volume from first set");
}
for disk_index in 0..4 {
tokio::fs::remove_dir_all(temp_dir.path().join(format!("pool1-disk{disk_index}")).join(&bucket))
.await
.expect("remove volume from second set");
}
store
.recover_orphaned_bucket(&bucket, incarnation)
.await
.expect("recovery should retire the exact generation and clean stale metadata");
assert!(
store
.is_bucket_incarnation_retired(&bucket, incarnation)
.await
.expect("retirement proof remains readable")
);
store
.recover_orphaned_bucket(&bucket, incarnation)
.await
.expect("retry after metadata cleanup should converge from durable retirement proof");
}
async fn setup_bucket_quorum_test_env(
drives_per_pool: &[usize],
standard_parity: Option<usize>,
@@ -3418,6 +3757,116 @@ mod tests {
assert!(!any_disk_path_exists(&disk_paths, bucket_deleted_marker_volume(&bucket)).await);
}
#[tokio::test]
#[serial]
async fn force_bucket_delete_preserves_object_lock_versions() {
let (_temp_dir, store) = setup_bucket_quorum_test_env(&[4], Some(2)).await;
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("force-delete-worm-{}", Uuid::new_v4().simple());
store
.make_bucket(
&bucket,
&MakeBucketOptions {
lock_enabled: true,
versioning_enabled: true,
..Default::default()
},
)
.await
.expect("create Object Lock bucket");
let until = (OffsetDateTime::now_utc() + time::Duration::days(365))
.format(&time::format_description::well_known::Rfc3339)
.expect("format future retention date");
let written = store
.put_object(
&bucket,
"retained",
&mut PutObjReader::from_vec(b"retained bytes".to_vec()),
&ObjectOptions {
versioned: true,
user_defined: HashMap::from([
("x-amz-object-lock-mode".to_owned(), "COMPLIANCE".to_owned()),
("x-amz-object-lock-retain-until-date".to_owned(), until),
]),
..Default::default()
},
)
.await
.expect("write retained object");
for operation in [SRBucketDeleteOp::NoOp, SRBucketDeleteOp::MarkDelete, SRBucketDeleteOp::Purge] {
let err = store
.delete_bucket(
&bucket,
&DeleteBucketOptions {
force: true,
srdelete_op: operation,
..Default::default()
},
)
.await
.expect_err("force deletion must not bypass Object Lock");
assert!(matches!(err, StorageError::PrefixAccessDenied(_, _)), "unexpected denial: {err}");
let info = store
.get_object_info(
&bucket,
"retained",
&ObjectOptions {
version_id: written.version_id.map(|version| version.to_string()),
..Default::default()
},
)
.await
.expect("retained version must survive the rejected bucket deletion");
assert_eq!(info.etag, written.etag);
assert_eq!(info.size, written.size);
assert_eq!(info.version_id, written.version_id);
}
metadata_sys::inject_object_lock_disk_read_error_in(&store.ctx, &bucket)
.await
.expect("inject authoritative metadata read failure");
let error = store
.delete_bucket(
&bucket,
&DeleteBucketOptions {
force: true,
..Default::default()
},
)
.await
.expect_err("an unreadable Object Lock configuration must fail closed");
assert!(error.to_string().contains("injected Object Lock metadata disk read failure"));
let info = store
.get_object_info(&bucket, "retained", &ObjectOptions::default())
.await
.expect("metadata read failure must leave the retained object intact");
assert_eq!(info.version_id, written.version_id);
let unlocked = format!("force-delete-unlocked-{}", Uuid::new_v4().simple());
store
.make_bucket(&unlocked, &MakeBucketOptions::default())
.await
.expect("create unlocked control bucket");
store
.put_object(
&unlocked,
"ordinary",
&mut PutObjReader::from_vec(b"ordinary bytes".to_vec()),
&ObjectOptions::default(),
)
.await
.expect("write ordinary control object");
store
.delete_bucket(
&unlocked,
&DeleteBucketOptions {
force: true,
..Default::default()
},
)
.await
.expect("force deletion must still work for an unlocked bucket");
}
#[tokio::test]
#[serial]
async fn bucket_delete_mark_delete_rejects_hidden_object_paths_without_force() {
+347 -3
View File
@@ -14521,15 +14521,19 @@ mod tests {
let barrier = TierDeleteChunkTestBarrier::install(stage);
let worker_store = store.clone();
let worker_bucket = bucket.clone();
// Simulate a crash at the barrier. Aborting a caller of the public entry
// point no longer stops the delete, which runs on a detached task, so
// drive the delete below that task.
let worker = tokio::spawn(async move {
worker_store
.delete_object_with_tier_delete_journal(
.handle_delete_object_with_journal(
&worker_bucket,
prefix,
ObjectOptions {
delete_prefix: true,
..Default::default()
},
Some(worker_store.clone()),
)
.await
});
@@ -14538,6 +14542,11 @@ mod tests {
.unwrap_or_else(|_| panic!("chunk delete did not reach crash boundary {stage:?}"));
worker.abort();
let _ = worker.await;
assert_eq!(
ctx.detached_mutation_count(),
0,
"the crash must stop the delete below the detached task, so nothing keeps running after the abort"
);
drop(barrier);
let released_bucket_guard =
tokio::time::timeout(Duration::from_secs(5), store.acquire_bucket_lifecycle_write_lock(&bucket))
@@ -14683,15 +14692,19 @@ mod tests {
let barrier = TierDeleteChunkTestBarrier::install(TierDeleteChunkTestStage::DispatchAuthorized);
let worker_store = store.clone();
// Simulate a crash at the barrier. Aborting a caller of the public entry
// point no longer stops the delete, which runs on a detached task, so
// drive the delete below that task.
let worker = tokio::spawn(async move {
worker_store
.delete_object_with_tier_delete_journal(
.handle_delete_object_with_journal(
bucket,
prefix,
ObjectOptions {
delete_prefix: true,
..Default::default()
},
Some(worker_store.clone()),
)
.await
});
@@ -14700,6 +14713,11 @@ mod tests {
.expect("chunk delete should persist its authorization before corruption injection");
worker.abort();
let _ = worker.await;
assert_eq!(
ctx.detached_mutation_count(),
0,
"the crash must stop the delete below the detached task, so nothing keeps running after the abort"
);
drop(barrier);
let records = store
@@ -17536,6 +17554,332 @@ mod tests {
.expect("new-generation object must survive the old batch delete");
}
async fn put_detached_delete_fixture(store: &Arc<crate::store::ECStore>, bucket: &str, object: &str) {
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create detached delete bucket");
// Large enough for a data dir, so the delete commit leaves a marked
// data dir and a rollback backup until its cleanup pass runs.
let mut reader = PutObjReader::from_vec(vec![7u8; 2 * 1024 * 1024]);
store
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("put detached delete fixture");
}
async fn assert_delete_lock_moved_to_detached_task(store: &Arc<crate::store::ECStore>, bucket: &str, object: &str) {
let lock = store
.handle_new_ns_lock(bucket, object)
.await
.expect("namespace lock handle for the deleted key");
lock.get_write_lock_quiet(Duration::from_millis(200))
.await
.expect_err("the detached delete must keep its object lock after the caller is dropped");
}
fn assert_no_delete_residue(temp_dir: &std::path::Path, bucket: &str, object: &str, disks: usize) {
for disk in 0..disks {
let object_dir = temp_dir.join(format!("pool0/set0/disk{disk}")).join(bucket).join(object);
assert!(
!object_dir.exists(),
"disk {disk} must hold neither metadata, rollback backup nor data of the deleted object: {object_dir:?}"
);
}
}
// #6898: a DeleteObjects request dropped between the per-disk commit and the
// cleanup pass (client disconnect) must not leave metadata-less residue.
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn delete_objects_finishes_cleanup_after_caller_is_dropped() {
let temp = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp.path(), "detached-batch-delete", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("detached-batch-delete-{}", uuid::Uuid::new_v4());
let object = "residue/batch.bin";
put_detached_delete_fixture(&store, &bucket, object).await;
let barrier = crate::set_disk::DeleteCleanupBarrier::install(&bucket);
let delete = store.delete_objects_with_tier_delete_journal_and_accounting(
&bucket,
vec![ObjectToDelete {
object_name: object.to_string(),
..Default::default()
}],
ObjectOptions::default(),
);
tokio::select! {
() = barrier.wait_until_paused() => {}
_ = delete => panic!("batch delete must pause between its commit and its cleanup"),
}
assert_eq!(ctx.detached_mutation_count(), 1, "the dropped request must leave its delete running");
assert_delete_lock_moved_to_detached_task(&store, &bucket, object).await;
barrier.release();
assert!(
ctx.wait_for_detached_mutations(Duration::from_secs(30)).await,
"the detached batch delete should finish"
);
assert_no_delete_residue(temp.path(), &bucket, object, 4);
store
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.expect_err("the object must be deleted");
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn delete_object_finishes_cleanup_after_caller_is_dropped() {
let temp = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp.path(), "detached-single-delete", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("detached-single-delete-{}", uuid::Uuid::new_v4());
let object = "residue/single.bin";
put_detached_delete_fixture(&store, &bucket, object).await;
let barrier = crate::set_disk::DeleteCleanupBarrier::install(&bucket);
let delete = store.delete_object_with_tier_delete_journal(&bucket, object, ObjectOptions::default());
tokio::select! {
() = barrier.wait_until_paused() => {}
_ = delete => panic!("single delete must pause between its commit and its cleanup"),
}
assert_eq!(ctx.detached_mutation_count(), 1, "the dropped request must leave its delete running");
assert_delete_lock_moved_to_detached_task(&store, &bucket, object).await;
barrier.release();
assert!(
ctx.wait_for_detached_mutations(Duration::from_secs(30)).await,
"the detached single delete should finish"
);
assert_no_delete_residue(temp.path(), &bucket, object, 4);
store
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.expect_err("the object must be deleted");
}
// A recursive (force) delete runs under its caller's bucket lifecycle write
// lock and carries only that lock's fence. If the caller is dropped, the
// detached delete must still hold the lock: otherwise a PUT under the prefix
// succeeds and is then removed by the delete that is still running.
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn recursive_delete_keeps_bucket_lock_after_caller_is_dropped() {
let temp = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp.path(), "detached-recursive-delete", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("detached-recursive-delete-{}", uuid::Uuid::new_v4());
let old_object = "folder/old.bin";
let new_object = "folder/new.bin";
put_detached_delete_fixture(&store, &bucket, old_object).await;
// The S3 force-delete path: the caller owns the lock, opts carry its fence.
let guard = store
.lock_bucket_for_recursive_delete(&bucket)
.await
.expect("recursive delete should lock the bucket");
let mut opts = ObjectOptions {
delete_prefix: true,
..Default::default()
};
opts.add_bucket_lifecycle_lock_guard(&guard);
let barrier = crate::store::object::DeleteAfterObjectLockSnapshotBarrier::install(&bucket);
let delete = store.delete_object_with_tier_delete_journal_and_guards(&bucket, "folder/", opts, Some(guard));
tokio::select! {
() = barrier.wait_until_paused() => {}
_ = delete => panic!("recursive delete must pause inside its detached task"),
}
assert_eq!(ctx.detached_mutation_count(), 1, "the dropped caller must leave its delete running");
let put_store = Arc::clone(&store);
let put_bucket = bucket.clone();
let mut put = tokio::spawn(async move {
let mut reader = PutObjReader::from_vec(b"written while the delete runs".to_vec());
put_store
.put_object(&put_bucket, new_object, &mut reader, &ObjectOptions::default())
.await
});
assert!(
tokio::time::timeout(Duration::from_millis(500), &mut put).await.is_err(),
"a PUT under the prefix must wait while the detached recursive delete runs"
);
barrier.release();
assert!(
ctx.wait_for_detached_mutations(Duration::from_secs(30)).await,
"the detached recursive delete should finish"
);
put.await
.expect("PUT task should join")
.expect("the PUT should succeed once the recursive delete has finished");
store
.get_object_info(&bucket, old_object, &ObjectOptions::default())
.await
.expect_err("the recursive delete must remove the object it covered");
store
.get_object_info(&bucket, new_object, &ObjectOptions::default())
.await
.expect("the PUT that waited for the recursive delete must survive it");
for disk in 0..4 {
let metadata = temp
.path()
.join(format!("pool0/set0/disk{disk}"))
.join(&bucket)
.join(new_object)
.join(crate::disk::STORAGE_FORMAT_FILE);
assert!(
metadata.exists(),
"disk {disk} must keep the object written after the delete: {metadata:?}"
);
}
}
// Lifecycle expiry holds the table-bucket publication read lock and passes
// only its lock-lost signal to the delete. A cancelled expiry worker must not
// release that lock while its detached delete is still running.
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn lifecycle_expiry_keeps_publication_lock_after_worker_is_dropped() {
let temp = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp.path(), "detached-lifecycle-expiry", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("detached-lifecycle-expiry-{}", uuid::Uuid::new_v4());
let object = "expired.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create lifecycle bucket");
let mut reader = PutObjReader::from_vec(b"expired data".to_vec());
let object_info = store
.put_object(&bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("put expired object");
let bucket_incarnation_id = store.bucket_incarnation_id(&bucket).await.expect("read bucket incarnation");
let barrier = crate::store::object::DeleteAfterObjectLockSnapshotBarrier::install(&bucket);
let expiry_store = Arc::clone(&store);
let expiry = tokio::spawn(async move {
crate::bucket::lifecycle::bucket_lifecycle_ops::apply_expiry_on_non_transitioned_objects(
expiry_store,
&object_info,
&crate::bucket::lifecycle::lifecycle::Event {
action: crate::bucket::lifecycle::lifecycle::IlmAction::DeleteAction,
..Default::default()
},
&crate::bucket::lifecycle::bucket_lifecycle_audit::LcEventSrc::Scanner,
bucket_incarnation_id,
)
.await
});
tokio::time::timeout(Duration::from_secs(30), barrier.wait_until_paused())
.await
.expect("lifecycle delete should pause inside its detached task");
// A cancelled worker drops its future while the delete keeps running.
expiry.abort();
assert!(
expiry
.await
.expect_err("aborted expiry worker should not join")
.is_cancelled(),
"expiry worker should be cancelled"
);
assert_eq!(ctx.detached_mutation_count(), 1, "the cancelled worker must leave its delete running");
let publication_lock = store
.handle_new_ns_lock(&bucket, rustfs_common::table_catalog::TABLE_BUCKET_PUBLICATION_LOCK_PATH)
.await
.expect("table-bucket publication lock handle");
publication_lock
.get_write_lock_quiet(Duration::from_millis(300))
.await
.expect_err("table-bucket publication must wait for the detached lifecycle delete");
barrier.release();
assert!(
ctx.wait_for_detached_mutations(Duration::from_secs(30)).await,
"the detached lifecycle delete should finish"
);
let publication_guard = publication_lock
.get_write_lock_quiet(crate::set_disk::get_lock_acquire_timeout())
.await
.expect("table-bucket publication should proceed once the lifecycle delete has finished");
drop(publication_guard);
store
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.expect_err("the detached lifecycle delete should still remove the expired object");
}
// #6898: residue that an older build stranded (every disk committed the
// delete, the cleanup pass never ran) is reclaimed by deleting the key again.
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn repeated_delete_objects_finishes_committed_delete_residue() {
use crate::disk::DiskAPI as _;
for drives in [1, 4] {
let temp = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp.path(), "residue-retry", &[drives])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("residue-retry-{drives}-{}", uuid::Uuid::new_v4());
let object = "residue/retry.bin";
put_detached_delete_fixture(&store, &bucket, object).await;
let transaction = uuid::Uuid::new_v4();
let disks = store.pools[0].disk_set[0].disks.read().await.clone();
for disk in disks.iter().flatten() {
let errors = disk
.delete_versions(
&bucket,
vec![crate::disk::FileInfoVersions {
name: object.to_string(),
versions: vec![rustfs_filemeta::FileInfo {
name: object.to_string(),
..Default::default()
}],
..Default::default()
}],
crate::disk::DeleteOptions {
old_data_dir: Some(transaction),
..Default::default()
},
)
.await;
assert!(errors.iter().all(Option::is_none), "the delete should commit on every disk: {errors:?}");
}
for disk in 0..drives {
let object_dir = temp.path().join(format!("pool0/set0/disk{disk}")).join(&bucket).join(object);
assert!(!object_dir.join(crate::disk::STORAGE_FORMAT_FILE).exists());
assert!(
object_dir
.join(transaction.to_string())
.join(crate::disk::STORAGE_FORMAT_FILE_BACKUP)
.exists(),
"disk {disk} should hold the stranded rollback backup"
);
}
let (_deleted, errors) = store
.delete_objects(
&bucket,
vec![ObjectToDelete {
object_name: object.to_string(),
..Default::default()
}],
ObjectOptions::default(),
)
.await;
assert!(errors.iter().all(Option::is_none), "a missing key deletes successfully: {errors:?}");
assert_no_delete_residue(temp.path(), &bucket, object, drives);
}
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn prefix_delete_serializes_with_concurrent_object_lock_metadata_updates() {
@@ -17974,7 +18318,7 @@ mod tests {
let bucket = format!("lock-create-intent-retry-{}", uuid::Uuid::new_v4());
let mut intent = crate::bucket::metadata::BucketMetadata::new(&bucket);
intent.lock_enabled = true;
crate::bucket::metadata_sys::set_new_bucket_metadata_in(&ctx, intent)
crate::bucket::metadata_sys::set_new_bucket_metadata_intent_in(&ctx, intent)
.await
.expect("persist pre-visibility lock intent");
assert!(
+440 -42
View File
@@ -448,8 +448,8 @@ const MAX_LIST_OBJECTS_METADATA_FAST_STALENESS_MS: u64 = 60_000;
const LIST_OBJECTS_INDEX_PROVIDER_WALKER_KEY_ONLY: &str = "walker_key_only";
const LIST_OBJECTS_INDEX_PROVIDER_PERSISTENT_KEY_ONLY: &str = "persistent_key_only";
const LIST_OBJECTS_INDEX_PROVIDER_PERSISTENT_KEY_ONLY_DEFAULT_GENERATION: &str = "persistent-key-only";
const PERSISTENT_KEY_ONLY_INDEX_FORMAT_VERSION: u8 = 2;
const PERSISTENT_KEY_ONLY_INDEX_HEADER: &str = "# rustfs-listobjects-key-only-v2";
const PERSISTENT_KEY_ONLY_INDEX_FORMAT_VERSION: u8 = 3;
const PERSISTENT_KEY_ONLY_INDEX_HEADER: &str = "# rustfs-listobjects-key-only-v3";
const PERSISTENT_KEY_ONLY_INDEX_BUCKET_HEADER: &str = "# bucket=";
const PERSISTENT_KEY_ONLY_INDEX_GENERATION_HEADER: &str = "# generation=";
const PERSISTENT_KEY_ONLY_INDEX_CHECKPOINT_HEADER: &str = "# checkpoint_high_water_mark=";
@@ -656,7 +656,10 @@ impl PersistentListMetadataObject {
name: object.name.clone(),
size: object.size,
mod_time: object.mod_time,
etag: object.etag.clone(),
etag: object
.etag
.clone()
.filter(|_| rustfs_utils::http::etag_can_be_listed(&object.user_defined)),
storage_class: object.storage_class.clone(),
}
}
@@ -2194,6 +2197,41 @@ fn last_scanned_entry_name(entries: Option<&MetaCacheEntriesSorted>) -> Option<S
entries.and_then(|entries| entries.entries().last().map(|entry| entry.name.clone()))
}
fn filter_versions_common_prefixes_after_marker(objects: &mut Vec<ObjectInfo>, delimiter: Option<&str>, marker: Option<&str>) {
if delimiter.is_some()
&& let Some(marker) = marker
{
// S3 excludes CommonPrefixes that are not lexicographically greater than KeyMarker.
// Filter before applying max-keys so a resumed prefix cannot consume the page budget.
objects.retain(|object| !(object.is_dir && object.mod_time.is_none()) || object.name.as_str() > marker);
}
}
fn last_raw_key_for_prefix<'a>(raw_keys: impl Iterator<Item = &'a str>, prefix: &str) -> Option<&'a str> {
raw_keys.filter(|key| key.starts_with(prefix)).last()
}
fn versioned_prefix_page_boundary_key<'a>(
objects: &[ObjectInfo],
raw_keys: impl Iterator<Item = &'a str>,
delimiter: Option<&str>,
max_keys: i32,
disk_has_more: bool,
) -> Option<String> {
if delimiter.is_none() || !disk_has_more || max_keys <= 0 || objects.len() < max_keys as usize {
return None;
}
let last_visible = objects.get(max_keys as usize - 1)?;
if !last_visible.is_dir || last_visible.mod_time.is_some() {
return None;
}
// A folded prefix cannot advance the raw versioned walker. Continue at the last
// raw key represented by the final returned prefix, not at the next prefix's key.
last_raw_key_for_prefix(raw_keys, &last_visible.name).map(str::to_owned)
}
fn build_list_next_marker(objects: &[ObjectInfo], prefixes: &[String], cache_id: Option<&str>) -> Option<String> {
if let Some(last) = objects.last() {
Some(append_list_cache_id_to_marker(last.name.clone(), cache_id))
@@ -2280,14 +2318,20 @@ fn list_objects_paginate(
}
}
if !is_truncated && disk_has_more && !include_version_id {
if !is_truncated && disk_has_more {
let visible_count = objects.len() + prefixes.len();
let should_truncate = if delimiter.is_none() {
visible_count > 0
} else {
visible_count >= max_keys as usize
};
if should_truncate {
if delimiter.is_some()
&& include_version_id
&& let Some(last_scanned) = last_scanned_key
{
is_truncated = true;
next_marker = Some(append_list_cache_id_to_marker(last_scanned.to_owned(), cache_id));
} else if should_truncate {
is_truncated = true;
if include_version_id {
(next_marker, next_version_idmarker) = build_list_versions_next_marker(&objects, &prefixes, cache_id);
@@ -2320,6 +2364,49 @@ fn list_objects_paginate(
(objects, prefixes, is_truncated, next_marker, next_version_idmarker)
}
struct VersionedListPagination<'a, I> {
delimiter: &'a Option<String>,
max_keys: i32,
disk_has_more: bool,
cache_id: Option<&'a str>,
marker: Option<&'a str>,
last_scanned_key: Option<&'a str>,
raw_keys: I,
}
fn list_objects_paginate_versions<'a, I>(
mut get_objects: Vec<ObjectInfo>,
pagination: VersionedListPagination<'a, I>,
) -> (Vec<ObjectInfo>, Vec<String>, bool, Option<String>, Option<String>)
where
I: Iterator<Item = &'a str>,
{
filter_versions_common_prefixes_after_marker(&mut get_objects, pagination.delimiter.as_deref(), pagination.marker);
let prefix_at_page_boundary = versioned_prefix_page_boundary_key(
&get_objects,
pagination.raw_keys,
pagination.delimiter.as_deref(),
pagination.max_keys,
pagination.disk_has_more,
);
let (objects, prefixes, is_truncated, mut next_marker, mut next_version_idmarker) = list_objects_paginate(
get_objects,
pagination.delimiter,
pagination.max_keys,
pagination.disk_has_more,
pagination.cache_id,
true,
pagination.last_scanned_key,
);
if is_truncated && let Some(raw_key) = prefix_at_page_boundary {
next_marker = Some(append_list_cache_id_to_marker(raw_key, pagination.cache_id));
next_version_idmarker = None;
}
(objects, prefixes, is_truncated, next_marker, next_version_idmarker)
}
enum VerifiedIndexVisibleEntry {
Object(Box<ObjectInfo>),
Prefix(String),
@@ -4259,14 +4346,17 @@ impl ECStore {
ObjectInfo::from_meta_cache_entries_sorted_versions(&entries, bucket, prefix, delimiter.clone(), version_marker).await
};
let (objects, prefixes, is_truncated, next_marker, next_version_idmarker) = list_objects_paginate(
let (objects, prefixes, is_truncated, next_marker, next_version_idmarker) = list_objects_paginate_versions(
get_objects,
&delimiter,
max_keys,
disk_has_more,
next_cache_id.as_deref(),
true,
last_scanned_key.as_deref(),
VersionedListPagination {
delimiter: &delimiter,
max_keys,
disk_has_more,
cache_id: next_cache_id.as_deref(),
marker: opts.marker.as_deref(),
last_scanned_key: last_scanned_key.as_deref(),
raw_keys: entries.entries().iter().map(|entry| entry.name.as_str()),
},
);
Ok(ListObjectVersionsInfo {
@@ -5681,23 +5771,22 @@ impl Sets {
// Last RAW scanned key, captured before folding (ECA-03 / #944).
let last_scanned_key = last_scanned_entry_name(list_result.entries.as_ref());
let get_objects = ObjectInfo::from_meta_cache_entries_sorted_versions(
&list_result.entries.unwrap_or_default(),
bucket,
prefix,
delimiter.clone(),
version_marker,
)
.await;
let entries = list_result.entries.unwrap_or_default();
let get_objects =
ObjectInfo::from_meta_cache_entries_sorted_versions(&entries, bucket, prefix, delimiter.clone(), version_marker)
.await;
let (objects, prefixes, is_truncated, next_marker, next_version_idmarker) = list_objects_paginate(
let (objects, prefixes, is_truncated, next_marker, next_version_idmarker) = list_objects_paginate_versions(
get_objects,
&delimiter,
max_keys,
disk_has_more,
next_cache_id.as_deref(),
true,
last_scanned_key.as_deref(),
VersionedListPagination {
delimiter: &delimiter,
max_keys,
disk_has_more,
cache_id: next_cache_id.as_deref(),
marker: opts.marker.as_deref(),
last_scanned_key: last_scanned_key.as_deref(),
raw_keys: entries.entries().iter().map(|entry| entry.name.as_str()),
},
);
Ok(ListObjectVersionsInfo {
@@ -6498,23 +6587,22 @@ impl SetDisks {
// Last RAW scanned key, captured before folding (ECA-03 / #944).
let last_scanned_key = last_scanned_entry_name(list_result.entries.as_ref());
let get_objects = ObjectInfo::from_meta_cache_entries_sorted_versions(
&list_result.entries.unwrap_or_default(),
bucket,
prefix,
delimiter.clone(),
version_marker,
)
.await;
let entries = list_result.entries.unwrap_or_default();
let get_objects =
ObjectInfo::from_meta_cache_entries_sorted_versions(&entries, bucket, prefix, delimiter.clone(), version_marker)
.await;
let (objects, prefixes, is_truncated, next_marker, next_version_idmarker) = list_objects_paginate(
let (objects, prefixes, is_truncated, next_marker, next_version_idmarker) = list_objects_paginate_versions(
get_objects,
&delimiter,
max_keys,
disk_has_more,
next_cache_id.as_deref(),
true,
last_scanned_key.as_deref(),
VersionedListPagination {
delimiter: &delimiter,
max_keys,
disk_has_more,
cache_id: next_cache_id.as_deref(),
marker: opts.marker.as_deref(),
last_scanned_key: last_scanned_key.as_deref(),
raw_keys: entries.entries().iter().map(|entry| entry.name.as_str()),
},
);
Ok(ListObjectVersionsInfo {
@@ -8697,6 +8785,223 @@ mod test {
assert_eq!(next_marker.as_deref(), Some("obj-0002"));
}
// ECA-03 / #944 (versioned variant): a versions listing whose raw keys fully
// collapse into fewer than max_keys common prefixes must still report
// truncation and carry a continuation marker, otherwise every key and every
// version past the scan window is silently dropped.
#[test]
fn list_objects_paginate_versioned_delimiter_refold_reports_truncation() {
let delimiter = Some("-".to_string());
let get_objects = vec![folded_prefix_object("data-")];
let (objects, prefixes, is_truncated, next_marker, next_version_idmarker) = list_objects_paginate(
get_objects,
&delimiter,
1000,
true, // disk_has_more: walker filled its raw candidate limit
None,
true,
Some("data-1001"), // last RAW scanned key
);
assert!(objects.is_empty());
assert_eq!(prefixes, vec!["data-".to_string()]);
assert!(is_truncated, "versioned re-folded page with more on disk must be truncated");
// The marker must be the last RAW key, NOT the folded prefix "data-":
// versioned walks are excluded from the gather-level common-prefix
// collector, so a prefix marker could never advance `forward_past`.
assert_eq!(next_marker.as_deref(), Some("data-1001"));
assert!(next_version_idmarker.is_none(), "a folded prefix page carries no version marker");
}
// Same contract when the page is FILLED by folded common prefixes
// (`visible_count >= max_keys`): the versioned path must still prefer the
// raw scanned key over `prefixes.last()`, which would loop forever.
#[test]
fn list_objects_paginate_versioned_delimiter_full_prefix_page_uses_raw_marker() {
let delimiter = Some("-".to_string());
let get_objects = vec![folded_prefix_object("data-")];
let (objects, prefixes, is_truncated, next_marker, next_version_idmarker) =
list_objects_paginate(get_objects, &delimiter, 1, true, None, true, Some("data-0002"));
assert!(objects.is_empty());
assert_eq!(prefixes, vec!["data-".to_string()]);
assert!(is_truncated, "a prefix-filled versioned page with more on disk must be truncated");
assert_eq!(next_marker.as_deref(), Some("data-0002"));
assert!(next_version_idmarker.is_none());
}
// End-to-end simulation of versioned pagination with `max_keys=1`, where the
// next page resumes via a bare key marker (`forward_past` keeps `name > marker`
// only, matching `inner_list_object_versions_with_projection` without a version
// marker). Before the raw-key marker the same window replayed forever, so
// `other-`/`zzz` were never listed.
#[test]
fn list_objects_paginate_versioned_delimiter_prefix_filled_pages_terminate() {
let all: Vec<String> = [
"data-0001",
"data-0002",
"data-0003",
"data-0004",
"data-0005",
"other-0001",
"zzz",
]
.into_iter()
.map(str::to_string)
.collect();
let delimiter = Some("-".to_string());
let max_keys = 1i32;
let limit = max_keys_plus_one(max_keys, true) as usize;
let mut marker: Option<String> = None;
let mut seen_prefixes = Vec::new();
let mut seen_objects = Vec::new();
let mut page_prefixes = Vec::new();
let mut pages = 0;
loop {
pages += 1;
assert!(pages <= 16, "versioned pagination did not terminate (possible infinite loop)");
let start = match &marker {
Some(m) => all.partition_point(|k| k.as_str() <= m.as_str()),
None => 0,
};
let window: Vec<String> = all[start..].iter().take(limit).cloned().collect();
let disk_has_more = window.len() == limit;
let last_scanned = window.last().cloned();
let get_objects = fold_delimiter_page(&window, "", "-");
let (objects, prefixes, is_truncated, next_marker, _v) = super::list_objects_paginate_versions(
get_objects,
super::VersionedListPagination {
delimiter: &delimiter,
max_keys,
disk_has_more,
cache_id: None,
marker: marker.as_deref(),
last_scanned_key: last_scanned.as_deref(),
raw_keys: window.iter().map(String::as_str),
},
);
assert!(
prefixes.windows(2).all(|pair| pair[0] != pair[1]),
"a page must not contain duplicate common prefixes"
);
assert!(
prefixes
.iter()
.all(|prefix| marker.as_deref().is_none_or(|marker| prefix.as_str() > marker)),
"a resumed page must not repeat a prefix at or before its key marker"
);
page_prefixes.push(prefixes.clone());
seen_objects.extend(objects.into_iter().map(|object| object.name));
seen_prefixes.extend(prefixes);
if !is_truncated {
assert!(next_marker.is_none());
break;
}
let next = next_marker.expect("truncated versioned page must carry a continuation marker");
if let Some(prev) = &marker {
assert!(
next.as_str() > prev.as_str(),
"next_marker must strictly advance to stay finite, got {next} after {prev}"
);
}
marker = Some(next);
}
assert_eq!(
page_prefixes,
vec![vec!["data-".to_string()], vec![], vec!["other-".to_string()], vec![]],
"versioned pagination must not repeat a folded prefix while scanning later raw keys"
);
assert_eq!(
seen_prefixes,
vec!["data-".to_string(), "other-".to_string()],
"versioned pagination must preserve the ordered visible result sequence"
);
assert_eq!(seen_objects, vec!["zzz".to_string()]);
}
#[test]
fn list_objects_paginate_versioned_prefix_boundary_uses_last_raw_key_in_returned_prefix() {
let all: Vec<String> = ["alpha-0001", "beta-0001", "charlie-0001"]
.into_iter()
.map(str::to_string)
.collect();
let delimiter = Some("-".to_string());
let max_keys = 1;
let limit = max_keys_plus_one(max_keys, true) as usize;
let mut marker: Option<String> = None;
let mut page_prefixes = Vec::new();
let mut pages = 0;
loop {
pages += 1;
assert!(pages <= 8, "versioned prefix pagination did not terminate");
let start = marker
.as_deref()
.map(|marker| all.partition_point(|key| key.as_str() <= marker))
.unwrap_or(0);
let window: Vec<String> = all[start..].iter().take(limit).cloned().collect();
let disk_has_more = window.len() == limit;
let last_scanned = window.last().cloned();
let get_objects = fold_delimiter_page(&window, "", "-");
let (objects, prefixes, is_truncated, next_marker, _) = super::list_objects_paginate_versions(
get_objects,
super::VersionedListPagination {
delimiter: &delimiter,
max_keys,
disk_has_more,
cache_id: None,
marker: marker.as_deref(),
last_scanned_key: last_scanned.as_deref(),
raw_keys: window.iter().map(String::as_str),
},
);
assert_eq!(objects.len() + prefixes.len(), 1, "a page must respect max-keys");
page_prefixes.push(prefixes);
if !is_truncated {
assert!(next_marker.is_none());
break;
}
let next = next_marker.expect("truncated page must carry a continuation key marker");
if let Some(previous) = &marker {
assert!(next > *previous, "the continuation marker must strictly advance");
}
marker = Some(next);
}
assert_eq!(
page_prefixes,
vec![
vec!["alpha-".to_string()],
vec!["beta-".to_string()],
vec!["charlie-".to_string()]
]
);
}
#[test]
fn versioned_marker_filter_does_not_change_no_delimiter_directories() {
let mut objects = vec![folded_prefix_object("data-")];
super::filter_versions_common_prefixes_after_marker(&mut objects, None, Some("z"));
assert_eq!(objects.len(), 1);
assert!(super::versioned_prefix_page_boundary_key(&objects, std::iter::once("data-"), None, 1, true).is_none());
}
// End-to-end pagination over 5000 `data-*` + 100 `other-*` with delimiter '-'.
// Every full page re-folds to a single common prefix (< max_keys), the exact
// trigger for ECA-03 / #944. Asserts pagination terminates in a bounded number
@@ -8903,6 +9208,38 @@ mod test {
assert_eq!(parsed, object);
}
#[test]
fn persistent_listing_snapshots_cannot_restore_legacy_encrypted_fingerprints() {
let mut object = ObjectInfo {
name: "encrypted".to_owned(),
etag: Some("fbfcbcc6d2f035411e6268c8b119593e".to_owned()),
user_defined: Arc::new(HashMap::from([(
"x-amz-server-side-encryption-customer-algorithm".to_owned(),
"AES256".to_owned(),
)])),
..Default::default()
};
let snapshot = PersistentListMetadataObject::from_object_info(&object);
assert!(snapshot.to_object_info("bucket").etag.is_none());
let mut old_snapshot = snapshot.clone();
old_snapshot.etag = object.etag.clone();
let old_contents = format!(
"# rustfs-listobjects-key-only-v2\n{}\n",
encode_persistent_list_metadata_object(&old_snapshot)
);
let old_index = parse_persistent_key_only_index(&old_contents);
assert_ne!(old_index.format_version, PERSISTENT_KEY_ONLY_INDEX_FORMAT_VERSION);
rustfs_utils::http::metadata_compat::insert_str(
Arc::make_mut(&mut object.user_defined),
rustfs_utils::http::SUFFIX_OPAQUE_ENCRYPTED_ETAG,
"v1".to_owned(),
);
assert_eq!(PersistentListMetadataObject::from_object_info(&object).etag, object.etag);
object.user_defined = Arc::new(HashMap::new());
assert_eq!(PersistentListMetadataObject::from_object_info(&object).etag, object.etag);
}
#[test]
fn persistent_key_only_index_parses_legacy_keys_and_metadata_snapshots() {
let object = PersistentListMetadataObject {
@@ -9644,6 +9981,67 @@ mod test {
}
}
// #6898: what an abandoned DeleteObjects left on every disk (rollback
// backup plus marked data dir) no longer blocks DeleteBucket after an empty
// recursive listing of the bucket.
#[tokio::test]
async fn empty_recursive_listing_purges_stranded_delete_rollback_backups() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions};
let (dirs, store) = isolated_store_over_temp_disks().await;
let bucket = "listing-purge-rollback-backup";
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created with authoritative metadata");
let transaction = uuid::Uuid::new_v4();
for object in ["repro/obj-00001.bin", "repro/obj-00002.bin"] {
let data_dir = uuid::Uuid::new_v4();
for dir in &dirs {
let object_dir = dir.path().join(bucket).join(object);
let residue = object_dir.join(data_dir.to_string());
tokio::fs::create_dir_all(&residue)
.await
.expect("committed data dir should be created");
tokio::fs::write(residue.join("part.1"), b"stale")
.await
.expect("stale part should be written");
tokio::fs::write(
residue.join(format!("{}{}", crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX, transaction)),
[],
)
.await
.expect("committed delete marker should be written");
let backup = object_dir.join(transaction.to_string());
tokio::fs::create_dir_all(&backup)
.await
.expect("rollback dir should be created");
tokio::fs::write(backup.join(crate::disk::STORAGE_FORMAT_FILE_BACKUP), b"rollback metadata")
.await
.expect("rollback backup should be written");
}
}
let result = store
.clone()
.list_objects_generic(bucket, "", None, None, 1000, false)
.await
.expect("recursive bucket listing should succeed");
assert!(result.objects.is_empty());
for dir in &dirs {
assert!(
!dir.path().join(bucket).join("repro").exists(),
"the empty listing should reclaim the stranded delete residue"
);
}
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("the bucket is empty once the residue is reclaimed");
}
#[tokio::test]
async fn empty_recursive_bucket_listing_purges_orphan_directory_prefixes() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
+174 -25
View File
@@ -2456,6 +2456,27 @@ fn return_batch_delete_lock_error_with_accounting(
(deleted, errors, vec![None; objects.len()])
}
const EVENT_DETACHED_DELETE_FAILED: &str = "detached_delete_failed";
const LOG_SUBSYSTEM_STORE_DELETE: &str = "store_delete";
/// A detached delete only fails to join when it panicked or the runtime is
/// shutting down; report either as a failed delete to the waiting caller.
fn detached_delete_failed(operation: &'static str, err: tokio::task::JoinError) -> Error {
error!(
event = EVENT_DETACHED_DELETE_FAILED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_STORE_DELETE,
operation,
reason = if err.is_panic() { "task_panicked" } else { "task_cancelled" },
"Detached delete task failed"
);
if err.is_cancelled() {
Error::OperationCanceled
} else {
Error::Unexpected
}
}
fn sorted_unique_delete_object_names(objects: &[ObjectToDelete]) -> Vec<&str> {
let mut object_names: Vec<&str> = objects.iter().map(|object| object.object_name.as_str()).collect();
object_names.sort_unstable();
@@ -4798,19 +4819,54 @@ impl ECStore {
purged
}
/// Delete one object (or a prefix) on behalf of a caller that may be
/// dropped mid-way, such as an S3 request whose client disconnects.
///
/// The delete runs to completion on a detached task that owns its locks
/// and both on-disk phases, so the object is left either intact or fully
/// deleted, never with its metadata gone and its data still in place.
pub async fn delete_object_with_tier_delete_journal(
self: &Arc<Self>,
bucket: &str,
object: &str,
opts: ObjectOptions,
) -> Result<ObjectInfo> {
let result = self
.handle_delete_object_with_journal(bucket, object, opts, Some(Arc::clone(self)))
.await;
if result.is_ok() {
list_objects::observe_list_objects_mutation(self, bucket).await;
}
result
self.delete_object_with_tier_delete_journal_and_guards(bucket, object, opts, None)
.await
}
/// [`Self::delete_object_with_tier_delete_journal`] for a caller that
/// holds namespace locks covering the delete and passes only their fences
/// in `opts`, such as the bucket lifecycle write lock of a recursive delete.
///
/// The guards move into the detached task and are released only after
/// the delete has finished. Dropping the caller therefore cannot let a
/// writer into the scope they exclude while the delete is still running.
pub async fn delete_object_with_tier_delete_journal_and_guards(
self: &Arc<Self>,
bucket: &str,
object: &str,
opts: ObjectOptions,
guards: impl IntoIterator<Item = rustfs_lock::NamespaceLockGuard>,
) -> Result<ObjectInfo> {
let guards: Vec<_> = guards.into_iter().collect();
let store = Arc::clone(self);
let bucket = bucket.to_owned();
let object = object.to_owned();
self.ctx
.run_detached_mutation(async move {
let result = store
.handle_delete_object_with_journal(&bucket, &object, opts, Some(Arc::clone(&store)))
.await;
if result.is_ok() {
list_objects::observe_list_objects_mutation(&store, &bucket).await;
}
// Keep: the only use of `guards` here, it moves them into the task so the locks outlive a dropped caller.
drop(guards);
result
})
.await
.unwrap_or_else(|err| Err(detached_delete_failed("delete_object", err)))
}
pub async fn delete_objects_with_tier_delete_journal(
@@ -4819,30 +4875,43 @@ impl ECStore {
objects: Vec<ObjectToDelete>,
opts: ObjectOptions,
) -> (Vec<DeletedObject>, Vec<Option<Error>>) {
let result = self
.handle_delete_objects_with_journal(bucket, objects, opts, Some(Arc::clone(self)))
let (deleted, errors, _) = self
.delete_objects_with_tier_delete_journal_and_accounting(bucket, objects, opts)
.await;
let success_count = result.1.iter().filter(|err| err.is_none()).count();
if success_count > 0 {
list_objects::observe_list_objects_mutations(self, bucket, success_count).await;
}
result
(deleted, errors)
}
/// Batch form of [`Self::delete_object_with_tier_delete_journal`]: the
/// whole batch, including its object locks, runs on one detached task.
pub async fn delete_objects_with_tier_delete_journal_and_accounting(
self: &Arc<Self>,
bucket: &str,
objects: Vec<ObjectToDelete>,
opts: ObjectOptions,
) -> (Vec<DeletedObject>, Vec<Option<Error>>, Vec<Option<DeleteAccounting>>) {
let result = self
.handle_delete_objects_with_journal_and_accounting(bucket, objects, opts, Some(Arc::clone(self)))
.await;
let success_count = result.1.iter().filter(|err| err.is_none()).count();
if success_count > 0 {
list_objects::observe_list_objects_mutations(self, bucket, success_count).await;
}
result
let store = Arc::clone(self);
let bucket = bucket.to_owned();
let object_count = objects.len();
self.ctx
.run_detached_mutation(async move {
let result = store
.handle_delete_objects_with_journal_and_accounting(&bucket, objects, opts, Some(Arc::clone(&store)))
.await;
let success_count = result.1.iter().filter(|err| err.is_none()).count();
if success_count > 0 {
list_objects::observe_list_objects_mutations(&store, &bucket, success_count).await;
}
result
})
.await
.unwrap_or_else(|err| {
let err = detached_delete_failed("delete_objects", err);
(
vec![DeletedObject::default(); object_count],
vec![Some(err); object_count],
vec![None; object_count],
)
})
}
#[instrument(skip(self))]
@@ -5282,13 +5351,41 @@ impl ECStore {
tier_journal_api: Option<Arc<ECStore>>,
) -> (Vec<DeletedObject>, Vec<Option<Error>>, Vec<Option<DeleteAccounting>>) {
Box::pin(async move {
let count = objects.len();
let mut deleted = vec![DeletedObject::default(); count];
let mut errors: Vec<Option<Error>> = (0..count).map(|_| None).collect();
let mut accounting = vec![None; count];
let mut valid_objects = Vec::with_capacity(count);
let mut valid_indices = Vec::with_capacity(count);
// Validate the literal key before encoding it or taking locks. A
// dot-segment key must never reach filesystem path resolution.
for (index, object) in objects.into_iter().enumerate() {
match check_del_obj_args(bucket, &object.object_name) {
Ok(()) => {
valid_indices.push(index);
valid_objects.push(object);
}
Err(err) => errors[index] = Some(err),
}
}
if valid_objects.is_empty() {
return (deleted, errors, accounting);
}
let mut opts = opts;
let receipt_sink = install_tier_free_version_receipt_sink(&mut opts);
let result = self
.handle_delete_objects_with_journal_and_accounting_inner(bucket, objects, opts, tier_journal_api)
let (valid_deleted, valid_errors, valid_accounting) = self
.handle_delete_objects_with_journal_and_accounting_inner(bucket, valid_objects, opts, tier_journal_api)
.await;
enqueue_recorded_tier_free_versions(self, receipt_sink).await;
result
for (index, ((object, error), receipt)) in valid_indices
.into_iter()
.zip(valid_deleted.into_iter().zip(valid_errors).zip(valid_accounting))
{
deleted[index] = object;
errors[index] = error;
accounting[index] = receipt;
}
(deleted, errors, accounting)
})
.await
}
@@ -9025,6 +9122,58 @@ mod tests {
(dirs, store)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn batch_delete_rejects_dot_segments_and_keeps_valid_result_positions() {
let bucket = "batch-delete-key-validation";
let (_dirs, store) = multipool_version_test_store(bucket).await;
let options = ObjectOptions {
versioned: true,
..Default::default()
};
let victim = store
.put_object(bucket, "victim/object", &mut PutObjReader::from_vec(b"victim".to_vec()), &options)
.await
.expect("write protected-prefix control object");
store
.put_object(bucket, "allowed/object", &mut PutObjReader::from_vec(b"allowed".to_vec()), &options)
.await
.expect("write valid deletion control object");
let keys = [
"allowed/../victim/object",
"allowed/object",
"a/./b",
"../other-bucket/object",
];
let objects = keys
.iter()
.map(|key| ObjectToDelete {
object_name: (*key).to_owned(),
..Default::default()
})
.collect();
let (deleted, errors) = store.handle_delete_objects(bucket, objects, options).await;
assert_eq!(deleted.len(), keys.len());
assert_eq!(errors.len(), keys.len());
for index in [0, 2, 3] {
assert!(
matches!(&errors[index], Some(StorageError::ObjectNameInvalid(_, _))),
"invalid key {}: {:?}",
keys[index],
errors[index]
);
assert!(!deleted[index].delete_marker);
}
assert!(errors[1].is_none(), "valid deletion must still succeed: {:?}", errors[1]);
assert!(deleted[1].delete_marker);
assert_eq!(deleted[1].object_name, "allowed/object");
let after = store
.get_object_info(bucket, "victim/object", &ObjectOptions::default())
.await
.expect("the resolved victim path must remain visible");
assert_eq!(after.version_id, victim.version_id);
assert_eq!(after.etag, victim.etag);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn retired_marker_converges_after_bucket_recreation_on_sixteen_disks() {
let bucket = "retired-marker-c11";
+43 -4
View File
@@ -141,7 +141,7 @@ impl Error {
return true;
}
err.is_quorum_error()
|| matches!(err, EcstoreError::Io(error) if is_recoverable_internode_error(error))
|| matches!(err, EcstoreError::Io(error) if is_recoverable_io_error(error))
|| matches!(
err,
EcstoreError::DiskNotFound
@@ -171,11 +171,11 @@ impl Error {
| DiskError::FaultyRemoteDisk
| DiskError::FaultyDisk
| DiskError::RemoteClientUnavailable(_)
) || matches!(err, DiskError::Io(error) if is_recoverable_internode_error(error))
) || matches!(err, DiskError::Io(error) if is_recoverable_io_error(error))
|| is_recoverable_heal_error_message(&err.to_string())
}
Error::TaskExecutionFailed { message } | Error::Other(message) => is_recoverable_heal_error_message(message),
Error::Io(err) => is_recoverable_internode_error(err) || is_recoverable_heal_error_message(&err.to_string()),
Error::Io(err) => is_recoverable_io_error(err) || is_recoverable_heal_error_message(&err.to_string()),
_ => false,
}
}
@@ -222,7 +222,15 @@ impl Error {
}
}
fn is_recoverable_internode_error(error: &std::io::Error) -> bool {
fn is_recoverable_io_error(error: &std::io::Error) -> bool {
// ObjectIO preserves store errors as IO sources; their quorum identity must survive this boundary.
if error
.get_ref()
.and_then(|source| source.downcast_ref::<EcstoreError>())
.is_some_and(EcstoreError::is_quorum_error)
{
return true;
}
// A peer restart can cancel an RPC after a partial repair. Replay it within
// the existing heal retry budget; task cancellation remains terminal.
if DiskError::io_error_is_rpc_cancelled(error) {
@@ -301,6 +309,37 @@ mod tests {
use super::Error;
use crate::heal::{DiskError, EcstoreError};
#[test]
fn namespace_lock_quorum_is_recoverable_across_io_error_conversions() {
for (required, achieved) in [(2, 1), (1, 0)] {
let quorum = || EcstoreError::NamespaceLockQuorumUnavailable {
mode: "write",
bucket: "bucket".to_owned(),
object: "object".to_owned(),
required,
achieved,
};
for error in [
Error::Storage(quorum()),
Error::Io(std::io::Error::from(quorum())),
Error::Storage(EcstoreError::from(std::io::Error::from(quorum()))),
Error::Disk(DiskError::from(std::io::Error::from(quorum()))),
] {
assert!(
error.is_recoverable_object_heal(),
"typed lock quorum failure must remain retryable: {error:?}"
);
}
}
let message = "Namespace lock quorum unavailable for write lock on bucket/object: required 2, achieved 1";
assert!(!Error::Io(std::io::Error::other(message)).is_recoverable_object_heal());
let denied = EcstoreError::Lock(rustfs_lock::LockError::PermissionDenied {
reason: message.to_owned(),
});
assert!(!Error::Storage(EcstoreError::from(std::io::Error::from(denied))).is_recoverable_object_heal());
}
#[test]
fn cancelled_rpc_is_recoverable_across_storage_error_conversions() {
let status = tonic::Status::cancelled("operation was canceled");
+46
View File
@@ -337,6 +337,12 @@ impl ErasureSetHealer {
return HealObjectOutcome::Failed;
};
if let EcstoreError::Lock(lock_error) = se
&& !lock_error.is_fatal()
{
return HealObjectOutcome::Transient;
}
// Genuine object/version absence: nothing left to heal, treat as handled.
if matches!(
se,
@@ -2142,6 +2148,46 @@ mod tests {
));
}
#[test]
fn retryable_lock_failures_are_transient_but_fatal_lock_errors_are_not() {
use rustfs_lock::LockError;
assert!(matches!(
ErasureSetHealer::classify_heal_object_error(&Error::Storage(EcstoreError::Lock(LockError::timeout(
"bucket/object",
std::time::Duration::from_secs(5)
)))),
HealObjectOutcome::Transient
));
assert!(matches!(
ErasureSetHealer::classify_heal_object_error(&Error::Storage(EcstoreError::Lock(LockError::QuorumNotReached {
required: 3,
achieved: 1,
}))),
HealObjectOutcome::Transient
));
assert!(matches!(
ErasureSetHealer::classify_heal_object_error(&Error::Storage(EcstoreError::Lock(LockError::already_locked(
"bucket/object",
"node-2"
)))),
HealObjectOutcome::Transient
));
assert!(matches!(
ErasureSetHealer::classify_heal_object_error(&Error::Storage(EcstoreError::Lock(LockError::InsufficientNodes {
required: 3,
available: 1,
}))),
HealObjectOutcome::Transient
));
assert!(matches!(
ErasureSetHealer::classify_heal_object_error(&Error::Storage(EcstoreError::Lock(LockError::permission_denied(
"lock policy"
)))),
HealObjectOutcome::Failed
));
}
#[test]
fn dangling_delete_grace_is_transient() {
assert!(matches!(
+5
View File
@@ -45,6 +45,7 @@ use tokio::{
};
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
use uuid::Uuid;
use super::{DiskError, Endpoint, HealDiskExt as _, local_disk_map_read};
@@ -1903,6 +1904,10 @@ impl HealManager {
}
}
pub(crate) async fn mrf_bucket_incarnation_retired(&self, bucket: &str, expected: Uuid) -> Result<bool> {
self.storage.mrf_bucket_incarnation_retired(bucket, expected).await
}
async fn submit_heal_request_with_receipt_alias_and_mrf_notice(
&self,
mut request: HealRequest,
+4 -1
View File
@@ -1366,8 +1366,10 @@ impl MrfRuntime {
rustfs_common::mrf_channel::consume_recorded_verified_mrf_repair_events_for(bucket.as_ref(), &mut anchors);
}
let remaining: HashSet<_> = anchors.into_iter().collect();
let previous_replay_anchor_count = self.durable_replay_anchors.len();
self.durable_replay_anchors.retain(|anchor| remaining.contains(anchor));
self.dirty |= self.partial_writes.retain_unproven(&remaining);
let replay_anchors_changed = self.durable_replay_anchors.len() != previous_replay_anchor_count;
self.dirty |= replay_anchors_changed || self.partial_writes.retain_unproven(&remaining);
for bucket in buckets {
for event in rustfs_common::mrf_channel::take_mrf_unverified_legacy_events_for(bucket.as_ref()) {
if self.partial_writes.park_unverified_legacy(&event.anchor) {
@@ -1944,6 +1946,7 @@ async fn run_mrf_consumer(
}
}
_ = flush_tick.tick() => {
runtime.partial_writes.note_retired_generation_proofs(manager.as_ref()).await;
runtime.discharge_durable_replay_anchors();
match tick_action(
runtime.dirty,
+156 -1
View File
@@ -16,10 +16,14 @@ use super::{
HealManager, MrfConsumerConfig, MrfDurableRepairAnchor, MrfIntent, MrfLegacyRiskAcceptanceRequest, MrfQueueKey, queue_key,
submit_mrf_heal_request,
};
use rustfs_common::mrf_channel::{MrfDurableAdmissionError, MrfIngressResult, release_mrf_intent, try_rearm_mrf_replay_intent};
use rustfs_common::mrf_channel::{
MrfDurableAdmissionError, MrfIngressResult, MrfVerifiedRepairDisposition, MrfVerifiedRepairEvent, release_mrf_intent,
try_rearm_mrf_replay_intent,
};
use rustfs_heal_contracts::heal_channel::HealAdmissionResult;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::time::Instant;
use uuid::Uuid;
@@ -29,6 +33,7 @@ const MAX_OPERATOR_ACTOR_BYTES: usize = 256;
const MAX_OPERATOR_REFERENCE_BYTES: usize = 256;
const LIFECYCLE_CHECKPOINT_RECORD_OVERHEAD_BYTES: usize = 256;
const MAX_UNVERIFIED_RETRY_BACKOFF: Duration = Duration::from_secs(60);
const MRF_RETIREMENT_PROOF_RECHECK_INTERVAL: Duration = Duration::from_secs(5);
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct PartialWriteKey {
@@ -229,6 +234,7 @@ pub(super) struct PartialWrites {
entries: HashMap<PartialWriteKey, Responsibility>,
retry_order: VecDeque<PartialWriteKey>,
retry_index: HashSet<PartialWriteKey>,
retirement_checks: HashMap<(Arc<str>, Uuid), (Instant, bool)>,
bytes: usize,
}
@@ -335,6 +341,66 @@ impl PartialWrites {
self.entries.values().filter_map(|entry| entry.anchor.as_ref())
}
pub(super) async fn note_retired_generation_proofs(&mut self, manager: &HealManager) {
let mut anchors_by_generation: HashMap<(Arc<str>, Uuid), Vec<MrfDurableRepairAnchor>> = HashMap::new();
for entry in self.entries.values() {
if !entry.persisted {
continue;
}
let Some(source_incarnation) = entry.source_bucket_incarnation_id else {
continue;
};
let Some(anchor) = MrfDurableRepairAnchor::from_intent(&entry.intent, source_incarnation)
.filter(|anchor| anchor.bucket_incarnation_id == source_incarnation)
else {
continue;
};
anchors_by_generation
.entry((anchor.bucket.clone(), source_incarnation))
.or_default()
.push(anchor);
}
self.retirement_checks
.retain(|generation, _| anchors_by_generation.contains_key(generation));
let now = Instant::now();
for ((bucket, incarnation), anchors) in anchors_by_generation {
let retired = match self.retirement_checks.get(&(bucket.clone(), incarnation)) {
Some((_, true)) => true,
Some((checked_at, false))
if now.saturating_duration_since(*checked_at) < MRF_RETIREMENT_PROOF_RECHECK_INTERVAL =>
{
false
}
_ => {
let retired = manager
.mrf_bucket_incarnation_retired(bucket.as_ref(), incarnation)
.await
.unwrap_or(false);
self.retirement_checks.insert((bucket.clone(), incarnation), (now, retired));
retired
}
};
if !retired {
continue;
}
for anchor in anchors {
rustfs_common::mrf_channel::note_mrf_verified_repair(MrfVerifiedRepairEvent {
kind: anchor.kind,
bucket: anchor.bucket.clone(),
object: anchor.object.clone(),
version_id: anchor.version_id,
scope: anchor.scope,
delete_marker_purge: anchor.delete_marker_purge,
lease: Some(anchor.lease),
bucket_incarnation_id: anchor.bucket_incarnation_id,
disposition: MrfVerifiedRepairDisposition::AuthoritativelyAbsent,
});
}
}
}
pub(super) fn park_unverified_legacy(&mut self, anchor: &MrfDurableRepairAnchor) -> bool {
let key = PartialWriteKey::from_anchor(anchor);
let Some(entry) = self.entries.get_mut(&key) else {
@@ -945,6 +1011,7 @@ mod tests {
use super::*;
use crate::heal::mrf_queue::MrfLegacyRiskAcceptanceRequest;
use crate::heal::storage::{ECStoreHealStorage, HealStorageAPI};
use crate::heal::storage_api::storage::{BucketOperations as _, DeleteBucketOptions};
use rustfs_common::mrf_channel::{MrfKind, MrfScope, MrfVerifiedRepairDisposition, MrfVerifiedRepairEvent};
use serial_test::serial;
use std::sync::Arc;
@@ -1861,6 +1928,94 @@ mod tests {
assert!(runtime.partial_writes.intent_for_responsibility(current_id).is_none());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn durable_mrf_anchor_retires_only_after_exact_bucket_generation_is_deleted() {
use super::super::{MrfQueue, MrfRuntime};
let env = rustfs_test_utils::TestECStoreEnv::builder()
.prefix("rustfs_mrf_retirement_proof")
.build()
.await;
let bucket = "mrf-retired-generation";
env.make_bucket(bucket, false).await;
let incarnation = env
.ecstore
.bucket_incarnation_id_from_disk(bucket)
.await
.expect("current generation identity");
let storage: Arc<dyn HealStorageAPI> = Arc::new(ECStoreHealStorage::new(env.ecstore.clone()));
let manager = Arc::new(HealManager::new_without_root_recovery_for_test(storage, None));
let mut item = intent("durable-object");
item.bucket = Arc::from(bucket);
let mut partial_writes = PartialWrites::default();
partial_writes
.admit_with_source_incarnation(item.clone(), Some(incarnation), 1, 8192)
.expect("retain exact old-generation responsibility");
partial_writes.mark_persisted();
let key = PartialWriteKey::new(&item, Some(incarnation));
let anchor = MrfDurableRepairAnchor::from_intent(&item, incarnation).expect("exact retirement anchor");
partial_writes.entries.get_mut(&key).expect("durable responsibility").anchor = Some(anchor);
assert!(
!manager
.mrf_bucket_incarnation_retired(bucket, incarnation)
.await
.expect("live generation is queryable"),
"a live generation cannot retire its MRF anchors"
);
env.ecstore
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("normal bucket deletion publishes durable retirement evidence");
partial_writes.note_retired_generation_proofs(manager.as_ref()).await;
let config = MrfConsumerConfig::default();
let mut runtime = MrfRuntime {
partial_writes,
queue: MrfQueue::new(config.queue_capacity, config.journal_max_bytes),
config,
checkpoint_owner: Uuid::new_v4(),
next_checkpoint_sequence: 1,
new_since_flush: 0,
dirty: false,
journal_on_disk: true,
retain_replay_journal: false,
durable_replay_anchors: Vec::new(),
replay_cleanup: None,
runtime_checkpoint: None,
backoff_until: None,
};
runtime.discharge_durable_replay_anchors();
assert_eq!(
runtime.partial_writes.depth(),
0,
"retirement proof should discharge the exact old anchor"
);
assert!(runtime.dirty, "discharging durable responsibility must publish a successor checkpoint");
let manually_removed_bucket = "mrf-manually-removed-volume";
env.make_bucket(manually_removed_bucket, false).await;
let manual_incarnation = env
.ecstore
.bucket_incarnation_id_from_disk(manually_removed_bucket)
.await
.expect("manual-removal bucket generation identity");
for disk_path in &env.disk_paths {
tokio::fs::remove_dir_all(disk_path.join(manually_removed_bucket))
.await
.expect("simulate out-of-band volume removal");
}
assert!(
!manager
.mrf_bucket_incarnation_retired(manually_removed_bucket, manual_incarnation)
.await
.expect("missing retirement proof is authoritative absence of proof"),
"out-of-band disk removal must not be mistaken for a durable retirement record"
);
}
#[test]
fn lifecycle_listing_uses_stable_bounded_cursors() {
let mut writes = PartialWrites::default();
+14
View File
@@ -459,6 +459,13 @@ pub trait HealStorageAPI: Send + Sync {
Ok(None)
}
/// Whether the exact old bucket generation has a durable deletion record.
/// Alternate backends must fail closed unless they can validate the same
/// deployment, bucket and incarnation identity.
async fn mrf_bucket_incarnation_retired(&self, _bucket: &str, _expected: Uuid) -> Result<bool> {
Ok(false)
}
/// Aggregate usage-cache baselines for the requested buckets.
async fn erasure_set_usage_baseline(&self, _buckets: &[String]) -> Result<Option<HealBucketUsageBaseline>> {
Ok(None)
@@ -1178,6 +1185,13 @@ impl HealStorageAPI for ECStoreHealStorage {
.map_err(Error::Storage)
}
async fn mrf_bucket_incarnation_retired(&self, bucket: &str, expected: Uuid) -> Result<bool> {
self.ecstore
.is_bucket_incarnation_retired(bucket, expected)
.await
.map_err(Error::Storage)
}
async fn erasure_set_usage_baseline(&self, buckets: &[String]) -> Result<Option<HealBucketUsageBaseline>> {
if buckets.is_empty() {
return Ok(None);
+3
View File
@@ -53,6 +53,9 @@ pub(crate) mod storage {
BucketInfo, BucketOperations, DiskSetSelector, HealOperations, ListOperations, ObjectIO, ObjectOperations,
StorageAdminApi,
};
#[cfg(test)]
pub(crate) use super::storage_contracts::DeleteBucketOptions;
}
#[cfg(test)]
+1
View File
@@ -30,6 +30,7 @@ workspace = true
[features]
default = []
test-util = []
hotpath = [
"hotpath/hotpath",
"hotpath/tokio",
+500
View File
@@ -0,0 +1,500 @@
// 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::{FederatedAuthorization, VerifiedFederatedIdentity};
use crate::{EVENT_OIDC_DIAGNOSTICS, LOG_COMPONENT_IAM, LOG_SUBSYSTEM_OIDC};
use rustfs_policy::policy::{ClaimLookup, get_claim_case_insensitive};
use std::{collections::HashMap, sync::Arc};
use tracing::debug;
#[derive(Clone)]
pub(crate) struct FederatedAuthorizationRule {
provider_id: String,
claim_name: String,
claim_prefix: String,
role_policy: String,
groups_claim: String,
roles_claim: String,
}
impl FederatedAuthorizationRule {
pub(crate) fn new(
provider_id: String,
claim_name: String,
claim_prefix: String,
role_policy: String,
groups_claim: String,
roles_claim: String,
) -> Self {
Self {
provider_id,
claim_name,
claim_prefix,
role_policy,
groups_claim,
roles_claim,
}
}
fn as_ref(&self) -> FederatedAuthorizationRuleRef<'_> {
FederatedAuthorizationRuleRef::new(
&self.claim_name,
&self.claim_prefix,
&self.role_policy,
&self.groups_claim,
&self.roles_claim,
)
}
}
#[derive(Clone, Copy)]
pub(crate) struct FederatedAuthorizationRuleRef<'a> {
claim_name: &'a str,
claim_prefix: &'a str,
role_policy: &'a str,
groups_claim: &'a str,
roles_claim: &'a str,
}
impl<'a> FederatedAuthorizationRuleRef<'a> {
pub(crate) fn new(
claim_name: &'a str,
claim_prefix: &'a str,
role_policy: &'a str,
groups_claim: &'a str,
roles_claim: &'a str,
) -> Self {
Self {
claim_name,
claim_prefix,
role_policy,
groups_claim,
roles_claim,
}
}
}
/// Immutable provider-specific inputs for core federation authorization mapping.
#[derive(Clone, Default)]
pub struct FederatedAuthorizationRules {
providers: Arc<HashMap<String, FederatedAuthorizationRule>>,
}
impl FederatedAuthorizationRules {
pub(crate) fn new(rules: impl IntoIterator<Item = FederatedAuthorizationRule>) -> Self {
Self {
providers: Arc::new(rules.into_iter().map(|rule| (rule.provider_id.clone(), rule)).collect()),
}
}
}
/// Maps a verified external identity into RustFS authorization data.
#[derive(Clone)]
pub struct CoreFederatedAuthorizationMapper {
rules: FederatedAuthorizationRules,
}
impl CoreFederatedAuthorizationMapper {
pub fn new(rules: FederatedAuthorizationRules) -> Self {
Self { rules }
}
pub(crate) fn map(&self, identity: VerifiedFederatedIdentity) -> FederatedAuthorization {
let (provider, claims) = identity.into_parts();
let provider_id = provider.into_string();
let Some(rule) = self.rules.providers.get(&provider_id).map(FederatedAuthorizationRule::as_ref) else {
return FederatedAuthorization {
provider_id,
claims,
policies: Vec::new(),
group_claim_policies: Vec::new(),
groups: Vec::new(),
roles_claim_key: None,
roles: Vec::new(),
};
};
let (policies, groups) = Self::map_policies_and_groups(&provider_id, rule, &claims.groups, &claims.raw, true);
let group_claim_policies = Self::group_claim_policy_names(rule, &claims.groups, &claims.raw);
let roles_claim_key = (!rule.roles_claim.trim().is_empty()).then(|| rule.roles_claim.trim().to_string());
let roles = roles_claim_key
.as_deref()
.map(|claim_name| role_values(&claims.raw, claim_name))
.unwrap_or_default();
FederatedAuthorization {
provider_id,
claims,
policies,
group_claim_policies,
groups,
roles_claim_key,
roles,
}
}
pub(crate) fn map_policies_and_groups(
provider_id: &str,
rule: FederatedAuthorizationRuleRef<'_>,
source_groups: &[String],
raw_claims: &HashMap<String, serde_json::Value>,
emit_diagnostics: bool,
) -> (Vec<String>, Vec<String>) {
let has_role_policy = !rule.role_policy.trim().is_empty();
let mut policies: Vec<String> = if has_role_policy {
rule.role_policy
.split(',')
.map(str::trim)
.filter(|policy| !policy.is_empty())
.map(ToOwned::to_owned)
.collect()
} else {
source_groups
.iter()
.map(|group| policy_name(rule.claim_prefix, group))
.collect()
};
if !has_role_policy && rule.claim_name != rule.groups_claim {
policies.extend(
claim_values(raw_claims, rule.claim_name)
.into_iter()
.map(|value| policy_name(rule.claim_prefix, &value)),
);
}
policies.sort();
policies.dedup();
let mut groups = source_groups.to_vec();
groups.sort();
groups.dedup();
if emit_diagnostics {
record_mapping_diagnostics(provider_id, rule, raw_claims, &policies, &groups);
}
(policies, groups)
}
pub(crate) fn group_claim_policy_names(
rule: FederatedAuthorizationRuleRef<'_>,
source_groups: &[String],
raw_claims: &HashMap<String, serde_json::Value>,
) -> Vec<String> {
if !rule.role_policy.trim().is_empty() {
return Vec::new();
}
let explicit_policy_names: Vec<String> = if rule.claim_name != rule.groups_claim {
claim_values(raw_claims, rule.claim_name)
.iter()
.map(|value| policy_name(rule.claim_prefix, value))
.collect()
} else {
Vec::new()
};
let mut group_policies: Vec<String> = source_groups
.iter()
.map(|group| policy_name(rule.claim_prefix, group))
.filter(|policy| !explicit_policy_names.contains(policy))
.collect();
group_policies.sort();
group_policies.dedup();
group_policies
}
}
fn record_mapping_diagnostics(
provider_id: &str,
rule: FederatedAuthorizationRuleRef<'_>,
raw_claims: &HashMap<String, serde_json::Value>,
policies: &[String],
groups: &[String],
) {
let (claim_name_lookup, claim_name_type) = claim_lookup_details(raw_claims, rule.claim_name);
let (groups_claim_lookup, groups_claim_type) = claim_lookup_details(raw_claims, rule.groups_claim);
let (roles_claim_lookup, roles_claim_type) = claim_lookup_details(raw_claims, rule.roles_claim);
let claim_name_value_count = claim_values(raw_claims, rule.claim_name).len();
let groups_claim_value_count = claim_values(raw_claims, rule.groups_claim).len();
let roles_claim_value_count = claim_values(raw_claims, rule.roles_claim).len();
debug!(
event = EVENT_OIDC_DIAGNOSTICS,
component = LOG_COMPONENT_IAM,
subsystem = LOG_SUBSYSTEM_OIDC,
result = "claims_policy_mapped",
provider_id,
claim_name = %rule.claim_name,
claim_prefix = %rule.claim_prefix,
groups_claim = %rule.groups_claim,
roles_claim = %rule.roles_claim,
role_policy_configured = !rule.role_policy.trim().is_empty(),
policy_count = policies.len(),
group_count = groups.len(),
raw_claim_key_count = raw_claims.len(),
claim_name_lookup,
claim_name_type,
claim_name_value_count,
groups_claim_lookup,
groups_claim_type,
groups_claim_value_count,
roles_claim_lookup,
roles_claim_type,
roles_claim_value_count,
"oidc claims mapped to policies"
);
}
fn policy_name(prefix: &str, value: &str) -> String {
format!("{prefix}{value}")
}
fn claim_values(claims: &HashMap<String, serde_json::Value>, claim_name: &str) -> Vec<String> {
match get_claim_case_insensitive(claims, claim_name) {
ClaimLookup::Found(serde_json::Value::Array(values)) => values
.iter()
.filter_map(|value| value.as_str().map(ToOwned::to_owned))
.collect(),
ClaimLookup::Found(serde_json::Value::String(value)) => value.split(',').map(str::trim).map(ToOwned::to_owned).collect(),
ClaimLookup::Missing | ClaimLookup::Ambiguous | ClaimLookup::Found(_) => Vec::new(),
}
}
fn role_values(claims: &HashMap<String, serde_json::Value>, claim_name: &str) -> Vec<String> {
match get_claim_case_insensitive(claims, claim_name) {
ClaimLookup::Found(serde_json::Value::Array(values)) => values
.iter()
.filter_map(|value| value.as_str().map(ToOwned::to_owned))
.collect(),
ClaimLookup::Found(serde_json::Value::String(value)) => value
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect(),
ClaimLookup::Missing | ClaimLookup::Ambiguous | ClaimLookup::Found(_) => Vec::new(),
}
}
fn claim_lookup_details(claims: &HashMap<String, serde_json::Value>, claim_name: &str) -> (&'static str, &'static str) {
match get_claim_case_insensitive(claims, claim_name) {
ClaimLookup::Found(value) => ("found", claim_value_type(value)),
ClaimLookup::Missing => ("missing", "none"),
ClaimLookup::Ambiguous => ("ambiguous", "none"),
}
}
fn claim_value_type(value: &serde_json::Value) -> &'static str {
match value {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "bool",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::federation::{FederatedClaims, FederatedProviderRef, VerifiedFederatedIdentity};
use serde_json::json;
fn mapper(rule: FederatedAuthorizationRule) -> CoreFederatedAuthorizationMapper {
CoreFederatedAuthorizationMapper::new(FederatedAuthorizationRules::new([rule]))
}
fn identity(provider_id: &str) -> VerifiedFederatedIdentity {
VerifiedFederatedIdentity::from_claims(
FederatedProviderRef::new(provider_id.to_string()),
FederatedClaims {
sub: "subject".to_string(),
email: "user@example.test".to_string(),
username: "user".to_string(),
groups: vec![
"source-ops".to_string(),
"source-developers".to_string(),
"source-ops".to_string(),
],
raw: HashMap::from([
("Policy".to_string(), json!(["explicit", "source-ops"])),
("Roles".to_string(), json!("reader, writer, ,auditor")),
]),
},
)
}
#[test]
fn claim_mapping_preserves_policy_group_role_and_case_behavior() {
let mapper = mapper(FederatedAuthorizationRule::new(
"corp".to_string(),
"policy".to_string(),
"mapped-".to_string(),
String::new(),
"groups".to_string(),
"roles".to_string(),
));
let authorization = mapper.map(identity("corp"));
assert_eq!(
authorization.policies,
["mapped-explicit", "mapped-source-developers", "mapped-source-ops"]
);
assert_eq!(authorization.group_claim_policies, ["mapped-source-developers"]);
assert_eq!(authorization.groups, ["source-developers", "source-ops"]);
assert_eq!(authorization.roles_claim_key.as_deref(), Some("roles"));
assert_eq!(authorization.roles, ["reader", "writer", "auditor"]);
}
#[test]
fn role_policy_mode_keeps_groups_without_group_derived_policies() {
let mapper = mapper(FederatedAuthorizationRule::new(
"corp".to_string(),
"policy".to_string(),
"mapped-".to_string(),
" readonly, readwrite, readonly ".to_string(),
"groups".to_string(),
String::new(),
));
let authorization = mapper.map(identity("corp"));
assert_eq!(authorization.policies, ["readonly", "readwrite"]);
assert!(authorization.group_claim_policies.is_empty());
assert_eq!(authorization.groups, ["source-developers", "source-ops"]);
}
#[test]
fn missing_provider_rules_preserve_identity_without_authorization_context() {
let authorization =
CoreFederatedAuthorizationMapper::new(FederatedAuthorizationRules::default()).map(identity("missing"));
assert_eq!(authorization.provider_id, "missing");
assert_eq!(authorization.claims.username, "user");
assert!(!authorization.has_authorization_context());
}
#[test]
fn exact_claim_name_wins_over_case_insensitive_candidates() {
let mapper = mapper(FederatedAuthorizationRule::new(
"corp".to_string(),
"policy".to_string(),
"mapped-".to_string(),
String::new(),
"groups".to_string(),
String::new(),
));
let identity = VerifiedFederatedIdentity::from_claims(
FederatedProviderRef::new("corp".to_string()),
FederatedClaims {
sub: String::new(),
email: String::new(),
username: String::new(),
groups: Vec::new(),
raw: HashMap::from([
("policy".to_string(), json!("exact")),
("Policy".to_string(), json!("fallback")),
]),
},
);
let authorization = mapper.map(identity);
assert_eq!(authorization.policies, ["mapped-exact"]);
}
#[test]
fn ambiguous_case_insensitive_claim_name_is_ignored() {
let mapper = mapper(FederatedAuthorizationRule::new(
"corp".to_string(),
"pOlIcY".to_string(),
"mapped-".to_string(),
String::new(),
"groups".to_string(),
String::new(),
));
let identity = VerifiedFederatedIdentity::from_claims(
FederatedProviderRef::new("corp".to_string()),
FederatedClaims {
sub: String::new(),
email: String::new(),
username: String::new(),
groups: vec!["source-ops".to_string()],
raw: HashMap::from([
("Policy".to_string(), json!("first")),
("POLICY".to_string(), json!("second")),
]),
},
);
let authorization = mapper.map(identity);
assert_eq!(authorization.policies, ["mapped-source-ops"]);
assert_eq!(claim_lookup_details(&authorization.claims.raw, "pOlIcY"), ("ambiguous", "none"));
assert_eq!(claim_lookup_details(&authorization.claims.raw, "missing"), ("missing", "none"));
}
#[test]
fn comma_separated_claim_values_keep_existing_empty_value_behavior() {
let mapper = mapper(FederatedAuthorizationRule::new(
"corp".to_string(),
"Policy".to_string(),
"mapped-".to_string(),
String::new(),
"groups".to_string(),
String::new(),
));
let identity = VerifiedFederatedIdentity::from_claims(
FederatedProviderRef::new("corp".to_string()),
FederatedClaims {
sub: String::new(),
email: String::new(),
username: String::new(),
groups: Vec::new(),
raw: HashMap::from([("Policy".to_string(), json!(" explicit, ,audit "))]),
},
);
let authorization = mapper.map(identity);
assert_eq!(authorization.policies, ["mapped-", "mapped-audit", "mapped-explicit"]);
}
#[test]
fn role_array_values_keep_existing_empty_value_behavior() {
let mapper = mapper(FederatedAuthorizationRule::new(
"corp".to_string(),
"policy".to_string(),
String::new(),
String::new(),
"groups".to_string(),
"roles".to_string(),
));
let identity = VerifiedFederatedIdentity::from_claims(
FederatedProviderRef::new("corp".to_string()),
FederatedClaims {
sub: String::new(),
email: String::new(),
username: String::new(),
groups: Vec::new(),
raw: HashMap::from([("roles".to_string(), json!(["reader", "", 7, "writer"]))]),
},
);
let authorization = mapper.map(identity);
assert_eq!(authorization.roles, ["reader", "", "writer"]);
}
}
+8 -5
View File
@@ -14,18 +14,21 @@
mod binding;
mod error;
mod mapper;
mod model;
pub mod oidc;
mod provider;
mod registry;
mod transaction;
pub use binding::FederatedSessionBinding;
pub use error::{FederatedSessionBindingError, FederationError, Result};
pub use mapper::{CoreFederatedAuthorizationMapper, FederatedAuthorizationRules};
pub(crate) use mapper::{FederatedAuthorizationRule, FederatedAuthorizationRuleRef};
pub(crate) use model::OpaqueLogoutContinuation;
pub use model::{
FederatedAuthorization, FederatedClaims, FederatedCodeExchange, FederatedLoginSession, FederatedSession,
FederatedSessionTransaction, OIDC_VIRTUAL_PARENT_CLAIM,
FederatedAuthorization, FederatedClaims, FederatedLoginSession, FederatedProviderRef, FederatedSession,
FederatedSessionTransaction, OIDC_VIRTUAL_PARENT_CLAIM, VerifiedFederatedIdentity,
};
pub use provider::FederatedIdentityProvider;
pub use registry::FederatedIdentityRegistry;
pub(crate) use provider::{FederatedProviderQuery, StandardOidcAuthentication, VerifiedFederatedCodeExchange};
pub use provider::{FederatedProviderView, FederatedRedirectPolicy};
pub use transaction::FederatedIdentityService;
+171 -6
View File
@@ -15,7 +15,7 @@
use rustfs_credentials::Credentials;
use rustfs_utils::HashAlgorithm;
use serde_json::Value;
use std::collections::HashMap;
use std::{collections::HashMap, fmt};
pub const OIDC_VIRTUAL_PARENT_CLAIM: &str = "x-rustfs-internal-oidc-parent";
@@ -42,6 +42,89 @@ impl FederatedClaims {
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FederatedProviderRef {
provider_id: String,
}
impl FederatedProviderRef {
/// Retains the configured provider instance ID exactly as selected by the authenticator.
pub(crate) fn new(provider_id: String) -> Self {
Self { provider_id }
}
pub fn as_str(&self) -> &str {
&self.provider_id
}
pub(crate) fn into_string(self) -> String {
self.provider_id
}
}
#[derive(Clone)]
pub struct VerifiedFederatedIdentity {
provider: FederatedProviderRef,
claims: FederatedClaims,
}
impl fmt::Debug for VerifiedFederatedIdentity {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("VerifiedFederatedIdentity")
.field("provider", &self.provider)
.field("issuer_present", &self.issuer().is_some())
.field("subject_present", &!self.claims.sub.is_empty())
.field("email_present", &!self.claims.email.is_empty())
.field("username_present", &!self.claims.username.is_empty())
.field("source_group_count", &self.claims.groups.len())
.field("attribute_count", &self.claims.raw.len())
.finish()
}
}
impl VerifiedFederatedIdentity {
pub(crate) fn from_claims(provider: FederatedProviderRef, claims: FederatedClaims) -> Self {
Self { provider, claims }
}
pub fn provider(&self) -> &FederatedProviderRef {
&self.provider
}
pub fn issuer(&self) -> Option<&str> {
self.claims.raw.get("iss").and_then(Value::as_str)
}
pub fn subject(&self) -> &str {
&self.claims.sub
}
pub fn email(&self) -> &str {
&self.claims.email
}
pub fn username(&self) -> &str {
&self.claims.username
}
pub fn source_groups(&self) -> &[String] {
&self.claims.groups
}
pub fn attributes(&self) -> &HashMap<String, Value> {
&self.claims.raw
}
pub fn session_identity(&self) -> String {
self.claims.session_identity()
}
pub(crate) fn into_parts(self) -> (FederatedProviderRef, FederatedClaims) {
(self.provider, self.claims)
}
}
#[derive(Debug, Clone)]
pub struct FederatedAuthorization {
pub provider_id: String,
@@ -81,11 +164,30 @@ impl FederatedAuthorization {
}
}
#[derive(Debug)]
pub struct FederatedCodeExchange {
pub authorization: FederatedAuthorization,
pub redirect_after: Option<String>,
pub id_token: String,
/// Carries the provider-bound ID token between code exchange and logout token creation.
pub(crate) struct OpaqueLogoutContinuation {
provider: FederatedProviderRef,
id_token: String,
}
impl OpaqueLogoutContinuation {
pub(crate) fn new(provider: FederatedProviderRef, id_token: String) -> Self {
Self { provider, id_token }
}
pub(crate) fn into_parts(self) -> (FederatedProviderRef, String) {
(self.provider, self.id_token)
}
}
impl fmt::Debug for OpaqueLogoutContinuation {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("OpaqueLogoutContinuation")
.field("provider", &self.provider)
.field("id_token", &"[REDACTED]")
.finish()
}
}
#[derive(Debug)]
@@ -208,4 +310,67 @@ mod tests {
assert_ne!(plain.oidc_virtual_parent(), padded.oidc_virtual_parent());
}
#[test]
fn verified_identity_preserves_exact_values_and_distinct_groups() {
let attributes = HashMap::from([
("iss".to_string(), Value::String(" https://issuer.example/path ".to_string())),
("department".to_string(), Value::String("identity-attribute-poison".to_string())),
]);
let identity = VerifiedFederatedIdentity {
provider: FederatedProviderRef::new(" Corp Provider ".to_string()),
claims: FederatedClaims {
sub: " subject ".to_string(),
email: " email@example.test ".to_string(),
username: " username ".to_string(),
groups: vec![" source-b ".to_string(), "source-a".to_string(), " source-b ".to_string()],
raw: attributes.clone(),
},
};
let debug = format!("{identity:?}");
assert_eq!(identity.provider().as_str(), " Corp Provider ");
assert_eq!(identity.issuer(), Some(" https://issuer.example/path "));
assert_eq!(identity.subject(), " subject ");
assert_eq!(identity.email(), " email@example.test ");
assert_eq!(identity.username(), " username ");
assert_eq!(identity.source_groups(), [" source-b ", "source-a", " source-b "]);
assert_eq!(identity.attributes(), &attributes);
assert!(!debug.contains(" subject "));
assert!(!debug.contains(" email@example.test "));
assert!(!debug.contains(" username "));
assert!(!debug.contains(" source-b "));
assert!(!debug.contains("identity-attribute-poison"));
let authorization = FederatedAuthorization {
provider_id: " Corp Provider ".to_string(),
claims: FederatedClaims {
sub: "subject".to_string(),
email: String::new(),
username: String::new(),
groups: vec!["different-source-group".to_string()],
raw: HashMap::new(),
},
policies: Vec::new(),
group_claim_policies: Vec::new(),
groups: vec!["mapped-a".to_string(), "mapped-b".to_string()],
roles_claim_key: None,
roles: Vec::new(),
};
assert_eq!(authorization.claims.groups, ["different-source-group"]);
assert_eq!(authorization.groups, ["mapped-a", "mapped-b"]);
}
#[test]
fn logout_continuation_binds_provider_and_redacts_token() {
let continuation =
OpaqueLogoutContinuation::new(FederatedProviderRef::new("standard_oidc".to_string()), "secret-id-token".to_string());
let continuation_debug = format!("{continuation:?}");
let (provider, id_token) = continuation.into_parts();
assert_eq!(provider.as_str(), "standard_oidc");
assert_eq!(id_token, "secret-id-token");
assert!(!continuation_debug.contains("secret-id-token"));
assert!(continuation_debug.contains("[REDACTED]"));
}
}
+16 -93
View File
@@ -12,111 +12,39 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::config::roles_claim_key;
use crate::{
federation::{FederatedAuthorization, FederatedClaims},
oidc::{OidcClaims, OidcSys},
federation::{FederatedClaims, FederatedProviderRef, VerifiedFederatedIdentity},
oidc::OidcClaims,
};
use rustfs_policy::policy::{ClaimLookup, get_claim_case_insensitive};
fn string_list_claim(claims: &OidcClaims, claim_name: &str) -> Vec<String> {
match get_claim_case_insensitive(&claims.raw, claim_name) {
ClaimLookup::Found(serde_json::Value::Array(values)) => values
.iter()
.filter_map(|value| value.as_str().map(ToOwned::to_owned))
.collect(),
ClaimLookup::Found(serde_json::Value::String(value)) => value
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect(),
ClaimLookup::Missing | ClaimLookup::Ambiguous | ClaimLookup::Found(_) => Vec::new(),
}
}
pub(super) fn authorization(oidc: &OidcSys, provider_id: String, claims: OidcClaims) -> FederatedAuthorization {
let (policies, groups) = oidc.map_claims_to_policies(&provider_id, &claims);
let group_claim_policies = oidc.group_claim_policy_names(&provider_id, &claims);
let roles_claim_key = roles_claim_key(oidc, &provider_id);
let roles = roles_claim_key
.as_deref()
.map(|claim_name| string_list_claim(&claims, claim_name))
.unwrap_or_default();
FederatedAuthorization {
provider_id,
claims: FederatedClaims {
pub(super) fn verified_identity(provider: FederatedProviderRef, claims: OidcClaims) -> VerifiedFederatedIdentity {
VerifiedFederatedIdentity::from_claims(
provider,
FederatedClaims {
sub: claims.sub,
email: claims.email,
username: claims.username,
groups: claims.groups,
raw: claims.raw,
},
policies,
group_claim_policies,
groups,
roles_claim_key,
roles,
}
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::oidc::{make_test_sys, test_config};
use serde_json::json;
use std::collections::HashMap;
#[test]
fn string_list_claim_matches_existing_array_and_csv_behavior() {
let claims = OidcClaims {
raw: HashMap::from([
("array_roles".to_string(), json!(["reader", 7, "writer"])),
("csv_roles".to_string(), json!("reader, writer, ,auditor")),
]),
..Default::default()
};
assert_eq!(string_list_claim(&claims, "array_roles"), ["reader", "writer"]);
assert_eq!(string_list_claim(&claims, "csv_roles"), ["reader", "writer", "auditor"]);
}
#[test]
fn string_list_claim_preserves_exact_and_ambiguous_match_behavior() {
let exact = OidcClaims {
raw: HashMap::from([
("Roles".to_string(), json!(["mixed-case"])),
("roles".to_string(), json!(["exact-match"])),
]),
..Default::default()
};
assert_eq!(string_list_claim(&exact, "roles"), ["exact-match"]);
let ambiguous = OidcClaims {
raw: HashMap::from([
("Roles".to_string(), json!(["mixed-case"])),
("ROLES".to_string(), json!(["upper-case"])),
]),
..Default::default()
};
assert!(string_list_claim(&ambiguous, "roles").is_empty());
}
#[test]
fn authorization_preserves_verified_claims_and_keeps_source_groups_distinct() {
let mut config = test_config("corp");
config.claim_prefix = "mapped-".to_string();
config.roles_claim = "roles".to_string();
let oidc = make_test_sys(vec![config]);
fn verified_identity_preserves_provider_and_claim_values() {
let raw = HashMap::from([
("iss".to_string(), json!("https://corp.example.test")),
("department".to_string(), json!("engineering")),
("roles".to_string(), json!(["reader", "admin"])),
]);
let authorization = authorization(
&oidc,
"corp".to_string(),
let identity = verified_identity(
FederatedProviderRef::new("corp".to_string()),
OidcClaims {
sub: " subject-123 ".to_string(),
email: " user@example.test ".to_string(),
@@ -130,16 +58,11 @@ mod tests {
},
);
assert_eq!(authorization.provider_id, "corp");
assert_eq!(authorization.claims.sub, " subject-123 ");
assert_eq!(authorization.claims.email, " user@example.test ");
assert_eq!(authorization.claims.username, " user ");
assert_eq!(authorization.claims.groups, ["source-ops", "source-developers", "source-ops"]);
assert_eq!(authorization.claims.raw, raw);
assert_eq!(authorization.policies, ["mapped-source-developers", "mapped-source-ops"]);
assert_eq!(authorization.group_claim_policies, ["mapped-source-developers", "mapped-source-ops"]);
assert_eq!(authorization.groups, ["source-developers", "source-ops"]);
assert_eq!(authorization.roles_claim_key.as_deref(), Some("roles"));
assert_eq!(authorization.roles, ["reader", "admin"]);
assert_eq!(identity.provider().as_str(), "corp");
assert_eq!(identity.subject(), " subject-123 ");
assert_eq!(identity.email(), " user@example.test ");
assert_eq!(identity.username(), " user ");
assert_eq!(identity.source_groups(), ["source-ops", "source-developers", "source-ops"]);
assert_eq!(identity.attributes(), &raw);
}
}
+42 -10
View File
@@ -12,16 +12,37 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::oidc::{OidcProviderConfig, OidcSys};
use crate::{
federation::{FederatedAuthorizationRule, FederatedAuthorizationRules, FederatedRedirectPolicy},
oidc::{OidcConfigSnapshot, OidcSys},
};
pub(super) fn provider_config<'a>(oidc: &'a OidcSys, id: &str) -> Option<&'a OidcProviderConfig> {
oidc.get_provider_config(id)
pub(crate) fn authorization_rules(oidc: &OidcSys) -> FederatedAuthorizationRules {
FederatedAuthorizationRules::new(oidc.provider_configs().map(|config| {
FederatedAuthorizationRule::new(
config.id.clone(),
config.claim_name.clone(),
config.claim_prefix.clone(),
config.role_policy.clone(),
config.groups_claim.clone(),
config.roles_claim.clone(),
)
}))
}
pub(super) fn roles_claim_key(oidc: &OidcSys, provider_id: &str) -> Option<String> {
provider_config(oidc, provider_id)
.map(|config| config.roles_claim.trim().to_string())
.filter(|claim| !claim.is_empty())
/// Read-only access to the active standard OIDC configuration.
///
/// This interface stays separate from authentication and generic provider
/// views because its snapshots include OIDC-specific fields and secrets.
pub trait OidcConfigQuery: Send + Sync {
fn config_snapshot(&self) -> OidcConfigSnapshot;
}
pub(super) fn redirect_policy(oidc: &OidcSys, provider_id: &str) -> Option<FederatedRedirectPolicy> {
oidc.get_provider_config(provider_id).map(|config| FederatedRedirectPolicy {
redirect_uri: config.redirect_uri.clone(),
allow_request_origin: config.redirect_uri_dynamic,
})
}
#[cfg(test)]
@@ -29,8 +50,19 @@ mod tests {
use super::*;
#[test]
fn roles_claim_key_requires_explicit_provider_config() {
let oidc = OidcSys::empty().expect("empty OIDC configuration should be valid");
assert_eq!(roles_claim_key(&oidc, "default"), None);
fn redirect_policy_keeps_configured_uri_and_dynamic_setting() {
let mut config = crate::oidc::test_config("default");
config.redirect_uri = Some("https://console.example.test/callback".to_string());
config.redirect_uri_dynamic = false;
let oidc = crate::oidc::make_test_sys(vec![config]);
assert_eq!(
redirect_policy(&oidc, "default"),
Some(FederatedRedirectPolicy {
redirect_uri: Some("https://console.example.test/callback".to_string()),
allow_request_origin: false,
})
);
assert_eq!(redirect_policy(&oidc, "missing"), None);
}
}
+45 -5
View File
@@ -12,12 +12,52 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::oidc::{OidcProviderSummary, OidcSys};
use crate::{federation::FederatedProviderView, oidc::OidcSys};
pub(super) fn list_providers(oidc: &OidcSys) -> Vec<OidcProviderSummary> {
oidc.list_providers()
fn provider_view(provider: &crate::oidc::OidcProviderConfig) -> FederatedProviderView {
FederatedProviderView {
provider_id: provider.id.clone(),
display_name: provider.display_name.clone(),
}
}
pub(super) fn list_visible_providers(oidc: &OidcSys) -> Vec<OidcProviderSummary> {
oidc.list_visible_providers()
pub(super) fn list_providers(oidc: &OidcSys) -> Vec<FederatedProviderView> {
oidc.provider_configs().map(provider_view).collect()
}
pub(super) fn list_visible_providers(oidc: &OidcSys) -> Vec<FederatedProviderView> {
oidc.provider_configs()
.filter(|provider| !provider.hide_from_ui)
.map(provider_view)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_views_preserve_names_and_visibility() {
let mut visible = crate::oidc::test_config("default");
visible.display_name = "Company SSO".to_string();
let mut hidden = crate::oidc::test_config("workload");
hidden.hide_from_ui = true;
let oidc = crate::oidc::make_test_sys(vec![visible, hidden]);
let all = list_providers(&oidc);
assert_eq!(all.len(), 2);
assert!(
all.iter()
.any(|provider| { provider.provider_id == "default" && provider.display_name == "Company SSO" })
);
assert!(all.iter().any(|provider| provider.provider_id == "workload"));
assert_eq!(
list_visible_providers(&oidc),
vec![FederatedProviderView {
provider_id: "default".to_string(),
display_name: "Company SSO".to_string(),
}]
);
}
}
+60 -30
View File
@@ -12,68 +12,98 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::config::OidcConfigQuery;
use super::{claims, config, discovery, http};
use crate::{
federation::{FederatedAuthorization, FederatedCodeExchange, FederatedIdentityProvider, FederationError, Result},
oidc::{OidcProviderConfig, OidcProviderSummary, OidcSys},
federation::{
CoreFederatedAuthorizationMapper, FederatedAuthorizationRules, FederatedIdentityService, FederatedProviderQuery,
FederatedProviderRef, FederatedProviderView, FederatedRedirectPolicy, FederationError, OpaqueLogoutContinuation, Result,
StandardOidcAuthentication, VerifiedFederatedCodeExchange,
},
oidc::{OidcConfigSnapshot, OidcSys},
};
use std::sync::Arc;
pub struct StandardOidcAdapter {
oidc: Arc<OidcSys>,
authorization_rules: FederatedAuthorizationRules,
}
impl StandardOidcAdapter {
pub fn new(oidc: Arc<OidcSys>) -> Self {
Self { oidc }
let authorization_rules = config::authorization_rules(&oidc);
Self {
oidc,
authorization_rules,
}
}
pub fn authorization_rules(&self) -> FederatedAuthorizationRules {
self.authorization_rules.clone()
}
pub fn into_service(self: Arc<Self>, mapper: CoreFederatedAuthorizationMapper) -> FederatedIdentityService {
let authentication: Arc<dyn StandardOidcAuthentication> = self.clone();
let provider_query: Arc<dyn FederatedProviderQuery> = self;
FederatedIdentityService::from_standard_oidc_parts(provider_query, authentication, mapper)
}
}
impl OidcConfigQuery for StandardOidcAdapter {
fn config_snapshot(&self) -> OidcConfigSnapshot {
self.oidc.config_snapshot()
}
}
#[async_trait::async_trait]
impl FederatedIdentityProvider for StandardOidcAdapter {
fn has_providers(&self) -> bool {
self.oidc.has_providers()
}
fn list_providers(&self) -> Vec<OidcProviderSummary> {
discovery::list_providers(&self.oidc)
}
fn list_visible_providers(&self) -> Vec<OidcProviderSummary> {
discovery::list_visible_providers(&self.oidc)
}
fn provider_config(&self, id: &str) -> Option<&OidcProviderConfig> {
config::provider_config(&self.oidc, id)
}
impl StandardOidcAuthentication for StandardOidcAdapter {
async fn authorize_url(&self, provider_id: &str, redirect_uri: &str, redirect_after: Option<String>) -> Result<String> {
http::authorize_url(&self.oidc, provider_id, redirect_uri, redirect_after).await
}
async fn exchange_code(&self, state: &str, code: &str, redirect_uri: &str) -> Result<FederatedCodeExchange> {
async fn exchange_identity(&self, state: &str, code: &str, redirect_uri: &str) -> Result<VerifiedFederatedCodeExchange> {
let (oidc_claims, provider_id, session, id_token) = http::exchange_code(&self.oidc, state, code, redirect_uri).await?;
Ok(FederatedCodeExchange {
authorization: claims::authorization(&self.oidc, provider_id, oidc_claims),
redirect_after: session.redirect_after,
id_token,
})
let provider = FederatedProviderRef::new(provider_id);
Ok(VerifiedFederatedCodeExchange::new(
claims::verified_identity(provider.clone(), oidc_claims),
session.redirect_after,
OpaqueLogoutContinuation::new(provider, id_token),
))
}
async fn verify_web_identity_token(&self, jwt: &str) -> Result<FederatedAuthorization> {
async fn verify_identity(&self, jwt: &str) -> Result<crate::federation::VerifiedFederatedIdentity> {
let (oidc_claims, provider_id) = self
.oidc
.verify_web_identity_token(jwt)
.await
.map_err(FederationError::TokenVerification)?;
Ok(claims::authorization(&self.oidc, provider_id, oidc_claims))
Ok(claims::verified_identity(FederatedProviderRef::new(provider_id), oidc_claims))
}
async fn create_logout_token(&self, provider_id: &str, id_token: &str) -> Result<String> {
http::create_logout_token(&self.oidc, provider_id, id_token).await
async fn create_logout_token(&self, continuation: OpaqueLogoutContinuation) -> Result<String> {
let (provider, id_token) = continuation.into_parts();
http::create_logout_token(&self.oidc, provider.as_str(), &id_token).await
}
async fn build_logout_url(&self, logout_token: &str, post_logout_redirect_uri: &str) -> Result<Option<String>> {
http::build_logout_url(&self.oidc, logout_token, post_logout_redirect_uri).await
}
}
impl FederatedProviderQuery for StandardOidcAdapter {
fn has_providers(&self) -> bool {
self.oidc.has_providers()
}
fn list_providers(&self) -> Vec<FederatedProviderView> {
discovery::list_providers(&self.oidc)
}
fn list_visible_providers(&self) -> Vec<FederatedProviderView> {
discovery::list_visible_providers(&self.oidc)
}
fn redirect_policy(&self, provider_id: &str) -> Option<FederatedRedirectPolicy> {
config::redirect_policy(&self.oidc, provider_id)
}
}
+1
View File
@@ -18,4 +18,5 @@ mod discovery;
mod flow;
mod http;
pub use config::OidcConfigQuery;
pub use flow::StandardOidcAdapter;
+72 -18
View File
@@ -12,26 +12,80 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::{FederatedAuthorization, FederatedCodeExchange, Result};
use crate::oidc::{OidcProviderConfig, OidcProviderSummary};
use super::{OpaqueLogoutContinuation, Result, VerifiedFederatedIdentity};
use serde::{Deserialize, Serialize};
/// Provider data exposed to login discovery consumers.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FederatedProviderView {
pub provider_id: String,
pub display_name: String,
}
/// Redirect inputs needed by the browser authorization flow.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FederatedRedirectPolicy {
pub redirect_uri: Option<String>,
pub allow_request_origin: bool,
}
pub(crate) struct VerifiedFederatedCodeExchange {
identity: VerifiedFederatedIdentity,
redirect_after: Option<String>,
logout_continuation: OpaqueLogoutContinuation,
}
impl VerifiedFederatedCodeExchange {
pub(crate) fn new(
identity: VerifiedFederatedIdentity,
redirect_after: Option<String>,
logout_continuation: OpaqueLogoutContinuation,
) -> Self {
Self {
identity,
redirect_after,
logout_continuation,
}
}
pub(crate) fn into_parts(self) -> (VerifiedFederatedIdentity, Option<String>, OpaqueLogoutContinuation) {
(self.identity, self.redirect_after, self.logout_continuation)
}
}
#[async_trait::async_trait]
pub trait FederatedIdentityProvider: Send + Sync {
fn has_providers(&self) -> bool;
fn list_providers(&self) -> Vec<OidcProviderSummary>;
fn list_visible_providers(&self) -> Vec<OidcProviderSummary>;
fn provider_config(&self, id: &str) -> Option<&OidcProviderConfig>;
pub(crate) trait StandardOidcAuthentication: Send + Sync {
async fn authorize_url(&self, provider_id: &str, redirect_uri: &str, redirect_after: Option<String>) -> Result<String>;
async fn exchange_code(&self, state: &str, code: &str, redirect_uri: &str) -> Result<FederatedCodeExchange>;
async fn verify_web_identity_token(&self, jwt: &str) -> Result<FederatedAuthorization>;
async fn create_logout_token(&self, provider_id: &str, id_token: &str) -> Result<String>;
async fn exchange_identity(&self, state: &str, code: &str, redirect_uri: &str) -> Result<VerifiedFederatedCodeExchange>;
async fn verify_identity(&self, jwt: &str) -> Result<VerifiedFederatedIdentity>;
async fn create_logout_token(&self, continuation: OpaqueLogoutContinuation) -> Result<String>;
async fn build_logout_url(&self, logout_token: &str, post_logout_redirect_uri: &str) -> Result<Option<String>>;
}
pub(crate) trait FederatedProviderQuery: Send + Sync {
fn has_providers(&self) -> bool;
fn list_providers(&self) -> Vec<FederatedProviderView>;
fn list_visible_providers(&self) -> Vec<FederatedProviderView>;
fn redirect_policy(&self, provider_id: &str) -> Option<FederatedRedirectPolicy>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_view_preserves_login_json_shape() {
let view = FederatedProviderView {
provider_id: "default".to_string(),
display_name: "Company SSO".to_string(),
};
assert_eq!(
serde_json::to_string(&[view]).expect("provider view should serialize"),
r#"[{"provider_id":"default","display_name":"Company SSO"}]"#
);
}
}
-31
View File
@@ -1,31 +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.
use super::FederatedIdentityProvider;
use std::sync::Arc;
/// Immutable registry of built-in federation adapters.
pub struct FederatedIdentityRegistry {
standard_oidc: Arc<dyn FederatedIdentityProvider>,
}
impl FederatedIdentityRegistry {
pub fn new(standard_oidc: Arc<dyn FederatedIdentityProvider>) -> Self {
Self { standard_oidc }
}
pub(crate) fn standard_oidc(&self) -> &dyn FederatedIdentityProvider {
self.standard_oidc.as_ref()
}
}
+233 -86
View File
@@ -13,14 +13,15 @@
// limitations under the License.
use super::{
FederatedIdentityRegistry, FederatedLoginSession, FederatedSession, FederatedSessionBinding, FederatedSessionTransaction,
FederationError, Result,
CoreFederatedAuthorizationMapper, FederatedAuthorization, FederatedLoginSession, FederatedProviderQuery,
FederatedProviderView, FederatedRedirectPolicy, FederatedSession, FederatedSessionBinding, FederatedSessionTransaction,
FederationError, OpaqueLogoutContinuation, Result, StandardOidcAuthentication,
};
use crate::oidc::{OidcProviderConfig, OidcProviderSummary};
use std::sync::Arc;
const DEFAULT_OIDC_PROVIDER_ID: &str = "default";
fn sorted_provider_summaries(mut providers: Vec<OidcProviderSummary>) -> Vec<OidcProviderSummary> {
fn sorted_provider_views(mut providers: Vec<FederatedProviderView>) -> Vec<FederatedProviderView> {
providers.sort_by(|left, right| {
(left.provider_id != DEFAULT_OIDC_PROVIDER_ID)
.cmp(&(right.provider_id != DEFAULT_OIDC_PROVIDER_ID))
@@ -30,33 +31,68 @@ fn sorted_provider_summaries(mut providers: Vec<OidcProviderSummary>) -> Vec<Oid
}
pub struct FederatedIdentityService {
registry: FederatedIdentityRegistry,
provider_query: Arc<dyn FederatedProviderQuery>,
authentication: Arc<dyn StandardOidcAuthentication>,
mapper: CoreFederatedAuthorizationMapper,
}
struct RuntimeCodeExchange {
authorization: FederatedAuthorization,
redirect_after: Option<String>,
logout_continuation: OpaqueLogoutContinuation,
}
impl FederatedIdentityService {
pub fn new(registry: FederatedIdentityRegistry) -> Self {
Self { registry }
async fn exchange_code(&self, state: &str, code: &str, redirect_uri: &str) -> Result<RuntimeCodeExchange> {
let exchange = self.authentication.exchange_identity(state, code, redirect_uri).await?;
let (identity, redirect_after, logout_continuation) = exchange.into_parts();
Ok(RuntimeCodeExchange {
authorization: self.mapper.map(identity),
redirect_after,
logout_continuation,
})
}
async fn verify_identity(&self, jwt: &str) -> Result<FederatedAuthorization> {
Ok(self.mapper.map(self.authentication.verify_identity(jwt).await?))
}
async fn create_logout_token(&self, continuation: OpaqueLogoutContinuation) -> Result<String> {
self.authentication.create_logout_token(continuation).await
}
pub(super) fn from_standard_oidc_parts(
provider_query: Arc<dyn FederatedProviderQuery>,
authentication: Arc<dyn StandardOidcAuthentication>,
mapper: CoreFederatedAuthorizationMapper,
) -> Self {
Self {
provider_query,
authentication,
mapper,
}
}
pub fn has_providers(&self) -> bool {
self.registry.standard_oidc().has_providers()
self.provider_query.has_providers()
}
pub fn list_providers(&self) -> Vec<OidcProviderSummary> {
sorted_provider_summaries(self.registry.standard_oidc().list_providers())
pub fn list_providers(&self) -> Vec<FederatedProviderView> {
let providers = self.provider_query.list_providers();
sorted_provider_views(providers)
}
pub fn list_visible_providers(&self) -> Vec<OidcProviderSummary> {
sorted_provider_summaries(self.registry.standard_oidc().list_visible_providers())
pub fn list_visible_providers(&self) -> Vec<FederatedProviderView> {
let providers = self.provider_query.list_visible_providers();
sorted_provider_views(providers)
}
pub fn get_provider_config(&self, id: &str) -> Option<&OidcProviderConfig> {
self.registry.standard_oidc().provider_config(id)
pub fn redirect_policy(&self, provider_id: &str) -> Option<FederatedRedirectPolicy> {
self.provider_query.redirect_policy(provider_id)
}
pub async fn authorize_url(&self, provider_id: &str, redirect_uri: &str, redirect_after: Option<String>) -> Result<String> {
self.registry
.standard_oidc()
self.authentication
.authorize_url(provider_id, redirect_uri, redirect_after)
.await
}
@@ -69,20 +105,14 @@ impl FederatedIdentityService {
duration_seconds: usize,
binding: &dyn FederatedSessionBinding,
) -> Result<FederatedLoginSession> {
let exchange = self.registry.standard_oidc().exchange_code(state, code, redirect_uri).await?;
let provider_id = exchange.authorization.provider_id.clone();
let exchange = self.exchange_code(state, code, redirect_uri).await?;
let transaction = FederatedSessionTransaction {
authorization: exchange.authorization,
duration_seconds,
session_policy: None,
};
let credentials = binding.bind(&transaction).await?;
let logout_token = self
.registry
.standard_oidc()
.create_logout_token(&provider_id, &exchange.id_token)
.await?;
let logout_token = self.create_logout_token(exchange.logout_continuation).await?;
Ok(FederatedLoginSession {
session: FederatedSession {
credentials,
@@ -100,7 +130,7 @@ impl FederatedIdentityService {
session_policy: Option<String>,
binding: &dyn FederatedSessionBinding,
) -> Result<FederatedSession> {
let authorization = self.registry.standard_oidc().verify_web_identity_token(jwt).await?;
let authorization = self.verify_identity(jwt).await?;
if !authorization.has_authorization_context() {
tracing::warn!(
provider_id = %authorization.provider_id,
@@ -136,8 +166,7 @@ impl FederatedIdentityService {
}
pub async fn build_logout_url(&self, logout_token: &str, post_logout_redirect_uri: &str) -> Result<Option<String>> {
self.registry
.standard_oidc()
self.authentication
.build_logout_url(logout_token, post_logout_redirect_uri)
.await
}
@@ -147,9 +176,9 @@ impl FederatedIdentityService {
mod tests {
use super::*;
use crate::federation::{
FederatedAuthorization, FederatedClaims, FederatedCodeExchange, FederatedIdentityProvider, FederatedSessionBindingError,
FederatedAuthorizationRule, FederatedAuthorizationRules, FederatedClaims, FederatedProviderQuery, FederatedProviderRef,
FederatedSessionBindingError, VerifiedFederatedCodeExchange, VerifiedFederatedIdentity,
};
use crate::oidc::{OidcProviderConfig, OidcProviderSummary};
use rustfs_credentials::Credentials;
use std::sync::{Arc, Mutex};
@@ -168,6 +197,8 @@ mod tests {
web_provider_id: &'static str,
failure: ProviderFailure,
events: Arc<Mutex<Vec<&'static str>>>,
authorize_calls: Mutex<Vec<(String, String, Option<String>)>>,
logout_url_calls: Mutex<Vec<(String, String)>>,
expected_logout: (&'static str, &'static str),
listed_provider_ids: Vec<&'static str>,
visible_provider_ids: Vec<&'static str>,
@@ -182,6 +213,8 @@ mod tests {
web_provider_id: DEFAULT_OIDC_PROVIDER_ID,
failure: ProviderFailure::None,
events,
authorize_calls: Mutex::new(Vec::new()),
logout_url_calls: Mutex::new(Vec::new()),
expected_logout: (DEFAULT_OIDC_PROVIDER_ID, "id-token"),
listed_provider_ids: Vec::new(),
visible_provider_ids: Vec::new(),
@@ -192,108 +225,139 @@ mod tests {
self.events.lock().expect("event log should not be poisoned").push(event);
}
fn authorization(&self, provider_id: &str) -> FederatedAuthorization {
FederatedAuthorization {
provider_id: provider_id.to_string(),
claims: FederatedClaims {
fn identity(&self, provider_id: &str) -> VerifiedFederatedIdentity {
VerifiedFederatedIdentity::from_claims(
FederatedProviderRef::new(provider_id.to_string()),
FederatedClaims {
sub: "subject".to_string(),
email: String::new(),
username: "user".to_string(),
groups: vec!["source-group".to_string()],
groups: if self.with_group {
vec!["developers".to_string()]
} else {
Vec::new()
},
raw: Default::default(),
},
policies: if self.with_policy {
vec!["readwrite".to_string()]
} else {
Vec::new()
},
group_claim_policies: Vec::new(),
groups: if self.with_group {
vec!["developers".to_string()]
} else {
Vec::new()
},
roles_claim_key: None,
roles: Vec::new(),
}
)
}
}
fn provider_summaries(provider_ids: &[&str]) -> Vec<OidcProviderSummary> {
fn provider_views(provider_ids: &[&str]) -> Vec<FederatedProviderView> {
provider_ids
.iter()
.map(|provider_id| OidcProviderSummary {
.map(|provider_id| FederatedProviderView {
provider_id: (*provider_id).to_string(),
display_name: (*provider_id).to_string(),
})
.collect()
}
#[async_trait::async_trait]
impl FederatedIdentityProvider for TestProvider {
impl FederatedProviderQuery for TestProvider {
fn has_providers(&self) -> bool {
true
}
fn list_providers(&self) -> Vec<OidcProviderSummary> {
provider_summaries(&self.listed_provider_ids)
fn list_providers(&self) -> Vec<FederatedProviderView> {
provider_views(&self.listed_provider_ids)
}
fn list_visible_providers(&self) -> Vec<OidcProviderSummary> {
provider_summaries(&self.visible_provider_ids)
fn list_visible_providers(&self) -> Vec<FederatedProviderView> {
provider_views(&self.visible_provider_ids)
}
fn provider_config(&self, _id: &str) -> Option<&OidcProviderConfig> {
None
fn redirect_policy(&self, provider_id: &str) -> Option<FederatedRedirectPolicy> {
(provider_id == DEFAULT_OIDC_PROVIDER_ID).then_some(FederatedRedirectPolicy {
redirect_uri: None,
allow_request_origin: true,
})
}
}
async fn authorize_url(
&self,
_provider_id: &str,
_redirect_uri: &str,
_redirect_after: Option<String>,
) -> Result<String> {
#[async_trait::async_trait]
impl StandardOidcAuthentication for TestProvider {
async fn authorize_url(&self, provider_id: &str, redirect_uri: &str, redirect_after: Option<String>) -> Result<String> {
self.authorize_calls
.lock()
.expect("authorize call log should not be poisoned")
.push((provider_id.to_string(), redirect_uri.to_string(), redirect_after));
Ok("https://identity.example/authorize".to_string())
}
async fn exchange_code(&self, _state: &str, _code: &str, _redirect_uri: &str) -> Result<FederatedCodeExchange> {
async fn exchange_identity(
&self,
_state: &str,
_code: &str,
_redirect_uri: &str,
) -> Result<VerifiedFederatedCodeExchange> {
self.record("exchange");
if self.failure == ProviderFailure::Exchange {
return Err(FederationError::CodeExchange("exchange failed".to_string()));
}
Ok(FederatedCodeExchange {
authorization: self.authorization(self.browser_provider_id),
redirect_after: Some("/browser".to_string()),
id_token: "id-token".to_string(),
})
let provider = FederatedProviderRef::new(self.browser_provider_id.to_string());
Ok(VerifiedFederatedCodeExchange::new(
self.identity(self.browser_provider_id),
Some("/browser".to_string()),
OpaqueLogoutContinuation::new(provider, "id-token".to_string()),
))
}
async fn verify_web_identity_token(&self, _jwt: &str) -> Result<FederatedAuthorization> {
async fn verify_identity(&self, _jwt: &str) -> Result<VerifiedFederatedIdentity> {
self.record("verify");
if self.failure == ProviderFailure::Verification {
return Err(FederationError::TokenVerification("verification failed".to_string()));
}
Ok(self.authorization(self.web_provider_id))
Ok(self.identity(self.web_provider_id))
}
async fn create_logout_token(&self, provider_id: &str, id_token: &str) -> Result<String> {
async fn create_logout_token(&self, continuation: OpaqueLogoutContinuation) -> Result<String> {
self.record("logout");
assert_eq!((provider_id, id_token), self.expected_logout);
let (provider, id_token) = continuation.into_parts();
assert_eq!((provider.as_str(), id_token.as_str()), self.expected_logout);
if self.failure == ProviderFailure::Logout {
return Err(FederationError::Logout("logout failed".to_string()));
}
Ok("logout-token".to_string())
}
async fn build_logout_url(&self, _logout_token: &str, _post_logout_redirect_uri: &str) -> Result<Option<String>> {
async fn build_logout_url(&self, logout_token: &str, post_logout_redirect_uri: &str) -> Result<Option<String>> {
self.logout_url_calls
.lock()
.expect("logout URL call log should not be poisoned")
.push((logout_token.to_string(), post_logout_redirect_uri.to_string()));
Ok(Some("https://identity.example/logout".to_string()))
}
}
fn standard_service(provider: Arc<TestProvider>) -> FederatedIdentityService {
let mut provider_ids = vec![provider.browser_provider_id, provider.web_provider_id];
provider_ids.sort_unstable();
provider_ids.dedup();
let role_policy = if provider.with_policy { "readwrite" } else { "" };
let rules = FederatedAuthorizationRules::new(provider_ids.into_iter().map(|provider_id| {
FederatedAuthorizationRule::new(
provider_id.to_string(),
"groups".to_string(),
String::new(),
role_policy.to_string(),
"groups".to_string(),
String::new(),
)
}));
let authentication: Arc<dyn StandardOidcAuthentication> = provider.clone();
let provider_query: Arc<dyn FederatedProviderQuery> = provider;
FederatedIdentityService::from_standard_oidc_parts(
provider_query,
authentication,
CoreFederatedAuthorizationMapper::new(rules),
)
}
struct RecordingBinding {
fail: bool,
events: Arc<Mutex<Vec<&'static str>>>,
transactions: Mutex<Vec<(String, usize, Option<String>)>>,
mapped_policies: Mutex<Vec<Vec<String>>>,
}
impl RecordingBinding {
@@ -302,6 +366,7 @@ mod tests {
fail: false,
events,
transactions: Mutex::new(Vec::new()),
mapped_policies: Mutex::new(Vec::new()),
}
}
}
@@ -318,6 +383,10 @@ mod tests {
transaction.duration_seconds,
transaction.session_policy.clone(),
));
self.mapped_policies
.lock()
.expect("mapped policies should not be poisoned")
.push(transaction.authorization.policies.clone());
if self.fail {
return Err(FederatedSessionBindingError::Internal("binding failed".to_string()));
}
@@ -334,7 +403,7 @@ mod tests {
let mut provider = TestProvider::new(events);
provider.listed_provider_ids = vec!["zeta", "hidden", DEFAULT_OIDC_PROVIDER_ID, "alpha"];
provider.visible_provider_ids = vec!["zeta", DEFAULT_OIDC_PROVIDER_ID, "alpha"];
let service = FederatedIdentityService::new(FederatedIdentityRegistry::new(Arc::new(provider)));
let service = standard_service(Arc::new(provider));
assert_eq!(
service
@@ -353,7 +422,7 @@ mod tests {
[DEFAULT_OIDC_PROVIDER_ID, "alpha", "zeta"]
);
assert_eq!(
sorted_provider_summaries(provider_summaries(&["zeta", "alpha"]))
sorted_provider_views(provider_views(&["zeta", "alpha"]))
.into_iter()
.map(|provider| provider.provider_id)
.collect::<Vec<_>>(),
@@ -361,6 +430,48 @@ mod tests {
);
}
#[tokio::test]
async fn authorization_and_logout_urls_preserve_service_arguments() {
let provider = Arc::new(TestProvider::new(Arc::new(Mutex::new(Vec::new()))));
let service = standard_service(provider.clone());
let authorize_url = service
.authorize_url(
"corp",
"https://console.example.com/oauth/callback",
Some("/browser/path?tab=objects".to_string()),
)
.await
.expect("authorization URL should be returned");
let logout_url = service
.build_logout_url("opaque-logout-token", "https://console.example.com/signed-out")
.await
.expect("logout URL should be returned");
assert_eq!(authorize_url, "https://identity.example/authorize");
assert_eq!(logout_url.as_deref(), Some("https://identity.example/logout"));
assert_eq!(
provider
.authorize_calls
.lock()
.expect("authorize call log should not be poisoned")
.as_slice(),
[(
"corp".to_string(),
"https://console.example.com/oauth/callback".to_string(),
Some("/browser/path?tab=objects".to_string()),
)]
);
assert_eq!(
provider
.logout_url_calls
.lock()
.expect("logout URL call log should not be poisoned")
.as_slice(),
[("opaque-logout-token".to_string(), "https://console.example.com/signed-out".to_string(),)]
);
}
#[tokio::test]
async fn callback_and_web_identity_preserve_provider_and_transaction_boundaries() {
let events = Arc::new(Mutex::new(Vec::new()));
@@ -368,9 +479,8 @@ mod tests {
provider.browser_provider_id = "corp";
provider.web_provider_id = "partner";
provider.expected_logout = ("corp", "id-token");
let provider = Arc::new(provider);
let binding = Arc::new(RecordingBinding::new(events.clone()));
let service = FederatedIdentityService::new(FederatedIdentityRegistry::new(provider));
let service = standard_service(Arc::new(provider));
let login = service
.complete_authorization_code("state", "code", "https://console.example/callback", 3600, binding.as_ref())
@@ -406,14 +516,52 @@ mod tests {
);
}
#[tokio::test]
async fn standard_runtime_maps_before_common_browser_and_web_identity_flows() {
let events = Arc::new(Mutex::new(Vec::new()));
let mut provider = TestProvider::new(events.clone());
provider.browser_provider_id = "corp";
provider.web_provider_id = "partner";
provider.expected_logout = ("corp", "id-token");
let binding = Arc::new(RecordingBinding::new(events.clone()));
let service = standard_service(Arc::new(provider));
let login = service
.complete_authorization_code("state", "code", "https://console.example/callback", 3600, binding.as_ref())
.await
.expect("standard callback flow should complete");
assert_eq!(login.session.authorization.provider_id, "corp");
assert_eq!(login.session.authorization.policies, ["readwrite"]);
assert_eq!(
events.lock().expect("event log should not be poisoned").as_slice(),
["exchange", "bind", "logout"]
);
events.lock().expect("event log should not be poisoned").clear();
let web_identity = service
.assume_role_with_web_identity("jwt", 7200, Some("session-policy".to_string()), binding.as_ref())
.await
.expect("standard web identity flow should complete");
assert_eq!(web_identity.authorization.provider_id, "partner");
assert_eq!(web_identity.authorization.policies, ["readwrite"]);
assert_eq!(events.lock().expect("event log should not be poisoned").as_slice(), ["verify", "bind"]);
assert_eq!(
binding
.mapped_policies
.lock()
.expect("mapped policies should not be poisoned")
.as_slice(),
[vec!["readwrite".to_string()], vec!["readwrite".to_string()]]
);
}
#[tokio::test]
async fn web_identity_without_policy_or_group_is_not_bound() {
let events = Arc::new(Mutex::new(Vec::new()));
let mut provider = TestProvider::new(events.clone());
provider.with_policy = false;
let provider = Arc::new(provider);
let binding = Arc::new(RecordingBinding::new(events.clone()));
let service = FederatedIdentityService::new(FederatedIdentityRegistry::new(provider));
let service = standard_service(Arc::new(provider));
let error = service
.assume_role_with_web_identity("jwt", 3600, None, binding.as_ref())
@@ -430,16 +578,15 @@ mod tests {
let mut provider = TestProvider::new(events.clone());
provider.with_policy = false;
provider.with_group = true;
let provider = Arc::new(provider);
let binding = Arc::new(RecordingBinding::new(events.clone()));
let service = FederatedIdentityService::new(FederatedIdentityRegistry::new(provider));
let service = standard_service(Arc::new(provider));
let session = service
.assume_role_with_web_identity("jwt", 3600, None, binding.as_ref())
.await
.expect("a mapped group is an authorization context");
assert!(session.authorization.policies.is_empty());
assert_eq!(session.authorization.policies, ["developers"]);
assert_eq!(session.authorization.groups, ["developers"]);
assert_eq!(events.lock().expect("event log should not be poisoned").as_slice(), ["verify", "bind"]);
}
@@ -457,7 +604,7 @@ mod tests {
let mut binding = RecordingBinding::new(events.clone());
binding.fail = binding_failure;
let binding = Arc::new(binding);
let service = FederatedIdentityService::new(FederatedIdentityRegistry::new(Arc::new(provider)));
let service = standard_service(Arc::new(provider));
let error = service
.complete_authorization_code("state", "code", "https://console.example/callback", 3600, binding.as_ref())
@@ -496,7 +643,7 @@ mod tests {
let mut binding = RecordingBinding::new(events.clone());
binding.fail = binding_failure;
let binding = Arc::new(binding);
let service = FederatedIdentityService::new(FederatedIdentityRegistry::new(Arc::new(provider)));
let service = standard_service(Arc::new(provider));
let error = service
.assume_role_with_web_identity("jwt", 3600, None, binding.as_ref())
+4 -3
View File
@@ -20,10 +20,11 @@ use store::object::ObjectStore;
use sys::IamSys;
use tracing::{debug, error, info, instrument, warn};
const LOG_COMPONENT_IAM: &str = "iam";
pub(crate) const LOG_COMPONENT_IAM: &str = "iam";
const LOG_SUBSYSTEM_RUNTIME: &str = "runtime";
const LOG_SUBSYSTEM_OIDC: &str = "oidc";
pub(crate) const LOG_SUBSYSTEM_OIDC: &str = "oidc";
const EVENT_IAM_STATE: &str = "iam_state";
pub(crate) const EVENT_OIDC_DIAGNOSTICS: &str = "oidc_diagnostics";
const EVENT_OIDC_STATE: &str = "oidc_state";
pub mod cache;
@@ -332,7 +333,7 @@ async fn init_oidc_sys_with_extra_root_ca_provider_inner(
event = EVENT_OIDC_STATE,
component = LOG_COMPONENT_IAM,
subsystem = LOG_SUBSYSTEM_OIDC,
provider_count = sys.list_providers().len(),
provider_count = sys.provider_configs().count(),
state = "ready",
"OIDC runtime ready"
);
+30 -42
View File
@@ -3345,27 +3345,23 @@ mod tests {
#[test]
fn test_extract_jwt_claims_basic() {
let user_identity = UserIdentity {
version: 1,
credentials: Credentials {
access_key: "test-access-key".to_string(),
secret_key: "test-secret-key".to_string(),
session_token: "invalid-token".to_string(), // Invalid token for testing error handling
expiration: None,
status: "enabled".to_string(),
parent_user: "".to_string(),
groups: None,
claims: Some({
let mut claims = HashMap::new();
claims.insert("sub".to_string(), json!("test-user"));
claims.insert("aud".to_string(), json!("test-audience"));
claims
}),
name: None,
description: None,
},
update_at: Some(OffsetDateTime::now_utc()),
};
let user_identity = UserIdentity::new(Credentials {
access_key: "test-access-key".to_string(),
secret_key: "test-secret-key".to_string(),
session_token: "invalid-token".to_string(), // Invalid token for testing error handling
expiration: None,
status: "enabled".to_string(),
parent_user: "".to_string(),
groups: None,
claims: Some({
let mut claims = HashMap::new();
claims.insert("sub".to_string(), json!("test-user"));
claims.insert("aud".to_string(), json!("test-audience"));
claims
}),
name: None,
description: None,
});
let result = extract_jwt_claims(&user_identity);
// In test environment without proper JWT setup, this should fail
@@ -3374,22 +3370,18 @@ mod tests {
#[test]
fn test_extract_jwt_claims_no_claims() {
let user_identity = UserIdentity {
version: 1,
credentials: Credentials {
access_key: "test-access-key".to_string(),
secret_key: "test-secret-key".to_string(),
session_token: "".to_string(), // Empty token
expiration: None,
status: "enabled".to_string(),
parent_user: "".to_string(),
groups: None,
claims: None,
name: None,
description: None,
},
update_at: Some(OffsetDateTime::now_utc()),
};
let user_identity = UserIdentity::new(Credentials {
access_key: "test-access-key".to_string(),
secret_key: "test-secret-key".to_string(),
session_token: "".to_string(), // Empty token
expiration: None,
status: "enabled".to_string(),
parent_user: "".to_string(),
groups: None,
claims: None,
name: None,
description: None,
});
let result = extract_jwt_claims(&user_identity);
// Should fail with empty session token
@@ -3448,11 +3440,7 @@ mod tests {
description: None,
};
let user_identity = UserIdentity {
version: 1,
credentials,
update_at: Some(OffsetDateTime::now_utc()),
};
let user_identity = UserIdentity::new(credentials);
// Test basic structure
assert_eq!(user_identity.version, 1);
+142 -213
View File
@@ -18,7 +18,11 @@
//! `openidconnect` crate for standards-compliant discovery, token exchange,
//! and ID token verification.
use crate::oidc_state::{OidcAuthSession, OidcLogoutSession, OidcStateStore};
use crate::{
EVENT_OIDC_DIAGNOSTICS, LOG_COMPONENT_IAM, LOG_SUBSYSTEM_OIDC,
federation::{CoreFederatedAuthorizationMapper, FederatedAuthorizationRuleRef},
oidc_state::{OidcAuthSession, OidcLogoutSession, OidcStateStore},
};
use openidconnect::core::{
CoreAuthenticationFlow, CoreClient, CoreIdToken, CoreIdTokenVerifier, CoreJsonWebKeySet, CoreJwsSigningAlgorithm,
};
@@ -47,9 +51,6 @@ use tokio::time::sleep;
use tracing::{debug, error, warn};
use url::Url;
const LOG_COMPONENT_IAM: &str = "iam";
const LOG_SUBSYSTEM_OIDC: &str = "oidc";
const EVENT_OIDC_DIAGNOSTICS: &str = "oidc_diagnostics";
const EVENT_OIDC_HTTP: &str = "oidc_http";
const OIDC_JWKS_REFRESH_INTERVAL: StdDuration = StdDuration::from_secs(24 * 60 * 60);
const OIDC_DISCOVERY_TRANSPORT_RETRIES: usize = 3;
@@ -682,7 +683,7 @@ impl<'c> AsyncHttpClient<'c> for ReqwestHttpClient {
}
}
// ---- Public types (unchanged API) ----
// ---- Public types ----
const REDACTED_SECRET: &str = "***redacted***";
@@ -753,6 +754,31 @@ pub struct SourcedOidcProviderConfig {
pub source: OidcProviderConfigSource,
}
/// Immutable OIDC configuration entries returned by configuration queries.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OidcConfigSnapshot {
providers: Vec<SourcedOidcProviderConfig>,
}
impl OidcConfigSnapshot {
pub fn new(mut providers: Vec<SourcedOidcProviderConfig>) -> Self {
providers.sort_by(|left, right| {
(left.config.id != "default")
.cmp(&(right.config.id != "default"))
.then_with(|| left.config.id.cmp(&right.config.id))
});
Self { providers }
}
pub fn providers(&self) -> &[SourcedOidcProviderConfig] {
&self.providers
}
pub fn into_providers(self) -> Vec<SourcedOidcProviderConfig> {
self.providers
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OidcProviderValidationResult {
pub issuer: String,
@@ -760,12 +786,8 @@ pub struct OidcProviderValidationResult {
pub token_endpoint: Option<String>,
}
/// Summary info about a provider, returned to the console.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcProviderSummary {
pub provider_id: String,
pub display_name: String,
}
/// Compatibility name for the provider summary returned by OIDC list APIs.
pub type OidcProviderSummary = crate::federation::FederatedProviderView;
/// Claims extracted from an OIDC ID token.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -932,7 +954,7 @@ impl ProviderState {
/// Global OIDC manager for all configured providers.
pub struct OidcSys {
configs: HashMap<String, OidcProviderConfig>,
configs: HashMap<String, SourcedOidcProviderConfig>,
provider_states: RwLock<HashMap<String, ProviderState>>,
state_store: OidcStateStore,
http_client: ReqwestHttpClient,
@@ -967,22 +989,22 @@ impl OidcSys {
async fn new_with_http_client(http_client: ReqwestHttpClient) -> Result<Self, String> {
let server_config = crate::server_config::current_server_config();
let parsed_configs = load_effective_oidc_provider_configs(server_config.as_ref());
let parsed_configs = load_oidc_config_snapshot(server_config.as_ref());
let mut configs = HashMap::new();
let mut provider_states = HashMap::new();
for sourced_config in parsed_configs {
let config = sourced_config.config;
for sourced_config in parsed_configs.into_providers() {
let config = &sourced_config.config;
if !config.enabled {
debug!(provider = %config.id, "OIDC provider disabled");
continue;
}
match Self::discover_provider(&config, &http_client).await {
match Self::discover_provider(config, &http_client).await {
Ok(state) => {
debug!(provider = %config.id, "OIDC provider discovered");
provider_states.insert(config.id.clone(), state);
configs.insert(config.id.clone(), config);
configs.insert(config.id.clone(), sourced_config);
}
Err(e) => {
error!(
@@ -1026,29 +1048,35 @@ impl OidcSys {
!self.configs.is_empty()
}
/// List all providers (including hidden ones). Used by site-replication and admin config.
pub(crate) fn provider_configs(&self) -> impl Iterator<Item = &OidcProviderConfig> {
self.configs.values().map(|provider| &provider.config)
}
/// List all providers, including providers hidden from the login UI.
pub fn list_providers(&self) -> Vec<OidcProviderSummary> {
self.configs
.values()
.map(|c| OidcProviderSummary {
provider_id: c.id.clone(),
display_name: c.display_name.clone(),
self.provider_configs()
.map(|config| OidcProviderSummary {
provider_id: config.id.clone(),
display_name: config.display_name.clone(),
})
.collect()
}
/// List only visible providers (excludes those with `hide_from_ui = true`).
/// List providers visible in the login UI.
pub fn list_visible_providers(&self) -> Vec<OidcProviderSummary> {
self.configs
.values()
.filter(|c| !c.hide_from_ui)
.map(|c| OidcProviderSummary {
provider_id: c.id.clone(),
display_name: c.display_name.clone(),
self.provider_configs()
.filter(|config| !config.hide_from_ui)
.map(|config| OidcProviderSummary {
provider_id: config.id.clone(),
display_name: config.display_name.clone(),
})
.collect()
}
pub fn config_snapshot(&self) -> OidcConfigSnapshot {
OidcConfigSnapshot::new(self.configs.values().cloned().collect())
}
/// Build the PKCE authorization URL for a provider, store state in the state store.
pub async fn authorize_url(
&self,
@@ -1057,8 +1085,7 @@ impl OidcSys {
redirect_after: Option<String>,
) -> Result<String, String> {
let config = self
.configs
.get(provider_id)
.get_provider_config(provider_id)
.ok_or_else(|| format!("unknown OIDC provider: {provider_id}"))?;
let state = self.ensure_provider_state(provider_id, config).await?;
@@ -1116,8 +1143,7 @@ impl OidcSys {
.ok_or_else(|| "invalid or expired OIDC state".to_string())?;
let config = self
.configs
.get(&session.provider_id)
.get_provider_config(&session.provider_id)
.ok_or_else(|| format!("unknown provider: {}", session.provider_id))?;
let provider_state = self.get_provider_state(&session.provider_id)?;
let issuer = provider_state.metadata.issuer().to_string();
@@ -1451,8 +1477,7 @@ impl OidcSys {
.ok_or_else(|| "invalid or expired OIDC logout token".to_string())?;
let config = self
.configs
.get(&session.provider_id)
.get_provider_config(&session.provider_id)
.ok_or_else(|| format!("unknown OIDC provider: {}", session.provider_id))?;
let state = self.ensure_provider_state(&session.provider_id, config).await?;
let Some(end_session_endpoint) = state.metadata.console()?.additional_metadata().end_session_endpoint.clone() else {
@@ -1478,88 +1503,10 @@ impl OidcSys {
/// Map OIDC claims to rustfs policy names.
pub fn map_claims_to_policies(&self, provider_id: &str, claims: &OidcClaims) -> (Vec<String>, Vec<String>) {
let config = match self.configs.get(provider_id) {
Some(c) => c,
None => return (vec![], vec![]),
let Some(rule) = self.authorization_rule(provider_id) else {
return (Vec::new(), Vec::new());
};
let mut policies = Vec::new();
let mut groups = Vec::new();
// Role-policy and claim-based authorization are separate OIDC modes. When a
// role policy is configured, group claims still provide group context but
// must not also become policy names.
let has_role_policy = !config.role_policy.trim().is_empty();
if has_role_policy {
for policy in config.role_policy.split(',') {
let policy = policy.trim();
if !policy.is_empty() {
policies.push(policy.to_string());
}
}
}
for group in &claims.groups {
groups.push(group.clone());
if !has_role_policy {
policies.push(claim_policy_name(&config.claim_prefix, group));
}
}
if !has_role_policy && config.claim_name != config.groups_claim {
for val in extract_groups_claim(&claims.raw, &config.claim_name) {
policies.push(claim_policy_name(&config.claim_prefix, &val));
}
}
// Deduplicate
policies.sort();
policies.dedup();
groups.sort();
groups.dedup();
let mut raw_claim_keys: Vec<&str> = claims.raw.keys().map(String::as_str).collect();
raw_claim_keys.sort_unstable();
let (claim_name_lookup, claim_name_raw_value) = claim_lookup_for_log(&claims.raw, &config.claim_name);
let (groups_claim_lookup, groups_claim_raw_value) = claim_lookup_for_log(&claims.raw, &config.groups_claim);
let (roles_claim_lookup, roles_claim_raw_value) = claim_lookup_for_log(&claims.raw, &config.roles_claim);
let claim_name_values = extract_groups_claim(&claims.raw, &config.claim_name);
let groups_claim_values = extract_groups_claim(&claims.raw, &config.groups_claim);
let roles_claim_values = extract_groups_claim(&claims.raw, &config.roles_claim);
debug!(
event = EVENT_OIDC_DIAGNOSTICS,
component = LOG_COMPONENT_IAM,
subsystem = LOG_SUBSYSTEM_OIDC,
result = "claims_policy_mapped",
provider_id = %provider_id,
claim_name = %config.claim_name,
claim_prefix = %config.claim_prefix,
groups_claim = %config.groups_claim,
roles_claim = %config.roles_claim,
role_policy = %config.role_policy,
policy_count = policies.len(),
group_count = groups.len(),
policies = ?policies,
groups = ?groups,
raw_claim_keys = ?raw_claim_keys,
raw_claims = ?claims.raw,
claim_name_lookup = %claim_name_lookup,
claim_name_type = claim_value_type_for_log(claim_name_raw_value),
claim_name_value = ?claim_name_raw_value,
claim_name_values = ?claim_name_values,
groups_claim_lookup = %groups_claim_lookup,
groups_claim_type = claim_value_type_for_log(groups_claim_raw_value),
groups_claim_value = ?groups_claim_raw_value,
groups_claim_values = ?groups_claim_values,
roles_claim_lookup = %roles_claim_lookup,
roles_claim_type = claim_value_type_for_log(roles_claim_raw_value),
roles_claim_value = ?roles_claim_raw_value,
roles_claim_values = ?roles_claim_values,
"oidc claims mapped to policies"
);
(policies, groups)
CoreFederatedAuthorizationMapper::map_policies_and_groups(provider_id, rule, &claims.groups, &claims.raw, true)
}
/// Policy names produced only by the canonical groups in claim-based mode: the groups claim
@@ -1571,30 +1518,22 @@ impl OidcSys {
/// dedicated policy claim are never included, so those configurations keep requiring every
/// policy to resolve.
pub fn group_claim_policy_names(&self, provider_id: &str, claims: &OidcClaims) -> Vec<String> {
let Some(config) = self.configs.get(provider_id) else {
let Some(rule) = self.authorization_rule(provider_id) else {
return Vec::new();
};
if !config.role_policy.trim().is_empty() {
return Vec::new();
}
CoreFederatedAuthorizationMapper::group_claim_policy_names(rule, &claims.groups, &claims.raw)
}
let explicit_policy_names: Vec<String> = if config.claim_name != config.groups_claim {
extract_groups_claim(&claims.raw, &config.claim_name)
.iter()
.map(|value| claim_policy_name(&config.claim_prefix, value))
.collect()
} else {
Vec::new()
};
let mut policies: Vec<String> = claims
.groups
.iter()
.map(|group| claim_policy_name(&config.claim_prefix, group))
.filter(|policy| !explicit_policy_names.contains(policy))
.collect();
policies.sort();
policies.dedup();
policies
fn authorization_rule(&self, provider_id: &str) -> Option<FederatedAuthorizationRuleRef<'_>> {
self.get_provider_config(provider_id).map(|config| {
FederatedAuthorizationRuleRef::new(
&config.claim_name,
&config.claim_prefix,
&config.role_policy,
&config.groups_claim,
&config.roles_claim,
)
})
}
/// Verify a raw JWT (id_token) for the AssumeRoleWithWebIdentity flow.
@@ -1678,7 +1617,7 @@ impl OidcSys {
&& issuer_host == provider_host
&& issuer_port == provider_port
&& issuer_path == provider_path
&& let Some(config) = self.configs.get(id)
&& let Some(config) = self.get_provider_config(id)
{
return Some((id.clone(), config.clone(), state.clone()));
}
@@ -1747,7 +1686,7 @@ impl OidcSys {
/// Get a provider config by ID.
pub fn get_provider_config(&self, id: &str) -> Option<&OidcProviderConfig> {
self.configs.get(id)
self.configs.get(id).map(|provider| &provider.config)
}
/// Parse all OIDC provider configs from environment variables.
@@ -2225,6 +2164,10 @@ pub fn load_effective_oidc_provider_configs(server_config: Option<&ServerConfig>
merge_oidc_provider_configs(env_configs, persisted_configs)
}
pub fn load_oidc_config_snapshot(server_config: Option<&ServerConfig>) -> OidcConfigSnapshot {
OidcConfigSnapshot::new(load_effective_oidc_provider_configs(server_config))
}
pub async fn validate_oidc_provider_config(config: &OidcProviderConfig) -> Result<OidcProviderValidationResult, String> {
validate_oidc_provider_config_with_extra_root_ca(config, None).await
}
@@ -2387,10 +2330,6 @@ fn extract_string_claim(claims: &HashMap<String, serde_json::Value>, key: &str)
}
}
fn claim_policy_name(claim_prefix: &str, value: &str) -> String {
format!("{claim_prefix}{value}")
}
/// Extract a groups/array claim from raw claims with case-insensitive fallback. Handles both string arrays and single strings.
fn extract_groups_claim(claims: &HashMap<String, serde_json::Value>, key: &str) -> Vec<String> {
match get_claim_case_insensitive(claims, key) {
@@ -2415,32 +2354,14 @@ fn extract_canonical_group_values(
groups
}
fn claim_lookup_for_log<'a>(
claims: &'a HashMap<String, serde_json::Value>,
key: &str,
) -> (&'static str, Option<&'a serde_json::Value>) {
match get_claim_case_insensitive(claims, key) {
ClaimLookup::Found(value) => ("found", Some(value)),
ClaimLookup::Missing => ("missing", None),
ClaimLookup::Ambiguous => ("ambiguous", None),
}
}
fn claim_value_type_for_log(value: Option<&serde_json::Value>) -> &'static str {
match value {
Some(serde_json::Value::Null) => "null",
Some(serde_json::Value::Bool(_)) => "bool",
Some(serde_json::Value::Number(_)) => "number",
Some(serde_json::Value::String(_)) => "string",
Some(serde_json::Value::Array(_)) => "array",
Some(serde_json::Value::Object(_)) => "object",
None => "none",
}
}
#[cfg(test)]
pub(crate) fn make_test_sys(configs: Vec<OidcProviderConfig>) -> OidcSys {
let configs = configs.into_iter().map(|config| (config.id.clone(), config)).collect();
#[cfg(any(test, feature = "test-util"))]
#[doc(hidden)]
pub fn make_test_sys(configs: Vec<OidcProviderConfig>) -> OidcSys {
let configs = configs
.into_iter()
.map(test_sourced_config)
.map(|provider| (provider.config.id.clone(), provider))
.collect();
OidcSys {
configs,
provider_states: RwLock::new(HashMap::new()),
@@ -2449,8 +2370,17 @@ pub(crate) fn make_test_sys(configs: Vec<OidcProviderConfig>) -> OidcSys {
}
}
#[cfg(test)]
pub(crate) fn test_config(id: &str) -> OidcProviderConfig {
#[cfg(any(test, feature = "test-util"))]
fn test_sourced_config(config: OidcProviderConfig) -> SourcedOidcProviderConfig {
SourcedOidcProviderConfig {
config,
source: OidcProviderConfigSource::Persisted,
}
}
#[cfg(any(test, feature = "test-util"))]
#[doc(hidden)]
pub fn test_config(id: &str) -> OidcProviderConfig {
OidcProviderConfig {
id: id.to_string(),
enabled: true,
@@ -3041,7 +2971,7 @@ mod tests {
.await
.expect("initial OIDC discovery should succeed");
let sys = OidcSys {
configs: HashMap::from([(config.id.clone(), config.clone())]),
configs: HashMap::from([(config.id.clone(), test_sourced_config(config.clone()))]),
provider_states: RwLock::new(HashMap::from([(config.id.clone(), state)])),
state_store: OidcStateStore::new(),
http_client,
@@ -3175,7 +3105,7 @@ mod tests {
let http_client = ReqwestHttpClient::with_policy(OutboundPolicy::from_allowed_origins(&base).unwrap());
let discovered = OidcSys::discover_provider(&config, &http_client).await.unwrap();
let sys = OidcSys {
configs: HashMap::from([(config.id.clone(), config)]),
configs: HashMap::from([(config.id.clone(), test_sourced_config(config))]),
provider_states: RwLock::new(HashMap::from([("console".into(), discovered)])),
state_store: OidcStateStore::new(),
http_client,
@@ -3403,13 +3333,13 @@ mod tests {
.expect("workload discovery must succeed");
assert!(state.metadata.authorization_endpoint().is_none());
let sys = OidcSys {
configs: HashMap::from([(config.id.clone(), config.clone())]),
configs: HashMap::from([(config.id.clone(), test_sourced_config(config.clone()))]),
provider_states: RwLock::new(HashMap::from([(config.id.clone(), state)])),
state_store: OidcStateStore::new(),
http_client,
};
assert!(sys.has_providers());
assert!(sys.list_visible_providers().is_empty());
assert!(sys.provider_configs().all(|provider| provider.hide_from_ui));
let error = sys
.authorize_url(&config.id, "https://console.example.com/callback", None)
.await
@@ -4033,7 +3963,7 @@ mod tests {
fn test_oidc_sys_empty() {
let sys = OidcSys::empty().expect("failed to initialize empty OIDC system");
assert!(!sys.has_providers());
assert!(sys.list_providers().is_empty());
assert!(sys.config_snapshot().providers().is_empty());
}
#[test]
@@ -4183,7 +4113,7 @@ mod tests {
)
.await;
let sys = OidcSys {
configs: HashMap::from([(provider_id.to_string(), config)]),
configs: HashMap::from([(provider_id.to_string(), test_sourced_config(config))]),
provider_states: RwLock::new(HashMap::from([(
provider_id.to_string(),
ProviderState {
@@ -4432,8 +4362,8 @@ mod tests {
let listed = sys.list_visible_providers();
assert_eq!(listed.len(), 1);
assert!(listed.iter().any(|p| p.provider_id == "dex"));
assert!(!listed.iter().any(|p| p.provider_id == "kubernetes"));
assert!(listed.iter().any(|provider| provider.provider_id == "dex"));
assert!(!listed.iter().any(|provider| provider.provider_id == "kubernetes"));
}
#[test]
@@ -4449,44 +4379,28 @@ mod tests {
}
#[test]
fn test_list_providers_includes_hidden_for_replication() {
fn test_config_snapshot_includes_hidden_for_replication() {
let visible = test_config("dex");
let mut hidden = test_config("kubernetes");
hidden.hide_from_ui = true;
let sys = make_test_sys(vec![visible, hidden]);
assert_eq!(sys.config_snapshot().providers().len(), 2);
}
#[test]
fn test_list_providers_includes_hidden_for_compatibility() {
let visible = test_config("dex");
let mut hidden = test_config("kubernetes");
hidden.hide_from_ui = true;
let sys = make_test_sys(vec![visible, hidden]);
// Unfiltered list returns all (used by site-replication)
assert_eq!(sys.list_providers().len(), 2);
// UI-filtered list hides the hidden one
assert_eq!(sys.list_visible_providers().len(), 1);
}
#[test]
fn test_list_providers_all_visible_by_default() {
let a = test_config("okta");
let b = test_config("dex");
let sys = make_test_sys(vec![a, b]);
let listed = sys.list_visible_providers();
assert_eq!(listed.len(), 2);
}
#[test]
fn test_list_visible_providers_all_hidden() {
let mut a = test_config("k8s-a");
a.hide_from_ui = true;
let mut b = test_config("k8s-b");
b.hide_from_ui = true;
let sys = make_test_sys(vec![a, b]);
let listed = sys.list_visible_providers();
assert!(listed.is_empty());
assert!(sys.has_providers());
}
#[test]
fn test_hide_from_ui_default_is_false() {
let config = test_config("default");
@@ -4723,17 +4637,32 @@ mod tests {
}
#[test]
fn test_list_providers() {
fn test_config_snapshot_lists_providers() {
let mut config = test_config("keycloak");
config.display_name = "Keycloak SSO".to_string();
let sys = make_test_sys(vec![config]);
assert!(sys.has_providers());
let summaries = sys.list_providers();
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].provider_id, "keycloak");
assert_eq!(summaries[0].display_name, "Keycloak SSO");
let snapshot = sys.config_snapshot();
assert_eq!(snapshot.providers().len(), 1);
assert_eq!(snapshot.providers()[0].config.id, "keycloak");
assert_eq!(snapshot.providers()[0].config.display_name, "Keycloak SSO");
assert_eq!(snapshot.providers()[0].source, OidcProviderConfigSource::Persisted);
}
#[test]
fn test_active_config_snapshot_puts_default_first() {
let sys = make_test_sys(vec![test_config("zeta"), test_config("default"), test_config("alpha")]);
assert_eq!(
sys.config_snapshot()
.providers()
.iter()
.map(|provider| provider.config.id.as_str())
.collect::<Vec<_>>(),
["default", "alpha", "zeta"]
);
}
#[test]
+1
View File
@@ -91,6 +91,7 @@ pub(crate) mod crate_boundary {
pub(crate) mod object_store {
pub(crate) use super::storage_contracts::{HTTPPreconditions, ListOperations, ObjectInfoOrErr, ObjectOperations};
pub(crate) use rustfs_ecstore::api::config::com::read_config_preserve_empty_with_metadata_opts;
}
pub(crate) mod runtime {
+10 -3
View File
@@ -42,6 +42,7 @@ use tracing::{debug, error, warn};
use crate::storage_api::object_store::{
HTTPPreconditions, ListOperations, ObjectInfoOrErr as StorageObjectInfoOrErr, ObjectOperations,
read_config_preserve_empty_with_metadata_opts,
};
pub static IAM_CONFIG_PREFIX: LazyLock<String> = LazyLock::new(|| format!("{IAM_CONFIG_ROOT_PREFIX}/iam"));
@@ -414,9 +415,14 @@ impl ObjectStore {
&self,
path: impl AsRef<str> + Send,
mode: LoadMode,
preserve_empty: bool,
) -> Result<(Vec<u8>, IamObjectInfo)> {
let path_ref = path.as_ref();
let (data, obj) = read_iam_config_with_metadata(self.object_api.clone(), path_ref, &mode.read_opts()).await?;
let (data, obj) = if preserve_empty {
read_config_preserve_empty_with_metadata_opts(self.object_api.clone(), path_ref, &mode.read_opts()).await?
} else {
read_iam_config_with_metadata(self.object_api.clone(), path_ref, &mode.read_opts()).await?
};
let outcome = Self::decrypt_data_with_source(&data)?;
self.maybe_schedule_lazy_rewrite(path_ref, &outcome, &obj);
@@ -614,8 +620,9 @@ impl ObjectStore {
/// Parameterized core of [`Store::load_user_identity`].
async fn load_user_identity_with(&self, name: &str, user_type: UserType, mode: LoadMode) -> Result<UserIdentity> {
let mut u: UserIdentity = self
.load_iam_config_with(get_user_identity_path(name, user_type), mode)
.load_iamconfig_bytes_with_metadata(get_user_identity_path(name, user_type), mode, true)
.await
.and_then(|(data, _)| serde_json::from_slice(&data).map_err(Error::from))
.map_err(|err| {
if is_err_config_not_found(&err) {
debug!(name = %MaskedAccessKey(name), user_type = ?user_type, "IAM user identity missing");
@@ -695,7 +702,7 @@ impl ObjectStore {
/// Parameterized core of [`Store::load_policy`].
async fn load_policy_with(&self, name: &str, mode: LoadMode) -> Result<PolicyDoc> {
let (data, obj) = self
.load_iamconfig_bytes_with_metadata(get_policy_doc_path(name), mode)
.load_iamconfig_bytes_with_metadata(get_policy_doc_path(name), mode, false)
.await
.map_err(|err| {
if is_err_config_not_found(&err) {
+61 -20
View File
@@ -2667,11 +2667,7 @@ mod tests {
name: None,
description: None,
};
let parent_identity = UserIdentity {
version: 1,
credentials: creds,
update_at: Some(OffsetDateTime::now_utc()),
};
let parent_identity = UserIdentity::new(creds);
let mut users = HashMap::new();
users.insert(PARENT_USER.to_string(), parent_identity);
@@ -2703,11 +2699,7 @@ mod tests {
name: None,
description: None,
};
let parent_identity = UserIdentity {
version: 1,
credentials: creds,
update_at: Some(OffsetDateTime::now_utc()),
};
let parent_identity = UserIdentity::new(creds);
let mut users = HashMap::new();
users.insert(PARENT_USER.to_string(), parent_identity);
@@ -2820,16 +2812,12 @@ mod tests {
async fn group_writes_stamped_before_the_cache_load_time_still_publish() {
let iam_sys = test_iam_sys().await;
let member = "group-stamp-member";
let identity = UserIdentity {
version: 1,
credentials: Credentials {
access_key: member.to_string(),
secret_key: "longenoughsecret".to_string(),
status: "on".to_string(),
..Default::default()
},
update_at: Some(OffsetDateTime::now_utc()),
};
let identity = UserIdentity::new(Credentials {
access_key: member.to_string(),
secret_key: "longenoughsecret".to_string(),
status: "on".to_string(),
..Default::default()
});
iam_sys.store.cache.with_write_lock(|cache| {
cache.add_or_update_user(member, &identity, OffsetDateTime::now_utc());
// The startup load publishes every entity with the load time.
@@ -2888,6 +2876,59 @@ mod tests {
}
}
#[tokio::test]
async fn service_account_preserves_long_secret_keys_after_rotation_and_reload() {
ensure_test_global_credentials();
let iam_sys = test_iam_sys().await;
for length in [41, 90, 128, 256, 257, 4096] {
let access_key = format!("long-secret-{length}");
let secret_key = format!("{}a", "s".repeat(length - 1));
let (created, _) = iam_sys
.new_service_account("parent-user", None, service_account_opts(&access_key, &secret_key))
.await
.expect("create service account with a long secret");
assert_eq!(created.secret_key, secret_key);
get_claims_from_token_with_secret_allow_missing_exp(&created.session_token, &secret_key)
.expect("the complete secret verifies the created session token");
let rotated_secret = format!("{}b", "s".repeat(length - 1));
iam_sys
.update_service_account(
&access_key,
UpdateServiceAccountOpts {
secret_key: Some(rotated_secret.clone()),
session_policy: None,
status: None,
name: None,
description: None,
expiration: None,
parent_user: None,
allow_site_replicator_account: false,
},
)
.await
.expect("rotate the complete long secret");
let persisted = iam_sys
.store
.api
.load_user_identity(&access_key, UserType::Svc)
.await
.expect("load the persisted service account identity");
assert_eq!(persisted.credentials.secret_key, rotated_secret);
get_claims_from_token_with_secret_allow_missing_exp(&persisted.credentials.session_token, &rotated_secret)
.expect("the rotated secret verifies the persisted session token");
assert!(
get_claims_from_token_with_secret_allow_missing_exp(&persisted.credentials.session_token, &secret_key).is_err()
);
assert!(
get_claims_from_token_with_secret_allow_missing_exp(&persisted.credentials.session_token, &rotated_secret[..40])
.is_err()
);
}
}
#[tokio::test]
async fn new_service_account_rejects_parent_access_key() {
ensure_test_global_credentials();
@@ -22,7 +22,11 @@
#[allow(unused_imports)]
pub(crate) mod fixture {
pub(crate) use rustfs_ecstore::api::bucket::migration::try_migrate_iam_config;
pub(crate) use rustfs_ecstore::api::config::com::save_config;
pub(crate) use rustfs_ecstore::api::global::set_object_store_resolver;
pub(crate) use rustfs_ecstore::api::layout::SetupType;
pub(crate) use rustfs_ecstore::api::runtime::{InstanceContext, first_cluster_node_is_local, object_store_handle};
pub(crate) use rustfs_ecstore::api::storage::ECStore;
// `update_erasure_type` is a write-side global facade entry. Its use is
// restricted to reviewed storage_api boundaries; this test-only module is
+458 -4
View File
@@ -33,12 +33,21 @@
mod ecstore_test_compat;
use ecstore_test_compat::fixture::{SetupType, update_erasure_type};
use ecstore_test_compat::fixture::{
ECStore, InstanceContext, SetupType, first_cluster_node_is_local, object_store_handle, save_config,
set_object_store_resolver, update_erasure_type,
};
use rustfs_credentials::{Credentials, IAM_POLICY_CLAIM_NAME_SA};
use rustfs_iam::cache::Cache;
use rustfs_iam::store::object::ObjectStore;
use rustfs_iam::store::{GroupInfo, Store};
use rustfs_iam::error::Error as IamError;
use rustfs_iam::manager::{IamCache, IamState};
use rustfs_iam::store::object::{IAM_CONFIG_POLICIES_PREFIX, IAM_CONFIG_USERS_PREFIX, ObjectStore};
use rustfs_iam::store::{GroupInfo, Store, UserType};
use rustfs_policy::auth::UserIdentity;
use serial_test::serial;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU8, AtomicU64};
use std::sync::{Arc, Mutex, OnceLock, Weak};
const TEST_GROUP: &str = "seq-restart-group";
const TEST_MEMBERS: [&str; 2] = ["alice", "bob"];
@@ -69,7 +78,8 @@ async fn load_all_bypasses_namespace_lock_quorum() {
.build()
.await
.ecstore;
let store = ObjectStore::new(ecstore);
let _route_guard = TestStoreRouteGuard::new(&ecstore);
let store = ObjectStore::new(ecstore.clone());
// Seed IAM data while namespace locks still work (single-node mode).
store
@@ -128,3 +138,447 @@ async fn load_all_bypasses_namespace_lock_quorum() {
})
.await;
}
const IDENTITY_PARENT: &str = "fixture-parent";
const IDENTITY_CHILD: &str = "fixture-service-child";
static TEST_STORE_ROUTE: Mutex<Option<Weak<ECStore>>> = Mutex::new(None);
static TEST_STORE_RESOLVER: OnceLock<()> = OnceLock::new();
struct TestStoreRouteGuard;
impl TestStoreRouteGuard {
fn new(ecstore: &Arc<ECStore>) -> Self {
TEST_STORE_RESOLVER.get_or_init(|| {
assert!(
set_object_store_resolver(Arc::new(|| {
TEST_STORE_ROUTE
.lock()
.expect("lock test store route")
.as_ref()
.and_then(Weak::upgrade)
})),
"test binary must own its resolver registration"
);
});
let mut route = TEST_STORE_ROUTE.lock().expect("lock test store route");
assert!(route.is_none(), "another test store route is active");
*route = Some(Arc::downgrade(ecstore));
Self
}
}
impl Drop for TestStoreRouteGuard {
fn drop(&mut self) {
*TEST_STORE_ROUTE.lock().expect("lock test store route for cleanup") = None;
}
}
struct IdentityStoreFixture {
previous_store: Option<Arc<ECStore>>,
ecstore: Arc<ECStore>,
manager: IamCache<ObjectStore>,
_route_guard: TestStoreRouteGuard,
_temp_dir: tempfile::TempDir,
}
impl IdentityStoreFixture {
async fn new() -> Self {
let temp_dir = tempfile::TempDir::with_prefix("rustfs_iam_identity_fixture_").expect("create identity fixture directory");
let ctx = Arc::new(InstanceContext::new());
let env = rustfs_test_utils::TestECStoreEnv::builder()
.base_dir(temp_dir.path())
.init_bucket_metadata(false)
.instance_ctx(ctx.clone())
.build()
.await;
ctx.set_endpoints(env.endpoint_pools.clone());
let ecstore = env.ecstore;
let previous_store = object_store_handle();
let route_guard = TestStoreRouteGuard::new(&ecstore);
let selected = object_store_handle().expect("fixture route selects its ECStore");
assert!(Arc::ptr_eq(&selected, &ecstore), "fixture route must select the isolated ECStore");
drop(selected);
assert!(first_cluster_node_is_local().await, "isolated fixture has a local first endpoint");
temp_env::async_with_vars([("RUSTFS_SKIP_BACKGROUND_TASK", Some("1"))], rustfs_iam::build_iam_sys(ecstore.clone()))
.await
.expect("initialize IAM metadata through production owner");
let store = ObjectStore::new(ecstore.clone());
let parent = UserIdentity::new(Credentials {
access_key: IDENTITY_PARENT.to_string(),
secret_key: "fixture-parent-secret".to_string(),
expiration: None,
session_token: String::new(),
status: "on".to_string(),
..Default::default()
});
let child = UserIdentity::new(Credentials {
access_key: IDENTITY_CHILD.to_string(),
secret_key: "fixture-child-secret".to_string(),
expiration: None,
session_token: String::new(),
status: "on".to_string(),
parent_user: IDENTITY_PARENT.to_string(),
claims: Some(HashMap::from([(
IAM_POLICY_CLAIM_NAME_SA.to_string(),
serde_json::json!("inherited-policy"),
)])),
..Default::default()
});
assert!(child.credentials.is_service_account(), "child fixture must be a service account");
store
.save_user_identity(IDENTITY_PARENT, UserType::Reg, parent, None)
.await
.expect("persist parent identity");
store
.save_user_identity(IDENTITY_CHILD, UserType::Svc, child, None)
.await
.expect("persist service child identity");
let manager = IamCache {
cache: Cache::default(),
api: store,
state: Arc::new(AtomicU8::new(IamState::Ready as u8)),
loading: Arc::new(AtomicBool::new(false)),
roles: HashMap::new(),
send_chan: tokio::sync::mpsc::channel::<i64>(1).0,
last_timestamp: AtomicI64::new(0),
sync_failures: AtomicU64::new(0),
sync_successes: AtomicU64::new(0),
last_sync_duration_millis: AtomicU64::new(0),
};
manager
.load_user(IDENTITY_PARENT)
.await
.expect("load persisted parent into cache");
manager
.load_user(IDENTITY_CHILD)
.await
.expect("load persisted service child into cache");
Self {
previous_store,
ecstore,
manager,
_route_guard: route_guard,
_temp_dir: temp_dir,
}
}
async fn assert_parent_and_child(&self) {
let parent = self
.manager
.api
.load_user_identity(IDENTITY_PARENT, UserType::Reg)
.await
.expect("read parent from real storage");
assert_eq!(parent.credentials.access_key, IDENTITY_PARENT);
assert_eq!(parent.credentials.parent_user, "");
let child = self
.manager
.api
.load_user_identity(IDENTITY_CHILD, UserType::Svc)
.await
.expect("read child from real storage");
assert_eq!(child.credentials.access_key, IDENTITY_CHILD);
assert_eq!(child.credentials.parent_user, IDENTITY_PARENT);
assert!(child.credentials.is_service_account());
let cached_parent = self.manager.get_user(IDENTITY_PARENT).await.expect("parent remains in cache");
assert_eq!(cached_parent.credentials.access_key, IDENTITY_PARENT);
assert_eq!(cached_parent.credentials.parent_user, "");
let cached_child = self
.manager
.get_user(IDENTITY_CHILD)
.await
.expect("service child remains in cache");
assert_eq!(cached_child.credentials.access_key, IDENTITY_CHILD);
assert_eq!(cached_child.credentials.parent_user, IDENTITY_PARENT);
assert!(cached_child.credentials.is_service_account());
}
async fn overwrite_parent_bytes(&self, bytes: &[u8]) {
let path = format!("{}{IDENTITY_PARENT}/identity.json", IAM_CONFIG_USERS_PREFIX.as_str());
save_config(self.ecstore.clone(), &path, bytes.to_vec())
.await
.expect("overwrite real parent identity bytes");
}
async fn assert_child_storage_and_identity_cache(&self) {
let child = self
.manager
.api
.load_user_identity(IDENTITY_CHILD, UserType::Svc)
.await
.expect("notification must preserve persisted service child");
assert_eq!(child.credentials.access_key, IDENTITY_CHILD);
assert_eq!(child.credentials.parent_user, IDENTITY_PARENT);
assert!(child.credentials.is_service_account());
let parent = self
.manager
.get_user(IDENTITY_PARENT)
.await
.expect("failed notification preserves cached parent");
assert_eq!(parent.credentials.access_key, IDENTITY_PARENT);
assert_eq!(parent.credentials.parent_user, "");
let child = self
.manager
.get_user(IDENTITY_CHILD)
.await
.expect("failed notification preserves cached child");
assert_eq!(child.credentials.access_key, IDENTITY_CHILD);
assert_eq!(child.credentials.parent_user, IDENTITY_PARENT);
assert!(child.credentials.is_service_account());
}
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn identity_store_fixture_roundtrips_parent_and_service_child() {
let fixture = IdentityStoreFixture::new().await;
let before = fixture.previous_store.clone();
let selected = object_store_handle().expect("fixture route selects its ECStore");
assert!(Arc::ptr_eq(&selected, &fixture.ecstore));
drop(selected);
fixture.assert_parent_and_child().await;
drop(fixture);
assert!(TEST_STORE_ROUTE.lock().expect("inspect cleared test store route").is_none());
match (before, object_store_handle()) {
(None, None) => {}
(Some(before), Some(after)) => assert!(Arc::ptr_eq(&before, &after), "fixture drop restores prior store identity"),
_ => panic!("fixture drop must restore the prior store option"),
}
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn identity_notification_valid_parent_preserves_child_storage_and_cache() {
let fixture = IdentityStoreFixture::new().await;
fixture.assert_parent_and_child().await;
fixture
.manager
.user_notification_handler(IDENTITY_PARENT, UserType::Reg)
.await
.expect("refresh valid parent notification");
fixture.assert_parent_and_child().await;
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn identity_store_fixture_raw_valid_overwrite_is_observed() {
let fixture = IdentityStoreFixture::new().await;
let parent = fixture
.manager
.api
.load_user_identity(IDENTITY_PARENT, UserType::Reg)
.await
.expect("load valid parent before raw overwrite");
let mut raw = serde_json::to_value(parent).expect("serialize valid parent fixture");
raw["credentials"]["name"] = serde_json::json!("updated-name");
let path = format!("{}{IDENTITY_PARENT}/identity.json", IAM_CONFIG_USERS_PREFIX.as_str());
save_config(
fixture.ecstore.clone(),
&path,
serde_json::to_vec(&raw).expect("encode raw parent fixture"),
)
.await
.expect("overwrite parent through real config storage");
fixture
.manager
.load_user(IDENTITY_PARENT)
.await
.expect("reload parent after raw overwrite");
let stored = fixture
.manager
.api
.load_user_identity(IDENTITY_PARENT, UserType::Reg)
.await
.expect("read overwritten parent from storage");
assert_eq!(stored.credentials.name.as_deref(), Some("updated-name"));
let cached = fixture
.manager
.get_user(IDENTITY_PARENT)
.await
.expect("read overwritten parent from cache");
assert_eq!(cached.credentials.name.as_deref(), Some("updated-name"));
fixture.assert_parent_and_child().await;
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn identity_corrupt_reads_preserve_crypto_error() {
let fixture = IdentityStoreFixture::new().await;
fixture.overwrite_parent_bytes(&[0xff, 0xfe, 0xfd]).await;
let locked = fixture
.manager
.api
.load_user_identity(IDENTITY_PARENT, UserType::Reg)
.await
.expect_err("corrupt stored identity must fail locked read");
assert!(matches!(locked, IamError::CryptoError(_)), "preserve crypto error, got {locked:?}");
let mut users = HashMap::new();
let no_lock = fixture
.manager
.api
.load_user_no_lock(IDENTITY_PARENT, UserType::Reg, &mut users)
.await
.expect_err("corrupt stored identity must fail no-lock read");
assert!(matches!(no_lock, IamError::CryptoError(_)), "preserve crypto error, got {no_lock:?}");
assert!(users.is_empty(), "failed no-lock read must not populate users");
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn identity_corrupt_notification_preserves_child_storage_and_cache() {
let fixture = IdentityStoreFixture::new().await;
fixture.assert_parent_and_child().await;
fixture.overwrite_parent_bytes(&[0xff, 0xfe, 0xfd]).await;
let notification = fixture
.manager
.user_notification_handler(IDENTITY_PARENT, UserType::Reg)
.await;
fixture.assert_child_storage_and_identity_cache().await;
let error = notification.expect_err("corrupt parent notification must return an error");
assert!(
matches!(error, IamError::CryptoError(_)),
"preserve notification crypto error, got {error:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn identity_invalid_schema_preserves_decode_error() {
let fixture = IdentityStoreFixture::new().await;
fixture.assert_parent_and_child().await;
fixture
.overwrite_parent_bytes(br#"{"version":"not-an-integer","credentials":{}}"#)
.await;
let error = fixture
.manager
.api
.load_user_identity(IDENTITY_PARENT, UserType::Reg)
.await
.expect_err("invalid identity field type must fail decode");
assert_identity_data_error(error);
let mut users = HashMap::new();
let error = fixture
.manager
.api
.load_user_no_lock(IDENTITY_PARENT, UserType::Reg, &mut users)
.await
.expect_err("invalid identity field type must fail no-lock decode");
assert_identity_data_error(error);
assert!(users.is_empty(), "failed decode must not populate users");
let notification = fixture
.manager
.user_notification_handler(IDENTITY_PARENT, UserType::Reg)
.await;
fixture.assert_child_storage_and_identity_cache().await;
assert_identity_data_error(notification.expect_err("invalid parent notification must return decode error"));
}
fn assert_identity_data_error(error: IamError) {
let IamError::Io(inner) = error else {
panic!("identity decode must preserve Io serde error, got {error:?}");
};
let cause = inner
.get_ref()
.and_then(|cause| cause.downcast_ref::<serde_json::Error>())
.expect("identity decode error retains serde source");
assert!(cause.is_data(), "identity field type must produce serde Data error");
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn identity_physically_missing_is_no_such_user() {
let fixture = IdentityStoreFixture::new().await;
let name = "physically-missing-identity";
let error = fixture
.manager
.api
.load_user_identity(name, UserType::Reg)
.await
.expect_err("missing identity must be absent");
assert!(
matches!(error, IamError::NoSuchUser(ref missing) if missing == name),
"preserve absent name, got {error:?}"
);
let mut users = HashMap::new();
let error = fixture
.manager
.api
.load_user_no_lock(name, UserType::Reg, &mut users)
.await
.expect_err("missing no-lock identity must be absent");
assert!(
matches!(error, IamError::NoSuchUser(ref missing) if missing == name),
"preserve absent name, got {error:?}"
);
assert!(users.is_empty(), "missing read must not populate users");
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn identity_empty_reads_preserve_crypto_error() {
let fixture = IdentityStoreFixture::new().await;
fixture.overwrite_parent_bytes(&[]).await;
let locked = fixture
.manager
.api
.load_user_identity(IDENTITY_PARENT, UserType::Reg)
.await
.expect_err("empty stored identity must fail locked read");
assert!(matches!(locked, IamError::CryptoError(_)), "preserve crypto error, got {locked:?}");
let mut users = HashMap::new();
let no_lock = fixture
.manager
.api
.load_user_no_lock(IDENTITY_PARENT, UserType::Reg, &mut users)
.await
.expect_err("empty stored identity must fail no-lock read");
assert!(matches!(no_lock, IamError::CryptoError(_)), "preserve crypto error, got {no_lock:?}");
assert!(users.is_empty(), "failed no-lock read must not populate users");
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn identity_empty_notification_preserves_child_storage_and_cache() {
let fixture = IdentityStoreFixture::new().await;
fixture.assert_parent_and_child().await;
fixture.overwrite_parent_bytes(&[]).await;
let notification = fixture
.manager
.user_notification_handler(IDENTITY_PARENT, UserType::Reg)
.await;
fixture.assert_child_storage_and_identity_cache().await;
let error = notification.expect_err("empty parent notification must return an error");
assert!(
matches!(error, IamError::CryptoError(_)),
"preserve notification crypto error, got {error:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn policy_empty_payload_retains_no_such_policy() {
let fixture = IdentityStoreFixture::new().await;
let name = "empty-policy";
let path = format!("{}{name}/policy.json", IAM_CONFIG_POLICIES_PREFIX.as_str());
save_config(fixture.ecstore.clone(), &path, Vec::new())
.await
.expect("save real empty policy payload");
let locked = fixture
.manager
.api
.load_policy(name)
.await
.err()
.expect("empty policy must retain absent-policy behavior");
assert!(matches!(locked, IamError::NoSuchPolicy), "preserve policy error, got {locked:?}");
let mut policies = HashMap::new();
let no_lock = fixture
.manager
.api
.load_policy_doc_no_lock(name, &mut policies)
.await
.expect_err("empty no-lock policy must retain absent-policy behavior");
assert!(matches!(no_lock, IamError::NoSuchPolicy), "preserve policy error, got {no_lock:?}");
assert!(policies.is_empty(), "failed no-lock policy read must not populate policies");
}
+3 -2
View File
@@ -281,8 +281,9 @@ pub use deadlock_metrics::{
// Lock metrics exports
pub use lock_metrics::{
LockMetricsSummary, record_contention_event, record_early_release, record_lock_hold_time, record_lock_optimization_enabled,
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, record_spin_attempt, record_spin_count_change,
record_object_lock_acquire_failure, 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,
record_spin_attempt, record_spin_count_change,
};
pub use process_lock_metrics::{
+45
View File
@@ -62,6 +62,25 @@ pub fn record_contention_event() {
counter!("rustfs_lock_contentions").increment(1);
}
/// Record a failed object namespace lock acquisition with bounded labels.
#[inline(always)]
pub fn record_object_lock_acquire_failure(
op: &'static str,
mode: &'static str,
resource_class: &'static str,
reason: &'static str,
) {
use metrics::counter;
counter!(
"rustfs_object_lock_acquire_failures_total",
"op" => op,
"mode" => mode,
"resource_class" => resource_class,
"reason" => reason
)
.increment(1);
}
/// Record a remote lock RPC that exceeded its caller's deadline.
#[inline(always)]
pub fn record_remote_lock_rpc_timeout(peer: &str, op: &'static str) {
@@ -244,6 +263,7 @@ mod tests {
record_remote_lock_rpc_detached("lock", "detached");
record_remote_lock_rpc_late_completion("lock", "success");
record_remote_lock_late_release("released");
record_object_lock_acquire_failure("put_object_commit", "write", "system_metadata", "timeout");
});
let emitted: std::collections::HashSet<String> = snapshotter
@@ -267,10 +287,35 @@ mod tests {
"rustfs_remote_lock_rpc_detached_total",
"rustfs_remote_lock_rpc_late_completions_total",
"rustfs_remote_lock_late_releases_total",
"rustfs_object_lock_acquire_failures_total",
] {
assert!(emitted.contains(expected), "{expected} must be emitted by its record helper");
}
}
#[test]
fn object_lock_acquire_failure_metric_is_recorded_without_resource_labels() {
let recorder = SeenMetricsRecorder::default();
metrics::with_local_recorder(&recorder, || {
record_object_lock_acquire_failure("put_object_commit", "write", "system_metadata", "timeout");
});
let keys = recorder.counters.lock().expect("counter keys should be lockable");
let key = keys
.iter()
.find(|key| key.name() == "rustfs_object_lock_acquire_failures_total")
.expect("object lock failure counter should be emitted");
let labels = key.labels().map(|label| (label.key(), label.value())).collect::<Vec<_>>();
assert_eq!(
labels,
[
("op", "put_object_commit"),
("mode", "write"),
("resource_class", "system_metadata"),
("reason", "timeout"),
]
);
}
use metrics::{Counter, CounterFn, Gauge, GaugeFn, Histogram, HistogramFn, Key, KeyName, Metadata, SharedString, Unit};
use std::sync::{Arc, Mutex};
+55 -1
View File
@@ -29,6 +29,7 @@ pub type S3HttpCompletionObserver = fn(S3Operation, Duration, bool);
pub type S3HttpCompletionObserverEnabled = fn() -> bool;
const METRIC: &str = "rustfs_s3_http_requests_total";
const LOCK_FAILURE_METRIC: &str = "rustfs_s3_lock_failures_total";
const METHODS: [&str; 10] = [
"GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS", "PATCH", "CONNECT", "TRACE", "OTHER",
];
@@ -112,6 +113,21 @@ pub(crate) fn observe_s3_http_operation(op: S3Operation) {
});
}
/// Attribute a lock-related S3 failure to the request operation without
/// exporting bucket, object, owner, or request identifiers as metric labels.
pub fn record_s3_http_lock_failure(resource_class: &'static str, mode: &'static str, reason: &'static str) {
let _ = CURRENT_OPERATION.try_with(|current| {
counter!(
LOCK_FAILURE_METRIC,
"op" => operation_label(current.get()),
"resource_class" => resource_class,
"mode" => mode,
"reason" => reason
)
.increment(1);
});
}
/// An external request is counted exactly once: at response headers, at a
/// service error, or when its future is dropped before producing a response.
/// Body-stream failures after headers use the existing streaming metrics.
@@ -196,7 +212,7 @@ pub fn s3_http_metrics_snapshot() -> Vec<S3HttpMetricSnapshot> {
mod tests {
use super::*;
use metrics::with_local_recorder;
use metrics_util::debugging::DebuggingRecorder;
use metrics_util::debugging::{DebugValue, DebuggingRecorder};
#[test]
fn completion_observer_sees_each_dispatched_request_once() {
@@ -303,4 +319,42 @@ mod tests {
assert_eq!(other.method, METHODS.len() - 1);
assert_eq!(other.operation, UNKNOWN_OPERATION);
}
#[test]
fn lock_failure_metric_uses_bounded_request_labels() {
let recorder = DebuggingRecorder::new();
with_local_recorder(&recorder, || {
let mut request = S3HttpRequestGuard::new("PUT");
request.in_scope(|| {
observe_s3_http_operation(S3Operation::PutObject);
record_s3_http_lock_failure("system_metadata", "write", "timeout");
});
record_s3_http_lock_failure("object_namespace", "read", "quorum");
});
let exported = recorder
.snapshotter()
.snapshot()
.into_vec()
.into_iter()
.filter(|(key, _, _, _)| key.key().name() == LOCK_FAILURE_METRIC)
.collect::<Vec<_>>();
assert_eq!(exported.len(), 1, "background lock errors must not be attributed to an S3 request");
let (key, _, _, value) = &exported[0];
assert!(matches!(value, DebugValue::Counter(1)));
let labels = key
.key()
.labels()
.map(|label| (label.key(), label.value()))
.collect::<std::collections::BTreeMap<_, _>>();
assert_eq!(
labels,
std::collections::BTreeMap::from([
("mode", "write"),
("op", S3Operation::PutObject.as_str()),
("reason", "timeout"),
("resource_class", "system_metadata"),
])
);
}
}
+3 -3
View File
@@ -36,7 +36,7 @@ use rustfs_obs::init_obs;
#[tokio::main]
async fn main() {
// Build config from environment variables, then initialise all backends.
let _guard = init_obs(None).await.expect("failed to initialise observability");
let _guard = init_obs(None, None).await.expect("failed to initialise observability");
tracing::info!("RustFS started");
@@ -56,7 +56,7 @@ async fn main() {
```rust
use rustfs_obs::init_obs;
let _guard = init_obs(Some("http://otel-collector:4318".to_string()))
let _guard = init_obs(Some("http://otel-collector:4318".to_string()), None)
.await
.expect("observability init failed");
```
@@ -136,7 +136,7 @@ With OTLP → Collector → Prometheus, the time from a failure to an alert also
| Variable | Default | Description |
|------------------------------|-------------------|---------------------------------------------------------|
| `RUSTFS_OBS_SERVICE_NAME` | `rustfs` | OTel `service.name` |
| `RUSTFS_OBS_SERVICE_VERSION` | _(crate version)_ | OTel `service.version` |
| `RUSTFS_OBS_SERVICE_VERSION` | RustFS build version | OTel `service.version`; the explicit environment value overrides the version passed by the RustFS binary |
| `RUSTFS_OBS_ENVIRONMENT` | `development` | Deployment environment (`production`, `development`, …) |
### Local logging
+1 -1
View File
@@ -20,7 +20,7 @@ use tracing::{Level, info, instrument};
#[tokio::main]
async fn main() {
let obs_conf = Some("http://localhost:4318".to_string());
let _guard = init_obs(obs_conf).await;
let _guard = init_obs(obs_conf, None).await;
let span = tracing::span!(Level::INFO, "main");
let _enter = span.enter();
info!("Program starts");
+39 -3
View File
@@ -46,7 +46,7 @@ use rustfs_config::observability::{
use rustfs_config::{
APP_NAME, DEFAULT_LOG_KEEP_FILES, DEFAULT_LOG_LEVEL, DEFAULT_LOG_ROTATION_TIME, DEFAULT_OBS_LOG_FILENAME,
DEFAULT_OBS_LOGS_EXPORT_ENABLED, DEFAULT_OBS_METRICS_EXPORT_ENABLED, DEFAULT_OBS_PROFILING_EXPORT_ENABLED,
DEFAULT_OBS_TRACES_EXPORT_ENABLED, ENVIRONMENT, METER_INTERVAL, SAMPLE_RATIO, SERVICE_VERSION, USE_STDOUT,
DEFAULT_OBS_TRACES_EXPORT_ENABLED, ENVIRONMENT, METER_INTERVAL, SAMPLE_RATIO, USE_STDOUT, VERSION,
};
use rustfs_utils::{
get_env_bool, get_env_bool_with_aliases, get_env_f64, get_env_opt_bool, get_env_opt_str, get_env_opt_u64, get_env_str,
@@ -162,7 +162,7 @@ pub struct OtelConfig {
pub meter_interval: Option<u64>,
/// OTel `service.name` attribute (default: `APP_NAME`).
pub service_name: Option<String>,
/// OTel `service.version` attribute (default: `SERVICE_VERSION`).
/// OTel `service.version` attribute (default: the supplied application version or Cargo package version).
pub service_version: Option<String>,
/// Deployment environment tag, e.g. `production` or `development`.
pub environment: Option<String>,
@@ -234,6 +234,10 @@ impl OtelConfig {
/// `None` or an empty string the value is read from the
/// `RUSTFS_OBS_ENDPOINT` environment variable instead.
///
/// The default service version is the Cargo package version. Applications
/// that know their display/build version should use
/// [`OtelConfig::extract_otel_config_from_env_with_version`].
///
/// When no endpoint is configured at all, `use_stdout` is forced to `true`
/// so that logs are still visible during development.
///
@@ -250,6 +254,15 @@ impl OtelConfig {
/// );
/// ```
pub fn extract_otel_config_from_env(endpoint: Option<String>) -> OtelConfig {
Self::extract_otel_config_from_env_with_version(endpoint, None)
}
/// Build an [`OtelConfig`] using `version` as the default OTel service version.
///
/// An explicit `RUSTFS_OBS_SERVICE_VERSION` environment variable takes
/// precedence over `version`. When `version` is `None`, the current Cargo
/// package version is used.
pub fn extract_otel_config_from_env_with_version(endpoint: Option<String>, version: Option<String>) -> OtelConfig {
let endpoint = match endpoint {
Some(ep) if !ep.is_empty() => ep,
_ => env::var(ENV_OBS_ENDPOINT).unwrap_or_default(),
@@ -312,7 +325,7 @@ impl OtelConfig {
sample_ratio: Some(get_env_f64(ENV_OBS_SAMPLE_RATIO, SAMPLE_RATIO)),
meter_interval: Some(get_env_u64(ENV_OBS_METER_INTERVAL, METER_INTERVAL)),
service_name: Some(get_env_str(ENV_OBS_SERVICE_NAME, APP_NAME)),
service_version: Some(get_env_str(ENV_OBS_SERVICE_VERSION, SERVICE_VERSION)),
service_version: Some(get_env_str(ENV_OBS_SERVICE_VERSION, version.as_deref().unwrap_or(VERSION))),
environment: Some(get_env_str(ENV_OBS_ENVIRONMENT, ENVIRONMENT)),
// Local logging
logger_level: Some(get_env_str(ENV_OBS_LOGGER_LEVEL, DEFAULT_LOG_LEVEL)),
@@ -459,6 +472,29 @@ mod tests {
OtelConfig::extract_otel_config_from_env(None).profiling_export_enabled
}
#[test]
fn service_version_uses_application_version_when_environment_is_unset() {
with_profiling_env_lock(|| {
temp_env::with_var_unset(ENV_OBS_SERVICE_VERSION, || {
let config = OtelConfig::extract_otel_config_from_env_with_version(None, Some("2.3.4".to_string()));
assert_eq!(config.service_version.as_deref(), Some("2.3.4"));
let config = OtelConfig::extract_otel_config_from_env_with_version(None, None);
assert_eq!(config.service_version.as_deref(), Some(VERSION));
});
});
}
#[test]
fn service_version_environment_override_takes_precedence_over_application_version() {
with_profiling_env_lock(|| {
temp_env::with_var(ENV_OBS_SERVICE_VERSION, Some("custom-build-42"), || {
let config = OtelConfig::extract_otel_config_from_env_with_version(None, Some("2.3.4".to_string()));
assert_eq!(config.service_version.as_deref(), Some("custom-build-42"));
});
});
}
#[test]
fn profiling_export_defaults_to_disabled_when_unset() {
with_profiling_env_lock(|| {
+10 -9
View File
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::{AppConfig, GlobalError, OtelConfig, OtelGuard, telemetry::init_telemetry};
use crate::{GlobalError, OtelConfig, OtelGuard, telemetry::init_telemetry};
use std::sync::{Arc, Mutex};
use tokio::sync::OnceCell;
use tracing::{info, warn};
@@ -68,10 +68,12 @@ pub(crate) fn set_observability_metric_enabled(enabled: bool) {
/// Initialize the observability module
///
/// # Parameters
/// - `config`: Configuration information
/// - `endpoint`: Optional root OTLP endpoint; falls back to `RUSTFS_OBS_ENDPOINT`.
/// - `version`: Optional application build version used as the default OTel
/// `service.version`; `RUSTFS_OBS_SERVICE_VERSION` takes precedence.
///
/// # Returns
/// A tuple containing the logger and the telemetry guard
/// An [`OtelGuard`] that must remain alive for the lifetime of the application.
///
/// # Example
/// ```no_run
@@ -79,16 +81,15 @@ pub(crate) fn set_observability_metric_enabled(enabled: bool) {
///
/// # #[tokio::main]
/// # async fn main() {
/// # match init_obs(None).await {
/// # match init_obs(None, None).await {
/// # Ok(guard) => {}
/// # Err(e) => { eprintln!("Failed to initialize observability: {}", e); }
/// # }
/// # }
/// ```
pub async fn init_obs(endpoint: Option<String>) -> Result<OtelGuard, GlobalError> {
// Load the configuration file
let config = AppConfig::new_with_endpoint(endpoint);
init_obs_with_config(&config.observability).await
pub async fn init_obs(endpoint: Option<String>, version: Option<String>) -> Result<OtelGuard, GlobalError> {
let config = OtelConfig::extract_otel_config_from_env_with_version(endpoint, version);
init_obs_with_config(&config).await
}
/// Initialize the observability module with an explicit [`OtelConfig`].
@@ -142,7 +143,7 @@ pub async fn init_obs_with_config(config: &OtelConfig) -> Result<OtelGuard, Glob
/// # use rustfs_obs::{ init_obs, set_global_guard};
///
/// # async fn init() -> Result<(), Box<dyn std::error::Error>> {
/// # let guard = match init_obs(None).await{
/// # let guard = match init_obs(None, None).await{
/// # Ok(g) => g,
/// # Err(e) => { return Err(Box::new(e)); }
/// # };
+1 -1
View File
@@ -31,7 +31,7 @@
//!
//! # #[tokio::main]
//! # async fn main() {
//! # let _guard = match init_obs(None).await {
//! # let _guard = match init_obs(None, None).await {
//! # Ok(g) => g,
//! # Err(e) => {
//! # panic!("Failed to initialize observability: {:?}", e);
+19 -3
View File
@@ -31,7 +31,7 @@ use opentelemetry_semantic_conventions::{
SERVICE_VERSION as OTEL_SERVICE_VERSION,
},
};
use rustfs_config::{APP_NAME, ENVIRONMENT, SERVICE_VERSION};
use rustfs_config::{APP_NAME, ENVIRONMENT, VERSION};
use rustfs_utils::get_local_ip_with_default;
use std::borrow::Cow;
@@ -40,7 +40,7 @@ use std::borrow::Cow;
/// The resource carries the following attributes:
/// - `service.name` — from `config.service_name`, defaulting to [`APP_NAME`].
/// - `service.version` — from `config.service_version`, defaulting to
/// [`SERVICE_VERSION`].
/// [`VERSION`].
/// - `deployment.environment` — from `config.environment`, defaulting to
/// [`ENVIRONMENT`].
/// - `rustfs.node` / `service.instance.id` — the stable RustFS local node name
@@ -61,7 +61,7 @@ pub(super) fn build_resource(config: &OtelConfig) -> Resource {
[
KeyValue::new(
OTEL_SERVICE_VERSION,
Cow::Borrowed(config.service_version.as_deref().unwrap_or(SERVICE_VERSION)).to_string(),
Cow::Borrowed(config.service_version.as_deref().unwrap_or(VERSION)).to_string(),
),
KeyValue::new(
DEPLOYMENT_ENVIRONMENT_NAME,
@@ -81,6 +81,22 @@ mod tests {
use super::*;
use opentelemetry::Key;
#[test]
fn build_resource_uses_configured_service_version() {
let config = OtelConfig {
service_version: Some("@a1b2c3d".to_string()),
..OtelConfig::default()
};
let resource = build_resource(&config);
assert_eq!(
resource
.get(&Key::from_static_str(OTEL_SERVICE_VERSION))
.map(|value| value.to_string()),
Some("@a1b2c3d".to_string())
);
}
#[tokio::test]
async fn build_resource_uses_stable_local_node_identity() {
let _guard = crate::node_identity::local_node_identity_test_guard().await;
+1
View File
@@ -58,6 +58,7 @@ tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] }
time = { workspace = true, features = ["serde", "parsing", "formatting", "macros"] }
serde = { workspace = true, features = ["derive", "rc"] }
serde_json = { workspace = true, features = ["raw_value"] }
uuid = { workspace = true, features = ["serde"] }
thiserror.workspace = true
strum = { workspace = true, features = ["derive"] }
rustfs-crypto = { workspace = true }
+50 -2
View File
@@ -26,7 +26,6 @@ use tracing::debug;
const ACCESS_KEY_MIN_LEN: usize = 3;
const ACCESS_KEY_MAX_LEN: usize = 128;
const SECRET_KEY_MIN_LEN: usize = 8;
const SECRET_KEY_MAX_LEN: usize = 40;
pub const ACCOUNT_ON: &str = "on";
pub const ACCOUNT_OFF: &str = "off";
@@ -122,7 +121,7 @@ pub fn create_new_credentials_with_metadata(
return Err(Error::InvalidAccessKeyLength);
}
if sk.len() < SECRET_KEY_MIN_LEN || sk.len() > SECRET_KEY_MAX_LEN {
if !is_secret_key_valid(sk) {
return Err(Error::InvalidSecretKeyLength);
}
@@ -450,4 +449,53 @@ mod reserved_chars_tests {
assert!(matches!(err, crate::error::Error::InvalidSecretKeyLength));
}
#[test]
fn credential_creation_preserves_custom_secret_keys() {
for length in [8, 40, 41, 90, 128, 256, 257, 4096] {
let secret_key = "s".repeat(length);
let cred = create_new_credentials_with_metadata("custom-access-key", &secret_key, &HashMap::new(), "")
.expect("custom secret key should be accepted");
assert_eq!(cred.secret_key, secret_key, "the full {length}-byte secret must be preserved");
}
}
#[test]
fn credential_creation_measures_the_minimum_in_utf8_bytes() {
let cred = create_new_credentials_with_metadata("custom-access-key", "éééé", &HashMap::new(), "")
.expect("eight UTF-8 bytes should be accepted");
assert_eq!(cred.secret_key, "éééé");
for secret_key in ["1234567", "ééé"] {
let err = create_new_credentials_with_metadata("custom-access-key", secret_key, &HashMap::new(), "")
.expect_err("fewer than eight UTF-8 bytes must be rejected");
assert!(matches!(err, crate::error::Error::InvalidSecretKeyLength));
}
}
#[test]
fn credential_creation_signs_with_the_entire_custom_secret() {
let mut claims = HashMap::new();
claims.insert(
"exp".to_owned(),
serde_json::json!((time::OffsetDateTime::now_utc() + time::Duration::hours(1)).unix_timestamp()),
);
for length in [90, 256, 4096] {
let secret_key = format!("{}a", "s".repeat(length - 1));
let cred = create_new_credentials_with_metadata("custom-access-key", &secret_key, &claims, &secret_key)
.expect("long secret key should sign a session token");
crate::utils::extract_claims::<serde_json::Value>(&cred.session_token, &secret_key)
.expect("the original secret should verify the session token");
let changed_tail = format!("{}b", "s".repeat(length - 1));
assert!(crate::utils::extract_claims::<serde_json::Value>(&cred.session_token, &changed_tail).is_err());
assert!(crate::utils::extract_claims::<serde_json::Value>(&cred.session_token, &secret_key[..40]).is_err());
}
}
#[test]
fn credential_generation_keeps_the_default_lengths() {
let (access_key, secret_key) = super::generate_credentials().expect("generate default credentials");
assert_eq!(access_key.len(), 20);
assert_eq!(secret_key.len(), 40);
}
}
+142 -1
View File
@@ -22,6 +22,33 @@ use serde_json::json;
use std::collections::HashMap;
use time::OffsetDateTime;
/// A nonnil stored principal identifier; parsing it does not establish identity authority.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(try_from = "uuid::Uuid", into = "uuid::Uuid")]
pub struct PrincipalIncarnation(uuid::Uuid);
#[derive(Debug, thiserror::Error)]
#[error("principal incarnation must not be nil")]
pub struct InvalidPrincipalIncarnation;
impl TryFrom<uuid::Uuid> for PrincipalIncarnation {
type Error = InvalidPrincipalIncarnation;
fn try_from(value: uuid::Uuid) -> Result<Self, Self::Error> {
if value.is_nil() {
Err(InvalidPrincipalIncarnation)
} else {
Ok(Self(value))
}
}
}
impl From<PrincipalIncarnation> for uuid::Uuid {
fn from(value: PrincipalIncarnation) -> Self {
value.0
}
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct UserIdentity {
pub version: i64,
@@ -29,6 +56,8 @@ pub struct UserIdentity {
/// updatedAt (RFC3339), legacy RustFS: update_at. Serialize as updatedAt
#[serde(rename = "updatedAt", alias = "update_at", default, with = "crate::serde_datetime::option")]
pub update_at: Option<OffsetDateTime>,
#[serde(default, rename = "principalIncarnation", skip_serializing_if = "Option::is_none")]
pub principal_incarnation: Option<PrincipalIncarnation>,
}
impl UserIdentity {
@@ -44,6 +73,7 @@ impl UserIdentity {
version: 1,
credentials,
update_at: Some(OffsetDateTime::now_utc()),
principal_incarnation: None,
}
}
@@ -73,13 +103,124 @@ impl From<Credentials> for UserIdentity {
version: 1,
credentials: value,
update_at: Some(OffsetDateTime::now_utc()),
principal_incarnation: None,
}
}
}
#[cfg(test)]
mod tests {
use super::UserIdentity;
use super::{PrincipalIncarnation, UserIdentity};
use rustfs_credentials::Credentials;
use serde_json::json;
use uuid::Uuid;
const INCARNATION: &str = "8f422eba-aab4-4ec3-a7ec-d1717463b520";
#[test]
fn test_user_identity_legacy_fields_absent() {
let mut legacy = json!({"version": 1, "credentials": {"accessKey": "ak", "secretKey": "sk12345678"}});
let identity: UserIdentity = serde_json::from_value(legacy.clone()).expect("decode legacy identity");
assert!(identity.principal_incarnation.is_none());
legacy["principalIncarnation"] = serde_json::Value::Null;
let identity: UserIdentity = serde_json::from_value(legacy).expect("decode null incarnation");
assert!(identity.principal_incarnation.is_none());
}
#[test]
fn test_user_identity_legacy_serialization_shape() {
let identity = UserIdentity::default();
let serialized = serde_json::to_value(&identity).expect("serialize legacy identity");
assert_eq!(serialized, json!({"version": 0, "credentials": identity.credentials, "updatedAt": null}));
assert!(serialized.get("principalIncarnation").is_none());
}
#[test]
fn test_user_identity_constructors_do_not_mint_incarnation() {
let default = UserIdentity::default();
assert!(default.principal_incarnation.is_none());
assert_eq!(default.version, 0);
assert!(default.update_at.is_none());
let credentials = Credentials {
access_key: "ak".to_string(),
secret_key: "sk12345678".to_string(),
..Default::default()
};
let expected_credentials = serde_json::to_value(&credentials).expect("serialize expected credentials");
for identity in [UserIdentity::new(credentials.clone()), UserIdentity::from(credentials)] {
assert!(identity.principal_incarnation.is_none());
assert_eq!(identity.version, 1);
assert!(identity.update_at.is_some());
assert_eq!(
serde_json::to_value(&identity.credentials).expect("serialize constructor credentials"),
expected_credentials
);
assert!(
serde_json::to_value(identity)
.expect("serialize constructor identity")
.get("principalIncarnation")
.is_none()
);
}
}
#[test]
fn test_principal_incarnation_rejects_nil_and_malformed() {
assert!(PrincipalIncarnation::try_from(Uuid::nil()).is_err());
for value in ["00000000-0000-0000-0000-000000000000", "not-a-uuid", ""] {
assert!(serde_json::from_value::<PrincipalIncarnation>(json!(value)).is_err(), "reject {value}");
let identity = json!({"version": 1, "credentials": {}, "principalIncarnation": value});
assert!(serde_json::from_value::<UserIdentity>(identity).is_err(), "identity rejects {value}");
}
}
#[test]
fn test_principal_incarnation_roundtrip_preserves_uuid() {
let uuid = Uuid::parse_str(INCARNATION).expect("parse fixed incarnation");
let incarnation = PrincipalIncarnation::try_from(uuid).expect("accept nonnil incarnation");
assert_eq!(Uuid::from(incarnation).as_bytes(), uuid.as_bytes());
let serialized = serde_json::to_value(incarnation).expect("serialize incarnation");
assert_eq!(serialized, json!(INCARNATION));
assert_eq!(
serde_json::from_value::<PrincipalIncarnation>(serialized).expect("decode incarnation"),
incarnation
);
}
#[test]
fn test_user_identity_live_incarnation_roundtrip() {
let mut identity = UserIdentity::new(Credentials::default());
identity.principal_incarnation = Some(
PrincipalIncarnation::try_from(Uuid::parse_str(INCARNATION).expect("parse fixed incarnation"))
.expect("accept fixed incarnation"),
);
let serialized = serde_json::to_value(&identity).expect("serialize identity with incarnation");
assert_eq!(serialized["principalIncarnation"], INCARNATION);
let encoded = serde_json::to_string(&identity).expect("encode identity with incarnation");
let decoded: UserIdentity = serde_json::from_str(&encoded).expect("decode identity with incarnation");
assert_eq!(decoded.principal_incarnation, identity.principal_incarnation);
assert_eq!(serde_json::to_value(decoded).expect("reserialize identity"), serialized);
}
#[test]
fn test_user_identity_timestamp_aliases_unchanged() {
let mut minio = json!({"version": 1, "credentials": {}, "updatedAt": "2025-03-07T12:00:00Z"});
let encoded = serde_json::to_string(&minio).expect("encode updatedAt fixture");
let identity: UserIdentity = serde_json::from_str(&encoded).expect("decode updatedAt timestamp");
minio["update_at"] = minio
.as_object_mut()
.expect("identity object")
.remove("updatedAt")
.expect("existing timestamp");
let encoded = serde_json::to_string(&minio).expect("encode update_at fixture");
let legacy: UserIdentity = serde_json::from_str(&encoded).expect("decode update_at alias");
assert_eq!(legacy.update_at, identity.update_at);
assert_eq!(legacy.version, 1);
let serialized = serde_json::to_value(legacy).expect("serialize legacy timestamp");
assert_eq!(serialized["updatedAt"], "2025-03-07T12:00:00Z");
assert!(serialized.get("update_at").is_none());
assert!(serialized.get("principalIncarnation").is_none());
}
/// Deserialize UserIdentity from MinIO-style JSON (RFC3339 updatedAt).
#[test]
+12
View File
@@ -527,6 +527,9 @@ pub enum AdminAction {
ImportBucketMetadataAction,
#[strum(serialize = "admin:ExportBucketMetadata")]
ExportBucketMetadataAction,
/// Reconcile metadata left after an operator removed all physical bucket volumes.
#[strum(serialize = "admin:RecoverOrphanedBucket")]
RecoverOrphanedBucketAction,
#[strum(serialize = "admin:GetTableCatalog")]
GetTableCatalogAction,
#[strum(serialize = "admin:MigrateTableCatalog")]
@@ -682,6 +685,7 @@ impl AdminAction {
| AdminAction::GetReplicationMetricsAction
| AdminAction::ImportBucketMetadataAction
| AdminAction::ExportBucketMetadataAction
| AdminAction::RecoverOrphanedBucketAction
| AdminAction::GetTableCatalogAction
| AdminAction::MigrateTableCatalogAction
| AdminAction::GetTableBucketAction
@@ -909,6 +913,14 @@ mod tests {
assert!(AdminAction::GetMetricsAction.is_valid());
}
#[test]
fn orphaned_bucket_recovery_admin_action_is_valid() {
let action = AdminAction::try_from("admin:RecoverOrphanedBucket").expect("parse recovery action");
assert_eq!(action, AdminAction::RecoverOrphanedBucketAction);
assert!(action.is_valid());
assert_eq!(<&str>::from(action), "admin:RecoverOrphanedBucket");
}
#[test]
fn test_bucket_on_demand_migration_admin_actions_are_valid() {
let set_action = AdminAction::try_from("admin:SetBucketOnDemandMigration").expect("parse set action");
@@ -198,6 +198,9 @@ pub enum S3KeyName {
#[strum(serialize = "s3:object-lock-retain-until-date")]
S3ObjectLockRetainUntilDate,
#[strum(serialize = "s3:object-lock-remaining-retention-days")]
S3ObjectLockRemainingRetentionDays,
#[strum(serialize = "s3:object-lock-legal-hold")]
S3ObjectLockLegalHold,
@@ -393,6 +396,10 @@ mod tests {
#[test_case("s3:versionid", KeyName::S3(S3KeyName::S3VersionId) ; "minio_version_id")]
#[test_case("s3:object-lock-mode", KeyName::S3(S3KeyName::S3ObjectLockMode))]
#[test_case("s3:object-lock-legal-hold", KeyName::S3(S3KeyName::S3ObjectLockLegalHold))]
#[test_case(
"s3:object-lock-remaining-retention-days",
KeyName::S3(S3KeyName::S3ObjectLockRemainingRetentionDays)
)]
#[test_case("aws:SecureTransport", KeyName::Aws(AwsKeyName::AWSSecureTransport))]
#[test_case("jwt:sub", KeyName::Jwt(JwtKeyName::JWTSub))]
#[test_case("ldap:user", KeyName::Ldap(LdapKeyName::User))]
@@ -417,6 +424,10 @@ mod tests {
#[test_case("s3:versionid", KeyName::S3(S3KeyName::S3VersionId) ; "minio_version_id")]
#[test_case("s3:object-lock-mode", KeyName::S3(S3KeyName::S3ObjectLockMode))]
#[test_case("s3:object-lock-legal-hold", KeyName::S3(S3KeyName::S3ObjectLockLegalHold))]
#[test_case(
"s3:object-lock-remaining-retention-days",
KeyName::S3(S3KeyName::S3ObjectLockRemainingRetentionDays)
)]
#[test_case("aws:SecureTransport", KeyName::Aws(AwsKeyName::AWSSecureTransport))]
#[test_case("jwt:sub", KeyName::Jwt(JwtKeyName::JWTSub))]
#[test_case("ldap:user", KeyName::Ldap(LdapKeyName::User))]
@@ -437,6 +448,10 @@ mod tests {
#[test_case("s3:versionid", KeyName::S3(S3KeyName::S3VersionId))]
#[test_case("s3:object-lock-mode", KeyName::S3(S3KeyName::S3ObjectLockMode))]
#[test_case("s3:object-lock-legal-hold", KeyName::S3(S3KeyName::S3ObjectLockLegalHold))]
#[test_case(
"s3:object-lock-remaining-retention-days",
KeyName::S3(S3KeyName::S3ObjectLockRemainingRetentionDays)
)]
#[test_case("aws:SecureTransport", KeyName::Aws(AwsKeyName::AWSSecureTransport))]
#[test_case("jwt:sub", KeyName::Jwt(JwtKeyName::JWTSub))]
#[test_case("ldap:user", KeyName::Ldap(LdapKeyName::User))]
@@ -104,6 +104,7 @@ mod tests {
key_name::KeyName::{self, *},
key_name::S3KeyName::*,
};
use std::collections::HashMap;
use test_case::test_case;
fn new_func(name: KeyName, variable: Option<String>, value: i64) -> NumberFunc {
@@ -125,6 +126,35 @@ mod tests {
Ok(())
}
#[test_case(r#"{"s3:object-lock-remaining-retention-days": 30}"#; "number")]
#[test_case(r#"{"s3:object-lock-remaining-retention-days": "30"}"#; "string")]
fn remaining_retention_days_deserializes(input: &str) -> Result<(), serde_json::Error> {
let v: NumberFunc = serde_json::from_str(input)?;
assert_eq!(v, new_func(S3(S3ObjectLockRemainingRetentionDays), None, 30));
Ok(())
}
#[test_case("29", true, false, false; "below limit")]
#[test_case("30", true, false, true; "at limit")]
#[test_case("31", false, true, false; "above limit")]
fn remaining_retention_days_numeric_comparisons(days: &str, less_than_equals: bool, greater_than: bool, equals: bool) {
let limit = new_func(S3(S3ObjectLockRemainingRetentionDays), None, 30);
let values = HashMap::from([("object-lock-remaining-retention-days".to_string(), vec![days.to_string()])]);
assert_eq!(limit.evaluate(i64::le, false, &values), less_than_equals);
assert_eq!(limit.evaluate(i64::gt, false, &values), greater_than);
assert_eq!(limit.evaluate(i64::eq, false, &values), equals);
}
#[test]
fn remaining_retention_days_numeric_condition_without_value_uses_if_exists() {
let limit = new_func(S3(S3ObjectLockRemainingRetentionDays), None, 30);
let values = HashMap::new();
assert!(!limit.evaluate(i64::le, false, &values));
assert!(limit.evaluate(i64::le, true, &values));
}
#[test_case(r#"{"s3:max-keys":"1"}"#, new_func(S3(S3MaxKeys), None, 1); "1")]
#[test_case(r#"{"s3:max-keys/a":"1"}"#, new_func(S3(S3MaxKeys), Some("a".into()), 1); "2")]
fn test_ser(expect: &str, input: NumberFunc) -> Result<(), serde_json::Error> {
+89 -19
View File
@@ -252,6 +252,13 @@ impl PolicyVariableResolver for VariableResolver {
}
pub async fn resolve_aws_variables(pattern: &str, resolver: &dyn PolicyVariableResolver) -> Vec<String> {
resolve_aws_variables_with_depth(pattern, resolver, 0).await
}
async fn resolve_aws_variables_with_depth(pattern: &str, resolver: &dyn PolicyVariableResolver, depth: usize) -> Vec<String> {
if depth >= 10 {
return vec![pattern.to_string()];
}
let mut results = vec![pattern.to_string()];
let mut changed = true;
@@ -264,7 +271,7 @@ pub async fn resolve_aws_variables(pattern: &str, resolver: &dyn PolicyVariableR
let mut new_results = Vec::new();
for result in &results {
let resolved = resolve_single_pass(result, resolver).await;
let resolved = resolve_single_pass(result, resolver, depth).await;
if resolved.len() > 1 || (resolved.len() == 1 && &resolved[0] != result) {
changed = true;
}
@@ -288,30 +295,31 @@ pub async fn resolve_aws_variables(pattern: &str, resolver: &dyn PolicyVariableR
fn resolve_aws_variables_boxed<'a>(
pattern: &'a str,
resolver: &'a dyn PolicyVariableResolver,
depth: usize,
) -> std::pin::Pin<Box<dyn Future<Output = Vec<String>> + Send + 'a>> {
Box::pin(resolve_aws_variables(pattern, resolver))
Box::pin(resolve_aws_variables_with_depth(pattern, resolver, depth))
}
/// Single pass resolution of variables in a string
async fn resolve_single_pass(pattern: &str, resolver: &dyn PolicyVariableResolver) -> Vec<String> {
async fn resolve_single_pass(pattern: &str, resolver: &dyn PolicyVariableResolver, depth: usize) -> Vec<String> {
// Find all ${...} format variables
let mut results = vec![pattern.to_string()];
let mut results = vec![(pattern.to_string(), 0)];
// Process each result string
let mut i = 0;
while i < results.len() {
let mut start = 0;
let mut start = results[i].1;
let mut modified = false;
// Find variables in current string
while let Some(pos) = results[i][start..].find("${") {
while let Some(pos) = results[i].0[start..].find("${") {
let actual_pos = start + pos;
// Find the matching closing brace, taking into account nested braces
let mut brace_count = 1;
let mut end_pos = actual_pos + 2; // Start after "${"
let bytes = results[i].as_bytes();
let bytes = results[i].0.as_bytes();
while end_pos < bytes.len() && brace_count > 0 {
match bytes[end_pos] {
b'{' => brace_count += 1,
@@ -324,19 +332,23 @@ async fn resolve_single_pass(pattern: &str, resolver: &dyn PolicyVariableResolve
}
if brace_count == 0 {
let var_name = &results[i][actual_pos + 2..end_pos];
let var_name = &results[i].0[actual_pos + 2..end_pos];
// Check if this is a nested variable (contains ${...} inside)
if var_name.contains("${") {
// For nested variables like ${${a}-${b}}, we need to resolve the inner variables first
// Then use the resolved result as a new variable to resolve
let resolved_inner = resolve_aws_variables_boxed(var_name, resolver).await;
let resolved_inner = resolve_aws_variables_boxed(var_name, resolver, depth + 1).await;
if resolved_inner.len() == 1 && resolved_inner[0] == var_name {
start = end_pos + 1;
continue;
}
let mut new_results = Vec::new();
for resolved_var_name in resolved_inner {
let prefix = &results[i][..actual_pos];
let suffix = &results[i][end_pos + 1..];
new_results.push(format!("{prefix}{resolved_var_name}{suffix}"));
let prefix = &results[i].0[..actual_pos];
let suffix = &results[i].0[end_pos + 1..];
new_results.push((format!("{prefix}{resolved_var_name}{suffix}"), actual_pos + resolved_var_name.len()));
}
if !new_results.is_empty() {
@@ -354,11 +366,13 @@ async fn resolve_single_pass(pattern: &str, resolver: &dyn PolicyVariableResolve
if !values.is_empty() {
// If there are multiple values, create a new result for each value
let mut new_results = Vec::new();
let prefix = &results[i][..actual_pos];
let suffix = &results[i][end_pos + 1..];
let prefix = &results[i].0[..actual_pos];
let suffix = &results[i].0[end_pos + 1..];
for value in values {
new_results.push(format!("{prefix}{value}{suffix}"));
// Resume after the substitution; a generated placeholder
// belongs to the next bounded pass, not this one.
new_results.push((format!("{prefix}{value}{suffix}"), actual_pos + value.len()));
}
results.splice(i..i + 1, new_results);
@@ -367,9 +381,9 @@ async fn resolve_single_pass(pattern: &str, resolver: &dyn PolicyVariableResolve
} else {
// Variable resolved to empty, just remove the variable placeholder
let mut new_results = Vec::new();
let prefix = &results[i][..actual_pos];
let suffix = &results[i][end_pos + 1..];
new_results.push(format!("{prefix}{suffix}"));
let prefix = &results[i].0[..actual_pos];
let suffix = &results[i].0[end_pos + 1..];
new_results.push((format!("{prefix}{suffix}"), actual_pos));
results.splice(i..i + 1, new_results);
modified = true;
@@ -391,7 +405,7 @@ async fn resolve_single_pass(pattern: &str, resolver: &dyn PolicyVariableResolve
}
}
results
results.into_iter().map(|(value, _)| value).collect()
}
#[cfg(test)]
@@ -399,6 +413,62 @@ mod tests {
use super::*;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
struct CycleResolver {
calls: AtomicUsize,
}
#[async_trait]
impl PolicyVariableResolver for CycleResolver {
async fn resolve(&self, name: &str) -> Option<String> {
// Make the vulnerable implementation terminate so a regression
// reports an assertion failure instead of hanging the test runner.
if self.calls.fetch_add(1, Ordering::Relaxed) >= 100 {
return None;
}
match name {
"aws:AccountId" => Some("$$".to_string()),
"aws:username" => Some("{aws:AccountId}{aws:username}".to_string()),
_ => None,
}
}
fn is_dynamic(&self, _: &str) -> bool {
false
}
}
#[tokio::test]
async fn generated_policy_variable_cycles_are_bounded() {
let resolver = CycleResolver {
calls: AtomicUsize::new(0),
};
let result = resolve_aws_variables("${aws:AccountId}{aws:username}", &resolver).await;
assert!(resolver.calls.load(Ordering::Relaxed) <= 10);
assert_eq!(result, vec!["${aws:AccountId}{aws:username}".to_string()]);
}
#[tokio::test]
async fn a_single_pass_resolves_all_original_variables() {
let mut context = VariableContext::new();
context.username = Some("alice".to_string());
let resolver = VariableResolver::new(context);
let pattern = "${aws:username}".repeat(20);
assert_eq!(resolve_single_pass(&pattern, &resolver, 0).await, vec!["alice".repeat(20)]);
}
#[tokio::test]
async fn nested_policy_variables_have_a_depth_limit() {
let resolver = CycleResolver {
calls: AtomicUsize::new(0),
};
let pattern = format!("{}aws:username{}", "${".repeat(100), "}".repeat(100));
let result = resolve_aws_variables(&pattern, &resolver).await;
assert_eq!(result.len(), 1);
assert!(result[0].contains("${"));
assert!(resolver.calls.load(Ordering::Relaxed) <= 10);
}
#[tokio::test]
async fn test_resolve_aws_variables_with_username() {
+1
View File
@@ -80,6 +80,7 @@ rustfs-tls-runtime.workspace = true
rustfs-utils = { workspace = true, features = ["io", "hash", "compress"] }
serde_json = { workspace = true, features = ["raw_value"] }
md-5 = { workspace = true }
hmac.workspace = true
minlz.workspace = true
tracing.workspace = true
thiserror.workspace = true
+64
View File
@@ -14,8 +14,10 @@
use crate::compress_index::{Index, TryGetIndex};
use crate::{BadDigest, EtagResolvable, HashReaderDetector, HashReaderMut};
use hmac::{Hmac, KeyInit, Mac};
use md5::{Digest, Md5};
use pin_project_lite::pin_project;
use sha2::Sha256;
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::io::{AsyncRead, ReadBuf};
@@ -29,6 +31,7 @@ pin_project! {
pub finished: bool,
pub checksum: Option<String>,
resolved_etag: Option<String>,
encrypted_etag_mac: Option<Hmac<Sha256>>,
}
}
@@ -40,9 +43,21 @@ impl<R> EtagReader<R> {
finished: false,
checksum,
resolved_etag: None,
encrypted_etag_mac: None,
}
}
/// Protect the content fingerprint with the object's encryption material.
/// Plaintext checksum verification remains the inner reader's responsibility.
pub fn new_encrypted(inner: R, key: [u8; 32], nonce: [u8; 12]) -> std::io::Result<Self> {
let mut mac = <Hmac<Sha256> as KeyInit>::new_from_slice(&key).map_err(std::io::Error::other)?;
mac.update(b"RustFS:encrypted-ETag:v1\0");
mac.update(&nonce);
let mut reader = Self::new(inner, None);
reader.encrypted_etag_mac = Some(mac);
Ok(reader)
}
/// Get the final md5 value (etag) as a hex string, only compute once.
/// Can be called multiple times, always returns the same result after finished.
pub fn get_etag(&mut self) -> String {
@@ -108,6 +123,14 @@ impl<R> EtagResolvable for EtagReader<R> {
true
}
fn try_resolve_etag(&mut self) -> Option<String> {
if let Some(mut mac) = self.encrypted_etag_mac.clone() {
if !self.finished {
return None;
}
mac.update(self.get_etag().as_bytes());
let protected = mac.finalize().into_bytes();
return Some(hex_simd::encode_to_string(&protected[..16], hex_simd::AsciiCase::Lower));
}
// EtagReader provides its own etag, not delegating to inner
if let Some(checksum) = &self.checksum {
Some(checksum.clone())
@@ -148,6 +171,47 @@ mod tests {
use std::io::Cursor;
use tokio::io::{AsyncReadExt, BufReader};
#[tokio::test]
async fn encrypted_etags_hide_plaintext_fingerprints_including_empty_objects() {
for data in [b"".as_slice(), b"Salary 2026: 85000 EUR".as_slice()] {
let md5 = hex_simd::encode_to_string(Md5::digest(data), hex_simd::AsciiCase::Lower);
let mut tags = Vec::new();
for (key, nonce) in [([1; 32], [1; 12]), ([2; 32], [1; 12]), ([1; 32], [2; 12])] {
let mut reader = EtagReader::new_encrypted(Cursor::new(data), key, nonce).expect("create protected ETag reader");
assert!(reader.try_resolve_etag().is_none());
let mut output = Vec::new();
reader.read_to_end(&mut output).await.expect("read unchanged plaintext");
assert_eq!(output, data);
let tag = reader.try_resolve_etag().expect("resolve protected ETag after EOF");
assert_eq!(tag.len(), 32);
assert_ne!(tag, md5);
assert_eq!(reader.try_resolve_etag().as_ref(), Some(&tag));
tags.push(tag);
}
assert_ne!(tags[0], tags[1], "different keys must not link identical content");
assert_ne!(tags[0], tags[2], "different objects under a legacy key must not link identical content");
}
}
#[tokio::test]
async fn encrypted_etag_reader_preserves_plaintext_digest_rejection() {
let plaintext = crate::HashReader::from_stream(
Cursor::new(b"actual plaintext"),
16,
16,
Some("00000000000000000000000000000000".to_owned()),
None,
false,
)
.expect("create checked plaintext reader");
let mut reader = EtagReader::new_encrypted(plaintext, [1; 32], [2; 12]).expect("create protected ETag reader");
let err = reader
.read_to_end(&mut Vec::new())
.await
.expect_err("wrong plaintext MD5 must still be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[tokio::test]
async fn test_etag_reader_basic() {
let data = b"hello world";
+5
View File
@@ -136,6 +136,7 @@ pin_project! {
content_sha256: Option<String>,
content_sha256_hasher: Option<Sha256Hasher>,
checksum_on_finish: bool,
pub encrypted_etag: bool,
// Read only at EOF; see the timing contract in `crate::trailer`.
trailer: Option<SharedTrailerSource>,
@@ -184,6 +185,7 @@ impl HashReader {
content_sha256: sha256hex.clone(),
content_sha256_hasher: sha256hex.map(|_| Sha256Hasher::new()),
checksum_on_finish: false,
encrypted_etag: false,
trailer: None,
})
}
@@ -224,6 +226,7 @@ impl HashReader {
content_sha256: sha256hex.clone(),
content_sha256_hasher: sha256hex.map(|_| Sha256Hasher::new()),
checksum_on_finish: false,
encrypted_etag: false,
trailer: None,
})
}
@@ -294,6 +297,7 @@ impl HashReader {
content_hash,
content_hasher,
checksum_on_finish: false,
encrypted_etag: false,
trailer: existing_hash_reader.get_trailer().cloned(),
})
} else {
@@ -322,6 +326,7 @@ impl HashReader {
content_sha256: sha256hex.clone(),
content_sha256_hasher: sha256hex.map(|_| Sha256Hasher::new()),
checksum_on_finish: false,
encrypted_etag: false,
trailer: None,
})
}
@@ -96,7 +96,7 @@ async fn quota_reset_preservation_unknown_protocol_rejects_put_after_restart() {
let (_directory, store, incarnation, path, bytes) = reservation_fixture().await;
let mut quota = serde_json::json!({
"quota_type": "Hard",
"reservation_protocol": 2,
"reservation_protocol": 3,
"reservation_quota": 1024
});
match quota_shape {
+2 -1
View File
@@ -27,9 +27,10 @@ pub(crate) mod fixture {
pub(crate) use rustfs_ecstore::api::disk::endpoint::Endpoint;
pub(crate) use rustfs_ecstore::api::layout::{EndpointServerPools, Endpoints, PoolEndpoints};
pub(crate) use rustfs_ecstore::api::object::{PutObjReader, SelectObjectSnapshot};
pub(crate) use rustfs_ecstore::api::runtime::InstanceContext;
#[cfg(feature = "put-object-commit-barrier")]
pub(crate) use rustfs_ecstore::api::set_disk as ecstore_set_disk;
pub(crate) use rustfs_ecstore::api::storage::{ECStore, init_local_disks};
pub(crate) use rustfs_ecstore::api::storage::{ECStore, init_local_disks, init_local_disks_with_instance_ctx};
pub(crate) use rustfs_storage_api::{BucketOperations, BucketOptions, MakeBucketOptions, ObjectIO};
#[cfg(test)]
+108 -8
View File
@@ -35,8 +35,9 @@ use std::sync::{Arc, Once};
#[cfg(feature = "put-object-commit-barrier")]
use ecstore_test_compat::fixture::ecstore_set_disk;
use ecstore_test_compat::fixture::{
BucketOperations as _, BucketOptions, ECStore, Endpoint, EndpointServerPools, Endpoints, MakeBucketOptions, ObjectIO as _,
PoolEndpoints, PutObjReader, SelectObjectSnapshot, init_bucket_metadata_sys, init_local_disks,
BucketOperations as _, BucketOptions, ECStore, Endpoint, EndpointServerPools, Endpoints, InstanceContext, MakeBucketOptions,
ObjectIO as _, PoolEndpoints, PutObjReader, SelectObjectSnapshot, init_bucket_metadata_sys, init_local_disks,
init_local_disks_with_instance_ctx,
};
use tokio_util::sync::CancellationToken;
@@ -90,7 +91,7 @@ pub struct TestECStoreEnv {
pub temp_root: PathBuf,
/// The per-disk directories (`disk1`..`diskN`) under `temp_root`.
pub disk_paths: Vec<PathBuf>,
/// The store, bootstrapped exactly like the historical test setups:
/// By default, the store is bootstrapped exactly like the historical test setups:
/// `init_local_disks` + `ECStore::new` on `127.0.0.1:0` (random port keeps
/// nextest's process-per-test parallelism safe).
pub ecstore: Arc<ECStore>,
@@ -155,6 +156,7 @@ pub struct TestECStoreEnvBuilder {
prefix: String,
base_dir: Option<PathBuf>,
init_bucket_metadata: bool,
instance_ctx: Option<Arc<InstanceContext>>,
}
impl Default for TestECStoreEnvBuilder {
@@ -164,6 +166,7 @@ impl Default for TestECStoreEnvBuilder {
prefix: "rustfs_test_utils".to_string(),
base_dir: None,
init_bucket_metadata: true,
instance_ctx: None,
}
}
}
@@ -196,6 +199,12 @@ impl TestECStoreEnvBuilder {
self
}
/// Use a caller-owned context for local disks and store construction.
pub fn instance_ctx(mut self, instance_ctx: Arc<InstanceContext>) -> Self {
self.instance_ctx = Some(instance_ctx);
self
}
/// Build the environment. Panics on any bootstrap failure — this is test
/// scaffolding, and a broken environment must fail the test loudly.
pub async fn build(self) -> TestECStoreEnv {
@@ -237,14 +246,22 @@ impl TestECStoreEnvBuilder {
};
let endpoint_pools = EndpointServerPools::from(vec![pool_endpoints]);
init_local_disks(endpoint_pools.clone()).await.expect("init local disks");
// Port 0 keeps ECStore-backed integration binaries parallel-safe under
// nextest: no fixed peer port is ever shared between test processes.
let server_addr: std::net::SocketAddr = "127.0.0.1:0".parse().expect("parse test addr");
let ecstore = ECStore::new(server_addr, endpoint_pools.clone(), CancellationToken::new())
.await
.expect("build test ECStore");
let ecstore = if let Some(instance_ctx) = self.instance_ctx {
init_local_disks_with_instance_ctx(&instance_ctx, endpoint_pools.clone())
.await
.expect("init instance local disks");
ECStore::new_with_instance_ctx(server_addr, endpoint_pools.clone(), CancellationToken::new(), instance_ctx)
.await
.expect("build instance test ECStore")
} else {
init_local_disks(endpoint_pools.clone()).await.expect("init local disks");
ECStore::new(server_addr, endpoint_pools.clone(), CancellationToken::new())
.await
.expect("build test ECStore")
};
// The production bootstrap only persists pool.bin from the elected
// first cluster node. Test stores intentionally have no cluster
@@ -278,3 +295,86 @@ impl TestECStoreEnvBuilder {
}
}
}
#[cfg(test)]
mod context_tests {
use super::{InstanceContext, TestECStoreEnv};
use crate::ecstore_test_compat::fixture::{read_config, save_config};
use std::sync::Arc;
#[tokio::test]
async fn explicit_context_keeps_stores_isolated() {
let root_a = tempfile::tempdir().expect("create context A disk root");
let root_b = tempfile::tempdir().expect("create context B disk root");
let ctx_a = Arc::new(InstanceContext::new());
let ctx_b = Arc::new(InstanceContext::new());
let env_a = TestECStoreEnv::builder()
.base_dir(root_a.path())
.init_bucket_metadata(false)
.instance_ctx(ctx_a.clone())
.build()
.await;
let env_b = TestECStoreEnv::builder()
.base_dir(root_b.path())
.init_bucket_metadata(false)
.instance_ctx(ctx_b.clone())
.build()
.await;
assert!(env_a.ecstore.instance_endpoints().is_none());
assert!(env_b.ecstore.instance_endpoints().is_none());
ctx_a.set_endpoints(env_a.endpoint_pools.clone());
let endpoints_a = env_a
.ecstore
.instance_endpoints()
.expect("instance A sees its caller-owned context topology");
assert_eq!(
endpoints_a.0[0]
.endpoints
.into_ref()
.first()
.expect("context A first endpoint")
.to_string(),
env_a.disk_paths[0].to_string_lossy(),
);
assert!(
env_b.ecstore.instance_endpoints().is_none(),
"publishing context A must not publish context B"
);
ctx_b.set_endpoints(env_b.endpoint_pools.clone());
let endpoints_b = env_b
.ecstore
.instance_endpoints()
.expect("instance B sees its caller-owned context topology");
assert_eq!(
endpoints_b.0[0]
.endpoints
.into_ref()
.first()
.expect("context B first endpoint")
.to_string(),
env_b.disk_paths[0].to_string_lossy(),
);
assert_eq!(
env_a.ecstore.instance_endpoints().expect("context A remains published").0[0]
.endpoints
.into_ref()
.first()
.expect("context A retained endpoint")
.to_string(),
env_a.disk_paths[0].to_string_lossy(),
);
let path = "config/test-context-isolation.json";
let bytes_a = br#"{"context":"a"}"#.to_vec();
let bytes_b = br#"{"context":"b"}"#.to_vec();
save_config(env_a.ecstore.clone(), path, bytes_a.clone())
.await
.expect("write context A config");
save_config(env_b.ecstore.clone(), path, bytes_b.clone())
.await
.expect("write context B config");
assert_eq!(read_config(env_a.ecstore.clone(), path).await.expect("read context A config"), bytes_a);
assert_eq!(read_config(env_b.ecstore.clone(), path).await.expect("read context B config"), bytes_b);
}
}
@@ -55,11 +55,9 @@ pub const MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER: &str = "X-Minio-Internal
/// Reserved RustFS-branded twin of the MinIO-internal SSE key family.
///
/// No RustFS writer emits these keys today — the SSE writer persists the
/// MinIO-branded `X-Minio-Internal-Server-Side-Encryption-*` keys verbatim for
/// interoperability — but redaction (`rustfs_filemeta`) and replication
/// stripping treat the family as sensitive so that a future or third-party
/// writer cannot leak sealed material through the reserved names.
/// The SSE writer persists sealed material under the MinIO-branded names for
/// interoperability. Redaction and replication stripping also cover this twin
/// family, including the encrypted ETag format marker.
pub const RUSTFS_INTERNAL_ENCRYPTION_PREFIX: &str = "x-rustfs-internal-server-side-encryption-";
pub const REPLICATION_SSEC_ALGORITHM_HEADER: &str = "X-Rustfs-Replication-Ssec-Algorithm";
@@ -71,6 +69,15 @@ pub const REPLICATION_SSE_SEAL_ALGORITHM_HEADER: &str = "X-Rustfs-Replication-Se
pub const REPLICATION_SSE_SEALED_KEY_HEADER: &str = "X-Rustfs-Replication-Server-Side-Encryption-Sealed-Key";
pub const REPLICATION_ENCRYPTED_MULTIPART_HEADER: &str = "X-Rustfs-Replication-Encrypted-Multipart";
pub const SUFFIX_OPAQUE_ENCRYPTED_ETAG: &str = "server-side-encryption-opaque-etag";
/// Old encrypted objects can carry a plaintext MD5. Keyless listings expose
/// an encrypted ETag only when its producer recorded the protected format.
pub fn etag_can_be_listed(metadata: &HashMap<String, String>) -> bool {
!metadata.keys().any(|key| is_replication_stripped_encryption_key(key))
|| super::metadata_compat::get_consistent_str(metadata, SUFFIX_OPAQUE_ENCRYPTED_ETAG) == Some("v1")
}
/// Stored SSE-C metadata keys and the wire names they replicate under.
///
/// Source keys must match what `encryption_material_to_metadata` persists; the
@@ -307,6 +314,28 @@ pub fn stored_managed_encryption_key(metadata: &HashMap<String, String>) -> Opti
mod tests {
use super::*;
#[test]
fn keyless_listings_hide_unproven_encrypted_etags() {
assert!(etag_can_be_listed(&HashMap::from([("content-type".to_owned(), "text/plain".to_owned())])));
for key in [
SSEC_ALGORITHM_HEADER,
INTERNAL_ENCRYPTION_KEY_HEADER,
MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER,
MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER,
"X-AMZ-SERVER-SIDE-ENCRYPTION",
"x-rustfs-internal-server-side-encryption-sealed-key",
] {
let mut metadata = HashMap::from([(key.to_owned(), "encryption-metadata".to_owned())]);
assert!(!etag_can_be_listed(&metadata), "legacy encrypted metadata: {key}");
super::super::metadata_compat::insert_str(&mut metadata, SUFFIX_OPAQUE_ENCRYPTED_ETAG, "unknown".to_owned());
assert!(!etag_can_be_listed(&metadata), "unknown producer format: {key}");
super::super::metadata_compat::insert_str(&mut metadata, SUFFIX_OPAQUE_ENCRYPTED_ETAG, "v1".to_owned());
assert!(etag_can_be_listed(&metadata), "protected encrypted metadata: {key}");
metadata.insert("x-minio-internal-server-side-encryption-opaque-etag".to_owned(), "unknown".to_owned());
assert!(!etag_can_be_listed(&metadata), "conflicting format markers: {key}");
}
}
#[test]
fn ssec_transport_projection_retains_only_redacted_reader_headers() {
let mut headers = http::HeaderMap::new();
+4
View File
@@ -49,6 +49,10 @@ rustfs-rio.workspace = true
tokio = { workspace = true, features = ["io-util", "macros", "rt"] }
thiserror = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
# Keep XZ decoding self-contained in macOS binaries instead of linking Homebrew liblzma.
liblzma = { version = "0.4.8", features = ["static"] }
[dev-dependencies]
rustfs-tokio-tar = { workspace = true }
futures = { workspace = true }
+3
View File
@@ -21,6 +21,9 @@ For bounded local read-backend startup and cancellation, see
For legacy protection assessment and bounded protected copies, see [Object integrity inventory, audit, and migration](operations/shard-integrity-audit.md).
For encrypted listing compatibility and older stored fingerprints, see
[Encrypted object ETags](operations/encrypted-etags.md).
Operational runbooks live under [`operations/`](operations/). Replication
operators should start with:
+1
View File
@@ -32,6 +32,7 @@ Required headings and strings in these files are asserted by `scripts/check_arch
| Document | Use this when |
|---|---|
| [capture-checkpoint-contract.md](capture-checkpoint-contract.md) | changing Capture checkpoint capture, provenance, installation, recovery, or log cleanup |
| [local-descriptor-cache.md](local-descriptor-cache.md) | changing descriptor cache keys/open modes or invalidation after local mutations |
| [erasure-coding.md](erasure-coding.md) | changing anything under `crates/ecstore/src/erasure/`, `crates/filemeta/`, `crates/ecstore/src/set_disk/`, storage-class or layout code, or any decode, quorum, or heal boundary (normative spec) |
| [placement-repair-invariants.md](placement-repair-invariants.md) | changing anything that resolves an object to a pool, set, or disk, or that admits scanner or heal work |
@@ -0,0 +1,211 @@
**Use this when:** changing Capture checkpoint capture, provenance, installation, recovery, or log cleanup.
# Capture checkpoint contract
This document defines the requirements for a recoverable Capture checkpoint.
It is a design contract, not evidence that production capture, installation,
purge, quorum integration, or S3 behavior implements these requirements.
## Existing evidence and the missing boundary
In `crates/capture-authority/src/application.rs`, `apply_created` inserts one
canonical command and result on the first decision for an operation ID. An
identical command returns that stored result; a different command returns
`OperationConflict` without replacing the canonical record. The first result
remains authoritative even when later state would produce a different result.
`CreatedOperation` in `crates/capture-authority/src/created.rs` contains command
bytes and a result. Only `CreatedResult::Created` carries `decision_log_id`.
Negative results carry no first-decision ID. Heads, versions, events, and
operations therefore cannot reconstruct the source of every first decision.
For example, a stored `HeadMismatch` records an observed head, but does not
prove that the head existed at the original decision cut. Later retries do
not supply the missing historical evidence.
`validate_application` in `crates/capture-authority/src/application.rs` derives
first operation IDs from retained log/application records and, for native
schema 4, replays decisions from the beginning using in-memory maps and sets.
That intact-prefix validator is evidence for existing behavior; it is not a
checkpoint-baseline validator or a bounded recovery algorithm.
The `Row` comment and `StagingSink` in
`crates/capture-authority/src/snapshot.rs` reserve operation provenance for
caller validation and require isolated staging. Rows remain opaque to the
framing decoder. A framing digest detects byte corruption; it does not establish
semantic consistency, historical truth, or authority to install a checkpoint.
## Canonical operation provenance
Checkpoint-capable storage must use an explicitly versioned operation envelope
containing the full first-decision `LogId` and the existing canonical operation
bytes, including their stored result. Preserve those bytes and result; do not
recompute a negative result against checkpoint-time heads or binding state.
The envelope version is a storage capability boundary, distinct from the
snapshot framing version. The existing CSNP v1 framing and header must not be
silently extended. Header capability metadata requires a declared framing
revision or an authenticated, versioned manifest whose consistency with the
captured header is validated. Distinct storage and framing versions do not
permit silently changing the v1 format.
The first insertion must atomically record the envelope, application response,
applied position, and any successful head/version/event changes in the same
write transaction. Its first-decision ID must identify that insertion's log
entry. An identical-command retry returns the original result and leaves both
bytes and first-decision ID unchanged. A conflicting-command retry records its
own application response as `OperationConflict`; it cannot overwrite the
canonical envelope or claim to be the first decision.
```mermaid
flowchart TD
A[Committed command and operation ID] --> B{Canonical operation exists?}
B -->|No| C[Evaluate once at this log ID]
C --> D[Atomically persist first ID, original bytes and result]
B -->|Yes| E{Same canonical command bytes?}
E -->|Yes| F[Return original result; preserve first ID]
E -->|No| G[Return OperationConflict; preserve canonical record]
```
## Compatibility and promotion
The existing schema 3 to schema 4 migration remains unchanged. Adding provenance
requires a separately declared storage capability with explicit readers, writers,
upgrade, and downgrade rules. A schema 4 label alone must not imply provenance
support. Unknown envelope versions and stores missing required provenance must
be rejected by checkpoint installation and purge authorization.
An old schema 4 store may backfill first IDs only from its complete, intact,
validated prefix: replay and validate the original command/results, identify the
first canonical insertion for each operation, then atomically persist the
capability and envelopes. A retry's ID or a successful event's ID cannot stand
in for missing negative provenance. If the prefix has already been removed or
cannot be validated, refuse promotion and cleanup; do not invent IDs. Readers
without the new capability must refuse the upgraded format rather than silently
interpret it as the old operation encoding. Retaining all history forever is
not the checkpoint design: validated promotion supplies a recoverable baseline
that permits bounded suffix retention.
## Capture cut and resource ownership
Capture must read the header, heads, versions, events, and operations through
one owned read transaction. The view must retain the owning store/database
handle for the entire transaction, cursor, and output lifetime; an owned
transaction type alone does not prove database lifetime safety. Keep the store
`Arc` alive so dropping another handle cannot invalidate the view. These are
ownership requirements, not a claim about unverified library behavior.
The header must describe that same committed, applied cut with its full `LogId`,
full effective membership including its decision position and node metadata,
cluster identity, binding identity/revision, and supported storage/envelope
capabilities. Membership and binding must come from the captured transaction,
not from a later live read. Never capture beyond the durable committed position.
A four-table view without this cut and metadata is not independently installable.
Emit rows incrementally in deterministic section/key order. Bound row size,
section and total bytes, counts, temporary disk consumption, and concurrent
capture admission. Use checked arithmetic. Do not collect the whole snapshot
in RAM. Cancellation, disk exhaustion, or limit failure must leave the live
store unchanged and release task-owned staging resources.
## Isolated validation and trust
Decode only into isolated disk staging. Check framing, limits, section order,
unique keys, complete header metadata, capability versions, cluster/binding
compatibility, canonical command/result encoding, envelope IDs at or before the
cut, and all head/version/event/operation relationships. A successful operation
must agree with its version, event, and first decision. Negative operations must
have no success event or side effect attributable to that decision.
These checks still cannot prove the historical predicate behind a negative
result. Neither a digest nor a first ID proves that a `HeadMismatch`,
`BindingMismatch`, or `VersionConflict` was justified when first decided.
Remote installation therefore requires an explicitly authenticated and
authorized Raft authority for the claimed cluster, cut, and membership, or a
complete historical proof validated from a trusted baseline. Reject input that
has neither. Authentication and authorization must precede publication and
purge; a caller's assertion of trust is insufficient. This document does not
claim that this remote Raft authentication boundary is implemented.
Replay validation must use disk-backed staging/indexes and bounded buffers,
including duplicate-operation and version/head tracking. Putting the input on
disk while retaining replay maps proportional to history does not meet this
requirement. The existing `validate_application` RAM replay cannot simply be
reused to claim bounded checkpoint installation.
## Atomic publication and recovery
After isolated validation, publication must have one durable atomic point that
selects the complete checkpoint generation, its application state, cut,
membership, binding, capability, and recovery metadata. A pointer switch is
acceptable only when all referenced bytes are already durable and recovery
cannot observe a partial generation. Serialize publication with application so
no committed suffix write is lost between validation and the switch.
Preserve local node identity and persisted vote/hard state. Remote checkpoint
metadata cannot overwrite either. Validate cluster identity, binding, full cut
identity, and membership before replacing state. Reject rollback behind the
local durable application/checkpoint floor; an equal index with a different
term/leader identity is a conflict, not an equivalent cut. Retain valid local
suffix and commit state consistently with the installed baseline, rather than
resetting them from remote metadata.
On reopen, select only a fully published generation and verify its recoverable
baseline before replay. The suffix starts exactly at cut.index + 1, with checked
index arithmetic; the complete cut ID must agree wherever the boundary is
retained. Validate contiguous suffix indices, entry identities, committed and
applied bounds, typed application responses, canonical retry behavior, and
membership transitions relative to checkpoint state. Reject gaps, conflicting
boundary IDs, stale membership, or a suffix belonging to another cluster or
binding. Recovery must begin from checkpoint heads and canonical operations,
not from empty state or the assumption that the retained log starts at zero.
Purge is authorized only after the published checkpoint can be reopened and
validated as a recovery baseline. Delete only entries/results covered by that
exact cut, retaining everything needed for the continuous suffix and local
Raft durability. Re-check the selected generation and cut immediately before
cleanup. Persist a recoverable purge boundary so interrupted cleanup is
idempotent. Never delete the only evidence before provenance promotion or
checkpoint durability succeeds.
```mermaid
flowchart TD
A[One owned transaction and store lifetime] --> B[Stream captured cut to isolated disk staging]
B --> C[Validate framing, semantics, provenance and authorized source]
C --> D[Make complete generation durable]
D --> E[Single atomic publication point]
E --> F[Reopen baseline and validate continuous suffix]
F --> G[Authorize purge only through that exact cut]
G --> H[Idempotent cleanup; preserve recoverable baseline and suffix]
B -->|Failure| I[Discard staging; live generation remains usable]
C -->|Failure| I
D -->|Before publication crash| I
```
A crash before publication leaves the previous generation recoverable. A crash
after publication must recover the new complete baseline, including when purge
has not begun or is only partly complete. Orphan staging is never selected as
live state. Old generations may be reclaimed only after no active view needs
them and the selected generation remains recoverable.
## Behavioral verification requirements
The following are required behavior checks, not reports of tests run or gates
passed. Existing symbols above anchor the semantics; future checkpoint paths
must demonstrate the additional requirements directly.
| Behavior | Required observation |
|---|---|
| First negative result | Original command/result and first full ID survive capture, install, and reopen; later state does not re-evaluate it. |
| Identical and conflicting retries | `apply_created` semantics hold across the checkpoint boundary: original result/ID retained; conflict cannot overwrite the canonical record. |
| Old schema 4 promotion | An intact prefix validates and supplies first IDs; missing/corrupt prefix refuses promotion and purge; schema 3 to 4 behavior stays unchanged. |
| Snapshot cut under concurrent writes | Header, membership, binding, and all rows describe one transaction cut; later writes remain in the suffix. |
| View lifetime | Dropping other store handles during row emission cannot invalidate the retained transaction or database. |
| Framing versus authority | Valid digest with forged negative history is insufficient; unauthenticated or unauthorized remote input is rejected. |
| Cross-record corruption | Altered command, first ID, event, version, head, membership, or binding fails isolated validation before publication. |
| Recovery boundary | Exact cut plus continuous suffix recovers; gaps, conflicting full IDs, cross-cluster input, and rollback are rejected. |
| Local Raft state | Installation preserves local identity, vote/hard state, and compatible durable suffix/commit information. |
| Resource exhaustion | Oversize rows, arithmetic overflow, disk exhaustion, cancellation, and large histories fail safely within declared memory/disk bounds. |
| Crash publication | Crashes before and after the atomic point select a complete old or new generation, never a mixture. |
| Interrupted purge | Reopen succeeds before cleanup is authorized; each interruption resumes without deleting the recoverable baseline or required suffix. |
Documentation path and whitespace guards only validate document hygiene. They
cannot establish these production behaviors or the remote trust boundary.
@@ -33,7 +33,7 @@ The guard pins the production files allowed to reference `rustfs_ecstore::api::g
- `rustfs/src/storage/storage_api.rs`
That boundary keeps only bootstrap writes and lifecycle controls (`set_global_endpoints`, `set_global_region`, `set_global_rustfs_port`, `set_object_store_resolver`, `shutdown_background_services`, `update_erasure_type`). Read-only runtime getters are exported through `rustfs_ecstore::api::runtime` and consumed through the local storage facade. A new direct use either moves behind an existing owner-local boundary or updates this plan and the guard in the same reviewed change.
That boundary keeps only bootstrap writes and lifecycle controls (`set_global_endpoints`, `set_global_region`, `set_global_rustfs_port`, `set_object_store_resolver`, `shutdown_background_services`, `update_erasure_type`, `wait_for_detached_mutations`). Read-only runtime getters are exported through `rustfs_ecstore::api::runtime` and consumed through the local storage facade. A new direct use either moves behind an existing owner-local boundary or updates this plan and the guard in the same reviewed change.
## Fallback Removal Plan
+1 -1
View File
@@ -27,7 +27,7 @@ Runtime ownership handles that exist today. Reads go through `rustfs_ecstore::ap
| `global_tier_config_mgr` | Tier config manager | Reads and reloads stay behind this helper. |
| `bucket_monitor` | Replication bandwidth monitor | Replication pool/stat handles are projected into RustFS wrapper types at the storage boundary. |
| `global_lock_client`, `global_lock_clients` | `GLOBAL_LOCAL_LOCK_CLIENT`, `GLOBAL_LOCK_CLIENTS` (`crates/ecstore/src/runtime/global.rs`) | Preserve lock quorum and client selection; the process-level `GLOBAL_LOCK_MANAGER` stays separate. |
| `boot_time`, `deployment_id`, `region`, `rustfs_port` | `GLOBAL_BOOT_TIME`, deployment id, region, and port state (`crates/ecstore/src/runtime/global.rs`) | Scalar writes remain behind the `api::global` setters (`set_global_endpoints`, `set_global_region`, `set_global_rustfs_port`, `set_object_store_resolver`, `shutdown_background_services`, `update_erasure_type`). |
| `boot_time`, `deployment_id`, `region`, `rustfs_port` | `GLOBAL_BOOT_TIME`, deployment id, region, and port state (`crates/ecstore/src/runtime/global.rs`) | Scalar writes remain behind the `api::global` setters (`set_global_endpoints`, `set_global_region`, `set_global_rustfs_port`, `set_object_store_resolver`, `shutdown_background_services`, `update_erasure_type`, `wait_for_detached_mutations`). |
Owner-helper handles outside the runtime-source list stay inside their owner and are reached through owner functions: `GLOBAL_EVENT_NOTIFIER` (`crates/ecstore/src/runtime/global.rs`); `GLOBAL_NOTIFICATION_SYS`, `EVENT_DISPATCH_HOOK`, `GLOBAL_PROCESSORS`, `INTERNODE_DATA_TRANSPORT`, `GLOBAL_BUCKET_TARGET_SYS`, `GLOBAL_CONFIG_SYS`, `GLOBAL_STORAGE_CLASS`, `WORKLOAD_ADMISSION_SNAPSHOT_PROVIDER` (ECStore owner modules); `GLOBAL_SERVER_CONFIG` (`crates/config/src/server_config.rs`); `GLOBAL_HEAL_RUNTIME`, `GLOBAL_AHM_SERVICES_CANCEL_TOKEN` (`crates/heal/src/lib.rs`); `GLOBAL_KMS_SERVICE_MANAGER` (`crates/kms/src/service_manager.rs`); `GLOBAL_CAPACITY_MANAGER` (`crates/object-capacity/src/capacity_manager.rs`); `APP_CONTEXT_SINGLETON` (`rustfs/src/app/context/global.rs`).

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