Compare commits

...

80 Commits

Author SHA1 Message Date
overtrue 3f994c59eb fix(select): classify function argument planner errors 2026-08-11 23:14:23 +08:00
GatewayJ 7c1d9dec8f Merge branch 'main' into fix/s3-select-error-semantics 2026-08-11 14:13:53 +08:00
Zhengchao An 31cb720471 fix: exclude e2e_test from s3s footprint ratchet baseline (#5949) 2026-08-11 05:48:27 +00:00
唐小鸭 603bdea516 fix(site-replication): route state RMW through one locked transaction (#5882)
* test(site-replication): pin retry-event lost-update against locked RMW (red)

P1-15 (rustfs/backlog#1675 B2): the site-replication retry-event writers
(enqueue/dequeue, which hang off every hook broadcast path) perform a
load -> mutate -> persist without taking SITE_REPLICATION_STATE_LOCK, so
a single process can lose a concurrent lock-holding writer's update; the
service-side reload path is equally unlocked, and no writer holds a
distributed lock across the read-modify-write, so multi-node RMW loses
updates even where the process lock is held.

Red evidence (current main): replaying enqueue's exact three steps around
a completed mark_pending_rotation_peer_acked commit wipes the rotation
ack — the final state holds the retry event but not the ack.

* fix(site-replication): route state RMW through one locked transaction

P1-15 PR1 (rustfs/backlog#1675 B2). The site-replication state object
(config/site-replication/state.json, which also carries the retry-event
queue) was mutated through read-modify-write sequences with inconsistent
locking: the retry-event writers on every hook broadcast path and the
RPC-driven service reload took no lock at all (single-process lost
updates, pinned by the red commit), and no writer held a distributed lock
across the whole RMW (cross-node lost updates everywhere).

- New admin/site_replication_state module: the state transaction boundary
  `with_site_replication_state_lock[_on]` — process mutex plus the
  distributed config-object write lock (the pattern proven by the repair
  state), with the shared path constant. The process mutex is transitional
  until PR2 migrates the remaining ~26 call sites.
- handlers: typed `update_site_replication_state` (no-lock load /
  persist-or-clear inside the boundary; normalizes the peer map exactly
  once, retiring the double-clone/double-normalize persist path, P2-22).
  Migrated: retry-event enqueue (always-write), dequeue (lock-free probe,
  transaction on hit), mark_pending_rotation/remove_peer_acked.
- service reload: the tolerant byte-level read->normalize->save now runs
  inside the same boundary via no-lock IO — a cluster-wide reload fan-out
  can no longer overwrite a concurrent state writer. Normalization
  semantics untouched (all six service-side tests unchanged and green).
- Add/PeerJoin/Edit handlers release the state guard before their peer
  fan-out: the transport helpers' retry-event bookkeeping now re-enters
  the state transaction and must not nest inside the guard (the
  adversarial review caught this as a re-entrancy deadlock; the fix
  mirrors the Remove/Rotate handlers' existing scope). The Edit non-
  refresh branch commits before fanning out — the old fanout-first order
  recorded retry events pointing at a state the local site had not saved.
- ecstore: delete_config_no_lock (+ facade/bridge exports) so the clear
  half of persist-or-clear works under the held object lock.

Red -> green: the red commit pinned the deterministic lost-update
interleaving (stale retry-event persist wiping a committed rotation ack);
the test now drives the real functions concurrently for 8 rounds and
asserts every retry event and every ack survives. Full
handlers/service site-replication unit suites green (171 + 6); dual-node
site-replication e2e (state edit fresh/stale, object replication) green;
fmt / clippy / logging guardrails clean.

Adversarial review: one blocking finding (the re-entrancy deadlock above)
fixed and re-verified by a full second pass over all 30 lock sites and
the Add/Join/Edit call graphs. Non-blocking notes recorded for PR2:
mark_* now persists on miss (persist-or-clear semantics; a miss-skip
return is a cheap follow-up), Add still holds the guard across the peer
join probe (pre-existing availability debt), and a timeout-guarded
unreachable-peer regression test for the fan-out paths.

* fix(site-replication): keep the state mutex behind an owner helper

CI's architecture migration guard lists SITE_REPLICATION_STATE_LOCK as an
owner-local static, so it may not be `pub(crate)`. Keep it private to the
new module and let the not-yet-migrated RMW call sites take it through
`site_replication_state_process_guard()` — the sanctioned owner-helper
pattern; the helper disappears with the mutex in PR2.

* fix(site-replication): keep peer-edit delivery under the state guard

Review follow-up (#5882).

Releasing the guard before the fan-out (my deadlock fix) traded the
ordering the guard used to provide: edit A could commit and stall while
edit B committed and reached a peer first, then A arrived last and won.
The peer edit handler applies whatever arrives — it has no generation or
updated-at fence — and a successful stale delivery is not repaired by the
retry queue, so the sites diverge silently.

The fan-out is back under the guard. What actually could not run there is
the retry-event bookkeeping, which re-enters the state transaction, so the
edit branch now delivers with the plain transport and settles the retry
queue after the guard is released: successes dequeue, the first failure
enqueues and is returned. Ordering and bookkeeping both preserved. The add
handler keeps its peer-edit finalize fan-out under the guard for the same
reason and releases only before bootstrap/back-fill, which send bucket-ops
(not peer edits) through retry-event transports.

The concurrency test could not tell the two guards apart — both writers
took both locks, so it passed with either removed. Replaced by two tests
that isolate one guard each, both verified by mutation:

- a process-only legacy writer (the shape the not-yet-migrated call sites
  still use) racing the transaction: fails when the transaction stops
  taking the process mutex;
- two writers that bypass the process mutex, as separate nodes do, driving
  the production object-lock path (`with_site_replication_state_object_lock`
  factored out for exactly this): fails when the distributed lock is
  removed.

Verification: handlers 173 + service 6 unit tests green; site-replication
dual-node and three-node edit e2e green; arch/layer/logging guardrails,
fmt and clippy clean.

* fix(site-replication): fence peer-edit delivery by generation

Review follow-up on the two remaining holes in the edit path.

Ordering was only process-local. `SITE_REPLICATION_STATE_LOCK` is per
node, so holding it across the fan-out orders the edits ONE node accepts
and nothing else: two nodes of the same site can both commit and reach a
peer in the opposite order, and the peer edit handler applied whatever
arrived last. Each edit now takes a generation from
`SiteReplicationState::edit_generation`, allocated in the same commit as
the edit itself — i.e. under the distributed state-object lock, so two
nodes can never share one. The generation rides the peer-edit request as
query parameters and the receiver rejects (acks without applying) a
delivery at or below the mark it already applied for that origin site,
recording the mark in the same commit as the edit it fences. Peers that
predate the fence send no parameters and are applied as before.

Retry settlement could discard a newer failure. After the guard is
released, a success for edit A removed every retry event for
(peer, peer-edit): if edit B committed, failed its own delivery and
enqueued while A was in flight, A erased it — local state B, peer on A,
nothing queued to converge them. Settlement now only removes events whose
recorded generation is not newer than the one being settled, and a later
failure never lowers the fence. Broadcast paths carry no generation and
settle unconditionally as before; their events live under their own
paths and cannot collide with a peer-edit delivery.

A departed peer's mark is dropped on load: a site that leaves drops below
two peers, which clears its state object and restarts its counter at
zero, so a leftover mark would reject every edit it sends after it
rejoins.

Tests: two-node generation uniqueness (drop the object lock and the two
nodes collide), the receiver's staleness predicate and its wiring, the
settlement interleaving (drop the fence and B's retry is erased), and the
rejoin reset.

Refs: rustfs/backlog#1675 (P1-15)
2026-08-11 13:41:28 +08:00
唐小鸭 a076ae4045 test(replication): pin the scanner existing-object compensation matrix (#5877)
P1-20 (rustfs/backlog#1675 B2, test-only). No prior test wrote objects
BEFORE the replication rule arrived, leaving the scanner's existing-object
resync pass — the only channel for such objects — without end-to-end
coverage, and the enqueue truth table partially unpinned at unit level.

e2e (both negative cells are contracts, asserted over multiple fast-scanner
cycles next to a replicated control key that proves the scanner and the
live path are running):
- test_scanner_compensates_existing_objects_across_write_paths: plain PUT,
  CopyObject and Snowball auto-extract products written pre-rule all
  converge via scanner compensation; a null-version object (PUT before the
  bucket became versioned) is pinned as never compensated (the scanner heal
  gate skips nil-version objects).
- test_scanner_never_compensates_when_existing_object_replication_disabled:
  ExistingObjectReplication=Disabled is a contract, not a delay — existing
  keys stay absent while post-rule writes replicate normally.

Unit truth-table pins (crates/replication):
- queue.rs: an empty replicate decision (Disabled existing-object, inbound
  REPLICA) skips heal queueing for every status; Completed without a resync
  decision skips.
- operation.rs: existing-object resync without a reset replicates exactly
  the never-replicated (Empty) objects.

Helper: put_bucket_replication_with_statuses parameterizes the previously
hardcoded ExistingObjectReplication status; the nextest count comments are
refreshed to the post-rebase totals.
2026-08-11 03:58:25 +00:00
houseme 8a8be12f0b perf(ecstore): raise replay cache auto capacity (#5946)
Increase the replay cache resource model so 16 CPU / 31-32 GiB field nodes auto-size to the 32M cap without an env override.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-11 11:37:58 +08:00
唐小鸭 2ecf6b4575 fix(replication): probe the version-identity contract in replication-check (#5881)
* test(replication): pin the version-fidelity probe contract (red)

P1-19 (rustfs/backlog#1675 B2): the supported replication contract is
targets that adopt the source version id — a target that mints its own ids
silently breaks every version-addressed operation that follows (version
deletes, heal re-drives never match), diverging the two sides with no
signal. replication-check already captures the probe PUT's response version
id but never compares it.

Red evidence (current main): against a FakeS3Target with
assign_own_version_ids enabled, ?replication-check returns Status "OK" —
the drift is invisible.

test_replication_check_flags_version_minting_target expects a
VersionFidelity phase that fails with the machine-readable code
BucketRemoteTargetVersionMismatch, skips the later mutation phases, and
still cleans up the probe via the version id the target actually assigned.

Test infra: FakeS3Target gains assign_own_version_ids (models a generic S3
service; validated-but-not-mirrored source version headers) and a
prefix+max-keys ListObjectVersions implementation (the probe key allocation
requires it); stored_versions accessor duplicated from the P1-21 branch
(identical code, resolves clean on merge).

* fix(replication): probe the version-identity contract in replication-check

P1-19 (rustfs/backlog#1675 B2, plan B). Replication only converges on
targets that adopt the source version id: version-addressed deletes and
heal re-drives address the source id, so a target that mints its own ids
silently diverges — nothing surfaced this. replication-check already
captured the probe PUT's response version id but never compared it.

- The probe PUT now carries the source version as `?versionId=` (the exact
  shape live replication uses since P0-5, and the only shape MinIO
  consumes; the internal source-version-id header alone would let the
  probe pass against targets the real data path drifts on). Reuses
  ecstore's append_version_id_query through the api facade.
- New VersionFidelity phase: the probe PUT's response version id must
  equal the sent source id. On mismatch the phase fails with the
  machine-readable extension key `"Code": "BucketRemoteTargetVersionMismatch"`
  (new optional Code field on phase statuses; Go decoders ignore unknown
  keys), the overall target fails, the later version-addressed mutation
  phases are skipped, and cleanup still removes the probe via the id the
  target actually assigned (with the existing list-based sweep as backstop
  when the target returns no version id at all).
- Runtime half: TargetClient::put_object now returns the assigned version
  id (mirroring remove_object), and the replication PUT path audits it —
  every drifting PUT increments
  rustfs_replication_version_identity_drift_total and the first drift per
  target ARN logs a structured warning pointing at ?replication-check.
  The drift judgment is a pure function with an exemption-matrix test
  (empty / literal "null" / nil-uuid sources carry no contract).
- docs/operations/replication-check.md documents the phase and the code.

Red -> green: test_replication_check_flags_version_minting_target (fake
target with assign_own_version_ids; on main the check reported Status
"OK"). The probe's query shape is pinned by a journal assertion (revert
of the query hunk alone fails it), probe-level unit tests cover the
mismatch/mirror matrix including cleanup addressing the minted id, and
the existing success e2e now asserts VersionFidelity OK against a RustFS
target. Adversarial review (seven roles): non-blocking; noted follow-ups
are the multipart runtime audit (the probe phase already pins the
contract) and per-target re-warning after reconfiguration.

* fix(e2e): stop the fake target self-deadlocking on version-id minting

The assign_own_version_ids flag was read with a fresh `lock(&self.store)`
inside two paths that already hold that guard — delete_object's
marker-creation branch and create_multipart_upload — and the store mutex
is not reentrant, so both hung forever (CI: the fake target's own
multipart and delete-marker tests ran >1560s until the job was
cancelled). Read the flag from the live guard instead.

The replication e2e paths did not catch this: a version-addressed purge
DELETE never mints an id, and the probe PUT reads the flag before taking
the guard.

* chore(test): refresh the nextest replication count invariant

The e2e-smoke/e2e-repl-nightly split comment is descriptive metadata
(authority: `cargo nextest list`); refresh it to this branch's
post-rebase total.
2026-08-11 03:04:05 +00:00
cxymds 2aa0148454 fix: report stalled object traffic as unready (#5936)
* fix: report stalled object traffic as unready

* fix: track fully received PUT storage progress
2026-08-11 10:55:22 +08:00
Xiaoyang Han 3289d40ce9 fix(ecstore): publish multipart parts on Windows (#5937)
* fix(ecstore): publish multipart parts on Windows

* test(ecstore): pin Windows multipart durability

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-08-11 10:01:03 +08:00
cxymds 849837e262 fix(rpc): negotiate replay-safe mutation authentication (#5928)
* fix(rpc): negotiate replay-safe mutation auth

* fix(rpc): preserve strict legacy replay scope
2026-08-11 01:03:25 +00:00
Henry Guo 727a10e111 fix(scanner): skip disk inventory in scan spans (#5933)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-08-11 09:01:51 +08:00
GatewayJ 4a8759239d fix(select): enforce typed S3 Select error semantics 2026-08-11 02:26:58 +08:00
houseme 3747d19ce5 perf(ecstore): hedge bounded GET metadata fanout (#5935)
Keep opt-in bounded GET data-read fanout from waiting on a single pending ReadVersion response when an unscheduled spare disk can satisfy quorum. Add a deterministic 2+2 regression that pauses the third scheduled metadata read and verifies the spare is started before returning.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-08-10 17:11:04 +00:00
houseme 1148e76279 test(ecstore): update prepared GET fanout default (#5932)
Assert the prepared GET metadata path keeps the default full data-read fanout after PR #5929 made bounded data-read fanout opt-in.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-10 15:38:37 +00:00
唐小鸭 320b788a50 test(admin): relax object-lambda SNI test timeout under full-suite load (#5923)
The SNI preservation test is the only object-lambda test doing a real
TLS handshake; the shared helper's 2s whole-request timeout turns
concurrent fsync-heavy TestECStoreEnv neighbors into a deterministic
TimedOut when the per-build nextest schedule overlaps them. The test
verifies SNI, not latency, so widen its budget to a still-bounded 30s.
2026-08-10 22:20:34 +08:00
唐小鸭 3c31eaf06f fix(replication): retry, persist and replay failed delete-marker purges (#5864)
* test(replication): pin delayed delete-marker purge failure handling (red)

P1-21 (rustfs/backlog#1675 B2): two failing e2e tests that pin the missing
failure handling of the delayed delete-marker purge:

- test_delayed_delete_marker_purge_retries_after_transient_target_failure:
  four scripted 503s outlast every existing channel (version-purge
  replication + its in-process MRF fast retries + the watcher's single
  attempt = 3 target DELETEs, all faulted in the recorded run); the
  replicated marker is stranded on the target forever.
- test_delayed_delete_marker_purge_exhaustion_persists_to_mrf_and_replays_on_restart:
  exhausted purge intents never reach the durable MRF journal, so a restart
  replays nothing (recorded run: 3 faulted attempts, zero post-restart).

Red-light evidence (current main):
- Test A: FAILED, journal shows 3x DeleteObject fault=Status(503), no clean
  attempt, target marker still present after 15s.
- Test B: FAILED after 468s, same 3 faulted attempts, no purge DELETE after
  restart, marker still present.

Test infra: FakeS3Target::stored_versions() exposes per-key version state so
purge tests assert target state instead of inferring it from the journal;
nextest count comments 36->38 nightly / 56->58 total.

* fix(replication): retry, persist and replay failed delete-marker purges

P1-21 (rustfs/backlog#1675 B2). The delayed delete-marker purge was
fire-and-forget: the target DELETE discarded its result (`let _ =`), a
missing target client was silently skipped, and nothing recorded the intent
— one transient target error stranded the replicated marker on the target
forever. Separately, `replicate_delete_with_outcome` held its outcome
hostage to `!requires_delayed_purge`, pinning every delete-marker MRF entry
to Missed so the durable backlog retained them permanently.

Changes:
- `replicate_delete_marker_purge_to_targets` now reports per-target
  results (warn + metrics on failure, including `target_client_missing`),
  supports retrying only the failed targets, and treats a target-side
  NoSuchKey/NoSuchVersion as purge success (strict-404 targets must not
  retain the intent forever).
- The delayed watcher (`watch_and_purge_source_delete_marker`) retries
  failed targets across its 5x1s watch window; on exhaustion it persists
  the purge intent to the durable MRF journal via the new
  `ReplicationPoolTrait::persist_mrf_entry` (journal-only on purpose: live
  re-dispatch would loop unboundedly against a down target). Intent entries
  are shaped as marker-creation deletes so replay funnels into the stale-
  marker branch.
- The stale-marker branch (source marker already gone) now purges the
  targets instead of silently returning success — closing a latent leak —
  and reports the purge result as the replay outcome. Heal callers retry
  for the full window (the startup MRF processor runs before target
  clients initialize); live callers attempt once and fall back to a fresh
  durable intent, so a down target cannot pin a replication worker.
- The outcome formula (extracted as `replicate_delete_outcome` and pinned
  by a unit test) no longer includes the delayed purge, so successfully
  replayed delete-marker entries are acknowledged instead of retained
  forever.

Verification: red -> green e2e pair (transient-failure retry; exhaustion ->
durable MRF -> restart replay -> second-restart zero-replay ack) plus unit
tests; `make pre-commit`, logging guardrails, clippy (ecstore + e2e_test)
all clean; full ecstore lib suite 3729 passed (3 pre-existing local-DNS
kubernetes endpoint failures reproduce without this change).

Adversarial validation (7 roles): no blocking findings after adding the
outcome-formula guard test. Known residuals recorded in the PR: watcher
shutdown window (intent not yet persisted), rolling-downgrade replay acks
without purging (equals pre-fix behavior), and replay falling back to the
source version id on targets that mint their own version ids (P1-19).

* chore(test): refresh the nextest replication count invariant

The e2e-smoke/e2e-repl-nightly split comment is descriptive metadata
(authority: `cargo nextest list`); refresh it to this branch's
post-rebase total.

* fix(replication): purge the marker version the target actually assigned

Review follow-up (#5864), two real defects:

- The delayed purge watcher was spawned with the pre-merge `dobj`, so the
  per-target marker version ids this round recorded were invisible to it.
  Against a target that mints its own ids the purge fell back to a
  source-derived id, the target answered the versioned DELETE with an
  idempotent 204, and that "success" cleared the retry set while the real
  marker stayed behind. The watcher now receives the merged replication
  state (`drs`), which folds this round's target-assigned ids in.
- A target whose recorded version metadata is inconsistent was skipped
  without entering `failed_arns`, so an empty result made both the watcher
  and the MRF replay treat a purge that issued no DELETE as successful and
  drop the intent. The refusal is now a per-target failure (own metric
  label): the leak stays visible and the intent is retained instead of
  being acknowledged. The version decision also moved ahead of the client
  lookup, so the refusal is decided from metadata alone.

Tests: a new e2e drives a fake target with `assign_own_version_ids`, which
ignores the forwarded source-version header for both objects and delete
markers, and asserts the replicated marker is really gone; a unit test
pins the corrupt-metadata refusal as a failed outcome without any target
client registered. The detached-watcher shutdown window is documented at
the watcher as a known non-durable window with the write-ahead follow-up
spelled out.
2026-08-10 22:16:21 +08:00
houseme fe2516ee86 perf(ecstore): keep bounded GET fanout opt-in (#5929)
Keep GET data-read metadata early-stop and bounded fanout behind explicit environment switches so the default path preserves full fanout read-failure tolerance.

Retain the focused opt-in A/B coverage and the invalid parity full-fanout guard for heterogeneous set layouts.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-10 22:15:34 +08:00
cxymds 7ca69eb39c fix: correct SNSD cluster diagnostics (#5930) 2026-08-10 20:32:01 +08:00
hector 95627cb601 fix: create config file before fpm RPM packaging (#5924)
The RPM build step fails because fpm's --config-files flag requires
/etc/default/rustfs to exist in the staging area, but unlike the DEB
build (which creates it in its package directory structure), the fpm
command has no prior step creating this file.

Create the config file in a temporary directory and pass it to fpm
via a source=dest mapping, matching the DEB build's behavior.
2026-08-10 08:15:15 +00:00
houseme d97e059c3c fix(iam): merge OIDC extra root CAs (#5915)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-10 15:24:52 +08:00
houseme d900e11a09 perf(ecstore): expose replay cache RPC sources (#5926)
Track accepted replay cache records by gRPC operation and split Lock/Unlock and ReadVersion methods out of grpc_other so hotpath validation can attribute nonce pressure without changing replay protection semantics.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-10 15:13:27 +08:00
houseme f1ff9a36bc test(heal): cover replacement terminal recovery (#5920)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-10 14:51:26 +08:00
houseme 276eea1fba test(heal): cover replacement target evidence failures (#5919)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-10 14:50:55 +08:00
houseme 88e285c523 perf(ecstore): gate bounded GET metadata fanout (#5917)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-10 05:20:21 +00:00
houseme 785ee719e7 feat(heal): aggregate replacement recovery status (#5916)
Add a replacement recovery peer RPC so Admin v4 can distinguish definitive cluster proofs from unsupported, unavailable, or conflicting peer state without extending the existing background heal v3/v1 status protocol.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-10 05:03:27 +00:00
Zhengchao An a8c15e90ec docs(agents): tighten production code growth rules (#5907) 2026-08-10 11:12:52 +08:00
hector 63b564d064 fix: prevent tilde expansion in DEB version substitution (#5913)
The DEB version substitution used ${VERSION/-/~} which caused bash
to expand ~ to $HOME (e.g. /home/runner), producing an invalid
version string like '1.0.0/home/runnerrc.1'.

Store ~ in a variable first to prevent tilde expansion.
2026-08-10 11:11:49 +08:00
GatewayJ d51191f81b build(deps): use RustFS s3s fork (#5901)
* build(deps): use RustFS s3s fork

* ci: allow RustFS s3s source

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-08-10 02:45:49 +00:00
houseme 1aeb84dd6b feat(heal): expose replacement recovery status (#5912)
Add a v4 admin status endpoint for local durable automatic replacement recovery records without changing the v3 background heal status or peer v1 payloads.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-10 01:48:18 +00:00
houseme f17ea7f146 fix(heal): harden replacement rebuild tracking (#5892)
* fix(heal): gate auto replacement formatting

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

* fix(heal): require replacement target outcomes

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

* fix(heal): bind resumes to replacement targets

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

* fix(heal): fence healing marker ownership

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

* test(heal): cover replacement target completion

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

* docs(heal): clarify replacement recovery status

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

* fix(heal): canonicalize replacement target checks

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

* fix(heal): satisfy marker test module lint

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

* fix(heal): scope automatic replacement format

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

* fix(heal): require a mounted replacement target

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

* fix(ecstore): avoid cloned ref slice in test

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

* fix(heal): revalidate replacement before scanning

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

* fix(heal): reset stale resume checkpoints

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

* fix(heal): release scanner disk map before probing

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

* fix(heal): persist replacement intent before format

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

* fix(heal): fail closed on mountinfo read errors

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

* fix(heal): fence replacement target identity

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

* fix(heal): order replacement completion cleanup

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

* fix(heal): atomically seal replacement completion

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

* test(heal): census replacement target shards

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

* fix(heal): fence replacement recovery ownership

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

* fix(heal): preserve replacement recovery anchors

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

* fix(heal): satisfy replacement recovery lint gates

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

* fix(ecstore): bind replacement identity to mount lease

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

* test(heal): cover durable replacement recovery states

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

* fix(heal): validate persisted resume task identifiers

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

* fix(ecstore): avoid blocking replacement marker CAS

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

* fix(heal): report failed marker rollback

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

* test(heal): pin replacement resume schema compatibility

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

* fix(heal): preserve durable recovery anchors

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

* fix(ecstore): preserve public disk path semantics

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

* test(heal): use canonical replacement task ids

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

* test(heal): cover automatic replacement in 3x4 cluster

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

* fix(heal): verify replacement target commits

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

* fix(heal): persist replacement completion proof

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

* feat(heal): expose durable replacement status

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

* fix(heal): bound durable replacement discovery

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

* fix(heal): remove replacement readiness bypass

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

* fix(heal): retry terminal replacement cleanup

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

* fix(heal): isolate replacement intents from legacy resume

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

* fix(heal): migrate legacy replacement intents at startup

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

* style(heal): apply strict clippy fix

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

* fix(heal): prioritize active replacement recovery state

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

* fix(heal): bind readiness to the admitted mount lease

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

* fix(heal): atomically publish replacement intents

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

* fix(heal): isolate replacement recovery directory

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

* fix(heal): tolerate an empty recovery directory

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

* style(heal): remove redundant disk bytes conversion

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

* fix(heal): reconcile proof-first replacement recovery

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

* fix(heal): fence torn intent recovery

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

* test(heal): cover replacement migration conflicts

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

* fix(ecstore): fence replacement lease mount identity

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

* test(heal): cover missing replacement path admission

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

* fix(heal): reject conflicting legacy completion proof

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

* fix(ecstore): fall back to proc mount identity

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

* feat(admin): expose replacement recovery status

Surface the local durable replacement recovery snapshot in the background heal status response so operators can tell whether replacement cleanup is definitive or still pending.

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

* fix(heal): keep replacement status compatible

Keep the existing background heal status response wire-compatible while retaining the Linux mount lease cleanup needed for the replacement recovery branch.

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

* style(ecstore): match linux mount lease formatting

Keep Linux rustfmt output stable for the replacement mount lease comparison.

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

* fix(ecstore): qualify mount lease test constant

Use the disk module path for the format config constant in the Linux mount lease regression test.

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

* fix(ecstore): keep procfd mount roots directory-safe

Use a procfd path with an explicit directory component so Unix directory guards can open the replacement mount lease root with O_NOFOLLOW while preserving handle-relative I/O semantics.

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

* fix(ecstore): delete empty leased buckets via dirfd

Use the held mount lease fd as the parent for non-force empty bucket deletion on Linux so procfd-rooted paths do not get rejected as BucketNotEmpty. Also make the download-part OpenOptions truncate behavior explicit and keep fsync test recording stable across procfd canonicalization.

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

* fix(ecstore): scan leased bucket paths for emptiness

Use the local disk I/O root for bucket emptiness probes before non-force bucket deletion and table-bucket metadata checks. This keeps validation on the same mount instance as the subsequent local disk delete path.

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

* test(ecstore): align lease path test probes

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

* fix(heal): block unsafe replacement recovery restarts

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

* fix(heal): defer blocked replacement candidates

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

* fix(heal): retry transient replacement discovery

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

* fix(heal): keep transient recovery errors retryable

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

* fix(heal): block corrupt legacy replacement state

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

* fix(heal): classify flat replacement intent corruption

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

* fix(heal): keep transient resume loads retryable

Classify malformed legacy replacement state as blocking corruption while preserving disk and transient load failures for retry. This avoids permanently blocking replacement recovery on temporary storage errors.

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

* fix(heal): avoid latching transient legacy publishes

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

* fix(heal): retry blocked legacy migrations

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

* fix(heal): defer blocked startup recoveries

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

* fix(ecstore): preserve disk sync limiter across lease roots

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-08-10 08:32:47 +08:00
houseme 10a1d6b6e6 perf(get): avoid zeroing response body chunks (#5905)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-10 06:43:35 +08:00
Zhengchao An be0cea83b7 test(ecstore): pin persisted metadata key literals and bucket config goldens (#5904) 2026-08-09 22:12:26 +00:00
houseme b4b891afad fix(ecstore): raise replay cache auto headroom (#5902)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-09 18:34:27 +00:00
唐小鸭 88756ea8e1 test(ecstore): decouple kubernetes endpoint tests from kernel hostname (#5900)
Three Kubernetes endpoint-identity tests read the real kernel hostname
and panicked when it is an IP literal (e.g. macOS without a static
HostName, where DHCP/reverse-DNS sets the kernel hostname to an address
like 192.168.1.11).

Add a cfg(test) override seam (force_kernel_hostname_for_test, mirroring
the existing force_local_host_resolution_timeout_for_test pattern) and
route the production read through kernel_hostname_for_endpoint_identity()
so the tests inject deterministic hostnames instead of depending on the
host environment. Production behavior is unchanged.
2026-08-09 17:05:18 +00:00
唐小鸭 6333f21a2e feat(replication): SSE-C ciphertext passthrough replication (#5898)
Complete the encrypted-object replication series (backlog#1783, PR-C of
3, after #5872 and #5885): SSE-C objects replicate as ciphertext
passthrough — the source holds no customer key, so the stored bytes and
their encryption metadata travel verbatim and the replica decrypts only
with the original customer key, single-part and multipart.

- Sender: SSE-C objects read raw (raw_data_movement_read), transfer at
  ciphertext size, and range multipart parts over stored part sizes.
- Receiver: authorized replication PUTs restore the stored SSE-C keys
  from the transport headers (exact lowercase forms - the read-path
  check is case-sensitive), set ObjectOptions.preserve_ciphertext, and
  skip compression, bucket-default SSE, and sse_encryption behind one
  restore-derived gate. Multipart uses an internal session marker to
  store parts verbatim and strips it on complete.
- Convergence: the replication HEAD sends
  x-rustfs-source-replication-check; the target authorizes it as
  ReplicateObjectAction and skips SSE-C read validation for that
  request only, so keyless convergence HEADs see etag/size/mtime
  instead of 400 and SSE-C replicas stop re-driving forever.
- e2e: SSE-C contract flips to a key-gated readable replica (no-key and
  wrong-key GETs fail - the direct silent-plaintext detector); new
  multipart passthrough contract with ETag/marker/stability assertions.
2026-08-09 23:53:04 +08:00
Henry Guo 942faefb25 fix(ecstore): anchor Windows rename publication (#5677)
* fix(ecstore): anchor Windows rename publication

* fix(ecstore): complete Windows rename confinement

* test(ecstore): retain Windows retry assertion path

* fix(ecstore): accept configured Windows root paths

* fix(ecstore): size Windows rename buffers correctly

* fix(ecstore): use native relative rename on Windows

* fix(ecstore): preserve Windows rename parent guards

* fix(ecstore): reuse guarded Windows rename trees

* fix(ecstore): compile Windows publication helpers

* fix(ecstore): preserve configured Windows disk roots

* fix(ecstore): flush Windows shards with write access

* fix(ecstore): stage Windows rollback backup replacement

* fix(ecstore): defer Windows staged file cleanup

* fix(ecstore): type Windows staged write result

* fix(ecstore): retry Windows sharing violations

* fix(ecstore): share Windows staged deletes

* fix(ecstore): split Windows staged publication handles

* fix(ecstore): close Windows staged writer before rename

* fix(ecstore): share Windows staged publication deletes

* fix(ecstore): allow guarded Windows child publication

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
Co-authored-by: cxymds <cxymds@gmail.com>
Co-authored-by: houseme <housemecn@gmail.com>
2026-08-09 22:56:37 +08:00
houseme 08de165358 perf(get): reduce response body chunk overhead (#5897) 2026-08-09 22:36:39 +08:00
Zhengchao An 1e6f5f1e35 test: promote passing S3 compatibility cases (#5895)
test: promote passing s3 compatibility cases
2026-08-09 21:58:17 +08:00
Zhengchao An 5513dc75ee docs: update security advisory lessons (#5896) 2026-08-09 21:57:56 +08:00
Ramakrishna Chilaka d7f014cf5f fix(docker): support TZ environment variable (#5891)
Install tzdata in both published runtime variants and verify IANA timezone resolution during image builds.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-09 21:54:59 +08:00
cxymds 8f9633ee83 fix(rpc): negotiate authenticated file writes (#5880)
* fix(rpc): negotiate authenticated file writes

* fix(rpc): share capability probe failures

* test(rpc): cover dedicated capability route

* fix(rpc): satisfy capability cache lints

* fix(rpc): retry timed out capability probes

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

---------

Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-09 21:19:47 +08:00
cxymds 1be636b914 fix(replication): make resync recovery resilient (#5883)
Co-authored-by: houseme <housemecn@gmail.com>
2026-08-09 19:42:06 +08:00
houseme ec7f5f7b7d perf(http): reduce tracing/logging hotpath overhead (#5893)
perf(http): reduce disabled tracing overhead

Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-09 11:35:55 +00:00
唐小鸭 73e4ef4dd4 feat(replication): replicate managed-SSE objects via target re-encryption (#5885)
Open the managed-SSE replication gate (backlog#1783, PR-B of 3, after
#5872): the replication reader already decrypts through the injected
object-encryption resolver, so the source sends plaintext plus an
encryption intent header (AES256 / aws:kms, never the source key id) and
the target re-encrypts on its normal PUT path with its own KMS. No DEK
crosses sites.

- replication_put_object_options: fail closed only on Unsupported;
  insert the SSE intent after the strip loop.
- TargetClient::create_multipart_upload sends the full opts.header()
  set, fixing multipart replicas losing content-type/user metadata
  (plaintext included).
- Preserve source ETag and mtime on replicas (authorized replication
  only): receiver wires x-rustfs-source-etag into preserve_etag for PUT
  and CompleteMultipartUpload, resolve_complete_etag consumes it, and
  complete options carry source_etag/source_mtime (absent mtime
  degrades to epoch, not now_utc). Without this every replication HEAD
  comparison re-drives re-encrypted objects forever.
- e2e: managed SSE contracts flip to success on an independent-KMS
  dual-process pair (byte-identical plain GET proves target-owned
  envelopes; ETag/mtime preserved; version stable across scanner
  cycles; resync converges; multipart keeps structure and metadata);
  new target-without-KMS fail-closed contract; SSE-C stays FAILED.

Co-authored-by: houseme <housemecn@gmail.com>
2026-08-09 10:21:35 +00:00
houseme a71726ef49 perf(get): reduce response write allocations (#5890)
Avoid cloning cache-served GET bodies, preserve downstream vectored writes through the GET close-detection wrapper, and remove per-stripe EC decode sidecar allocations.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-08-09 08:58:41 +00:00
houseme 27ecdb88b1 fix(admin): allow owner service account updates (#5889)
* fix(admin): allow owner service account updates

* test(admin): cover console admin update scope

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

---------

Co-authored-by: ccccpj <ccccpj@outlook.com>
Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-09 08:46:27 +00:00
houseme 2c7d0fb2ce feat: add hotpath observability for S3 data paths (#5860) 2026-08-09 08:36:58 +00:00
houseme f72ad77aa4 fix(ecstore): use existing two-set test fixture (#5887)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-09 07:59:02 +00:00
Zhengchao An 255f3395bc fix(ecstore): rename stale two_set_test_sets references to make_local_two_set_sets (#5886) 2026-08-09 07:56:51 +00:00
Zhengchao An a07ad4a9ff test(replication): cover rule id byte limit (#5873) 2026-08-09 14:48:44 +08:00
唐小鸭 c619d8f2d6 fix(replication): persist REPLICA status on inbound replication writes (#5878) 2026-08-09 14:10:34 +08:00
Zhengchao An 6ce0961780 fix(policy): accept object lock mode condition (#5874) 2026-08-09 14:10:25 +08:00
terem42 578d02977e fix(heal): log the number of drives actually healed, not the drives consulted (#5871) 2026-08-09 14:10:11 +08:00
唐小鸭 eb377209c1 docs(ci): make e2e-replication-nightly test-count comments drift-resistant (#5866) 2026-08-09 14:09:44 +08:00
terem42 9c1c44807d fix(admin): answer background-heal/status partially when peers are unreachable (#5862) 2026-08-09 14:09:34 +08:00
GatewayJ 70deb3284b fix(select): pin object snapshot for query lifetime (#5835) 2026-08-09 14:08:53 +08:00
houseme b9d1ca3e4d chore(deps): update flake.lock (#5884) 2026-08-09 14:07:33 +08:00
cxymds 0cb9952aa0 fix(rpc): make authenticated file writes atomic (#5879) 2026-08-09 12:26:09 +08:00
cxymds 47369ff027 fix(heal): defer scoped repair on suspended pools (#5876) 2026-08-09 11:50:17 +08:00
唐小鸭 10c7476883 fix(replication): rebuild SSE metadata boundary for encrypted objects (#5872)
Groundwork for encrypted-object replication (backlog#1783, PR-A of 3):

- classify_replication_source_encryption: accept the AES256 marker that
  every stored SSE-C object carries; the SseC arm was unreachable.
- Fail closed on sealed material without an SSE marker (MinIO-written
  objects) instead of replicating ciphertext as plaintext.
- Replace the dead VALID_SSE_REPLICATION_HEADERS table with a transport
  map keyed by the metadata keys the SSE writer actually persists, shared
  via the new rustfs_utils::http::object_encryption_keys module.
- Structurally strip all encryption metadata from outbound replication
  (x-rustfs-encryption-* envelopes previously passed the filters).
- Skip decrypt_checksums for encrypted objects at the boundary so its
  is_multipart=false (a response-path contract) cannot misroute
  encrypted multipart objects once managed replication opens.
- Redact X-Rustfs-Replication-* SSE transport values in FileInfo Debug.

A reconciliation test pins that every key encryption_material_to_metadata
produces is either transport-mapped or stripped. All four SSE replication
e2e contracts still assert FAILED unchanged.
2026-08-09 03:05:11 +00:00
Heracles 9996d567d9 fix(build): support non-Linux Unix targets (illumos/Solaris/*BSD) (#5853)
* fix(build): support non-Linux Unix targets (illumos/Solaris/*BSD)

Two independent build-infrastructure blockers kept RustFS from building on
non-Linux Unix platforms. Neither touches runtime logic.

1. pulsar regenerates its protobuf bindings in build.rs on every build, which
   needs `protoc`. Platforms without a packaged protoc (illumos/Solaris/*BSD)
   now enable pulsar's `protobuf-src` feature via a cfg-gated dependency, which
   builds a vendored protoc from C++ sources. Mainstream targets keep the lean
   dependency and their existing system/CI protoc.

2. clocksource 0.8.3 (pulled in transitively by ratelimit 0.10) used the
   Linux-only `CLOCK_MONOTONIC_COARSE`. ratelimit 2.0 dropped the clocksource
   dependency entirely, so upgrading removes the portability problem at the
   root rather than patching clocksource. The bandwidth throttle's bulk
   `consume()` is rewritten onto ratelimit 2.0's `try_wait_n`, preserving the
   best-effort partial-consumption semantics.

Verified: cargo check + bandwidth monitor unit tests pass; cargo tree confirms
protobuf-src is enabled only for illumos/Solaris/*BSD and clocksource is gone
from the graph. The final illumos build must be confirmed on-platform.

Closes #3195

* fix(ecstore): guard ratelimit v2 capacity overflow

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

* test(ecstore): avoid slow bandwidth reader timeout

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

* fix(targets): drop vendored pulsar protobuf build

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

---------
Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-09 10:07:00 +08:00
houseme 6106cd3772 chore(hotpath): add samply symbol summary tools (#5875)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-09 09:25:29 +08:00
cxymds 3b9c67e79b fix(rpc): authenticate internode put file bodies (#5868) 2026-08-09 08:05:16 +08:00
cxymds d36166ffb5 fix(ecstore): bound decommission listing retries (#5861) 2026-08-09 08:05:12 +08:00
cxymds 963a107b33 fix(ecstore): fence bucket memo on live lock loss (#5852) 2026-08-09 08:00:46 +08:00
cxymds 02b4e082e8 fix(get): pin resume reads to resolved version (#5859) 2026-08-09 07:48:51 +08:00
cxymds b4133d69e6 fix(heal): respect scoped object repair limits (#5855) 2026-08-09 07:23:16 +08:00
houseme 134081b27b chore(deps): fix cargo shear dependency metadata (#5854)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-08 15:52:00 +00:00
houseme 7217cccc91 fix: isolate ssh stdin in hotpath artifact collection (#5851)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-08 15:09:39 +00:00
houseme dafc922e72 chore: harden hotpath profiling artifact collection (#5848)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-08 13:32:29 +00:00
houseme 6fcf0d250e fix(admin): return upgrade-required for v4 fallback (#5847)
Return HTTP 426 for unmatched admin v4 routes so madmin-go v4 can downgrade to RustFS admin v3 handlers.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-08 21:26:33 +08:00
cxymds 65e55c0f8e fix(rebalance): fence batch delete source pools (#5846) 2026-08-08 21:14:26 +08:00
cxymds 8c75a3834a fix(rebalance): fence writer pool lookups (#5845) 2026-08-08 21:14:11 +08:00
houseme f96346124e fix(multipart): recover part transactions by write quorum (#5844)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-08 20:22:51 +08:00
cxymds a7de957eb8 fix(rebalance): fence peers before activation (#5842) 2026-08-08 11:55:50 +00:00
houseme c2e23411e8 test(filemeta): cover crc heal classification (#5841)
* fix(filemeta): classify xl.meta CRC mismatch as FileCorrupt so heal repairs it

A failed CRC means the metadata bytes on disk are not the bytes that were
written — bitrot. Raising it as Error::other() surfaces a generic Io error,
which should_heal_object_on_disk does not recognise as heal-worthy: the drive
is skipped, disks_to_heal_count stays 0, heal_object returns ok, and the
corrupted xl.meta is never rewritten — while the scanner re-submits the same
no-op heal every deep-scan cycle. An explicit admin deep heal fails the same
way, so no heal path repairs metadata bitrot, and every one of them reports
success.

check_xl2_v1 already classifies a short or wrong-magic header as FileCorrupt
for exactly this reason (#5716); this completes the pattern for the two CRC
sites. The existing From<rustfs_filemeta::Error> for DiskError conversion maps
the variant to DiskError::FileCorrupt, which the heal path already handles.
The previously silent is_indexed_meta site now logs the mismatch (structured
event shape) like unmarshal_msg does.

Regression test: corrupt one byte of a marshalled FileMeta and assert
unmarshal_msg reports FileCorrupt; fails on the previous code, which returned
Io(Other).

Verified end-to-end on a 3-node / 12-drive EC:4 cluster: xl.meta corrupted on
2 of 12 drives via dd, admin deep heal — before this change the heal returns
ok with the corruption intact and the scanner loops forever; with it, both
copies are rewritten (decode-identical to the healthy quorum), the object
reads back byte-correct, and a follow-up heal reports all twelve drives
clean.

* test(filemeta): cover crc heal classification

Add regression coverage for the indexed xl.meta CRC path and the metadata-heal decision that consumes FileCorrupt.

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

---------

Co-authored-by: terem42 <9478806+terem42@users.noreply.github.com>
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-08-08 11:36:44 +00:00
GatewayJ 4a234c0fe3 fix(iam): stabilize OIDC provider ordering (#5832) 2026-08-08 19:29:04 +08:00
GatewayJ 1b1b217826 fix(iam): preserve OIDC outbound policy errors (#5762) 2026-08-08 19:28:47 +08:00
terem42 7e8b500420 fix(filemeta): classify xl.meta CRC mismatch as FileCorrupt so heal repairs it (#5838)
A failed CRC means the metadata bytes on disk are not the bytes that were
written — bitrot. Raising it as Error::other() surfaces a generic Io error,
which should_heal_object_on_disk does not recognise as heal-worthy: the drive
is skipped, disks_to_heal_count stays 0, heal_object returns ok, and the
corrupted xl.meta is never rewritten — while the scanner re-submits the same
no-op heal every deep-scan cycle. An explicit admin deep heal fails the same
way, so no heal path repairs metadata bitrot, and every one of them reports
success.

check_xl2_v1 already classifies a short or wrong-magic header as FileCorrupt
for exactly this reason (#5716); this completes the pattern for the two CRC
sites. The existing From<rustfs_filemeta::Error> for DiskError conversion maps
the variant to DiskError::FileCorrupt, which the heal path already handles.
The previously silent is_indexed_meta site now logs the mismatch (structured
event shape) like unmarshal_msg does.

Regression test: corrupt one byte of a marshalled FileMeta and assert
unmarshal_msg reports FileCorrupt; fails on the previous code, which returned
Io(Other).

Verified end-to-end on a 3-node / 12-drive EC:4 cluster: xl.meta corrupted on
2 of 12 drives via dd, admin deep heal — before this change the heal returns
ok with the corruption intact and the scanner loops forever; with it, both
copies are rewritten (decode-identical to the healthy quorum), the object
reads back byte-correct, and a follow-up heal reports all twelve drives
clean.
2026-08-08 18:48:18 +08:00
houseme e342457830 perf(filemeta): reduce meta object key allocations (#5836)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-08 17:23:08 +08:00
215 changed files with 36624 additions and 3626 deletions
+16 -16
View File
@@ -1,6 +1,6 @@
---
name: adversarial-validation
description: Execute the Adversarial Validation policy from the root AGENTS.md — run the seven reviewer roles (correctness, simplicity, security, concurrency/durability, compatibility, performance, test coverage) with RustFS-specific attack probes. Use on every behavior-affecting code change, bug fix, or design proposal before declaring it done.
description: Execute the Adversarial Validation policy from the root AGENTS.md — run the applicable reviewer roles with RustFS-specific attack probes. Use on every behavior-affecting code change, bug fix, design proposal, or agent-instruction change that alters execution before declaring it done.
---
# Adversarial Validation Playbooks
@@ -61,14 +61,15 @@ Null report example: "Attacked quorum-1 error reduction, exact max-keys listing
### Simplicity adversary
- Smaller-diff attack: rewrite the diff's change mentally (or actually, in scratch) as the minimal in-place edit and compare. Flag as findings: a helper function with exactly one caller introduced by this diff; a file rewrite where a 3-line edit inside the existing control flow suffices; reshaped control flow in init/locking/metadata/quorum paths beyond what the fix requires; new string literals duplicating existing constants (grep the token first); #[path] module inclusion. If the smaller diff achieves identical behavior, report it with the concrete replacement.
- Smaller-diff attack: inspect production growth separately from tests, fixtures, generated code, and documentation; test additions have no growth budget. Rewrite the production diff mentally (or in scratch) as the minimal equivalent edit. Report a finding only with a concrete smaller design that preserves correctness, compatibility, readability, and real boundaries; fewer lines alone are not evidence.
- Where: Any diff; extra scrutiny for crates/ecstore, crates/lock, rustfs/src/storage where 'preserve the existing control-flow shape' is an explicit rule
- Evidence: AGENTS.md 'Change Style for Existing Logic' (one-off helper ban, preserve control-flow shape in distributed/locking/metadata paths, no #[path]) and 'Reuse Before You Write' (constants clause); the Adversarial Validation roles list charters the simplicity adversary with exactly this attack.
- Reuse-and-necessity attack: for each new helper the diff introduces, run `ls crates/utils/src crates/common/src` and `rg -i 'fn \w*<term>'` over those dirs plus the touched crate (snake_case signatures — a full-text single-word grep drowns, a multi-word phrase returns nothing). A reimplementation of an existing workspace utility, or of plain std/tokio behavior no wrapper refines, is a finding — but so is forced reuse with mismatched semantics (normalization such as `clean` resolving `.`/`..` against raw S3 keys, error type, backoff, durability gating). For each new defensive branch, demand the nameable trigger and flag re-validation of what a validated upstream layer on the SAME path already guarantees — excluding the Cross-Cutting Domain Invariant patterns (nil/empty/absent UUID, dual metadata keys, unversioned-tier versionId) and re-checks before destructive actions, which are load-bearing even when redundant on the happy path. For each new test, flag near-duplicates pinning the same code path AND poison-value class as an existing test — boundary companions (n==max vs max+1, absent vs empty vs nil UUID, MetaObject vs MetaDeleteMarker) are never near-duplicates; the test-coverage skeptic playbook below mandates them.
- Where: Any diff adding helpers, branches on decoded/peer data, or tests; helper checks against crates/utils, crates/common, and the touched crate
- Evidence: AGENTS.md 'Change Style for Existing Logic' (conditional extraction rule, preserve sensitive control flow, canonical modules) and 'Reuse Before You Write'; the Adversarial Validation roles list charters this attack.
- Reuse-and-necessity attack: for each new helper, search `crates/utils`, `crates/common`, the touched crate, the likely domain owner, and relevant direct dependencies. A reimplementation is a finding, but forced reuse with mismatched normalization, error, backoff, or durability semantics is also a finding. Demand a nameable trigger for new defensive branches. Tests remain subject to validity and near-duplicate coverage review, never a size limit.
- Where: Any diff adding helpers, branches on decoded/peer data, or tests
- Evidence: AGENTS.md 'Reuse Before You Write' and 'Necessary Code Only'; GHSA-f4vq-9ffr-m8m3 (normalization-asymmetry traversal — why forced reuse of normalizing helpers on raw keys is itself an attack); docs/operations/tier-ilm-debugging.md nil-versionId incident (why boundary re-checks are load-bearing).
- Replacement-and-comment attack: when the diff introduces a replacement path or representation, trace all callers and flag a superseded in-scope path left behind without a compatibility requirement. Keep one canonical core behind compatibility adapters. Comments must state non-obvious invariants completely without narration or change history. Never demand unrelated deletion or trade away correctness, compatibility, or readability to reduce the diff.
Null report example: "Rewrote the diff as an in-place edit (no smaller equivalent exists), grepped both new helpers against crates/utils, crates/common, and the touched crate (no existing equivalent; call-site semantics checked), verified the two new defensive branches name concrete corrupt-input triggers, and checked the added tests against the existing suite (each pins a distinct poison-value class) — no break found."
Null report example: "Separated production growth from tests/docs, tested a smaller equivalent, checked helper reuse and superseded paths, and found no break."
### Security reviewer
@@ -195,9 +196,9 @@ Null report example: "Attacked dual-key metadata writes/removals against MinIO-o
### Performance reviewer
- For every `.clone()` the diff adds or moves onto a per-request/per-object path, open the cloned type and count heap fields (String, Vec, HashMap, Bytes). If >5 heap fields or it contains an EC block buffer, construct the cost: N concurrent PUTs x M objects -> N*M deep copies per second. Demand Arc-wrapping of heavy fields or pass-by-reference; also flag new `String` allocations in header/path/signature parsing where `&str`/`Cow<str>` suffices.
- For each `.clone()` or allocation added to a per-request/per-object path, identify the copied data and execution frequency. Report a finding only for a concrete repeated cost or benchmark regression. Recommend borrowing, moving, `Bytes`/`Arc`, `Cow`, or capacity reservation only when it reduces that cost without obscuring ownership or APIs.
- Where: crates/ecstore/src/set_disk/**, crates/ecstore/src/store*.rs, rustfs/src/storage/, crates/filemeta/, request handlers in rustfs/src/
- Evidence: crates/ecstore/AGENTS.md 'Allocation Discipline in Hot Paths' (no Clone on >5-heap-field structs, Arc for large buffers, &str/Cow for temporary computations); .agents/skills/rust-code-quality/SKILL.md ranks 'unnecessary clone in hot path' as P1 must-fix
- Evidence: crates/ecstore/AGENTS.md 'Allocation Discipline in Hot Paths'; .agents/skills/rust-code-quality/SKILL.md requires a concrete hot-path cost rather than a proxy metric
- For every new sync_all/sync_data/fdatasync/flush/File::sync call in the diff, trace the call chain to DurabilityMode / RUSTFS_DRIVE_SYNC_ENABLE resolution (crates/ecstore/src/disk/local.rs:291 DurabilityMode, :347 resolve_durability_mode) and to per-bucket durability overrides. Construct the run where the operator sets mode=none (or legacy RUSTFS_DRIVE_SYNC_ENABLE=false) and the new fsync still fires — that is an ungated durability cost and a regression on 4KiB writes.
- Where: crates/ecstore/src/disk/local.rs, crates/ecstore/src/bucket/durability.rs, crates/ecstore/src/set_disk/** (rename_data/commit paths), any crate doing tokio::fs or std::fs writes
- Evidence: #4221 fsync work caused a measured -10% 4KiB write regression (#814 investigation), later gated; durability modes added in eaff17cad (#4397), per-bucket tier overrides in 13e48d93a (#4407); 2df315baf (#4493) shows even ancestor-dir fsyncs are routed through the gate
@@ -230,12 +231,12 @@ Null report example: "Attacked the new rename_data commit-section work, durabili
### Test-coverage skeptic
- For every behavior claim in the PR description, revert that hunk (git stash / manual undo of the changed lines) and name the exact test (`cargo test -p <crate> <test_name>`) that fails. If no test fails on revert, the behavior is untested — file a finding, not a note. Especially verify the test exercises the REAL production call path, not a lookalike helper.
- For every testable behavior claim in the PR description, revert that hunk and name the focused test or executable check that detects the revert. If no reasonable check exists, require the reason and residual risk from the validation floor. Especially verify the check exercises the real production path, not a lookalike helper.
- Where: All crates; highest value in crates/ecstore, rustfs/src/storage, crates/heal
- Evidence: AGENTS.md exit criterion 'Every behavior change has a test that fails without it'. Real bug: PR #4220 (ghost-directory cleanup) merged with green tests but its fix never executed on the real delete path — required follow-up rustfs#4307, backlog#798 stayed OPEN. The tests exercised a path the production flow never took.
- Evidence: AGENTS.md testable-behavior exit criterion. Real bug: PR #4220 (ghost-directory cleanup) merged with green tests but its fix never executed on the real delete path — required follow-up rustfs#4307, backlog#798 stayed OPEN. The tests exercised a path the production flow never took.
- Read each added/modified test and confirm it asserts the real outcome (returned value, stored bytes, error variant), not merely 'call succeeded' or 'no panic'. Flag any test whose only observable is that the function returned, and any `assert!(result.is_err())` that never checks WHICH error. Then check: does the test prove the exploit/failure form is denied, or only that the intended form still works?
- Where: crates/e2e_test (security_boundary_test.rs pattern), and every #[cfg(test)] module in the diff
- Evidence: Commit dee8e4e63 (#4466) had to rewrite 277 lines of crates/e2e_test/src/security_boundary_test.rs because 'security boundary tests' passed without asserting real outcomes. .agents/skills/rust-code-quality/SKILL.md checklist: 'Every test function has at least one assert!'; .agents/skills/security-advisory-lessons/SKILL.md: 'Does the test prove the exploit form is denied, or only that the intended form still works?'
- Evidence: Commit dee8e4e63 (#4466) had to rewrite 277 lines of crates/e2e_test/src/security_boundary_test.rs because 'security boundary tests' passed without asserting real outcomes. .agents/skills/rust-code-quality/SKILL.md requires an observable failure criterion; .agents/skills/security-advisory-lessons/SKILL.md asks whether the exploit form is denied.
- When the diff adds a boolean/mode parameter or config flag, find the test that fails if the flag's effect is INVERTED inside the changed function. Tests that were mechanically updated to pass `false`/default at every call site assert nothing about the new behavior. Execute the check: flip the flag's branch in the source and confirm at least one test goes red for each branch.
- Where: crates/ecstore/src/set_disk/ (e.g. build_codec_streaming_part_reader), any function gaining a parameter
- Evidence: Commit 05890d6e2 (#4573): PR #4560 added a 15th param allow_inplace_legacy_fallback; the arity tests were fixed by passing `false` everywhere — they assert Err outcomes independent of the flag, so the fallback behavior itself has no revert-detecting test at those sites.
@@ -260,7 +261,7 @@ Null report example: "Attacked the new rename_data commit-section work, durabili
- For any pagination/limit/truncation change, construct the exact-boundary test: result count == max (page exactly full), max+1, and a delimiter re-fold that lands precisely on the page boundary — assert both the item count AND the is_truncated/continuation marker. Off-by-one at the page boundary is a recurring shipped bug here.
- Where: crates/ecstore listing paths (list_objects, ListMultipartUploads, metacache), S3 handlers in rustfs/src/storage
- Evidence: Two shipped boundary bugs: fefa70b31 (#4447) ListMultipartUploads returned one upload past max-uploads; d91f4d455 (#4538) delimiter re-fold of a full page lost the truncation flag. Both survived existing tests because no test pinned n == max exactly.
- Green `cargo test -p <crate>` on the touched crate is not a coverage verdict for the diff's test code itself: run `cargo clippy --all-targets -p <crate>` and a workspace-wide test BUILD (`cargo check --workspace --all-targets` at minimum) before accepting the tests as evidence. Test-only code that doesn't compile workspace-wide or fails clippy has repeatedly broken main and masked whether tests ran at all.
- A green focused test is evidence only for the targets it builds. Follow the `AGENTS.md` validation tier: add package-scoped Clippy or broader test-target compilation only when changed targets, features, or dependents remain uncovered; do not require a workspace-wide build by default.
- Where: All crates; especially concurrent-branch merges into crates/ecstore
- Evidence: #4322 broke main because only cargo test ran (field_reassign_with_default is clippy-only). b06f3df6b (#4441) and 05890d6e2 (#4573): test code broke the workspace test build (E0061) on main after textually-clean merges, failing CI for every open PR.
@@ -271,7 +272,6 @@ Null report example: "Attacked revert-detection for all 3 claimed behaviors (eac
Probes are distilled from shipped bugs in git history (commit/PR references
above), GitHub security advisories (see the security-advisory-lessons
skill), scoped `AGENTS.md` rules, and invariants under `docs/architecture/`
and `docs/operations/`. Line numbers drift; when a cited location no longer
matches, trust the invariant and re-locate the code. When a new bug class
ships, add a probe with its evidence here rather than growing the policy
section in `AGENTS.md`.
and `docs/operations/`. Line numbers drift; re-locate the invariant. Merge
new incidents into an existing probe when they share a failure class; add a
new probe only for a distinct attack, rather than growing the root policy.
+7 -4
View File
@@ -24,15 +24,17 @@ Use this skill before `gh pr create`, before `gh pr edit`, or when reviewing whe
2. Inspect change scope
- Review the diff and summarize what changed.
- Inspect `git diff --stat` and `git diff --numstat`; assess production-code growth separately. Tests, fixtures, generated code, and documentation have no growth budget. Treat line counts as signals, not quotas.
- Call out unrelated edits, generated artifacts, logs, or secrets as blockers.
- Mark risky areas explicitly: auth, storage, config, network, migrations, breaking changes.
- Use the simplicity-adversary verdict instead of producing a per-symbol inventory. Block growth only when the review identifies duplication or gives a concrete smaller design that preserves correctness, compatibility, readability, and real boundaries.
- Confirm replacement implementations remove the superseded in-scope path or adapt compatibility at the boundary to one canonical core.
- Scan the diff for newly added string literals and confirm whether they duplicate values already defined as constants/enums/typed wrappers in the same module or shared modules.
- Treat introducing a new hardcoded literal where a project constant already exists as a likely regression risk; require either a refactor to reuse the constant or an explicit exception explanation in the PR body.
3. Verify readiness requirements
- Require `make pre-commit` before marking PRs ready when the diff changes Rust code, product behavior, CI behavior, runtime configuration, security-sensitive logic, migrations, storage, auth, networking, or other high-risk paths.
- For documentation-only, agent-instruction-only, or local developer-tooling-only changes, allow focused verification instead of `make pre-commit` when it directly validates the changed surface.
- For focused verification, explain why the full gate was not run and list the scope-specific commands in the PR body.
- Select checks from `AGENTS.md` "Verification Before PR" based on the final diff's risk tier. Do not replace a focused behavioral test with `make pre-commit`, or a required high-risk `make pre-pr` with a narrower gate.
- For focused verification, state why the selected tier is sufficient and list the scope-specific commands in the PR body.
- If `make` is unavailable, use the equivalent commands from `.config/make/`.
- Add scope-specific verification commands when the changed area needs more than the baseline.
- If required checks fail, stop and return `BLOCKED`.
@@ -81,13 +83,14 @@ Use this skill before `gh pr create`, before `gh pr edit`, or when reviewing whe
## Blocker rules
- Return `BLOCKED` if a code, behavior, CI, runtime configuration, security-sensitive, migration, storage, auth, networking, or other high-risk change has not passed `make pre-commit`.
- Return `BLOCKED` if the checks required by the `AGENTS.md` validation tier have not passed.
- Return `BLOCKED` if a documentation-only, agent-instruction-only, or local developer-tooling-only change lacks focused verification for the changed surface.
- Return `BLOCKED` if the diff contains unrelated changes that are not acknowledged.
- Return `BLOCKED` if required template sections are missing.
- Return `BLOCKED` if the title/body is not in English.
- Return `BLOCKED` if the title does not follow the repository's Conventional Commit rule.
- Return `BLOCKED` if the diff introduces string literals that should use existing constants but did not.
- Return `BLOCKED` for production-code growth only when the review identifies a duplicated or superseded implementation, or supplies a concrete smaller design with equivalent semantics. Fewer lines alone are not evidence.
## Reference
@@ -3,8 +3,8 @@
- Confirm the branch is based on current `main`.
- Confirm the diff matches the stated scope.
- Confirm no secrets, logs, temp files, or unrelated refactors are included.
- Confirm `make pre-commit` passed for code, behavior, CI, runtime configuration, security-sensitive, migration, storage, auth, networking, or other high-risk changes.
- For documentation-only, agent-instruction-only, or local developer-tooling-only changes, confirm focused verification covered the changed surface and the PR body explains why the full gate was not run.
- Confirm the checks required by the `AGENTS.md` validation tier passed.
- For focused verification, confirm it covered the changed surface and the PR body explains why the selected tier is sufficient.
- Confirm extra verification commands are listed for risky changes.
- Confirm the PR title uses Conventional Commits and stays within 72 characters.
- Confirm the PR title does not use tool-specific prefixes such as `[codex]`.
+32 -32
View File
@@ -1,6 +1,6 @@
---
name: rust-code-quality
description: Enforce Rust-specific code quality rules on every code change. Use before merge to catch unwrap abuse, silent truncation, unnecessary cloning, lock ordering violations, recursion risks, and error type anti-patterns.
description: Enforce Rust-specific code quality rules on every Rust change. Use before merge to catch unwrap abuse, silent truncation, unnecessary cloning, lock ordering violations, recursion risks, and error type anti-patterns.
---
# Rust Code Quality Gate
@@ -12,27 +12,29 @@ Use this skill on every Rust code change to enforce quality rules that `cargo cl
1. Identify changed `.rs` files.
2. Run automated checks on changed files.
3. Run manual review checklist on the diff.
4. Report findings; block merge if P0/P1 issues exist.
4. Resolve or rebut every finding with evidence; P0/P1 findings cannot be deferred.
## Automated Checks
Run these on every changed `.rs` file (excluding test modules):
Use these searches to find candidates in changed `.rs` files. Inspect syntax,
`#[cfg(test)]` scope, and the changed hunk before reporting a finding; text
filters do not reliably distinguish production code from tests.
```bash
# 1. unwrap/expect in production code
rg -n '\.unwrap\(\)|\.expect\(' <changed-files> | grep -v '#\[cfg(test)\]' | grep -v 'test' | grep -v 'bench'
# 1. unwrap/expect candidates
rg -n '\.unwrap\(\)|\.expect\(' <changed-files>
# 2. Silent type truncation via `as` cast
rg -n ' as (u8|u16|u32|u64|usize|i8|i16|i32|i64|isize)\b' <changed-files>
# 3. String as error type
rg -n 'Result<.*String>' <changed-files> | grep -v test
rg -n 'Result<.*String>' <changed-files>
# 4. Box<dyn Error> in public APIs
rg -n 'Box<dyn.*Error' <changed-files> | grep -v test
rg -n 'Box<dyn.*Error' <changed-files>
# 5. println/eprintln in production
rg -n 'println!\|eprintln!' <changed-files> | grep -v test
rg -n 'println!\|eprintln!' <changed-files>
# 6. Ordering::Relaxed usage (verify each is intentional)
rg -n 'Ordering::Relaxed' <changed-files>
@@ -46,37 +48,35 @@ rg -n 'unwrap_or_default\(\)|unwrap_or\(' <changed-files>
For every Rust code change, verify:
### Error Handling
- [ ] No `unwrap()` or `expect()` in production code without justification comment
- [ ] Every production `unwrap()` or `expect()` is infallible by type or a checked invariant; explain only non-obvious invariants, using an existing type, a useful `expect` message, or a concise comment
- [ ] No `Result<_, String>` in public API signatures
- [ ] No `Box<dyn Error>` in public trait/struct methods
- [ ] Public library APIs use domain errors unless deliberate error erasure at a boundary is part of the contract
- [ ] `Error::source()` is overridden when inner error is stored
- [ ] Error messages are actionable (what failed, with what input)
- [ ] Error messages are actionable without exposing secret input
### Type Safety
- [ ] No silent `as` truncation (negative→unsigned, large→small)
- [ ] `try_into()` or explicit clamping used for numeric conversions
- [ ] No `f64 as usize` without prior clamping
- [ ] Fallible numeric conversions use `TryFrom`/`try_into()` and return a typed error; clamp or saturate only when the domain explicitly requires it
- [ ] Floating-point to integer conversion validates finiteness, sign, and range before conversion
### Concurrency
- [ ] Lock acquisition order is documented when multiple locks are used, and matches every other call site taking any overlapping subset (ABBA check)
- [ ] No `tokio::sync` lock guard (read or write) held across `.await` without bounded hold time — long-lived read guards wedge writers (#4195)
- [ ] Concurrent counters use `compare_exchange` loops, not load-then-store
- [ ] Atomic read-modify-write uses the direct `fetch_*` operation when possible; use `compare_exchange` only for conditional updates
- [ ] `std::sync::Mutex` in async context is held only briefly, never across `.await`
### Memory and Performance
- [ ] No `.clone()` on structs with >5 heap-allocated fields in hot paths
- [ ] `HashMap::with_capacity()` / `Vec::with_capacity()` used when size is known
- [ ] Large buffers wrapped in `Arc` rather than cloned
- [ ] Temporary string computations use `&str` or `Cow<str>` instead of `String`
- [ ] On an identified hot path, report cloning or allocation only with a concrete per-request/per-object cost or benchmark signal
- [ ] Prefer borrowing, moving, `Bytes`/`Arc`, or capacity reservation only when it reduces that cost without obscuring ownership or APIs
### Recursion Safety
- [ ] Recursive functions have a depth limit or use iterative traversal
- [ ] Recursion over untrusted, persisted, or otherwise unbounded input has a depth limit or uses iterative traversal
- [ ] Tree/cache traversals handle corrupted/cyclic input safely
### Testing
- [ ] Every test function has at least one `assert!`
- [ ] Tests use `.expect("context")` not bare `.unwrap()`
- [ ] No `println!`/`eprintln!` in production code (use `tracing`)
- [ ] Tests have an observable failure criterion; delegated assertions, `#[should_panic]`, snapshot/property checks, and meaningful `Result` failures do not need a redundant `assert!`
- [ ] Use `expect` only when its message improves failure diagnosis; do not add boilerplate to self-evident test setup
- [ ] Test volume and line count are never treated as production-code growth
### Serde
- [ ] Structs from untrusted input have `#[serde(deny_unknown_fields)]`
@@ -88,18 +88,18 @@ For every Rust code change, verify:
- [ ] New string literals don't duplicate existing constants
### Reuse and Necessity
- [ ] No new helper duplicating an existing workspace utility (`crates/utils`, `crates/common`, the touched crate) or plain std/tokio behavior no wrapper refines; reused helpers match the call site's semantics (normalization, error type, backoff, durability gating)
- [ ] No new helper duplicates `crates/utils`, `crates/common`, the touched crate, the likely domain-owning crate, a relevant direct dependency, or plain std/tokio behavior; reused helpers match the call site's semantics
- [ ] No branch without a nameable concrete trigger; no re-validation of what a validated upstream layer on the same path already guarantees (Cross-Cutting Domain Invariant patterns and pre-destructive-action re-checks are load-bearing — keep them)
- [ ] Error context attached once where actionable, not re-wrapped at every hop; no typed→generic error conversion below aggregation/quorum layers
- [ ] No comments narrating the next line, restating a signature, or describing the change itself (invariant comments — lock ordering, `SAFETY`, unwrap justification — are not narration)
- [ ] Comments avoid narration and change history while completely stating non-obvious lock, `SAFETY`, durability, compatibility, and unwrap invariants
- [ ] No near-duplicate test pinning the same code path and poison-value class as an existing test (boundary companions — n==max vs max+1, absent/empty/nil UUID — are never near-duplicates)
## Severity Classification
- **P0 (Block merge)**: `unwrap()` in request hot path, silent truncation on user input, lock ordering violation, recursion without depth limit
- **P1 (Must fix)**: `Result<_, String>` in public API, unnecessary clone in hot path, `Box<dyn Error>` in trait method, `unwrap_or_default()` on a domain-required value (metadata, quorum, version id)
- **P2 (Should fix)**: Missing `assert!` in test, `println!` in production, missing `with_capacity`, new helper duplicating an existing workspace utility, defensive branch with no nameable trigger (corrupt or stale persisted/peer data is always a nameable trigger for boundary-crossing values), near-duplicate test, redundant error re-wrapping
- **P3 (Nice to fix)**: Naming convention violation, missing doc comment, `as_ptr()` vs `Arc::ptr_eq`, narrating comment
- **P0 (Block merge)**: demonstrated data loss, security breach, remote crash, or deadlock
- **P1 (Must fix)**: concrete correctness, compatibility, or material hot-path regression
- **P2 (Should fix)**: avoidable duplication or maintainability issue with a concrete simpler replacement
- **P3 (Nice to fix)**: local style or clarity issue with no behavioral risk
## Output Template
@@ -107,10 +107,10 @@ For every Rust code change, verify:
## Rust Code Quality Report
### Automated Scan
- unwrap/expect in production: N found
- as casts: N found
- String errors: N found
- println/eprintln: N found
- unwrap/expect candidates inspected: N
- numeric-cast candidates inspected: N
- error-type candidates inspected: N
- output-macro candidates inspected: N
### Findings
- [P1] `path:line` — description
@@ -1,52 +0,0 @@
# Rust Code Quality Checklist
Use this as a quick pre-merge checklist for every Rust code change.
## Critical (P0 — block merge)
| Check | Command |
|-------|---------|
| No `unwrap()` in request/storage hot path | `rg '\.unwrap\(\)' <files> \| grep -v test` |
| No `as` truncation on user input | `rg ' as (u32\|usize\|i32)' <files>` |
| Lock order consistent across call sites | Manual: trace all lock acquisitions |
| Recursive functions have depth limit | Manual: check for `max_depth` or iterative pattern |
| No `panic!`/`unwrap_or_else(panic!)` in production | `rg 'panic!\|unwrap_or_else.*panic' <files> \| grep -v test` |
## High (P1 — must fix)
| Check | Command |
|-------|---------|
| No `Result<_, String>` in public API | `rg 'Result<.*String>' <files> \| grep -v test` |
| No `Box<dyn Error>` in public trait | `rg 'Box<dyn.*Error' <files> \| grep -v test` |
| No unnecessary `.clone()` in hot path | Manual: check loops and per-request paths |
| `Error::source()` implemented when inner error stored | Manual: check `impl Error` |
| No `eprintln!`/`println!` in production | `rg 'println!\|eprintln!' <files> \| grep -v test` |
## Medium (P2 — should fix)
| Check | Command |
|-------|---------|
| Tests have assertions | Manual: check for `assert` in test functions |
| `HashMap`/`Vec` use `with_capacity` when size known | Manual: check `::new()` in loops |
| No `#![allow(dead_code)]` at crate root | `rg 'allow.dead_code' <files> \| grep 'lib.rs'` |
| Serde structs from untrusted input have `deny_unknown_fields` | Manual: check `#[derive(Deserialize)]` |
## Low (P3 — nice to fix)
| Check | Command |
|-------|---------|
| No camelCase statics | `rg 'static ref [a-z]' <files>` |
| `Arc::ptr_eq` instead of `as_ptr + ptr::eq` | `rg 'as_ptr\|ptr::eq' <files>` |
| Public functions have doc comments | `rg 'pub fn' <files> \| grep -v '///'` |
## Quick One-Liner
```bash
# Run all automated checks on changed files
CHANGED=$(git diff --name-only HEAD~1 -- '*.rs' | grep -v test | grep -v bench)
echo "=== unwrap/expect ===" && rg -c '\.unwrap\(\)|\.expect\(' $CHANGED 2>/dev/null
echo "=== as casts ===" && rg -c ' as (u8|u16|u32|u64|usize|i8|i16|i32|i64|isize)\b' $CHANGED 2>/dev/null
echo "=== String errors ===" && rg -c 'Result<.*String>' $CHANGED 2>/dev/null
echo "=== println ===" && rg -c 'println!|eprintln!' $CHANGED 2>/dev/null
echo "=== Ordering::Relaxed ===" && rg -c 'Ordering::Relaxed' $CHANGED 2>/dev/null
```
@@ -66,14 +66,23 @@ For the full pattern map, read [advisory-patterns.md](references/advisory-patter
### STS, OIDC, and federation flows
- Every STS endpoint must have an explicit authentication story: SigV4 where required, OIDC token verification for web identity, and role/session policy validation before issuing credentials.
- For web identity, the JWT is the credential; exemption from SigV4 is not itself an authentication bypass. Treat pre-verification claims only as untrusted routing hints, bound token size, normalize public failures, rate-limit discovery, and issue credentials only after signature, issuer, audience, and expiration checks.
- JWT session tokens must be signed and verified by a trusted issuer/key path, not by service-account-controlled material or a reused root secret.
- JWT verification must enforce required claims and expiration for every bearer token path; "allow missing exp" is never acceptable for user-presented credentials.
- Public OIDC bootstrap and callback routes must treat `Host`, `X-Forwarded-Proto`, redirect targets, `state`, and callback parameters as untrusted; credential-bearing redirects require a configured, allowlisted origin.
- OIDC discovery and validation URLs are SSRF sinks. Resolve and classify hostnames at connection time, reject rebinding to loopback/private/link-local ranges, and do not rely on literal string checks.
### S3 copy, multipart, and presigned POST
### IAM policy conditions and plugins
- Treat request headers as attacker-controlled even after SigV4; callers sign their own spoofed headers. Do not merge them into server-derived condition keys such as identity, groups, version ID, signature version, JWT, or LDAP claims.
- Keep the condition-key namespace explicit. Reserved server-derived keys must reject or ignore colliding headers, while intentional request-header keys such as `s3:x-amz-*` remain available.
- Quantified IAM condition tests need partially overlapping multi-value sets. Fully contained and fully disjoint sets cannot distinguish `ForAllValues` from `ForAnyValue` bugs.
- External policy plugins must receive the same security context as built-in policy evaluation. If OPA or another plugin depends on existing object tags, load and pass `ExistingObjectTag/*` before the plugin decision.
### S3 object actions, copy, multipart, and presigned POST
- Version-aware object requests need version-aware actions. Explicit `versionId` reads and copy sources must authorize `s3:GetObjectVersion`, not only `s3:GetObject`.
- Multipart copy must enforce source `GetObject` and destination `PutObject` semantics equivalent to `CopyObject`, including copy-source and policy conditions.
- Do not let `CreateMultipartUpload`, `UploadPartCopy`, `CompleteMultipartUpload`, or `AbortMultipartUpload` return success without authorization.
- Fallbacks from version actions to non-version actions must still pass the same public-access-block, anonymous-deny, and post-authorization gates as a direct allow.
- Presigned POST policies are server-side contracts. Enforce `content-length-range`, key prefix, exact metadata/content-type, and all signed policy conditions.
### Protocol frontends and IAM parity
@@ -132,6 +141,11 @@ For the full pattern map, read [advisory-patterns.md](references/advisory-patter
- When touching reader/writer wrappers such as hashing, encryption, compression, or warp readers, verify wrapper order and inspect stored bytes in regression tests.
- Avoid helper shortcuts that unwrap nested readers and accidentally bypass encryption or integrity layers.
### Object Lock and retention invariants
- Object Lock state must fail closed when bucket metadata is unreadable, fabricated, or unparsable. Only a confirmed absence of Object Lock configuration may permit unprotected deletes or writes.
- Do not collapse metadata read faults, missing persisted metadata, parse failures, and genuinely absent Object Lock config into one "not configured" result.
- Retention enforcement must cover foreground deletes, batch deletes, force-delete helpers, default-retention materialization on PUT, lifecycle expiry, scanner sweeps, and all-versions expiry.
## Review Prompts
Use these prompts while reviewing a diff:
@@ -148,5 +162,9 @@ Use these prompts while reviewing a diff:
- Does this outbound validation path resolve attacker-supplied hostnames and reject private, loopback, link-local, and rebound addresses at the actual connection boundary?
- Is an archive entry, object key, or policy resource normalized differently between authorization and storage?
- Is the same operation implemented in multiple paths, such as `CopyObject` vs `UploadPartCopy`, and do all paths enforce the same security contract?
- Does an explicit object version, fallback action, or plugin authorization path pass through the same action and post-authorization gates as the direct S3 path?
- Can a caller-controlled header populate a condition key that should be derived only by the server?
- Do condition tests include partially overlapping multi-value inputs for quantified operators?
- Does unreadable bucket metadata make Object Lock or retention enforcement fail closed rather than disappear?
- Does a preview or browser-surface fix preserve the original security invariant when adding alternate viewers or file-type detection?
- Does the test prove the exploit form is denied, or only that the intended form still works?
@@ -35,12 +35,21 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
### STS, OIDC, and federation flows
- `GHSA-5qfg-mf7r-jp3w` and `GHSA-3473-5353-xhwh`: `AssumeRoleWithWebIdentity` was reachable through unauthenticated `POST /` routing and could issue temporary credentials from crafted web identity input. Lesson: every STS route needs explicit SigV4 or trusted identity-provider validation before role assumption, and unauthenticated exemptions must be narrowed to the exact action with uniform failure responses.
- `GHSA-ccrv-v8v9-ch9q` and `GHSA-48rf-7j3q-3hfv`: service-account-controlled material could self-sign JWT session tokens with forged policy claims, and missing `exp` was accepted for service-account tokens. Lesson: session tokens must be signed by a trusted issuer/key path, enforce required claims and expiration, and reject self-signed or principal-controlled tokens.
- `GHSA-jxrr-r6pv-h958`: unsigned JWT issuer data was decoded before verification to select an OIDC provider, and distinguishable failures could expose provider configuration. Lesson: web-identity routing may be unauthenticated, but pre-verification claims are untrusted routing hints; bound and rate-limit the request, normalize public errors, and verify signature, issuer, audience, and expiration before issuing credentials.
- `GHSA-ccrv-v8v9-ch9q`, `GHSA-48rf-7j3q-3hfv`, and `GHSA-xvfh-7c9g-hpw2`: service-account-controlled material could self-sign JWT session tokens with forged policy claims, and missing `exp` was accepted for service-account tokens. Lesson: session tokens must be signed by a trusted issuer/key path, enforce required claims and expiration, and reject self-signed or principal-controlled tokens.
- `GHSA-9pjf-w3c2-m32r`, `GHSA-4x2q-cpx9-9h26`, and `GHSA-xvpm-p3f7-34c3`: public OIDC authorize/callback flows trusted request `Host` or forwarded scheme when building credential-bearing redirects. Lesson: OIDC redirects must use configured allowlisted origins and trusted-proxy handling; never derive the post-login credential destination from direct client headers.
- `GHSA-m479-9x88-94w6`, `GHSA-frwq-mfqx-83p8`, `GHSA-q9q8-rf9r-fg9f`, and `GHSA-j5c2-hhf7-6gf5`: OIDC validation accepted attacker-controlled discovery URLs because hostname checks rejected only literal forbidden IPs, allowing DNS rebinding SSRF. Lesson: outbound federation URL validation must resolve and classify hostnames at the connection boundary and reject loopback, private, link-local, and rebound addresses.
### S3 copy, multipart, and upload policy validation
### IAM policy conditions and external policy plugins
- `GHSA-6r96-hmgc-726c`: request headers collided with lowercase server-derived condition keys such as `userid`, `groups`, `versionid`, and JWT/LDAP claims. Lesson: never let caller-controlled headers append to or replace server-derived policy context; reserve trusted condition keys and keep intentional request-header keys separate.
- `GHSA-v9cp-qfw9-9pfp`: quantified negated string conditions applied negation after aggregation, transposing `ForAllValues` and `ForAnyValue` semantics. Lesson: push negation into the per-value predicate for quantified operators and test partially overlapping multi-value sets.
- `GHSA-5w8r-p896-6vq2`: OPA policy mode skipped `ExistingObjectTag/*` loading, so tagged objects looked untagged to external policies. Lesson: external authorization plugins need the same object-tag and request context as built-in policy evaluation before they decide.
### S3 object actions, copy, multipart, and upload policy validation
- `GHSA-3ppv-fx5m-m749`: explicit `versionId` reads and copy sources authorized `s3:GetObject` instead of `s3:GetObjectVersion`. Lesson: version-specific object access must select version-specific actions for direct reads, `CopyObject`, and `UploadPartCopy`, with tests proving the backend is not reached on denial.
- `GHSA-x298-9x87-fvjq`: anonymous `ListObjectVersions` fell back to `ListBucket` and returned before public-access-block gates. Lesson: compatibility fallbacks must converge on the same post-authorization checks as direct grants, especially `RestrictPublicBuckets` and anonymous data-plane denies.
- `GHSA-mx42-j6wv-px98`: `UploadPartCopy` missed source authorization and allowed cross-bucket object exfiltration. Lesson: multipart copy must enforce the same source and destination contract as `CopyObject`.
- `GHSA-wfxj-ph3v-7mjf`: `UploadPartCopy` checked source and destination independently but missed destination copy-source policy constraints. Lesson: source read and destination write checks are not sufficient when policy constrains allowed copy sources.
- `GHSA-w5fh-f8xh-5x3p`: presigned POST accepted uploads without enforcing signed policy conditions. Lesson: parse and enforce all POST policy constraints server-side, including size, key prefix, and content type.
@@ -59,7 +68,7 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
### Secrets, defaults, and cryptographic misuse
- `GHSA-j59h-h7q5-q348`, `GHSA-3wm5-wpm5-hmfm`, `GHSA-6wc8-xm48-qhmx`, `GHSA-9gf3-jx4p-4xxf`, and `GHSA-63xc-c3w3-m2cf`: RustFS shipped known default root credentials that could authenticate to S3, admin APIs, IAM, KMS, console, and token-signing surfaces. Lesson: root credentials must be operator-provided or generated per install; known defaults and warnings are not acceptable for network-reachable deployments.
- `GHSA-j59h-h7q5-q348`, `GHSA-3wm5-wpm5-hmfm`, `GHSA-6wc8-xm48-qhmx`, `GHSA-9gf3-jx4p-4xxf`, `GHSA-63xc-c3w3-m2cf`, and `GHSA-ch63-6q4v-hwp5`: RustFS shipped known default root credentials that could authenticate to S3, admin APIs, IAM, KMS, console, and token-signing surfaces. Lesson: root credentials must be operator-provided or generated per install; known defaults and warnings are not acceptable for network-reachable deployments.
- `GHSA-h956-rh7x-ppgj`: gRPC used the hard-coded token `rustfs rpc` on both client and server. Lesson: source-visible shared tokens are authentication bypasses.
- `GHSA-r5qv-rc46-hv8q`: internode RPC HMAC secret fell back to the public default `rustfsadmin`. Lesson: RPC/internode auth must fail closed instead of silently using public defaults.
- `GHSA-75fx-qg6f-8rm7` and `GHSA-68cw-96m3-h2cf`: internode RPC secrets were derivable from known root credentials, making raw storage RPC signatures forgeable when explicit RPC secrets were unset. Lesson: RPC auth keys must be independent random secrets, never derived from S3 root credentials, and raw storage RPC should not share the public S3 listener without an internode-only boundary.
@@ -92,6 +101,10 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
- `GHSA-xrrf-67jm-3c2r`: SSE metadata reported encryption while reader composition bypassed `EncryptReader` and stored plaintext. Lesson: test actual bytes on disk and wrapper order, not only API metadata.
### Object Lock and retention invariants
- `GHSA-j548-9grx-fh4f`: Object Lock enforcement treated unreadable, fabricated, or unparsable bucket metadata as absent configuration and allowed retained objects to be deleted or expired. Lesson: retention must fail closed unless Object Lock absence is authoritative, and every delete, lifecycle, scanner, force-delete, and default-retention path needs the same state distinction.
### Serde deserialization and input validation
- No `#[serde(deny_unknown_fields)]` found across the entire codebase. Lesson: all structs deserialized from untrusted input (S3 API XML/JSON, lifecycle rules, bucket policies, replication configs) should have `#[serde(deny_unknown_fields)]` to reject malformed or adversarial payloads.
@@ -107,11 +120,13 @@ Use these targeted searches when a diff touches security-sensitive code:
rg -n "validate_admin_request|check_permissions|AdminAction::|deny_only|is_allowed" rustfs crates
rg -n "authorize_operation|FtpsDriver|SftpDriver|RETR|MKD|SIZE|MDTM|CreateBucket|GetObject|HeadObject" crates/protocols rustfs
rg -n "UploadPartCopy|upload_part_copy|CompleteMultipart|PostObject|content-length-range|starts-with" rustfs crates
rg -n "ListBucketVersions|GetObjectVersion|versionId|VersionId|ExistingObjectTag|ForAllValues|ForAnyValue|POLICY_PLUGIN|opa" rustfs crates
rg -n "normalize_extract_entry_key|Snowball|auto-extract|PathBuf::join|canonicalize|\\.\\.|x-forwarded-for|x-real-ip|SourceIp" rustfs crates
rg -n "DEFAULT_SECRET|DEFAULT_ACCESS|TEST_PRIVATE_KEY|rustfs rpc|RUSTFS_RPC_SECRET" rustfs crates
rg -n "TONIC_RPC_PREFIX|verify_rpc_signature|check_auth|NodeServiceServer|x-rustfs-signature" rustfs crates
rg -n "debug!|trace!|info!|error!|\\?resp|\\?merged_config|session_token|secret_key" rustfs crates
rg -n "HashReader|EncryptReader|SSE|server-side encryption|Access-Control-Allow-Credentials|Origin" rustfs crates
rg -n "ObjectLock|object_lock|retention|COMPLIANCE|GOVERNANCE|delete_prefix|lifecycle|scanner" rustfs crates
rg -n "deny_unknown_fields|serde.default|as u32|as usize|as i32" rustfs crates
```
@@ -121,9 +136,12 @@ rg -n "deny_unknown_fields|serde.default|as u32|as usize|as i32" rustfs crates
- Protocol frontend authz fixes: include denied `RETR`, `SIZE`/`MDTM`, `MKD`, bucket probe, and sibling allowed-operation cases, and assert denied paths do not reach the storage backend.
- IAM fixes: include import/update/list service-account cases with attacker-controlled parent, claims, access key, secret key, and policy.
- Copy/upload fixes: include cross-bucket, cross-user, source-denied, destination-denied, copy-source-condition, and multipart completion cases.
- Version-action fixes: include historical UUID, explicit current version, `null`, range, partNumber, presigned, STS/session, service-account, anonymous bucket-policy, copy source, and multipart-copy source cases.
- Policy-condition fixes: include reserved-key header collisions, missing keys, partially overlapping multi-value sets, plugin mode, and built-in policy mode.
- Path fixes: include encoded traversal, absolute path, nested traversal, archive entries with `..`, valid object keys that resemble traversal text but should be rejected, and canonical bucket/prefix boundary checks.
- Logging fixes: assert redacted output for structs and response bodies that may contain credentials.
- IAM export fixes: assert exported archives omit plaintext user and service-account secrets unless the format deliberately encrypts or seals them.
- RPC auth fixes: include captured metadata replay across two concrete methods, stale timestamps, wrong path, wrong method surrogate, wrong secret, and valid same-method calls.
- Browser/CORS fixes: assert no credentials on reflected/default origins, correct behavior for explicit allowlists, and no same-origin script execution for previewed object content.
- SSE fixes: inspect stored bytes and verify API metadata, read-back behavior, and on-disk ciphertext together.
- Object Lock fixes: include unreadable metadata, fabricated metadata defaults, unparsable config, confirmed absent config, COMPLIANCE/GOVERNANCE retention, lifecycle expiry, scanner sweeps, and force-delete paths.
+1
View File
@@ -25,6 +25,7 @@ TEST_THREADS ?= 1
script-tests: ## Run shell script tests
@echo "Running script tests..."
./scripts/test_build_rustfs_options.sh
./scripts/test_docker_runtime_timezone.sh
./scripts/test_entrypoint_credentials.sh
./scripts/test_internode_grpc_ab_bench.sh
./scripts/test_object_batch_bench_enhanced.sh
+7 -5
View File
@@ -218,7 +218,7 @@ test-group = 'ecstore-serial-flaky'
# the nightly profile derives its set as "the replication module MINUS this
# allowlist", so any new replication test lands in nightly by default (never
# silently unrun) until it is explicitly blessed as fast here. Keep the two
# regexes byte-identical. Count invariant: 20 here + 36 nightly = 56 total
# regexes byte-identical. Count invariant: 20 here + 49 nightly = 69 total
# (authority: `cargo nextest list`; docs/testing/e2e-suite-inventory.md).
# HISTORY (2026-07-11): the 20 fast tests were briefly pulled out of this lane
# (#4724) because they set a loopback (127.0.0.1) replication target that the
@@ -280,10 +280,12 @@ slow-timeout = { period = "60s", terminate-after = 2, grace-period = "10s" }
# tests that are unfit for the per-PR e2e-smoke gate:
#
# * 2 remote-target TLS validation tests.
# * 13 bucket-replication data-plane/helper tests — they PUT/delete objects
# * 15 bucket-replication data-plane/helper tests — they PUT/delete objects
# and poll until source and target converge; two replicate over HTTPS,
# four pin active SSE fail-closed contracts (SSE-C, SSE-S3, SSE-KMS, and
# the SSE-S3 resync path), and one guards event/history observers.
# six pin SSE replication contracts (managed SSE-S3/SSE-KMS re-encrypt on
# the target incl. multipart and the resync path, SSE-C and
# target-without-KMS stay fail-closed), and one guards event/history
# observers.
# * 12 `_real_dual_node` site-replication tests — each spawns TWO full rustfs
# servers and drives the cross-process site-replication control plane.
# * 1 `_real_three_node` site-replication test.
@@ -342,7 +344,7 @@ path = "junit.xml"
# object_lambda) — too heavy for the merge budget; they run in ci-7's
# nightly 4-node lane.
# * replication_extension_test — repl-1 already splits it into the PR
# `e2e-smoke` (20 fast) and `e2e-repl-nightly` (27 slow) lanes and reserves
# `e2e-smoke` (20 fast) and `e2e-repl-nightly` (49 slow) lanes and reserves
# it for those, so e2e-full does not double-run it.
# * #[ignore]d tests — nextest skips them by default (no --run-ignored); the
# manual-localhost:9000 reliant/policy tests are ci-13's migration.
+2 -2
View File
@@ -57,7 +57,7 @@ runs:
using: "composite"
steps:
# protobuf-compiler is deliberately absent: the setup-protoc step below
# installs 34.1 into the tool cache and prepends it to PATH, so the apt
# installs 35.1 into the tool cache and prepends it to PATH, so the apt
# build (older, and never version-matched) was shadowed on every run and
# simply never used.
- name: Install system dependencies (Ubuntu)
@@ -81,7 +81,7 @@ runs:
- name: Install protoc
uses: rustfs/setup-protoc@a3705324d8f9bf5b6c3573fb6cf8ae421db55dd6 # v3.0.1
with:
version: "34.1"
version: "35.1"
repo-token: ${{ github.token }}
- name: Install flatc
+14 -15
View File
@@ -14,25 +14,24 @@
# Nightly full replication e2e lane (backlog#1147 repl-1, deps: ci-4).
#
# The per-PR gate (ci.yml `e2e-tests` job, `--profile e2e-smoke`) runs the 20
# FAST replication tests. This scheduled lane runs the remaining 27
# heavier replication e2e tests that are unfit for a per-PR gate:
#
# * 2 remote-target TLS validation tests.
# * 12 bucket-replication data-plane/helper tests (PUT/delete + poll for
# convergence; two replicate over HTTPS, two pin active SSE failure
# contracts, and one guards event/history observers). The SSE-S3 contract
# remains ignored under backlog#1291.
# * 11 `_real_dual_node` site-replication tests (each spawns TWO rustfs
# servers and drives the cross-process site-replication control plane).
# * 1 `_real_three_node` site-replication test.
# * 1 `_real_single_node` service-account round-trip test.
# The per-PR gate (ci.yml `e2e-tests` job, `--profile e2e-smoke`) runs the
# FAST replication tests. This scheduled lane runs the remaining heavier
# replication e2e tests that are unfit for a per-PR gate: remote-target TLS
# validation, bucket-replication data-plane/helper tests (PUT/delete + poll
# for convergence, HTTPS targets, active SSE failure contracts, event/history
# observers), and the `_real_dual_node` / `_real_three_node` /
# `_real_single_node` site-replication tests that each spawn full rustfs
# server processes.
#
# The selection is the [profile.e2e-repl-nightly] default-filter in
# .config/nextest.toml — the single wiring mechanism (repl-1 / ci-4). Do NOT
# add ad-hoc cargo-test steps here; change the filterset instead.
# add ad-hoc cargo-test steps here; change the filterset instead. The
# authoritative membership and count come from
# `cargo nextest list -p e2e_test --profile e2e-repl-nightly`; the PR/nightly
# count invariant is maintained next to the filtersets in .config/nextest.toml
# (deliberately not duplicated here).
#
# Explicit division of labor: these 27 tests run ONLY here, never double-run
# Explicit division of labor: the nightly subset runs ONLY here, never double-run
# in ci-5's future e2e-full merge gate. TODO(ci-7): once the ci domain's
# consolidated scheduled e2e workflow exists, fold this interim repl-owned lane
# into it rather than growing a second scheduled entrypoint.
+15 -1
View File
@@ -225,7 +225,9 @@ jobs:
VERSION="${{ needs.resolve.outputs.version }}"
DEB_ARCH="${{ matrix.deb_arch }}"
# DEB version: replace - with ~ (1.0.0-beta.12 -> 1.0.0~beta.12)
DEB_VERSION="${VERSION/-/~}"
# Use a variable for ~ to prevent tilde expansion by bash
TILDE='~'
DEB_VERSION="${VERSION/-/$TILDE}"
PKG_DIR="rustfs_${DEB_VERSION}_${DEB_ARCH}"
echo "Building DEB: ${PKG_DIR}.deb"
@@ -320,6 +322,17 @@ jobs:
sudo apt-get update && sudo apt-get install -y ruby ruby-dev build-essential
sudo gem install fpm
# Create config file for fpm (DEB build creates it in its package dir structure,
# but fpm needs the file to exist before packaging)
mkdir -p ./tmp-pkg/etc/default
cat > ./tmp-pkg/etc/default/rustfs << 'ENVEOF'
# RustFS Environment Configuration
# See https://rustfs.com/docs/ for more information
# RUSTFS_VOLUMES=""
# RUSTFS_ROOT_USER=""
# RUSTFS_ROOT_PASSWORD=""
ENVEOF
fpm -s dir -t rpm \
--name rustfs \
--version "$VERSION" \
@@ -360,6 +373,7 @@ jobs:
) \
--config-files /etc/default/rustfs \
./bin/rustfs=/usr/bin/rustfs \
./tmp-pkg/etc/default/rustfs=/etc/default/rustfs \
deploy/build/rustfs.service=/lib/systemd/system/rustfs.service \
LICENSE=/usr/share/doc/rustfs/LICENSE \
README.md=/usr/share/doc/rustfs/README.md
+4
View File
@@ -69,6 +69,10 @@ jobs:
install-build-packaging-tools: 'false'
install-test-tools: 'false'
- name: Check production Windows dependencies
shell: pwsh
run: cargo check -p rustfs-ecstore --lib
- name: Test guarded rename publication
shell: pwsh
run: cargo test -p rustfs-ecstore --lib rename_all_ -- --nocapture
+19 -15
View File
@@ -51,26 +51,25 @@ If repo-level instructions conflict, follow the nearest file and keep behavior a
## Change Style for Existing Logic
- Prefer direct, local code over extracting one-off helpers.
- Extract a helper only when logic is reused or the extraction materially clarifies a non-trivial flow.
- Start with the smallest direct, local edit. Add production files, types, traits, helpers, wrappers, or abstraction layers only when current behavior requires them. Extraction must remove present duplication, enforce a real boundary, or materially clarify a non-trivial flow; anticipated reuse is not enough.
- Use Rust's default module file layout (`mod foo;` with `foo.rs` or `foo/mod.rs`/`foo/*.rs`).
Avoid `#[path = "..."]` for module inclusion; move files into the canonical module tree instead.
If an unavoidable generated-code, FFI, or test-fixture exception remains, keep it local and document why the canonical layout cannot work.
- Solve only the requested problem; do not add speculative features, configurability, or adjacent improvements.
- Prefer editing existing code over rewriting files or reshaping unrelated logic.
- Modify only what is required and remove only artifacts introduced by your own changes.
- Modify only what is required. Remove any in-scope path or representation superseded by the change. If compatibility or rollback requires retention, adapt at the boundary to one canonical core and follow the repository's `RUSTFS_COMPAT_TODO` removal policy; never delete unrelated code merely to improve addition/deletion statistics.
- Preserve the existing control-flow and logic shape when fixing bugs or addressing review comments, especially in init, distributed coordination, locking, metadata, and concurrency paths.
- Do not refactor existing code only to make it easier to unit test.
- Keep fixes narrowly aligned with the requested behavior; avoid semantic-adjacent rewrites while touching sensitive paths.
- Keep code elegant, concise, and direct. Prefer minimal, readable implementations over over-engineering and excessive abstraction. Use comments to clarify non-obvious intent and invariants, not to compensate for unclear code.
- Do not write comments that narrate what the next line does, restate a signature, or describe the change you just made — that commentary belongs in the PR description, not the code. Required invariant comments — lock ordering, `SAFETY`, unwrap justification, `#[allow(dead_code)]` rationale, `RUSTFS_COMPAT_TODO` — are never narration.
- Keep code elegant, concise, and direct. Prefer the smallest readable design and existing abstractions over parallel managers, factories, adapters, or wrappers added only to make the design look extensible.
- Comments state non-obvious reasons, assumptions, and invariants in the shortest complete form. Their length follows the invariant's complexity: `SAFETY`, lock ordering, durability, and compatibility contracts may need a short list of conditions. Never narrate the next line, restate a signature, or record change history; move durable design rationale to architecture or operations documentation.
- Mention unrelated issues when useful, but do not fix them as part of a narrow task.
## Reuse Before You Write
Search for an existing implementation before writing a new one; extend what exists instead of duplicating it:
- **Helpers and utilities** (path/string handling, hashing, retry, env parsing, IO wrappers): check `ls crates/utils/src` first — file names map to operations (`retry.rs`, `envs.rs`, `hash.rs`, `path.rs`, `string.rs`, `io.rs`) — plus `crates/common` (shared structures/globals), then `rg -i 'fn \w*<term>' crates/utils/src crates/common/src <touched-crate>/src` for signatures. Helpers are snake_case: a full-text single-word grep over a large crate drowns you and a multi-word phrase returns nothing. Reimplementing an existing workspace helper — or hand-rolling what `std`, `tokio`, or an existing workspace dependency already provides — is a review finding, not a style preference.
- **Helpers and utilities** (path/string handling, hashing, retry, env parsing, IO wrappers): check `crates/utils`, `crates/common`, the touched crate, the likely domain-owning crate, and relevant direct workspace dependencies from `Cargo.toml`. Search snake_case signatures with a focused term. Reimplementing an existing workspace helper — or hand-rolling what `std`, `tokio`, or an existing dependency already provides — is a review finding, not a style preference.
- **Reuse requires matching semantics, not a matching name**: before adopting a helper, check its normalization (`clean` resolves `.`/`..` — never apply it to raw S3 object keys), error type, backoff/deadline behavior, and durability gating against the call site. When semantics differ, a new narrowly-named helper with a comment naming the rejected lookalike is the correct outcome. The inverse also holds: workspace wrappers exist because raw `std`/`tokio` semantics were insufficient (durability gates, retries) — prefer the wrapper over the raw call.
- **Constants and fixed tokens** (protocol labels, error identifiers, header keys, event names, metric names, command tags): search for existing constants/enums that already represent the same semantic value and reuse them. If a value is truly new, define one local constant near related logic; never scatter the literal across sites. When changing existing behavior, align naming and format with the established constants.
- **Test scaffolding**: reuse existing test utilities and fixtures (the touched crate's own `test_util` module and `tests/fixtures`, or `crates/test-utils`) instead of writing new setup code — run `rg -l '<fn-under-test>' <crate>/src <crate>/tests` before writing a test. A new test must pin a failure mode no existing test covers. Near-duplicate means same code path AND same poison-value class: this repo's boundary companions (n==max vs max+1, absent vs empty vs nil UUID bytes, MetaObject vs MetaDeleteMarker) are distinct by definition and must all be written.
@@ -79,6 +78,7 @@ Search for an existing implementation before writing a new one; extend what exis
Net-new code — files, types, branches, comments — is cost to justify, not progress:
- Inspect production-code additions separately. Tests, fixtures, generated code, and documentation do not count as production-code growth. Line counts are signals, not quotas: new production structures must map to a current requirement, and a blocker requires a concrete smaller design that preserves correctness, compatibility, readability, and real boundaries.
- Validate at the trust boundary — untrusted client input, bytes read from disk, RPC payloads, config (see Serde Safety and Cross-Cutting Domain Invariants) — then trust the type: do not re-check what the type system or a validated upstream layer already guarantees, and cite the establishing check (`file:line`) when the guarantee is not obvious.
- The exception is load-bearing: a value that crossed a persistence, RPC, or version boundary is never guaranteed by the code on the other side — a peer may be older or buggy, disk bytes may be corrupt — so the Cross-Cutting Domain Invariant patterns apply at every consumer, and re-checks immediately before a destructive action (delete, overwrite, quorum decision) stay. Deleting an existing guard is a behavior change requiring adversarial review, not cleanup.
- Every new branch needs a nameable trigger: a concrete input, state, or failure that reaches it — for boundary-crossing values, corrupt or stale persisted/peer data is always nameable. If you cannot name one, do not write the branch. If the case is truly unreachable, encode the invariant in the type; where that is impossible, return a typed internal error (fail closed). `debug_assert!` is acceptable only for pure internal arithmetic on values that never crossed a disk/RPC/config boundary — never as the sole guard on decoded or peer-supplied data.
@@ -218,9 +218,10 @@ not to bless it.
Pick the tier from the riskiest file touched; when in doubt, pick the higher.
- **Exempt:** docs/comments/instruction-only changes, formatting, typos with
no runtime surface. Skip this section.
- **Mechanical:** pure renames, file moves, test-only or tooling changes
- **Exempt:** docs/comments, formatting, and typos that cannot affect runtime,
builds, tests, or agent execution. Skip this section.
- **Mechanical:** pure renames, file moves, test-only or tooling changes, and
agent-instruction changes that alter execution —
correctness and simplicity adversaries only.
- **Standard (the default):** any change that affects behavior.
- **High risk:** touches locking, erasure coding, quorum/heal, replication,
@@ -242,7 +243,7 @@ encode this repo's shipped bugs.
- **Correctness adversary** — construct a concrete input/state/interleaving
that yields wrong output, data loss, or a crash. Probe error paths and edge
values (empty, nil UUID, zero-length, quorum1, missing version).
- **Simplicity adversary** — same behavior, less code. Hunt the materially smaller or more idiomatic diff (see Change Style for Existing Logic, Reuse Before You Write, and Necessary Code Only): reimplemented workspace helpers, one-caller extractions, rewrites where an in-place edit suffices, defensive branches with no nameable trigger, redundant error wrapping, near-duplicate tests, narration comments. A smaller diff achieving identical behavior is a finding, reported with the concrete replacement; forced reuse of a helper with mismatched semantics is equally a finding.
- **Simplicity adversary** — same behavior, less code. Hunt reimplemented helpers, rewrites where an in-place edit suffices, speculative abstractions, defensive branches with no nameable trigger, redundant error wrapping, near-duplicate tests, and narration comments. A one-caller helper is a finding only when it merely forwards or splits a short linear flow without adding domain naming, boundary isolation, an invariant, or useful error context. Report a concrete smaller replacement; fewer lines alone are not evidence.
- **Security reviewer** — authn/authz bypass, injection, secret leakage,
untrusted deserialization (see Serde Safety), path traversal, timing leaks.
- **Concurrency/durability reviewer** — lock ordering, races, cancellation,
@@ -253,10 +254,11 @@ encode this repo's shipped bugs.
time across IO, sync or CPU-heavy work on async runtime threads, added
fsync/flush outside the durability gate, hot-path logging noise. A
measurable regression on a per-request or per-object path is a finding.
- **Test-coverage skeptic** — for each claimed behavior, name the test that
fails if the change is reverted; then name a changed line that could be
wrong while all tests stay green — if one exists, coverage is insufficient.
A missing test is a finding, not a note.
- **Test-coverage skeptic** — for each testable behavior claim, name the test
or executable check that detects a revert; then name a changed line that
could be wrong while all checks stay green. If a focused check is not
reasonable, require the reason and residual risk from the validation floor.
Test additions have no line-count or growth budget.
Standard tier: correctness adversary + simplicity adversary + test-coverage
skeptic, plus every role whose domain the diff touches (async or
@@ -282,7 +284,9 @@ High risk: all seven roles.
- Every applicable role has run; every finding is fixed or rebutted with
evidence.
- Every behavior change has a test that fails without it.
- Every testable behavior change has a focused regression check. Exceptions
follow the validation floor and state why a check is impractical and what
risk remains.
- The Verification Before PR gates pass — adversarial review supplements
those gates, never replaces them.
- High risk only: record a one-line verdict per role in the PR description.
Generated
+115 -123
View File
@@ -284,7 +284,7 @@ dependencies = [
"serde_json",
"strum 0.27.2",
"strum_macros 0.27.2",
"thiserror 2.0.19",
"thiserror 2.0.20",
"uuid",
]
@@ -572,7 +572,7 @@ dependencies = [
"nom 7.1.3",
"num-traits",
"rusticata-macros",
"thiserror 2.0.19",
"thiserror 2.0.20",
"time",
]
@@ -726,7 +726,7 @@ dependencies = [
"serde_json",
"serde_nanos",
"serde_repr",
"thiserror 2.0.19",
"thiserror 2.0.20",
"time",
"tokio",
"tokio-rustls",
@@ -773,9 +773,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.91"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
@@ -812,7 +812,7 @@ dependencies = [
"crc32fast",
"futures-lite",
"pin-project",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tokio-util",
]
@@ -903,9 +903,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.17.3"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1"
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -914,9 +914,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.43.0"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c"
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
dependencies = [
"cc",
"cmake",
@@ -1253,7 +1253,7 @@ dependencies = [
"regex-lite",
"roxmltree",
"serde_json",
"thiserror 2.0.19",
"thiserror 2.0.20",
]
[[package]]
@@ -1792,7 +1792,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"thiserror 2.0.19",
"thiserror 2.0.20",
]
[[package]]
@@ -1840,9 +1840,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.4.1"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9066c49992464636f92905fa096ec58baaa4d57ec19a5c096c68d3e25ef3d136"
checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -2006,17 +2006,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "clocksource"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a4f8c23584e9dc6e40de1406e8c776ae727c49f7cb85c0bb23fb8c2096f7e0"
dependencies = [
"libc",
"time",
"winapi",
]
[[package]]
name = "cmake"
version = "0.1.58"
@@ -3415,7 +3404,7 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror 2.0.19",
"thiserror 2.0.20",
]
[[package]]
@@ -4484,7 +4473,7 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.11.0",
"thiserror 2.0.19",
"thiserror 2.0.20",
"time",
"tokio",
"url",
@@ -4505,7 +4494,7 @@ dependencies = [
"rand 0.10.2",
"serde",
"serde_json",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
]
@@ -4538,7 +4527,7 @@ dependencies = [
"rustc_version",
"serde",
"serde_json",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tokio-stream",
"tonic",
@@ -4645,7 +4634,7 @@ dependencies = [
"serde_json",
"serde_with",
"sha2 0.11.0",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tokio-stream",
"tracing",
@@ -4677,7 +4666,7 @@ dependencies = [
"serde",
"serde_json",
"serde_with",
"thiserror 2.0.19",
"thiserror 2.0.20",
"time",
"url",
]
@@ -4894,7 +4883,7 @@ dependencies = [
"ipnet",
"jni",
"rand 0.10.2",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tinyvec",
"tokio",
"tracing",
@@ -4915,7 +4904,7 @@ dependencies = [
"prefix-trie",
"rand 0.10.2",
"ring",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tinyvec",
"tracing",
"url",
@@ -4942,7 +4931,7 @@ dependencies = [
"resolv-conf",
"smallvec",
"system-configuration",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tracing",
]
@@ -5601,7 +5590,7 @@ dependencies = [
"jni-sys",
"log",
"simd_cesu8",
"thiserror 2.0.19",
"thiserror 2.0.20",
"walkdir",
"windows-link",
]
@@ -5650,9 +5639,9 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.103"
version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
dependencies = [
"cfg-if",
"futures-util",
@@ -5963,7 +5952,7 @@ dependencies = [
"once_cell",
"serde",
"sha2 0.10.9",
"thiserror 2.0.19",
"thiserror 2.0.20",
"uuid",
]
@@ -5989,7 +5978,7 @@ dependencies = [
"rustls",
"slog",
"slog-stdlog",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tokio-rustls",
"tokio-util",
@@ -6104,7 +6093,7 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb4bdc8b0ce69932332cf76d24af69c3a155242af95c226b2ab6c2e371ed1149"
dependencies = [
"thiserror 2.0.19",
"thiserror 2.0.20",
"zerocopy",
"zerocopy-derive",
]
@@ -6452,7 +6441,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff7ae19c74aba9e0ed6e4071cd52aa364e020076fa3cc6ef17e43662f756f3c"
dependencies = [
"bytes",
"thiserror 2.0.19",
"thiserror 2.0.20",
]
[[package]]
@@ -6482,7 +6471,7 @@ dependencies = [
"quote",
"syn 2.0.119",
"termcolor",
"thiserror 2.0.19",
"thiserror 2.0.20",
]
[[package]]
@@ -6506,7 +6495,7 @@ dependencies = [
"rustls",
"serde",
"socket2",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tokio-rustls",
"tokio-util",
@@ -6539,7 +6528,7 @@ dependencies = [
"serde_json",
"sha1 0.10.7",
"sha2 0.10.9",
"thiserror 2.0.19",
"thiserror 2.0.20",
"uuid",
]
@@ -6975,7 +6964,7 @@ dependencies = [
"itertools 0.14.0",
"parking_lot",
"percent-encoding",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tracing",
"url",
@@ -7062,7 +7051,7 @@ dependencies = [
"futures-sink",
"js-sys",
"pin-project-lite",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tracing",
]
@@ -7106,7 +7095,7 @@ dependencies = [
"opentelemetry_sdk",
"prost 0.14.4",
"reqwest",
"thiserror 2.0.19",
"thiserror 2.0.20",
]
[[package]]
@@ -7150,7 +7139,7 @@ dependencies = [
"percent-encoding",
"portable-atomic",
"rand 0.9.5",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tokio-stream",
]
@@ -7204,7 +7193,7 @@ dependencies = [
"rc2",
"sha1 0.10.7",
"sha2 0.10.9",
"thiserror 2.0.19",
"thiserror 2.0.20",
"x509-parser",
]
@@ -7297,7 +7286,7 @@ dependencies = [
"log",
"rand 0.10.2",
"sha2 0.11.0",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"windows",
"windows-strings",
@@ -7425,7 +7414,7 @@ checksum = "97f6fccfd2d9d2df765ca23ff85fe5cc437fb0e6d3e164e4d3cbe09d14780c93"
dependencies = [
"arrayvec",
"bitflags 2.13.1",
"thiserror 2.0.19",
"thiserror 2.0.20",
"zerocopy",
"zerocopy-derive",
]
@@ -7925,7 +7914,7 @@ dependencies = [
"lazy_static",
"memchr",
"parking_lot",
"thiserror 2.0.19",
"thiserror 2.0.20",
]
[[package]]
@@ -8147,7 +8136,7 @@ dependencies = [
"spin 0.12.2",
"symbolic-demangle",
"tempfile",
"thiserror 2.0.19",
"thiserror 2.0.20",
"url",
"uuid",
]
@@ -8205,7 +8194,7 @@ dependencies = [
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tracing",
"web-time",
@@ -8228,7 +8217,7 @@ dependencies = [
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tinyvec",
"tracing",
"web-time",
@@ -8394,13 +8383,11 @@ dependencies = [
[[package]]
name = "ratelimit"
version = "0.10.1"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dc94ed8e3de45f6d8d052869d48c0dbeebcaa7a6c345ec7f0f917e10347428e"
checksum = "e78b08065c51c82ff8c4a0d88e3dce3edfce39c375e946c3464210fff3433fb8"
dependencies = [
"clocksource",
"parking_lot",
"thiserror 2.0.19",
"thiserror 2.0.20",
]
[[package]]
@@ -8540,7 +8527,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 2.0.19",
"thiserror 2.0.20",
]
[[package]]
@@ -8679,7 +8666,7 @@ dependencies = [
"http 1.5.0",
"reqwest",
"serde",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tower-service",
]
@@ -8809,7 +8796,7 @@ dependencies = [
"rustls-native-certs",
"rustls-pki-types",
"rustls-webpki",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tokio-rustls",
]
@@ -8842,7 +8829,7 @@ dependencies = [
"rustls-native-certs",
"rustls-pki-types",
"rustls-webpki",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tokio-rustls",
"tokio-util",
@@ -8913,7 +8900,7 @@ dependencies = [
"ssh-encoding",
"ssh-key",
"subtle",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"typenum",
"universal-hash",
@@ -8946,7 +8933,7 @@ dependencies = [
"log",
"serde",
"serde_bytes",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tokio-util",
"wasm-bindgen-futures",
@@ -9139,7 +9126,7 @@ dependencies = [
"sysinfo",
"temp-env",
"tempfile",
"thiserror 2.0.19",
"thiserror 2.0.20",
"time",
"tokio",
"tokio-rustls",
@@ -9176,7 +9163,7 @@ dependencies = [
"serde",
"serde_json",
"temp-env",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tracing",
"url",
@@ -9270,7 +9257,7 @@ dependencies = [
"serde_json",
"sha2 0.11.0",
"test-case",
"thiserror 2.0.19",
"thiserror 2.0.20",
"time",
]
@@ -9388,9 +9375,10 @@ dependencies = [
"smallvec",
"temp-env",
"tempfile",
"thiserror 2.0.19",
"thiserror 2.0.20",
"time",
"tokio",
"tokio-stream",
"tokio-util",
"tonic",
"tower",
@@ -9430,7 +9418,7 @@ dependencies = [
"hotpath",
"serde",
"serde_json",
"thiserror 2.0.19",
"thiserror 2.0.20",
]
[[package]]
@@ -9452,7 +9440,7 @@ dependencies = [
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.19",
"thiserror 2.0.20",
"time",
"tokio",
"tracing",
@@ -9483,7 +9471,7 @@ dependencies = [
"serial_test",
"temp-env",
"tempfile",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tokio-util",
"tracing",
@@ -9505,6 +9493,7 @@ dependencies = [
"moka",
"openidconnect",
"pollster",
"rcgen",
"reqwest",
"rustfs-config",
"rustfs-credentials",
@@ -9516,12 +9505,14 @@ dependencies = [
"rustfs-storage-api",
"rustfs-test-utils",
"rustfs-utils",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serial_test",
"temp-env",
"tempfile",
"thiserror 2.0.19",
"thiserror 2.0.20",
"time",
"tokio",
"tokio-util",
@@ -9537,7 +9528,7 @@ dependencies = [
"hotpath",
"memmap2",
"rustfs-io-metrics",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tracing",
]
@@ -9555,7 +9546,7 @@ dependencies = [
"rustfs-s3-ops",
"rustfs-utils",
"sysinfo",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tracing",
]
@@ -9579,7 +9570,7 @@ dependencies = [
"rustls-native-certs",
"sha2 0.11.0",
"socket2",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tracing",
"twox-hash",
"webpki-roots 1.0.9",
@@ -9627,7 +9618,7 @@ dependencies = [
"serde",
"serde_json",
"temp-env",
"thiserror 2.0.19",
"thiserror 2.0.20",
"time",
"tokio",
"tower",
@@ -9672,7 +9663,7 @@ dependencies = [
"subtle",
"temp-env",
"tempfile",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tokio-util",
"tracing",
@@ -9722,7 +9713,7 @@ dependencies = [
"serde_json",
"smallvec",
"smartstring",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tonic",
"tracing",
@@ -9742,7 +9733,7 @@ dependencies = [
"sha2 0.11.0",
"tar",
"tempfile",
"thiserror 2.0.19",
"thiserror 2.0.20",
"walkdir",
"zip",
"zstd",
@@ -9790,7 +9781,7 @@ dependencies = [
"serde_json",
"starshard",
"tempfile",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tokio-util",
"tracing",
@@ -9830,7 +9821,7 @@ dependencies = [
"moka",
"starshard",
"sysinfo",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tracing",
]
@@ -9878,7 +9869,7 @@ dependencies = [
"sysinfo",
"temp-env",
"tempfile",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tokio-util",
"tracing",
@@ -9914,7 +9905,7 @@ dependencies = [
"strum 0.28.0",
"temp-env",
"test-case",
"thiserror 2.0.19",
"thiserror 2.0.20",
"time",
"tokio",
"tracing",
@@ -9946,7 +9937,6 @@ dependencies = [
"md-5 0.11.0",
"percent-encoding",
"proptest",
"quick-xml",
"regex",
"russh",
"russh-sftp",
@@ -9971,7 +9961,7 @@ dependencies = [
"socket2",
"subtle",
"tempfile",
"thiserror 2.0.19",
"thiserror 2.0.20",
"time",
"tokio",
"tokio-rustls",
@@ -10056,7 +10046,7 @@ dependencies = [
"serde_json",
"sha1 0.11.0",
"sha2 0.11.0",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tokio-test",
"tokio-util",
@@ -10125,12 +10115,13 @@ dependencies = [
"s3s",
"serde_json",
"serial_test",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tokio-util",
"tracing",
"transform-stream",
"url",
"uuid",
]
[[package]]
@@ -10146,6 +10137,7 @@ dependencies = [
"hotpath",
"parking_lot",
"rustfs-s3select-api",
"rustfs-test-utils",
"s3s",
"tokio",
"tracing",
@@ -10182,7 +10174,7 @@ dependencies = [
"sha2 0.11.0",
"temp-env",
"tempfile",
"thiserror 2.0.19",
"thiserror 2.0.20",
"time",
"tokio",
"tokio-util",
@@ -10196,7 +10188,7 @@ name = "rustfs-security-governance"
version = "1.0.0-rc.1"
dependencies = [
"hotpath",
"thiserror 2.0.19",
"thiserror 2.0.20",
]
[[package]]
@@ -10211,7 +10203,7 @@ dependencies = [
"rustfs-utils",
"s3s",
"serde_urlencoded",
"thiserror 2.0.19",
"thiserror 2.0.20",
"time",
"tracing",
"tracing-subscriber",
@@ -10274,7 +10266,7 @@ dependencies = [
"snap",
"sysinfo",
"tempfile",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tokio-postgres",
"tokio-postgres-rustls",
@@ -10318,7 +10310,7 @@ dependencies = [
"serde_json",
"sha2 0.11.0",
"tempfile",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tracing",
]
@@ -10342,7 +10334,7 @@ dependencies = [
"serde_json",
"serial_test",
"temp-env",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tower",
"tracing",
@@ -10411,7 +10403,7 @@ dependencies = [
"criterion",
"hotpath",
"tempfile",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tokio-stream",
"zip",
@@ -10600,7 +10592,7 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "s3s"
version = "0.14.1"
source = "git+https://github.com/cxymds/s3s.git?rev=fe3941d91fa1c69956f209a9145995c9f0235bff#fe3941d91fa1c69956f209a9145995c9f0235bff"
source = "git+https://github.com/rustfs/s3s.git?rev=d7028511a53f69d41ed3c69f36899f9b1aede647#d7028511a53f69d41ed3c69f36899f9b1aede647"
dependencies = [
"arc-swap",
"arrayvec",
@@ -10637,7 +10629,7 @@ dependencies = [
"std-next",
"subtle",
"sync_wrapper",
"thiserror 2.0.19",
"thiserror 2.0.20",
"time",
"tokio",
"tower",
@@ -11187,7 +11179,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [
"num-bigint 0.4.8",
"num-traits",
"thiserror 2.0.19",
"thiserror 2.0.20",
"time",
]
@@ -11479,7 +11471,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04082e93ed1a06debd9148c928234b46d2cf260bc65f44e1d1d3fa594c5beebc"
dependencies = [
"simdutf8",
"thiserror 2.0.19",
"thiserror 2.0.20",
]
[[package]]
@@ -11567,7 +11559,7 @@ dependencies = [
"pin-project",
"rustls",
"rustls-pki-types",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tokio-rustls",
]
@@ -11819,11 +11811,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.19"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl 2.0.19",
"thiserror-impl 2.0.20",
]
[[package]]
@@ -11839,9 +11831,9 @@ dependencies = [
[[package]]
name = "thiserror-impl"
version = "2.0.19"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
@@ -12299,7 +12291,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
dependencies = [
"crossbeam-channel",
"symlink",
"thiserror 2.0.19",
"thiserror 2.0.20",
"time",
"tracing-subscriber",
]
@@ -12434,7 +12426,7 @@ dependencies = [
"rustls",
"rustls-pki-types",
"sha1 0.10.7",
"thiserror 2.0.19",
"thiserror 2.0.20",
]
[[package]]
@@ -12472,7 +12464,7 @@ dependencies = [
"derive_more",
"libc",
"md-5 0.10.6",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"x509-parser",
]
@@ -12620,7 +12612,7 @@ dependencies = [
"rustify_derive",
"serde",
"serde_json",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tracing",
"url",
]
@@ -12700,9 +12692,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.126"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
dependencies = [
"cfg-if",
"once_cell",
@@ -12713,9 +12705,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.76"
version = "0.4.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -12723,9 +12715,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.126"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -12733,9 +12725,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.126"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -12746,9 +12738,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.126"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
dependencies = [
"unicode-ident",
]
@@ -12768,9 +12760,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.103"
version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -13136,7 +13128,7 @@ dependencies = [
"nom 7.1.3",
"oid-registry",
"rusticata-macros",
"thiserror 2.0.19",
"thiserror 2.0.20",
"time",
]
+5 -5
View File
@@ -139,7 +139,7 @@ async_zip = { default-features = false, version = "0.0.18" }
mysql_async = { default-features = false, version = "0.37" }
async-compression = { version = "0.4.43" }
async-recursion = "1.1.1"
async-trait = "0.1.91"
async-trait = "0.1.92"
async-nats = { version = "0.50.0", default-features = false }
axum = "0.8.9"
futures = "0.3.33"
@@ -278,7 +278,7 @@ percent-encoding = "2.3.2"
pin-project-lite = "0.2.17"
pretty_assertions = "1.4.1"
rand = { version = "0.10.2" }
ratelimit = "0.10.1"
ratelimit = "2.0.0"
rayon = "1.12.0"
reed-solomon-erasure = { package = "rustfs-erasure-codec", version = "8.0.2" }
reed-solomon-simd = "3.1.0"
@@ -289,7 +289,7 @@ rustify = { version = "0.7", default-features = false }
rustix = { version = "1.1.4" }
rust-embed = { version = "8.12.0" }
rustc-hash = { version = "2.1.3" }
s3s = { git = "https://github.com/cxymds/s3s.git", rev = "fe3941d91fa1c69956f209a9145995c9f0235bff" }
s3s = { git = "https://github.com/rustfs/s3s.git", rev = "d7028511a53f69d41ed3c69f36899f9b1aede647" }
serial_test = "4.0.1"
shadow-rs = { default-features = false, version = "2.0.0" }
siphasher = "1.0.3"
@@ -302,7 +302,7 @@ sysinfo = "0.39.6"
temp-env = "0.3.6"
tempfile = "3.27.0"
test-case = "3.3.1"
thiserror = "2.0.19"
thiserror = "2.0.20"
tracing = { version = "0.1.44" }
tracing-appender = "0.2.5"
tracing-core = "0.1.36"
@@ -354,7 +354,7 @@ hotpath = { version = "0.23.1", default-features = false }
insta = { version = "1.48" }
[workspace.metadata.cargo-shear]
ignored = ["rustfs"]
ignored = ["hotpath", "rustfs"]
[profile.dev]
# Full debuginfo roughly doubles compile+link time and produces multi-GB
+6 -1
View File
@@ -91,7 +91,12 @@ LABEL name="RustFS" \
# Upgrade base-image packages so published images pick up security fixes
# (e.g. openssl/libssl3 CVEs) without waiting for a new Alpine point release.
RUN apk upgrade --no-cache && \
apk add --no-cache ca-certificates coreutils curl
apk add --no-cache \
ca-certificates \
coreutils \
curl \
tzdata \
&& test "$(TZ=Asia/Kolkata date +%z)" = "+0530"
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /build/rustfs /usr/bin/rustfs
+3 -1
View File
@@ -96,9 +96,11 @@ LABEL name="RustFS" \
# Upgrade base-image packages so published images pick up security fixes
# (e.g. tar/gzip/perl CVEs) without waiting for a new Ubuntu point release.
RUN apt-get update && apt-get upgrade -y \
&& apt-get install -y --no-install-recommends \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates \
curl \
tzdata \
&& test "$(TZ=Asia/Kolkata date +%z)" = "+0530" \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /build/rustfs /usr/bin/rustfs
+8
View File
@@ -97,6 +97,14 @@ Current guidance:
- enables minimal payload mode for GET health responses (`status`, `ready` only).
- `RUSTFS_HEALTH_READINESS_CACHE_TTL_MS`
- TTL for readiness cache evaluation.
- `RUSTFS_HEALTH_OBJECT_PROGRESS_ENABLE`
- withdraws readiness when bounded object read/write stages stop completing while requests remain active.
- default is `true`.
- `RUSTFS_HEALTH_OBJECT_PROGRESS_TIMEOUT_MS`
- maximum time without completion in a bounded object stage before readiness is withdrawn.
- default is `30000`; `0` uses the default.
- the effective value is at least 5 seconds longer than `RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT`.
- this readiness SLO is independent of disk read/write failure deadlines and may withdraw traffic before those deadlines expire.
- `RUSTFS_HEALTH_COMPAT_BUSY_CHECK_ENABLE`
- enables busy protection behavior for health probes.
- default is `false`.
+13
View File
@@ -22,6 +22,19 @@ pub const DEFAULT_HEALTH_ENDPOINT_ENABLE: bool = true;
pub const ENV_HEALTH_READINESS_CACHE_TTL_MS: &str = "RUSTFS_HEALTH_READINESS_CACHE_TTL_MS";
pub const DEFAULT_HEALTH_READINESS_CACHE_TTL_MS: u64 = 1000;
/// Enable readiness withdrawal when bounded object read/write stages stop
/// completing while requests remain active.
pub const ENV_HEALTH_OBJECT_PROGRESS_ENABLE: &str = "RUSTFS_HEALTH_OBJECT_PROGRESS_ENABLE";
pub const DEFAULT_HEALTH_OBJECT_PROGRESS_ENABLE: bool = true;
/// Requested time without completion in a bounded object stage before local
/// readiness is withdrawn (milliseconds). A value of `0` uses the default;
/// runtime adds a safety floor based on the object-lock acquisition timeout.
pub const ENV_HEALTH_OBJECT_PROGRESS_TIMEOUT_MS: &str = "RUSTFS_HEALTH_OBJECT_PROGRESS_TIMEOUT_MS";
pub const DEFAULT_HEALTH_OBJECT_PROGRESS_TIMEOUT_MS: u64 = 30_000;
/// Additional time beyond the configured object-lock acquisition deadline.
pub const HEALTH_OBJECT_PROGRESS_LOCK_MARGIN_MS: u64 = 5_000;
/// Timeout for cluster health readiness collectors (milliseconds).
/// This bounds expensive storage and lock quorum checks used by cluster probes.
pub const ENV_HEALTH_CLUSTER_TIMEOUT_MS: &str = "RUSTFS_HEALTH_CLUSTER_TIMEOUT_MS";
+5
View File
@@ -36,6 +36,11 @@ pub const ENV_TRUST_SYSTEM_CA: &str = "RUSTFS_TRUST_SYSTEM_CA";
/// To change this behavior, set the environment variable RUSTFS_TRUST_SYSTEM_CA=1
pub const DEFAULT_TRUST_SYSTEM_CA: bool = false;
/// Environment variable for an extra outbound root CA certificate bundle.
/// Use this to trust an internal CA for outbound HTTPS clients without replacing
/// the default operating-system/web PKI roots via SSL_CERT_FILE.
pub const ENV_RUSTFS_EXTRA_CA_CERT: &str = "RUSTFS_EXTRA_CA_CERT";
/// Environment variable to trust leaf certificates as CA
/// When set to "1", RustFS will treat leaf certificates as CA certificates for trust validation.
/// By default, this is disabled.
+259 -1
View File
@@ -26,7 +26,9 @@
//! Later batches tracked on backlog#1154: config get/set, info, pools status,
//! group lifecycle, import/export IAM.
use crate::common::{RustFSTestEnvironment, admin_ok, admin_request, init_logging};
use crate::common::{
RustFSTestEnvironment, admin_ok, admin_request, admin_request_with_session_token, build_test_sts_client, init_logging,
};
use aws_sdk_s3::config::{Credentials, Region};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::{Client, Config};
@@ -87,6 +89,262 @@ fn bucket_rw_policy(bucket: &str) -> String {
.to_string()
}
async fn create_user_with_service_account_update_policy(
env: &RustFSTestEnvironment,
user: &str,
secret: &str,
policy: &str,
) -> TestResult {
admin_ok(
env,
http::Method::PUT,
&format!("/rustfs/admin/v3/add-user?accessKey={user}"),
Some(serde_json::json!({ "secretKey": secret, "status": "enabled" }).to_string()),
)
.await?;
admin_ok(
env,
http::Method::PUT,
&format!("/rustfs/admin/v3/add-canned-policy?name={policy}"),
Some(
serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["admin:UpdateServiceAccount"]
},
{
"Effect": "Allow",
"Action": ["sts:AssumeRole"],
"Resource": ["arn:aws:s3:::*"]
}
]
})
.to_string(),
),
)
.await?;
admin_ok(
env,
http::Method::POST,
"/rustfs/admin/v3/idp/builtin/policy/attach",
Some(serde_json::json!({ "policies": [policy], "user": user }).to_string()),
)
.await?;
Ok(())
}
async fn create_service_account_for(
env: &RustFSTestEnvironment,
parent: &str,
) -> Result<(String, String), Box<dyn Error + Send + Sync>> {
let response = admin_ok(
env,
http::Method::PUT,
"/rustfs/admin/v3/add-service-accounts",
Some(serde_json::json!({ "targetUser": parent }).to_string()),
)
.await?;
let response: serde_json::Value = serde_json::from_str(&response)?;
let access_key = response["credentials"]["accessKey"]
.as_str()
.ok_or("service account response should contain credentials.accessKey")?
.to_owned();
let secret_key = response["credentials"]["secretKey"]
.as_str()
.ok_or("service account response should contain credentials.secretKey")?
.to_owned();
Ok((access_key, secret_key))
}
async fn assert_admin_status(
env: &RustFSTestEnvironment,
credentials: (&str, &str, Option<&str>),
path: &str,
body: String,
expected: StatusCode,
context: &str,
) -> TestResult {
let (access_key, secret_key, session_token) = credentials;
let (status, response) =
admin_request_with_session_token(&env.url, http::Method::POST, path, Some(body), access_key, secret_key, session_token)
.await?;
assert_eq!(status, expected, "{context}: got {status}: {response}");
if expected == StatusCode::FORBIDDEN {
assert!(response.contains("AccessDenied"), "{context}: expected AccessDenied body, got {response}");
}
Ok(())
}
#[tokio::test]
#[serial]
async fn test_update_service_account_enforces_owner_and_parent_scope() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let parent = "updateparent";
let parent_secret = "updateparentsecret";
let outsider = "updateoutsider";
let outsider_secret = "updateoutsidersecret";
let ordinary = "updateordinary";
let ordinary_secret = "updateordinarysecret";
create_user_with_service_account_update_policy(&env, parent, parent_secret, "update-parent-policy").await?;
create_user_with_service_account_update_policy(&env, outsider, outsider_secret, "update-outsider-policy").await?;
admin_ok(
&env,
http::Method::POST,
"/rustfs/admin/v3/idp/builtin/policy/attach",
Some(serde_json::json!({ "policies": ["consoleAdmin"], "user": outsider }).to_string()),
)
.await?;
admin_ok(
&env,
http::Method::PUT,
&format!("/rustfs/admin/v3/add-user?accessKey={ordinary}"),
Some(serde_json::json!({ "secretKey": ordinary_secret, "status": "enabled" }).to_string()),
)
.await?;
let (target_access_key, _) = create_service_account_for(&env, parent).await?;
let target_path = format!("/rustfs/admin/v3/update-service-account?accessKey={target_access_key}");
assert_admin_status(
&env,
(&env.access_key, &env.secret_key, None),
&target_path,
serde_json::json!({}).to_string(),
StatusCode::NO_CONTENT,
"root no-op update across parents must succeed",
)
.await?;
let custom_policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::update-scope/*"]
}]
});
assert_admin_status(
&env,
(&env.access_key, &env.secret_key, None),
&target_path,
serde_json::json!({ "newPolicy": custom_policy }).to_string(),
StatusCode::NO_CONTENT,
"root implied-to-custom update across parents must succeed",
)
.await?;
assert_admin_status(
&env,
(parent, parent_secret, None),
&target_path,
serde_json::json!({ "newDescription": "updated by parent" }).to_string(),
StatusCode::NO_CONTENT,
"parent with UpdateServiceAccount may update its own service account",
)
.await?;
let takeover = serde_json::json!({
"newSecretKey": "cross-parent-takeover-secret",
"newDescription": "cross-parent takeover"
})
.to_string();
assert_admin_status(
&env,
(ordinary, ordinary_secret, None),
&target_path,
takeover.clone(),
StatusCode::FORBIDDEN,
"ordinary user must not update another parent's service account",
)
.await?;
assert_admin_status(
&env,
(outsider, outsider_secret, None),
&target_path,
takeover.clone(),
StatusCode::FORBIDDEN,
"non-owner consoleAdmin must not update across parents",
)
.await?;
let (derived_access_key, derived_secret_key) = create_service_account_for(&env, outsider).await?;
assert_admin_status(
&env,
(&derived_access_key, &derived_secret_key, None),
&target_path,
takeover.clone(),
StatusCode::FORBIDDEN,
"service-account credential must not update across parents",
)
.await?;
let assumed = build_test_sts_client(&env.url, outsider, outsider_secret, None, "e2e-admin-update-service-account")
.assume_role()
.role_arn("arn:aws:iam::123456789012:role/update-service-account")
.role_session_name("update-service-account-scope")
.send()
.await?;
let temporary = assumed
.credentials()
.ok_or("AssumeRole response should contain credentials")?;
assert_admin_status(
&env,
(temporary.access_key_id(), temporary.secret_access_key(), Some(temporary.session_token())),
&target_path,
takeover,
StatusCode::FORBIDDEN,
"temporary credential must not update across parents",
)
.await?;
let info = admin_ok(
&env,
http::Method::GET,
&format!("/rustfs/admin/v3/info-service-account?accessKey={target_access_key}"),
None,
)
.await?;
let info: serde_json::Value = serde_json::from_str(&info)?;
assert_eq!(
info["impliedPolicy"].as_bool(),
Some(false),
"root update must replace the implied policy with a custom policy"
);
assert!(
info["policy"].as_str().is_some_and(|policy| policy.contains("s3:GetObject")),
"custom policy must round-trip through the handler: {info}"
);
assert_eq!(
info["description"].as_str(),
Some("updated by parent"),
"denied takeover attempts must not mutate target"
);
let (missing_status, missing_body) = admin_request(
&env.url,
http::Method::POST,
"/rustfs/admin/v3/update-service-account?accessKey=missing-service-account",
Some(serde_json::json!({}).to_string()),
&env.access_key,
&env.secret_key,
)
.await?;
assert_eq!(missing_status, StatusCode::NOT_FOUND, "missing target must fail closed: {missing_body}");
assert!(
missing_body.contains("NoSuchResource"),
"missing target must preserve the lookup error: {missing_body}"
);
env.stop_server();
Ok(())
}
/// Full user -> policy -> service-account lifecycle, proving each management
/// call takes effect on the data plane, not just that the endpoint answers 200.
#[tokio::test]
+101
View File
@@ -40,6 +40,7 @@ use http::header::{CONTENT_TYPE, HOST};
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4;
use s3s::Body;
use std::collections::BTreeSet;
use std::error::Error;
use std::path::{Path, PathBuf};
use tracing::info;
@@ -48,6 +49,34 @@ use walkdir::WalkDir;
type ChaosResult<T> = Result<T, Box<dyn Error + Send + Sync>>;
/// Physical `xl.meta` and shard-file census for one object version on one disk.
///
/// A successful S3 GET only proves that a quorum can serve an object. Replacement
/// tests need this lower-level record to prove that the rebuilt target holds the
/// `xl.meta` selected for a specific version and every `part.N` it declares.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct VersionShardCensus {
pub version_id: Option<String>,
pub has_xl_meta: bool,
pub data_dir: Option<String>,
pub expected_part_numbers: BTreeSet<usize>,
pub present_part_numbers: BTreeSet<usize>,
}
impl VersionShardCensus {
pub(crate) fn is_complete(&self) -> bool {
self.has_xl_meta && self.expected_part_numbers == self.present_part_numbers
}
pub(crate) fn matches_manifest(&self, manifest: &Self) -> bool {
self.version_id == manifest.version_id
&& self.is_complete()
&& manifest.is_complete()
&& self.data_dir == manifest.data_dir
&& self.expected_part_numbers == manifest.expected_part_numbers
}
}
/// Single-node RustFS server with `disk_count` local volume directories that
/// can be faulted individually while the server is running.
pub struct DiskFaultHarness {
@@ -219,6 +248,78 @@ impl DiskFaultHarness {
pub fn object_metadata_exists_on_disk(&self, disk_index: usize, bucket: &str, key: &str) -> bool {
self.disks[disk_index].join(bucket).join(key).join("xl.meta").is_file()
}
/// Census the physical files selected by `version_id` on one disk.
///
/// Missing metadata and missing shard files are represented in the returned
/// census rather than as an error so callers can poll replacement progress.
/// Invalid metadata or an unknown requested version remains an error: treating
/// either as an incomplete rebuild would hide corruption or a wrong-version
/// recovery result.
pub(crate) fn census_object_version(
&self,
disk_index: usize,
bucket: &str,
key: &str,
version_id: Option<&str>,
) -> ChaosResult<VersionShardCensus> {
census_object_version_on_disk(&self.disks[disk_index], bucket, key, version_id)
}
}
/// Census one physical object version without requiring a single-node harness.
/// Cluster replacement tests use the same evidence as the disk-fault tests.
pub(crate) fn census_object_version_on_disk(
disk: &Path,
bucket: &str,
key: &str,
version_id: Option<&str>,
) -> ChaosResult<VersionShardCensus> {
let version_id = version_id.map(str::to_owned);
let object_dir = disk.join(bucket).join(key);
let meta_path = object_dir.join("xl.meta");
if !meta_path.is_file() {
return Ok(VersionShardCensus {
version_id,
has_xl_meta: false,
data_dir: None,
expected_part_numbers: BTreeSet::new(),
present_part_numbers: BTreeSet::new(),
});
}
let metadata = rustfs_filemeta::FileMeta::load(&std::fs::read(&meta_path)?)?;
let file_info = metadata.into_fileinfo(bucket, key, version_id.as_deref().unwrap_or_default(), true, false, true)?;
let expected_part_numbers = if file_info.inline_data() {
BTreeSet::new()
} else {
file_info.parts.iter().map(|part| part.number).collect()
};
let data_dir = file_info.data_dir.map(|id| id.to_string());
let part_dir = data_dir.as_ref().map_or_else(|| object_dir.clone(), |id| object_dir.join(id));
let present_part_numbers = match std::fs::read_dir(&part_dir) {
Ok(entries) => entries
.filter_map(Result::ok)
.filter_map(|entry| {
entry
.file_type()
.ok()
.filter(|kind| kind.is_file())
.and_then(|_| entry.file_name().to_str().map(str::to_owned))
})
.filter_map(|name| name.strip_prefix("part.").and_then(|number| number.parse::<usize>().ok()))
.collect(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => BTreeSet::new(),
Err(error) => return Err(error.into()),
};
Ok(VersionShardCensus {
version_id,
has_xl_meta: true,
data_dir,
expected_part_numbers,
present_part_numbers,
})
}
/// `POST` a signed (SigV4, service `s3`) admin request without relying on the
+34 -2
View File
@@ -137,6 +137,18 @@ pub(crate) async fn signed_s3_request(
content_type: Option<&str>,
access_key: &str,
secret_key: &str,
) -> Result<reqwest::Response, Box<dyn std::error::Error + Send + Sync>> {
signed_s3_request_with_session_token(method, url, body, content_type, access_key, secret_key, None).await
}
async fn signed_s3_request_with_session_token(
method: http::Method,
url: &str,
body: Option<String>,
content_type: Option<&str>,
access_key: &str,
secret_key: &str,
session_token: Option<&str>,
) -> Result<reqwest::Response, Box<dyn std::error::Error + Send + Sync>> {
let uri = url.parse::<http::Uri>()?;
let authority = uri.authority().ok_or("S3 URL missing authority")?.to_string();
@@ -150,7 +162,14 @@ pub(crate) async fn signed_s3_request(
}
let content_length = i64::try_from(body.as_ref().map_or(0, String::len)).map_err(|_| "S3 request body is too large")?;
let signed = sign_v4(request.body(Body::empty())?, content_length, access_key, secret_key, "", "us-east-1");
let signed = sign_v4(
request.body(Body::empty())?,
content_length,
access_key,
secret_key,
session_token.unwrap_or_default(),
"us-east-1",
);
let mut request = local_http_client().request(method, url);
for (name, value) in signed.headers() {
@@ -170,10 +189,23 @@ pub(crate) async fn admin_request(
body: Option<String>,
access_key: &str,
secret_key: &str,
) -> Result<(StatusCode, String), Box<dyn std::error::Error + Send + Sync>> {
admin_request_with_session_token(base_url, method, path_and_query, body, access_key, secret_key, None).await
}
pub(crate) async fn admin_request_with_session_token(
base_url: &str,
method: http::Method,
path_and_query: &str,
body: Option<String>,
access_key: &str,
secret_key: &str,
session_token: Option<&str>,
) -> Result<(StatusCode, String), Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{base_url}{path_and_query}");
let content_type = body.as_ref().map(|_| "application/json");
let response = signed_s3_request(method, &url, body, content_type, access_key, secret_key).await?;
let response =
signed_s3_request_with_session_token(method, &url, body, content_type, access_key, secret_key, session_token).await?;
let status = response.status();
let body = response.text().await?;
Ok((status, body))
+208 -26
View File
@@ -30,10 +30,10 @@ use s3s::access::{S3Access, S3AccessContext};
use s3s::auth::SimpleAuth;
use s3s::dto::{
AbortMultipartUploadInput, AbortMultipartUploadOutput, CompleteMultipartUploadInput, CompleteMultipartUploadOutput,
CreateMultipartUploadInput, CreateMultipartUploadOutput, DeleteObjectInput, DeleteObjectOutput, ETag,
CreateMultipartUploadInput, CreateMultipartUploadOutput, DeleteMarkerEntry, DeleteObjectInput, DeleteObjectOutput, ETag,
GetBucketVersioningInput, GetBucketVersioningOutput, GetObjectInput, GetObjectOutput, HeadBucketInput, HeadBucketOutput,
HeadObjectInput, HeadObjectOutput, PutObjectInput, PutObjectOutput, StreamingBlob, Timestamp, TimestampFormat,
UploadPartInput, UploadPartOutput,
HeadObjectInput, HeadObjectOutput, ListObjectVersionsInput, ListObjectVersionsOutput, ObjectVersionId, PutObjectInput,
PutObjectOutput, StreamingBlob, Timestamp, TimestampFormat, UploadPartInput, UploadPartOutput,
};
use s3s::service::{S3Service, S3ServiceBuilder};
use s3s::validation::{AwsNameValidation, NameValidation};
@@ -91,6 +91,7 @@ pub enum Operation {
GetObject,
HeadObject,
DeleteObject,
ListObjectVersions,
CreateMultipartUpload,
UploadPart,
CompleteMultipartUpload,
@@ -109,6 +110,8 @@ pub enum FaultAction {
/// already have buffered the rest of the current frame; the journal reports
/// the threshold, and the backend never receives or stores the request.
DisconnectAfterBytes(usize),
/// Apply the request, then close the connection before returning its response.
DisconnectAfterResponse,
/// Drain a request body in fixed-size slices, sleeping after every slice.
SlowDrain { chunk_bytes: usize, delay: Duration },
/// Store the request normally but replace the response ETag.
@@ -134,12 +137,15 @@ pub struct RequestRecord {
#[derive(Default)]
struct ControlState {
scripts: HashMap<Operation, VecDeque<FaultAction>>,
keyed_scripts: HashMap<(Operation, String), VecDeque<FaultAction>>,
requests: VecDeque<RequestRecord>,
next_sequence: u64,
}
#[derive(Default)]
struct StoreState {
assign_own_version_ids: bool,
assign_own_multipart_version_ids: bool,
buckets: HashMap<String, BucketState>,
uploads: HashMap<String, MultipartState>,
total_bytes: usize,
@@ -352,25 +358,60 @@ impl FakeS3Target {
state.buckets.entry(bucket).or_default();
}
/// Remove all retained object versions while preserving the bucket.
pub fn clear_bucket_objects(&self, bucket: &str) {
let mut state = lock(&self.backend.store);
let (removed_versions, removed_bytes) = state
.buckets
.get_mut(bucket)
.expect("fake target bucket must exist")
.objects
.drain()
.flat_map(|(_, versions)| versions)
.fold((0usize, 0usize), |(count, bytes), version| (count + 1, bytes + version.body.len()));
state.total_versions = state
.total_versions
.checked_sub(removed_versions)
.expect("fake target version accounting must not underflow");
state.total_bytes = state
.total_bytes
.checked_sub(removed_bytes)
.expect("fake target byte accounting must not underflow");
}
pub fn has_object(&self, bucket: &str, key: &str) -> bool {
lock(&self.backend.store)
.buckets
.get(bucket)
.and_then(|bucket| bucket.objects.get(key))
.and_then(|versions| versions.last())
.is_some_and(|version| !version.delete_marker)
}
/// Make the target mint its own version ids instead of mirroring the
/// forwarded source version id — models a generic S3 service.
pub fn assign_own_version_ids(&self, enabled: bool) {
lock(&self.backend.store).assign_own_version_ids = enabled;
}
/// Mint own version ids for the multipart path only — models a target
/// that adopts PutObject version ids but not CreateMultipartUpload ones.
pub fn assign_own_multipart_version_ids(&self, enabled: bool) {
lock(&self.backend.store).assign_own_multipart_version_ids = enabled;
}
pub fn active_multipart_upload_count(&self) -> usize {
lock(&self.backend.store).uploads.len()
}
/// Queue `times` copies of a fault for one operation.
pub fn inject(&self, operation: Operation, action: FaultAction, times: usize) {
if times == 0 {
return;
}
if let FaultAction::SlowDrain { chunk_bytes: 0, .. } = action {
panic!("slow-drain chunk size must be non-zero");
}
match &action {
FaultAction::Delay(duration) if *duration > MAX_FAULT_DURATION => {
panic!("fault delay must not exceed 30 seconds");
}
FaultAction::SlowDrain { delay, .. } if *delay >= MAX_FAULT_DURATION => {
panic!("slow-drain slice delay must be below 30 seconds");
}
_ => {}
}
validate_fault_action(&action);
let mut state = lock(&self.control);
let queued = state.scripts.values().map(VecDeque::len).sum::<usize>();
let queued = queued_fault_count(&state);
if queued.checked_add(times).is_none_or(|total| total > MAX_SCRIPTED_FAULTS) {
panic!("fake target queues at most 4096 scripted faults");
}
@@ -381,8 +422,28 @@ impl FakeS3Target {
.extend(std::iter::repeat_n(action, times));
}
/// Queue faults for one exact object key without affecting concurrent requests.
pub fn inject_for_key(&self, operation: Operation, key: impl Into<String>, action: FaultAction, times: usize) {
if times == 0 {
return;
}
validate_fault_action(&action);
let mut state = lock(&self.control);
let queued = queued_fault_count(&state);
if queued.checked_add(times).is_none_or(|total| total > MAX_SCRIPTED_FAULTS) {
panic!("fake target queues at most 4096 scripted faults");
}
state
.keyed_scripts
.entry((operation, key.into()))
.or_default()
.extend(std::iter::repeat_n(action, times));
}
pub fn clear_faults(&self) {
lock(&self.control).scripts.clear();
let mut state = lock(&self.control);
state.scripts.clear();
state.keyed_scripts.clear();
}
pub fn requests(&self) -> Vec<RequestRecord> {
@@ -393,6 +454,25 @@ impl FakeS3Target {
lock(&self.control).requests.drain(..).collect()
}
/// Stored versions for one key as `(version_id, is_delete_marker)`, oldest
/// first. Empty when the bucket or key does not exist. Lets purge tests
/// assert on the target's actual state instead of inferring it from the
/// request journal (a versioned DELETE is a silent no-op for missing ids).
pub fn stored_versions(&self, bucket: &str, key: &str) -> Vec<(String, bool)> {
let state = lock(&self.backend.store);
state
.buckets
.get(bucket)
.and_then(|bucket_state| bucket_state.objects.get(key))
.map(|versions| {
versions
.iter()
.map(|version| (version.version_id.clone(), version.delete_marker))
.collect()
})
.unwrap_or_default()
}
pub async fn shutdown(mut self) {
let _ = self.shutdown.send(true);
if let Some(task) = self.task.take() {
@@ -420,6 +500,25 @@ fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn validate_fault_action(action: &FaultAction) {
if let FaultAction::SlowDrain { chunk_bytes: 0, .. } = action {
panic!("slow-drain chunk size must be non-zero");
}
match action {
FaultAction::Delay(duration) if *duration > MAX_FAULT_DURATION => {
panic!("fault delay must not exceed 30 seconds");
}
FaultAction::SlowDrain { delay, .. } if *delay >= MAX_FAULT_DURATION => {
panic!("slow-drain slice delay must be below 30 seconds");
}
_ => {}
}
}
fn queued_fault_count(state: &ControlState) -> usize {
state.scripts.values().map(VecDeque::len).sum::<usize>() + state.keyed_scripts.values().map(VecDeque::len).sum::<usize>()
}
#[async_trait]
impl S3Access for FaultAccess {
async fn check(&self, context: &mut S3AccessContext<'_>) -> S3Result<()> {
@@ -492,7 +591,12 @@ fn record_request(
content_length: Option<u64>,
) -> Option<RequestFault> {
let mut state = lock(control);
let action = state.scripts.get_mut(&operation).and_then(VecDeque::pop_front);
let action = parsed
.key
.as_ref()
.and_then(|key| state.keyed_scripts.get_mut(&(operation, key.clone())))
.and_then(VecDeque::pop_front)
.or_else(|| state.scripts.get_mut(&operation).and_then(VecDeque::pop_front));
state.next_sequence += 1;
let sequence = state.next_sequence;
if state.requests.len() == MAX_REQUEST_RECORDS {
@@ -569,6 +673,7 @@ fn parse_request(method: &Method, uri: &Uri) -> ParsedRequest {
let operation = match (method, key.is_some()) {
(&Method::HEAD, false) => Operation::HeadBucket,
(&Method::GET, false) if query.contains_key("versioning") => Operation::GetBucketVersioning,
(&Method::GET, false) if query.contains_key("versions") => Operation::ListObjectVersions,
(&Method::PUT, true) if upload_id.is_some() && part_number.is_some() => Operation::UploadPart,
(&Method::PUT, true) if upload_id.is_some() || query.contains_key("partNumber") => Operation::Unknown,
(&Method::POST, true) if query.contains_key("uploads") => Operation::CreateMultipartUpload,
@@ -625,10 +730,17 @@ fn validate_retained_identifier(value: String, field: &str) -> S3Result<String>
}
}
fn new_version_id(headers: &HeaderMap) -> S3Result<String> {
/// `assign_own` models a target that mints its own version ids (a generic S3
/// service): the forwarded source-version-id header is validated but NOT
/// mirrored into the stored version.
fn new_version_id(headers: &HeaderMap, assign_own: bool) -> S3Result<String> {
let Some(value) = header_value(headers, &SOURCE_VERSION_ID_HEADERS) else {
return Ok(Uuid::new_v4().to_string());
};
if assign_own {
validate_retained_identifier(value.trim().to_owned(), "source version ID")?;
return Ok(Uuid::new_v4().to_string());
}
let value = validate_retained_identifier(value.trim().to_owned(), "source version ID")?;
let version_id = Uuid::parse_str(&value).map_err(|_| s3s::s3_error!(InvalidArgument, "source version ID must be a UUID"))?;
Ok(version_id.to_string())
@@ -713,7 +825,10 @@ async fn apply_non_body_fault(fault: Option<&RequestFault>, control: &Mutex<Cont
update_consumed(control, fault.expect("matched fault").sequence, 0);
Err(scripted_disconnect_error())
}
Some(FaultAction::SlowDrain { .. }) | Some(FaultAction::WrongEtag) | None => Ok(()),
Some(FaultAction::SlowDrain { .. })
| Some(FaultAction::WrongEtag)
| Some(FaultAction::DisconnectAfterResponse)
| None => Ok(()),
}
}
@@ -754,7 +869,7 @@ async fn collect_stream(
Some(FaultAction::SlowDrain { chunk_bytes, delay }) => {
return collect_stream_slow(body, capacity, *chunk_bytes, *delay).await;
}
Some(FaultAction::WrongEtag) | None => {}
Some(FaultAction::WrongEtag) | Some(FaultAction::DisconnectAfterResponse) | None => {}
}
let mut output = BytesMut::with_capacity(capacity);
@@ -816,6 +931,9 @@ fn apply_response_fault<T>(mut response: S3Response<T>, fault: Option<&RequestFa
if fault.is_some_and(|fault| fault.action == FaultAction::WrongEtag) {
response.headers.insert(ETAG, HeaderValue::from_static(WRONG_ETAG));
}
if fault.is_some_and(|fault| fault.action == FaultAction::DisconnectAfterResponse) {
response.headers.insert(DISCONNECT_HEADER, HeaderValue::from_static("true"));
}
response
}
@@ -1004,6 +1122,63 @@ impl S3 for FakeBackend {
))
}
/// Prefix + max-keys subset only — enough for the replication-check probe
/// key allocation. No pagination markers or delimiter folding.
async fn list_object_versions(
&self,
req: S3Request<ListObjectVersionsInput>,
) -> S3Result<S3Response<ListObjectVersionsOutput>> {
let fault = request_fault(&req);
apply_non_body_fault(fault.as_ref(), &self.control).await?;
let state = lock(&self.store);
let Some(bucket_state) = state.buckets.get(&req.input.bucket) else {
return Err(s3s::s3_error!(NoSuchBucket, "bucket does not exist"));
};
let prefix = req.input.prefix.as_deref().unwrap_or_default();
let max_keys = req.input.max_keys.unwrap_or(1000).max(0) as usize;
let mut keys: Vec<&String> = bucket_state.objects.keys().filter(|key| key.starts_with(prefix)).collect();
keys.sort();
let mut versions = Vec::new();
let mut delete_markers = Vec::new();
'keys: for key in keys {
for version in bucket_state.objects[key].iter().rev() {
if versions.len() + delete_markers.len() >= max_keys {
break 'keys;
}
if version.delete_marker {
delete_markers.push(DeleteMarkerEntry {
key: Some(key.clone()),
version_id: Some(ObjectVersionId::from(version.version_id.clone())),
last_modified: Some(version.last_modified.clone()),
..Default::default()
});
} else {
versions.push(s3s::dto::ObjectVersion {
key: Some(key.clone()),
version_id: Some(ObjectVersionId::from(version.version_id.clone())),
last_modified: Some(version.last_modified.clone()),
e_tag: Some(ETag::Strong(version.e_tag.clone())),
size: Some(version.body.len() as i64),
..Default::default()
});
}
}
}
drop(state);
Ok(apply_response_fault(
S3Response::new(ListObjectVersionsOutput {
name: Some(req.input.bucket),
versions: Some(versions),
delete_markers: Some(delete_markers),
..Default::default()
}),
fault.as_ref(),
))
}
async fn put_object(&self, req: S3Request<PutObjectInput>) -> S3Result<S3Response<PutObjectOutput>> {
let fault = request_fault(&req);
let _body_permit = timeout(MAX_FAULT_DURATION, Arc::clone(&self.body_limit).acquire_owned())
@@ -1014,7 +1189,8 @@ impl S3 for FakeBackend {
let input = req.input;
let body = collect_stream(input.body, input.content_length, fault.as_ref(), &self.control).await?;
validate_stored_metadata(&input.content_type, &input.metadata)?;
let version_id = new_version_id(&headers)?;
let assign_own = lock(&self.store).assign_own_version_ids;
let version_id = new_version_id(&headers, assign_own)?;
let e_tag = match source_etag(&headers)? {
Some(value) => value,
None => {
@@ -1057,7 +1233,7 @@ impl S3 for FakeBackend {
content_type: version.content_type,
metadata: version.metadata,
e_tag: Some(ETag::Strong(version.e_tag)),
last_modified: Some(version.last_modified),
last_modified: Some(version.last_modified.clone()),
version_id: Some(version.version_id),
..Default::default()
}),
@@ -1079,7 +1255,7 @@ impl S3 for FakeBackend {
content_type: version.content_type,
metadata: version.metadata,
e_tag: Some(ETag::Strong(version.e_tag)),
last_modified: Some(version.last_modified),
last_modified: Some(version.last_modified.clone()),
version_id: Some(version.version_id),
..Default::default()
}),
@@ -1148,7 +1324,9 @@ impl S3 for FakeBackend {
));
}
let version_id = new_version_id(&headers)?;
// `state` is the live store guard: read the flag from it. Re-locking
// would self-deadlock (the store mutex is not reentrant).
let version_id = new_version_id(&headers, state.assign_own_version_ids)?;
upsert_version(
&mut state,
&input.bucket,
@@ -1188,12 +1366,16 @@ impl S3 for FakeBackend {
ensure_upload_budget(&state)?;
validate_stored_metadata(&input.content_type, &input.metadata)?;
let upload_id = Uuid::new_v4().to_string();
// Read the flag before the mutable borrow of `state.uploads` below
// (and never re-lock the store: the mutex is not reentrant).
let mint_own = state.assign_own_version_ids || state.assign_own_multipart_version_ids;
let version_id = new_version_id(&headers, mint_own)?;
state.uploads.insert(
upload_id.clone(),
MultipartState {
bucket: input.bucket.clone(),
key: input.key.clone(),
version_id: new_version_id(&headers)?,
version_id,
content_type: input.content_type,
metadata: input.metadata,
parts: BTreeMap::new(),
@@ -431,4 +431,104 @@ mod tests {
)
.into())
}
/// Issue #5850: `background-heal/status` must answer while a peer is down.
///
/// Exercises the production path in `read_cluster_heal_status` end to end,
/// which the unit tests around `merge_peer_heal_statuses` cannot: with one
/// node stopped, the endpoint must return 200 with
/// `clusterStatusComplete: false` and an explicit `degraded` (or, when
/// heal work is known active, `active`) state — never the previous
/// cluster-wide 500 — and must return to a complete, non-degraded answer
/// once the node rejoins. Reverting either all-or-nothing gate (the
/// topology early-return or the merge hard-fail) turns the down-window
/// response into a 500 and fails this test.
#[tokio::test]
#[serial]
async fn test_background_heal_status_degrades_while_peer_down_and_recovers_after_rejoin()
-> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
info!("Issue #5850: background-heal/status must degrade, not 500, while a peer is down");
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
cluster.set_env("RUSTFS_UNSAFE_BYPASS_DISK_CHECK", "true");
cluster.start().await?;
let status_url = format!("{}/rustfs/admin/v3/background-heal/status", cluster.nodes[0].url);
// Owned copies: the closure must not borrow `cluster`, which
// stop_node/start_node need mutably between polls.
let access_key = cluster.access_key.clone();
let secret_key = cluster.secret_key.clone();
let fetch_status = || async {
let body = signed_admin_post(&status_url, None, &access_key, &secret_key).await?;
let json: serde_json::Value =
serde_json::from_str(&body).map_err(|err| format!("heal status response is not JSON ({err}): {body}"))?;
Ok::<serde_json::Value, Box<dyn Error + Send + Sync>>(json)
};
// Healthy cluster: the answer must be definitive. Poll briefly — the
// peer grid may still be settling right after start().
let mut healthy = fetch_status().await?;
for _ in 0..30 {
if healthy["clusterStatusComplete"] == serde_json::Value::Bool(true) {
break;
}
sleep(Duration::from_secs(1)).await;
healthy = fetch_status().await?;
}
assert_eq!(
healthy["clusterStatusComplete"],
serde_json::Value::Bool(true),
"healthy cluster should report a complete heal status: {healthy}"
);
cluster.stop_node(1)?;
// While the peer is down every response must stay 200 (signed_admin_post
// fails on any non-2xx, so the old 500 fails the test immediately) and
// must degrade to an explicitly-partial answer. The peer query timeout
// is 5 s, so a couple of polls are enough for the dead peer to surface.
let mut degraded = serde_json::Value::Null;
for _ in 0..30 {
degraded = fetch_status().await?;
if degraded["clusterStatusComplete"] == serde_json::Value::Bool(false) {
break;
}
sleep(Duration::from_secs(1)).await;
}
assert_eq!(
degraded["clusterStatusComplete"],
serde_json::Value::Bool(false),
"heal status must mark itself partial while a peer is down: {degraded}"
);
let state = degraded["state"].as_str().unwrap_or_default();
assert!(
state == "degraded" || state == "active",
"a partial answer must be labeled degraded (or active for known work), got {state:?}: {degraded}"
);
cluster.start_node(1).await?;
// After the rejoin the endpoint must return to a definitive answer.
let mut recovered = serde_json::Value::Null;
for _ in 0..60 {
recovered = fetch_status().await?;
if recovered["clusterStatusComplete"] == serde_json::Value::Bool(true) {
break;
}
sleep(Duration::from_secs(1)).await;
}
assert_eq!(
recovered["clusterStatusComplete"],
serde_json::Value::Bool(true),
"heal status should be complete again after the node rejoined: {recovered}"
);
assert_ne!(
recovered["state"].as_str().unwrap_or_default(),
"degraded",
"a complete answer must not be labeled degraded: {recovered}"
);
Ok(())
}
}
@@ -22,15 +22,16 @@
#[cfg(test)]
mod tests {
use crate::chaos::{DiskFaultHarness, signed_admin_post};
use crate::chaos::{DiskFaultHarness, VersionShardCensus, signed_admin_post};
use crate::common::init_logging;
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
use aws_sdk_s3::types::{BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, VersioningConfiguration};
use serial_test::serial;
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use tokio::time::{Duration, sleep, timeout};
use std::error::Error;
use tokio::time::{Duration, Instant, interval, timeout};
use tracing::info;
const GET_TIMEOUT: Duration = Duration::from_secs(60);
@@ -271,12 +272,17 @@ mod tests {
put_and_record(&client, bucket, "heal/nested/large.bin", payload(2 * 1024 * 1024, 34), &mut manifest).await?;
verify_manifest(&client, bucket, &manifest, "baseline before disk replacement").await?;
for (key, _) in &manifest {
assert!(
harness.object_metadata_exists_on_disk(0, bucket, key),
"disk0 should hold xl.meta for {key} before replacement"
);
}
let manifest_keys = manifest.iter().map(|(key, _)| key.clone()).collect::<Vec<_>>();
let target_manifest: Vec<(String, VersionShardCensus)> = manifest_keys
.iter()
.map(|key| {
let census = harness.census_object_version(0, bucket, key, None)?;
if !census.is_complete() {
return Err(format!("disk 0 has incomplete physical census for {key}: {census:?}").into());
}
Ok((key.clone(), census))
})
.collect::<Result<_, Box<dyn Error + Send + Sync>>>()?;
harness.kill_server();
harness.replace_disk_with_empty(0)?;
@@ -287,21 +293,112 @@ mod tests {
signed_admin_post(&heal_url, Some(heal_body), &harness.env.access_key, &harness.env.secret_key).await?;
let client = harness.env.create_s3_client();
let mut remaining: HashSet<String> = manifest.iter().map(|(key, _)| key.clone()).collect();
let mut remaining: HashSet<String> = manifest_keys.iter().cloned().collect();
let heal_timeout_secs = std::env::var("RUSTFS_RELIABILITY_HEAL_TIMEOUT_SECS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(120);
let deadline = Instant::now() + Duration::from_secs(heal_timeout_secs);
let mut retry = interval(Duration::from_secs(1));
for _ in 0..heal_timeout_secs {
remaining.retain(|key| !harness.object_metadata_exists_on_disk(0, bucket, key));
loop {
remaining.retain(|key| {
let expected = target_manifest
.iter()
.find(|(manifest_key, _)| manifest_key == key)
.map(|(_, manifest)| manifest)
.expect("every key has a physical manifest");
harness
.census_object_version(0, bucket, key, None)
.map(|census| !census.matches_manifest(expected))
.unwrap_or(true)
});
if remaining.is_empty() {
verify_manifest(&client, bucket, &manifest, "after fresh-disk heal completed").await?;
return Ok(());
}
sleep(Duration::from_secs(1)).await;
if Instant::now() >= deadline {
break;
}
retry.tick().await;
}
Err(format!("fresh-disk heal did not rebuild {remaining:?} on the replaced disk within {heal_timeout_secs}s").into())
}
#[tokio::test]
#[serial]
async fn test_versioned_shard_census_selects_each_version_data_dir() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
info!("Reliability: physical shard census selects the requested object version");
let mut harness = DiskFaultHarness::new(4).await?;
harness.start_server().await?;
let client = harness.env.create_s3_client();
let bucket = "reliability-versioned-census";
let key = "versions/large.bin";
client.create_bucket().bucket(bucket).send().await?;
client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
let first = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(payload(256 * 1024, 41)))
.send()
.await?;
let first_version = first.version_id().ok_or("first PUT did not return a version ID")?;
let second = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(payload(256 * 1024, 42)))
.send()
.await?;
let second_version = second.version_id().ok_or("second PUT did not return a version ID")?;
let delete = client.delete_object().bucket(bucket).key(key).send().await?;
let delete_version = delete.version_id().ok_or("delete marker did not return a version ID")?;
let first_census = harness.census_object_version(0, bucket, key, Some(first_version))?;
let second_census = harness.census_object_version(0, bucket, key, Some(second_version))?;
let delete_census = harness.census_object_version(0, bucket, key, Some(delete_version))?;
assert!(
first_census.is_complete(),
"first version physical census is incomplete: {first_census:?}"
);
assert!(
second_census.is_complete(),
"second version physical census is incomplete: {second_census:?}"
);
assert_ne!(
first_census.data_dir, second_census.data_dir,
"distinct object versions must select distinct physical data directories"
);
assert_eq!(
first_census.expected_part_numbers, second_census.expected_part_numbers,
"same single-part shape should expose the same part numbers"
);
assert!(
delete_census.is_complete(),
"delete marker physical census is incomplete: {delete_census:?}"
);
assert!(
delete_census.expected_part_numbers.is_empty(),
"delete marker must not declare object shards: {delete_census:?}"
);
assert!(
delete_census.present_part_numbers.is_empty(),
"delete marker must not select stale object shards: {delete_census:?}"
);
Ok(())
}
}
@@ -848,6 +848,13 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("lock-only test server"))
}
async fn replacement_recovery_status(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::ReplacementRecoveryStatusRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::ReplacementRecoveryStatusResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn get_metacache_listing(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::GetMetacacheListingRequest>,
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -181,6 +181,7 @@ path-absolutize = { workspace = true }
rmp.workspace = true
rmp-serde.workspace = true
tokio-util = { workspace = true, features = ["io", "compat"] }
tokio-stream = { workspace = true, features = ["sync"] }
base64 = { workspace = true }
hmac = { workspace = true }
sha1 = { workspace = true }
@@ -239,7 +240,19 @@ rustfs-uring = "0.2.1"
[target.'cfg(windows)'.dependencies]
winapi-util.workspace = true
windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_Storage_FileSystem"] }
windows-sys = { workspace = true, features = [
"Wdk_Foundation",
"Wdk_Storage_FileSystem",
"Win32_Foundation",
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_IO",
"Win32_System_SystemServices",
"Win32_System_WindowsProgramming",
] }
[target.'cfg(windows)'.dev-dependencies]
windows-sys = { workspace = true, features = ["Win32_System_Ioctl"] }
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util", "fs"] }
+26 -12
View File
@@ -32,7 +32,7 @@ pub mod bucket {
pub mod bucket_target_sys {
pub use crate::bucket::bucket_target_sys::{
AdvancedPutOptions, BucketTargetError, BucketTargetSys, PutObjectOptions, RemoveObjectOptions, S3ClientError,
TargetClient,
TargetClient, append_version_id_query,
};
}
@@ -281,7 +281,7 @@ pub mod config {
pub mod com {
pub use crate::config::com::{
COMMA_SEPARATED_LISTS, CONFIG_PREFIX, ENV_CONFIG_RECOVER_ON_CORRUPTION, STORAGE_CLASS_SUB_SYS,
ServerConfigCorruptError, ServerConfigSaveResult, ServerConfigSnapshot, delete_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,
@@ -326,8 +326,8 @@ pub mod disk {
pub use crate::disk::local::ScanGuard;
pub use crate::disk::{
BATCH_READ_VERSION_MAX_ITEMS, BUCKET_META_PREFIX, BatchReadVersionItem, BatchReadVersionReq, BatchReadVersionResp,
CheckPartsResp, DeleteOptions, Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption, DiskStore,
FileInfoVersions, FileReader, FileWriter, HEALING_MARKER_PATH, NsScannerOpenRequest, OldCurrentSize,
CheckPartsResp, ConditionalFileUpdate, DeleteOptions, Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption,
DiskStore, FileInfoVersions, FileReader, FileWriter, HEALING_MARKER_PATH, NsScannerOpenRequest, OldCurrentSize,
PartTransactionAction, RUSTFS_META_BUCKET, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp,
STORAGE_FORMAT_FILE, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions, new_disk,
validate_batch_read_version_item_count,
@@ -414,7 +414,10 @@ pub mod object {
lookup_get_object_body_cache_hook, register_get_object_body_cache_hook, register_object_mutation_hook,
unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
};
pub use crate::store::PreparedGetObjectReader;
pub use crate::store::{
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
SnapshotConsistencyError,
};
}
pub mod rebalance {
@@ -436,18 +439,29 @@ pub mod rpc {
pub use crate::cluster::rpc::{
AuthenticatedChannel, KMS_SIGNAL_SUBSYSTEM, LocalPeerS3Client, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS,
PeerRestClient, PeerS3Client, S3PeerSys, SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC,
ScannerBucketListing, ScannerPeerActivity, TONIC_RPC_PREFIX, TonicInterceptor, gen_signature_headers,
gen_tonic_replay_scope_headers, gen_tonic_signature_headers, gen_tonic_signature_interceptor,
node_service_time_out_client, node_service_time_out_client_no_auth, normalize_tonic_rpc_audience,
set_tonic_canonical_body_digest, sign_ns_scanner_capability, sign_tonic_rpc_response_proof, tonic_boot_epoch_challenge,
tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason, verify_rpc_signature, verify_tonic_boot_epoch_response,
verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest, verify_tonic_rpc_response_proof,
verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
ScannerBucketListing, ScannerPeerActivity, TONIC_RPC_PREFIX, TonicInterceptor, build_put_file_auth_trailer,
check_and_record_signed_rpc_nonce, gen_signature_headers, gen_tonic_replay_scope_headers, gen_tonic_signature_headers,
gen_tonic_signature_interceptor, node_service_time_out_client, node_service_time_out_client_no_auth,
normalize_tonic_rpc_audience, set_tonic_canonical_body_digest, sign_ns_scanner_capability, sign_put_file_capability,
sign_tonic_rpc_response_proof, tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers,
tonic_rpc_auth_failure_reason, verify_put_file_auth_trailer, verify_put_file_capability, verify_rpc_signature,
verify_tonic_boot_epoch_response, verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest,
verify_tonic_rpc_response_proof, verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
};
}
pub mod set_disk {
pub use crate::set_disk::{DEFAULT_READ_BUFFER_SIZE, SetDisks, get_lock_acquire_timeout, is_valid_storage_class};
/// Return the canonical object-metadata identity used for read-quorum grouping.
pub fn file_info_quorum_hash(meta: &rustfs_filemeta::FileInfo) -> [u8; 32] {
crate::set_disk::SetDisks::file_info_quorum_hash(meta)
}
#[cfg(feature = "test-util")]
pub mod test_util {
pub use crate::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause};
}
}
pub mod store_list {
+101 -25
View File
@@ -13,7 +13,7 @@
// limitations under the License.
use crate::bucket::bandwidth::reader::BucketOptions;
use ratelimit::{Error as RatelimitError, Ratelimiter};
use ratelimit::{Clock, Error as RatelimitError, Ratelimiter};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -24,6 +24,33 @@ use tracing::warn;
/// BETA_BUCKET is the weight used to calculate exponential moving average
const BETA_BUCKET: f64 = 0.1;
// ratelimit 2.0 stores tokens at six decimal places. Above this limit its
// scaled capacity and token-cost calculations saturate instead of preserving
// the configured bandwidth.
const MAX_RATELIMIT_TOKENS: i64 = 18_446_744_073_709;
fn consume_tokens<C: Clock>(limiter: &Ratelimiter<C>, n: u64) -> (u64, f64, u64) {
if n == 0 {
return (0, limiter.rate() as f64, 0);
}
let mut consumed = 0u64;
// Consuming one token also refills the bucket based on elapsed time, so
// the subsequent `available()` read reflects freshly accrued tokens.
if limiter.try_wait().is_ok() {
consumed = 1;
}
let available = limiter.available();
let to_consume = n - consumed;
let batch = to_consume.min(available);
if batch > 0 && limiter.try_wait_n(batch).is_ok() {
consumed += batch;
}
let deficit = n.saturating_sub(consumed);
let rate = limiter.rate() as f64;
(deficit, rate, consumed)
}
#[derive(Clone)]
pub struct BucketThrottle {
limiter: Arc<Mutex<Ratelimiter>>,
@@ -34,9 +61,9 @@ impl BucketThrottle {
fn new(node_bandwidth_per_sec: i64) -> Result<Self, RatelimitError> {
let node_bandwidth_per_sec = node_bandwidth_per_sec.max(1);
let amount = node_bandwidth_per_sec as u64;
let limiter_inner = Ratelimiter::builder(amount, Duration::from_secs(1))
.max_tokens(amount)
.build()?;
// ratelimit 2.0's builder takes a per-second rate; the refill period
// defaults to one second, so `amount` tokens accrue per second.
let limiter_inner = Ratelimiter::builder(amount).max_tokens(amount).build()?;
Ok(Self {
limiter: Arc::new(Mutex::new(limiter_inner)),
node_bandwidth_per_sec,
@@ -47,32 +74,21 @@ impl BucketThrottle {
self.limiter.lock().unwrap_or_else(|e| e.into_inner()).max_tokens()
}
/// The ratelimit crate (0.10.0) does not provide a bulk token consumption API.
/// try_wait() first to consume 1 token AND trigger the internal refill
/// mechanism (tokens are only refilled during try_wait/wait calls).
/// directly adjust available tokens via set_available() to consume the remaining amount.
/// Best-effort bulk token consumption: consume up to `n` tokens and report
/// how many were taken plus any shortfall.
///
/// `try_wait_n` on the ratelimit crate is all-or-nothing, so we cannot ask
/// for `n` directly and still consume a partial amount. Instead we take one
/// token first (which also triggers the internal time-based refill), read
/// the now-current available count, and consume `min(remaining, available)`
/// in a single `try_wait_n` call — that batch never exceeds `available`, so
/// it always succeeds.
pub(crate) fn consume(&self, n: u64) -> (u64, f64, u64) {
let guard = self.limiter.lock().unwrap_or_else(|e| {
warn!("bucket throttle mutex poisoned, recovering");
e.into_inner()
});
if n == 0 {
return (0, guard.rate(), 0);
}
let mut consumed = 0u64;
if guard.try_wait().is_ok() {
consumed = 1;
}
let available = guard.available();
let to_consume = n - consumed;
let batch = to_consume.min(available);
if batch > 0 {
let _ = guard.set_available(available - batch);
consumed += batch;
}
let deficit = n.saturating_sub(consumed);
let rate = guard.rate();
(deficit, rate, consumed)
consume_tokens(&guard, n)
}
}
@@ -329,6 +345,16 @@ impl Monitor {
"bandwidth limit too small for cluster size, per-node limit will clamp to 1 byte/s"
);
}
if limit_bytes > MAX_RATELIMIT_TOKENS {
warn!(
bucket = bucket,
arn = arn,
limit_bytes = limit_bytes,
max_limit_bytes = MAX_RATELIMIT_TOKENS,
"bandwidth limit exceeds ratelimiter capacity, throttling disabled for this target"
);
return;
}
let opts = BucketOptions {
name: bucket.to_string(),
replication_arn: arn.to_string(),
@@ -375,6 +401,30 @@ mod tests {
use super::*;
use std::panic::{AssertUnwindSafe, catch_unwind};
#[derive(Clone)]
struct TestClock {
elapsed_ns: Arc<AtomicU64>,
}
impl TestClock {
fn new() -> Self {
Self {
elapsed_ns: Arc::new(AtomicU64::new(0)),
}
}
fn advance(&self, duration: Duration) {
let elapsed_ns = u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX);
self.elapsed_ns.fetch_add(elapsed_ns, Ordering::Relaxed);
}
}
impl Clock for TestClock {
fn elapsed(&self) -> Duration {
Duration::from_nanos(self.elapsed_ns.load(Ordering::Relaxed))
}
}
#[test]
fn test_set_and_get_throttle_with_node_split() {
let monitor = Monitor::new(4);
@@ -426,6 +476,15 @@ mod tests {
assert!(!monitor.is_throttled("b1", "arn1"));
}
#[test]
fn test_set_bandwidth_limit_rejects_unrepresentable_rate() {
let monitor = Monitor::new(1);
monitor.set_bandwidth_limit("b1", "arn1", MAX_RATELIMIT_TOKENS + 1);
assert!(!monitor.is_throttled("b1", "arn1"));
}
#[test]
fn test_consume_returns_deficit_when_tokens_exhausted() {
let throttle = BucketThrottle::new(100).expect("test");
@@ -436,6 +495,23 @@ mod tests {
assert!(rate > 0.0);
}
#[test]
fn test_consume_refills_continuously() {
let clock = TestClock::new();
let limiter = Ratelimiter::with_clock(100, clock.clone());
assert_eq!(consume_tokens(&limiter, 100), (100, 100.0, 0));
clock.advance(Duration::from_millis(250));
assert_eq!(consume_tokens(&limiter, 100), (75, 100.0, 25));
clock.advance(Duration::from_millis(250));
assert_eq!(consume_tokens(&limiter, 100), (75, 100.0, 25));
clock.advance(Duration::from_millis(500));
assert_eq!(consume_tokens(&limiter, 100), (50, 100.0, 50));
}
#[test]
fn test_consume_no_deficit_when_tokens_sufficient() {
let throttle = BucketThrottle::new(10000).expect("test");
@@ -306,7 +306,7 @@ mod tests {
#[tokio::test]
async fn test_monitored_reader_header_size_accounting() {
let monitor = Monitor::new(1);
monitor.set_bandwidth_limit("b1", "arn1", 100);
monitor.set_bandwidth_limit("b1", "arn1", 1_000_000_000);
let data = vec![0u8; 200];
let inner = TestAsyncReader::new(&data);
+25 -10
View File
@@ -1450,7 +1450,7 @@ fn resolve_put_api_version_id(source_version_id: &str) -> Option<&str> {
/// member, so the query is spliced in via `map_request`, which runs at
/// `modify_before_signing`: the parameter becomes part of the SigV4 canonical
/// request.
fn append_version_id_query(uri: &str, version_id: &str) -> String {
pub fn append_version_id_query(uri: &str, version_id: &str) -> String {
let separator = if uri.contains('?') { '&' } else { '?' };
format!("{uri}{separator}versionId={}", urlencoding::encode(version_id))
}
@@ -1832,12 +1832,27 @@ impl TargetClient {
object: &str,
version_id: Option<String>,
) -> Result<HeadObjectOutput, SdkError<HeadObjectError>> {
// Announce the replication check so a RustFS target returns SSE-C
// object metadata (etag/size) without the customer key the replication
// worker cannot hold; otherwise SSE-C replicas never converge on HEAD.
let mut headers = HeaderMap::new();
insert_header(&mut headers, SUFFIX_SOURCE_REPLICATION_CHECK, "true");
match self
.client
.head_object()
.bucket(bucket)
.key(object)
.set_version_id(version_id)
.customize()
.map_request(move |mut req| {
for (k, v) in headers.clone().into_iter() {
if let Some(key_str) = k.map(|k| k.as_str().to_string()) {
let value_str = v.to_str().unwrap_or("").to_string();
req.headers_mut().insert(key_str, value_str);
}
}
Result::<_, std::convert::Infallible>::Ok(req)
})
.send()
.await
{
@@ -1846,6 +1861,9 @@ impl TargetClient {
}
}
/// On success returns the version id the target assigned (from
/// `x-amz-version-id`), letting callers audit the version-identity
/// contract — a target that adopts the source version echoes it back.
pub async fn put_object(
&self,
bucket: &str,
@@ -1853,7 +1871,7 @@ impl TargetClient {
size: i64,
body: ByteStream,
opts: &PutObjectOptions,
) -> Result<(), S3ClientError> {
) -> Result<Option<String>, S3ClientError> {
let mut headers = opts.header();
let builder = self.client.put_object();
@@ -1888,7 +1906,7 @@ impl TargetClient {
.send()
.await
{
Ok(_) => Ok(()),
Ok(output) => Ok(output.version_id().map(ToOwned::to_owned)),
Err(e) => match e {
SdkError::ServiceError(service_err) => {
let err = service_err.into_err();
@@ -1922,14 +1940,11 @@ impl TargetClient {
object: &str,
opts: &PutObjectOptions,
) -> Result<String, S3ClientError> {
let mut headers = HeaderMap::new();
// Object metadata belongs to CreateMultipartUpload in S3 semantics;
// building only the source-version headers here used to drop user
// metadata, content-type, and the SSE intent for multipart replicas.
let headers = opts.header();
let version_id = opts.internal.source_version_id.clone();
if !version_id.is_empty() {
insert_header(&mut headers, SUFFIX_SOURCE_VERSION_ID, &version_id);
}
if opts.internal.replication_request {
insert_header(&mut headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true");
}
// The remote version of a multipart replication is decided at initiate
// time; CompleteMultipartUpload does not read a versionId.
let api_version_id = resolve_put_api_version_id(&version_id).map(ToOwned::to_owned);
@@ -200,6 +200,29 @@ mod tests {
assert!(retention.retain_until_date.is_some());
}
/// backlog#1733 g-key-002: the persisted literal keys must still be read
/// through the current header constants, or WORM metadata fails open.
#[test]
fn persisted_compliance_lock_metadata_remains_effective() {
let mut meta = HashMap::new();
meta.insert("x-amz-object-lock-mode".to_string(), "COMPLIANCE".to_string());
meta.insert("x-amz-object-lock-retain-until-date".to_string(), "9999-01-01T00:00:00Z".to_string());
meta.insert("x-amz-object-lock-legal-hold".to_string(), "ON".to_string());
let retention = get_object_retention_meta(&meta);
assert_eq!(
retention.mode.as_ref().map(|mode| mode.as_str()),
Some(ObjectLockRetentionMode::COMPLIANCE)
);
assert!(retention.retain_until_date.is_some(), "persisted retention date must remain readable");
let legal_hold = get_object_legalhold_meta(&meta);
assert_eq!(
legal_hold.status.as_ref().map(|status| status.as_str()),
Some(ObjectLockLegalHoldStatus::ON)
);
}
#[test]
fn test_get_object_legalhold_meta_empty() {
let meta = HashMap::new();
@@ -710,8 +710,8 @@ pub struct ReplicationPool<S: ReplicationStorage> {
mrf_save_tx: Sender<MrfReplicateEntry>,
mrf_save_rx: Mutex<Option<Receiver<MrfReplicateEntry>>>,
// Control channels
mrf_worker_kill_tx: Sender<()>,
// MRF worker lifecycle
mrf_worker_cancellations: Mutex<Vec<CancellationToken>>,
mrf_stop_tx: Sender<()>,
// Worker size tracking
@@ -734,7 +734,6 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
// Create MRF channels
let (mrf_replica_tx, mrf_replica_rx) = mpsc::channel(100000);
let (mrf_save_tx, mrf_save_rx) = mpsc::channel(100000);
let (mrf_worker_kill_tx, _mrf_worker_kill_rx) = mpsc::channel(worker_counts.mrf_workers);
let (mrf_stop_tx, _mrf_stop_rx) = mpsc::channel(1);
let pool = Arc::new(Self {
@@ -752,7 +751,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
mrf_replica_rx: Arc::new(Mutex::new(mrf_replica_rx)),
mrf_save_tx,
mrf_save_rx: Mutex::new(Some(mrf_save_rx)),
mrf_worker_kill_tx,
mrf_worker_cancellations: Mutex::new(Vec::with_capacity(worker_counts.mrf_workers)),
mrf_stop_tx,
mrf_worker_size: AtomicI32::new(0),
task_handles: Mutex::new(Vec::new()),
@@ -896,12 +895,12 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
/// Resizes the failed workers pool
pub async fn resize_failed_workers(&self, n: i32) {
// Spawn workers up to n. Each worker shares the receiver via Arc<Mutex<...>>.
// The mutex is held only while calling recv() — released before processing — so
// all workers process entries concurrently (the dequeue step is serialised but
// the replication I/O is not).
while self.mrf_worker_size.load(Ordering::SeqCst) < n {
self.mrf_worker_size.fetch_add(1, Ordering::SeqCst);
let target = mrf_worker_size_to_count(n);
let mut cancellations = self.mrf_worker_cancellations.lock().await;
while cancellations.len() < target {
let cancellation = CancellationToken::new();
cancellations.push(cancellation.clone());
let active_counter = self.active_mrf_workers.clone();
let stats = self.stats.clone();
@@ -910,7 +909,18 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
let handle = tokio::spawn(async move {
loop {
let operation = { mrf_rx.lock().await.recv().await };
let operation = tokio::select! {
biased;
operation = async {
let mut receiver = mrf_rx.lock().await;
tokio::select! {
biased;
operation = receiver.recv() => operation,
_ = cancellation.cancelled() => None,
}
} => operation,
_ = cancellation.cancelled() => break,
};
let Some(operation) = operation else { break };
let _active = ActiveWorkerGuard::new(active_counter.clone());
@@ -920,11 +930,13 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
self.task_handles.lock().await.push(handle);
}
// Remove workers if needed
while self.mrf_worker_size.load(Ordering::SeqCst) > n {
self.mrf_worker_size.fetch_sub(1, Ordering::SeqCst);
let _ = self.mrf_worker_kill_tx.try_send(());
while cancellations.len() > target {
if let Some(cancellation) = cancellations.pop() {
cancellation.cancel();
}
}
self.mrf_worker_size.store(n.max(0), Ordering::SeqCst);
}
/// Resizes worker priority and counts
@@ -2555,6 +2567,12 @@ pub trait ReplicationPoolTrait: std::fmt::Debug {
async fn queue_replica_task(&self, ri: ReplicateObjectInfo) -> ReplicationQueueAdmission;
async fn queue_replica_delete_task(&self, ri: DeletedObjectReplicationInfo) -> ReplicationQueueAdmission;
async fn queue_replica_delete_batch(&self, deletes: &[DeletedObjectReplicationInfo]) -> ReplicationBatchAdmission;
/// Persist one entry straight to the durable MRF journal, bypassing the
/// live worker queues. For failures whose source state is already gone —
/// e.g. exhausted delete-marker purges — where only a startup replay can
/// retry, and live re-dispatch would loop unboundedly against a down
/// target.
async fn persist_mrf_entry(&self, entry: MrfReplicateEntry) -> ReplicationQueueAdmission;
async fn resize(&self, priority: ReplicationPriority, max_workers: usize, max_l_workers: usize);
async fn get_bucket_resync_status(&self, bucket: &str) -> Result<BucketReplicationResyncStatus, EcstoreError>;
async fn cancel_bucket_resync(&self, opts: ResyncOpts) -> Result<(), EcstoreError>;
@@ -2595,6 +2613,10 @@ impl<S: ReplicationStorage> ReplicationPoolTrait for ReplicationPool<S> {
self.queue_replica_delete_batch(deletes).await
}
async fn persist_mrf_entry(&self, entry: MrfReplicateEntry) -> ReplicationQueueAdmission {
self.queue_mrf_save_admission(entry, "delete_marker_purge").await
}
async fn resize(&self, priority: ReplicationPriority, max_workers: usize, max_l_workers: usize) {
self.resize(priority, max_workers, max_l_workers).await;
}
@@ -3350,7 +3372,6 @@ mod tests {
) -> Arc<ReplicationPool<LoadResyncNodeStore>> {
let (mrf_replica_tx, mrf_replica_rx) = mpsc::channel(1);
let (mrf_save_tx, mrf_save_rx) = mpsc::channel(mrf_save_capacity);
let (mrf_worker_kill_tx, _) = mpsc::channel(1);
let (mrf_stop_tx, _) = mpsc::channel(1);
Arc::new(ReplicationPool {
@@ -3368,7 +3389,7 @@ mod tests {
mrf_replica_rx: Arc::new(Mutex::new(mrf_replica_rx)),
mrf_save_tx,
mrf_save_rx: Mutex::new(Some(mrf_save_rx)),
mrf_worker_kill_tx,
mrf_worker_cancellations: Mutex::new(Vec::new()),
mrf_stop_tx,
mrf_worker_size: AtomicI32::new(0),
task_handles: Mutex::new(Vec::new()),
@@ -3971,6 +3992,54 @@ mod tests {
);
}
#[tokio::test]
async fn resize_failed_workers_cancels_idle_workers() {
let shared = empty_resync_shared_state();
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("mrf-resize", shared))).await;
pool.resize_failed_workers(4).await;
assert_eq!(pool.mrf_worker_cancellations.lock().await.len(), 4);
assert_eq!(pool.mrf_worker_size.load(Ordering::SeqCst), 4);
pool.resize_failed_workers(1).await;
tokio::time::timeout(Duration::from_secs(10), async {
loop {
let finished = pool
.task_handles
.lock()
.await
.iter()
.filter(|handle| handle.is_finished())
.count();
if finished == 3 {
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("canceled MRF workers should exit while the shared queue is idle");
assert_eq!(pool.mrf_worker_cancellations.lock().await.len(), 1);
assert_eq!(pool.mrf_worker_size.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn resize_failed_workers_is_idempotent_across_growth_and_shrink() {
let shared = empty_resync_shared_state();
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("mrf-resize-repeat", shared))).await;
for target in [2, 4, 1, 4, 4] {
pool.resize_failed_workers(target).await;
assert_eq!(
pool.mrf_worker_cancellations.lock().await.len(),
usize::try_from(target).expect("test worker count should fit usize")
);
assert_eq!(pool.mrf_worker_size.load(Ordering::SeqCst), target);
}
}
#[test]
fn replicate_object_info_from_object_info_preserves_ssec_checksum() {
let checksum = bytes::Bytes::from_static(b"ssec-checksum");
File diff suppressed because it is too large Load Diff
@@ -28,7 +28,7 @@ use rustfs_utils::http::{
AMZ_STORAGE_CLASS, AMZ_TAG_COUNT, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE,
HeaderExt as _, SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP,
SUFFIX_REPLICATION_ACTUAL_OBJECT_SIZE, SUFFIX_REPLICATION_SSEC_CRC, SUFFIX_TAGGING_TIMESTAMP, get_str, insert_header_map,
is_internal_key,
is_internal_key, is_object_encryption_marker, is_replication_stripped_encryption_key, ssec_replication_transport_header,
};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
@@ -62,24 +62,6 @@ static STANDARD_HEADERS: &[&str] = &[
AMZ_SERVER_SIDE_ENCRYPTION,
];
static VALID_SSE_REPLICATION_HEADERS: &[(&str, &str)] = &[
(
"X-Rustfs-Internal-Server-Side-Encryption-Sealed-Key",
"X-Rustfs-Replication-Server-Side-Encryption-Sealed-Key",
),
(
"X-Rustfs-Internal-Server-Side-Encryption-Seal-Algorithm",
"X-Rustfs-Replication-Server-Side-Encryption-Seal-Algorithm",
),
(
"X-Rustfs-Internal-Server-Side-Encryption-Iv",
"X-Rustfs-Replication-Server-Side-Encryption-Iv",
),
("X-Rustfs-Internal-Encrypted-Multipart", "X-Rustfs-Replication-Encrypted-Multipart"),
("X-Rustfs-Internal-Actual-Object-Size", "X-Rustfs-Replication-Actual-Object-Size"),
];
const ERR_REPLICATION_MANAGED_SSE_UNSUPPORTED: &str = "managed SSE replication requires target encryption support";
const ERR_REPLICATION_ENCRYPTION_METADATA_UNSUPPORTED: &str = "replication source contains unsupported encryption metadata";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -105,15 +87,29 @@ fn classify_replication_source_encryption(metadata: &HashMap<String, String>) ->
let kms_context = metadata_value(metadata, AMZ_SERVER_SIDE_ENCRYPTION_KMS_CONTEXT);
if is_ssec {
return if sse.is_some() || kms_key_id.is_some() || kms_context.is_some() {
ReplicationSourceEncryption::Unsupported
} else {
// Stored SSE-C objects always carry x-amz-server-side-encryption=AES256
// alongside the customer-algorithm key; only KMS evidence marks a
// mixed, unsupported state.
let sse_compatible = sse.map(str::trim).is_none_or(|value| value.eq_ignore_ascii_case("AES256"));
return if sse_compatible && kms_key_id.is_none() && kms_context.is_none() {
ReplicationSourceEncryption::SseC
} else {
ReplicationSourceEncryption::Unsupported
};
}
match sse.map(str::trim) {
None if kms_key_id.is_none() && kms_context.is_none() => ReplicationSourceEncryption::Plaintext,
None if kms_key_id.is_none() && kms_context.is_none() => {
// Sealed material without any recognizable SSE marker (e.g. an
// object written by MinIO, which does not persist the x-amz SSE
// intent header) must fail closed: replicating it as plaintext
// ships ciphertext the target can never decrypt.
if metadata.keys().any(|key| is_object_encryption_marker(key)) {
ReplicationSourceEncryption::Unsupported
} else {
ReplicationSourceEncryption::Plaintext
}
}
Some(value) if value.eq_ignore_ascii_case("AES256") && kms_key_id.is_none() && kms_context.is_none() => {
ReplicationSourceEncryption::SseS3
}
@@ -163,28 +159,38 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo)
let source_encryption = classify_replication_source_encryption(&object_info.user_defined);
let is_ssec = matches!(source_encryption, ReplicationSourceEncryption::SseC);
match source_encryption {
ReplicationSourceEncryption::Plaintext | ReplicationSourceEncryption::SseC => {}
ReplicationSourceEncryption::SseS3 | ReplicationSourceEncryption::SseKms => {
return Err(Error::other(ERR_REPLICATION_MANAGED_SSE_UNSUPPORTED));
}
ReplicationSourceEncryption::Unsupported => {
return Err(Error::other(ERR_REPLICATION_ENCRYPTION_METADATA_UNSUPPORTED));
}
if matches!(source_encryption, ReplicationSourceEncryption::Unsupported) {
return Err(Error::other(ERR_REPLICATION_ENCRYPTION_METADATA_UNSUPPORTED));
}
for (key, value) in object_info.user_defined.iter() {
let has_valid_sse_header = valid_sse_replication_header(key).is_some();
if (!is_ssec || !has_valid_sse_header) && (is_internal_key(key) || is_standard_header(key)) {
if is_ssec && let Some(transport_header) = ssec_replication_transport_header(key) {
meta.insert(transport_header.to_string(), value.to_string());
continue;
}
if let Some(replication_header) = valid_sse_replication_header(key) {
meta.insert(replication_header.to_string(), value.to_string());
} else {
meta.insert(key.to_string(), value.to_string());
// Encryption metadata that is not remapped for SSE-C passthrough must
// never leave the source site: envelopes and intent headers are only
// meaningful to the source KMS.
if is_replication_stripped_encryption_key(key) {
continue;
}
if is_internal_key(key) || is_standard_header(key) {
continue;
}
meta.insert(key.to_string(), value.to_string());
}
// Managed SSE replicates as plaintext (the replication reader decrypts via
// the object-encryption resolver) and re-encrypts on the target with the
// target's own KMS. Send only the encryption intent — never the source
// key id, whose meaning is local to the source site's KMS.
if matches!(source_encryption, ReplicationSourceEncryption::SseS3) {
meta.insert(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "AES256".to_string());
} else if matches!(source_encryption, ReplicationSourceEncryption::SseKms) {
meta.insert(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "aws:kms".to_string());
}
let mut is_multipart = object_info.is_multipart();
@@ -195,6 +201,11 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo)
if is_ssec {
let encoded = BASE64_STANDARD.encode(checksum_data);
insert_header_map(&mut meta, SUFFIX_REPLICATION_SSEC_CRC, encoded);
} else if object_info.is_encrypted() {
// Encrypted checksums cannot be exposed as plaintext headers, and
// decrypt_checksums reports is_multipart=false for them (a value
// the response path relies on). Keep the object's own multipart
// flag so encrypted objects stay on the multipart route.
} else {
let (checksum_meta, is_mp) = object_info.decrypt_checksums(0, &HeaderMap::new())?;
is_multipart = is_mp;
@@ -394,13 +405,22 @@ pub(crate) fn replication_force_delete_remove_options() -> RemoveObjectOptions {
}
}
pub(crate) fn replication_complete_multipart_options(actual_size: String) -> PutObjectOptions {
pub(crate) fn replication_complete_multipart_options(
actual_size: String,
source_etag: String,
source_mtime: Option<OffsetDateTime>,
) -> PutObjectOptions {
let mut user_metadata = HashMap::new();
insert_header_map(&mut user_metadata, SUFFIX_REPLICATION_ACTUAL_OBJECT_SIZE, actual_size);
PutObjectOptions {
user_metadata,
internal: AdvancedPutOptions {
source_etag,
// AdvancedPutOptions::default() stamps now_utc(); an absent source
// mtime must degrade to epoch so header() suppresses the header
// instead of asserting the replication time as the object's mtime.
source_mtime: source_mtime.unwrap_or(OffsetDateTime::UNIX_EPOCH),
replication_status: ReplicationStatusType::Replica,
replication_request: true,
..Default::default()
@@ -413,20 +433,14 @@ fn is_standard_header(key: &str) -> bool {
STANDARD_HEADERS.iter().any(|header| header.eq_ignore_ascii_case(key))
}
fn valid_sse_replication_header(key: &str) -> Option<&str> {
VALID_SSE_REPLICATION_HEADERS
.iter()
.find(|(internal, _)| key.eq_ignore_ascii_case(internal))
.map(|(_, replication)| *replication)
}
#[cfg(test)]
mod tests {
use super::*;
use aws_smithy_types::DateTime;
use rustfs_replication::content_matches_by_etag;
use rustfs_utils::http::{
SSEC_ALGORITHM_HEADER, SUFFIX_REPLICATION_ACTUAL_OBJECT_SIZE, SUFFIX_REPLICATION_SSEC_CRC, get_header_map,
SSEC_ALGORITHM_HEADER, SSEC_KEY_MD5_HEADER, SUFFIX_REPLICATION_ACTUAL_OBJECT_SIZE, SUFFIX_REPLICATION_SSEC_CRC,
get_header_map,
};
use std::sync::Arc;
use time::Duration;
@@ -571,7 +585,21 @@ mod tests {
#[test]
fn replication_complete_multipart_options_sets_actual_size() {
let options = replication_complete_multipart_options("1024".to_string());
let source_mtime = OffsetDateTime::from_unix_timestamp(1_716_170_000).expect("valid test timestamp");
let options = replication_complete_multipart_options(
"1024".to_string(),
"0123456789abcdef0123456789abcdef-3".to_string(),
Some(source_mtime),
);
assert_eq!(options.internal.source_etag, "0123456789abcdef0123456789abcdef-3");
assert_eq!(options.internal.source_mtime, source_mtime);
// Absent source mtime must degrade to epoch (header suppressed), not
// the AdvancedPutOptions default of now_utc() — that default would
// stamp the replication time as the replica's mtime and break the
// multipart HEAD convergence.
let options_no_mtime = replication_complete_multipart_options("1024".to_string(), String::new(), None);
assert_eq!(options_no_mtime.internal.source_mtime.unix_timestamp(), 0);
assert_eq!(
get_header_map(&options.user_metadata, SUFFIX_REPLICATION_ACTUAL_OBJECT_SIZE).as_deref(),
@@ -583,11 +611,29 @@ mod tests {
#[test]
fn replication_put_options_filter_and_map_metadata() {
use rustfs_utils::http::object_encryption_keys::{
INTERNAL_ENCRYPTION_IV_HEADER, MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER, MINIO_INTERNAL_ENCRYPTION_IV_HEADER,
MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER, MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER,
REPLICATION_ENCRYPTED_MULTIPART_HEADER, REPLICATION_ENCRYPTION_IV_HEADER, REPLICATION_SSE_IV_HEADER,
REPLICATION_SSE_SEAL_ALGORITHM_HEADER, REPLICATION_SSE_SEALED_KEY_HEADER, REPLICATION_SSEC_ALGORITHM_HEADER,
REPLICATION_SSEC_KEY_MD5_HEADER, REPLICATION_SSEC_ORIGINAL_SIZE_HEADER, SSEC_ORIGINAL_SIZE_HEADER,
};
// The stored shape of a real SSE-C object: SSE marker plus customer
// material, per encryption_material_to_metadata. Every transport-table
// source key is present so each mapping is pinned individually.
let mut metadata = HashMap::new();
metadata.insert(CONTENT_TYPE.to_string(), "text/plain".to_string());
metadata.insert("x-user-meta".to_string(), "value".to_string());
metadata.insert(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "AES256".to_string());
metadata.insert(SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string());
metadata.insert("X-Rustfs-Internal-Server-Side-Encryption-Sealed-Key".to_string(), "sealed".to_string());
metadata.insert(SSEC_KEY_MD5_HEADER.to_string(), "md5-value".to_string());
metadata.insert(SSEC_ORIGINAL_SIZE_HEADER.to_string(), "1024".to_string());
metadata.insert(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), "iv-direct".to_string());
metadata.insert(MINIO_INTERNAL_ENCRYPTION_IV_HEADER.to_string(), "iv-minio".to_string());
metadata.insert(MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(), "DAREv2-HMAC-SHA256".to_string());
metadata.insert(MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER.to_string(), "sealed".to_string());
metadata.insert(MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER.to_string(), "true".to_string());
let object_info = ObjectInfo {
user_defined: Arc::new(metadata),
@@ -605,12 +651,40 @@ mod tests {
assert!(!is_multipart);
assert_eq!(options.user_metadata.get("x-user-meta"), Some(&"value".to_string()));
assert!(!options.user_metadata.contains_key(CONTENT_TYPE));
// Every stored SSE-C material key is remapped onto its transport name.
assert_eq!(options.user_metadata.get(REPLICATION_SSEC_ALGORITHM_HEADER), Some(&"AES256".to_string()));
assert_eq!(options.user_metadata.get(REPLICATION_SSEC_KEY_MD5_HEADER), Some(&"md5-value".to_string()));
assert_eq!(
options
.user_metadata
.get("X-Rustfs-Replication-Server-Side-Encryption-Sealed-Key"),
Some(&"sealed".to_string())
options.user_metadata.get(REPLICATION_SSEC_ORIGINAL_SIZE_HEADER),
Some(&"1024".to_string())
);
assert_eq!(
options.user_metadata.get(REPLICATION_ENCRYPTION_IV_HEADER),
Some(&"iv-direct".to_string())
);
assert_eq!(options.user_metadata.get(REPLICATION_SSE_IV_HEADER), Some(&"iv-minio".to_string()));
assert_eq!(
options.user_metadata.get(REPLICATION_SSE_SEAL_ALGORITHM_HEADER),
Some(&"DAREv2-HMAC-SHA256".to_string())
);
assert_eq!(options.user_metadata.get(REPLICATION_SSE_SEALED_KEY_HEADER), Some(&"sealed".to_string()));
assert_eq!(
options.user_metadata.get(REPLICATION_ENCRYPTED_MULTIPART_HEADER),
Some(&"true".to_string())
);
// The stored keys themselves and the SSE intent header must not leave
// the source verbatim.
assert!(!options.user_metadata.contains_key(AMZ_SERVER_SIDE_ENCRYPTION));
assert!(!options.user_metadata.contains_key(SSEC_ALGORITHM_HEADER));
assert!(!options.user_metadata.contains_key(INTERNAL_ENCRYPTION_IV_HEADER));
assert!(
!options
.user_metadata
.contains_key(MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER)
);
assert_eq!(options.content_type, "text/plain");
assert_eq!(options.content_encoding, "gzip");
assert_eq!(options.user_tags.get("env"), Some(&"prod".to_string()));
@@ -620,6 +694,68 @@ mod tests {
assert!(options.internal.replication_request);
}
#[test]
fn replication_put_options_strip_encryption_metadata_from_plaintext_objects() {
use rustfs_utils::http::object_encryption_keys::{INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER, SSEC_ORIGINAL_SIZE_HEADER};
// Migration leftovers: original-size metadata is not an encryption
// marker (older plaintext objects can retain it), so the object still
// classifies as plaintext — but the keys must be stripped, never
// forwarded as plain user metadata (backlog#1783 D2). The SSE-C
// original-size key is also a transport-table source key, so this
// doubles as the guard for the is_ssec gate: without SSE-C
// classification it must be stripped, not remapped.
let metadata = HashMap::from([
("x-user-meta".to_string(), "value".to_string()),
(INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER.to_string(), "1024".to_string()),
(SSEC_ORIGINAL_SIZE_HEADER.to_string(), "1024".to_string()),
]);
let object_info = ObjectInfo {
user_defined: Arc::new(metadata),
..Default::default()
};
let (options, _) = replication_put_object_options("", &object_info).expect("build put options");
assert_eq!(options.user_metadata.get("x-user-meta"), Some(&"value".to_string()));
assert!(!options.user_metadata.contains_key(INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER));
assert!(!options.user_metadata.contains_key(SSEC_ORIGINAL_SIZE_HEADER));
assert!(
!options
.user_metadata
.keys()
.any(|key| key.to_ascii_lowercase().starts_with("x-rustfs-replication-")),
"non-SSE-C objects must never emit SSE replication transport keys"
);
}
#[test]
fn replication_put_options_fail_closed_on_sealed_material_without_sse_marker() {
use rustfs_utils::http::object_encryption_keys::{
INTERNAL_ENCRYPTION_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER,
};
// Sealed material without a recognizable SSE marker (MinIO-written
// objects, or corrupted metadata) must fail closed instead of
// replicating ciphertext as a plaintext object.
for sealed_key in [
INTERNAL_ENCRYPTION_KEY_HEADER,
MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER,
] {
let object_info = ObjectInfo {
user_defined: Arc::new(HashMap::from([(sealed_key.to_string(), "sealed-envelope".to_string())])),
..Default::default()
};
let err = match replication_put_object_options("", &object_info) {
Ok(_) => panic!("sealed material without an SSE marker must fail closed ({sealed_key})"),
Err(err) => err,
};
assert!(err.to_string().contains(ERR_REPLICATION_ENCRYPTION_METADATA_UNSUPPORTED));
assert!(!err.to_string().contains("sealed-envelope"));
}
}
#[test]
fn replication_put_options_adds_ssec_checksum_metadata() {
let metadata = HashMap::from([(SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string())]);
@@ -658,6 +794,30 @@ mod tests {
classify_replication_source_encryption(&HashMap::from([(SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string())])),
ReplicationSourceEncryption::SseC
);
// Real stored SSE-C objects carry the AES256 SSE marker alongside the
// customer algorithm (encryption_material_to_metadata writes both).
assert_eq!(
classify_replication_source_encryption(&HashMap::from([
(SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string()),
("x-amz-server-side-encryption".to_string(), "AES256".to_string()),
])),
ReplicationSourceEncryption::SseC
);
// SSE-C material mixed with KMS evidence stays unsupported.
assert_eq!(
classify_replication_source_encryption(&HashMap::from([
(SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string()),
(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID.to_string(), "key-1".to_string()),
])),
ReplicationSourceEncryption::Unsupported
);
assert_eq!(
classify_replication_source_encryption(&HashMap::from([
(SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string()),
("x-amz-server-side-encryption".to_string(), "aws:kms".to_string()),
])),
ReplicationSourceEncryption::Unsupported
);
assert_eq!(
classify_replication_source_encryption(&HashMap::from([(
"x-amz-server-side-encryption".to_string(),
@@ -675,36 +835,75 @@ mod tests {
}
#[test]
fn replication_put_options_rejects_sse_s3_until_target_encryption_is_supported() {
let object_info = ObjectInfo {
user_defined: Arc::new(HashMap::from([(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "AES256".to_string())])),
..Default::default()
fn replication_put_options_sends_sse_s3_intent_without_source_material() {
use rustfs_utils::http::object_encryption_keys::{
INTERNAL_ENCRYPTION_ALGORITHM_HEADER, INTERNAL_ENCRYPTION_IV_HEADER, INTERNAL_ENCRYPTION_KEY_HEADER,
INTERNAL_ENCRYPTION_KEY_ID_HEADER, INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER,
};
let err = match replication_put_object_options("", &object_info) {
Ok(_) => panic!("SSE-S3 replication should fail closed until target encryption headers are supported"),
Err(err) => err,
};
assert!(err.to_string().contains(ERR_REPLICATION_MANAGED_SSE_UNSUPPORTED));
}
#[test]
fn replication_put_options_rejects_sse_kms_until_target_encryption_is_supported() {
// The stored shape of a managed SSE-S3 object per
// encryption_material_to_metadata: SSE marker plus envelope material.
let object_info = ObjectInfo {
user_defined: Arc::new(HashMap::from([
(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "aws:kms".to_string()),
(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID.to_string(), "key-1".to_string()),
(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "AES256".to_string()),
(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), "default".to_string()),
(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), "sealed-envelope".to_string()),
(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), "iv".to_string()),
(INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(), "AES256-GCM".to_string()),
(INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER.to_string(), "1024".to_string()),
("x-user-meta".to_string(), "value".to_string()),
])),
..Default::default()
};
let err = match replication_put_object_options("", &object_info) {
Ok(_) => panic!("SSE-KMS replication should fail closed until target encryption headers are supported"),
Err(err) => err,
let (options, _) = replication_put_object_options("", &object_info).expect("managed SSE-S3 must build put options");
assert_eq!(options.user_metadata.get(AMZ_SERVER_SIDE_ENCRYPTION), Some(&"AES256".to_string()));
assert_eq!(options.user_metadata.get("x-user-meta"), Some(&"value".to_string()));
// No envelope material and no key id may leave the source.
assert!(!options.user_metadata.contains_key(INTERNAL_ENCRYPTION_KEY_HEADER));
assert!(!options.user_metadata.contains_key(INTERNAL_ENCRYPTION_KEY_ID_HEADER));
assert!(!options.user_metadata.contains_key(INTERNAL_ENCRYPTION_IV_HEADER));
assert!(
!options.user_metadata.values().any(|value| value.contains("sealed-envelope")),
"source envelope material must never leave the source site"
);
}
#[test]
fn replication_put_options_sends_sse_kms_intent_without_source_key_id() {
use rustfs_utils::http::object_encryption_keys::{
INTERNAL_ENCRYPTION_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER,
};
assert!(err.to_string().contains(ERR_REPLICATION_MANAGED_SSE_UNSUPPORTED));
let object_info = ObjectInfo {
user_defined: Arc::new(HashMap::from([
(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "aws:kms".to_string()),
(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID.to_string(), "source-key-1".to_string()),
(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), "sealed-envelope".to_string()),
(MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER.to_string(), "ctx".to_string()),
])),
..Default::default()
};
let (options, _) = replication_put_object_options("", &object_info).expect("managed SSE-KMS must build put options");
// Intent only: the target encrypts with its own default KMS key.
assert_eq!(options.user_metadata.get(AMZ_SERVER_SIDE_ENCRYPTION), Some(&"aws:kms".to_string()));
assert!(!options.user_metadata.contains_key(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID));
assert!(!options.user_metadata.contains_key(INTERNAL_ENCRYPTION_KEY_HEADER));
assert!(
!options
.user_metadata
.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER)
);
assert!(
!options
.user_metadata
.values()
.any(|value| value.contains("sealed-envelope") || value.contains("source-key-1")),
"source KMS identifiers and envelopes must never leave the source site"
);
}
#[test]
@@ -57,7 +57,7 @@ fn build_part_path(file_path: &Path) -> PathBuf {
async fn open_download_part_file(file_part_path: &Path) -> io::Result<tokio::fs::File> {
let mut options = OpenOptions::new();
options.create(true).read(true).write(true);
options.create(true).truncate(false).read(true).write(true);
#[cfg(not(windows))]
options.mode(0o600);
+631 -47
View File
@@ -15,12 +15,12 @@
#[cfg(test)]
use crate::cluster::rpc::http_auth::RPC_REPLAY_SCOPE_VERSION_HEADER;
use crate::cluster::rpc::http_auth::{
RPC_AUTH_VERSION_HEADER, RPC_AUTH_VERSION_V2, RPC_BOOT_EPOCH_CHALLENGE_HEADER, RPC_BOOT_EPOCH_HEADER,
RPC_BOOT_EPOCH_PROOF_HEADER, RPC_CONTENT_SHA256_HEADER, TIMESTAMP_HEADER,
};
use crate::cluster::rpc::{
gen_tonic_replay_scope_headers, gen_tonic_signature_headers, normalize_tonic_rpc_audience, verify_tonic_boot_epoch_response,
AuthenticatedPeerReplayCapabilities, RPC_AUTH_VERSION_HEADER, RPC_AUTH_VERSION_V2, RPC_BOOT_EPOCH_CHALLENGE_HEADER,
RPC_CONTENT_SHA256_HEADER, RPC_REPLAY_CACHE_CAPABILITY_HEADER, RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER,
RollingMutationBodyDigest, TIMESTAMP_HEADER, internode_rpc_body_digest_strict,
verify_tonic_peer_replay_capabilities_response,
};
use crate::cluster::rpc::{gen_tonic_replay_scope_headers, gen_tonic_signature_headers, normalize_tonic_rpc_audience};
#[cfg(test)]
use crate::cluster::rpc::{tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers};
use crate::disk::error::{DiskError, Error as DiskErrorType, RpcStatusError};
@@ -233,7 +233,22 @@ pub struct ReplayScopeChannel<S> {
/// The channel type used by internode clients after v2 authentication and replay-scope handling.
pub type AuthenticatedChannel = ReplayScopeChannel<Channel>;
static PEER_BOOT_EPOCHS: LazyLock<Mutex<HashMap<String, Uuid>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PeerReplayCapability {
Capable { boot_epoch: Uuid },
Revoked,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct PeerReplayState {
boot_epoch: Option<Uuid>,
cache_capability: Option<PeerReplayCapability>,
}
#[derive(Clone, Copy, Debug)]
struct PeerReplayStateSnapshot(PeerReplayState);
static PEER_REPLAY_STATES: LazyLock<Mutex<HashMap<String, PeerReplayState>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
impl<S> ReplayScopeChannel<S> {
fn new(inner: S, audience: Option<String>) -> Self {
@@ -241,13 +256,67 @@ impl<S> ReplayScopeChannel<S> {
}
}
fn cached_peer_boot_epoch(audience: &str) -> Option<Uuid> {
PEER_BOOT_EPOCHS.lock().ok().and_then(|epochs| epochs.get(audience).copied())
fn peer_replay_state(audience: &str) -> PeerReplayState {
PEER_REPLAY_STATES
.lock()
.ok()
.and_then(|states| states.get(audience).copied())
.unwrap_or_default()
}
fn remember_peer_boot_epoch(audience: String, epoch: Uuid) {
if let Ok(mut epochs) = PEER_BOOT_EPOCHS.lock() {
epochs.insert(audience, epoch);
fn apply_peer_replay_response(
audience: String,
sent_state: PeerReplayState,
response: std::io::Result<AuthenticatedPeerReplayCapabilities>,
) {
if let Ok(mut states) = PEER_REPLAY_STATES.lock() {
let current_state = states.get(&audience).copied().unwrap_or_default();
let mut next_state = current_state;
if let Ok(response) = &response
&& sent_state.boot_epoch == current_state.boot_epoch
{
next_state.boot_epoch = Some(response.boot_epoch);
}
if sent_state.boot_epoch == current_state.boot_epoch {
let response_capability = response
.as_ref()
.ok()
.filter(|response| response.dynamic_replay_cache)
.map(|response| response.boot_epoch);
match (sent_state.cache_capability, current_state.cache_capability, response_capability) {
(None, None, Some(boot_epoch))
| (Some(PeerReplayCapability::Revoked), Some(PeerReplayCapability::Revoked), Some(boot_epoch)) => {
next_state.cache_capability = Some(PeerReplayCapability::Capable { boot_epoch });
}
(
Some(PeerReplayCapability::Capable {
boot_epoch: sent_boot_epoch,
}),
Some(PeerReplayCapability::Capable {
boot_epoch: current_boot_epoch,
}),
Some(response_boot_epoch),
) if sent_boot_epoch == current_boot_epoch => {
next_state.cache_capability = Some(PeerReplayCapability::Capable {
boot_epoch: response_boot_epoch,
});
}
(
Some(PeerReplayCapability::Capable {
boot_epoch: sent_boot_epoch,
}),
Some(PeerReplayCapability::Capable {
boot_epoch: current_boot_epoch,
}),
None,
) if sent_boot_epoch == current_boot_epoch => {
next_state.cache_capability = Some(PeerReplayCapability::Revoked);
}
_ => {}
}
}
states.insert(audience, next_state);
}
}
@@ -276,6 +345,11 @@ where
== Some(RPC_AUTH_VERSION_V2)
});
let challenge = authenticated.then(Uuid::new_v4);
let sent_state = request
.extensions()
.get::<PeerReplayStateSnapshot>()
.map(|snapshot| snapshot.0)
.unwrap_or_default();
if let (Some(audience), Some(challenge)) = (self.audience.as_deref(), challenge) {
// The challenge is independently HMAC-authenticated by the response proof. It is not
// part of v2 so old peers ignore it, while a new peer can safely advertise its epoch.
@@ -284,7 +358,7 @@ where
challenge.to_string().parse().expect("UUID must be a valid header value"),
);
if let (Some(boot_epoch), Some(timestamp), Some(content_sha256)) = (
cached_peer_boot_epoch(audience),
sent_state.boot_epoch,
request.headers().get(TIMESTAMP_HEADER).and_then(|value| value.to_str().ok()),
request
.headers()
@@ -303,16 +377,21 @@ where
Box::pin(async move {
let response = future.await?;
if let (Some(audience), Some(challenge)) = (audience, challenge) {
match verify_tonic_boot_epoch_response(&audience, challenge, response.headers()) {
Ok(epoch) => remember_peer_boot_epoch(audience, epoch),
Err(error)
if response.headers().contains_key(RPC_BOOT_EPOCH_HEADER)
|| response.headers().contains_key(RPC_BOOT_EPOCH_PROOF_HEADER) =>
{
debug!(error = %error, "peer boot epoch response proof was rejected")
}
Err(_) => {}
let response_state = verify_tonic_peer_replay_capabilities_response(&audience, challenge, response.headers());
if let Err(error) = &response_state
&& (response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_HEADER)
|| response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER))
{
debug!(
event = "internode_rpc_capability_proof_rejected",
component = "ecstore",
subsystem = "rpc_client",
result = "rejected",
error = %error,
"internode RPC capability proof rejected"
)
}
apply_peer_replay_response(audience, sent_state, response_state);
}
Ok(response)
})
@@ -321,6 +400,7 @@ where
pub struct TonicSignatureInterceptor {
audience: Option<String>,
body_digest_strict: bool,
}
impl tonic::service::Interceptor for TonicSignatureInterceptor {
@@ -337,9 +417,31 @@ impl tonic::service::Interceptor for TonicSignatureInterceptor {
.metadata()
.get(RPC_CONTENT_SHA256_HEADER)
.and_then(|value| value.to_str().ok());
// RUSTFS_COMPAT_TODO(disk-mutation-body-digest): use cache-free v2 for peers without an authenticated boot epoch. Remove after every supported peer advertises the authenticated dynamic replay-cache capability and body-digest strict mode is the default.
// beta.11 verifies v2 body digests but stores their nonces in a fixed-size cache.
let rolling_mutation = req.extensions().get::<RollingMutationBodyDigest>().is_some();
let peer_state = PEER_REPLAY_STATES
.lock()
.map_err(|_| tonic::Status::unauthenticated("RPC peer capability state unavailable"))?
.get(audience)
.copied()
.unwrap_or_default();
let content_sha256 = if content_sha256.is_some() {
if peer_state.cache_capability == Some(PeerReplayCapability::Revoked) {
return Err(tonic::Status::unauthenticated("RPC peer replay capability changed"));
}
if rolling_mutation && !self.body_digest_strict && peer_state.boot_epoch.is_none() {
None
} else {
content_sha256
}
} else {
content_sha256
};
let headers = gen_tonic_signature_headers(audience, method.service(), method.method(), content_sha256)
.map_err(|_| tonic::Status::unauthenticated("No valid auth token"))?;
req.metadata_mut().as_mut().extend(headers);
req.extensions_mut().insert(PeerReplayStateSnapshot(peer_state));
inject_trace_context_into_metadata(req.metadata_mut());
inject_request_id_into_metadata(req.metadata_mut());
Ok(req)
@@ -347,7 +449,10 @@ impl tonic::service::Interceptor for TonicSignatureInterceptor {
}
pub fn gen_tonic_signature_interceptor() -> TonicSignatureInterceptor {
TonicSignatureInterceptor { audience: None }
TonicSignatureInterceptor {
audience: None,
body_digest_strict: internode_rpc_body_digest_strict(),
}
}
pub struct NoOpInterceptor;
@@ -409,6 +514,7 @@ mod tests {
#[derive(Clone)]
struct EpochProofService {
audience: String,
include_capability: bool,
seen_headers: std::sync::Arc<Mutex<Vec<http::HeaderMap>>>,
}
@@ -430,29 +536,97 @@ mod tests {
.expect("client challenge must be syntactically valid")
.expect("authenticated client request must carry a boot epoch challenge");
let mut response = HttpResponse::new(());
response.headers_mut().extend(
tonic_boot_epoch_response_headers(&self.audience, challenge)
.expect("test server must be able to sign an epoch proof"),
);
let mut headers = tonic_boot_epoch_response_headers(&self.audience, challenge)
.expect("test server must be able to sign an epoch proof");
if !self.include_capability {
headers.remove(RPC_REPLAY_CACHE_CAPABILITY_HEADER);
headers.remove(RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER);
}
response.headers_mut().extend(headers);
std::future::ready(Ok(response))
}
}
#[derive(Clone)]
struct MissingProofService;
impl Service<HttpRequest<()>> for MissingProofService {
type Response = HttpResponse<()>;
type Error = std::convert::Infallible;
type Future = std::future::Ready<Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, _request: HttpRequest<()>) -> Self::Future {
std::future::ready(Ok(HttpResponse::new(())))
}
}
fn ensure_test_rpc_secret() {
runtime_sources::ensure_test_rpc_secret();
}
fn test_request() -> tonic::Request<()> {
test_request_for("Ping")
}
fn test_request_for(method: &'static str) -> tonic::Request<()> {
let mut request = tonic::Request::new(());
request
.extensions_mut()
.insert(tonic::GrpcMethod::new("node_service.NodeService", "Ping"));
.insert(tonic::GrpcMethod::new("node_service.NodeService", method));
request
}
fn test_interceptor() -> TonicSignatureInterceptor {
test_interceptor_for("node-a:9000", false)
}
fn test_interceptor_for(audience: &str, body_digest_strict: bool) -> TonicSignatureInterceptor {
TonicSignatureInterceptor {
audience: Some("node-a:9000".to_string()),
audience: Some(audience.to_string()),
body_digest_strict,
}
}
fn clear_peer_capability(audience: &str) {
PEER_REPLAY_STATES
.lock()
.expect("peer capability cache lock must not be poisoned")
.remove(audience);
}
fn rolling_mutation_request(method: &'static str) -> tonic::Request<()> {
let mut request = tonic::Request::new(rustfs_protos::proto_gen::node_service::GenerallyLockRequest {
args: "canonical mutation request".to_string(),
});
request
.extensions_mut()
.insert(tonic::GrpcMethod::new("node_service.NodeService", method));
crate::cluster::rpc::set_tonic_rolling_mutation_body_digest(&mut request).expect("test mutation digest must be attached");
request.map(|_| ())
}
fn replay_scope_request(audience: &str, method: &'static str) -> HttpRequest<()> {
let mut request = HttpRequest::builder()
.uri(format!("/node_service.NodeService/{method}"))
.body(())
.expect("test RPC request must build");
request.headers_mut().extend(
gen_tonic_signature_headers(audience, "node_service.NodeService", method, None).expect("v2 test headers must mint"),
);
request
.extensions_mut()
.insert(PeerReplayStateSnapshot(peer_replay_state(audience)));
request
}
fn authenticated_peer_response(boot_epoch: Uuid, dynamic_replay_cache: bool) -> AuthenticatedPeerReplayCapabilities {
AuthenticatedPeerReplayCapabilities {
boot_epoch,
dynamic_replay_cache,
}
}
@@ -567,6 +741,431 @@ mod tests {
);
}
#[test]
fn unknown_peer_mutations_use_cache_free_unsigned_v2() {
ensure_test_rpc_secret();
let audience = "legacy-body-digest-client-test:9000";
clear_peer_capability(audience);
let mut interceptor = test_interceptor_for(audience, false);
for method in ["Lock", "WriteAll"] {
let request = interceptor
.call(rolling_mutation_request(method))
.expect("interceptor call should succeed");
assert_eq!(
request
.metadata()
.get("x-rustfs-content-sha256")
.and_then(|value| value.to_str().ok()),
Some("UNSIGNED-PAYLOAD")
);
assert_eq!(
request
.metadata()
.get("x-rustfs-rpc-nonce")
.and_then(|value| value.to_str().ok()),
Some("unsigned")
);
assert!(
crate::cluster::rpc::verify_tonic_rpc_signature(
audience,
&format!("/node_service.NodeService/{method}"),
request.metadata().as_ref(),
)
.is_ok(),
"the cache-free request must retain valid audience- and method-bound v2 authentication"
);
}
}
#[test]
fn unknown_peer_exact_body_contract_remains_body_bound() {
ensure_test_rpc_secret();
let audience = "exact-body-contract-client-test:9000";
clear_peer_capability(audience);
let mut interceptor = test_interceptor_for(audience, false);
let mut request = test_request_for("ScannerActivity");
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, b"exact scanner activity body")
.expect("test exact body digest must be attached");
let expected_digest = request
.metadata()
.get("x-rustfs-content-sha256")
.and_then(|value| value.to_str().ok())
.expect("test request must carry its digest")
.to_string();
let request = interceptor.call(request).expect("interceptor call should succeed");
assert_eq!(
request
.metadata()
.get("x-rustfs-content-sha256")
.and_then(|value| value.to_str().ok()),
Some(expected_digest.as_str())
);
assert!(
crate::cluster::rpc::verify_tonic_rpc_signature(
audience,
"/node_service.NodeService/ScannerActivity",
request.metadata().as_ref(),
)
.is_ok()
);
}
#[test]
fn unknown_peer_iam_mutation_helper_remains_body_bound() {
ensure_test_rpc_secret();
let audience = "exact-iam-mutation-client-test:9000";
clear_peer_capability(audience);
let mut interceptor = test_interceptor_for(audience, false);
let mut request = tonic::Request::new(rustfs_protos::proto_gen::node_service::DeleteUserRequest {
access_key: "target-access-key".to_string(),
});
request
.extensions_mut()
.insert(tonic::GrpcMethod::new("node_service.NodeService", "DeleteUser"));
crate::cluster::rpc::set_tonic_mutation_body_digest(&mut request).expect("test IAM mutation digest must be attached");
let request = request.map(|_| ());
let expected_digest = request
.metadata()
.get("x-rustfs-content-sha256")
.and_then(|value| value.to_str().ok())
.expect("test IAM mutation must carry its digest")
.to_string();
let request = interceptor.call(request).expect("interceptor call should succeed");
assert_eq!(
request
.metadata()
.get("x-rustfs-content-sha256")
.and_then(|value| value.to_str().ok()),
Some(expected_digest.as_str())
);
assert!(
crate::cluster::rpc::verify_tonic_rpc_signature(
audience,
"/node_service.NodeService/DeleteUser",
request.metadata().as_ref(),
)
.is_ok(),
"IAM mutations must remain body-bound before capability discovery"
);
}
#[test]
fn authenticated_replay_cache_capability_enables_body_binding() {
ensure_test_rpc_secret();
let audience = "body-digest-capable-client-test:9000";
clear_peer_capability(audience);
let seen_headers = std::sync::Arc::new(Mutex::new(Vec::new()));
let service = EpochProofService {
audience: audience.to_string(),
include_capability: true,
seen_headers,
};
let mut channel = ReplayScopeChannel::new(service, Some(audience.to_string()));
futures::executor::block_on(channel.call(replay_scope_request(audience, "Ping")))
.expect("authenticated capability probe must complete");
let mut interceptor = test_interceptor_for(audience, false);
let request = rolling_mutation_request("Lock");
let expected_digest = request
.metadata()
.get("x-rustfs-content-sha256")
.and_then(|value| value.to_str().ok())
.expect("test mutation must carry its digest")
.to_string();
let request = interceptor.call(request).expect("interceptor call should succeed");
assert_eq!(
request
.metadata()
.get("x-rustfs-content-sha256")
.and_then(|value| value.to_str().ok()),
Some(expected_digest.as_str())
);
let nonce = request
.metadata()
.get("x-rustfs-rpc-nonce")
.and_then(|value| value.to_str().ok())
.and_then(|value| Uuid::parse_str(value).ok())
.expect("capable peer body-bound mutation must carry a UUID nonce");
assert!(!nonce.is_nil());
assert!(
crate::cluster::rpc::verify_tonic_rpc_signature(
audience,
"/node_service.NodeService/Lock",
request.metadata().as_ref(),
)
.is_ok(),
"the body-bound request must retain valid audience- and method-bound v2 authentication"
);
clear_peer_capability(audience);
}
#[test]
fn invalid_capability_proof_does_not_enable_body_binding() {
ensure_test_rpc_secret();
let audience = "invalid-capability-client-test:9000";
clear_peer_capability(audience);
let service = EpochProofService {
audience: "wrong-capability-audience:9000".to_string(),
include_capability: true,
seen_headers: std::sync::Arc::new(Mutex::new(Vec::new())),
};
let mut channel = ReplayScopeChannel::new(service, Some(audience.to_string()));
futures::executor::block_on(channel.call(replay_scope_request(audience, "Ping")))
.expect("invalid capability response must still complete");
let mut interceptor = test_interceptor_for(audience, false);
let request = interceptor
.call(rolling_mutation_request("Lock"))
.expect("legacy-compatible mutation must still be signed");
assert_eq!(
request
.metadata()
.get("x-rustfs-content-sha256")
.and_then(|value| value.to_str().ok()),
Some("UNSIGNED-PAYLOAD")
);
}
#[test]
fn legacy_boot_proof_keeps_mutations_body_bound_and_enables_non_ping_v3() {
ensure_test_rpc_secret();
let audience = "legacy-boot-proof-client-test:9000";
clear_peer_capability(audience);
let seen_headers = std::sync::Arc::new(Mutex::new(Vec::new()));
let service = EpochProofService {
audience: audience.to_string(),
include_capability: false,
seen_headers: seen_headers.clone(),
};
let mut channel = ReplayScopeChannel::new(service, Some(audience.to_string()));
futures::executor::block_on(channel.call(replay_scope_request(audience, "Ping")))
.expect("legacy boot proof response must complete");
let state = peer_replay_state(audience);
assert!(state.boot_epoch.is_some(), "authenticated legacy proof must enable replay-scoped v3");
assert_eq!(state.cache_capability, None);
let mut interceptor = test_interceptor_for(audience, false);
let request = rolling_mutation_request("Lock");
let expected_digest = request
.metadata()
.get("x-rustfs-content-sha256")
.and_then(|value| value.to_str().ok())
.expect("test mutation must carry its digest")
.to_string();
let request = interceptor.call(request).expect("legacy-compatible mutation must be signed");
assert_eq!(
request
.metadata()
.get("x-rustfs-content-sha256")
.and_then(|value| value.to_str().ok()),
Some(expected_digest.as_str())
);
let (metadata, extensions, body) = request.into_parts();
let mut request = HttpRequest::new(body);
*request.uri_mut() = "/node_service.NodeService/Lock".parse().expect("test RPC URI must parse");
*request.headers_mut() = metadata.into_headers();
*request.extensions_mut() = extensions;
futures::executor::block_on(channel.call(request)).expect("legacy strict-compatible lock request must complete");
let headers = seen_headers.lock().expect("test header capture lock must not be poisoned");
assert!(
headers[1].contains_key(RPC_REPLAY_SCOPE_VERSION_HEADER),
"authenticated legacy boot proof must enable v3 on a non-Ping request"
);
}
#[test]
fn reordered_capability_responses_cannot_undo_newer_state() {
let audience = "reordered-capability-client-test:9000";
let epoch_one = Uuid::new_v4();
let epoch_two = Uuid::new_v4();
clear_peer_capability(audience);
let unknown = PeerReplayState::default();
apply_peer_replay_response(audience.to_string(), unknown, Ok(authenticated_peer_response(epoch_one, true)));
apply_peer_replay_response(audience.to_string(), unknown, Err(std::io::Error::other("delayed legacy response")));
let epoch_one_state = PeerReplayState {
boot_epoch: Some(epoch_one),
cache_capability: Some(PeerReplayCapability::Capable { boot_epoch: epoch_one }),
};
assert_eq!(peer_replay_state(audience), epoch_one_state);
apply_peer_replay_response(audience.to_string(), epoch_one_state, Err(std::io::Error::other("rollback response")));
apply_peer_replay_response(audience.to_string(), epoch_one_state, Ok(authenticated_peer_response(epoch_one, true)));
assert_eq!(
peer_replay_state(audience),
PeerReplayState {
boot_epoch: Some(epoch_one),
cache_capability: Some(PeerReplayCapability::Revoked),
}
);
let revoked = peer_replay_state(audience);
apply_peer_replay_response(audience.to_string(), revoked, Ok(authenticated_peer_response(epoch_two, true)));
apply_peer_replay_response(audience.to_string(), epoch_one_state, Ok(authenticated_peer_response(epoch_one, true)));
assert_eq!(
peer_replay_state(audience),
PeerReplayState {
boot_epoch: Some(epoch_two),
cache_capability: Some(PeerReplayCapability::Capable { boot_epoch: epoch_two }),
}
);
clear_peer_capability(audience);
}
#[test]
fn stale_capability_response_cannot_cross_a_new_boot_epoch() {
let audience = "cross-epoch-capability-client-test:9000";
let epoch_one = Uuid::new_v4();
let epoch_two = Uuid::new_v4();
let epoch_three = Uuid::new_v4();
clear_peer_capability(audience);
let revoked_epoch_one = PeerReplayState {
boot_epoch: Some(epoch_one),
cache_capability: Some(PeerReplayCapability::Revoked),
};
PEER_REPLAY_STATES
.lock()
.expect("peer replay state lock must not be poisoned")
.insert(audience.to_string(), revoked_epoch_one);
apply_peer_replay_response(
audience.to_string(),
revoked_epoch_one,
Ok(authenticated_peer_response(epoch_three, false)),
);
apply_peer_replay_response(audience.to_string(), revoked_epoch_one, Ok(authenticated_peer_response(epoch_two, true)));
assert_eq!(
peer_replay_state(audience),
PeerReplayState {
boot_epoch: Some(epoch_three),
cache_capability: Some(PeerReplayCapability::Revoked),
},
"a stale dynamic-cache proof must not cross a newer authenticated boot epoch"
);
clear_peer_capability(audience);
}
#[test]
fn interceptor_snapshot_prevents_delayed_legacy_response_from_revoking_capability() {
ensure_test_rpc_secret();
let audience = "capability-snapshot-client-test:9000";
clear_peer_capability(audience);
let boot_epoch = Uuid::new_v4();
let mut interceptor = test_interceptor_for(audience, false);
let request = interceptor
.call(rolling_mutation_request("Lock"))
.expect("legacy-compatible request must pass the interceptor");
assert_eq!(
request
.extensions()
.get::<PeerReplayStateSnapshot>()
.map(|snapshot| snapshot.0),
Some(PeerReplayState::default()),
"interceptor must preserve its unknown-state admission snapshot"
);
let capable_state = PeerReplayState {
boot_epoch: Some(boot_epoch),
cache_capability: Some(PeerReplayCapability::Capable { boot_epoch }),
};
PEER_REPLAY_STATES
.lock()
.expect("peer capability cache lock must not be poisoned")
.insert(audience.to_string(), capable_state);
let (metadata, extensions, body) = request.into_parts();
let mut request = HttpRequest::new(body);
*request.uri_mut() = "/node_service.NodeService/Lock".parse().expect("test RPC URI must parse");
*request.headers_mut() = metadata.into_headers();
*request.extensions_mut() = extensions;
let mut channel = ReplayScopeChannel::new(MissingProofService, Some(audience.to_string()));
futures::executor::block_on(channel.call(request)).expect("in-flight request response must complete");
assert_eq!(peer_replay_state(audience), capable_state);
clear_peer_capability(audience);
}
#[test]
fn strict_mode_keeps_unknown_peer_mutations_body_bound() {
ensure_test_rpc_secret();
let audience = "strict-body-digest-client-test:9000";
clear_peer_capability(audience);
let mut interceptor = test_interceptor_for(audience, true);
let request = rolling_mutation_request("WriteAll");
let expected_digest = request
.metadata()
.get("x-rustfs-content-sha256")
.and_then(|value| value.to_str().ok())
.expect("test mutation must carry its digest")
.to_string();
let request = interceptor.call(request).expect("interceptor call should succeed");
assert_eq!(
request
.metadata()
.get("x-rustfs-content-sha256")
.and_then(|value| value.to_str().ok()),
Some(expected_digest.as_str())
);
let nonce = request
.metadata()
.get("x-rustfs-rpc-nonce")
.and_then(|value| value.to_str().ok())
.and_then(|value| Uuid::parse_str(value).ok())
.expect("strict body-bound mutation must carry a UUID nonce");
assert!(!nonce.is_nil());
assert!(
crate::cluster::rpc::verify_tonic_rpc_signature(
audience,
"/node_service.NodeService/WriteAll",
request.metadata().as_ref(),
)
.is_ok()
);
}
#[test]
fn missing_capability_after_pin_fails_closed() {
ensure_test_rpc_secret();
let audience = "revoked-capability-client-test:9000";
let boot_epoch = Uuid::new_v4();
PEER_REPLAY_STATES
.lock()
.expect("peer capability cache lock must not be poisoned")
.insert(
audience.to_string(),
PeerReplayState {
boot_epoch: Some(boot_epoch),
cache_capability: Some(PeerReplayCapability::Capable { boot_epoch }),
},
);
let mut channel = ReplayScopeChannel::new(MissingProofService, Some(audience.to_string()));
futures::executor::block_on(channel.call(replay_scope_request(audience, "Ping")))
.expect("legacy response must complete before capability rejection");
let mut interceptor = test_interceptor_for(audience, false);
let error = interceptor
.call(rolling_mutation_request("Lock"))
.expect_err("a peer that loses its pinned capability must fail closed");
assert_eq!(error.code(), tonic::Code::Unauthenticated);
assert_eq!(error.message(), "RPC peer replay capability changed");
clear_peer_capability(audience);
}
#[test]
fn test_signature_interceptor_binds_audience_from_peer_uri() {
let interceptor = TonicInterceptor::Signature(gen_tonic_signature_interceptor())
@@ -583,27 +1182,15 @@ mod tests {
fn replay_scope_channel_uses_epoch_proof_before_sending_v3() {
ensure_test_rpc_secret();
let audience = "replay-scope-client-test:9000";
PEER_BOOT_EPOCHS
.lock()
.expect("peer epoch cache lock must not be poisoned")
.remove(audience);
clear_peer_capability(audience);
let seen_headers = std::sync::Arc::new(Mutex::new(Vec::new()));
let service = EpochProofService {
audience: audience.to_string(),
include_capability: true,
seen_headers: seen_headers.clone(),
};
let mut channel = ReplayScopeChannel::new(service, Some(audience.to_string()));
let make_request = || {
let mut request = HttpRequest::builder()
.uri("/node_service.NodeService/Ping")
.body(())
.expect("test RPC request must build");
request.headers_mut().extend(
gen_tonic_signature_headers(audience, "node_service.NodeService", "Ping", None)
.expect("v2 test headers must mint"),
);
request
};
let make_request = || replay_scope_request(audience, "Ping");
futures::executor::block_on(channel.call(make_request())).expect("first request must complete");
futures::executor::block_on(channel.call(make_request())).expect("second request must complete");
@@ -619,10 +1206,7 @@ mod tests {
headers[1].contains_key(RPC_REPLAY_SCOPE_VERSION_HEADER),
"the second request must carry the replay-scoped v3 signature"
);
PEER_BOOT_EPOCHS
.lock()
.expect("peer epoch cache lock must not be poisoned")
.remove(audience);
clear_peer_capability(audience);
}
#[test]
+481 -22
View File
@@ -27,7 +27,10 @@
//! Advisory: <https://github.com/rustfs/rustfs/security/advisories/GHSA-r5qv-rc46-hv8q>
use crate::cluster::rpc::context_propagation::{inject_request_id_into_http_headers, inject_trace_context_into_http_headers};
use crate::storage_api_contracts::internode::NS_SCANNER_PROTOCOL_VERSION;
use crate::storage_api_contracts::internode::{
NS_SCANNER_PROTOCOL_VERSION, PUT_FILE_AUTH_TRAILER_DIGEST_LEN, PUT_FILE_AUTH_TRAILER_LEN, PUT_FILE_AUTH_TRAILER_MAC_LEN,
PUT_FILE_AUTH_TRAILER_MAGIC, PUT_FILE_CAPABILITY_VERSION,
};
use base64::Engine as _;
use base64::engine::general_purpose;
use hmac::{Hmac, KeyInit, Mac};
@@ -37,8 +40,11 @@ use http::{HeaderMap, HeaderValue, Method, Uri};
use rustfs_credentials::{DEFAULT_SECRET_KEY, RPC_SECRET_REQUIRED_MESSAGE};
use rustfs_credentials::{RPC_SECRET_REQUIRED_OPERATOR_MESSAGE, try_get_rpc_token};
use rustfs_io_metrics::internode_metrics::{
INTERNODE_OPERATION_GRPC_OTHER, INTERNODE_OPERATION_GRPC_READ_ALL, INTERNODE_OPERATION_GRPC_READ_MULTIPLE,
INTERNODE_OPERATION_GRPC_WRITE_ALL, INTERNODE_TRANSPORT_BACKEND_GRPC, global_internode_metrics,
INTERNODE_OPERATION_GRPC_BATCH_READ_VERSION, INTERNODE_OPERATION_GRPC_FORCE_UNLOCK, INTERNODE_OPERATION_GRPC_LOCK,
INTERNODE_OPERATION_GRPC_LOCK_BATCH, INTERNODE_OPERATION_GRPC_OTHER, INTERNODE_OPERATION_GRPC_READ_ALL,
INTERNODE_OPERATION_GRPC_READ_MULTIPLE, INTERNODE_OPERATION_GRPC_READ_VERSION, INTERNODE_OPERATION_GRPC_REFRESH,
INTERNODE_OPERATION_GRPC_UNLOCK, INTERNODE_OPERATION_GRPC_UNLOCK_BATCH, INTERNODE_OPERATION_GRPC_WRITE_ALL,
INTERNODE_TRANSPORT_BACKEND_GRPC, global_internode_metrics,
};
use rustfs_object_data_cache::{MemoryBasis, resolve_effective_memory};
use rustfs_utils::get_env_bool;
@@ -67,19 +73,26 @@ pub const RPC_REPLAY_SCOPE_NONCE_HEADER: &str = "x-rustfs-rpc-replay-nonce";
pub const RPC_BOOT_EPOCH_HEADER: &str = "x-rustfs-rpc-boot-epoch";
pub const RPC_BOOT_EPOCH_CHALLENGE_HEADER: &str = "x-rustfs-rpc-boot-epoch-challenge";
pub const RPC_BOOT_EPOCH_PROOF_HEADER: &str = "x-rustfs-rpc-boot-epoch-proof";
pub(crate) const RPC_REPLAY_CACHE_CAPABILITY_HEADER: &str = "x-rustfs-rpc-replay-cache-capability";
pub(crate) const RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER: &str = "x-rustfs-rpc-replay-cache-capability-proof";
const RPC_REPLAY_SCOPE_VERSION_V3: &str = "3";
const RPC_RESPONSE_PROOF_DOMAIN: &[u8] = b"rustfs-rpc-response-proof-v1\0";
const RPC_REPLAY_SCOPE_DOMAIN: &[u8] = b"rustfs-rpc-replay-scope-v3\0";
const RPC_BOOT_EPOCH_PROOF_DOMAIN: &[u8] = b"rustfs-rpc-boot-epoch-proof-v1\0";
const RPC_REPLAY_CACHE_CAPABILITY_PROOF_DOMAIN: &[u8] = b"rustfs-rpc-replay-cache-capability-proof-v1\0";
const RPC_REPLAY_CACHE_CAPABILITY_V1: &str = "dynamic-replay-cache-v1";
const HTTP_PUT_FILE_AUTH_DOMAIN: &[u8] = b"rustfs-http-put-file-auth-v1\0";
const HTTP_PUT_FILE_CAPABILITY_AUTH_DOMAIN: &[u8] = b"rustfs-http-put-file-capability-v1\0";
const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
const UNSIGNED_PAYLOAD_NONCE: &str = "unsigned";
const SIGNATURE_VALID_DURATION: i64 = 300; // 5 minutes
const REPLAY_CACHE_RETENTION: Duration = Duration::from_secs(601);
const REPLAY_CACHE_RETENTION_SECS: usize = 601;
const REPLAY_CACHE_ENTRY_BYTES_ESTIMATE: u64 = 128;
const REPLAY_CACHE_AUTO_MEMORY_PERCENT: u64 = 8;
const REPLAY_CACHE_AUTO_RPC_RPS_PER_CPU: usize = 2048;
const REPLAY_CACHE_AUTO_MAX_CAPACITY: usize = 16_777_216;
// Keep 16 CPU / 32 GiB field nodes at the 32M cap without requiring an env override.
const REPLAY_CACHE_AUTO_MEMORY_PERCENT: u64 = 13;
const REPLAY_CACHE_AUTO_RPC_RPS_PER_CPU: usize = 4096;
const REPLAY_CACHE_AUTO_MAX_CAPACITY: usize = 33_554_432;
const NS_SCANNER_CAPABILITY_AUTH_DOMAIN: &[u8] = b"rustfs-ns-scanner-capability-v3";
pub const TONIC_RPC_PREFIX: &str = "/node_service.NodeService";
static INTERNODE_RPC_SIGNATURE_STRICT: LazyLock<bool> = LazyLock::new(|| {
@@ -94,6 +107,10 @@ static INTERNODE_RPC_BODY_DIGEST_STRICT: LazyLock<bool> = LazyLock::new(|| {
rustfs_config::DEFAULT_INTERNODE_RPC_BODY_DIGEST_STRICT,
)
});
pub(crate) fn internode_rpc_body_digest_strict() -> bool {
*INTERNODE_RPC_BODY_DIGEST_STRICT
}
static INTERNODE_RPC_REPLAY_SCOPE_STRICT: LazyLock<bool> = LazyLock::new(|| {
get_env_bool(
rustfs_config::ENV_INTERNODE_RPC_REPLAY_SCOPE_STRICT,
@@ -335,6 +352,7 @@ struct RpcNonceCacheMetrics<'a> {
expired: usize,
entries: usize,
capacity: usize,
record_scope: Option<RpcReplayCacheMetricScope<'a>>,
overflow_scope: Option<RpcReplayCacheMetricScope<'a>>,
}
@@ -345,6 +363,13 @@ fn publish_nonce_cache_metrics(metrics: Option<RpcNonceCacheMetrics<'_>>) {
let internode_metrics = global_internode_metrics();
internode_metrics.record_replay_cache_evictions("expired", metrics.expired);
internode_metrics.record_replay_cache_state(metrics.entries, metrics.capacity);
if let Some(scope) = metrics.record_scope {
internode_metrics.record_replay_cache_record_for_operation_and_backend_path(
scope.operation,
scope.backend,
scope.rpc_path,
);
}
if let Some(scope) = metrics.overflow_scope {
internode_metrics.record_replay_cache_overflow_for_operation_and_backend_path(
scope.operation,
@@ -380,6 +405,7 @@ impl RpcNonceCache {
expired,
entries: self.nonces.len(),
capacity: record.capacity,
record_scope: None,
overflow_scope: None,
};
if self.nonces.contains(&record.nonce) {
@@ -404,6 +430,7 @@ impl RpcNonceCache {
Ok(()),
Some(RpcNonceCacheMetrics {
entries: self.nonces.len(),
record_scope: Some(record.metric_scope),
..metrics
}),
)
@@ -475,6 +502,13 @@ fn signature_payload(url: &str, method: &Method, timestamp: i64) -> String {
format!("{url}|{method}|{timestamp}")
}
fn canonical_path_and_query(url: &str) -> std::io::Result<String> {
let uri: Uri = url.parse().map_err(|_| std::io::Error::other("Invalid RPC URL"))?;
uri.path_and_query()
.map(ToString::to_string)
.ok_or_else(|| std::io::Error::other("Invalid RPC URL"))
}
fn redacted_rpc_path(url: &str) -> String {
url.parse::<Uri>()
.ok()
@@ -502,6 +536,106 @@ fn verify_signature(secret: &str, url: &str, method: &Method, timestamp: i64, si
mac.verify_slice(&signature).is_ok()
}
fn update_put_file_auth_mac(
mac: &mut HmacSha256,
url: &str,
method: &Method,
nonce: Uuid,
body_sha256: &str,
) -> std::io::Result<()> {
if !valid_content_sha256(body_sha256) || body_sha256 == UNSIGNED_PAYLOAD {
return Err(std::io::Error::other("Invalid RPC content SHA-256"));
}
let path_and_query = canonical_path_and_query(url)?;
mac.update(HTTP_PUT_FILE_AUTH_DOMAIN);
for part in [
path_and_query.as_bytes(),
b"|",
method.as_str().as_bytes(),
b"|",
nonce.as_bytes(),
b"|",
body_sha256.as_bytes(),
] {
mac.update(part);
}
Ok(())
}
fn put_file_auth_mac(url: &str, method: &Method, nonce: Uuid, body_sha256: &str) -> std::io::Result<[u8; 32]> {
let mut mac = <HmacSha256 as KeyInit>::new_from_slice(get_shared_secret()?.as_bytes())
.map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
update_put_file_auth_mac(&mut mac, url, method, nonce, body_sha256)?;
Ok(mac.finalize().into_bytes().into())
}
fn verify_put_file_auth_mac(url: &str, method: &Method, nonce: Uuid, body_sha256: &str, signature: &[u8]) -> std::io::Result<()> {
let mut mac = <HmacSha256 as KeyInit>::new_from_slice(get_shared_secret()?.as_bytes())
.map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
update_put_file_auth_mac(&mut mac, url, method, nonce, body_sha256)?;
mac.verify_slice(signature)
.map_err(|_| std::io::Error::other("Invalid put_file auth trailer"))
}
pub fn build_put_file_auth_trailer(url: &str, method: &Method, nonce: Uuid, body_sha256: &str) -> std::io::Result<Vec<u8>> {
let mac = put_file_auth_mac(url, method, nonce, body_sha256)?;
let mut trailer = Vec::with_capacity(PUT_FILE_AUTH_TRAILER_LEN);
trailer.extend_from_slice(PUT_FILE_AUTH_TRAILER_MAGIC);
trailer.extend_from_slice(body_sha256.as_bytes());
trailer.extend_from_slice(&mac);
Ok(trailer)
}
pub fn verify_put_file_auth_trailer(url: &str, method: &Method, nonce: Uuid, trailer: &[u8]) -> std::io::Result<String> {
if trailer.len() != PUT_FILE_AUTH_TRAILER_LEN {
return Err(std::io::Error::other("Invalid put_file auth trailer length"));
}
if &trailer[..PUT_FILE_AUTH_TRAILER_MAGIC.len()] != PUT_FILE_AUTH_TRAILER_MAGIC {
return Err(std::io::Error::other("Invalid put_file auth trailer"));
}
let digest_start = PUT_FILE_AUTH_TRAILER_MAGIC.len();
let digest_end = digest_start + PUT_FILE_AUTH_TRAILER_DIGEST_LEN;
let body_sha256 = std::str::from_utf8(&trailer[digest_start..digest_end])
.map_err(|_| std::io::Error::other("Invalid RPC content SHA-256"))?;
if !valid_content_sha256(body_sha256) || body_sha256 == UNSIGNED_PAYLOAD {
return Err(std::io::Error::other("Invalid RPC content SHA-256"));
}
let mac_start = digest_end;
let mac_end = mac_start + PUT_FILE_AUTH_TRAILER_MAC_LEN;
verify_put_file_auth_mac(url, method, nonce, body_sha256, &trailer[mac_start..mac_end])?;
Ok(body_sha256.to_string())
}
fn update_put_file_capability_mac(mac: &mut HmacSha256, challenge: Uuid, server_epoch: Uuid, version: u16) {
mac.update(HTTP_PUT_FILE_CAPABILITY_AUTH_DOMAIN);
mac.update(challenge.as_bytes());
mac.update(server_epoch.as_bytes());
mac.update(&version.to_be_bytes());
}
fn put_file_capability_mac(challenge: Uuid, server_epoch: Uuid, version: u16) -> std::io::Result<HmacSha256> {
if challenge.is_nil() || server_epoch.is_nil() || version != PUT_FILE_CAPABILITY_VERSION {
return Err(std::io::Error::other("Invalid put_file capability scope"));
}
let mut mac = HmacSha256::new_from_slice(get_shared_secret()?.as_bytes())
.map_err(|_| std::io::Error::other("Invalid RPC HMAC secret"))?;
update_put_file_capability_mac(&mut mac, challenge, server_epoch, version);
Ok(mac)
}
pub fn sign_put_file_capability(challenge: Uuid, server_epoch: Uuid, version: u16) -> std::io::Result<Vec<u8>> {
Ok(put_file_capability_mac(challenge, server_epoch, version)?
.finalize()
.into_bytes()
.to_vec())
}
pub fn verify_put_file_capability(challenge: Uuid, server_epoch: Uuid, version: u16, proof: &[u8]) -> std::io::Result<()> {
put_file_capability_mac(challenge, server_epoch, version)?
.verify_slice(proof)
.map_err(|_| std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Invalid put_file capability proof"))
}
fn update_ns_scanner_capability_mac(mac: &mut HmacSha256, challenge: Uuid, server_epoch: Uuid) {
mac.update(NS_SCANNER_CAPABILITY_AUTH_DOMAIN);
mac.update(&NS_SCANNER_PROTOCOL_VERSION.to_be_bytes());
@@ -664,6 +798,50 @@ fn verify_boot_epoch_proof(secret: &str, audience: &str, challenge: Uuid, boot_e
.map_err(|_| std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Invalid RPC boot epoch proof"))
}
fn update_replay_cache_capability_proof(mac: &mut HmacSha256, audience: &str, challenge: Uuid, boot_epoch: Uuid) {
mac.update(RPC_REPLAY_CACHE_CAPABILITY_PROOF_DOMAIN);
for part in [
audience.as_bytes(),
b"|",
challenge.as_bytes(),
b"|",
boot_epoch.as_bytes(),
b"|",
RPC_REPLAY_CACHE_CAPABILITY_V1.as_bytes(),
] {
mac.update(part);
}
}
fn generate_replay_cache_capability_proof(
secret: &str,
audience: &str,
challenge: Uuid,
boot_epoch: Uuid,
) -> std::io::Result<String> {
let mut mac =
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
update_replay_cache_capability_proof(&mut mac, audience, challenge, boot_epoch);
Ok(general_purpose::STANDARD.encode(mac.finalize().into_bytes()))
}
fn verify_replay_cache_capability_proof(
secret: &str,
audience: &str,
challenge: Uuid,
boot_epoch: Uuid,
proof: &str,
) -> std::io::Result<()> {
let proof = general_purpose::STANDARD
.decode(proof)
.map_err(|_| std::io::Error::other("Invalid RPC replay cache capability proof"))?;
let mut mac =
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
update_replay_cache_capability_proof(&mut mac, audience, challenge, boot_epoch);
mac.verify_slice(&proof)
.map_err(|_| std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Invalid RPC replay cache capability proof"))
}
fn non_nil_uuid(value: &str, name: &str) -> std::io::Result<Uuid> {
let value = Uuid::parse_str(value).map_err(|_| std::io::Error::other(format!("Invalid {name}")))?;
(!value.is_nil())
@@ -746,15 +924,34 @@ pub fn tonic_boot_epoch_challenge(headers: &HeaderMap) -> std::io::Result<Option
/// Build the authenticated response headers for a client boot-epoch challenge.
pub fn tonic_boot_epoch_response_headers(audience: &str, challenge: Uuid) -> std::io::Result<HeaderMap> {
let boot_epoch = tonic_rpc_boot_epoch();
let proof = generate_boot_epoch_proof(&get_shared_secret()?, audience, challenge, boot_epoch)?;
let secret = get_shared_secret()?;
let proof = generate_boot_epoch_proof(&secret, audience, challenge, boot_epoch)?;
let capability_proof = generate_replay_cache_capability_proof(&secret, audience, challenge, boot_epoch)?;
let mut headers = HeaderMap::new();
headers.insert(RPC_BOOT_EPOCH_HEADER, header_value(&boot_epoch.to_string(), RPC_BOOT_EPOCH_HEADER)?);
headers.insert(RPC_BOOT_EPOCH_PROOF_HEADER, header_value(&proof, RPC_BOOT_EPOCH_PROOF_HEADER)?);
headers.insert(
RPC_REPLAY_CACHE_CAPABILITY_HEADER,
HeaderValue::from_static(RPC_REPLAY_CACHE_CAPABILITY_V1),
);
headers.insert(
RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER,
header_value(&capability_proof, RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER)?,
);
Ok(headers)
}
/// Verify the server boot-epoch response for a challenge generated by this client.
pub fn verify_tonic_boot_epoch_response(audience: &str, challenge: Uuid, headers: &HeaderMap) -> std::io::Result<Uuid> {
verify_tonic_boot_epoch_response_with_secret(&get_shared_secret()?, audience, challenge, headers)
}
fn verify_tonic_boot_epoch_response_with_secret(
secret: &str,
audience: &str,
challenge: Uuid,
headers: &HeaderMap,
) -> std::io::Result<Uuid> {
let boot_epoch = headers
.get(RPC_BOOT_EPOCH_HEADER)
.and_then(|value| value.to_str().ok())
@@ -764,10 +961,47 @@ pub fn verify_tonic_boot_epoch_response(audience: &str, challenge: Uuid, headers
.get(RPC_BOOT_EPOCH_PROOF_HEADER)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| std::io::Error::other("Missing RPC boot epoch proof"))?;
verify_boot_epoch_proof(&get_shared_secret()?, audience, challenge, boot_epoch, proof)?;
verify_boot_epoch_proof(secret, audience, challenge, boot_epoch, proof)?;
Ok(boot_epoch)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct AuthenticatedPeerReplayCapabilities {
pub(crate) boot_epoch: Uuid,
pub(crate) dynamic_replay_cache: bool,
}
pub(crate) fn verify_tonic_peer_replay_capabilities_response(
audience: &str,
challenge: Uuid,
headers: &HeaderMap,
) -> std::io::Result<AuthenticatedPeerReplayCapabilities> {
let secret = get_shared_secret()?;
let boot_epoch = verify_tonic_boot_epoch_response_with_secret(&secret, audience, challenge, headers)?;
let capability = headers.get(RPC_REPLAY_CACHE_CAPABILITY_HEADER);
let proof = headers.get(RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER);
if capability.is_none() && proof.is_none() {
return Ok(AuthenticatedPeerReplayCapabilities {
boot_epoch,
dynamic_replay_cache: false,
});
}
let capability = capability
.and_then(|value| value.to_str().ok())
.ok_or_else(|| std::io::Error::other("Missing RPC replay cache capability"))?;
if capability != RPC_REPLAY_CACHE_CAPABILITY_V1 {
return Err(std::io::Error::other("Unsupported RPC replay cache capability"));
}
let proof = proof
.and_then(|value| value.to_str().ok())
.ok_or_else(|| std::io::Error::other("Missing RPC replay cache capability proof"))?;
verify_replay_cache_capability_proof(&secret, audience, challenge, boot_epoch, proof)?;
Ok(AuthenticatedPeerReplayCapabilities {
boot_epoch,
dynamic_replay_cache: true,
})
}
fn valid_content_sha256(value: &str) -> bool {
value == UNSIGNED_PAYLOAD
|| (value.len() == 64
@@ -801,12 +1035,26 @@ fn tonic_rpc_metric_operation(path: &str) -> &'static str {
match parse_tonic_rpc_path(path).ok().map(|(_, rpc_method)| rpc_method) {
Some("ReadAll") => INTERNODE_OPERATION_GRPC_READ_ALL,
Some("ReadMultiple") => INTERNODE_OPERATION_GRPC_READ_MULTIPLE,
Some("ReadVersion") => INTERNODE_OPERATION_GRPC_READ_VERSION,
Some("BatchReadVersion") => INTERNODE_OPERATION_GRPC_BATCH_READ_VERSION,
Some("WriteAll") => INTERNODE_OPERATION_GRPC_WRITE_ALL,
Some("Lock") => INTERNODE_OPERATION_GRPC_LOCK,
Some("UnLock") => INTERNODE_OPERATION_GRPC_UNLOCK,
Some("LockBatch") => INTERNODE_OPERATION_GRPC_LOCK_BATCH,
Some("UnLockBatch") => INTERNODE_OPERATION_GRPC_UNLOCK_BATCH,
Some("Refresh") => INTERNODE_OPERATION_GRPC_REFRESH,
Some("ForceUnLock") => INTERNODE_OPERATION_GRPC_FORCE_UNLOCK,
_ => INTERNODE_OPERATION_GRPC_OTHER,
}
}
fn check_and_record_nonce(nonce: Uuid, signed_at: i64, rpc_path: &str) -> std::io::Result<()> {
fn check_and_record_nonce_with_scope(
nonce: Uuid,
signed_at: i64,
rpc_path: &str,
operation: &'static str,
backend: &'static str,
) -> std::io::Result<()> {
let wall_time = OffsetDateTime::now_utc().unix_timestamp();
let (result, metrics) = {
let mut cache = LOCAL_RPC_NONCE_CACHE
@@ -826,8 +1074,8 @@ fn check_and_record_nonce(nonce: Uuid, signed_at: i64, rpc_path: &str) -> std::i
expires_at,
capacity: *REPLAY_CACHE_CAPACITY,
metric_scope: RpcReplayCacheMetricScope {
operation: tonic_rpc_metric_operation(rpc_path),
backend: INTERNODE_TRANSPORT_BACKEND_GRPC,
operation,
backend,
rpc_path,
},
})
@@ -836,6 +1084,37 @@ fn check_and_record_nonce(nonce: Uuid, signed_at: i64, rpc_path: &str) -> std::i
result
}
fn check_and_record_tonic_nonce(nonce: Uuid, signed_at: i64, rpc_path: &str) -> std::io::Result<()> {
check_and_record_nonce_with_scope(
nonce,
signed_at,
rpc_path,
tonic_rpc_metric_operation(rpc_path),
INTERNODE_TRANSPORT_BACKEND_GRPC,
)
}
pub fn check_and_record_signed_rpc_nonce(
headers: &HeaderMap,
nonce: Uuid,
rpc_path: &str,
operation: &'static str,
backend: &'static str,
) -> std::io::Result<()> {
if nonce.is_nil() {
return Err(std::io::Error::other("Invalid RPC nonce"));
}
let timestamp_header = headers
.get(TIMESTAMP_HEADER)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| std::io::Error::other("Missing timestamp header"))?;
let timestamp = timestamp_header
.parse::<i64>()
.map_err(|_| std::io::Error::other("Invalid timestamp format"))?;
check_timestamp(timestamp)?;
check_and_record_nonce_with_scope(nonce, timestamp, rpc_path, operation, backend)
}
/// Build headers with authentication signature
pub fn build_auth_headers(url: &str, method: &Method, headers: &mut HeaderMap) -> std::io::Result<()> {
let auth_headers = gen_signature_headers(url, method)?;
@@ -933,6 +1212,23 @@ pub fn set_tonic_mutation_body_digest<T: rustfs_protos::CanonicalMutationBody>(
set_tonic_canonical_body_digest(request, &canonical_body)
}
pub fn set_tonic_rolling_mutation_body_digest<T: rustfs_protos::CanonicalMutationBody>(
request: &mut tonic::Request<T>,
) -> std::io::Result<()> {
set_tonic_mutation_body_digest(request)?;
request.extensions_mut().insert(RollingMutationBodyDigest);
Ok(())
}
pub fn set_tonic_rolling_canonical_body_digest<T>(request: &mut tonic::Request<T>, canonical_body: &[u8]) -> std::io::Result<()> {
set_tonic_canonical_body_digest(request, canonical_body)?;
request.extensions_mut().insert(RollingMutationBodyDigest);
Ok(())
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct RollingMutationBodyDigest;
pub fn verify_tonic_canonical_body_digest<T>(request: &tonic::Request<T>, canonical_body: &[u8]) -> std::io::Result<()> {
let version = request
.metadata()
@@ -969,7 +1265,7 @@ pub fn verify_tonic_canonical_body_digest<T>(request: &tonic::Request<T>, canoni
/// including v1-downgraded ones. It converges independently of the signature-strict switch
/// (<https://github.com/rustfs/backlog/issues/1327>).
pub fn verify_tonic_mutation_body_digest<T>(request: &tonic::Request<T>, canonical_body: &[u8]) -> std::io::Result<()> {
verify_tonic_mutation_body_digest_with_strictness(request, canonical_body, *INTERNODE_RPC_BODY_DIGEST_STRICT)
verify_tonic_mutation_body_digest_with_strictness(request, canonical_body, internode_rpc_body_digest_strict())
}
/// [`verify_tonic_mutation_body_digest`] with the strict gate injected as a parameter, so both
@@ -1095,7 +1391,7 @@ fn verify_tonic_replay_scope_signature(audience: &str, path: &str, headers: &Hea
if boot_epoch != tonic_rpc_boot_epoch() {
return Err(std::io::Error::other("RPC boot epoch is stale"));
}
check_and_record_nonce(nonce, signed_at, path)
check_and_record_tonic_nonce(nonce, signed_at, path)
}
/// Verify gRPC authentication, preferring v2 without downgrade on malformed v2 metadata.
@@ -1160,6 +1456,8 @@ pub fn tonic_rpc_auth_failure_reason(error: &std::io::Error) -> &'static str {
"Invalid unsigned RPC nonce" => "invalid_unsigned_v2_nonce",
"Missing RPC content SHA-256" => "missing_content_sha256",
"Invalid RPC content SHA-256" => "invalid_content_sha256",
"Invalid put_file auth trailer length" => "invalid_put_file_auth_trailer_length",
"Invalid put_file auth trailer" => "invalid_put_file_auth_trailer",
"Missing signature header" => "missing_v1_signature",
"Invalid signature" => "invalid_v1_signature",
"Invalid RPC HMAC key" => "invalid_hmac_key",
@@ -1286,7 +1584,7 @@ fn verify_tonic_rpc_signature_with_strictness(
return Err(std::io::Error::other("Invalid RPC v2 signature"));
}
if let Some(nonce) = parsed_nonce {
check_and_record_nonce(nonce, timestamp, path)?;
check_and_record_tonic_nonce(nonce, timestamp, path)?;
}
Ok(())
}
@@ -2020,6 +2318,23 @@ mod tests {
assert!(verify_tonic_boot_epoch_response("node-a:9000", Uuid::new_v4(), &headers).is_err());
}
#[test]
fn replay_cache_capability_proof_binds_audience_challenge_epoch_and_value() {
ensure_test_rpc_secret();
let challenge = Uuid::new_v4();
let headers = tonic_boot_epoch_response_headers("node-a:9000", challenge).expect("capability headers should build");
let capabilities = verify_tonic_peer_replay_capabilities_response("node-a:9000", challenge, &headers)
.expect("matching capability proof should verify");
assert_eq!(capabilities.boot_epoch, tonic_rpc_boot_epoch());
assert!(capabilities.dynamic_replay_cache);
assert!(verify_tonic_peer_replay_capabilities_response("node-b:9000", challenge, &headers).is_err());
assert!(verify_tonic_peer_replay_capabilities_response("node-a:9000", Uuid::new_v4(), &headers).is_err());
let mut changed_capability = headers;
changed_capability.insert(RPC_REPLAY_CACHE_CAPABILITY_HEADER, HeaderValue::from_static("dynamic-replay-cache-v2"));
assert!(verify_tonic_peer_replay_capabilities_response("node-a:9000", challenge, &changed_capability).is_err());
}
#[test]
fn tonic_rpc_auth_failure_reason_maps_security_relevant_errors() {
for (message, reason) in [
@@ -2031,6 +2346,8 @@ mod tests {
("Request timestamp expired", "timestamp_expired"),
("Missing RPC content SHA-256", "missing_content_sha256"),
("Invalid RPC content SHA-256", "invalid_content_sha256"),
("Invalid put_file auth trailer length", "invalid_put_file_auth_trailer_length"),
("Invalid put_file auth trailer", "invalid_put_file_auth_trailer"),
] {
assert_eq!(
tonic_rpc_auth_failure_reason(&std::io::Error::other(message)),
@@ -2178,6 +2495,51 @@ mod tests {
assert_eq!(tampered.to_string(), "RPC content SHA-256 mismatch");
}
#[test]
fn put_file_auth_trailer_binds_url_nonce_and_body_digest() {
ensure_test_rpc_secret();
let url = concat!(
"/rustfs/rpc/put_file_stream?disk=disk-a&volume=bucket&path=object%2Fpart.1",
"&append=false&size=11&put_file_auth=digest-trailer-v1&put_file_nonce=11111111-2222-4333-8444-555555555555"
);
let nonce = Uuid::parse_str("11111111-2222-4333-8444-555555555555").expect("nonce");
let body_sha256 = hex_simd::encode_to_string(Sha256::digest(b"hello world"), hex_simd::AsciiCase::Lower);
let trailer = build_put_file_auth_trailer(url, &Method::PUT, nonce, &body_sha256).expect("trailer should build");
assert_eq!(trailer.len(), PUT_FILE_AUTH_TRAILER_LEN);
let verified = verify_put_file_auth_trailer(url, &Method::PUT, nonce, &trailer).expect("trailer should verify");
assert_eq!(verified, body_sha256);
let different_url = url.replace("size=11", "size=12");
let err = verify_put_file_auth_trailer(&different_url, &Method::PUT, nonce, &trailer)
.expect_err("trailer must bind the signed URL");
assert_eq!(err.to_string(), "Invalid put_file auth trailer");
let err =
verify_put_file_auth_trailer(url, &Method::PUT, Uuid::new_v4(), &trailer).expect_err("trailer must bind the nonce");
assert_eq!(err.to_string(), "Invalid put_file auth trailer");
let mut tampered = trailer;
tampered[PUT_FILE_AUTH_TRAILER_MAGIC.len()] = b'0';
let err =
verify_put_file_auth_trailer(url, &Method::PUT, nonce, &tampered).expect_err("trailer must bind the digest bytes");
assert_eq!(err.to_string(), "Invalid put_file auth trailer");
}
#[test]
fn put_file_capability_proof_binds_challenge_epoch_and_version() {
ensure_test_rpc_secret();
let challenge = Uuid::parse_str("11111111-2222-4333-8444-555555555555").expect("challenge");
let server_epoch = Uuid::parse_str("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee").expect("server epoch");
let proof = sign_put_file_capability(challenge, server_epoch, PUT_FILE_CAPABILITY_VERSION)
.expect("capability proof should build");
assert!(verify_put_file_capability(challenge, server_epoch, PUT_FILE_CAPABILITY_VERSION, &proof).is_ok());
assert!(verify_put_file_capability(Uuid::new_v4(), server_epoch, PUT_FILE_CAPABILITY_VERSION, &proof).is_err());
assert!(verify_put_file_capability(challenge, Uuid::new_v4(), PUT_FILE_CAPABILITY_VERSION, &proof).is_err());
assert!(verify_put_file_capability(challenge, server_epoch, PUT_FILE_CAPABILITY_VERSION + 1, &proof).is_err());
}
#[test]
fn tier_mutation_rpc_contract_requires_method_bound_v2_body_digest() {
ensure_test_rpc_secret();
@@ -2259,10 +2621,42 @@ mod tests {
tonic_rpc_metric_operation("/node_service.NodeService/ReadMultiple"),
INTERNODE_OPERATION_GRPC_READ_MULTIPLE
);
assert_eq!(
tonic_rpc_metric_operation("/node_service.NodeService/ReadVersion"),
INTERNODE_OPERATION_GRPC_READ_VERSION
);
assert_eq!(
tonic_rpc_metric_operation("/node_service.NodeService/BatchReadVersion"),
INTERNODE_OPERATION_GRPC_BATCH_READ_VERSION
);
assert_eq!(
tonic_rpc_metric_operation("/node_service.NodeService/WriteAll"),
INTERNODE_OPERATION_GRPC_WRITE_ALL
);
assert_eq!(
tonic_rpc_metric_operation("/node_service.NodeService/Lock"),
INTERNODE_OPERATION_GRPC_LOCK
);
assert_eq!(
tonic_rpc_metric_operation("/node_service.NodeService/UnLock"),
INTERNODE_OPERATION_GRPC_UNLOCK
);
assert_eq!(
tonic_rpc_metric_operation("/node_service.NodeService/LockBatch"),
INTERNODE_OPERATION_GRPC_LOCK_BATCH
);
assert_eq!(
tonic_rpc_metric_operation("/node_service.NodeService/UnLockBatch"),
INTERNODE_OPERATION_GRPC_UNLOCK_BATCH
);
assert_eq!(
tonic_rpc_metric_operation("/node_service.NodeService/Refresh"),
INTERNODE_OPERATION_GRPC_REFRESH
);
assert_eq!(
tonic_rpc_metric_operation("/node_service.NodeService/ForceUnLock"),
INTERNODE_OPERATION_GRPC_FORCE_UNLOCK
);
assert_eq!(
tonic_rpc_metric_operation("/node_service.NodeService/SignalService"),
INTERNODE_OPERATION_GRPC_OTHER
@@ -2301,21 +2695,37 @@ mod tests {
assert_eq!(decision.source, ReplayCacheCapacitySource::Auto);
assert_eq!(decision.memory_basis, Some(MemoryBasis::Host));
assert_eq!(decision.memory_based_capacity, 10_737_418);
assert_eq!(decision.cpu_based_capacity, 9_846_784);
assert_eq!(decision.capacity, 9_846_784);
assert_eq!(decision.memory_based_capacity, 17_448_304);
assert_eq!(decision.cpu_based_capacity, 19_693_568);
assert_eq!(decision.capacity, 17_448_304);
}
#[test]
fn replay_cache_capacity_auto_reaches_hotpath_verified_capacity_on_larger_nodes() {
fn replay_cache_capacity_auto_uses_32m_on_field_sized_nodes() {
let gib = 1024_u64 * 1024 * 1024;
let decision =
replay_cache_capacity_decision(rustfs_utils::EnvParseOutcome::Absent, 16, Some(32 * gib), Some(MemoryBasis::Host));
assert_eq!(decision.source, ReplayCacheCapacitySource::Auto);
assert_eq!(decision.memory_based_capacity, 21_474_836);
assert_eq!(decision.cpu_based_capacity, 19_693_568);
assert_eq!(decision.capacity, 16_777_216);
assert_eq!(decision.memory_based_capacity, 34_896_609);
assert_eq!(decision.cpu_based_capacity, 39_387_136);
assert_eq!(decision.capacity, REPLAY_CACHE_AUTO_MAX_CAPACITY);
let observed_field_node =
replay_cache_capacity_decision(rustfs_utils::EnvParseOutcome::Absent, 16, Some(31 * gib), Some(MemoryBasis::Host));
assert_eq!(observed_field_node.memory_based_capacity, 33_806_090);
assert_eq!(observed_field_node.cpu_based_capacity, 39_387_136);
assert_eq!(observed_field_node.capacity, REPLAY_CACHE_AUTO_MAX_CAPACITY);
}
#[test]
fn replay_cache_capacity_auto_caps_extreme_nodes() {
let gib = 1024_u64 * 1024 * 1024;
let decision =
replay_cache_capacity_decision(rustfs_utils::EnvParseOutcome::Absent, 128, Some(512 * gib), Some(MemoryBasis::Host));
assert_eq!(decision.source, ReplayCacheCapacitySource::Auto);
assert_eq!(decision.capacity, REPLAY_CACHE_AUTO_MAX_CAPACITY);
}
#[test]
@@ -2337,7 +2747,7 @@ mod tests {
let decision = replay_cache_capacity_decision(rustfs_utils::EnvParseOutcome::Invalid, 8, None, None);
assert_eq!(decision.source, ReplayCacheCapacitySource::AutoInvalidEnv);
assert_eq!(decision.capacity, 9_846_784);
assert_eq!(decision.capacity, 19_693_568);
}
fn check_test_nonce_record(cache: &mut RpcNonceCache, record: RpcNonceRecord<'_>) -> std::io::Result<()> {
@@ -2346,6 +2756,13 @@ mod tests {
result
}
fn check_test_nonce_record_with_metrics<'a>(
cache: &mut RpcNonceCache,
record: RpcNonceRecord<'a>,
) -> (std::io::Result<()>, Option<RpcNonceCacheMetrics<'a>>) {
cache.check_and_record(record)
}
fn test_nonce_record(
nonce: Uuid,
signed_at: i64,
@@ -2389,6 +2806,48 @@ mod tests {
assert!(cache.nonces.contains(&nonce_b));
}
#[test]
fn nonce_cache_metrics_mark_successful_records_only() {
let now = Instant::now();
let expiry = now.checked_add(REPLAY_CACHE_RETENTION).expect("test expiry should fit");
let nonce_a = Uuid::new_v4();
let nonce_b = Uuid::new_v4();
let mut cache = RpcNonceCache::default();
let (recorded, metrics) =
check_test_nonce_record_with_metrics(&mut cache, test_nonce_record(nonce_a, 100, now, 100, expiry, 1));
recorded.expect("first nonce should be recorded");
let metrics = metrics.expect("successful nonce should publish metrics");
let record_scope = metrics.record_scope.expect("successful nonce should carry record scope");
assert_eq!(record_scope.operation, INTERNODE_OPERATION_GRPC_READ_ALL);
assert_eq!(record_scope.backend, INTERNODE_TRANSPORT_BACKEND_GRPC);
assert_eq!(record_scope.rpc_path, "/node_service.NodeService/ReadAll");
assert!(metrics.overflow_scope.is_none());
let (replay, metrics) =
check_test_nonce_record_with_metrics(&mut cache, test_nonce_record(nonce_a, 100, now, 100, expiry, 1));
assert_eq!(
replay.expect_err("duplicate nonce must fail closed").to_string(),
"RPC request replay detected"
);
let metrics = metrics.expect("replay rejection should still publish cache state");
assert!(metrics.record_scope.is_none());
assert!(metrics.overflow_scope.is_none());
let (overflow, metrics) =
check_test_nonce_record_with_metrics(&mut cache, test_nonce_record(nonce_b, 100, now, 100, expiry, 1));
assert_eq!(
overflow.expect_err("full cache must fail closed").to_string(),
"RPC replay cache capacity exceeded"
);
let metrics = metrics.expect("overflow should publish cache state");
assert!(metrics.record_scope.is_none());
let overflow_scope = metrics.overflow_scope.expect("overflow should keep diagnostic scope");
assert_eq!(overflow_scope.operation, INTERNODE_OPERATION_GRPC_READ_ALL);
assert_eq!(overflow_scope.backend, INTERNODE_TRANSPORT_BACKEND_GRPC);
assert_eq!(overflow_scope.rpc_path, "/node_service.NodeService/ReadAll");
}
// The `rpc_body_digest_fallback_counter` serial group covers every test that drives (or
// asserts on) the process-global body-digest fallback counter, so exact-delta assertions
// cannot race with each other.
@@ -12,13 +12,17 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::cluster::rpc::{build_auth_headers, verify_ns_scanner_capability};
use crate::cluster::rpc::{
build_auth_headers, build_put_file_auth_trailer, verify_ns_scanner_capability, verify_put_file_capability,
};
use crate::disk::error::{Error, Result};
use crate::disk::{FileReader, FileWriter};
use crate::storage_api_contracts::internode::{
NS_SCANNER_BODY_SHA256_QUERY, NS_SCANNER_CAPABILITY_CHALLENGE_QUERY, NS_SCANNER_CYCLE_QUERY, NS_SCANNER_LEADER_EPOCH_QUERY,
NS_SCANNER_PROTOCOL_VERSION, NS_SCANNER_PROTOCOL_VERSION_QUERY, NS_SCANNER_REQUEST_ID_QUERY, NS_SCANNER_SERVER_EPOCH_QUERY,
NS_SCANNER_SESSION_ID_QUERY, NS_SCANNER_SESSION_SEQUENCE_QUERY, NsScannerCapabilityResponse, WALK_DIR_BODY_SHA256_QUERY,
NS_SCANNER_SESSION_ID_QUERY, NS_SCANNER_SESSION_SEQUENCE_QUERY, NsScannerCapabilityResponse, PUT_FILE_AUTH_QUERY,
PUT_FILE_AUTH_V1, PUT_FILE_CAPABILITY_CHALLENGE_QUERY, PUT_FILE_CAPABILITY_QUERY, PUT_FILE_CAPABILITY_VERSION,
PUT_FILE_NONCE_QUERY, PUT_FILE_SERVER_EPOCH_QUERY, PutFileCapabilityResponse, WALK_DIR_BODY_SHA256_QUERY,
WALK_DIR_STREAM_COMPLETION_QUERY, WALK_DIR_STREAM_COMPLETION_V1,
};
use async_trait::async_trait;
@@ -29,18 +33,29 @@ use rustfs_config::{
};
use rustfs_rio::{HttpReader, HttpWriter};
use sha2::{Digest, Sha256};
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use tokio::io::AsyncReadExt;
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, LazyLock, OnceLock};
use std::task::{Context, Poll};
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWrite};
use tokio::sync::OnceCell;
use uuid::Uuid;
static INTERNODE_DATA_TRANSPORT: OnceLock<std::result::Result<Arc<dyn InternodeDataTransport>, String>> = OnceLock::new();
const READ_FILE_STREAM_PATH: &str = "/rustfs/rpc/read_file_stream";
const PUT_FILE_STREAM_PATH: &str = "/rustfs/rpc/put_file_stream";
const PUT_FILE_AUTH_STREAM_PATH: &str = "/rustfs/rpc/put_file_stream_v1";
const PUT_FILE_CAPABILITY_PATH: &str = "/rustfs/rpc/put_file_capability";
const WALK_DIR_PATH: &str = "/rustfs/rpc/walk_dir";
const NS_SCANNER_PATH: &str = "/rustfs/rpc/ns_scanner";
const NS_SCANNER_MAX_CAPABILITY_RESPONSE_SIZE: usize = 1024;
const PUT_FILE_MAX_CAPABILITY_RESPONSE_SIZE: usize = 1024;
const PUT_FILE_LEGACY_CAPABILITY_TTL: Duration = Duration::from_secs(30);
const PUT_FILE_V1_CAPABILITY_TTL: Duration = Duration::from_secs(30);
const PUT_FILE_CAPABILITY_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
const CONTENT_TYPE_JSON: &str = "application/json";
const CONTENT_TYPE_MSGPACK: &str = "application/msgpack";
@@ -51,6 +66,73 @@ fn unsupported_transport_message(transport: &str) -> String {
)
}
#[derive(Debug, Clone, Copy)]
enum PutFileCapabilityState {
LegacyUntil(Instant),
V1 { server_epoch: Uuid, revalidate_after: Instant },
}
#[derive(Debug)]
struct PutFileCapabilityProbeFailure(Error);
impl PutFileCapabilityProbeFailure {
fn to_error(&self) -> Error {
match &self.0 {
Error::Io(error) => rustfs_rio::clone_internode_http_io_error(error)
.map(Error::Io)
.unwrap_or_else(|| self.0.clone()),
_ => self.0.clone(),
}
}
}
type PutFileCapabilityProbeOutcome = std::result::Result<Option<Uuid>, PutFileCapabilityProbeFailure>;
#[derive(Debug, Clone)]
struct PutFileCapabilityFlight {
generation: u64,
v1_was_pinned: bool,
outcome: Arc<OnceCell<PutFileCapabilityProbeOutcome>>,
}
#[derive(Debug, Default)]
struct PutFileCapabilityCacheState {
cached: Option<PutFileCapabilityState>,
generation: u64,
in_flight: Option<PutFileCapabilityFlight>,
}
type PutFileCapabilityCacheEntry = Arc<tokio::sync::RwLock<PutFileCapabilityCacheState>>;
static PUT_FILE_CAPABILITY_CACHE: LazyLock<parking_lot::RwLock<HashMap<String, PutFileCapabilityCacheEntry>>> =
LazyLock::new(|| parking_lot::RwLock::new(HashMap::new()));
fn put_file_capability_cache_entry(endpoint: &str) -> PutFileCapabilityCacheEntry {
if let Some(entry) = PUT_FILE_CAPABILITY_CACHE.read().get(endpoint).cloned() {
return entry;
}
PUT_FILE_CAPABILITY_CACHE
.write()
.entry(endpoint.to_owned())
.or_insert_with(|| Arc::new(tokio::sync::RwLock::new(PutFileCapabilityCacheState::default())))
.clone()
}
fn fresh_put_file_capability(state: Option<PutFileCapabilityState>, now: Instant) -> Option<Option<Uuid>> {
match state {
Some(PutFileCapabilityState::V1 {
server_epoch,
revalidate_after,
}) if now < revalidate_after => Some(Some(server_epoch)),
Some(PutFileCapabilityState::LegacyUntil(expires_at)) if now < expires_at => Some(None),
Some(PutFileCapabilityState::V1 { .. }) | Some(PutFileCapabilityState::LegacyUntil(_)) | None => None,
}
}
fn put_file_capability_status_is_legacy(status: u16) -> bool {
status == 404
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub struct InternodeDataTransportCapabilities {
/// Backend can open a streaming remote disk reader.
@@ -166,10 +248,16 @@ impl InternodeDataTransport for TcpHttpInternodeDataTransport {
}
async fn open_write(&self, request: WriteStreamRequest) -> Result<FileWriter> {
let url = build_put_file_stream_url(&request);
let server_epoch = self.put_file_auth_capability(&request.endpoint).await?;
let nonce = server_epoch.map(|_| Uuid::new_v4());
let url = build_put_file_stream_url(&request, nonce.zip(server_epoch));
let mut headers = json_headers();
build_auth_headers(&url, &Method::PUT, &mut headers)?;
Ok(Box::new(HttpWriter::new(url, Method::PUT, headers).await?))
let writer = HttpWriter::new(url.clone(), Method::PUT, headers).await?;
match nonce {
Some(nonce) => Ok(Box::new(PutFileAuthWriter::new(writer, url, nonce))),
None => Ok(Box::new(writer)),
}
}
async fn open_walk_dir(&self, request: WalkDirStreamRequest) -> Result<FileReader> {
@@ -223,6 +311,134 @@ impl InternodeDataTransport for TcpHttpInternodeDataTransport {
}
}
impl TcpHttpInternodeDataTransport {
async fn put_file_auth_capability(&self, endpoint: &str) -> Result<Option<Uuid>> {
resolve_put_file_auth_capability(endpoint, || async {
tokio::time::timeout(PUT_FILE_CAPABILITY_PROBE_TIMEOUT, self.probe_put_file_auth(endpoint))
.await
.map_err(|_| {
Error::from(rustfs_rio::internode_http_timeout_error(
&Method::GET,
&format!("{endpoint}{PUT_FILE_CAPABILITY_PATH}"),
))
})?
})
.await
}
async fn probe_put_file_auth(&self, endpoint: &str) -> Result<Option<Uuid>> {
let challenge = Uuid::new_v4();
let url = build_put_file_capability_url(endpoint, challenge);
let mut headers = msgpack_headers();
build_auth_headers(&url, &Method::GET, &mut headers)?;
let reader = match HttpReader::new(url, Method::GET, headers, None).await {
Ok(reader) => reader,
Err(err) => {
let err = Error::from(err);
if matches!(
err.internode_http_error_kind(),
Some(rustfs_rio::InternodeHttpErrorKind::HttpStatus(status))
if put_file_capability_status_is_legacy(status.as_u16())
) {
return Ok(None);
}
return Err(err);
}
};
let mut body = Vec::new();
reader
.take(u64::try_from(PUT_FILE_MAX_CAPABILITY_RESPONSE_SIZE + 1).unwrap_or(u64::MAX))
.read_to_end(&mut body)
.await?;
Ok(Some(verify_put_file_capability_response(challenge, &body)?))
}
}
async fn resolve_put_file_auth_capability<F, Fut>(endpoint: &str, probe: F) -> Result<Option<Uuid>>
where
F: FnOnce() -> Fut,
Fut: Future<Output = Result<Option<Uuid>>>,
{
let entry = put_file_capability_cache_entry(endpoint);
{
let state = entry.read().await;
if let Some(cached) = fresh_put_file_capability(state.cached, Instant::now()) {
return Ok(cached);
}
}
let flight = {
let mut state = entry.write().await;
if let Some(cached) = fresh_put_file_capability(state.cached, Instant::now()) {
return Ok(cached);
}
if let Some(flight) = state.in_flight.clone() {
flight
} else {
state.generation = state
.generation
.checked_add(1)
.ok_or_else(|| Error::other("put_file capability probe generation exhausted"))?;
let flight = PutFileCapabilityFlight {
generation: state.generation,
v1_was_pinned: matches!(state.cached, Some(PutFileCapabilityState::V1 { .. })),
outcome: Arc::new(OnceCell::new()),
};
state.in_flight = Some(flight.clone());
flight
}
};
let outcome = flight
.outcome
.get_or_init(|| async { probe().await.map_err(PutFileCapabilityProbeFailure) })
.await;
{
let mut state = entry.write().await;
let is_current_flight = state
.in_flight
.as_ref()
.is_some_and(|current| current.generation == flight.generation && Arc::ptr_eq(&current.outcome, &flight.outcome));
if is_current_flight {
match outcome {
Ok(Some(server_epoch)) => {
state.cached = Some(PutFileCapabilityState::V1 {
server_epoch: *server_epoch,
revalidate_after: Instant::now() + PUT_FILE_V1_CAPABILITY_TTL,
});
}
Ok(None) if !flight.v1_was_pinned => {
state.cached = Some(PutFileCapabilityState::LegacyUntil(Instant::now() + PUT_FILE_LEGACY_CAPABILITY_TTL));
}
Ok(None) | Err(_) => {}
}
state.in_flight = None;
}
}
match outcome {
Ok(Some(server_epoch)) => Ok(Some(*server_epoch)),
Ok(None) if flight.v1_was_pinned => Err(Error::other("remote put_file capability downgrade rejected")),
Ok(None) => Ok(None),
Err(failure) => Err(failure.to_error()),
}
}
fn verify_put_file_capability_response(challenge: Uuid, body: &[u8]) -> Result<Uuid> {
if body.is_empty() || body.len() > PUT_FILE_MAX_CAPABILITY_RESPONSE_SIZE {
return Err(Error::other("invalid remote put_file capability response size"));
}
let response: PutFileCapabilityResponse =
rmp_serde::from_slice(body).map_err(|_| Error::other("invalid remote put_file capability response"))?;
if response.version != PUT_FILE_CAPABILITY_VERSION || response.server_epoch.is_nil() {
return Err(Error::other("incompatible remote put_file capability response"));
}
verify_put_file_capability(challenge, response.server_epoch, response.version, &response.proof)
.map_err(|err| Error::other(format!("remote put_file capability authentication failed: {err}")))?;
Ok(response.server_epoch)
}
fn build_read_file_stream_url(request: &ReadStreamRequest) -> String {
format!(
"{}{}?disk={}&volume={}&path={}&offset={}&length={}",
@@ -236,19 +452,133 @@ fn build_read_file_stream_url(request: &ReadStreamRequest) -> String {
)
}
fn build_put_file_stream_url(request: &WriteStreamRequest) -> String {
format!(
fn build_put_file_stream_url(request: &WriteStreamRequest, auth_scope: Option<(Uuid, Uuid)>) -> String {
let stream_path = if auth_scope.is_some() {
PUT_FILE_AUTH_STREAM_PATH
} else {
PUT_FILE_STREAM_PATH
};
let mut url = format!(
"{}{}?disk={}&volume={}&path={}&append={}&size={}",
request.endpoint,
PUT_FILE_STREAM_PATH,
stream_path,
urlencoding::encode(&request.disk),
urlencoding::encode(&request.volume),
urlencoding::encode(&request.path),
request.append,
request.size
);
if let Some((nonce, server_epoch)) = auth_scope {
url.push_str(&format!(
"&{}={}&{}={}&{}={}",
PUT_FILE_AUTH_QUERY, PUT_FILE_AUTH_V1, PUT_FILE_NONCE_QUERY, nonce, PUT_FILE_SERVER_EPOCH_QUERY, server_epoch
));
}
url
}
fn build_put_file_capability_url(endpoint: &str, challenge: Uuid) -> String {
format!(
"{}{}?{}={}&{}={}",
endpoint,
PUT_FILE_CAPABILITY_PATH,
PUT_FILE_CAPABILITY_QUERY,
PUT_FILE_CAPABILITY_VERSION,
PUT_FILE_CAPABILITY_CHALLENGE_QUERY,
challenge
)
}
struct PutFileAuthWriter<W> {
inner: W,
url: String,
nonce: Uuid,
hasher: Sha256,
trailer: Option<Vec<u8>>,
trailer_offset: usize,
}
impl<W> PutFileAuthWriter<W> {
fn new(inner: W, url: String, nonce: Uuid) -> Self {
Self {
inner,
url,
nonce,
hasher: Sha256::new(),
trailer: None,
trailer_offset: 0,
}
}
fn ensure_trailer(&mut self) -> std::io::Result<()> {
if self.trailer.is_some() {
return Ok(());
}
let digest = hex_simd::encode_to_string(self.hasher.clone().finalize(), hex_simd::AsciiCase::Lower);
self.trailer = Some(build_put_file_auth_trailer(&self.url, &Method::PUT, self.nonce, &digest)?);
Ok(())
}
fn poll_write_trailer(&mut self, cx: &mut Context<'_>) -> Poll<std::io::Result<()>>
where
W: AsyncWrite + Unpin,
{
self.ensure_trailer()?;
let Some(trailer) = self.trailer.as_ref() else {
return Poll::Ready(Err(std::io::Error::other("put_file auth trailer missing")));
};
while self.trailer_offset < trailer.len() {
let written = match Pin::new(&mut self.inner).poll_write(cx, &trailer[self.trailer_offset..]) {
Poll::Ready(Ok(0)) => {
return Poll::Ready(Err(std::io::Error::new(
std::io::ErrorKind::WriteZero,
"failed to write put_file auth trailer",
)));
}
Poll::Ready(Ok(written)) => written,
Poll::Ready(Err(err)) => return Poll::Ready(Err(err)),
Poll::Pending => return Poll::Pending,
};
self.trailer_offset += written;
}
Poll::Ready(Ok(()))
}
}
impl<W> AsyncWrite for PutFileAuthWriter<W>
where
W: AsyncWrite + Unpin,
{
fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll<std::io::Result<usize>> {
if self.trailer.is_some() {
return Poll::Ready(Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"cannot write after put_file auth trailer",
)));
}
match Pin::new(&mut self.inner).poll_write(cx, buf) {
Poll::Ready(Ok(written)) => {
self.hasher.update(&buf[..written]);
Poll::Ready(Ok(written))
}
other => other,
}
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.inner).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
match self.poll_write_trailer(cx) {
Poll::Ready(Ok(())) => {}
Poll::Ready(Err(err)) => return Poll::Ready(Err(err)),
Poll::Pending => return Poll::Pending,
}
Pin::new(&mut self.inner).poll_shutdown(cx)
}
}
fn build_walk_dir_url(request: &WalkDirStreamRequest) -> String {
let body_sha256 = hex_simd::encode_to_string(Sha256::digest(&request.body), hex_simd::AsciiCase::Lower);
format!(
@@ -348,6 +678,28 @@ pub fn build_internode_data_transport_from_env() -> Result<Arc<dyn InternodeData
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use tokio::sync::{Barrier, Notify};
async fn wait_for_capability_flight_waiters(entry: &PutFileCapabilityCacheEntry, waiters: usize) {
tokio::time::timeout(Duration::from_secs(5), async {
loop {
let strong_count = entry
.read()
.await
.in_flight
.as_ref()
.map(|flight| Arc::strong_count(&flight.outcome))
.unwrap_or_default();
if strong_count > waiters {
return;
}
tokio::task::yield_now().await;
}
})
.await
.expect("capability callers should join the in-flight probe");
}
#[derive(Debug)]
struct LegacyTestTransport;
@@ -455,14 +807,17 @@ mod tests {
#[test]
fn put_file_stream_url_encodes_query_values() {
let url = build_put_file_stream_url(&WriteStreamRequest {
endpoint: "http://node1:9000".to_string(),
disk: "http://node1:9000/data/rustfs0".to_string(),
volume: "bucket".to_string(),
path: "object/part.1".to_string(),
append: false,
size: 4096,
});
let url = build_put_file_stream_url(
&WriteStreamRequest {
endpoint: "http://node1:9000".to_string(),
disk: "http://node1:9000/data/rustfs0".to_string(),
volume: "bucket".to_string(),
path: "object/part.1".to_string(),
append: false,
size: 4096,
},
None,
);
assert_eq!(
url,
@@ -470,6 +825,450 @@ mod tests {
);
}
#[test]
fn put_file_stream_url_advertises_auth_nonce_when_enabled() {
let nonce = Uuid::parse_str("11111111-2222-4333-8444-555555555555").expect("nonce");
let server_epoch = Uuid::parse_str("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee").expect("server epoch");
let url = build_put_file_stream_url(
&WriteStreamRequest {
endpoint: "http://node1:9000".to_string(),
disk: "http://node1:9000/data/rustfs0".to_string(),
volume: "bucket".to_string(),
path: "object/part.1".to_string(),
append: false,
size: 4096,
},
Some((nonce, server_epoch)),
);
assert_eq!(
url,
concat!(
"http://node1:9000/rustfs/rpc/put_file_stream_v1?disk=http%3A%2F%2Fnode1%3A9000%2Fdata%2Frustfs0",
"&volume=bucket&path=object%2Fpart.1&append=false&size=4096",
"&put_file_auth=digest-trailer-v1&put_file_nonce=11111111-2222-4333-8444-555555555555",
"&put_file_server_epoch=aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
)
);
}
#[test]
fn put_file_capability_url_binds_version_and_challenge() {
let challenge = Uuid::parse_str("11111111-2222-4333-8444-555555555555").expect("challenge");
assert_eq!(
build_put_file_capability_url("http://node1:9000", challenge),
concat!(
"http://node1:9000/rustfs/rpc/put_file_capability?put_file_capability=1",
"&put_file_challenge=11111111-2222-4333-8444-555555555555"
)
);
}
#[test]
fn put_file_capability_legacy_statuses_are_exact() {
assert!(put_file_capability_status_is_legacy(404));
for status in [200, 400, 401, 403, 405, 408, 426, 429, 500, 503] {
assert!(!put_file_capability_status_is_legacy(status));
}
}
#[test]
fn put_file_capability_timeout_is_retryable() {
let error = Error::from(rustfs_rio::internode_http_timeout_error(
&Method::GET,
"http://node:9000/rustfs/rpc/put_file_capability",
));
assert_eq!(
error.internode_http_error_kind(),
Some(rustfs_rio::InternodeHttpErrorKind::ConnectTimeout)
);
assert!(error.is_retryable_internode_write_failure());
}
#[tokio::test]
async fn put_file_capability_cache_pins_v1_and_honors_live_legacy_ttl() {
let transport = TcpHttpInternodeDataTransport;
let v1_endpoint = format!("http://v1-{}.invalid", Uuid::new_v4());
let v1_entry = put_file_capability_cache_entry(&v1_endpoint);
let server_epoch = Uuid::new_v4();
v1_entry.write().await.cached = Some(PutFileCapabilityState::V1 {
server_epoch,
revalidate_after: Instant::now() + PUT_FILE_V1_CAPABILITY_TTL,
});
assert_eq!(
transport.put_file_auth_capability(&v1_endpoint).await.expect("v1 cache"),
Some(server_epoch)
);
let cache_probe_called = AtomicBool::new(false);
assert_eq!(
resolve_put_file_auth_capability(&v1_endpoint, || async {
cache_probe_called.store(true, Ordering::SeqCst);
Ok(None)
})
.await
.expect("live v1 cache"),
Some(server_epoch)
);
assert!(!cache_probe_called.load(Ordering::SeqCst));
v1_entry.write().await.cached = Some(PutFileCapabilityState::V1 {
server_epoch,
revalidate_after: Instant::now(),
});
assert!(
resolve_put_file_auth_capability(&v1_endpoint, || async { Ok(None) })
.await
.is_err()
);
let replacement_epoch = Uuid::new_v4();
assert_eq!(
resolve_put_file_auth_capability(&v1_endpoint, || async { Ok(Some(replacement_epoch)) })
.await
.expect("authenticated replacement should refresh the epoch"),
Some(replacement_epoch)
);
let legacy_endpoint = format!("http://legacy-{}.invalid", Uuid::new_v4());
let legacy_entry = put_file_capability_cache_entry(&legacy_endpoint);
legacy_entry.write().await.cached =
Some(PutFileCapabilityState::LegacyUntil(Instant::now() + PUT_FILE_LEGACY_CAPABILITY_TTL));
assert!(
transport
.put_file_auth_capability(&legacy_endpoint)
.await
.expect("legacy cache")
.is_none()
);
let expired_endpoint = format!("http://expired-legacy-{}.invalid", Uuid::new_v4());
let expired_entry = put_file_capability_cache_entry(&expired_endpoint);
expired_entry.write().await.cached = Some(PutFileCapabilityState::LegacyUntil(Instant::now()));
let reprobed = std::sync::atomic::AtomicBool::new(false);
assert_eq!(
resolve_put_file_auth_capability(&expired_endpoint, || async {
reprobed.store(true, std::sync::atomic::Ordering::SeqCst);
Ok(Some(server_epoch))
})
.await
.expect("expired legacy cache should reprobe"),
Some(server_epoch)
);
assert!(reprobed.load(std::sync::atomic::Ordering::SeqCst));
}
#[tokio::test]
async fn legacy_put_file_capability_omits_the_auth_trailer_protocol() {
let endpoint = format!("http://legacy-selection-{}.invalid", Uuid::new_v4());
let server_epoch = resolve_put_file_auth_capability(&endpoint, || async { Ok(None) })
.await
.expect("legacy capability result");
let auth_scope = server_epoch.map(|epoch| (Uuid::new_v4(), epoch));
let url = build_put_file_stream_url(
&WriteStreamRequest {
endpoint,
disk: "http://node1:9000/data/rustfs0".to_string(),
volume: "bucket".to_string(),
path: "object/part.1".to_string(),
append: false,
size: 4096,
},
auth_scope,
);
assert!(auth_scope.is_none());
assert!(!url.contains(PUT_FILE_AUTH_QUERY));
assert!(!url.contains(PUT_FILE_NONCE_QUERY));
}
#[tokio::test]
async fn put_file_capability_probe_is_singleflight_per_endpoint() {
let endpoint = format!("http://singleflight-{}.invalid", Uuid::new_v4());
let entry = put_file_capability_cache_entry(&endpoint);
let calls = Arc::new(AtomicUsize::new(0));
let release = Arc::new(Notify::new());
let start = Arc::new(Barrier::new(65));
let mut tasks = Vec::with_capacity(64);
for _ in 0..64 {
let endpoint = endpoint.clone();
let calls = Arc::clone(&calls);
let release = Arc::clone(&release);
let start = Arc::clone(&start);
tasks.push(tokio::spawn(async move {
start.wait().await;
resolve_put_file_auth_capability(&endpoint, || async move {
calls.fetch_add(1, Ordering::SeqCst);
release.notified().await;
Err(Error::from(rustfs_rio::new_test_internode_http_io_error(
rustfs_rio::InternodeHttpErrorKind::ConnectionRefused,
)))
})
.await
}));
}
start.wait().await;
wait_for_capability_flight_waiters(&entry, 64).await;
assert_eq!(calls.load(Ordering::SeqCst), 1);
release.notify_waiters();
let results = tokio::time::timeout(Duration::from_secs(1), futures::future::join_all(tasks))
.await
.expect("all callers should finish within one probe window");
for result in results {
let error = result.expect("capability task should finish").expect_err("probe should fail");
assert_eq!(
error.internode_http_error_kind(),
Some(rustfs_rio::InternodeHttpErrorKind::ConnectionRefused)
);
assert!(error.is_retryable_internode_write_failure());
}
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn put_file_capability_probe_recovers_when_initializer_is_cancelled() {
let endpoint = format!("http://cancelled-singleflight-{}.invalid", Uuid::new_v4());
let entry = put_file_capability_cache_entry(&endpoint);
let calls = Arc::new(AtomicUsize::new(0));
let initializer_started = Arc::new(Notify::new());
let never_release = Arc::new(Notify::new());
let first = {
let endpoint = endpoint.clone();
let calls = Arc::clone(&calls);
let initializer_started = Arc::clone(&initializer_started);
let never_release = Arc::clone(&never_release);
tokio::spawn(async move {
resolve_put_file_auth_capability(&endpoint, || async move {
calls.fetch_add(1, Ordering::SeqCst);
initializer_started.notify_one();
never_release.notified().await;
Ok(Some(Uuid::new_v4()))
})
.await
})
};
initializer_started.notified().await;
let replacement_epoch = Uuid::new_v4();
let second = {
let endpoint = endpoint.clone();
let calls = Arc::clone(&calls);
tokio::spawn(async move {
resolve_put_file_auth_capability(&endpoint, || async move {
calls.fetch_add(1, Ordering::SeqCst);
Ok(Some(replacement_epoch))
})
.await
})
};
wait_for_capability_flight_waiters(&entry, 2).await;
first.abort();
assert!(first.await.expect_err("initializer should be cancelled").is_cancelled());
assert_eq!(
second.await.expect("waiter should finish").expect("waiter should take over"),
Some(replacement_epoch)
);
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn put_file_capability_probe_recovers_after_all_callers_cancel() {
let endpoint = format!("http://all-cancelled-{}.invalid", Uuid::new_v4());
let entry = put_file_capability_cache_entry(&endpoint);
let calls = Arc::new(AtomicUsize::new(0));
let initializer_started = Arc::new(Notify::new());
let never_release = Arc::new(Notify::new());
let first = {
let endpoint = endpoint.clone();
let calls = Arc::clone(&calls);
let initializer_started = Arc::clone(&initializer_started);
let never_release = Arc::clone(&never_release);
tokio::spawn(async move {
resolve_put_file_auth_capability(&endpoint, || async move {
calls.fetch_add(1, Ordering::SeqCst);
initializer_started.notify_one();
never_release.notified().await;
Ok(None)
})
.await
})
};
initializer_started.notified().await;
let second = {
let endpoint = endpoint.clone();
let calls = Arc::clone(&calls);
tokio::spawn(async move {
resolve_put_file_auth_capability(&endpoint, || async move {
calls.fetch_add(1, Ordering::SeqCst);
Ok(None)
})
.await
})
};
wait_for_capability_flight_waiters(&entry, 2).await;
first.abort();
second.abort();
assert!(first.await.expect_err("initializer should be cancelled").is_cancelled());
assert!(second.await.expect_err("waiter should be cancelled").is_cancelled());
let server_epoch = Uuid::new_v4();
assert_eq!(
resolve_put_file_auth_capability(&endpoint, || async {
calls.fetch_add(1, Ordering::SeqCst);
Ok(Some(server_epoch))
})
.await
.expect("later caller should initialize the abandoned flight"),
Some(server_epoch)
);
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn put_file_capability_failed_wave_can_retry_immediately() {
let endpoint = format!("http://retry-after-failure-{}.invalid", Uuid::new_v4());
let first = resolve_put_file_auth_capability(&endpoint, || async { Err(Error::Timeout) }).await;
assert!(matches!(first, Err(Error::Timeout)));
let server_epoch = Uuid::new_v4();
assert_eq!(
resolve_put_file_auth_capability(&endpoint, || async { Ok(Some(server_epoch)) })
.await
.expect("new request should reprobe"),
Some(server_epoch)
);
}
#[tokio::test]
async fn put_file_capability_probes_different_endpoints_in_parallel() {
let first_endpoint = format!("http://parallel-a-{}.invalid", Uuid::new_v4());
let second_endpoint = format!("http://parallel-b-{}.invalid", Uuid::new_v4());
let probes_started = Arc::new(Barrier::new(2));
let first_barrier = Arc::clone(&probes_started);
let second_barrier = Arc::clone(&probes_started);
let results = tokio::time::timeout(Duration::from_secs(5), async {
tokio::join!(
resolve_put_file_auth_capability(&first_endpoint, || async move {
first_barrier.wait().await;
Ok(None)
}),
resolve_put_file_auth_capability(&second_endpoint, || async move {
second_barrier.wait().await;
Ok(None)
})
)
})
.await
.expect("different endpoints should not serialize");
assert!(results.0.expect("first result").is_none());
assert!(results.1.expect("second result").is_none());
}
#[tokio::test]
async fn stale_put_file_capability_flight_cannot_overwrite_newer_state() {
let endpoint = format!("http://stale-flight-{}.invalid", Uuid::new_v4());
let entry = put_file_capability_cache_entry(&endpoint);
let probe_started = Arc::new(Notify::new());
let release = Arc::new(Notify::new());
let stale_epoch = Uuid::new_v4();
let newer_epoch = Uuid::new_v4();
let task = {
let endpoint = endpoint.clone();
let probe_started = Arc::clone(&probe_started);
let release = Arc::clone(&release);
tokio::spawn(async move {
resolve_put_file_auth_capability(&endpoint, || async move {
probe_started.notify_one();
release.notified().await;
Ok(Some(stale_epoch))
})
.await
})
};
probe_started.notified().await;
{
let mut state = entry.write().await;
state.generation = state.generation.checked_add(1).expect("test generation should advance");
state.cached = Some(PutFileCapabilityState::V1 {
server_epoch: newer_epoch,
revalidate_after: Instant::now() + PUT_FILE_V1_CAPABILITY_TTL,
});
state.in_flight = None;
}
release.notify_one();
assert_eq!(
task.await.expect("stale task should finish").expect("stale probe result"),
Some(stale_epoch)
);
assert_eq!(
fresh_put_file_capability(entry.read().await.cached, Instant::now()),
Some(Some(newer_epoch))
);
}
#[test]
fn put_file_capability_response_fails_closed_on_malformed_or_unbound_data() {
let _ = rustfs_credentials::set_global_rpc_secret("put-file-capability-response-test-secret".to_string());
let challenge = Uuid::parse_str("11111111-2222-4333-8444-555555555555").expect("challenge");
let server_epoch = Uuid::parse_str("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee").expect("server epoch");
let proof = crate::cluster::rpc::sign_put_file_capability(challenge, server_epoch, PUT_FILE_CAPABILITY_VERSION)
.expect("proof should build");
let response = PutFileCapabilityResponse {
version: PUT_FILE_CAPABILITY_VERSION,
server_epoch,
proof,
};
let body = rmp_serde::to_vec_named(&response).expect("response should encode");
assert_eq!(
verify_put_file_capability_response(challenge, &body).expect("response should verify"),
server_epoch
);
assert!(verify_put_file_capability_response(Uuid::new_v4(), &body).is_err());
assert!(verify_put_file_capability_response(challenge, &body[..body.len() - 1]).is_err());
assert!(verify_put_file_capability_response(challenge, &[]).is_err());
assert!(verify_put_file_capability_response(challenge, &vec![0_u8; PUT_FILE_MAX_CAPABILITY_RESPONSE_SIZE + 1]).is_err());
}
#[tokio::test]
async fn put_file_auth_writer_appends_trailer_on_shutdown() {
use tokio::io::AsyncWriteExt;
let _ = rustfs_credentials::set_global_rpc_secret("put-file-auth-writer-test-secret".to_string());
let nonce = Uuid::parse_str("11111111-2222-4333-8444-555555555555").expect("nonce");
let url = concat!(
"http://node1:9000/rustfs/rpc/put_file_stream?disk=disk-a&volume=bucket&path=object%2Fpart.1",
"&append=false&size=11&put_file_auth=digest-trailer-v1&put_file_nonce=11111111-2222-4333-8444-555555555555"
)
.to_string();
let mut sink = Vec::new();
{
let mut writer = PutFileAuthWriter::new(&mut sink, url.clone(), nonce);
writer.write_all(b"hello world").await.expect("body write should succeed");
writer.shutdown().await.expect("shutdown should append auth trailer");
let err = writer
.write_all(b"!")
.await
.expect_err("post-trailer writes must be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::BrokenPipe);
}
assert_eq!(&sink[..11], b"hello world");
let trailer = &sink[11..];
let expected_digest = hex_simd::encode_to_string(Sha256::digest(b"hello world"), hex_simd::AsciiCase::Lower);
let verified = crate::cluster::rpc::verify_put_file_auth_trailer(&url, &Method::PUT, nonce, trailer)
.expect("emitted trailer should verify");
assert_eq!(verified, expected_digest);
}
#[test]
fn walk_dir_url_encodes_disk_ref() {
let url = build_walk_dir_url(&WalkDirStreamRequest {
+6 -4
View File
@@ -32,10 +32,12 @@ pub use client::{
// Re-exported through `api::rpc`; not every item is consumed inside this crate.
#[allow(unused_imports)]
pub use http_auth::{
TONIC_RPC_PREFIX, build_auth_headers, gen_signature_headers, gen_tonic_replay_scope_headers, gen_tonic_signature_headers,
normalize_tonic_rpc_audience, set_tonic_canonical_body_digest, set_tonic_mutation_body_digest, sign_ns_scanner_capability,
sign_tonic_rpc_response_proof, tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason,
verify_ns_scanner_capability, verify_rpc_signature, verify_tonic_boot_epoch_response, verify_tonic_canonical_body_digest,
TONIC_RPC_PREFIX, build_auth_headers, build_put_file_auth_trailer, check_and_record_signed_rpc_nonce, gen_signature_headers,
gen_tonic_replay_scope_headers, gen_tonic_signature_headers, normalize_tonic_rpc_audience, set_tonic_canonical_body_digest,
set_tonic_mutation_body_digest, set_tonic_rolling_canonical_body_digest, set_tonic_rolling_mutation_body_digest,
sign_ns_scanner_capability, sign_put_file_capability, sign_tonic_rpc_response_proof, tonic_boot_epoch_challenge,
tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason, verify_ns_scanner_capability, verify_put_file_auth_trailer,
verify_put_file_capability, verify_rpc_signature, verify_tonic_boot_epoch_response, verify_tonic_canonical_body_digest,
verify_tonic_mutation_body_digest, verify_tonic_rpc_response_proof, verify_tonic_rpc_signature,
verify_tonic_rpc_signature_with_bootstrap,
};
@@ -44,9 +44,9 @@ use rustfs_protos::proto_gen::node_service::{
GetPartitionsRequest, GetProcInfoRequest, GetSeLinuxInfoRequest, GetSysConfigRequest, GetSysErrorsRequest,
HealControlRequest, LoadBucketMetadataRequest, LoadGroupRequest, LoadPolicyMappingRequest, LoadPolicyRequest,
LoadRebalanceMetaRequest, LoadServiceAccountRequest, LoadTransitionTierConfigRequest, LoadUserRequest,
LocalStorageInfoRequest, Mss, ReloadPoolMetaRequest, ReloadSiteReplicationConfigRequest, ScannerActivityRequest,
ScannerActivityResponse, ServerInfoRequest, SignalServiceRequest, SignalServiceResponse, StartDecommissionRequest,
StartProfilingRequest, StopRebalanceRequest, TierMutationAbortRequest, TierMutationCommitRequest,
LocalStorageInfoRequest, Mss, ReloadPoolMetaRequest, ReloadSiteReplicationConfigRequest, ReplacementRecoveryStatusRequest,
ScannerActivityRequest, ScannerActivityResponse, ServerInfoRequest, SignalServiceRequest, SignalServiceResponse,
StartDecommissionRequest, StartProfilingRequest, StopRebalanceRequest, TierMutationAbortRequest, TierMutationCommitRequest,
TierMutationControlResponse, TierMutationPeerState, TierMutationPrepareRequest, node_service_client::NodeServiceClient,
tier_mutation_control_service_client::TierMutationControlServiceClient,
};
@@ -78,6 +78,7 @@ pub const SERVICE_SIGNAL_RELOAD_DYNAMIC: u64 = 2;
/// reload signal transport.
pub const KMS_SIGNAL_SUBSYSTEM: &str = "kms";
const BACKGROUND_HEAL_STATUS_MAX_MESSAGE_SIZE: usize = 64 * 1024;
const REPLACEMENT_RECOVERY_STATUS_MAX_MESSAGE_SIZE: usize = 64 * 1024;
const HEAL_CONTROL_FINGERPRINT_MAX_SIZE: usize = 256;
const HEAL_CONTROL_PAYLOAD_MAX_SIZE: usize = 64 * 1024;
const PEER_REST_RECOVERY_MAX_ATTEMPTS: u32 = 60;
@@ -1083,6 +1084,38 @@ impl PeerRestClient {
.await
}
pub async fn replacement_recovery_status(&self) -> Result<Option<Vec<u8>>> {
self.finalize_result(
async {
let mut client = self
.get_client()
.await?
.max_decoding_message_size(REPLACEMENT_RECOVERY_STATUS_MAX_MESSAGE_SIZE);
let response = match client
.replacement_recovery_status(Request::new(ReplacementRecoveryStatusRequest::default()))
.await
{
Ok(response) => response.into_inner(),
Err(status) if status.code() == tonic::Code::Unimplemented => {
// RUSTFS_COMPAT_TODO(replacement-recovery-status-v1): old peers cannot prove replacement completion during rolling upgrades. Remove after the minimum supported RustFS peer version implements ReplacementRecoveryStatus.
return Ok(None);
}
Err(status) => return Err(status.into()),
};
if !response.success {
return Err(Error::other(
response
.error_info
.unwrap_or_else(|| "peer replacement recovery status failed without an error".to_string()),
));
}
Ok(Some(response.recovery_status.to_vec()))
}
.await,
)
.await
}
pub async fn prepare_tier_mutation(&self, mutation_id: Uuid, canonical_payload: Bytes) -> Result<PeerTierMutationOutcome> {
self.tier_mutation_control(TierMutationRpcPhase::Prepare, mutation_id, canonical_payload)
.await
@@ -784,7 +784,11 @@ impl PeerS3Client for LocalPeerS3Client {
if opts.force_if_empty && !opts.force {
for disk in local_disks.iter() {
if has_xlmeta_files(&disk.path().join(bucket)).await.map_err(Error::Io)? {
let Some(bucket_path) = disk.get_bucket_path_for_io_if_local(bucket) else {
continue;
};
let bucket_path = bucket_path?;
if has_xlmeta_files(&bucket_path).await.map_err(Error::Io)? {
return Err(Error::VolumeNotEmpty);
}
}
+38 -2
View File
@@ -16,7 +16,6 @@ use crate::cluster::rpc::client::{
AuthenticatedChannel, TonicInterceptor, gen_tonic_signature_interceptor, is_network_like_disk_error,
node_service_time_out_client, node_service_time_out_client_for_class, node_service_time_out_client_no_auth,
};
use crate::cluster::rpc::http_auth::set_tonic_canonical_body_digest;
use crate::cluster::rpc::internode_data_transport::{
InternodeDataTransport, NsScannerCapabilityRequest, NsScannerStreamRequest, ReadStreamRequest, WalkDirStreamRequest,
WriteStreamRequest,
@@ -123,7 +122,7 @@ fn attach_mutation_body_digest<T>(
op: &'static str,
) -> Result<()> {
let canonical_body = canonical_body.map_err(|_| Error::other(format!("{op} request length cannot be represented")))?;
set_tonic_canonical_body_digest(request, &canonical_body).map_err(Error::other)
crate::cluster::rpc::set_tonic_rolling_canonical_body_digest(request, &canonical_body).map_err(Error::other)
}
fn decode_volume_infos(volume_infos: Vec<String>) -> Result<Vec<VolumeInfo>> {
@@ -3029,6 +3028,22 @@ mod tests {
static INIT: Once = Once::new();
#[test]
fn disk_mutation_digest_marks_rolling_compatibility() {
let mut request = Request::new(());
attach_mutation_body_digest(&mut request, Ok(b"canonical disk mutation".to_vec()), "WriteAll")
.expect("disk mutation digest must be attached");
assert!(
request
.extensions()
.get::<crate::cluster::rpc::http_auth::RollingMutationBodyDigest>()
.is_some(),
"remote-disk mutations must reach the cache-free compatibility gate"
);
}
// `#[serial(internode_metrics)]` marks every test that observes
// `global_internode_metrics()`. Those counters are a process-wide singleton:
// some of these tests snapshot a counter, run one decode, and assert on the
@@ -4486,6 +4501,27 @@ mod tests {
assert_eq!(snapshot.outgoing_requests_total, 0);
}
#[tokio::test]
#[serial(internode_metrics)]
async fn test_remote_disk_create_file_retries_once_on_capability_probe_timeout() {
let transport = RetryingOpenWriteInternodeDataTransport::with_steps(vec![
OpenWriteTestStep::Error(DiskError::from(rustfs_rio::internode_http_timeout_error(
&http::Method::GET,
"http://remote-node:9000/rustfs/rpc/put_file_capability",
))),
OpenWriteTestStep::Success,
]);
let remote_disk = new_remote_disk_with_transport(Arc::new(transport.clone())).await;
crate::cluster::rpc::runtime_sources::reset_internode_metrics_for_test();
let _created = remote_disk
.create_file("orig-bucket", "bucket", "object/part.1", 4096)
.await
.expect("capability probe timeout should recover on retry");
assert_eq!(transport.calls().len(), 2, "create_file should retry capability probe timeouts once");
}
#[tokio::test]
async fn test_remote_disk_append_file_does_not_retry_non_retryable_open_write_error() {
let transport = RetryingOpenWriteInternodeDataTransport::with_steps(vec![OpenWriteTestStep::Error(DiskError::from(
@@ -15,7 +15,7 @@
use crate::cluster::rpc::client::{
AuthenticatedChannel, TonicInterceptor, gen_tonic_signature_interceptor, node_service_time_out_client,
};
use crate::cluster::rpc::set_tonic_mutation_body_digest;
use crate::cluster::rpc::set_tonic_rolling_mutation_body_digest;
use async_trait::async_trait;
use bytes::Bytes;
use rustfs_lock::{
@@ -33,6 +33,10 @@ use tonic::Request;
use tonic::service::interceptor::InterceptedService;
use tracing::{debug, info, warn};
fn attach_lock_mutation_body_digest<T: rustfs_protos::CanonicalMutationBody>(request: &mut Request<T>) -> std::io::Result<()> {
set_tonic_rolling_mutation_body_digest(request)
}
/// Remote lock client implementation
#[derive(Debug, Clone)]
pub struct RemoteClient {
@@ -319,7 +323,7 @@ impl LockClient for RemoteClient {
args: serde_json::to_string(&request)
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
});
set_tonic_mutation_body_digest(&mut req)?;
attach_lock_mutation_body_digest(&mut req)?;
let resp = match self.execute_rpc("lock", &resource_summary, client.lock(req)).await {
Ok(resp) => resp.into_inner(),
@@ -358,7 +362,7 @@ impl LockClient for RemoteClient {
})
.collect::<Result<Vec<_>>>()?,
});
set_tonic_mutation_body_digest(&mut req)?;
attach_lock_mutation_body_digest(&mut req)?;
let resp = match self
.execute_rpc("lock_batch", &resource_summary, client.lock_batch(req))
@@ -400,7 +404,7 @@ impl LockClient for RemoteClient {
let mut client = self.get_client().await?;
let resource_summary = unlock_request.resource.to_string();
let mut req = Request::new(GenerallyLockRequest { args: request_string });
set_tonic_mutation_body_digest(&mut req)?;
attach_lock_mutation_body_digest(&mut req)?;
let resp = self
.execute_rpc("release", &resource_summary, client.un_lock(req))
.await?
@@ -427,7 +431,7 @@ impl LockClient for RemoteClient {
})
.collect::<Result<Vec<_>>>()?,
});
set_tonic_mutation_body_digest(&mut req)?;
attach_lock_mutation_body_digest(&mut req)?;
let resp = self
.execute_rpc("release_batch", &resource_summary, client.un_lock_batch(req))
@@ -450,7 +454,7 @@ impl LockClient for RemoteClient {
args: serde_json::to_string(&refresh_request)
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
});
set_tonic_mutation_body_digest(&mut req)?;
attach_lock_mutation_body_digest(&mut req)?;
let resp = self
.execute_rpc("refresh", &resource_summary, client.refresh(req))
.await?
@@ -470,7 +474,7 @@ impl LockClient for RemoteClient {
args: serde_json::to_string(&force_request)
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
});
set_tonic_mutation_body_digest(&mut req)?;
attach_lock_mutation_body_digest(&mut req)?;
let resp = self
.execute_rpc("force_release", &resource_summary, client.force_un_lock(req))
.await?
@@ -495,7 +499,7 @@ impl LockClient for RemoteClient {
args: serde_json::to_string(&status_request)
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
});
set_tonic_mutation_body_digest(&mut req)?;
attach_lock_mutation_body_digest(&mut req)?;
// Try exclusive lock first with very short timeout
let resp = match self.execute_rpc("check_status", &resource_summary, client.lock(req)).await {
@@ -510,7 +514,7 @@ impl LockClient for RemoteClient {
args: serde_json::to_string(&status_request)
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
});
set_tonic_mutation_body_digest(&mut release_req)?;
attach_lock_mutation_body_digest(&mut release_req)?;
let _ = self
.execute_rpc("check_status_release", &resource_summary, client.un_lock(release_req))
.await;
@@ -626,6 +630,31 @@ mod tests {
.with_priority(LockPriority::Normal)
}
#[test]
fn lock_mutation_helper_marks_single_and_batch_requests_for_rolling_auth() {
let mut single = Request::new(GenerallyLockRequest {
args: "single-lock".to_string(),
});
attach_lock_mutation_body_digest(&mut single).expect("single lock digest must be attached");
assert!(
single
.extensions()
.get::<crate::cluster::rpc::http_auth::RollingMutationBodyDigest>()
.is_some()
);
let mut batch = Request::new(BatchGenerallyLockRequest {
args: vec!["batch-lock".to_string()],
});
attach_lock_mutation_body_digest(&mut batch).expect("batch lock digest must be attached");
assert!(
batch
.extensions()
.get::<crate::cluster::rpc::http_auth::RollingMutationBodyDigest>()
.is_some()
);
}
#[tokio::test]
#[serial_test::serial]
async fn test_remote_client_acquire_lock_uses_rpc_timeout_and_evicts_connection() {
+38
View File
@@ -584,6 +584,44 @@ where
.await
}
/// `delete_config` with `no_lock` set — for callers already holding the
/// config object's namespace lock (e.g. inside `with_config_object_write_lock`),
/// where the locked variant would self-deadlock.
pub async fn delete_config_no_lock<S>(api: Arc<S>, file: &str) -> Result<()>
where
S: ObjectOperations<
Error = Error,
ObjectInfo = ObjectInfo,
ObjectOptions = ObjectOptions,
FileInfo = FileInfo,
ObjectToDelete = ObjectToDelete,
DeletedObject = DeletedObject,
>,
{
match api
.delete_object(
RUSTFS_META_BUCKET,
file,
ObjectOptions {
delete_prefix: true,
delete_prefix_object: true,
no_lock: true,
..Default::default()
},
)
.await
{
Ok(_) => Ok(()),
Err(err) => {
if err == Error::FileNotFound || matches!(err, Error::ObjectNotFound(_, _)) {
Err(Error::ConfigNotFound)
} else {
Err(err)
}
}
}
}
#[instrument(skip(api))]
pub async fn delete_config<S>(api: Arc<S>, file: &str) -> Result<()>
where
+256 -84
View File
@@ -91,6 +91,8 @@ const DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD: usize = 1000;
const DECOMMISSION_BUCKET_CONCURRENCY_ENV: &str = "RUSTFS_DECOMMISSION_BUCKET_CONCURRENCY";
const DECOMMISSION_BUCKET_CONCURRENCY_DEFAULT_CAP: usize = 4;
const DECOMMISSION_TARGET_CAPACITY_OVERHEAD_PERCENT: usize = 30;
const DECOMMISSION_LISTING_MAX_ATTEMPTS: usize = 3;
const DECOMMISSION_LISTING_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
pub const POOL_META_NAME: &str = "pool.bin";
pub const POOL_META_FORMAT: u16 = 1;
@@ -885,9 +887,149 @@ fn ensure_pool_not_left_in_cmdline_after_decommission(position: usize, cmd_line:
fn resolve_decommission_listing_worker_result(
set_idx: usize,
worker_result: std::result::Result<(), tokio::task::JoinError>,
worker_result: std::result::Result<Result<()>, tokio::task::JoinError>,
) -> Result<()> {
worker_result.map_err(|err| Error::other(format!("decommission listing worker {set_idx} task join error: {err}")))
worker_result.map_err(|err| Error::other(format!("decommission listing worker {set_idx} task join error: {err}")))?
}
fn should_retry_decommission_listing(err: &Error, attempt: usize, max_attempts: usize) -> bool {
!is_err_bucket_not_found(err) && attempt + 1 < max_attempts
}
async fn wait_decommission_listing_retry(rx: &CancellationToken, delay: std::time::Duration) -> bool {
tokio::select! {
_ = rx.cancelled() => true,
_ = tokio::time::sleep(delay) => false,
}
}
async fn run_decommission_listing_with_retry<List, ListFuture>(
rx: CancellationToken,
bucket: String,
cb: ListCallback,
pool_idx: usize,
set_idx: usize,
max_attempts: usize,
mut list: List,
) -> Result<()>
where
List: FnMut(ListCallback) -> ListFuture,
ListFuture: std::future::Future<Output = Result<()>>,
{
let max_attempts = max_attempts.max(1);
for attempt in 0..max_attempts {
if rx.is_cancelled() {
debug!(
event = EVENT_DECOMMISSION_BUCKET,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
pool_index = pool_idx,
set_index = set_idx,
bucket = %bucket,
state = "listing_worker_cancelled",
"Decommission listing worker cancelled"
);
return Ok(());
}
debug!(
event = EVENT_DECOMMISSION_BUCKET,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
pool_index = pool_idx,
set_index = set_idx,
bucket = %bucket,
attempt = attempt + 1,
max_attempts,
state = "listing_started",
"Decommission listing started"
);
match list(cb.clone()).await {
Ok(()) => {
debug!(
event = EVENT_DECOMMISSION_BUCKET,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
pool_index = pool_idx,
set_index = set_idx,
bucket = %bucket,
attempt = attempt + 1,
max_attempts,
state = "listing_completed",
"Decommission listing completed"
);
return Ok(());
}
Err(err) if is_err_bucket_not_found(&err) => {
warn!(
event = EVENT_DECOMMISSION_BUCKET,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
pool_index = pool_idx,
set_index = set_idx,
bucket = %bucket,
attempt = attempt + 1,
max_attempts,
state = "listing_bucket_missing",
"Decommission listing bucket missing"
);
return Ok(());
}
Err(err) if should_retry_decommission_listing(&err, attempt, max_attempts) => {
error!(
event = EVENT_DECOMMISSION_BUCKET,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
pool_index = pool_idx,
set_index = set_idx,
bucket = %bucket,
attempt = attempt + 1,
max_attempts,
retry_delay_ms = DECOMMISSION_LISTING_RETRY_DELAY.as_millis(),
state = "listing_failed_retrying",
error = ?err,
"Decommission listing failed; retrying"
);
if wait_decommission_listing_retry(&rx, DECOMMISSION_LISTING_RETRY_DELAY).await {
debug!(
event = EVENT_DECOMMISSION_BUCKET,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
pool_index = pool_idx,
set_index = set_idx,
bucket = %bucket,
state = "listing_worker_cancelled",
"Decommission listing worker cancelled during retry wait"
);
return Ok(());
}
}
Err(err) => {
error!(
event = EVENT_DECOMMISSION_BUCKET,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
pool_index = pool_idx,
set_index = set_idx,
bucket = %bucket,
attempt = attempt + 1,
max_attempts,
state = "listing_failed",
error = ?err,
"Decommission listing failed"
);
return Err(Error::other(format!(
"decommission listing failed for bucket {bucket} pool {pool_idx} set {set_idx} attempt {}/{}: {err}",
attempt + 1,
max_attempts
)));
}
}
}
Ok(())
}
fn should_count_decommission_version_complete(ignore: bool, cleanup_ignored: bool, failure: bool) -> bool {
@@ -3261,78 +3403,21 @@ impl ECStore {
let set_id = set_idx;
let worker = tokio::spawn(async move {
let _listing_permit = listing_permit;
loop {
if rx_clone.is_cancelled() {
debug!(
event = EVENT_DECOMMISSION_BUCKET,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
pool_index = idx,
set_index = set_id,
bucket = %bi.name,
state = "listing_worker_cancelled",
"Decommission listing worker cancelled"
);
break;
}
debug!(
event = EVENT_DECOMMISSION_BUCKET,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
pool_index = idx,
set_index = set_id,
bucket = %bi.name,
state = "listing_started",
"Decommission listing started"
);
match set
.list_objects_to_decommission(rx_clone.clone(), bi.clone(), decommission_entry.clone())
.await
{
Ok(_) => {
debug!(
event = EVENT_DECOMMISSION_BUCKET,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
pool_index = idx,
set_index = set_id,
bucket = %bi.name,
state = "listing_completed",
"Decommission listing completed"
);
break;
}
Err(err) => {
error!(
event = EVENT_DECOMMISSION_BUCKET,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
pool_index = idx,
set_index = set_id,
bucket = %bi.name,
state = "listing_failed",
error = ?err,
"Decommission listing failed"
);
if is_err_bucket_not_found(&err) {
warn!(
event = EVENT_DECOMMISSION_BUCKET,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
pool_index = idx,
set_index = set_id,
bucket = %bi.name,
state = "listing_bucket_missing",
"Decommission listing bucket missing"
);
break;
}
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
}
}
}
run_decommission_listing_with_retry(
rx_clone.clone(),
bi.name.clone(),
decommission_entry.clone(),
idx,
set_id,
DECOMMISSION_LISTING_MAX_ATTEMPTS,
|callback| {
let set = set.clone();
let rx = rx_clone.clone();
let bucket = bi.clone();
async move { set.list_objects_to_decommission(rx, bucket, callback).await }
},
)
.await
});
listing_workers.push((set_id, worker));
}
@@ -4959,8 +5044,8 @@ pub(crate) fn fallback_free_capacity_dedup(disks: &[rustfs_madmin::Disk]) -> usi
mod pools_tests {
use super::{
DECOMMISSION_PROGRESS_SAVE_INTERVAL, DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD, DecomBucketInfo,
DecommissionStartPoolState, DecommissionTerminalState, PoolDecommissionInfo, PoolMeta, PoolSpaceInfo, PoolStatus,
apply_decommission_status_space_info, bind_decommission_cancelers, bind_missing_decommission_cancelers,
DecommissionStartPoolState, DecommissionTerminalState, ListCallback, PoolDecommissionInfo, PoolMeta, PoolSpaceInfo,
PoolStatus, apply_decommission_status_space_info, bind_decommission_cancelers, bind_missing_decommission_cancelers,
cancel_decommission_canceler, classify_decommission_terminal_state, count_decommission_item,
decommission_cancel_signal_result, decommission_item_size, decommission_meta_bucket_options,
decommission_start_pool_state, dedup_indices, default_decommission_bucket_concurrency,
@@ -4982,17 +5067,18 @@ mod pools_tests {
resolve_decommission_spawn_failure_result, resolve_decommission_terminal_mark_after_error_result,
resolve_decommission_terminal_mark_result, resolve_decommission_update_after_result,
resolve_start_decommission_pool_meta_reload_result, rollback_start_decommission_pool_meta,
run_decommission_buckets_bounded, should_cleanup_decommission_source_entry, should_continue_decommission_queue,
should_count_decommission_version_complete, should_preserve_decommission_canceled_state,
should_reject_decommission_cancel_as_terminal, should_retry_decommission_cancel_reload,
should_skip_canceled_decommission_routine, split_decommission_buckets, take_and_cancel_decommission_canceler,
take_decommission_canceler, touch_decommission_progress, track_decommission_current_object,
track_decommission_current_object_stage, validate_start_decommission_request, wait_decommission_worker_drain,
run_decommission_buckets_bounded, run_decommission_listing_with_retry, should_cleanup_decommission_source_entry,
should_continue_decommission_queue, should_count_decommission_version_complete,
should_preserve_decommission_canceled_state, should_reject_decommission_cancel_as_terminal,
should_retry_decommission_cancel_reload, should_retry_decommission_listing, should_skip_canceled_decommission_routine,
split_decommission_buckets, take_and_cancel_decommission_canceler, take_decommission_canceler,
touch_decommission_progress, track_decommission_current_object, track_decommission_current_object_stage,
validate_start_decommission_request, wait_decommission_listing_retry, wait_decommission_worker_drain,
with_decommission_entry_context,
};
use crate::data_movement;
use crate::disk::endpoint::Endpoint;
use crate::error::Error;
use crate::error::{Error, StorageError};
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
use crate::services::rebalance::{RebalStatus, RebalanceInfo, RebalanceMeta, RebalanceStats};
use rustfs_filemeta::{FileInfo, FileInfoVersions, MetaCacheEntry, ObjectPartInfo};
@@ -5006,6 +5092,10 @@ mod pools_tests {
use tokio::sync::Semaphore;
use tokio_util::sync::CancellationToken;
fn noop_decommission_list_callback() -> ListCallback {
Arc::new(|_| Box::pin(async {}))
}
fn decommission_test_pool_endpoint(idx: usize, is_local: bool) -> PoolEndpoints {
let port = 9000usize + idx;
let mut endpoint =
@@ -6046,7 +6136,15 @@ mod pools_tests {
#[test]
fn test_resolve_decommission_listing_worker_result_passthrough_ok() {
assert!(resolve_decommission_listing_worker_result(2, Ok(())).is_ok());
assert!(resolve_decommission_listing_worker_result(2, Ok(Ok(()))).is_ok());
}
#[test]
fn test_resolve_decommission_listing_worker_result_passthrough_worker_error() {
let err = resolve_decommission_listing_worker_result(2, Ok(Err(Error::SlowDown)))
.expect_err("listing worker error should be returned");
assert!(matches!(err, Error::SlowDown));
}
#[tokio::test]
@@ -6064,6 +6162,80 @@ mod pools_tests {
assert!(message.contains("panic"));
}
#[test]
fn test_should_retry_decommission_listing_respects_attempt_limit_and_bucket_missing() {
assert!(should_retry_decommission_listing(&Error::SlowDown, 0, 2));
assert!(!should_retry_decommission_listing(&Error::SlowDown, 1, 2));
assert!(!should_retry_decommission_listing(
&StorageError::BucketNotFound("bucket".to_string()),
0,
2
));
}
#[tokio::test]
async fn test_wait_decommission_listing_retry_reports_canceled_without_sleeping() {
let token = CancellationToken::new();
token.cancel();
assert!(wait_decommission_listing_retry(&token, StdDuration::from_secs(30)).await);
}
#[tokio::test(start_paused = true)]
async fn test_run_decommission_listing_with_retry_stops_after_attempt_limit() {
let attempts = Arc::new(AtomicUsize::new(0));
let err = run_decommission_listing_with_retry(
CancellationToken::new(),
"bucket-a".to_string(),
noop_decommission_list_callback(),
1,
2,
3,
{
let attempts = attempts.clone();
move |_| {
let attempts = attempts.clone();
async move {
attempts.fetch_add(1, Ordering::SeqCst);
Err(Error::SlowDown)
}
}
},
)
.await
.expect_err("permanent listing failure must not retry forever");
assert_eq!(attempts.load(Ordering::SeqCst), 3);
assert!(err.to_string().contains("attempt 3/3"));
}
#[tokio::test]
async fn test_run_decommission_listing_with_retry_treats_bucket_missing_as_complete() {
let attempts = Arc::new(AtomicUsize::new(0));
run_decommission_listing_with_retry(
CancellationToken::new(),
"bucket-a".to_string(),
noop_decommission_list_callback(),
1,
2,
3,
{
let attempts = attempts.clone();
move |_| {
let attempts = attempts.clone();
async move {
attempts.fetch_add(1, Ordering::SeqCst);
Err(StorageError::BucketNotFound("bucket-a".to_string()))
}
}
},
)
.await
.expect("missing bucket should keep previous decommission listing behavior");
assert_eq!(attempts.load(Ordering::SeqCst), 1);
}
#[test]
fn test_should_count_decommission_version_complete_for_cleanup_safe_ignored_result() {
assert!(should_count_decommission_version_complete(true, true, false));
+204 -84
View File
@@ -286,6 +286,23 @@ impl Sets {
self.get_disks(self.get_hashed_set_index(key))
}
pub(crate) fn get_disks_for_heal_object(&self, key: &str, opts: &HealOpts) -> Result<Arc<SetDisks>> {
match opts.set {
Some(set_idx) => self.disk_set.get(set_idx).cloned().ok_or_else(|| {
StorageError::InvalidArgument(
"heal".to_string(),
"set".to_string(),
format!(
"invalid heal set index {set_idx} for pool {} with {} sets",
self.pool_idx,
self.disk_set.len()
),
)
}),
None => Ok(self.get_disks_by_key(key)),
}
}
pub(crate) async fn storage_info_snapshot(&self) -> rustfs_madmin::StorageInfo {
let mut futures = Vec::with_capacity(self.disk_set.len());
@@ -1041,17 +1058,23 @@ impl crate::storage_api_contracts::heal::HealOperations for Sets {
for (i, set) in new_format_sets.iter().enumerate() {
for (j, fm) in set.iter().enumerate() {
if let Some(fm) = fm {
res.after.drives[i * self.set_drive_count + j].uuid = fm.erasure.this.to_string();
res.after.drives[i * self.set_drive_count + j].state = DriveState::Ok.to_string();
tmp_new_formats[i * self.set_drive_count + j] = Some(fm.clone());
}
}
}
// Save new formats `format.json` on unformatted disks.
for (fm, disk) in tmp_new_formats.iter_mut().zip(disks.iter()) {
if fm.is_some() && disk.is_some() && save_format_file(disk, fm).await.is_err() {
let _ = disk.as_ref().unwrap().close().await;
*fm = None;
for (index, (fm, disk)) in tmp_new_formats.iter_mut().zip(disks.iter()).enumerate() {
if fm.is_some() && disk.is_some() {
if let Err(err) = save_format_file(disk, fm).await {
if let Some(disk) = disk.as_ref() {
let _ = disk.close().await;
}
return Ok((res, Some(err.into())));
}
if let Some(saved_format) = fm.as_ref() {
res.after.drives[index].uuid = saved_format.erasure.this.to_string();
res.after.drives[index].state = DriveState::Ok.to_string();
}
}
}
@@ -1101,7 +1124,7 @@ impl crate::storage_api_contracts::heal::HealOperations for Sets {
version_id: &str,
opts: &HealOpts,
) -> Result<(HealResultItem, Option<Error>)> {
self.get_disks_by_key(object)
self.get_disks_for_heal_object(object, opts)?
.heal_object(bucket, object, version_id, opts)
.await
}
@@ -1198,6 +1221,98 @@ async fn init_storage_disks_with_errors(
(disks, errs)
}
#[cfg(test)]
pub(crate) async fn make_local_two_set_sets() -> (Vec<tempfile::TempDir>, Arc<Sets>) {
make_local_two_set_sets_with_ctx(bootstrap_ctx()).await
}
#[cfg(test)]
pub(crate) async fn make_local_two_set_sets_with_ctx(ctx: Arc<InstanceContext>) -> (Vec<tempfile::TempDir>, Arc<Sets>) {
use crate::layout::endpoint::Endpoint;
use rustfs_lock::client::local::LocalClient;
let format = FormatV3::new(2, 2);
let mut temp_dirs = Vec::new();
let mut all_endpoints = Vec::new();
let mut disk_sets = Vec::new();
for set_index in 0..2 {
let mut endpoints = Vec::new();
let mut disks = Vec::new();
for disk_index in 0..2 {
let temp_dir = tempfile::tempdir().expect("tempdir should be created");
let mut endpoint = Endpoint::try_from(temp_dir.path().to_str().expect("tempdir path should be utf8"))
.expect("endpoint should parse");
endpoint.set_pool_index(0);
endpoint.set_set_index(set_index);
endpoint.set_disk_index(disk_index);
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("disk should be created");
let mut disk_format = format.clone();
disk_format.erasure.this = format.erasure.sets[set_index][disk_index];
save_format_file(&Some(disk.clone()), &Some(disk_format))
.await
.expect("format should be saved");
temp_dirs.push(temp_dir);
all_endpoints.push(endpoint.clone());
endpoints.push(endpoint);
disks.push(Some(disk));
}
let lockers = (0..2)
.map(|_| {
Arc::new(LocalClient::with_manager(Arc::new(rustfs_lock::GlobalLockManager::Enabled(Arc::new(
rustfs_lock::FastObjectLockManager::new(),
))))) as Arc<dyn rustfs_lock::LockClient>
})
.collect();
disk_sets.push(
SetDisks::new_with_instance_ctx(
"test-owner".to_string(),
Arc::new(RwLock::new(disks)),
2,
1,
set_index,
0,
endpoints,
format.clone(),
lockers,
Arc::clone(&ctx),
)
.await,
);
}
let sets = Arc::new(Sets {
id: format.id,
disk_set: disk_sets,
pool_idx: 0,
endpoints: PoolEndpoints {
legacy: false,
set_count: 2,
drives_per_set: 2,
endpoints: Endpoints::from(all_endpoints),
cmd_line: String::new(),
platform: String::new(),
},
format,
parity_count: 1,
set_count: 2,
set_drive_count: 2,
default_parity_count: 1,
distribution_algo: DistributionAlgoVersion::V1,
exit_signal: None,
ctx,
});
(temp_dirs, sets)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1356,84 +1471,56 @@ mod tests {
assert_eq!(result, (Some(3), Some(1), Some(0)));
}
async fn two_set_test_sets() -> (Vec<tempfile::TempDir>, Arc<Sets>) {
let format = FormatV3::new(2, 2);
let mut temp_dirs = Vec::new();
let mut all_endpoints = Vec::new();
let mut disk_sets = Vec::new();
#[tokio::test]
async fn heal_object_uses_explicit_set_scope() {
let (_temp_dirs, sets) = make_local_two_set_sets().await;
let selected = sets
.get_disks_for_heal_object(
"object",
&HealOpts {
set: Some(1),
..Default::default()
},
)
.expect("requested set should be selected");
for set_index in 0..2 {
let mut endpoints = Vec::new();
let mut disks = Vec::new();
for disk_index in 0..2 {
let temp_dir = tempfile::tempdir().expect("tempdir should be created");
let mut endpoint = Endpoint::try_from(temp_dir.path().to_str().expect("tempdir path should be utf8"))
.expect("endpoint should parse");
endpoint.set_pool_index(0);
endpoint.set_set_index(set_index);
endpoint.set_disk_index(disk_index);
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("disk should be created");
let mut disk_format = format.clone();
disk_format.erasure.this = format.erasure.sets[set_index][disk_index];
save_format_file(&Some(disk.clone()), &Some(disk_format))
.await
.expect("format should be saved");
temp_dirs.push(temp_dir);
all_endpoints.push(endpoint.clone());
endpoints.push(endpoint);
disks.push(Some(disk));
}
disk_sets.push(
SetDisks::new(
"test-owner".to_string(),
Arc::new(RwLock::new(disks)),
2,
1,
set_index,
0,
endpoints,
format.clone(),
vec![Arc::new(LocalClient::new()), Arc::new(LocalClient::new())],
)
.await,
);
}
assert!(Arc::ptr_eq(&selected, &sets.disk_set[1]));
}
let sets = Arc::new(Sets {
id: format.id,
disk_set: disk_sets,
pool_idx: 0,
endpoints: PoolEndpoints {
legacy: false,
set_count: 2,
drives_per_set: 2,
endpoints: Endpoints::from(all_endpoints),
cmd_line: String::new(),
platform: String::new(),
},
format,
parity_count: 1,
set_count: 2,
set_drive_count: 2,
default_parity_count: 1,
distribution_algo: DistributionAlgoVersion::V1,
exit_signal: None,
ctx: bootstrap_ctx(),
});
(temp_dirs, sets)
#[tokio::test]
async fn heal_object_without_set_scope_keeps_hash_routing() {
let (_temp_dirs, sets) = make_local_two_set_sets().await;
let object = "object";
let selected = sets
.get_disks_for_heal_object(object, &HealOpts::default())
.expect("hash-routed set should be selected");
assert!(Arc::ptr_eq(&selected, &sets.get_disks_by_key(object)));
}
#[tokio::test]
async fn heal_object_rejects_invalid_set_scope() {
let (_temp_dirs, sets) = make_local_two_set_sets().await;
let err = sets
.get_disks_for_heal_object(
"object",
&HealOpts {
set: Some(2),
..Default::default()
},
)
.expect_err("out-of-range set scope must fail closed");
assert!(
matches!(err, StorageError::InvalidArgument(_, ref field, ref reason)
if field == "set" && reason.contains("invalid heal set index 2 for pool 0 with 2 sets")),
"unexpected invalid set error: {err:?}"
);
}
#[tokio::test]
async fn delete_prefix_surfaces_a_hard_error_from_any_set() {
let (_temp_dirs, sets) = two_set_test_sets().await;
let (_temp_dirs, sets) = make_local_two_set_sets().await;
let bucket = format!("delete-prefix-{}", Uuid::new_v4().simple());
sets.make_bucket(&bucket, &MakeBucketOptions::default())
.await
@@ -1482,7 +1569,7 @@ mod tests {
#[tokio::test]
async fn delete_prefix_keeps_a_missing_bucket_idempotent_across_sets() {
let (_temp_dirs, sets) = two_set_test_sets().await;
let (_temp_dirs, sets) = make_local_two_set_sets().await;
let bucket = format!("delete-prefix-{}", Uuid::new_v4().simple());
sets.make_bucket(&bucket, &MakeBucketOptions::default())
.await
@@ -1521,7 +1608,7 @@ mod tests {
#[tokio::test]
async fn delete_prefix_preserves_a_completely_missing_bucket_error() {
let (_temp_dirs, sets) = two_set_test_sets().await;
let (_temp_dirs, sets) = make_local_two_set_sets().await;
let bucket = format!("delete-prefix-missing-{}", Uuid::new_v4().simple());
let err = sets
@@ -1541,7 +1628,7 @@ mod tests {
#[tokio::test]
async fn delete_prefix_fails_when_one_set_is_entirely_offline() {
let (_temp_dirs, sets) = two_set_test_sets().await;
let (_temp_dirs, sets) = make_local_two_set_sets().await;
let bucket = format!("delete-prefix-{}", Uuid::new_v4().simple());
sets.make_bucket(&bucket, &MakeBucketOptions::default())
.await
@@ -1588,7 +1675,7 @@ mod tests {
#[tokio::test]
async fn set_format_heal_accepts_quorum_from_a_nonzero_set() {
let (_temp_dirs, sets) = two_set_test_sets().await;
let (_temp_dirs, sets) = make_local_two_set_sets().await;
let (result, err) = sets.disk_set[1]
.heal_format(false)
@@ -1693,7 +1780,7 @@ mod tests {
#[serial]
async fn list_multipart_uploads_merges_all_sets_without_pagination_loss() {
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::Erasure).await;
let (_temp_dirs, sets) = two_set_test_sets().await;
let (_temp_dirs, sets) = make_local_two_set_sets().await;
let bucket = format!("multipart-list-{}", Uuid::new_v4().simple());
sets.make_bucket(&bucket, &MakeBucketOptions::default())
.await
@@ -2125,6 +2212,39 @@ mod tests {
);
}
#[tokio::test]
#[serial]
async fn replacement_format_only_writes_the_requested_slot() {
let (_dirs, _ref_format, sets) = setup_heal_format_sets(1, false).await;
let target = sets.endpoints.endpoints.as_ref()[1].to_string();
let untouched = sets.endpoints.endpoints.as_ref()[2].to_string();
let set = set_level_heal_view(&sets).await;
let (result, error) = set
.heal_replacement_format(false, std::slice::from_ref(&target))
.await
.expect("target-scoped replacement format should run");
assert!(error.is_none(), "target format must not report an error: {error:?}");
assert!(
result
.after
.drives
.iter()
.any(|drive| drive.endpoint == target && drive.state == DriveState::Ok.to_string()),
"requested replacement slot must be formatted"
);
let untouched_format = std::path::Path::new(&sets.endpoints.endpoints.as_ref()[2].get_file_path())
.join(crate::disk::RUSTFS_META_BUCKET)
.join(crate::disk::FORMAT_CONFIG_FILE);
assert!(
!tokio::fs::try_exists(untouched_format)
.await
.expect("untouched replacement format path should be inspectable"),
"unrequested slot {untouched} must remain unformatted"
);
}
fn instance_ctx_test_pool_endpoints() -> (FormatV3, PoolEndpoints) {
let format = FormatV3::new(1, 2);
let endpoints = vec![
+33
View File
@@ -152,6 +152,7 @@ const DISK_OPERATION_NAMES: &[&str] = &[
"read_parts",
"read_multiple",
"write_all",
"compare_and_update_file",
"read_all",
];
@@ -1092,6 +1093,18 @@ impl LocalDiskWrapper {
self.disk.get_object_path(volume, path)
}
pub(crate) fn get_object_path_for_io(&self, volume: &str, path: &str) -> crate::disk::error::Result<std::path::PathBuf> {
self.disk.get_object_path_for_io(volume, path)
}
pub(crate) fn get_bucket_path_for_io(&self, volume: &str) -> crate::disk::error::Result<std::path::PathBuf> {
self.disk.get_bucket_path_for_io(volume)
}
pub fn replacement_mount_lease_root(&self) -> Option<std::path::PathBuf> {
self.disk.replacement_mount_lease_root()
}
pub fn runtime_state(&self) -> RuntimeDriveHealthState {
self.health.runtime_state()
}
@@ -1639,6 +1652,10 @@ impl LocalDiskWrapper {
#[async_trait::async_trait]
impl DiskAPI for LocalDiskWrapper {
fn has_replacement_mount_lease(&self) -> bool {
self.disk.has_replacement_mount_lease()
}
async fn read_metadata(&self, volume: &str, path: &str) -> Result<Bytes> {
self.track_disk_health_with_op_and_timeout_action(
"read_metadata",
@@ -2140,6 +2157,22 @@ impl DiskAPI for LocalDiskWrapper {
.await
}
async fn compare_and_update_file(
&self,
volume: &str,
path: &str,
expected: Option<Bytes>,
replacement: Option<Bytes>,
) -> Result<crate::disk::ConditionalFileUpdate> {
self.track_disk_health_mutation(
"compare_and_update_file",
DiskMetricMutation::Write,
|| async { self.disk.compare_and_update_file(volume, path, expected, replacement).await },
get_max_timeout_duration(),
)
.await
}
async fn read_all(&self, volume: &str, path: &str) -> Result<Bytes> {
self.track_disk_health_with_op(
"read_all",
File diff suppressed because it is too large Load Diff
+89
View File
@@ -115,6 +115,15 @@ pub enum PartTransactionAction {
Rollback,
}
/// Result of an owner-aware file mutation. The disk applies the mutation only
/// while the current contents match the supplied expected value.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConditionalFileUpdate {
Updated,
Missing,
Mismatch,
}
#[derive(Clone, Copy, Debug)]
pub struct MmapCopyStageMetrics {
pub(crate) path: &'static str,
@@ -557,6 +566,26 @@ impl DiskAPI for Disk {
}
}
async fn compare_and_update_file(
&self,
volume: &str,
path: &str,
expected: Option<Bytes>,
replacement: Option<Bytes>,
) -> Result<ConditionalFileUpdate> {
match self {
Disk::Local(local_disk) => local_disk.compare_and_update_file(volume, path, expected, replacement).await,
Disk::Remote(remote_disk) => remote_disk.compare_and_update_file(volume, path, expected, replacement).await,
}
}
fn has_replacement_mount_lease(&self) -> bool {
match self {
Disk::Local(local_disk) => local_disk.has_replacement_mount_lease(),
Disk::Remote(remote_disk) => remote_disk.has_replacement_mount_lease(),
}
}
#[tracing::instrument(level = "trace", skip_all)]
async fn read_all(&self, volume: &str, path: &str) -> Result<Bytes> {
match self {
@@ -695,6 +724,34 @@ impl Disk {
Disk::Remote(_) => None,
}
}
pub(crate) fn get_object_path_for_io_if_local(
&self,
volume: &str,
path: &str,
) -> Option<crate::disk::error::Result<std::path::PathBuf>> {
match self {
Disk::Local(w) => Some(w.get_object_path_for_io(volume, path)),
Disk::Remote(_) => None,
}
}
pub(crate) fn get_bucket_path_for_io_if_local(&self, volume: &str) -> Option<crate::disk::error::Result<std::path::PathBuf>> {
match self {
Disk::Local(w) => Some(w.get_bucket_path_for_io(volume)),
Disk::Remote(_) => None,
}
}
/// Return the descriptor-rooted mount path admitted for automatic
/// replacement, or `None` when the configured endpoint no longer names
/// that held mount instance.
pub fn replacement_mount_lease_root(&self) -> Option<PathBuf> {
match self {
Disk::Local(local_disk) => local_disk.replacement_mount_lease_root(),
Disk::Remote(_) => None,
}
}
}
pub async fn new_disk(ep: &Endpoint, opt: &DiskOption) -> Result<DiskStore> {
@@ -860,6 +917,24 @@ pub trait DiskAPI: Debug + Send + Sync + 'static {
// CleanAbandonedData
async fn write_all(&self, volume: &str, path: &str, data: Bytes) -> Result<()>;
async fn read_all(&self, volume: &str, path: &str) -> Result<Bytes>;
/// Atomically replace or remove a small control file only when its current
/// contents match `expected`. Implementations that cannot provide this
/// cross-process guarantee must fail closed instead of emulating it with a
/// read-then-write sequence.
async fn compare_and_update_file(
&self,
_volume: &str,
_path: &str,
_expected: Option<Bytes>,
_replacement: Option<Bytes>,
) -> Result<ConditionalFileUpdate> {
Err(DiskError::MethodNotAllowed)
}
/// Whether local I/O is rooted at a held mount descriptor. Auto-replacement
/// refuses destructive work when this is false.
fn has_replacement_mount_lease(&self) -> bool {
false
}
async fn disk_info(&self, opts: &DiskInfoOptions) -> Result<DiskInfo>;
fn start_scan(&self) -> ScanGuard;
}
@@ -1612,6 +1687,7 @@ mod tests {
let endpoint = Endpoint::try_from(test_dir).unwrap();
let local_disk = LocalDisk::new(&endpoint, false).await.unwrap();
let expected_object_path = local_disk.root.join("test-bucket/test-object");
let disk = Disk::Local(Box::new(LocalDiskWrapper::new(Arc::new(local_disk), false)));
// Test basic methods
@@ -1626,6 +1702,19 @@ mod tests {
// Test path method
let path = disk.path();
assert!(path.exists());
let object_path = disk
.get_object_path_if_local("test-bucket", "test-object")
.expect("local disk should expose an object path")
.expect("object path should resolve");
assert_eq!(object_path, expected_object_path);
assert!(!object_path.starts_with("/proc/self/fd/"));
#[cfg(target_os = "linux")]
assert!(
disk.get_object_path_for_io_if_local("test-bucket", "test-object")
.expect("local disk should expose an I/O object path")
.expect("I/O object path should resolve")
.starts_with("/proc/self/fd/")
);
// Test disk location
let location = disk.get_disk_location();
File diff suppressed because it is too large Load Diff
+13 -14
View File
@@ -1069,13 +1069,11 @@ where
}
// Pre-claim per-slot buffers so the `self.readers` borrow below stays
// disjoint from `self.buffers`.
let participating: Vec<bool> = (0..num_readers)
.map(|i| self.engaged[i] && self.readers[i].is_some())
.collect();
// disjoint from `self.buffers`; `Some(buffer)` also records which slots
// participate, avoiding a per-stripe sidecar allocation.
let mut bufs: Vec<Option<Vec<u8>>> = Vec::with_capacity(num_readers);
for (i, participates) in participating.iter().enumerate() {
bufs.push(if *participates {
for i in 0..num_readers {
bufs.push(if self.engaged[i] && self.readers[i].is_some() {
Some(self.buffers.take(i, shard_size))
} else {
None
@@ -1085,7 +1083,6 @@ where
let data_shards = self.data_shards;
let read_timeout = self.read_timeout;
let metrics_path = self.metrics_path;
let read_costs = self.read_costs.clone();
let locality_preference_enabled = self.locality_preference_enabled;
let stripe_read_start = metrics_path.map(|_| Instant::now());
@@ -1100,19 +1097,21 @@ where
// before the retirement pass mutates `self.readers` below.
{
let mut sets = FuturesUnordered::new();
let reader_iter = ReaderLaunchIter::new(&mut self.readers, &read_costs, locality_preference_enabled);
let reader_iter = ReaderLaunchIter::new(&mut self.readers, self.read_costs.as_slice(), locality_preference_enabled);
for (i, reader) in reader_iter {
if reader.is_none() || !participating[i] {
if reader.is_none() {
continue;
}
let read_cost = read_costs.get(i).copied().unwrap_or(ShardReadCost::Unknown);
let recycled_buf = bufs[i].take();
let Some(recycled_buf) = bufs[i].take() else {
continue;
};
let read_cost = self.read_costs.get(i).copied().unwrap_or(ShardReadCost::Unknown);
scheduled += 1;
sets.push(read_shard(
i,
read_cost,
reader,
recycled_buf,
Some(recycled_buf),
shard_size,
data_shards,
read_timeout,
@@ -1208,7 +1207,7 @@ where
// covered by the stripe-aligned parity substitution below.
if hedged {
for i in 0..num_readers {
if participating[i] && shards[i].is_none() && errs[i].is_none() {
if self.engaged[i] && self.readers[i].is_some() && shards[i].is_none() && errs[i].is_none() {
errs[i] = Some(Error::from(io::Error::new(ErrorKind::TimedOut, "shard read hedged after a slow shard")));
retire_readers.push(i);
}
@@ -1237,7 +1236,7 @@ where
if !self.try_engage_parity(idx, stripe_index) {
continue;
}
let read_cost = read_costs.get(idx).copied().unwrap_or(ShardReadCost::Unknown);
let read_cost = self.read_costs.get(idx).copied().unwrap_or(ShardReadCost::Unknown);
let recycled_buf = Some(self.buffers.take(idx, shard_size));
scheduled += 1;
let (i, _read_cost, result, _should_retire) = read_shard(
+47 -21
View File
@@ -643,6 +643,48 @@ fn local_host_resolution_timeout_forced(host: &Host<&str>) -> bool {
.contains(&host)
}
#[cfg(test)]
static FORCED_KERNEL_HOSTNAME: LazyLock<Mutex<Option<String>>> = LazyLock::new(|| Mutex::new(None));
#[cfg(test)]
struct KernelHostnameOverrideGuard;
#[cfg(test)]
impl Drop for KernelHostnameOverrideGuard {
fn drop(&mut self) {
*FORCED_KERNEL_HOSTNAME
.lock()
.expect("kernel-hostname test override mutex poisoned") = None;
}
}
/// Overrides the kernel hostname seen by Kubernetes endpoint-identity
/// inference so tests stay deterministic on hosts whose kernel hostname is
/// not a DNS name (e.g. macOS with a DHCP-assigned IP-literal hostname).
#[cfg(test)]
fn force_kernel_hostname_for_test(hostname: &str) -> KernelHostnameOverrideGuard {
*FORCED_KERNEL_HOSTNAME
.lock()
.expect("kernel-hostname test override mutex poisoned") = Some(hostname.to_string());
KernelHostnameOverrideGuard
}
fn kernel_hostname_for_endpoint_identity() -> Result<String> {
#[cfg(test)]
if let Some(hostname) = FORCED_KERNEL_HOSTNAME
.lock()
.expect("kernel-hostname test override mutex poisoned")
.clone()
{
return Ok(hostname);
}
hostname::get()
.map_err(|err| Error::other(format!("failed to read the kernel hostname for Kubernetes endpoint identity: {err}")))?
.into_string()
.map_err(|_| Error::new(ErrorKind::InvalidData, "kernel hostname is not valid UTF-8"))
}
fn endpoint_is_local_host(host: Host<&str>, port: u16, local_port: u16) -> Result<bool> {
#[cfg(test)]
if local_host_resolution_timeout_forced(&host) {
@@ -1268,12 +1310,7 @@ impl EndpointServerPools {
&& std::env::var_os(ENV_KUBERNETES_SERVICE_HOST).is_some()
&& matches!(wait_mode.as_deref(), None | Some("") | Some("auto") | Some("orchestrated"));
if infer_kubernetes_host {
let kernel_hostname = hostname::get()
.map_err(|err| {
Error::other(format!("failed to read the kernel hostname for Kubernetes endpoint identity: {err}"))
})?
.into_string()
.map_err(|_| Error::new(ErrorKind::InvalidData, "kernel hostname is not valid UTF-8"))?;
let kernel_hostname = kernel_hostname_for_endpoint_identity()?;
let local_port = check_local_server_addr(server_addr)?.port();
match infer_kubernetes_local_endpoint_host(disks_layout, local_port, &kernel_hostname)? {
Some(inferred_host) => local_endpoint_host = Some(inferred_host),
@@ -2217,21 +2254,8 @@ mod test {
#[serial]
#[tokio::test]
async fn create_server_endpoints_infers_kubernetes_pod_host_without_peer_dns() {
let raw_hostname = hostname::get()
.expect("kernel hostname should be available")
.into_string()
.expect("kernel hostname should be UTF-8");
let Host::Domain(kernel_hostname) = Host::parse(raw_hostname.trim()).expect("kernel hostname should be a DNS name")
else {
panic!("kernel hostname should be a DNS name");
};
let kernel_hostname =
domain_without_optional_trailing_dot(&kernel_hostname).expect("kernel hostname should be canonical");
let local_host = if kernel_hostname.contains('.') {
kernel_hostname.to_string()
} else {
format!("{kernel_hostname}.rustfs-headless.ns.svc.cluster.local")
};
let _kernel_hostname = force_kernel_hostname_for_test("rustfs-0");
let local_host = "rustfs-0.rustfs-headless.ns.svc.cluster.local";
async_with_vars(
[
@@ -2287,6 +2311,7 @@ mod test {
#[serial]
#[tokio::test]
async fn create_server_endpoints_bounds_kubernetes_alias_dns_fallback() {
let _kernel_hostname = force_kernel_hostname_for_test("unmatched-test-node");
let _resolution_timeout =
force_local_host_resolution_timeout_for_test(&["unrelated-0.example.invalid", "unrelated-1.example.invalid"]);
@@ -2319,6 +2344,7 @@ mod test {
#[serial]
#[tokio::test]
async fn create_server_endpoints_preserves_resolvable_kubernetes_aliases() {
let _kernel_hostname = force_kernel_hostname_for_test("unmatched-test-node");
async_with_vars(
[
(ENV_LOCAL_ENDPOINT_HOST, None),
+1
View File
@@ -894,6 +894,7 @@ impl GetObjectReader {
.await?
.into_reader(reader, oi)
}
#[hotpath::measure(impl_type = "GetObjectReader")]
pub async fn read_all(&mut self) -> Result<Vec<u8>> {
let mut data = Vec::new();
self.stream.read_to_end(&mut data).await?;
+37 -1
View File
@@ -253,6 +253,11 @@ pub struct ObjectOptions {
/// fence avoids recursively acquiring the read lock behind a queued writer.
pub bucket_lifecycle_lock_fence: Option<NamespaceLockFence>,
pub replication_request: bool,
/// Authorized SSE-C replication passthrough: the body is already
/// ciphertext, so the write path must not encrypt or compress it and
/// stores the restored encryption metadata verbatim. Only the
/// replication-authorized options builders may set this.
pub preserve_ciphertext: bool,
pub delete_marker: bool,
pub synthetic_version_id: bool,
@@ -1041,7 +1046,10 @@ impl ObjectInfo {
if let Some(data) = &self.checksum {
if self.is_encrypted() {
// Object-level encrypted checksum bytes require SSE decrypt material,
// so do not expose them as plaintext checksum headers here.
// so do not expose them as plaintext checksum headers here. The
// `false` multipart flag feeds the response-path COMPOSITE
// fallback; callers that need accurate multipart routing must
// consult `is_multipart()` instead of this value.
return Ok((HashMap::new(), false));
}
@@ -1712,6 +1720,34 @@ mod tests {
assert!(checksums.is_empty());
}
#[test]
fn decrypt_checksums_keeps_encrypted_multipart_flag_false_for_response_paths() {
let checksum = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::CRC32, b"encrypted-object")
.expect("test checksum should be valid");
let info = ObjectInfo {
checksum: Some(checksum.to_bytes(&[])),
// Multipart ETag shape: md5-of-md5s with a part-count suffix.
etag: Some("0123456789abcdef0123456789abcdef-3".to_string()),
user_defined: Arc::new(HashMap::from([(
rustfs_utils::http::headers::AMZ_SERVER_SIDE_ENCRYPTION.to_string(),
"AES256".to_string(),
)])),
..Default::default()
};
let (checksums, is_multipart) = info
.decrypt_checksums(0, &HeaderMap::new())
.expect("encrypted checksum should fail closed");
// The response path infers COMPOSITE from is_multipart=true when the
// checksum type is unreadable, so encrypted objects must keep the
// flag false here even when the object itself is multipart. Callers
// that need routing (replication) consult is_multipart() directly.
assert!(checksums.is_empty());
assert!(!is_multipart);
assert!(info.is_multipart());
}
#[test]
fn decrypt_checksums_keeps_encrypted_part_checksum_metadata() {
let checksum = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::CRC32, b"encrypted-object")
+5
View File
@@ -208,6 +208,11 @@ impl InstanceContext {
}
}
#[cfg(test)]
pub(crate) fn with_lock_manager_for_test(lock_manager: Arc<GlobalLockManager>) -> Self {
Self::with_lock_manager(lock_manager)
}
/// This instance's namespace lock manager.
pub fn lock_manager(&self) -> Arc<GlobalLockManager> {
self.lock_manager.clone()
@@ -327,16 +327,8 @@ impl ECStore {
#[tracing::instrument(skip(self, bucktes))]
pub async fn init_and_start_rebalance(self: &Arc<Self>, bucktes: Vec<String>) -> Result<String> {
let _start_guard = self.start_gate.lock().await;
let decommission_running = self.is_decommission_running().await;
{
let rebalance_meta = self.rebalance_meta.read().await;
validate_init_rebalance_state(decommission_running, rebalance_meta.as_ref())?;
}
let id = self.init_rebalance_meta(bucktes).await?;
if let Err(start_err) = self.start_rebalance().await {
let id = self.init_rebalance_start(bucktes).await?;
if let Err(start_err) = self.start_rebalance_for_id(&id).await {
if let Err(rollback_err) = self
.rollback_rebalance_start_without_worker_for_id(Some(&id), start_err.to_string())
.await
@@ -354,6 +346,47 @@ impl ECStore {
Ok(id)
}
#[tracing::instrument(skip(self, bucktes))]
pub async fn init_rebalance_start(self: &Arc<Self>, bucktes: Vec<String>) -> Result<String> {
let _start_guard = self.start_gate.lock().await;
let decommission_running = self.is_decommission_running().await;
{
let rebalance_meta = self.rebalance_meta.read().await;
validate_init_rebalance_state(decommission_running, rebalance_meta.as_ref())?;
}
self.init_rebalance_meta(bucktes).await
}
#[tracing::instrument(skip(self))]
pub async fn start_rebalance_for_id(self: &Arc<Self>, expected_id: &str) -> Result<()> {
let _start_guard = self.start_gate.lock().await;
{
let rebalance_meta = self.rebalance_meta.read().await;
let Some(meta) = rebalance_meta.as_ref() else {
return Err(Error::ConfigNotFound);
};
if meta.id != expected_id {
return Err(Error::other(format!(
"rebalance metadata changed before start: expected {expected_id}, found {}",
meta.id
)));
}
if meta.stopped_at.is_some() {
return Err(Error::other(format!("rebalance {expected_id} was stopped before start")));
}
}
self.start_rebalance().await
}
pub async fn rollback_rebalance_start_for_id(self: &Arc<Self>, expected_id: Option<&str>, start_error: String) -> Result<()> {
self.rollback_rebalance_start_without_worker_for_id(expected_id, start_error)
.await
}
#[tracing::instrument(skip(self, fi))]
pub async fn update_pool_stats(&self, pool_index: usize, bucket: String, fi: &FileInfo) -> Result<()> {
self.update_pool_stats_batch(pool_index, bucket, &[fi]).await
@@ -2584,21 +2584,7 @@ async fn test_init_and_start_rebalance_rejects_second_start_after_gate() {
}],
..Default::default()
};
let endpoint_pools: crate::layout::endpoints::EndpointServerPools = Vec::new().into();
let store = Arc::new(crate::store::ECStore {
id: uuid::Uuid::new_v4(),
disk_map: std::collections::HashMap::new(),
pools: Vec::new(),
peer_sys: crate::cluster::rpc::S3PeerSys::new(&endpoint_pools),
pool_meta: tokio::sync::RwLock::new(crate::core::pools::PoolMeta::default()),
rebalance_meta: tokio::sync::RwLock::new(Some(active_meta)),
decommission_cancelers: tokio::sync::RwLock::new(Vec::new()),
start_gate: tokio::sync::Mutex::new(()),
pool_meta_save_gate: tokio::sync::Mutex::new(()),
ctx: crate::runtime::instance::bootstrap_ctx(),
bucket_fence_registry: std::sync::Arc::default(),
});
let store = test_store_with_rebalance_meta(active_meta);
let err = store
.init_and_start_rebalance(vec!["bucket".to_string()])
@@ -2608,6 +2594,72 @@ async fn test_init_and_start_rebalance_rejects_second_start_after_gate() {
assert!(matches!(err, Error::RebalanceAlreadyRunning));
}
#[tokio::test]
async fn test_start_rebalance_for_id_rejects_changed_metadata() {
let meta = RebalanceMeta {
id: "rebalance-a".to_string(),
pool_stats: vec![RebalanceStats {
participating: true,
info: RebalanceInfo {
status: RebalStatus::Started,
..Default::default()
},
..Default::default()
}],
..Default::default()
};
let store = test_store_with_rebalance_meta(meta);
let err = store
.start_rebalance_for_id("rebalance-b")
.await
.expect_err("staged start must not start changed metadata");
assert!(err.to_string().contains("rebalance metadata changed before start"));
}
#[tokio::test]
async fn test_start_rebalance_for_id_rejects_stopped_metadata() {
let meta = RebalanceMeta {
id: "rebalance-a".to_string(),
stopped_at: Some(OffsetDateTime::now_utc()),
pool_stats: vec![RebalanceStats {
participating: true,
info: RebalanceInfo {
status: RebalStatus::Started,
..Default::default()
},
..Default::default()
}],
..Default::default()
};
let store = test_store_with_rebalance_meta(meta);
let err = store
.start_rebalance_for_id("rebalance-a")
.await
.expect_err("staged start must not restart stopped metadata");
assert!(err.to_string().contains("was stopped before start"));
}
fn test_store_with_rebalance_meta(meta: RebalanceMeta) -> Arc<crate::store::ECStore> {
let endpoint_pools: crate::layout::endpoints::EndpointServerPools = Vec::new().into();
Arc::new(crate::store::ECStore {
id: uuid::Uuid::new_v4(),
disk_map: std::collections::HashMap::new(),
pools: Vec::new(),
peer_sys: crate::cluster::rpc::S3PeerSys::new(&endpoint_pools),
pool_meta: tokio::sync::RwLock::new(crate::core::pools::PoolMeta::default()),
rebalance_meta: tokio::sync::RwLock::new(Some(meta)),
decommission_cancelers: tokio::sync::RwLock::new(Vec::new()),
start_gate: tokio::sync::Mutex::new(()),
pool_meta_save_gate: tokio::sync::Mutex::new(()),
ctx: crate::runtime::instance::bootstrap_ctx(),
bucket_fence_registry: std::sync::Arc::default(),
})
}
#[test]
fn test_percent_free_ratio_zero_capacity_is_zero() {
assert_eq!(percent_free_ratio(100, 0), 0.0);
+496 -56
View File
@@ -461,6 +461,34 @@ impl MetadataQuorumAccumulator {
None
}
pub(in crate::set_disk) fn can_still_reach_early_stop_with_pending(&self, pending: usize) -> bool {
if !self.allow_early_stop {
return false;
}
if self.delete_marker_votes.saturating_add(pending) >= self.default_write_quorum() {
return true;
}
if self.conflicting_metadata
|| self.delete_marker_seen
|| self.not_found_responses > 0
|| self.version_not_found_responses > 0
|| self.hard_errors > 0
{
return false;
}
if !self.requested_version_id.is_empty()
&& self.matching_version_votes.saturating_add(pending) >= self.read_quorum_for_version()
{
return true;
}
match &self.candidate {
Some(candidate) => self
.candidate_latest_quorum(candidate)
.is_some_and(|latest_quorum| self.candidate_votes.saturating_add(pending) >= latest_quorum),
None => pending >= self.default_write_quorum(),
}
}
/// Compute the read quorum threshold for version-aware early-stop.
/// Uses `total_disks / 2` (like `missing_response_quorum`) when
/// `default_parity_count` is set, otherwise requires all disks.
@@ -510,7 +538,7 @@ impl MetadataQuorumAccumulator {
}
pub(in crate::set_disk) fn default_write_quorum(&self) -> usize {
if self.default_parity_count == 0 {
if self.default_parity_count == 0 || self.default_parity_count >= self.total_disks {
return self.total_disks;
}
let data_blocks = self.total_disks.saturating_sub(self.default_parity_count);
@@ -522,7 +550,7 @@ impl MetadataQuorumAccumulator {
}
pub(in crate::set_disk) fn missing_response_quorum(&self) -> usize {
if self.default_parity_count == 0 {
if self.default_parity_count == 0 || self.default_parity_count >= self.total_disks {
self.total_disks
} else {
self.total_disks / 2
@@ -1439,7 +1467,7 @@ async fn try_create_bitrot_readers_via_batch_pread(
if let Some(disk) = disk_op.as_ref() {
let data_dir = files[idx].data_dir.unwrap_or_default();
let path_str = format!("{object}/{data_dir}/part.{part_number}");
match disk.get_object_path_if_local(bucket, &path_str) {
match disk.get_object_path_for_io_if_local(bucket, &path_str) {
Some(Ok(p)) => batch_items.push((idx, p, adj_off, adj_len)),
_ => return None,
}
@@ -1982,7 +2010,7 @@ pub(in crate::set_disk) fn should_allow_metadata_early_stop(
healing: bool,
incl_free_versions: bool,
) -> bool {
if read_data {
if read_data && !is_get_metadata_data_read_early_stop_enabled() {
return false;
}
@@ -2289,23 +2317,41 @@ impl SetDisks {
let object = Arc::new(object.to_string());
let version_id = Arc::new(version_id.to_string());
let mut join_set = JoinSet::new();
let bounded_fanout = is_get_metadata_early_stop_bounded_fanout_enabled();
let mut next_disk_index = 0usize;
let spawn_read_version =
|join_set: &mut JoinSet<(usize, disk::error::Result<FileInfo>, Duration)>, index: usize, disk: Option<DiskStore>| {
let opts = opts.clone();
let org_bucket = org_bucket.clone();
let bucket = bucket.clone();
let object = object.clone();
let version_id = version_id.clone();
join_set.spawn(async move {
let response_start = Instant::now();
let result = if let Some(disk) = disk {
Self::record_read_version_call(&object, index);
#[cfg(test)]
Self::read_version_fanout_barrier(&object, index).await;
disk.read_version(&org_bucket, &bucket, &object, &version_id, &opts).await
} else {
Err(DiskError::DiskNotFound)
};
(index, result, response_start.elapsed())
});
};
for (index, disk) in disks.iter().cloned().enumerate() {
let opts = opts.clone();
let org_bucket = org_bucket.clone();
let bucket = bucket.clone();
let object = object.clone();
let version_id = version_id.clone();
join_set.spawn(async move {
let response_start = Instant::now();
let result = if let Some(disk) = disk {
Self::record_read_version_call(&object, index);
disk.read_version(&org_bucket, &bucket, &object, &version_id, &opts).await
} else {
Err(DiskError::DiskNotFound)
};
(index, result, response_start.elapsed())
});
if bounded_fanout {
let initial_target = accumulator.default_write_quorum().min(disks.len());
while next_disk_index < initial_target {
if let Some(disk) = disks.get(next_disk_index).cloned() {
spawn_read_version(&mut join_set, next_disk_index, disk);
}
next_disk_index = next_disk_index.saturating_add(1);
}
} else {
for (index, disk) in disks.iter().cloned().enumerate() {
spawn_read_version(&mut join_set, index, disk);
}
}
while let Some(result) = join_set.join_next().await {
@@ -2337,7 +2383,11 @@ impl SetDisks {
.early_stop_decision()
.or_else(|| accumulator.version_early_stop_decision())
{
let saved_responses = join_set.len();
let saved_responses = if bounded_fanout {
disks.len().saturating_sub(observations.len())
} else {
join_set.len()
};
join_set.abort_all();
rustfs_io_metrics::record_get_object_metadata_early_stop_hit(GET_OBJECT_PATH_LEGACY_DUPLEX, decision.reason);
rustfs_io_metrics::record_get_object_metadata_early_stop_saved_responses(
@@ -2348,6 +2398,20 @@ impl SetDisks {
let diagnostics = MetadataFanoutDiagnostics::new(fanout_start.elapsed(), observations);
return Ok((ress, errors, diagnostics));
}
let pending_responses = join_set.len();
let should_hedge_single_pending_data_read =
read_data && pending_responses == 1 && accumulator.can_still_reach_early_stop_with_pending(pending_responses);
if bounded_fanout
&& next_disk_index < disks.len()
&& (!accumulator.can_still_reach_early_stop_with_pending(pending_responses)
|| should_hedge_single_pending_data_read)
{
if let Some(disk) = disks.get(next_disk_index).cloned() {
spawn_read_version(&mut join_set, next_disk_index, disk);
}
next_disk_index = next_disk_index.saturating_add(1);
}
}
rustfs_io_metrics::record_get_object_metadata_early_stop_miss(
@@ -3259,6 +3323,12 @@ impl SetDisks {
#[inline(always)]
fn record_read_version_call(_object: &str, _disk_index: usize) {}
#[cfg(test)]
#[inline]
async fn read_version_fanout_barrier(object: &str, disk_index: usize) {
rename_fanout_barrier::checkpoint(object, disk_index, rename_fanout_barrier::PHASE_READ_VERSION).await;
}
/// Test-only awaitable pause point for the rename/commit fan-out (backlog#1325,
/// serving the barrier-style acceptances of #1312 / #1319 / #1313). `phase` is
/// [`rename_fanout_barrier::PHASE_RENAME`] or `PHASE_CLEANUP`. When a test has
@@ -3412,6 +3482,18 @@ impl SetDisks {
}
async fn recover_part_transaction(&self, dst_object: &str, write_quorum: usize) -> disk::error::Result<bool> {
struct PartTransactionObservation {
transaction_meta: Option<Bytes>,
current_meta: Option<Bytes>,
rollback: bool,
err: Option<DiskError>,
}
enum PartTransactionOutcome {
Commit,
Rollback,
}
let disks = self.get_disks_internal().await;
let transaction_path = part_transaction_path(dst_object);
let transaction_meta_path = format!("{transaction_path}/{PART_TRANSACTION_NEW_META}");
@@ -3425,36 +3507,76 @@ impl SetDisks {
let current_meta_path = current_meta_path.clone();
async move {
let Some(disk) = disk else {
return Ok((None, None, false));
return PartTransactionObservation {
transaction_meta: None,
current_meta: None,
rollback: false,
err: Some(DiskError::DiskNotFound),
};
};
let transaction_meta = match disk.read_all(RUSTFS_META_MULTIPART_BUCKET, &transaction_meta_path).await {
Ok(meta) => Some(meta),
Err(DiskError::FileNotFound) => None,
Err(err) => return Err(err),
Err(err) => {
return PartTransactionObservation {
transaction_meta: None,
current_meta: None,
rollback: false,
err: Some(err),
};
}
};
let rollback = match disk.read_all(RUSTFS_META_MULTIPART_BUCKET, &rollback_path).await {
Ok(_) => true,
Err(DiskError::FileNotFound) => false,
Err(err) => return Err(err),
Err(err) => {
return PartTransactionObservation {
transaction_meta,
current_meta: None,
rollback: false,
err: Some(err),
};
}
};
let current_meta = match disk.read_all(RUSTFS_META_MULTIPART_BUCKET, &current_meta_path).await {
Ok(meta) => Some(meta),
Err(DiskError::FileNotFound | DiskError::DiskNotFound) => None,
Err(_) => None,
};
Ok((transaction_meta, current_meta, rollback))
PartTransactionObservation {
transaction_meta,
current_meta,
rollback,
err: None,
}
}
});
let observations = join_all(reads).await.into_iter().collect::<disk::error::Result<Vec<_>>>()?;
if observations.iter().all(|(transaction, _, _)| transaction.is_none()) {
let observations = join_all(reads).await;
let read_errs = observations
.iter()
.map(|observation| observation.err.clone())
.collect::<Vec<_>>();
if let Some(err) = reduce_write_quorum_errs(&read_errs, OBJECT_OP_IGNORED_ERRS, write_quorum) {
return Err(err);
}
if observations
.iter()
.filter(|observation| observation.err.is_none())
.all(|observation| observation.transaction_meta.is_none())
{
return Ok(false);
}
let mut current_counts: HashMap<Bytes, usize> = HashMap::new();
for (_, current, _) in &observations {
if let Some(current) = current {
let mut transaction_meta_values = HashSet::new();
for observation in observations.iter().filter(|observation| observation.err.is_none()) {
if let Some(current) = &observation.current_meta {
*current_counts.entry(current.clone()).or_default() += 1;
}
if let Some(transaction_meta) = &observation.transaction_meta {
transaction_meta_values.insert(transaction_meta.clone());
}
}
let current_quorum = current_counts
.into_iter()
@@ -3462,11 +3584,29 @@ impl SetDisks {
let old_meta_path = format!("{transaction_path}/{PART_TRANSACTION_OLD_META}");
let old_meta_absent_path = format!("{transaction_path}/old.meta.absent");
let mut outcomes = Vec::with_capacity(observations.len());
for observation in &observations {
let outcome = if observation.err.is_none() && observation.transaction_meta.is_none() {
match &observation.current_meta {
Some(current_meta) if transaction_meta_values.contains(current_meta) => Some(PartTransactionOutcome::Commit),
_ => Some(PartTransactionOutcome::Rollback),
}
} else {
None
};
outcomes.push(outcome);
}
let decisions = observations
.iter()
.enumerate()
.filter_map(|(index, (transaction_meta, _, rollback))| {
transaction_meta.as_ref().map(|meta| (index, meta.clone(), *rollback))
.filter_map(|(index, observation)| {
if observation.err.is_some() {
return None;
}
observation
.transaction_meta
.as_ref()
.map(|meta| (index, meta.clone(), observation.rollback))
})
.map(|(index, transaction_meta, rollback)| {
let disk = disks[index].clone();
@@ -3475,38 +3615,68 @@ impl SetDisks {
let current_quorum = current_quorum.clone();
async move {
let Some(disk) = disk else {
return Err(DiskError::DiskNotFound);
return (index, Err(DiskError::DiskNotFound));
};
let action = if rollback {
PartTransactionAction::Rollback
} else if current_quorum.as_ref() == Some(&transaction_meta) {
PartTransactionAction::Commit
} else if let Some(current_quorum) = current_quorum {
match disk.read_all(RUSTFS_META_MULTIPART_BUCKET, &old_meta_path).await {
Ok(old_meta) if old_meta == current_quorum => PartTransactionAction::Rollback,
Ok(_) => PartTransactionAction::Commit,
Err(DiskError::FileNotFound) => {
match disk.read_all(RUSTFS_META_MULTIPART_BUCKET, &old_meta_absent_path).await {
Ok(_) => PartTransactionAction::Commit,
Err(_) => return Err(DiskError::FileCorrupt),
let result = async {
let action = if rollback {
PartTransactionAction::Rollback
} else if current_quorum.as_ref() == Some(&transaction_meta) {
PartTransactionAction::Commit
} else if let Some(current_quorum) = current_quorum {
match disk.read_all(RUSTFS_META_MULTIPART_BUCKET, &old_meta_path).await {
Ok(old_meta) if old_meta == current_quorum => PartTransactionAction::Rollback,
Ok(_) => PartTransactionAction::Commit,
Err(DiskError::FileNotFound) => {
match disk.read_all(RUSTFS_META_MULTIPART_BUCKET, &old_meta_absent_path).await {
Ok(_) => PartTransactionAction::Commit,
Err(_) => return Err(DiskError::FileCorrupt),
}
}
Err(err) => return Err(err),
}
Err(err) => return Err(err),
}
} else {
PartTransactionAction::Rollback
} else {
PartTransactionAction::Rollback
};
disk.settle_part_transaction(RUSTFS_META_MULTIPART_BUCKET, dst_object, action)
.await?;
let outcome = match action {
PartTransactionAction::Commit => PartTransactionOutcome::Commit,
PartTransactionAction::Rollback => PartTransactionOutcome::Rollback,
};
Ok(outcome)
};
disk.settle_part_transaction(RUSTFS_META_MULTIPART_BUCKET, dst_object, action)
.await?;
Ok(action == PartTransactionAction::Commit)
(index, result.await)
}
});
let results = join_all(decisions).await;
if let Some(err) = results.iter().find_map(|result| result.as_ref().err()) {
return Err(err.clone());
let mut settle_errs = read_errs;
for result in results {
match result {
(index, Ok(outcome)) => outcomes[index] = Some(outcome),
(index, Err(err)) => settle_errs[index] = Some(err),
}
}
Ok(results.iter().any(|result| matches!(result, Ok(true))))
if let Some(err) = reduce_write_quorum_errs(&settle_errs, OBJECT_OP_IGNORED_ERRS, write_quorum) {
return Err(err);
}
let commit_count = outcomes
.iter()
.filter(|outcome| matches!(outcome, Some(PartTransactionOutcome::Commit)))
.count();
if commit_count >= write_quorum {
return Ok(true);
}
let rollback_count = outcomes
.iter()
.filter(|outcome| matches!(outcome, Some(PartTransactionOutcome::Rollback)))
.count();
if rollback_count >= write_quorum {
return Ok(false);
}
Err(DiskError::ErasureWriteQuorum)
}
pub(in crate::set_disk) async fn recover_part_transactions(
@@ -4551,7 +4721,7 @@ pub(in crate::set_disk) mod cleanup_fault_injection {
/// unobserved object records nothing, keeping the registry bounded, and each
/// [`CallCounterScope`] clears only its own object's counts on drop.
#[cfg(test)]
pub(in crate::set_disk) mod disk_call_counters {
pub(crate) mod disk_call_counters {
use std::collections::{HashMap, HashSet};
use std::sync::{Mutex, OnceLock};
@@ -4645,6 +4815,8 @@ pub(in crate::set_disk) mod rename_fanout_barrier_phase {
pub const RENAME: &str = "rename";
/// The per-disk old-data-dir cleanup phase of the commit fan-out.
pub const CLEANUP: &str = "cleanup";
/// The per-disk `read_version` phase of metadata read fan-out.
pub const READ_VERSION: &str = "read_version";
}
/// Test-only awaitable pause barrier + background-task introspection for the
@@ -4688,7 +4860,9 @@ pub(in crate::set_disk) mod rename_fanout_barrier {
use std::sync::{Arc, Mutex, OnceLock};
use tokio::sync::Notify;
pub use super::rename_fanout_barrier_phase::{CLEANUP as PHASE_CLEANUP, RENAME as PHASE_RENAME};
pub use super::rename_fanout_barrier_phase::{
CLEANUP as PHASE_CLEANUP, READ_VERSION as PHASE_READ_VERSION, RENAME as PHASE_RENAME,
};
/// One armed barrier: the fan-out task matching `(disk_index, phase)` pauses.
struct Armed {
@@ -5154,6 +5328,262 @@ mod tests {
drop(dirs);
}
fn valid_metadata_fanout_fileinfo(
bucket: &str,
object: &str,
version_id: Uuid,
data_dir: Uuid,
mod_time: OffsetDateTime,
) -> FileInfo {
let mut fi = FileInfo::new(object, 2, 2);
fi.volume = bucket.to_string();
fi.name = object.to_string();
fi.size = 1;
fi.erasure.index = 1;
fi.version_id = Some(version_id);
fi.is_latest = true;
fi.data_dir = Some(data_dir);
fi.mod_time = Some(mod_time);
fi.metadata.insert("etag".to_string(), "etag-1".to_string());
fi.add_object_part(1, "part-etag".to_string(), 1, fi.mod_time, 1, None, None);
fi
}
async fn install_metadata_fanout_fileinfo(
disks: &[Option<DiskStore>],
bucket: &str,
object: &str,
missing_part_disk: Option<usize>,
) {
let version_id = Uuid::new_v4();
let data_dir = Uuid::new_v4();
let mod_time = OffsetDateTime::now_utc();
for (index, disk) in disks
.iter()
.enumerate()
.filter_map(|(index, disk)| disk.as_ref().map(|disk| (index, disk)))
{
if missing_part_disk != Some(index) {
disk.write_all(bucket, &format!("{object}/{data_dir}/part.1"), Bytes::from_static(b"x"))
.await
.expect("part data should be installed on every disk");
}
disk.write_metadata(
bucket,
bucket,
object,
valid_metadata_fanout_fileinfo(bucket, object, version_id, data_dir, mod_time),
)
.await
.expect("metadata should be installed on every disk");
}
}
#[tokio::test]
async fn bounded_metadata_early_stop_ab_hedges_data_get_read_version_fanout() {
const DISKS: usize = 4;
let bucket = "bounded-data-get-fanout-bucket";
let control_object = "bounded-data-get-control-object";
let treatment_object = "bounded-data-get-treatment-object";
let (dirs, disks) = call_counter_local_disks(bucket, DISKS).await;
install_metadata_fanout_fileinfo(&disks, bucket, control_object, None).await;
install_metadata_fanout_fileinfo(&disks, bucket, treatment_object, None).await;
temp_env::async_with_vars(
[
("RUSTFS_GET_METADATA_EARLY_STOP_ENABLE", Some("true")),
("RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE", Some("false")),
("RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT", Some("true")),
],
async {
let calls = disk_call_counters::observe(control_object);
let (_, _, diagnostics) =
SetDisks::read_all_fileinfo_observed(&disks, bucket, bucket, control_object, "", true, false, false, true, 2)
.await
.expect("control metadata should resolve");
assert_eq!(
calls.total(disk_call_counters::KIND_READ_VERSION),
DISKS as u64,
"control path should keep full fanout when data-read early stop is explicitly disabled"
);
assert_eq!(diagnostics.total_responses(), DISKS);
},
)
.await;
temp_env::async_with_vars(
[
("RUSTFS_GET_METADATA_EARLY_STOP_ENABLE", Some("true")),
("RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE", Some("true")),
("RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT", Some("true")),
],
async {
let calls = disk_call_counters::observe(treatment_object);
let (parts_metadata, errs, diagnostics) = SetDisks::read_all_fileinfo_observed(
&disks,
bucket,
bucket,
treatment_object,
"",
true,
false,
false,
true,
2,
)
.await
.expect("healthy object metadata should reach early-stop quorum");
assert!(
(3..=DISKS as u64).contains(&calls.total(disk_call_counters::KIND_READ_VERSION)),
"healthy 2+2 bounded data-read fanout may finish at quorum before a spare hedge is needed"
);
assert!(
(3..=DISKS).contains(&diagnostics.total_responses()),
"treatment path should return after reaching quorum, with at most the spare hedge response observed"
);
assert!(parts_metadata.iter().filter(|fi| fi.name == treatment_object).count() >= 3);
assert!(errs.iter().all(Option::is_none));
},
)
.await;
drop(dirs);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn bounded_data_get_hedges_single_pending_read_version() {
const DISKS: usize = 4;
let bucket = "bounded-data-get-hedge-bucket";
let object = "bounded-data-get-hedge-object";
let (dirs, disks) = call_counter_local_disks(bucket, DISKS).await;
install_metadata_fanout_fileinfo(&disks, bucket, object, None).await;
temp_env::async_with_vars(
[
("RUSTFS_GET_METADATA_EARLY_STOP_ENABLE", Some("true")),
("RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE", Some("true")),
("RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT", Some("true")),
],
async {
let barrier = rename_fanout_barrier::arm(object, 2, rename_fanout_barrier::PHASE_READ_VERSION);
let calls = disk_call_counters::observe(object);
let disks_for_read = disks.clone();
let mut read = tokio::spawn(async move {
SetDisks::read_all_fileinfo_observed(&disks_for_read, bucket, bucket, object, "", true, false, false, true, 2)
.await
});
tokio::time::timeout(BARRIER_PAUSE_GUARD, barrier.wait_until_paused())
.await
.expect("third scheduled read_version should pause at the deterministic barrier");
tokio::time::timeout(BARRIER_PAUSE_GUARD, async {
while calls.for_disk(disk_call_counters::KIND_READ_VERSION, 3) == 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("bounded data-read fanout should hedge by starting the spare disk");
let completed = tokio::time::timeout(BARRIER_PAUSE_GUARD, &mut read).await;
if completed.is_err() {
barrier.release();
}
let (parts_metadata, errs, diagnostics) = completed
.expect("spare metadata should allow early-stop without waiting for the paused disk")
.expect("metadata read task should not panic")
.expect("healthy spare metadata should resolve");
assert_eq!(
calls.total(disk_call_counters::KIND_READ_VERSION),
DISKS as u64,
"bounded data-read fanout should issue the paused disk plus one spare hedge"
);
assert_eq!(diagnostics.total_responses(), 3);
assert_eq!(parts_metadata.iter().filter(|fi| fi.name == object).count(), 3);
assert!(errs.iter().all(Option::is_none));
},
)
.await;
drop(dirs);
}
#[tokio::test]
async fn bounded_metadata_early_stop_defaults_keep_data_get_full_fanout() {
const DISKS: usize = 4;
let bucket = "bounded-data-get-default-bucket";
let object = "bounded-data-get-default-object";
let (dirs, disks) = call_counter_local_disks(bucket, DISKS).await;
install_metadata_fanout_fileinfo(&disks, bucket, object, None).await;
temp_env::async_with_vars(
[
("RUSTFS_GET_METADATA_EARLY_STOP_ENABLE", None::<&str>),
("RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE", None::<&str>),
("RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT", None::<&str>),
],
async {
let calls = disk_call_counters::observe(object);
let (parts_metadata, errs, diagnostics) =
SetDisks::read_all_fileinfo_observed(&disks, bucket, bucket, object, "", true, false, false, true, 2)
.await
.expect("default data-read metadata should resolve");
assert_eq!(
calls.total(disk_call_counters::KIND_READ_VERSION),
DISKS as u64,
"default GET data-read metadata must keep full fanout for read-failure tolerance"
);
assert_eq!(diagnostics.total_responses(), DISKS);
assert_eq!(parts_metadata.iter().filter(|fi| fi.name == object).count(), DISKS);
assert!(errs.iter().all(Option::is_none));
},
)
.await;
drop(dirs);
}
#[tokio::test]
async fn bounded_metadata_early_stop_falls_back_to_full_fanout_on_data_read_error() {
const DISKS: usize = 4;
let bucket = "bounded-data-get-error-bucket";
let object = "bounded-data-get-error-object";
let (dirs, disks) = call_counter_local_disks(bucket, DISKS).await;
install_metadata_fanout_fileinfo(&disks, bucket, object, Some(0)).await;
temp_env::async_with_vars(
[
("RUSTFS_GET_METADATA_EARLY_STOP_ENABLE", Some("true")),
("RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE", Some("true")),
("RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT", Some("true")),
],
async {
let calls = disk_call_counters::observe(object);
let (_, errs, diagnostics) =
SetDisks::read_all_fileinfo_observed(&disks, bucket, bucket, object, "", true, false, false, true, 2)
.await
.expect("metadata fanout should complete after falling back to all disks");
assert_eq!(
calls.total(disk_call_counters::KIND_READ_VERSION),
DISKS as u64,
"a data-read error must force bounded fanout to schedule every disk before returning"
);
assert_eq!(diagnostics.total_responses(), DISKS);
assert!(
errs.iter()
.any(|err| err.as_ref().is_some_and(|err| matches!(err, DiskError::FileNotFound)))
);
},
)
.await;
drop(dirs);
}
/// Bound for the pause handshake. This is a hang-guard, not a timing
/// dependency: under a working barrier `wait_until_paused` returns via the
/// `Notify` handshake far below this bound regardless of IO pressure, so the
@@ -5691,6 +6121,16 @@ mod tests {
assert_eq!(accumulator.candidate_latest_quorum(&impossible_parity), None);
}
#[test]
fn metadata_quorum_accumulator_treats_invalid_default_parity_as_full_fanout() {
let accumulator = MetadataQuorumAccumulator::new(2, 2, true);
assert_eq!(accumulator.default_write_quorum(), 2);
assert_eq!(accumulator.missing_response_quorum(), 2);
assert!(accumulator.can_still_reach_early_stop_with_pending(2));
assert!(!accumulator.can_still_reach_early_stop_with_pending(1));
}
#[test]
fn confirmed_missing_part_error_recognizes_legacy_and_s3_markers() {
assert!(!is_confirmed_missing_part_error(None));
+1 -1
View File
@@ -580,7 +580,7 @@ impl SetDisks {
}
}
pub(super) fn file_info_quorum_hash(meta: &FileInfo) -> [u8; 32] {
pub(crate) fn file_info_quorum_hash(meta: &FileInfo) -> [u8; 32] {
let mut hasher = Sha256::new();
Self::update_file_info_quorum_hash(&mut hasher, meta);
let digest = hasher.finalize();
+113 -11
View File
@@ -672,10 +672,10 @@ const DEFAULT_RUSTFS_GET_SMALL_OBJECT_DIRECT_MEMORY_THRESHOLD: usize = 128 * 102
const ENV_RUSTFS_GET_METADATA_EARLY_STOP_ENABLE: &str = "RUSTFS_GET_METADATA_EARLY_STOP_ENABLE";
// Enabled by default (backlog#872): the early-stop path only engages for
// requests `should_allow_metadata_early_stop` classifies as safe (metadata-only
// reads without version_id / healing / free-version needs) and still requires
// a full read-quorum agreement before stopping. Set the env var to `false` to
// fall back to full-wait metadata fanout.
// requests `should_allow_metadata_early_stop` classifies as safe (latest-version
// metadata-only reads by default, without version_id / healing / free-version
// needs) and still requires a full read-quorum agreement before stopping. Set
// the env var to `false` to fall back to full-wait metadata fanout.
const DEFAULT_RUSTFS_GET_METADATA_EARLY_STOP_ENABLE: bool = true;
const ENV_RUSTFS_GET_METADATA_EARLY_STOP_ROLLOUT_PCT: &str = "RUSTFS_GET_METADATA_EARLY_STOP_ROLLOUT_PCT";
@@ -684,6 +684,12 @@ const DEFAULT_RUSTFS_GET_METADATA_EARLY_STOP_ROLLOUT_PCT: u32 = 100;
const ENV_RUSTFS_GET_METADATA_VERSION_EARLY_STOP_ENABLE: &str = "RUSTFS_GET_METADATA_VERSION_EARLY_STOP_ENABLE";
const DEFAULT_RUSTFS_GET_METADATA_VERSION_EARLY_STOP_ENABLE: bool = false;
const ENV_RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE: &str = "RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE";
const DEFAULT_RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE: bool = false;
const ENV_RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT: &str = "RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT";
const DEFAULT_RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT: bool = false;
// --- Multipart Reader-Setup Prefetch Configuration (backlog#870) ---
const ENV_RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH: &str = "RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH";
@@ -692,6 +698,8 @@ const DEFAULT_RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH: bool = true;
static OBJECT_LOCK_DIAG_ENABLED: OnceLock<bool> = OnceLock::new();
mod core;
#[cfg(test)]
pub(crate) use core::io_primitives::disk_call_counters;
mod ctx;
mod metadata;
mod ops;
@@ -702,8 +710,8 @@ pub(crate) use ops::object::TransitionCleanupStoreBarrier as SetDiskTransitionCl
pub(crate) use ops::object::body_cache_plaintext_len;
#[cfg(test)]
pub(crate) use ops::object::cleanup_rejected_transition_upload_durably;
#[cfg(test)]
pub(crate) use ops::object::{PutObjectCommitBarrier, PutObjectCommitPause};
#[cfg(any(test, feature = "test-util"))]
pub use ops::object::{PutObjectCommitBarrier, PutObjectCommitPause};
mod read;
mod replication;
pub(crate) mod shard_source;
@@ -731,6 +739,10 @@ impl PreparedGetObjectMetadata {
.take()
.expect("prepared GET metadata ObjectInfo must be consumed exactly once")
}
pub(crate) fn read_semantics_identity(&self) -> [u8; 32] {
SetDisks::file_info_quorum_hash(&self.fi)
}
}
tokio::task_local! {
@@ -824,11 +836,8 @@ mod prepared_get_object_metadata_tests {
.prepare_get_object_metadata(bucket, object, &opts)
.await
.expect("prepared metadata should resolve");
assert_eq!(
calls.total(disk_call_counters::KIND_READ_VERSION),
4,
"preparation should fan out to each online disk exactly once"
);
let prepared_calls = calls.total(disk_call_counters::KIND_READ_VERSION);
assert_eq!(prepared_calls, 4, "default prepared GET metadata should keep full data-read fanout");
let mut reader = set_disks
.get_object_reader_with_prepared_metadata(bucket, object, None, HeaderMap::new(), &opts, metadata)
@@ -1188,6 +1197,46 @@ fn is_version_early_stop_enabled() -> bool {
}
}
fn is_get_metadata_data_read_early_stop_enabled() -> bool {
#[cfg(test)]
{
rustfs_utils::get_env_bool(
ENV_RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE,
DEFAULT_RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE,
)
}
#[cfg(not(test))]
{
static CACHED: OnceLock<bool> = OnceLock::new();
*CACHED.get_or_init(|| {
rustfs_utils::get_env_bool(
ENV_RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE,
DEFAULT_RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE,
)
})
}
}
fn is_get_metadata_early_stop_bounded_fanout_enabled() -> bool {
#[cfg(test)]
{
rustfs_utils::get_env_bool(
ENV_RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT,
DEFAULT_RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT,
)
}
#[cfg(not(test))]
{
static CACHED: OnceLock<bool> = OnceLock::new();
*CACHED.get_or_init(|| {
rustfs_utils::get_env_bool(
ENV_RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT,
DEFAULT_RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT,
)
})
}
}
/// Check if multipart reads prefetch the next part's bitrot reader setup
/// while the current part decodes (backlog#870).
///
@@ -2553,6 +2602,53 @@ impl SetDisks {
))
}
#[cfg(any(test, feature = "test-util"))]
async fn acquire_write_lock_diag_with_pending_hook(
&self,
op: &'static str,
bucket: &str,
object: &str,
on_pending: impl FnOnce(),
) -> Result<ObjectLockDiagGuard> {
crate::hp_guard!("SetDisks::acquire_write_lock");
let diag_enabled = is_object_lock_diag_enabled();
let ns_lock = self.new_ns_lock(bucket, object).await?;
let acquire_start = Instant::now();
let acquire = ns_lock.get_write_lock(get_lock_acquire_timeout());
tokio::pin!(acquire);
let mut on_pending = Some(on_pending);
let guard = futures::future::poll_fn(|cx| match std::future::Future::poll(acquire.as_mut(), cx) {
std::task::Poll::Pending => {
if let Some(on_pending) = on_pending.take() {
on_pending();
}
std::task::Poll::Pending
}
std::task::Poll::Ready(result) => std::task::Poll::Ready(result),
})
.await
.map_err(|e| self.map_namespace_lock_error(bucket, object, "write", e))?;
let owner = diag_enabled.then(|| ns_lock.owner().to_string());
self.log_object_lock_acquire_if_slow(
op,
bucket,
object,
"write",
owner.as_deref(),
acquire_start.elapsed(),
diag_enabled,
);
Ok(ObjectLockDiagGuard::new(
guard,
diag_enabled,
op,
diag_enabled.then(|| bucket.to_string()),
diag_enabled.then(|| object.to_string()),
owner,
"write",
))
}
#[allow(clippy::too_many_arguments)]
fn log_object_lock_acquire_if_slow(
&self,
@@ -7391,6 +7487,12 @@ mod tests {
let (should_heal, _, _) = should_heal_object_on_disk(&err, &[], &meta, &latest_meta);
assert!(should_heal);
let err = Some(DiskError::FileCorrupt);
let (should_heal, is_meta, reason) = should_heal_object_on_disk(&err, &[], &meta, &latest_meta);
assert!(should_heal);
assert!(is_meta);
assert_eq!(reason, Some(DiskError::FileCorrupt));
// Test with no error and no part errors
let (should_heal, _, _) = should_heal_object_on_disk(&None, &[CHECK_PART_SUCCESS], &meta, &latest_meta);
assert!(!should_heal);
+267 -16
View File
@@ -331,6 +331,85 @@ fn warn_heal_writer_failures(
}
impl SetDisks {
/// Read back one healed version from every explicitly admitted replacement
/// target. This is intentionally separate from the normal heal result: a
/// successful result describes the transaction attempt, while automatic
/// replacement completion needs physical evidence that survives a crash
/// before its checkpoint is persisted.
pub(crate) async fn replacement_targets_have_version(
&self,
bucket: &str,
object: &str,
version_id: &str,
targets: &[String],
) -> disk::error::Result<bool> {
let disks = self.get_disks_internal().await;
let mut target_disks = Vec::with_capacity(targets.len());
for target in targets {
let Some(index) = self.set_endpoints.iter().position(|endpoint| endpoint.to_string() == *target) else {
return Ok(false);
};
let Some(disk) = disks.get(index).and_then(Option::as_ref) else {
return Ok(false);
};
target_disks.push(disk.clone());
}
let read_options = ReadOptions {
incl_free_versions: false,
read_data: true,
healing: true,
};
let checks = target_disks.into_iter().map(|disk| {
let read_options = read_options.clone();
async move {
let file_info = match disk.read_version("", bucket, object, version_id, &read_options).await {
Ok(file_info) => file_info,
Err(
DiskError::DiskNotFound
| DiskError::VolumeNotFound
| DiskError::FileNotFound
| DiskError::FileVersionNotFound
| DiskError::PathNotFound,
) => return Ok(false),
Err(err) => return Err(err),
};
if !file_info_is_valid_for_metadata(&file_info) {
return Ok(false);
}
if !version_id.is_empty() && file_info.version_id.as_ref().map(ToString::to_string).as_deref() != Some(version_id)
{
return Ok(false);
}
if file_info.is_canonical_delete_marker() || file_info.is_remote() {
return Ok(true);
}
if (file_info.data.is_some() || file_info.size == 0) && !file_info.parts.is_empty() {
return Ok(true);
}
let check = match disk.check_parts(bucket, object, &file_info).await {
Ok(check) => check,
Err(
DiskError::DiskNotFound
| DiskError::VolumeNotFound
| DiskError::FileNotFound
| DiskError::FileVersionNotFound
| DiskError::PathNotFound,
) => return Ok(false),
Err(err) => return Err(err),
};
Ok(!check.results.is_empty() && check.results.iter().all(|result| *result == CHECK_PART_SUCCESS))
}
});
Ok(futures::future::try_join_all(checks)
.await?
.into_iter()
.all(|committed| committed))
}
#[tracing::instrument(level = "trace", skip(self, opts), fields(bucket = %bucket, object = %object, version_id = %version_id))]
pub(in crate::set_disk) async fn heal_object(
&self,
@@ -1711,19 +1790,35 @@ impl SetDisks {
}
}
// Heal operation family: the storage-api `HealOperations` contract stays
// implemented `for SetDisks` (contract bounds unchanged) but now lives beside
// its inherent helpers in the `set_disk::ops::heal` module. Bodies are moved
// unchanged; `get_pool_and_set` reads the core through `SetDisksCtx` to keep
// the Heal family aligned with the borrow pattern from #816.
#[async_trait::async_trait]
impl crate::storage_api_contracts::heal::HealOperations for SetDisks {
type Error = Error;
type HealResultItem = HealResultItem;
type HealOptions = HealOpts;
impl SetDisks {
pub(crate) async fn heal_replacement_format(
&self,
dry_run: bool,
targets: &[String],
) -> Result<(HealResultItem, Option<Error>)> {
if targets.is_empty() {
return Err(Error::other("replacement format requires at least one target"));
}
#[tracing::instrument(skip(self))]
async fn heal_format(&self, dry_run: bool) -> Result<(HealResultItem, Option<Error>)> {
let mut target_slots = Vec::with_capacity(targets.len());
for target in targets {
let Some(slot) = self.set_endpoints.iter().position(|endpoint| endpoint.to_string() == *target) else {
return Err(Error::other("replacement format target does not belong to the set"));
};
if target_slots.contains(&slot) {
return Err(Error::other("replacement format target is duplicated"));
}
target_slots.push(slot);
}
self.heal_format_for_slots(dry_run, Some(&target_slots)).await
}
async fn heal_format_for_slots(
&self,
dry_run: bool,
target_slots: Option<&[usize]>,
) -> Result<(HealResultItem, Option<Error>)> {
let disks = self.disks.read().await.clone();
let (formats, errs) = load_format_erasure_all(&disks, true).await;
if errs.iter().any(|err| {
@@ -1785,21 +1880,43 @@ impl crate::storage_api_contracts::heal::HealOperations for SetDisks {
if !dry_run {
for (disk_idx, err) in errs.iter().enumerate() {
if !matches!(err, Some(DiskError::UnformattedDisk)) {
if !matches!(err, Some(DiskError::UnformattedDisk))
|| target_slots.is_some_and(|slots| !slots.contains(&disk_idx))
{
continue;
}
let mut new_format = ref_format.clone();
new_format.erasure.this = ref_format.erasure.sets[self.set_index][disk_idx];
if save_format_file(&disks[disk_idx], &Some(new_format.clone())).await.is_ok() {
result.after.drives[disk_idx].uuid = new_format.erasure.this.to_string();
result.after.drives[disk_idx].state = DriveState::Ok.to_string();
match save_format_file(&disks[disk_idx], &Some(new_format.clone())).await {
Ok(()) => {
result.after.drives[disk_idx].uuid = new_format.erasure.this.to_string();
result.after.drives[disk_idx].state = DriveState::Ok.to_string();
}
Err(err) => return Ok((result, Some(err.into()))),
}
}
}
Ok((result, None))
}
}
// Heal operation family: the storage-api `HealOperations` contract stays
// implemented `for SetDisks` (contract bounds unchanged) but now lives beside
// its inherent helpers in the `set_disk::ops::heal` module. Bodies are moved
// unchanged; `get_pool_and_set` reads the core through `SetDisksCtx` to keep
// the Heal family aligned with the borrow pattern from #816.
#[async_trait::async_trait]
impl crate::storage_api_contracts::heal::HealOperations for SetDisks {
type Error = Error;
type HealResultItem = HealResultItem;
type HealOptions = HealOpts;
#[tracing::instrument(skip(self))]
async fn heal_format(&self, dry_run: bool) -> Result<(HealResultItem, Option<Error>)> {
self.heal_format_for_slots(dry_run, None).await
}
#[tracing::instrument(skip(self))]
async fn heal_bucket(&self, bucket: &str, opts: &HealOpts) -> Result<HealResultItem> {
@@ -2397,6 +2514,140 @@ mod heal_result_report_tests {
}
}
#[tokio::test]
async fn replacement_target_readback_requires_the_committed_shard() {
let (temp_dirs, disks, set) = hermetic_set_disks_isolated(4).await;
let bucket = "replacement-target-readback";
let object = "object.bin";
for disk in &disks {
disk.make_volume(bucket).await.expect("bucket volume should be created");
}
let mut reader = PutObjReader::from_vec(vec![0x5a; 1024 * 1024]);
set.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("source object should be written");
let source = disks[2]
.read_version("", bucket, object, "", &ReadOptions::default())
.await
.expect("source metadata should be readable");
let data_dir = source.data_dir.expect("non-inline source should have a data directory");
let targets = vec![set.set_endpoints[0].to_string(), set.set_endpoints[1].to_string()];
assert!(
set.replacement_targets_have_version(bucket, object, "", &targets)
.await
.expect("healthy target shards should be readable")
);
tokio::fs::remove_file(
temp_dirs[1]
.path()
.join(bucket)
.join(object)
.join(data_dir.to_string())
.join("part.1"),
)
.await
.expect("target shard should be removed after the initial commit");
assert!(
!set.replacement_targets_have_version(bucket, object, "", &targets)
.await
.expect("missing target shard should be observable")
);
}
#[tokio::test]
async fn replacement_target_readback_checks_the_requested_historical_version() {
let (temp_dirs, disks, set) = hermetic_set_disks_isolated(4).await;
let bucket = "replacement-target-readback-versioned";
let object = "object.bin";
set.make_bucket(
bucket,
&MakeBucketOptions {
versioning_enabled: true,
..Default::default()
},
)
.await
.expect("versioned bucket should be created");
let mut old_reader = PutObjReader::from_vec(vec![0x5a; 1024 * 1024]);
let old_info = set
.put_object(
bucket,
object,
&mut old_reader,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("old object version should be written");
let old_version = old_info
.version_id
.expect("versioned put should return the old version id")
.to_string();
let mut latest_reader = PutObjReader::from_vec(vec![0x33; 1024 * 1024]);
let latest_info = set
.put_object(
bucket,
object,
&mut latest_reader,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("latest object version should be written");
let latest_version = latest_info
.version_id
.expect("versioned put should return the latest version id")
.to_string();
let old_source = disks[2]
.read_version("", bucket, object, &old_version, &ReadOptions::default())
.await
.expect("old version metadata should be readable");
let old_data_dir = old_source.data_dir.expect("old version should have a data directory");
let targets = vec![set.set_endpoints[0].to_string(), set.set_endpoints[1].to_string()];
assert!(
set.replacement_targets_have_version(bucket, object, &old_version, &targets)
.await
.expect("healthy historical target shards should be readable")
);
assert!(
set.replacement_targets_have_version(bucket, object, &latest_version, &targets)
.await
.expect("healthy latest target shards should be readable")
);
tokio::fs::remove_file(
temp_dirs[1]
.path()
.join(bucket)
.join(object)
.join(old_data_dir.to_string())
.join("part.1"),
)
.await
.expect("old target shard should be removed after the initial commit");
assert!(
!set.replacement_targets_have_version(bucket, object, &old_version, &targets)
.await
.expect("missing old target shard should be observable")
);
assert!(
set.replacement_targets_have_version(bucket, object, &latest_version, &targets)
.await
.expect("latest target evidence should remain independent")
);
}
#[tokio::test]
async fn format_heal_cached_layout_rejects_a_disk_from_another_slot() {
let mut _temp_dirs = Vec::new();
+104 -7
View File
@@ -1897,6 +1897,13 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
}
}
// The SSE-C passthrough session marker is upload-scoped; drop it from
// the completed object's metadata.
rustfs_utils::http::metadata_compat::remove_str(
&mut fi.metadata,
rustfs_utils::http::SUFFIX_REPLICATION_PRESERVE_CIPHERTEXT,
);
if checksum_type.is_set() {
checksum_type
.merge(rustfs_rio::ChecksumType::MULTIPART)
@@ -1919,13 +1926,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
}
// etag
let etag = {
if let Some(etag) = opts.user_defined.get("etag") {
etag.clone()
} else {
get_complete_multipart_md5(&uploaded_parts)
}
};
let etag = resolve_complete_etag(opts, &uploaded_parts);
fi.metadata.insert("etag".to_owned(), etag);
@@ -2167,6 +2168,21 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
}
}
/// Final ETag for a completed multipart object. An authorized replication
/// request preserves the source ETag so the replication HEAD comparison
/// converges even when the source ETag is not derivable from the uploaded
/// parts (foreign-origin objects, ciphertext-derived ETags); the internal
/// metadata override comes next; otherwise the ETag is computed from parts.
fn resolve_complete_etag(opts: &ObjectOptions, uploaded_parts: &[CompletePart]) -> String {
if let Some(etag) = opts.preserve_etag.as_ref().filter(|etag| !etag.is_empty()) {
return etag.clone();
}
if let Some(etag) = opts.user_defined.get("etag") {
return etag.clone();
}
get_complete_multipart_md5(uploaded_parts)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -3177,6 +3193,63 @@ mod tests {
);
}
#[tokio::test]
async fn put_object_part_recovers_transaction_with_one_faulty_disk_at_write_quorum() {
use tokio::io::AsyncReadExt as _;
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks_for_pool_with_default_parity(4, 0, 2).await;
assert_eq!(set_disks.default_read_quorum(), 2);
assert_eq!(set_disks.default_write_quorum(), 3);
let bucket = "multipart-degraded-upload-part-bucket";
let object = "object";
make_bucket_on_all(&disk_stores, bucket).await;
let upload = set_disks
.new_multipart_upload(bucket, object, &ObjectOptions::default())
.await
.expect("multipart upload should be created before the disk fault");
disk_stores[0]
.set_disk_id_state(Some(Uuid::new_v4()))
.await
.expect("test should mark one disk stale");
let payload = vec![0x5b; 4096];
let mut reader = PutObjReader::from_vec(payload.clone());
let part = set_disks
.put_object_part(bucket, object, &upload.upload_id, 1, &mut reader, &ObjectOptions::default())
.await
.expect("upload part should commit with exactly write quorum healthy disks");
set_disks
.clone()
.complete_multipart_upload(
bucket,
object,
&upload.upload_id,
vec![CompletePart {
part_num: part.part_num,
etag: part.etag,
..Default::default()
}],
&ObjectOptions::default(),
)
.await
.expect("completion should settle the write-quorum part");
let mut object_reader = set_disks
.get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("completed object should be readable through read quorum");
let mut restored = Vec::new();
object_reader
.stream
.read_to_end(&mut restored)
.await
.expect("completed object should stream fully");
assert_eq!(restored, payload);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn put_object_part_rechecks_upload_after_commit_lock() {
@@ -5133,4 +5206,28 @@ mod tests {
assert_eq!(body_after, new, "reclaiming the leftover upload must not disturb the committed object");
}
}
#[test]
fn resolve_complete_etag_prefers_preserved_source_etag() {
// A replication-preserved ETag that no part combination can derive
// (foreign-origin object) must win over the computed md5-of-parts.
let foreign_etag = "11111111111111111111111111111111-7".to_string();
let opts = ObjectOptions {
preserve_etag: Some(foreign_etag.clone()),
..Default::default()
};
assert_eq!(resolve_complete_etag(&opts, &[]), foreign_etag);
// Empty preserve value degrades to the next source.
let opts_empty = ObjectOptions {
preserve_etag: Some(String::new()),
user_defined: std::collections::HashMap::from([("etag".to_string(), "override-etag".to_string())]),
..Default::default()
};
assert_eq!(resolve_complete_etag(&opts_empty, &[]), "override-etag");
// Without either source the ETag is computed from the parts.
let computed = resolve_complete_etag(&ObjectOptions::default(), &[]);
assert_eq!(computed, get_complete_multipart_md5(&[]));
}
}
+65 -16
View File
@@ -1233,6 +1233,16 @@ impl SetDisks {
}
}
// SSE-C replication carries the source object's sealed checksum
// out of band; store it verbatim like the multipart path does.
if let Some(cssum) =
rustfs_utils::http::get_header_map(&user_defined, rustfs_utils::http::SUFFIX_REPLICATION_SSEC_CRC)
&& !cssum.is_empty()
{
fi.checksum = base64_simd::STANDARD.decode_to_vec(&cssum).ok().map(bytes::Bytes::from);
rustfs_utils::http::remove_header_map(&mut user_defined, rustfs_utils::http::SUFFIX_REPLICATION_SSEC_CRC);
}
if fi.checksum.is_none()
&& let Some(content_hash) = data.as_hash_reader().content_hash()
{
@@ -1312,7 +1322,7 @@ impl SetDisks {
}
if !opts.no_lock && object_lock_guard.is_none() {
#[cfg(test)]
#[cfg(any(test, feature = "test-util"))]
pause_put_object_commit(bucket, object, PutObjectCommitPause::BeforeNamespace).await;
if let Some(expected_incarnation_id) = opts.expected_bucket_incarnation_id
&& opts.bucket_lifecycle_lock_fence.is_none()
@@ -1324,9 +1334,21 @@ impl SetDisks {
.await?,
);
}
object_lock_guard = Some(self.acquire_write_lock_diag("put_object_commit", bucket, object).await?);
#[cfg(any(test, feature = "test-util"))]
{
object_lock_guard = Some(
self.acquire_write_lock_diag_with_pending_hook("put_object_commit", bucket, object, || {
notify_put_object_commit_namespace_pending(bucket, object);
})
.await?,
);
}
#[cfg(not(any(test, feature = "test-util")))]
{
object_lock_guard = Some(self.acquire_write_lock_diag("put_object_commit", bucket, object).await?);
}
}
#[cfg(test)]
#[cfg(any(test, feature = "test-util"))]
pause_put_object_commit(bucket, object, PutObjectCommitPause::AfterNamespace).await;
if deferred_data_movement_precondition && let Some(err) = self.check_write_precondition(bucket, object, opts).await {
@@ -2575,41 +2597,43 @@ fn remote_version_state_writer_enabled_for(requested: bool, fleet_confirmed: boo
requested && fleet_confirmed && fleet_proof_valid
}
#[cfg(test)]
#[cfg(any(test, feature = "test-util"))]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum PutObjectCommitPause {
pub enum PutObjectCommitPause {
BeforeNamespace,
AfterNamespace,
BeforeMetadata,
}
#[cfg(test)]
#[cfg(any(test, feature = "test-util"))]
struct PutObjectCommitBarrierState {
bucket: String,
object: String,
pause: PutObjectCommitPause,
arrived: tokio::sync::Notify,
release: tokio::sync::Notify,
namespace_pending: tokio::sync::Notify,
}
#[cfg(test)]
pub(crate) struct PutObjectCommitBarrier {
#[cfg(any(test, feature = "test-util"))]
pub struct PutObjectCommitBarrier {
state: Arc<PutObjectCommitBarrierState>,
}
#[cfg(test)]
#[cfg(any(test, feature = "test-util"))]
static PUT_OBJECT_COMMIT_BARRIER: std::sync::OnceLock<std::sync::Mutex<Vec<Arc<PutObjectCommitBarrierState>>>> =
std::sync::OnceLock::new();
#[cfg(test)]
#[cfg(any(test, feature = "test-util"))]
impl PutObjectCommitBarrier {
pub(crate) fn install(bucket: &str, object: &str, pause: PutObjectCommitPause) -> Self {
pub fn install(bucket: &str, object: &str, pause: PutObjectCommitPause) -> Self {
let state = Arc::new(PutObjectCommitBarrierState {
bucket: bucket.to_string(),
object: object.to_string(),
pause,
arrived: tokio::sync::Notify::new(),
release: tokio::sync::Notify::new(),
namespace_pending: tokio::sync::Notify::new(),
});
let mut slot = PUT_OBJECT_COMMIT_BARRIER
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
@@ -2626,18 +2650,27 @@ impl PutObjectCommitBarrier {
Self { state }
}
pub(crate) async fn wait_until_paused(&self) {
pub async fn wait_until_paused(&self) {
tokio::time::timeout(Duration::from_secs(30), self.state.arrived.notified())
.await
.expect("put object should reach the deterministic commit barrier");
}
pub(crate) fn release(&self) {
pub fn release(&self) {
self.state.release.notify_one();
}
pub async fn release_and_wait_until_namespace_pending(&self) {
assert_eq!(self.state.pause, PutObjectCommitPause::BeforeNamespace);
let namespace_pending = self.state.namespace_pending.notified();
self.release();
tokio::time::timeout(Duration::from_secs(5), namespace_pending)
.await
.expect("put object should wait for the namespace lock after leaving the commit barrier");
}
}
#[cfg(test)]
#[cfg(any(test, feature = "test-util"))]
impl Drop for PutObjectCommitBarrier {
fn drop(&mut self) {
self.state.release.notify_one();
@@ -2649,7 +2682,7 @@ impl Drop for PutObjectCommitBarrier {
}
}
#[cfg(test)]
#[cfg(any(test, feature = "test-util"))]
async fn pause_put_object_commit(bucket: &str, object: &str, pause: PutObjectCommitPause) {
let barrier = PUT_OBJECT_COMMIT_BARRIER
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
@@ -2664,6 +2697,22 @@ async fn pause_put_object_commit(bucket: &str, object: &str, pause: PutObjectCom
}
}
#[cfg(any(test, feature = "test-util"))]
fn notify_put_object_commit_namespace_pending(bucket: &str, object: &str) {
let barrier = PUT_OBJECT_COMMIT_BARRIER
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
.lock()
.expect("put object commit barrier mutex should not poison")
.iter()
.find(|barrier| {
barrier.bucket == bucket && barrier.object == object && barrier.pause == PutObjectCommitPause::BeforeNamespace
})
.cloned();
if let Some(barrier) = barrier {
barrier.namespace_pending.notify_one();
}
}
#[cfg(test)]
struct DeleteObjectCommitBarrierState {
bucket: String,
@@ -4414,7 +4463,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
self.invalidate_get_object_metadata_cache(bucket, object).await;
// Guard lock for metadata update
#[cfg(test)]
#[cfg(any(test, feature = "test-util"))]
pause_put_object_commit(bucket, object, PutObjectCommitPause::BeforeMetadata).await;
let _lock_guard = if !opts.no_lock {
Some(self.acquire_write_lock_diag("put_object_metadata", bucket, object).await?)
+95 -3
View File
@@ -48,6 +48,7 @@ use metrics::counter;
use std::{
collections::{HashMap, VecDeque},
future::Future,
io::IoSlice,
pin::Pin,
sync::OnceLock,
task::{Context, Poll},
@@ -76,6 +77,16 @@ impl<W: AsyncWrite + Unpin> AsyncWrite for GetObjectDownstreamWriter<W> {
.map(|result| result.map_err(mark_get_object_downstream_closed))
}
fn poll_write_vectored(mut self: Pin<&mut Self>, cx: &mut Context<'_>, bufs: &[IoSlice<'_>]) -> Poll<std::io::Result<usize>> {
Pin::new(&mut self.inner)
.poll_write_vectored(cx, bufs)
.map(|result| result.map_err(mark_get_object_downstream_closed))
}
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<()>> {
Pin::new(&mut self.inner)
.poll_flush(cx)
@@ -3101,7 +3112,7 @@ mod metadata_cache_tests {
mod tests {
use super::*;
use crate::erasure::coding::BitrotWriter;
use std::io::{Cursor, ErrorKind};
use std::io::{Cursor, ErrorKind, IoSlice};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
@@ -3128,6 +3139,63 @@ mod tests {
);
}
#[tokio::test]
async fn downstream_writer_preserves_vectored_write_support() {
#[derive(Default)]
struct VectoredSink {
writes: usize,
vectored_writes: usize,
bytes: Vec<u8>,
}
impl AsyncWrite for VectoredSink {
fn poll_write(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &[u8]) -> Poll<std::io::Result<usize>> {
self.writes += 1;
self.bytes.extend_from_slice(buf);
Poll::Ready(Ok(buf.len()))
}
fn poll_write_vectored(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
bufs: &[IoSlice<'_>],
) -> Poll<std::io::Result<usize>> {
self.vectored_writes += 1;
let mut written = 0;
for buf in bufs {
written += buf.len();
self.bytes.extend_from_slice(buf);
}
Poll::Ready(Ok(written))
}
fn is_write_vectored(&self) -> bool {
true
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
}
let mut writer = GetObjectDownstreamWriter::new(VectoredSink::default());
assert!(writer.is_write_vectored(), "downstream writer must preserve vectored-write capability");
let written = writer
.write_vectored(&[IoSlice::new(b"hello "), IoSlice::new(b"world")])
.await
.expect("vectored write through downstream adapter must succeed");
assert_eq!(written, 11);
assert_eq!(writer.inner.vectored_writes, 1);
assert_eq!(writer.inner.writes, 0);
assert_eq!(writer.inner.bytes, b"hello world");
}
async fn local_test_disks(count: usize, bucket: &str) -> (Vec<tempfile::TempDir>, Vec<Option<crate::disk::DiskStore>>) {
let mut dirs = Vec::with_capacity(count);
let mut disks = Vec::with_capacity(count);
@@ -3766,18 +3834,42 @@ mod tests {
assert!(metadata_early_stop_permitted(true, true, false, "", false, false));
// observe=false (non-observed fanout) also disables early-stop.
assert!(!metadata_early_stop_permitted(true, false, false, "", false, false));
// Data reads are never eligible regardless of caller opt-in.
assert!(!metadata_early_stop_permitted(true, true, true, "", false, false));
},
);
}
#[test]
fn metadata_early_stop_rejects_data_reads() {
fn metadata_early_stop_keeps_data_reads_opt_in_by_default() {
temp_env::with_vars(
[
(ENV_RUSTFS_GET_METADATA_EARLY_STOP_ENABLE, Some("true")),
(ENV_RUSTFS_GET_METADATA_VERSION_EARLY_STOP_ENABLE, None),
(ENV_RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE, None),
],
|| {
assert!(!should_allow_metadata_early_stop(true, "", false, false));
assert!(!should_allow_metadata_early_stop(true, "version-id", false, false));
assert!(should_allow_metadata_early_stop(false, "", false, false));
assert!(!should_allow_metadata_early_stop(false, "version-id", false, false));
},
);
temp_env::with_vars(
[
(ENV_RUSTFS_GET_METADATA_EARLY_STOP_ENABLE, Some("true")),
(ENV_RUSTFS_GET_METADATA_VERSION_EARLY_STOP_ENABLE, Some("true")),
(ENV_RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE, Some("true")),
],
|| {
assert!(should_allow_metadata_early_stop(true, "", false, false));
assert!(should_allow_metadata_early_stop(true, "version-id", false, false));
},
);
temp_env::with_vars(
[
(ENV_RUSTFS_GET_METADATA_EARLY_STOP_ENABLE, Some("true")),
(ENV_RUSTFS_GET_METADATA_VERSION_EARLY_STOP_ENABLE, Some("true")),
(ENV_RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE, Some("false")),
],
|| {
assert!(!should_allow_metadata_early_stop(true, "", false, false));
@@ -27,7 +27,10 @@ pub(crate) mod internode {
NS_SCANNER_BODY_SHA256_QUERY, NS_SCANNER_CAPABILITY_CHALLENGE_QUERY, NS_SCANNER_CYCLE_QUERY,
NS_SCANNER_LEADER_EPOCH_QUERY, NS_SCANNER_PROTOCOL_VERSION, NS_SCANNER_PROTOCOL_VERSION_QUERY,
NS_SCANNER_REQUEST_ID_QUERY, NS_SCANNER_SERVER_EPOCH_QUERY, NS_SCANNER_SESSION_ID_QUERY,
NS_SCANNER_SESSION_SEQUENCE_QUERY, NsScannerCapabilityResponse, SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION,
NS_SCANNER_SESSION_SEQUENCE_QUERY, NsScannerCapabilityResponse, PUT_FILE_AUTH_QUERY, PUT_FILE_AUTH_TRAILER_DIGEST_LEN,
PUT_FILE_AUTH_TRAILER_LEN, PUT_FILE_AUTH_TRAILER_MAC_LEN, PUT_FILE_AUTH_TRAILER_MAGIC, PUT_FILE_AUTH_V1,
PUT_FILE_CAPABILITY_CHALLENGE_QUERY, PUT_FILE_CAPABILITY_QUERY, PUT_FILE_CAPABILITY_VERSION, PUT_FILE_NONCE_QUERY,
PUT_FILE_SERVER_EPOCH_QUERY, PutFileCapabilityResponse, SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION,
SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION, SCANNER_ACTIVITY_PROTOCOL_VERSION, WALK_DIR_BODY_SHA256_QUERY,
WALK_DIR_STREAM_COMPLETION_QUERY, WALK_DIR_STREAM_COMPLETION_V1,
};
+13 -7
View File
@@ -52,7 +52,10 @@ fn validate_table_bucket_delete_allowed(
async fn table_catalog_metadata_exists(ctx: &crate::runtime::instance::InstanceContext, bucket: &str) -> Result<bool> {
let local_disks = runtime_sources::local_disks_in(ctx).await;
for disk in local_disks.iter() {
let catalog_path = disk.path().join(bucket).join(BUCKET_TABLE_RESERVED_PREFIX);
let Some(bucket_path) = disk.get_bucket_path_for_io_if_local(bucket) else {
continue;
};
let catalog_path = bucket_path?.join(BUCKET_TABLE_RESERVED_PREFIX);
if has_xlmeta_files(&catalog_path).await? {
return Ok(true);
}
@@ -197,8 +200,8 @@ impl ECStore {
registry: self.bucket_fence_registry.clone(),
inner,
};
let memoized = pieces.enter(bucket);
let current = match memoized {
let registration = pieces.enter(bucket);
let current = match registration.memoized {
Some(current) => current,
None => match metadata_sys::get_bucket_incarnation_id_in(&self.ctx, bucket).await {
Ok(current) => {
@@ -210,16 +213,16 @@ impl ECStore {
current
}
Err(err) => {
pieces.abandon(bucket);
pieces.abandon(bucket, registration.token);
return Err(err);
}
},
};
if current != expected {
pieces.abandon(bucket);
pieces.abandon(bucket, registration.token);
return Err(StorageError::BucketNotFound(bucket.to_string()));
}
Ok(pieces.into_guard(bucket))
Ok(pieces.into_guard(bucket, registration.token))
}
pub(crate) async fn acquire_bucket_lifecycle_write_lock(&self, bucket: &str) -> Result<rustfs_lock::NamespaceLockGuard> {
@@ -727,7 +730,10 @@ impl ECStore {
if !opts.force {
let local_disks = runtime_sources::local_disks_in(&self.ctx).await;
for disk in local_disks.iter() {
let bucket_path = disk.path().join(bucket);
let Some(bucket_path) = disk.get_bucket_path_for_io_if_local(bucket) else {
continue;
};
let bucket_path = bucket_path?;
if has_xlmeta_files(&bucket_path).await? {
return Err(StorageError::BucketNotEmpty(bucket.to_string()));
}
+183 -33
View File
@@ -44,14 +44,51 @@ use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use rustfs_lock::NamespaceLockGuard;
use rustfs_lock::distributed_lock::LockLostSignal;
use uuid::Uuid;
#[derive(Default)]
struct FenceEntry {
guards: usize,
next_token: u64,
guards: Vec<RegisteredGuard>,
validated: Option<Uuid>,
}
struct RegisteredGuard {
token: u64,
loss_probe: LockLossProbe,
}
enum LockLossProbe {
Distributed(Arc<LockLostSignal>),
Local,
#[cfg(test)]
Test(Arc<std::sync::atomic::AtomicBool>),
}
impl LockLossProbe {
fn from_guard(guard: &NamespaceLockGuard) -> Self {
match guard.lock_lost_signal() {
Some(signal) => Self::Distributed(signal),
None => Self::Local,
}
}
fn is_lost(&self) -> bool {
match self {
Self::Distributed(signal) => signal.is_lost(),
Self::Local => false,
#[cfg(test)]
Self::Test(lost) => lost.load(std::sync::atomic::Ordering::SeqCst),
}
}
}
pub(super) struct FenceRegistration {
pub(super) token: u64,
pub(super) memoized: Option<Uuid>,
}
/// Per-store registry tracking, per bucket, how many lifecycle read guards are
/// live on this node and the incarnation id validated under that coverage.
#[derive(Default)]
@@ -62,11 +99,20 @@ pub(crate) struct BucketFenceRegistry {
impl BucketFenceRegistry {
/// Register a new live guard for `bucket` and return the memoized
/// incarnation id if one is valid for the current coverage window.
fn enter(&self, bucket: &str) -> Option<Uuid> {
fn enter(&self, bucket: &str, loss_probe: LockLossProbe) -> FenceRegistration {
let mut entries = self.entries.lock().expect("bucket fence registry poisoned");
let entry = entries.entry(bucket.to_string()).or_default();
entry.guards += 1;
entry.validated
let token = entry.next_token;
entry.next_token = entry.next_token.wrapping_add(1);
entry.guards.push(RegisteredGuard { token, loss_probe });
let has_lost_guard = entry.guards.iter().any(|guard| guard.loss_probe.is_lost());
if has_lost_guard {
entry.validated = None;
}
FenceRegistration {
token,
memoized: if has_lost_guard { None } else { entry.validated },
}
}
/// Memoize `incarnation` for `bucket`. Only meaningful while the caller
@@ -74,23 +120,27 @@ impl BucketFenceRegistry {
fn memoize(&self, bucket: &str, incarnation: Uuid) {
let mut entries = self.entries.lock().expect("bucket fence registry poisoned");
if let Some(entry) = entries.get_mut(bucket)
&& entry.guards > 0
&& !entry.guards.is_empty()
{
entry.validated = Some(incarnation);
if entry.guards.iter().any(|guard| guard.loss_probe.is_lost()) {
entry.validated = None;
} else {
entry.validated = Some(incarnation);
}
}
}
/// Deregister a guard. Clears the memo when the last guard leaves or when
/// the leaving guard lost its lock (lost coverage means a lifecycle write
/// lock may have been granted, so the memo can no longer be trusted).
fn exit(&self, bucket: &str, lock_lost: bool) {
fn exit(&self, bucket: &str, token: u64, lock_lost: bool) {
let mut entries = self.entries.lock().expect("bucket fence registry poisoned");
if let Some(entry) = entries.get_mut(bucket) {
entry.guards = entry.guards.saturating_sub(1);
entry.guards.retain(|guard| guard.token != token);
if lock_lost {
entry.validated = None;
}
if entry.guards == 0 {
if entry.guards.is_empty() {
entries.remove(bucket);
}
}
@@ -104,6 +154,7 @@ pub(crate) struct BucketIncarnationFenceGuard {
inner: Option<NamespaceLockGuard>,
registry: Arc<BucketFenceRegistry>,
bucket: String,
token: u64,
}
impl BucketIncarnationFenceGuard {
@@ -115,7 +166,7 @@ impl BucketIncarnationFenceGuard {
impl Drop for BucketIncarnationFenceGuard {
fn drop(&mut self) {
let lost = self.is_lock_lost();
self.registry.exit(&self.bucket, lost);
self.registry.exit(&self.bucket, self.token, lost);
self.inner.take();
}
}
@@ -128,8 +179,8 @@ pub(super) struct FencePieces {
impl FencePieces {
/// Register the freshly acquired read lock and return the memoized
/// incarnation for the coverage window, if any.
pub(super) fn enter(&self, bucket: &str) -> Option<Uuid> {
self.registry.enter(bucket)
pub(super) fn enter(&self, bucket: &str) -> FenceRegistration {
self.registry.enter(bucket, LockLossProbe::from_guard(&self.inner))
}
pub(super) fn memoize(&self, bucket: &str, incarnation: Uuid) {
@@ -140,18 +191,19 @@ impl FencePieces {
self.inner.is_lock_lost()
}
pub(super) fn into_guard(self, bucket: &str) -> BucketIncarnationFenceGuard {
pub(super) fn into_guard(self, bucket: &str, token: u64) -> BucketIncarnationFenceGuard {
BucketIncarnationFenceGuard {
inner: Some(self.inner),
registry: self.registry,
bucket: bucket.to_string(),
token,
}
}
/// Abandon the acquisition (validation failed): deregister and release.
pub(super) fn abandon(self, bucket: &str) {
pub(super) fn abandon(self, bucket: &str, token: u64) {
let lost = self.lock_lost();
self.registry.exit(bucket, lost);
self.registry.exit(bucket, token, lost);
drop(self.inner);
}
}
@@ -159,57 +211,155 @@ impl FencePieces {
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use rustfs_lock::{LocalClient, LockRequest, LockType, NamespaceLock, ObjectKey};
fn uuid(n: u128) -> Uuid {
Uuid::from_u128(n)
}
fn live_probe() -> LockLossProbe {
LockLossProbe::Test(Arc::new(std::sync::atomic::AtomicBool::new(false)))
}
fn controllable_probe() -> (LockLossProbe, Arc<std::sync::atomic::AtomicBool>) {
let lost = Arc::new(std::sync::atomic::AtomicBool::new(false));
(LockLossProbe::Test(lost.clone()), lost)
}
fn lock_request(owner: &str) -> LockRequest {
LockRequest::new(ObjectKey::new("b", "lifecycle"), LockType::Shared, owner)
.with_acquire_timeout(Duration::from_millis(100))
.with_ttl(Duration::from_millis(20))
.with_refresh_interval(Duration::from_millis(50))
}
#[test]
fn memo_valid_only_while_guards_overlap() {
let reg = BucketFenceRegistry::default();
assert_eq!(reg.enter("b"), None, "first guard sees no memo");
let first = reg.enter("b", live_probe());
assert_eq!(first.memoized, None, "first guard sees no memo");
reg.memoize("b", uuid(1));
assert_eq!(reg.enter("b"), Some(uuid(1)), "overlapping guard reuses memo");
reg.exit("b", false);
reg.exit("b", false);
let second = reg.enter("b", live_probe());
assert_eq!(second.memoized, Some(uuid(1)), "overlapping guard reuses memo");
reg.exit("b", first.token, false);
reg.exit("b", second.token, false);
// Coverage gap: all guards gone, memo must be dropped.
assert_eq!(reg.enter("b"), None, "post-gap guard must revalidate");
reg.exit("b", false);
let third = reg.enter("b", live_probe());
assert_eq!(third.memoized, None, "post-gap guard must revalidate");
reg.exit("b", third.token, false);
}
#[test]
fn lost_lock_clears_memo_but_keeps_other_guards_registered() {
let reg = BucketFenceRegistry::default();
assert_eq!(reg.enter("b"), None);
let first = reg.enter("b", live_probe());
assert_eq!(first.memoized, None);
reg.memoize("b", uuid(7));
assert_eq!(reg.enter("b"), Some(uuid(7)));
let second = reg.enter("b", live_probe());
assert_eq!(second.memoized, Some(uuid(7)));
// First guard exits reporting a lost lock: memo cleared even though
// a second guard is still live.
reg.exit("b", true);
assert_eq!(reg.enter("b"), None, "memo not trusted after a lost lock");
reg.exit("b", false);
reg.exit("b", false);
reg.exit("b", first.token, true);
let third = reg.enter("b", live_probe());
assert_eq!(third.memoized, None, "memo not trusted after a lost lock");
reg.exit("b", second.token, false);
reg.exit("b", third.token, false);
}
#[test]
fn live_lost_guard_blocks_memo_reuse_before_drop() {
let reg = BucketFenceRegistry::default();
let (first_probe, first_lost) = controllable_probe();
let first = reg.enter("b", first_probe);
assert_eq!(first.memoized, None);
reg.memoize("b", uuid(7));
first_lost.store(true, std::sync::atomic::Ordering::SeqCst);
let second = reg.enter("b", live_probe());
assert_eq!(second.memoized, None, "live lost guard must force disk revalidation");
reg.memoize("b", uuid(8));
let third = reg.enter("b", live_probe());
assert_eq!(third.memoized, None, "memo remains blocked while the lost guard is live");
reg.exit("b", first.token, true);
reg.memoize("b", uuid(8));
let fourth = reg.enter("b", live_probe());
assert_eq!(fourth.memoized, Some(uuid(8)), "memo resumes after lost coverage leaves");
reg.exit("b", second.token, false);
reg.exit("b", third.token, false);
reg.exit("b", fourth.token, false);
}
#[tokio::test]
async fn fence_pieces_forwards_distributed_lock_loss_to_registry() {
let registry = Arc::new(BucketFenceRegistry::default());
let lock = NamespaceLock::new("bucket-fence-test".to_string(), Arc::new(LocalClient::new()));
let first_guard = lock
.acquire_guard(&lock_request("first"))
.await
.expect("distributed lock acquisition should not fail")
.expect("distributed lock quorum should be reached");
let first_pieces = FencePieces {
registry: registry.clone(),
inner: first_guard,
};
let first = first_pieces.enter("b");
assert_eq!(first.memoized, None);
first_pieces.memoize("b", uuid(7));
tokio::time::timeout(Duration::from_secs(2), first_pieces.inner.lock_lost_notified())
.await
.expect("non-renewed distributed guard should lose its lease");
assert!(first_pieces.lock_lost(), "test guard should observe lost refresh quorum");
let second_guard = lock
.acquire_guard(&lock_request("second"))
.await
.expect("second distributed lock acquisition should not fail")
.expect("second distributed lock quorum should be reached");
let second_pieces = FencePieces {
registry: registry.clone(),
inner: second_guard,
};
let second = second_pieces.enter("b");
assert_eq!(
second.memoized, None,
"a live distributed guard whose signal is lost must block memo reuse"
);
second_pieces.abandon("b", second.token);
first_pieces.abandon("b", first.token);
}
#[test]
fn buckets_are_isolated() {
let reg = BucketFenceRegistry::default();
assert_eq!(reg.enter("a"), None);
let first = reg.enter("a", live_probe());
assert_eq!(first.memoized, None);
reg.memoize("a", uuid(1));
assert_eq!(reg.enter("b"), None, "memo does not leak across buckets");
reg.exit("b", false);
reg.exit("a", false);
let second = reg.enter("b", live_probe());
assert_eq!(second.memoized, None, "memo does not leak across buckets");
reg.exit("b", second.token, false);
reg.exit("a", first.token, false);
}
#[test]
fn memoize_without_live_guard_is_ignored() {
let reg = BucketFenceRegistry::default();
reg.memoize("b", uuid(9));
assert_eq!(reg.enter("b"), None);
reg.exit("b", false);
let first = reg.enter("b", live_probe());
assert_eq!(first.memoized, None);
reg.exit("b", first.token, false);
}
}
+303 -3
View File
@@ -21,7 +21,27 @@ const LOG_SUBSYSTEM_HEAL: &str = "heal";
const EVENT_HEAL_FORMAT_COMPLETED: &str = "heal_format_completed";
const EVENT_HEAL_OBJECT_STARTED: &str = "heal_object_started";
fn invalid_heal_pool_index(pool_idx: usize, pool_count: usize) -> Error {
StorageError::InvalidArgument(
"heal".to_string(),
"pool".to_string(),
format!("invalid heal pool index {pool_idx} for {pool_count} pools"),
)
}
impl ECStore {
fn get_pools_for_heal_object(&self, opts: &HealOpts) -> Result<Vec<Arc<Sets>>> {
match opts.pool {
Some(pool_idx) => Ok(vec![
self.pools
.get(pool_idx)
.cloned()
.ok_or_else(|| invalid_heal_pool_index(pool_idx, self.pools.len()))?,
]),
None => Ok(self.pools.clone()),
}
}
#[instrument(skip(self))]
pub(super) async fn handle_heal_format(&self, dry_run: bool) -> Result<(HealResultItem, Option<Error>)> {
let mut r = HealResultItem {
@@ -77,6 +97,56 @@ impl ECStore {
Ok((r, None))
}
#[instrument(skip(self, targets), fields(pool_index, set_index, target_count = targets.len()))]
pub async fn heal_replacement_format(
&self,
dry_run: bool,
pool_index: usize,
set_index: usize,
targets: &[String],
) -> Result<(HealResultItem, Option<Error>)> {
let pool = self
.pools
.get(pool_index)
.ok_or_else(|| invalid_heal_pool_index(pool_index, self.pools.len()))?;
let set = pool.disk_set.get(set_index).cloned().ok_or_else(|| {
StorageError::InvalidArgument(
"heal".to_string(),
"set".to_string(),
format!("invalid heal set index {set_index} for pool {pool_index}"),
)
})?;
set.heal_replacement_format(dry_run, targets).await
}
#[instrument(skip(self, targets), fields(pool_index, set_index, target_count = targets.len()))]
pub async fn replacement_targets_have_version(
&self,
bucket: &str,
object: &str,
version_id: &str,
pool_index: usize,
set_index: usize,
targets: &[String],
) -> Result<bool> {
let pool = self
.pools
.get(pool_index)
.ok_or_else(|| invalid_heal_pool_index(pool_index, self.pools.len()))?;
let set = pool.disk_set.get(set_index).cloned().ok_or_else(|| {
StorageError::InvalidArgument(
"heal".to_string(),
"set".to_string(),
format!("invalid heal set index {set_index} for pool {pool_index}"),
)
})?;
set.replacement_targets_have_version(bucket, object, version_id, targets)
.await
.map_err(Into::into)
}
#[instrument(skip(self))]
pub(super) async fn handle_heal_bucket(&self, bucket: &str, opts: &HealOpts) -> Result<HealResultItem> {
let res = self.peer_sys.heal_bucket(bucket, opts).await?;
@@ -105,9 +175,34 @@ impl ECStore {
);
let object = encode_dir_object(object);
let mut futures = Vec::with_capacity(self.pools.len());
for pool in self.pools.iter() {
if self.is_suspended(pool.pool_idx).await {
let pools = self.get_pools_for_heal_object(opts)?;
let mut futures = Vec::with_capacity(pools.len());
for pool in pools.iter() {
let suspended_complete = {
let pool_meta = self.pool_meta.read().await;
pool_meta.is_suspended(pool.pool_idx).then(|| {
pool_meta
.pools
.get(pool.pool_idx)
.and_then(|status| status.decommission.as_ref())
.is_some_and(|decommission| decommission.complete)
})
};
if let Some(complete) = suspended_complete {
if opts.pool.is_some() {
let _ = pool.get_disks_for_heal_object(&object, opts)?;
let err = if complete {
StorageError::InvalidArgument(
"heal".to_string(),
"pool".to_string(),
format!("heal pool {} has completed decommission", pool.pool_idx),
)
} else {
Error::SlowDown
};
return Ok((HealResultItem::default(), Some(err)));
}
continue;
}
futures.push(pool.heal_object(bucket, &object, version_id, opts));
@@ -174,10 +269,215 @@ impl ECStore {
#[cfg(test)]
mod tests {
use super::*;
use crate::core::pools::{PoolDecommissionInfo, PoolStatus};
use crate::disk::{DiskOption, format::FormatV3, new_disk};
use crate::layout::endpoints::{Endpoints, PoolEndpoints};
use crate::store::init_format::{load_format_erasure, save_format_file};
async fn minimal_heal_pool(pool_idx: usize) -> Arc<Sets> {
let format = FormatV3::new(1, 1);
let endpoint_url = format!("http://127.0.0.1:{}/data", 19000 + pool_idx);
let mut endpoint = Endpoint::try_from(endpoint_url.as_str()).expect("endpoint should parse");
endpoint.set_pool_index(pool_idx);
endpoint.set_set_index(0);
endpoint.set_disk_index(0);
Sets::new(
vec![None],
&PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: 1,
endpoints: Endpoints::from(vec![endpoint]),
cmd_line: String::new(),
platform: String::new(),
},
&format,
pool_idx,
0,
)
.await
.expect("minimal pool should build")
}
async fn minimal_heal_store() -> ECStore {
ECStore {
id: Uuid::new_v4(),
disk_map: HashMap::new(),
pools: vec![minimal_heal_pool(0).await, minimal_heal_pool(1).await],
peer_sys: S3PeerSys {
clients: Vec::new(),
pools_count: 2,
},
pool_meta: RwLock::new(PoolMeta::default()),
rebalance_meta: RwLock::new(None),
decommission_cancelers: RwLock::new(Vec::new()),
start_gate: Mutex::new(()),
pool_meta_save_gate: Mutex::new(()),
ctx: crate::runtime::instance::bootstrap_ctx(),
bucket_fence_registry: std::sync::Arc::default(),
}
}
#[tokio::test]
async fn heal_object_pool_scope_selects_only_requested_pool() {
let store = minimal_heal_store().await;
let pools = store
.get_pools_for_heal_object(&HealOpts {
pool: Some(1),
..Default::default()
})
.expect("requested pool should be selected");
assert_eq!(pools.len(), 1);
assert!(Arc::ptr_eq(&pools[0], &store.pools[1]));
}
#[tokio::test]
async fn heal_object_pool_scope_rejects_invalid_pool() {
let store = minimal_heal_store().await;
let err = store
.get_pools_for_heal_object(&HealOpts {
pool: Some(2),
..Default::default()
})
.expect_err("out-of-range pool scope must fail closed");
assert!(
matches!(err, StorageError::InvalidArgument(_, ref field, ref reason)
if field == "pool" && reason.contains("invalid heal pool index 2 for 2 pools")),
"unexpected invalid pool error: {err:?}"
);
}
#[tokio::test]
async fn scoped_heal_object_defers_when_requested_pool_is_suspended() {
let mut store = minimal_heal_store().await;
store.pool_meta = RwLock::new(PoolMeta {
pools: vec![
PoolStatus {
id: 0,
cmd_line: "pool-0".to_string(),
last_update: OffsetDateTime::UNIX_EPOCH,
decommission: None,
},
PoolStatus {
id: 1,
cmd_line: "pool-1".to_string(),
last_update: OffsetDateTime::UNIX_EPOCH,
decommission: Some(PoolDecommissionInfo {
start_time: Some(OffsetDateTime::UNIX_EPOCH),
..Default::default()
}),
},
],
..Default::default()
});
let (_, err) = store
.handle_heal_object(
"bucket",
"object",
"",
&HealOpts {
pool: Some(1),
set: Some(0),
..Default::default()
},
)
.await
.expect("suspended pool should return a deferred heal result");
assert!(matches!(err, Some(StorageError::SlowDown)));
let (_, err) = store
.handle_heal_object(
"bucket",
"object",
"",
&HealOpts {
set: Some(1),
..Default::default()
},
)
.await
.expect("unscoped heal should return the active pool result");
assert!(matches!(err, Some(StorageError::InvalidArgument(_, ref field, _)) if field == "set"));
let err = store
.handle_heal_object(
"bucket",
"object",
"",
&HealOpts {
pool: Some(1),
set: Some(1),
..Default::default()
},
)
.await
.expect_err("invalid set scope should fail before suspended pool deferral");
assert!(matches!(err, StorageError::InvalidArgument(_, ref field, _) if field == "set"));
{
let mut pool_meta = store.pool_meta.write().await;
let decommission = pool_meta.pools[1]
.decommission
.as_mut()
.expect("test pool should have decommission state");
decommission.complete = true;
}
let (_, err) = store
.handle_heal_object(
"bucket",
"object",
"",
&HealOpts {
pool: Some(1),
set: Some(0),
..Default::default()
},
)
.await
.expect("completed pool should return a terminal heal result");
assert!(matches!(
err,
Some(StorageError::InvalidArgument(_, ref field, ref reason))
if field == "pool" && reason.contains("completed decommission")
));
for canceled in [false, true] {
{
let mut pool_meta = store.pool_meta.write().await;
let decommission = pool_meta.pools[1]
.decommission
.as_mut()
.expect("test pool should have decommission state");
decommission.complete = false;
decommission.failed = !canceled;
decommission.canceled = canceled;
}
let (_, err) = store
.handle_heal_object(
"bucket",
"object",
"",
&HealOpts {
pool: Some(1),
set: Some(0),
..Default::default()
},
)
.await
.expect("clearable terminal pool should return a deferred heal result");
assert!(matches!(err, Some(StorageError::SlowDown)));
}
}
#[tokio::test]
async fn handle_heal_format_continues_after_a_pool_error() {
let canonical_format = FormatV3::new(1, 3);
+253 -1
View File
@@ -602,7 +602,7 @@ mod tests {
error::{Error, Result, StorageError},
layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints},
object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader},
services::rebalance::RebalanceMeta,
services::rebalance::{RebalStatus, RebalanceInfo, RebalanceMeta, RebalanceStats},
storage_api_contracts::{
bucket::{BucketOperations as _, MakeBucketOptions},
multipart::MultipartOperations as _,
@@ -1155,6 +1155,258 @@ mod tests {
(instance_ctx, store, shutdown)
}
fn active_rebalance_meta_for_pool(pool_count: usize, active_pool_idx: usize) -> RebalanceMeta {
let now = OffsetDateTime::now_utc();
let mut pool_stats = vec![RebalanceStats::default(); pool_count];
pool_stats[active_pool_idx] = RebalanceStats {
participating: true,
info: RebalanceInfo {
start_time: Some(now),
status: RebalStatus::Started,
..Default::default()
},
..Default::default()
};
RebalanceMeta {
id: uuid::Uuid::new_v4().to_string(),
pool_stats,
..Default::default()
}
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn tag_updates_skip_active_rebalance_source_pool() {
let temp_dir = tempfile::tempdir().expect("create writer-fencing store dir");
let (_ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "writer-fencing-tags", &[4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("writer-fencing-tags-{}", uuid::Uuid::new_v4());
let object = "tagged-object.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create writer fencing bucket");
let old_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("fixed timestamp should be valid");
let newer_time = old_time + time::Duration::seconds(10);
let mut source_reader = PutObjReader::from_vec(b"source-body".to_vec());
store.pools[0]
.put_object(
&bucket,
object,
&mut source_reader,
&ObjectOptions {
mod_time: Some(newer_time),
..Default::default()
},
)
.await
.expect("write newer source object");
let mut target_reader = PutObjReader::from_vec(b"target-body".to_vec());
store.pools[1]
.put_object(
&bucket,
object,
&mut target_reader,
&ObjectOptions {
mod_time: Some(old_time),
..Default::default()
},
)
.await
.expect("write older target object");
*store.rebalance_meta.write().await = Some(active_rebalance_meta_for_pool(store.pools.len(), 0));
assert!(store.is_pool_rebalancing(0).await, "pool 0 must be marked as an active rebalance source");
let tags = "rebalance=target";
assert_ne!(
store.pools[0]
.get_object_tags(&bucket, object, &ObjectOptions::default())
.await
.expect("source object tags should be readable before update"),
tags,
"source object must start without the target tag"
);
assert_ne!(
store.pools[1]
.get_object_tags(&bucket, object, &ObjectOptions::default())
.await
.expect("target object tags should be readable before update"),
tags,
"target object must start without the target tag"
);
let selected_pool = store
.get_pool_idx_existing_with_opts(
&bucket,
object,
&ObjectOptions {
no_lock: true,
metadata_chg: true,
skip_decommissioned: true,
skip_rebalancing: true,
..Default::default()
},
)
.await
.expect("writer lookup should select an existing non-rebalancing pool");
assert_eq!(selected_pool, 1, "writer lookup must skip active rebalance pool 0");
let updated = store
.put_object_tags(&bucket, object, tags, &ObjectOptions::default())
.await
.expect("tag update should use the non-rebalancing target pool");
assert_eq!(
updated.mod_time,
Some(old_time),
"tag update must return the non-rebalancing pool object rather than the newer active source"
);
let target_tags = store.pools[1]
.get_object_tags(&bucket, object, &ObjectOptions::default())
.await
.expect("target object tags should be readable");
assert_eq!(target_tags, tags, "non-rebalancing pool must receive writer tag updates");
shutdown.cancel();
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn multipart_listing_skips_active_rebalance_source_pool() {
let temp_dir = tempfile::tempdir().expect("create multipart writer-fencing store dir");
let (_ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "writer-fencing-multipart", &[4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("writer-fencing-multipart-{}", uuid::Uuid::new_v4());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create multipart writer fencing bucket");
let incarnation = store.bucket_incarnation_id(&bucket).await.expect("read bucket incarnation");
let lifecycle_guard = store
.acquire_bucket_lifecycle_read_lock(&bucket)
.await
.expect("acquire multipart test lifecycle fence");
let mut upload_opts = ObjectOptions {
expected_bucket_incarnation_id: Some(incarnation),
..Default::default()
};
upload_opts.add_bucket_lifecycle_lock_guard(&lifecycle_guard);
let source_upload = store.pools[0]
.new_multipart_upload(&bucket, "source-only.bin", &upload_opts)
.await
.expect("create source upload");
let target_upload = store.pools[1]
.new_multipart_upload(&bucket, "target-visible.bin", &upload_opts)
.await
.expect("create target upload");
*store.rebalance_meta.write().await = Some(active_rebalance_meta_for_pool(store.pools.len(), 0));
assert!(store.is_pool_rebalancing(0).await, "pool 0 must be marked as an active rebalance source");
let listed = store
.list_multipart_uploads(&bucket, "", None, None, None, 100)
.await
.expect("list multipart uploads");
let listed_uploads: Vec<(&str, &str)> = listed
.uploads
.iter()
.map(|upload| (upload.object.as_str(), upload.upload_id.as_str()))
.collect();
assert!(
!listed_uploads.contains(&("source-only.bin", source_upload.upload_id.as_str())),
"active source pool upload must be hidden from multipart listing"
);
assert!(
listed_uploads.contains(&("target-visible.bin", target_upload.upload_id.as_str())),
"non-rebalancing pool upload must remain visible"
);
shutdown.cancel();
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn delete_objects_skips_active_rebalance_source_pool() {
let temp_dir = tempfile::tempdir().expect("create batch-delete writer-fencing store dir");
let (_ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "batch-delete-rebalance", &[4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("batch-delete-rebalance-{}", uuid::Uuid::new_v4());
let object = "delete-me.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create batch delete rebalance bucket");
let mut source_reader = PutObjReader::from_vec(b"source-body".to_vec());
store.pools[0]
.put_object(&bucket, object, &mut source_reader, &ObjectOptions::default())
.await
.expect("write object on active source pool");
let mut target_reader = PutObjReader::from_vec(b"target-body".to_vec());
store.pools[1]
.put_object(&bucket, object, &mut target_reader, &ObjectOptions::default())
.await
.expect("write object on non-rebalancing target pool");
let mut pool_stats = vec![RebalanceStats::default(); store.pools.len()];
pool_stats[0] = RebalanceStats {
participating: true,
info: RebalanceInfo {
start_time: Some(OffsetDateTime::now_utc()),
status: RebalStatus::Started,
..Default::default()
},
..Default::default()
};
*store.rebalance_meta.write().await = Some(RebalanceMeta {
id: uuid::Uuid::new_v4().to_string(),
pool_stats,
..Default::default()
});
assert!(store.is_pool_rebalancing(0).await, "pool 0 must be marked as an active rebalance source");
let (deleted, errs) = store
.delete_objects(
&bucket,
vec![crate::storage_api_contracts::object::ObjectToDelete {
object_name: object.to_string(),
..Default::default()
}],
ObjectOptions::default(),
)
.await;
assert!(matches!(errs.as_slice(), [None]), "batch delete must not fail: {errs:?}");
assert!(
matches!(deleted.as_slice(), [deleted] if deleted.found && deleted.object_name == object),
"batch delete must report the non-rebalancing pool deletion"
);
store.pools[0]
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.expect("active source pool object must not be deleted by DeleteObjects");
let target_err = store.pools[1]
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.expect_err("non-rebalancing target pool object must be deleted");
assert!(
matches!(target_err, StorageError::ObjectNotFound(_, _)),
"target pool should report object not found after DeleteObjects, got {target_err:?}"
);
shutdown.cancel();
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn data_movement_conflicts_preserve_newer_target_and_abort_staging() {
+4 -1
View File
@@ -152,7 +152,10 @@ mod list;
pub(crate) mod list_objects;
mod multipart;
mod object;
pub use object::PreparedGetObjectReader;
pub use object::{
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
SnapshotConsistencyError,
};
mod peer;
mod rebalance;
pub(crate) mod utils;
+6 -6
View File
@@ -221,7 +221,7 @@ impl ECStore {
}
for pool in self.pools.iter() {
if self.is_suspended(pool.pool_idx).await {
if self.is_suspended(pool.pool_idx).await || self.is_pool_rebalancing(pool.pool_idx).await {
continue;
}
return match pool
@@ -284,7 +284,7 @@ impl ECStore {
let mut source_truncated = false;
for pool in self.pools.iter() {
if self.is_suspended(pool.pool_idx).await {
if self.is_suspended(pool.pool_idx).await || self.is_pool_rebalancing(pool.pool_idx).await {
continue;
}
let res = list_pool_multipart_uploads_for_incarnation(
@@ -433,7 +433,7 @@ impl ECStore {
}
for pool in self.pools.iter() {
if self.is_suspended(pool.pool_idx).await {
if self.is_suspended(pool.pool_idx).await || self.is_pool_rebalancing(pool.pool_idx).await {
continue;
}
let err = match pool.put_object_part(bucket, object, upload_id, part_id, data, opts).await {
@@ -472,7 +472,7 @@ impl ECStore {
}
for pool in self.pools.iter() {
if self.is_suspended(pool.pool_idx).await {
if self.is_suspended(pool.pool_idx).await || self.is_pool_rebalancing(pool.pool_idx).await {
continue;
}
@@ -510,7 +510,7 @@ impl ECStore {
}
for pool in self.pools.iter() {
if self.is_suspended(pool.pool_idx).await {
if self.is_suspended(pool.pool_idx).await || self.is_pool_rebalancing(pool.pool_idx).await {
continue;
}
@@ -551,7 +551,7 @@ impl ECStore {
}
for pool in self.pools.iter() {
if self.is_suspended(pool.pool_idx).await {
if self.is_suspended(pool.pool_idx).await || self.is_pool_rebalancing(pool.pool_idx).await {
continue;
}
File diff suppressed because it is too large Load Diff
+11 -3
View File
@@ -541,14 +541,22 @@ impl ECStore {
opts: &ObjectOptions,
) -> Result<(ObjectInfo, usize)> {
let mut futures = Vec::with_capacity(self.pools.len());
for pool in self.pools.iter() {
futures.push(pool.get_object_info(bucket, object, opts));
for (idx, pool) in self.pools.iter().enumerate() {
if opts.skip_decommissioned && self.is_suspended(idx).await {
continue;
}
if opts.skip_rebalancing && self.is_pool_rebalancing(idx).await {
continue;
}
futures.push(async move { (idx, pool.get_object_info(bucket, object, opts).await) });
}
let results = join_all(futures).await;
let mut candidates = Vec::with_capacity(self.pools.len());
for (idx, result) in results.into_iter().enumerate() {
for (idx, result) in results {
match result {
Ok(res) => {
candidates.push(LatestObjectInfoCandidate {
+29 -1
View File
@@ -278,7 +278,11 @@ pub struct FileInfo {
fn is_sensitive_metadata_key(key: &str) -> bool {
// `is_encryption_metadata_key` covers the x-minio-internal- SSE prefix but not
// its x-rustfs-internal- twin, which the dual-key invariant writes alongside it.
is_encryption_metadata_key(key) || starts_with_ignore_ascii_case(key, "x-rustfs-internal-server-side-encryption-")
is_encryption_metadata_key(key)
|| starts_with_ignore_ascii_case(key, "x-rustfs-internal-server-side-encryption-")
|| rustfs_utils::http::REPLICATION_SSE_TRANSPORT_PREFIXES
.iter()
.any(|prefix| starts_with_ignore_ascii_case(key, prefix))
}
struct RedactedMetadata<'a>(&'a HashMap<String, String>);
@@ -2559,6 +2563,30 @@ mod tests {
assert!(dump.contains("text/plain"));
}
#[test]
fn debug_redacts_replication_sse_transport_metadata_values() {
let sealed_key = "IAAfANqt7wIJfVSgFAG3f5S6HuC2eyM5DdJlx7RSJKw2ZakSb3d5";
let mut fi = FileInfo::default();
for key in [
"X-Rustfs-Replication-Server-Side-Encryption-Sealed-Key",
"X-Rustfs-Replication-Server-Side-Encryption-Iv",
"X-Rustfs-Replication-Encryption-Iv",
"X-Rustfs-Replication-Ssec-Key-Md5",
] {
fi.metadata.insert(key.to_string(), sealed_key.to_string());
}
fi.metadata.insert("content-type".to_string(), "text/plain".to_string());
let dump = format!("{fi:?}");
assert!(
!dump.contains(sealed_key),
"replication SSE transport value leaked into Debug output: {dump}"
);
assert!(dump.contains("X-Rustfs-Replication-Server-Side-Encryption-Sealed-Key"));
assert!(dump.contains(&format!("<redacted {} bytes>", sealed_key.len())));
assert!(dump.contains("text/plain"));
}
#[test]
fn debug_elides_inline_data_bytes() {
let fi = FileInfo {
+106
View File
@@ -1016,6 +1016,83 @@ mod test {
use proptest::collection::vec;
use proptest::prelude::*;
/// A restore header meaning "restored copy is on disk until far in the future".
/// Format produced by `RestoreStatusOps::to_string` and consumed by
/// `parse_restore_obj_status` (fileinfo.rs).
const RESTORED_ON_DISK: &str = "ongoing-request=\"false\", expiry-date=\"9999-01-01T00:00:00Z\"";
/// backlog#1733 (P9-01 §4.3/§7.6, g-key-001): pin the five `s3s::header`
/// constants that double as **persisted metadata map keys**. They are not
/// just HTTP header names — they are stored inside xl.meta (`meta_user`)
/// and read back by fail-open code, so a silent drift produces zero
/// HTTP-visible errors while:
///
/// 1. **WORM silently dissolves** — `get_object_retention_meta`
/// (ecstore objectlock.rs) returns an empty retention when the lock keys
/// are unreadable, making every compliance-locked object deletable.
/// 2. **Live data dirs can be reclaimed** — `MetaObject::uses_data_dir`
/// falls back to `is_restored_object_on_disk`, which returns `false`
/// when `x-amz-restore` is unreadable, so a restored object's data dir
/// is judged unused.
///
/// Any migration replacing these constants must keep the literals byte-stable.
#[test]
fn persisted_metadata_keys_are_byte_stable() {
use s3s::header::{
X_AMZ_OBJECT_LOCK_LEGAL_HOLD, X_AMZ_OBJECT_LOCK_MODE, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE,
X_AMZ_SERVER_SIDE_ENCRYPTION,
};
assert_eq!(X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str(), "x-amz-object-lock-legal-hold");
assert_eq!(X_AMZ_OBJECT_LOCK_MODE.as_str(), "x-amz-object-lock-mode");
assert_eq!(X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str(), "x-amz-object-lock-retain-until-date");
assert_eq!(X_AMZ_RESTORE.as_str(), "x-amz-restore");
assert_eq!(X_AMZ_SERVER_SIDE_ENCRYPTION.as_str(), "x-amz-server-side-encryption");
}
/// backlog#1733 g-key-003: a restored-to-local object must keep its data
/// dir. The restore marker lives under the pinned `x-amz-restore` key; if
/// the key ever drifts this flips to `false` and the data dir becomes
/// eligible for reclamation while the restored copy is still being served.
#[test]
fn restored_object_keeps_using_data_dir() {
let mut obj = MetaObject::default();
obj.meta_user
.insert("x-amz-restore".to_string(), RESTORED_ON_DISK.to_string());
assert!(obj.uses_data_dir(), "restored object's data dir must be considered in use");
// The same fail-open shape the pin protects against: without the marker
// the data dir is judged unused — exactly what a key drift would cause.
let bare = MetaObject::default();
assert!(!bare.uses_data_dir(), "object without restore marker reports data dir unused");
}
/// backlog#1733 g-key-004: a transition-complete object short-circuits to
/// `false` even when the restore marker is present — the existing
/// precedence must not change.
#[test]
fn transition_complete_object_does_not_use_data_dir() {
use rustfs_utils::http::{SUFFIX_TRANSITION_STATUS, insert_bytes};
let mut obj = MetaObject::default();
obj.meta_user
.insert("x-amz-restore".to_string(), RESTORED_ON_DISK.to_string());
insert_bytes(&mut obj.meta_sys, SUFFIX_TRANSITION_STATUS, TRANSITION_COMPLETE.as_bytes().to_vec());
assert!(!obj.uses_data_dir(), "transition-complete short-circuit must win over the restore marker");
}
/// The restore-header parser and the pinned key literal must agree: the
/// marker written under `x-amz-restore` is only meaningful if the parser
/// accepts it.
#[test]
fn restore_marker_roundtrips_through_parser() {
let mut meta = HashMap::new();
meta.insert(X_AMZ_RESTORE.as_str().to_string(), RESTORED_ON_DISK.to_string());
assert!(crate::is_restored_object_on_disk(&meta));
// An in-progress restore is not "on disk".
meta.insert(X_AMZ_RESTORE.as_str().to_string(), "ongoing-request=\"true\"".to_string());
assert!(!crate::is_restored_object_on_disk(&meta));
}
/// backlog#580: RustFS parses real MinIO-written object xl.meta (inline,
/// versioned, and multipart) into equivalent `FileInfo`. Object metadata is
/// the strong part of MinIO interop; this pins it against real fixtures.
@@ -1463,6 +1540,35 @@ mod test {
/// Regression test for rustfs/rustfs#2715: a corrupted version count in
/// xl.meta must yield a decode error instead of sizing a huge allocation
/// from the bogus count (which aborts the whole process).
/// A CRC mismatch means the bytes on disk are not the bytes that were
/// written — bitrot. It must surface as `Error::FileCorrupt` specifically:
/// that variant converts to `DiskError::FileCorrupt`, which is the only
/// corruption signal `should_heal_object_on_disk` recognises. As a generic
/// error the drive is skipped, the heal reports success, and the damaged
/// `xl.meta` is never rewritten.
#[test]
fn test_unmarshal_reports_file_corrupt_on_crc_mismatch() {
let mut fm = FileMeta::default();
let mut buf = fm.marshal_msg().expect("serialize default FileMeta");
// Flip one byte inside the meta blob: past the 8-byte XL2 header and
// the 5-byte bin32 length prefix, before the CRC trailer.
let idx = 8 + 5;
buf[idx] ^= 0xff;
let err = fm.unmarshal_msg(&buf).expect_err("corrupted meta must fail to decode");
assert_eq!(err, Error::FileCorrupt, "CRC mismatch must classify as FileCorrupt, got: {err}");
}
#[test]
fn test_is_indexed_meta_reports_file_corrupt_on_crc_mismatch() {
let fm = FileMeta::default();
let mut buf = fm.marshal_msg().expect("serialize default FileMeta");
let idx = 8 + 5;
buf[idx] ^= 0xff;
let err = FileMeta::is_indexed_meta(&buf).expect_err("corrupted indexed metadata must fail");
assert_eq!(err, Error::FileCorrupt, "indexed CRC mismatch must classify as FileCorrupt, got: {err}");
}
#[test]
fn test_unmarshal_rejects_absurd_version_count() {
let mut meta = Vec::new();
+24 -3
View File
@@ -97,7 +97,20 @@ impl FileMeta {
let meta_crc = xxh64::xxh64(meta, XXHASH_SEED) as u32;
if crc != meta_crc {
return Err(Error::other("xl file crc check failed"));
error!(
event = "filemeta_xl_crc_mismatch",
component = "filemeta",
expected_crc = meta_crc,
actual_crc = crc,
"xl.meta payload failed its CRC check"
);
// Error::FileCorrupt, not a generic error, for the same reason
// check_xl2_v1 classifies a bad magic as FileCorrupt: heal
// classification (should_heal_object_on_disk) recognises
// corruption only by the DiskError::FileCorrupt variant this
// converts to. As a generic error the drive is skipped,
// heal_object reports ok, and on-disk bitrot is never repaired.
return Err(Error::FileCorrupt);
}
Ok((meta, inline_data))
@@ -163,8 +176,16 @@ impl FileMeta {
let meta_crc = xxh64::xxh64(meta, XXHASH_SEED) as u32;
if crc != meta_crc {
error!("xl file crc check failed: expected CRC {:#x}, got {:#x}", meta_crc, crc);
return Err(Error::other("xl file crc check failed"));
error!(
event = "filemeta_xl_crc_mismatch",
component = "filemeta",
expected_crc = meta_crc,
actual_crc = crc,
"xl.meta payload failed its CRC check"
);
// See is_indexed_meta: the FileCorrupt variant is what makes heal
// classify this drive as needing metadata repair.
return Err(Error::FileCorrupt);
}
if !buf.is_empty() {
+70 -8
View File
@@ -93,6 +93,8 @@ fn read_msgp_bin<R: std::io::Read>(rd: &mut R) -> Result<Vec<u8>> {
read_exact_vec(rd, len)
}
const MSGP_OBJECT_KEY_STACK_CAP: usize = 16;
/// Writes an `OffsetDateTime` as the ext8 / legacy (type 5, 12-byte
/// seconds+nanos) msgpack time encoding used by the V1 (Legacy) object body.
/// `read_msgp_time` decodes exactly this shape via `MSGPACK_TIME_EXT_LEGACY`.
@@ -1853,6 +1855,7 @@ impl From<MetaObjectV1ChecksumInfo> for ChecksumInfo {
"highwayhash256" => HashAlgorithm::HighwayHash256,
"highwayhash256S" => HashAlgorithm::HighwayHash256S,
"blake2b" | "blake2b512" => HashAlgorithm::BLAKE2b512,
"md5" => HashAlgorithm::Md5,
_ => HashAlgorithm::HighwayHash256S,
},
hash: Bytes::from(value.hash),
@@ -2060,16 +2063,33 @@ impl MetaObject {
tracing::error!(error = %e, "decode_from: read_str_len key failed");
e
})?;
let key_buf = read_exact_vec(rd, key_len as usize).map_err(|e| {
tracing::error!(error = %e, "decode_from: read key_buf failed");
e
})?;
let key = String::from_utf8(key_buf).map_err(|e| {
tracing::error!(error = %e, "decode_from: from_utf8 key failed");
e
let key_len = usize::try_from(key_len).map_err(|e| {
tracing::error!(error = %e, "decode_from: key length conversion failed");
Error::other(e)
})?;
match key.as_str() {
let mut inline_key = [0u8; MSGP_OBJECT_KEY_STACK_CAP];
let heap_key;
let key_buf = if key_len <= MSGP_OBJECT_KEY_STACK_CAP {
rd.read_exact(&mut inline_key[..key_len]).map_err(|e| {
tracing::error!(error = %e, "decode_from: read key_buf failed");
Error::from(e)
})?;
&inline_key[..key_len]
} else {
heap_key = read_exact_vec(rd, key_len).map_err(|e| {
tracing::error!(error = %e, "decode_from: read key_buf failed");
e
})?;
heap_key.as_slice()
};
let key = std::str::from_utf8(key_buf).map_err(|e| {
tracing::error!(error = %e, "decode_from: from_utf8 key failed");
Error::FromUtf8(e.to_string())
})?;
match key {
"ID" => {
let _ = rmp::decode::read_bin_len(rd).map_err(|e| {
tracing::error!(error = %e, "decode_from: read_bin_len ID failed");
@@ -2285,6 +2305,7 @@ impl MetaObject {
Some(n) => n,
};
self.meta_sys.clear();
self.meta_sys.reserve(prealloc_hint(len));
for _ in 0..len {
let k_len = rmp::decode::read_str_len(rd).map_err(|e| {
tracing::error!(error = %e, "decode_from: read_str_len MetaSys key failed");
@@ -2323,6 +2344,7 @@ impl MetaObject {
Some(n) => n,
};
self.meta_user.clear();
self.meta_user.reserve(prealloc_hint(len));
for _ in 0..len {
let k_len = rmp::decode::read_str_len(rd).map_err(|e| {
tracing::error!(error = %e, "decode_from: read_str_len MetaUsr key failed");
@@ -4824,6 +4846,46 @@ mod tests {
}
}
#[test]
fn meta_object_decode_round_trips_stack_sized_keys() {
let object = signed_object();
let encoded = object.marshal_msg().expect("object marshal should succeed");
let mut decoded = MetaObject::default();
decoded
.decode_from(&mut std::io::Cursor::new(&encoded))
.expect("object decode should succeed");
assert_eq!(decoded, object);
}
#[test]
fn meta_object_decode_skips_unknown_valid_utf8_field() {
let mut encoded = Vec::new();
rmp::encode::write_map_len(&mut encoded, 1).expect("map header should encode");
rmp::encode::write_str(&mut encoded, "UnknownFutureField").expect("field key should encode");
rmp::encode::write_nil(&mut encoded).expect("nil payload should encode");
let mut decoded = MetaObject::default();
decoded
.decode_from(&mut std::io::Cursor::new(&encoded))
.expect("unknown valid UTF-8 field should be skipped");
assert_eq!(decoded, MetaObject::default());
}
#[test]
fn meta_object_decode_rejects_invalid_utf8_field_name() {
let encoded = [0x81, 0xa1, 0xff, 0xc0];
let mut decoded = MetaObject::default();
let err = decoded
.decode_from(&mut std::io::Cursor::new(encoded))
.expect_err("invalid UTF-8 field name should fail");
assert!(matches!(err, Error::FromUtf8(_)), "unexpected error: {err}");
}
#[test]
fn signature_is_no_longer_hardcoded_zero() {
// Regression for B12: the write-path header must carry a real signature.
+44 -7
View File
@@ -189,7 +189,14 @@ fn encode_legacy_v1_header(version_id: Uuid, mod_time: OffsetDateTime) -> Vec<u8
wr
}
fn encode_legacy_v1_body(version_id: Uuid, data_dir: Uuid, mod_time: OffsetDateTime) -> Vec<u8> {
fn encode_legacy_v1_body(
version_id: Uuid,
data_dir: Uuid,
mod_time: OffsetDateTime,
erasure_index: usize,
checksum: Option<(&str, &[u8])>,
object_size: usize,
) -> Vec<u8> {
let mut wr = Vec::new();
rmp::encode::write_map_len(&mut wr, 3).unwrap();
@@ -208,7 +215,7 @@ fn encode_legacy_v1_body(version_id: Uuid, data_dir: Uuid, mod_time: OffsetDateT
rmp::encode::write_str(&mut wr, "Stat").unwrap();
rmp::encode::write_map_len(&mut wr, 5).unwrap();
rmp::encode::write_str(&mut wr, "Size").unwrap();
rmp::encode::write_sint(&mut wr, 11).unwrap();
rmp::encode::write_sint(&mut wr, object_size as i64).unwrap();
rmp::encode::write_str(&mut wr, "ModTime").unwrap();
write_legacy_time(&mut wr, mod_time);
rmp::encode::write_str(&mut wr, "Name").unwrap();
@@ -229,14 +236,23 @@ fn encode_legacy_v1_body(version_id: Uuid, data_dir: Uuid, mod_time: OffsetDateT
rmp::encode::write_str(&mut wr, "BlockSize").unwrap();
rmp::encode::write_sint(&mut wr, 1_048_576).unwrap();
rmp::encode::write_str(&mut wr, "Index").unwrap();
rmp::encode::write_sint(&mut wr, 1).unwrap();
rmp::encode::write_sint(&mut wr, erasure_index as i64).unwrap();
rmp::encode::write_str(&mut wr, "Distribution").unwrap();
rmp::encode::write_array_len(&mut wr, 6).unwrap();
for value in 1..=6 {
rmp::encode::write_sint(&mut wr, value).unwrap();
}
rmp::encode::write_str(&mut wr, "Checksums").unwrap();
rmp::encode::write_array_len(&mut wr, 0).unwrap();
rmp::encode::write_array_len(&mut wr, u32::from(checksum.is_some())).unwrap();
if let Some((algorithm, hash)) = checksum {
rmp::encode::write_map_len(&mut wr, 3).unwrap();
rmp::encode::write_str(&mut wr, "PartNumber").unwrap();
rmp::encode::write_sint(&mut wr, 1).unwrap();
rmp::encode::write_str(&mut wr, "Algorithm").unwrap();
rmp::encode::write_str(&mut wr, algorithm).unwrap();
rmp::encode::write_str(&mut wr, "Hash").unwrap();
rmp::encode::write_bin(&mut wr, hash).unwrap();
}
rmp::encode::write_str(&mut wr, "Meta").unwrap();
rmp::encode::write_map_len(&mut wr, 1).unwrap();
@@ -251,9 +267,9 @@ fn encode_legacy_v1_body(version_id: Uuid, data_dir: Uuid, mod_time: OffsetDateT
rmp::encode::write_str(&mut wr, "n").unwrap();
rmp::encode::write_sint(&mut wr, 1).unwrap();
rmp::encode::write_str(&mut wr, "s").unwrap();
rmp::encode::write_sint(&mut wr, 11).unwrap();
rmp::encode::write_sint(&mut wr, object_size as i64).unwrap();
rmp::encode::write_str(&mut wr, "as").unwrap();
rmp::encode::write_sint(&mut wr, 11).unwrap();
rmp::encode::write_sint(&mut wr, object_size as i64).unwrap();
rmp::encode::write_str(&mut wr, "mt").unwrap();
write_legacy_time(&mut wr, mod_time);
@@ -275,8 +291,29 @@ pub fn create_legacy_v1_object_xlmeta() -> Result<Vec<u8>> {
let mod_time = OffsetDateTime::from_unix_timestamp_nanos(1_705_312_200_123_456_789)?;
let header = encode_legacy_v1_header(version_id, mod_time);
let body = encode_legacy_v1_body(version_id, data_dir, mod_time);
let body = encode_legacy_v1_body(version_id, data_dir, mod_time, 1, None, 11);
encode_legacy_v1_xlmeta(header, body)
}
/// Legacy V1 xl.meta fixture with a per-drive whole-file bitrot checksum.
pub fn create_legacy_v1_object_xlmeta_with_checksum(
erasure_index: usize,
algorithm: &str,
hash: &[u8],
object_size: usize,
) -> Result<Vec<u8>> {
let version_id = Uuid::parse_str("01234567-89ab-cdef-0123-456789abcdef")?;
let data_dir = Uuid::parse_str("fedcba98-7654-3210-fedc-ba9876543210")?;
let mod_time = OffsetDateTime::from_unix_timestamp_nanos(1_705_312_200_123_456_789)?;
let header = encode_legacy_v1_header(version_id, mod_time);
let body = encode_legacy_v1_body(version_id, data_dir, mod_time, erasure_index, Some((algorithm, hash)), object_size);
encode_legacy_v1_xlmeta(header, body)
}
fn encode_legacy_v1_xlmeta(header: Vec<u8>, body: Vec<u8>) -> Result<Vec<u8>> {
let mut wr = Vec::new();
wr.extend_from_slice(b"XL2 ");
wr.extend_from_slice(&1u16.to_le_bytes());
+628 -27
View File
@@ -14,7 +14,10 @@
use crate::heal::{
progress::HealProgress,
resume::{CheckpointManager, ResumeManager, ResumeUtils, compose_key},
resume::{
CheckpointManager, ReplacementTargetIdentity, ResumeManager, ResumeUtils, compose_key,
replacement_target_identities_match,
},
storage::{HealStorageAPI, next_heal_listing_token},
task::{demote_to_debug_when, is_missing_object_dir_heal_result, take_failure_log_sample},
};
@@ -22,6 +25,7 @@ use crate::{Error, Result};
use futures::{StreamExt, stream::FuturesUnordered};
use metrics::gauge;
use rustfs_common::heal_channel::{HealOpts, HealRequestSource, HealScanMode};
use rustfs_madmin::heal_commands::HealResultItem;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
@@ -85,6 +89,16 @@ pub struct ErasureSetHealer {
disk: DiskStore,
heal_opts: HealOpts,
source: HealRequestSource,
target_endpoints: Arc<[String]>,
replacement_task_id: Option<String>,
replacement_target_identities: Option<Arc<[ReplacementTargetIdentity]>>,
}
pub(crate) fn target_outcomes_complete(result: &HealResultItem, target_endpoints: &[String]) -> bool {
target_endpoints.iter().all(|endpoint| {
let mut drives = result.after.drives.iter().filter(|drive| drive.endpoint == *endpoint);
matches!(drives.next(), Some(drive) if drive.state == "ok") && drives.next().is_none()
})
}
impl ErasureSetHealer {
@@ -182,9 +196,46 @@ impl ErasureSetHealer {
disk,
heal_opts,
source,
target_endpoints: Vec::new().into(),
replacement_task_id: None,
replacement_target_identities: None,
}
}
pub(crate) fn with_replacement_targets(
mut self,
mut target_endpoints: Vec<String>,
replacement_task_id: Option<String>,
) -> Self {
target_endpoints.sort_unstable();
target_endpoints.dedup();
self.target_endpoints = target_endpoints.into();
self.replacement_task_id = replacement_task_id;
self
}
pub(crate) fn with_replacement_identity_fence(
mut self,
replacement_target_identities: Option<Vec<ReplacementTargetIdentity>>,
) -> Self {
self.replacement_target_identities = replacement_target_identities.map(Into::into);
self
}
async fn verify_replacement_identity_fence(&self, stage: &str) -> Result<()> {
let Some(expected_identities) = self.replacement_target_identities.as_ref() else {
return Ok(());
};
let actual_identities = self.storage.replacement_target_identities(&self.target_endpoints).await?;
if replacement_target_identities_match(expected_identities, &actual_identities) {
return Ok(());
}
Err(Error::TaskExecutionFailed {
message: format!("Replacement target changed during {stage}"),
})
}
/// execute erasure set heal with resume
#[tracing::instrument(skip(self, buckets), fields(set_disk_id = %set_disk_id, bucket_count = buckets.len()))]
#[hotpath::measure]
@@ -212,9 +263,15 @@ impl ErasureSetHealer {
.await;
result?;
self.verify_replacement_identity_fence("completion").await?;
if self.replacement_task_id.is_some() {
// A replacement marker must outlive the successful data scan. The
// task clears that owner marker before deleting these artifacts.
resume_manager.mark_replacement_completed_and_verified().await?;
return Ok(());
}
// The healing marker is cleared by the caller only after both cleanup
// operations succeed. Cleanup is idempotent, so a retry is safe.
checkpoint_manager.cleanup().await?;
resume_manager.cleanup().await?;
Ok(())
@@ -222,6 +279,21 @@ impl ErasureSetHealer {
/// get or create task id
async fn get_or_create_task_id(&self, set_disk_id: &str) -> Result<String> {
if let Some(task_id) = &self.replacement_task_id {
let manager = ResumeManager::load_replacement_intent(self.disk.clone(), task_id).await?;
let state = manager.get_state().await;
if !state.completed
&& state.set_disk_id == set_disk_id
&& state.replacement_targets.as_slice() == self.target_endpoints.as_ref()
&& state.replacement_generation.as_deref() == Some(task_id.as_str())
{
return Ok(task_id.clone());
}
return Err(Error::TaskExecutionFailed {
message: format!("Replacement resume intent does not match task {task_id}"),
});
}
// check if there are resumable tasks
let resumable_tasks = ResumeUtils::get_resumable_tasks(&self.disk).await?;
@@ -231,6 +303,7 @@ impl ErasureSetHealer {
let state = manager.get_state().await;
if !state.completed
&& state.set_disk_id == set_disk_id
&& state.replacement_targets.as_slice() == self.target_endpoints.as_ref()
&& ResumeUtils::can_resume_task(&self.disk, &task_id).await
{
debug!(
@@ -263,7 +336,7 @@ impl ErasureSetHealer {
}
// create new task id
let task_id = format!("{}_{}", set_disk_id, ResumeUtils::generate_task_id());
let task_id = ResumeUtils::generate_task_id();
debug!(
target: "rustfs::heal::erasure_healer",
event = EVENT_HEAL_ERASURE_RESUME_STATE,
@@ -285,7 +358,12 @@ impl ErasureSetHealer {
buckets: &[String],
) -> Result<(ResumeManager, CheckpointManager)> {
// check if resume state exists
if ResumeManager::has_resume_state(&self.disk, task_id).await {
let has_resume_state = if self.replacement_task_id.is_some() {
ResumeManager::has_replacement_intent(&self.disk, task_id).await
} else {
ResumeManager::has_resume_state(&self.disk, task_id).await
};
if has_resume_state {
debug!(
target: "rustfs::heal::erasure_healer",
event = EVENT_HEAL_ERASURE_RESUME_STATE,
@@ -297,7 +375,11 @@ impl ErasureSetHealer {
"Erasure set resume state loading"
);
let resume_manager = ResumeManager::load_from_disk(self.disk.clone(), task_id).await?;
let resume_manager = if self.replacement_task_id.is_some() {
ResumeManager::load_replacement_intent(self.disk.clone(), task_id).await?
} else {
ResumeManager::load_from_disk(self.disk.clone(), task_id).await?
};
let checkpoint_manager = if CheckpointManager::has_checkpoint(&self.disk, task_id).await {
CheckpointManager::load_from_disk(self.disk.clone(), task_id).await?
} else {
@@ -340,6 +422,9 @@ impl ErasureSetHealer {
buckets.to_vec(),
)
.await?;
resume_manager
.set_replacement_targets(self.target_endpoints.as_ref().to_vec())
.await?;
let checkpoint_manager = CheckpointManager::new(self.disk.clone(), task_id.to_string()).await?;
@@ -485,6 +570,12 @@ impl ErasureSetHealer {
// later heal cycle via the same bounded-retry mechanism as failures —
// never hot-retried in place here.
if failed_objects > 0 || skipped_objects > 0 || failed_buckets > 0 {
if self.replacement_task_id.is_some() && resume_manager.schedule_retry().await? {
checkpoint_manager.reset_for_retry().await?;
return Err(Error::transient_skip(format!(
"Replacement erasure set heal incomplete: {failed_buckets} bucket(s) failed, {failed_objects} object(s) failed, {skipped_objects} object(s) skipped; retry scheduled"
)));
}
if resume_manager.schedule_retry().await? {
// Both persistence layers must be reset together: schedule_retry
// rewinds the resume state (cursor + counters), and the
@@ -508,15 +599,15 @@ impl ErasureSetHealer {
state = "retry_scheduled",
"Erasure set heal pass finished with unhealed versions; scheduled full re-heal retry"
);
return Err(Error::other(format!(
return Err(Error::transient_skip(format!(
"Erasure set heal incomplete: {failed_buckets} bucket(s) failed, {failed_objects} object(s) failed, {skipped_objects} object(s) skipped; retry scheduled"
)));
}
// Retry budget exhausted: drop the resume/checkpoint state so this
// task does not loop, but keep the healing markers (return Err) so a
// later heal cycle / the background scanner starts a fresh attempt.
// Never silently claim a clean completion while objects are unhealed.
// Retry budget exhausted: keep the resume/checkpoint state while
// the replacement marker remains. A later repair must retain the
// durable evidence of the incomplete generation instead of
// starting from an indistinguishable blank state.
error!(
target: "rustfs::heal::erasure_healer",
event = EVENT_HEAL_ERASURE_RESUME_STATE,
@@ -529,15 +620,16 @@ impl ErasureSetHealer {
state = "failed_after_retries",
"Erasure set heal exhausted retries with unrecovered versions"
);
checkpoint_manager.cleanup().await?;
resume_manager.cleanup().await?;
return Err(Error::other(format!(
"Erasure set heal exhausted retries with {failed_buckets} bucket(s) failed, {failed_objects} object(s) failed, {skipped_objects} object(s) skipped"
)));
}
// no failures — mark task completed
resume_manager.mark_completed().await?;
// No failures — ordinary heals are complete now. Replacement heals
// atomically transition to Verified after the terminal identity fence.
if self.replacement_task_id.is_none() {
resume_manager.mark_completed().await?;
}
debug!(
target: "rustfs::heal::erasure_healer",
@@ -628,6 +720,7 @@ impl ErasureSetHealer {
matches!(self.heal_opts.scan_mode, HealScanMode::Deep) || matches!(self.source, HealRequestSource::AutoHeal);
loop {
self.verify_replacement_identity_fence("page scan").await?;
// Get one page of object versions
let (objects, next_token, is_truncated) = if use_disk_walk {
self.storage
@@ -672,6 +765,8 @@ impl ErasureSetHealer {
let set_label = set_disk_id.to_string();
let heal_opts = self.heal_opts;
let semaphore = semaphore.clone();
let target_endpoints = self.target_endpoints.clone();
let replacement_commit_evidence_required = self.replacement_task_id.is_some();
page_tasks.push(async move {
let permit = semaphore
@@ -699,6 +794,35 @@ impl ErasureSetHealer {
.heal_object(&bucket_name, &object_name, version_id.as_deref(), &heal_opts)
.await
{
Ok((result, None))
if target_outcomes_complete(&result, &target_endpoints) =>
{
if !replacement_commit_evidence_required {
Ok(true)
} else {
match storage
.replacement_targets_have_version(
&bucket_name,
&object_name,
version_id.as_deref(),
&heal_opts,
&target_endpoints,
)
.await
{
Ok(true) => Ok(true),
Ok(false) => Err(Error::transient_skip(format!(
"Skipped heal for {bucket_name}/{object_name} because replacement target readback did not confirm the committed version"
))),
Err(err) => Err(Error::transient_skip(format!(
"Skipped heal for {bucket_name}/{object_name} because replacement target readback failed: {err}"
))),
}
}
}
Ok((_result, None)) if !target_endpoints.is_empty() => Err(Error::transient_skip(format!(
"Skipped heal for {bucket_name}/{object_name} because a replacement target was not committed"
))),
Ok((_result, None)) => Ok(true),
Ok((_, Some(err))) if is_missing_object_dir_heal_result(&object_name, &err) => Ok(false),
Ok((_, Some(err))) | Err(err) => match Self::classify_heal_object_error(&err) {
@@ -1011,9 +1135,12 @@ mod resume_loop_tests {
//! that emits programmable multi-version pages. These exercise the real loop
//! logic (cursor seeding, per-version dedup, anti-loop guard, absence
//! handling) — not merely a mock's own output.
use super::ErasureSetHealer;
use super::{ErasureSetHealer, target_outcomes_complete};
use crate::heal::progress::HealProgress;
use crate::heal::resume::{CheckpointManager, RESUME_CHECKPOINT_FILE, ResumeDeleteFailure, ResumeManager, compose_key};
use crate::heal::resume::{
CheckpointManager, RESUME_CHECKPOINT_FILE, ReplacementTargetIdentity, ResumeDeleteFailure, ResumeManager, ResumeUtils,
compose_key,
};
use crate::heal::storage::{DiskStatus, HealListItem, HealObjectInfo, HealStorageAPI};
use crate::heal::storage_api::status::BucketInfo;
use crate::heal::{
@@ -1021,8 +1148,8 @@ mod resume_loop_tests {
};
use crate::{Error, Result};
use rustfs_common::heal_channel::{HealOpts, HealRequestSource};
use rustfs_madmin::heal_commands::HealResultItem;
use std::collections::HashMap;
use rustfs_madmin::heal_commands::{HealDriveInfo, HealResultItem, Infos};
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tempfile::TempDir;
@@ -1037,6 +1164,53 @@ mod resume_loop_tests {
}
}
#[test]
fn target_outcomes_require_each_requested_endpoint_once_and_ok() {
let result = HealResultItem {
after: Infos {
drives: vec![
HealDriveInfo {
endpoint: "replacement-a".to_string(),
state: "ok".to_string(),
..Default::default()
},
HealDriveInfo {
endpoint: "replacement-b".to_string(),
state: "missing".to_string(),
..Default::default()
},
],
},
..Default::default()
};
assert!(target_outcomes_complete(&result, &["replacement-a".to_string()]));
assert!(!target_outcomes_complete(
&result,
&["replacement-a".to_string(), "replacement-b".to_string()]
));
assert!(!target_outcomes_complete(&result, &["replacement-c".to_string()]));
let duplicate = HealResultItem {
after: Infos {
drives: vec![
HealDriveInfo {
endpoint: "replacement-a".to_string(),
state: "ok".to_string(),
..Default::default()
},
HealDriveInfo {
endpoint: "replacement-a".to_string(),
state: "missing".to_string(),
..Default::default()
},
],
},
..Default::default()
};
assert!(!target_outcomes_complete(&duplicate, &["replacement-a".to_string()]));
}
#[derive(Clone)]
struct Page {
items: Vec<HealListItem>,
@@ -1055,14 +1229,26 @@ mod resume_loop_tests {
Timeout,
}
#[derive(Clone)]
enum ReplacementCommitEvidence {
Confirmed(bool),
Error(String),
}
#[derive(Default)]
struct FakeStorage {
/// page keyed by the *incoming* continuation token
pages: Mutex<HashMap<Option<String>, Page>>,
/// per-`compose_key` heal outcome; default is `Ok`
outcomes: Mutex<HashMap<String, HealOutcome>>,
/// successful low-level result per `compose_key`; default has no drive outcomes.
results: Mutex<HashMap<String, HealResultItem>>,
/// Target-specific physical readback evidence per `compose_key`; the
/// fake models a healthy backend unless a test explicitly revokes it.
replacement_commit_evidence: Mutex<HashMap<String, ReplacementCommitEvidence>>,
/// every heal_object call recorded as (name, version_id)
heal_calls: Mutex<Vec<(String, Option<String>)>>,
replacement_target_identity_sequences: Mutex<VecDeque<Vec<ReplacementTargetIdentity>>>,
fail_listing: AtomicBool,
}
@@ -1073,6 +1259,21 @@ mod resume_loop_tests {
fn set_outcome(&self, name: &str, version: Option<&str>, outcome: HealOutcome) {
self.outcomes.lock().unwrap().insert(compose_key(name, version), outcome);
}
fn set_result(&self, name: &str, version: Option<&str>, result: HealResultItem) {
self.results.lock().unwrap().insert(compose_key(name, version), result);
}
fn set_replacement_commit_evidence(&self, name: &str, version: Option<&str>, committed: bool) {
self.replacement_commit_evidence
.lock()
.unwrap()
.insert(compose_key(name, version), ReplacementCommitEvidence::Confirmed(committed));
}
fn set_replacement_commit_evidence_error(&self, name: &str, version: Option<&str>, message: &str) {
self.replacement_commit_evidence
.lock()
.unwrap()
.insert(compose_key(name, version), ReplacementCommitEvidence::Error(message.to_string()));
}
fn calls(&self) -> Vec<(String, Option<String>)> {
self.heal_calls.lock().unwrap().clone()
}
@@ -1143,7 +1344,7 @@ mod resume_loop_tests {
let key = compose_key(object, version_id);
let outcome = self.outcomes.lock().unwrap().get(&key).cloned().unwrap_or(HealOutcome::Ok);
match outcome {
HealOutcome::Ok => Ok((HealResultItem::default(), None)),
HealOutcome::Ok => Ok((self.results.lock().unwrap().get(&key).cloned().unwrap_or_default(), None)),
HealOutcome::VersionNotFound => {
Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::FileVersionNotFound))))
}
@@ -1157,6 +1358,26 @@ mod resume_loop_tests {
async fn heal_format(&self, _dry: bool) -> Result<(HealResultItem, Option<Error>)> {
Ok((HealResultItem::default(), None))
}
async fn replacement_targets_have_version(
&self,
_bucket: &str,
object: &str,
version_id: Option<&str>,
_opts: &HealOpts,
_targets: &[String],
) -> Result<bool> {
match self
.replacement_commit_evidence
.lock()
.unwrap()
.get(&compose_key(object, version_id))
.cloned()
.unwrap_or(ReplacementCommitEvidence::Confirmed(true))
{
ReplacementCommitEvidence::Confirmed(committed) => Ok(committed),
ReplacementCommitEvidence::Error(message) => Err(Error::other(message)),
}
}
async fn list_objects_for_heal(&self, _b: &str, _p: &str) -> Result<Vec<HealListItem>> {
Ok(Vec::new())
}
@@ -1179,6 +1400,13 @@ mod resume_loop_tests {
async fn get_disk_for_resume(&self, _id: &str) -> Result<DiskStore> {
Err(Error::other("not implemented in tests"))
}
async fn replacement_target_identities(&self, _targets: &[String]) -> Result<Vec<ReplacementTargetIdentity>> {
self.replacement_target_identity_sequences
.lock()
.unwrap()
.pop_front()
.ok_or_else(|| Error::other("replacement identity sequence exhausted"))
}
}
async fn make_disk(temp: &TempDir) -> DiskStore {
@@ -1204,13 +1432,19 @@ mod resume_loop_tests {
storage: Arc<FakeStorage>,
resume: ResumeManager,
checkpoint: CheckpointManager,
task_id: String,
_temp: TempDir,
}
async fn make_env() -> Env {
make_env_with_targets(Vec::new()).await
}
async fn make_env_with_targets(target_endpoints: Vec<String>) -> Env {
let temp = TempDir::new().unwrap();
let disk = make_disk(&temp).await;
let storage = Arc::new(FakeStorage::default());
let task_id = ResumeUtils::generate_task_id();
let healer = ErasureSetHealer::new(
storage.clone(),
Arc::new(RwLock::new(HealProgress::new())),
@@ -1218,22 +1452,24 @@ mod resume_loop_tests {
disk.clone(),
HealOpts::default(),
HealRequestSource::Internal,
);
)
.with_replacement_targets(target_endpoints, None);
let resume = ResumeManager::new(
disk.clone(),
"task".to_string(),
task_id.clone(),
"erasure_set".to_string(),
"pool_0_set_0".to_string(),
vec!["b".to_string()],
)
.await
.unwrap();
let checkpoint = CheckpointManager::new(disk, "task".to_string()).await.unwrap();
let checkpoint = CheckpointManager::new(disk, task_id.clone()).await.unwrap();
Env {
healer,
storage,
resume,
checkpoint,
task_id,
_temp: temp,
}
}
@@ -1277,6 +1513,112 @@ mod resume_loop_tests {
assert_eq!(env.resume.resume_cursor().await, None);
}
#[tokio::test]
async fn replacement_targets_use_a_canonical_order() {
let env = make_env_with_targets(vec![
"replacement-b".to_string(),
"replacement-a".to_string(),
"replacement-b".to_string(),
])
.await;
assert_eq!(env.healer.target_endpoints.as_ref(), ["replacement-a", "replacement-b"]);
}
#[tokio::test]
async fn replacement_identity_fence_rejects_a_remount_before_page_scan() {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
let expected_identity = ReplacementTargetIdentity {
endpoint: "replacement-a".to_string(),
canonical_path: "/mnt/replacement-a".to_string(),
physical_device_ids: vec!["device-a".to_string()],
filesystem_identity: "filesystem-a".to_string(),
};
let remounted_identity = ReplacementTargetIdentity {
physical_device_ids: vec!["device-b".to_string()],
filesystem_identity: "filesystem-b".to_string(),
..expected_identity.clone()
};
env.storage
.replacement_target_identity_sequences
.lock()
.unwrap()
.push_back(vec![remounted_identity]);
let healer = ErasureSetHealer::new(
env.storage.clone(),
Arc::new(RwLock::new(HealProgress::new())),
CancellationToken::new(),
env.healer.disk.clone(),
HealOpts::default(),
HealRequestSource::AutoHeal,
)
.with_replacement_targets(vec!["replacement-a".to_string()], Some("generation-a".to_string()))
.with_replacement_identity_fence(Some(vec![expected_identity]));
let mut current_object_index = 0;
let mut processed = 0;
let mut successful = 0;
let mut failed = 0;
let mut skipped = 0;
let error = healer
.heal_bucket_with_resume(
"b",
"pool_0_set_0",
0,
&mut current_object_index,
&mut processed,
&mut successful,
&mut failed,
&mut skipped,
&env.resume,
&env.checkpoint,
)
.await
.expect_err("a remounted target must not begin a new page scan");
assert!(error.to_string().contains("page scan"));
assert!(env.storage.calls().is_empty());
}
#[tokio::test]
async fn replacement_generation_never_reuses_another_disk_cursor() {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
ResumeManager::new_replacement_intent(
env.healer.disk.clone(),
ResumeUtils::generate_task_id(),
"pool_0_set_0".to_string(),
vec!["b".to_string()],
vec!["replacement-a".to_string()],
vec![crate::heal::resume::ReplacementTargetIdentity {
endpoint: "replacement-a".to_string(),
canonical_path: "/mnt/replacement-a".to_string(),
physical_device_ids: vec!["device-a".to_string()],
filesystem_identity: "1:2:3".to_string(),
}],
)
.await
.expect("first replacement intent should persist");
let healer = ErasureSetHealer::new(
env.storage.clone(),
Arc::new(RwLock::new(HealProgress::new())),
CancellationToken::new(),
env.healer.disk.clone(),
HealOpts::default(),
HealRequestSource::AutoHeal,
)
.with_replacement_targets(vec!["replacement-a".to_string()], Some(ResumeUtils::generate_task_id()));
let error = healer
.get_or_create_task_id("pool_0_set_0")
.await
.expect_err("a second replacement must not reuse the first replacement cursor");
assert!(
!error.to_string().contains("generation-a"),
"the previous replacement generation must not be selected"
);
}
#[tokio::test]
async fn object_timeout_aborts_the_bucket_page_immediately() {
let env = make_env().await;
@@ -1330,13 +1672,14 @@ mod resume_loop_tests {
.await
.expect("new heal should allocate a task id");
assert_ne!(task_id, "task", "a completed resume state must not suppress a new heal");
assert_ne!(task_id, env.task_id, "a completed resume state must not suppress a new heal");
assert!(uuid::Uuid::parse_str(&task_id).is_ok(), "new resume task ids must be UUIDs");
}
#[tokio::test]
async fn cleanup_failure_keeps_erasure_set_heal_incomplete() {
let env = make_env().await;
let checkpoint_path = format!("{BUCKET_META_PREFIX}/task_{RESUME_CHECKPOINT_FILE}");
let checkpoint_path = format!("{BUCKET_META_PREFIX}/{}_{RESUME_CHECKPOINT_FILE}", env.task_id);
let _failure = ResumeDeleteFailure::install(checkpoint_path, crate::heal::DiskError::DiskAccessDenied);
let error = env
@@ -1346,7 +1689,7 @@ mod resume_loop_tests {
.expect_err("checkpoint cleanup failure must fail the erasure-set heal");
assert!(matches!(error, Error::Disk(crate::heal::DiskError::DiskAccessDenied)));
let state = ResumeManager::load_from_disk(env.healer.disk.clone(), "task")
let state = ResumeManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
.await
.expect("completed state must remain discoverable after cleanup failure")
.get_state()
@@ -1354,6 +1697,84 @@ mod resume_loop_tests {
assert!(state.completed, "successful data heal must be persisted before cleanup is attempted");
}
#[tokio::test]
async fn replacement_completion_keeps_resume_artifacts_until_marker_cleanup() {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
let replacement_task_id = ResumeUtils::generate_task_id();
ResumeManager::new_replacement_intent(
env.healer.disk.clone(),
replacement_task_id.clone(),
"pool_0_set_0".to_string(),
vec!["b".to_string()],
vec!["replacement-a".to_string()],
vec![crate::heal::resume::ReplacementTargetIdentity {
endpoint: "replacement-a".to_string(),
canonical_path: "/mnt/replacement-a".to_string(),
physical_device_ids: vec!["device-a".to_string()],
filesystem_identity: "1:2:3".to_string(),
}],
)
.await
.expect("replacement intent should persist");
let checkpoint = CheckpointManager::new(env.healer.disk.clone(), replacement_task_id.clone())
.await
.expect("replacement checkpoint should persist");
let healer = ErasureSetHealer::new(
env.storage.clone(),
Arc::new(RwLock::new(HealProgress::new())),
CancellationToken::new(),
env.healer.disk.clone(),
HealOpts::default(),
HealRequestSource::AutoHeal,
)
.with_replacement_targets(vec!["replacement-a".to_string()], Some(replacement_task_id.clone()));
healer
.heal_erasure_set(&["b".to_string()], "pool_0_set_0")
.await
.expect("replacement data scan should complete");
let state = ResumeManager::load_replacement_intent(env.healer.disk.clone(), &replacement_task_id)
.await
.expect("verified replacement state must remain after data scan")
.get_state()
.await;
assert!(state.completed, "the verified state must record a completed data scan");
assert_eq!(state.replacement_phase, crate::heal::resume::ReplacementPhase::Verified);
assert!(
CheckpointManager::has_checkpoint(&env.healer.disk, &replacement_task_id).await,
"the checkpoint must survive until the caller clears the healing marker"
);
drop(checkpoint);
}
#[tokio::test]
async fn retry_exhaustion_keeps_resume_artifacts_for_recovery() {
let env = make_env().await;
for _ in 0..3 {
assert!(env.resume.schedule_retry().await.expect("retry state should persist"));
env.checkpoint
.reset_for_retry()
.await
.expect("checkpoint reset should persist");
}
env.storage.fail_listing();
env.healer
.execute_heal_with_resume(&["b".to_string()], "pool_0_set_0", &env.resume, &env.checkpoint)
.await
.expect_err("exhausted retry state must report the incomplete heal");
assert!(
ResumeManager::has_resume_state(&env.healer.disk, &env.task_id).await,
"retry exhaustion must not delete the resumable state while a marker may remain"
);
assert!(
CheckpointManager::has_checkpoint(&env.healer.disk, &env.task_id).await,
"retry exhaustion must retain the checkpoint with the resumable state"
);
}
#[tokio::test]
async fn retry_resume_repairs_checkpoint_after_crash_between_resets() {
let env = make_env().await;
@@ -1385,7 +1806,7 @@ mod resume_loop_tests {
let (_, checkpoint) = env
.healer
.initialize_resume_state("task", "pool_0_set_0", &["b".to_string()])
.initialize_resume_state(&env.task_id, "pool_0_set_0", &["b".to_string()])
.await
.expect("resume initialization should repair a stale checkpoint");
let checkpoint = checkpoint.get_checkpoint().await;
@@ -1653,4 +2074,184 @@ mod resume_loop_tests {
"the skipped set must be cleared so the retry re-heals the version"
);
}
#[tokio::test]
async fn replacement_target_missing_from_success_result_retries_the_full_pass() {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
env.storage.set_page(
None,
Page {
items: vec![item("object", Some("v1"), false)],
next: None,
truncated: false,
},
);
env.storage.set_result(
"object",
Some("v1"),
HealResultItem {
after: Infos {
drives: vec![HealDriveInfo {
endpoint: "replacement-a".to_string(),
state: "missing".to_string(),
..Default::default()
}],
},
..Default::default()
},
);
let result = env
.healer
.execute_heal_with_resume(&["b".to_string()], "pool_0_set_0", &env.resume, &env.checkpoint)
.await;
result.expect_err("a missing replacement target must not report completion");
let state = env.resume.get_state().await;
assert!(!state.completed);
assert_eq!(state.retry_count, 1);
assert_eq!(env.storage.calls(), vec![("object".to_string(), Some("v1".to_string()))]);
assert!(env.checkpoint.get_checkpoint().await.processed_objects.is_empty());
}
#[tokio::test]
async fn replacement_target_readback_evidence_must_confirm_the_healed_version() {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
let healer = ErasureSetHealer::new(
env.storage.clone(),
Arc::new(RwLock::new(HealProgress::new())),
CancellationToken::new(),
env.healer.disk.clone(),
HealOpts::default(),
HealRequestSource::AutoHeal,
)
.with_replacement_targets(vec!["replacement-a".to_string()], Some("generation-a".to_string()));
env.storage.set_page(
None,
Page {
items: vec![item("object", Some("v1"), false)],
next: None,
truncated: false,
},
);
env.storage.set_result(
"object",
Some("v1"),
HealResultItem {
after: Infos {
drives: vec![HealDriveInfo {
endpoint: "replacement-a".to_string(),
state: "ok".to_string(),
..Default::default()
}],
},
..Default::default()
},
);
env.storage.set_replacement_commit_evidence("object", Some("v1"), false);
let result = healer
.execute_heal_with_resume(&["b".to_string()], "pool_0_set_0", &env.resume, &env.checkpoint)
.await;
result.expect_err("a success result without target readback evidence must retry");
assert_eq!(env.resume.get_state().await.retry_count, 1);
assert!(env.checkpoint.get_checkpoint().await.processed_objects.is_empty());
}
#[tokio::test]
async fn replacement_delete_marker_readback_error_keeps_auto_heal_resumable() {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
let healer = ErasureSetHealer::new(
env.storage.clone(),
Arc::new(RwLock::new(HealProgress::new())),
CancellationToken::new(),
env.healer.disk.clone(),
HealOpts::default(),
HealRequestSource::AutoHeal,
)
.with_replacement_targets(vec!["replacement-a".to_string()], Some("generation-a".to_string()));
env.storage.set_page(
None,
Page {
items: vec![item("object", Some("dm-v1"), true)],
next: None,
truncated: false,
},
);
env.storage.set_result(
"object",
Some("dm-v1"),
HealResultItem {
after: Infos {
drives: vec![HealDriveInfo {
endpoint: "replacement-a".to_string(),
state: "ok".to_string(),
..Default::default()
}],
},
..Default::default()
},
);
env.storage
.set_replacement_commit_evidence_error("object", Some("dm-v1"), "injected target readback failure");
let result = healer
.execute_heal_with_resume(&["b".to_string()], "pool_0_set_0", &env.resume, &env.checkpoint)
.await;
let error = result.expect_err("a target readback error must not complete automatic replacement");
let error = error.to_string();
assert!(error.contains("Transient heal skip"), "unexpected error: {error}");
assert!(error.contains("retry scheduled"), "unexpected error: {error}");
let state = env.resume.get_state().await;
assert!(!state.completed, "readback errors must leave the replacement task incomplete");
assert_eq!(state.retry_count, 1, "readback errors must arm the bounded retry path");
assert!(env.checkpoint.get_checkpoint().await.processed_objects.is_empty());
assert_eq!(env.storage.calls(), vec![("object".to_string(), Some("dm-v1".to_string()))]);
}
#[tokio::test]
async fn manual_targeted_heal_keeps_existing_best_effort_result_semantics() {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
let healer = ErasureSetHealer::new(
env.storage.clone(),
Arc::new(RwLock::new(HealProgress::new())),
CancellationToken::new(),
env.healer.disk.clone(),
HealOpts::default(),
HealRequestSource::Admin,
)
.with_replacement_targets(vec!["replacement-a".to_string()], None);
env.storage.set_page(
None,
Page {
items: vec![item("object", Some("v1"), false)],
next: None,
truncated: false,
},
);
env.storage.set_result(
"object",
Some("v1"),
HealResultItem {
after: Infos {
drives: vec![HealDriveInfo {
endpoint: "replacement-a".to_string(),
state: "ok".to_string(),
..Default::default()
}],
},
..Default::default()
},
);
env.storage.set_replacement_commit_evidence("object", Some("v1"), false);
healer
.execute_heal_with_resume(&["b".to_string()], "pool_0_set_0", &env.resume, &env.checkpoint)
.await
.expect("manual targeted healing must retain its existing success semantics");
assert_eq!(env.resume.get_state().await.retry_count, 0);
}
}
File diff suppressed because it is too large Load Diff
+327 -35
View File
@@ -17,6 +17,7 @@ pub mod erasure_healer;
pub mod event;
pub mod manager;
pub mod progress;
pub(crate) mod replacement_readiness;
pub mod resume;
pub mod storage;
pub(crate) mod storage_api;
@@ -25,8 +26,8 @@ pub mod utils;
use storage_api::owner::{
ECSTORE_BUCKET_META_PREFIX, ECSTORE_DATA_USAGE_CACHE_NAME, ECSTORE_HEALING_MARKER_PATH, ECSTORE_RUSTFS_META_BUCKET,
EcstoreDeleteOptions, EcstoreDiskAPI, EcstoreDiskBytes, EcstoreDiskError, EcstoreDiskResult, EcstoreDiskStore,
EcstoreEndpoint, EcstoreErrorType, EcstoreStorageError, EcstoreStore, ObjectIO, ObjectOperations,
EcstoreConditionalFileUpdate, EcstoreDeleteOptions, EcstoreDiskAPI, EcstoreDiskBytes, EcstoreDiskError, EcstoreDiskResult,
EcstoreDiskStore, EcstoreEndpoint, EcstoreErrorType, EcstoreStorageError, EcstoreStore, ObjectIO, ObjectOperations,
ecstore_local_disk_map_read,
};
#[cfg(test)]
@@ -76,56 +77,155 @@ pub(crate) const HEALING_MARKER_PATH: &str = ECSTORE_HEALING_MARKER_PATH;
/// Write the healing marker on the local disks matching `endpoints` so their
/// `DiskInfo.healing` reports true while the erasure-set heal rebuilds them.
pub(crate) async fn set_healing_markers(endpoints: &[String], set_disk_id: &str) {
apply_healing_markers(endpoints, Some(set_disk_id)).await;
pub(crate) async fn set_healing_markers(endpoints: &[String], marker: &str) -> crate::Result<()> {
apply_healing_markers(endpoints, Some(marker), None, false).await
}
/// Remove the healing markers written by [`set_healing_markers`].
pub(crate) async fn clear_healing_markers(endpoints: &[String]) {
apply_healing_markers(endpoints, None).await;
/// Remove an owner marker after the replacement scan's verified state is
/// durable. A missing marker is idempotent here because a crash may have
/// happened after the previous terminal clear and before resume cleanup.
pub(crate) async fn clear_healing_markers_after_verified(endpoints: &[String], marker: &str) -> crate::Result<()> {
apply_healing_markers(endpoints, None, Some(marker), true).await
}
async fn apply_healing_markers(endpoints: &[String], set_disk_id: Option<&str>) {
#[cfg(test)]
fn marker_matches(current: &[u8], expected_marker: Option<&str>) -> bool {
expected_marker.is_some_and(|expected| current == expected.as_bytes())
}
async fn apply_healing_markers(
endpoints: &[String],
marker: Option<&str>,
expected_marker: Option<&str>,
allow_missing: bool,
) -> crate::Result<()> {
if endpoints.is_empty() {
return;
return Ok(());
}
let local_disk_map = local_disk_map_read().await;
for disk in local_disk_map.values().flatten() {
let endpoint = EcstoreDiskAPI::endpoint(disk.as_ref()).to_string();
if !endpoints.iter().any(|candidate| candidate == &endpoint) {
continue;
let mut local_disks = std::collections::HashMap::new();
{
let local_disk_map = local_disk_map_read().await;
for disk in local_disk_map.values().flatten() {
local_disks.insert(EcstoreDiskAPI::endpoint(disk.as_ref()).to_string(), disk.clone());
}
let result = match set_disk_id {
Some(set_disk_id) => {
EcstoreDiskAPI::write_all(
}
let mut matched_endpoints = std::collections::HashSet::new();
let mut targets = Vec::with_capacity(endpoints.len());
for endpoint in endpoints {
if !matched_endpoints.insert(endpoint.clone()) {
return Err(DiskError::other("healing marker endpoint is duplicated").into());
}
let Some(disk) = local_disks.remove(endpoint) else {
return Err(DiskError::other("healing marker target is unavailable").into());
};
targets.push(disk);
}
apply_healing_markers_to_targets(targets, marker, expected_marker, allow_missing).await
}
async fn apply_healing_markers_to_targets(
targets: Vec<DiskStore>,
marker: Option<&str>,
expected_marker: Option<&str>,
allow_missing: bool,
) -> crate::Result<()> {
apply_healing_markers_to_targets_with_after_acquire(targets, marker, expected_marker, allow_missing, |_| {}).await
}
async fn apply_healing_markers_to_targets_with_after_acquire<F>(
targets: Vec<DiskStore>,
marker: Option<&str>,
expected_marker: Option<&str>,
allow_missing: bool,
mut after_acquire: F,
) -> crate::Result<()>
where
F: FnMut(&DiskStore),
{
let marker_bytes = marker.map(|marker| EcstoreDiskBytes::copy_from_slice(marker.as_bytes()));
let expected_bytes = expected_marker.map(|marker| EcstoreDiskBytes::copy_from_slice(marker.as_bytes()));
let mut newly_acquired = Vec::new();
for disk in targets {
let result = match marker_bytes.as_ref() {
Some(marker) => {
match EcstoreDiskAPI::compare_and_update_file(
disk.as_ref(),
RUSTFS_META_BUCKET,
HEALING_MARKER_PATH,
EcstoreDiskBytes::copy_from_slice(set_disk_id.as_bytes()),
None,
Some(marker.clone()),
)
.await
{
Ok(EcstoreConditionalFileUpdate::Updated) => {
newly_acquired.push(disk.clone());
after_acquire(&disk);
Ok(())
}
Ok(EcstoreConditionalFileUpdate::Mismatch) => match EcstoreDiskAPI::compare_and_update_file(
disk.as_ref(),
RUSTFS_META_BUCKET,
HEALING_MARKER_PATH,
Some(marker.clone()),
Some(marker.clone()),
)
.await
{
Ok(EcstoreConditionalFileUpdate::Updated) => Ok(()),
Ok(_) => Err(DiskError::other("healing marker ownership changed")),
Err(err) => Err(err),
},
Ok(EcstoreConditionalFileUpdate::Missing) => Err(DiskError::other("healing marker disappeared")),
Err(err) => Err(err),
}
}
None => {
match EcstoreDiskAPI::compare_and_update_file(
disk.as_ref(),
RUSTFS_META_BUCKET,
HEALING_MARKER_PATH,
expected_bytes.clone(),
None,
)
.await
{
Ok(EcstoreConditionalFileUpdate::Updated) => Ok(()),
Ok(EcstoreConditionalFileUpdate::Missing) if allow_missing => Ok(()),
Ok(EcstoreConditionalFileUpdate::Missing) => Err(DiskError::other("healing marker is missing")),
Ok(EcstoreConditionalFileUpdate::Mismatch) => Err(DiskError::other("healing marker ownership changed")),
Err(err) => Err(err),
}
}
None => match EcstoreDiskAPI::delete(
disk.as_ref(),
RUSTFS_META_BUCKET,
HEALING_MARKER_PATH,
EcstoreDeleteOptions::default(),
)
.await
{
Err(DiskError::FileNotFound) => Ok(()),
other => other,
},
};
if let Err(err) = result {
tracing::warn!(
endpoint = %endpoint,
action = if set_disk_id.is_some() { "set" } else { "clear" },
error = ?err,
"failed to update healing marker"
);
if let Some(marker) = marker_bytes.as_ref() {
let mut rollback_error = None;
for acquired in newly_acquired.iter().rev() {
if let Err(rollback) = EcstoreDiskAPI::compare_and_update_file(
acquired.as_ref(),
RUSTFS_META_BUCKET,
HEALING_MARKER_PATH,
Some(marker.clone()),
None,
)
.await
{
rollback_error.get_or_insert(rollback);
}
}
if let Some(rollback) = rollback_error {
return Err(DiskError::other(format!(
"healing marker acquisition failed ({err}) and owner-safe rollback failed ({rollback})"
))
.into());
}
}
return Err(err.into());
}
}
Ok(())
}
pub(crate) type DiskError = EcstoreDiskError;
@@ -203,3 +303,195 @@ where
pub type HealObjectInfo = <ECStore as ObjectOperations>::ObjectInfo;
pub type HealObjectOptions = <ECStore as ObjectOperations>::ObjectOptions;
pub type HealPutObjReader = <ECStore as ObjectIO>::PutObjectReader;
#[cfg(test)]
mod tests {
use super::{
DiskError, DiskOption, Endpoint, HEALING_MARKER_PATH, RUSTFS_META_BUCKET, apply_healing_markers_to_targets,
apply_healing_markers_to_targets_with_after_acquire, marker_matches, new_disk,
};
use crate::{
Error,
heal::storage_api::owner::{EcstoreConditionalFileUpdate, EcstoreDiskAPI, EcstoreDiskBytes},
};
use tempfile::TempDir;
async fn make_marker_disk(temp: &TempDir, name: &str) -> super::DiskStore {
let path = temp.path().join(name);
std::fs::create_dir_all(&path).expect("marker disk directory should be created");
let endpoint = Endpoint::try_from(path.to_string_lossy().as_ref()).expect("marker disk endpoint should be valid");
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("marker disk should initialize");
let metadata_volume = disk.make_volume(RUSTFS_META_BUCKET).await;
assert!(
matches!(metadata_volume, Ok(()) | Err(DiskError::VolumeExists)),
"marker metadata volume should exist: {metadata_volume:?}"
);
disk
}
#[test]
fn marker_clear_requires_the_current_owner_token() {
assert!(marker_matches(b"set:task-a", Some("set:task-a")));
assert!(!marker_matches(b"set:task-b", Some("set:task-a")));
assert!(!marker_matches(b"set:task-a", None));
}
#[tokio::test]
async fn marker_acquisition_rolls_back_after_second_disk_ownership_conflict() {
let temp = TempDir::new().expect("marker test directory should be created");
let first = make_marker_disk(&temp, "first").await;
let second = make_marker_disk(&temp, "second").await;
let owner_b = EcstoreDiskBytes::from_static(b"owner-b");
assert_eq!(
EcstoreDiskAPI::compare_and_update_file(
second.as_ref(),
RUSTFS_META_BUCKET,
HEALING_MARKER_PATH,
None,
Some(owner_b.clone()),
)
.await
.expect("second disk owner should acquire marker"),
EcstoreConditionalFileUpdate::Updated
);
let err = apply_healing_markers_to_targets(vec![first.clone(), second.clone()], Some("owner-a"), None, false)
.await
.expect_err("second disk ownership must reject the partial acquisition");
assert!(matches!(err, Error::Disk(DiskError::Io(ref io)) if io.to_string() == "healing marker ownership changed"));
assert!(matches!(
EcstoreDiskAPI::read_all(first.as_ref(), RUSTFS_META_BUCKET, HEALING_MARKER_PATH).await,
Err(DiskError::FileNotFound)
));
assert_eq!(
EcstoreDiskAPI::read_all(second.as_ref(), RUSTFS_META_BUCKET, HEALING_MARKER_PATH)
.await
.expect("conflicting owner marker must remain"),
owner_b
);
}
#[tokio::test]
async fn marker_acquisition_rolls_back_after_second_disk_io_error() {
let temp = TempDir::new().expect("marker test directory should be created");
let first = make_marker_disk(&temp, "first").await;
let second_path = temp.path().join("second");
std::fs::create_dir_all(&second_path).expect("second marker disk directory should be created");
let second_endpoint =
Endpoint::try_from(second_path.to_string_lossy().as_ref()).expect("second marker endpoint should be valid");
let second = new_disk(
&second_endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("second marker disk should initialize");
std::fs::remove_dir_all(second_path.join(RUSTFS_META_BUCKET))
.expect("second marker metadata directory should be removed for the I/O failure fixture");
std::fs::write(second_path.join(RUSTFS_META_BUCKET), b"not a directory")
.expect("second marker volume should become an I/O failure fixture");
let err = apply_healing_markers_to_targets(vec![first.clone(), second], Some("owner-a"), None, false)
.await
.expect_err("second disk I/O failure must reject the partial acquisition");
assert!(
matches!(err, Error::Disk(DiskError::FileAccessDenied)),
"second marker operation must report its mapped filesystem failure: {err:?}"
);
assert!(matches!(
EcstoreDiskAPI::read_all(first.as_ref(), RUSTFS_META_BUCKET, HEALING_MARKER_PATH).await,
Err(DiskError::FileNotFound)
));
}
#[tokio::test]
async fn marker_acquisition_reports_an_owner_safe_rollback_io_failure() {
let temp = TempDir::new().expect("marker test directory should be created");
let first = make_marker_disk(&temp, "first").await;
let second = make_marker_disk(&temp, "second").await;
let owner_b = EcstoreDiskBytes::from_static(b"owner-b");
let first_path = EcstoreDiskAPI::path(first.as_ref());
let moved_metadata_path = first_path.join("metadata-before-rollback");
assert_eq!(
EcstoreDiskAPI::compare_and_update_file(
second.as_ref(),
RUSTFS_META_BUCKET,
HEALING_MARKER_PATH,
None,
Some(owner_b),
)
.await
.expect("second disk owner should acquire marker"),
EcstoreConditionalFileUpdate::Updated
);
let err =
apply_healing_markers_to_targets_with_after_acquire(vec![first, second], Some("owner-a"), None, false, |disk| {
let metadata_path = EcstoreDiskAPI::path(disk.as_ref()).join(RUSTFS_META_BUCKET);
std::fs::rename(&metadata_path, &moved_metadata_path)
.expect("first marker metadata should move after acquisition");
std::fs::write(&metadata_path, b"not a directory")
.expect("first marker metadata should become a rollback I/O failure fixture");
})
.await
.expect_err("rollback I/O failure must remain visible to the caller");
let message = err.to_string();
assert!(message.contains("healing marker acquisition failed"));
assert!(message.contains("owner-safe rollback failed"));
assert!(moved_metadata_path.join(HEALING_MARKER_PATH).exists());
}
#[tokio::test]
async fn concurrent_marker_acquisition_has_one_owner_on_every_disk() {
let temp = TempDir::new().expect("marker test directory should be created");
let first = make_marker_disk(&temp, "first").await;
let second = make_marker_disk(&temp, "second").await;
let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(3));
let owner_a_barrier = barrier.clone();
let owner_a_first = first.clone();
let owner_a_second = second.clone();
let owner_a = tokio::spawn(async move {
owner_a_barrier.wait().await;
apply_healing_markers_to_targets(vec![owner_a_first, owner_a_second], Some("owner-a"), None, false).await
});
let owner_b_barrier = barrier.clone();
let owner_b_first = first.clone();
let owner_b_second = second.clone();
let owner_b = tokio::spawn(async move {
owner_b_barrier.wait().await;
apply_healing_markers_to_targets(vec![owner_b_first, owner_b_second], Some("owner-b"), None, false).await
});
barrier.wait().await;
let owner_a_result = owner_a.await.expect("owner a task should join");
let owner_b_result = owner_b.await.expect("owner b task should join");
assert_ne!(
owner_a_result.is_ok(),
owner_b_result.is_ok(),
"exactly one owner must acquire both markers"
);
let winning_marker = if owner_a_result.is_ok() { b"owner-a" } else { b"owner-b" };
for disk in [&first, &second] {
assert_eq!(
EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, HEALING_MARKER_PATH)
.await
.expect("every disk must retain the winning owner marker"),
EcstoreDiskBytes::from_static(winning_marker)
);
}
}
}
@@ -0,0 +1,160 @@
// 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 std::{fs, path::Path};
#[cfg(test)]
use super::Endpoint;
use super::{DiskStore, HealDiskExt as _, local_disk_map_read, resume::ReplacementTargetIdentity};
pub(crate) async fn auto_replacement_target_ready(disk: &DiskStore, local_disks: &[DiskStore]) -> bool {
auto_replacement_target_identity(disk, local_disks).await.is_some()
}
pub(crate) async fn auto_replacement_target_identity(
disk: &DiskStore,
local_disks: &[DiskStore],
) -> Option<ReplacementTargetIdentity> {
let lease_root = disk.replacement_mount_lease_root()?;
let endpoint = disk.endpoint().to_string();
let sibling_lease_roots = local_disks
.iter()
.filter(|sibling| sibling.endpoint().is_local && sibling.endpoint().to_string() != endpoint)
.map(|sibling| sibling.replacement_mount_lease_root())
.collect::<Option<Vec<_>>>()?;
tokio::task::spawn_blocking(move || {
let canonical_path = fs::canonicalize(&lease_root).ok()?;
let metadata = fs::metadata(&lease_root).ok()?;
let Ok(target_device_ids) = rustfs_utils::os::get_physical_device_ids(lease_root.to_string_lossy().as_ref()) else {
return None;
};
let Ok(root_device_ids) = rustfs_utils::os::get_physical_device_ids("/") else {
return None;
};
if target_device_ids.is_empty()
|| root_device_ids.is_empty()
|| target_device_ids.iter().any(|target| root_device_ids.contains(target))
|| !rustfs_utils::os::is_mount_point(&canonical_path).unwrap_or(false)
{
return None;
}
if sibling_lease_roots.iter().any(|sibling_lease_root| {
rustfs_utils::os::get_physical_device_ids(sibling_lease_root.to_string_lossy().as_ref())
.map(|ids| ids.iter().any(|id| target_device_ids.contains(id)))
.unwrap_or(true)
}) {
return None;
}
let filesystem_identity = filesystem_identity(&metadata, &canonical_path)?;
Some(ReplacementTargetIdentity {
endpoint,
canonical_path: canonical_path.to_string_lossy().into_owned(),
physical_device_ids: target_device_ids,
filesystem_identity,
})
})
.await
.ok()
.flatten()
}
pub(crate) async fn auto_replacement_targets_ready(targets: &[String]) -> bool {
auto_replacement_target_identities(targets).await.is_some()
}
pub(crate) async fn auto_replacement_target_identities(targets: &[String]) -> Option<Vec<ReplacementTargetIdentity>> {
let local_disk_map = local_disk_map_read().await;
let local_disks = local_disk_map
.values()
.flatten()
.filter(|disk| disk.endpoint().is_local)
.cloned()
.collect::<Vec<_>>();
drop(local_disk_map);
let mut identities = Vec::with_capacity(targets.len());
for target in targets {
let disk = local_disks.iter().find(|disk| disk.endpoint().to_string() == *target)?;
identities.push(auto_replacement_target_identity(disk, &local_disks).await?);
}
identities.sort_by(|left, right| left.endpoint.cmp(&right.endpoint));
identities.dedup_by(|left, right| left.endpoint == right.endpoint);
(identities.len() == targets.len()).then_some(identities)
}
#[cfg(target_os = "linux")]
fn filesystem_identity(metadata: &fs::Metadata, canonical_path: &Path) -> Option<String> {
use std::os::unix::fs::MetadataExt as _;
let escaped_path = canonical_path.to_string_lossy().replace(' ', "\\040");
let mountinfo = fs::read_to_string("/proc/self/mountinfo").ok()?;
let mount_id = mountinfo.lines().find_map(|line| {
let mut fields = line.split_whitespace();
let mount_id = fields.next()?;
fields.next()?;
fields.next()?;
fields.next()?;
(fields.next()? == escaped_path).then_some(mount_id)
})?;
Some(format!("{mount_id}:{}:{}", metadata.dev(), metadata.ino()))
}
#[cfg(all(unix, not(target_os = "linux")))]
fn filesystem_identity(metadata: &fs::Metadata, _canonical_path: &Path) -> Option<String> {
use std::os::unix::fs::MetadataExt as _;
Some(format!("{}:{}", metadata.dev(), metadata.ino()))
}
#[cfg(not(unix))]
fn filesystem_identity(_metadata: &fs::Metadata, _canonical_path: &Path) -> Option<String> {
None
}
#[cfg(test)]
mod tests {
use super::super::{DiskOption, new_disk};
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn runtime_environment_cannot_bypass_mount_admission() {
temp_env::async_with_vars(
[
("RUSTFS_TEST_AUTO_REPLACEMENT_READINESS_BYPASS", Some("1")),
("RUSTFS_E2E_AUTO_REPLACEMENT_READINESS_BYPASS", Some("1")),
],
async {
let temp = TempDir::new().expect("temporary replacement root should be created");
let endpoint =
Endpoint::try_from(temp.path().to_string_lossy().as_ref()).expect("replacement endpoint should parse");
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("temporary disk should initialize");
assert!(!auto_replacement_target_ready(&disk, std::slice::from_ref(&disk)).await);
},
)
.await;
}
}
File diff suppressed because it is too large Load Diff
+156 -4
View File
@@ -26,7 +26,7 @@ use super::storage_api::storage::{
BucketInfo, BucketOperations, DiskSetSelector, HealOperations as _, ListOperations as _, ObjectIO as _,
ObjectOperations as _, StorageAdminApi,
};
use super::{DiskStore, ECStore, Endpoint, StorageError};
use super::{DiskStore, ECStore, Endpoint, HealDiskExt as _, StorageError, resume::ReplacementTargetIdentity};
pub use super::{HealObjectInfo, HealObjectOptions, HealPutObjReader};
const LOG_COMPONENT_HEAL: &str = "heal";
@@ -37,6 +37,11 @@ const EVENT_HEAL_STORAGE_OBJECT_VERIFY: &str = "heal_storage_object_verify";
const EVENT_HEAL_STORAGE_ADMIN_OP: &str = "heal_storage_admin_op";
const EVENT_HEAL_STORAGE_REPAIR_OP: &str = "heal_storage_repair_op";
pub enum ReplacementResumeDisk {
Fresh,
Existing(DiskStore),
}
pub(crate) fn next_heal_listing_token(
bucket: &str,
prefix: &str,
@@ -354,6 +359,42 @@ pub trait HealStorageAPI: Send + Sync {
/// Heal format using ecstore
async fn heal_format(&self, dry_run: bool) -> Result<(HealResultItem, Option<Error>)>;
/// Heal only the explicitly admitted replacement targets in one erasure set.
///
/// The default is deliberately fail-closed so alternate storage
/// implementations cannot accidentally fall back to the global format path.
async fn heal_replacement_format(
&self,
_dry_run: bool,
_pool_index: usize,
_set_index: usize,
_targets: &[String],
) -> Result<(HealResultItem, Option<Error>)> {
Err(Error::other("target-scoped replacement format is unsupported"))
}
/// Recheck admitted replacement targets immediately before destructive work.
async fn replacement_targets_ready(&self, _targets: &[String]) -> Result<bool> {
Ok(false)
}
/// Read target-specific physical evidence for one replacement version.
///
/// This is only used by automatic replacement healing after the normal
/// transaction returns success. The conservative default prevents an
/// alternate backend from turning an unverified replacement into a
/// completed generation.
async fn replacement_targets_have_version(
&self,
_bucket: &str,
_object: &str,
_version_id: Option<&str>,
_opts: &HealOpts,
_targets: &[String],
) -> Result<bool> {
Ok(false)
}
/// List object versions for healing (returns all versions, may use significant memory for large buckets)
///
/// WARNING: This method loads all object versions into memory at once. For buckets with many
@@ -390,8 +431,30 @@ pub trait HealStorageAPI: Send + Sync {
self.list_objects_for_heal_page(bucket, prefix, continuation_token).await
}
/// Get disk for resume functionality
/// Get disk for resume functionality.
async fn get_disk_for_resume(&self, set_disk_id: &str) -> Result<DiskStore>;
/// Get a healthy non-target disk for durable replacement state.
async fn get_disk_for_resume_excluding(&self, _set_disk_id: &str, _excluded_targets: &[String]) -> Result<DiskStore> {
Err(Error::other("target-excluding resume disk selection is unsupported"))
}
/// Reopen the exact surviving disk that owns an existing replacement
/// intent. Falling back to another disk would create a second copy of the
/// same generation and split its progress.
async fn get_replacement_resume_disk(
&self,
_set_disk_id: &str,
_task_id: &str,
_excluded_targets: &[String],
) -> Result<ReplacementResumeDisk> {
Err(Error::other("durable replacement resume selection is unsupported"))
}
/// Capture the mounted replacement instance before it is formatted.
async fn replacement_target_identities(&self, _targets: &[String]) -> Result<Vec<ReplacementTargetIdentity>> {
Err(Error::other("replacement target identity collection is unsupported"))
}
}
/// ECStore Heal storage layer implementation
@@ -1306,6 +1369,44 @@ impl HealStorageAPI for ECStoreHealStorage {
}
}
async fn heal_replacement_format(
&self,
dry_run: bool,
pool_index: usize,
set_index: usize,
targets: &[String],
) -> Result<(HealResultItem, Option<Error>)> {
self.ecstore
.heal_replacement_format(dry_run, pool_index, set_index, targets)
.await
.map(|(result, error)| (result, error.map(Error::Storage)))
.map_err(Error::Storage)
}
async fn replacement_targets_ready(&self, targets: &[String]) -> Result<bool> {
Ok(super::replacement_readiness::auto_replacement_targets_ready(targets).await)
}
async fn replacement_targets_have_version(
&self,
bucket: &str,
object: &str,
version_id: Option<&str>,
opts: &HealOpts,
targets: &[String],
) -> Result<bool> {
let pool_index = opts
.pool
.ok_or_else(|| Error::other("replacement target readback is missing pool scope"))?;
let set_index = opts
.set
.ok_or_else(|| Error::other("replacement target readback is missing set scope"))?;
self.ecstore
.replacement_targets_have_version(bucket, object, version_id.unwrap_or(""), pool_index, set_index, targets)
.await
.map_err(Error::Storage)
}
async fn list_objects_for_heal(&self, bucket: &str, prefix: &str) -> Result<Vec<HealListItem>> {
debug!(
target: "rustfs::heal::storage",
@@ -1543,6 +1644,10 @@ impl HealStorageAPI for ECStoreHealStorage {
}
async fn get_disk_for_resume(&self, set_disk_id: &str) -> Result<DiskStore> {
self.get_disk_for_resume_excluding(set_disk_id, &[]).await
}
async fn get_disk_for_resume_excluding(&self, set_disk_id: &str, excluded_targets: &[String]) -> Result<DiskStore> {
debug!(
target: "rustfs::heal::storage",
event = EVENT_HEAL_STORAGE_ADMIN_OP,
@@ -1564,8 +1669,18 @@ impl HealStorageAPI for ECStoreHealStorage {
message: format!("Failed to get disks for pool {pool_idx} set {set_idx}: {e}"),
})?;
// Find the first available disk
if let Some(disk_store) = disks.into_iter().flatten().next() {
// The replacement target is unformatted before repair and must never
// host the intent that authorizes its own formatting.
for disk_store in disks.into_iter().flatten() {
if !disk_store.endpoint().is_local {
continue;
}
if excluded_targets.contains(&disk_store.endpoint().to_string()) {
continue;
}
if !matches!(disk_store.get_disk_id().await, Ok(Some(id)) if !id.is_nil()) {
continue;
}
debug!(
target: "rustfs::heal::storage",
event = EVENT_HEAL_STORAGE_ADMIN_OP,
@@ -1584,6 +1699,43 @@ impl HealStorageAPI for ECStoreHealStorage {
message: format!("No available disk found for set_disk_id: {set_disk_id}"),
})
}
async fn get_replacement_resume_disk(
&self,
set_disk_id: &str,
task_id: &str,
excluded_targets: &[String],
) -> Result<ReplacementResumeDisk> {
let (pool_idx, set_idx) = crate::heal::utils::parse_set_disk_id(set_disk_id)?;
let disks = StorageAdminApi::disk_set_inventory(self.ecstore.as_ref(), DiskSetSelector::new(pool_idx, set_idx))
.await
.map_err(|e| Error::TaskExecutionFailed {
message: format!("Failed to get disks for pool {pool_idx} set {set_idx}: {e}"),
})?;
let mut existing = None;
for disk_store in disks.into_iter().flatten() {
if !disk_store.endpoint().is_local || excluded_targets.contains(&disk_store.endpoint().to_string()) {
continue;
}
if !matches!(disk_store.get_disk_id().await, Ok(Some(id)) if !id.is_nil()) {
continue;
}
if super::resume::ResumeManager::has_replacement_intent(&disk_store, task_id).await
&& existing.replace(disk_store).is_some()
{
return Err(Error::TaskExecutionFailed {
message: format!("Replacement resume intent is duplicated for set_disk_id: {set_disk_id}"),
});
}
}
Ok(existing.map_or(ReplacementResumeDisk::Fresh, ReplacementResumeDisk::Existing))
}
async fn replacement_target_identities(&self, targets: &[String]) -> Result<Vec<ReplacementTargetIdentity>> {
super::replacement_readiness::auto_replacement_target_identities(targets)
.await
.ok_or_else(|| Error::other("replacement target is not a stable mounted disk"))
}
}
#[cfg(test)]
+6 -4
View File
@@ -16,8 +16,9 @@ pub(crate) use rustfs_ecstore::api::data_usage::DATA_USAGE_CACHE_NAME as ECSTORE
pub(crate) use rustfs_ecstore::api::disk::endpoint::Endpoint as EcstoreEndpoint;
pub(crate) use rustfs_ecstore::api::disk::error::{DiskError as EcstoreDiskError, Result as EcstoreDiskResult};
pub(crate) use rustfs_ecstore::api::disk::{
BUCKET_META_PREFIX as ECSTORE_BUCKET_META_PREFIX, Bytes as EcstoreDiskBytes, DeleteOptions as EcstoreDeleteOptions,
DiskAPI as EcstoreDiskAPI, DiskStore as EcstoreDiskStore, HEALING_MARKER_PATH as ECSTORE_HEALING_MARKER_PATH,
BUCKET_META_PREFIX as ECSTORE_BUCKET_META_PREFIX, Bytes as EcstoreDiskBytes,
ConditionalFileUpdate as EcstoreConditionalFileUpdate, DeleteOptions as EcstoreDeleteOptions, DiskAPI as EcstoreDiskAPI,
DiskStore as EcstoreDiskStore, HEALING_MARKER_PATH as ECSTORE_HEALING_MARKER_PATH,
RUSTFS_META_BUCKET as ECSTORE_RUSTFS_META_BUCKET,
};
#[cfg(test)]
@@ -32,8 +33,9 @@ pub(crate) mod owner {
pub(crate) use super::{
ECSTORE_BUCKET_META_PREFIX, ECSTORE_DATA_USAGE_CACHE_NAME, ECSTORE_HEALING_MARKER_PATH, ECSTORE_RUSTFS_META_BUCKET,
EcstoreDeleteOptions, EcstoreDiskAPI, EcstoreDiskBytes, EcstoreDiskError, EcstoreDiskResult, EcstoreDiskStore,
EcstoreEndpoint, EcstoreErrorType, EcstoreStorageError, EcstoreStore, ecstore_local_disk_map_read,
EcstoreConditionalFileUpdate, EcstoreDeleteOptions, EcstoreDiskAPI, EcstoreDiskBytes, EcstoreDiskError,
EcstoreDiskResult, EcstoreDiskStore, EcstoreEndpoint, EcstoreErrorType, EcstoreStorageError, EcstoreStore,
ecstore_local_disk_map_read,
};
#[cfg(test)]
File diff suppressed because it is too large Load Diff
+90 -1
View File
@@ -18,9 +18,12 @@ pub mod heal;
pub use error::{Error, Result};
pub use heal::{
HealManager, HealOperationsSnapshot, HealOptions, HealPriority, HealPriorityCounts, HealRequest, HealSourceCounts, HealType,
channel::HealChannelProcessor, progress::HealProgress,
channel::HealChannelProcessor,
progress::HealProgress,
resume::{ReplacementRecoveryRecord, ReplacementRecoveryState, ResumeUtils},
};
use rustfs_concurrency::WorkloadAdmissionSnapshotProvider;
use std::collections::BTreeMap;
use std::future::Future;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
@@ -73,6 +76,17 @@ static GLOBAL_HEAL_RUNTIME_INIT: Mutex<()> = Mutex::const_new(());
static GLOBAL_HEAL_ACTIVE_TASKS: AtomicU64 = AtomicU64::new(0);
static GLOBAL_HEAL_QUEUE_LENGTH: AtomicU64 = AtomicU64::new(0);
/// Local view of durable replacement recovery state. `definitive` only covers
/// the local survivor-disk records; a distributed caller must additionally
/// establish that every peer returned a compatible snapshot.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReplacementRecoverySnapshot {
pub records: Vec<ReplacementRecoveryRecord>,
pub definitive: bool,
pub reason: Option<String>,
}
#[cfg(test)]
#[derive(Default)]
struct HealRuntimeInitTestHook {
@@ -243,6 +257,81 @@ pub async fn current_heal_progress_snapshot() -> Option<HealProgress> {
}
}
/// Read all local survivor-disk replacement records without conflating an I/O
/// failure or conflicting copies with successful completion.
pub async fn current_replacement_recovery_snapshot() -> ReplacementRecoverySnapshot {
if !heal_runtime_initialized() {
return ReplacementRecoverySnapshot {
records: Vec::new(),
definitive: false,
reason: Some("heal runtime is not initialized".to_string()),
};
}
let disks = {
let local_disk_map = heal::local_disk_map_read().await;
local_disk_map.values().flatten().cloned().collect::<Vec<_>>()
};
if disks.is_empty() {
return ReplacementRecoverySnapshot {
records: Vec::new(),
definitive: false,
reason: Some("no local survivor disks are available".to_string()),
};
}
let mut records = BTreeMap::<String, ReplacementRecoveryRecord>::new();
let mut reason = None;
for disk in disks {
match ResumeUtils::get_replacement_recovery_records(&disk).await {
Ok(disk_records) => {
for record in disk_records {
let task_id = record.task_id.clone();
if matches!(record.state, ReplacementRecoveryState::Unknown) {
reason.get_or_insert_with(|| "invalid durable replacement record".to_string());
}
match records.entry(task_id.clone()) {
std::collections::btree_map::Entry::Vacant(entry) => {
entry.insert(record);
}
std::collections::btree_map::Entry::Occupied(entry) if entry.get() == &record => {}
std::collections::btree_map::Entry::Occupied(entry)
if matches!(entry.get().state, ReplacementRecoveryState::CleanupPending)
&& matches!(record.state, ReplacementRecoveryState::Completed) => {}
std::collections::btree_map::Entry::Occupied(mut entry)
if matches!(entry.get().state, ReplacementRecoveryState::Completed)
&& matches!(record.state, ReplacementRecoveryState::CleanupPending) =>
{
entry.insert(record);
}
std::collections::btree_map::Entry::Occupied(mut entry) => {
entry.insert(ReplacementRecoveryRecord {
task_id,
state: ReplacementRecoveryState::Unknown,
generation: None,
set_disk_id: None,
target_slots: Vec::new(),
reason: Some("conflicting durable replacement records across survivor disks".to_string()),
verified_at: None,
});
reason.get_or_insert_with(|| "conflicting durable replacement records".to_string());
}
}
}
}
Err(error) => {
reason.get_or_insert_with(|| format!("failed to read local replacement recovery records: {error}"));
}
}
}
ReplacementRecoverySnapshot {
records: records.into_values().collect(),
definitive: reason.is_none(),
reason,
}
}
fn usize_to_u64_saturated(value: usize) -> u64 {
u64::try_from(value).unwrap_or(u64::MAX)
}
+3
View File
@@ -107,7 +107,10 @@ url = { workspace = true }
[dev-dependencies]
pollster.workspace = true
rcgen.workspace = true
rustfs-test-utils = { workspace = true }
rustls = { workspace = true, default-features = false, features = ["aws-lc-rs", "logging", "tls12", "prefer-post-quantum", "std"] }
rustls-pki-types.workspace = true
serial_test = { workspace = true }
temp-env = { workspace = true, features = ["async_closure"] }
tempfile = { workspace = true }
+65 -7
View File
@@ -18,6 +18,17 @@ use super::{
};
use crate::oidc::{OidcProviderConfig, OidcProviderSummary};
const DEFAULT_OIDC_PROVIDER_ID: &str = "default";
fn sorted_provider_summaries(mut providers: Vec<OidcProviderSummary>) -> Vec<OidcProviderSummary> {
providers.sort_by(|left, right| {
(left.provider_id != DEFAULT_OIDC_PROVIDER_ID)
.cmp(&(right.provider_id != DEFAULT_OIDC_PROVIDER_ID))
.then_with(|| left.provider_id.cmp(&right.provider_id))
});
providers
}
pub struct FederatedIdentityService {
registry: FederatedIdentityRegistry,
}
@@ -32,11 +43,11 @@ impl FederatedIdentityService {
}
pub fn list_providers(&self) -> Vec<OidcProviderSummary> {
self.registry.standard_oidc().list_providers()
sorted_provider_summaries(self.registry.standard_oidc().list_providers())
}
pub fn list_visible_providers(&self) -> Vec<OidcProviderSummary> {
self.registry.standard_oidc().list_visible_providers()
sorted_provider_summaries(self.registry.standard_oidc().list_visible_providers())
}
pub fn get_provider_config(&self, id: &str) -> Option<&OidcProviderConfig> {
@@ -158,6 +169,8 @@ mod tests {
failure: ProviderFailure,
events: Arc<Mutex<Vec<&'static str>>>,
expected_logout: (&'static str, &'static str),
listed_provider_ids: Vec<&'static str>,
visible_provider_ids: Vec<&'static str>,
}
impl TestProvider {
@@ -165,11 +178,13 @@ mod tests {
Self {
with_policy: true,
with_group: false,
browser_provider_id: "default",
web_provider_id: "default",
browser_provider_id: DEFAULT_OIDC_PROVIDER_ID,
web_provider_id: DEFAULT_OIDC_PROVIDER_ID,
failure: ProviderFailure::None,
events,
expected_logout: ("default", "id-token"),
expected_logout: (DEFAULT_OIDC_PROVIDER_ID, "id-token"),
listed_provider_ids: Vec::new(),
visible_provider_ids: Vec::new(),
}
}
@@ -203,6 +218,16 @@ mod tests {
}
}
fn provider_summaries(provider_ids: &[&str]) -> Vec<OidcProviderSummary> {
provider_ids
.iter()
.map(|provider_id| OidcProviderSummary {
provider_id: (*provider_id).to_string(),
display_name: (*provider_id).to_string(),
})
.collect()
}
#[async_trait::async_trait]
impl FederatedIdentityProvider for TestProvider {
fn has_providers(&self) -> bool {
@@ -210,11 +235,11 @@ mod tests {
}
fn list_providers(&self) -> Vec<OidcProviderSummary> {
Vec::new()
provider_summaries(&self.listed_provider_ids)
}
fn list_visible_providers(&self) -> Vec<OidcProviderSummary> {
Vec::new()
provider_summaries(&self.visible_provider_ids)
}
fn provider_config(&self, _id: &str) -> Option<&OidcProviderConfig> {
@@ -302,6 +327,39 @@ mod tests {
}
}
#[test]
fn provider_listing_puts_default_first_and_sorts_named_providers() {
let events = Arc::new(Mutex::new(Vec::new()));
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)));
assert_eq!(
service
.list_providers()
.into_iter()
.map(|provider| provider.provider_id)
.collect::<Vec<_>>(),
[DEFAULT_OIDC_PROVIDER_ID, "alpha", "hidden", "zeta"]
);
assert_eq!(
service
.list_visible_providers()
.into_iter()
.map(|provider| provider.provider_id)
.collect::<Vec<_>>(),
[DEFAULT_OIDC_PROVIDER_ID, "alpha", "zeta"]
);
assert_eq!(
sorted_provider_summaries(provider_summaries(&["zeta", "alpha"]))
.into_iter()
.map(|provider| provider.provider_id)
.collect::<Vec<_>>(),
["alpha", "zeta"]
);
}
#[tokio::test]
async fn callback_and_web_identity_preserve_provider_and_transaction_boundaries() {
let events = Arc::new(Mutex::new(Vec::new()));
+23 -2
View File
@@ -14,7 +14,7 @@
use crate::error::{Error, Result};
use manager::IamCache;
use oidc::OidcSys;
use oidc::{OidcExtraRootCaProvider, OidcSys};
use std::sync::{Arc, OnceLock};
use store::object::ObjectStore;
use sys::IamSys;
@@ -284,6 +284,23 @@ pub fn get_global_iam_sys() -> Option<Arc<IamSys<ObjectStore>>> {
/// Initialize the global OIDC system. Non-fatal if no OIDC providers are configured.
pub async fn init_oidc_sys() -> Result<()> {
init_oidc_sys_with_extra_root_ca(None).await
}
/// Initialize the global OIDC system with an additional outbound root CA bundle.
pub async fn init_oidc_sys_with_extra_root_ca(root_ca_pem: Option<&[u8]>) -> Result<()> {
init_oidc_sys_with_extra_root_ca_provider_inner(None, root_ca_pem).await
}
/// Initialize the global OIDC system with a reload-aware outbound root CA provider.
pub async fn init_oidc_sys_with_extra_root_ca_provider(extra_root_ca_provider: OidcExtraRootCaProvider) -> Result<()> {
init_oidc_sys_with_extra_root_ca_provider_inner(Some(extra_root_ca_provider), None).await
}
async fn init_oidc_sys_with_extra_root_ca_provider_inner(
extra_root_ca_provider: Option<OidcExtraRootCaProvider>,
root_ca_pem: Option<&[u8]>,
) -> Result<()> {
if OIDC_SYS.get().is_some() {
debug!(
event = EVENT_OIDC_STATE,
@@ -303,7 +320,11 @@ pub async fn init_oidc_sys() -> Result<()> {
"OIDC runtime starting"
);
let oidc_sys = match OidcSys::new().await {
let oidc_sys_result = match extra_root_ca_provider {
Some(provider) => OidcSys::new_with_extra_root_ca_provider(provider).await,
None => OidcSys::new_with_extra_root_ca(root_ca_pem).await,
};
let oidc_sys = match oidc_sys_result {
Ok(sys) => {
if sys.has_providers() {
debug!(
+757 -94
View File
File diff suppressed because it is too large Load Diff
+88 -10
View File
@@ -22,11 +22,20 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
pub const INTERNODE_OPERATION_READ_FILE_STREAM: &str = "read_file_stream";
pub const INTERNODE_OPERATION_PUT_FILE_STREAM: &str = "put_file_stream";
pub const INTERNODE_OPERATION_PUT_FILE_CAPABILITY: &str = "put_file_capability";
pub const INTERNODE_OPERATION_WALK_DIR: &str = "walk_dir";
pub const INTERNODE_OPERATION_NS_SCANNER: &str = "ns_scanner";
pub const INTERNODE_OPERATION_GRPC_READ_ALL: &str = "grpc_read_all";
pub const INTERNODE_OPERATION_GRPC_WRITE_ALL: &str = "grpc_write_all";
pub const INTERNODE_OPERATION_GRPC_READ_MULTIPLE: &str = "grpc_read_multiple";
pub const INTERNODE_OPERATION_GRPC_READ_VERSION: &str = "grpc_read_version";
pub const INTERNODE_OPERATION_GRPC_BATCH_READ_VERSION: &str = "grpc_batch_read_version";
pub const INTERNODE_OPERATION_GRPC_LOCK: &str = "grpc_lock";
pub const INTERNODE_OPERATION_GRPC_UNLOCK: &str = "grpc_unlock";
pub const INTERNODE_OPERATION_GRPC_LOCK_BATCH: &str = "grpc_lock_batch";
pub const INTERNODE_OPERATION_GRPC_UNLOCK_BATCH: &str = "grpc_unlock_batch";
pub const INTERNODE_OPERATION_GRPC_REFRESH: &str = "grpc_refresh";
pub const INTERNODE_OPERATION_GRPC_FORCE_UNLOCK: &str = "grpc_force_unlock";
pub const INTERNODE_OPERATION_GRPC_OTHER: &str = "grpc_other";
pub const INTERNODE_TRANSPORT_BACKEND_TCP_HTTP: &str = "tcp-http";
pub const INTERNODE_TRANSPORT_BACKEND_GRPC: &str = "grpc";
@@ -77,6 +86,7 @@ const INTERNODE_REPLAY_SCOPE_FALLBACK_TOTAL: &str = "rustfs_system_network_inter
const INTERNODE_REPLAY_CACHE_OVERFLOW_TOTAL: &str = "rustfs_system_network_internode_replay_cache_overflow_total";
const INTERNODE_REPLAY_CACHE_OVERFLOW_BY_OPERATION_TOTAL: &str =
"rustfs_system_network_internode_replay_cache_overflow_by_operation_total";
const INTERNODE_REPLAY_CACHE_RECORDS_TOTAL: &str = "rustfs_system_network_internode_replay_cache_records_total";
const INTERNODE_REPLAY_CACHE_ENTRIES: &str = "rustfs_system_network_internode_replay_cache_entries";
const INTERNODE_REPLAY_CACHE_CAPACITY: &str = "rustfs_system_network_internode_replay_cache_capacity";
const INTERNODE_REPLAY_CACHE_EVICTIONS_TOTAL: &str = "rustfs_system_network_internode_replay_cache_evictions_total";
@@ -156,6 +166,10 @@ pub const INTERNODE_OPERATION_METRICS: &[InternodeOperationMetricDescriptor] = &
name: INTERNODE_REPLAY_CACHE_OVERFLOW_BY_OPERATION_TOTAL,
labels: SERVER_OPERATION_BACKEND_RPC_PATH_LABELS,
},
InternodeOperationMetricDescriptor {
name: INTERNODE_REPLAY_CACHE_RECORDS_TOTAL,
labels: SERVER_OPERATION_BACKEND_RPC_PATH_LABELS,
},
InternodeOperationMetricDescriptor {
name: INTERNODE_REPLAY_CACHE_ENTRIES,
labels: SERVER_LABELS,
@@ -618,6 +632,22 @@ impl InternodeMetrics {
.increment(1);
}
pub fn record_replay_cache_record_for_operation_and_backend_path(
&self,
operation: &'static str,
backend: &'static str,
rpc_path: &str,
) {
counter!(
INTERNODE_REPLAY_CACHE_RECORDS_TOTAL,
SERVER_LABEL => current_server_label(),
OPERATION_LABEL => operation,
BACKEND_LABEL => backend,
RPC_PATH_LABEL => rpc_path.to_owned()
)
.increment(1);
}
pub fn record_replay_cache_state(&self, entries: usize, capacity: usize) {
let entries = usize_to_u64_saturating(entries);
let capacity = usize_to_u64_saturating(capacity);
@@ -963,7 +993,7 @@ mod tests {
#[test]
fn operation_metric_descriptors_include_backend_and_operation_labels() {
assert_eq!(INTERNODE_OPERATION_METRICS.len(), 20);
assert_eq!(INTERNODE_OPERATION_METRICS.len(), 21);
for metric in &INTERNODE_OPERATION_METRICS[..6] {
assert_eq!(metric.labels, &[SERVER_LABEL, OPERATION_LABEL, BACKEND_LABEL]);
}
@@ -985,23 +1015,36 @@ mod tests {
INTERNODE_OPERATION_METRICS[13].labels,
&[SERVER_LABEL, OPERATION_LABEL, BACKEND_LABEL, RPC_PATH_LABEL]
);
for metric in &INTERNODE_OPERATION_METRICS[14..16] {
assert_eq!(
INTERNODE_OPERATION_METRICS[14].labels,
&[SERVER_LABEL, OPERATION_LABEL, BACKEND_LABEL, RPC_PATH_LABEL]
);
for metric in &INTERNODE_OPERATION_METRICS[15..17] {
assert_eq!(metric.labels, &[SERVER_LABEL]);
}
assert_eq!(INTERNODE_OPERATION_METRICS[16].labels, &[SERVER_LABEL, REASON_LABEL]);
assert_eq!(INTERNODE_OPERATION_METRICS[17].labels, &[SERVER_LABEL, STAGE_LABEL, DOMINANT_ERROR_LABEL]);
assert_eq!(INTERNODE_OPERATION_METRICS[17].labels, &[SERVER_LABEL, REASON_LABEL]);
assert_eq!(INTERNODE_OPERATION_METRICS[18].labels, &[SERVER_LABEL, STAGE_LABEL, DOMINANT_ERROR_LABEL]);
// Payload histogram + large-payload counter carry operation+backend labels.
assert_eq!(INTERNODE_OPERATION_METRICS[18].labels, &[SERVER_LABEL, OPERATION_LABEL, BACKEND_LABEL]);
assert_eq!(INTERNODE_OPERATION_METRICS[19].labels, &[SERVER_LABEL, OPERATION_LABEL, BACKEND_LABEL]);
assert_eq!(INTERNODE_OPERATION_METRICS[20].labels, &[SERVER_LABEL, OPERATION_LABEL, BACKEND_LABEL]);
}
#[test]
fn operation_metric_names_and_low_cardinality_values_are_stable() {
assert_eq!(INTERNODE_OPERATION_READ_FILE_STREAM, "read_file_stream");
assert_eq!(INTERNODE_OPERATION_PUT_FILE_STREAM, "put_file_stream");
assert_eq!(INTERNODE_OPERATION_PUT_FILE_CAPABILITY, "put_file_capability");
assert_eq!(INTERNODE_OPERATION_WALK_DIR, "walk_dir");
assert_eq!(INTERNODE_OPERATION_GRPC_READ_ALL, "grpc_read_all");
assert_eq!(INTERNODE_OPERATION_GRPC_WRITE_ALL, "grpc_write_all");
assert_eq!(INTERNODE_OPERATION_GRPC_READ_VERSION, "grpc_read_version");
assert_eq!(INTERNODE_OPERATION_GRPC_BATCH_READ_VERSION, "grpc_batch_read_version");
assert_eq!(INTERNODE_OPERATION_GRPC_LOCK, "grpc_lock");
assert_eq!(INTERNODE_OPERATION_GRPC_UNLOCK, "grpc_unlock");
assert_eq!(INTERNODE_OPERATION_GRPC_LOCK_BATCH, "grpc_lock_batch");
assert_eq!(INTERNODE_OPERATION_GRPC_UNLOCK_BATCH, "grpc_unlock_batch");
assert_eq!(INTERNODE_OPERATION_GRPC_REFRESH, "grpc_refresh");
assert_eq!(INTERNODE_OPERATION_GRPC_FORCE_UNLOCK, "grpc_force_unlock");
assert_eq!(INTERNODE_OPERATION_GRPC_OTHER, "grpc_other");
assert_eq!(INTERNODE_TRANSPORT_BACKEND_TCP_HTTP, "tcp-http");
@@ -1046,26 +1089,30 @@ mod tests {
);
assert_eq!(
INTERNODE_OPERATION_METRICS[14].name,
"rustfs_system_network_internode_replay_cache_entries"
"rustfs_system_network_internode_replay_cache_records_total"
);
assert_eq!(
INTERNODE_OPERATION_METRICS[15].name,
"rustfs_system_network_internode_replay_cache_capacity"
"rustfs_system_network_internode_replay_cache_entries"
);
assert_eq!(
INTERNODE_OPERATION_METRICS[16].name,
"rustfs_system_network_internode_replay_cache_evictions_total"
"rustfs_system_network_internode_replay_cache_capacity"
);
assert_eq!(
INTERNODE_OPERATION_METRICS[17].name,
"rustfs_system_storage_erasure_write_quorum_failures_total"
"rustfs_system_network_internode_replay_cache_evictions_total"
);
assert_eq!(
INTERNODE_OPERATION_METRICS[18].name,
"rustfs_system_network_internode_operation_payload_bytes"
"rustfs_system_storage_erasure_write_quorum_failures_total"
);
assert_eq!(
INTERNODE_OPERATION_METRICS[19].name,
"rustfs_system_network_internode_operation_payload_bytes"
);
assert_eq!(
INTERNODE_OPERATION_METRICS[20].name,
"rustfs_system_network_internode_operation_large_payloads_total"
);
assert_eq!(INTERNODE_OPERATION_GRPC_READ_MULTIPLE, "grpc_read_multiple");
@@ -1089,6 +1136,10 @@ mod tests {
INTERNODE_SIGNATURE_V1_FALLBACK_TOTAL,
"rustfs_system_network_internode_signature_v1_fallback_total"
);
assert_eq!(
INTERNODE_REPLAY_CACHE_RECORDS_TOTAL,
"rustfs_system_network_internode_replay_cache_records_total"
);
assert_eq!(FAILURE_REASON_LABEL, "failure_reason");
assert_eq!(RPC_PATH_LABEL, "rpc_path");
assert_eq!(REASON_LABEL, "reason");
@@ -1142,6 +1193,11 @@ mod tests {
INTERNODE_TRANSPORT_BACKEND_GRPC,
"/node_service.NodeService/ReadAll",
);
metrics.record_replay_cache_record_for_operation_and_backend_path(
INTERNODE_OPERATION_GRPC_READ_VERSION,
INTERNODE_TRANSPORT_BACKEND_GRPC,
"/node_service.NodeService/ReadVersion",
);
});
let snapshot = metrics.snapshot();
@@ -1177,6 +1233,28 @@ mod tests {
assert_eq!(labels.get(BACKEND_LABEL).map(String::as_str), Some(INTERNODE_TRANSPORT_BACKEND_GRPC));
assert_eq!(labels.get(RPC_PATH_LABEL).map(String::as_str), Some("/node_service.NodeService/ReadAll"));
assert!(labels.get(SERVER_LABEL).is_some_and(|value| !value.is_empty()));
let records: Vec<_> = entries
.iter()
.filter(|(composite, _, _, _)| composite.key().name() == INTERNODE_REPLAY_CACHE_RECORDS_TOTAL)
.collect();
assert_eq!(records.len(), 1);
let labels: HashMap<_, _> = records[0]
.0
.key()
.labels()
.map(|label| (label.key().to_string(), label.value().to_string()))
.collect();
assert_eq!(
labels.get(OPERATION_LABEL).map(String::as_str),
Some(INTERNODE_OPERATION_GRPC_READ_VERSION)
);
assert_eq!(labels.get(BACKEND_LABEL).map(String::as_str), Some(INTERNODE_TRANSPORT_BACKEND_GRPC));
assert_eq!(
labels.get(RPC_PATH_LABEL).map(String::as_str),
Some("/node_service.NodeService/ReadVersion")
);
assert!(labels.get(SERVER_LABEL).is_some_and(|value| !value.is_empty()));
}
#[test]
+83
View File
@@ -29,6 +29,11 @@ pub struct Infos {
pub drives: Vec<HealDriveInfo>,
}
/// String form of `DriveState::Ok` as recorded in `HealDriveInfo::state`
/// (this crate stores drive states as strings and does not depend on the
/// enum's crate).
const DRIVE_STATE_OK: &str = "ok";
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct HealResultItem {
#[serde(rename = "resultId")]
@@ -58,3 +63,81 @@ pub struct HealResultItem {
#[serde(rename = "objectSize")]
pub object_size: usize,
}
impl HealResultItem {
/// Number of drives this heal repaired: pairwise `before`/`after` state
/// transitions to ok (issue #5863). `None` when the result carries no
/// aligned drive data (e.g. remote bucket results) — not the same as zero.
pub fn drives_healed(&self) -> Option<usize> {
if self.after.drives.is_empty() || self.before.drives.len() != self.after.drives.len() {
return None;
}
Some(
self.before
.drives
.iter()
.zip(&self.after.drives)
.filter(|(before, after)| before.state != after.state && after.state == DRIVE_STATE_OK)
.count(),
)
}
/// Drives consulted, or `None` when the result has no drive entries.
pub fn drives_reported(&self) -> Option<usize> {
if self.after.drives.is_empty() {
None
} else {
Some(self.after.drives.len())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn drive(state: &str) -> HealDriveInfo {
HealDriveInfo {
uuid: String::new(),
endpoint: String::new(),
state: state.to_string(),
}
}
#[test]
fn drives_healed_counts_transitions_to_ok_not_consulted_drives() {
let mut item = HealResultItem::default();
item.before.drives = vec![drive("ok"), drive("missing"), drive("corrupt"), drive("offline")];
item.after.drives = vec![drive("ok"), drive("ok"), drive("ok"), drive("offline")];
// 4 drives consulted, 2 repaired (missing->ok, corrupt->ok); the
// already-ok drive and the still-offline drive are not repairs.
assert_eq!(item.drives_healed(), Some(2));
assert_eq!(item.drives_reported(), Some(4));
let mut noop = HealResultItem::default();
noop.before.drives = vec![drive("ok"); 12];
noop.after.drives = vec![drive("ok"); 12];
assert_eq!(noop.drives_healed(), Some(0));
}
#[test]
fn drives_healed_reports_unknown_not_zero_without_drive_data() {
// Empty successful remote result (RemotePeerS3Client::heal_bucket
// default) is "unknown", never a definitive zero.
let remote = HealResultItem::default();
assert_eq!(remote.drives_healed(), None);
assert_eq!(remote.drives_reported(), None);
// A local missing -> ok result keeps its real count.
let mut local = HealResultItem::default();
local.before.drives = vec![drive("ok"), drive("missing")];
local.after.drives = vec![drive("ok"), drive("ok")];
assert_eq!(local.drives_healed(), Some(1));
// Misaligned arrays cannot be paired: also unknown.
let mut misaligned = HealResultItem::default();
misaligned.before.drives = vec![drive("missing")];
misaligned.after.drives = vec![drive("ok"), drive("ok")];
assert_eq!(misaligned.drives_healed(), None);
}
}
@@ -198,6 +198,9 @@ pub enum S3KeyName {
#[strum(serialize = "s3:object-lock-retain-until-date")]
S3ObjectLockRetainUntilDate,
#[strum(serialize = "s3:object-lock-mode")]
S3ObjectLockMode,
#[strum(serialize = "s3:max-keys")]
S3MaxKeys,
@@ -385,6 +388,7 @@ mod tests {
#[test_case("s3:x-amz-copy-source", KeyName::S3(S3KeyName::S3XAmzCopySource))]
#[test_case("s3:VersionId", KeyName::S3(S3KeyName::S3VersionId) ; "aws_version_id")]
#[test_case("s3:versionid", KeyName::S3(S3KeyName::S3VersionId) ; "minio_version_id")]
#[test_case("s3:object-lock-mode", KeyName::S3(S3KeyName::S3ObjectLockMode))]
#[test_case("aws:SecureTransport", KeyName::Aws(AwsKeyName::AWSSecureTransport))]
#[test_case("jwt:sub", KeyName::Jwt(JwtKeyName::JWTSub))]
#[test_case("ldap:user", KeyName::Ldap(LdapKeyName::User))]
@@ -407,6 +411,7 @@ mod tests {
#[test_case("s3:x-amz-copy-source", KeyName::S3(S3KeyName::S3XAmzCopySource))]
#[test_case("s3:VersionId", KeyName::S3(S3KeyName::S3VersionId) ; "aws_version_id")]
#[test_case("s3:versionid", KeyName::S3(S3KeyName::S3VersionId) ; "minio_version_id")]
#[test_case("s3:object-lock-mode", KeyName::S3(S3KeyName::S3ObjectLockMode))]
#[test_case("aws:SecureTransport", KeyName::Aws(AwsKeyName::AWSSecureTransport))]
#[test_case("jwt:sub", KeyName::Jwt(JwtKeyName::JWTSub))]
#[test_case("ldap:user", KeyName::Ldap(LdapKeyName::User))]
@@ -425,6 +430,7 @@ mod tests {
#[test_case("s3:x-amz-copy-source", KeyName::S3(S3KeyName::S3XAmzCopySource))]
#[test_case("s3:versionid", KeyName::S3(S3KeyName::S3VersionId))]
#[test_case("s3:object-lock-mode", KeyName::S3(S3KeyName::S3ObjectLockMode))]
#[test_case("aws:SecureTransport", KeyName::Aws(AwsKeyName::AWSSecureTransport))]
#[test_case("jwt:sub", KeyName::Jwt(JwtKeyName::JWTSub))]
#[test_case("ldap:user", KeyName::Ldap(LdapKeyName::User))]
+3 -1
View File
@@ -287,7 +287,7 @@ mod tests {
};
use std::collections::HashMap;
use crate::policy::function::key_name::S3KeyName::S3LocationConstraint;
use crate::policy::function::key_name::S3KeyName::{S3LocationConstraint, S3ObjectLockMode};
use test_case::test_case;
fn new_func(name: KeyName, variable: Option<String>, values: Vec<&str>) -> StringFunc {
@@ -308,6 +308,7 @@ mod tests {
))]
#[test_case(r#"{"aws:username/value": ["johndoe", "aaa"]}"#, new_func(Aws(AWSUsername), Some("value".into()), vec!["johndoe", "aaa"]
))]
#[test_case(r#"{"s3:object-lock-mode": "COMPLIANCE"}"#, new_func(S3(S3ObjectLockMode), None, vec!["COMPLIANCE"]))]
fn test_deser(input: &str, expect: StringFunc) -> Result<(), serde_json::Error> {
let v: StringFunc = serde_json::from_str(input)?;
assert_eq!(v, expect);
@@ -410,6 +411,7 @@ mod tests {
#[test_case(new_fkv("s3:ExistingObjectTag/security", vec!["public"]), false, vec![("ExistingObjectTag/project", vec!["webapp"])] => false ; "21")]
#[test_case(new_fkv("s3:VersionId", vec!["version-1"]), false, vec![("versionid", vec!["version-1"])] => true ; "aws_version_id")]
#[test_case(new_fkv("s3:versionid", vec!["version-1"]), false, vec![("versionid", vec!["version-1"])] => true ; "minio_version_id")]
#[test_case(new_fkv("s3:object-lock-mode", vec!["COMPLIANCE"]), false, vec![("object-lock-mode", vec!["COMPLIANCE"])] => true ; "object_lock_mode")]
fn test_string_equals(s: FuncKeyValue<StringFuncValue>, for_all: bool, values: Vec<(&str, Vec<&str>)>) -> bool {
test_eval(s, for_all, false, false, values)
}
-2
View File
@@ -97,7 +97,6 @@ swift = [
"dep:serde",
"dep:urlencoding",
"dep:md-5",
"dep:quick-xml",
"dep:hmac",
"dep:sha1",
"dep:hex",
@@ -162,7 +161,6 @@ tokio-util = { workspace = true, optional = true, features = ["rt", "io", "compa
serde = { workspace = true, optional = true, features = ["derive"] }
urlencoding = { workspace = true, optional = true }
md-5 = { workspace = true, optional = true }
quick-xml = { workspace = true, optional = true, features = ["serialize"] }
hmac = { workspace = true, optional = true }
sha1 = { workspace = true, optional = true }
hex = { workspace = true, optional = true }

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