Compare commits

...

377 Commits

Author SHA1 Message Date
overtrue debd664016 ci: model server as a composition root 2026-07-29 10:38:01 +08:00
Zhengchao An 957080bea5 fix(swift): persist container and account metadata writes (#5398)
Swift container and account metadata handlers cloned the cached
BucketMetadata, set the tagging fields, and called set_bucket_metadata,
which only updates the in-memory cache map. Nothing reached
.metadata.bin, so every Swift metadata POST was lost on restart and
silently overwritten by the next disk-truth reload (a peer
LoadBucketMetadata notification or the 15-minute refresh loop) — while
the client had already been told 2xx.

Route these writes through a new metadata_sys::update_config_with: a
read-modify-write that loads the on-disk metadata and persists the
result under the same write guard metadata_sys::update uses, so the
rewrite merges against disk truth instead of a possibly stale cache and
cannot clobber a concurrent update to another config file. Peers are
notified afterwards, matching the S3 config handlers.

Persisting these writes required hardening the paths that now produce
durable state:

- Account metadata writes validate account ownership. This metadata
  holds the account's TempURL signing key, so an unauthenticated write
  for someone else's account would have become a durable, cluster-wide
  takeover of that account's pre-signed URLs. Reads stay open because
  TempURL signature validation runs before credentials exist.
- disable_versioning verifies the container exists. Without it the
  metadata loader's "no metadata on disk" default would be persisted,
  creating an orphan metadata file and caching a fabricated default as
  authoritative.
- Container and account metadata are size- and count-limited, reusing
  the Swift limits object metadata already enforces; these tags land in
  the bucket metadata file that every later config write rewrites whole.
- A rewrite refuses to run when the persisted tagging config is
  unreadable, instead of merging onto an empty set and wiping the
  container ACL and versioning tags. It reports 409 naming the remedy.
- Storage errors are logged in full and reported generically, since they
  now carry real disk and quorum detail.

The tagging arm of BucketMetadata::update_config also clears the parsed
config, as the lifecycle arm does: parse_all_configs skips empty XML
rather than clearing, so a cleared config kept serving the old tags.
Tagging is serialized with the S3 XML serializer the loader can parse
back, not quick_xml, whose output was never round-trippable.
2026-07-29 02:31:11 +00:00
cxymds c1538cf1c3 fix(multipart): serialize complete and abort (#5356)
* fix(multipart): serialize complete and abort

* test(multipart): order abort-first finalization

* fix(multipart): enforce quorum staging cleanup

* fix(multipart): remove stale mutable binding
2026-07-29 01:49:19 +00:00
houseme 5af56cbb02 test(ci): stabilize lifecycle timeout coverage (#5404)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-29 01:37:29 +00:00
houseme f99956eade test(lifecycle): classify mixed rollout harness results (#5384)
* test(lifecycle): classify mixed rollout harness results

Classify Docker #1508 evidence as strict, baseline, blocked, or failed so tiered-storage baseline runs cannot be mistaken for strict mixed-version rollout closure evidence.

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

* test: classify Docker manual transition preemption

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-29 01:21:23 +00:00
houseme 775279b6fd fix(notify): defer disabled bootstrap storage refresh (#5373)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-28 17:39:22 +00:00
cxymds eb755e2b97 fix(auth): compare sensitive tokens in constant time (#5371)
* fix(auth): compare sensitive tokens in constant time

* test(auth): scope constant-time source guard

* test(auth): ignore test source in comparison guard

* chore(deps): use constant-time s3s authentication
2026-07-28 17:56:22 +08:00
cxymds 7f146fc5de feat(tiering): model provider version capabilities (#5372) 2026-07-28 17:42:08 +08:00
cxymds 5426237a49 fix(filemeta): preserve canonical version order (#5353) 2026-07-28 17:38:59 +08:00
cxymds d7afa4e38e fix(heal): report partial rename failures (#5355)
* fix(heal): report partial rename failures

* fix(log-analyzer): track partial heal rename failures
2026-07-28 17:37:02 +08:00
cxymds a3f5a8eaf9 fix(ecstore): fence object tagging updates (#5360)
* fix(ecstore): fence object tagging updates

* test(ecstore): stabilize tagging lock regressions

* test(ecstore): restore setup type on current-thread runtime
2026-07-28 17:36:00 +08:00
cxymds 547c678eed fix(filemeta): reject positive size without parts (#5354)
* fix(filemeta): reject positive size without parts

* test(ecstore): keep optimized read fixture valid

* test(ecstore): keep listing fixtures valid
2026-07-28 17:35:53 +08:00
cxymds df945b275a fix(lifecycle): recover saturated scanner tasks (#5369) 2026-07-28 17:30:52 +08:00
cxymds 2e78a49c95 fix(lifecycle): reject invalid modification times (#5370) 2026-07-28 17:23:21 +08:00
cxymds 7ad0e726db fix(ecstore): use set read quorum for version reads (#5365)
* fix(ecstore): use set read quorum for version reads

* fix(ecstore): convert optimized read batch errors
2026-07-28 17:23:15 +08:00
cxymds 45c3386b68 fix(bitrot): reject trailing shard data (#5358)
* fix(bitrot): reject trailing shard data

* test(ecstore): align bitrot disk fixture type
2026-07-28 17:23:08 +08:00
cxymds 7af92b4f54 fix(ecstore): handle pre-epoch disk health clocks (#5383) 2026-07-28 17:22:26 +08:00
cxymds daa627ee0e fix(auth): enforce presigned URL expiry limit (#5368)
* fix(auth): enforce presigned URL expiry limit

* ci(deny): allow presigned expiry s3s fork

* chore(deps): update presigned expiry validation
2026-07-28 17:11:30 +08:00
cxymds 5c3d3a8220 fix(ecstore): handle mutex poisoning explicitly (#5379)
* fix(ecstore): handle mutex poisoning explicitly

* test(ecstore): satisfy poison assertion lint
2026-07-28 17:09:09 +08:00
cxymds 6bc5fc77b5 fix(notify): emit noop event for missing deletes (#5381) 2026-07-28 17:05:10 +08:00
cxymds e822fc1552 fix(swift): enforce cumulative container quotas (#5378) 2026-07-28 17:05:04 +08:00
cxymds 2f6115e058 fix(swift): enforce TempURL IP restrictions (#5377) 2026-07-28 17:04:58 +08:00
cxymds 882e1a71b1 fix(tiering): abort failed multipart restores (#5367)
* fix(tiering): abort failed multipart restores

* fix(tiering): compile restore abort regressions

* test(tiering): assert restored multipart metadata

* test(tiering): parse completed restore status
2026-07-28 17:04:52 +08:00
cxymds b432f31c2c fix(multipart): preserve retried parts on quorum failure (#5363)
* fix(multipart): preserve retried parts on quorum failure

* style(multipart): format transaction rollback

* fix(proto): regenerate multipart transaction RPCs

* fix(multipart): import rollback marker constant

* fix(multipart): export transaction action

* fix: import multipart transaction test requests
2026-07-28 17:04:46 +08:00
cxymds 6e0640444e fix(multipart): list uploads across all sets (#5362) 2026-07-28 17:04:40 +08:00
cxymds 4fb9b0dc7f fix(authz): fail closed on policy load errors (#5359)
* fix(authz): fail closed on policy load errors

* test(authz): cover policy failure precedence

* test: align policy failure expectations

* test: align upload part copy fail-closed expectation
2026-07-28 17:04:35 +08:00
唐小鸭 2216f00cfd fix(kms): unify persisted SSE data key envelopes (#5343)
* feat(kms): implement secure handling of static KMS secret keys and enhance encryption context validation

* feat: enhance local SSE DEK handling with JSON envelope format and versioning
2026-07-28 17:02:18 +08:00
cxymds fd2a87d47e fix(s3): reject empty multipart completions (#5352)
Co-authored-by: houseme <housemecn@gmail.com>
2026-07-28 08:35:50 +00:00
houseme 1e10d752b9 test(e2e): stabilize inline full gate checks (#5351)
Align inline fast path cluster tests with the current tier mutation and manual transition contracts while keeping msgpack compat assertions focused on observed traffic.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-28 04:31:19 +00:00
houseme 362f6026ac chore(build): pin Rust toolchain configs to 1.97.1 (#5350)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-28 03:53:32 +00:00
GatewayJ 5cedab09ab fix(sts): return Query API-compatible responses (#5282)
* fix(sts): return Query API-compatible responses

* fix(sts): resolve review and CI failures

* fix(sts): harden query response conversion

* test(e2e): stabilize transition conflict coverage

* fix(ilm): retry vanished transition admission CAS

* test(e2e): narrow transition overlap coverage

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-07-28 10:39:17 +08:00
houseme d385cea7c6 test(lifecycle): add manual transition rollout harnesses (#5346)
* test(lifecycle): add manual transition rollout harnesses

Add a dedicated #1508 ignored E2E harness for non-empty manual transition rollout readback and a reusable Docker mixed-version harness entrypoint.

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

* test(lifecycle): satisfy rollout harness clippy

Use unwrap_or_default for the optional transition_failed report field while preserving the zero-default status readback behavior.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-28 00:04:47 +00:00
Henry Guo d5df66ac4f fix(scanner): require authoritative usage snapshots (#5333) 2026-07-28 07:29:57 +08:00
Zhengchao An 5a768d3a44 perf(s3): resolve bucket versioning once per DeleteObjects request (#5340)
DeleteObjects rebuilt ObjectOptions inside its per-key phase-1 loop, and
del_opts re-fetched the bucket versioning configuration on every call: a
1000-key request paid 1001 identical metadata-sys lookups, each taking the
global bucket-metadata read lock and cloning a VersioningConfiguration.

Split del_opts into the existing async entry point plus a synchronous
del_opts_with_versioning that takes an already-resolved configuration, and
reuse the request-level version_cfg the handler had already fetched.

This also removes a snapshot inconsistency. The skip-stat decision
(can_skip_delete_objects_pre_stat) and the post-delete accounting already
derived from the request-level snapshot while del_opts re-read the config
per key, so a PutBucketVersioning committing mid-request could give a key
opts.versioned=true while the batch delete still ran with versioned=false:
the object was deleted outright with replication state attached to a delete
marker that was never created, and the advisory object-lock pre-check was
skipped on a false premise. All three now derive from one snapshot.

GET, HEAD and DeleteObject additionally establish bucket existence before
building ObjectOptions, so a request naming a nonexistent bucket no longer
performs bucket-metadata work first. GET keeps its cheap request-shape
validations (key, range, partNumber) ahead of the existence check so
InvalidArgument still wins over NoSuchBucket for malformed requests.

Bucket validation moves to validate_bucket_exists, which takes an explicit
store and shares the existing 5s-TTL cache. GET, PUT, HEAD and DeleteObject
now resolve the store through the request-bound server context
(backlog#1052 S6) instead of the process-global handle: get_validated_store
resolves the first-published global AppContext, so in an embedded
multi-instance process a request to the second server operated on the first
server's store. Remaining global-handle call sites in bucket_usecase and
select_object are left for a follow-up.
2026-07-28 06:33:17 +08:00
houseme 6d3ce90c0f fix(lifecycle): harden manual transition rollout checks (#5344)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-27 17:29:06 +00:00
houseme 0e42a3d1d9 fix(audit): load env-only targets at startup (#5342)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-27 16:41:38 +00:00
abdullahnah92 c3aac2279f fix(site-replication): keep reverse direction after config broadcast (#5292)
* fix(site-replication): keep reverse direction after config broadcast

`site-repl-*` rules encode the sender's outbound direction: their
destination ARN names the receiver. `apply_bucket_meta_item` wrote an
incoming rule set verbatim over the receiver's, leaving the receiver with
a rule whose ARN is its own deployment ID. `reconcile_site_replication_bucket_targets`
skips the local peer, so no bucket target can back that ARN and every
object was dropped; the follow-up call reconciled targets only, so
nothing rebuilt the lost reverse rule. Replication went one-directional
after any PutBucketReplication broadcast — the console's Save button,
`mc replicate import`, a metadata import, or `/site-replication/repair`.

Only operator-authored rules now travel between sites; each site owns its
`site-repl-*` rules and rebuilds them from the current peer set.

Four defects kept that invisible or unrecoverable:

- `update_all_targets` discarded target-client build errors silently, and
  `replicate_object` logged the resulting missing-target drop at debug
  while every other failure there logs at error. Both now report.
- `site_replication_rule_complete` never checked that a rule's
  destination named a remote site, so two sites holding identical
  configs — the post-clobber state — passed as in sync.
- `update_service_account` cannot rewrite `parent_user`, and IAM records
  encrypted with a previous root secret decode as "no such account".
  Startup now reconciles the account, reseeding from the secret every
  site-replication bucket target already stores, and refuses the
  delete-then-create sequence when the parent cannot back an account.
  Bucket rules are reconciled too, so an already-broken site heals on
  upgrade.
- A joined site never verified it could reach the initiator, whose
  endpoint is derived from the Host header of the admin request that
  created the topology. The join now probes each peer and reports through
  `initial_sync_error_message`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(site-replication): report unreachable targets and reconcile on a timer

Rule-shape checking cannot see an unreachable peer. A `site-repl-*` rule
can be perfectly formed while the endpoint recorded for its peer is one
this site cannot reach: `update_all_targets` then builds no client and
`replicate_object` drops every object against that ARN, yet the rule set
still reads as correct and the bucket reports in sync.

Each site now reports whether all of its `site-repl-*` rules resolve to a
live target (`SRBucketInfo.replicationTargetsOnline`, read from the
already-resolved client map so the status path stays cheap), and the
status aggregation treats an offline report as a mismatch. The field is
additive and optional: peers that omit it are "unknown", never a fault,
so a mixed-version topology does not flip every bucket to out of sync.

The reconcilers also run on a 10-minute timer instead of at startup only,
so drift is repaired without waiting for a restart. Both are no-ops when
the wiring already matches — the bucket pass compares serialized targets
and the rule set before writing. The tick takes the site-replication
lifecycle lock with a non-blocking try_acquire and skips the round when
an add/remove/endpoint-refresh holds it: those run in phases, and
rebuilding rules between two of them would resurrect what the operation
just tore down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(site-replication): invert reconcile dependency to satisfy layers

`startup_services.rs` sits in the infra layer and was calling the
reconcilers in `admin::handlers::site_replication`, which is interface —
a reverse dependency that check_layer_dependencies.sh rejects.

Moving the reconcilers down is not viable in this change: they rest on
the site-replication state core (`SiteReplicationState` alone has 107
in-file uses, `load_site_replication_state` 38, the state lock 33), so
relocating it would move ~2000 lines and ~200 call sites through a
bug-fix PR.

Invert the direction instead. A new infra module owns the contract and
the schedule; the admin layer registers its reconciler from
`register_site_replication_route`, which runs while the admin router is
built — `init_startup_http_servers` awaits that before
`init_startup_runtime_services` reconciles, so the hook is always
installed in time. No logic moves and no baseline entry is added: the
dependency genuinely reverses.

The lifecycle guard now wraps both reconcilers in one round rather than
each separately, closing the window between them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(site-replication): harden reconcile per review feedback

Addresses the automated review on #5292.

Security: secret recovery from bucket targets accepted any target carrying
the `site-replicator-0` access key. Bucket targets are writable by anyone
holding `admin:SetBucketTarget`, so such a principal could plant a secret
and have reconciliation recreate the broadly privileged replication
account with it. A target must now name a peer in the persisted state and
point at that peer's recorded endpoint, disagreeing targets abort the
recovery, and only missing/unreadable-account errors may trigger it at
all — a transient store failure no longer rewrites a live account.

Durability: the repair no longer deletes before creating. A readable
account is rebound in place through a new `parent_user` field on
`UpdateServiceAccountOpts`, gated to `site-replicator-0` under
`allow_site_replicator_account` exactly as the account itself is. The
parent also lives in the session-token claims, and
`prepare_service_account_auth` denies the account when the two disagree,
so both move together.

Availability: the reconcile scheduler no longer requires an inline IAM
bootstrap. Deferred IAM recovers in the background with no callback into
the scheduler, which left a recovered node with self-pointing rules until
the next restart. It now starts unconditionally and returns early while
IAM or the object store are unavailable. Its first pass runs inside the
task, so walking every bucket no longer delays startup.

Correctness: an endpoint refresh commits bucket targets and peer state in
separate steps without holding the lifecycle lock, so a tick landing
between them rewrote targets from the stale endpoint; the reconciler now
also skips while any pending marker is set. Rule repair preserves an
operator-authored `role` and clears only sender-owned site-replication
ARNs, matching the merge path.

Hot path: the per-object missing-target message returns to debug. The
condition is reported once per bucket per reconcile pass instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: houseme <housemecn@gmail.com>
2026-07-27 22:39:23 +08:00
houseme 300beff970 fix: classify manual transition tier failures (#5335)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-27 12:49:08 +00:00
houseme 0a2370c024 docs: clarify outbound allowlist scope (#5336) 2026-07-27 19:57:22 +08:00
Heracles 0c9d721910 docs: document RUSTFS_OUTBOUND_ALLOW_ORIGINS outbound policy (#5334) 2026-07-27 19:50:47 +08:00
houseme f6c227628f test(scripts): capture manual transition failure samples (#5331)
Add a reusable evidence collector for manual transition tier-failure attribution samples and wire the runbook tests to cover its dry-run and argument guards.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-27 18:49:48 +08:00
houseme 0cbfa1ac90 test(internode): pin JSON fallback compatibility (#5330)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-27 09:49:24 +00:00
houseme ab5aa54035 fix(ecstore): replay manual transition task journal (#5329)
Recover accepted manual-transition tasks from the durable task journal when worker-result markers are missing after a restart.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-27 17:36:40 +08:00
houseme 464bf45e15 chore(scripts): add manual transition follow-up monitor (#5328)
* test(scripts): cover manual transition runbooks

Add a script-test entry for the manual transition rollout and soak runbook generators. The test covers ratio fail-fast behavior, generated status/metrics/journal checks, and runnable dry-run artifacts.

Verification:

- ./scripts/test_manual_transition_runbooks.sh

- make script-tests

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

* test(scripts): assert nightly stress snapshot hooks

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

* feat(scripts): add transition CI follow-up monitor

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-27 16:36:41 +08:00
houseme bc2d8e0c60 test(scripts): cover manual transition runbooks (#5327)
Add a script-test entry for the manual transition rollout and soak runbook generators. The test covers ratio fail-fast behavior, generated status/metrics/journal checks, and runnable dry-run artifacts.

Verification:

- ./scripts/test_manual_transition_runbooks.sh

- make script-tests

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-27 07:56:58 +00:00
GatewayJ 24cf2cdb78 fix(iam): align OIDC parent IDs with MinIO (#5290)
* fix(iam): align OIDC parent IDs with MinIO

* test(iam): cover OIDC STS binding policy lookup

* test(admin): use runtime facade for AppContext setup

* test(ilm): wait for lifecycle backfill before manual failure

* test(ilm): make overlapping admission check concurrent
2026-07-27 07:16:22 +00:00
houseme e076e8cc6e feat(scripts): add runbook entrypoints for #1508 #1510 (#5326)
* feat(ecstore): attribute manual transition worker failures

Add manual transition worker failure reason tracking and persistence recovery compatibility for checksum validation.

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

* chore(ilm): add manual transition diagnostics scripts

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

* style(ecstore): format manual transition attribution

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

* fix(ecstore): avoid copying failure reasons via clone

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

* fix(manual-transition): improve matrix verification scripts

- Add strict mixed rollout phase ratio validation.
- Add strict read/write ratio validation for soak mix.
- Inline generated admin-check command blocks into mixed-rollout run script.

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

* feat(scripts): add runbook entrypoints for #1508 #1510

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-27 06:47:28 +00:00
houseme 9d84056d7b feat(ecstore): attribute manual transition worker failures (#5324)
* feat(ecstore): attribute manual transition worker failures

Add manual transition worker failure reason tracking and persistence recovery compatibility for checksum validation.

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

* chore(ilm): add manual transition diagnostics scripts

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

* style(ecstore): format manual transition attribution

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

* fix(ecstore): avoid copying failure reasons via clone

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-27 06:06:12 +00:00
houseme f566b382a0 fix(bench): formalize paired ABBA corrected artifacts (#5322)
Add a reusable offline rebuild path for pinned paired ABBA benchmark evidence so corrected summaries can be regenerated from raw warp logs after parser fixes.

Record ACK-contract comparability explicitly in the rebuilt product comparison output, preventing relaxed RustFS ACK results from being treated as direct strict MinIO comparisons.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-27 04:06:51 +00:00
dependabot[bot] a909f2f27b chore(deps): bump the dependencies group with 3 updates (#5321)
Bumps the dependencies group with 3 updates: [jiff](https://github.com/BurntSushi/jiff), [serial_test](https://github.com/palfrey/serial_test) and [hotpath](https://github.com/pawurb/hotpath-rs).


Updates `jiff` from 0.2.34 to 0.2.35
- [Release notes](https://github.com/BurntSushi/jiff/releases)
- [Changelog](https://github.com/BurntSushi/jiff/blob/master/CHANGELOG.md)
- [Commits](https://github.com/BurntSushi/jiff/compare/jiff-static-0.2.34...jiff-static-0.2.35)

Updates `serial_test` from 3.5.0 to 4.0.1
- [Release notes](https://github.com/palfrey/serial_test/releases)
- [Commits](https://github.com/palfrey/serial_test/compare/v3.5.0...v4.0.1)

Updates `hotpath` from 0.21.5 to 0.22.0
- [Release notes](https://github.com/pawurb/hotpath-rs/releases)
- [Changelog](https://github.com/pawurb/hotpath-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/pawurb/hotpath-rs/compare/v0.21.5...v0.22.0)

---
updated-dependencies:
- dependency-name: jiff
  dependency-version: 0.2.35
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependencies
- dependency-name: serial_test
  dependency-version: 4.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: dependencies
- dependency-name: hotpath
  dependency-version: 0.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 02:42:07 +00:00
houseme 5af997ce5f test(ilm): cover manual transition e2e stress (#5320)
* test(e2e): cover distributed manual transition admission

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

* test: cover manual transition restart recovery

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

* test(ilm): cover queue pressure status readback

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 18:46:15 +00:00
houseme 4bd8cc1369 fix(bench): parse final warp report metrics (#5308)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 17:49:00 +00:00
houseme 3500f2e5ee test(e2e): cover distributed manual transition admission (#5319)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 17:42:29 +00:00
houseme e0e2eb30a9 test(ilm): cover pending worker budget status (#5317)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 17:38:28 +00:00
houseme 467fb0a15c test(ilm): cover restart cancel readback (#5316)
* test(ilm): cover restart cancel readback

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

* style(ilm): format restart cancel e2e

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 17:36:58 +00:00
houseme 0902538ceb test(ilm): cover manual transition restart recovery (#5318)
test: cover manual transition restart recovery

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 17:33:59 +00:00
houseme fec09968b9 fix(ilm): preserve manual transition duration budget (#5315)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 17:32:33 +00:00
houseme 4b09239ceb test(ilm): cover parallel bucket admission (#5314)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 17:30:48 +00:00
houseme cc3c39da5c test(ilm): cover queue pressure job readback (#5313)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 17:16:53 +00:00
houseme 07f6d5cda6 test(admin): cover manual transition capabilities e2e (#5311)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 17:14:14 +00:00
houseme e79337bb5c test(ilm): cover queue-full terminal job records (#5310)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 17:11:47 +00:00
houseme 683ff52a7b feat(internode): track msgpack decode traffic (#5309)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 17:03:58 +00:00
Zhengchao An 63e57378d6 fix(ecstore): never cache fabricated bucket metadata as authoritative (#5307)
BucketMetadataSys::get_config lazily fabricated a default BucketMetadata
(object-lock off) for any bucket whose .metadata.bin was ConfigNotFound and
cached it in the map that the map-only, fail-closed metadata_sys::get()
serves. The object-lock batch-delete gate (object_lock_delete_check_required,
backlog#929 / #4297) treats that map as authoritative, so a metadata miss
became a cached "no lock" answer: a versioning peek could poison the cache
and let delete_objects skip the per-object retention/legal-hold stat. The
same fabrication raced make_bucket (lost update overwriting freshly
persisted lock-enabled metadata) and let the 15-minute refresh loop replace
good cached metadata on a transient quorum dip.

Production changes:
- get_config caches only metadata actually read from disk; misses are
  recorded in a bounded negative cache (30s TTL, 10k entries, invalidated by
  set()) so repeated lookups for metadata-less names cost no extra
  namespace-lock + erasure-set fanout (reachable pre-auth via CORS
  preflight and per-key in DeleteObjects).
- concurrent_load never lets a fabricated default REPLACE an existing map
  entry; startup insert-if-vacant behavior for legacy buckets is preserved.
- delete_objects and new_ns_lock resolve dist-erasure, versioning, and the
  object-lock gate from the set's own instance context (backlog#1052)
  instead of the ambient facade, so a second in-process instance (or, in
  tests, another test's transient DistErasure window) cannot reroute
  locking onto an empty dist locker list or answer with the wrong
  instance's bucket state.

Test-isolation changes (the bug that surfaced all of the above: the
delete_objects lock-gating test failed deterministically when sharing a
process with the lifecycle env tests):
- The MinIO-migration test builds on an isolated InstanceContext instead of
  registering soon-deleted disks in the shared bootstrap registry.
- The cached lifecycle env re-registers its disks on every use, surviving
  other serial tests' reset_local_disk_test_state.
- Hermetic SetDisks helpers gain isolated-context variants pinned to plain
  erasure; tier-free non-serial test modules use them, guard-based
  SetupTypeGuard tests stay on the bootstrap context.
- Three deterministic pin tests (nextest-safe) cover the caching contract,
  the delete gate resolution source, and the ns-lock resolution source.

Verification:
- cargo test -p rustfs-ecstore --lib -- --exact <4-test combo from the
  report> (previously failing, now green)
- cargo test -p rustfs-ecstore --lib: 3169 passed / 0 failed across
  repeated runs; cargo fmt --check and cargo clippy --lib --tests clean
- Adversarial validation (high-risk tier, all seven roles) run per
  AGENTS.md; all findings fixed or rebutted with evidence
2026-07-27 00:53:48 +08:00
Zhengchao An b2a376c2d2 Merge commit from fork
* fix(admin): bound IAM import archive expansion

MAX_IAM_IMPORT_SIZE caps the compressed upload at 10 MB, but every member of the
archive was then read with read_to_end into an unbounded Vec. Deflate ratios well
above 100:1 are easy to construct, so a small authorized upload could expand
without limit across the seven members ImportIam reads.

Add a shared expansion budget (MAX_IAM_IMPORT_EXPANDED_SIZE, 10x the compressed
cap) drawn down by every member, and route all seven reads through one helper
that reads a byte past the remaining budget to detect overrun. Sharing the budget
bounds the archive as a whole rather than letting each member spend the full
limit independently.

Covers R03-CAN-024 through R03-CAN-030 plus R04-CAN-077 (backlog #1471) — one
fix rather than seven, since all seven call sites were byte-identical.

* fix(kms): confine local key paths and refuse silent key replacement

Local KMS key identifiers arrive from request input — the `name` tag on CreateKey,
the `keyId` body field or query parameter on DeleteKey — and were joined onto
`key_dir` with no validation. An identifier such as `../../tmp/evil` escaped the
configured directory, making key creation a constrained arbitrary-file write and
`DeleteKey` with `force_immediate` a cross-directory delete.

Validate in `master_key_path` and make it fallible, so every filesystem path in
this backend inherits the guard: decode_stored_key, load_master_key,
save_master_key, create_key and delete_key all derive their paths there. The rule
is containment rather than a character allowlist, so identifiers already in use
keep resolving; only separators, NUL, absolute paths and non-single-component
forms are refused. Note `.` and `..` are contained rather than refused — the
`.key` suffix turns them into the ordinary filenames `..key` and `...key`.

Separately, `LocalKmsBackend::create_key` had no existence check, while the
sibling `KmsClient::create_key` has always had one. Since `save_master_key`
renames over its destination, creating a key under an existing name silently
replaced its material and destroyed the ability to decrypt everything wrapped
under it — and the backend path is the one the admin API uses. It now returns
KeyAlreadyExists, matching StaticKmsBackend.

Covers R03-CAN-072, R03-CAN-073 and R07-CAN-103 (backlog #1475). R03-CAN-073
needed no separate change: delete_key routes both its load and its remove_file
through master_key_path.

* fix(swift): bound SLO manifest reads to the 2 MiB manifest limit

The three Swift SLO handlers that load a stored manifest (handle_slo_get,
handle_slo_get_manifest, handle_slo_delete) read the `<object>.slo-manifest`
object to EOF with AsyncReadExt::read_to_end. That key is predictable and
writable through the ordinary object PUT path, so a tenant can replace the
manifest with an arbitrarily large object and then make the server allocate
its full size on every SLO GET, multipart-manifest=get, or
multipart-manifest=delete request - a memory amplification bounded only by
the stored object size (CWE-400 / CWE-770). The 2 MiB manifest limit that
handle_slo_put enforces was not applied on the read side.

Introduce MAX_SLO_MANIFEST_SIZE (the existing 2 MiB PUT limit, now a named
constant) and a shared read_manifest_bytes helper that reads through a
`take(limit + 1)` and rejects anything larger, so an oversized manifest is
refused instead of being buffered first. All three call sites go through the
helper. handle_slo_put now checks the size before parsing the JSON.

Regression tests: test_read_manifest_bytes_rejects_oversized_manifest and
test_read_manifest_bytes_stops_reading_oversized_manifest (which asserts the
reader is not consumed past the limit), plus a boundary test that a manifest
at exactly 2 MiB is still accepted.

* fix(protocols): authorize every object in FTPS/WebDAV recursive deletes

The FTPS and WebDAV gateways authorized only the container before a
recursive delete and then destroyed everything inside it without a
further check:

- FTPS RMD (and DELE on a bucket path ending in '/') cleared
  s3:DeleteBucket, then delete_bucket_recursively listed the bucket and
  deleted every object.
- WebDAV DELETE on a bucket did the same via its own
  delete_bucket_recursively.
- WebDAV DELETE on a directory cleared s3:DeleteObject for the directory
  marker key ("dir/") only, then listed that prefix and deleted every
  child under it.

A principal holding s3:DeleteBucket (or s3:DeleteObject on a single
marker key) could therefore erase objects it had no s3:DeleteObject
permission for, and the operation reported success.

Deletion stays recursive - that is the expected behaviour for these
protocols - but each object now clears s3:DeleteObject on its own key
before it is removed, and the enumeration clears s3:ListBucket. A denial
aborts the whole operation with access denied rather than being skipped,
so the caller can never be told the delete succeeded while objects were
left behind or removed without authorization.

The test double gained shared-state cloning, delete_object/delete_bucket
call logs, and list/delete queue helpers so the regression tests can
observe that nothing is deleted once a deny lands.

* fix(server,ecstore): bound TLS handshakes and remote volume RPC waits

Three call sites let an unauthenticated client or a misbehaving peer hold
server resources with no deadline.

TLS listener (R03-CAN-035): process_connection awaited
`acceptor.accept(socket)` with no bound. A client that opens a TCP
connection and never finishes the handshake parks a Tokio task and a socket
forever, and the connection cap (RUSTFS_API_MAX_CONNECTIONS) is unlimited by
default, so nothing else sheds it. The handshake now runs under
accept_tls_with_deadline(), reusing the existing HTTP/1 header-read budget —
the established slow-client bound for the pre-request phase — and the
expiry is recorded through the same log/metric path as a handshake error,
under a new TIMEOUT failure kind.

Remote disk RPCs (R03-CAN-049, R03-CAN-050): list_volumes and delete_volume
passed Duration::ZERO, which execute_with_timeout treats as "no deadline",
so a peer that accepts the request and never answers stalls the coordinator
(and, for delete_volume, the bucket-deletion workflow). Both now pass
get_max_timeout_duration(), matching every sibling method in the file.

Regression tests: a silent TLS peer must be shed by the handshake deadline;
list_volumes/delete_volume against a peer that completes the TCP connect and
then goes silent must fail with DiskError::Timeout instead of hanging.

* fix(security): stop leaking signed headers and bound OIDC/KMS credentials

Three independent hygiene fixes found by the security review.

R03-CAN-018 (crates/signer): try_get_canonical_headers and get_signed_headers
logged the complete header map at DEBUG before signing. Runtime callers pass
session credentials and SSE-C key material through these headers, so anyone
able to raise the log level (or read DEBUG logs) recovered
X-Amz-Security-Token and SSE-C keys verbatim. The statements were debugging
leftovers with no operational value and are deleted rather than redacted.

R03-CAN-014 (crates/iam): the OIDC HTTP adapter buffered provider responses
with an unbounded Response::bytes(), so a configured, compromised or
attacker-pointed IdP endpoint could stream an arbitrarily large or endless
body into memory (the ValidateOidcConfig admin handler lets a ServerInfo
caller choose the endpoint). Responses are now read incrementally and fail
closed past MAX_OIDC_RESPONSE_SIZE, and the already SSRF-hardened client
builder gains request and connect timeouts so a stalled provider cannot pin
the calling task indefinitely.

R07-CAN-105 (helm): the Vault KMS token was serialized into the chart
ConfigMap, exposing it to every subject allowed to get ConfigMaps in the
namespace. It now renders into a dedicated Secret that the Deployment and
StatefulSet consume via envFrom; the Secret is separate from the main
credentials Secret so it also works when secret.existingSecret is set.

Regression tests:
- rustfs-signer: signing_never_logs_signed_header_material
- rustfs-iam: oidc_response_body_past_the_limit_is_rejected,
  oidc_response_body_at_the_limit_is_accepted
- scripts/test_helm_templates.sh: KMS token must never render in plaintext

* fix(webdav): enforce body limit, request timeout and connection cap

The configured WebDAV maximum body size was enforced from Content-Length, so a
chunked request declared no length and bypassed it entirely. The configured
request timeout was never applied to the connection at all, and the accept loop
spawned a task per connection with no bound, so an unauthenticated client could
hold resources indefinitely and in unbounded number.

Enforce the limit on bytes actually read rather than the declared length, apply
the configured timeout to the request, and bound accepted connections with a new
RUSTFS_WEBDAV_MAX_CONNECTIONS (default 1024) surfaced in the config report.

Covers R03-CAN-051, R03-CAN-052, R03-CAN-067, R04-CAN-089, R05-CAN-094 and
R05-CAN-097 (backlog #1471, #1474).

* fix(security): stop STS credentials from crossing the parent trust boundary

Two related credential-boundary holes let a short-lived STS credential act
with the full, unrestricted authority of the long-term user it was minted
from.

AddUser (R03-CAN-021, CWE-269/863): should_check_deny_only relaxes the admin
policy check to deny-only when a Console/STS session targets the IAM user it
represents. Nothing then stopped that session from calling AddUser with its
own parent's access key, so the handler wrote an attacker-chosen secret key
and status over the parent's stored Credentials via create_user ->
save_user_identity. A session that expires in minutes became permanent
control of the account. AddUser now rejects any temp or service-account
requester whose resolved parent equals the target access key, resolving the
parent the same way should_check_deny_only does (parent_user field, else the
JWT `parent` claim, since some stores persist the parent only in the token).

FTPS/SFTP/WebDAV password auth (R04-CAN-086, CWE-287/862): these protocols
looked the access key up with check_key, which falls back to the STS account
cache, and then compared only the stored secret. An STS access key plus
secret therefore authenticated with no session token presented and no
session-policy claims applied - the holder got the parent's full permissions.
Password authentication now rejects temporary credentials before the secret
comparison. The discriminator is is_temp() && !is_service_account(), the same
one IamCache::update_user_with_claims uses to route an identity into the STS
cache, so service accounts - which resolve policy from stored IAM state
rather than a client-presented token - keep working over these protocols.

Regression tests cover both predicates and pin the guards to their call
sites so neither can be dropped without a test failure.
2026-07-27 00:22:50 +08:00
houseme 887868e7cd test(admin): expose manual transition job capabilities (#5306)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-27 00:17:40 +08:00
houseme 0ea7f17fd7 test(ilm): cover transition budget partial records (#5305)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-27 00:15:58 +08:00
houseme 5532510e42 test(ilm): cover terminal job status readback (#5304)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 14:56:31 +00:00
houseme fc6dfa891a test(ilm): cover heartbeat backpressure status (#5303)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 14:41:18 +00:00
houseme 994678cfcf test(ilm): cover unknown checkpoint counters (#5299)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 14:33:21 +00:00
houseme cee5009c57 test(ilm): cover manual job status cancel matrix (#5302)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 14:07:02 +00:00
houseme bb130e2655 test(ilm): cover async partial continuation resume (#5301)
* test(ilm): cover async partial continuation resume

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

* style(ilm): apply rustfmt to continuation e2e

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 21:59:47 +08:00
houseme 0711a6f4fe test(ilm): cover manual transition journal replay matrix (#5300)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 13:41:32 +00:00
houseme 0a25d25e68 test(ilm): cover cancelled continuation resume (#5294)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 13:40:36 +00:00
houseme 009dd93788 test(ilm): cover restart unknown readback (#5298)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 13:15:25 +00:00
houseme 7cacd1f558 feat(ilm): persist manual transition task journals (#5296)
* feat(ilm): persist manual transition task journal

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

* fix(ilm): reconcile manual task journals on recovery

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 12:38:41 +00:00
houseme c3242f83ba test(tier): cover reporter committed prepare replay (#5297)
Add a deterministic four-hot AddTier regression for the reporter path where one peer has already durably committed prepare replay and the remaining peers still need commit fanout before local publish.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 12:37:14 +00:00
houseme e0bd18bd50 test(ilm): cover manual transition endpoint contract (#5295)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 12:36:53 +00:00
houseme 7f0c42e2bb test(perf): reject zero-byte paired warp sizes (#5289)
* test(perf): tolerate empty paired runner node args

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

* test(perf): reject zero-byte paired warp sizes

Keep the #1432 pinned paired ABBA default matrix on positive object sizes because warp refuses zero-byte object generation. Add a dry-run guard so zero-byte compatibility stays a separate functional probe instead of failing mid-benchmark.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 20:31:00 +08:00
houseme 09a991e735 test(perf): cover GET fallback metric probes (#5291)
* test(perf): cover GET fallback metric probes

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

* test(perf): prefer format fallback labels

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

* test(ilm): remove duplicate manual transition import

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 11:44:53 +00:00
houseme 14c249c266 test(ilm): cover durable checkpoint persistence (#5288)
* test(ilm): cover durable checkpoint persistence

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

* style: format checkpoint persistence imports

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 11:32:23 +00:00
houseme 5acc52b7f9 test(ilm): cover admission scope parallelism (#5293)
* test(ilm): cover cross-bucket admission scope

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

* fix(ilm): remove duplicate transition progress import

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 11:30:08 +00:00
houseme 26663e0d5d test(ilm): cover durable progress checkpoint renewal (#5287)
Add regression coverage for durable manual transition progress checkpoints so page progress persists the report, queue snapshot, and renewed scope admission lease together.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 10:34:08 +00:00
houseme caeaa4bf34 test(ilm): cover durable transition progress recovery (#5286)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 10:06:24 +00:00
houseme 05f52beea3 test(ilm): cover cancelled worker counter persistence (#5285)
* test(ilm): cover combined manual transition failures

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

* test(ecstore): route transition matrix imports

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

* test(ilm): satisfy transition matrix lint

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

* test(ilm): cover cancelled worker counter persistence

Add a focused manual transition job matrix regression for cancel-requested terminal records that already contain mixed worker completion and failure counters. The test verifies cancelled state reduction preserves worker counters and survives encode/decode.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 10:01:03 +00:00
houseme 25cf7922f5 test(perf): fix GET stage metrics capture (#5284)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 17:14:24 +08:00
houseme e6706bb94a test(ilm): cover combined manual transition failures (#5279)
* test(ilm): cover combined manual transition failures

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

* test(ecstore): route transition matrix imports

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

* test(ilm): satisfy transition matrix lint

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 17:14:01 +08:00
houseme a609b92b3c fix(ilm): retry transient scope admission races (#5280)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 17:13:53 +08:00
Zhengchao An 4e353fb3c8 refactor(storage): resolve staged dead code in store module (#5283)
refactor(storage): resolve staged dead code in store module (#730)

Drop the store module's blanket #![allow(dead_code)] and resolve every
unit it was masking, instead of keeping them staged:

- write_persistent_key_only_index: the keys-only writer wrapper only had
  test callers; the in-process rebuild flow already exists and uses the
  _with_metadata variant (prepare -> rebuild, triggered lazily from the
  opt-in listing path). Tests call _with_metadata directly now.
- ListIndexLifecycle::recover_after_restart / mark_corrupt: production
  derives index health per request from the persisted artifacts (index
  file + namespace mutation journal), and restart recovery already lives
  in load_persistent_key_only_index's journal restore. The persisted-
  lifecycle design these transitions served was superseded, and Corrupt
  had no detector, so the never-constructed Corrupt state/reason
  variants go with them.
- record_list_objects_index_opt_in_fallback (+ helpers): superseded by
  the inline per-site fallback recording in the opt-in listing path; the
  recorder recomputed health with a hardcoded None provider, so it could
  only ever report a disabled-based reason.
- Provider-contract traits (Generation/Page/PageIterator/KeyLookup):
  provider dispatch went the ListObjectsIndexProviderKind enum route;
  only ListMetadataIndexHealth is live (dyn in source-mode selection)
  and stays, minus its unused is_healthy default.
- Delete the unused list_quorum_from_env/RUSTFS_API_LIST_QUORUM pair,
  fold should_resume_local_decommission and
  should_auto_start_rebalance_after_recovered_meta into their surviving
  primitives, drop get_disk_via_endpoint, and cfg(test) the remaining
  test-only conveniences.
2026-07-26 08:54:12 +00:00
houseme 058f81c61f fix(ilm): deduplicate manual transition worker results (#5278)
* fix(ilm): fail closed on unsafe manual job recovery

Mark expired manual transition jobs Unknown when recovery cannot prove queued worker outcomes are durable. Enforce ETag-guarded admission release with exact delete preconditions so stale releasers cannot remove a replacement admission, while still allowing drained cancelled jobs to terminate.

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

* style(ilm): format manual recovery imports

Apply rustfmt import ordering after merging main into the manual recovery branch.

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

* fix(ilm): deduplicate manual transition worker results

Persist per-task manual transition worker result markers before applying job counters so duplicate worker completion reports are no-ops. Reconcile persisted markers during drained-queue lease renewal and recovery to restore marker-before-record crash windows. Fail closed when persisted worker result markers are corrupt.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 08:28:33 +00:00
houseme b4e3c7117e fix(ilm): fail closed on unsafe manual recovery (#5276)
fix(ilm): fail closed on unsafe manual job recovery

Mark expired manual transition jobs Unknown when recovery cannot prove queued worker outcomes are durable. Enforce ETag-guarded admission release with exact delete preconditions so stale releasers cannot remove a replacement admission, while still allowing drained cancelled jobs to terminate.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 07:36:58 +00:00
houseme 23f4683f20 test(ilm): cover queue terminal partial reports (#5277)
Cover manual transition terminal job records for queue closed and queue send timeout outcomes so backpressure summaries remain partial with the expected counters and queue snapshots.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 07:21:37 +00:00
houseme a539b33583 test(perf): tolerate empty paired runner node args (#5275)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 07:06:27 +00:00
houseme 4e63ee962f test(perf): preflight GET smoke buckets (#5274)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 07:01:35 +00:00
houseme 12b7f22fca test(ilm): cover stale transition admission reclaim (#5273)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 06:55:36 +00:00
houseme a047bbfcfb test(ilm): cover manual transition worker failure summary (#5272)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 06:45:06 +00:00
houseme 556f8ed62f chore(deps): update flake.lock (#5271)
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/20535e4' (2026-07-18)
  → 'github:NixOS/nixpkgs/335f073' (2026-07-24)
• Updated input 'rust-overlay':
    'github:oxalica/rust-overlay/afacd68' (2026-07-19)
  → 'github:oxalica/rust-overlay/fe21243' (2026-07-26)
2026-07-26 06:40:33 +00:00
houseme 02ad75e552 test(tier): cover committed prepare replay fanout (#5270)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 13:41:37 +08:00
houseme 21c85481b8 test(ilm): cover unknown manual transition worker loss (#5269)
test(ilm): cover unknown state after lost worker result

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 13:41:26 +08:00
houseme d5409845e2 test(perf): add exact 1MiB ABBA benchmark drivers (#5260)
* test(perf): add exact 1mib get abba harness

Add a backlog#1434 exact-1MiB GET attribution wrapper around the codec streaming smoke runner. The harness fixes the legacy versus codec-legacy ABBA profile order, the outer ReaderStream buffer axis, path-proof artifacts, and GET stage-metrics capture so reviewers can reproduce isolated-host attribution without drifting the experiment matrix.

Verification:

- scripts/test_get_1mib_abba_stage_metrics.sh

- bash -n scripts/run_get_1mib_abba_stage_metrics.sh scripts/test_get_1mib_abba_stage_metrics.sh

- git diff --check

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

* test(perf): add exact 1MiB handoff ABBA driver

Add a backlog#1434 benchmark orchestrator for isolated-host exact-1MiB handoff evidence. The runner records A/B/B/A and C/D/D/C legs, server provenance, expected reader path labels, inner and outer buffer capacity labels, and stage metrics capture arguments while delegating workload execution to the existing enhanced batch benchmark driver.

Verification:

- scripts/test_exact_1mib_handoff_abba.sh

- bash -n scripts/run_exact_1mib_handoff_abba.sh scripts/test_exact_1mib_handoff_abba.sh

- make script-tests

- git diff --check

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 13:41:00 +08:00
houseme 7667b7aaf8 feat(admin): expose ILM expiry status (#5268)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 05:32:28 +00:00
houseme 29b4a98e74 test(ilm): cover same-scope async transition conflict (#5267)
Add a black-box e2e for concurrent manual transition durable jobs with the same bucket and prefix. The test asserts that exactly one request is accepted, the other returns 409 with the active job status and cancel endpoint, and the accepted job reaches a completed terminal status after queued transitions settle.

Verification:

- cargo fmt --all --check

- git diff --check

- CARGO_TARGET_DIR=/private/tmp/rustfs-target-1481-same-scope cargo test -p e2e_test --lib reliant::tiering::test_manual_transition_async_same_scope_conflict_reports_active_job -- --exact --nocapture

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 04:51:51 +00:00
houseme fa7499ce1c fix: treat blank console address as disabled (#5266)
Keep startup validation and console server config consistent for whitespace-only console listener addresses.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 04:35:42 +00:00
houseme 1397a4e7ca feat(ilm): expose lifecycle expiry scanner counters (#5262)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 12:11:03 +08:00
houseme afaea2a3ec test(ilm): cover noncurrent version expiry e2e (#5264)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 12:10:34 +08:00
houseme 78dd2d40d3 test(ilm): cover deterministic manual transition cancel (#5263)
Add a focused ecstore regression that exercises active manual transition cancellation through the existing cancel_check hook after scan progress has been made. The test verifies the cancelled report, dry-run counters, and opaque resume cursor without relying on wall-clock timing.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 04:03:19 +00:00
houseme 342f9f94bc test(ilm): cover manual transition queue pressure status (#5265)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 04:02:44 +00:00
houseme d887e7e31d test(internode): pin msgpack rollout gates (#5261)
Extend the internode gRPC benchmark driver with P2 request-only, canary, after, and rollback phases. The request-only phase proves that requesting msgpack-only without fleet confirmation keeps compatibility JSON fields, while canary and rollback materialize the operator states needed for mixed-version convergence without claiming real fleet soak evidence.

Verification:

- scripts/test_internode_grpc_ab_bench.sh

- bash -n scripts/run_internode_grpc_ab_bench.sh scripts/test_internode_grpc_ab_bench.sh

- git diff --check

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 03:58:47 +00:00
houseme 0eb9dd5bdc test(perf): add pinned paired ABBA benchmark (#5259)
test(perf): add pinned paired abba bench

Add a backlog#1432 paired benchmark orchestrator that runs MinIO/RustFS in an A1/B1/B2/A2 schedule while requiring immutable server identity, source revision or release, and ACK contract labels. The runner delegates actual workload execution to run_object_batch_bench_enhanced.sh so node metrics, resource captures, and provenance stay in the shared artifact format.

Verification:

- scripts/test_pinned_paired_abba_bench.sh

- bash -n scripts/run_pinned_paired_abba_bench.sh scripts/test_pinned_paired_abba_bench.sh

- git diff --check

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 03:51:35 +00:00
cxymds 50c4dcca4f fix(helm): coordinate distributed pod startup (#5258) 2026-07-26 03:37:01 +00:00
houseme bdf3f0484d test(ilm): cover manual job status after restart (#5256)
* test(ilm): cover manual job status after restart

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

* test(ilm): retry job status recovery after restart

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 03:29:47 +00:00
houseme e897b2d7bb fix: reject overlapping console listener address (#5257)
Fail startup when the configured console listener resolves to the same listener port covered by the S3 address. This keeps SO_REUSEPORT from hiding an intra-process S3/console endpoint collision.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 03:27:13 +00:00
houseme 92f72c3912 fix(ilm): normalize cancelled manual job reports (#5255)
Ensure cancelled durable manual transition jobs expose a cancelled report both when worker results drain after a cancel request and when legacy persisted records are decoded.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 03:18:53 +00:00
houseme 6fa2d06731 fix(ilm): honor explicit object lock metadata (#5253)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 02:57:37 +00:00
houseme 18ff36c22d test(ilm): cover manual transition job auth matrix (#5254)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 02:55:38 +00:00
harry han 03af8e472b fix(lifecycle): stop tier free-version recovery walk-timeout loop (#5194)
The background tier free-version recovery walk pinned a hardcoded 60s
total wall-clock timeout that overrides every operator knob, so any
bucket whose healthy full walk exceeds 60s fails forever; the failed
run's duration was also subtracted from the next 60s tick, restarting
the walk immediately and pinning CPU and disk I/O.

- Drop the total wall-clock budget on the recovery walk
  (walkdir_timeout: Duration::ZERO) and inherit the operator-tunable
  drive stall budget (RUSTFS_DRIVE_WALKDIR_STALL_TIMEOUT_SECS) for
  per-call progress, so hung disks still fail fast.
- Back off failed runs from completion time: 60s doubling to a 600s
  cap, reset on success; never subtract the failed run's duration.
- Add RUSTFS_TIER_FREE_VERSION_RECOVERY_ENABLED (default true) to opt
  out of the recovery worker on deployments with no remote tiers;
  invalid values warn and fail open.

Fixes #5130

Co-authored-by: claude <claude@ehdtn.com>
2026-07-26 02:54:33 +00:00
houseme 42fc840630 test(e2e): cover manual transition active cancel (#5252)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 02:51:24 +00:00
houseme 3fe935982f test(e2e): assert msgpack decode-error controls (#5251)
Extend the inline fast-path mixed-msgpack E2E collector to capture msgpack/json decode-error counters by direction, message, and codec. Assert those counters remain unchanged for the multipart, part-number, SSE, compression, and transition fallback-control scenarios.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 02:07:39 +00:00
houseme 6d8e196f36 feat(ilm): trace lifecycle expiry execution (#5250)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-26 01:58:48 +00:00
Zhengchao An 5c99ca1328 fix(ecstore): fail fast on missing RPC secret in distributed startup (#5248)
When endpoints include remote nodes and no internode RPC secret is
resolvable, every remote format read fails client-side with "No valid
auth token" and startup dies ~2 minutes later with the misleading
"store init failed to load formats after 10 retries: erasure read
quorum" error (issues #4939, #5153).

Add a preflight to ECStore init that resolves the RPC secret once
before any disk opens: if any endpoint is non-local and resolution
fails, abort immediately with the operator-facing remediation message.
The preflight also emits the "RPC auth secret resolution failed" log
line so the log-analyzer rpc-secret-resolution rule keeps firing for
this scenario. Single-node (all-local) topologies never invoke the
resolver.
2026-07-26 00:28:38 +00:00
Zhengchao An 44d2c3bd34 test(kms): rename secret-named fixture bindings to satisfy logging guardrails (#5247)
scripts/check_logging_guardrails.sh flags any lowercase secret-named
identifier interpolated into a format string. The static-secret-file test
added in #5245 interpolates two fixture bindings (file_secret, env_secret)
into format! when constructing the secret file and env var, tripping the
heuristic and breaking make pre-commit on every branch based on main.

Rename the bindings to file_key_b64 / env_key_b64 so they no longer match
the heuristic. The fixtures are dummy base64 key material written to a
temp file and env var, not log output, and the test coverage is unchanged.
The guard script itself is untouched.
2026-07-25 23:28:26 +00:00
Zhengchao An 7f19e9a465 Merge commit from fork
docs/testing/security-regressions.md requires every fixed advisory to map to a
named, greppable regression test. Rename the tests added with the fixes to carry
their advisory id so `rg -i ghsa` finds them, and add the four rows to the
advisory -> test map.

Adds the FTPS MKD regression test that was missing. It primes the dummy backend
with a successful create_bucket, so the assertion distinguishes "denied at the
authorization boundary" from "backend refused" — without the queued success an
unconfigured create_bucket fails on its own and the test would pass even with
the authorization check removed. Verified it fails when the check is reverted.

DummyBackend gains a Debug impl (FtpsDriver's trait bounds require it) and a
queue_create_bucket_ok helper.

Records in the CI-execution map why ghsa_g3vq_* runs in the default pass despite
sitting behind the ftps feature: the rustfs crate defaults to ["ftps", "webdav"]
and cargo unifies features across the workspace build, so the test executes
there even though `cargo test -p rustfs-protocols` alone would skip it.
2026-07-26 07:10:50 +08:00
Zhengchao An 92f83bfe15 Merge commit from fork
* fix(policy): quantify negated string conditions per value

ForAllValues:/ForAnyValue: negated string operators computed the positive
quantified match and then negated the aggregate. That yields NOT(all match)
and NOT(any match), which is the semantics of the *other* quantifier, so
ForAllValues:StringNotEquals and ForAnyValue:StringNotEquals were exactly
transposed. The same applied to StringNotEqualsIgnoreCase, StringNotLike,
ArnNotEquals and ArnNotLike.

Introduce an explicit Quantifier and push negation into the per-value
predicate for the qualified forms, so ForAllValues requires every request
value to satisfy the operator and ForAnyValue requires at least one.
Unqualified operators keep negating the aggregate, preserving AWS
single-valued-key semantics. Absent keys now follow AWS: ForAllValues is
vacuously satisfied, ForAnyValue is not.

A request value set that is fully contained in or fully disjoint from the
policy set cannot distinguish the two quantifiers, which is why the existing
cases missed this; the new tests use partially overlapping sets.

* fix(auth): keep request headers out of server-derived condition keys

get_condition_values folded every remaining request header into the policy
condition map. HeaderMap lowercases header names, and the server-derived keys
userid, username, principaltype, versionid and signatureversion are lowercase
too, so a header of the same name collided with them. The collision branch used
extend(), and non-quantified string operators match if ANY value in the vector
matches, so sending `userid: admin` was enough to satisfy a condition on
aws:userid. The jwt:/ldap: claim keys were reachable the same way whenever the
credential carried no such claim.

groups was worse than an append: the header loop ran before the cred.groups
block, which is gated on !args.contains_key("groups"), so a `groups:` header
both injected a value and suppressed the credential's real group list.

Resolve claims and group membership before merging headers, then skip any header
naming a key the server already derived or a well-known identity/context key.
The reserved set comes from KeyName so it tracks the key registry; s3:x-amz-*
keys stay mergeable because they mirror request headers by design.

* fix(access): gate the ListBucketVersions fallback on public-access checks

An anonymous request for ListObjectVersions that the bucket policy does not
grant directly falls back to re-checking the grant as s3:ListBucket. That
fallback returned Ok(()) straight away, skipping the two gates the direct grant
passes through: deny_anonymous_table_data_plane_if_needed and the
RestrictPublicBuckets check on the bucket's public-access block.

So a bucket whose policy allows anonymous s3:ListBucket kept serving anonymous
version listings after an operator enabled RestrictPublicBuckets, even though
the equivalent GetObject was correctly denied.

Fold the fallback into policy_allowed so both routes reach the same gates.

* fix(ftps): authorize MKD against the CreateBucket boundary

FTPS MKD creates a bucket but ran no authorization check, so any principal that
could open an FTPS session could create buckets regardless of policy. Every
other operation in this driver authorizes first — LIST, RETR, STOR, DELE and
RMD all call authorize_operation — and the WebDAV gateway checks
S3Action::CreateBucket on the equivalent path.

Add the matching check so MKD clears the same boundary as an S3 CreateBucket.
2026-07-26 06:14:01 +08:00
Zhengchao An 21787a4742 test(kms): cover static secret file config (#5245) 2026-07-25 21:47:07 +00:00
Zhengchao An d874831cec chore(security): add guardrails against secret values in error strings (#5244)
* fix(kms): do not include secret value in static KMS format error

The malformed-format error message in build_static_kms_config embedded the
raw env var value. The most likely misconfiguration is setting
RUSTFS_KMS_STATIC_SECRET_KEY to the bare base64 key without the <key-name>:
prefix, in which case the full secret key material would be written to
startup logs. Drop the value from the message, matching the equivalent
parsing error in KmsConfig::from_env().

* chore(security): add guardrails against secret values in error strings

Follow-up to the PR #5222 review finding fixed in PR #5243: a config value
that fails secret-format parsing is typically the raw secret itself, and
error messages are log content — they propagate via ? and are printed by
startup/error logging far from the construction site.

Three layers so this class of leak is caught earlier next time:

- security-advisory-lessons: state explicitly that error/panic messages are
  log content; parse-failure hints must name the env var and expected
  format, never echo the input. Add a matching review prompt.
- adversarial-validation: add a security-reviewer probe that greps
  error-construction sites on secret-bearing config paths for interpolation
  of the raw value, including the duplicated-parse trap where the leak hid.
- check_logging_guardrails.sh: mechanical backstop — flag inline format
  interpolation of secret-named identifiers (secret/password/passwd/
  private_key) in checked files; add crates/kms/src/config.rs (the twin
  parse site) to the checked list. Verified the check passes on the fixed
  tree and catches the original leak at rustfs/src/init.rs:399.
2026-07-25 21:44:23 +00:00
Zhengchao An 6da69180d8 docs: streamline AGENTS.md per prompting best practices (#5246)
docs: add autonomy and approval boundaries to AGENTS.md
2026-07-25 21:34:45 +00:00
Zhengchao An 258b7d6f06 fix(kms): do not include secret value in static KMS format error (#5243)
The malformed-format error message in build_static_kms_config embedded the
raw env var value. The most likely misconfiguration is setting
RUSTFS_KMS_STATIC_SECRET_KEY to the bare base64 key without the <key-name>:
prefix, in which case the full secret key material would be written to
startup logs. Drop the value from the message, matching the equivalent
parsing error in KmsConfig::from_env().
2026-07-25 21:24:34 +00:00
Zhengchao An 05886a2c3c docs: fix dead TLS configuration doc links (#5242)
docs.rustfs.com dropped the .html suffix from page URLs, so
https://docs.rustfs.com/integration/tls-configured.html now returns 404.
Point both READMEs at the live canonical URL instead.

Supersedes #5154, which replaced the links with Wayback Machine
snapshots even though the docs site is alive and the page still exists.
2026-07-26 04:56:43 +08:00
唐小鸭 99e1f5fbd2 feat(kms): add static single-key backend (#5222) 2026-07-26 04:12:56 +08:00
Zhengchao An c984bc7251 fix(s3): return Cache-Control on GET object (#5241) 2026-07-26 04:12:11 +08:00
唐小鸭 233865d172 feat(kms): introduce KMS unavailability error and enhance data key handling (#5184) 2026-07-26 04:04:35 +08:00
Zhengchao An c5eb1c69ba test(rpc): cross-process replay/tamper acceptance for internode RPC signatures (#5236) 2026-07-26 04:04:16 +08:00
houseme 77b5e1b64c fix(ilm): report manual transition tier failures (#5238)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-25 18:02:37 +00:00
houseme 23a5db4012 fix(ilm): preserve lifecycle version groups (#5239)
* fix(ilm): evaluate complete version groups

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

* feat(ilm): log replication expiry blocks

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

* fix(ilm): diagnose incomplete noncurrent chains

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

* test(ilm): cover purge-pending version groups

Add regression coverage for lifecycle-only version listing so purge-pending versions stay present for ILM evaluation while the public ListObjectVersions projection remains filtered.

Refs rustfs/backlog#1500

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

* feat(ilm): log lifecycle evaluation failures

Emit structured lifecycle_evaluation_failed events when version group loading or evaluation fails during immediate and existing-object expiry scans.

Refs rustfs/backlog#1503

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

* test(ilm): cover incomplete noncurrent chains

Pin the fail-closed behavior for noncurrent expiration when successor_mod_time is missing and reuse a stable structured event name for that skip path.

Refs rustfs/backlog#1502

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

* test(ilm): cover one-day noncurrent expiry boundary

Add a runtime regression test proving NoncurrentVersionExpiration Days=1 stays inactive before expected_expiry_time and deletes exactly at the computed due boundary.

Refs rustfs/backlog#1504

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

* fix(ilm): keep transition checks after incomplete expiry

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-25 17:42:02 +00:00
houseme 516f7fecc1 fix(tier): converge config after peer recovery (#5240)
Retry peer tier config reloads after committed mutations so recovered nodes converge without requiring a second admin change.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-25 17:25:37 +00:00
houseme 1e95e6d311 fix(ilm): harden durable transition admission (#5235)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-25 15:46:55 +00:00
houseme 3cbe3d6b94 fix(tier): reconcile committed mutation replay (#5230)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-25 15:20:54 +00:00
Zhengchao An 61d4e04d65 feat(rpc): bind canonical body digest into internode mutating disk RPC signatures (#5234)
* feat(rpc): bind canonical body digest into internode mutating disk RPC signatures

Binds a domain-separated, length-prefixed canonical request-body digest into the
v2 HMAC signature scope for every mutating NodeService disk RPC, so an on-path
attacker on the default-plaintext internode channel can no longer tamper with a
mutation payload (or strip the msgpack `_bin` field to force the JSON fallback
decode) without invalidating the signature.

Covers 13 mutating disk RPCs: RenameData, DeleteVersion, DeleteVersions,
WriteMetadata, UpdateMetadata, WriteAll, Delete, DeletePaths, RenameFile,
RenamePart, DeleteVolume, MakeVolume, MakeVolumes. The digest covers both the
msgpack `_bin` payloads and their JSON compatibility copies. Gated fail-open by
default (RUSTFS_INTERNODE_RPC_BODY_DIGEST_STRICT) with a convergence counter, so
rolling upgrades are byte-for-byte unaffected; the replay-cache capacity is now
configurable and overflow fails closed with a metric.

Refs https://github.com/rustfs/backlog/issues/1327

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(rpc): satisfy architecture-migration compat-marker guard

Put the removal condition on the RUSTFS_COMPAT_TODO marker line itself, and
stop backticking env-var/metric names in the cleanup-register entry so the
guard's id extractor only sees the task-id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:56:27 +08:00
houseme 6974963e20 feat(ilm): add durable manual transition job store (#5229)
* feat(ilm): add manual transition job route contract

Refs #1479

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

* feat(ilm): add durable manual transition job store

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

* fix(ilm): harden durable transition job cancellation

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-25 14:54:14 +00:00
houseme 7ab0955f8b test(e2e): stabilize tier and storage class checks (#5228)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-25 14:21:52 +00:00
houseme 2cf5fd6bfc fix(getobject): avoid duplicate downstream-close metrics (#5231)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-25 14:13:47 +00:00
houseme de2c337fae test(e2e): cover async transition limit partial (#5233)
Cover async manual transition jobs that finish as partial because maxObjects truncates the scan, including terminal cancel idempotence and unchanged report counters.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-25 14:05:47 +00:00
houseme 5988606e68 test(internode): extend fallback and transition coverage (#5232)
Add decode-error metrics for internode msgpack/json compatibility paths, extend the mixed fallback e2e assertions for transitioned multipart partNumber reads, and cover manual transition async status polling plus inactive-owner status behavior.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-25 13:26:23 +00:00
Hiroaki KAWAI e73ed4f2a1 fix(lock): jitter distributed lock retries (#5113)
* fix(lock): jitter distributed lock retries

* fix(lock): keep retry jitter within bounds

* fix(lock): qualify test size_of usage

---------

Signed-off-by: houseme <housemecn@gmail.com>
Co-authored-by: houseme <housemecn@gmail.com>
2026-07-25 20:26:37 +08:00
houseme ffde6c43ee feat(ilm): add manual transition job route contract (#5221)
Refs #1479

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-25 12:22:46 +00:00
cxymds f52dde87d1 fix(object): make version undo preconditions atomic (#5225)
* fix(object): make version undo preconditions atomic

* fix(object): fence metadata-only undo copies

* fix(object): order versions by commit time
2026-07-25 19:26:50 +08:00
houseme 8e83087ba4 fix(tier): handle mutation intent CAS races (#5227)
* test(scripts): expand internode grpc ab env coverage

* fix(tier): handle mutation intent CAS races

Make tier mutation intent advancement retry idempotently when an If-Match CAS loses to a matching committed peer update.

Expand four-node inline fallback E2E coverage for mixed msgpack controls, transition readiness evidence, and per-node environment overrides.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-25 11:10:13 +00:00
Henry Guo a63b79004c fix(scanner): make distributed usage convergence authoritative (#5151)
* fix(scanner): make distributed usage cycles authoritative

* fix(scanner): close distributed refresh races

* fix(config): align scanner reload integration

* fix(admin): scope config test helpers

* fix(scanner): harden distributed usage convergence

* fix(scanner): preserve rolling activity compatibility

* fix(admin): expose non-secret optional config values

* fix(scanner): acknowledge distributed dirty usage

* fix(ecstore): make bucket mutations cancellation safe

* fix(scanner): preserve pending dirty acknowledgements

* test(obs): account for superseded scanner metric

* fix(api): reject excess detached bucket mutations

* test: close scanner convergence coverage gaps

* fix(scanner): make path tracking cleanup one-shot

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
Co-authored-by: houseme <housemecn@gmail.com>
2026-07-25 18:45:16 +08:00
GatewayJ 0364523dad fix(s3select): enforce query and resource limits (#5028)
* fix(s3select): enforce query and resource limits

* fix(s3select): close query resource limit gaps

* fix(s3select): preserve timeout and stream invariants

* fix(s3select): enforce staged query limits

* fix(s3select): preserve policy error compatibility

* fix(s3select): bound error source traversal
2026-07-25 18:44:53 +08:00
houseme 2dc4d0b651 refactor(getobject): scope downstream close tagging (#5224)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-25 09:36:19 +00:00
houseme 2ee111ad8b feat(ilm): add durable manual transition jobs (#5223)
Refs #1479

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-25 09:12:44 +00:00
houseme 9ddb30139d fix(getobject): distinguish downstream output closure (#5220)
fix(getobject): preserve internal broken pipe failures

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-25 08:22:20 +00:00
Zhengchao An ffd1b94e1f feat(rpc): add server-side internode v2 signature verification (fail-open, method-path binding groundwork) (#5160)
* feat(rpc): gate internode legacy signature fallback behind a convergence counter and strict env

Add the backlog#1327 Plan-A rollout infrastructure on top of the already-merged v2 target-bound internode gRPC authentication: a rustfs_system_network_internode_signature_v1_fallback_total counter that increments only when a request without any v2 auth headers is accepted through the legacy constant-target signature, and a RUSTFS_INTERNODE_RPC_SIGNATURE_STRICT env (default false, compile-time asserted) that, when enabled later, closes the legacy fallback path. Default behavior is fail-open and byte-identical for legacy-only peers; requests carrying v2 headers are verified as v2 with no downgrade exactly as before.

Refs https://github.com/rustfs/backlog/issues/1327

* ci: fix internode auth test lint failures

* perf(ecstore): cache internode sig strict env

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-07-25 08:17:49 +00:00
houseme ce41adfa9b test(ilm): cover manual transition e2e gaps (#5219)
* test(ilm): cover manual transition queue pressure

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

* test(protos): serialize msgpack-only env tests

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-25 07:00:24 +00:00
cxymds 59361ed786 feat(site-replication): make repair operations durable (#5217)
* feat(site-replication): make repair operations durable

* fix(site-replication): serialize durable repair execution
2026-07-25 10:35:37 +08:00
houseme dfb0a20048 test(rpc): add corrupt-json decode coverage (#5216)
* test(rpc): add corrupt-json decode coverage

* test(ilm): assert manual transition status-less contract
2026-07-25 01:03:59 +00:00
cxymds 7320d7fab2 fix(replication): make resync starts atomic (#5215) 2026-07-25 08:58:06 +08:00
Zhengchao An 28d19db9fc fix(console): handle apple touch icon paths (#5214) 2026-07-25 08:06:48 +08:00
dependabot[bot] e257573962 chore(deps): bump quinn-proto from 0.11.14 to 0.11.16 in /fuzz in the cargo group across 1 directory (#5210)
chore(deps): bump quinn-proto

Bumps the cargo group with 1 update in the /fuzz directory: [quinn-proto](https://github.com/quinn-rs/quinn).


Updates `quinn-proto` from 0.11.14 to 0.11.16
- [Release notes](https://github.com/quinn-rs/quinn/releases)
- [Commits](https://github.com/quinn-rs/quinn/compare/quinn-proto-0.11.14...quinn-proto-0.11.16)

---
updated-dependencies:
- dependency-name: quinn-proto
  dependency-version: 0.11.16
  dependency-type: indirect
  dependency-group: cargo
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 08:05:02 +08:00
cxymds 45b675c641 fix(replication): report authoritative backlog metrics (#5209) 2026-07-25 01:32:50 +08:00
cxymds 05caec0bd5 feat(replication): structure active check results (#5208) 2026-07-25 01:28:03 +08:00
cxymds eaa17e0441 feat(admin): add encrypted diagnostic archives (#5207) 2026-07-25 01:19:47 +08:00
cxymds 2b6cc0ee08 fix(admin): redact and seal config history (#5206) 2026-07-24 23:54:24 +08:00
cxymds 938f7296f9 fix(admin): report truthful diagnostic capabilities (#5205) 2026-07-24 23:33:45 +08:00
cxymds 866ac5073d fix(kms): validate configured backend cleanup (#5204)
test(kms): validate configured backend cleanup
2026-07-24 23:24:40 +08:00
houseme 187a060919 test(internode): pin msgpack gate rollback coverage (#5203)
Add test coverage for the msgpack-only request/fleet confirmation truth table and exact request JSON policy manifest.

Pin request and response rollback behavior so removing either gate restores JSON compatibility while both gates enter msgpack-only for eligible fields.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-24 15:16:23 +00:00
houseme cda443bd81 feat(ilm): reject overlapping manual transition runs (#5202)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-24 14:56:42 +00:00
houseme 44b1916103 fix(getobject): enrich stream error context (#5201)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-24 14:47:56 +00:00
Henry Guo 9d1b10144f perf(ecstore): avoid redundant leaf object scans (#5176)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-07-24 22:25:03 +08:00
cxymds d7f30fe0a2 fix(replication): persist sanitized resync errors (#5200) 2026-07-24 22:15:51 +08:00
houseme 20c4ea864a fix(ecstore): fix wide-prefix listing stalls and decode downstream logging (#5198)
* fix(ecstore): handle list stall and downstream decode logs

* docs(ecstore): keep wide-directory stall context for list_dir
2026-07-24 14:05:27 +00:00
houseme d1c2c42c90 fix(internode): align msgpack benchmark gates (#5197)
Update the internode gRPC benchmark P2 env output to require both the msgpack-only request flag and the fleet-confirmed gate before measuring a true msgpack-only phase.

Document the dual-gate rollback semantics and add a dry-run script test so the benchmark driver cannot silently regress to the single-flag flow.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-24 13:32:44 +00:00
cxymds fc43b149c5 fix(admin): restore complete config history snapshots (#5196) 2026-07-24 21:28:12 +08:00
cxymds fa235e9018 fix(s3): list multipart uploads by bucket prefix (#5195)
* fix(s3): list multipart uploads by bucket prefix

* fix(s3): preserve exact-key crash reclamation
2026-07-24 21:28:06 +08:00
houseme dd46de0945 fix(heal): report no-parity bitrot as unrecoverable (#5192)
Keep corrupted no-parity heal results on the integrity-failure path, preserve the object geometry in operator output, and document the recovery boundary for historical bad shards.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-24 20:20:40 +08:00
houseme bf6f0e5e81 test(internode): pin msgpack compatibility send sites (#5193)
test(internode): pin msgpack compat send sites

Add a checked test manifest for internode dual-encoded request and response payloads so node.proto _bin fields must be explicitly classified as msgpack-only eligible or always dual-write.

The manifest also pins the current JSON encoder call sites for request and response send paths, making future send-site drift visible before rollout.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-24 12:07:11 +00:00
houseme beb807ae2b perf(protos): cache internode msgpack-only flag (#5191)
Cache the internode msgpack-only env gate after the first read so metadata RPC send paths avoid repeated environment parsing. Keep a reset hook for tests that intentionally change the env in-process.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-24 11:54:14 +00:00
houseme d8426dc459 feat(ilm): gate durable manual transition mode (#5190)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-24 11:53:19 +00:00
cxymds f46ea6e14f fix(s3): enforce SSE-C copy key validation (#5185) 2026-07-24 19:11:36 +08:00
houseme fa26b6730d test(ilm): deny manual transition for non-admin (#5188)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-24 11:10:31 +00:00
houseme 7876319811 test(ilm): cover manual transition queue pressure (#5189)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-24 11:10:04 +00:00
houseme ea417b6a32 feat(ilm): trace manual transition runs (#5187)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-24 10:58:04 +00:00
houseme 4963412265 fix(internode): guard msgpack-only JSON fallback (#5180)
Keep internode JSON compatibility fields unless operators explicitly confirm fleet-wide msgpack-only readiness. This prevents a single legacy rollout flag from emptying JSON fields in mixed-version clusters where older peers may still read the legacy JSON payload.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-24 17:57:51 +08:00
houseme 8ac618e6c2 feat(ilm): count already transitioned manual runs (#5177)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-24 17:52:56 +08:00
GatewayJ 1c88aa43c1 fix(iam): virtualize OIDC service account parents (#5152)
* fix(iam): preserve OIDC service account policy boundary

* fix(iam): virtualize OIDC service account parents

* fix(iam): reject malformed OIDC policy boundaries

* test(iam): isolate federated policy regression

* fix(iam): keep OIDC replication envelope off claims
2026-07-24 17:41:52 +08:00
houseme a5a73610b6 fix(ecstore): self-verify no-parity bitrot writes (#5179)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-24 17:36:30 +08:00
cxymds 9eaf5fc8e3 fix(s3): complete CopyObject checksum support (#5178) 2026-07-24 16:57:11 +08:00
houseme 3132637294 feat(ilm): bound manual transition duration (#5174)
* feat(ilm): bound manual transition duration

Add maxDurationSeconds handling for manual transition runs so operators can bound long scoped scans without changing the existing enqueue_only job contract.

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

* docs(ilm): document manual transition run limits

Document the enqueue_only manual transition contract, secret-handling guidance, and best-effort duration budget while pinning the new duration flag in the e2e response model.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-24 07:54:52 +00:00
cxymds 358caa23cb fix(storage): expose truthful storage class capabilities (#5172) 2026-07-24 15:28:03 +08:00
houseme 6765aca3f9 feat(ilm): add manual transition run endpoint (#5171)
* feat(ilm): report manual transition backfill outcomes

Add scoped lifecycle transition backfill reporting for backlog #1478 and expose enqueue outcomes needed by #1479 without changing the existing scanner/compensation bool API.

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

* feat(admin): add manual transition run endpoint

Add a bounded POST /rustfs/admin/v3/ilm/transition/run API for backlog #1477 and cover the route, policy, query parsing, and partial status contract needed by #1481. Console operations from #1480 are intentionally left for a later client integration.

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

* feat(ilm): allow manual transition resume markers

Accept additive marker and versionMarker parameters on the bounded manual transition run API so clients can continue from a partial report without changing the existing default scan behavior.

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

* fix(ilm): harden manual transition partial reports

Preserve the null-version cursor contract, stop manual scans on enqueue pressure without skipping the failed object, and keep raw resume markers out of admin JSON responses.

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

* test(ilm): add manual transition e2e coverage

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

* perf(ilm): keep transition enqueue hot path direct

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-24 15:13:22 +08:00
houseme 4133fbe0fc fix(storage): cover inline reader fallback controls (#5169)
* test(ecstore): cover inline fast path boundaries

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

* fix(storage): cover inline reader fallback controls

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

* perf(tier): keep commit fanout concurrent

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-24 06:08:17 +00:00
cxymds 6f6d8a4d3e fix(s3): persist multipart upload storage class (#5170)
Fixes rustfs/backlog#1464.
2026-07-24 13:47:25 +08:00
cxymds cb344a3c77 fix(s3): honor CopyObject replacement metadata (#5168)
Fixes rustfs/backlog#1463.
2026-07-24 12:33:33 +08:00
Zhengchao An 36e97aba26 fix(security): reject federated STS credentials that collide with the root principal (#5161)
A federated OIDC / AssumeRoleWithWebIdentity login derives parent_user from the external identity via session_identity() (username -> email -> sub -> oidc-user-unknown). At IAM request time a temporary credential whose parent_user equals the root access key is treated as owner (see rustfs/src/admin/auth.rs and the rustfs_iam owner resolution used by the policy engine and service-account paths). A federated identity whose display name happens to equal the root access key would therefore be silently granted full owner access on a string match alone.

Reject such issuance up front: immediately after parent_user is derived, and before credential generation, set_temp_user, and the site-replication hook, deny the binding when parent_user equals the root access key. Both federated flows funnel through the single DefaultFederatedSessionBinding::bind -> issue_credentials chokepoint, so this covers browser OIDC callback and AssumeRoleWithWebIdentity alike.

- rustfs/src/admin/service/federated_identity.rs: issue_credentials resolves the root access key via current_action_credentials() and rejects with FederatedSessionBindingError::InvalidRequest on collision. Added the pure helper parent_user_is_reserved(parent_user, root_access_key) so the collision decision is unit-testable without process globals; the comparison is exact and case-sensitive, mirroring the request-time owner comparison.

This is the federated-principal / root isolation pre-work (batch 0A, the immediate issuance-time block) from the OIDC review in rustfs/backlog#1437. Layer 2 (a signature-protected federated credential origin that forces is_owner=false at request time regardless of the parent_user string) is a separate follow-up. Already-issued colliding credentials continue on their existing TTL and should be revoked proactively. Legitimate federated identities are unaffected; only an exact collision with the configured root access key is denied.
2026-07-24 11:32:05 +08:00
cxymds a2fc6e15df fix(s3): honor CopyObject tagging directives (#5165)
Closes rustfs/backlog#1462.
2026-07-24 10:30:43 +08:00
houseme 0269c47bc6 fix(runtime): update codec and debug stack headroom (#5164)
* fix(runtime): raise debug worker stack headroom

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

* chore(deps): update codec to 8.0.2

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-24 09:27:49 +08:00
Zhengchao An d26adc29ca fix(security): pin OIDC discovery/token client to the outbound egress policy (#5159) 2026-07-23 17:13:33 +00:00
houseme 5131ba8271 chore(deps): refresh workspace dependencies (#5157)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-23 15:59:37 +00:00
houseme 14f31b797a feat(bench): capture node attribution metadata (#5158)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-23 15:56:48 +00:00
houseme 9f61bad94f chore(deps): update erasure codec and russh (#5155)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-23 18:13:33 +08:00
Zhengchao An 8e214104f3 perf(ecstore): add guarded encrypted multipart range seeks (#5145)
* perf(ecstore): seek encrypted multipart Range GETs to the covering part boundary on the Legacy rio v1 backend

Phase A of https://github.com/rustfs/backlog/issues/1316. Encrypted Range GETs on the default (non rio-v2) backend previously planned storage_offset=0, storage_length=oi.size and discarded the decrypted prefix, so a small Range on a large SSE object read, erasure-decoded, and decrypted the whole object. Eligible multipart objects now seek to the covering part boundary using only the per-part size/actual_size metadata facts; the multipart decrypt reader already handles streams starting at any part boundary, so rio and the on-disk format are untouched. Single-part objects, compressed payloads, defective parts tables, and zero-length ranges keep the previous full read, and RUSTFS_ENCRYPTED_RANGE_SEEK=false restores it globally. A new histogram rustfs_get_encrypted_range_read_amplification plus a full|part_seek path counter record the physical/plaintext amplification at the ReadPlan decision point.

* fix(ecstore): guard encrypted multipart range seeks

* fix(io-metrics): align encrypted range metric names with the rustfs_io_ prefix

Every other metric in rustfs-io-metrics uses the rustfs_io_ prefix; the
two encrypted-range-seek metrics were the only exception. Rename before
first release so dashboards never see the unprefixed names.
2026-07-23 08:33:54 +00:00
Zhengchao An 6bab9e421b fix(iam): route issuer-relative JWKS through config URL (#5150) 2026-07-23 13:46:51 +08:00
Zhengchao An d9e0a25174 fix(oidc): support separate discovery issuer (#5149) 2026-07-23 02:44:32 +00:00
Zhengchao An 1c8088d0b2 fix(security): redact OIDC secrets from logs and errors (#5147)
Stop the OIDC subsystem from writing credential-grade secrets into logs and returned errors.

- crates/iam/src/oidc.rs: redact sensitive header values (authorization, proxy-authorization, cookie, set-cookie) in format_http_headers, emitting only name and length; drop the raw request/response body from the DEBUG events (keep byte length); stop logging and stop splicing the raw token response body into the error returned on token_response_parse_failed (the TokenResponseBodyShape summary and length are retained); remove the now-unused format_http_body helper.
- rustfs/src/admin/handlers/oidc.rs: stop logging the raw authorization code and state on the code-exchange error path (code_len/state_len are kept).

This is the OIDC log/error redaction pre-work (batch 0B) from the OIDC review in rustfs/backlog#1437. It changes diagnostic content only; HTTP/STS status codes and legitimate request results are unchanged.
2026-07-23 01:09:53 +00:00
Zhengchao An ffcdab900a chore(release): prepare 1.0.0-beta.11 (#5146)
Bump workspace version and release assets to 1.0.0-beta.11.
2026-07-23 01:09:29 +00:00
Zhengchao An b94bf874bf test(admin): pin GHSA-5354 scope guard for derived credentials (#5144)
* fix(admin): confine service-account parent to caller scope (GHSA-5354)

AddServiceAccount gated creation only on CreateServiceAccountAdminAction and did not constrain the targetUser (the new service account's parent) to the caller's own scope. Combined with the deliberate root-credential exemption in the existence check, a non-owner principal holding that admin action could create a service account parented to the root credential, which then authenticates as owner via prepare_service_account_auth. This is the direct-handler analogue of the ImportIam parent-scope check (GHSA-566f), which already enforces the invariant via imported_service_account_parent_allowed.

Enforce the same invariant through add_service_account_parent_within_scope: a non-owner may create a service account only for itself (or, for a derived credential, its own parent); owners retain cross-user creation. Add named regression test ghsa_5354_non_owner_service_account_parent_confined_to_scope.

Refs GHSA-5354-r3w2-34m8.

* test(admin): pin GHSA-5354 scope guard for derived credentials

The GHSA-5354 fix (#5141) added `add_service_account_parent_within_scope` and a
named-boundary test, but that test only covers the self-scoped case with
`req_user == req_parent_user`. A derived credential — a service account or STS
token holding CreateServiceAccountAdminAction — has `req_user` (its own key)
distinct from `req_parent_user` (its parent), which is the realistic attacker
shape and was left unexercised.

Add a test that pins the guard's allow set to exactly `owner || is_svc_acc`
across that derived-credential shape, including a derived non-owner aiming at the
root credential, so a later change to either the guard or the `is_svc_acc`
rewrite cannot silently let a derived non-owner escape its own parent's scope.
Also note at the check site that the guard is evaluated on the original
`target_user`, before the derived-credential rewrite to `req_parent_user`.

Refs GHSA-5354-r3w2-34m8.
2026-07-23 08:48:28 +08:00
Zhengchao An 7d96ffd7fb docs(testing): record GHSA-5354 and GHSA-3ppv regression guards (#5143)
PRs #5141 and #5142 fixed GHSA-5354 (service-account parent-scope enforcement) and GHSA-3ppv (versioned object-read authorization) but did not add the advisories to the security-regressions inventory that repo policy requires for every fixed GHSA. Add both rows to the advisory -> test mapping (advisory, class, fix PR, named regression test, layer) and list ghsa_5354_* / ghsa_3ppv_* under the default-CI unit-test execution map, since both tests live in the rustfs lib target and run in the default `cargo nextest run --profile ci --all --exclude e2e_test` pass.
2026-07-23 08:31:21 +08:00
cxymds 027a749646 feat(admin): advertise site replication capabilities (#5131) 2026-07-23 08:02:50 +08:00
cxymds 6c23b8506e feat(site-replication): persist durable resync lifecycle (#5125) 2026-07-23 08:02:30 +08:00
Zhengchao An 8166561702 fix(s3): authorize versioned object reads against GetObjectVersion (GHSA-3ppv) (#5142)
get_object, the CopyObject source, and the UploadPartCopy source authorized reads that name an explicit versionId against s3:GetObject rather than s3:GetObjectVersion. A principal holding s3:GetObject but not s3:GetObjectVersion could therefore read historical object versions. get_object_attributes was already version-aware; these three paths were not.

Add a shared versioned_read_action helper that selects s3:GetObjectVersion when a version is named and s3:GetObject otherwise, and apply it to the three read/copy-source authorization sites. Add named regression test ghsa_3ppv_versioned_read_selects_get_object_version_action.

The ActionSet::is_match GetObjectVersion->GetObject compatibility mapping is intentionally retained for now; its removal is gated on a follow-up audit of the remaining version-aware read paths (HeadObject, GetObjectAcl, tagging). It does not affect this fix, which only prevents a GetObject grant from satisfying a versioned read.

Refs GHSA-3ppv-fx5m-m749.
2026-07-22 23:44:05 +00:00
Zhengchao An 9866f68d86 fix(admin): confine service-account parent to caller scope (GHSA-5354) (#5141)
AddServiceAccount gated creation only on CreateServiceAccountAdminAction and did not constrain the targetUser (the new service account's parent) to the caller's own scope. Combined with the deliberate root-credential exemption in the existence check, a non-owner principal holding that admin action could create a service account parented to the root credential, which then authenticates as owner via prepare_service_account_auth. This is the direct-handler analogue of the ImportIam parent-scope check (GHSA-566f), which already enforces the invariant via imported_service_account_parent_allowed.

Enforce the same invariant through add_service_account_parent_within_scope: a non-owner may create a service account only for itself (or, for a derived credential, its own parent); owners retain cross-user creation. Add named regression test ghsa_5354_non_owner_service_account_parent_confined_to_scope.

Refs GHSA-5354-r3w2-34m8.
2026-07-22 23:43:30 +00:00
abdullahnah92 0e8f1a187c fix(site-replication): replicate object-lock retention and legal hold (#5105)
* fix(site-replication): replicate object-lock retention and legal hold

Object Lock did not survive site replication, so a WORM-protected object was
either missing from the peer entirely or present there unprotected. Two
independent defects caused this.

1. The site-replicator service account policy granted no object-lock actions.
   Any replicated object carrying x-amz-object-lock-mode /
   x-amz-object-lock-retain-until-date was rejected by the peer with
   AccessDenied, so an object created under a retention rule never replicated
   at all. Grant s3:GetObjectRetention, s3:PutObjectRetention,
   s3:GetObjectLegalHold and s3:PutObjectLegalHold. s3:BypassGovernanceRetention
   is deliberately NOT granted: replication must never be able to erase a
   retained version on the peer.

2. PutObjectRetention and PutObjectLegalHold only rewrote object metadata. The
   object PUT path is what normally computes a replication decision and
   schedules replication, so a lock applied after upload stayed local and the
   peer kept its previous, unprotected state. Schedule replication explicitly
   after both metadata writes.

Verified live on a two-site cluster: an object created under a COMPLIANCE
retention rule now replicates, the peer copy reports COMPLIANCE, a
version-scoped delete on the peer is refused, and the content matches by MD5.
Retention and legal hold applied after upload propagate to the peer within
about 15s, and the replicated copy is likewise undeletable. No AccessDenied
replication errors remain.

Adds a regression test asserting the policy allows the four object-lock actions
and still denies governance bypass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(site-replication): persist pending marker before object-lock metadata commit

Review follow-up on the object-lock replication fix.

PutObjectRetention and PutObjectLegalHold scheduled replication but did not
persist a pending replication marker first, unlike the canonical object PUT
path. If the process restarted between the metadata commit and the replication
worker write-back, the in-memory task was lost and the object carried no
Pending/Failed marker, so the scanner heal skipped it and the peer silently
stayed unprotected.

Both handlers now mirror the PUT path ordering: compute the replication
decision once BEFORE the commit, fold SUFFIX_REPLICATION_STATUS and
SUFFIX_REPLICATION_TIMESTAMP into the eval_metadata passed to
put_object_metadata, then schedule after the commit. eval_metadata is merged
into the object metadata, so the lock fields are preserved. When the object
info cannot be read the decision is empty and nothing is scheduled.

Also aligns the two handlers to build their options the same way, and notes at
both schedule sites that this triggers a full object re-upload because
metadata-only replication is not implemented yet.

Adds an integration test asserting both handlers compute a replication decision
exactly once, so a regression that drops the scheduling is caught.

Verified live on a two-site cluster: applying retention while the peer is down
now leaves Replication Status: PENDING on disk at commit time, and once the
peer returns the lock propagates in about 15s and the status becomes COMPLETED.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 07:34:22 +08:00
Zhengchao An b32bd1f8a9 fix(ecstore): cap mmap-copy shard reads to stop large single-part GET OOM (#5140) 2026-07-22 23:32:29 +00:00
Zhengchao An c9848a6096 fix(security): enforce outbound connection policy (#5135)
Co-authored-by: cxymds <cxymds@gmail.com>
2026-07-22 22:11:28 +00:00
Zhengchao An 6e88ab2a25 fix(targets): accept both DnsFailure and Unreachable in macOS DNS test (#5138)
On macOS with DNS interception services, .invalid TLD domains resolve
to an interception address (e.g. 198.18.16.173) instead of failing DNS
resolution. The health probe then classifies the error as Unreachable
rather than DnsFailure. Accept both outcomes since both are correct
non-reachable error classifications.
2026-07-23 03:02:47 +08:00
houseme 05d4480f08 fix(admin): preserve peer topology slots (#5136)
Keep remote peer topology slots observable when peer client construction cannot build a dialing client, and publish admin server_info cache/failure state only after a complete probe round.

Covers rustfs/backlog#1426 and rustfs/backlog#1430.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-22 18:02:41 +00:00
houseme abee09dad9 fix(admin): preserve v3 topology membership (#5134)
* fix(admin): use raw topology host for peer mapping

Backlog: rustfs/backlog#1427

Keep endpoint topology membership keyed by raw host:port when mapping peers to grid hosts, while preserving DNS resolution only for the PeerRestClient dialing host.

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

* fix(admin): reconcile v3 info with topology

Backlog: rustfs/backlog#1425

Backlog: rustfs/backlog#1428

Synthesize additive unknown server rows from configured endpoint topology before v3 backend counters are computed, and add exact drive-identity coverage tests so balanced totals cannot hide a missing member.

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

* refactor(admin): collapse topology reconciliation

Build a single endpoint topology index for v3 admin server reconciliation and completeness reporting, and reuse the raw peer/grid mapping when constructing peer clients.

Backlog: rustfs/backlog#1424

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-22 15:31:31 +00:00
houseme a044d11443 chore(deps): refresh cargo dependencies (#5132)
Update selected workspace dependencies and lockfile entries.

Keep async-nats and rcgen on explicit feature sets while preserving the RustFS targets and TLS test surfaces.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-22 21:02:59 +08:00
houseme e1e6a8b020 fix(tier): gate exact remote version consumption (#5126)
* fix(tier): gate exact remote version consumption

Reject non-empty remote tier versions before transitioned GET and remote delete backend I/O when the tier backend does not support exact version operations.

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

* test(tier): split mock remote version validation fault

Separate one-shot mock remote version validation failures from persistent unsupported-backend behavior so cleanup durability tests can still verify exact-version recovery while #1358 fail-closed gate tests keep asserting no backend I/O.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-22 11:52:56 +00:00
houseme 5cfe4ccc7d fix(tier): include persisted refs in tier proof (#5128)
Extend tier mutation reference proof to cover persisted delete journal, transition transaction, and free-version references with one-pass target matching.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-22 11:31:03 +00:00
houseme df2db15ce8 fix(tier): persist unknown upload outcomes (#5127)
Advance transition transactions to UploadOutcomeUnknown before remote tier PUT so response-loss windows can be recovered through provider-authoritative probing.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-22 19:14:47 +08:00
houseme 0321e9350d fix(tier): retain prepared intents after abort failure (#5129)
Keep coordinator Prepared intents durable when peer abort recovery fails, then make reload retry abort or commit based on the persisted tier config digest.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-22 19:12:55 +08:00
houseme cd9a2eecb1 test(tier): fix uring test build fake peer (#5124)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-22 10:03:50 +00:00
houseme 8b09634e62 fix(tier): fan out coordinator mutations to peers (#5122)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-22 17:02:57 +08:00
houseme 1ede77b1c1 fix(ilm): recover unknown transition uploads by probing tier (#5120)
* fix(ilm): recover unknown transition uploads by probing tier

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

* style(ilm): format transition recovery assertions

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-22 17:02:54 +08:00
houseme 666e251b78 test(tier): cover prepared intent scan cleanup (#5121)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-22 17:02:44 +08:00
houseme 65ba138c27 test(tier): cover mixed-version committed replay (#5119)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-22 17:02:32 +08:00
Zhengchao An 92ae19b340 fix(targets): isolate invalid target instances instead of failing the whole subsystem (#5118)
A single instance with a malformed `enable` value (e.g. the typo `enable`
instead of `enabled`/`on`) made every configured notification/audit target
of every type fail to load. `create_targets_from_config_with_store_mode`
collected instance configs through the strict `try_collect_target_configs`,
whose `.collect::<Result<Vec, _>>()` short-circuits on the first error and
drops all siblings; the surrounding `?` then aborted the loop over every
plugin type.

This contradicted the function's own documented contract ("Creation is
fault-isolated per instance") and was inconsistent: target construction
failures were already isolated into the `failures` accumulator, only config
collection failures were fatal.

Add `collect_target_config_results[_from_env]`, which returns the enabled
`(id, config)` pairs plus a per-instance failure summary, and use it in the
create path. Per-instance collection errors now flow into the same
`failures`/`creation_failures` accumulator as construction failures: healthy
siblings and other target types still load, while the malformed instance is
surfaced so the notify lifecycle stays non-converged/retryable and an Admin
write cannot report a false success. Audit likewise keeps logging through
its valid targets instead of going fully Stopped.

Follow-up to #5088.
2026-07-22 16:32:16 +08:00
houseme 4607c3be53 fix(tier): recover coordinator mutation intents (#5114)
Persist coordinator tier mutation intents before config CAS, recover prepared coordinator intents whose candidate digest already matches the saved config, and clean finished coordinator records after local publish.

Refs rustfs/backlog#1357

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-22 07:35:29 +00:00
cxymds e0bac66941 fix(targets): unify runtime health snapshots (#5110)
* fix(targets): unify runtime health snapshots

* fix(targets): stabilize health snapshot merge

* fix(admin): import runtime health test type
2026-07-22 06:50:36 +00:00
houseme 31dc78eab0 feat(tier): probe transition candidates from providers (#5112)
* feat(tier): add transition candidate probe contract

Add a fail-closed WarmBackend probe contract for provider-authoritative transition candidate state. Default providers report Unsupported, while the shared mock backend can now model missing, unversioned, and exact-version candidates for follow-up recovery tests.

This is a forward-compatible foundation for #1352/#1358 recovery work and does not change production cleanup behavior.

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

* feat(tier): probe transition candidates from providers

Implement provider-authoritative transition candidate probing for S3-family warm backends by querying ListObjectVersions with exact-key filtering and fail-closed classification for delete markers, multiple versions, truncation, and unknown versioning state.

This keeps non-S3 providers on the default Unsupported probe result and forwards MinIO, RustFS, and R2 through the S3 probe implementation.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-22 06:13:32 +00:00
Zhengchao An daca7294c7 fix(ecstore): stop logging not-found listing quorum miss at error (#5111)
A metacache listing that misses quorum purely because the volume or path
is absent on a quorum of drives (every drive reports VolumeNotFound or
FileNotFound) is a benign, expected outcome: list_path_raw already returns
VolumeNotFound/FileNotFound and lets the caller decide how to react.

The common trigger is a startup race where the system bucket (.rustfs.sys)
is not yet created on every drive when an early reader such as the IAM
config loader lists config/iam/. Logging that at error prints a scary
message during normal boot and, in #5076, misled a user into blaming it
for unrelated upload failures.

Add is_benign_not_found_listing_failure() and demote the pure not-found
case to debug (state = "quorum_not_found"), keeping error for listings
that failed for a real reason (I/O, timeout, corruption). Return value and
control flow are unchanged. Add a unit test for the classifier.

Refs: #5076
2026-07-22 05:26:09 +00:00
cxymds 1655f3192e fix(notify): unify runtime lifecycle coordination (#5088)
* fix(notify): unify runtime lifecycle coordination

* fix(notify): repair lifecycle convergence checks

* fix(admin): expose effective notify state (#5097)
2026-07-22 05:01:15 +00:00
houseme 0adb3c5ea1 fix(tier): gate inexact provider versions (#5109)
Bind provider exact GET and DELETE capability to tier operation leases and reject non-empty remote versions before committing transition metadata when the provider cannot address exact versions.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-22 12:24:05 +08:00
houseme 68e156a5c5 fix(tier): retain unproven transition candidates (#5108)
Keep LocalCommitStarted transition transaction records retained when the local source cannot prove the commit, so recovery does not classify the record as failed or delete the remote candidate without cleanup proof.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-22 04:03:33 +00:00
houseme 3f60cc743e fix(tier): replay committed mutation intents (#5107)
Keep recovered tier mutation blocks installed until the local publish transition has atomically established draining for the affected tiers, so old-generation leases cannot slip in after peer commit replay and before local publish.

Delay committed intent cleanup until local publish succeeds. If peer replay succeeds but local publish fails, the durable committed intent remains available for retry and the runtime block stays fail-closed.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-22 03:06:55 +00:00
houseme 35af4a611f fix(tier): fail closed legacy mutations without etag (#5106)
* fix: fail closed legacy tier mutations without etag

Reject non-add tier config mutations when the loaded durable snapshot has no current config ETag. This keeps legacy JSON/no-ETag paths from being saved as creation-style updates before coordinator intent state can bind an old config revision.

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

* fix(tier): allow empty clear without config etag

Allow empty tier clears to initialize the binary config under the namespace coordinator lock while keeping non-empty legacy config mutations fail-closed when no current ETag exists.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-22 02:02:00 +00:00
houseme a8e7cce5e1 feat: expose list read-dir amplification metrics (#5103)
Record local read_dir entry counts and duration for live-walker ListObjects scans so wide root/prefix amplification can be measured below the cross-set merge layer.

Add a focused LocalDisk scan_dir test showing a page limit of one still observes the whole parent directory enumeration, plus metric helper coverage.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-22 08:15:41 +08:00
Jason Kossis 9469dfa5b8 fix(site-replication): delete replicated buckets 2026-07-22 01:22:41 +08:00
houseme f1d2af698c fix(tier): add mutation peer fanout helpers (#5102)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-21 17:19:07 +00:00
houseme d5f8c6c044 test(tier): cover zero reference proof matrix (#5100)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-21 17:17:38 +00:00
houseme f5303bad95 test(tier): cover peer mutation fail-closed statuses (#5101)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-21 17:15:17 +00:00
houseme cb0d4ffa76 perf: bound list merge prefetch (#5099)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-21 17:03:23 +00:00
houseme 5b61b030a4 fix(tier): narrow mutation proof targets (#5098)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-22 00:34:22 +08:00
houseme 0fbb5ba87b fix(tier): add peer mutation control client (#5096)
* fix(tier): lock tier config mutations

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

* fix(tier): add mutation RPC auth contract (#5082)

Co-authored-by: heihutu <heihutu@gmail.com>

* fix(tier): add peer mutation handler core (#5084)

Co-authored-by: heihutu <heihutu@gmail.com>

* fix(tier): add mutation control rpc service (#5087)

Co-authored-by: heihutu <heihutu@gmail.com>

* fix(tier): recover prepared mutation drains (#5093)

Recover prepared tier mutation intent records into the local tier runtime so a restarted peer fails closed before issuing new remote-tier operation leases or conflicting admin publishes.

Reconcile the recovered block map on each scan so committed, aborted, or removed intents clear stale local blocks instead of wedging the peer until process restart.

Co-authored-by: heihutu <heihutu@gmail.com>

* fix(tier): prove zero references before tier removal (#5092)

Signed-off-by: houseme <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>

* fix(tier): clear peer mutation runtime blocks (#5094)

Install prepared mutation runtime blocks when peer prepare requests are applied or replayed so followers fail closed immediately before restart recovery.

Clear the in-memory block once peer commit or abort reaches a durable terminal state, including delayed duplicate prepare requests that observe a committed or aborted record.

Co-authored-by: heihutu <heihutu@gmail.com>

* fix(tier): add peer mutation control client

Add signed tier mutation prepare, commit, and abort client calls for peer fanout while preserving the existing protobuf and RPC contract.

Verify response proofs before interpreting peer outcomes, fail closed on invalid states, and reject oversized payloads before dialing peers.

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

---------

Signed-off-by: houseme <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-21 15:52:46 +00:00
houseme f6e8ce4639 fix: preserve walk-dir internode metrics fallback (#5095)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-21 15:30:34 +00:00
houseme 937b311316 fix(tier): lock tier config mutations (#5080)
* fix(tier): lock tier config mutations

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

* fix(tier): add mutation RPC auth contract (#5082)

Co-authored-by: heihutu <heihutu@gmail.com>

* fix(tier): add peer mutation handler core (#5084)

Co-authored-by: heihutu <heihutu@gmail.com>

* fix(tier): add mutation control rpc service (#5087)

Co-authored-by: heihutu <heihutu@gmail.com>

* fix(tier): recover prepared mutation drains (#5093)

Recover prepared tier mutation intent records into the local tier runtime so a restarted peer fails closed before issuing new remote-tier operation leases or conflicting admin publishes.

Reconcile the recovered block map on each scan so committed, aborted, or removed intents clear stale local blocks instead of wedging the peer until process restart.

Co-authored-by: heihutu <heihutu@gmail.com>

* fix(tier): prove zero references before tier removal (#5092)

Signed-off-by: houseme <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>

* fix(tier): clear peer mutation runtime blocks (#5094)

Install prepared mutation runtime blocks when peer prepare requests are applied or replayed so followers fail closed immediately before restart recovery.

Clear the in-memory block once peer commit or abort reaches a durable terminal state, including delayed duplicate prepare requests that observe a committed or aborted record.

Co-authored-by: heihutu <heihutu@gmail.com>

---------

Signed-off-by: houseme <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-21 23:16:12 +08:00
houseme 62c2f81afd fix(admin): expose cluster diagnostic components (#5090)
Add machine-readable storage, listing, usage, and workload component diagnostics to the admin cluster snapshot response while keeping the external S3 API untouched.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-21 14:33:10 +00:00
Zhengchao An eed1e97967 fix: update cluster snapshot test for peer health local node semantics (#5089)
The peer health snapshot now reports CapabilityState::Supported for
local nodes (no probing needed), but the cluster_snapshot test still
expected Unknown. Update the assertion to match the new behavior
introduced in commit 7805cf5ae.
2026-07-21 13:02:25 +00:00
GatewayJ 97b618bc2b fix(iam): reject cross-identity access key collisions (#5085)
fix(iam): reject service account access key collisions
2026-07-21 20:36:25 +08:00
houseme 7805cf5ae6 fix(cluster): clarify peer health and listing timeouts (#5086)
* fix(cluster): surface observed peer health

Refs rustfs/backlog#1387

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

* fix(admin): distinguish unreported peer health

Refs rustfs/backlog#1388

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

* fix(ecstore): decouple metacache peek timeout

Refs rustfs/backlog#1389

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

* docs(ops): diagnose metacache listing timeouts

Refs rustfs/backlog#1390

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

* refactor(cluster): clarify observed peer health

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

* fix(ecstore): route capability state through contract

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-21 11:11:24 +00:00
Henry Guo bb7bba3237 fix(obs): clarify cluster bucket usage metrics (#5081)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-07-21 17:23:49 +08:00
GatewayJ 17f0bd2637 fix(iam): report duplicate access keys clearly (#5066)
* fix(iam): report duplicate access keys clearly

* fix(iam): narrow duplicate access key handling
2026-07-21 16:14:06 +08:00
houseme 7f569b67cb fix(tier): add mutation intent CAS advance (#5078)
Add idempotent terminal transition handling for tier mutation intents and a compare-and-swap record update helper backed by config-object ETags. Cover commit and abort retries, conflicting terminal updates, stale ETag rejection, and exact-limit scan pagination.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-21 08:10:31 +00:00
唐小鸭 d13345dc65 fix(site-replication): preserve HTTPS peer join endpoints (#5045)
fix(site-replication): enhance TLS handling for peer joins and add tests
2026-07-21 16:04:12 +08:00
cxymds 79d745413e fix(admin): support pool listing on single-drive setups (#5071) 2026-07-21 15:32:40 +08:00
houseme 9f25858b05 fix(tier): add mutation intent record store (#5075)
Add canonical record-object naming and crate-private save, load, and idempotent delete helpers for tier mutation intents. Cover malformed record keys, mismatched persisted mutation IDs, and an ECStore config-object round trip under test-util.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-21 07:26:23 +00:00
houseme a4e7dd70a6 fix(tier): add mutation intent foundation (#5074)
Add an internal durable tier mutation intent model for the #1357 distributed fencing work. The new model validates schema, checksum, canonical targets, mutation-specific target identity shape, config ETags, expiry, and prepared-to-terminal state transitions without changing the production mutation path yet.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-21 06:24:57 +00:00
houseme d6d22afc6e fix(ilm): recover cleanup pending transactions (#5073)
Retry cleanup-pending transition transaction recovery after restart, keeping committed remote bodies when local metadata already references them and preserving records when remote cleanup fails.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-21 14:17:32 +08:00
houseme 75381d4ffe fix(ilm): accept null version transition sources (#5069)
* fix(ilm): accept null version transition sources

Treat nil/null source version IDs as null-version transition sources when building transition transaction source identity, while preserving fail-closed validation for truly missing IDs on versioned sources.

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

* fix(ilm): preserve multipart source versions

Propagate bucket versioning into CompleteMultipartUpload, assign a concrete version ID for versioned multipart completions, and classify unversioned FileInfo sources as null-version transition sources.

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

* fix(ilm): normalize multipart completion versions

Treat nil staged version IDs as missing for versioned multipart completion and clear staged version IDs when completion publishes a null-version object under suspended or unversioned semantics.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-21 13:18:45 +08:00
houseme f32597bdb0 fix(ilm): recover uploaded transition transactions (#5068)
* fix(ilm): recover uploaded transition transactions

Persist transition transaction records through production transition uploads, use transaction-scoped remote object names, and start a conservative recovery loop for uploaded candidates.

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

* fix(ilm): reconcile committed transition records

Drop LocalCommitStarted transaction records only after object metadata confirms that the local transition commit already points at the same tier object and version.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-21 03:17:24 +00:00
houseme 1fac7a5871 chore(deps): refresh workspace dependencies (#5067)
* chore(deps): refresh workspace dependencies

Refresh compatible workspace dependencies while preserving the requested
version pins. Update async-nats and Hyper, and replace the temporary Hyper
Git patch with the released crate.

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

* chore(deps): bump hotpath to 0.21.5

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-21 02:05:42 +00:00
cxymds 0e2e01d060 fix(site-replication): harden add finalization (#5064) 2026-07-21 00:12:08 +08:00
cxymds 4f133eb95f feat(tiering): add Wasabi lifecycle target support (#5057) 2026-07-21 00:11:53 +08:00
houseme 302dd42d38 fix(ilm): add transition transaction foundation (#5065)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-20 14:10:11 +00:00
GatewayJ 48b2f3d6e3 fix(s3select): preserve CSV input as strings (#5030)
* fix(s3select): preserve CSV input as strings

* fix(s3select): address CSV schema review findings
2026-07-20 21:02:54 +08:00
Henry Guo 376b90f61f fix(lifecycle): back off idle free-version recovery (#5025)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-07-20 21:02:29 +08:00
abdullahnah92 b6838b262f fix(site-repl): inject local site into replicate add when omitted (fixes web console setup) (#5023)
fix(site-replication): inject local deployment into replicate-add payload when omitted

The web console's "Set Up Site Replication" flow posts only the remote peer(s) to
/rustfs/admin/v3/site-replication/add and omits the local deployment. The add
preflight requires the local deployment to be present
(validate_add_preflight_topology), so the console's request failed with
"site replication add request must include the local deployment" and no
replication was configured from the web UI.

Inject the local site into the sites list when the payload does not already
include it (matched by endpoint identity), before validation. `mc admin
replicate add` always sends every site, so this is a no-op for the CLI; the
local site carries no credentials (validate_add_sites already skips credential
checks for it).

Adds unit tests for inject-when-missing and no-op-when-already-present.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:56:18 +08:00
cxymds 28fdcc87be fix(tiering): make rejected upload cleanup durable (#5059)
* fix(tiering): make rejected upload cleanup durable

* fix(tiering): close transition upload cancellation gap

* test(tiering): cover failed upload without candidate

* test(tiering): synchronize cancelled cleanup recovery

* test(tiering): stabilize cancelled cleanup recovery

Prefer cancellation when the tier delete journal recovery worker is racing an immediate tick, and build the cancelled-cleanup regression store with an already-cancelled token so production recovery cannot consume the test journal.

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

---------

Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-20 20:54:32 +08:00
houseme 35f3599992 fix(ecstore): fence restore final commit by operation id (#5062)
Refs rustfs/backlog#1356

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-20 12:08:55 +00:00
houseme b44e82fef1 fix(notify): restore webhook HTTPS target initialization (#5060)
Restore the workspace reqwest default feature stack for RustFS outbound HTTPS clients, while keeping per-crate extra APIs such as json, stream, and multipart explicit. Lazily initialize the notification runtime from admin target access when RUSTFS_NOTIFY_ENABLE=true is already effective, and add regression coverage for HTTPS webhook custom CA handling and target-list visibility.

Fixes #5052.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-20 19:55:07 +08:00
abdullahnah92 7ddaae397b fix(site-repl): count replicated buckets as in-sync (base64 decode in status path) (#5022)
fix(site-replication): count replicated buckets as in-sync in replicate status

`mc admin replicate status` reported "0/N Buckets in sync" with a cross mark for
every bucket even when replication was healthy and objects had propagated.

build_sr_info stores each bucket's replication_config in the wire form as
base64-encoded XML (raw_config_to_base64), but site_replication_config_mismatch
XML-parsed that string directly without base64-decoding. The parse always
failed, so every bucket with a replication config was reported as a config
mismatch (replication_cfg_mismatch = true), which the status endpoint and mc
render as out-of-sync.

Decode the wire value (tolerant base64 decode, falling back to raw bytes) before
XML-parsing. Adds a regression test that feeds the base64 wire form used in
production; the existing tests only exercised raw XML, which masked the bug.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-07-20 10:49:51 +00:00
houseme 9e4c5e949f fix(ecstore): fence restore cleanup by operation id (#5058)
Refs rustfs/backlog#1356

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-20 10:32:02 +00:00
abdullahnah92 7cc211ae17 fix(site-replication): report peer sync state in replicate info and s… (#5020)
fix(site-replication): report peer sync state in replicate info and surface pre-existing back-fill failures

BUG 1 — blank/Unknown Sync in `mc admin replicate info` and the console:
The info endpoint (SiteReplicationInfoHandler) serializes the persisted peer
map, whose sync_state is constructed as Unknown and never updated; the health
derivation added earlier only runs in build_status_info (the status endpoint).
Persist sync_state = Enable for peers on the add and join enable paths
(promote only Unknown, never clobber an explicit Disable). build_status_info
still refines live health for the status endpoint, so info/status/console and
peer_states all report a real sync state for a healthy peer.

BUG 2 — pre-existing-bucket back-fill failures were silently swallowed:
backfill_existing_buckets_after_add returned () and the add handler reported
success with an initial_sync_error_message that only ever carried
metadata-bootstrap errors, so a pre-existing bucket that failed to propagate
was invisible. Return per-bucket failures (including the previously silent
Ok(false) runtime-unavailable no-op and the versioning-failure drop), fold
them into the add response via compose_initial_sync_error_message, and log
reverse-direction gaps on the join path.

Adds unit tests for sync_state promotion (and Disable preservation) and for
back-fill failure surfacing.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:47:31 +08:00
houseme a27fe2f56c test(ecstore): cover transition upload cancellation (#5056)
Refs rustfs/backlog#1353

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-20 09:47:19 +00:00
cxymds bd978bed2d fix(admin): stabilize cluster capacity usage reporting (#5053)
* fix(admin): stabilize cluster capacity usage reporting

* test(admin): strengthen capacity usage regressions
2026-07-20 17:42:03 +08:00
cxymds fe67af3524 fix(heal): coordinate cluster-wide control operations (#5003) 2026-07-20 09:40:46 +00:00
houseme 26573622bc test(ecstore): cover post-apply rollback error (#5054)
Refs rustfs/backlog#1355

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-20 17:05:53 +08:00
cxymds eeafc355d4 feat(heal): define fenced control wire envelopes (#4997)
* fix(rpc): bind internode auth to exact targets

* fix(heal): initialize the runtime atomically

* fix(heal): aggregate status across cluster nodes

* fix(heal): return canonical tokens for duplicate starts

* feat(heal): add authenticated control RPC contract

* feat(heal): gate control capability by cluster topology

* feat(heal): define fenced control wire envelopes

* fix(heal): return canonical tokens for duplicate starts (#4992)

---------

Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-07-20 16:54:37 +08:00
houseme 955577b66f test(ecstore): cover transition source dedupe (#5050)
Refs rustfs/backlog#1355

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-20 06:31:48 +00:00
houseme 1cff6f20c9 test(ecstore): cover transition partial rollback (#5048)
Refs rustfs/backlog#1355

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-20 06:17:48 +00:00
houseme 2abfdd8261 test(ecstore): cover transition lock-lost cleanup (#5047)
Refs rustfs/backlog#1355

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-20 13:47:42 +08:00
cxymds 908ca548bb feat(heal): gate control capability by cluster topology (#4994)
* fix(rpc): bind internode auth to exact targets

* fix(heal): initialize the runtime atomically

* fix(heal): aggregate status across cluster nodes

* fix(heal): return canonical tokens for duplicate starts

* feat(heal): add authenticated control RPC contract

* feat(heal): gate control capability by cluster topology

* fix(heal): return canonical tokens for duplicate starts (#4992)

---------

Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-07-20 05:04:30 +00:00
houseme c92e99ba95 chore(deps): refresh workspace dependencies (#5044)
Update compatible workspace dependencies and refresh the lockfile while
keeping ratelimit pinned.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-20 12:32:13 +08:00
cxymds a774bc07da feat(heal): add authenticated control RPC contract (#4993)
* fix(rpc): bind internode auth to exact targets

* fix(heal): initialize the runtime atomically

* fix(heal): aggregate status across cluster nodes

* fix(heal): return canonical tokens for duplicate starts

* feat(heal): add authenticated control RPC contract

* fix(heal): return canonical tokens for duplicate starts (#4992)

---------

Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-07-20 12:07:38 +08:00
houseme 69f543568b test(ecstore): cover transition cleanup failure (#5042)
Refs rustfs/backlog#1355

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-20 04:03:24 +00:00
houseme 2269896f5e test(ecstore): cover transition lock cleanup (#5040)
Refs rustfs/backlog#1353

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-20 02:51:29 +00:00
houseme 67c4e3e60e fix(ecstore): read provider tier version headers (#5041)
Refs rustfs/backlog#1358

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-20 02:46:29 +00:00
Zhengchao An 4f0be83ea5 ci(cla): upgrade CLA to v2 (#5046)
Point the cla-bot config and the PR template at cla/v2.md so new
contributions are checked against the v2 Individual CLA.

- .github/cla.yml: document.version v1 -> v2, url -> cla/v2.md
- .github/pull_request_template.md: CLA link -> cla/v2.md
2026-07-20 10:44:06 +08:00
Zhengchao An db3b08b612 fix(e2e): use non-default credentials in cluster test constructor (#5039)
Commit aec2ee9ec (#5005) enforced that RPC secrets cannot be derived
from the public default credentials. This broke all multi-node cluster
E2E tests that relied on the constructor's DEFAULT_ACCESS_KEY /
DEFAULT_SECRET_KEY defaults.

Move the non-default credential and RUSTFS_RPC_SECRET blanking into
the RustFSTestClusterEnvironment constructor so every cluster test
starts with a valid RPC secret derivation base. Remove the per-test
overrides in cluster_multidrive_pool_test that were added as a partial
fix in #5005.
2026-07-20 02:37:37 +00:00
houseme 998c3f561c test(ecstore): cover transition prepared combo (#5037)
Add a deterministic transition matrix case for a stale prepared metadata snapshot followed by a committed transition, duplicate transition, and late GET. The test proves the stale metadata generation is physically reclaimed, duplicate transition does not upload a second remote candidate, and the late GET reads the committed remote body byte-for-byte instead of reopening the released local source.

Refs rustfs/backlog#1355

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-19 17:13:22 +00:00
houseme f42fc54362 test(ecstore): cover real transition bitrot failures (#5036)
Refs rustfs/backlog#1353

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-19 16:09:57 +00:00
GatewayJ 8ebedddfa1 fix(s3select): reject truncated object streams (#5027)
* fix(s3select): reject truncated object streams

* fix(s3select): validate raw stream before conversion
2026-07-19 15:34:20 +00:00
Zhengchao An a73f4c345f test(rpc): cover tonic auth service binding (#5034) 2026-07-19 23:27:08 +08:00
Zhengchao An ebc0aa0365 docs: update security advisory lessons (#5032) 2026-07-19 23:26:56 +08:00
cxymds aec2ee9ec1 fix(credentials): enforce RPC fallback credential policy (#5005) 2026-07-19 23:26:42 +08:00
cxymds 4290f390dd fix(heal): aggregate status across cluster nodes (#4990)
* fix(rpc): bind internode auth to exact targets

* fix(heal): initialize the runtime atomically

* fix(heal): aggregate status across cluster nodes

---------

Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-07-19 15:25:52 +00:00
houseme 056ebcee38 fix(targets): avoid Pulsar producer name collisions (#5033)
Generate a unique Pulsar producer name for each producer instance while preserving the target type and target id context. This avoids reload-time collisions when an old producer is still connected while a new destination is initialized.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-19 15:15:32 +00:00
houseme 18f0c161dd fix(ecstore): harden tier reader and restore cleanup races (#5035)
* fix(tier): hold generation lease through readers

Refs rustfs/backlog#1354

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

* fix(restore): fence failed cleanup by source identity

Refs rustfs/backlog#1356

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-19 23:05:58 +08:00
cxymds 1ac0841f6f fix(heal): initialize the runtime atomically (#4989)
* fix(rpc): bind internode auth to exact targets

* fix(heal): initialize the runtime atomically
2026-07-19 14:20:37 +00:00
cxymds 133499c2d5 fix(rpc): bind internode auth to exact targets (#4988) 2026-07-19 21:52:57 +08:00
cxymds b0c6c4cbce fix(storage): resolve erasure parity per pool (#4977)
* fix(filemeta): add state-aware file info validation

* fix(filemeta): validate shard arithmetic and delete paths

* fix(ecstore): add fallible erasure construction

* fix(ecstore): resolve storage parity per pool

* fix(storage): report heterogeneous erasure layouts

* fix(admin): publish prepared storage config atomically

* fix(storage): harden per-pool parity boundaries

* fix(storage): address pre-PR validation findings

* test(ci): fix strict-topology validation fixtures

* fix(heal): preserve delete markers during repair

* refactor(filemeta): drop unused ValidatedFileInfo witness

ValidatedFileInfo wrapped an unread `_file_info` reference alongside an `Option<ValidatedErasureLayout>`, but only the layout was ever consumed. Return the layout directly from `FileInfo::validate` so the sole production consumer (`LocalDisk::check_parts`) and the two unit tests read it without the extra witness type and lifetime.

No behavior change.

* fix(filemeta): keep compressed and MinIO-migrated tiered objects readable

The new decode-path validation rejected several legitimate on-disk shapes that older RustFS and MinIO-migrated data carry, turning readable objects into FileCorrupt:

- Compressed objects written with an unknown upload size persist a negative per-part actual_size (the documented "unknown size" sentinel that ObjectInfo::get_actual_size already tolerates). validate_collection_contents rejected it via usize::try_from; now a negative actual_size skips shard validation and only real, non-negative sizes are checked.
- MinIO-migrated objects transitioned to a versioned remote tier store the tier version id as a UUID string, not 16 raw bytes. MetaObject::into_fileinfo returned FileCorrupt (main tolerated it as None), making all versions of the object unreadable; MetaDeleteMarker free-version records took a Some(nil) sentinel path with the same effect, which also breaks free-version expiry (remote-tier leak). Both now decode through a shared transitioned_version_id_from_meta_sys helper: 16 raw bytes or a UUID string are accepted, anything else is tolerated as None instead of failing the read.

Regression tests updated to assert the readable/compat behavior, with new tests covering MinIO string-form recovery.

* fix(scanner): build the delete-marker test fixture without erasure geometry

get_size_counts_delete_markers_separately_from_versions built its delete marker with `FileInfo::new(object, 1, 1)`, which attaches erasure geometry (data=1/parity=1/distribution). This PR classifies versions by shape via `is_storage_delete_marker()` (no geometry) rather than the raw `deleted` flag, so a geometry-bearing "delete marker" is correctly serialized as a purge-pending payload Object and counted as a version — CI saw summary.versions=3, expected 2.

Real delete markers carry no erasure geometry (delete paths build them as `FileInfo { deleted: true, ..Default::default() }`), so construct the fixture the same way. It then classifies as a storage delete marker and the counts (versions=2, delete_markers=1) hold. This keeps the PR's more-correct classification, which prevents a purge-pending object's geometry from being dropped when serialized as a bare delete marker.

* docs(changelog): note per-pool parity fix and storage-class startup upgrade caveat

Records the #4801 per-pool erasure parity fix under Fixed, and documents the upgrade behavior where a persisted storage class that a small or heterogeneous pool cannot satisfy now fails startup — with the RUSTFS_STORAGE_CLASS_STANDARD recovery steps. Docs-only; covers R4 from the on-disk compatibility audit.

* fix(heal): report parity from erasure geometry, not is_valid()

heal_object set HealResultItem.parity_blocks via `if lfi.is_valid()`, which was missed by the migration of the other quorum/metadata predicates. With the new `is_valid()` semantics (full payload validation; delete markers now return false), a delete marker or a geometry-bearing version with a benign collection quirk would misreport parity as the pool default instead of its own. Use `has_valid_erasure_geometry()` — the narrow "does this carry erasure geometry" predicate the rest of the migration uses — so reporting matches the object's actual layout. Reporting-only; no data-path change.

* fix(filemeta): do not silently serialize a non-canonical deleted FileInfo as an Object

`From<FileInfo> for FileMetaVersion` classifies by `is_storage_delete_marker()` (shape), which correctly routes canonical delete markers to Delete and purge-pending payloads (deleted=true with real erasure geometry) to Object. But a `deleted` FileInfo that is neither a canonical marker nor a valid erasure payload would silently serialize as a zero-geometry MetaObject that later fails `validate_for_metadata_read`. Write paths validate first (`validate_for_erasure_write` / `validate_for_metadata_read`), so this is a caller bug; `From` is infallible, so surface it with a structured `warn!` on the malformed branch instead of writing corrupt metadata silently. Legitimate purge-pending objects (valid geometry) are unaffected — the guard only fires for `deleted && !has_valid_erasure_geometry()`.

* test(filemeta): assert real historical xl.meta versions pass metadata-read validation

Empirical companion to the code-reasoned decode-tolerance invariants (docs/architecture/erasure-coding.md §11) and the rolling-upgrade / MinIO-migration compatibility concern: the tightened `validate_for_metadata_read` runs on every local disk read and peer-RPC-decoded FileInfo, so it must accept every version of real historically-written xl.meta, never reject it as FileCorrupt.

Loads five real fixtures — MinIO small-inline, MinIO versioned (two object versions + a delete marker), MinIO large multipart, a legacy V1 (xl.json-derived) object, and a legacy meta_ver 2 object — decodes every version with parts materialized, and asserts validate_for_metadata_read() is Ok for each. Reverting the tolerant handling (delete-marker shape, legacy per-part checksums, string/short transitioned-versionID, negative actual_size) turns this red.

* fix(ci): remove duplicate storage test re-exports

---------

Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-07-19 21:52:31 +08:00
houseme 21049401fa fix(ilm): harden tier transition failure boundaries (#5031)
* fix(tier): fence generation-scoped operations

Refs rustfs/backlog#1354

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

* fix(ilm): verify transition upload streams

Refs rustfs/backlog#1353

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

* test(ecstore): expand transition fault matrix

Refs rustfs/backlog#1355

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-19 10:48:32 +00:00
houseme 83d73b34f3 chore(deps): update flake.lock (#5026) 2026-07-19 14:28:20 +08:00
GatewayJ f9e8440a04 refactor(iam): introduce federated identity boundary (#5018) 2026-07-19 14:28:08 +08:00
Zhengchao An 7f5873dac8 fix(ecstore): resolve erasure parity per pool (#4801) (#5015)
* fix(ecstore): add fallible erasure construction

(cherry picked from commit bd148b20f7)

* fix(ecstore): resolve storage parity per pool

(cherry picked from commit c05c2cb24b)

* fix(ecstore): keep carved per-pool parity core self-contained on main

Fixups so the cherry-picked fallible-erasure + per-pool-parity core builds standalone on current main without the excluded scope-creep commits:
- runtime/sources.rs: re-add backend_storage_class_parities (removed by the per-pool commit; its rebalance caller was updated in an unrelated reporting commit that was left out). Reimplemented over the snapshot API, behavior-identical.
- config/mod.rs: rename the storage-class publish test module (main independently added a mod tests, so the cherry-pick collided).
- rustfs storage_api.rs + startup_storage.rs: route the storage-class ENV consts through the startup storage facade and use a local const for the erasure-set-drive-count env name, satisfying the layer/facade guardrail (main's guardrail is stricter than when the core was authored).

* fix(ecstore): use struct-init in erasure test helper to satisfy clippy field_reassign_with_default

The cherry-picked fallible-erasure commit's `erasure_with_invalid_dimensions` test helper built `Erasure` via `default()` then reassigned fields, which trips `clippy::field_reassign_with_default` under `-D warnings` (only surfaced by `--all-targets`, which lints test code). #4977 fixed this in a later commit that was not part of the carved core. Use struct-init with `..Default::default()`, matching #4977's final form.

* fix(rustfs): gate the test-only storage-class ENV facade re-export behind cfg(test)

The ENV constants (INLINE_BLOCK_ENV/OPTIMIZE_ENV/RRS_ENV/STANDARD_ENV) re-exported through the startup storage facade are only consumed by a #[cfg(test)] test in startup_storage.rs, so in a non-test lib build the re-export is unused and trips -D unused-imports under clippy --all-targets. Gate it with #[cfg(test)], matching #4977's final form.

---------

Co-authored-by: cxymds <cxymds@gmail.com>
2026-07-19 03:06:46 +00:00
Zhengchao An 3ed682be42 feat: offline log fault-analysis system (rustfs diagnose) (#4876)
* feat(log-analyzer): add crate skeleton and unified event model

Implements LA-1 (rustfs/backlog#1282) of the log fault-analysis system
(rustfs/backlog#1281): new synchronous rustfs-log-analyzer crate with the
LogEvent/LogLevel/SourceRef/EventKind/ParseStats model shared by all
later stages. No tokio, no rustfs-* internal deps by design.

Note: thiserror listed in the issue is deferred until a stage actually
defines error types (LA-3/LA-4) to avoid an unused dependency.

* feat(log-analyzer): add line parsing layer

Implements LA-2 (rustfs/backlog#1283): four parse channels tried in order
per line — native tracing JSON, container-prefix stripping (K8s CRI /
docker compose / journald) with JSON retry, multi-line Rust panic block
folding (both pre- and post-1.65 formats, stderr has no JSON logger), and
a plain-text fallback that never fails. Parse accounting feeds the report
parse-ratio disclosure.

* feat(log-analyzer): add ingest layer for directories and archives

Implements LA-3 (rustfs/backlog#1284): expands customer inputs (files,
directories, zip/tar/tar.gz/.zst/.gz, stdin-like readers) into parsed
events. Magic-byte detection with extension fallback, recursive archive
walking with depth/entry/byte/memory caps (every capped input disclosed
in IngestReport.skipped), first-level directory names become node labels,
and nothing is ever extracted to disk so hostile entry paths are inert.

Adds tar 0.4 to workspace deps (sync; the async astral-tokio-tar used by
rustfs-zip does not fit this crate's no-tokio contract).

* feat(log-analyzer): add rule model, matching engine, and finding aggregation

Implements LA-4 (rustfs/backlog#1285): owned serde-round-trippable Rule/
Matcher/Severity types (external JSON rules deserialize into the same
types later), fail-fast RuleSet validation that reports every problem at
once, a linear-scan engine with regexes compiled once, and an
order-independent FindingsCollector (commutative aggregates only; the
test asserts byte-identical output across shuffled input orders).

* feat(log-analyzer): add built-in seed rule library (68 rules, 12 categories)

Implements LA-5 (rustfs/backlog#1286): the 2026-07 repository-wide
failure-log survey distilled into rules across disk health, erasure/
bitrot, quorum, network/RPC, distributed locks, heal, scanner, IAM,
startup/config/TLS, capacity, decommission/rebalance, and process panics.

Every anchor was verified verbatim against the source tree (94/94 hits,
zero corrections needed). Quorum rules pre-fill implies_root_cause for
the Phase-2 folding (rustfs/backlog#1290); client-side rules carry burst
thresholds (min_count) so isolated client mistakes don't clutter reports.
Tests: one realistic positive sample per rule (table-driven), exact-set
smoke samples including the intentional internode/client signature
double-hit, and negative cases.

* feat(log-analyzer): add analysis orchestration, report rendering, and redaction

Implements LA-6 (rustfs/backlog#1287): a single-pass Analyzer that does
rule matching, minute-bucket timelines (gap-filled, merged to <=60
buckets), unmatched WARN/ERROR template clustering (placeholders for
numbers/uuids/paths/addresses/quotes, 5000-template cap disclosed as
<overflow>), mixed-UTC-offset detection, and below-min_count demotion to
a low-confidence section. Renderers: pipe-friendly terminal text, stable
JSON (schema_version=1), and ticket-pasteable Markdown. --redact hashes
customer identifiers (stable h:sha256[..8]) in samples/evidence/messages
while keeping rule ids, targets, and panic locations intact.

* feat(rustfs): add 'rustfs diagnose' subcommand for offline log fault analysis

Implements LA-7 (rustfs/backlog#1288): wires rustfs-log-analyzer into the
main binary as a diagnose subcommand that short-circuits before
observability/storage init (same pattern as 'info' / 'tls inspect') so
the report on stdout is never wrapped by the JSON logger.

rustfs diagnose <paths>... [--format text|json|md] [--since 24h]
  [--until ...] [--min-level warn] [--redact] [--top N] [--samples N]
Accepts files, directories, archives (.zip/.tar/.tar.gz/.zst/.gz) and '-'
for stdin. Exit codes: 0 = diagnosis completed (findings never fail the
process), 2 = bad arguments / no readable input.

diagnose_e2e covers the six MVP acceptance scenarios from
rustfs/backlog#1281 (directory+zst archive, multi-node zip attribution,
CRI-prefixed kubectl logs, panic folding, stable JSON schema, CLI parsing
incl. the legacy 'rustfs <volume>' preprocessor regression); the
full-binary smoke test is #[ignore]d (run with -- --ignored).
Usage doc: docs/operations/log-diagnose.md.

* ci(log-analyzer): guard rule anchors against log-message drift

Implements LA-8 (rustfs/backlog#1289): every seed-rule anchor must exist
verbatim in the rustfs source tree, so changing a log message without
updating its rule fails the gate instead of silently killing the rule.

- la-dump-anchors bin emits 'rule_id<TAB>anchor' TSV;
- scripts/check_log_analyzer_rules.sh greps each anchor (fixed-string,
  *.rs only, excluding crates/log-analyzer itself to avoid self-matches);
- RuleSet::new now rejects anchors that are blank, contain tab/newline,
  or are shorter than 8 bytes (no discriminating power); the '[FATAL]'
  anchor gained its trailing space to meet the floor while still matching
  the emit_fatal_stderr format string;
- wired as log-analyzer-rules-check into the pre-pr gate (it compiles the
  crate, so it stays out of the fast pre-commit set).

Negative self-test: breaking an anchor makes the script exit 1 naming the
rule ('MISSING anchor for rule inconsistent-drive: zzz-not-exist-anchor').

* refactor(log-analyzer): use root-relative provenance for directory inputs

Binary smoke run showed report samples citing full absolute paths, which
drowns the useful part. Directory inputs now label sources as
"<root-name>/<relative-path>" (e.g. "smoke-logs/node1/rustfs.log");
archives and single files keep their existing provenance.

* chore(log-analyzer): reword comment to satisfy the typos gate

* fix(log-analyzer): declare chrono serde feature locally after workspace feature localization

* fix(log-analyzer): bound line reads so a newline-less input cannot bypass the byte cap

read_until grew the line buffer with the entire remaining stream before the
max_total_bytes check ran, so a single multi-GB line (decompression bomb or
corrupt file) could allocate unboundedly. Replace it with a capped reader that
enforces the remaining global budget chunk-by-chunk and adds a per-line cap
(IngestOptions::max_line_bytes, default 1 MiB); over-cap tails are discarded
but still charged, and truncation is disclosed once per file as the new
line_too_long skip reason. Flagged by Codex review on #4876.

* fix(log-analyzer): redact field-shaped identifiers inside message text and widen the hash to 64 bits

--redact only hashed IPv4 literals in unstructured message text, so
bucket/object/access-key values embedded in messages (access_key=AK123,
'bucket: media, object: private/a.bin') leaked into reports documented as
safe to forward. Apply the SENSITIVE_FIELDS list to key=value / key: value
shapes in message text with the same hash as structured fields, and extend
the hash from 8 to 16 hex chars so cross-identifier collisions stay
negligible. Flagged by Codex and Copilot review on #4876.

* fix(log-analyzer): strip collector prefixes before panic-block absorption

An open panic block tested continuation lines against absorbs() before their
CRI/compose/journald prefix was stripped, so containerized panics stored the
prefix in the payload and split into a truncated panic plus text noise as soon
as the note/backtrace lines arrived. Stripping now happens once at the top of
feed() and every channel judges the payload. Flagged by Codex review on #4876.

* fix(log-analyzer): remove two input-order dependencies in representative selection

The unmatched-cluster target stayed pinned to the first-seen event while the
representative sample could be replaced, so sample and target could come from
different events and vary with input order; the pair now updates together by
lexicographic (sample, target) min. Sample selection tie-broke on line number
alone, which is only unique within one file; the key now includes the source
file. Flagged by Copilot review on #4876.

* fix(rustfs): reject negative relative times in diagnose --since/--until

parse_time_arg accepted "-24h" and produced a future timestamp, contradicting
the documented 'counted back from now' semantics. The amount now parses as
unsigned, so a leading '-' fails with the usual invalid-time error. Flagged by
Copilot review on #4876.

* feat(log-analyzer): Phase 2 — causal folding, timeline anomalies, external rules (LA-9) (#4942)

* feat(log-analyzer): collapse cascade symptoms under their root-cause finding

Phase-2 sub-item A (rustfs/backlog#1290): a finding whose rule declares
implies_root_cause edges folds under a qualifying root — root.first_seen <=
symptom.first_seen + 5min and root.last_seen >= symptom.first_seen - 30min,
existence-based when either side has no timestamps (pure stderr panics).
Findings gain collapsed_into/caused; text/markdown render the root block with
an indented cascade line and stop listing collapsed symptoms flat, while JSON
keeps every finding. Roots are promoted to the most severe position among
their block so the report top still answers 'the most likely cause'.

* feat(log-analyzer): detect timeline/clock anomalies (schema v2)

Phase-2 sub-item B (rustfs/backlog#1290): three deterministic hints rendered
between the summary and findings — mixed UTC offsets (with a clock-skew note
when signature-mismatch findings coexist), per-node time ranges that do not
overlap at all (both nodes >100 timestamped events), and log gaps of at least
max(15min, 3x bucket width) after >=3 consecutive active minutes, upgraded to
restart evidence when a startup-class finding begins within 5min after the
gap. JSON gains timeline_anomalies and schema_version bumps to 2.

* feat(diagnose): load external rules with --rules <file.json>

Phase-2 sub-item C (rustfs/backlog#1290): an external JSON rule file
({schema_version: 1, rules: [Rule...]}, the exact serde shape of the built-in
rules) merges over the seed library, with same-id rules replacing built-ins so
the support team can hotfix a misfiring rule without a release. The merged set
validates as a whole and any problem (bad regex, duplicate id, empty matcher
group, wrong schema version) prints every error and exits 2 — analysis never
runs on a half-broken set. External anchors are exempt from the CI anchor
guard, documented as author-owned quality. Adds the custom-rules section to
docs/operations/log-diagnose.md.

* fix(log-analyzer): address PR #4876 review (redaction coverage, order-independence, guards)

Redaction (--redact) now honours its "forwardable" intent across every report surface instead of a 15-name field whitelist applied over a subset:
- redact_event scrubs the full fields map (sensitive names hashed whole, every other value run through redact_text) plus provenance, so the JSON/Markdown full-sample dump no longer leaks non-whitelisted fields (client_ip, url, user, ...).
- node labels are hashed once at ingestion, so summary.nodes, per-node timeline ranges, samples and timeline anomalies stay consistent and correlatable under one stable hash.
- evidence values, unmatched-cluster templates and skipped-input paths are now redacted; peer/disk/drive/volume/node/user added to the sensitive set; IPv6 literals are hashed (without touching `rust::paths` or HH:MM:SS clocks); provenance keeps the leaf filename and hashes the customer directory/archive prefix.
- redact.rs and docs/operations/log-diagnose.md reworded to "best-effort identifier scrubbing", not an anonymization guarantee.

Order-independence (the crate's headline contract):
- the evidence value cap keeps the lexicographically smallest N distinct values instead of the first-N-by-arrival (previously order-dependent).
- first_seen/last_seen break equal-instant ties on the offset, so the serialized RFC3339 offset no longer depends on input order.

Parsing:
- a new-format panic header no longer swallows the line immediately after it when that line is itself a JSON event or a second panic header (previously dropped an interleaved ERROR in merged stdout/stderr, or merged a panic-during-panic); trailing "note: ..." backtrace lines now fold into the block.

CLI:
- diagnose --since/--until reject absurd relative amounts via checked_sub_signed instead of panicking; the exit-code doc now matches actual behaviour.

CI:
- check_log_analyzer_rules.sh is wired into the ci.yml test-and-lint job (it was only in make pre-pr, so anchor drift from other PRs could merge green).

Markdown table cells escape '|' so customer log text cannot break the table structure.

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-07-18 15:00:34 +00:00
houseme 15f4e75870 fix(cache): harden object data cache coordination (#5004)
* fix(cache): enforce projected entry capacity

Refs: rustfs/backlog#1335

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

* fix(cache): fence identity budget eviction by generation

Refs rustfs/backlog#1334.

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

* fix(cache): fence clear against concurrent fills

Refs rustfs/backlog#1333

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

* fix(cache): linearize memory reservation claims

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

* fix(cache): retain allocation memory claims

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

* fix(cache): publish memory snapshots by epoch

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

* fix(cache): coordinate cold object fills

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

* fix(ecstore): fence metadata cache transition races

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-18 14:37:10 +00:00
Zhengchao An 4faea7fcbc ci: bump repo-visuals-action to v1.3.0 (#5014)
Backward-compatible feature release; existing chart-style/animate/contributors inputs are unchanged and continue to work.
2026-07-18 22:30:28 +08:00
Zhengchao An 9b197fc1c2 docs(architecture): correct erasure-coding spec statements that contradict main (#5012)
The normative erasure-coding spec stated several target behaviors and known baseline defects as active invariants, and cited two symbols that do not exist on main. Each correction below was verified against the code.

- §4.1 shard size: legacy even-padding is RustFS-legacy-only and is NOT MinIO's sizing. MinIO (and the modern GF(2⁸) path) use plain div_ceil; for 1 MiB / 6 data shards that is 174763 bytes versus the legacy even-padded 174764. MinIO-migrated data is decoded by the modern path, so the legacy formula never applies to it.
- §6.3 / §11 MTime: the MTime key is always written (a None mod_time encodes as UNIX_EPOCH = 0), not omitted. Round-trip safety to None is enforced on the decode side, not by write omission. Only the legacy StatInfo.ModTime field is omitted-when-None.
- §7 commit: rollback after a missed write quorum is best-effort — undo failures are counted and warn!-logged, never propagated or retried — so a partially-failed rollback can leave shards behind; softened "never left partially committed" accordingly.
- §7 data_dir: reduce_common_data_dir votes over each disk's old_data_dir (a GC input for reclaiming the superseded dir), not the newly committed data_dir.
- §7 convergence: classify_rename_convergence is consumed only by the multipart-complete path (convergence.needs_heal()); the regular put_object path discards it.
- §7 write layout: removed the WriteLayout / resolve_write_layout citation — neither symbol exists on main (the write layout is computed inline), which violated the spec's own cite-real-symbols rule.
- §6.6 inline: inline presence is read from the meta_sys[inline-data] body marker alone; the header InlineData flag is written but not consulted on read, and disagreement is tolerated (MinIO may write the flag unset with inline data present). Removed the false "must agree" invariant.
- §11 UUID tolerance: version_id / data_dir (ID/DDir) decode as a fixed 16-byte Uuid::from_bytes and fail closed on a present-but-non-16-byte value; only transitioned-versionID uses the tolerant Uuid::from_slice(...).ok().filter(!nil). Split the previously-conflated bullet.
- §11 decode blanket: narrowed "everything else must degrade to a tolerant default" to enumerate the legitimate structural/geometry/version/length fail-closed guards (header array length, unknown header_ver, version > max, versions_len / bin_len bounds, non-16-byte ID/DDir).
- §8 / §13 codec guard: has_valid_dimensions() is a &self method, so it runs after construction and reliably covers only block_size == 0; a data_blocks == 0 geometry with parity > 0 panics in the .expect constructor before the guard runs. Noted the fallible-constructor fix.

Refuted and intentionally left unchanged: the "accepts container major == 1 with any minor" statement is correct — the reader compares only major (rejects major > 1) and never gates on minor, so minor 4 is accepted.
2026-07-18 21:05:22 +08:00
Zhengchao An 53728a03d3 chore: self-host contributor wall (#5006) 2026-07-18 09:48:36 +00:00
Zhengchao An 04bfd48eb1 fix(ecstore): invalidate metadata cache after ILM transition persists (#4951)
A duplicate transition task admitted after the winner released its
in-flight claim (#4839) re-reads the version before uploading, but on
unversioned buckets that read could hit a stale pre-transition entry in
the 2s-TTL GET metadata cache: transition_object never invalidated the
cache after delete_object_version persisted transition_status=complete
and freed the local data. The stale hit defeated the TRANSITION_COMPLETE
early-return, so the duplicate streamed the already-deleted local data
to the remote tier (NotFound reader errors + rejected duplicate tier
PUT with UnexpectedContent).

Invalidate the cache right after the transitioned metadata is persisted,
matching the other metadata-mutating paths, and add a regression test
that runs a duplicate transition against an already-transitioned version
and asserts no second tier upload and unchanged remote object metadata.

Fixes #4827
2026-07-18 15:03:27 +08:00
Zhengchao An 2c113542f8 docs(architecture): normative erasure-coding algorithm and on-disk compatibility contract (#4999)
* docs(architecture): add normative erasure-coding algorithm and on-disk compatibility contract

Adds docs/architecture/erasure-coding.md as the source of truth for how RustFS erasure-codes, stores, reads, reconstructs, and heals user data, and the on-disk (xl.meta) / decode compatibility contract every future change must preserve. Grounded in the baseline (main) implementation with file:line anchors; cross-links (does not duplicate) the existing placement, MinIO-format-compat, layout-boundary, decommission, and tier-ILM docs, and the AGENTS.md cross-cutting invariants.

Covers: Reed-Solomon over GF(2^8) modern vs GF(2^16) legacy backends and how each is selected; pool/set/drive geometry with the 2..=16 set-size and per-pool parity invariants; the key-derived distribution permutation (1..=N); 1 MiB block size and the modern/legacy shard-size formulas; HighwayHash256S interleaved bitrot layout; the full xl.meta container/header/version-body schema and internal dual-key convention; write/read/heal quorum rules; and, newly codified as a first-class contract, the decode-tolerance invariants (nil-UUID/epoch-mod_time to None, skip unknown fields, hard-guard only length-critical arrays, tolerate the negative actual_size compressed sentinel, tolerate a malformed transitioned-versionID). Linked from the architecture README under "Contracts & invariants". Docs-only; check_doc_paths.sh passes.

* docs(architecture): anchor erasure spec by symbol name, not line numbers

Line numbers rot as code changes and are not validated by check_doc_paths.sh, so they would silently mislead the very code changes this normative spec is meant to guide. Replace all file:line citations with file-path + symbol-name references (functions/consts/types are greppable and rename only on deliberate changes; format byte offsets are kept). Add an explicit "references work here" note and a §13 rule that governed changes must update this spec in the same PR.

* docs(architecture): correct erasure spec after multi-expert adversarial review

Four independent adversarial reviewers fact-checked every claim against the code. Fixes:

- CRITICAL (found independently by two reviewers): §1 mislabeled the GF(2^16) reed-solomon-simd "legacy" backend as the reader for "older MinIO-lineage format". It is the opposite — that backend serves RustFS's own older main-branch (rmp_serde, uses_legacy_checksum) objects; MinIO-migrated data uses the same rs-vandermonde GF(2^8) scheme and is decoded by the modern backend. The old wording contradicted §1's own MinIO-interop line, §12, minio-file-format-compat.md, and the source comments, and would have misled the highest-stakes decode-routing decision.
- §2.1/§12: set size 2..=16 holds for multi-drive layouts; single-drive deployments run at N=1 (parity 0) outside is_valid_set_size.
- §2.2: validate_parity_inner enforces parity <= N/2 only for N > 2 (user storage-class parity flows through it); the standalone validate_parity is unconditional but only applied to the resolved default.
- §6.2/§12: header format is dispatched by header_ver; array length (4/5/7) is a per-version validation, not the discriminator.
- §6.3: the part-array length guard applies on the all_parts decode path.
- §6.5: get_bytes matches only the two canonical lowercase keys; only is_internal_key/get_str are case-insensitive.
- §6.5: transitioned-versionID — state the load-bearing invariant (non-16-byte decodes to None, never fatal); string-form recovery is optional, and transitioned-xl.meta interop is out of scope.
- §11: typo RustSF -> RustFS.

All other claims across §1-§14 were verified accurate against code (distribution formula, quorum formulas, on-disk key set/endianness/markers, decode-tolerance invariants, version anchors, standard references).
2026-07-18 14:44:04 +08:00
Zhengchao An 825bf0e2d8 ci: self-host star history chart (#5001)
* ci: self-host star history chart

* ci: use animated gradient star chart
2026-07-18 14:33:45 +08:00
Zhengchao An 0346108ae4 fix(s3): populate CopyObject response checksums and persist them (#4998)
fix(s3): populate CopyObject response checksums and persist them (#4996)

A CopyObject that requested ChecksumAlgorithm=SHA256 applied the copy but returned no CopyObjectResult.ChecksumSHA256, and a later checksum-mode HEAD/GET on the destination returned nothing: execute_copy_object never read the requested algorithm, never gave the destination write a checksum, and built CopyObjectResult with ..Default::default() (all checksum fields None).

Give the destination object a checksum on the copy path. When the caller requests an algorithm, compute it fresh over the copied plaintext (the hasher sits on the innermost reader so it digests plaintext, before compression/encryption wrap it) so it persists into the object's checksum and a checksum-mode HEAD/GET returns it. When no algorithm is requested, carry the source object's stored checksum over unchanged — the copy does not transform the plaintext, so re-hashing would be wasted work and would flatten a multipart composite value. Fill CopyObjectResult from the persisted destination checksum decoded exactly the way GetObject/HeadObject do, so the response value is identical to a later checksum-mode HEAD/GET.

Add e2e regression tests: requested SHA256 is computed, returned, and HEAD-consistent; a no-algorithm copy preserves and reports the source SHA256; and a requested CRC32 over a SHA256 source is computed fresh (equals a reference CRC32 PUT), overrides the source algorithm, and is not inherited.

Fixes #4996
2026-07-18 13:32:51 +08:00
houseme 79509aad2d chore(deps): refresh workspace dependencies (#5002)
Update direct dependency constraints and refresh the lockfile while keeping
ratelimit pinned.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-18 05:22:58 +00:00
Zhengchao An d7d880b37d test(1306): pin usage serialization, snapshot cache invalidation, and listing send classification (#5000)
test(1306): pin Some(0) usage serialization, snapshot cache invalidation, and listing send classification

Follow-up test hardening for the merged admin-usage-snapshot work
(#4979/#4980/#4981/#4982, rustfs/backlog#1306). Tests only; no production
behavior change.

- B-1 madmin: pin that a scanned-but-empty bucket (Some(0)) serializes usage
  stats as zeros, staying distinct from the no-snapshot (None) omitted case.
- B-2 gating: revert detector proving save_data_usage_in_backend invalidates
  the 30s snapshot cache so a fresh save is visible to the next cached read.
- A-1 list_objects: pin that a successful gather_results send is never
  misclassified as ConsumerGone (correct state + err sentinel delivered), and
  document the wrapper Err arm invariant. A full wrapper-level producer-error
  integration test is deferred as it needs the fake-disk list harness.
2026-07-18 04:30:53 +00:00
Zhengchao An cf9e9c6fd5 fix(ilm): implement expire_restored delete semantics for restore expiry (#4950)
DeleteRestoredAction is supposed to demote a restored object back to its
pure transitioned state: remove only the local restored copy, strip the
x-amz-restore headers, and leave the version (and the remote tier data)
untouched. expire_transitioned_object set
opts.transition.expire_restored accordingly, but no delete path ever
read the flag, so delete_object ran an ordinary delete: on unversioned
buckets the whole object vanished and the free-version record scheduled
remote tier cleanup (tier data loss); on versioned buckets the latest
version got a spurious delete marker that replication propagated.

Route expire_restored explicitly in SetDisks::delete_object before
delete-marker resolution and replication dispatch: target the found
version with FileInfo.expire_restored=true and return early. The
FileMeta::delete_version layer already implements the semantics (strip
restore headers, keep the version, hand back the local data dir); this
wires it up.

Also fix the action matching in expire_transitioned_object (extracted
into transitioned_object_delete_opts): DeleteRestoredVersionAction
previously fell through to the full transitioned-object delete, which
removed the remote tier data of a noncurrent restored version. It now
routes through the same restored-copy cleanup with the exact version id,
matching MinIO's Action.DeleteVersioned()/DeleteRestored() dispatch.

Re-enable test_restore_chain_local_read_expiry_keeps_remote_and_allows_
re_restore in the ILM Integration (serial) lane; add unit tests pinning
the event->options routing and the filemeta expire_restored branch.

Closes rustfs/backlog#1302
2026-07-18 02:55:29 +00:00
Henry Guo 361334ab08 chore(deps): sync merged s3s SigV4 fix (#4987)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-07-18 10:50:13 +08:00
Henry Guo 889a45ad4d fix(scanner): back off clean idle scans across erasure clusters (#4984)
* fix(scanner): back off clean single-disk cycles

* fix(scanner): extend idle backoff across erasure clusters

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-07-18 10:49:45 +08:00
darion-yaphet 5ec124bf23 docs(architecture): prevent workspace overview from drifting (#4983)
Replace stale dependency-depth and line-count snapshots with a domain overview based on the current Cargo workspace. This preserves the high-level architecture while avoiding references to removed crates and fragile size estimates.

Constraint: Workspace membership changes independently of architecture documentation updates
Rejected: Refresh the old numeric snapshots | they would immediately become stale again
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep Cargo.toml as the source of truth for workspace membership
Tested: git diff --check; scripts/check_doc_paths.sh; cargo metadata --no-deps --format-version 1
Not-tested: make pre-commit (documentation-only change)
2026-07-18 10:49:05 +08:00
Henry Guo 906805568b feat(table-catalog): add durable backing state transfer (#4952)
* feat(table-catalog): add durable backing state transfer

* fix(table-catalog): reject orphaned migration entries

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-07-18 10:45:32 +08:00
Zhengchao An 230e5fc31a fix(auth): POST-object lock fields and signed metadata=true listing 403s (#4959)
* fix(sse): surface unconfigured managed SSE as 400, fix POST SSE-S3 e2e

Managed SSE (SSE-S3 / bucket-default) on a server without KMS and without RUSTFS_SSE_S3_MASTER_KEY fails closed by design since #3564, but surfaced as 500 InternalError. Map the misconfiguration to InvalidRequest (400) and seed the local SSE master key in the anonymous POST-object SSE e2e tests; align the missing-from-policy test with the MinIO-compatible SSE field exemption (s3s#608). Re-admit the tests to the e2e-full profile (rustfs#4844).

* fix(auth): POST-object lock fields and signed metadata=true listing 403s

Two authorization surfaces returned 403 for allowed requests (rustfs#4845): the PutObject access hook demanded PutObjectRetention/PutObjectLegalHold IAM actions for POST-object form uploads whose lock fields are governed by the validated POST policy, and the metadata=true listing route never resolved SigV4-verified credentials into ReqInfo so signed requests were evaluated as anonymous. Skip the PUT-header lock actions for POST form uploads and resolve credentials in the metadata route; re-admit the four e2e tests to the e2e-full profile.
2026-07-18 10:44:53 +08:00
Zhengchao An 40c089f31b feat(server): add opt-in global connection cap to the S3 accept loop (#4957)
Follow-up to backlog#1191 (optional sub-item). The main accept loop
spawned one task per socket with no global bound, so a connection flood
could exhaust file descriptors and memory and take existing traffic
down with it.

- New RUSTFS_API_MAX_CONNECTIONS (default 0 = unlimited, no semaphore
  constructed, accept loop unchanged).
- When set, the loop acquires an owned semaphore permit BEFORE
  accept(): at saturation it simply stops accepting and lets the
  kernel backlog absorb bursts (TCP-native backpressure) instead of
  accept-then-close churn. The permit moves into the connection task
  and is released by RAII on any exit path, including TLS handshake
  failures.
- Saturation is observable via the
  rustfs_http_server_connection_cap_saturated_total counter, a
  connection_cap_state startup event, and a per-wait debug event;
  shutdown stays responsive while parked on the semaphore.
- The cap covers everything on the main listener (S3, admin, console,
  internode gRPC); the constant docs carry sizing guidance.
- E2E coverage: ten sequential Connection-close requests against cap 2
  prove permits never leak; two stalled connections saturating cap 2
  leave a third unserved until their permits are released, after which
  the queued request is accepted and answered.
2026-07-18 10:41:51 +08:00
Zhengchao An 569fa3ec87 fix(ecstore): tolerate illumos/Solaris EEXIST for non-empty directory removal (#4995)
POSIX lets rmdir report a non-empty directory as either ENOTEMPTY or EEXIST. Linux/macOS/Windows use ENOTEMPTY (ErrorKind::DirectoryNotEmpty); illumos/Solaris return EEXIST (errno 17), which Rust surfaces as ErrorKind::AlreadyExists and which a DirectoryNotEmpty match never catches.

LocalDisk::delete_file removes xl.meta and then recurses to rmdir the object directory, tolerating only NotFound and DirectoryNotEmpty. Since #4300 (transactional delete rollback-staging, new in beta9) the object directory still holds the rollback backup dir when that rmdir runs — the caller removes it only after write quorum is confirmed — so the rmdir legitimately reports "not empty". On Linux that is tolerated; on Solaris it is EEXIST, which fell through to the catch-all arm and became FileAccessDeniedWithContext. That failed the delete commit, rolled the metadata back, and left the object undeletable, so the client retried indefinitely with a spurious FileAccessDenied and no EACCES anywhere (rustfs/rustfs#4978). The same Linux-errno assumption also broke non-force DeleteBucket on a populated bucket on Solaris.

Add a portable is_dir_not_empty_error classifier (DirectoryNotEmpty kind plus raw ENOTEMPTY/EEXIST), mirroring MinIO's isSysErrNotEmpty, and use it at the two directory-removal sites via is_benign_object_rmdir_error (delete_file) and classify_delete_volume_error (delete_volume). The classifier is applied only at rmdir/remove_dir_all sites, where EEXIST unambiguously means "not empty", so EEXIST keeps its normal meaning everywhere else. rmdir never returns EEXIST on Linux/macOS/Windows, so the new raw-errno branch is unreachable there and the change is a strict no-op off illumos/Solaris.

Adds unit tests for the classifier (DirectoryNotEmpty/ENOTEMPTY/EEXIST match, EACCES/ENOENT reject, real non-empty rmdir against the host errno) and call-site decision tests that make a Solaris EEXIST regression detectable on Linux CI.

Fixes #4978
2026-07-18 02:37:43 +00:00
Zhengchao An edfb3c134f fix(s3): return x-amz-copy-source-version-id for versioned CopyObject source (#4985)
For a CopyObject whose source carries versioning, the response must echo the exact source version copied via x-amz-copy-source-version-id (SDK CopySourceVersionId), kept distinct from the newly created destination x-amz-version-id. RustFS set the destination version_id but left copy_source_version_id unset, so AWS SDK clients could not prove which source version was copied — the same field UploadPartCopy already populates.

Populate CopyObjectOutput.copy_source_version_id from the version actually read from the source, gated on the source bucket carrying versioning (enabled or suspended) and rendering the null version as "null", mirroring the GET/HEAD convention. Add an e2e regression test that copies a non-latest source version and asserts the source-version header equals the requested version, the destination header is a distinct new version, the destination bytes/size match the copied version, and the source version remains present.

Fixes #4976
2026-07-18 07:33:35 +08:00
Zhengchao An 314c17205e fix(replication): recover targets after outage (#4986) 2026-07-18 07:03:23 +08:00
Zhengchao An 814682d6bb fix(ecstore): accept illumos non-empty rmdir errors (#4991) 2026-07-18 06:01:25 +08:00
Zhengchao An 87128682b0 fix(ecstore): recover data usage snapshots safely (#4979) 2026-07-17 19:02:16 +00:00
Zhengchao An 4589148f48 fix(admin): serve data usage endpoints from scanner snapshot instead of live listing (#4980) 2026-07-18 00:09:02 +08:00
Zhengchao An accd312465 fix(ecstore): classify listing consumer disconnect as non-error completion (#4981) 2026-07-17 16:05:21 +00:00
Zhengchao An 627c396649 fix(scanner): allow usage snapshot save when existing timestamp is future-dated beyond clock tolerance (#4982) 2026-07-17 16:04:43 +00:00
Zhengchao An c818177b54 test(ilm): re-enable test_transition_and_restore_flows; fix test-util disk-open and restore error-path lock (#4945)
test(ilm): re-enable test_transition_and_restore_flows; fix test-util disk-open and restore error-path lock (rustfs/backlog#1303)

The excluded test's 'missing xl.meta ... on disk2' was NOT an EC
metadata-distribution issue: after a transition all four shard disks hold
a fully consistent xl.meta (verified by decoding each shard). The panic
came from the tier test util's open_disk, which hardcoded disk_index 0
for every disk path; LocalDisk::new validates the endpoint's
(set_idx, disk_idx) against the disk's own format.json and rejected every
non-slot-0 disk with InconsistentDisk, which read_transition_meta
collapsed into 'missing xl.meta'. Derive the real indices from
format.json instead. This also un-breaks free_version_count /
wait_for_free_version_absence for non-first disks (silently 0 before).

With that fixed, the test advanced to the #4877 restore self-deadlock,
whose main paths #4886 already fixed. Complete that fix on the one path
it missed: update_restore_metadata (the restore-failure metadata
rewrite) still rebuilt copy_object options with no_lock=false and would
re-acquire the object write lock the restore handler already holds.
Propagate the caller's no_lock there too.

Remove the test from the serial-lane exclusion list; the four remaining
exclusions are unrelated known issues and stay.
2026-07-17 11:33:28 +00:00
cxymds ec47c20ced fix(lifecycle): expire sole delete markers by days (#4974) 2026-07-17 19:03:33 +08:00
cxymds 1e14c05cf0 fix(tiering): support UTF-8 metadata signing (#4969)
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-07-17 19:03:13 +08:00
Zhengchao An 7b2cc1f427 fix(sse): surface unconfigured managed SSE as 400 and fix anonymous POST SSE-S3 e2e (#4958)
fix(sse): surface unconfigured managed SSE as 400, fix POST SSE-S3 e2e

Managed SSE (SSE-S3 / bucket-default) on a server without KMS and without RUSTFS_SSE_S3_MASTER_KEY fails closed by design since #3564, but surfaced as 500 InternalError. Map the misconfiguration to InvalidRequest (400) and seed the local SSE master key in the anonymous POST-object SSE e2e tests; align the missing-from-policy test with the MinIO-compatible SSE field exemption (s3s#608). Re-admit the tests to the e2e-full profile (rustfs#4844).
2026-07-17 19:02:21 +08:00
Zhengchao An 97edb2e5cf feat(api): add opt-in per-bucket dimension to the S3 API rate limiter (#4949)
Follow-up to #4895 (backlog#1191 deferred sub-item). The client-IP
dimension gives per-client fairness; this adds a collective per-bucket
budget so one hot bucket cannot monopolize the server regardless of how
many client IPs the traffic is spread across.

- Generalize RateLimiter over its key type with a Borrow-based check()
  so &str lookups against String bucket keys stay allocation-free on
  the hit path; client-IP call sites are unchanged in behavior.
- RateLimitLayer now carries optional client and bucket limiters; a
  request must pass every configured dimension, and rejections report
  which one tripped via a new 'dimension' metric label.
- Bucket extraction mirrors s3s host routing: virtual-hosted-style
  resolves the Host/authority prefix against the same expanded domain
  set (with port variants) the s3s router uses; otherwise the first
  path segment. Admin and table-catalog namespaces are never buckets.
- New env vars RUSTFS_API_RATE_LIMIT_BUCKET_RPM/_BURST (default 0 =
  dimension off) under the existing enable switch; bucket-only
  configurations (client RPM 0) are supported.
- Bucket names are attacker-chosen, so the bounded-shards design
  (100k keys, lossless idle sweeps, most-idle eviction) is the memory
  defense; a test floods 10k random names and asserts the cap holds.
- Unit tests for extraction, shared bucket budgets across IPs, both-
  dimensions interaction, and the extended env matrix; e2e test proves
  bucket-only throttling on the real server with an unrelated bucket
  unaffected.
2026-07-17 19:01:18 +08:00
Zhengchao An e1fc4b12ea fix(api): descriptive InvalidArgument reason for Windows-unsupported object keys (#4947)
fix(api): return descriptive InvalidArgument reason for Windows-unsupported object keys

On Windows hosts object keys containing NTFS-reserved characters or
Win32-unaddressable path segments are rejected up front, but the client
only saw a bare "Invalid argument" (issue #3299). Attach an explicit
reason to these rejections and surface non-empty InvalidArgument reasons
through the S3 error message.
2026-07-17 19:00:52 +08:00
Zhengchao An e279a4f48a fix(extract): treat tar mtime=0 as unset to fix unreadable entries (#4948)
An extracted entry whose tar header mtime is 0 was stored with
mod_time = UNIX_EPOCH, which xl.meta encodes as 0 nanos (= no
mod_time). On read-back the version failed valid() and parsing fell
into the legacy rmp_serde fallback, so every read of the object
returned 500 (invalid type: integer 0, expected an OffsetDateTime).

Treat mtime 0 as unset (tar convention) and fall back to the upload
time. Also fix two test-side issues uncovered behind the 500: the pax
fixture must use a ustar header for its XHeader entry, and the SSE-S3
extract tests must provision RUSTFS_SSE_S3_MASTER_KEY. Re-admit the 19
quarantined tests to the e2e-full merge gate.

Fixes #4842
2026-07-17 19:00:31 +08:00
Zhengchao An 8ace340694 docs(agents): add anti-bloat guidelines and simplicity adversary role (#4975)
Add Reuse Before You Write and Necessary Code Only sections to AGENTS.md,
promote quality probes into a dedicated seventh adversarial-validation
role (simplicity adversary), extend rust-code-quality checks, and dedupe
code-change-verification's restated Rust checklist into a pointer.
2026-07-17 18:59:59 +08:00
Zhengchao An 701c3eee5b fix(ilm): replace whole-copy-back restore lock with accept-path CAS (#4956)
fix(ilm): serialize RestoreObject accepts with a CAS guard, not the whole copy-back

Implements the backlog#1304 decision: replace #4877's object write lock
held across the entire tier copy-back with an atomic compare-and-set on
the restore ongoing flag.

- ecstore: add ECStore::acquire_restore_accept_guard + RestoreAcceptGuard
  (opaque, purpose-scoped object write lock with an is_lock_lost fence);
  handle_restore_transitioned_object no longer locks the copy-back, so
  HEAD/get_object_info stay non-blocking during a restore and the inner
  put_object/complete_multipart_upload commit locks no longer self-deadlock.
- API: execute_restore_object holds the guard across the restore-status
  read-check-write (no_lock inside the scope), drops it before spawning
  the copy-back; SELECT-type restores keep the plain read-locked path;
  the copy-back pins the resolved version so a concurrent PUT cannot
  strand the flagged version at ongoing=true; concurrent restores are
  rejected with 409 RestoreAlreadyInProgress (was a retryable 500) and
  guard contention maps to 503 SlowDown.
- tests: drop the #4877 entry-blocking unit test (semantics deliberately
  reversed), pin accept-guard mutual exclusion, update the ilm-8 restore
  integration test to the final semantics, and add a concurrent
  double-POST test asserting exactly one acceptance and one tier GET.
2026-07-17 17:56:45 +08:00
Zhengchao An a9e3613cfd fix(quota): only require decoded length for actually framed aws-chunked bodies (#4968)
PUT admission since #4928 rejected any request whose Content-Encoding declares
aws-chunked but lacks x-amz-decoded-content-length with 400 UnexpectedContent.
Whether the body is actually chunk-framed is signalled by a STREAMING-*
x-amz-content-sha256, not by the declared encoding: the s3s auth layer only
de-frames streaming payloads and already requires the decoded length for them,
so a declared-only aws-chunked request (issue #1857 clients) carries an
unframed body whose wire Content-Length is the authoritative object size.
Admit it against that length; keep failing closed for genuinely framed bodies
without a decoded length, and use the decoded length for streaming payloads
even when Content-Encoding is absent.

Refs: https://github.com/rustfs/backlog/issues/1336
2026-07-17 16:23:00 +08:00
Zhengchao An f67e8a6cdc chore(release): prepare 1.0.0-beta.10 (#4946) 2026-07-17 10:57:58 +08:00
Zhengchao An fedc37c834 docs(operations): add drive timeout tuning guide for slow storage (#4944)
Issue #4810's production incident showed the drive timeout knobs are
undiscoverable from symptoms: a listing that outruns the walk budget
gives operators no signal pointing at RUSTFS_DRIVE_* tuning. The knobs
existed only as code constants in crates/config/src/constants/drive.rs
with no operator-facing documentation.

Add docs/operations/drive-timeout-tuning.md covering the per-operation
drive timeout knobs, resolution precedence, the high_latency profile,
and a dedicated section on listing truncation and the walk stall
budget - including the correction that since the stall-budget rework
the foreground listing path is governed by
RUSTFS_DRIVE_WALKDIR_STALL_TIMEOUT_SECS, not the total-timeout knob
the issue suggested. Link the guide from the README.

Ref #4810
2026-07-17 10:34:16 +08:00
Zhengchao An e55fdd275a docs(skills): single-commit release flow with preview and final tags on the same commit (#4943)
docs(skills): single-commit release flow — preview and final tags share the validated commit

Version files are bumped once, directly to the final target version; the -preview.N suffix now exists only in tag names. The binary self-reports build::TAG and build.yml derives artifact naming and prerelease classification from the tag, so the preview tag and the final tag can point at the exact same commit — eliminating the post-validation version-bump commit that previously separated the validated hash from the released tag.

rustfs-release-version-bump gains a guard rejecting any -preview. target version.
2026-07-17 10:26:54 +08:00
Zhengchao An 3b1bed7009 test(e2e): socket-level network fault-injection proxy (backlog#1325) (#4938)
test(e2e): add socket-level network fault-injection proxy (backlog#1325)

Add `FaultProxy`, an in-process async TCP proxy for black-box cluster E2E tests, under `crates/e2e_test/src/fault_proxy.rs`. It binds a random local port, forwards every accepted connection to a fixed target, and lets a test flip the wire behaviour at runtime.

Fault modes: `Pass` (transparent), `Latency(Duration)` (delay each forwarded chunk), `Blackhole` (accept but forward nothing either way and never respond), and `Partition(Direction)` (block exactly one direction while the other keeps flowing). Modes are switchable at runtime via `set_mode`, and `shutdown` stops the accept loop and drops the listener so the bound port is released.

This is the black-box counterpart to the in-process white-box hooks (rename barrier, disk call counters), which cannot reach across the process boundary into a spawned RustFS server. It serves the lock-plane one-way partition and accept-then-blackhole-peer acceptance for #1312/#1319 and the cross-process replay/tamper seam for #1327. Wiring it into the cluster harness (expose a node port through a proxy) plus the 5GiB / nightly CI-lane budget are follow-ups, since the harness multi-drive / 2-pool work lands separately (rustfs#4937); this block stays independent and self-tested.

Self-tests run against a loopback echo server that records received bytes, covering: pass round-trip identity, latency lower-bound delay, blackhole no-response-no-panic, one-way partition in both directions (request-path vs return-path), runtime mode switching on a live connection, and port release after shutdown. Timing assertions use loose lower bounds and bounded read timeouts only, never fixed-sleep absolute-value checks, to stay non-flaky. No product code changes; the module is `#[cfg(test)]`-gated.
2026-07-17 01:04:32 +00:00
Zhengchao An b0b5ffb8e2 test(e2e): cluster harness drivesPerNode + 2-pool topology (backlog #1325) (#4937)
test(e2e): add drivesPerNode and 2-pool cluster harness topology (backlog #1325)

Extend the e2e cluster harness so tests can declare per-node multiple drives (drivesPerNode) and a two-pool topology, alongside a smoke suite that boots such a cluster and round-trips a PUT/GET.

A new `ClusterTopology` describes node_count, drives_per_node, and pool membership, and `RustFSTestClusterEnvironment::with_topology` builds the matching on-disk drive directories and `RUSTFS_VOLUMES` string. `new(node_count)` now delegates to a single-pool single-drive topology, so existing single-drive cluster tests are byte-for-byte unchanged. `ClusterNode` gains `data_dirs` (all drives, `data_dirs[0] == data_dir`) and `pool_idx`; multi-drive/multi-pool clusters automatically add `RUSTFS_UNSAFE_BYPASS_DISK_CHECK=true` because their drives share the same temp filesystem.

The `RUSTFS_VOLUMES` assembly matches the two forms the server parser accepts on a single localhost machine (verified against ecstore `DisksLayout::from_volumes`): a single pool is the explicit enumeration of every `(node, drive)` endpoint (no ellipses, one legacy DistErasure pool), while multiple pools each contribute one ellipses argument `http://<addr><node-base>/drive{0...N-1}`. A pool argument is a single URL template, so it cannot enumerate multiple distinct-port hosts; a pool striped across several localhost nodes would need a host ellipses that forces a shared on-disk path and collides physically. The topology validator therefore requires every pool in a multi-pool layout to own exactly one node and requires drives_per_node >= 2 (the parser rejects a single-drive `drive{0...0}` ellipses pool). Genuine multi-node pools need real multi-host infrastructure and are deferred to the nightly cluster lane (backlog #1313/#1314).

Unit tests assert the volumes-string layout for single-pool single-drive (backward compatible), single-pool multi-drive (8 explicit endpoints), and two-pool (one ellipses arg per pool), plus the validator rejections. The smoke suite `cluster_multidrive_pool_test` boots a 4-node x 2-drive single pool and a 2-node/2-pool cluster, asserts the volumes layout, and round-trips a PUT/GET using the harness readiness handshake (no fixed sleeps). It joins the six existing RustFSTestClusterEnvironment suites excluded from the merge-gate `e2e-full` profile so it runs in the nightly 4-node lane.

Network fault injection (toxiproxy / socket proxy) and 5GiB large-object budgets remain out of scope for this block.
2026-07-17 01:02:38 +00:00
Zhengchao An be2e454d9d test(ecstore): rename/commit fan-out pause barrier + background-task introspection (backlog#1325 block 2) (#4936)
test(ecstore): add rename/commit fan-out pause barrier and background-task introspection

Second white-box test-infra block for https://github.com/rustfs/backlog/issues/1325 (the first block landed the per-disk call counters in PR#4914). Adds a `#[cfg(test)]` awaitable pause barrier plus in-flight background-task introspection to the rename/commit fan-out in `crates/ecstore/src/set_disk/core/io_primitives.rs`, following the same dual-cfg seam style as the existing `disk_call_counters` and `cleanup_fault_injection` seams.

A test arms a barrier for `(object, disk_index, phase)`; the matching spawned fan-out task parks at its checkpoint until the test releases it, and the test awaits the pause through a deterministic `tokio::sync::Notify` handshake (no sleeps). A separate object-keyed task tracker reports how many rename/cleanup background disk tasks are still in flight, so a test can assert "a background disk write is still running" while paused and "no background disk write remains" once the fan-out drains. Both mechanisms live in one process-global registry keyed by object name, so concurrent tests using distinct object names stay isolated. Barriers are placed on the real `rename_data` fan-out (phase `rename`) and the `commit_rename_data_dir` old-data-dir cleanup fan-out (phase `cleanup`).

In production the barrier compiles to an immediately-ready `#[inline(always)]` no-op future and the task guard to `()`, so the fan-out control-flow shape and behavior are unchanged; only the `#[cfg(test)]` variants touch the registry. Coordinator lock-holding is asserted by the test at the store/coordinator layer via the guard it already holds; io_primitives has no handle to that namespace lock. Cross-process/black-box fault injection (toxiproxy, blackhole peers, 2-pool) remains a later cluster-harness block.

Serves the barrier-style white-box acceptances of #1312 (commit fencing: abort at the first-disk rename barrier, assert no background disk write remains after release), #1319, and #1313. Three demo tests drive the real fan-out functions and double as regression guards: neutralizing the barrier seam makes the pause await time out, and neutralizing the task guard pins the in-flight count at zero, so reverting either seam fails the demos.
2026-07-17 08:31:41 +08:00
Zhengchao An 4ff7775ecb fix(admin): map service account validation errors (#4932) 2026-07-17 08:31:17 +08:00
Zhengchao An 78469aa63b fix(get): bound GET disk-read admission with a hard cap instead of unbounded permit bypass (#4935)
Refs https://github.com/rustfs/backlog/issues/1317

Previously, when the primary disk-read permit pool stayed saturated past `RUSTFS_OBJECT_DISK_PERMIT_WAIT_TIMEOUT` (default 5s) — which a handful of slow clients can cause because a permit is held for the whole body transfer — the GET path set `disk_permit = None` and continued reading with no admission token at all. That made the disk-read concurrency limit unbounded under exactly the overload it is meant to protect against: any number of GETs could pile onto the disks simultaneously.

This replaces the permit-less bypass with a bounded overflow lane and a hard cap:

- A new bounded degraded semaphore (`RUSTFS_OBJECT_DISK_DEGRADED_READ_CAP`, default mirrors the primary cap) is consulted only after the primary wait times out. A GET takes one degraded permit without blocking, or is rejected with `SlowDown`/503 once that lane is also full. The total number of GETs performing disk-active reads is therefore hard-capped at `primary_cap + degraded_cap`, and no GET ever reads without holding an admission token.
- Admission is centralized in `ConcurrencyManager::admit_disk_read`, returning `Primary`/`Degraded`/`Unbounded`/`Rejected`. `Degraded` and `Rejected` are counted in metrics (`rustfs.get_object.disk_permit.degraded.total`, `rustfs.get_object.disk_permit.hard_reject.total`), replacing the removed `rustfs.get_object.disk_permit.bypass.total`.
- A primary cap of `0` (disk-read throttling disabled) is preserved as the only intentional permit-less path via `Unbounded`, so that degenerate-but-served configuration is not turned into all-503. The `primary_wait == 0` wait-forever opt-out is likewise unchanged.

Healthy GETs are unaffected: concurrency at or below the primary cap is admitted immediately from the primary pool exactly as before, with no new latency or rejection. The rejection is surfaced before response headers are constructed, so it is a clean pre-header 503, never a post-header 504 masquerade. Degraded-lane GETs use the identical streaming reader and forward-progress stall timeout as primary GETs, so a slow but progressing large download is not killed. Owned permits (primary or degraded) are held by `DiskReadPermitReader` and released on body EOF or client drop/cancel, so tokens are always returned.

Body stall timeout already resets on forward progress and post-header failures already surface as body errors, so no timeout-split changes were needed here.

Scope: this PR implements the minimal safe correctness core (eliminate unbounded bypass + hard cap + SlowDown). The two-level weighted-fair + aging scheduler (small setup cap feeding a size-aware data-producer fair queue) and the strictly-bounded producer/client buffer with cancellation propagation from the issue plan remain follow-ups. The black-box slow-client soak is deferred to the unbuilt facility in https://github.com/rustfs/backlog/issues/1325 rather than faked.

Tests (white-box, virtual clock via `start_paused`): degrade-then-hard-reject with token release; 100 concurrent GETs against primary=1/degraded=1 never exceed 2 simultaneous admissions with every request either admitted or explicitly rejected; disabled cap serves unbounded; zero-wait blocks on the primary lane. Reverting the hard cap makes the concurrency invariant fail.
2026-07-17 08:30:39 +08:00
Zhengchao An a2a336aec3 fix(replication): compute the PUT replication decision exactly once (#4934)
The PutObject usecase computed `must_replicate_object` twice with the same inputs: once before commit to persist the pending replication metadata, and again after commit to drive `schedule_object_replication`. Besides repeating the versioning/config/target traversal on the hot path, the two computations read the replication configuration independently, so a config hot update landing between them could split the two phases into a pending-without-schedule or schedule-without-pending divergence.

Reuse the single immutable `ReplicateDecision` computed before commit for both the pending metadata and the post-commit schedule. The two former computations were already equivalent for a stable config (`must_replicate` reads only `opts.replication_request` from the options, which the pending-suffix insertion does not touch), so this preserves replication semantics while removing the redundant traversal and closing the config-race window. The decision carries only stable target/rule/status identifiers (arn, replicate, synchronous, id) and no secrets.

Add a white-box regression test that drives a real PutObject through the usecase and asserts, via a test-only invocation counter on `must_replicate_object`, that a single PUT computes the decision exactly once; reverting to the pre-commit + post-commit recompute makes the counter observe 2 and fails the test.

Refs: https://github.com/rustfs/backlog/issues/1320
2026-07-17 08:30:03 +08:00
Zhengchao An 4d22ed4465 perf(capacity): drop per-PUT global lock and per-disk allocation from write dirty-scope (#4933)
perf(capacity): remove per-PUT global lock and per-disk allocation from write dirty-scope

Every successful write recorded its capacity dirty scope by allocating an endpoint/path String per online disk, deduplicating through a HashSet, entering the global dirty-scope Mutex, and — in the app response path — taking a global async RwLock to record the write frequency. Under small-object high concurrency this created a global serialization point and O(disks) allocation on the hot path (https://github.com/rustfs/backlog/issues/1315).

This change makes the steady-state write path allocation-free and lock-free without altering capacity accounting semantics:

- Memoize the per-set dirty scope. Each set resolves its disks' immutable endpoint/path identity lazily into a slot-indexed cache and reuses a shared `Arc<CapacityScope>`; steady-state writes clone the Arc under a read lock instead of rebuilding String/HashSet. The heal path keeps an ad-hoc scope builder because it passes disks in erasure-distribution order rather than physical-slot order.
- Add a monotonic generation to the global dirty-scope registry, advanced only when a non-empty drain removes disks. A set upgrades the global registry mutex only on the first write of each generation and then skips it while the generation is unchanged; the observed generation is read under the registry lock so a concurrent drain forces a re-mark, preventing lost updates. The write commits its bytes before recording the scope, so any drain that could remove the mark is ordered after the commit and the following refresh reads the committed bytes.
- Replace the write-frequency `RwLock<WriteRecord>` with lock-free atomics: per-second CAS buckets, an atomic last-write timestamp, and an atomic total counter. The frequency window and debounce semantics the refresh scheduler relies on are unchanged.

Capacity marking remains a conservative superset of the disks actually written, so admin/scan totals are byte-for-byte identical: extra dirty marks only trigger a re-read of a disk whose usage is unchanged. White-box tests assert the memoized scope equals the previous ad-hoc construction, that the global registry is upgraded exactly once per generation and re-marked after a drain, and that the lock-free write record is exact under concurrent contention.

Ref: https://github.com/rustfs/backlog/issues/1315
2026-07-17 08:29:38 +08:00
Zhengchao An fc7d46b6cf fix(quota): admit hard quota against authoritative decoded size and fail closed on checker faults (#4928)
fix(quota): admit bucket hard quota against authoritative decoded size and fail closed on checker faults

Bucket-quota admission for PUT/POST previously ran before the authoritative object length was known and used the raw wire Content-Length: for an aws-chunked upload that length counts chunk framing (overcounting) and can be absent entirely, in which case the check was silently skipped. Separately, any quota-checker fault (bucket-config read, config parse, usage lookup) degraded to allow, which silently bypasses a configured hard quota.

Resolve the authoritative decoded/plain object length first — rejecting negative and unknown lengths, and requiring x-amz-decoded-content-length for aws-chunked instead of falling back to the framed wire length — then run quota admission exactly once against that size. This is the same basis the settle phase records via ObjectInfo.size, so admission and accounting agree. When no quota is configured the QuotaChecker keeps its zero-extra-I/O fast path; once a hard quota is set, checker faults now fail closed with a retryable ServiceUnavailable, increment rustfs_bucket_quota_check_failed_total, and keep the client-facing message generic so internal config/usage details are not leaked.

Size resolution and quota-outcome mapping are extracted into pure functions (resolve_put_object_authoritative_size, map_quota_check_outcome) with unit tests covering aws-chunked decoded-vs-wire, missing/negative/unknown lengths, plain PUT, the exact/over-limit admission split, and fail-closed on checker error. QuotaCheckResult is re-exported through the ecstore api::bucket::quota surface for the app layer. Cross-node reservation and overwrite-delta accounting remain out of scope (sibling issue). Also corrects one stale doc path (set_disk/core/local.rs -> disk/local.rs) flagged by the doc-paths guard.

Refs: https://github.com/rustfs/backlog/issues/1311
2026-07-16 18:10:52 +00:00
Zhengchao An b41bbe2db4 fix(ecstore): split rename_data signature from heal-convergence decision (#4926)
CompleteMultipartUpload enqueued a normal-priority heal whenever
`rename_data` returned a `Some(versions)` signature. But the per-disk
signature is produced for every object with <=10 versions, and a healthy
quorum reduces to `Some` as well, so the `Option<Vec<u8>>` return value
conflated two distinct facts — "a version signature exists" and "the
committed replicas need heal". The result: nearly every healthy MPU
completion self-enqueued a heal, while >10-version objects (signature
`None`) did not — an algorithmic heal amplification on the healthy path
(rustfs/backlog#1321).

Replace the overloaded `Option<Vec<u8>>` second element of
`SetDisks::rename_data` with an explicit `RenameConvergence` classification
computed after the write-quorum gate:

- AllSuccessIdentical — every attempted disk committed with an identical,
  known signature (no heal).
- PartialCommit — write quorum met but a disk failed/offline; a committed
  replica is missing or stale (heal).
- SignatureDivergent — all committed but signatures diverge, or mix signed
  (<=10-version) with unsigned (>10-version) disks (heal).
- Unknown — all committed, no signature produced (>10 versions); latent
  divergence is left to the scanner backstop, not self-enqueued.

`RenameConvergence::needs_heal()` is the single decision point. The version
signature is now only comparison material; it no longer doubles as a heal
flag. The old `select_rename_data_versions` / `reduce_common_versions` /
`rename_data_versions_key` machinery that carried the conflation is removed.

The heal submission in `complete_multipart_upload` moves off the ACK
critical path into a detached task: it runs after the object lock is
dropped and after the durable `rename_data` commit, survives cancellation
of the completion future, and coalesces through the existing bounded /
deduplicated / observable heal-channel admission (one submit per degraded
completion, at most). A completion cancelled in the narrow window between
the durable commit and reaching the enqueue is scanner-backstopped, as is
the Unknown (>10-version) case.

The PUT path (`object.rs`) binds the second element as `_` and is
unchanged. The change is orthogonal to and composes with the #1312 commit
fence on the same `rename_data` path (epoch rejection is a commit-gate
failure surfaced through `Result::Err`, convergence is a post-commit
signal); documented in docs/architecture/unified-object-generation.md.

Tests: `classify_rename_convergence` white-box cases cover the full
acceptance matrix (healthy 4/4 and 8/8, 3-same-1-divergent, failed/offline
disk, no-common-quorum split, >10-version all-success and with-failure,
mixed signed/unsigned) and fail on revert to the old "signature exists =>
heal" semantics. The decision function is tested directly rather than
through the process-global heal channel, whose receiver is owned
exclusively by the blackbox serial test (init_heal_channel is once per
binary).

Refs: https://github.com/rustfs/backlog/issues/1321
2026-07-17 01:38:15 +08:00
Zhengchao An 6559248f55 fix(ecstore): make legacy stripe prefetch cancel-safe on emit termination (#4930)
The legacy erasure-decode overlap path drove the speculative next-stripe read and the current-stripe emit with `tokio::join!`, which runs both futures to completion. When the current stripe's emit terminated the loop — a client disconnect or any emit error — the join still waited for the prefetch read, so a `Stop` could stall for a full shard-read deadline on a slow or wedged remote shard before the GET could fail.

Drive the two futures with a biased `select!` instead and, the moment emit reports `Stop`, drop the in-flight read future. Because the entire read pipeline is structured async (a `FuturesUnordered` of `read_shard` futures inside `ParallelReader::read`/`read_lockstep`, with no `tokio::spawn`), dropping the read future is a real cancellation: it drops every in-flight shard read and propagates cancellation down to the RemoteDisk/HTTP reader, leaving no background read behind. The `select!` is scoped so both pinned futures drop before `reader`/`shards` are reused, which is what performs the cancellation in the `Stop` case.

This only affects the overlap-enabled path. The default remains OFF (`prefetch_count == 1` and bitrot-decode overlap disabled), and the strictly-serial read -> reconstruct -> emit default branch is untouched and byte-for-byte identical. The `Continue` path preserves offset, short-tail, buffer recycle, and bitrot/reconstruction error ordering exactly as before.

Scope: cancel-safety only. The rollout decision (whether to enable overlap by default) still requires the Linux multi-node high-RTT three-size A/B from https://github.com/rustfs/backlog/issues/1310 and is deferred; this change does not flip the default or introduce any behavior that A/B must adjudicate.

White-box test `test_legacy_prefetch_cancels_next_read_on_emit_failure` drives the real `Erasure::decode` path with overlap enabled, a writer that fails emit, and shards that serve the first stripe then stall the next-stripe read far beyond the assertion window. Under the paused clock the read future is dropped and decode returns at virtual t~=0; reverting cancel-safety makes it wait out the shard-read timeout, so the test fails closed.

Refs: https://github.com/rustfs/backlog/issues/1310
2026-07-17 01:37:44 +08:00
Zhengchao An caf42018b1 fix: pin tokio to 1.52.3 and fix stale doc path reference (#4931)
fix(deps): pin tokio to 1.52.3 to fix dial9-tokio-telemetry build

tokio 1.52.4 made  private, breaking
dial9-tokio-telemetry 0.3.14 which references it as public.
Pin tokio back to 1.52.3 until the upstream dependency fixes
compatibility.

Also fix a stale doc path reference in unified-object-generation.md
where  is a planned file that does not exist yet.
2026-07-16 17:36:37 +00:00
Zhengchao An 7cc92ac93c test(replication): add programmable fake S3 target (#4929) 2026-07-17 01:20:09 +08:00
Zhengchao An 3674f5f56e fix(ecstore): bound remote shard writers with a progress deadline so one black-hole peer cannot pin write quorum (#4925)
A PUT that fans out erasure shards to remote peers awaited every shard writer to completion on both the per-block write and the final shutdown, and the remote HttpWriter had no progress deadline. A peer that accepts the TCP connection but never drains the request body (or never sends a response) therefore wedges the writer forever once the bounded buffers fill, pinning an otherwise-healthy write quorum indefinitely — a cluster-level write-availability hazard triggered by a single bad peer (rustfs/backlog#1319, https://github.com/rustfs/backlog/issues/1319).

MultiWriter now wraps each shard write and each shard-writer shutdown in a forward-progress deadline. The budget is re-armed on every block, so it bounds a stall rather than the total transfer time of a large object: a slow-but-honest writer that keeps completing shards is never killed, while a writer that makes no progress within the budget is failed and its disk dropped before commit. An optional absolute per-object cap (disabled by default) backstops a slow-drip peer that dribbles just enough progress to reset the per-block timer without ever converging; it is off by default so a legitimate large upload over a slow link is not killed on total time alone. Both knobs come from RUSTFS_OBJECT_DISK_WRITE_STALL_TIMEOUT (default 30s) and RUSTFS_OBJECT_DISK_WRITE_ABSOLUTE_CAP (default 0 = disabled); setting the stall timeout to 0 restores the previous wait-forever behavior for a conservative rollback.

The deadline enforcement lives in MultiWriter (writer-agnostic), so it covers local and remote writers alike and keeps the existing control-flow shape: a timed-out shard is marked failed (Error::Timeout, which is not an ignored error) and excluded from the write quorum exactly like any other shard write failure, and the unchanged nil_count/quorum check then continues on quorum or fails cleanly. This deliberately stays out of the MultiWriter lifecycle / commit-coordinator territory owned by rustfs/backlog#1312.

When a stalled writer is dropped to fail its shard, the remote HttpWriter must stop holding the connection and its buffered body. HttpWriter previously left its spawned request task running on drop; it now aborts that background task in Drop (it is no longer pin-projected, since every field is Unpin and the AsyncWrite impl already used get_mut). Bytes already handed to the transport cannot be unsent, but they land only in this upload's unique tmp path and are reclaimed by tmp GC — they never touch a committed object.

Tests, all on a paused virtual clock so they are deterministic and non-flaky:
- one black-hole writer still meets a 3/4 write quorum without hanging; two black holes fail the quorum cleanly (both for the per-block write and the shutdown paths).
- a slow-but-honest writer that keeps making progress within the stall budget is never failed across many blocks.
- the absolute cap bounds a slow-drip writer within a finite budget while the healthy writers keep quorum.
- the default policy is armed by default and honors 0 as disabled.
- HttpWriter aborts its background request task on drop against a hanging peer.

The toxiproxy/black-hole 4x4 end-to-end acceptance depends on black-box test facilities from rustfs/backlog#1325, which are not built yet; that acceptance is deferred to #1325 and intentionally not faked here.
2026-07-16 16:41:38 +00:00
houseme 4f04d5f883 chore(docker): update Alpine and Ubuntu base images (#4924)
chore(docker): update base images

Upgrade Alpine images to 3.24.1 and Ubuntu runtime images to 26.04.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-16 16:36:36 +00:00
Zhengchao An 84a34890ef docs(architecture): pin JSON→msgpack migration interaction for generation transport (#4913) 2026-07-17 00:12:24 +08:00
Zhengchao An da0c2d3730 test(ecstore): per-disk call-counter registry for metadata fan-out (backlog#1325 block 1) (#4914)
test(ecstore): add per-disk call-counter registry for metadata fan-out

First landable piece of the backlog#1325 test-infrastructure work: a test-only, per-disk call-counter registry that can observe `read_version` RPC counts recorded inside `tokio::spawn` tasks. This unblocks the RPC-count assertions in backlog #1309 / #1314 / #1315, which the thread-local `CapturingRecorder` cannot serve because it is blind to metrics emitted from spawned tasks.

The new `#[cfg(test)]` module `disk_call_counters` (modeled on the existing `cleanup_fault_injection` seam) is a process-global registry keyed by object name; an RAII `observe(object)` scope collects per-disk counts and clears only its own on drop, so parallel tests using distinct object names stay isolated. A dual-`cfg` `SetDisks::record_read_version_call` seam records from inside both metadata-fanout `read_version` spawn sites for online disks only; the `#[cfg(not(test))]` variant is an empty `#[inline(always)]` fn, so production runtime behavior is unchanged.

Two demo/regression tests prove the facility works across worker threads and is revert-detecting (neutralizing the recorder makes them fail).

Refs: https://github.com/rustfs/backlog/issues/1325
2026-07-17 00:02:58 +08:00
Zhengchao An 73d75428a8 fix(get): enforce strict exact-length materialization for in-memory GET bodies (#1324) (#4922)
* fix(get): enforce strict exact-length materialization for in-memory GET bodies (#1324)

The streaming GET path already fails a short read with UnexpectedEof, but several in-memory materialization branches only WARNed on a length mismatch and then served the body anyway: the encrypted-buffer path, the direct-memory/cache buffered-body path, and the seek-support buffer. Most dangerously, the seek-support path fell through to streaming the *same* reader after read_to_end had already drained K bytes on error, shipping a body missing its prefix (prefix-misaligned data — the closest this code came to returning wrong bytes rather than merely a short body).

Because the HTTP response commits to Content-Length == expected before the body is produced, any other body length is an unrecoverable, broken response. This change gives every in-memory source the same exact-length contract that the ODC materialize-fill path already had.

- Add strict_materialize_object_body, a shared helper that bounds the read to expected+1 (so an over-long stream is detected without buffering it unbounded), requires bytes_read == expected, and on any read error returns without reusing the partially consumed reader. The encrypted, seek, and ODC materialize branches now all route through it.
- The buffered-body branch hard-fails a length mismatch before headers instead of WARN-and-serve.
- MemoryTrackedBytesStream gains a defense-in-depth guard: a buffer whose length disagrees with the declared content length yields a stream error on first poll instead of a clean short body or a silently truncated over-long body. This backstops the cache-hit paths that hand bytes straight to the blob.

The compatibility boundary is preserved: expected is derived from get_actual_size(), the same value used for the committed Content-Length, so a legitimate object (including a legacy/backfilled-size object) whose decoded bytes equal its declared size still serves cleanly — only genuine short, over-long, or errored reads fail. This matches the streaming path, which already hard-fails short reads, so no new large-object behavior is introduced.

Tests: each source (encrypted/seek via the shared helper, cache via build_get_object_body_with_cache, buffered/memory via MemoryTrackedBytesStream) is exercised with expected N and actual N-1 / N / N+1 plus a read-K-then-error injection; only the exact-length read succeeds. Reversal is guarded: restoring WARN-and-serve or a partial fallback flips the short/over-long/error assertions from Err to Ok. Existing streaming UnexpectedEof tests are unchanged.

Refs: https://github.com/rustfs/backlog/issues/1324

* fix(app): reword WARNed comment to satisfy typos check
2026-07-16 16:01:10 +00:00
Zhengchao An 082061f18b fix(object): losslessly convert suffix Range from u64 to i64 (#4921)
s3s parses a `Range` suffix length as `u64`, but the GET and HEAD handlers cast it straight to `i64` and bypass any satisfiability check. This truncates deterministically: `bytes=-18446744073709551615` wraps to `-1` and is then read as "last 1 byte", and `bytes=-0` produces a 0-length 206 instead of a 416.

Both handlers now share a single `range_to_http_range_spec` conversion that rejects a zero-length suffix with `InvalidRange` (416), clamps any suffix above `i64::MAX` to `i64::MAX` (such a suffix always covers the whole object, which `HTTPRangeSpec::get_length` then clamps to the real size), and keeps the int branch as a checked cast (s3s already caps `first`/`last` at `i64::MAX`). The scattered `as i64` casts are removed.

Note on ordering: a zero-length suffix is now rejected at conversion time, so `bytes=-0` on a missing object returns 416 rather than 404. This matches the handler's existing behavior of validating range shape (range + partNumber -> 400) before object existence.

Adds a table-driven unit test covering suffix `0/1/size/size+1/i64::MAX/i64::MAX+1/u64::MAX` over empty, 1-byte, and normal objects, asserting the InvalidRange (416) mapping, full-object return for over-size suffixes, and no regression of int first-last / open-ended ranges.

Refs: https://github.com/rustfs/backlog/issues/1322
2026-07-17 00:00:31 +08:00
Zhengchao An cbf1c69234 docs(architecture): fix object commit path reference (#4920) 2026-07-16 23:21:47 +08:00
Zhengchao An 2ae2081753 docs(skill): unwrap prose lines and add semver.org references (#4918)
Remove hard line wrapping from rustfs-release-publish prose (one logical line per sentence/paragraph, soft wrap for display) and link SemVer 2.0.0 spec including spec item 11 for prerelease precedence.
2026-07-16 22:54:28 +08:00
Zhengchao An 1305f7590d docs(skill): add semver confirmation gate to rustfs-release-publish (#4916)
Require explicit target-version confirmation (AskUserQuestion with concrete semver candidates derived from the latest tag) before any release phase runs, and document SemVer 2.0.0 precedence and bump-type rules.
2026-07-16 22:50:29 +08:00
Zhengchao An f17f0470b0 docs(skill): add rustfs-release-publish preview-validated release pipeline (#4915)
Adds a project skill that orchestrates the full release flow: preview tag first, CI/artifact verification, local run with console checks, rc client command validation, then final version bump and tag cut from the validated preview hash instead of latest main.
2026-07-16 22:46:48 +08:00
Zhengchao An f40abbb9f2 docs(architecture): unify per-object generation authority (#4912)
Add the shared design/contract document for backlog #1326: a single
per-object generation authority (the #1312 fencing epoch) that spans
commit fencing, read lease, prepared pool read, quota reservation, and
old-dir GC. Pins the five cross-cutting constraints once - RPC signature
binding with server-side nonce enforcement, xl.meta encoding contract
(no meta_ver bump, no positional FileInfo field, internal metadata map
under the dual-key contract), proto3 optional presence, mixed-version
fallback direction, and cluster-level capability negotiation - so the
implementation sub-issues follow them rather than each re-deciding.

No product code changes.
2026-07-16 22:29:07 +08:00
Zhengchao An dbfe1c9bae fix(docker): upgrade base-image packages in runtime stages to clear Trivy CVE alerts (#4909)
fix(docker): upgrade base-image packages in runtime stages

Trivy code scanning reports 40 open alerts against the published
container images, all from OS packages frozen at the base-image tag:

- musl image (alpine:3.23.4): openssl libssl3/libcrypto3 3.5.6-r0,
  including HIGH CVE-2026-45447; fixed in 3.5.7-r0
- glibc image (ubuntu:24.04): tar, gzip, perl-base, ncurses CVEs with
  fixes already published in the Ubuntu archive

The runtime stages only ever installed packages and never upgraded the
ones shipped with the base image, so distro security fixes could not
reach released images until the base tag itself moved. Run
`apk upgrade` / `apt-get upgrade -y` in the runtime stages of
Dockerfile, Dockerfile.glibc and Dockerfile.source so each build picks
up current security fixes.

Verified against the exact base tags: after upgrade, alpine 3.23.4
resolves libssl3/libcrypto3 3.5.7-r0 and ubuntu 24.04 resolves
tar 1.35+dfsg-3ubuntu0.2, gzip 1.12-1ubuntu3.2,
perl-base 5.38.2-3.2ubuntu0.3, ncurses 6.4+20240113-1ubuntu2.1 —
matching every fixed version demanded by the open alerts.
2026-07-16 22:15:29 +08:00
Zhengchao An dc4b85eb2d ci: log console version and completion after asset download (#4910) 2026-07-16 22:11:59 +08:00
houseme 75c3403dcc chore(release): prepare 1.0.0-beta.10-preview.4 (#4908)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-16 14:08:34 +00:00
Zhengchao An 381639fbbc fix(release): require embedded console assets (#4907) 2026-07-16 21:23:48 +08:00
houseme 1badff3923 fix: resolve moved-value diagnostics (#4905)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-16 18:46:56 +08:00
Zhengchao An d5baaa67b8 ci: restore latest.json updates for prerelease tags (#4903)
PR #4582 gated the update-latest-version job to stable release tags so prereleases could not overwrite the stable version pointer. But the project currently ships prerelease tags only (beta previews), so the job never runs and latest.json has been stale since. Run the job for every release tag again, and keep the honest half of the #4582 fix by writing release_type from the actual build type instead of hardcoding "stable".
2026-07-16 17:39:56 +08:00
Henry Guo f5e715a8fd fix(table-catalog): allow table recreation after drop (#4900)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-07-16 17:25:47 +08:00
Zhengchao An e2f394a897 feat(helm): flexible drivesPerNode topology with backward-compatible per-pool defaults (#4901)
* helm chart: one data drive per node - fix1

* refactor(helm): prevent negative or 0 replicaCount

Co-authored-by: Copilot <copilot@github.com>

* refactor(helm): remove env REPLICA_COUNT

* feat(helm): default no logs directory to force stdout

Co-authored-by: Copilot <copilot@github.com>

* feat(helm): add drivesPerNode

* feat(helm): correct default parity with 4 nodes /  1 drive per node

* conditional render of RUSTFS_OBS_LOG_DIRECTORY

* feat(chart): add table doc for parity

* fix(chart): handle invalid annotation objects

* fix(chart): move logging and obsevability options together; default for kubernetes output to stdout

* feat(chart): better table doc for parity

* fix(chart): leave RUSTFS_OBS_LOG_DIRECTORY empty, or the defaults will attempt to write to readonly fs

* fix(chart): chart defaults as the previous version: 4 nodes with 4 drives per node

* feat(chart): render RUSTFS_STORAGE_CLASS_STANDARD in configmap

* minor fix for pvcAnnotations

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Cristian Chiru <cristi.chiru@gmail.com>

* fix(chart): better drivesPerNode handling

* fix(chart): obs_log_directory default non empty again to preserve previous deployments compatibility

* fix(chart): proper drivesPerNode impl

* fix(chart): values clarification for empty vs unset `obs_log_directory`

* feat(chart): add service externalIPs

* feat(helm): add optional service labels

* fix(helm): merge service labels

* fix(helm): storageclass pvcAnnotations

* fix(chart): move logging and obsevability options together; default for kubernetes output to stdout

* fix(helm): remove duplicate keys

* fix(helm): default drivesPerNode null, keep legacy chart behavior

* feat(helm): add template test for externalIPs

* fix(helm): per-pool drivesPerNode inference, restore regression tests

Repairs the drivesPerNode feature from #2693 so it renders and stays
upgrade-safe:

- define the missing drives inference (rustfs.poolDrives) and compute it
  per pool inside rustfs.pools, so mixed 4x4 + 16x1 pool deployments keep
  their exact legacy volumeClaimTemplates when drivesPerNode is unset
- restore the $poolsEnabled definition dropped in the rebase (chart failed
  to render at all)
- fix .Values references inside the pool range (dot is the pool there) and
  keep per-pool storageclass pvcAnnotations overrides working
- make rustfs.volumes derive the drive range from pool drives instead of
  the pod count, and relax the pools.list 4-or-16 restriction to >= 2
- reject drivesPerNode=0 explicitly instead of silently inferring
- keep default rendering identical to main: storage_class_standard stays
  unrendered by default, obs_endpoint.use_stdout stays false, clusterDomain
  value restored
- restore the clusterDomain/mTLS SAN/explicit-volumes regression tests that
  the branch deleted, keeping the new topology tests

---------

Signed-off-by: Cristian Chiru <cristi.chiru@gmail.com>
Co-authored-by: Cristian Chiru <cristi.chiru@gmail.com>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-16 09:02:04 +00:00
houseme 8a126bb176 ci: drop macOS x86_64 release target (#4899)
Remove the Intel macOS entry from the Build and Release platform matrix so official release builds only publish the Apple Silicon macOS binary.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-16 16:03:20 +08:00
Zhengchao An ae15f5804d test(ilm): fix restore integration test object key to match transition filter (#4886)
* test(ilm): fix restore test object key to match transition filter

restore_object_usecase_reports_ongoing_conflict_and_completion used the
object key "restore/api-object.bin", but the shared
set_bucket_lifecycle_transition_with_tier helper only transitions objects
under the "test/" prefix. enqueue_transition_for_existing_objects therefore
matched nothing and wait_for_transition timed out at 15s, failing the test
deterministically.

The test was added in #4860 but its ILM Integration (serial) lane is
skipped on regular PRs, so it merged red and has failed on every main run
since. Move the object under the test/ prefix like every passing sibling
test in this file.

* ci(ilm): exclude broken RestoreObject API test from serial lane

restore_object_usecase_reports_ongoing_conflict_and_completion exposes a
real regression, not a test bug: the RestoreObject copy-back
(handle_restore_transitioned_object) now holds the object write lock added
in #4877 across the entire tier read-back, so it never releases in time and
the test's concurrent get_object_info times out with Lock(Timeout, 5s). The
failure is deterministic and independent of the mock tier's injected latency.

This is the same class of known-broken restore/transition failure already
tracked under backlog#1148 (three sibling scanner tests are excluded here by
name for the same reason), so exclude this one the same way until the restore
copy-back path is fixed or the #4877 lock scope is revisited. The prior commit
keeps its correct fix (the object key must live under the test/ transition
prefix); that was masking this deeper issue by never letting the object
transition in the first place.

Restore copy-back deadlock/hang under the #4877 lock is escalated separately
for a product-level decision (fix the copy-back vs. narrow/revert #4877).

* test(ilm): fix scanner restore test object keys to match transition filter

test_restore_chain_local_read_expiry_keeps_remote_and_allows_re_restore and
test_multipart_restore_preserves_parts_and_etag (both added in #4860) keyed
their objects under restore/ instead of the test/ prefix that
set_bucket_lifecycle_transition_with_tier filters on, so the objects never
transitioned and wait_for_transition timed out at 15s.

These surfaced only after the prior commit excluded the rustfs-side restore
API test: nextest runs -j1 fail-fast, so that earlier failure stopped the run
before these scanner tests executed. Unlike the excluded API test, both call
restore_transitioned_object().await sequentially and only read afterwards, so
they don't hit the concurrent-read-vs-#4877-write-lock timeout; the key
prefix was their only problem.

* ci(ilm): exclude the two remaining #4877-broken restore tests

test_multipart_restore_preserves_parts_and_etag and
test_restore_chain_local_read_expiry_keeps_remote_and_allows_re_restore both
call restore_transitioned_object().await, which since #4877 acquires the
object write lock and deterministically times out (Lock Timeout, 5s) against
an already-held lock, so restore never completes. They surfaced one at a time
because nextest runs -j1 fail-fast. The earlier prefix fix was necessary but
only advanced them from the transition wait to this restore-lock timeout.

Exclude both by name alongside their already-excluded sibling
test_transition_and_restore_flows (same root cause, tracked under
backlog#1148) so the ILM Integration (serial) lane goes green. The #4877 lock
scope still needs a product fix before any of these re-enable.

* docs(ilm): describe the excluded restore tests' symptom as a lock timeout, not a deadlock

The #4877 write lock is held across the tier read-back and outlives the 5s
lock timeout; nothing proves a true deadlock. Wording flagged by Copilot
review.

* fix(ecstore): stop restore copy-back self-deadlocking on the #4877 write lock

#4877 made handle_restore_transitioned_object hold the object write lock for
the whole restore and forward no_lock=true so the set layer would not
reacquire it. But the set-level copy-back rebuilds its own options
(put_restore_opts -> ropts, and the complete_multipart_upload opts) that
default no_lock=false, so the inner put_object / new_multipart_upload /
complete_multipart_upload each re-acquire this object's write lock in their
commit phase and block on the lock the restore already holds -> Lock(Timeout,
5s), and restore never completes.

Confirmed via RUSTFS_OBJECT_LOCK_DIAG_ENABLE: restore_transitioned_object
acquires the write lock, then holds it ~10.5s across two nested 5s acquire
timeouts before failing. This is a real product deadlock: a RestoreObject on
any transitioned object (multipart especially) hangs, not just the tests.

Propagate no_lock into the copy-back options so the inner writes inherit the
already-held lock. Use opts.no_lock (not a hardcoded true) so a caller that
restores without the outer lock still locks correctly. put_object_part is left
as-is: it locks the multipart upload-id resource, not the object key, so it
does not conflict. Verified test_multipart_restore_preserves_parts_and_etag
now passes (3.6s, was a 15s+ hang).

* ci(ilm): re-enable multipart restore test; scope remaining exclusions

The prior commit fixes the #4877 restore self-deadlock, so
test_multipart_restore_preserves_parts_and_etag passes again - drop it from
the serial-lane exclusion list and remove its 'currently excluded' note.

The other restore/transition tests still fail, but each on a DIFFERENT,
independent issue unrelated to the (now-fixed) lock, verified locally:
  - test_restore_chain_...: DeleteRestoredAction sets expire_restored but no
    delete path reads it, so cleanup deletes the whole object (unimplemented
    semantics), not the local restored copy only.
  - test_transition_and_restore_flows: transition xl.meta missing on one drive
    (EC metadata distribution), not restore.
  - restore_object_usecase_reports_ongoing_conflict_and_completion: asserts a
    concurrent mid-restore ongoing=true read that #4877's read-vs-restore
    serialization rules out (backlog#1148 ilm-8 criterion 1, an API-semantics
    decision).
Comments and #[ignore] reasons updated to reflect each real cause. All remain
tracked under backlog#1148.
2026-07-16 07:43:57 +00:00
657 changed files with 171895 additions and 13172 deletions
+17 -6
View File
@@ -1,6 +1,6 @@
---
name: adversarial-validation
description: Execute the Adversarial Validation policy from the root AGENTS.md — run the six reviewer roles (correctness, 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 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.
---
# Adversarial Validation Playbooks
@@ -53,14 +53,22 @@ shipped bug or rule that earns each probe its place.
- Exercise the zero/empty end of every new size or count parameter: zero-length object PUT then GET (body must be empty, not error), part count 0, empty Vec of disks/entries into aggregation functions, and env/config values of 0 (must clamp or reject, never divide-by-zero or 'scan nothing and report zero usage'). Anywhere the diff computes a ratio, capacity, or progress percentage, plug in 0 and the max value.
- Where: crates/ecstore aggregation and scanner paths; crates/object-capacity; config/env parsing in touched crates
- Evidence: Commits 787cc77a7 'clamp zero capacity env values to safe defaults' (#4559) and 32b1094ec 'resolve a symlinked scan root instead of silently counting zero' (#4564) — zero-as-silent-wrong-answer is a recurring repo bug class.
- 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.
- 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 'Constant and String Usage'; Adversarial Validation section names the smaller-diff clause as a correctness-adversary finding.
- For any diff touching multipart or object commit paths, order the operations on paper and attack the failure point between them: kill the process (or return Err) after the commit rename but before cleanup, and after cleanup but before commit. Verify the earlier-failure case leaves the object readable and the later-failure case leaves no half-visible object; part meta files must never be deleted before the commit is durable.
- Where: crates/ecstore multipart commit/cleanup (set_disk/ops); rustfs/src/storage multipart handlers
- Evidence: Commit c77c5f047 'defer multipart part.N.meta cleanup until after commit' (#4548) — cleanup-before-commit ordering already caused a real data-loss window; the #4221 durability work shows fsync/ordering bugs are endemic here.
Null report example: "Attacked quorum-1 error reduction, exact max-keys listing boundary, trailing-slash dir keys, nil-UUID tier versionId, mid-stream reconstruct error propagation, and a minimal-diff rewrite — no break found; diff is already the minimal in-place edit."
Null report example: "Attacked quorum-1 error reduction, exact max-keys listing boundary, trailing-slash dir keys, nil-UUID tier versionId, and mid-stream reconstruct error propagation — no break found."
### 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.
- 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 '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).
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."
### Security reviewer
@@ -76,6 +84,9 @@ Null report example: "Attacked quorum-1 error reduction, exact max-keys listing
- For any secret/token/signature/password comparison in the diff, check it uses a constant-time compare (e.g. subtle/constant_time_eq), not == or early-return byte loops. Then check the failure-response paths: construct an invalid-user request and an invalid-secret request and confirm they are indistinguishable (same error, no early length short-circuit) so an attacker cannot enumerate valid users or time-side-channel the secret.
- Where: crates/protocols/ (FTPS/WebDAV/FormPost auth), crates/credentials/, rustfs/src/auth.rs, RPC signature verification
- Evidence: GHSA-3p3x-734c-h5vx (FTPS/WebDAV early-return string equality + distinguishable invalid-user vs invalid-password). Fix commits 3c3113619 (constant-time FTPS/WebDAV) and c41062f27 (constant-time FormPost signature). 3p3x was fixed by PR #4403.
- If the diff parses or transports secret-bearing config (env vars, key files, connection strings), grep every error-construction and format site on that value's path (`format!` feeding `Error::other`/`configuration_error`/`panic!`/`expect`) for interpolation of the raw value or of variables named like secret material. Construct the likeliest misconfiguration: the operator supplies the bare secret without the expected `<name>:` prefix (or with a stray newline) — if the parse-failure hint echoes the input, the secret lands in startup logs. Error strings are log content; the hint may name the env var and expected format, never the value. If the diff re-implements an existing parse helper, diff the two error paths — the duplicate is where the leak hides.
- Where: rustfs/src/init.rs (env plumbing), crates/kms/src/config.rs, crates/credentials/, any from_env/parse on secret values; mechanical backstop in scripts/check_logging_guardrails.sh (secret-interpolation check)
- Evidence: PR #5222 introduced `got: {secret_str}` in build_static_kms_config's format-hint error — a bare base64 key (the secret itself) would have been echoed into startup logs; fixed by PR #5243. The parallel parse in KmsConfig::from_env already omitted the value: the leak lived only in the duplicated copy (AGENTS.md 'Reuse Before You Write').
- If the diff touches internode/RPC auth secret handling, trace whether the RPC HMAC secret can fall back to a public default (e.g. 'rustfsadmin', 'rustfs rpc') or be derived deterministically from the S3 root credentials. Construct the case where RUSTFS_RPC_SECRET is unset and confirm the code fails closed rather than silently using a default or a root-derived key. Verify RPC signing keys are independent random secrets, not reused across S3-root/RPC-HMAC/STS-JWT roles.
- Where: crates/credentials/, crates/ecstore/src/rpc/, internode auth setup
- Evidence: GHSA-r5qv-rc46-hv8q (fell back to 'rustfsadmin'), GHSA-75fx/68cw (RPC secret derivable from root creds → forgeable signatures), GHSA-h956 (hard-coded 'rustfs rpc'), GHSA-m77q (STS JWT reused root secret). Fix commit 7b2055405 (fail closed when deriving RPC secret from default credentials, PR#4402).
@@ -243,7 +254,7 @@ Null report example: "Attacked the new rename_data commit-section work, durabili
- If the diff writes internal object metadata, run the dual-key mutation: delete the `x-minio-internal-<suffix>` write (keeping only `x-rustfs-internal-`) and check whether any test fails. Because `get_bytes` prefers the RustFS key, every read-back test stays green while MinIO interop is silently broken — coverage must include an assertion that BOTH keys are present in the stored metadata map.
- Where: crates/utils/src/http/metadata_compat.rs and all its callers in crates/ecstore and rustfs/src/storage
- Evidence: CLAUDE.md domain convention: metadata must be written under both x-rustfs-internal- and x-minio-internal- keys for MinIO interop; get_bytes prefers the RustFS key, making the MinIO-key half of the invariant invisible to read-back tests.
- For changed quorum/version/UUID logic, name the tests covering the specific poison values: quorum1 disks, nil UUID, absent vs empty vs nil-serialized UUID bytes, and remote-tier version_id of None/"" (unversioned tier bucket → no versionId sent). Mutation check: remove a `.filter(|u| !u.is_nil())` guard from the diff and confirm a test fails; if none does, the nil-UUID class is uncovered.
- For changed quorum/version/UUID logic, name the tests covering the specific poison values: quorum1 disks, nil UUID, absent vs empty vs nil-serialized UUID bytes, remote-tier version_id of None/"" (unversioned tier bucket → no versionId sent), and the same metadata read on both MetaObject and MetaDeleteMarker version types. Mutation check: remove a `.filter(|u| !u.is_nil())` guard from the diff and confirm a test fails; if none does, the nil-UUID class is uncovered.
- Where: crates/ecstore (tier recovery, heal, quorum paths), crates/filemeta, code reading UUIDs from xl.meta metadata
- Evidence: Commit 726f3dc18 (#4552) fixed rejection of empty remote version_id in tier recovery. CLAUDE.md invariant: absent/empty/nil UUID all mean 'no value', not Uuid::nil(). docs/operations/tier-ilm-debugging.md: None/"" tier version means unversioned bucket. df9cbc4ed (#4427): unvalidated distribution values caused shuffle index panic — edge values reached production untested.
- 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.
+8 -2
View File
@@ -10,10 +10,16 @@ never weaken a check to get green.
## `check_layer_dependencies.sh` — layer DAG in `rustfs/src`
Enforces `interface (admin, storage/ecfs, storage/s3_api) → app → infra`; no
upward imports. Known legacy violations live in
Enforces `composition (server, startup/init) → interface (admin,
storage/ecfs, storage/s3_api) → app → infra`; no upward imports. Server source
files are composition roots, while imports of their exported HTTP contracts
are classified as interface dependencies. Known legacy violations live in
`scripts/layer-dependency-baseline.txt`.
Dedicated `*_test.rs` and `tests/` modules are outside this production guard.
Inline `#[cfg(test)]` imports remain checked under their source file's layer;
move architecture-crossing test scaffolding into a dedicated test module.
- **New violation**: restructure your change so the dependency points
downward (move the shared type/function to the lower layer).
- **You legitimately removed a baseline entry**: run
@@ -43,16 +43,7 @@ Use this skill to review code changes consistently before merge, before release,
#### Rust-specific checks (apply to all Rust changes)
- **unwrap/expect in production**: Search changed files for `.unwrap()` and `.expect(` outside test modules. Every `unwrap()` in production code must have a justification comment or be replaced with `?`.
- **Silent type truncation**: Search for `as u8/u16/u32/u64/usize/i8/i16/i32/i64/isize` casts. Every `as` cast must be justified; negative-to-unsigned and large-to-small are bugs by default. Use `try_into()` or explicit clamping.
- **Unnecessary cloning**: Check `.clone()` calls in loops, per-request paths, and on structs with >5 heap-allocated fields. Consider `Arc`, references, or `Cow<str>`.
- **Lock ordering**: If the change acquires multiple locks, verify the order matches all other call sites. Document the order in a comment.
- **Locks across .await**: Flag any `tokio::sync::RwLock`/`Mutex` guard held across an `.await` point without bounded hold time.
- **Recursion depth**: If the change adds or modifies a recursive function, verify it has a depth limit or uses iterative traversal with an explicit stack.
- **Error types**: Flag `Result<_, String>`, `Box<dyn Error>`, and missing `Error::source()` implementations in public APIs.
- **Test assertions**: Every test function must have at least one `assert!`. Flag tests that only call code without verifying results.
- **println/eprintln**: Search changed files for `println!`/`eprintln!` outside test modules. Production code must use `tracing` macros.
- **Serde safety**: Structs deserialized from untrusted input (S3 API, user config) should have `#[serde(deny_unknown_fields)]`.
Run the full checklist in [rust-code-quality](../rust-code-quality/SKILL.md) — the canonical Rust review checklist for the unwrap/casting/cloning/locking/recursion/error-type/serde/test rules and the reuse-and-necessity checks (duplicated helpers, defensive branches without a nameable trigger, redundant error wrapping). Do not restate those rules here; carry its P0P3 ratings over unchanged and use this skill's output format.
### 4) Findings-first output
- Order findings by severity:
+15 -5
View File
@@ -36,6 +36,9 @@ rg -n 'println!\|eprintln!' <changed-files> | grep -v test
# 6. Ordering::Relaxed usage (verify each is intentional)
rg -n 'Ordering::Relaxed' <changed-files>
# 7. Default substituted for a possibly-required value (judge each: is the value optional by domain?)
rg -n 'unwrap_or_default\(\)|unwrap_or\(' <changed-files>
```
## Manual Review Checklist
@@ -55,8 +58,8 @@ For every Rust code change, verify:
- [ ] No `f64 as usize` without prior clamping
### Concurrency
- [ ] Lock acquisition order is documented when multiple locks are used
- [ ] No `tokio::sync` write guards held across `.await` without bounded hold time
- [ ] 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
- [ ] `std::sync::Mutex` in async context is held only briefly, never across `.await`
@@ -84,12 +87,19 @@ For every Rust code change, verify:
- [ ] No camelCase statics or Hungarian notation
- [ ] 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 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)
- [ ] 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
- **P2 (Should fix)**: Missing `assert!` in test, `println!` in production, missing `with_capacity`
- **P3 (Nice to fix)**: Naming convention violation, missing doc comment, `as_ptr()` vs `Arc::ptr_eq`
- **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
## Output Template
@@ -0,0 +1,169 @@
---
name: rustfs-release-publish
description: "End-to-end RustFS release pipeline: bump version files on main directly to the final target version, cut a preview tag on that commit, verify the CI build and release artifacts, run the downloaded binary locally and exercise the console, validate the server with the latest rc client, then publish the final tag on the SAME validated commit — never a new bump commit, never latest main. Use whenever the user wants to release/publish a RustFS version (发版/发布)."
---
# RustFS Release Publish (preview-validated pipeline)
This skill orchestrates a full release. It wraps `rustfs-release-version-bump` (which only edits version files and opens the PR) with a mandatory preview-tag validation loop before the final tag is published.
Core design: **version files never carry a `-preview.N` suffix**. The preview suffix exists only in tag names. This works because the binary self-reports the git tag it was built from (`build::TAG` via shadow_rs, see `rustfs/src/config/cli.rs` `SHORT_VERSION`), and `build.yml` derives artifact names and prerelease classification from the tag name — Cargo.toml's version is only a no-tag fallback. Therefore the preview tag and the final tag can (and MUST) point at the exact same commit: what you validated is byte-for-byte the source that ships.
Pipeline shape:
```
bump version files to <target> (final version, ONE commit) -> merge
-> tag <preview-tag> at that commit -> CI green
-> verify release artifacts -> run binary locally + console checks
-> validate with latest rc client
-> tag <target> at the SAME commit (zero delta) -> re-verify CI/release
```
On validation failure: fix lands on main via normal PR (version files are already at `<target>`, no new bump PR), then tag `<preview-tag N+1>` at the new main commit and restart from Phase 2.
## Required inputs
- Final target version, for example `1.0.0-beta.10`.
- Preview iteration `N` (default: next unused preview tag for that target; check with `git tag -l '<target>-preview.*'` — and for stable targets `git tag -l '<target>-rc.*'` — after `git fetch --tags`).
If the target version is missing or ambiguous, stop and ask before doing anything (see the semver gate below).
## Semver gate — confirm the target version before touching anything
Versions follow [SemVer 2.0.0](https://semver.org/). Precedence reminder:
```
1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0 < 1.0.1 < 1.1.0 < 2.0.0
```
Numeric prerelease identifiers compare numerically (`beta.9 < beta.10`), not lexically — see [semver.org spec item 11](https://semver.org/#spec-item-11). Preview tags are internal validation tags layered on top of the target's prerelease channel — they are never themselves a deliverable version and never appear in version files.
Rules:
- A request like "发个版" / "release the next version" without an exact version string is ALWAYS ambiguous. Derive the current latest tag (`git tag --sort=-v:refname | head`), then ask the user to choose via AskUserQuestion with concrete candidates, e.g. from `1.0.0-beta.10`: next prerelease `1.0.0-beta.11`, promote to `1.0.0-rc.1`, promote to stable `1.0.0`. Never guess between these — they have very different meanings (channel promotion vs. iteration) and different CI classification consequences.
- After a stable `X.Y.Z` exists, the next version must state which component bumps: patch `X.Y.(Z+1)` for fixes only, minor `X.(Y+1).0` for backward-compatible features, major `(X+1).0.0` for breaking changes. If the user names a bump type but not a number, compute it from the latest stable tag and echo the exact resulting version back for confirmation.
- Echo the final confirmed version string verbatim in your first status report; every later phase must use exactly that string. If at any point the user's wording and the confirmed version diverge, stop and re-confirm.
## Preview tag naming
- Prerelease target (contains `alpha`/`beta`/`rc`): preview tag is `<target>-preview.N`, e.g. `1.0.0-beta.10-preview.3`. It contains `beta`, so `build.yml`'s substring-based classification marks it prerelease — safe.
- **Stable** target (e.g. `1.1.0`): NEVER tag `1.1.0-preview.N``build.yml` marks a tag prerelease only if its name contains `alpha`, `beta`, or `rc`, so `1.1.0-preview.N` would be treated as a stable release and overwrite `latest.json` as stable. Use `1.1.0-rc.N` as the preview tag instead.
## Hard rules
- Version files (Cargo.toml, Cargo.lock, README, flake.nix, Chart.yaml, rustfs.spec) are bumped ONCE, directly to `<target>`. Never write a `-preview.N` suffix into any version file. If `rustfs-release-version-bump` is ever asked for a `-preview` version, that is a pipeline bug — stop.
- Tags have no `v` prefix. Always annotated: `git tag -a <tag> -m "Release <tag>"`.
- The final tag MUST point at exactly `PREVIEW_HASH` — the commit the validated preview tag points at. Never tag current `main` HEAD (commits merged after validation are unvalidated), and never create an extra version-bump commit between preview and final.
- Phases run in order; a failure in any phase blocks everything after it. After the fix lands on main, restart from Phase 2 with the next preview iteration against the new `origin/main` hash — do not resume mid-pipeline against a stale hash.
- If the release is abandoned after Phase 1 merged, main's version files claim a version that was never tagged. Either revert the bump PR or leave it to be overwritten by the next release — but tell the user explicitly and record the decision.
- User-facing status updates in Chinese; commits, PR titles/bodies, and tag messages in English. No hard-wrapping in commit messages, PR bodies, or documentation prose — one logical line per sentence/paragraph, let soft wrap handle display.
## Phase 0 — Preflight
- `git status --short` clean; `git fetch origin main --tags`.
- `gh auth status` works; confirm you can view `gh release list -L 3`.
- Confirm the exact final target version with the user if not explicit.
## Phase 1 — Version bump to the final target (once)
- If main's version files already read `<target>` (e.g. this is a restart after a failed preview), verify with `rg -n "<target>" Cargo.toml rustfs.spec helm/rustfs/Chart.yaml` and skip to Phase 2.
- Otherwise invoke the `rustfs-release-version-bump` skill with the final `<target>` (NOT a preview version), full GitHub flow (commit/push/PR).
- Get the PR merged into main. Record the resulting main commit:
```bash
git fetch origin main
PREVIEW_HASH=$(git rev-parse origin/main) # must contain the bump PR
```
`PREVIEW_HASH` is the single source of truth for the rest of the pipeline — report it to the user and reuse it verbatim in Phases 2 and 6. Both the preview tag and the final tag will point at it.
## Phase 2 — Publish the preview tag
```bash
git tag -a "<preview-tag>" -m "Release <preview-tag>" "$PREVIEW_HASH"
git push origin "<preview-tag>"
```
Pushing the tag triggers `.github/workflows/build.yml` ("Build and Release"); `docker.yml` chains off it via `workflow_run`.
On a restart (N+1), refresh `PREVIEW_HASH=$(git rev-parse origin/main)` first — it must contain the fix — and re-report it.
## Phase 3 — CI and artifact verification
- Watch the tag build: `gh run list --workflow build.yml --limit 5` then `gh run watch <run-id>`. Every matrix target must succeed (linux x86_64/aarch64 × musl/gnu, macos-aarch64, windows-x86_64) plus the release and latest.json jobs.
- Verify the GitHub release: `gh release view "<preview-tag>" --json isPrerelease,assets`
- `isPrerelease` must be `true`.
- Assets must include all 6 platform zips in both versioned (`rustfs-<platform>-v<tag>.zip`) and `-latest` forms, plus `SHA256SUMS`, `SHA512SUMS`, `rustfs-<tag>.sbom.cdx.json`, `rustfs-<tag>.provenance.json`.
- Verify the chained Docker run succeeded: `gh run list --workflow docker.yml --limit 3`.
- Checksum spot-check for the platform you will run locally: download the zip and `SHA256SUMS`, verify with `shasum -a 256 -c` (grep to one line).
## Phase 4 — Run the artifact locally, verify the console
Work inside the session scratchpad directory; never leave stray data dirs.
```bash
gh release download "<preview-tag>" -p "rustfs-macos-aarch64-v<preview-tag>.zip" -D "$SCRATCH"
cd "$SCRATCH" && unzip -o rustfs-*.zip
./rustfs --version # must report the PREVIEW TAG (build::TAG), not the Cargo.toml version, plus expected short SHA
mkdir -p data
RUSTFS_ACCESS_KEY=rustfsadmin RUSTFS_SECRET_KEY=rustfsadmin ./rustfs ./data
```
Defaults: S3 endpoint `:9000`, embedded console `:9001`.
Checks (all must pass):
- `./rustfs --version` reports the preview tag name and the short SHA of `PREVIEW_HASH`. Reporting `<target>` without the `-preview.N` suffix means the build did not embed the tag — treat as FAIL and investigate before proceeding.
- `curl -fsS http://localhost:9000/health/ready` returns ready.
- Startup log shows the embedded console being served (this was the regression that `fix(release): require embedded console assets` guards).
- Open `http://localhost:9001` in the browser: login with `rustfsadmin`/`rustfsadmin`; dashboard renders without JS console errors; create a bucket, upload a file, download it back (byte-identical), delete the object and bucket. Keep the server running for Phase 5.
## Phase 5 — Validate with the latest rc client
`rc` is the RustFS CLI client from <https://github.com/rustfs/cli>.
- Ensure the latest release is installed: compare `rc --version` against `gh api repos/rustfs/cli/releases/latest --jq .tag_name`; update via `brew upgrade rustfs/tap/rc` (or download the release binary).
- Point it at the preview server and run the command matrix, recording PASS/FAIL per command:
```bash
rc alias set preview http://localhost:9000 rustfsadmin rustfsadmin
rc ls preview/
rc mb preview/rel-check
rc cp <local-file> preview/rel-check/
rc stat preview/rel-check/<file>
rc cat preview/rel-check/<file> # matches source
rc cp preview/rel-check/<file> ./out && cmp <local-file> ./out
rc cp -r <local-dir>/ preview/rel-check/dir/
rc find preview/rel-check --name "*"
rc share download preview/rel-check/<file> --expire 1h # presigned URL fetchable via curl
rc rm preview/rel-check/<file> && rc rm -r --force preview/rel-check/dir
rc rb preview/rel-check
rc admin user list preview/
rc admin user add preview/ relcheckuser relchecksecret12
rc admin user remove preview/ relcheckuser
rc alias remove preview
```
- Any FAIL blocks the release. Afterwards stop the server and delete the scratch data directory.
## Phase 6 — Publish the final tag on the validated commit
No second version bump, no release branch. The final tag goes on the exact commit the preview validated:
```bash
git fetch origin --tags
git rev-parse "<preview-tag>^{commit}" # must equal PREVIEW_HASH — abort if not
git tag -a "<target>" -m "Release <target>" "$PREVIEW_HASH"
git push origin "<target>"
```
- CI rebuilds from the same source; the only changed input is the tag name, so the binary now self-reports `<target>`.
- Re-run the Phase 3 verification against the final tag: all matrix jobs green; `gh release view "<target>"` shows the full asset set; for a prerelease target `isPrerelease` is `true`, for a stable target it must be `false` and `latest.json` must be updated.
- Optionally spot-check `./rustfs --version` from a final-tag artifact — it must report `<target>`.
## Output contract
Always report:
- Target version, preview tag(s) used, `PREVIEW_HASH` (which both tags point at).
- Per-phase result (PASS/FAIL/BLOCKED) with key evidence: CI run URLs, release URLs, console check results, the rc command matrix.
- Any deviation from this pipeline and why the user approved it.
@@ -18,6 +18,8 @@ Validated baseline: release pattern used in PR `#2957`.
If target version is missing or ambiguous, stop and ask before editing.
Reject any target version containing a `-preview.` suffix: preview identifiers are tag-only (see `rustfs-release-publish`) and must never be written into version files. If asked for one, stop and point to the release pipeline instead of editing.
## Read before editing
- `AGENTS.md` (root and nearest path-specific files).
@@ -60,12 +60,14 @@ For the full pattern map, read [advisory-patterns.md](references/advisory-patter
### IAM and service accounts
- Treat imported IAM payload fields as attacker-controlled: `parent`, `claims`, `accessKey`, `secretKey`, status, policy names, and groups.
- For service account create/update/import, prove parent ownership or root/admin authority before writing credentials or claims; an action permission alone must not allow choosing root or another user as `target_user`.
- Treat IAM export packages as credential disclosure surfaces; never include plaintext user or service-account secret keys unless the caller is allowed to recover those secrets and the export format is intentionally sealed.
- Do not let `deny_only` or "no explicit deny" become an allow decision that skips required allow checks.
- Test cross-user list/update/import flows with wrong, correct, self, parent, and root identities.
### 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.
- 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.
@@ -97,6 +99,8 @@ For the full pattern map, read [advisory-patterns.md](references/advisory-patter
### Logging and debug output
- Logs must never include access keys beyond safe identifiers, secret keys, session tokens, JWT claims, HMAC secrets, expected signatures, license secrets, or raw response bodies containing credentials.
- Treat `Debug` implementations, `?value` tracing, merged config dumps, and dependency-level HTTP body logging as leak surfaces.
- Error and panic messages are log content: they propagate through `?` and get printed by `error!`/startup logging far from where they were constructed. Never interpolate a raw config or credential value into an error string.
- A value that fails secret-format parsing is usually the secret itself (e.g. a bare base64 key missing its `<name>:` prefix), so a parse-failure hint must name the env var or file and the expected format, never echo the input. Redacting `Debug` impls does not cover this channel.
- Add log-capture tests or targeted unit tests for redaction wrappers when changing credential structs or response bodies.
### RPC, parsing, and panic safety
@@ -137,7 +141,10 @@ Use these prompts while reviewing a diff:
- Does a public/default/empty config change security behavior from fail-closed to fail-open?
- Is any attacker-controlled value later used as a path, policy condition, credential identity, log field, URL, Origin, or response body?
- Does this response contain stored replication, remote target, or service credentials that need redaction or stricter authorization?
- Does any error constructor or `format!` interpolate a variable that can hold secret material, including a config parse error that echoes the raw input?
- Does an IAM export/import path expose or trust plaintext credential secrets beyond the caller's intended authority?
- Can this STS/OIDC path issue credentials without SigV4, trusted issuer validation, allowlisted redirects, or trusted-proxy host/scheme handling?
- Can a service-account or STS token omit `exp`, forge `sessionPolicy`, or use a principal-controlled key as signing authority?
- 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?
@@ -27,14 +27,15 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
### IAM import, service accounts, and privilege boundaries
- `GHSA-566f-q62r-wcr8`: `ImportIam` accepted attacker-controlled service account `parent`, `claims`, `accessKey`, and `secretKey`, enabling persistent backdoor accounts under root. Lesson: imported IAM payloads are untrusted data and must be validated against privilege boundaries.
- `GHSA-3495-h8r9-gfqg`: `ExportIAM` wrote regular-user and service-account secret keys into exported ZIP data. Lesson: IAM export is a credential-disclosure boundary; redact, seal, or strictly justify every exported secret before treating export permission as safe.
- `GHSA-5354-r3w2-34m8`: `AddServiceAccount` checked `CreateServiceAccountAdminAction` but trusted caller-supplied `target_user`, allowing service accounts under the root parent. Lesson: service-account create paths must validate parent ownership or root/admin authority, not only the create action.
- `GHSA-xgr5-qc6w-vcg9`: `deny_only=true` skipped allow checks and let restricted service accounts mint unrestricted children. Lesson: deny-only logic must never become implicit allow for privilege creation.
- `GHSA-mm2q-qcmx-gw4w`: leaked service account access keys plus update-without-ownership formed an escalation chain. Lesson: service-account identifiers are security-sensitive because update APIs consume them.
### STS, OIDC, and federation flows
- `GHSA-5qfg-mf7r-jp3w`: `AssumeRoleWithWebIdentity` was reachable without the required request authentication 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.
- `GHSA-ccrv-v8v9-ch9q`: service-account-controlled material could self-sign JWT session tokens with forged policy claims. Lesson: session tokens must be signed by a trusted issuer/key path and validation must reject self-signed or principal-controlled tokens.
- `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-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.
@@ -58,7 +59,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`, and `GHSA-9gf3-jx4p-4xxf`: 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`, 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-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.
@@ -122,6 +123,7 @@ rg -n "deny_unknown_fields|serde.default|as u32|as usize|as i32" rustfs crates
- Copy/upload fixes: include cross-bucket, cross-user, source-denied, destination-denied, copy-source-condition, and multipart completion cases.
- 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.
+5
View File
@@ -60,6 +60,11 @@ body-cache-whitelist-check: ## Check the body-cache eligibility gate stays a fai
@echo "🧱 Checking body-cache whitelist guard..."
./scripts/check_body_cache_whitelist.sh
.PHONY: log-analyzer-rules-check
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
@echo "🩺 Checking log-analyzer rule anchors..."
./scripts/check_log_analyzer_rules.sh
.PHONY: compilation-check
compilation-check: core-deps ## Run compilation check
@echo "🔨 Running compilation check..."
+1 -1
View File
@@ -23,7 +23,7 @@ pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-gua
@echo "✅ All pre-commit checks passed!"
.PHONY: pre-pr
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check doc-paths-check planning-docs-check clippy-check test ## Run full pre-PR checks with clippy and tests
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check doc-paths-check planning-docs-check log-analyzer-rules-check clippy-check test ## Run full pre-PR checks with clippy and tests
@echo "✅ All pre-PR checks passed!"
.PHONY: dev-check
+8
View File
@@ -26,6 +26,14 @@ script-tests: ## Run shell script tests
@echo "Running script tests..."
./scripts/test_build_rustfs_options.sh
./scripts/test_entrypoint_credentials.sh
./scripts/test_internode_grpc_ab_bench.sh
./scripts/test_object_batch_bench_enhanced.sh
./scripts/test_exact_1mib_handoff_abba.sh
./scripts/test_pinned_paired_abba_bench.sh
./scripts/test_manual_transition_runbooks.sh
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
python3 ./scripts/check_object_data_cache_follower_samples.py --self-test
./scripts/validate_object_data_cache_cold_stampede.sh --self-test
.PHONY: test
test: core-deps script-tests ## Run all tests (needs cargo-nextest; RUSTFS_ALLOW_CARGO_TEST_FALLBACK=1 to override)
+42 -33
View File
@@ -1,17 +1,14 @@
# nextest configuration for RustFS.
#
# Serialize two known load-sensitive / global-state-sharing ecstore test groups
# so the full parallel nextest suite stops producing spurious failures
# (backlog #937). These tests pass in isolation but flake under the loaded
# parallel run for two distinct reasons:
# Serialize the ecstore tests that share the process-wide disk registry or
# exercise a multi-disk commit handoff across nextest process boundaries.
#
# * store::bucket::tests::bucket_delete_* share process/global state (disk
# registry, lock client) and race make_bucket into InsufficientWriteQuorum
# when run concurrently with other ecstore tests.
# * bucket_lifecycle_ops::tests::concurrent_resend_same_part_commits_one_generation
# asserts a lock-acquire correctness property whose serialized cross-disk
# commits exceed the (already max'd, 60s) acquire deadline only when the
# suite saturates disk I/O.
# uses the shared multipart fixture and a deterministic uploadId-lock
# handoff, so it must not overlap another process mutating that fixture.
#
# serial_test's #[serial] attribute does NOT serialize these across runs:
# nextest executes each test in its own process, where the in-process
@@ -39,10 +36,11 @@ ecstore-serial-flaky = { max-threads = 1 }
# servers never run at once. ci-7's nightly picks these up via the e2e suite;
# they are deliberately NOT in the fast PR `e2e-smoke` filter.
e2e-reliability = { max-threads = 1 }
e2e-inline-boundaries = { max-threads = 1 }
# --- default profile (local): serialize the flaky groups, never retry --------
[[profile.default.overrides]]
filter = 'package(rustfs-ecstore) & (test(concurrent_resend_same_part_commits_one_generation) | test(/^store::bucket::tests::bucket_delete_(mark_delete_marks|purge_removes|default_s3_delete)/))'
filter = 'package(rustfs-ecstore) & (test(concurrent_resend_same_part_commits_one_generation) | test(/^store::bucket::tests::bucket_delete_(mark_delete|purge_removes|default_s3_delete)/))'
test-group = 'ecstore-serial-flaky'
# Serialize the multipart crash-consistency scenarios (dist-2, backlog#1150):
@@ -54,6 +52,12 @@ test-group = 'ecstore-serial-flaky'
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::multipart::tests::crash_consistency::/)'
test-group = 'ecstore-serial-flaky'
# Serialize the durable manual-transition checkpoint test across nextest's
# process boundary; it mutates bucket lifecycle metadata and is not quarantined.
[[profile.default.overrides]]
filter = 'package(rustfs-ecstore) & test(manual_transition_page_checkpoint_persists_durable_job_progress)'
test-group = 'ecstore-serial-flaky'
# Serialize the 4-disk reliability / degraded-read e2e tests (see the
# e2e-reliability test-group note above). The matching ci-profile override is at
# the end of the file, after [profile.ci] is declared.
@@ -61,6 +65,10 @@ test-group = 'ecstore-serial-flaky'
filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression)_test::/)'
test-group = 'e2e-reliability'
[[profile.default.overrides]]
filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
test-group = 'e2e-inline-boundaries'
# ---------------------------------------------------------------------------
# ci profile — the strict CI gate (ci.yml `cargo nextest run --profile ci`)
# ---------------------------------------------------------------------------
@@ -89,20 +97,18 @@ path = "junit.xml"
# profile's own overrides list, not the default profile's).
# ===========================================================================
# QUARANTINE: OPEN backlog#937 — concurrent_resend lock-acquire deadline flakes
# under saturated disk I/O in the full parallel suite.
[[profile.ci.overrides]]
filter = 'package(rustfs-ecstore) & test(concurrent_resend_same_part_commits_one_generation)'
test-group = 'ecstore-serial-flaky'
retries = 2
# QUARANTINE: OPEN backlog#937 — store::bucket::tests::bucket_delete_* race
# make_bucket into InsufficientWriteQuorum via shared global state under load.
[[profile.ci.overrides]]
filter = 'package(rustfs-ecstore) & test(/^store::bucket::tests::bucket_delete_(mark_delete_marks|purge_removes|default_s3_delete)/)'
filter = 'package(rustfs-ecstore) & test(/^store::bucket::tests::bucket_delete_(mark_delete|purge_removes|default_s3_delete)/)'
test-group = 'ecstore-serial-flaky'
retries = 2
# Keep the deterministic multipart handoff isolated across nextest processes.
[[profile.ci.overrides]]
filter = 'package(rustfs-ecstore) & test(concurrent_resend_same_part_commits_one_generation)'
test-group = 'ecstore-serial-flaky'
# QUARANTINE: OPEN rustfs#4690 — walk_dir stall-budget accounting test depends
# on producer/consumer timing windows that stretch past the budget on loaded
# CI runners (regression test for rustfs#4644; failed on a zero-Rust-diff PR).
@@ -125,6 +131,12 @@ test-group = 'e2e-reliability'
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::multipart::tests::crash_consistency::/)'
test-group = 'ecstore-serial-flaky'
# Serialize the durable manual-transition checkpoint test under the ci profile
# too. No retries: failures stay visible.
[[profile.ci.overrides]]
filter = 'package(rustfs-ecstore) & test(manual_transition_page_checkpoint_persists_durable_job_progress)'
test-group = 'ecstore-serial-flaky'
# ---------------------------------------------------------------------------
# e2e-smoke profile — PR smoke subset of the e2e_test crate (backlog#1149 ci-4)
# ---------------------------------------------------------------------------
@@ -138,6 +150,10 @@ test-group = 'ecstore-serial-flaky'
# Each e2e test spawns its own rustfs server on a random port with an isolated
# temp dir (crates/e2e_test/src/common.rs), so the subset is parallel-safe.
#
# Replication failure harness (backlog#1147 repl-8): the first clause admits
# its four in-process fake-target self-tests. They bind random loopback ports,
# use no external service, and finish in under a second.
#
# Replication PR subset (backlog#1147 repl-1): the second clause admits the 20
# FAST bucket-replication tests from replication_extension_test — the
# target-registration / replication-check / list / remove / delete admin paths
@@ -152,7 +168,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 + 27 nightly = 47 total
# regexes byte-identical. Count invariant: 20 here + 28 nightly = 48 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
@@ -188,7 +204,7 @@ test-group = 'ecstore-serial-flaky'
[profile.e2e-smoke]
default-filter = """
package(e2e_test) & (
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_source_invalid_date|content_encoding|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud)_test::/)
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat)_test::|^fake_s3_target::/)
| test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/)
| test(/^reliant::lifecycle::/)
| test(/^reliant::tiering::/)
@@ -207,7 +223,7 @@ fail-fast = false
# and poll until source and target converge; 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 full rustfs
# * 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.
# * 1 `_real_single_node` service-account round-trip test.
@@ -259,8 +275,8 @@ path = "junit.xml"
# The filter is "the whole e2e_test crate MINUS the sets owned by other lanes":
# * protocols:: — FTPS/SFTP/WebDAV, still pinned to --test-threads=1 by fixed
# ports; they join a scheduled lane once ci-6 randomises the ports (ci-7).
# * the 6 cluster suites that spin up a RustFSTestClusterEnvironment
# (cluster_concurrency, stale_multipart_cleanup_cluster,
# * the 7 cluster suites that spin up a RustFSTestClusterEnvironment
# (cluster_concurrency, cluster_multidrive_pool, stale_multipart_cleanup_cluster,
# namespace_lock_quorum, heal_erasure_disk_rebuild, admin_timeout_regression,
# object_lambda) — too heavy for the merge budget; they run in ci-7's
# nightly 4-node lane.
@@ -282,14 +298,8 @@ path = "junit.xml"
# ci-profile quarantine (docs/testing/README.md): every entry MUST cite one
# OPEN issue, and the fixing PR MUST delete the exclusion. The passing
# negative-path siblings of each family stay in as regression guards.
# * rustfs#4842 — extract/snowball expand pipeline 500s (mtime=0
# OffsetDateTime deserialization + same-path failures).
# * rustfs#4843 — over-limit archive entry paths hard-reject the whole
# archive even under ignore-errors semantics.
# * rustfs#4844 — anonymous POST-object with SSE-S3 / bucket-default SSE
# returns 500.
# * rustfs#4845 — 403 on allowed anonymous POST object-lock fields and on
# the list metadata=true extension.
# * rustfs#4846 — distributed-lock quorum tests misclassify as timeout
# under parallel load (multi-node in-process clusters; natural home is
# ci-7's nightly cluster lane).
@@ -297,15 +307,10 @@ path = "junit.xml"
default-filter = """
package(e2e_test)
& !test(/^protocols::/)
& !test(/^(admin_timeout_regression_test|cluster_concurrency_test|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
& !test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
& !test(/^replication_extension_test::/)
& !test(/^multipart_auth_test::test_signed_put_object_extract_(accepts_compat_header|expands_tar_entries_with_prefix_headers|expands_tar_gz_archive|expands_tbz2_archive|expands_tgz_archive|expands_txz_archive|expands_tzst_archive|normalizes_prefix_header_value|preserves_directory_markers_by_default|preserves_object_lock_legal_hold|preserves_object_lock_retention|preserves_pax_metadata_and_version_id|preserves_request_metadata_on_extracted_objects|preserves_sse_c|preserves_sse_s3_and_redirect|preserves_storage_class|uses_bucket_default_sse_s3)$/)
& !test(/^snowball_auto_extract_test::tests::snowball_auto_extract_(prefers_exact_minio_prefix_over_suffix_fallback|supports_minio_prefix_and_directory_markers)$/)
& !test(/^multipart_auth_test::test_signed_put_object_extract_skips_invalid_entry_when_ignore_errors_enabled$/)
& !test(/^snowball_auto_extract_test::tests::snowball_auto_extract_(ignores_invalid_entries_when_requested|supports_standard_headers_with_combined_extract_options)$/)
& !test(/^multipart_auth_test::test_anonymous_post_object_(accepts_sse_s3|rejects_sse_s3_missing_from_policy_conditions|uses_bucket_default_sse_kms|uses_bucket_default_sse_s3)$/)
& !test(/^multipart_auth_test::test_anonymous_post_object_(accepts_object_lock_legal_hold_field|accepts_object_lock_retention_fields)$/)
& !test(/^list_object(s_v2|_versions)_metadata_extension_test::/)
& !test(/^reliant::lock::test_distributed_lock_(2_nodes_grpc_read_survives_failed_node|4_nodes_grpc_read_write_quorum_split_with_two_failed_nodes)$/)
"""
fail-fast = false
@@ -321,3 +326,7 @@ path = "junit.xml"
[[profile.e2e-full.overrides]]
filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression)_test::/)'
test-group = 'e2e-reliability'
[[profile.e2e-full.overrides]]
filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
test-group = 'e2e-inline-boundaries'
@@ -1744,7 +1744,7 @@
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "sum by (bucket) (rustfs_bucket_api_objects_total{job=~\"$job\", bucket=~\"$bucket\"})",
"expr": "max by (job, bucket) (rustfs_cluster_usage_buckets_objects_count{job=~\"$job\", bucket=~\"$bucket\"})",
"legendFormat": "{{bucket}}",
"range": true,
"refId": "A"
@@ -1844,7 +1844,7 @@
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "sum by (bucket) (rustfs_bucket_api_usage_bytes{job=~\"$job\", bucket=~\"$bucket\"})",
"expr": "max by (job, bucket) (rustfs_cluster_usage_buckets_total_bytes{job=~\"$job\", bucket=~\"$bucket\"})",
"legendFormat": "{{bucket}}",
"range": true,
"refId": "A"
@@ -11583,7 +11583,7 @@
"text": "All",
"value": "$__all"
},
"definition": "label_values(rustfs_bucket_api_objects_total,bucket)",
"definition": "label_values(rustfs_cluster_usage_buckets_objects_count,bucket)",
"includeAll": true,
"label": "Bucket",
"multi": true,
@@ -11591,7 +11591,7 @@
"options": [],
"query": {
"qryType": 1,
"query": "label_values(rustfs_bucket_api_objects_total,bucket)",
"query": "label_values(rustfs_cluster_usage_buckets_objects_count,bucket)",
"refId": "PrometheusVariableQueryEditor-VariableQuery"
},
"refresh": 2,
@@ -18,6 +18,7 @@ set -eu
ACCESS_KEY="${RUSTFS_SITE_REPL_ACCESS_KEY:-rustfsadmin}"
SECRET_KEY="${RUSTFS_SITE_REPL_SECRET_KEY:-rustfsadmin}"
BUCKET="${RUSTFS_SITE_REPL_FLOW_BUCKET:-site-repl-flow-check}"
DELETE_BUCKET="${RUSTFS_SITE_REPL_DELETE_BUCKET:-site-repl-delete-$(date +%Y%m%d-%H%M%S)-$$}"
PREFIX="${RUSTFS_SITE_REPL_FLOW_PREFIX:-flow-$(date +%Y%m%d-%H%M%S)}"
WAIT_ATTEMPTS="${RUSTFS_SITE_REPL_WAIT_ATTEMPTS:-90}"
WAIT_SLEEP_SECONDS="${RUSTFS_SITE_REPL_WAIT_SLEEP_SECONDS:-2}"
@@ -85,17 +86,39 @@ wait_for_object() {
wait_for_bucket() {
site="$1"
bucket="${2:-$BUCKET}"
attempt=1
while [ "$attempt" -le "$WAIT_ATTEMPTS" ]; do
if mc stat "$site/$BUCKET" >/dev/null 2>&1; then
if mc stat "$site/$bucket" >/dev/null 2>&1; then
return 0
fi
sleep "$WAIT_SLEEP_SECONDS"
attempt=$((attempt + 1))
done
echo "bucket was not replicated in time: $site/$BUCKET" >&2
echo "bucket was not replicated in time: $site/$bucket" >&2
return 1
}
wait_for_bucket_delete() {
site="$1"
bucket="$2"
attempt=1
while [ "$attempt" -le "$WAIT_ATTEMPTS" ]; do
if result="$(mc stat --json "$site/$bucket" 2>&1)"; then
:
else
case "$result" in
*NoSuchBucket*) return 0 ;;
esac
fi
sleep "$WAIT_SLEEP_SECONDS"
attempt=$((attempt + 1))
done
echo "bucket deletion was not replicated in time: $site/$bucket" >&2
return 1
}
@@ -186,6 +209,20 @@ EOF
echo "verified replicated downloads for $object_name"
done
echo "creating empty bucket for replicated delete check: $DELETE_BUCKET"
mc mb "site1/$DELETE_BUCKET" >/dev/null
for site in site1 site2 site3; do
wait_for_bucket "$site" "$DELETE_BUCKET"
done
echo "deleting empty bucket on site1: $DELETE_BUCKET"
mc rb "site1/$DELETE_BUCKET" >/dev/null
for site in site1 site2 site3; do
wait_for_bucket_delete "$site" "$DELETE_BUCKET"
done
echo "site replication object flow check passed"
echo "bucket: $BUCKET"
echo "prefix: $PREFIX"
Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

+2 -2
View File
@@ -15,8 +15,8 @@
enabled: true
document:
version: v1
url: https://github.com/rustfs/cla/blob/main/cla/v1.md
version: v2
url: https://github.com/rustfs/cla/blob/main/cla/v2.md
signing:
mode: comment
+1 -1
View File
@@ -33,4 +33,4 @@ documentation impact. Use N/A when there is no expected impact.
---
Thank you for your contribution! Please ensure your PR follows the community standards ([CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)). If this is your first contribution, review the [CLA document](https://github.com/rustfs/cla/blob/main/cla/v1.md) and sign it by commenting `I have read and agree to the CLA.` on the PR.
Thank you for your contribution! Please ensure your PR follows the community standards ([CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)). If this is your first contribution, review the [CLA document](https://github.com/rustfs/cla/blob/main/cla/v2.md) and sign it by commenting `I have read and agree to the CLA.` on the PR.
+84 -11
View File
@@ -190,7 +190,6 @@ jobs:
{"target_id":"linux-x86_64-gnu","os":"sm-standard-2","target":"x86_64-unknown-linux-gnu","cross":false,"platform":"linux","rustflags":""},
{"target_id":"linux-aarch64-gnu","os":"sm-standard-2","target":"aarch64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":""},
{"target_id":"macos-aarch64","os":"macos-latest","target":"aarch64-apple-darwin","cross":false,"platform":"macos","rustflags":""},
{"target_id":"macos-x86_64","os":"macos-26-intel","target":"x86_64-apple-darwin","cross":false,"platform":"macos","rustflags":""},
{"target_id":"windows-x86_64","os":"windows-latest","target":"x86_64-pc-windows-msvc","cross":false,"platform":"windows","rustflags":""}
]}'
@@ -290,6 +289,7 @@ jobs:
local console_url
local console_sha256
local curl_auth_args=()
console_tag=""
if [[ "${{ matrix.platform }}" == "windows" ]]; then
curl_bin="curl.exe"
@@ -312,7 +312,7 @@ jobs:
"$curl_bin" "${curl_auth_args[@]}" --fail -L "$console_api" \
-o "$console_json" --retry 3 --retry-delay 5 --max-time 300 || return 1
read -r console_url console_sha256 < <("$python_bin" - "$console_json" <<'PY'
read -r console_tag console_url console_sha256 < <("$python_bin" - "$console_json" <<'PY'
import json
import re
import sys
@@ -320,6 +320,8 @@ jobs:
with open(sys.argv[1], encoding="utf-8") as handle:
release = json.load(handle)
tag = release.get("tag_name", "") or "unknown"
for asset in release.get("assets", []):
name = asset.get("name", "")
digest = asset.get("digest", "")
@@ -328,7 +330,7 @@ jobs:
sha256 = digest.split(":", 1)[1]
if not re.fullmatch(r"[0-9a-fA-F]{64}", sha256):
raise SystemExit(f"console zip asset has invalid sha256 digest: {sha256}")
sys.stdout.buffer.write(f"{url} {sha256}\n".encode("utf-8"))
sys.stdout.buffer.write(f"{tag} {url} {sha256}\n".encode("utf-8"))
break
else:
raise SystemExit("no console zip asset with sha256 digest found")
@@ -340,6 +342,8 @@ jobs:
return 1
fi
echo "Console release: ${console_tag}"
echo "Downloading console asset: ${console_url}"
"$curl_bin" --fail -L "$console_url" -o console.zip --retry 3 --retry-delay 5 --max-time 300 || return 1
verify_sha256 "$console_sha256" console.zip || return 2
unzip -o console.zip -d ./rustfs/static || return 2
@@ -352,12 +356,19 @@ jobs:
rm -f console.zip console-release.json
if [[ "$status" -eq 2 ]]; then
echo "Console asset integrity verification failed" >&2
exit 1
fi
echo "Warning: Failed to download verified console assets, continuing without them"
echo "// Static assets not available" > ./rustfs/static/empty.txt
echo "Failed to download verified console assets" >&2
exit 1
fi
if [[ ! -s ./rustfs/static/index.html ]]; then
echo "Console asset archive is missing static/index.html" >&2
exit 1
fi
asset_count=$(find ./rustfs/static -type f | wc -l | tr -d '[:space:]')
echo "Console assets ready: version=${console_tag:-unknown}, ${asset_count} files extracted to ./rustfs/static"
- name: Build RustFS
shell: bash
run: |
@@ -559,6 +570,60 @@ jobs:
echo "🔧 Build type: ${BUILD_TYPE}"
echo "📊 Version: ${VERSION}"
- name: Verify packaged console
if: matrix.target == 'x86_64-unknown-linux-gnu'
shell: bash
run: |
set -euo pipefail
find_free_port() {
python3 - <<'PY'
import socket
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
print(sock.getsockname()[1])
PY
}
package_dir="$(mktemp -d)"
data_dir="$(mktemp -d)"
log_file="${RUNNER_TEMP}/rustfs-console-smoke.log"
server_pid=""
trap '
if [[ -n "${server_pid:-}" ]]; then
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
fi
rm -rf "$package_dir" "$data_dir"
' EXIT
unzip -q "${{ steps.package.outputs.package_file }}" -d "$package_dir"
api_port="$(find_free_port)"
console_port="$(find_free_port)"
console_url="http://127.0.0.1:${console_port}/rustfs/console/"
"$package_dir/rustfs" server \
--address "127.0.0.1:${api_port}" \
--console-enable \
--console-address "127.0.0.1:${console_port}" \
--access-key console-smoke \
--secret-key console-smoke-secret \
"$data_dir" >"$log_file" 2>&1 &
server_pid=$!
for _ in {1..40}; do
response="$(curl --silent --output /dev/null --write-out '%{http_code} %{content_type}' "$console_url" || true)"
if [[ "$response" == 200\ text/html* ]]; then
exit 0
fi
sleep 0.25
done
echo "Console endpoint did not return 200 text/html: $response" >&2
cat "$log_file"
exit 1
- name: Upload to GitHub artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
@@ -849,12 +914,14 @@ jobs:
echo "✅ All assets uploaded successfully"
# Update latest.json for stable releases only: prerelease tags (alpha/beta/
# rc) must never overwrite the stable version pointer.
# Update latest.json for every release tag (stable and prerelease): the
# project currently ships prerelease tags only, so gating this to stable
# left the version pointer permanently stale. release_type records whether
# the pointed-to version is a prerelease.
update-latest-version:
name: Update Latest Version
needs: [ build-check, upload-release-assets ]
if: startsWith(github.ref, 'refs/tags/') && needs.build-check.outputs.build_type == 'release'
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- name: Update latest.json
@@ -873,6 +940,12 @@ jobs:
VERSION="${{ needs.build-check.outputs.version }}"
TAG="${{ needs.build-check.outputs.version }}"
if [[ "${{ needs.build-check.outputs.build_type }}" == "prerelease" ]]; then
RELEASE_TYPE="prerelease"
else
RELEASE_TYPE="stable"
fi
# Install ossutil
OSSUTIL_VERSION="2.1.1"
OSSUTIL_ZIP="ossutil-${OSSUTIL_VERSION}-linux-amd64.zip"
@@ -893,7 +966,7 @@ jobs:
"version": "${VERSION}",
"tag": "${TAG}",
"release_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"release_type": "stable",
"release_type": "${RELEASE_TYPE}",
"download_url": "https://github.com/${{ github.repository }}/releases/tag/${TAG}"
}
EOF
@@ -901,7 +974,7 @@ jobs:
# Upload to OSS
"$OSSUTIL_BIN" cp latest.json oss://rustfs-version/latest.json --force
echo "✅ Updated latest.json for stable release $VERSION"
echo "✅ Updated latest.json for ${RELEASE_TYPE} release $VERSION"
# Publish release (remove draft status)
publish-release:
+21 -6
View File
@@ -141,7 +141,7 @@ jobs:
name: Test and Lint
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: sm-standard-4
timeout-minutes: 60
timeout-minutes: 90
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
@@ -167,6 +167,13 @@ jobs:
cargo nextest run --profile ci --all --exclude e2e_test
cargo test --all --doc
# rustfs/backlog#1289: fail if a seed rule's log anchor no longer exists
# verbatim in the source tree (log message drifted without updating the
# rule). Placed here where the workspace — including the la-dump-anchors
# bin — is already built by the clippy/test steps above.
- name: Check log-analyzer rule anchors
run: ./scripts/check_log_analyzer_rules.sh
- name: Upload test junit report
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
@@ -221,15 +228,23 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }}
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
# Three scanner tests fail on main independently of this lane (restore of
# a transitioned multipart object, noncurrent transition/expiry after an
# immediate compensation transition); they keep #[ignore] with a backlog
# reference and are excluded here by name until fixed (rustfs/backlog#1148).
# test_transition_and_restore_flows was re-enabled by rustfs/backlog#1303:
# its "missing xl.meta on disk2" was a test-util bug (open_disk hardcoded
# disk_index 0), not an EC metadata-distribution issue.
# restore_object_usecase_reports_ongoing_conflict_and_completion was
# re-enabled by backlog#1304 (restore accepts serialize on a short CAS
# guard; the copy-back no longer holds the #4877 whole-copy-back lock,
# so the mid-restore ongoing read and fast 409 rejection it asserts are
# the implemented contract). The remaining exclusions each hit a
# DIFFERENT, independent issue (all tracked under rustfs/backlog#1148;
# they keep #[ignore] with a backlog reference):
# - test_noncurrent_{expiry,transition}_still_works_after_immediate_compensation_transition:
# noncurrent transition/expiry after an immediate compensation transition.
- name: Run ignored ILM integration tests serially
run: |
cargo nextest run -j1 --run-ignored ignored-only \
-p rustfs-scanner -p rustfs \
-E '(binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))) and not (test(test_transition_and_restore_flows) or test(test_noncurrent_expiry_still_works_after_immediate_compensation_transition) or test(test_noncurrent_transition_still_works_after_immediate_compensation_transition))'
-E '(binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))) and not (test(test_noncurrent_expiry_still_works_after_immediate_compensation_transition) or test(test_noncurrent_transition_still_works_after_immediate_compensation_transition))'
test-and-lint-rio-v2:
name: Test and Lint (rio-v2)
+26
View File
@@ -0,0 +1,26 @@
name: Star History
on:
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
permissions:
contents: write
concurrency:
group: star-history
cancel-in-progress: false
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: overtrue/repo-visuals-action@72f34d24769ff5d341956da2f23952594ef2f1e2 # v1.3.0
with:
github-token: ${{ github.token }}
output-branch: star-history
output-path: .
chart-style: gradient
animate: "true"
contributors: "true"
+35 -8
View File
@@ -172,7 +172,7 @@
],
},
{
"name": "Debug executable target/debug/rustfs with sse",
"name": "Debug executable target/debug/rustfs with sse kms",
"type": "lldb",
"request": "launch",
"program": "${workspaceFolder}/target/debug/rustfs",
@@ -200,7 +200,7 @@
// 2. kms local backend test key
// "RUSTFS_KMS_ENABLE": "true",
// "RUSTFS_KMS_BACKEND": "local",
// "RUSTFS_KMS_KEY_DIR": "./target/kms-key-dir",
// "RUSTFS_KMS_KEY_DIR": "/tmp/kms-key-dir",
// "RUSTFS_KMS_LOCAL_MASTER_KEY": "my-secret-key", // Some Password
// "RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
@@ -212,13 +212,40 @@
// "RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
// 4. kms vault transit backend test key
// "RUSTFS_KMS_ENABLE": "true",
// "RUSTFS_KMS_BACKEND": "vault-transit",
// "RUSTFS_KMS_VAULT_ADDRESS": "http://127.0.0.1:8200",
// "RUSTFS_KMS_VAULT_TOKEN": "Dev Token",
// "RUSTFS_KMS_VAULT_MOUNT_PATH": "transit",
// "RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
// 5、kms static backend test key
"RUSTFS_KMS_ENABLE": "true",
"RUSTFS_KMS_BACKEND": "vault-transit",
"RUSTFS_KMS_VAULT_ADDRESS": "http://127.0.0.1:8200",
"RUSTFS_KMS_VAULT_TOKEN": "Dev Token",
"RUSTFS_KMS_VAULT_MOUNT_PATH": "transit",
"RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
"RUSTFS_KMS_BACKEND": "static",
"RUSTFS_KMS_STATIC_SECRET_KEY": "rustfs-master-key:2dfNXGHlsEflGVCxb+5DIdGEl1sIvtwX+QfmYasi5QM="
},
"sourceLanguages": [
"rust"
],
},
{
"name": "Debug executable target/debug/rustfs with local sse",
"type": "lldb",
"request": "launch",
"program": "${workspaceFolder}/target/debug/rustfs",
"args": [],
"cwd": "${workspaceFolder}",
"env": {
"RUSTFS_ACCESS_KEY": "rustfsadmin",
"RUSTFS_SECRET_KEY": "rustfsadmin",
"RUSTFS_VOLUMES": "./target/volumes/test{1...4}",
"RUSTFS_ADDRESS": ":9000",
"RUSTFS_CONSOLE_ENABLE": "true",
"RUSTFS_CONSOLE_ADDRESS": "127.0.0.1:9001",
"RUSTFS_OBS_LOG_DIRECTORY": "./target/logs",
"RUSTFS_UNSAFE_BYPASS_DISK_CHECK": "true",
"RUSTFS_SSE_S3_MASTER_KEY": "xGb3aYSp825j2tPpg8JrUzghiXsIkfdOtmrsJ/iafiM=",
"RUST_LOG": "rustfs=debug,ecstore=debug,s3s=debug,iam=debug",
},
"sourceLanguages": [
"rust"
+34 -15
View File
@@ -14,13 +14,19 @@ If repo-level instructions conflict, follow the nearest file and keep behavior a
## Execution Discipline
- Read the relevant existing code, tests, and local guidance before changing behavior.
- Read the relevant existing code, tests, and local guidance before changing behavior. For new helpers or test setup, that read includes `crates/utils`, `crates/common`, and the touched crate's own `test_util`/fixtures (see Reuse Before You Write).
- State assumptions when they affect the implementation or verification path.
- If a task has multiple plausible interpretations, list the options briefly and choose the narrowest reasonable path; ask when the ambiguity would make the change risky.
- For multi-step work, keep the plan minimal and tied to verifiable outcomes.
- Avoid redundant file reads, repeated commands, and unnecessary exploratory work once enough context is available.
- A good result is a minimal diff with clear assumptions, no over-engineering, and independent verification that survives Adversarial Validation (below).
## Autonomy and Approval Boundaries
- Inquiry tasks (answer, explain, review, diagnose, plan): report findings; do not change files unless a fix is explicitly requested.
- Action tasks (change, build, fix): make in-scope local changes without asking for approval.
- Ask for confirmation before destructive or hard-to-reverse operations (force-pushes, history rewrites, deleting data or branches), merging a PR (reviewer approval required), or any material expansion of the requested scope.
## Communication and Language
- Respond in the same language used by the requester.
@@ -41,14 +47,27 @@ If repo-level instructions conflict, follow the nearest file and keep behavior a
- 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.
- Mention unrelated issues when useful, but do not fix them as part of a narrow task.
## Constant and String Usage
## Reuse Before You Write
- Before introducing new string literals, search for existing constants/enums that already represent the same semantic value.
- Reuse existing constants for protocol labels, error identifiers, header keys, event names, metric names, command tags, and similar fixed tokens.
- If a new string is truly unique, define a local constant near related logic and avoid scattering the literal across multiple sites.
- When changing existing behavior, keep naming and format consistency by aligning with established project constants.
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.
- **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.
## Necessary Code Only
Net-new code — files, types, branches, comments — is cost to justify, not progress:
- 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.
- Never substitute a default where the value is required (e.g. `unwrap_or_default()` on metadata that must exist) — that converts corruption into a wrong answer. Return the typed error instead: explicit failure over implicit success.
- Attach error context once, at the layer where it is actionable: re-wrapping equivalent context at every hop is noise, and expanding a fallible chain into nested `match` blocks where `?` or a combinator suffices is a finding. Never add context by converting a typed error into a generic variant below an error-aggregation or quorum layer (`reduce_errs` classifies by variant equality) — context there belongs in a `tracing` event, not the error value.
## Sources of Truth
@@ -141,7 +160,7 @@ 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 —
correctness adversary only.
correctness and simplicity adversaries only.
- **Standard (the default):** any change that affects behavior.
- **High risk:** touches locking, erasure coding, quorum/heal, replication,
multipart, RPC, lifecycle/tiering, metadata formats (`xl.meta`),
@@ -161,9 +180,8 @@ 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). For code
diffs, a materially smaller or more idiomatic diff achieving the same
behavior is also a finding (see Change Style for Existing Logic).
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.
- **Security reviewer** — authn/authz bypass, injection, secret leakage,
untrusted deserialization (see Serde Safety), path traversal, timing leaks.
- **Concurrency/durability reviewer** — lock ordering, races, cancellation,
@@ -179,11 +197,12 @@ encode this repo's shipped bugs.
wrong while all tests stay green — if one exists, coverage is insufficient.
A missing test is a finding, not a note.
Standard tier: correctness adversary + test-coverage skeptic, plus every
role whose domain the diff touches (async or shared-state code →
concurrency; parsing of untrusted input → security; public crate API shape
→ compatibility; per-request or per-object hot paths → performance).
High risk: all six roles.
Standard tier: correctness adversary + simplicity adversary + test-coverage
skeptic, plus every role whose domain the diff touches (async or
shared-state code → concurrency; parsing of untrusted input → security;
public crate API shape → compatibility; per-request or per-object hot paths
→ performance).
High risk: all seven roles.
### Protocol
+18 -117
View File
@@ -41,7 +41,7 @@ The repository is a Cargo workspace with a flat `crates/` layout:
```
rustfs/ # Workspace root (virtual manifest)
├── rustfs/ # Main binary + library crate (75K lines)
├── rustfs/ # Main binary + library crate
│ └── src/
│ ├── main.rs # Entry point, startup sequence
│ ├── lib.rs # Module tree root
@@ -53,7 +53,7 @@ rustfs/ # Workspace root (virtual manifest)
│ ├── config/ # CLI args, config parsing, workload profiles
│ └── ...
├── crates/ # library crates (authoritative list: Cargo.toml [workspace].members)
│ ├── ecstore/ # Erasure-coded storage engine (⚠️ 87K lines)
│ ├── ecstore/ # Erasure-coded storage engine
│ ├── rio/ # Reader I/O pipeline (encrypt, compress, hash)
│ ├── io-core/ # Zero-copy I/O, scheduling, buffer pool
│ ├── io-metrics/ # I/O metrics collection
@@ -83,124 +83,25 @@ A request flows **downward** through the layers. No layer should reach upward
### Crate Reference
> Depth levels, line counts, and crate counts in this section are a
> point-in-time snapshot and drift with refactors. Treat them as orders of
> magnitude; `Cargo.toml` and `cargo tree` are the source of truth.
Crates are organized in a dependency DAG with 9 depth levels (0 = leaf, 8 = top):
```
Depth 0 — LEAF (no internal deps):
appauth, checksums, config, credentials, crypto, io-metrics,
madmin, s3-common, workers, zip
Depth 1:
io-core (→ io-metrics)
policy (→ config, credentials, crypto)
utils (historical → config edge removed; now effectively leaf)
Depth 2:
concurrency, filemeta, keystone, kms, lock, obs,
signer, targets, trusted-proxies
Depth 3:
common (historical → filemeta/madmin edges removed; now effectively leaf)
Depth 4:
object-capacity, protos, rio
Depth 5 — CORE:
ecstore (16 internal deps, 11 dependents — the architectural heart)
Depth 6:
audit, heal, iam, metrics, notify, s3select-api, scanner
Depth 7:
object-io, protocols, s3select-query
Depth 8 — TOP:
rustfs (35 internal deps — the binary, depends on almost everything)
```
`Cargo.toml` is the authoritative workspace membership and `cargo tree` is the
authoritative dependency graph. This overview deliberately avoids line-count
and dependency-depth snapshots because both quickly become stale during
refactors.
#### By Domain
**Core Infrastructure:**
| Domain | Current workspace crates | Responsibility |
|--------|--------------------------|----------------|
| Foundation | `checksums`, `common`, `config`, `data-usage`, `utils` | Shared configuration, data-usage models, utilities, and checksums. |
| I/O and storage | `concurrency`, `ecstore`, `filemeta`, `heal`, `io-core`, `io-metrics`, `lifecycle`, `lock`, `object-capacity`, `object-data-cache`, `replication`, `rio`, `rio-v2`, `scanner`, `storage-api` | Erasure-coded object storage, metadata, recovery, lifecycle, replication, locking, cache, and I/O pipelines. |
| Security and identity | `credentials`, `crypto`, `iam`, `keystone`, `kms`, `policy`, `security-governance`, `signer`, `tls-runtime`, `trusted-proxies` | Credentials, authentication, authorization, encryption, key management, TLS, and security contracts. |
| Protocols and contracts | `extension-schema`, `madmin`, `protos`, `protocols`, `s3-ops`, `s3-types`, `s3select-api`, `s3select-query` | Admin, inter-node, S3, S3 Select, and optional protocol contracts. |
| Operations and integration | `audit`, `notify`, `obs`, `targets`, `zip` | Auditing, observability, event delivery, notification targets, and archive support. |
| Test support | `e2e_test`, `test-utils` | End-to-end validation and shared test bootstrap utilities. |
| Crate | Lines | Purpose |
|-------|-------|---------|
| `config` | 3.3K | Configuration types and environment parsing |
| `utils` | 8.7K | Pure utilities (paths, compression, network, retry) |
| `common` | 4.4K | Shared runtime state, globals, data usage types, metrics |
| `madmin` | 5.5K | Admin API request/response types |
**I/O Pipeline:**
| Crate | Lines | Purpose |
|-------|-------|---------|
| `io-core` | 6.5K | Zero-copy I/O, buffer pool, direct I/O, scheduling, backpressure |
| `io-metrics` | 4.5K | I/O operation metrics and counters |
| `rio` | 6.9K | Composable reader chain (encrypt → compress → hash → limit) |
| `object-io` | 2.4K | High-level object read/write using rio + ecstore |
| `concurrency` | 0.8K | Shared concurrency contract types: workload admission snapshots, worker-slot pool, policy types (runtime control lives in `rustfs/src/storage`) |
**Storage Engine:**
| Crate | Lines | Purpose |
|-------|-------|---------|
| `ecstore` | 87K | ⚠️ Erasure-coded storage: disks, pools, buckets, replication, lifecycle |
| `filemeta` | 10K | File/object metadata types and versioning |
| `checksums` | 732 | Checksum computation |
| `lock` | 7.1K | Distributed lock manager |
| `heal` | 5.9K | Data healing / bitrot repair |
| `scanner` | 5.4K | Background data usage scanner |
| `object-capacity` | 2.5K | Capacity tracking and management |
**Security & Auth:**
| Crate | Lines | Purpose |
|-------|-------|---------|
| `crypto` | 1.6K | Encryption primitives |
| `credentials` | 713 | Credential types (access key / secret key) |
| `signer` | 1.4K | S3 v4 request signing |
| `iam` | 9.0K | Identity and access management |
| `policy` | 8.8K | Policy engine (S3 bucket/IAM policies) |
| `kms` | 8.1K | Key management service integration |
| `keystone` | 1.9K | OpenStack Keystone auth |
| `appauth` | 143 | Application-level auth tokens |
**Protocol & API:**
| Crate | Lines | Purpose |
|-------|-------|---------|
| `protos` | 5.7K | Protobuf/gRPC definitions for inter-node RPC |
| `protocols` | 18K | FTP/FTPS, WebDAV, Swift API support |
| `s3-common` | 738 | Shared S3 types |
| `s3select-api` | 1.9K | S3 Select interface |
| `s3select-query` | 3.6K | S3 Select query engine |
**Observability:**
| Crate | Lines | Purpose |
|-------|-------|---------|
| `metrics` | 8.4K | Prometheus metric collectors |
| `io-metrics` | 4.5K | I/O-specific metrics |
| `obs` | 5.6K | OpenTelemetry tracing and telemetry |
| `audit` | 2.4K | Audit logging |
**Events:**
| Crate | Lines | Purpose |
|-------|-------|---------|
| `notify` | 5.5K | Event notification system |
| `targets` | 3.2K | Notification targets (Kafka, AMQP, webhook, etc.) |
**Other:**
| Crate | Lines | Purpose |
|-------|-------|---------|
| `trusted-proxies` | 4.0K | Trusted proxy / IP forwarding |
| `zip` | 986 | ZIP archive support for bulk downloads |
| `workers` | 136 | Simple worker abstraction |
The `rustfs` binary crate composes these libraries into the running server.
`ecstore` remains the storage engine at the architectural center; its internal
module split is tracked under `docs/architecture/`.
## Architecture Invariants
@@ -212,7 +113,7 @@ Depth 8 — TOP:
No upward imports.
2. **Leaf crates have zero internal dependencies.** `config`, `credentials`, `crypto`,
`io-metrics`, `madmin`, `s3-common` should depend only on external crates.
`io-metrics`, and `madmin` should depend only on external crates.
- ✅ RESOLVED: the historical `utils → config` and `common → filemeta`/`madmin`
edges were removed; do not reintroduce them (see Known Structural Issues).
+2
View File
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **Helm Ingress**: `customAnnotations` are now merged with class-specific annotations (nginx/traefik) instead of being ignored when `ingress.className` is set.
- **Per-pool erasure parity**: Erasure parity (STANDARD and reduced-redundancy) is now resolved independently for every pool instead of reusing the first pool's value. A heterogeneous topology — for example a 4-drive pool plus a 2-drive pool created during expansion — previously inherited the first pool's parity and could resolve to zero data shards in the smaller pool, panicking Reed-Solomon construction on write. Automatic parity now resolves per pool (for example `2+2` in the 4-drive pool and `1+1` in the 2-drive pool). Fixes #4801.
### Added
- **NATS JetStream Publish Path**: Opt-in at-least-once delivery for the NATS notify and audit targets. A NATS Core publish flushes to the connection without awaiting a broker acknowledgement, so an event can be lost across a broker restart or a reconnect after the send queue has already cleared it. A queued event now clears only after the JetStream `PublishAck`, so bucket notifications survive those interruptions. Off by default and byte-identical to the NATS Core path when disabled.
@@ -38,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- **HTTP Server Stack**: Integrated `KeystoneAuthLayer` middleware from `rustfs-keystone` crate into service stack (positioned after ReadinessGateLayer)
- **Storage-class validation on startup (upgrade note)**: A persisted explicit storage class (`RUSTFS_STORAGE_CLASS_STANDARD` / `RUSTFS_STORAGE_CLASS_RRS`, for example `EC:2`) is now validated against the actual per-pool drive counts at startup and rejected when a pool cannot satisfy it. This is fail-closed and correct, but a cluster that persisted a storage class larger than a small or heterogeneous pool can hold (for example `EC:2` alongside a 2-drive pool), which earlier releases accepted and silently resolved to an invalid layout, will now refuse to start after upgrade. To recover, unset `RUSTFS_STORAGE_CLASS_STANDARD` so the server derives a valid per-pool default automatically, or set it to a value every pool can satisfy.
- **IAMAuth**: Enhanced `get_secret_key()` to return empty secret for Keystone credentials (bypasses signature validation)
- **Auth Module**: Modified `check_key_valid()` to retrieve Keystone credentials from task-local storage and determine admin status
- **`StorageBackend` trait**: extended with multipart upload methods (`create_multipart_upload`, `upload_part`, `complete_multipart_upload`, `abort_multipart_upload`) plus `upload_part_copy`. Streaming-upload code path is now available to FTPS, WebDAV, and Swift drivers as well.
Generated
+528 -410
View File
File diff suppressed because it is too large Load Diff
+91 -104
View File
@@ -31,6 +31,7 @@ members = [
"crates/lifecycle", # Lifecycle rule evaluation contracts
"crates/kms", # Key Management Service
"crates/lock", # Distributed locking implementation
"crates/log-analyzer", # Offline log fault-analysis core (rustfs diagnose)
"crates/madmin", # Management dashboard and admin API interface
"crates/notify", # Notification system for events
"crates/obs", # Observability utilities
@@ -67,8 +68,8 @@ resolver = "3"
edition = "2024"
license = "Apache-2.0"
repository = "https://github.com/rustfs/rustfs"
rust-version = "1.96.0"
version = "1.0.0-beta.10-preview.1"
rust-version = "1.97.1"
version = "1.0.0-beta.11"
homepage = "https://rustfs.com"
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
@@ -85,51 +86,52 @@ redundant_clone = "warn"
[workspace.dependencies]
# RustFS Internal Crates
rustfs = { path = "./rustfs", version = "1.0.0-beta.10-preview.1" }
rustfs-heal = { path = "crates/heal", version = "1.0.0-beta.10-preview.1" }
rustfs-audit = { path = "crates/audit", version = "1.0.0-beta.10-preview.1" }
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-beta.10-preview.1" }
rustfs-common = { path = "crates/common", version = "1.0.0-beta.10-preview.1" }
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-beta.10-preview.1" }
rustfs-config = { path = "./crates/config", version = "1.0.0-beta.10-preview.1" }
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-beta.10-preview.1" }
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-beta.10-preview.1" }
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-beta.10-preview.1" }
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-beta.10-preview.1" }
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-beta.10-preview.1" }
rustfs-iam = { path = "crates/iam", version = "1.0.0-beta.10-preview.1" }
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-beta.10-preview.1" }
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-beta.10-preview.1" }
rustfs-kms = { path = "crates/kms", version = "1.0.0-beta.10-preview.1" }
rustfs-lock = { path = "crates/lock", version = "1.0.0-beta.10-preview.1" }
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-beta.10-preview.1" }
rustfs-notify = { path = "crates/notify", version = "1.0.0-beta.10-preview.1" }
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-beta.10-preview.1" }
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-beta.10-preview.1" }
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-beta.10-preview.1" }
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-beta.10-preview.1" }
rustfs-obs = { path = "crates/obs", version = "1.0.0-beta.10-preview.1" }
rustfs-policy = { path = "crates/policy", version = "1.0.0-beta.10-preview.1" }
rustfs-protos = { path = "crates/protos", version = "1.0.0-beta.10-preview.1" }
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-beta.10-preview.1" }
rustfs-replication = { path = "crates/replication", version = "1.0.0-beta.10-preview.1" }
rustfs-rio = { path = "crates/rio", version = "1.0.0-beta.10-preview.1" }
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-beta.10-preview.1" }
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-beta.10-preview.1" }
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-beta.10-preview.1" }
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-beta.10-preview.1" }
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-beta.10-preview.1" }
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-beta.10-preview.1" }
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-beta.10-preview.1" }
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-beta.10-preview.1" }
rustfs-signer = { path = "crates/signer", version = "1.0.0-beta.10-preview.1" }
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-beta.10-preview.1" }
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-beta.10-preview.1" }
rustfs-targets = { path = "crates/targets", version = "1.0.0-beta.10-preview.1" }
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-beta.10-preview.1" }
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-beta.10-preview.1" }
rustfs-utils = { path = "crates/utils", version = "1.0.0-beta.10-preview.1" }
rustfs-zip = { path = "./crates/zip", version = "1.0.0-beta.10-preview.1" }
rustfs = { path = "./rustfs", version = "1.0.0-beta.11" }
rustfs-heal = { path = "crates/heal", version = "1.0.0-beta.11" }
rustfs-audit = { path = "crates/audit", version = "1.0.0-beta.11" }
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-beta.11" }
rustfs-common = { path = "crates/common", version = "1.0.0-beta.11" }
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-beta.11" }
rustfs-config = { path = "./crates/config", version = "1.0.0-beta.11" }
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-beta.11" }
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-beta.11" }
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-beta.11" }
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-beta.11" }
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-beta.11" }
rustfs-iam = { path = "crates/iam", version = "1.0.0-beta.11" }
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-beta.11" }
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-beta.11" }
rustfs-kms = { path = "crates/kms", version = "1.0.0-beta.11" }
rustfs-lock = { path = "crates/lock", version = "1.0.0-beta.11" }
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-beta.11" }
rustfs-notify = { path = "crates/notify", version = "1.0.0-beta.11" }
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-beta.11" }
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-beta.11" }
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-beta.11" }
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-beta.11" }
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-beta.11" }
rustfs-obs = { path = "crates/obs", version = "1.0.0-beta.11" }
rustfs-policy = { path = "crates/policy", version = "1.0.0-beta.11" }
rustfs-protos = { path = "crates/protos", version = "1.0.0-beta.11" }
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-beta.11" }
rustfs-replication = { path = "crates/replication", version = "1.0.0-beta.11" }
rustfs-rio = { path = "crates/rio", version = "1.0.0-beta.11" }
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-beta.11" }
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-beta.11" }
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-beta.11" }
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-beta.11" }
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-beta.11" }
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-beta.11" }
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-beta.11" }
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-beta.11" }
rustfs-signer = { path = "crates/signer", version = "1.0.0-beta.11" }
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-beta.11" }
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-beta.11" }
rustfs-targets = { path = "crates/targets", version = "1.0.0-beta.11" }
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-beta.11" }
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-beta.11" }
rustfs-utils = { path = "crates/utils", version = "1.0.0-beta.11" }
rustfs-zip = { path = "./crates/zip", version = "1.0.0-beta.11" }
# Async Runtime and Networking
async-channel = "2.5.0"
@@ -137,31 +139,31 @@ async_zip = { default-features = false, version = "0.0.18" }
mysql_async = { default-features = false, version = "0.37" }
async-compression = { version = "0.4.42" }
async-recursion = "1.1.1"
async-trait = "0.1.89"
async-nats = "0.49.1"
async-trait = "0.1.91"
async-nats = { version = "0.50.0", default-features = false }
axum = "0.8.9"
futures = "0.3.32"
futures-core = "0.3.32"
futures = "0.3.33"
futures-core = "0.3.33"
futures-lite = "2.6.1"
futures-util = "0.3.32"
futures-util = "0.3.33"
pollster = "1.0.1"
pulsar = { default-features = false, version = "6.8.0" }
lapin = { default-features = false, version = "4.10.0" }
hyper = { version = "1.10.1" }
hyper = { version = "1.11.0" }
hyper-rustls = { default-features = false, version = "0.27.9" }
hyper-util = { version = "0.1.20" }
http = "1.4.2"
http-body = "1.1.0"
http-body-util = "0.1.4"
minlz = "1.2.3"
reqwest = { default-features = false, version = "0.13.4" }
reqwest = "0.13.4"
rustfs-kafka-async = { version = "1.2.0" }
socket2 = { version = "0.6.5" }
tokio = { version = "1.52.3" }
tokio = { version = "1.53.1" }
tokio-rustls = { default-features = false, version = "0.26.4" }
tokio-stream = { version = "0.1.18" }
tokio-stream = { version = "0.1.19" }
tokio-test = "0.4.5"
tokio-util = { version = "0.7.18" }
tokio-util = { version = "0.7.19" }
tonic = { version = "0.14.6" }
tonic-prost = { version = "0.14.6" }
tonic-prost-build = { version = "0.14.6" }
@@ -179,8 +181,8 @@ prost = "0.14.4"
quick-xml = "0.41.0"
rmp = { version = "0.8.15" }
rmp-serde = { version = "1.3.1" }
serde = { version = "1.0.228" }
serde_json = { version = "1.0.150" }
serde = { version = "1.0.229" }
serde_json = { version = "1.0.151" }
serde_urlencoded = "0.7.1"
# Cryptography and Security
@@ -194,13 +196,13 @@ blake2 = "=0.11.0-rc.6"
chacha20poly1305 = { version = "=0.11.0" }
crc-fast = "1.10.0"
hmac = { version = "0.13.0" }
jsonwebtoken = { version = "10.4.0" }
jsonwebtoken = { version = "11.0.0" }
openidconnect = { default-features = false, version = "4.0" }
pbkdf2 = "0.13.0"
rsa = { version = "=0.10.0-rc.18" }
rustls = { default-features = false, version = "0.23.42" }
rustls-native-certs = "0.8"
rustls-pki-types = "1.15.0"
rustls-pki-types = "1.15.1"
sha1 = "0.11.0"
sha2 = "0.11.0"
subtle = "2.6"
@@ -209,8 +211,8 @@ zeroize = { version = "1.9.0" }
# Time and Date
chrono = { version = "0.4.45" }
humantime = "2.4.0"
jiff = { version = "0.2.32" }
time = { version = "0.3.53" }
jiff = { version = "0.2.35" }
time = { version = "0.3.54" }
# Database
deadpool-postgres = { version = "0.14" }
@@ -218,21 +220,22 @@ tokio-postgres = { default-features = false, version = "0.7.18" }
tokio-postgres-rustls = "0.14.0"
# Utilities and Tools
anyhow = "1.0.103"
anyhow = "1.0.104"
arc-swap = "1.9.2"
astral-tokio-tar = "0.6.3"
astral-tokio-tar = "0.6.4"
atoi = "3.1.0"
atomic_enum = "0.3.0"
aws-config = { version = "1.9.0" }
aws-config = { version = "1.10.1" }
aws-credential-types = { version = "1.3.0" }
aws-sdk-s3 = { default-features = false, version = "1.138.0" }
aws-sdk-s3 = { default-features = false, version = "1.140.0" }
aws-sdk-sts = { default-features = false, version = "1.110.0" }
aws-smithy-http-client = { default-features = false, version = "1.2.0" }
aws-smithy-runtime-api = { version = "1.13.0" }
aws-smithy-runtime-api = { version = "1.14.0" }
aws-smithy-types = { version = "1.6.1" }
base64 = "0.22.1"
base64 = "0.23.0"
base64-simd = "0.8.0"
brotli = "8.0.4"
clap = { version = "4.6.2" }
clap = { version = "4.6.4" }
const-str = { version = "1.1.0" }
convert_case = "0.11.0"
criterion = { version = "0.8" }
@@ -241,11 +244,12 @@ crossbeam-channel = "0.5.16"
crossbeam-deque = "0.8.7"
crossbeam-utils = "0.8.22"
datafusion = { default-features = false, git = "https://github.com/apache/datafusion.git", rev = "dae03ee062b2abf986de8df12ea82fb1578a2d99" }
#datafusion = { default-features = false, version = "54.1.0" }
derive_builder = "0.20.2"
enumset = "1.1.13"
enumset = "1.1.14"
faster-hex = "0.10.0"
flate2 = "1.1.9"
glob = "0.3.3"
glob = "0.3.4"
google-cloud-storage = "1.16.0"
google-cloud-auth = "1.14.0"
hashbrown = { version = "0.17.1" }
@@ -254,7 +258,7 @@ hex-simd = "0.8.0"
highway = { version = "1.3.0" }
ipnetwork = { version = "0.21.1" }
lazy_static = "1.5.0"
libc = "0.2.186"
libc = "0.2.189"
libsystemd = "0.7.2"
local-ip-address = "0.6.13"
memmap2 = "0.9.11"
@@ -276,17 +280,16 @@ pretty_assertions = "1.4.1"
rand = { version = "0.10.2" }
ratelimit = "0.10.1"
rayon = "1.12.0"
reed-solomon-erasure = { package = "rustfs-erasure-codec", version = "8.0.0" }
#reed-solomon-erasure = { version = "6.0", features = ["simd-accel"], git = "https://github.com/houseme/reed-solomon-erasure",rev = "main" }
reed-solomon-erasure = { package = "rustfs-erasure-codec", version = "8.0.2" }
reed-solomon-simd = "3.1.0"
regex = { version = "1.13.1" }
rumqttc = { package = "rumqttc-next", version = "0.33.2" }
redis = { version = "1.4.0" }
rumqttc = { package = "rumqttc-next", version = "0.33.3" }
redis = { version = "1.4.1" }
rustix = { version = "1.1.4" }
rust-embed = { version = "8.12.0" }
rustc-hash = { version = "2.1.3" }
s3s = { git = "https://github.com/s3s-project/s3s.git", rev = "ce69c3f10824535c7c24b2f71cdb2aaa4dffb5e0" }
serial_test = "3.5.0"
s3s = { git = "https://github.com/cxymds/s3s.git", rev = "fe3941d91fa1c69956f209a9145995c9f0235bff" }
serial_test = "4.0.1"
shadow-rs = { default-features = false, version = "2.0.0" }
siphasher = "1.0.3"
smallvec = { version = "1.15.2" }
@@ -298,7 +301,7 @@ sysinfo = "0.39.6"
temp-env = "0.3.6"
tempfile = "3.27.0"
test-case = "3.3.1"
thiserror = "2.0.18"
thiserror = "2.0.19"
tracing = { version = "0.1.44" }
tracing-appender = "0.2.5"
tracing-error = "0.2.1"
@@ -309,9 +312,10 @@ url = "2.5.8"
urlencoding = "2.1.3"
uuid = { version = "1.24.0" }
vaultrs = { version = "0.8.0" }
tar = "0.4.46"
walkdir = "2.5.0"
windows = { version = "0.62.2" }
xxhash-rust = { version = "0.8.17" }
xxhash-rust = { version = "0.8.18" }
zip = "8.6.0"
zstd = "0.13.3"
@@ -321,25 +325,26 @@ dial9-tokio-telemetry = "0.3"
opentelemetry = { version = "0.32.0" }
opentelemetry-appender-tracing = { version = "0.32.0" }
opentelemetry-otlp = { version = "0.32.0" }
opentelemetry-proto = { version = "0.32.0", default-features = false, features = ["metrics", "gen-tonic-messages"] }
opentelemetry_sdk = { version = "0.32.1" }
opentelemetry-semantic-conventions = { version = "0.32.1" }
opentelemetry-stdout = { version = "0.32.0" }
pyroscope = { version = "2.1.0" }
pyroscope = { version = "2.1.1" }
# FTP and SFTP
libunftp = { version = "0.23.0" }
unftp-core = "0.1.0"
suppaftp = { version = "10.0.1" }
rcgen = "0.14.8"
russh = { version = "0.62.2" }
rcgen = { version = "0.14.8", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
russh = { version = "0.62.4" }
russh-sftp = "2.3.0"
# WebDAV
dav-server = "0.11.0"
# Performance Analysis and Memory Profiling
mimalloc = "0.1"
hotpath = "0.21"
mimalloc = "0.1.52"
hotpath = "0.22.0"
# Snapshot testing for output format regression detection
insta = { version = "1.48" }
@@ -368,21 +373,3 @@ inherits = "release"
inherits = "release"
debug = true
strip = "none"
# Pin hyper to a revision that carries the HTTP/1 "flush buffered data before
# shutdown" fix (hyperium/hyper#4018, commit 72046cc7). This lands as a
# `[patch.crates-io]` entry — not on the `hyper` workspace dependency — so that
# every consumer in the tree, including the transitive `hyper-util` server path
# (`conn::auto` / `GracefulShutdown`) that actually drives our connections,
# resolves to the fixed hyper rather than the buggy crates.io copy.
#
# hyper <= 1.10.1 can call `poll_shutdown()` on the socket while response bytes
# are still buffered (a prior `poll_flush()` returned `Poll::Pending` and the
# result was discarded). A backpressured / slow-reading peer then receives a
# graceful FIN before the full Content-Length body is flushed, which standard S3
# clients (minio-go / warp) report as `unexpected EOF` on large-object GET under
# load. The fix is not in any crates.io release yet as of hyper 1.10.1; drop
# this patch once a released version (> 1.10.1) contains commit 72046cc7.
# See rustfs/backlog#1232.
[patch.crates-io]
hyper = { git = "https://github.com/hyperium/hyper.git", rev = "ccc1e850dc0cda3e71b0acd11f60ca3d48d09034" }
+5 -3
View File
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
FROM alpine:3.23.4 AS build
FROM alpine:3.24.1 AS build
ARG TARGETARCH
ARG RELEASE=latest
@@ -70,7 +70,7 @@ RUN set -eux; \
rm -rf rustfs.zip /build/.tmp || true
FROM alpine:3.23.4
FROM alpine:3.24.1
ARG RELEASE=latest
ARG BUILD_DATE
@@ -88,7 +88,9 @@ LABEL name="RustFS" \
url="https://rustfs.com" \
license="Apache-2.0"
RUN apk update && \
# 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
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
+1 -1
View File
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
FROM rust:1.97-trixie
FROM rust:1.97.1-trixie
RUN set -eux; \
export DEBIAN_FRONTEND=noninteractive; \
+6 -3
View File
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
FROM ubuntu:24.04 AS build
FROM ubuntu:26.04 AS build
ARG TARGETARCH
ARG RELEASE=latest
@@ -76,7 +76,7 @@ RUN set -eux; \
chmod +x /build/rustfs; \
rm -rf rustfs.zip /build/.tmp || true
FROM ubuntu:24.04
FROM ubuntu:26.04
ARG RELEASE=latest
ARG BUILD_DATE
@@ -93,7 +93,10 @@ LABEL name="RustFS" \
url="https://rustfs.com" \
license="Apache-2.0"
RUN apt-get update && apt-get install -y --no-install-recommends \
# 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 \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
+3 -2
View File
@@ -32,7 +32,7 @@ ARG RUSTFS_BUILD_FEATURES=""
# -----------------------------
# Build stage
# -----------------------------
FROM rust:1.97-trixie AS builder
FROM rust:1.97.1-trixie AS builder
# Re-declare args after FROM
ARG TARGETPLATFORM
@@ -208,7 +208,7 @@ CMD ["cargo", "run", "--bin", "rustfs", "--"]
# -----------------------------
# Runtime stage (Ubuntu minimal)
# -----------------------------
FROM ubuntu:24.04
FROM ubuntu:26.04
ARG BUILD_DATE
ARG VCS_REF
@@ -223,6 +223,7 @@ LABEL name="RustFS (dev-local)" \
RUN set -eux; \
export DEBIAN_FRONTEND=noninteractive; \
apt-get update; \
apt-get upgrade -y; \
apt-get install -y --no-install-recommends \
ca-certificates \
curl \
+20 -5
View File
@@ -116,7 +116,7 @@ chown -R 10001:10001 data logs
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# Using specific version
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.10-preview.1
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.11
```
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
@@ -163,6 +163,7 @@ docker run -d --name rustfs -p 9000:9000 \
-e RUSTFS_NOTIFY_WEBHOOK_ENABLE_PRIMARY=on \
-e RUSTFS_NOTIFY_WEBHOOK_ENDPOINT_PRIMARY=http://<host-ip>:3020/webhook \
-e RUSTFS_NOTIFY_WEBHOOK_QUEUE_DIR_PRIMARY=/tmp/rustfs-events \
-e RUSTFS_OUTBOUND_ALLOW_ORIGINS=http://<host-ip>:3020 \
rustfs/rustfs:latest
```
@@ -171,6 +172,11 @@ Notes:
- For ARN `arn:rustfs:sqs::primary:webhook`, use instance-scoped env vars with `_PRIMARY`.
- If queue dir is omitted, default is `/opt/rustfs/events`; ensure it is writable by the container runtime user.
- `RUSTFS_NOTIFY_WEBHOOK_SKIP_TLS_VERIFY_PRIMARY` defaults to `false`; enabling it skips webhook TLS certificate verification, allows MITM attacks, and emits a startup warning. Prefer `RUSTFS_NOTIFY_WEBHOOK_CLIENT_CA_PRIMARY` for private CAs.
- Since `1.0.0-beta.11`, webhook endpoints on private or container networks
(`Docker Compose service names`, `host.docker.internal`, RFC 1918 addresses) are
blocked unless their exact `scheme://host:port` origin is listed in
`RUSTFS_OUTBOUND_ALLOW_ORIGINS` (the origin only, without the path). See
[Outbound Connection Policy](docs/operations/outbound-connection-policy.md).
**NOTE**: We recommend reviewing the `docker-compose.yml` file before running. It defines several services including Grafana, Prometheus, and Jaeger, which are helpful for RustFS observability. If you wish to start Redis or Nginx containers, you can specify the corresponding profiles.
@@ -218,7 +224,10 @@ For scanner pacing, cycle budgets, bitrot cadence, lifecycle transition status,
and single-node single-disk idle CPU tuning, see
[Scanner Runtime Controls](docs/operations/scanner-runtime-controls.md). For
repeatable scanner-pressure validation, see
[Scanner Benchmark Runbook](docs/operations/scanner-benchmark-runbook.md).
[Scanner Benchmark Runbook](docs/operations/scanner-benchmark-runbook.md). For
drive timeout knobs on slow storage — including the walk stall budget that
governs `ListObjects` on large prefixes — see
[Drive Timeout Tuning](docs/operations/drive-timeout-tuning.md).
### 5\. Nix Flake (Option 5)
@@ -259,7 +268,7 @@ rustfs --help
2. **Create a Bucket**: Use the console to create a new bucket for your objects.
3. **Upload Objects**: You can upload files directly through the console or use S3-compatible APIs/clients to interact with your RustFS instance.
**NOTE**: To access the RustFS instance via `https`, please refer to the [TLS Configuration Docs](https://docs.rustfs.com/integration/tls-configured.html).
**NOTE**: To access the RustFS instance via `https`, please refer to the [TLS Configuration Docs](https://docs.rustfs.com/integration/tls-configured).
### OIDC Roles Claim (Microsoft Entra ID)
@@ -335,12 +344,18 @@ If you have any questions or need assistance:
RustFS is a community-driven project, and we appreciate all contributions. Check out the [Contributors](https://github.com/rustfs/rustfs/graphs/contributors) page to see the amazing people who have helped make RustFS better.
<a href="https://github.com/rustfs/rustfs/graphs/contributors">
<img src="https://opencollective.com/rustfs/contributors.svg?width=890&limit=500&button=false" alt="Contributors" />
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/rustfs/rustfs/star-history/contributors-dark.svg">
<img src="https://raw.githubusercontent.com/rustfs/rustfs/star-history/contributors-light.svg" alt="RustFS contributors">
</picture>
</a>
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=rustfs/rustfs&type=date&legend=top-left)](https://www.star-history.com/#rustfs/rustfs&type=date&legend=top-left)
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/rustfs/rustfs/star-history/star-history-dark.svg">
<img src="https://raw.githubusercontent.com/rustfs/rustfs/star-history/star-history-light.svg" alt="RustFS star history chart">
</picture>
## License
+10 -4
View File
@@ -113,7 +113,7 @@ chown -R 10001:10001 data logs
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# 使用指定版本运行
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.10-preview.1
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.11
```
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
@@ -214,7 +214,7 @@ rustfs --help
2. **创建存储桶**: 使用控制台为您​​的对象创建一个新的存储桶 (Bucket)。
3. **上传对象**: 您可以直接通过控制台上传文件,或使用 S3 兼容的 API/客户端与您的 RustFS 实例进行交互。
**注意**: 如果您希望通过 `https` 访问 RustFS 实例,请参考 [TLS 配置文档](https://docs.rustfs.com/integration/tls-configured.html)。
**注意**: 如果您希望通过 `https` 访问 RustFS 实例,请参考 [TLS 配置文档](https://docs.rustfs.com/integration/tls-configured)。
## 文档
@@ -247,12 +247,18 @@ rustfs --help
RustFS 是一个社区驱动的项目,我们感谢所有的贡献。请查看 [贡献者](https://github.com/rustfs/rustfs/graphs/contributors) 页面,看看那些让 RustFS 变得更好的了不起的人们。
<a href="https://github.com/rustfs/rustfs/graphs/contributors">
<img src="https://opencollective.com/rustfs/contributors.svg?width=890&limit=500&button=false" alt="Contributors" />
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/rustfs/rustfs/star-history/contributors-dark.svg">
<img src="https://raw.githubusercontent.com/rustfs/rustfs/star-history/contributors-light.svg" alt="RustFS 贡献者">
</picture>
</a>
## Star 历史
[![Star History Chart](https://api.star-history.com/svg?repos=rustfs/rustfs&type=date&legend=top-left)](https://www.star-history.com/#rustfs/rustfs&type=date&legend=top-left)
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/rustfs/rustfs/star-history/star-history-dark.svg">
<img src="https://raw.githubusercontent.com/rustfs/rustfs/star-history/star-history-light.svg" alt="RustFS Star 历史图表">
</picture>
## 许可证
+32 -3
View File
@@ -292,8 +292,8 @@ impl AuditPipeline {
}
pub async fn snapshot_target_health(&self) -> Vec<rustfs_targets::RuntimeTargetHealthSnapshot> {
let registry = self.registry.lock().await;
registry.runtime_manager().health_snapshots().await
let targets = self.registry.lock().await.list_target_values();
rustfs_targets::health_snapshots_for_targets(targets).await
}
}
@@ -570,7 +570,7 @@ mod tests {
use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
use rustfs_targets::{StoreError, Target, TargetError};
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::sync::{Mutex, Notify};
/// Mock target whose `save()` outcome is fixed at construction so tests can
/// force full-success / full-failure / partial-failure fan-outs.
@@ -578,6 +578,7 @@ mod tests {
struct MockTarget {
id: TargetID,
fail: bool,
health_gate: Option<(Arc<Notify>, Arc<Notify>)>,
}
impl MockTarget {
@@ -585,8 +586,14 @@ mod tests {
Self {
id: TargetID::new(id.to_string(), "webhook".to_string()),
fail,
health_gate: None,
}
}
fn with_health_gate(mut self, started: Arc<Notify>, release: Arc<Notify>) -> Self {
self.health_gate = Some((started, release));
self
}
}
#[async_trait]
@@ -599,6 +606,10 @@ mod tests {
}
async fn is_active(&self) -> Result<bool, TargetError> {
if let Some((started, release)) = &self.health_gate {
started.notify_one();
release.notified().await;
}
Ok(true)
}
@@ -673,6 +684,24 @@ mod tests {
pipeline.dispatch(entry()).await.expect("no targets should return Ok");
}
#[tokio::test]
async fn health_probe_does_not_hold_the_registry_lock() {
let started = Arc::new(Notify::new());
let release = Arc::new(Notify::new());
let pipeline = pipeline_with(vec![MockTarget::new("blocked", false).with_health_gate(started.clone(), release.clone())]);
let registry = Arc::clone(&pipeline.registry);
let snapshot_task = tokio::spawn(async move { pipeline.snapshot_target_health().await });
started.notified().await;
let guard = tokio::time::timeout(std::time::Duration::from_secs(1), registry.lock())
.await
.expect("network health probe must not retain the audit registry lock");
drop(guard);
release.notify_one();
assert_eq!(snapshot_task.await.expect("snapshot task should finish").len(), 1);
}
// backlog#962: dispatch_batch must mirror dispatch and propagate a
// whole-batch loss instead of returning Ok.
#[tokio::test]
+9
View File
@@ -54,6 +54,15 @@ pub async fn get_global_local_node_name() -> String {
GLOBAL_LOCAL_NODE_NAME.read().await.clone()
}
/// Read the local node name without waiting for initialization or a writer.
pub fn try_get_global_local_node_name() -> Option<String> {
GLOBAL_LOCAL_NODE_NAME
.try_read()
.ok()
.map(|name| name.clone())
.filter(|name| !name.is_empty())
}
/// Set the global RustFS initialization time to the current UTC time.
pub async fn set_global_init_time_now() {
let now = Utc::now();
+60 -8
View File
@@ -243,6 +243,19 @@ pub enum HealAdmissionResult {
Dropped(HealAdmissionDropReason),
}
/// Admission decision together with the canonical task identifier.
///
/// A merged request must return the identifier of the task that already owns
/// the work instead of exposing the discarded request identifier as a new
/// client token.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HealAdmissionReceipt {
/// Admission decision for the submitted request.
pub result: HealAdmissionResult,
/// Canonical identifier of the accepted or merged task.
pub task_id: String,
}
impl HealAdmissionResult {
pub fn result_label(self) -> &'static str {
match self {
@@ -382,8 +395,25 @@ pub type HealChannelSender = mpsc::UnboundedSender<HealChannelCommand>;
/// Heal channel receiver
pub type HealChannelReceiver = mpsc::UnboundedReceiver<HealChannelCommand>;
/// Canonical-receipt start command kept separate from the legacy public enum.
#[derive(Debug)]
pub struct HealReceiptCommand {
/// Heal request to admit.
pub request: HealChannelRequest,
/// Completion channel for the admission receipt.
pub response_tx: oneshot::Sender<Result<HealAdmissionReceipt, String>>,
}
/// Canonical-receipt command receiver.
pub type HealReceiptReceiver = mpsc::UnboundedReceiver<HealReceiptCommand>;
struct HealChannelSenders {
command: HealChannelSender,
receipt: mpsc::UnboundedSender<HealReceiptCommand>,
}
/// Global heal channel sender
static GLOBAL_HEAL_CHANNEL_SENDER: OnceLock<HealChannelSender> = OnceLock::new();
static GLOBAL_HEAL_CHANNEL_SENDERS: OnceLock<HealChannelSenders> = OnceLock::new();
type HealResponseSender = broadcast::Sender<HealChannelResponse>;
@@ -392,17 +422,24 @@ static GLOBAL_HEAL_RESPONSE_SENDER: OnceLock<HealResponseSender> = OnceLock::new
/// Initialize global heal channel
pub fn init_heal_channel() -> Result<HealChannelReceiver, &'static str> {
let (tx, rx) = mpsc::unbounded_channel();
if GLOBAL_HEAL_CHANNEL_SENDER.set(tx).is_ok() {
Ok(rx)
} else {
Err("Heal channel sender already initialized")
}
let (receiver, receipt_receiver) = init_heal_channels()?;
drop(receipt_receiver);
Ok(receiver)
}
/// Initialize the legacy command and canonical-receipt channels atomically.
pub fn init_heal_channels() -> Result<(HealChannelReceiver, HealReceiptReceiver), &'static str> {
let (command, command_receiver) = mpsc::unbounded_channel();
let (receipt, receipt_receiver) = mpsc::unbounded_channel();
GLOBAL_HEAL_CHANNEL_SENDERS
.set(HealChannelSenders { command, receipt })
.map_err(|_| "Heal channel sender already initialized")?;
Ok((command_receiver, receipt_receiver))
}
/// Get global heal channel sender
pub fn get_heal_channel_sender() -> Option<&'static HealChannelSender> {
GLOBAL_HEAL_CHANNEL_SENDER.get()
GLOBAL_HEAL_CHANNEL_SENDERS.get().map(|senders| &senders.command)
}
/// Send heal command through global channel
@@ -436,6 +473,21 @@ pub fn subscribe_heal_responses() -> broadcast::Receiver<HealChannelResponse> {
heal_response_sender().subscribe()
}
/// Send heal start request and wait for structured admission feedback.
pub async fn send_heal_request_with_receipt(request: HealChannelRequest) -> Result<HealAdmissionReceipt, String> {
let (response_tx, response_rx) = oneshot::channel();
let senders = GLOBAL_HEAL_CHANNEL_SENDERS
.get()
.ok_or_else(|| "Heal channel not initialized".to_string())?;
senders
.receipt
.send(HealReceiptCommand { request, response_tx })
.map_err(|err| format!("Failed to send heal receipt command: {err}"))?;
response_rx
.await
.map_err(|e| format!("Failed to receive heal admission response: {e}"))?
}
/// Send heal start request and wait for structured admission feedback.
pub async fn send_heal_request_with_admission(request: HealChannelRequest) -> Result<HealAdmissionResult, String> {
let (response_tx, response_rx) = oneshot::channel();
+188 -17
View File
@@ -768,6 +768,7 @@ pub struct Metrics {
last_scan_cycle_replication_checks: AtomicU64,
last_scan_cycle_usage_saves: AtomicU64,
failed_scan_cycles: AtomicU64,
superseded_scan_cycles: AtomicU64,
partial_scan_cycles_unknown: AtomicU64,
partial_scan_cycles_runtime: AtomicU64,
partial_scan_cycles_objects: AtomicU64,
@@ -785,6 +786,9 @@ pub struct Metrics {
scanner_expiry_queue_missed: AtomicU64,
scanner_expiry_queued_total: AtomicU64,
scanner_expiry_missed_total: AtomicU64,
scanner_expiry_blocked_total: AtomicU64,
scanner_expiry_not_enqueued_total: AtomicU64,
scanner_expiry_delete_failed_total: AtomicU64,
scanner_transition_queue_capacity: AtomicU64,
scanner_transition_queued: AtomicU64,
scanner_transition_active: AtomicU64,
@@ -833,10 +837,12 @@ const SCAN_CYCLE_RESULT_UNKNOWN: u8 = 0;
const SCAN_CYCLE_RESULT_SUCCESS: u8 = 1;
const SCAN_CYCLE_RESULT_ERROR: u8 = 2;
const SCAN_CYCLE_RESULT_PARTIAL: u8 = 3;
const SCAN_CYCLE_RESULT_SUPERSEDED: u8 = 4;
const SCAN_CYCLE_RESULT_UNKNOWN_LABEL: &str = "unknown";
const SCAN_CYCLE_RESULT_SUCCESS_LABEL: &str = "success";
const SCAN_CYCLE_RESULT_ERROR_LABEL: &str = "error";
const SCAN_CYCLE_RESULT_PARTIAL_LABEL: &str = "partial";
const SCAN_CYCLE_RESULT_SUPERSEDED_LABEL: &str = "superseded";
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ScanCyclePartialReason {
@@ -1048,6 +1054,12 @@ pub struct ScannerLifecycleExpirySnapshot {
pub queue_missed: u64,
pub scanner_queued: u64,
pub scanner_missed: u64,
#[serde(default)]
pub scanner_blocked: u64,
#[serde(default)]
pub scanner_not_enqueued: u64,
#[serde(default)]
pub delete_failed: u64,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -1208,6 +1220,8 @@ pub struct ScannerMetricsReport {
pub last_cycle_usage_saves: u64,
pub failed_cycles: u64,
#[serde(default)]
pub superseded_cycles: u64,
#[serde(default)]
pub partial_cycles_unknown: u64,
#[serde(default)]
pub partial_cycles_runtime: u64,
@@ -1310,6 +1324,7 @@ fn scan_cycle_result_label(result: u8) -> &'static str {
SCAN_CYCLE_RESULT_SUCCESS => SCAN_CYCLE_RESULT_SUCCESS_LABEL,
SCAN_CYCLE_RESULT_ERROR => SCAN_CYCLE_RESULT_ERROR_LABEL,
SCAN_CYCLE_RESULT_PARTIAL => SCAN_CYCLE_RESULT_PARTIAL_LABEL,
SCAN_CYCLE_RESULT_SUPERSEDED => SCAN_CYCLE_RESULT_SUPERSEDED_LABEL,
_ => SCAN_CYCLE_RESULT_UNKNOWN_LABEL,
}
}
@@ -1633,6 +1648,11 @@ pub fn emit_scan_cycle_partial_with_source(
metrics::counter!(OTEL_SCANNER_CYCLES, "result" => SCAN_CYCLE_RESULT_PARTIAL_LABEL).increment(1);
}
pub fn emit_scan_cycle_superseded(duration: Duration) {
global_metrics().record_scan_cycle_superseded(duration);
metrics::counter!(OTEL_SCANNER_CYCLES, "result" => SCAN_CYCLE_RESULT_SUPERSEDED_LABEL).increment(1);
}
pub fn emit_scan_bucket_drive_complete(success: bool, bucket: &str, disk: &str, duration: Duration) {
let result = if success { "success" } else { "error" };
metrics::counter!(
@@ -1726,6 +1746,7 @@ impl Metrics {
last_scan_cycle_replication_checks: AtomicU64::new(0),
last_scan_cycle_usage_saves: AtomicU64::new(0),
failed_scan_cycles: AtomicU64::new(0),
superseded_scan_cycles: AtomicU64::new(0),
partial_scan_cycles_unknown: AtomicU64::new(0),
partial_scan_cycles_runtime: AtomicU64::new(0),
partial_scan_cycles_objects: AtomicU64::new(0),
@@ -1743,6 +1764,9 @@ impl Metrics {
scanner_expiry_queue_missed: AtomicU64::new(0),
scanner_expiry_queued_total: AtomicU64::new(0),
scanner_expiry_missed_total: AtomicU64::new(0),
scanner_expiry_blocked_total: AtomicU64::new(0),
scanner_expiry_not_enqueued_total: AtomicU64::new(0),
scanner_expiry_delete_failed_total: AtomicU64::new(0),
scanner_transition_queue_capacity: AtomicU64::new(0),
scanner_transition_queued: AtomicU64::new(0),
scanner_transition_active: AtomicU64::new(0),
@@ -1973,9 +1997,18 @@ impl Metrics {
self.scanner_expiry_queued_total.fetch_add(count, Ordering::Relaxed);
} else {
self.scanner_expiry_missed_total.fetch_add(count, Ordering::Relaxed);
self.scanner_expiry_not_enqueued_total.fetch_add(count, Ordering::Relaxed);
}
}
pub fn record_scanner_expiry_blocked(&self, count: u64) {
self.scanner_expiry_blocked_total.fetch_add(count, Ordering::Relaxed);
}
pub fn record_scanner_expiry_delete_failed(&self, count: u64) {
self.scanner_expiry_delete_failed_total.fetch_add(count, Ordering::Relaxed);
}
pub fn record_scanner_transition_enqueue_result(&self, count: u64, queued: bool) {
self.record_scanner_ilm_enqueue_result(count, queued);
if queued {
@@ -2349,6 +2382,18 @@ impl Metrics {
.store(duration_millis_saturated(duration), Ordering::Relaxed);
}
pub fn record_scan_cycle_superseded(&self, duration: Duration) {
self.record_scanner_cycle_end_time();
self.superseded_scan_cycles.fetch_add(1, Ordering::Relaxed);
self.last_scan_cycle_result
.store(SCAN_CYCLE_RESULT_SUPERSEDED, Ordering::Relaxed);
self.last_scan_cycle_partial_reason
.store(ScanCyclePartialReason::Unknown as u8, Ordering::Relaxed);
self.last_scan_cycle_partial_source.store(0, Ordering::Relaxed);
self.last_scan_cycle_duration_millis
.store(duration_millis_saturated(duration), Ordering::Relaxed);
}
pub fn record_scan_cycle_partial(&self, duration: Duration, reason: ScanCyclePartialReason) {
self.record_scan_cycle_partial_with_source(duration, reason, None);
}
@@ -2434,6 +2479,18 @@ impl Metrics {
self.current_scan_cycle_work_active.store(false, Ordering::Relaxed);
}
pub fn current_scan_cycle_has_unresolved_heal_work(&self) -> bool {
if !self.current_scan_cycle_work_active.load(Ordering::Relaxed) {
return false;
}
let source_work = self.scanner_source_work_since(&self.current_scan_cycle_source_work_start_values());
[ScannerWorkSource::Heal, ScannerWorkSource::Bitrot]
.into_iter()
.filter_map(|source| source_work.get(source.index()))
.any(|work| work.queued > 0 || work.skipped > 0 || work.failed > 0 || work.missed > 0)
}
fn scan_cycle_work_snapshot(&self) -> ScanCycleWorkSnapshot {
ScanCycleWorkSnapshot {
objects_scanned: self.lifetime(Metric::ScanObject),
@@ -2790,6 +2847,7 @@ impl Metrics {
m.last_cycle_replication_repair =
self.scanner_replication_repair_work_counter_snapshots(&self.last_scan_cycle_replication_repair_work);
m.failed_cycles = self.failed_scan_cycles.load(Ordering::Relaxed);
m.superseded_cycles = self.superseded_scan_cycles.load(Ordering::Relaxed);
m.partial_cycles_unknown = self.partial_scan_cycles_unknown.load(Ordering::Relaxed);
m.partial_cycles_runtime = self.partial_scan_cycles_runtime.load(Ordering::Relaxed);
m.partial_cycles_objects = self.partial_scan_cycles_objects.load(Ordering::Relaxed);
@@ -2813,6 +2871,9 @@ impl Metrics {
queue_missed: self.scanner_expiry_queue_missed.load(Ordering::Relaxed),
scanner_queued: self.scanner_expiry_queued_total.load(Ordering::Relaxed),
scanner_missed: self.scanner_expiry_missed_total.load(Ordering::Relaxed),
scanner_blocked: self.scanner_expiry_blocked_total.load(Ordering::Relaxed),
scanner_not_enqueued: self.scanner_expiry_not_enqueued_total.load(Ordering::Relaxed),
delete_failed: self.scanner_expiry_delete_failed_total.load(Ordering::Relaxed),
};
m.lifecycle_transition = ScannerLifecycleTransitionSnapshot {
current_queue_capacity: self.scanner_transition_queue_capacity.load(Ordering::Relaxed),
@@ -2938,19 +2999,15 @@ pub type CloseDiskFn = Arc<dyn Fn() -> Pin<Box<dyn Future<Output = ()> + Send>>
/// Register a new disk in the global path tracker and return two callbacks:
/// one to update the current path and one to deregister the disk when done.
pub fn current_path_updater(disk: &str, initial: &str) -> (UpdateCurrentPathFn, CloseDiskFn) {
pub async fn current_path_updater(disk: &str, initial: &str) -> (UpdateCurrentPathFn, CloseDiskFn) {
let tracker = Arc::new(CurrentPathTracker::new(initial.to_string()));
let disk_name = disk.to_string();
let tracker_clone = Arc::clone(&tracker);
let disk_insert = disk_name.clone();
tokio::spawn(async move {
global_metrics()
.current_paths
.write()
.await
.insert(disk_insert, tracker_clone);
});
global_metrics()
.current_paths
.write()
.await
.insert(disk_name.clone(), Arc::clone(&tracker));
let update_fn: UpdateCurrentPathFn = {
let tracker = Arc::clone(&tracker);
@@ -2978,23 +3035,28 @@ pub fn current_path_updater(disk: &str, initial: &str) -> (UpdateCurrentPathFn,
// CloseDiskGuard
// ---------------------------------------------------------------------------
pub struct CloseDiskGuard(CloseDiskFn);
pub struct CloseDiskGuard(Option<CloseDiskFn>);
impl CloseDiskGuard {
pub fn new(close_disk: CloseDiskFn) -> Self {
Self(close_disk)
Self(Some(close_disk))
}
pub async fn close(&self) {
self.0().await;
pub async fn close(&mut self) {
let Some(close_disk) = self.0.clone() else {
return;
};
close_disk().await;
self.0 = None;
}
}
impl Drop for CloseDiskGuard {
fn drop(&mut self) {
if let Ok(handle) = tokio::runtime::Handle::try_current() {
let close_fn = self.0.clone();
handle.spawn(async move { close_fn().await });
if let Some(close_disk) = self.0.take()
&& let Ok(handle) = tokio::runtime::Handle::try_current()
{
handle.spawn(close_disk());
}
// If there is no runtime we are in a test or shutdown path; skip cleanup.
}
@@ -3004,6 +3066,61 @@ impl Drop for CloseDiskGuard {
mod tests {
use super::*;
#[tokio::test]
async fn close_disk_guard_runs_cleanup_when_an_early_return_drops_it() {
let (closed_tx, closed_rx) = tokio::sync::oneshot::channel();
let closed_tx = Arc::new(std::sync::Mutex::new(Some(closed_tx)));
let close_disk: CloseDiskFn = {
let closed_tx = Arc::clone(&closed_tx);
Arc::new(move || {
let closed_tx = closed_tx.lock().expect("close callback lock").take();
Box::pin(async move {
if let Some(closed_tx) = closed_tx {
let _ = closed_tx.send(());
}
})
})
};
let guard = CloseDiskGuard::new(close_disk);
drop(guard);
tokio::time::timeout(std::time::Duration::from_secs(1), closed_rx)
.await
.expect("drop cleanup should run")
.expect("drop cleanup should signal");
}
#[tokio::test]
async fn close_disk_guard_runs_explicit_cleanup_once() {
let close_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let close_disk: CloseDiskFn = {
let close_count = Arc::clone(&close_count);
Arc::new(move || {
close_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Box::pin(std::future::ready(()))
})
};
let mut guard = CloseDiskGuard::new(close_disk);
guard.close().await;
drop(guard);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
assert_eq!(close_count.load(std::sync::atomic::Ordering::Relaxed), 1);
}
#[tokio::test]
async fn current_path_updater_registers_before_return() {
let disk = format!("test-disk-{}", uuid::Uuid::new_v4());
let (_update_path, close_disk) = current_path_updater(&disk, "bucket-a").await;
assert!(global_metrics().current_paths.read().await.contains_key(&disk));
close_disk().await;
assert!(!global_metrics().current_paths.read().await.contains_key(&disk));
}
#[tokio::test]
async fn report_counts_active_scan_paths() {
let metrics = Metrics::new();
@@ -3292,6 +3409,8 @@ mod tests {
});
metrics.record_scanner_expiry_enqueue_result(6, true);
metrics.record_scanner_expiry_enqueue_result(2, false);
metrics.record_scanner_expiry_blocked(4);
metrics.record_scanner_expiry_delete_failed(1);
let report = metrics.report().await;
@@ -3302,6 +3421,9 @@ mod tests {
assert_eq!(report.lifecycle_expiry.queue_missed, 3);
assert_eq!(report.lifecycle_expiry.scanner_queued, 6);
assert_eq!(report.lifecycle_expiry.scanner_missed, 2);
assert_eq!(report.lifecycle_expiry.scanner_blocked, 4);
assert_eq!(report.lifecycle_expiry.scanner_not_enqueued, 2);
assert_eq!(report.lifecycle_expiry.delete_failed, 1);
}
#[tokio::test]
@@ -3361,6 +3483,40 @@ mod tests {
metrics.finish_scan_cycle_work(start);
}
#[test]
fn unresolved_heal_work_only_reflects_the_active_cycle() {
let metrics = Metrics::new();
assert!(!metrics.current_scan_cycle_has_unresolved_heal_work());
let start = metrics.start_scan_cycle_work();
metrics.record_scanner_source_missed(ScannerWorkSource::Heal, 1);
assert!(metrics.current_scan_cycle_has_unresolved_heal_work());
metrics.finish_scan_cycle_work(start);
assert!(!metrics.current_scan_cycle_has_unresolved_heal_work());
let start = metrics.start_scan_cycle_work();
metrics.record_scanner_source_queued(ScannerWorkSource::Heal, 1);
assert!(metrics.current_scan_cycle_has_unresolved_heal_work());
metrics.finish_scan_cycle_work(start);
let start = metrics.start_scan_cycle_work();
metrics.record_scanner_source_work(
ScannerWorkSource::Bitrot,
ScannerSourceWorkUpdate {
skipped: 1,
..Default::default()
},
);
assert!(metrics.current_scan_cycle_has_unresolved_heal_work());
metrics.finish_scan_cycle_work(start);
let start = metrics.start_scan_cycle_work();
metrics.record_scanner_source_failed(ScannerWorkSource::Bitrot, 1);
assert!(metrics.current_scan_cycle_has_unresolved_heal_work());
metrics.finish_scan_cycle_work(start);
}
#[tokio::test]
async fn report_marks_transition_failures_as_blocked_lifecycle_control() {
let metrics = Metrics::new();
@@ -3804,6 +3960,21 @@ mod tests {
assert_eq!(report.failed_cycles, 1);
}
#[tokio::test]
async fn report_tracks_superseded_cycle_without_failed_increment() {
let metrics = Metrics::new();
metrics.record_scan_cycle_superseded(Duration::from_millis(750));
let report = metrics.report().await;
assert_eq!(report.last_cycle_result, SCAN_CYCLE_RESULT_SUPERSEDED_LABEL);
assert_eq!(report.last_cycle_result_code, u64::from(SCAN_CYCLE_RESULT_SUPERSEDED));
assert_eq!(report.last_cycle_duration_seconds, 0.75);
assert_eq!(report.failed_cycles, 0);
assert_eq!(report.superseded_cycles, 1);
assert_eq!(report.partial_cycles, 0);
}
#[tokio::test]
async fn report_tracks_successful_scan_cycle_without_failed_increment() {
let metrics = Metrics::new();
+40
View File
@@ -50,3 +50,43 @@ pub const ENV_API_RATE_LIMIT_BURST: &str = "RUSTFS_API_RATE_LIMIT_BURST";
/// Default for `RUSTFS_API_RATE_LIMIT_BURST` (`0` = same as RPM).
pub const DEFAULT_API_RATE_LIMIT_BURST: u32 = 0;
/// Sustained S3 API request budget per addressed bucket, in requests per
/// minute — a collective ceiling shared by all clients of that bucket.
///
/// Complements the per-client-IP dimension: it protects the server from one
/// hot bucket regardless of how many client IPs the traffic comes from. `0`
/// disables the bucket dimension. Requires `RUSTFS_API_RATE_LIMIT_ENABLE`.
/// Environment variable: RUSTFS_API_RATE_LIMIT_BUCKET_RPM
/// Example: RUSTFS_API_RATE_LIMIT_BUCKET_RPM=60000
pub const ENV_API_RATE_LIMIT_BUCKET_RPM: &str = "RUSTFS_API_RATE_LIMIT_BUCKET_RPM";
/// Default for `RUSTFS_API_RATE_LIMIT_BUCKET_RPM` (`0` = dimension disabled).
pub const DEFAULT_API_RATE_LIMIT_BUCKET_RPM: u32 = 0;
/// Burst capacity per bucket (maximum tokens in the bucket-dimension bucket).
///
/// `0` means "same as `RUSTFS_API_RATE_LIMIT_BUCKET_RPM`".
/// Environment variable: RUSTFS_API_RATE_LIMIT_BUCKET_BURST
/// Example: RUSTFS_API_RATE_LIMIT_BUCKET_BURST=2000
pub const ENV_API_RATE_LIMIT_BUCKET_BURST: &str = "RUSTFS_API_RATE_LIMIT_BUCKET_BURST";
/// Default for `RUSTFS_API_RATE_LIMIT_BUCKET_BURST` (`0` = same as bucket RPM).
pub const DEFAULT_API_RATE_LIMIT_BUCKET_BURST: u32 = 0;
/// Maximum concurrently served connections on the main API listener.
///
/// `0` (the default) means unlimited. When set, the accept loop stops
/// accepting once the cap is reached and lets the kernel backlog absorb
/// bursts, releasing capacity as connections close. This bounds file
/// descriptor and memory usage under a connection flood.
///
/// The cap covers everything on the main listener — S3, admin, console,
/// and internode gRPC — so size it well above peer-node count plus the
/// expected client concurrency.
/// Environment variable: RUSTFS_API_MAX_CONNECTIONS
/// Example: RUSTFS_API_MAX_CONNECTIONS=10000
pub const ENV_API_MAX_CONNECTIONS: &str = "RUSTFS_API_MAX_CONNECTIONS";
/// Default for `RUSTFS_API_MAX_CONNECTIONS` (`0` = unlimited).
pub const DEFAULT_API_MAX_CONNECTIONS: usize = 0;
@@ -28,6 +28,15 @@ pub const MAX_ADMIN_REQUEST_BODY_SIZE: usize = 1024 * 1024; // 1 MB
/// Rationale: ZIP archives with hundreds of IAM entities. 10MB allows ~10,000 small configs.
pub const MAX_IAM_IMPORT_SIZE: usize = 10 * 1024 * 1024; // 10 MB
/// Maximum total size the members of an IAM import ZIP may expand to (100 MB).
/// Used for: bounding decompression of `ImportIam` archive members.
/// Rationale: `MAX_IAM_IMPORT_SIZE` caps the *compressed* upload only. Deflate
/// reaches ratios far above 100:1, so without a separate budget a 10 MB archive
/// can expand without bound. 100 MB keeps a 10x headroom over the compressed cap
/// — ample for legitimate IAM exports, which are small JSON documents — while
/// keeping the worst case bounded.
pub const MAX_IAM_IMPORT_EXPANDED_SIZE: u64 = 100 * 1024 * 1024; // 100 MB
/// Maximum size for bucket metadata import operations (100 MB)
/// Used for: Bucket metadata import containing configurations for many buckets
/// Rationale: Large deployments may have thousands of buckets with various configs.
@@ -54,3 +63,12 @@ pub const MAX_HEAL_REQUEST_SIZE: usize = 1024 * 1024; // 1 MB
/// 10MB provides generous headroom for legitimate responses while preventing
/// memory exhaustion from malicious or misconfigured remote services.
pub const MAX_S3_CLIENT_RESPONSE_SIZE: usize = 10 * 1024 * 1024; // 10 MB
/// Maximum size for OIDC provider response bodies (1 MB)
/// Used for: discovery documents, JWKS documents and token endpoint responses
/// Rationale: a hostile or compromised identity provider must not be able to exhaust
/// memory through an arbitrarily large or endless response body.
/// - Discovery documents: typically < 10KB
/// - JWKS documents: typically < 50KB
/// - Token responses: typically < 10KB
pub const MAX_OIDC_RESPONSE_SIZE: usize = 1024 * 1024; // 1 MB
+5
View File
@@ -39,6 +39,11 @@ pub const DEFAULT_DRIVE_WALKDIR_TIMEOUT_SECS: u64 = 5;
pub const ENV_DRIVE_WALKDIR_STALL_TIMEOUT_SECS: &str = "RUSTFS_DRIVE_WALKDIR_STALL_TIMEOUT_SECS";
pub const DEFAULT_DRIVE_WALKDIR_STALL_TIMEOUT_SECS: u64 = 5;
/// Maximum time the metacache merge consumer waits for the next visible
/// `walk_dir()` entry from a reader before detaching it from the merge.
pub const ENV_DRIVE_WALKDIR_PEEK_TIMEOUT_SECS: &str = "RUSTFS_DRIVE_WALKDIR_PEEK_TIMEOUT_SECS";
pub const DEFAULT_DRIVE_WALKDIR_PEEK_TIMEOUT_SECS: u64 = 10;
/// Interval in seconds between active health probes for local and remote drives.
pub const ENV_DRIVE_ACTIVE_CHECK_INTERVAL_SECS: &str = "RUSTFS_DRIVE_ACTIVE_CHECK_INTERVAL_SECS";
pub const DEFAULT_DRIVE_ACTIVE_CHECK_INTERVAL_SECS: u64 = 15;
+90 -7
View File
@@ -97,19 +97,80 @@ pub const ENV_INTERNODE_RPC_MAX_MESSAGE_SIZE: &str = "RUSTFS_INTERNODE_RPC_MAX_M
pub const ENV_INTERNODE_RPC_LARGE_PAYLOAD_WARN_BYTES: &str = "RUSTFS_INTERNODE_RPC_LARGE_PAYLOAD_WARN_BYTES";
pub const DEFAULT_INTERNODE_RPC_LARGE_PAYLOAD_WARN_BYTES: usize = 8 * 1024 * 1024;
/// Stop dual-writing the JSON compatibility strings on internode metadata RPCs and send only the
/// Request stopping the JSON compatibility strings on internode metadata RPCs and sending only the
/// msgpack `_bin` payloads (grpc-optimization P2-1).
///
/// Defaults to `false` (dual-write, byte-for-byte legacy behavior). This is a rollout lever, not a
/// wire-format change: it may only be enabled **after** the JSON-fallback counter
/// (`rustfs_system_network_internode_msgpack_json_fallback_total`) has read zero across a release
/// window fleet-wide, confirming every peer decodes `_bin` first. Single-env rollback. See
/// Defaults to `false` (dual-write, byte-for-byte legacy behavior). This is only a request; RustFS
/// keeps JSON compatibility fields unless [`ENV_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED`] is also
/// true after the release-window convergence and rollback gates pass. See
/// `docs/operations/internode-msgpack-json-convergence-runbook.md`.
pub const ENV_INTERNODE_RPC_MSGPACK_ONLY: &str = "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY";
pub const DEFAULT_INTERNODE_RPC_MSGPACK_ONLY: bool = false;
// Compile-time invariant: dual-write by default so the base build is byte-for-byte legacy behavior.
/// Explicit fleet-wide confirmation gate for [`ENV_INTERNODE_RPC_MSGPACK_ONLY`].
///
/// This separate default-off guard prevents a single legacy flag from accidentally emptying JSON
/// fields in a mixed-version fleet where an older peer still reads the JSON field.
pub const ENV_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED: &str = "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED";
pub const DEFAULT_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED: bool = false;
// Compile-time invariants: dual-write by default so the base build is byte-for-byte legacy behavior.
const _: () = assert!(!DEFAULT_INTERNODE_RPC_MSGPACK_ONLY);
const _: () = assert!(!DEFAULT_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED);
/// Require target-bound v2 signatures on every internode gRPC request, rejecting the legacy
/// constant-target fallback instead of accepting it (<https://github.com/rustfs/backlog/issues/1327>).
///
/// Defaults to `false` (fail-open): a request without any v2 auth headers keeps authenticating
/// through the legacy signature, so legacy-only peers survive rolling upgrades with byte-for-byte
/// the pre-gate acceptance behavior. This is a rollout lever, not a wire-format change: it may only
/// be enabled **after** the v1-fallback counter
/// (`rustfs_system_network_internode_signature_v1_fallback_total`) has read zero across a release
/// window fleet-wide, confirming every peer already sends v2 authentication on every internode gRPC
/// request. Single-env rollback. Requests that do carry v2 headers are unaffected by this switch:
/// they are always verified as v2 with no downgrade, strict or not.
pub const ENV_INTERNODE_RPC_SIGNATURE_STRICT: &str = "RUSTFS_INTERNODE_RPC_SIGNATURE_STRICT";
pub const DEFAULT_INTERNODE_RPC_SIGNATURE_STRICT: bool = false;
// Compile-time invariant: fail-open by default so legacy-only peers keep authenticating during
// rolling upgrades until the fleet-wide v1-fallback counter reads zero.
const _: () = assert!(!DEFAULT_INTERNODE_RPC_SIGNATURE_STRICT);
/// Require a signature-bound canonical body digest on every mutating internode disk RPC
/// (RenameData, DeleteVersion, DeleteVersions, WriteMetadata, UpdateMetadata, WriteAll, Delete,
/// DeletePaths, RenameFile, RenamePart, DeleteVolume, MakeVolume, MakeVolumes), rejecting requests
/// that authenticate without one (<https://github.com/rustfs/backlog/issues/1327>).
///
/// Defaults to `false` (fail-open): a mutating request without a body digest keeps authenticating
/// through the method-bound v2 (or legacy) signature, so peers from releases that predate
/// body-digest signing survive rolling upgrades unchanged. Requests that do carry a digest are
/// always verified with no downgrade, strict or not — the digest value is part of the signed v2
/// scope, so an on-path attacker cannot strip it without invalidating the signature. This is a
/// rollout lever gated on the body-digest fallback counter
/// (`rustfs_system_network_internode_body_digest_fallback_total`) reading zero across a release
/// window fleet-wide. Single-env rollback. It is deliberately separate from
/// [`ENV_INTERNODE_RPC_SIGNATURE_STRICT`]: the two enforcement flips converge on different
/// counters and must not gate each other.
pub const ENV_INTERNODE_RPC_BODY_DIGEST_STRICT: &str = "RUSTFS_INTERNODE_RPC_BODY_DIGEST_STRICT";
pub const DEFAULT_INTERNODE_RPC_BODY_DIGEST_STRICT: bool = false;
// Compile-time invariant: fail-open by default so digestless peers keep authenticating during
// rolling upgrades until the fleet-wide body-digest fallback counter reads zero.
const _: () = assert!(!DEFAULT_INTERNODE_RPC_BODY_DIGEST_STRICT);
/// Capacity (distinct nonces) of the process-local internode RPC replay cache that enforces
/// one-time consumption of body-bound v2 signatures.
///
/// The cache retains each nonce for the ~10-minute signature freshness envelope, so the steady
/// state holds roughly `mutating RPS x 601s` entries; the default sustains ~1,700 body-bound
/// mutating RPCs per second (about 120 MiB worst case, allocated only under sustained load).
/// Overflow fails closed — legitimate signed traffic is the only thing that can fill the cache
/// (replays are rejected before insertion, and an attacker cannot mint valid nonces without the
/// shared secret) — and increments
/// `rustfs_system_network_internode_replay_cache_overflow_total`, so a sustained non-zero overflow
/// counter means this capacity is undersized for the node's peak mutation rate.
pub const ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY: &str = "RUSTFS_INTERNODE_RPC_REPLAY_CACHE_CAPACITY";
pub const DEFAULT_INTERNODE_RPC_REPLAY_CACHE_CAPACITY: usize = 1_048_576;
/// Consecutive-failure threshold after which an internode peer is marked offline (grpc-optimization
/// P3 observability).
@@ -273,8 +334,30 @@ mod tests {
#[test]
fn internode_msgpack_only_env_name_is_stable() {
// The dual-write-by-default invariant is asserted at compile time next to the definition.
// The dual-write-by-default invariants are asserted at compile time next to the definitions.
assert_eq!(ENV_INTERNODE_RPC_MSGPACK_ONLY, "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY");
assert_eq!(
ENV_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED,
"RUSTFS_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED"
);
}
#[test]
fn internode_signature_strict_env_name_is_stable() {
// The fail-open default invariant is asserted at compile time next to the definition.
assert_eq!(ENV_INTERNODE_RPC_SIGNATURE_STRICT, "RUSTFS_INTERNODE_RPC_SIGNATURE_STRICT");
}
#[test]
fn internode_body_digest_strict_env_name_is_stable() {
// The fail-open default invariant is asserted at compile time next to the definition.
assert_eq!(ENV_INTERNODE_RPC_BODY_DIGEST_STRICT, "RUSTFS_INTERNODE_RPC_BODY_DIGEST_STRICT");
}
#[test]
fn internode_replay_cache_capacity_defaults_and_env_name() {
assert_eq!(ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY, "RUSTFS_INTERNODE_RPC_REPLAY_CACHE_CAPACITY");
assert_eq!(DEFAULT_INTERNODE_RPC_REPLAY_CACHE_CAPACITY, 1_048_576);
}
#[test]
+58 -4
View File
@@ -73,15 +73,36 @@ pub const DEFAULT_OBJECT_MAX_CONCURRENT_DISK_READS: usize = 64;
/// - Example: `export RUSTFS_OBJECT_DISK_PERMIT_WAIT_TIMEOUT=5`
pub const ENV_OBJECT_DISK_PERMIT_WAIT_TIMEOUT: &str = "RUSTFS_OBJECT_DISK_PERMIT_WAIT_TIMEOUT";
/// Maximum time a GET request waits for a disk read permit (seconds).
/// Maximum time a GET request waits for a primary disk read permit (seconds).
///
/// Permits are held for the whole response body transfer, so slow clients can
/// occupy all of them while the disks sit idle. Instead of stalling until the
/// request-level timeout fires, a GET that waits longer than this proceeds
/// without a permit (degraded pass-through) and the bypass is counted in
/// metrics/logs. Set to 0 to wait indefinitely (previous behavior).
/// request-level timeout fires, a GET that waits longer than this falls through
/// to a bounded degraded admission lane; if that lane is also full the request
/// is rejected with `SlowDown`/503 rather than proceeding without any permit.
/// Set to 0 to wait on the primary lane indefinitely (never degrade or reject).
pub const DEFAULT_OBJECT_DISK_PERMIT_WAIT_TIMEOUT: u64 = 5;
/// Environment variable for the bounded degraded disk-read admission lane size.
/// - Purpose: Cap how many GETs may proceed after the primary disk-read permit
/// pool is saturated, giving a hard upper bound on concurrent disk-active
/// reads (primary cap + degraded cap) instead of an unbounded pass-through.
/// - Unit: request count (usize). `0` means "mirror the primary cap", so the
/// absolute hard cap defaults to twice the primary disk-read cap.
/// - Example: `export RUSTFS_OBJECT_DISK_DEGRADED_READ_CAP=16`
pub const ENV_OBJECT_DISK_DEGRADED_READ_CAP: &str = "RUSTFS_OBJECT_DISK_DEGRADED_READ_CAP";
/// Size of the bounded degraded disk-read admission lane.
///
/// When the primary disk-read permit pool is saturated and a GET exceeds
/// [`DEFAULT_OBJECT_DISK_PERMIT_WAIT_TIMEOUT`], it may take one permit from this
/// bounded overflow lane instead of reading without any admission token. The
/// total number of GETs performing disk-active reads is therefore hard-capped at
/// `primary_cap + degraded_cap`; beyond that a GET is rejected with `SlowDown`.
/// The default `0` mirrors the primary cap, so the hard cap is twice the primary
/// disk-read concurrency.
pub const DEFAULT_OBJECT_DISK_DEGRADED_READ_CAP: usize = 0;
/// Skip bitrot hash verification on GetObject reads.
///
/// When enabled, GetObject reads skip the per-shard hash
@@ -128,6 +149,39 @@ pub const ENV_OBJECT_DISK_READ_TIMEOUT: &str = "RUSTFS_OBJECT_DISK_READ_TIMEOUT"
/// Default disk read timeout in seconds.
pub const DEFAULT_OBJECT_DISK_READ_TIMEOUT: u64 = 10;
/// Environment variable for the per-shard erasure write stall timeout (seconds).
///
/// A single shard write (or shard-writer shutdown) that makes no forward
/// progress for longer than this budget is failed and its disk is dropped
/// before commit, so a black-hole peer that accepts the connection but never
/// drains the body cannot pin an otherwise-healthy write quorum forever
/// (see `MultiWriter` in `erasure/coding/encode.rs`). The budget is re-armed on
/// every shard write, so it bounds a *stall* rather than the total transfer
/// time of a large object.
///
/// Unit: seconds (u64). `0` disables the stall deadline (previous behavior:
/// wait indefinitely). Default: 30 seconds.
pub const ENV_OBJECT_DISK_WRITE_STALL_TIMEOUT: &str = "RUSTFS_OBJECT_DISK_WRITE_STALL_TIMEOUT";
/// Default per-shard erasure write stall timeout in seconds.
pub const DEFAULT_OBJECT_DISK_WRITE_STALL_TIMEOUT: u64 = 30;
/// Environment variable for the absolute per-object erasure write cap (seconds).
///
/// Optional administrator backstop against a "slow-drip" peer that produces
/// just enough forward progress to reset the per-shard stall timeout on every
/// block while never converging. When set, the shard writers for one object are
/// engaged for at most this long in aggregate before a stalled writer is failed
/// and dropped. It is disabled by default because a legitimate large upload
/// over a slow-but-honest link must not be killed on total time alone; the
/// per-shard stall timeout is the primary guarantee.
///
/// Unit: seconds (u64). `0` (default) disables the absolute cap.
pub const ENV_OBJECT_DISK_WRITE_ABSOLUTE_CAP: &str = "RUSTFS_OBJECT_DISK_WRITE_ABSOLUTE_CAP";
/// Default absolute per-object erasure write cap in seconds (`0` = disabled).
pub const DEFAULT_OBJECT_DISK_WRITE_ABSOLUTE_CAP: u64 = 0;
/// Environment variable for minimum GetObject timeout in seconds.
///
/// When dynamic timeout calculation is enabled, this is the minimum timeout
+5 -1
View File
@@ -14,6 +14,7 @@
// OIDC configuration field keys (used in KVS)
pub const OIDC_CONFIG_URL: &str = "config_url";
pub const OIDC_ISSUER: &str = "issuer";
pub const OIDC_CLIENT_ID: &str = "client_id";
pub const OIDC_CLIENT_SECRET: &str = "client_secret";
pub const OIDC_SCOPES: &str = "scopes";
@@ -33,6 +34,7 @@ pub const OIDC_HIDE_FROM_UI: &str = "hide_from_ui";
// Environment variable names for OIDC
pub const ENV_IDENTITY_OPENID_ENABLE: &str = "RUSTFS_IDENTITY_OPENID_ENABLE";
pub const ENV_IDENTITY_OPENID_CONFIG_URL: &str = "RUSTFS_IDENTITY_OPENID_CONFIG_URL";
pub const ENV_IDENTITY_OPENID_ISSUER: &str = "RUSTFS_IDENTITY_OPENID_ISSUER";
pub const ENV_IDENTITY_OPENID_CLIENT_ID: &str = "RUSTFS_IDENTITY_OPENID_CLIENT_ID";
pub const ENV_IDENTITY_OPENID_CLIENT_SECRET: &str = "RUSTFS_IDENTITY_OPENID_CLIENT_SECRET";
pub const ENV_IDENTITY_OPENID_SCOPES: &str = "RUSTFS_IDENTITY_OPENID_SCOPES";
@@ -50,9 +52,10 @@ pub const ENV_IDENTITY_OPENID_USERNAME_CLAIM: &str = "RUSTFS_IDENTITY_OPENID_USE
pub const ENV_IDENTITY_OPENID_HIDE_FROM_UI: &str = "RUSTFS_IDENTITY_OPENID_HIDE_FROM_UI";
/// List of all environment variable keys for an OIDC provider.
pub const ENV_IDENTITY_OPENID_KEYS: &[&str; 17] = &[
pub const ENV_IDENTITY_OPENID_KEYS: &[&str; 18] = &[
ENV_IDENTITY_OPENID_ENABLE,
ENV_IDENTITY_OPENID_CONFIG_URL,
ENV_IDENTITY_OPENID_ISSUER,
ENV_IDENTITY_OPENID_CLIENT_ID,
ENV_IDENTITY_OPENID_CLIENT_SECRET,
ENV_IDENTITY_OPENID_SCOPES,
@@ -74,6 +77,7 @@ pub const ENV_IDENTITY_OPENID_KEYS: &[&str; 17] = &[
pub const IDENTITY_OPENID_KEYS: &[&str] = &[
crate::ENABLE_KEY,
OIDC_CONFIG_URL,
OIDC_ISSUER,
OIDC_CLIENT_ID,
OIDC_CLIENT_SECRET,
OIDC_SCOPES,
+1
View File
@@ -57,6 +57,7 @@ pub const ENV_WEBDAV_CERTS_DIR: &str = "RUSTFS_WEBDAV_CERTS_DIR";
pub const ENV_WEBDAV_CA_FILE: &str = "RUSTFS_WEBDAV_CA_FILE";
pub const ENV_WEBDAV_MAX_BODY_SIZE: &str = "RUSTFS_WEBDAV_MAX_BODY_SIZE";
pub const ENV_WEBDAV_REQUEST_TIMEOUT: &str = "RUSTFS_WEBDAV_REQUEST_TIMEOUT";
pub const ENV_WEBDAV_MAX_CONNECTIONS: &str = "RUSTFS_WEBDAV_MAX_CONNECTIONS";
/// Default SFTP server bind address.
pub const DEFAULT_SFTP_ADDRESS: &str = "0.0.0.0:2222";
+2 -4
View File
@@ -220,12 +220,10 @@ pub const ENV_SCANNER_YIELD_EVERY_N_OBJECTS: &str = "RUSTFS_SCANNER_YIELD_EVERY_
pub const DEFAULT_SCANNER_IDLE_MODE: bool = true;
/// Default set scan concurrency budget.
/// `0` means no additional limit beyond deployment topology.
pub const DEFAULT_SCANNER_MAX_CONCURRENT_SET_SCANS: usize = 0;
pub const DEFAULT_SCANNER_MAX_CONCURRENT_SET_SCANS: usize = 4;
/// Default disk scan concurrency budget.
/// `0` means no additional limit beyond available disks in the set.
pub const DEFAULT_SCANNER_MAX_CONCURRENT_DISK_SCANS: usize = 0;
pub const DEFAULT_SCANNER_MAX_CONCURRENT_DISK_SCANS: usize = 4;
/// Default object interval for cooperative scanner yields.
pub const DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS: u64 = 128;
+4
View File
@@ -142,6 +142,10 @@ pub const DEFAULT_H2_KEEP_ALIVE_TIMEOUT: u64 = 10;
/// proxy's upstream idle-keepalive, or lower the proxy's keepalive below this
/// value. Environments that expose RustFS directly to untrusted slow clients and
/// want tighter slowloris protection can lower it via the env var below.
///
/// The same budget bounds the TLS handshake on the listener, so an unauthenticated
/// peer cannot park an accept task and its socket indefinitely by opening a
/// connection and then stalling the handshake.
pub const ENV_HTTP1_HEADER_READ_TIMEOUT: &str = "RUSTFS_HTTP1_HEADER_READ_TIMEOUT";
pub const DEFAULT_HTTP1_HEADER_READ_TIMEOUT: u64 = 75;
+30
View File
@@ -56,3 +56,33 @@ pub const DEFAULT_OBJECT_MMAP_READ_ENABLE: bool = true;
///
/// Prefer [`DEFAULT_OBJECT_MMAP_READ_ENABLE`].
pub const DEFAULT_OBJECT_ZERO_COPY_ENABLE: bool = DEFAULT_OBJECT_MMAP_READ_ENABLE;
/// Environment variable capping the byte length a single mmap-copy read may
/// materialize in memory.
///
/// The mmap-copy read path returns the whole requested range as one owned
/// allocation before the first byte is served. GET/heal shard reads request
/// the entire part span in one call, so for a large single-part object
/// (e.g. a multi-gigabyte non-multipart upload) an uncapped mmap-copy read
/// allocates the whole shard in memory — stalling first-byte latency past the
/// disk-read timeout and OOM-killing memory-limited deployments
/// (<https://github.com/rustfs/rustfs/issues/5123>). Reads longer than this
/// cap fall back to the bounded streaming reader instead.
///
/// - Purpose: Bound per-shard-read memory for mmap-based reads
/// - Acceptable values: byte count as an unsigned integer; `0` disables
/// mmap-copy for all non-empty reads (every read streams)
/// - Example: `export RUSTFS_OBJECT_MMAP_READ_MAX_LENGTH=8388608`
pub const ENV_OBJECT_MMAP_READ_MAX_LENGTH: &str = "RUSTFS_OBJECT_MMAP_READ_MAX_LENGTH";
/// Default mmap-copy read length cap: 32 MiB per shard read.
///
/// Large enough that typical multipart part shards (parts up to a few hundred
/// megabytes across the erasure set) keep the mmap fast path, small enough
/// that whole-part reads of huge single-part objects stream instead of
/// materializing gigabytes per shard.
///
/// The cap bounds memory per shard reader, so a single part read can still
/// materialize up to `data_shards x cap` bytes; raising the cap raises that
/// per-request bound proportionally.
pub const DEFAULT_OBJECT_MMAP_READ_MAX_LENGTH: usize = 32 * 1024 * 1024;
+12 -18
View File
@@ -260,13 +260,10 @@ fn resolve_rpc_secret(env_secret: Option<&str>, global_access: Option<&str>, glo
match (global_access, global_secret) {
(Some(access_key), Some(secret_key)) => {
// Fail closed: never derive the RPC secret while the default secret
// key is in effect. The derivation uses `secret_key` as the HMAC key,
// so a public default secret yields a publicly computable RPC secret
// that any network peer can use to forge internode RPC signatures.
// Operators running with default credentials must configure
// RUSTFS_RPC_SECRET (or set a non-default RUSTFS_SECRET_KEY) instead.
if secret_key.trim() == DEFAULT_SECRET_KEY {
// Fail closed when either half of the active credential pair still
// uses the public default. Operators must configure both custom
// credentials or provide RUSTFS_RPC_SECRET explicitly.
if access_key.trim() == DEFAULT_ACCESS_KEY || secret_key.trim() == DEFAULT_SECRET_KEY {
return None;
}
derive_rpc_secret(access_key, secret_key)
@@ -589,18 +586,11 @@ mod tests {
fn test_resolve_rpc_secret_rejects_default_credentials_for_derivation() {
assert!(resolve_rpc_secret(None, None, None).is_none());
// Fail closed: the default secret key must not yield a derivable RPC
// secret, otherwise the derived value is publicly computable and any
// network peer can forge internode RPC signatures.
// Fail closed when either half of the credential pair uses the public
// default.
assert!(resolve_rpc_secret(None, Some(DEFAULT_ACCESS_KEY), Some(DEFAULT_SECRET_KEY)).is_none());
// A default access key paired with a non-default secret key is still
// safe to derive: the HMAC key (the secret key) is not public.
let expected = derive_rpc_secret(DEFAULT_ACCESS_KEY, "custom-global-secret").expect("secret should derive");
assert_eq!(
resolve_rpc_secret(None, Some(DEFAULT_ACCESS_KEY), Some("custom-global-secret")).as_deref(),
Some(expected.as_str())
);
assert!(resolve_rpc_secret(None, Some(DEFAULT_ACCESS_KEY), Some("custom-global-secret")).is_none());
assert!(resolve_rpc_secret(None, Some("custom-access"), Some(DEFAULT_SECRET_KEY)).is_none());
assert!(resolve_rpc_secret(Some(DEFAULT_SECRET_KEY), Some("custom-access"), Some("custom-global-secret")).is_none());
}
@@ -635,6 +625,10 @@ mod tests {
resolve_rpc_secret(Some("custom-rpc-secret"), None, None).as_deref(),
Some("custom-rpc-secret")
);
assert_eq!(
resolve_rpc_secret(Some("custom-rpc-secret"), Some(DEFAULT_ACCESS_KEY), Some(DEFAULT_SECRET_KEY)).as_deref(),
Some("custom-rpc-secret")
);
let expected = derive_rpc_secret("custom-access", "custom-global-secret").expect("secret should derive");
assert_eq!(
resolve_rpc_secret(None, Some("custom-access"), Some("custom-global-secret")).as_deref(),
+318 -15
View File
@@ -18,9 +18,41 @@ use std::{
collections::{HashMap, HashSet},
hash::{DefaultHasher, Hash, Hasher},
path::Path,
time::SystemTime,
time::{Duration, SystemTime},
};
/// Maximum amount a persisted `last_update` may lead the local wall clock before the
/// persisted timestamp is treated as untrustworthy.
///
/// Invariant: the "skip stale usage update" monotonicity check (incoming `last_update`
/// <= existing `last_update` => skip persisting) is only valid while the existing
/// timestamp could plausibly have been produced by a healthy clock. If the on-disk
/// snapshot is future-dated beyond this tolerance (NTP step-back, or scanner
/// leadership moving to a node with a slower clock), the comparison would skip every
/// save forever and freeze admin usage stats; callers must bypass the skip instead.
pub const USAGE_LAST_UPDATE_FUTURE_TOLERANCE: Duration = Duration::from_secs(5 * 60);
/// Cluster-wide usage snapshot written by coordinated scanners.
///
/// `usage_snapshot_complete` is an additive JSON field: older readers ignore
/// it, while current readers treat snapshots from older writers as unknown.
/// Keeping the existing object name preserves rolling-upgrade and rollback
/// compatibility without allowing an ambiguous snapshot to become authoritative.
pub const DATA_USAGE_OBJECT_NAME: &str = ".usage.v2.json";
/// Usage snapshot written by scanner implementations predating distributed
/// leadership fencing. It is read only when neither authoritative snapshot
/// copy exists.
// RUSTFS_COMPAT_TODO(scanner-usage-v2): keep .usage.json readable and removable during rolling upgrades from pre-v2 scanners. Remove after supported direct-upgrade sources all write .usage.v2.json.
pub const LEGACY_DATA_USAGE_OBJECT_NAME: &str = ".usage.json";
/// Returns true when `existing_last_update` is ahead of `now` by more than
/// [`USAGE_LAST_UPDATE_FUTURE_TOLERANCE`], i.e. the persisted timestamp cannot be
/// trusted for staleness comparisons and a fresh snapshot save must be allowed.
pub fn usage_last_update_is_untrusted_future(existing_last_update: SystemTime, now: SystemTime) -> bool {
existing_last_update > now + USAGE_LAST_UPDATE_FUTURE_TOLERANCE
}
#[derive(Clone, Copy, Default, Debug, Serialize, Deserialize, PartialEq)]
pub struct TierStats {
pub total_size: u64,
@@ -77,7 +109,7 @@ impl AllTierStats {
}
/// Bucket target usage info provides replication statistics
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BucketTargetUsageInfo {
pub replication_pending_size: u64,
pub replication_failed_size: u64,
@@ -89,7 +121,7 @@ pub struct BucketTargetUsageInfo {
}
/// Bucket usage info provides bucket-level statistics
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BucketUsageInfo {
pub size: u64,
// Following five fields suffixed with V1 are here for backward compatibility
@@ -115,7 +147,7 @@ pub struct BucketUsageInfo {
}
/// DataUsageInfo represents data usage stats of the underlying storage
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DataUsageInfo {
/// Total capacity
pub total_capacity: u64,
@@ -127,6 +159,22 @@ pub struct DataUsageInfo {
/// LastUpdate is the timestamp of when the data usage info was last updated
pub last_update: Option<SystemTime>,
/// Monotonic scanner cycle that produced this complete snapshot.
///
/// Older snapshots omit this field and continue to use `last_update` for
/// compatibility. New scanner snapshots use the cycle to fence stale
/// leaders independently of wall-clock skew.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scanner_cycle: Option<u64>,
/// Persisted scanner leadership epoch that produced this snapshot.
///
/// The epoch is claimed through the cycle-state CAS before scanning. It
/// orders snapshots from different leaders even when their wall clocks or
/// cycle counters coincide.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scanner_epoch: Option<u64>,
/// Objects total count across all buckets
pub objects_total_count: u64,
/// Versions total count across all buckets
@@ -142,6 +190,12 @@ pub struct DataUsageInfo {
pub buckets_count: u64,
/// Buckets usage info provides following information across all buckets
pub buckets_usage: HashMap<String, BucketUsageInfo>,
/// Whether this snapshot covers the complete bucket namespace.
///
/// Legacy snapshots default to `false`. A complete snapshot contains an
/// explicit entry for every bucket, including confirmed-empty buckets.
#[serde(default)]
pub usage_snapshot_complete: bool,
/// Deprecated kept here for backward compatibility reasons
pub bucket_sizes: HashMap<String, u64>,
/// Per-disk snapshot information when available
@@ -150,7 +204,7 @@ pub struct DataUsageInfo {
}
/// Metadata describing the status of a disk-level data usage snapshot.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DiskUsageStatus {
pub disk_id: String,
pub pool_index: Option<usize>,
@@ -250,12 +304,30 @@ impl DataUsageHash {
pub type DataUsageHashMap = HashSet<String>;
/// Size histogram for object size distribution
#[derive(Clone, Debug, Serialize, Deserialize)]
const SIZE_HISTOGRAM_LEN: usize = 11;
#[derive(Clone, Debug, Serialize)]
pub struct SizeHistogram(Vec<u64>);
impl Default for SizeHistogram {
fn default() -> Self {
Self(vec![0; 11]) // DATA_USAGE_BUCKET_LEN = 11
Self(vec![0; SIZE_HISTOGRAM_LEN])
}
}
impl<'de> Deserialize<'de> for SizeHistogram {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let values = Vec::<u64>::deserialize(deserializer)?;
if values.len() != SIZE_HISTOGRAM_LEN {
return Err(serde::de::Error::invalid_length(
values.len(),
&"exactly 11 object-size histogram buckets",
));
}
Ok(Self(values))
}
}
@@ -325,7 +397,7 @@ impl SizeHistogram {
.zip(names.iter())
.filter(|((_, (start, end)), name)| name != &&"BETWEEN_1024B_AND_1_MB" && *start >= 1024 && *end < ONE_MIB)
.map(|((count, _), _)| *count)
.sum();
.fold(0, u64::saturating_add);
let mut res = HashMap::new();
for (count, name) in self.0.iter().zip(names.iter()) {
@@ -346,12 +418,30 @@ impl SizeHistogram {
}
/// Versions histogram for version count distribution
#[derive(Clone, Debug, Serialize, Deserialize)]
const VERSIONS_HISTOGRAM_LEN: usize = 7;
#[derive(Clone, Debug, Serialize)]
pub struct VersionsHistogram(Vec<u64>);
impl Default for VersionsHistogram {
fn default() -> Self {
Self(vec![0; 7]) // DATA_USAGE_VERSION_LEN = 7
Self(vec![0; VERSIONS_HISTOGRAM_LEN])
}
}
impl<'de> Deserialize<'de> for VersionsHistogram {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let values = Vec::<u64>::deserialize(deserializer)?;
if values.len() != VERSIONS_HISTOGRAM_LEN {
return Err(serde::de::Error::invalid_length(
values.len(),
&"exactly 7 object-version histogram buckets",
));
}
Ok(Self(values))
}
}
@@ -517,13 +607,74 @@ impl DataUsageEntry {
}
}
for (i, v) in other.obj_sizes.0.iter().enumerate() {
self.obj_sizes.0[i] += v;
}
self.obj_sizes.merge_from(&other.obj_sizes);
self.obj_versions.merge_from(&other.obj_versions);
}
for (i, v) in other.obj_versions.0.iter().enumerate() {
self.obj_versions.0[i] += v;
pub fn checked_merge(&mut self, other: &DataUsageEntry) -> bool {
let scalar_counts_fit = self.objects.checked_add(other.objects).is_some()
&& self.versions.checked_add(other.versions).is_some()
&& self.delete_markers.checked_add(other.delete_markers).is_some()
&& self.size.checked_add(other.size).is_some()
&& self.failed_objects.checked_add(other.failed_objects).is_some();
let histograms_fit = self.obj_sizes.0.len() == SIZE_HISTOGRAM_LEN
&& other.obj_sizes.0.len() == SIZE_HISTOGRAM_LEN
&& self.obj_versions.0.len() == VERSIONS_HISTOGRAM_LEN
&& other.obj_versions.0.len() == VERSIONS_HISTOGRAM_LEN
&& self
.obj_sizes
.0
.iter()
.zip(other.obj_sizes.0.iter())
.all(|(left, right)| left.checked_add(*right).is_some())
&& self
.obj_versions
.0
.iter()
.zip(other.obj_versions.0.iter())
.all(|(left, right)| left.checked_add(*right).is_some());
let replication_fits = match (&self.replication_stats, &other.replication_stats) {
(_, None) | (None, Some(_)) => true,
(Some(left), Some(right)) => {
left.replica_size.checked_add(right.replica_size).is_some()
&& left.replica_count.checked_add(right.replica_count).is_some()
&& right.targets.iter().all(|(target, right_stats)| {
left.targets.get(target).is_none_or(|left_stats| {
left_stats.pending_size.checked_add(right_stats.pending_size).is_some()
&& left_stats.replicated_size.checked_add(right_stats.replicated_size).is_some()
&& left_stats.failed_size.checked_add(right_stats.failed_size).is_some()
&& left_stats.failed_count.checked_add(right_stats.failed_count).is_some()
&& left_stats.pending_count.checked_add(right_stats.pending_count).is_some()
&& left_stats
.missed_threshold_size
.checked_add(right_stats.missed_threshold_size)
.is_some()
&& left_stats
.after_threshold_size
.checked_add(right_stats.after_threshold_size)
.is_some()
&& left_stats
.missed_threshold_count
.checked_add(right_stats.missed_threshold_count)
.is_some()
&& left_stats
.after_threshold_count
.checked_add(right_stats.after_threshold_count)
.is_some()
&& left_stats
.replicated_count
.checked_add(right_stats.replicated_count)
.is_some()
})
})
}
};
if !scalar_counts_fit || !histograms_fit || !replication_fits {
return false;
}
self.merge(other);
true
}
}
@@ -536,6 +687,12 @@ pub struct DataUsageCacheInfo {
pub skip_healing: bool,
#[serde(default)]
pub failed_objects: HashMap<String, u64>,
/// Whether this per-set cache was produced by a completed scanner pass.
///
/// Older cache writers omit this field and therefore deserialize as
/// incomplete instead of exposing partial set totals as confirmed zeros.
#[serde(default)]
pub snapshot_complete: bool,
}
/// Data usage cache
@@ -855,6 +1012,7 @@ impl DataUsageCache {
objects_total_size: flat.size as u64,
buckets_count: u64::try_from(buckets.len()).unwrap_or(u64::MAX),
buckets_usage,
usage_snapshot_complete: self.info.snapshot_complete,
..Default::default()
}
}
@@ -933,6 +1091,13 @@ impl DataUsageInfo {
Self::default()
}
/// Whether this snapshot authoritatively covers every reported bucket.
pub fn is_complete_bucket_usage_snapshot(&self) -> bool {
self.usage_snapshot_complete
&& self.last_update.is_some()
&& u64::try_from(self.buckets_usage.len()).ok() == Some(self.buckets_count)
}
/// Add object metadata to data usage statistics
pub fn add_object(&mut self, object_path: &str, meta_object: &rustfs_filemeta::MetaObject) {
// This method is kept for backward compatibility
@@ -1297,6 +1462,51 @@ pub struct CompressionTotalInfo {
mod tests {
use super::*;
#[derive(Deserialize)]
struct LegacyUsageReader {
buckets_count: u64,
}
#[test]
fn completeness_marker_is_additive_for_legacy_named_readers() {
let current = DataUsageInfo {
last_update: Some(SystemTime::UNIX_EPOCH),
usage_snapshot_complete: true,
..Default::default()
};
let encoded = rmp_serde::to_vec_named(&current).expect("encode current data usage snapshot");
let legacy: LegacyUsageReader = rmp_serde::from_slice(&encoded).expect("legacy reader should ignore additive fields");
assert_eq!(legacy.buckets_count, 0);
assert!(current.is_complete_bucket_usage_snapshot());
}
#[test]
fn completeness_marker_requires_a_snapshot_timestamp() {
let untimestamped = DataUsageInfo {
usage_snapshot_complete: true,
..Default::default()
};
assert!(!untimestamped.is_complete_bucket_usage_snapshot());
}
#[test]
fn test_usage_last_update_future_tolerance_boundary() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
// Within tolerance (including the exact boundary) the timestamp is trusted.
assert!(!usage_last_update_is_untrusted_future(now, now));
assert!(!usage_last_update_is_untrusted_future(now - Duration::from_secs(60), now));
assert!(!usage_last_update_is_untrusted_future(now + USAGE_LAST_UPDATE_FUTURE_TOLERANCE, now));
// Beyond tolerance the persisted timestamp is untrustworthy.
assert!(usage_last_update_is_untrusted_future(
now + USAGE_LAST_UPDATE_FUTURE_TOLERANCE + Duration::from_secs(1),
now
));
}
#[test]
fn test_data_usage_info_creation() {
let mut info = DataUsageInfo::new();
@@ -1361,6 +1571,17 @@ mod tests {
assert_eq!(map["BETWEEN_512_KB_AND_1_MB"], 1);
}
#[test]
fn test_size_histogram_compat_rollup_saturates_on_corrupt_counts() {
let mut hist = SizeHistogram::default();
hist.0[1] = u64::MAX;
hist.0[2] = 1;
let map = hist.to_map();
assert_eq!(map["BETWEEN_1024B_AND_1_MB"], u64::MAX);
}
#[test]
fn test_data_usage_cache_merge_adds_missing_child() {
let mut base = DataUsageCache::default();
@@ -1674,4 +1895,86 @@ mod tests {
assert!(cache.find("bucket/large/a").is_some());
assert!(cache.find("bucket/large/b").is_some());
}
#[test]
fn checked_merge_rejects_scalar_and_replication_overflow_without_mutation() {
let mut entry = DataUsageEntry {
objects: usize::MAX,
replication_stats: Some(ReplicationAllStats {
replica_size: 7,
..Default::default()
}),
..Default::default()
};
let other = DataUsageEntry {
objects: 1,
replication_stats: Some(ReplicationAllStats {
replica_size: u64::MAX,
..Default::default()
}),
..Default::default()
};
assert!(!entry.checked_merge(&other));
assert_eq!(entry.objects, usize::MAX);
assert_eq!(entry.replication_stats.as_ref().map(|stats| stats.replica_size), Some(7));
}
#[test]
fn checked_merge_accepts_valid_usage() {
let mut entry = DataUsageEntry {
objects: 2,
size: 20,
..Default::default()
};
let other = DataUsageEntry {
objects: 3,
size: 30,
..Default::default()
};
assert!(entry.checked_merge(&other));
assert_eq!(entry.objects, 5);
assert_eq!(entry.size, 50);
}
#[test]
fn histogram_deserialization_rejects_noncanonical_lengths() {
let invalid_sizes =
rmp_serde::to_vec(&vec![0_u64; SIZE_HISTOGRAM_LEN + 1]).expect("encode invalid object-size histogram fixture");
let invalid_versions =
rmp_serde::to_vec(&vec![0_u64; VERSIONS_HISTOGRAM_LEN - 1]).expect("encode invalid object-version histogram fixture");
assert!(rmp_serde::from_slice::<SizeHistogram>(&invalid_sizes).is_err());
assert!(rmp_serde::from_slice::<VersionsHistogram>(&invalid_versions).is_err());
}
#[test]
fn replication_target_deserialization_preserves_large_historical_maps() {
let mut stats = ReplicationAllStats::default();
for index in 0..=1024 {
stats.targets.insert(format!("target-{index}"), ReplicationStats::default());
}
let encoded = rmp_serde::to_vec_named(&stats).expect("large replication target fixture should encode");
let decoded = rmp_serde::from_slice::<ReplicationAllStats>(&encoded)
.expect("historical replication target maps must remain readable");
assert_eq!(decoded.targets.len(), stats.targets.len());
}
#[test]
fn checked_merge_rejects_noncanonical_histograms_without_mutation() {
let mut entry = DataUsageEntry {
objects: 2,
..Default::default()
};
let other = DataUsageEntry {
objects: 3,
obj_sizes: SizeHistogram(vec![0; SIZE_HISTOGRAM_LEN + 1]),
..Default::default()
};
assert!(!entry.checked_merge(&other));
assert_eq!(entry.objects, 2);
}
}
+6 -1
View File
@@ -30,9 +30,11 @@ sftp = []
[dependencies]
rustfs-config = { workspace = true, features = ["constants"] }
rustfs-credentials.workspace = true
rustfs-ecstore.workspace = true
rustfs-data-usage.workspace = true
rustfs-rio.workspace = true
rustfs-utils = { workspace = true, features = ["egress"] }
flatbuffers.workspace = true
futures.workspace = true
rustfs-lock.workspace = true
@@ -48,6 +50,7 @@ rustfs-filemeta.workspace = true
bytes = { workspace = true, features = ["serde"] }
serial_test = { workspace = true }
aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
aws-sdk-sts = { workspace = true, default-features = false, features = ["default-https-client", "rt-tokio"] }
aws-config = { workspace = true }
aws-smithy-http-client = { workspace = true, default-features = false, features = ["rustls-aws-lc"] }
async-compression = { workspace = true, features = ["tokio", "bzip2", "xz"] }
@@ -57,7 +60,7 @@ http.workspace = true
http-body-util.workspace = true
hyper = { workspace = true, features = ["http2", "http1", "server"] }
hyper-util = { workspace = true, features = ["tokio", "server-auto", "server-graceful", "tracing"] }
reqwest = { workspace = true, default-features = false, features = ["rustls", "charset", "http2", "system-proxy", "stream", "json", "multipart"] }
reqwest = { workspace = true, features = ["json", "multipart", "stream"] }
rustfs-signer.workspace = true
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter", "time"] }
@@ -68,6 +71,8 @@ base64 = { workspace = true }
rand = { workspace = true, features = ["serde"] }
chrono = { workspace = true, features = ["serde"] }
md5 = { workspace = true }
opentelemetry-proto = { workspace = true }
prost.workspace = true
sha2 = { workspace = true }
astral-tokio-tar = { workspace = true }
s3s = { workspace = true, features = ["minio"] }
+163
View File
@@ -46,6 +46,45 @@ mod tests {
use std::time::{Duration, Instant};
const ADMIN_INFO_PATH: &str = "/rustfs/admin/v3/info";
const ADMIN_MANUAL_TRANSITION_BUCKET: &str = "auth-deny-manual-transition";
const ADMIN_MANUAL_TRANSITION_PATH: &str =
"/rustfs/admin/v3/ilm/transition/run?bucket=auth-deny-manual-transition&maxObjects=1&mode=async";
fn assert_no_raw_manual_transition_markers(body: &str, context: &str) {
assert!(
!body.contains("\"marker\"") && !body.contains("\"versionMarker\"") && !body.contains("\"version_marker\""),
"{context} must not expose raw manual transition resume markers, body: {body}"
);
}
async fn wait_for_terminal_manual_transition_job(
env: &RustFSTestEnvironment,
status_endpoint: &str,
) -> Result<String, Box<dyn Error + Send + Sync>> {
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let (status, body) =
signed_request(&env.url, http::Method::GET, status_endpoint, None, &env.access_key, &env.secret_key).await?;
assert_eq!(
status,
reqwest::StatusCode::OK,
"root credential must query manual transition job status, body: {body}"
);
assert_no_raw_manual_transition_markers(&body, "manual transition status response");
let value: serde_json::Value = serde_json::from_str(&body)?;
let job_status = value
.get("status")
.and_then(serde_json::Value::as_str)
.ok_or("manual transition job status response must include status")?;
if matches!(job_status, "completed" | "partial" | "cancelled" | "failed" | "unknown") {
return Ok(body);
}
if Instant::now() >= deadline {
return Err(format!("manual transition job did not reach terminal status within 30s; last={body}").into());
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
/// Send a SigV4-signed request to `path` (optionally with a JSON `body`) and
/// return `(status, body)`. Uses the `UNSIGNED_PAYLOAD` content hash so a
@@ -158,6 +197,130 @@ mod tests {
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn non_admin_credential_denied_on_manual_transition_run() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let user_ak = "ilmtransitionlimited";
let user_sk = "ilmtransitionlimitedsecret";
create_limited_user(&env, user_ak, user_sk).await?;
env.create_s3_client()
.create_bucket()
.bucket(ADMIN_MANUAL_TRANSITION_BUCKET)
.send()
.await?;
let (root_status, root_body) = signed_request(
&env.url,
http::Method::POST,
ADMIN_MANUAL_TRANSITION_PATH,
None,
&env.access_key,
&env.secret_key,
)
.await?;
assert_eq!(
root_status,
reqwest::StatusCode::ACCEPTED,
"root credential must reach the manual transition handler, body: {root_body}"
);
assert!(
root_body.contains("\"mode\":\"durable_job\""),
"root response should be the durable manual transition JSON contract, body: {root_body}"
);
assert_no_raw_manual_transition_markers(&root_body, "manual transition run response");
let root_value: serde_json::Value = serde_json::from_str(&root_body)?;
let job_id = root_value
.get("job_id")
.and_then(serde_json::Value::as_str)
.ok_or("manual transition async response must include job_id")?;
let status_endpoint = root_value
.get("status_endpoint")
.and_then(serde_json::Value::as_str)
.ok_or("manual transition async response must include status_endpoint")?;
let cancel_endpoint = root_value
.get("cancel_endpoint")
.and_then(serde_json::Value::as_str)
.ok_or("manual transition async response must include cancel_endpoint")?;
assert_eq!(
cancel_endpoint, status_endpoint,
"manual transition durable jobs currently use the same status/cancel endpoint"
);
assert!(
status_endpoint.ends_with(job_id),
"status endpoint must address the returned job id, job_id={job_id}, status_endpoint={status_endpoint}"
);
let terminal_body = wait_for_terminal_manual_transition_job(&env, status_endpoint).await?;
let terminal: serde_json::Value = serde_json::from_str(&terminal_body)?;
assert_eq!(terminal.get("job_id").and_then(serde_json::Value::as_str), Some(job_id));
assert_eq!(
terminal
.get("report")
.and_then(|report| report.get("bucket"))
.and_then(serde_json::Value::as_str),
Some(ADMIN_MANUAL_TRANSITION_BUCKET)
);
let (root_status, root_body) =
signed_request(&env.url, http::Method::DELETE, status_endpoint, None, &env.access_key, &env.secret_key).await?;
assert_eq!(
root_status,
reqwest::StatusCode::OK,
"root credential must cancel/query a terminal manual transition job idempotently, body: {root_body}"
);
assert_no_raw_manual_transition_markers(&root_body, "manual transition cancel response");
let root_cancel: serde_json::Value = serde_json::from_str(&root_body)?;
assert_eq!(root_cancel.get("job_id").and_then(serde_json::Value::as_str), Some(job_id));
assert!(
matches!(
root_cancel.get("status").and_then(serde_json::Value::as_str),
Some("completed" | "partial" | "failed" | "unknown")
),
"terminal cancel must not rewrite the job into cancelled state, body: {root_body}"
);
let (status, body) =
signed_request(&env.url, http::Method::POST, ADMIN_MANUAL_TRANSITION_PATH, None, user_ak, user_sk).await?;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"non-admin credential must get 403 on manual transition run, body: {body}"
);
assert!(
body.contains("AccessDenied"),
"manual transition rejection must carry the AccessDenied S3 error code, body: {body}"
);
let (status, body) = signed_request(&env.url, http::Method::GET, status_endpoint, None, user_ak, user_sk).await?;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"non-admin credential must get 403 on manual transition status, body: {body}"
);
assert_no_raw_manual_transition_markers(&body, "manual transition status rejection");
assert!(
body.contains("AccessDenied"),
"manual transition status rejection must carry the AccessDenied S3 error code, body: {body}"
);
let (status, body) = signed_request(&env.url, http::Method::DELETE, status_endpoint, None, user_ak, user_sk).await?;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"non-admin credential must get 403 on manual transition cancel, body: {body}"
);
assert_no_raw_manual_transition_markers(&body, "manual transition cancel rejection");
assert!(
body.contains("AccessDenied"),
"manual transition cancel rejection must carry the AccessDenied S3 error code, body: {body}"
);
env.stop_server();
Ok(())
}
/// Rotating the root credentials (restart with new `--access-key` /
/// `--secret-key` on the same data directory) takes effect: the new
/// credential is accepted and the old one is rejected, on both the S3 data
+79
View File
@@ -0,0 +1,79 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use http::header::HOST;
use reqwest::StatusCode;
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4;
use s3s::Body;
use serde::Deserialize;
use std::error::Error;
#[derive(Debug, Deserialize)]
struct PoolListItem {
id: usize,
cmdline: String,
status: String,
}
async fn signed_admin_get(env: &RustFSTestEnvironment, path: &str) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let url = format!("{}{path}", env.url);
let uri = url.parse::<http::Uri>()?;
let authority = uri.authority().ok_or("admin URL missing authority")?.to_string();
let request = http::Request::builder()
.method(http::Method::GET)
.uri(uri)
.header(HOST, authority)
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD)
.body(Body::empty())?;
let signed = sign_v4(request, 0, &env.access_key, &env.secret_key, "", "us-east-1");
let mut request = local_http_client().get(&url);
for (name, value) in signed.headers() {
request = request.header(name, value);
}
Ok(request.send().await?)
}
#[tokio::test]
async fn single_drive_pools_list_succeeds_without_enabling_decommission_status() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let response = signed_admin_get(&env, "/rustfs/admin/v3/pools/list").await?;
let status = response.status();
let body = response.bytes().await?;
assert_eq!(status, StatusCode::OK, "pools list failed: {}", String::from_utf8_lossy(&body));
let pools: Vec<PoolListItem> = serde_json::from_slice(&body)?;
assert_eq!(pools.len(), 1);
assert_eq!(pools[0].id, 0);
assert_eq!(pools[0].cmdline, env.temp_dir);
assert_eq!(pools[0].status, "active");
let response = signed_admin_get(&env, "/rustfs/admin/v3/decommission/status").await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(
status,
StatusCode::NOT_IMPLEMENTED,
"decommission status changed for a single pool: {body}"
);
assert!(body.contains("NotImplemented"), "unexpected decommission error body: {body}");
Ok(())
}
@@ -170,3 +170,81 @@ async fn test_anonymous_access_allowed_when_restrict_public_buckets_disabled()
info!("Test passed: anonymous access allowed with RestrictPublicBuckets=false");
Ok(())
}
/// A policy granting anonymous `s3:ListBucket` also permits ListObjectVersions.
/// That grant must still be subject to RestrictPublicBuckets: the versions listing
/// reaches authorization through a fallback branch, and that branch has to apply the
/// same public-access gate as a direct grant.
#[tokio::test]
#[serial]
async fn ghsa_x298_anonymous_list_object_versions_denied_when_restrict_public_buckets_enabled()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting test: anonymous ListObjectVersions denied with RestrictPublicBuckets=true...");
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket_name = "anon-test-restrict-versions";
let admin_client = env.create_s3_client();
admin_client.create_bucket().bucket(bucket_name).send().await?;
let policy_json = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowAnonymousListBucket",
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:ListBucket"],
"Resource": [format!("arn:aws:s3:::{}", bucket_name)]
}
]
})
.to_string();
admin_client
.put_bucket_policy()
.bucket(bucket_name)
.policy(&policy_json)
.send()
.await?;
admin_client
.put_object()
.bucket(bucket_name)
.key("test.txt")
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"hello anonymous"))
.send()
.await?;
// Without the public-access block the fallback grant is expected to work.
let versions_url = format!("{}/{}?versions=", env.url, bucket_name);
let resp = local_http_client().get(&versions_url).send().await?;
assert_eq!(
resp.status().as_u16(),
200,
"Anonymous ListObjectVersions should succeed via the s3:ListBucket grant"
);
admin_client
.put_public_access_block()
.bucket(bucket_name)
.public_access_block_configuration(
PublicAccessBlockConfiguration::builder()
.restrict_public_buckets(true)
.build(),
)
.send()
.await?;
let resp = local_http_client().get(&versions_url).send().await?;
assert_eq!(
resp.status().as_u16(),
403,
"Anonymous ListObjectVersions must be denied when RestrictPublicBuckets is true"
);
info!("Test passed: anonymous ListObjectVersions denied with RestrictPublicBuckets=true");
Ok(())
}
@@ -86,6 +86,52 @@ async fn api_rate_limit_enforces_429_with_retry_after_when_enabled() -> TestResu
Ok(())
}
#[tokio::test]
#[serial]
async fn api_rate_limit_bucket_dimension_throttles_per_bucket() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
// Bucket dimension only: the readiness-poll ListBuckets calls hit "/"
// (no bucket) and therefore do not consume any budget.
env.start_rustfs_server_with_env(
vec![],
&[
("RUSTFS_API_RATE_LIMIT_ENABLE", "true"),
("RUSTFS_API_RATE_LIMIT_BUCKET_RPM", "60"),
("RUSTFS_API_RATE_LIMIT_BUCKET_BURST", "5"),
],
)
.await?;
let client = local_http_client();
// Unauthenticated GETs are still counted arrivals (403, not 429, while
// within budget); the sixth rapid hit on the same bucket must throttle.
let mut throttled = false;
for i in 0..6 {
let response = client.get(format!("{}/hot-bucket/object-{i}", env.url)).send().await?;
if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
throttled = true;
assert!(
response.headers().contains_key(reqwest::header::RETRY_AFTER),
"bucket-dimension 429 must carry Retry-After"
);
break;
}
}
assert!(throttled, "6 rapid requests against burst 5 must trip the bucket dimension");
// A different bucket has its own budget.
let other = client.get(format!("{}/cold-bucket/object", env.url)).send().await?;
assert_ne!(
other.status(),
reqwest::StatusCode::TOO_MANY_REQUESTS,
"an unrelated bucket must not be throttled"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn api_rate_limit_stays_inert_by_default() -> TestResult {
@@ -0,0 +1,108 @@
// 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.
//! Smoke tests for the multi-drive / multi-pool cluster harness.
//!
//! These belong to the nightly 4-node cluster lane (they spin up real RustFS
//! processes, so they are excluded from the merge-gate `e2e-full` profile in
//! `.config/nextest.toml`). They assert that:
//!
//! * `drivesPerNode > 1` produces a bootable single-pool cluster whose
//! `RUSTFS_VOLUMES` string enumerates every drive, and that a PUT/GET
//! round-trips through the multi-drive erasure layout.
//! * A two-pool topology (one node per pool, `drivesPerNode` drives each) boots
//! with the ellipses `RUSTFS_VOLUMES` form and round-trips a PUT/GET.
//!
//! Readiness is established by the harness's `start()` handshake (TCP reachability
//! plus an S3 `ListBuckets` poll) — there are no fixed sleeps.
//!
//! Out of scope for this block (tracked separately): network fault injection
//! (toxiproxy / socket proxy) and 5GiB large-object budgets.
use crate::common::{ClusterTopology, RustFSTestClusterEnvironment};
use serial_test::serial;
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
const BUCKET: &str = "cluster-multidrive-pool-bucket";
/// PUT an object then GET it back and assert the bytes round-trip.
async fn put_get_roundtrip(cluster: &RustFSTestClusterEnvironment, key: &str, payload: &[u8]) -> TestResult {
let writer = cluster.create_s3_client(0)?;
writer
.put_object()
.bucket(BUCKET)
.key(key)
.body(bytes::Bytes::copy_from_slice(payload).into())
.send()
.await?;
// Read back from the last node to exercise the cross-node/cross-pool path.
let reader = cluster.create_s3_client(cluster.nodes.len() - 1)?;
let got = reader.get_object().bucket(BUCKET).key(key).send().await?;
let body = got.body.collect().await?.into_bytes();
assert_eq!(body.as_ref(), payload, "round-tripped object bytes must match");
Ok(())
}
/// 4 nodes x 2 drives, single pool: the multi-drive layout boots and round-trips.
#[tokio::test]
#[serial]
async fn cluster_multidrive_single_pool_smoke() -> TestResult {
crate::common::init_logging();
let mut cluster = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(4, 2)).await?;
// The single-pool multi-drive layout must list every (node, drive) endpoint
// explicitly (8 endpoints, no ellipses) so the server keeps one legacy pool.
let volumes = cluster.rustfs_volumes_arg();
assert_eq!(volumes.split(' ').count(), 8, "expected 8 explicit endpoints, got: {volumes}");
assert!(!volumes.contains('{'), "single-pool layout must not use ellipses: {volumes}");
cluster.start().await?;
cluster.create_test_bucket(BUCKET).await?;
let payload = vec![0xA5u8; 512 * 1024];
put_get_roundtrip(&cluster, "multidrive/object", &payload).await?;
Ok(())
}
/// Two single-node pools, 2 drives each: the multi-pool layout boots and
/// round-trips. Every pool is a distinct erasure pool (`pool_idx` 0 and 1).
#[tokio::test]
#[serial]
async fn cluster_two_pool_smoke() -> TestResult {
crate::common::init_logging();
let mut cluster =
RustFSTestClusterEnvironment::with_topology(ClusterTopology::per_node_pools(2, vec![vec![0], vec![1]])).await?;
// The two-pool layout must emit one ellipses argument per pool.
let volumes = cluster.rustfs_volumes_arg();
let args: Vec<&str> = volumes.split(' ').collect();
assert_eq!(args.len(), 2, "expected two pool arguments, got: {volumes}");
assert!(
args.iter().all(|a| a.contains("/drive{0...1}")),
"each pool arg must use the drive ellipses form: {volumes}"
);
assert_eq!(cluster.nodes[0].pool_idx, 0);
assert_eq!(cluster.nodes[1].pool_idx, 1);
cluster.start().await?;
cluster.create_test_bucket(BUCKET).await?;
let payload = vec![0x5Au8; 256 * 1024];
put_get_roundtrip(&cluster, "twopool/object", &payload).await?;
Ok(())
}
+426 -17
View File
@@ -717,15 +717,166 @@ pub async fn awscurl_delete(
execute_awscurl(url, "DELETE", None, access_key, secret_key).await
}
/// Cluster topology declaration for a `RustFSTestClusterEnvironment`.
///
/// Describes how many nodes to launch, how many data drives each node exposes,
/// and how those nodes are grouped into erasure pools. The topology drives both
/// on-disk directory creation and the `RUSTFS_VOLUMES` string assembled for the
/// node processes.
///
/// # Single-host expressibility constraints
///
/// The harness runs every node on `127.0.0.1` with a distinct port, so the
/// `RUSTFS_VOLUMES` string can only use the two forms the server parser accepts
/// on a single machine (verified against `ecstore` `DisksLayout::from_volumes`):
///
/// * **Single pool** — every `(node, drive)` endpoint listed explicitly, no
/// ellipses. This is the legacy form and yields exactly one pool spanning all
/// nodes and drives (`DistErasure`). Any `drives_per_node >= 1` works.
/// * **Multiple pools** — one ellipses arg per pool. A pool argument is a single
/// URL template, so it can enumerate several drives on *one* host but cannot
/// enumerate multiple distinct-port hosts. A pool that spanned two localhost
/// nodes would need a host ellipses (`127.0.0.1:900{0...1}`), which forces the
/// *same* on-disk path across those ports and collides physically. Therefore
/// every pool in a multi-pool topology owns exactly one node. In addition, the
/// parser rejects a single-drive ellipses pool (`drive{0...0}`), so a
/// multi-pool topology requires `drives_per_node >= 2`.
///
/// Genuine multi-node pools (a pool striped across several hosts) require real
/// multi-host infrastructure and are deferred to the nightly cluster CI lane
/// (backlog #1313 / #1314); they are intentionally not expressible here.
#[derive(Clone, Debug)]
pub struct ClusterTopology {
/// Number of node processes to launch.
pub node_count: usize,
/// Number of independent data drives (directories) each node exposes.
pub drives_per_node: usize,
/// Pool membership as a list of node-index groups. An empty vector means a
/// single pool spanning every node.
pub pools: Vec<Vec<usize>>,
}
impl ClusterTopology {
/// Single pool, one drive per node — identical to the historical
/// `RustFSTestClusterEnvironment::new` layout.
pub fn single_pool(node_count: usize) -> Self {
Self {
node_count,
drives_per_node: 1,
pools: Vec::new(),
}
}
/// Single pool with `drives_per_node` drives on every node. The pool spans
/// all nodes and all drives (one `DistErasure` pool).
pub fn single_pool_multidrive(node_count: usize, drives_per_node: usize) -> Self {
Self {
node_count,
drives_per_node,
pools: Vec::new(),
}
}
/// Multi-pool topology where every pool owns exactly one node. `pools` lists
/// the node index backing each pool (e.g. `vec![vec![0], vec![1]]` for a
/// two-pool cluster). Requires `drives_per_node >= 2` (see the type-level
/// note on single-host expressibility).
pub fn per_node_pools(drives_per_node: usize, pools: Vec<Vec<usize>>) -> Self {
let node_count = pools.iter().flatten().copied().max().map(|m| m + 1).unwrap_or(0);
Self {
node_count,
drives_per_node,
pools,
}
}
/// Normalized pool membership: an empty `pools` becomes a single pool over
/// all node indices.
fn normalized_pools(&self) -> Vec<Vec<usize>> {
if self.pools.is_empty() {
vec![(0..self.node_count).collect()]
} else {
self.pools.clone()
}
}
/// Validate that the topology is expressible on a single localhost machine.
fn validate(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if self.node_count == 0 {
return Err("Node count must be greater than zero".into());
}
if self.drives_per_node == 0 {
return Err("drives_per_node must be greater than zero".into());
}
let pools = self.normalized_pools();
// Every referenced node index must be in range.
for (pool_idx, nodes) in pools.iter().enumerate() {
if nodes.is_empty() {
return Err(format!("pool {pool_idx} has no nodes").into());
}
for &n in nodes {
if n >= self.node_count {
return Err(format!("pool {pool_idx} references node {n} but node_count is {}", self.node_count).into());
}
}
}
// Each node must belong to exactly one pool.
let mut seen = vec![0usize; self.node_count];
for nodes in &pools {
for &n in nodes {
seen[n] += 1;
}
}
for (n, count) in seen.iter().enumerate() {
match count {
0 => return Err(format!("node {n} is not assigned to any pool").into()),
1 => {}
_ => return Err(format!("node {n} is assigned to more than one pool").into()),
}
}
// Multi-pool constraints imposed by the single-host RUSTFS_VOLUMES syntax.
if pools.len() > 1 {
if self.drives_per_node < 2 {
return Err(
"multi-pool topology requires drives_per_node >= 2 (the server parser rejects a single-drive ellipses pool)"
.into(),
);
}
for (pool_idx, nodes) in pools.iter().enumerate() {
if nodes.len() != 1 {
return Err(format!(
"pool {pool_idx} spans {} nodes; a pool striped across multiple localhost nodes is not expressible (needs host ellipses, which collides on shared paths). Use one node per pool, or the nightly multi-host lane (backlog #1313/#1314).",
nodes.len()
)
.into());
}
}
}
Ok(())
}
}
/// Represents a single RustFS server instance in a test cluster.
///
/// Each `ClusterNode` tracks the node's network address, base URL for
/// S3-compatible requests, on-disk data directory, and the underlying
/// S3-compatible requests, on-disk data directories, and the underlying
/// child process handle when the node is running.
pub struct ClusterNode {
pub address: String,
pub url: String,
/// Primary data directory for the node. For single-drive nodes this is the
/// node's only drive; for multi-drive nodes it is the first drive. Kept as a
/// stable field so existing single-drive tests continue to compile.
pub data_dir: String,
/// All data drives exposed by this node (`data_dirs[0] == data_dir`).
pub data_dirs: Vec<String>,
/// Index of the pool this node belongs to.
pub pool_idx: usize,
pub process: Option<Child>,
}
@@ -741,6 +892,8 @@ pub struct RustFSTestClusterEnvironment {
pub access_key: String,
pub secret_key: String,
pub extra_env: Vec<(String, String)>,
pub node_extra_env: Vec<Vec<(String, String)>>,
pub topology: ClusterTopology,
}
impl RustFSTestClusterEnvironment {
@@ -762,34 +915,84 @@ impl RustFSTestClusterEnvironment {
/// * `Err(Box<dyn Error + Send + Sync>)` - An error if any step fails, such as temporary
/// directory creation failure or available port lookup failure.
pub async fn new(node_count: usize) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
if node_count == 0 {
return Err("Node count must be greater than zero".into());
}
Self::with_topology(ClusterTopology::single_pool(node_count)).await
}
/// Create a RustFS test cluster environment from an explicit topology.
///
/// Allocates a unique temporary root directory, an available TCP port per
/// node, and one data directory per drive. The topology is validated up front
/// (node/pool assignment, single-host multi-pool constraints); node processes
/// are not started at this stage.
///
/// When the topology exposes more than one local drive on a node (multi-drive
/// or multi-pool layouts), `RUSTFS_UNSAFE_BYPASS_DISK_CHECK=true` is added to
/// every node's environment: those drives live on the same temp filesystem, so
/// the server's distinct-physical-disk safety check would otherwise reject
/// startup. Single-drive single-pool clusters keep the historical environment
/// unchanged.
pub async fn with_topology(topology: ClusterTopology) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
topology.validate()?;
let temp_dir = format!("/tmp/rustfs_cluster_test_{}", Uuid::new_v4());
fs::create_dir_all(&temp_dir).await?;
let mut nodes = Vec::with_capacity(node_count);
for i in 0..node_count {
// Map every node to its owning pool index.
let pools = topology.normalized_pools();
let mut pool_of_node = vec![0usize; topology.node_count];
for (pool_idx, nodes) in pools.iter().enumerate() {
for &n in nodes {
pool_of_node[n] = pool_idx;
}
}
let multidrive = topology.drives_per_node > 1;
let mut nodes = Vec::with_capacity(topology.node_count);
for (i, &pool_idx) in pool_of_node.iter().enumerate() {
let port = RustFSTestEnvironment::find_available_port().await?;
let address = format!("127.0.0.1:{}", port);
let url = format!("http://{}", address);
let data_dir = format!("{}/node{}", temp_dir, i);
fs::create_dir_all(&data_dir).await?;
// Single-drive nodes keep the historical `{temp}/node{i}` path so
// existing tests (and their on-disk assertions) are unaffected.
// Multi-drive nodes nest one `drive{d}` directory per drive so the
// ellipses form `drive{0...N-1}` can address them.
let data_dirs: Vec<String> = if multidrive {
(0..topology.drives_per_node)
.map(|d| format!("{}/node{}/drive{}", temp_dir, i, d))
.collect()
} else {
vec![format!("{}/node{}", temp_dir, i)]
};
for dir in &data_dirs {
fs::create_dir_all(dir).await?;
}
nodes.push(ClusterNode {
address,
url,
data_dir,
data_dir: data_dirs[0].clone(),
data_dirs,
pool_idx,
process: None,
});
}
let mut extra_env = Vec::new();
extra_env.push(("RUSTFS_RPC_SECRET".to_string(), String::new()));
if multidrive {
extra_env.push(("RUSTFS_UNSAFE_BYPASS_DISK_CHECK".to_string(), "true".to_string()));
}
Ok(Self {
nodes,
temp_dir,
access_key: DEFAULT_ACCESS_KEY.to_string(),
secret_key: DEFAULT_SECRET_KEY.to_string(),
extra_env: Vec::new(),
access_key: "rustfs-cluster-test-access".to_string(),
secret_key: "rustfs-cluster-test-secret".to_string(),
extra_env,
node_extra_env: vec![Vec::new(); topology.node_count],
topology,
})
}
@@ -802,6 +1005,22 @@ impl RustFSTestClusterEnvironment {
self.extra_env.push((key.into(), value.into()));
}
/// Add an extra environment variable applied to a single cluster node.
pub fn set_node_env<K, V>(
&mut self,
node_idx: usize,
key: K,
value: V,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
where
K: Into<String>,
V: Into<String>,
{
self.ensure_node_index(node_idx)?;
self.node_extra_env[node_idx].push((key.into(), value.into()));
Ok(())
}
fn ensure_node_index(&self, node_idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if node_idx >= self.nodes.len() {
return Err(format!("node_idx {node_idx} is invalid").into());
@@ -809,14 +1028,51 @@ impl RustFSTestClusterEnvironment {
Ok(())
}
/// Build the volumes argument string for RustFS binary (internal helper method).
/// Build the `RUSTFS_VOLUMES` argument string for the cluster's topology.
///
/// Concatenates the address and data directory of all cluster nodes into a single string
/// used as the `RUSTFS_VOLUMES` environment variable for RustFS node processes.
/// * **Single pool** — every `(node, drive)` endpoint is listed explicitly and
/// space-joined. With no ellipses the server parser collapses them into one
/// legacy pool spanning all nodes and drives.
/// * **Multiple pools** — each pool (exactly one node) contributes one ellipses
/// argument `http://<addr><node-base>/drive{0...N-1}`; the space-separated
/// arguments become one pool each.
///
/// The server splits `RUSTFS_VOLUMES` on spaces (`value_delimiter = ' '`), which
/// this assembly matches, and the resulting layout is verified against the
/// `ecstore` parser in the unit tests below.
/// Public view of the assembled `RUSTFS_VOLUMES` string, so tests can assert
/// the pool/drive layout without starting node processes.
pub fn rustfs_volumes_arg(&self) -> String {
self.build_volumes_arg()
}
fn build_volumes_arg(&self) -> String {
self.nodes
let pools = self.topology.normalized_pools();
if pools.len() <= 1 {
// Single pool: explicit enumeration of every drive on every node.
return self
.nodes
.iter()
.flat_map(|n| n.data_dirs.iter().map(move |dir| format!("http://{}{}", n.address, dir)))
.collect::<Vec<_>>()
.join(" ");
}
// Multi-pool: one ellipses argument per single-node pool. The drive
// directories are `<temp>/node{i}/drive{0..N-1}`, so the ellipses base is
// the shared parent of the node's drives.
pools
.iter()
.map(|n| format!("http://{}{}", n.address, n.data_dir))
.map(|nodes| {
let node = &self.nodes[nodes[0]];
let base = node
.data_dirs
.first()
.and_then(|d| d.rsplit_once('/').map(|(parent, _)| parent))
.unwrap_or(&node.data_dir);
format!("http://{}{}/drive{{0...{}}}", node.address, base, self.topology.drives_per_node - 1)
})
.collect::<Vec<_>>()
.join(" ")
}
@@ -851,6 +1107,9 @@ impl RustFSTestClusterEnvironment {
for (key, value) in &self.extra_env {
command.env(key, value);
}
for (key, value) in &self.node_extra_env[i] {
command.env(key, value);
}
let process = command.current_dir(&node.data_dir).spawn()?;
@@ -892,6 +1151,9 @@ impl RustFSTestClusterEnvironment {
for (key, value) in &self.extra_env {
command.env(key, value);
}
for (key, value) in &self.node_extra_env[node_idx] {
command.env(key, value);
}
let process = command.current_dir(&node.data_dir).spawn()?;
node.process = Some(process);
@@ -1091,4 +1353,151 @@ mod tests {
stdfs::remove_file(stamp_path).ok();
}
/// Build a cluster environment struct in-memory (no ports, no processes) so
/// that `build_volumes_arg` can be exercised as a pure string builder. Node
/// directories mirror what `with_topology` would create for the topology.
fn fake_cluster(topology: ClusterTopology) -> RustFSTestClusterEnvironment {
let temp_dir = "/tmp/rustfs_cluster_test_FAKE".to_string();
let pools = topology.normalized_pools();
let mut pool_of_node = vec![0usize; topology.node_count];
for (pool_idx, nodes) in pools.iter().enumerate() {
for &n in nodes {
pool_of_node[n] = pool_idx;
}
}
let multidrive = topology.drives_per_node > 1;
let nodes = (0..topology.node_count)
.map(|i| {
let address = format!("127.0.0.1:{}", 9000 + i);
let data_dirs: Vec<String> = if multidrive {
(0..topology.drives_per_node)
.map(|d| format!("{}/node{}/drive{}", temp_dir, i, d))
.collect()
} else {
vec![format!("{}/node{}", temp_dir, i)]
};
ClusterNode {
url: format!("http://{}", address),
address,
data_dir: data_dirs[0].clone(),
data_dirs,
pool_idx: pool_of_node[i],
process: None,
}
})
.collect();
RustFSTestClusterEnvironment {
nodes,
temp_dir,
access_key: DEFAULT_ACCESS_KEY.to_string(),
secret_key: DEFAULT_SECRET_KEY.to_string(),
extra_env: Vec::new(),
node_extra_env: vec![Vec::new(); topology.node_count],
topology,
}
}
#[test]
fn volumes_single_pool_single_drive_is_backward_compatible() {
// The historical layout: one explicit endpoint per node, space-joined,
// no ellipses, no `drive` sub-directory.
let env = fake_cluster(ClusterTopology::single_pool(4));
assert_eq!(
env.build_volumes_arg(),
"http://127.0.0.1:9000/tmp/rustfs_cluster_test_FAKE/node0 \
http://127.0.0.1:9001/tmp/rustfs_cluster_test_FAKE/node1 \
http://127.0.0.1:9002/tmp/rustfs_cluster_test_FAKE/node2 \
http://127.0.0.1:9003/tmp/rustfs_cluster_test_FAKE/node3"
);
}
#[test]
fn volumes_single_pool_multidrive_enumerates_every_drive() {
// 4 nodes x 2 drives -> 8 explicit endpoints, one legacy pool. No
// ellipses, so the server parser keeps this as a single DistErasure pool.
let env = fake_cluster(ClusterTopology::single_pool_multidrive(4, 2));
let expected = (0..4)
.flat_map(|i| {
(0..2).map(move |d| format!("http://127.0.0.1:{}/tmp/rustfs_cluster_test_FAKE/node{}/drive{}", 9000 + i, i, d))
})
.collect::<Vec<_>>()
.join(" ");
assert_eq!(env.build_volumes_arg(), expected);
assert_eq!(env.build_volumes_arg().split(' ').count(), 8);
}
#[test]
fn volumes_two_pool_uses_one_ellipses_arg_per_pool() {
// Two single-node pools, 2 drives each -> two ellipses arguments. The
// server parser treats each space-separated ellipses arg as its own pool.
let env = fake_cluster(ClusterTopology::per_node_pools(2, vec![vec![0], vec![1]]));
assert_eq!(
env.build_volumes_arg(),
"http://127.0.0.1:9000/tmp/rustfs_cluster_test_FAKE/node0/drive{0...1} \
http://127.0.0.1:9001/tmp/rustfs_cluster_test_FAKE/node1/drive{0...1}"
);
}
#[test]
fn topology_rejects_multi_node_pool() {
// A pool striped across two localhost nodes is not expressible.
let err = ClusterTopology::per_node_pools(2, vec![vec![0, 1], vec![2, 3]])
.validate()
.unwrap_err()
.to_string();
assert!(err.contains("not expressible"), "unexpected error: {err}");
}
#[test]
fn topology_rejects_single_drive_multi_pool() {
// The server parser rejects a single-drive ellipses pool (`drive{0...0}`).
let err = ClusterTopology::per_node_pools(1, vec![vec![0], vec![1]])
.validate()
.unwrap_err()
.to_string();
assert!(err.contains("drives_per_node >= 2"), "unexpected error: {err}");
}
#[test]
fn topology_rejects_unassigned_and_duplicated_nodes() {
// Node 2 is never assigned to a pool.
let mut t = ClusterTopology::single_pool_multidrive(3, 2);
t.pools = vec![vec![0], vec![1]];
assert!(t.validate().unwrap_err().to_string().contains("not assigned"));
// Node 0 assigned twice.
let mut t = ClusterTopology::single_pool_multidrive(2, 2);
t.pools = vec![vec![0], vec![0]];
assert!(t.validate().unwrap_err().to_string().contains("more than one pool"));
}
#[test]
fn topology_single_pool_accepts_any_drive_count() {
assert!(ClusterTopology::single_pool(4).validate().is_ok());
assert!(ClusterTopology::single_pool_multidrive(4, 4).validate().is_ok());
assert!(ClusterTopology::single_pool_multidrive(1, 1).validate().is_ok());
}
#[test]
fn cluster_node_env_supports_per_node_overrides() {
let mut env = fake_cluster(ClusterTopology::single_pool(4));
env.set_node_env(2, "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY", "true").unwrap();
assert_eq!(
env.node_extra_env[2].as_slice(),
[("RUSTFS_INTERNODE_RPC_MSGPACK_ONLY".to_string(), "true".to_string())]
);
}
#[test]
fn cluster_node_env_rejects_invalid_index() {
let mut env = fake_cluster(ClusterTopology::single_pool(4));
let err = env
.set_node_env(4, "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY", "true")
.unwrap_err()
.to_string();
assert!(err.contains("invalid"), "unexpected error: {err}");
}
}
+127
View File
@@ -0,0 +1,127 @@
// 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.
//! E2E coverage for the opt-in global connection cap on the main API listener
//! (backlog#1191 follow-up, `RUSTFS_API_MAX_CONNECTIONS`): permits must be
//! released when connections close (no leak), and the cap must actually bound
//! concurrency — a queued connection is served only after a held one closes.
use crate::common::{RustFSTestEnvironment, init_logging};
use serial_test::serial;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::timeout;
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
/// Open a TCP connection and write one unauthenticated `GET /` (any response,
/// e.g. 403, proves the connection was accepted and served).
async fn open_and_request(addr: &str, connection: &str) -> std::io::Result<TcpStream> {
let mut stream = TcpStream::connect(addr).await?;
let request = format!("GET / HTTP/1.1\r\nHost: {addr}\r\nConnection: {connection}\r\n\r\n");
stream.write_all(request.as_bytes()).await?;
Ok(stream)
}
/// Read until the response head is complete, or `None` on timeout/close —
/// a `None` on an open socket means the connection sits unaccepted in the
/// kernel backlog behind the cap.
async fn read_response_head(stream: &mut TcpStream, dur: Duration) -> Option<String> {
let deadline = tokio::time::Instant::now() + dur;
let mut buf = vec![0u8; 4096];
let mut collected = String::new();
loop {
let remaining = deadline.checked_duration_since(tokio::time::Instant::now())?;
match timeout(remaining, stream.read(&mut buf)).await {
Ok(Ok(0)) | Ok(Err(_)) | Err(_) => return None,
Ok(Ok(n)) => {
collected.push_str(&String::from_utf8_lossy(&buf[..n]));
if collected.contains("\r\n\r\n") {
return Some(collected);
}
}
}
}
}
#[tokio::test]
#[serial]
async fn connection_cap_releases_permits_on_close() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_API_MAX_CONNECTIONS", "2")])
.await?;
// Ten sequential connections against cap 2: if permits leaked, the third
// request would already hang in the backlog and time out.
for i in 0..10 {
let mut stream = open_and_request(&env.address, "close").await?;
let head = read_response_head(&mut stream, Duration::from_secs(10))
.await
.unwrap_or_else(|| panic!("request {i} got no response — a connection permit leaked"));
assert!(head.starts_with("HTTP/1.1"), "request {i} unexpected response: {head}");
}
Ok(())
}
/// Open a TCP connection and send an INCOMPLETE request head. Once accepted
/// it pins a connection permit: hyper waits for the rest of the head (75s
/// default header timeout) until we close the socket.
async fn open_and_stall(addr: &str) -> std::io::Result<TcpStream> {
let mut stream = TcpStream::connect(addr).await?;
stream
.write_all(format!("GET / HTTP/1.1\r\nHost: {addr}\r\n").as_bytes())
.await?;
Ok(stream)
}
#[tokio::test]
#[serial]
async fn connection_cap_blocks_excess_connections_until_permits_free() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_API_MAX_CONNECTIONS", "2")])
.await?;
// Let the readiness poller's pooled connection close and free its permit.
tokio::time::sleep(Duration::from_secs(1)).await;
// Two stalled connections saturate cap 2 (a served-and-closed connection
// would release its permit immediately, so stalling is what makes the
// occupancy deterministic).
let stalled_a = open_and_stall(&env.address).await?;
let stalled_b = open_and_stall(&env.address).await?;
tokio::time::sleep(Duration::from_millis(300)).await;
// A complete request now sits in the kernel backlog: connect() succeeds
// but no permit is available, so no response arrives.
let mut blocked = open_and_request(&env.address, "close").await?;
assert!(
read_response_head(&mut blocked, Duration::from_secs(3)).await.is_none(),
"cap 2 with two stalled connections must leave the third unserved"
);
// Dropping the stalled connections releases their permits (hyper sees
// EOF while reading the head); the queued request's bytes already sit in
// the socket buffer, so it must now be accepted and served.
drop(stalled_a);
drop(stalled_b);
let head = read_response_head(&mut blocked, Duration::from_secs(10))
.await
.expect("queued connection must be served after permits are released");
assert!(head.starts_with("HTTP/1.1"), "unexpected response: {head}");
Ok(())
}
@@ -80,6 +80,25 @@ mod tests {
assert_eq!(head_resp.content_encoding(), Some("zstd"), "HEAD should return Content-Encoding: zstd");
assert_eq!(head_resp.content_type(), Some("text/plain"), "HEAD should return correct Content-Type");
client
.delete_object()
.bucket(bucket)
.key(key)
.send()
.await
.expect("DELETE object failed");
client
.delete_bucket()
.bucket(bucket)
.send()
.await
.expect("DELETE bucket failed");
client
.list_buckets()
.send()
.await
.expect("RustFS must remain available after deleting a bucket");
env.stop_server();
}
@@ -0,0 +1,708 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! CopyObject checksum compatibility tests. Covers all supported algorithms,
//! source-checksum preservation, explicit override, and fail-closed handling of
//! unsupported algorithms before destination mutation.
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::config::{Credentials, Region, RequestChecksumCalculation};
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
BucketVersioningStatus, ChecksumAlgorithm, ChecksumMode, ChecksumType, CompletedMultipartUpload, CompletedPart,
VersioningConfiguration,
};
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
use serial_test::serial;
use sha2::{Digest, Sha256};
use tracing::info;
async fn create_versioned_bucket(client: &aws_sdk_s3::Client, bucket: &str) {
client
.create_bucket()
.bucket(bucket)
.send()
.await
.expect("Failed to create bucket");
client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await
.expect("Failed to enable versioning");
}
fn create_s3_client_no_auto_checksum(env: &RustFSTestEnvironment) -> aws_sdk_s3::Client {
let credentials = Credentials::new(&env.access_key, &env.secret_key, None, None, "copy-checksum-e2e");
let config = aws_sdk_s3::Config::builder()
.credentials_provider(credentials)
.region(Region::new("us-east-1"))
.endpoint_url(format!("http://{}", env.address))
.force_path_style(true)
.behavior_version_latest()
.request_checksum_calculation(RequestChecksumCalculation::WhenRequired)
.http_client(SmithyHttpClientBuilder::new().build_http())
.build();
aws_sdk_s3::Client::from_conf(config)
}
fn algorithms() -> [(ChecksumAlgorithm, RioChecksumType); 10] {
[
(ChecksumAlgorithm::Crc32, RioChecksumType::CRC32),
(ChecksumAlgorithm::Crc32C, RioChecksumType::CRC32C),
(ChecksumAlgorithm::Crc64Nvme, RioChecksumType::CRC64_NVME),
(ChecksumAlgorithm::Sha1, RioChecksumType::SHA1),
(ChecksumAlgorithm::Sha256, RioChecksumType::SHA256),
(ChecksumAlgorithm::Md5, RioChecksumType::MD5),
(ChecksumAlgorithm::Sha512, RioChecksumType::SHA512),
(ChecksumAlgorithm::Xxhash3, RioChecksumType::XXHASH3),
(ChecksumAlgorithm::Xxhash64, RioChecksumType::XXHASH64),
(ChecksumAlgorithm::Xxhash128, RioChecksumType::XXHASH128),
]
}
fn result_checksums(result: &aws_sdk_s3::types::CopyObjectResult) -> [Option<&str>; 10] {
[
result.checksum_crc32(),
result.checksum_crc32_c(),
result.checksum_crc64_nvme(),
result.checksum_sha1(),
result.checksum_sha256(),
result.checksum_md5(),
result.checksum_sha512(),
result.checksum_xxhash3(),
result.checksum_xxhash64(),
result.checksum_xxhash128(),
]
}
fn head_checksums(output: &aws_sdk_s3::operation::head_object::HeadObjectOutput) -> [Option<&str>; 10] {
[
output.checksum_crc32(),
output.checksum_crc32_c(),
output.checksum_crc64_nvme(),
output.checksum_sha1(),
output.checksum_sha256(),
output.checksum_md5(),
output.checksum_sha512(),
output.checksum_xxhash3(),
output.checksum_xxhash64(),
output.checksum_xxhash128(),
]
}
#[tokio::test]
#[serial]
async fn test_copy_supports_all_checksum_algorithms() {
init_logging();
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = create_s3_client_no_auto_checksum(&env);
let src_bucket = "copy-all-checksums-src";
let dst_bucket = "copy-all-checksums-dst";
let src_key = "objects/source.bin";
let content = b"deterministic CopyObject payload for all ten checksum algorithms";
create_versioned_bucket(&client, src_bucket).await;
create_versioned_bucket(&client, dst_bucket).await;
client
.put_object()
.bucket(src_bucket)
.key(src_key)
.body(ByteStream::from_static(content))
.send()
.await
.expect("PUT source failed");
for (index, (sdk_algorithm, rio_algorithm)) in algorithms().into_iter().enumerate() {
let expected = Checksum::new_from_data(rio_algorithm, content)
.expect("supported checksum must be computable")
.encoded;
let dst_key = format!("objects/destination-{index}.bin");
let copy = client
.copy_object()
.bucket(dst_bucket)
.key(&dst_key)
.copy_source(format!("{src_bucket}/{src_key}"))
.checksum_algorithm(sdk_algorithm)
.send()
.await
.expect("CopyObject with supported checksum must succeed");
let result = copy.copy_object_result().expect("CopyObject result");
let checksums = result_checksums(result);
assert_eq!(checksums[index], Some(expected.as_str()), "{rio_algorithm}: response checksum");
assert_eq!(
checksums.iter().filter(|checksum| checksum.is_some()).count(),
1,
"{rio_algorithm}: only the requested checksum may be returned"
);
let head = client
.head_object()
.bucket(dst_bucket)
.key(&dst_key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("HEAD destination failed");
let checksums = head_checksums(&head);
assert_eq!(checksums[index], Some(expected.as_str()), "{rio_algorithm}: persisted checksum");
assert_eq!(
checksums.iter().filter(|checksum| checksum.is_some()).count(),
1,
"{rio_algorithm}: destination must persist only the requested checksum"
);
let body = client
.get_object()
.bucket(dst_bucket)
.key(&dst_key)
.send()
.await
.expect("GET destination failed")
.body
.collect()
.await
.expect("collect destination body")
.into_bytes();
assert_eq!(body.as_ref(), content, "{rio_algorithm}: full copied body");
}
env.stop_server();
}
#[tokio::test]
#[serial]
async fn test_copy_without_algorithm_preserves_every_supported_source_checksum() {
init_logging();
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = create_s3_client_no_auto_checksum(&env);
let src_bucket = "copy-preserve-all-src";
let dst_bucket = "copy-preserve-all-dst";
let content = b"source checksum preservation payload for all ten algorithms";
create_versioned_bucket(&client, src_bucket).await;
create_versioned_bucket(&client, dst_bucket).await;
for (index, (_sdk_algorithm, rio_algorithm)) in algorithms().into_iter().enumerate() {
let expected = Checksum::new_from_data(rio_algorithm, content)
.expect("supported checksum must be computable")
.encoded;
let checksum_header = rio_algorithm.key().expect("supported checksum header");
let request_checksum = expected.clone();
let src_key = format!("objects/source-{index}.bin");
let dst_key = format!("objects/destination-{index}.bin");
client
.put_object()
.bucket(src_bucket)
.key(&src_key)
.body(ByteStream::from_static(content))
.customize()
.mutate_request(move |request| {
request.headers_mut().insert(checksum_header, request_checksum.clone());
})
.send()
.await
.expect("PUT checksummed source failed");
let copy = client
.copy_object()
.bucket(dst_bucket)
.key(&dst_key)
.copy_source(format!("{src_bucket}/{src_key}"))
.send()
.await
.expect("CopyObject without algorithm must succeed");
let result = copy.copy_object_result().expect("CopyObject result");
let checksums = result_checksums(result);
assert_eq!(checksums[index], Some(expected.as_str()), "{rio_algorithm}: preserved response checksum");
assert_eq!(checksums.iter().filter(|checksum| checksum.is_some()).count(), 1);
let head = client
.head_object()
.bucket(dst_bucket)
.key(&dst_key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("HEAD destination failed");
let checksums = head_checksums(&head);
assert_eq!(checksums[index], Some(expected.as_str()), "{rio_algorithm}: preserved stored checksum");
assert_eq!(checksums.iter().filter(|checksum| checksum.is_some()).count(), 1);
}
env.stop_server();
}
#[tokio::test]
#[serial]
async fn test_copy_without_algorithm_preserves_composite_checksum_type() {
init_logging();
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = create_s3_client_no_auto_checksum(&env);
let bucket = "copy-preserve-composite";
let source_key = "objects/multipart-source.bin";
let destination_key = "objects/copied-multipart.bin";
let content = b"multipart source checksum must remain composite";
create_versioned_bucket(&client, bucket).await;
let created = client
.create_multipart_upload()
.bucket(bucket)
.key(source_key)
.checksum_algorithm(ChecksumAlgorithm::Sha256)
.send()
.await
.expect("CreateMultipartUpload failed");
let upload_id = created.upload_id().expect("multipart upload ID");
let checksum = Checksum::new_from_data(RioChecksumType::SHA256, content)
.expect("SHA256 checksum")
.encoded;
let uploaded = client
.upload_part()
.bucket(bucket)
.key(source_key)
.upload_id(upload_id)
.part_number(1)
.checksum_sha256(&checksum)
.body(ByteStream::from_static(content))
.send()
.await
.expect("UploadPart failed");
let completed_part = CompletedPart::builder()
.part_number(1)
.e_tag(uploaded.e_tag().expect("part ETag"))
.checksum_sha256(uploaded.checksum_sha256().expect("part checksum"))
.build();
client
.complete_multipart_upload()
.bucket(bucket)
.key(source_key)
.upload_id(upload_id)
.multipart_upload(CompletedMultipartUpload::builder().parts(completed_part).build())
.send()
.await
.expect("CompleteMultipartUpload failed");
let source_head = client
.head_object()
.bucket(bucket)
.key(source_key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("HEAD multipart source failed");
let source_checksum = source_head.checksum_sha256().expect("multipart source checksum");
assert_eq!(source_head.checksum_type(), Some(&ChecksumType::Composite));
let copied = client
.copy_object()
.bucket(bucket)
.key(destination_key)
.copy_source(format!("{bucket}/{source_key}"))
.send()
.await
.expect("CopyObject without algorithm failed");
let result = copied.copy_object_result().expect("CopyObject result");
assert_eq!(result.checksum_sha256(), Some(source_checksum));
assert_eq!(result.checksum_type(), Some(&ChecksumType::Composite));
let destination_head = client
.head_object()
.bucket(bucket)
.key(destination_key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("HEAD copied multipart object failed");
assert_eq!(destination_head.checksum_sha256(), Some(source_checksum));
assert_eq!(destination_head.checksum_type(), Some(&ChecksumType::Composite));
env.stop_server();
}
#[tokio::test]
#[serial]
async fn test_copy_rejects_unknown_algorithm_without_destination_mutation() {
init_logging();
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = env.create_s3_client();
let bucket = "copy-reject-unknown-checksum";
let src_key = "objects/source.bin";
let dst_key = "objects/destination.bin";
let source = b"source must never replace destination";
let destination = b"pre-existing destination must remain byte-for-byte unchanged";
let expected = Checksum::new_from_data(RioChecksumType::SHA256, destination)
.expect("SHA256 checksum")
.encoded;
create_versioned_bucket(&client, bucket).await;
client
.put_object()
.bucket(bucket)
.key(src_key)
.body(ByteStream::from_static(source))
.send()
.await
.expect("PUT source failed");
let original = client
.put_object()
.bucket(bucket)
.key(dst_key)
.metadata("state", "original")
.checksum_algorithm(ChecksumAlgorithm::Sha256)
.body(ByteStream::from_static(destination))
.send()
.await
.expect("PUT destination failed");
let original_version = original.version_id().expect("versioned PUT must return a version id");
let missing_source_error = client
.copy_object()
.bucket(bucket)
.key(dst_key)
.copy_source(format!("{bucket}/objects/missing-source.bin"))
.checksum_algorithm(ChecksumAlgorithm::from("BLAKE3"))
.send()
.await
.expect_err("checksum validation must precede source lookup");
assert_eq!(
missing_source_error.as_service_error().and_then(|value| value.code()),
Some("InvalidArgument")
);
assert_eq!(missing_source_error.raw_response().map(|response| response.status().as_u16()), Some(400));
let error = client
.copy_object()
.bucket(bucket)
.key(dst_key)
.copy_source(format!("{bucket}/{src_key}"))
.checksum_algorithm(ChecksumAlgorithm::from("BLAKE3"))
.send()
.await
.expect_err("unsupported checksum algorithm must fail");
assert_eq!(error.as_service_error().and_then(|value| value.code()), Some("InvalidArgument"));
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(400));
let head = client
.head_object()
.bucket(bucket)
.key(dst_key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("HEAD unchanged destination");
assert_eq!(head.version_id(), Some(original_version));
assert_eq!(
head.metadata().and_then(|metadata| metadata.get("state").map(String::as_str)),
Some("original")
);
assert_eq!(head.checksum_sha256(), Some(expected.as_str()));
let body = client
.get_object()
.bucket(bucket)
.key(dst_key)
.send()
.await
.expect("GET unchanged destination")
.body
.collect()
.await
.expect("collect unchanged destination")
.into_bytes();
assert_eq!(body.as_ref(), destination);
env.stop_server();
}
/// Requested algorithm: a CopyObject asking for SHA256 must compute it over the copied
/// bytes, return it in `CopyObjectResult.ChecksumSHA256`, and persist it so a checksum-mode
/// HEAD on the destination returns the identical value.
#[tokio::test]
#[serial]
async fn test_copy_with_checksum_algorithm_returns_and_persists_sha256() {
init_logging();
info!("Issue #4996: CopyObject with ChecksumAlgorithm=SHA256 must return and persist the checksum");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = env.create_s3_client();
let src_bucket = "copy-checksum-req-src";
let dst_bucket = "copy-checksum-req-dst";
let src_key = "objects/source.bin";
let dst_key = "objects/dest.bin";
create_versioned_bucket(&client, src_bucket).await;
create_versioned_bucket(&client, dst_bucket).await;
let content = b"deterministic synthetic payload for copy-object checksum #4996";
let expected_sha256 = BASE64.encode(Sha256::digest(content));
client
.put_object()
.bucket(src_bucket)
.key(src_key)
.body(ByteStream::from_static(content))
.send()
.await
.expect("PUT source failed");
let copy_out = client
.copy_object()
.bucket(dst_bucket)
.key(dst_key)
.copy_source(format!("{src_bucket}/{src_key}"))
.checksum_algorithm(ChecksumAlgorithm::Sha256)
.send()
.await
.expect("CopyObject with ChecksumAlgorithm must succeed");
// (3) The response must carry the freshly computed SHA-256 of the copied bytes.
let result = copy_out
.copy_object_result()
.expect("issue #4996: CopyObject must return a CopyObjectResult");
assert_eq!(
result.checksum_sha256(),
Some(expected_sha256.as_str()),
"issue #4996: CopyObjectResult.ChecksumSHA256 must equal the SHA-256 of the copied bytes"
);
// (4) A checksum-mode HEAD on the destination must return the same SHA-256.
let head = client
.head_object()
.bucket(dst_bucket)
.key(dst_key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("HEAD destination failed");
assert_eq!(
head.checksum_sha256(),
Some(expected_sha256.as_str()),
"issue #4996: destination checksum-mode HEAD must return the same SHA-256 the copy reported"
);
env.stop_server();
}
/// No algorithm requested: when the source object already carries a checksum, the copy must
/// preserve it on the destination (AWS default), visible via a checksum-mode HEAD.
#[tokio::test]
#[serial]
async fn test_copy_without_algorithm_preserves_source_checksum() {
init_logging();
info!("Issue #4996: CopyObject without ChecksumAlgorithm must preserve the source object's checksum");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = env.create_s3_client();
let src_bucket = "copy-checksum-preserve-src";
let dst_bucket = "copy-checksum-preserve-dst";
let src_key = "objects/source.bin";
let dst_key = "objects/dest.bin";
create_versioned_bucket(&client, src_bucket).await;
create_versioned_bucket(&client, dst_bucket).await;
let content = b"another deterministic payload whose source checksum must survive the copy";
let expected_sha256 = BASE64.encode(Sha256::digest(content));
// Store the source WITH a SHA-256 checksum so it has one to preserve.
let put_src = client
.put_object()
.bucket(src_bucket)
.key(src_key)
.checksum_algorithm(ChecksumAlgorithm::Sha256)
.body(ByteStream::from_static(content))
.send()
.await
.expect("PUT source with checksum failed");
assert_eq!(
put_src.checksum_sha256(),
Some(expected_sha256.as_str()),
"source PUT must report the SHA-256 it stored"
);
// Copy WITHOUT specifying a checksum algorithm.
let copy_out = client
.copy_object()
.bucket(dst_bucket)
.key(dst_key)
.copy_source(format!("{src_bucket}/{src_key}"))
.send()
.await
.expect("CopyObject without ChecksumAlgorithm must succeed");
// The response should echo the preserved source checksum.
let result = copy_out
.copy_object_result()
.expect("issue #4996: CopyObject must return a CopyObjectResult");
assert_eq!(
result.checksum_sha256(),
Some(expected_sha256.as_str()),
"issue #4996: a no-algorithm copy must preserve and report the source object's SHA-256"
);
// And a checksum-mode HEAD on the destination must return that same preserved SHA-256.
let head = client
.head_object()
.bucket(dst_bucket)
.key(dst_key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("HEAD destination failed");
assert_eq!(
head.checksum_sha256(),
Some(expected_sha256.as_str()),
"issue #4996: destination checksum-mode HEAD must return the preserved source SHA-256"
);
env.stop_server();
}
/// Requested algorithm differs from the source's: a source stored with SHA256, copied while
/// requesting CRC32, must return/persist the freshly computed CRC32 and must NOT carry the
/// source's SHA256 through. Guards the request-over-source precedence and the destination's
/// checksum-not-inherited path, and exercises the CRC32 code path (a different branch of
/// ChecksumType::from_string than SHA256).
#[tokio::test]
#[serial]
async fn test_copy_requested_algorithm_overrides_source_checksum() {
init_logging();
info!("Issue #4996: a requested CopyObject checksum algorithm must override the source object's algorithm");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = env.create_s3_client();
let src_bucket = "copy-checksum-override-src";
let dst_bucket = "copy-checksum-override-dst";
let src_key = "objects/source.bin";
let ref_key = "objects/reference-crc32.bin";
let dst_key = "objects/dest.bin";
create_versioned_bucket(&client, src_bucket).await;
create_versioned_bucket(&client, dst_bucket).await;
let content = b"payload whose copy must be re-checksummed with a different algorithm";
let expected_sha256 = BASE64.encode(Sha256::digest(content));
// Source is stored WITH a SHA-256 checksum.
client
.put_object()
.bucket(src_bucket)
.key(src_key)
.checksum_algorithm(ChecksumAlgorithm::Sha256)
.body(ByteStream::from_static(content))
.send()
.await
.expect("PUT source with SHA256 failed");
// Establish the canonical CRC32 the server computes for this content via a reference PUT,
// so the copy's CRC32 can be asserted against an exact server-computed value.
let ref_put = client
.put_object()
.bucket(src_bucket)
.key(ref_key)
.checksum_algorithm(ChecksumAlgorithm::Crc32)
.body(ByteStream::from_static(content))
.send()
.await
.expect("reference PUT with CRC32 failed");
let expected_crc32 = ref_put
.checksum_crc32()
.expect("reference PUT must report a CRC32")
.to_string();
// Copy the SHA256 source while requesting CRC32.
let copy_out = client
.copy_object()
.bucket(dst_bucket)
.key(dst_key)
.copy_source(format!("{src_bucket}/{src_key}"))
.checksum_algorithm(ChecksumAlgorithm::Crc32)
.send()
.await
.expect("CopyObject requesting a different algorithm must succeed");
let result = copy_out
.copy_object_result()
.expect("issue #4996: CopyObject must return a CopyObjectResult");
// The requested CRC32 must be computed and returned.
assert_eq!(
result.checksum_crc32(),
Some(expected_crc32.as_str()),
"issue #4996: a requested CRC32 must be computed fresh over the copied bytes"
);
// The source's SHA256 must NOT leak through — the requested algorithm wins.
assert_eq!(
result.checksum_sha256(),
None,
"issue #4996: the source object's SHA256 must not be inherited when a different algorithm is requested"
);
assert_ne!(
result.checksum_crc32(),
Some(expected_sha256.as_str()),
"sanity: CRC32 field must not carry the SHA256 value"
);
// The destination must persist CRC32 (and only CRC32) for a checksum-mode HEAD.
let head = client
.head_object()
.bucket(dst_bucket)
.key(dst_key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("HEAD destination failed");
assert_eq!(
head.checksum_crc32(),
Some(expected_crc32.as_str()),
"issue #4996: destination checksum-mode HEAD must return the requested CRC32"
);
assert_eq!(
head.checksum_sha256(),
None,
"issue #4996: destination must not report the source's SHA256 after an override copy"
);
env.stop_server();
}
}
@@ -17,14 +17,17 @@
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::MetadataDirective;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::{ByteStream, DateTime, DateTimeFormat};
use aws_sdk_s3::types::{
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, MetadataDirective, StorageClass, VersioningConfiguration,
};
use serial_test::serial;
use tracing::info;
#[tokio::test]
#[serial]
async fn test_self_copy_replace_metadata_preserves_readable_object() {
async fn copy_object_standard_metadata_copy_replace_and_clear() {
init_logging();
info!("Issue #2789: self-copy metadata replacement must preserve object data");
@@ -35,6 +38,14 @@ mod tests {
let bucket = "self-copy-metadata-replace-test";
let key = "assets/chunk-2F3R7JUG.js";
let content = b"console.log('metadata replacement should keep object data readable');";
let source_expires = DateTime::from_secs(1_893_456_000);
let source_expires_http_date = source_expires
.fmt(DateTimeFormat::HttpDate)
.expect("Test timestamp should format as an HTTP date");
let replacement_expires = DateTime::from_secs(1_924_992_000);
let replacement_expires_http_date = replacement_expires
.fmt(DateTimeFormat::HttpDate)
.expect("Test timestamp should format as an HTTP date");
client
.create_bucket()
@@ -47,7 +58,14 @@ mod tests {
.put_object()
.bucket(bucket)
.key(key)
.cache_control("max-age=60")
.content_disposition("inline; filename=source.js")
.content_encoding("br")
.content_language("en-US")
.content_type("text/javascript; charset=utf-8")
.expires(source_expires)
.website_redirect_location("/source.html")
.storage_class(StorageClass::ReducedRedundancy)
.metadata("mtime", "1777992333")
.metadata("stale", "must-be-removed")
.body(ByteStream::from_static(content))
@@ -55,13 +73,120 @@ mod tests {
.await
.expect("PUT failed");
let copied_key = "assets/default-copy.js";
client
.copy_object()
.bucket(bucket)
.key(copied_key)
.copy_source(format!("{bucket}/{key}"))
.send()
.await
.expect("default CopyObject failed");
let copied_head = client
.head_object()
.bucket(bucket)
.key(copied_key)
.send()
.await
.expect("HEAD failed after default copy");
assert_eq!(copied_head.cache_control(), Some("max-age=60"));
assert_eq!(copied_head.content_disposition(), Some("inline; filename=source.js"));
assert_eq!(copied_head.content_encoding(), Some("br"));
assert_eq!(copied_head.content_language(), Some("en-US"));
assert_eq!(copied_head.content_type(), Some("text/javascript; charset=utf-8"));
assert_eq!(copied_head.expires_string(), Some(source_expires_http_date.as_str()));
assert_eq!(
copied_head.storage_class(),
None,
"CopyObject without a storage class should write STANDARD"
);
assert_eq!(
copied_head.website_redirect_location(),
Some("/source.html"),
"default CopyObject should preserve source metadata"
);
assert_eq!(
copied_head.metadata().and_then(|metadata| metadata.get("stale")),
Some(&"must-be-removed".to_string())
);
client
.copy_object()
.bucket(bucket)
.key("assets/explicit-copy.js")
.copy_source(format!("{bucket}/{key}"))
.metadata_directive(MetadataDirective::Copy)
.send()
.await
.expect("explicit COPY directive failed");
let explicit_copy_head = client
.head_object()
.bucket(bucket)
.key("assets/explicit-copy.js")
.send()
.await
.expect("HEAD failed after explicit COPY");
assert_eq!(explicit_copy_head.cache_control(), Some("max-age=60"));
assert_eq!(
explicit_copy_head.website_redirect_location(),
None,
"explicit COPY does not inherit website redirect metadata"
);
client
.copy_object()
.bucket(bucket)
.key("assets/explicit-copy-redirect.js")
.copy_source(format!("{bucket}/{key}"))
.metadata_directive(MetadataDirective::Copy)
.website_redirect_location("/explicit-copy.html")
.send()
.await
.expect("explicit COPY with redirect failed");
let explicit_redirect_head = client
.head_object()
.bucket(bucket)
.key("assets/explicit-copy-redirect.js")
.send()
.await
.expect("HEAD failed after explicit COPY with redirect");
assert_eq!(explicit_redirect_head.website_redirect_location(), Some("/explicit-copy.html"));
client
.copy_object()
.bucket(bucket)
.key("assets/explicit-storage-class.js")
.copy_source(format!("{bucket}/{key}"))
.storage_class(StorageClass::ReducedRedundancy)
.send()
.await
.expect("CopyObject with an explicit storage class failed");
let explicit_storage_class_head = client
.head_object()
.bucket(bucket)
.key("assets/explicit-storage-class.js")
.send()
.await
.expect("HEAD failed after explicit storage class copy");
assert_eq!(
explicit_storage_class_head.storage_class().map(StorageClass::as_str),
Some("REDUCED_REDUNDANCY")
);
client
.copy_object()
.bucket(bucket)
.key(key)
.copy_source(format!("{bucket}/{key}"))
.metadata_directive(MetadataDirective::Replace)
.content_type("text/javascript; charset=utf-8")
.cache_control("no-cache")
.content_disposition("attachment; filename=replaced.js")
.content_encoding("gzip")
.content_language("fr-FR")
.content_type("application/javascript")
.expires(replacement_expires)
.website_redirect_location("/replaced.html")
.metadata("mtime", "1777992348")
.send()
.await
@@ -85,6 +210,14 @@ mod tests {
None,
"HEAD should not return metadata omitted by REPLACE"
);
assert_eq!(head_resp.cache_control(), Some("no-cache"));
assert_eq!(head_resp.content_disposition(), Some("attachment; filename=replaced.js"));
assert_eq!(head_resp.content_encoding(), Some("gzip"));
assert_eq!(head_resp.content_language(), Some("fr-FR"));
assert_eq!(head_resp.content_type(), Some("application/javascript"));
assert_eq!(head_resp.expires_string(), Some(replacement_expires_http_date.as_str()));
assert_eq!(head_resp.website_redirect_location(), Some("/replaced.html"));
assert_eq!(head_resp.storage_class(), None, "REPLACE without a storage class should write STANDARD");
let get_resp = client
.get_object()
@@ -123,6 +256,13 @@ mod tests {
None,
"HEAD should not return metadata omitted by empty REPLACE"
);
assert_eq!(empty_head_resp.cache_control(), None);
assert_eq!(empty_head_resp.content_disposition(), None);
assert_eq!(empty_head_resp.content_encoding(), None);
assert_eq!(empty_head_resp.content_language(), None);
assert_eq!(empty_head_resp.content_type(), None);
assert_eq!(empty_head_resp.expires_string(), None);
assert_eq!(empty_head_resp.website_redirect_location(), None);
let empty_get_resp = client
.get_object()
@@ -141,4 +281,333 @@ mod tests {
env.stop_server();
}
#[tokio::test]
#[serial]
async fn copy_object_replace_accepts_each_standard_field_independently() {
init_logging();
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = env.create_s3_client();
let bucket = "copy-object-metadata-fields";
let source = "source.txt";
client
.create_bucket()
.bucket(bucket)
.send()
.await
.expect("Failed to create bucket");
client
.put_object()
.bucket(bucket)
.key(source)
.cache_control("source-cache")
.content_disposition("inline")
.content_encoding("br")
.content_language("en")
.content_type("text/source")
.expires(DateTime::from_secs(1_893_456_000))
.body(ByteStream::from_static(b"field-by-field"))
.send()
.await
.expect("PUT failed");
let replacement_expires = DateTime::from_secs(1_924_992_000);
let replacement_expires_http_date = replacement_expires
.fmt(DateTimeFormat::HttpDate)
.expect("Test timestamp should format as an HTTP date");
for field in [
"cache-control",
"content-disposition",
"content-encoding",
"content-language",
"content-type",
"expires",
"website-redirect",
] {
let destination = format!("{field}.txt");
let request = client
.copy_object()
.bucket(bucket)
.key(&destination)
.copy_source(format!("{bucket}/{source}"))
.metadata_directive(MetadataDirective::Replace);
let request = match field {
"cache-control" => request.cache_control("field-cache"),
"content-disposition" => request.content_disposition("attachment"),
"content-encoding" => request.content_encoding("gzip"),
"content-language" => request.content_language("de"),
"content-type" => request.content_type("text/field"),
"expires" => request.expires(replacement_expires),
"website-redirect" => request.website_redirect_location("/field.html"),
_ => unreachable!("field table contains only supported entries"),
};
request.send().await.expect("field-specific CopyObject failed");
let head = client
.head_object()
.bucket(bucket)
.key(&destination)
.send()
.await
.expect("HEAD failed");
assert_eq!(head.cache_control(), (field == "cache-control").then_some("field-cache"));
assert_eq!(head.content_disposition(), (field == "content-disposition").then_some("attachment"));
assert_eq!(head.content_encoding(), (field == "content-encoding").then_some("gzip"));
assert_eq!(head.content_language(), (field == "content-language").then_some("de"));
assert_eq!(head.content_type(), (field == "content-type").then_some("text/field"));
assert_eq!(
head.expires_string(),
(field == "expires").then_some(replacement_expires_http_date.as_str())
);
assert_eq!(head.website_redirect_location(), (field == "website-redirect").then_some("/field.html"));
}
client
.copy_object()
.bucket(bucket)
.key("user-metadata-collision.txt")
.copy_source(format!("{bucket}/{source}"))
.metadata_directive(MetadataDirective::Replace)
.metadata("content-type", "user-content-type")
.metadata("content-encoding", "user-content-encoding")
.send()
.await
.expect("CopyObject should preserve user metadata namespaces");
let collision_head = client
.head_object()
.bucket(bucket)
.key("user-metadata-collision.txt")
.send()
.await
.expect("HEAD failed for metadata collision case");
assert_eq!(collision_head.content_type(), None);
assert_eq!(collision_head.content_encoding(), None);
assert_eq!(
collision_head.metadata().and_then(|metadata| metadata.get("content-type")),
Some(&"user-content-type".to_string())
);
assert_eq!(
collision_head
.metadata()
.and_then(|metadata| metadata.get("content-encoding")),
Some(&"user-content-encoding".to_string())
);
env.stop_server();
}
#[tokio::test]
#[serial]
async fn copy_object_replace_handles_versioned_multipart_source() {
init_logging();
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = env.create_s3_client();
let bucket = "copy-object-metadata-multipart";
let source = "source.bin";
let multipart_body = b"multipart historical source";
client
.create_bucket()
.bucket(bucket)
.send()
.await
.expect("Failed to create bucket");
client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await
.expect("Failed to enable versioning");
let upload = client
.create_multipart_upload()
.bucket(bucket)
.key(source)
.content_type("application/source")
.send()
.await
.expect("Failed to create multipart upload");
let upload_id = upload.upload_id().expect("Multipart upload should return an ID");
let part = client
.upload_part()
.bucket(bucket)
.key(source)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from_static(multipart_body))
.send()
.await
.expect("Failed to upload multipart part");
let completed = client
.complete_multipart_upload()
.bucket(bucket)
.key(source)
.upload_id(upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.e_tag(part.e_tag().expect("Uploaded part should return an ETag"))
.build(),
)
.build(),
)
.send()
.await
.expect("Failed to complete multipart upload");
let historical_version = completed
.version_id()
.expect("Versioned multipart upload should return a version ID")
.to_string();
client
.put_object()
.bucket(bucket)
.key(source)
.body(ByteStream::from_static(b"new current version"))
.send()
.await
.expect("Failed to write current version");
let copy = client
.copy_object()
.bucket(bucket)
.key("restored.bin")
.copy_source(format!("{bucket}/{source}?versionId={historical_version}"))
.metadata_directive(MetadataDirective::Replace)
.content_type("application/replaced")
.send()
.await
.expect("Failed to copy historical multipart version");
assert_eq!(copy.copy_source_version_id(), Some(historical_version.as_str()));
let restored = client
.get_object()
.bucket(bucket)
.key("restored.bin")
.send()
.await
.expect("Failed to read copied multipart source");
assert_eq!(restored.content_type(), Some("application/replaced"));
assert_eq!(
restored
.body
.collect()
.await
.expect("Failed to collect restored body")
.into_bytes()
.as_ref(),
multipart_body
);
env.stop_server();
}
#[tokio::test]
#[serial]
async fn invalid_replacement_metadata_does_not_mutate_destination() {
init_logging();
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_REJECT_ARCHIVE_CONTENT_ENCODING", "true")])
.await
.expect("Failed to start RustFS");
let client = env.create_s3_client();
let bucket = "copy-object-invalid-metadata";
let key = "destination.zip";
client
.create_bucket()
.bucket(bucket)
.send()
.await
.expect("Failed to create bucket");
client
.put_object()
.bucket(bucket)
.key(key)
.content_type("application/zip")
.metadata("state", "original")
.body(ByteStream::from_static(b"original destination"))
.send()
.await
.expect("Failed to write destination");
let error = client
.copy_object()
.bucket(bucket)
.key(key)
.copy_source(format!("{bucket}/{key}"))
.metadata_directive(MetadataDirective::Replace)
.content_type("application/zip")
.content_encoding("gzip")
.send()
.await
.expect_err("Invalid replacement metadata should be rejected");
assert_eq!(error.as_service_error().and_then(|err| err.code()), Some("InvalidArgument"));
let invalid_directive = client
.copy_object()
.bucket(bucket)
.key(key)
.copy_source(format!("{bucket}/{key}"))
.customize()
.mutate_request(|request| {
request.headers_mut().insert("x-amz-metadata-directive", "UNKNOWN");
})
.send()
.await
.expect_err("Unknown metadata directives should be rejected");
assert_eq!(
invalid_directive.as_service_error().and_then(|error| error.code()),
Some("InvalidArgument")
);
let ignored_replacement = client
.copy_object()
.bucket(bucket)
.key(key)
.copy_source(format!("{bucket}/{key}"))
.content_type("application/ignored")
.send()
.await
.expect_err("Replacement fields without REPLACE should be rejected");
assert_eq!(
ignored_replacement.as_service_error().and_then(|error| error.code()),
Some("InvalidRequest")
);
let unchanged = client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await
.expect("Destination should remain readable");
assert_eq!(unchanged.content_type(), Some("application/zip"));
assert_eq!(
unchanged.metadata().and_then(|metadata| metadata.get("state")),
Some(&"original".to_string())
);
assert_eq!(
unchanged
.body
.collect()
.await
.expect("Failed to collect destination body")
.into_bytes()
.as_ref(),
b"original destination"
);
env.stop_server();
}
}
@@ -0,0 +1,468 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! CopyObject tagging directive regression tests.
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, MetadataDirective, TaggingDirective, VersioningConfiguration};
use serial_test::serial;
use std::collections::BTreeMap;
async fn object_tags(client: &Client, bucket: &str, key: &str) -> BTreeMap<String, String> {
client
.get_object_tagging()
.bucket(bucket)
.key(key)
.send()
.await
.expect("GetObjectTagging should succeed")
.tag_set()
.iter()
.map(|tag| (tag.key().to_string(), tag.value().to_string()))
.collect()
}
#[tokio::test]
#[serial]
async fn copy_object_applies_copy_replace_and_empty_tagging_directives() {
init_logging();
let mut env = RustFSTestEnvironment::new()
.await
.expect("test environment should initialize");
env.start_rustfs_server(vec![]).await.expect("RustFS should start");
let client = env.create_s3_client();
let bucket = "copy-object-tagging-directive";
let source = "source.txt";
client
.create_bucket()
.bucket(bucket)
.send()
.await
.expect("bucket creation should succeed");
client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await
.expect("versioning should be enabled");
let first_version = client
.put_object()
.bucket(bucket)
.key(source)
.tagging("project=rustfs&stage=first")
.body(ByteStream::from_static(b"first"))
.send()
.await
.expect("first source version should be written")
.version_id()
.expect("versioned PUT should return a version ID")
.to_string();
client
.put_object()
.bucket(bucket)
.key(source)
.tagging("project=rustfs&stage=current")
.body(ByteStream::from_static(b"current"))
.send()
.await
.expect("current source version should be written");
client
.copy_object()
.bucket(bucket)
.key("default-copy.txt")
.copy_source(format!("{bucket}/{source}"))
.send()
.await
.expect("default CopyObject should preserve current source tags");
assert_eq!(
object_tags(&client, bucket, "default-copy.txt").await,
BTreeMap::from([
("project".to_string(), "rustfs".to_string()),
("stage".to_string(), "current".to_string()),
])
);
client
.copy_object()
.bucket(bucket)
.key("explicit-copy.txt")
.copy_source(format!("{bucket}/{source}?versionId={first_version}"))
.tagging_directive(TaggingDirective::Copy)
.send()
.await
.expect("COPY should preserve the selected historical version's tags");
assert_eq!(
object_tags(&client, bucket, "explicit-copy.txt").await,
BTreeMap::from([
("project".to_string(), "rustfs".to_string()),
("stage".to_string(), "first".to_string()),
])
);
client
.copy_object()
.bucket(bucket)
.key("replace.txt")
.copy_source(format!("{bucket}/{source}"))
.tagging_directive(TaggingDirective::Replace)
.tagging("project=cli&label=copy%20test")
.send()
.await
.expect("REPLACE should atomically apply requested tags");
assert_eq!(
object_tags(&client, bucket, "replace.txt").await,
BTreeMap::from([
("label".to_string(), "copy test".to_string()),
("project".to_string(), "cli".to_string()),
])
);
let replace_head = client
.head_object()
.bucket(bucket)
.key("replace.txt")
.send()
.await
.expect("HEAD should succeed after tag replacement");
assert_eq!(replace_head.tag_count(), Some(2));
client
.copy_object()
.bucket(bucket)
.key("empty-replace.txt")
.copy_source(format!("{bucket}/{source}"))
.tagging_directive(TaggingDirective::Replace)
.send()
.await
.expect("REPLACE without Tagging should clear the destination tag set");
assert!(object_tags(&client, bucket, "empty-replace.txt").await.is_empty());
let empty_head = client
.head_object()
.bucket(bucket)
.key("empty-replace.txt")
.send()
.await
.expect("HEAD should succeed after empty tag replacement");
assert_eq!(empty_head.tag_count(), None);
client
.copy_object()
.bucket(bucket)
.key("metadata-replace-tag-copy.txt")
.copy_source(format!("{bucket}/{source}"))
.metadata_directive(MetadataDirective::Replace)
.metadata("updated", "true")
.send()
.await
.expect("metadata REPLACE must preserve tags under the default COPY directive");
assert_eq!(
object_tags(&client, bucket, "metadata-replace-tag-copy.txt").await,
BTreeMap::from([
("project".to_string(), "rustfs".to_string()),
("stage".to_string(), "current".to_string()),
])
);
client
.copy_object()
.bucket(bucket)
.key("combined-replace.txt")
.copy_source(format!("{bucket}/{source}"))
.metadata_directive(MetadataDirective::Replace)
.metadata("updated", "true")
.tagging_directive(TaggingDirective::Replace)
.tagging("project=combined")
.send()
.await
.expect("metadata and tagging REPLACE directives must be independent");
assert_eq!(
object_tags(&client, bucket, "combined-replace.txt").await,
BTreeMap::from([("project".to_string(), "combined".to_string())])
);
client
.copy_object()
.bucket(bucket)
.key(source)
.copy_source(format!("{bucket}/{source}"))
.tagging_directive(TaggingDirective::Replace)
.tagging("project=self-copy")
.send()
.await
.expect("self-copy with tag replacement should update tags atomically");
assert_eq!(
object_tags(&client, bucket, source).await,
BTreeMap::from([("project".to_string(), "self-copy".to_string())])
);
let self_copy_body = client
.get_object()
.bucket(bucket)
.key(source)
.send()
.await
.expect("self-copy destination should remain readable")
.body
.collect()
.await
.expect("self-copy body should be complete")
.into_bytes();
assert_eq!(self_copy_body.as_ref(), b"current", "tag-only self-copy must preserve the object body");
client
.put_object()
.bucket(bucket)
.key("malformed.txt")
.tagging("state=original")
.body(ByteStream::from_static(b"original destination"))
.send()
.await
.expect("preexisting malformed-test destination should be written");
let malformed = client
.copy_object()
.bucket(bucket)
.key("malformed.txt")
.copy_source(format!("{bucket}/{source}"))
.tagging_directive(TaggingDirective::Replace)
.tagging("project=rustfs%ZZ")
.send()
.await
.expect_err("malformed tags must fail CopyObject");
assert_eq!(malformed.as_service_error().and_then(ProvideErrorMetadata::code), Some("InvalidTag"));
assert_eq!(
object_tags(&client, bucket, "malformed.txt").await,
BTreeMap::from([("state".to_string(), "original".to_string())])
);
let preserved_body = client
.get_object()
.bucket(bucket)
.key("malformed.txt")
.send()
.await
.expect("malformed tags must not replace an existing destination")
.body
.collect()
.await
.expect("preserved destination body should be readable")
.into_bytes();
assert_eq!(
preserved_body.as_ref(),
b"original destination",
"malformed tags must leave destination data unchanged"
);
let discarded = client
.copy_object()
.bucket(bucket)
.key("discarded.txt")
.copy_source(format!("{bucket}/{source}"))
.tagging("project=must-not-be-discarded")
.send()
.await
.expect_err("Tagging without REPLACE must fail instead of discarding requested tags");
assert_eq!(discarded.as_service_error().and_then(ProvideErrorMetadata::code), Some("InvalidRequest"));
let invalid_directive = client
.copy_object()
.bucket(bucket)
.key("invalid-directive.txt")
.copy_source(format!("{bucket}/{source}"))
.tagging_directive(TaggingDirective::from("UNKNOWN"))
.send()
.await
.expect_err("an unknown TaggingDirective must fail");
assert_eq!(
invalid_directive.as_service_error().and_then(ProvideErrorMetadata::code),
Some("InvalidArgument")
);
env.stop_server();
}
#[tokio::test]
#[serial]
async fn copy_object_tag_replacement_honors_request_tag_policy_denial() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
{
init_logging();
let source_bucket = "copy-tags-policy-source";
let destination_bucket = "copy-tags-policy-destination";
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let admin = env.create_s3_client();
admin.create_bucket().bucket(source_bucket).send().await?;
admin.create_bucket().bucket(destination_bucket).send().await?;
admin
.put_object()
.bucket(source_bucket)
.key("source.txt")
.tagging("source=allowed")
.body(ByteStream::from_static(b"source"))
.send()
.await?;
admin
.put_object()
.bucket(source_bucket)
.key("conditioned.txt")
.body(ByteStream::from_static(b"conditioned source"))
.send()
.await?;
let source_policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": [format!("arn:aws:s3:::{source_bucket}/source.txt")]
},
{
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": [format!("arn:aws:s3:::{source_bucket}/conditioned.txt")],
"Condition": {
"StringEquals": {
"s3:RequestObjectTag/classification": "public"
}
}
}
]
})
.to_string();
admin
.put_bucket_policy()
.bucket(source_bucket)
.policy(source_policy)
.send()
.await?;
let destination_policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:PutObject"],
"Resource": [format!("arn:aws:s3:::{destination_bucket}/*")]
},
{
"Effect": "Deny",
"Principal": "*",
"Action": ["s3:PutObject"],
"Resource": [format!("arn:aws:s3:::{destination_bucket}/*")],
"Condition": {
"StringEquals": {
"s3:RequestObjectTag/classification": "restricted"
}
}
}
]
})
.to_string();
admin
.put_bucket_policy()
.bucket(destination_bucket)
.policy(destination_policy)
.send()
.await?;
let copy_source = format!("/{source_bucket}/source.txt");
let allowed = local_http_client()
.put(format!("{}/{destination_bucket}/allowed.txt", env.url))
.header("x-amz-copy-source", &copy_source)
.header("x-amz-tagging-directive", "REPLACE")
.header("x-amz-tagging", "classification=public")
.send()
.await?;
assert_eq!(
allowed.status(),
reqwest::StatusCode::OK,
"a tag set allowed by the request-tag policy should copy successfully"
);
assert_eq!(
object_tags(&admin, destination_bucket, "allowed.txt").await,
BTreeMap::from([("classification".to_string(), "public".to_string())])
);
let denied = local_http_client()
.put(format!("{}/{destination_bucket}/denied.txt", env.url))
.header("x-amz-copy-source", copy_source)
.header("x-amz-tagging-directive", "REPLACE")
.header("x-amz-tagging", "classification=restricted")
.send()
.await?;
assert_eq!(
denied.status(),
reqwest::StatusCode::FORBIDDEN,
"CopyObject must honor a request-tag policy Deny"
);
let source_condition_bypass = local_http_client()
.put(format!("{}/{destination_bucket}/source-condition.txt", env.url))
.header("x-amz-copy-source", format!("/{source_bucket}/conditioned.txt"))
.header("x-amz-tagging-directive", "REPLACE")
.header("x-amz-tagging", "classification=public")
.send()
.await?;
assert_eq!(
source_condition_bypass.status(),
reqwest::StatusCode::FORBIDDEN,
"destination request tags must not satisfy source GetObject policy conditions"
);
let missing_destination = admin
.head_object()
.bucket(destination_bucket)
.key("denied.txt")
.send()
.await
.expect_err("an access-denied copy must not create a destination object");
assert_eq!(
missing_destination.as_service_error().and_then(ProvideErrorMetadata::code),
Some("NotFound")
);
let missing_bypass_destination = admin
.head_object()
.bucket(destination_bucket)
.key("source-condition.txt")
.send()
.await
.expect_err("a source authorization denial must not create a destination object");
assert_eq!(
missing_bypass_destination
.as_service_error()
.and_then(ProvideErrorMetadata::code),
Some("NotFound")
);
env.stop_server();
Ok(())
}
}
@@ -160,4 +160,146 @@ mod tests {
env.stop_server();
}
/// Regression test for Issue #4976: a versioned-source CopyObject must echo the exact source
/// version copied via `x-amz-copy-source-version-id` (SDK `CopySourceVersionId`), kept distinct
/// from the newly created destination `x-amz-version-id`.
#[tokio::test]
#[serial]
async fn test_copy_of_non_latest_source_version_returns_copy_source_version_id() {
init_logging();
info!("Issue #4976: versioned CopyObject must return x-amz-copy-source-version-id for the exact source version");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = env.create_s3_client();
let src_bucket = "copy-source-version-header-src";
let dst_bucket = "copy-source-version-header-dst";
let src_key = "reports/quarantined.bin";
let dst_key = "reports/promoted.bin";
for bucket in [src_bucket, dst_bucket] {
client
.create_bucket()
.bucket(bucket)
.send()
.await
.expect("Failed to create bucket");
client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await
.expect("Failed to enable versioning");
}
// Source version 1: the exact (non-latest) version we will copy.
let v1_content = b"quarantined payload -- source version one (target of the copy)";
let put_v1 = client
.put_object()
.bucket(src_bucket)
.key(src_key)
.body(ByteStream::from_static(v1_content))
.send()
.await
.expect("PUT source v1 failed");
let v1_id = put_v1.version_id().expect("source v1 must have a version id").to_string();
// Source version 2: becomes the latest, so v1 is deliberately NOT the current version.
let v2_content = b"quarantined payload -- source version two (now current, must be ignored)";
let put_v2 = client
.put_object()
.bucket(src_bucket)
.key(src_key)
.body(ByteStream::from_static(v2_content))
.send()
.await
.expect("PUT source v2 failed");
let v2_id = put_v2.version_id().expect("source v2 must have a version id").to_string();
assert_ne!(v1_id, v2_id, "the two source puts must produce distinct versions");
// Copy the exact NON-LATEST source version into the destination bucket.
let copy_out = client
.copy_object()
.bucket(dst_bucket)
.key(dst_key)
.copy_source(format!("{src_bucket}/{src_key}?versionId={v1_id}"))
.send()
.await
.expect("CopyObject of a versioned source must succeed");
// (3) The response must echo the exact source version copied.
let copy_source_version_id = copy_out
.copy_source_version_id()
.expect("issue #4976: response must include x-amz-copy-source-version-id for a versioned source");
assert_eq!(
copy_source_version_id, v1_id,
"x-amz-copy-source-version-id must equal the exact source version requested, not the latest source version"
);
assert_ne!(
copy_source_version_id, v2_id,
"x-amz-copy-source-version-id must not be the latest source version"
);
// (4) The destination header must identify a distinct, newly created version.
let dst_version_id = copy_out
.version_id()
.expect("destination copy must create a new version id")
.to_string();
assert!(!dst_version_id.is_empty(), "destination version id must be present");
assert_ne!(dst_version_id, v1_id, "destination version must be distinct from the source version");
assert_ne!(
dst_version_id, v2_id,
"destination version must be distinct from the source latest version"
);
// (5) The destination must hold the exact bytes/size of the copied (v1) source version.
let head = client
.head_object()
.bucket(dst_bucket)
.key(dst_key)
.send()
.await
.expect("HEAD destination failed");
assert_eq!(
head.content_length(),
Some(v1_content.len() as i64),
"destination size must equal the copied source version (v1)"
);
let get_dst = client
.get_object()
.bucket(dst_bucket)
.key(dst_key)
.version_id(&dst_version_id)
.send()
.await
.expect("GET destination failed");
let dst_body = get_dst.body.collect().await.expect("collect destination body").into_bytes();
assert_eq!(
dst_body.as_ref(),
v1_content,
"destination bytes must exactly equal the copied source version (v1), not the latest (v2)"
);
// (6) The source version copied from must remain present and independently readable.
let get_src_v1 = client
.get_object()
.bucket(src_bucket)
.key(src_key)
.version_id(&v1_id)
.send()
.await
.expect("GET source v1 failed after copy");
let src_v1_body = get_src_v1.body.collect().await.expect("collect source v1 body").into_bytes();
assert_eq!(src_v1_body.as_ref(), v1_content, "source v1 must remain intact after the copy");
env.stop_server();
}
}
@@ -0,0 +1,11 @@
# Programmable fake S3 target
This module is the shared failure-injection boundary for replication end-to-end tests. It runs an in-process, path-style S3 endpoint backed by `s3s`; no production crate depends on it.
`FakeS3Target::start()` creates the listener. Add target buckets with `create_bucket`, point a RustFS remote target at `address()`, use `FAKE_ACCESS_KEY` / `FAKE_SECRET_KEY`, then enqueue per-operation faults with `inject`. Faults for one operation are consumed in FIFO order and do not consume faults queued for another operation. A fault is consumed only after `s3s` verifies the full request signature, so anonymous, other-access-key, and bad-signature traffic cannot disturb a script.
Supported data operations are HeadBucket, GetBucketVersioning, PUT/GET/HEAD/DELETE Object, and create/upload/complete/abort multipart upload. `create_bucket` models general-purpose buckets in S3's shared global namespace; account-regional namespace buckets and their `-an` names are intentionally out of scope. Buckets are versioned: PUT creates a version, DELETE without `versionId` creates a delete marker, and DELETE with `versionId` removes exactly that version. Internal source version IDs must be UUIDs and are stored canonically. Source mtime is honored only for source-replication PUT/DELETE requests; absent or invalid values use receipt time, matching RustFS, while multipart completion always uses receipt time. Replicated versions are ordered newest-first by source mtime so late older versions and delete markers do not become current. Equal mtimes prefer objects over delete markers, then canonical UUID order; RustFS's internal FileMeta signature tie-break is intentionally out of scope because it is not part of the target S3 protocol. Multipart part numbers follow S3's `1..=10000` range, and every completed part except the final part must be at least 5 MiB.
Fault actions cover HTTP 401/403/503 responses, pre-dispatch delay, connection abort when a logical request-body threshold is reached, streaming slow drain, and a deliberately wrong response ETag (including multipart-complete XML). `requests()` returns the ordered, credential-free request journal for assertions.
The listener is loopback-only. It admits at most 64 active connections and two concurrently buffered request bodies; authenticated multipart-complete XML collection and assembly take both body permits. Keep-alive is disabled, request-header reads are bounded to 30 seconds, a parsed request is bounded to 65 seconds, and the complete connection lifetime is bounded to 100 seconds. It retains at most 256 buckets, 4,096 journal entries, 4,096 scripted faults, 4,096 object versions, 256 multipart uploads, and 10,000 multipart parts. Retained identifiers are capped at 1 KiB, user metadata at 2 KiB, and content type at 1 KiB. A PUT or uploaded part is capped at 64 MiB; a completed multipart object and all stored object/part data are capped at 128 MiB. Body drain, body-permit waits, delay, and slow-drain execution are bounded to 30 seconds; each slow-drain slice delay must be below that bound.
File diff suppressed because it is too large Load Diff
+498
View File
@@ -0,0 +1,498 @@
// 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.
//! In-process, socket-level network fault-injection proxy for black-box
//! cluster E2E tests (backlog#1325 network fault-injection block).
//!
//! `FaultProxy` binds a TCP listener on a random local port, forwards every
//! accepted connection to a fixed target address, and lets a test flip the
//! forwarding behaviour at runtime. It is the black-box counterpart to the
//! in-process white-box hooks (rename barrier, disk call counters): those
//! `#[cfg(test)]` primitives cannot reach across the process boundary into a
//! spawned RustFS server, whereas this proxy sits on the wire between two
//! nodes and needs no cooperation from the peer.
//!
//! Service targets:
//! - **#1312 / #1319** lock-plane one-way partition and accept-then-blackhole
//! peer: run a node's lock/RPC port through the proxy, then
//! [`FaultMode::Partition`] one direction (data plane stays reachable via a
//! separate direct port) or [`FaultMode::Blackhole`] the whole connection.
//! - **#1327** cross-process replay / tamper harness: the proxy is the wire
//! seam a replay/tamper filter can later hook into.
//!
//! Supported fault modes:
//! - [`FaultMode::Pass`]: transparent byte forwarding (round-trip identical).
//! - [`FaultMode::Latency`]: delay every forwarded chunk by a fixed duration.
//! - [`FaultMode::Blackhole`]: accept the connection but forward nothing in
//! either direction and never respond (models an accepted-then-dead peer);
//! the client observes a read timeout, the proxy never panics.
//! - [`FaultMode::Partition`]: block exactly one direction while the other
//! keeps flowing (models a lock-RPC one-way partition with a live data
//! plane).
//!
//! Wiring this into the cluster harness ("expose node port N through a proxy")
//! is intentionally left as a follow-up: the harness multi-drive / 2-pool
//! changes land separately (rustfs#4937), so this block ships the proxy tool
//! plus its local self-tests against a loopback echo server and stays
//! independent of that harness work.
//!
//! # Example
//!
//! ```ignore
//! let proxy = FaultProxy::start(target_addr).await?;
//! let via = proxy.local_addr(); // point the client here instead of `target_addr`
//! proxy.set_mode(FaultMode::Latency(Duration::from_millis(50)));
//! // ... exercise the client ...
//! proxy.set_mode(FaultMode::Partition(Direction::ClientToServer));
//! // ... assert the blocked direction is dead ...
//! proxy.shutdown().await; // releases the listener port
//! ```
use std::io;
use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::watch;
use tokio::task::JoinHandle;
/// A single logical direction of a proxied connection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
/// Bytes travelling from the connecting client towards the target server
/// (the forward / request path).
ClientToServer,
/// Bytes travelling from the target server back to the client (the return
/// / response path).
ServerToClient,
}
/// Runtime-switchable forwarding behaviour of a [`FaultProxy`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FaultMode {
/// Forward bytes transparently in both directions.
Pass,
/// Forward bytes in both directions, but delay every chunk by this
/// duration before it is written onward.
Latency(Duration),
/// Accept connections but forward nothing in either direction and never
/// respond. Bytes read from either side are drained and discarded so the
/// proxy never blocks or panics; the peer simply never hears back.
Blackhole,
/// Block exactly one direction (drop its bytes) while the other direction
/// keeps forwarding normally.
Partition(Direction),
}
/// An async TCP proxy that forwards a random local port to a fixed target and
/// can inject network faults at runtime.
///
/// The proxy owns a background accept loop; each accepted connection is
/// handled by its own task pair (one per direction). [`FaultProxy::shutdown`]
/// stops the accept loop and drops the listener, releasing the bound port.
pub struct FaultProxy {
listen_addr: SocketAddr,
target: SocketAddr,
mode_tx: watch::Sender<FaultMode>,
shutdown_tx: watch::Sender<bool>,
accept_task: JoinHandle<()>,
}
impl FaultProxy {
/// Bind a listener on `127.0.0.1:0` and start forwarding accepted
/// connections to `target`. Starts in [`FaultMode::Pass`].
pub async fn start(target: SocketAddr) -> io::Result<Self> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
let listen_addr = listener.local_addr()?;
let (mode_tx, mode_rx) = watch::channel(FaultMode::Pass);
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let accept_task = tokio::spawn(accept_loop(listener, target, mode_rx, shutdown_rx));
Ok(Self {
listen_addr,
target,
mode_tx,
shutdown_tx,
accept_task,
})
}
/// Local address the proxy is listening on. Point clients here instead of
/// the real target to route their traffic through the proxy.
pub fn local_addr(&self) -> SocketAddr {
self.listen_addr
}
/// The fixed target address every connection is forwarded to.
pub fn target_addr(&self) -> SocketAddr {
self.target
}
/// The mode currently in effect.
pub fn mode(&self) -> FaultMode {
*self.mode_tx.borrow()
}
/// Switch the fault mode at runtime. Takes effect on the next chunk read by
/// each direction of every live and future connection.
pub fn set_mode(&self, mode: FaultMode) {
// A send only fails if every receiver has dropped, i.e. the accept loop
// and all connections have already ended; there is nothing to steer.
let _ = self.mode_tx.send(mode);
}
/// Stop the accept loop, signal live connections to wind down, and drop the
/// listener so the bound port is released. Awaits the accept loop so the
/// port is free once this returns.
pub async fn shutdown(self) {
let _ = self.shutdown_tx.send(true);
let _ = self.accept_task.await;
}
}
/// Accept loop: takes new connections until shutdown is signalled, then returns
/// (dropping `listener`, which releases the port).
async fn accept_loop(
listener: TcpListener,
target: SocketAddr,
mode_rx: watch::Receiver<FaultMode>,
mut shutdown_rx: watch::Receiver<bool>,
) {
loop {
tokio::select! {
biased;
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
break;
}
}
accepted = listener.accept() => {
match accepted {
Ok((client, _peer)) => {
tokio::spawn(handle_connection(
client,
target,
mode_rx.clone(),
shutdown_rx.clone(),
));
}
// A transient accept error should not tear the proxy down.
Err(_) => continue,
}
}
}
}
}
/// Bridge one accepted client connection to a fresh connection to the target,
/// running an independent pump per direction.
async fn handle_connection(
client: TcpStream,
target: SocketAddr,
mode_rx: watch::Receiver<FaultMode>,
shutdown_rx: watch::Receiver<bool>,
) {
let server = match TcpStream::connect(target).await {
Ok(s) => s,
// Target unreachable: nothing to bridge; drop the client.
Err(_) => return,
};
let (client_rd, client_wr) = client.into_split();
let (server_rd, server_wr) = server.into_split();
let up = tokio::spawn(pump(
Direction::ClientToServer,
client_rd,
server_wr,
mode_rx.clone(),
shutdown_rx.clone(),
));
let down = tokio::spawn(pump(Direction::ServerToClient, server_rd, client_wr, mode_rx, shutdown_rx));
let _ = up.await;
let _ = down.await;
}
/// Copy bytes from `from` to `to` for a single direction, applying the current
/// [`FaultMode`]. Returns when the source hits EOF/error, a write fails, or
/// shutdown is signalled.
async fn pump<R, W>(
dir: Direction,
mut from: R,
mut to: W,
mut mode_rx: watch::Receiver<FaultMode>,
mut shutdown_rx: watch::Receiver<bool>,
) where
R: AsyncReadExt + Unpin,
W: AsyncWriteExt + Unpin,
{
let mut buf = vec![0u8; 16 * 1024];
loop {
let n = tokio::select! {
biased;
_ = shutdown_rx.changed() => break,
read = from.read(&mut buf) => match read {
Ok(0) => break, // clean EOF
Ok(n) => n,
Err(_) => break, // reset / error: end this direction
},
};
// Snapshot the mode without holding the borrow across an await.
let mode = *mode_rx.borrow_and_update();
match mode {
// Whole connection is a black hole: drain and drop.
FaultMode::Blackhole => continue,
// This direction is partitioned off: drop its bytes; the peer keeps
// reading nothing while the other direction may still flow.
FaultMode::Partition(blocked) if blocked == dir => continue,
FaultMode::Latency(delay) => {
tokio::time::sleep(delay).await;
if to.write_all(&buf[..n]).await.is_err() {
break;
}
if to.flush().await.is_err() {
break;
}
}
FaultMode::Pass | FaultMode::Partition(_) => {
if to.write_all(&buf[..n]).await.is_err() {
break;
}
if to.flush().await.is_err() {
break;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::Mutex;
/// A loopback echo server that records every byte it receives, so tests can
/// distinguish "the server never got it" (client->server blocked) from "the
/// server got it but the reply never came back" (server->client blocked).
struct EchoServer {
addr: SocketAddr,
received: Arc<Mutex<Vec<u8>>>,
_task: JoinHandle<()>,
}
impl EchoServer {
async fn start() -> io::Result<Self> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
let addr = listener.local_addr()?;
let received = Arc::new(Mutex::new(Vec::new()));
let received_task = received.clone();
let task = tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
break;
};
let received_conn = received_task.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; 16 * 1024];
loop {
match sock.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
received_conn.lock().await.extend_from_slice(&buf[..n]);
if sock.write_all(&buf[..n]).await.is_err() {
break;
}
let _ = sock.flush().await;
}
}
}
});
}
});
Ok(Self {
addr,
received,
_task: task,
})
}
async fn received(&self) -> Vec<u8> {
self.received.lock().await.clone()
}
}
/// Write `payload`, then try to read up to `want` bytes with a deadline.
/// Returns the bytes actually received before the timeout (possibly empty).
async fn write_then_read(stream: &mut TcpStream, payload: &[u8], want: usize, timeout: Duration) -> Vec<u8> {
stream.write_all(payload).await.expect("client write");
stream.flush().await.expect("client flush");
let mut out = Vec::new();
let mut buf = vec![0u8; want.max(1)];
let deadline = tokio::time::Instant::now() + timeout;
while out.len() < want {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
}
match tokio::time::timeout(remaining, stream.read(&mut buf)).await {
Ok(Ok(0)) => break, // peer closed
Ok(Ok(n)) => out.extend_from_slice(&buf[..n]),
Ok(Err(_)) => break, // connection error
Err(_) => break, // read timed out
}
}
out
}
#[tokio::test]
async fn pass_mode_round_trips_bytes() {
let echo = EchoServer::start().await.unwrap();
let proxy = FaultProxy::start(echo.addr).await.unwrap();
assert_eq!(proxy.mode(), FaultMode::Pass);
let mut client = TcpStream::connect(proxy.local_addr()).await.unwrap();
let payload = b"hello-fault-proxy";
let got = write_then_read(&mut client, payload, payload.len(), Duration::from_secs(5)).await;
assert_eq!(got, payload, "pass mode must forward bytes unchanged");
assert_eq!(echo.received().await, payload, "server must have received the bytes");
proxy.shutdown().await;
}
#[tokio::test]
async fn latency_mode_delays_forwarding() {
// Loose lower bound only: sleep() guarantees *at least* the delay, so
// the assertion cannot flake on a slow machine, and we never assert an
// upper bound.
let delay = Duration::from_millis(200);
let echo = EchoServer::start().await.unwrap();
let proxy = FaultProxy::start(echo.addr).await.unwrap();
proxy.set_mode(FaultMode::Latency(delay));
let mut client = TcpStream::connect(proxy.local_addr()).await.unwrap();
let payload = b"delayed";
let start = Instant::now();
let got = write_then_read(&mut client, payload, payload.len(), Duration::from_secs(5)).await;
let elapsed = start.elapsed();
assert_eq!(got, payload, "latency mode must still deliver the bytes");
// One delay on the request path plus one on the echo return path: the
// round trip must exceed at least a single configured delay.
assert!(elapsed >= delay, "round trip {elapsed:?} shorter than configured delay {delay:?}");
proxy.shutdown().await;
}
#[tokio::test]
async fn blackhole_mode_yields_no_response_and_no_panic() {
let echo = EchoServer::start().await.unwrap();
let proxy = FaultProxy::start(echo.addr).await.unwrap();
proxy.set_mode(FaultMode::Blackhole);
let mut client = TcpStream::connect(proxy.local_addr()).await.unwrap();
let got = write_then_read(&mut client, b"into-the-void", 1, Duration::from_millis(300)).await;
assert!(got.is_empty(), "blackhole mode must not return any bytes, got {got:?}");
assert!(echo.received().await.is_empty(), "blackhole must not forward to the server");
// Proxy is still alive and steerable after a blackholed connection.
proxy.set_mode(FaultMode::Pass);
proxy.shutdown().await;
}
#[tokio::test]
async fn partition_client_to_server_blocks_request_path() {
let echo = EchoServer::start().await.unwrap();
let proxy = FaultProxy::start(echo.addr).await.unwrap();
proxy.set_mode(FaultMode::Partition(Direction::ClientToServer));
let mut client = TcpStream::connect(proxy.local_addr()).await.unwrap();
let got = write_then_read(&mut client, b"blocked-request", 1, Duration::from_millis(300)).await;
assert!(got.is_empty(), "client->server partition must yield no echo");
assert!(
echo.received().await.is_empty(),
"client->server partition must stop bytes from reaching the server"
);
proxy.shutdown().await;
}
#[tokio::test]
async fn partition_server_to_client_blocks_only_return_path() {
let echo = EchoServer::start().await.unwrap();
let proxy = FaultProxy::start(echo.addr).await.unwrap();
proxy.set_mode(FaultMode::Partition(Direction::ServerToClient));
let mut client = TcpStream::connect(proxy.local_addr()).await.unwrap();
let payload = b"one-way";
let got = write_then_read(&mut client, payload, 1, Duration::from_millis(300)).await;
// Client hears nothing back...
assert!(got.is_empty(), "server->client partition must block the reply");
// ...but the request path is live, so the server did receive the bytes.
// Poll briefly to avoid racing the forward direction.
let mut server_saw = Vec::new();
for _ in 0..30 {
server_saw = echo.received().await;
if !server_saw.is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(server_saw, payload, "server->client partition must leave the request path intact");
proxy.shutdown().await;
}
#[tokio::test]
async fn set_mode_switches_behaviour_at_runtime() {
let echo = EchoServer::start().await.unwrap();
let proxy = FaultProxy::start(echo.addr).await.unwrap();
// Start passing: first exchange round-trips.
let mut client = TcpStream::connect(proxy.local_addr()).await.unwrap();
let first = write_then_read(&mut client, b"first", 5, Duration::from_secs(5)).await;
assert_eq!(first, b"first", "initial pass exchange must round-trip");
// Flip to blackhole on the same live connection: the next write gets no reply.
proxy.set_mode(FaultMode::Blackhole);
let second = write_then_read(&mut client, b"second", 1, Duration::from_millis(300)).await;
assert!(second.is_empty(), "after switching to blackhole the live connection must go silent");
proxy.shutdown().await;
}
#[tokio::test]
async fn shutdown_releases_the_listener_port() {
let echo = EchoServer::start().await.unwrap();
let proxy = FaultProxy::start(echo.addr).await.unwrap();
let addr = proxy.local_addr();
proxy.shutdown().await;
// The port must be free to bind again once shutdown returns.
let rebind = TcpListener::bind(addr).await;
assert!(rebind.is_ok(), "shutdown must release the listener port {addr}");
}
}
@@ -45,10 +45,10 @@
//! * Parity reconstruction: one data disk is taken offline
//! (`take_disk_offline`) and the SAME object matrix is GET both ways while
//! the EC 2+2 set rebuilds each large object from the surviving shards. The
//! codec-streaming reader gate never inspects drive health, so the codec
//! fast path is exercised end-to-end through reconstruction; the test
//! asserts byte- and header-equality vs the legacy path AND that the codec
//! phase never fell back to a duplex pipe while reconstructing.
//! eager first/single-part setup may keep its conservative whole-request
//! fallback when shard placement makes codec streaming unsafe, so this phase
//! asserts byte- and header-equality vs the legacy path rather than requiring
//! zero duplex fallbacks under degraded drive health.
//! * Missing object: a GET for an absent key is compared across both phases
//! to prove the error semantics (HTTP status + S3 error code) are identical
//! — the codec env must not perturb the NoSuchKey negative path.
@@ -475,15 +475,14 @@ mod tests {
"ranged GET length diverged with codec streaming enabled"
);
// ---- Phase B degraded: the same reconstruction, now on the codec path ----
// Re-run the reconstruction A/B with the codec-streaming gates still
// open. The reader gate decision is independent of drive health (it
// never inspects disk state), so the codec fast path is exercised
// end-to-end while the EC set rebuilds each large object from the
// surviving shards — this is a real codec-vs-legacy reconstruction test,
// not legacy-vs-legacy. Snapshot the duplex count first (the range GET
// above already used the duplex path) so we can measure only the markers
// these degraded codec GETs add.
// ---- Phase B degraded: the same reconstruction, with codec gates open ----
// Re-run the reconstruction A/B with codec-streaming enabled. If eager
// first/single-part setup cannot prove the codec path is safe for the
// surviving shards, the implementation intentionally preserves the
// whole-request legacy fallback; later multipart parts can degrade in
// place. This phase verifies parity-reconstructed bytes and headers,
// while the healthy phase above remains the strict zero-duplex path
// confirmation.
let dup_codec_before_degraded = count_marker(&codec_log, DUPLEX_MARKER);
harness.take_disk_offline(0)?;
let mut codec_degraded: BTreeMap<String, GetView> = BTreeMap::new();
@@ -511,16 +510,11 @@ mod tests {
);
}
// Path confirmation under reconstruction: the codec fast path must have
// served the reconstructed large objects without ever falling back to
// the legacy duplex pipe. Without this, the equivalence above could be
// legacy-vs-legacy and prove nothing about codec reconstruction.
// Keep degraded duplex markers as diagnostic evidence only: eager setup
// may fall back before streaming when shard safety cannot be proven.
sleep(Duration::from_millis(300)).await;
let dup_codec_degraded = count_marker(&codec_log, DUPLEX_MARKER).saturating_sub(dup_codec_before_degraded);
assert_eq!(
dup_codec_degraded, 0,
"codec phase created {dup_codec_degraded} duplex pipe(s) while reconstructing large objects with disk0 offline; the codec fast path was not exercised under degraded reads (see {codec_log})"
);
info!(dup_codec_degraded, "codec phase degraded-read legacy duplex marker count");
info!(
objects = baseline.len(),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,573 @@
#![cfg(test)]
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Cross-process replay / tamper acceptance for the internode NodeService v2 RPC
//! signature (<https://github.com/rustfs/backlog/issues/1327>).
//!
//! # Why this exists on top of the in-process tests
//!
//! `http_auth.rs` unit-tests the signature algebra by calling the verifier
//! directly. That proves the crypto, but it cannot prove that a *deployed*
//! server actually reaches it: the request has to survive the hybrid HTTP/gRPC
//! router, `check_auth`, tonic's own metadata handling, and finally the
//! per-handler body-digest gate. A handler that forgets its
//! `verify_disk_mutation_digest` call, or a router change that bypasses
//! `check_auth`, is invisible in-process and wide open in production. These
//! tests drive a real `rustfs` child process over a real TCP socket, so every
//! one of those layers is in the path.
//!
//! # Attacker model
//!
//! The adversary is on-path: it observed one legitimately signed request and
//! can resend, retarget, or edit those bytes — including individual headers.
//! It does **not** hold the RPC secret. The test process does hold the secret,
//! but uses it for exactly one purpose: minting the request that stands in for
//! the captured one. Every attack then only *reuses or edits* an already-minted
//! header set; no attack step ever re-signs. If any of these tests could pass
//! by re-signing, it would be testing nothing.
//!
//! # Isolating one variable at a time
//!
//! Each rejection is paired with an acceptance that differs in exactly one
//! respect, because a misconfigured harness (wrong audience, dead server,
//! ambient strict env) would otherwise make every "rejected" assertion pass
//! vacuously. Two pairings carry most of the weight:
//!
//! - Editing the body alone is caught by the *handler* (`PermissionDenied`);
//! editing the body **and** repairing the digest header to match is caught by
//! the *signature* (`Unauthenticated`). The second only fails closed if the
//! digest is genuinely inside the signed scope, so the pair pins both layers.
//! - Replaying a captured nonce is caught by the replay cache; swapping in a
//! fresh nonce is caught by the signature. Again, only the pair proves the
//! nonce is signed rather than merely cached.
//!
//! # Why `MakeVolume` against a non-existent disk
//!
//! Every covered handler checks the digest before touching storage, and
//! `MakeVolume` resolves its disk *after* that check. Aiming at a disk that
//! cannot exist gives three cleanly separable outcomes with zero side effects
//! on the server's real data:
//!
//! - `Err(Unauthenticated)` — rejected by `check_auth` (signature layer).
//! - `Err(PermissionDenied)` — rejected by the handler's body-digest gate.
//! - `Ok(success: false)` — **authentication passed**; the request reached
//! handler logic and only then failed on the bogus disk.
//!
//! # Coverage of the issue's acceptance matrix
//!
//! | Acceptance item | Test |
//! |---|---|
//! | replay a signature onto another method → reject | [`cross_method_signature_transplant_is_rejected`] |
//! | replay same method + body after nonce consumed → reject | [`nonce_replay_of_a_captured_mutation_is_rejected`] |
//! | nonce is signed, not just cached → reject a swapped nonce | [`swapping_in_a_fresh_nonce_is_rejected`] |
//! | tamper one byte of the body → reject | [`tampered_mutation_body_is_rejected`] |
//! | body digest is inside the signed scope → reject a repaired digest | [`rewriting_the_digest_to_match_a_tampered_body_is_rejected`] |
//! | wrong destination node identity → reject | [`signature_minted_for_another_node_is_rejected`] |
//! | mixed version: legacy-only still served, not blocked | [`legacy_only_signature_is_accepted_in_default_posture`] |
//! | strict flip closes the signature downgrade | [`signature_strict_rejects_legacy_only_downgrade`] |
//! | strict flip closes the body-digest downgrade, incl. v1 | [`body_digest_strict_rejects_digestless_mutation`] |
//!
//! Two acceptance items are deliberately left to the in-process tests. A stale
//! timestamp cannot be forged from outside — it is inside the HMAC — so
//! observing it would mean idling out the full freshness window. And the
//! `signature_v1_fallback_total` / `body_digest_fallback_total` counter deltas
//! that gate the strict flips are asserted directly in `http_auth.rs`; the
//! legacy test below proves only the *accepted* half of that behaviour.
use crate::common::{RustFSTestEnvironment, init_logging};
use crate::storage_api::internode_rpc_signature::{
TONIC_RPC_PREFIX, gen_signature_headers, gen_tonic_signature_headers, node_service_time_out_client_no_auth,
};
use http::{HeaderMap, Method};
use rustfs_config::{
ENV_INTERNODE_RPC_BODY_DIGEST_STRICT, ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY, ENV_INTERNODE_RPC_SIGNATURE_STRICT,
};
use rustfs_protos::canonical_make_volume_request_body;
use rustfs_protos::proto_gen::node_service::{MakeVolumeRequest, MakeVolumeResponse};
use serial_test::serial;
use sha2::{Digest, Sha256};
use std::error::Error;
use tonic::{Code, Request, Status};
use uuid::Uuid;
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
/// Shared internode secret handed to both the child server and this process.
///
/// Must not be the default credential: `resolve_rpc_secret` fails closed on
/// defaults (GHSA-r5qv), so a default here would break every request rather
/// than test anything.
const TEST_RPC_SECRET: &str = "rustfs-internode-signature-e2e-secret";
/// A disk path the server cannot possibly have configured, so a request that
/// clears authentication stops harmlessly at `find_disk`.
const ABSENT_DISK: &str = "/nonexistent/rustfs-signature-e2e-disk";
/// Wire names of the two v2 headers these tests edit. They are `pub(crate)` in
/// ecstore, so they are repeated here rather than imported — [`overwrite_header`]
/// asserts the header it replaces was actually present, which turns a rename
/// into a loud failure instead of silently reducing an attack to a no-op.
const CONTENT_SHA256_HEADER: &str = "x-rustfs-content-sha256";
const NONCE_HEADER: &str = "x-rustfs-rpc-nonce";
/// gRPC service name carried in the signed scope, i.e. `TONIC_RPC_PREFIX`
/// without its leading `/`.
fn node_service_name() -> &'static str {
TONIC_RPC_PREFIX.trim_start_matches('/')
}
/// Make the RPC secret of this test process match the child server's.
///
/// The secret lands in a process-wide `OnceLock`, so the first writer wins for
/// the whole test binary. Every test here uses the same constant, and the
/// assertion turns a cross-test collision into an explicit failure instead of a
/// confusing wall of signature rejections.
fn align_rpc_secret_with_server() {
let _ = rustfs_credentials::set_global_rpc_secret(TEST_RPC_SECRET.to_string());
let effective = rustfs_credentials::try_get_rpc_token().expect("RPC secret must resolve in the test process");
assert_eq!(
effective, TEST_RPC_SECRET,
"another test in this binary already fixed a different process-wide RPC secret; \
the signature tests cannot mint requests the child server will accept"
);
}
/// Start a `rustfs` child process sharing [`TEST_RPC_SECRET`], with the rollout
/// posture pinned explicitly.
///
/// The child inherits the ambient environment, so the strict gates and the
/// replay-cache capacity are set here rather than assumed: a developer or CI
/// runner exporting `RUSTFS_INTERNODE_RPC_*` would otherwise silently flip the
/// posture and fail these tests for a non-security reason. `extra_env` is
/// applied last so the strict tests can still override.
///
/// Uses the no-cleanup spawn so a `pkill` pattern cannot reap servers belonging
/// to other tests running in the same binary.
async fn start_server(extra_env: &[(&str, &str)]) -> Result<RustFSTestEnvironment, Box<dyn Error + Send + Sync>> {
let mut env = RustFSTestEnvironment::new().await?;
let mut child_env = vec![
("RUSTFS_RPC_SECRET", TEST_RPC_SECRET),
(ENV_INTERNODE_RPC_SIGNATURE_STRICT, "false"),
(ENV_INTERNODE_RPC_BODY_DIGEST_STRICT, "false"),
(ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY, "1048576"),
];
child_env.extend_from_slice(extra_env);
env.start_rustfs_server_without_cleanup_with_env(&child_env).await?;
Ok(env)
}
/// Stop the child and drop the cached gRPC channel for its address.
///
/// `node_service_time_out_client_no_auth` memoises channels in a process-global
/// map keyed by URL, and ports handed out by `find_available_port` can recur
/// within one test binary. Evicting here keeps a later test from inheriting a
/// channel aimed at this test's dead server.
async fn stop_server(mut env: RustFSTestEnvironment, url: &str) {
env.stop_server();
rustfs_protos::evict_failed_connection(url).await;
}
/// The audience the server binds into the v2 signature: its own node authority.
///
/// A single-node server started with `--address 127.0.0.1:PORT` over filesystem
/// endpoints has no URL peer set, so `init_local_peer` falls back to
/// `host:port` — exactly the address we dialed. The positive controls below
/// fail loudly if that ever stops holding.
fn audience_of(env: &RustFSTestEnvironment) -> String {
env.address.clone()
}
fn hex_sha256(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().fold(String::new(), |mut acc, byte| {
use std::fmt::Write as _;
let _ = write!(acc, "{byte:02x}");
acc
})
}
fn make_volume_request(volume: &str) -> MakeVolumeRequest {
MakeVolumeRequest {
disk: ABSENT_DISK.to_string(),
volume: volume.to_string(),
}
}
fn canonical_digest(request: &MakeVolumeRequest) -> String {
hex_sha256(&canonical_make_volume_request_body(request).expect("canonical body must encode"))
}
/// Mint a full v2 header set for `(audience, rpc_method, content_sha256)`.
///
/// This is the only place a signature is produced. Tests treat the returned map
/// as an opaque captured artifact.
fn mint_v2_headers(audience: &str, rpc_method: &str, content_sha256: Option<&str>) -> HeaderMap {
gen_tonic_signature_headers(audience, node_service_name(), rpc_method, content_sha256)
.expect("minting a v2 signature must succeed once the RPC secret is aligned")
}
/// Mint the pre-v2 header set: a signature over the fixed
/// `TONIC_RPC_PREFIX|GET|timestamp` constant, with no v2 headers at all. This is
/// both what an un-upgraded peer sends and what an attacker sends to force a
/// downgrade.
fn mint_legacy_only_headers() -> HeaderMap {
gen_signature_headers(TONIC_RPC_PREFIX, &Method::GET).expect("minting a legacy signature must succeed")
}
/// Replace one header of a captured set, asserting it was there to begin with.
fn overwrite_header(headers: &mut HeaderMap, name: &'static str, value: &str) {
assert!(
headers.contains_key(name),
"minted headers must carry {name}; the wire contract changed and this attack would edit nothing"
);
headers.insert(name, value.parse().expect("header value must be valid"));
}
/// Send `request` to the server's NodeService with exactly `headers` attached
/// and nothing else — no interceptor adds or rewrites auth metadata, so the
/// bytes on the wire are the ones the test chose.
async fn call_make_volume(url: &str, request: MakeVolumeRequest, headers: HeaderMap) -> Result<MakeVolumeResponse, Status> {
let mut client = node_service_time_out_client_no_auth(&url.to_string())
.await
.map_err(|err| Status::unavailable(format!("cannot reach the node service: {err}")))?;
let mut rpc_request = Request::new(request);
rpc_request.metadata_mut().as_mut().extend(headers);
client.make_volume(rpc_request).await.map(|response| response.into_inner())
}
/// Assert a call cleared authentication.
///
/// Receiving *any* `Ok` response is the load-bearing signal: both auth layers
/// reject with a `Status`, so an `Ok` means the request reached handler logic.
/// The failed disk lookup underneath is what keeps it side-effect free.
fn assert_authenticated(result: Result<MakeVolumeResponse, Status>, context: &str) {
match result {
Ok(response) => {
assert!(
!response.success,
"{context}: the absent disk {ABSENT_DISK} must not yield a successful volume creation"
);
assert!(
response.error.is_some(),
"{context}: expected the request to reach disk lookup and fail there, got no error"
);
}
Err(status) => panic!(
"{context}: the request must clear authentication, but was rejected with {:?}: {}",
status.code(),
status.message()
),
}
}
/// Assert a call was rejected, optionally pinning which check spoke.
///
/// `PermissionDenied` responses carry the reason on the wire, so the digest
/// tests pin it and cannot be satisfied by an unrelated digest-gate failure.
/// `Unauthenticated` is deliberately generic on the wire; those tests pin their
/// cause structurally instead, by differing from a passing request in exactly
/// one respect.
fn assert_rejected(result: Result<MakeVolumeResponse, Status>, expected: Code, expected_message: Option<&str>, context: &str) {
match result {
Ok(response) => panic!(
"{context}: the request must be rejected, but the server accepted it and ran the handler \
(success={}, error={:?})",
response.success, response.error
),
Err(status) => {
assert_eq!(
status.code(),
expected,
"{context}: expected {expected:?}, got {:?}: {}",
status.code(),
status.message()
);
if let Some(needle) = expected_message {
assert!(
status.message().contains(needle),
"{context}: expected the rejection to cite {needle:?}, got {:?}",
status.message()
);
}
}
}
}
/// Default posture (both strict gates off): the protections that hold without
/// any operator flip.
///
/// Grouped into one server start because each case is independent and spawning
/// a `rustfs` process per assertion would dominate the runtime.
#[tokio::test]
#[serial]
async fn internode_rpc_signature_default_posture_e2e() -> TestResult {
init_logging();
align_rpc_secret_with_server();
let env = start_server(&[]).await?;
let url = env.url.clone();
let audience = audience_of(&env);
signed_mutations_are_accepted(&url, &audience).await;
unsigned_request_is_rejected(&url).await;
cross_method_signature_transplant_is_rejected(&url, &audience).await;
nonce_replay_of_a_captured_mutation_is_rejected(&url, &audience).await;
swapping_in_a_fresh_nonce_is_rejected(&url, &audience).await;
tampered_mutation_body_is_rejected(&url, &audience).await;
rewriting_the_digest_to_match_a_tampered_body_is_rejected(&url, &audience).await;
signature_minted_for_another_node_is_rejected(&url).await;
legacy_only_signature_is_accepted_in_default_posture(&url).await;
stop_server(env, &url).await;
Ok(())
}
/// Baseline: correctly signed mutations are accepted, both with and without a
/// body digest.
///
/// These anchor every rejection below. The body-bound case proves the audience
/// the server verifies against really is the address we dialed. The digestless
/// case is the control the transplant test needs: without it, a regression that
/// rejected every `UNSIGNED-PAYLOAD` request would make the transplant
/// assertion pass for entirely the wrong reason. It also documents that the
/// default posture still serves digestless mutations.
async fn signed_mutations_are_accepted(url: &str, audience: &str) {
let bound = make_volume_request("signature-e2e-control-bound");
let bound_headers = mint_v2_headers(audience, "MakeVolume", Some(&canonical_digest(&bound)));
assert_authenticated(
call_make_volume(url, bound, bound_headers).await,
"a correctly signed body-bound mutation",
);
let digestless = make_volume_request("signature-e2e-control-digestless");
let digestless_headers = mint_v2_headers(audience, "MakeVolume", None);
assert_authenticated(
call_make_volume(url, digestless, digestless_headers).await,
"a correctly signed digestless mutation in the default posture",
);
}
/// A request with no auth metadata at all must never reach a handler.
async fn unsigned_request_is_rejected(url: &str) {
let result = call_make_volume(url, make_volume_request("signature-e2e-unsigned"), HeaderMap::new()).await;
assert_rejected(result, Code::Unauthenticated, None, "an entirely unsigned mutation");
}
/// GHSA-c667 class: a signature captured from one gRPC method must not be
/// replayable onto another.
///
/// Before method-path binding every NodeService call signed the same constant,
/// so a captured `Ping` — the cheapest, least privileged call on the service —
/// authenticated a `MakeVolume` just as well. The captured `Ping` signature is
/// transplanted verbatim; the server recomputes the scope with
/// `rpc_method = MakeVolume` and the HMAC no longer matches. It differs from the
/// accepted digestless control above only in the method it was minted for.
async fn cross_method_signature_transplant_is_rejected(url: &str, audience: &str) {
let captured_ping = mint_v2_headers(audience, "Ping", None);
let result = call_make_volume(url, make_volume_request("signature-e2e-transplant"), captured_ping).await;
assert_rejected(
result,
Code::Unauthenticated,
None,
"a Ping signature transplanted onto a MakeVolume mutation",
);
}
/// A body-bound mutation must be consumable exactly once.
///
/// The first send establishes that the captured artifact is genuinely valid —
/// without it, the second rejection could just mean the headers were malformed
/// all along. The replay reuses the identical `(signature, timestamp, nonce)`
/// well inside the freshness window, so only the server's replay cache can
/// stop it.
async fn nonce_replay_of_a_captured_mutation_is_rejected(url: &str, audience: &str) {
let request = make_volume_request("signature-e2e-replay");
let captured = mint_v2_headers(audience, "MakeVolume", Some(&canonical_digest(&request)));
let first = call_make_volume(url, request.clone(), captured.clone()).await;
assert_authenticated(first, "the captured mutation on its first delivery");
let replayed = call_make_volume(url, request, captured).await;
assert_rejected(
replayed,
Code::Unauthenticated,
None,
"the same captured mutation replayed after its nonce was consumed",
);
}
/// The nonce must be *signed*, not merely remembered.
///
/// A replay cache alone would be trivially defeated: swap in a fresh UUID and
/// the cache has never seen it. This request is byte-identical to one the server
/// would accept apart from that one header, so it can only be stopped by the
/// nonce being inside the signed scope.
async fn swapping_in_a_fresh_nonce_is_rejected(url: &str, audience: &str) {
let request = make_volume_request("signature-e2e-nonce-swap");
let mut captured = mint_v2_headers(audience, "MakeVolume", Some(&canonical_digest(&request)));
overwrite_header(&mut captured, NONCE_HEADER, &Uuid::new_v4().to_string());
let result = call_make_volume(url, request, captured).await;
assert_rejected(
result,
Code::Unauthenticated,
None,
"a captured mutation resent under a freshly minted nonce",
);
}
/// Editing the body of a captured request must invalidate it, in the default
/// posture, with no operator flip required.
///
/// The headers are left byte-identical — including the signed digest of the
/// original body — so `check_auth` still passes. Only the handler, recomputing
/// the canonical body from the fields it actually received, can catch this. It
/// is the test that fails if a handler ever loses its digest gate.
async fn tampered_mutation_body_is_rejected(url: &str, audience: &str) {
let signed = make_volume_request("signature-e2e-tamper-a");
let captured = mint_v2_headers(audience, "MakeVolume", Some(&canonical_digest(&signed)));
// Exactly one byte of the volume name differs from what the digest covers.
let tampered = make_volume_request("signature-e2e-tamper-b");
let result = call_make_volume(url, tampered, captured).await;
assert_rejected(
result,
Code::PermissionDenied,
Some("RPC content SHA-256 mismatch"),
"a mutation whose body was edited after signing",
);
}
/// The body digest must be *inside the signed scope*, not merely cross-checked
/// by the handler.
///
/// This is the same tampered body as above, except the attacker also repairs the
/// digest header so it matches what it sends — defeating the handler's
/// comparison. The only thing left standing is the signature, which covers the
/// digest header itself. Drop `content_sha256` from `update_signature_v2` and
/// this is the test that goes green when it should not.
async fn rewriting_the_digest_to_match_a_tampered_body_is_rejected(url: &str, audience: &str) {
let signed = make_volume_request("signature-e2e-scope-a");
let mut captured = mint_v2_headers(audience, "MakeVolume", Some(&canonical_digest(&signed)));
let tampered = make_volume_request("signature-e2e-scope-b");
overwrite_header(&mut captured, CONTENT_SHA256_HEADER, &canonical_digest(&tampered));
let result = call_make_volume(url, tampered, captured).await;
assert_rejected(
result,
Code::Unauthenticated,
None,
"a tampered mutation whose digest header was repaired to match",
);
}
/// A signature is bound to its destination node, so a request captured against
/// one node cannot be aimed at another.
///
/// `127.0.0.1:1` stands in for a different peer; the audience is inside the
/// HMAC, so the server's own authority no longer reproduces it.
async fn signature_minted_for_another_node_is_rejected(url: &str) {
let request = make_volume_request("signature-e2e-wrong-node");
let headers = mint_v2_headers("127.0.0.1:1", "MakeVolume", Some(&canonical_digest(&request)));
let result = call_make_volume(url, request, headers).await;
assert_rejected(result, Code::Unauthenticated, None, "a signature minted for a different node");
}
/// Rolling-upgrade compatibility: a peer that predates v2 must still be served
/// while the strict gates are off.
///
/// This is the case the issue insists must not fail closed during an upgrade.
/// It is also, honestly, the open downgrade window: an attacker can strip the
/// v2 headers and land here too. That window is what
/// [`signature_strict_rejects_legacy_only_downgrade`] closes.
async fn legacy_only_signature_is_accepted_in_default_posture(url: &str) {
let result = call_make_volume(url, make_volume_request("signature-e2e-legacy"), mint_legacy_only_headers()).await;
assert_authenticated(result, "a legacy-only signature in the default posture");
}
/// With `RUSTFS_INTERNODE_RPC_SIGNATURE_STRICT` on, the legacy downgrade lane is
/// closed: the exact request accepted in the default posture is now refused.
///
/// The paired v2 positive control rules out "strict simply breaks everything".
#[tokio::test]
#[serial]
async fn signature_strict_rejects_legacy_only_downgrade() -> TestResult {
init_logging();
align_rpc_secret_with_server();
let env = start_server(&[(ENV_INTERNODE_RPC_SIGNATURE_STRICT, "true")]).await?;
let url = env.url.clone();
let audience = audience_of(&env);
let downgraded = call_make_volume(&url, make_volume_request("signature-e2e-strict-legacy"), mint_legacy_only_headers()).await;
assert_rejected(
downgraded,
Code::Unauthenticated,
None,
"a legacy-only signature once signature-strict is enabled",
);
let request = make_volume_request("signature-e2e-strict-v2");
let signed = mint_v2_headers(&audience, "MakeVolume", Some(&canonical_digest(&request)));
assert_authenticated(
call_make_volume(&url, request, signed).await,
"a v2-signed mutation under signature-strict",
);
stop_server(env, &url).await;
Ok(())
}
/// With `RUSTFS_INTERNODE_RPC_BODY_DIGEST_STRICT` on, any mutation that arrives
/// without a body digest is refused — including one that downgraded all the way
/// to the legacy signature.
///
/// This gate converges independently of the signature gate, so it is exercised
/// on its own server with signature-strict left off. Both rejected requests
/// clear `check_auth` on their own terms (one is properly v2-signed, the other
/// takes the still-open legacy lane), which is what pins the rejection to the
/// handler's digest gate; the cited message confirms which check spoke.
#[tokio::test]
#[serial]
async fn body_digest_strict_rejects_digestless_mutation() -> TestResult {
init_logging();
align_rpc_secret_with_server();
let env = start_server(&[(ENV_INTERNODE_RPC_BODY_DIGEST_STRICT, "true")]).await?;
let url = env.url.clone();
let audience = audience_of(&env);
let digestless = mint_v2_headers(&audience, "MakeVolume", None);
assert_rejected(
call_make_volume(&url, make_volume_request("signature-e2e-digestless"), digestless).await,
Code::PermissionDenied,
Some("RPC mutation requires a body-bound v2 signature"),
"a v2-signed but digestless mutation once body-digest-strict is enabled",
);
assert_rejected(
call_make_volume(&url, make_volume_request("signature-e2e-digestless-legacy"), mint_legacy_only_headers()).await,
Code::PermissionDenied,
Some("RPC mutation requires a body-bound v2 signature"),
"a v1-downgraded mutation once body-digest-strict is enabled",
);
let request = make_volume_request("signature-e2e-digest-bound");
let bound = mint_v2_headers(&audience, "MakeVolume", Some(&canonical_digest(&request)));
assert_authenticated(
call_make_volume(&url, request, bound).await,
"a body-bound mutation under body-digest-strict",
);
stop_server(env, &url).await;
Ok(())
}
+88 -12
View File
@@ -29,6 +29,10 @@ use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::ServerSideEncryption;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use http::header::{CONTENT_TYPE, HOST};
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4;
use s3s::Body;
use serde_json;
use std::process::{Child, Command};
use std::time::Duration;
@@ -67,6 +71,49 @@ pub fn sse_customer_key_md5_base64(key: &str) -> String {
BASE64.encode(md5::compute(key).0)
}
pub async fn kms_admin_request(
base_url: &str,
method: http::Method,
path_and_query: &str,
body: Option<&str>,
access_key: &str,
secret_key: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{base_url}{path_and_query}");
let uri = url.parse::<http::Uri>()?;
let authority = uri.authority().ok_or("KMS admin URL missing authority")?.to_string();
let mut builder = http::Request::builder()
.method(method.clone())
.uri(uri)
.header(HOST, authority)
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
if body.is_some() {
builder = builder.header(CONTENT_TYPE, "application/json");
}
let content_len = match body {
Some(value) => i64::try_from(value.len())?,
None => 0,
};
let signed = sign_v4(builder.body(Body::empty())?, content_len, access_key, secret_key, "", "us-east-1");
let mut request = local_http_client().request(method.clone(), &url);
for (name, value) in signed.headers() {
request = request.header(name, value);
}
if let Some(value) = body {
request = request.body(value.to_owned());
}
let response = request.send().await?;
let status = response.status();
let response_body = response.text().await?;
if !status.is_success() {
return Err(format!("{method} {path_and_query} failed with {status}: {response_body}").into());
}
Ok(response_body)
}
// KMS-specific helper functions
/// Configure KMS backend via admin API
pub async fn configure_kms(
@@ -75,8 +122,19 @@ pub async fn configure_kms(
access_key: &str,
secret_key: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{base_url}/rustfs/admin/v3/kms/configure");
awscurl_post(&url, config_json, access_key, secret_key).await?;
let response = kms_admin_request(
base_url,
http::Method::POST,
"/rustfs/admin/v3/kms/configure",
Some(config_json),
access_key,
secret_key,
)
.await?;
let response: serde_json::Value = serde_json::from_str(&response)?;
if response["success"] != true {
return Err(format!("KMS configuration failed: {}", response["message"].as_str().unwrap_or("unknown error")).into());
}
info!("KMS configured successfully");
Ok(())
}
@@ -87,8 +145,19 @@ pub async fn start_kms(
access_key: &str,
secret_key: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{base_url}/rustfs/admin/v3/kms/start");
awscurl_post(&url, "{}", access_key, secret_key).await?;
let response = kms_admin_request(
base_url,
http::Method::POST,
"/rustfs/admin/v3/kms/start",
Some("{}"),
access_key,
secret_key,
)
.await?;
let response: serde_json::Value = serde_json::from_str(&response)?;
if response["success"] != true {
return Err(format!("KMS start failed: {}", response["message"].as_str().unwrap_or("unknown error")).into());
}
info!("KMS started successfully");
Ok(())
}
@@ -99,8 +168,8 @@ pub async fn get_kms_status(
access_key: &str,
secret_key: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{base_url}/rustfs/admin/v3/kms/status");
let status = awscurl_get(&url, access_key, secret_key).await?;
let status =
kms_admin_request(base_url, http::Method::GET, "/rustfs/admin/v3/kms/status", None, access_key, secret_key).await?;
info!("KMS status retrieved: {}", status);
Ok(status)
}
@@ -508,7 +577,8 @@ impl VaultTestEnvironment {
},
"mount_path": VAULT_TRANSIT_PATH,
"default_key_id": VAULT_KEY_NAME,
"skip_tls_verify": true
"skip_tls_verify": true,
"allow_insecure_dev_defaults": true
})
.to_string();
@@ -657,14 +727,19 @@ pub async fn test_multipart_upload_with_config(
.build();
info!("🔗 Completing multipart upload");
let complete_output = s3_client
let mut complete_request = s3_client
.complete_multipart_upload()
.bucket(bucket)
.key(&config.object_key)
.upload_id(upload_id)
.multipart_upload(completed_multipart_upload)
.send()
.await?;
.multipart_upload(completed_multipart_upload);
if let EncryptionType::SSEC { .. } = &config.encryption_type {
complete_request = complete_request
.sse_customer_algorithm("AES256")
.sse_customer_key(sse_c_key_b64.as_ref().unwrap())
.sse_customer_key_md5(sse_c_key_md5.as_ref().unwrap());
}
let complete_output = complete_request.send().await?;
debug!("Multipart upload finalized with ETag {:?}", complete_output.e_tag());
@@ -796,7 +871,8 @@ impl LocalKMSTestEnvironment {
"backend_type": "Local",
"key_dir": self.kms_keys_dir,
"file_permissions": 0o600,
"default_key_id": default_key_id
"default_key_id": default_key_id,
"allow_insecure_dev_defaults": true
})
.to_string();
@@ -0,0 +1,399 @@
// 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.
//! Configured-backend validation for the KMS admin and SSE-KMS round-trip
//! contract tracked by rustfs/backlog#1378.
use super::common::{
LocalKMSTestEnvironment, VAULT_KEY_NAME, VaultTestEnvironment, configure_kms, get_kms_status, kms_admin_request, start_kms,
};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, ServerSideEncryption, VersioningConfiguration};
use serial_test::serial;
use std::error::Error;
use uuid::Uuid;
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
async fn assert_configured_status(
base_url: &str,
access_key: &str,
secret_key: &str,
expected_backend: &str,
expected_default_key: &str,
) -> TestResult {
let body = get_kms_status(base_url, access_key, secret_key).await?;
let status: serde_json::Value = serde_json::from_str(&body)?;
assert_eq!(status["backend_type"], expected_backend);
assert_eq!(status["backend_status"], "healthy");
assert_eq!(status["default_key_id"], expected_default_key);
Ok(())
}
async fn create_and_verify_key(
base_url: &str,
access_key: &str,
secret_key: &str,
) -> Result<String, Box<dyn Error + Send + Sync>> {
let create_body = serde_json::json!({
"key_usage": "EncryptDecrypt",
"description": "configured KMS round-trip e2e key",
"tags": {
"test": "backlog-1378"
}
})
.to_string();
let created = kms_admin_request(
base_url,
http::Method::POST,
"/rustfs/admin/v3/kms/keys",
Some(&create_body),
access_key,
secret_key,
)
.await?;
let created: serde_json::Value = serde_json::from_str(&created)?;
assert_eq!(created["success"], true);
let key_id = created["key_id"]
.as_str()
.ok_or("create KMS key response omitted key_id")?
.to_string();
let described = kms_admin_request(
base_url,
http::Method::GET,
&format!("/rustfs/admin/v3/kms/keys/{key_id}"),
None,
access_key,
secret_key,
)
.await?;
let described: serde_json::Value = serde_json::from_str(&described)?;
assert_eq!(described["success"], true);
assert_eq!(described["key_metadata"]["key_id"], key_id);
assert_eq!(described["key_metadata"]["key_state"], "Enabled");
let listed =
kms_admin_request(base_url, http::Method::GET, "/rustfs/admin/v3/kms/keys", None, access_key, secret_key).await?;
let listed: serde_json::Value = serde_json::from_str(&listed)?;
let keys = listed["keys"].as_array().ok_or("list KMS keys response omitted keys")?;
assert!(keys.iter().any(|key| key["key_id"] == key_id), "created KMS key must appear in list");
Ok(key_id)
}
async fn assert_key_deletion_lifecycle(base_url: &str, access_key: &str, secret_key: &str, key_id: &str) -> TestResult {
let scheduled = kms_admin_request(
base_url,
http::Method::DELETE,
"/rustfs/admin/v3/kms/keys/delete",
Some(
&serde_json::json!({
"key_id": key_id,
"pending_window_in_days": 7,
"force_immediate": false
})
.to_string(),
),
access_key,
secret_key,
)
.await?;
let scheduled: serde_json::Value = serde_json::from_str(&scheduled)?;
assert_eq!(scheduled["success"], true);
assert!(scheduled["deletion_date"].is_string());
let cancelled = kms_admin_request(
base_url,
http::Method::POST,
"/rustfs/admin/v3/kms/keys/cancel-deletion",
Some(&serde_json::json!({ "key_id": key_id }).to_string()),
access_key,
secret_key,
)
.await?;
let cancelled: serde_json::Value = serde_json::from_str(&cancelled)?;
assert_eq!(cancelled["success"], true);
assert_eq!(cancelled["key_metadata"]["key_state"], "Enabled");
let removed = kms_admin_request(
base_url,
http::Method::DELETE,
"/rustfs/admin/v3/kms/keys/delete",
Some(
&serde_json::json!({
"key_id": key_id,
"force_immediate": true
})
.to_string(),
),
access_key,
secret_key,
)
.await?;
let removed: serde_json::Value = serde_json::from_str(&removed)?;
assert_eq!(removed["success"], true);
let listed =
kms_admin_request(base_url, http::Method::GET, "/rustfs/admin/v3/kms/keys", None, access_key, secret_key).await?;
let listed: serde_json::Value = serde_json::from_str(&listed)?;
assert_eq!(listed["success"], true);
let keys = listed["keys"]
.as_array()
.ok_or("list KMS keys response omitted keys after deletion")?;
if let Some(key) = keys.iter().find(|key| key["key_id"] == key_id) {
assert_eq!(key["status"], "PendingDeletion", "a retained force-deleted key must be pending deletion");
let removed = kms_admin_request(
base_url,
http::Method::DELETE,
"/rustfs/admin/v3/kms/keys/delete",
Some(
&serde_json::json!({
"key_id": key_id,
"force_immediate": true
})
.to_string(),
),
access_key,
secret_key,
)
.await?;
let removed: serde_json::Value = serde_json::from_str(&removed)?;
assert_eq!(removed["success"], true);
}
let listed =
kms_admin_request(base_url, http::Method::GET, "/rustfs/admin/v3/kms/keys", None, access_key, secret_key).await?;
let listed: serde_json::Value = serde_json::from_str(&listed)?;
assert_eq!(listed["success"], true);
let keys = listed["keys"]
.as_array()
.ok_or("final list KMS keys response omitted keys after deletion")?;
assert!(
keys.iter().all(|key| key["key_id"] != key_id),
"force-deleted KMS key must no longer appear in list"
);
Ok(())
}
async fn assert_versioned_sse_kms_roundtrip_and_cleanup(
env: &crate::common::RustFSTestEnvironment,
key_id: &str,
bucket_prefix: &str,
) -> TestResult {
let client = env.create_s3_client();
let bucket = format!("{bucket_prefix}-{}", Uuid::new_v4().simple());
let object = format!("configured-kms-probe/{}/object", Uuid::new_v4().simple());
let first_body = b"configured KMS version one";
let second_body = b"configured KMS version two";
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(&object)
.server_side_encryption(ServerSideEncryption::AwsKms)
.ssekms_key_id(key_id)
.body(ByteStream::from_static(first_body))
.send()
.await?;
assert_eq!(first.server_side_encryption(), Some(&ServerSideEncryption::AwsKms));
assert_eq!(first.ssekms_key_id(), Some(key_id));
let first_version = first.version_id().ok_or("first SSE-KMS PUT omitted version_id")?.to_string();
let second = client
.put_object()
.bucket(&bucket)
.key(&object)
.server_side_encryption(ServerSideEncryption::AwsKms)
.ssekms_key_id(key_id)
.body(ByteStream::from_static(second_body))
.send()
.await?;
assert_eq!(second.server_side_encryption(), Some(&ServerSideEncryption::AwsKms));
assert_eq!(second.ssekms_key_id(), Some(key_id));
let second_version = second
.version_id()
.ok_or("second SSE-KMS PUT omitted version_id")?
.to_string();
assert_ne!(first_version, second_version);
let storage_root = std::path::Path::new(&env.temp_dir);
super::encryption_metadata_test::assert_storage_encrypted(storage_root, &bucket, &object, first_body);
super::encryption_metadata_test::assert_storage_encrypted(storage_root, &bucket, &object, second_body);
for (version_id, expected) in [
(&first_version, first_body.as_slice()),
(&second_version, second_body.as_slice()),
] {
let response = client
.get_object()
.bucket(&bucket)
.key(&object)
.version_id(version_id)
.send()
.await?;
assert_eq!(response.server_side_encryption(), Some(&ServerSideEncryption::AwsKms));
assert_eq!(response.ssekms_key_id(), Some(key_id));
let actual = response.body.collect().await?.into_bytes();
assert_eq!(actual.len(), expected.len());
assert_eq!(actual.as_ref(), expected);
}
let marker = client.delete_object().bucket(&bucket).key(&object).send().await?;
assert_eq!(marker.delete_marker(), Some(true));
assert!(marker.version_id().is_some(), "versioned delete must create a delete marker");
let before_cleanup = client.list_object_versions().bucket(&bucket).prefix(&object).send().await?;
assert_eq!(
before_cleanup
.versions()
.iter()
.filter(|version| version.key() == Some(object.as_str()))
.count(),
2
);
assert_eq!(
before_cleanup
.delete_markers()
.iter()
.filter(|delete_marker| delete_marker.key() == Some(object.as_str()))
.count(),
1
);
client
.delete_object()
.bucket(&bucket)
.key(&object)
.customize()
.mutate_request(|request| {
request.headers_mut().insert("x-rustfs-force-delete", "true");
})
.send()
.await?;
let after_cleanup = client.list_object_versions().bucket(&bucket).prefix(&object).send().await?;
assert!(
after_cleanup
.versions()
.iter()
.all(|version| version.key() != Some(object.as_str())),
"force cleanup must remove every encrypted object version"
);
assert!(
after_cleanup
.delete_markers()
.iter()
.all(|delete_marker| delete_marker.key() != Some(object.as_str())),
"force cleanup must remove the delete marker"
);
let head_error = match client.head_object().bucket(&bucket).key(&object).send().await {
Ok(_) => return Err("force-cleaned probe object remained readable".into()),
Err(error) => error,
};
let service_error = head_error
.as_service_error()
.ok_or_else(|| format!("force-cleaned HEAD failed with a non-service error: {head_error}"))?;
assert!(
service_error.is_not_found(),
"force-cleaned HEAD returned the wrong service error: {service_error:?}"
);
client.delete_bucket().bucket(&bucket).send().await?;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_configured_local_kms_admin_and_versioned_cleanup() -> TestResult {
let mut env = LocalKMSTestEnvironment::new().await?;
env.base_env.start_rustfs_server(Vec::new()).await?;
let start_error = match start_kms(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await {
Ok(()) => return Err("unconfigured KMS start unexpectedly succeeded".into()),
Err(error) => error,
};
assert!(
start_error.to_string().contains("no configuration provided"),
"unconfigured KMS start returned the wrong business error: {start_error}"
);
let insecure_config = serde_json::json!({
"backend_type": "Local",
"key_dir": env.kms_keys_dir,
"file_permissions": 0o600,
"default_key_id": "rustfs-e2e-test-default-key"
})
.to_string();
let configure_error =
match configure_kms(&env.base_env.url, &insecure_config, &env.base_env.access_key, &env.base_env.secret_key).await {
Ok(()) => return Err("insecure Local KMS configuration unexpectedly succeeded".into()),
Err(error) => error,
};
assert!(
configure_error.to_string().contains("requires a master key"),
"invalid Local KMS configuration returned the wrong business error: {configure_error}"
);
let default_key_id = env.configure_local_kms().await?;
start_kms(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await?;
assert_configured_status(
&env.base_env.url,
&env.base_env.access_key,
&env.base_env.secret_key,
"local",
&default_key_id,
)
.await?;
let key_id = create_and_verify_key(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await?;
assert_versioned_sse_kms_roundtrip_and_cleanup(&env.base_env, &key_id, "kms-local-configured").await?;
assert_key_deletion_lifecycle(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key, &key_id).await?;
Ok(())
}
#[tokio::test]
#[serial]
#[ignore = "requires a Vault binary"]
async fn test_configured_vault_kms_admin_and_versioned_cleanup() -> TestResult {
let mut env = VaultTestEnvironment::new().await?;
env.start_vault().await?;
env.setup_vault_transit().await?;
env.start_rustfs_for_vault().await?;
env.configure_vault_transit_kms().await?;
start_kms(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await?;
assert_configured_status(
&env.base_env.url,
&env.base_env.access_key,
&env.base_env.secret_key,
"vault-transit",
VAULT_KEY_NAME,
)
.await?;
let key_id = create_and_verify_key(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await?;
assert_ne!(key_id, VAULT_KEY_NAME, "key lifecycle test must create a distinct Vault key");
assert_versioned_sse_kms_roundtrip_and_cleanup(&env.base_env, &key_id, "kms-vault-configured").await?;
assert_key_deletion_lifecycle(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key, &key_id).await?;
Ok(())
}
@@ -42,7 +42,7 @@ fn assert_managed_encryption_metadata_hidden(metadata: Option<&HashMap<String, S
}
}
fn assert_storage_encrypted(storage_root: &std::path::Path, bucket: &str, key: &str, plaintext: &[u8]) {
pub(super) fn assert_storage_encrypted(storage_root: &std::path::Path, bucket: &str, key: &str, plaintext: &[u8]) {
let mut stack = VecDeque::from([storage_root.to_path_buf()]);
let mut scanned = 0;
let mut plaintext_path: Option<std::path::PathBuf> = None;
@@ -625,6 +625,9 @@ async fn test_multipart_upload_with_sse_c(
.bucket(bucket)
.key(object_key)
.upload_id(upload_id)
.sse_customer_algorithm("AES256")
.sse_customer_key(&key_b64)
.sse_customer_key_md5(&key_md5)
.multipart_upload(completed_multipart_upload)
.send()
.await?;
+3
View File
@@ -50,3 +50,6 @@ mod encryption_metadata_test;
#[cfg(test)]
mod copy_object_version_restore_sse_test;
#[cfg(test)]
mod configured_roundtrip_test;
@@ -566,14 +566,19 @@ async fn test_multipart_encryption_type(
.set_parts(Some(completed_parts))
.build();
let _complete_output = s3_client
let mut complete_request = s3_client
.complete_multipart_upload()
.bucket(bucket)
.key(object_key)
.upload_id(upload_id)
.multipart_upload(completed_multipart_upload)
.send()
.await?;
.multipart_upload(completed_multipart_upload);
if matches!(encryption_type, EncryptionType::SSEC) {
complete_request = complete_request
.sse_customer_algorithm("AES256")
.sse_customer_key(sse_c_key.as_ref().unwrap())
.sse_customer_key_md5(sse_c_md5.as_ref().unwrap());
}
let _complete_output = complete_request.send().await?;
// Download and verify
let mut get_request = s3_client.get_object().bucket(bucket).key(object_key);
+51
View File
@@ -23,6 +23,18 @@ pub mod common;
#[cfg(test)]
pub mod chaos;
// Programmable S3 target for replication failure-path tests (backlog#1147 repl-8).
#[cfg(test)]
pub mod fake_s3_target;
// Socket-level network fault-injection proxy for black-box cluster tests
// (backlog#1325 network fault-injection block): latency / blackhole / one-way
// partition on the wire between nodes. Serves #1312/#1319 (lock-plane one-way
// partition, accept-then-blackhole peer) and #1327 (cross-process replay/tamper
// seam); cluster-harness wiring is a follow-up (rustfs#4937).
#[cfg(test)]
pub mod fault_proxy;
// Reliability tests built on the fault-injection harness
#[cfg(test)]
mod reliability_disk_fault_test;
@@ -67,10 +79,20 @@ mod bucket_policy_check_test;
#[cfg(test)]
mod security_boundary_test;
// Cross-process replay/tamper acceptance for the internode NodeService v2 RPC
// signature (backlog#1327): method-path transplant, nonce replay, body tampering
// and the two strict rollout flips, all against a real spawned server.
#[cfg(test)]
mod internode_rpc_signature_e2e_test;
// Opt-in per-client S3 API rate limiting (backlog#1191)
#[cfg(test)]
mod api_rate_limit_test;
// Opt-in global connection cap on the main listener (backlog#1191 follow-up)
#[cfg(test)]
mod connection_cap_test;
// Admin authorization gate: non-admin denial + root-credential lifecycle (backlog#1151 sec-4)
#[cfg(test)]
mod admin_auth_test;
@@ -79,6 +101,9 @@ mod admin_auth_test;
#[cfg(test)]
mod existing_object_tag_policy_test;
#[cfg(test)]
mod sts_query_compat_test;
// Regression tests for Issue #2036: anonymous access with PublicAccessBlock
#[cfg(test)]
mod anonymous_access_test;
@@ -147,6 +172,14 @@ mod object_lock;
#[cfg(test)]
mod cluster_concurrency_test;
// Multi-drive (drivesPerNode) and 2-pool cluster harness smoke tests
#[cfg(test)]
mod cluster_multidrive_pool_test;
// backlog#1433: real 4-node EC boundary gate for inline storage and GET paths.
#[cfg(test)]
mod inline_fast_path_cluster_test;
// PutObject / MultipartUpload with checksum (Content-MD5, x-amz-checksum-*)
#[cfg(test)]
mod checksum_upload_test;
@@ -167,9 +200,24 @@ mod heal_erasure_disk_rebuild_test;
#[cfg(test)]
mod copy_object_metadata_test;
#[cfg(test)]
mod copy_object_tagging_test;
#[cfg(test)]
mod copy_object_version_restore_test;
#[cfg(test)]
mod copy_object_checksum_test;
#[cfg(test)]
mod ssec_copy_test;
#[cfg(test)]
mod multipart_storage_class_test;
#[cfg(test)]
mod storage_class_capability_test;
// S3 dummy-compat bucket API tests
#[cfg(test)]
mod bucket_logging_test;
@@ -219,6 +267,9 @@ mod console_smoke_test;
#[cfg(test)]
mod admin_iam_crud_test;
#[cfg(test)]
mod admin_pools_test;
// Replication extension end-to-end regression tests
#[cfg(test)]
mod replication_extension_test;
+55 -17
View File
@@ -53,6 +53,17 @@ fn sse_customer_key_md5_base64(key: &str) -> String {
base64::engine::general_purpose::STANDARD.encode(md5::compute(key).0)
}
/// Env var consumed by the local SSE-S3 DEK provider when KMS is not configured.
///
/// Since rustfs#3564 the server fails closed on managed SSE (SSE-S3 or
/// bucket-default encryption) unless KMS is configured or this master key is
/// provided, so tests exercising managed SSE on a bare server must seed it.
const LOCAL_SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
fn local_sse_master_key_value() -> String {
base64::engine::general_purpose::STANDARD.encode([0x42u8; 32])
}
async fn make_tar(files: &[(&str, &[u8])], dirs: &[&str]) -> Vec<u8> {
let buf = Cursor::new(Vec::new());
let mut builder = tokio_tar::Builder::new(buf);
@@ -105,7 +116,11 @@ async fn make_tar_with_pax_entry(path: &str, data: &[u8], mtime: Option<u64>, pa
pax_payload.extend(build_pax_record(key, value));
}
let mut pax_header = tokio_tar::Header::new_gnu();
// Pax extension entries must carry a POSIX ustar header — this is what real
// tar writers emit, and the server-side reader rejects an XHeader typeflag on
// GNU-format headers ("extension typeflag is not permitted on an unrecognized
// header").
let mut pax_header = tokio_tar::Header::new_ustar();
pax_header.set_entry_type(tokio_tar::EntryType::XHeader);
pax_header.set_size(pax_payload.len() as u64);
pax_header.set_mode(0o644);
@@ -926,7 +941,9 @@ async fn test_anonymous_post_object_accepts_sse_s3() -> Result<(), Box<dyn std::
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let master_key = local_sse_master_key_value();
env.start_rustfs_server_with_env(vec![], &[(LOCAL_SSE_MASTER_KEY_ENV, master_key.as_str())])
.await?;
let bucket = "anon-post-sse-s3";
let object_key = "post-sse-s3-object.txt";
@@ -982,7 +999,9 @@ async fn test_anonymous_post_object_uses_bucket_default_sse_s3() -> Result<(), B
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let master_key = local_sse_master_key_value();
env.start_rustfs_server_with_env(vec![], &[(LOCAL_SSE_MASTER_KEY_ENV, master_key.as_str())])
.await?;
let bucket = "anon-post-default-sse-s3";
let object_key = "post-default-sse-s3-object.txt";
@@ -1053,7 +1072,9 @@ async fn test_anonymous_post_object_uses_bucket_default_sse_kms() -> Result<(),
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let master_key = local_sse_master_key_value();
env.start_rustfs_server_with_env(vec![], &[(LOCAL_SSE_MASTER_KEY_ENV, master_key.as_str())])
.await?;
let bucket = "anon-post-default-sse-kms";
let object_key = "post-default-sse-kms-object.txt";
@@ -1172,15 +1193,24 @@ async fn test_anonymous_post_object_rejects_sse_s3_policy_mismatch() -> Result<(
#[tokio::test]
#[serial]
async fn test_anonymous_post_object_rejects_sse_s3_missing_from_policy_conditions()
async fn test_anonymous_post_object_accepts_sse_s3_missing_from_policy_conditions()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
// MinIO-compatible POST-policy validation (s3s-project/s3s#608) exempts the
// x-amz-server-side-encryption* form fields from the "every form field must
// appear in the policy conditions" rule, so an SSE-S3 field that the policy
// does not mention is accepted and encryption is applied. When the policy
// does cover the field, a value mismatch is still rejected — see
// test_anonymous_post_object_rejects_sse_s3_policy_mismatch.
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let master_key = local_sse_master_key_value();
env.start_rustfs_server_with_env(vec![], &[(LOCAL_SSE_MASTER_KEY_ENV, master_key.as_str())])
.await?;
let bucket = "anon-post-sse-s3-missing";
let object_key = "post-sse-s3-missing-object.txt";
let expected_body = b"post-sse-s3-missing".to_vec();
let admin_client = env.create_s3_client();
admin_client.create_bucket().bucket(bucket).send().await?;
@@ -1198,7 +1228,7 @@ async fn test_anonymous_post_object_rejects_sse_s3_missing_from_policy_condition
.text("x-amz-server-side-encryption", "AES256")
.part(
"file",
reqwest::multipart::Part::bytes(b"post-sse-s3-missing".to_vec())
reqwest::multipart::Part::bytes(expected_body.clone())
.file_name("upload.txt")
.mime_str("text/plain")?,
);
@@ -1212,11 +1242,15 @@ async fn test_anonymous_post_object_rejects_sse_s3_missing_from_policy_condition
let status = post_resp.status();
let response_body = post_resp.text().await?;
assert_eq!(status, reqwest::StatusCode::FORBIDDEN);
assert!(
response_body.contains("<Code>AccessDenied</Code>"),
"response should contain AccessDenied code, got: {response_body}"
);
assert_eq!(status, reqwest::StatusCode::NO_CONTENT);
assert!(response_body.is_empty(), "204 response should not contain a body, got: {response_body}");
let head = admin_client.head_object().bucket(bucket).key(object_key).send().await?;
assert_eq!(head.server_side_encryption().map(|value| value.as_str()), Some("AES256"));
let uploaded = admin_client.get_object().bucket(bucket).key(object_key).send().await?;
let uploaded = uploaded.body.collect().await?.into_bytes();
assert_eq!(uploaded.as_ref(), expected_body.as_slice());
Ok(())
}
@@ -1233,7 +1267,7 @@ async fn test_anonymous_post_object_accepts_storage_class_exact_policy_match()
let bucket = "anon-post-storage-class";
let object_key = "post-storage-class-object.txt";
let expected_body = b"post-storage-class-body".to_vec();
let storage_class = "STANDARD_IA";
let storage_class = "REDUCED_REDUNDANCY";
let admin_client = env.create_s3_client();
admin_client.create_bucket().bucket(bucket).send().await?;
@@ -5041,7 +5075,9 @@ async fn test_signed_put_object_extract_preserves_sse_s3_and_redirect() -> Resul
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key.as_str())])
.await?;
let bucket = "signed-extract-sse-s3-redirect";
let archive_key = "encrypted-metadata.tar";
@@ -5102,7 +5138,7 @@ async fn test_signed_put_object_extract_preserves_storage_class() -> Result<(),
.put_object()
.bucket(bucket)
.key(archive_key)
.storage_class(aws_sdk_s3::types::StorageClass::StandardIa)
.storage_class(aws_sdk_s3::types::StorageClass::ReducedRedundancy)
.body(ByteStream::from(tar_bytes))
.customize()
.mutate_request(move |req| {
@@ -5119,7 +5155,7 @@ async fn test_signed_put_object_extract_preserves_storage_class() -> Result<(),
.send()
.await?;
assert_eq!(head.storage_class().map(|value| value.as_str()), Some("STANDARD_IA"));
assert_eq!(head.storage_class().map(|value| value.as_str()), Some("REDUCED_REDUNDANCY"));
Ok(())
}
@@ -5318,7 +5354,9 @@ async fn test_signed_put_object_extract_uses_bucket_default_sse_s3() -> Result<(
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key.as_str())])
.await?;
let bucket = "signed-extract-default-sse-s3";
let archive_key = "default-encryption.tar";
@@ -0,0 +1,364 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! CreateMultipartUpload storage-class persistence regression tests.
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart, StorageClass};
const PART_SIZE: usize = 5 * 1024 * 1024;
async fn assert_completed_object(
client: &Client,
bucket: &str,
key: &str,
expected_storage_class: &str,
expected_body: &[u8],
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let head = client.head_object().bucket(bucket).key(key).send().await?;
let expected_head_class = (expected_storage_class != "STANDARD").then_some(expected_storage_class);
assert_eq!(
head.storage_class().map(StorageClass::as_str),
expected_head_class,
"HeadObject should use S3's implicit STANDARD representation"
);
let listed = client.list_objects_v2().bucket(bucket).prefix(key).send().await?;
let object = listed
.contents()
.iter()
.find(|object| object.key() == Some(key))
.ok_or("completed multipart object missing from ListObjectsV2")?;
assert_eq!(
object.storage_class().map(|storage_class| storage_class.as_str()),
Some(expected_storage_class),
"ListObjectsV2 should report the completed object's storage class"
);
let body = client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await?
.body
.collect()
.await?
.into_bytes();
assert_eq!(body.as_ref(), expected_body, "completed multipart body should be byte-exact");
Ok(())
}
#[tokio::test]
async fn multipart_upload_preserves_standard_and_rrs_across_retry_and_resume()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(Vec::new()).await?;
let client = env.create_s3_client();
let bucket = "multipart-storage-class-retry";
env.create_test_bucket(bucket).await?;
for storage_class in [StorageClass::Standard, StorageClass::ReducedRedundancy] {
let class_name = storage_class.as_str();
let key = format!("retry-{class_name}.bin");
let create = client
.create_multipart_upload()
.bucket(bucket)
.key(&key)
.storage_class(storage_class.clone())
.content_type("application/octet-stream")
.metadata("content-type", "user-content-type")
.metadata("x-amz-storage-class", "user-storage-class")
.send()
.await?;
let upload_id = create.upload_id().ok_or("CreateMultipartUpload returned no upload ID")?;
let original_part = vec![b'a'; PART_SIZE];
let first_attempt = client
.upload_part()
.bucket(bucket)
.key(&key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from(original_part))
.send()
.await?;
let resumed_client = env.create_s3_client();
let before_retry = resumed_client
.list_parts()
.bucket(bucket)
.key(&key)
.upload_id(upload_id)
.send()
.await?;
assert_eq!(before_retry.storage_class().map(StorageClass::as_str), Some(class_name));
assert_eq!(before_retry.parts().len(), 1, "resume should find the previously uploaded part");
let retried_part = vec![b'b'; PART_SIZE];
let retry = resumed_client
.upload_part()
.bucket(bucket)
.key(&key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from(retried_part.clone()))
.send()
.await?;
assert_ne!(
first_attempt.e_tag(),
retry.e_tag(),
"retrying the same part number with different bytes should replace the part"
);
let tail = format!("-tail-{class_name}").into_bytes();
let second = resumed_client
.upload_part()
.bucket(bucket)
.key(&key)
.upload_id(upload_id)
.part_number(2)
.body(ByteStream::from(tail.clone()))
.send()
.await?;
let after_retry = resumed_client
.list_parts()
.bucket(bucket)
.key(&key)
.upload_id(upload_id)
.send()
.await?;
assert_eq!(after_retry.storage_class().map(StorageClass::as_str), Some(class_name));
assert_eq!(after_retry.parts().len(), 2);
assert_eq!(after_retry.parts()[0].e_tag(), retry.e_tag());
resumed_client
.complete_multipart_upload()
.bucket(bucket)
.key(&key)
.upload_id(upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.set_e_tag(retry.e_tag().map(str::to_owned))
.build(),
)
.parts(
CompletedPart::builder()
.part_number(2)
.set_e_tag(second.e_tag().map(str::to_owned))
.build(),
)
.build(),
)
.send()
.await?;
let mut expected_body = retried_part;
expected_body.extend_from_slice(&tail);
assert_completed_object(&resumed_client, bucket, &key, class_name, &expected_body).await?;
let metadata_head = resumed_client.head_object().bucket(bucket).key(&key).send().await?;
assert_eq!(metadata_head.content_type(), Some("application/octet-stream"));
assert_eq!(
metadata_head.metadata().and_then(|metadata| metadata.get("content-type")),
Some(&"user-content-type".to_string())
);
assert_eq!(
metadata_head
.metadata()
.and_then(|metadata| metadata.get("x-amz-storage-class")),
Some(&"user-storage-class".to_string())
);
}
env.stop_server();
Ok(())
}
#[tokio::test]
async fn multipart_copy_preserves_standard_and_rrs() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(Vec::new()).await?;
let client = env.create_s3_client();
let bucket = "multipart-storage-class-copy";
let source_key = "source.bin";
let source_body = vec![b'c'; 1024 * 1024];
env.create_test_bucket(bucket).await?;
client
.put_object()
.bucket(bucket)
.key(source_key)
.body(ByteStream::from(source_body.clone()))
.send()
.await?;
for storage_class in [StorageClass::Standard, StorageClass::ReducedRedundancy] {
let class_name = storage_class.as_str();
let key = format!("copy-{class_name}.bin");
let create = client
.create_multipart_upload()
.bucket(bucket)
.key(&key)
.storage_class(storage_class.clone())
.send()
.await?;
let upload_id = create.upload_id().ok_or("CreateMultipartUpload returned no upload ID")?;
let copied = client
.upload_part_copy()
.bucket(bucket)
.key(&key)
.upload_id(upload_id)
.part_number(1)
.copy_source(format!("{bucket}/{source_key}"))
.send()
.await?;
let e_tag = copied
.copy_part_result()
.and_then(|result| result.e_tag())
.ok_or("UploadPartCopy returned no ETag")?;
let parts = client
.list_parts()
.bucket(bucket)
.key(&key)
.upload_id(upload_id)
.send()
.await?;
assert_eq!(parts.storage_class().map(StorageClass::as_str), Some(class_name));
assert_eq!(parts.parts().len(), 1);
client
.complete_multipart_upload()
.bucket(bucket)
.key(&key)
.upload_id(upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(CompletedPart::builder().part_number(1).e_tag(e_tag).build())
.build(),
)
.send()
.await?;
assert_completed_object(&client, bucket, &key, class_name, &source_body).await?;
}
env.stop_server();
Ok(())
}
#[tokio::test]
async fn invalid_and_aborted_uploads_leave_no_session_or_object() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(Vec::new()).await?;
let client = env.create_s3_client();
let bucket = "multipart-storage-class-errors";
let invalid_key = "invalid.bin";
let aborted_key = "aborted.bin";
env.create_test_bucket(bucket).await?;
let invalid = client
.create_multipart_upload()
.bucket(bucket)
.key(invalid_key)
.storage_class(StorageClass::from("INVALID"))
.send()
.await
.expect_err("invalid storage class should be rejected");
assert_eq!(
invalid.as_service_error().and_then(ProvideErrorMetadata::code),
Some("InvalidStorageClass")
);
let after_invalid = client
.list_multipart_uploads()
.bucket(bucket)
.prefix(invalid_key)
.send()
.await?;
assert!(
after_invalid.uploads().is_empty(),
"validation failure must not create a multipart session"
);
let create = client
.create_multipart_upload()
.bucket(bucket)
.key(aborted_key)
.storage_class(StorageClass::ReducedRedundancy)
.send()
.await?;
let upload_id = create.upload_id().ok_or("CreateMultipartUpload returned no upload ID")?;
client
.upload_part()
.bucket(bucket)
.key(aborted_key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from_static(b"aborted multipart part"))
.send()
.await?;
let before_abort = client
.list_parts()
.bucket(bucket)
.key(aborted_key)
.upload_id(upload_id)
.send()
.await?;
assert_eq!(before_abort.storage_class().map(StorageClass::as_str), Some("REDUCED_REDUNDANCY"));
client
.abort_multipart_upload()
.bucket(bucket)
.key(aborted_key)
.upload_id(upload_id)
.send()
.await?;
let after_abort = client
.list_parts()
.bucket(bucket)
.key(aborted_key)
.upload_id(upload_id)
.send()
.await
.expect_err("aborted upload should not be resumable");
assert_eq!(after_abort.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchUpload"));
let remaining_uploads = client
.list_multipart_uploads()
.bucket(bucket)
.prefix(aborted_key)
.send()
.await?;
assert!(remaining_uploads.uploads().is_empty(), "abort should remove the multipart session");
let aborted_head = client
.head_object()
.bucket(bucket)
.key(aborted_key)
.send()
.await
.expect_err("aborted upload should not create an object");
assert_eq!(aborted_head.raw_response().map(|response| response.status().as_u16()), Some(404));
env.stop_server();
Ok(())
}
}
+292 -16
View File
@@ -39,10 +39,17 @@ use local_ip_address::local_ip;
use reqwest::StatusCode;
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4;
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
use s3s::Body;
use serde_json::Value;
use serial_test::serial;
use std::error::Error;
use std::path::Path;
use std::sync::{
Arc, Once,
atomic::{AtomicBool, Ordering},
};
use std::thread;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::mpsc;
@@ -64,6 +71,11 @@ fn target_arn(target_name: &str) -> String {
format!("arn:rustfs:sqs:{NOTIFY_REGION}:{target_name}:webhook")
}
fn endpoint_origin(endpoint: &str) -> Result<String, BoxError> {
let parsed = reqwest::Url::parse(endpoint)?;
Ok(parsed.origin().ascii_serialization())
}
// ---------------------------------------------------------------------------
// In-test HTTP event receiver
// ---------------------------------------------------------------------------
@@ -147,7 +159,175 @@ async fn spawn_event_collector() -> Result<(String, mpsc::UnboundedReceiver<Valu
let endpoint_ip = local_ip()?;
let (tx, rx) = mpsc::unbounded_channel();
let handle = serve_event_collector(listener, tx);
Ok((format!("http://{endpoint_ip}.nip.io:{port}/events"), rx, handle))
Ok((format!("http://{}/events", std::net::SocketAddr::new(endpoint_ip, port)), rx, handle))
}
struct HttpsEventCollector {
endpoint: String,
running: Arc<AtomicBool>,
handle: Option<thread::JoinHandle<()>>,
events: mpsc::UnboundedReceiver<Value>,
}
impl HttpsEventCollector {
fn endpoint(&self) -> &str {
&self.endpoint
}
fn events_mut(&mut self) -> &mut mpsc::UnboundedReceiver<Value> {
&mut self.events
}
fn shutdown(&mut self) -> TestResult {
self.running.store(false, Ordering::Relaxed);
if let Ok(parsed) = self.endpoint.parse::<reqwest::Url>()
&& let Some(port) = parsed.port()
{
let _ = std::net::TcpStream::connect(("127.0.0.1", port));
}
if let Some(handle) = self.handle.take() {
handle.join().map_err(|_| "https event collector thread panicked")?;
}
Ok(())
}
}
impl Drop for HttpsEventCollector {
fn drop(&mut self) {
let _ = self.shutdown();
}
}
fn spawn_https_event_collector(ca_path: &Path) -> Result<HttpsEventCollector, BoxError> {
use rustls::{
ServerConfig,
pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer},
};
use std::io::ErrorKind;
use std::net::TcpListener as StdTcpListener;
static INSTALL_CRYPTO_PROVIDER: Once = Once::new();
INSTALL_CRYPTO_PROVIDER.call_once(|| {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
});
let listener = StdTcpListener::bind("0.0.0.0:0")?;
listener.set_nonblocking(true)?;
let addr = listener.local_addr()?;
let endpoint_ip = local_ip()?;
let endpoint_host = endpoint_ip.to_string();
let rcgen::CertifiedKey { cert, signing_key } = rcgen::generate_simple_self_signed(vec![endpoint_host])?;
std::fs::write(ca_path, cert.pem())?;
let cert_chain = vec![cert.der().clone()];
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(signing_key.serialize_der()));
let server_config = Arc::new(
ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(cert_chain, key_der)?,
);
let running = Arc::new(AtomicBool::new(true));
let server_running = Arc::clone(&running);
let (tx, events) = mpsc::unbounded_channel();
let handle = thread::spawn(move || {
let mut connections = Vec::new();
while server_running.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => {
let config = Arc::clone(&server_config);
let tx = tx.clone();
connections.push(thread::spawn(move || {
let _ = handle_https_request(stream, config, tx);
}));
}
Err(err) if err.kind() == ErrorKind::WouldBlock => thread::sleep(Duration::from_millis(20)),
Err(_) => break,
}
}
for connection in connections {
let _ = connection.join();
}
});
Ok(HttpsEventCollector {
endpoint: format!("https://{}/events", std::net::SocketAddr::new(endpoint_ip, addr.port())),
running,
handle: Some(handle),
events,
})
}
fn read_sync_http_message<R: std::io::Read>(stream: &mut R) -> Result<(String, Vec<u8>), BoxError> {
let mut buffer = Vec::new();
let mut chunk = [0_u8; 4096];
let header_end = loop {
let read = stream.read(&mut chunk)?;
if read == 0 {
return Err("connection closed before request headers were complete".into());
}
buffer.extend_from_slice(&chunk[..read]);
if let Some(pos) = buffer.windows(4).position(|window| window == b"\r\n\r\n") {
break pos;
}
};
let header_text = std::str::from_utf8(&buffer[..header_end])?;
let mut lines = header_text.split("\r\n");
let method = lines
.next()
.and_then(|line| line.split_whitespace().next())
.ok_or("missing request method")?
.to_string();
let mut content_length = 0usize;
for line in lines {
if let Some((name, value)) = line.split_once(':')
&& name.trim().eq_ignore_ascii_case("content-length")
{
content_length = value.trim().parse()?;
}
}
let body_offset = header_end + 4;
while buffer.len().saturating_sub(body_offset) < content_length {
let read = stream.read(&mut chunk)?;
if read == 0 {
return Err("connection closed before request body was complete".into());
}
buffer.extend_from_slice(&chunk[..read]);
}
Ok((method, buffer[body_offset..body_offset + content_length].to_vec()))
}
fn handle_https_request(
stream: std::net::TcpStream,
server_config: Arc<rustls::ServerConfig>,
tx: mpsc::UnboundedSender<Value>,
) -> Result<(), BoxError> {
use std::io::Write;
stream.set_nonblocking(false)?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
let connection = rustls::ServerConnection::new(server_config)?;
let mut tls_stream = rustls::StreamOwned::new(connection, stream);
let (method, body) = read_sync_http_message(&mut tls_stream)?;
let response = "HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n";
tls_stream.write_all(response.as_bytes())?;
tls_stream.flush()?;
tls_stream.conn.send_close_notify();
while tls_stream.conn.wants_write() {
tls_stream.conn.write_tls(&mut tls_stream.sock)?;
}
let _ = tls_stream.sock.shutdown(std::net::Shutdown::Write);
if method == "POST"
&& !body.is_empty()
&& let Ok(event) = serde_json::from_slice(&body)
{
let _ = tx.send(event);
}
Ok(())
}
/// Decoded object key of the first record in an event envelope.
@@ -284,13 +464,24 @@ async fn enable_notify_module(env: &RustFSTestEnvironment) -> TestResult {
/// Registers a webhook notification target with a persistent queue directory, so
/// delivery goes through the durable store-and-forward path.
async fn configure_webhook_target(env: &RustFSTestEnvironment, target_name: &str, endpoint: &str) -> TestResult {
configure_webhook_target_with_key_values(env, target_name, vec![("endpoint", endpoint.to_string())]).await
}
async fn configure_webhook_target_with_key_values(
env: &RustFSTestEnvironment,
target_name: &str,
mut key_values: Vec<(&str, String)>,
) -> TestResult {
let queue_dir = format!("{}/notify-queue-{target_name}", env.temp_dir);
tokio::fs::create_dir_all(&queue_dir).await?;
if !key_values.iter().any(|(key, _)| *key == "queue_dir") {
key_values.push(("queue_dir", queue_dir));
}
let payload = serde_json::json!({
"key_values": [
{ "key": "endpoint", "value": endpoint },
{ "key": "queue_dir", "value": queue_dir },
]
"key_values": key_values
.into_iter()
.map(|(key, value)| serde_json::json!({ "key": key, "value": value }))
.collect::<Vec<_>>(),
});
let url = format!("{}/rustfs/admin/v3/target/notify_webhook/{target_name}", env.url);
let response = signed_admin_request(env, http::Method::PUT, &url, Some(payload.to_string().into_bytes())).await?;
@@ -321,6 +512,31 @@ async fn wait_for_target_registered(env: &RustFSTestEnvironment, target_name: &s
Err(format!("target {target_name} was not registered in admin ARNs").into())
}
async fn wait_for_target_online(env: &RustFSTestEnvironment, target_name: &str) -> TestResult {
let url = format!("{}/rustfs/admin/v3/target/list", env.url);
for _ in 0..40 {
let response = signed_admin_request(env, http::Method::GET, &url, None).await?;
if response.status() == StatusCode::OK {
let body: Value = serde_json::from_slice(&response.bytes().await?)?;
if body["notify_enabled"].as_bool() != Some(true) {
return Err(format!("admin target list did not report notify_enabled=true: {body}").into());
}
let listed = body["notification_endpoints"].as_array().is_some_and(|endpoints| {
endpoints.iter().any(|endpoint| {
endpoint["account_id"].as_str() == Some(target_name)
&& endpoint["service"].as_str() == Some("webhook")
&& endpoint["status"].as_str() == Some("online")
})
});
if listed {
return Ok(());
}
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
Err(format!("target {target_name} did not become online in admin targets").into())
}
/// Binds a bucket to a webhook target for ObjectCreated:*/ObjectRemoved:* events,
/// filtered to `prefix` + `suffix`.
async fn put_notification_config(client: &Client, bucket: &str, target_name: &str, prefix: &str, suffix: &str) -> TestResult {
@@ -367,6 +583,62 @@ fn trimmed_etag(value: Option<&str>) -> Option<String> {
// Tests
// ---------------------------------------------------------------------------
/// Regression for rustfs#5052: with the notify module enabled through
/// RUSTFS_NOTIFY_ENABLE, an HTTPS webhook using a configured CA must become
/// online and receive a real S3 event POST.
#[tokio::test]
#[serial]
async fn test_https_webhook_target_delivers_event_with_notify_env_enabled() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
let ca_path = Path::new(&env.temp_dir).join("https-webhook-ca.pem");
let mut collector = spawn_https_event_collector(&ca_path)?;
let allowed_origin = endpoint_origin(collector.endpoint())?;
env.start_rustfs_server_with_env(
vec![],
&[
("RUSTFS_NOTIFY_ENABLE", "true"),
(ENV_OUTBOUND_ALLOW_ORIGINS, allowed_origin.as_str()),
],
)
.await?;
let target = "peri1https";
let bucket = "peri1-https-events";
let key = "uploads/https.dat";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
configure_webhook_target_with_key_values(
&env,
target,
vec![
("endpoint", collector.endpoint().to_string()),
("client_ca", ca_path.to_string_lossy().into_owned()),
],
)
.await?;
wait_for_target_online(&env, target).await?;
wait_for_target_registered(&env, target).await?;
put_notification_config(&client, bucket, target, "uploads/", ".dat").await?;
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"https webhook event body"))
.send()
.await?;
let event = wait_for_event(collector.events_mut(), key, "s3:ObjectCreated:", Duration::from_secs(20)).await?;
assert_eq!(event["EventName"].as_str(), Some("s3:ObjectCreated:Put"));
assert_eq!(event["Records"][0]["s3"]["bucket"]["name"].as_str(), Some(bucket));
assert_eq!(event_key(&event).as_deref(), Some(key));
env.stop_server();
collector.shutdown()?;
Ok(())
}
/// PUT / multipart-complete / DELETE each deliver one event with correct fields,
/// and the prefix/suffix filter drops non-matching keys.
#[tokio::test]
@@ -375,9 +647,11 @@ async fn test_webhook_event_delivery_and_filtering() -> TestResult {
init_logging();
let (endpoint, mut rx, handle) = spawn_event_collector().await?;
let allowed_origin = endpoint_origin(&endpoint)?;
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
env.start_rustfs_server_with_env(vec![], &[(ENV_OUTBOUND_ALLOW_ORIGINS, allowed_origin.as_str())])
.await?;
enable_notify_module(&env).await?;
let bucket = "peri1-events";
@@ -529,15 +803,6 @@ async fn test_webhook_event_delivery_and_filtering() -> TestResult {
async fn test_webhook_redelivers_event_after_target_recovers() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
enable_notify_module(&env).await?;
let bucket = "peri1-redeliver";
let target = "peri1redeliver";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
// Configure the target while its endpoint is reachable so activation and
// ARN registration complete deterministically. Registering against a dead
// endpoint stalls behind the reachability probe's timeout and flakes the
@@ -546,10 +811,21 @@ async fn test_webhook_redelivers_event_after_target_recovers() -> TestResult {
let listener = TcpListener::bind("0.0.0.0:0").await?;
let port = listener.local_addr()?.port();
let endpoint_ip = local_ip()?;
let endpoint = format!("http://{endpoint_ip}.nip.io:{port}/events");
let endpoint = format!("http://{}/events", std::net::SocketAddr::new(endpoint_ip, port));
let allowed_origin = endpoint_origin(&endpoint)?;
let (setup_tx, _setup_rx) = mpsc::unbounded_channel();
let setup_handle = serve_event_collector(listener, setup_tx);
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(vec![], &[(ENV_OUTBOUND_ALLOW_ORIGINS, allowed_origin.as_str())])
.await?;
enable_notify_module(&env).await?;
let bucket = "peri1-redeliver";
let target = "peri1redeliver";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
configure_webhook_target(&env, target, &endpoint).await?;
wait_for_target_registered(&env, target).await?;
put_notification_config(&client, bucket, target, "uploads/", ".dat").await?;
+52 -19
View File
@@ -18,6 +18,7 @@ use http::header::{CONTENT_TYPE, HOST};
use reqwest::StatusCode;
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::{pre_sign_v4, sign_v4};
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
use s3s::Body;
use serial_test::serial;
use std::collections::HashMap;
@@ -49,7 +50,7 @@ fn find_header_terminator(buf: &[u8]) -> Option<usize> {
async fn read_http_request(
stream: &mut tokio::net::TcpStream,
) -> Result<(HashMap<String, String>, Vec<u8>), Box<dyn Error + Send + Sync>> {
) -> Result<(String, HashMap<String, String>, Vec<u8>), Box<dyn Error + Send + Sync>> {
let mut buffer = Vec::new();
let mut chunk = [0_u8; 4096];
@@ -67,7 +68,7 @@ async fn read_http_request(
let header_bytes = &buffer[..header_end];
let header_text = std::str::from_utf8(header_bytes)?;
let mut lines = header_text.split("\r\n");
let _request_line = lines.next().ok_or("missing request line")?;
let request_line = lines.next().ok_or("missing request line")?.to_string();
let mut headers = HashMap::new();
for line in lines {
if line.is_empty() {
@@ -79,8 +80,9 @@ async fn read_http_request(
let content_length = headers
.get("content-length")
.ok_or("missing content-length header")?
.parse::<usize>()?;
.map(|value| value.parse::<usize>())
.transpose()?
.unwrap_or_default();
let body_offset = header_end + 4;
while buffer.len().saturating_sub(body_offset) < content_length {
let read = stream.read(&mut chunk).await?;
@@ -90,7 +92,7 @@ async fn read_http_request(
buffer.extend_from_slice(&chunk[..read]);
}
Ok((headers, buffer[body_offset..body_offset + content_length].to_vec()))
Ok((request_line, headers, buffer[body_offset..body_offset + content_length].to_vec()))
}
async fn spawn_object_lambda_webhook_server() -> Result<
@@ -130,9 +132,17 @@ async fn spawn_object_lambda_webhook_server_with_response(
let handle = tokio::spawn(async move {
loop {
let (mut stream, _) = listener.accept().await?;
let Ok(Ok((headers, body))) = timeout(Duration::from_secs(2), read_http_request(&mut stream)).await else {
let Ok(Ok((request_line, headers, body))) = timeout(Duration::from_secs(2), read_http_request(&mut stream)).await
else {
continue;
};
if request_line == "HEAD / HTTP/1.1" {
stream
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
.await?;
stream.shutdown().await?;
continue;
}
let payload: serde_json::Value = serde_json::from_slice(&body)?;
let output_route = payload["getObjectContext"]["outputRoute"]
@@ -412,9 +422,33 @@ async fn wait_for_target_absence(
Err(format!("target {target_name} remained visible in admin APIs; targets={last_targets}, arns={last_arns:?}").into())
}
async fn restart_rustfs_server(env: &mut RustFSTestEnvironment) -> Result<(), Box<dyn Error + Send + Sync>> {
fn endpoint_origin(endpoint: &str) -> Result<String, Box<dyn Error + Send + Sync>> {
Ok(reqwest::Url::parse(endpoint)?.origin().ascii_serialization())
}
async fn start_rustfs_server_for_endpoint(
env: &mut RustFSTestEnvironment,
endpoint: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let origin = endpoint_origin(endpoint)?;
env.start_rustfs_server_with_env(
vec![],
&[
(ENV_OUTBOUND_ALLOW_ORIGINS, origin.as_str()),
("RUSTFS_NOTIFY_ENABLE", "true"),
],
)
.await
}
async fn restart_rustfs_server(env: &mut RustFSTestEnvironment, endpoint: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
let origin = endpoint_origin(endpoint)?;
env.stop_server();
env.start_rustfs_server_without_cleanup(vec![]).await
env.start_rustfs_server_without_cleanup_with_env(&[
(ENV_OUTBOUND_ALLOW_ORIGINS, origin.as_str()),
("RUSTFS_NOTIFY_ENABLE", "true"),
])
.await
}
async fn spawn_http_origin_probe_server() -> Result<
@@ -521,7 +555,7 @@ async fn test_notification_target_persists_across_restart_and_delete() -> Result
let (webhook_url, _request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?;
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
let target_name = "restart-target";
configure_webhook_target(&env, target_name, &webhook_url, "secret-token").await?;
@@ -535,7 +569,7 @@ async fn test_notification_target_persists_across_restart_and_delete() -> Result
"target ARN missing after initial configure: {visible_arns:?}"
);
restart_rustfs_server(&mut env).await?;
restart_rustfs_server(&mut env, &webhook_url).await?;
let (targets_after_restart, arns_after_restart) = wait_for_target_visibility(&env, target_name).await?;
assert!(notification_target_is_listed(&targets_after_restart, target_name));
@@ -556,7 +590,7 @@ async fn test_notification_target_persists_across_restart_and_delete() -> Result
"target ARN still visible after delete: {arns_after_delete:?}"
);
restart_rustfs_server(&mut env).await?;
restart_rustfs_server(&mut env, &webhook_url).await?;
let (targets_after_delete_restart, arns_after_delete_restart) = wait_for_target_absence(&env, target_name).await?;
assert!(!notification_target_is_listed(&targets_after_delete_restart, target_name));
@@ -581,8 +615,7 @@ async fn test_notification_target_with_path_is_online_via_transport_probe() -> R
let (webhook_url, mut probe_rx, probe_handle) = spawn_http_origin_probe_server().await?;
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_NOTIFY_ENABLE", "true")])
.await?;
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
let target_name = "path-probe";
configure_webhook_target(&env, target_name, &webhook_url, "secret-token").await?;
@@ -615,7 +648,7 @@ async fn test_get_object_lambda_accepts_presigned_requests() -> Result<(), Box<d
let (webhook_url, request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?;
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
let bucket = "object-lambda-e2e-presigned";
let key = "input.txt";
@@ -656,7 +689,7 @@ async fn test_get_object_lambda_accepts_named_webhook_target_arn() -> Result<(),
let (webhook_url, request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?;
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
let bucket = "object-lambda-e2e-named-target";
let key = "input.txt";
@@ -696,7 +729,7 @@ async fn test_get_object_lambda_invokes_runtime_webhook_target() -> Result<(), B
let (webhook_url, request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?;
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
let bucket = "object-lambda-e2e";
let key = "input.txt";
@@ -777,7 +810,7 @@ async fn test_get_object_lambda_passthroughs_non_success_webhook_response() -> R
.await?;
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
let bucket = "object-lambda-e2e-failure";
let key = "input.txt";
@@ -832,7 +865,7 @@ async fn test_get_object_lambda_rejects_success_response_without_auth_headers()
.await?;
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
let bucket = "object-lambda-e2e-missing-auth";
let key = "input.txt";
@@ -879,7 +912,7 @@ async fn test_get_object_lambda_rejects_success_response_with_mismatched_auth_he
.await?;
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
let bucket = "object-lambda-e2e-mismatched-auth";
let key = "input.txt";
+58
View File
@@ -277,6 +277,64 @@ mod integration_tests {
Ok(())
}
/// backlog#1336 regression: a PUT that merely declares `Content-Encoding: aws-chunked`
/// (no SigV4 streaming payload, so no `x-amz-decoded-content-length`) carries an unframed
/// body whose wire Content-Length is the real object size. Quota admission must use that
/// length — with and without a hard quota configured — instead of rejecting the request
/// with 400 UnexpectedContent, and an over-quota aws-chunked PUT must still get the quota
/// rejection.
#[tokio::test]
#[serial]
async fn test_quota_admission_aws_chunked_declared_encoding() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
let put_aws_chunked = |key: &'static str, size_bytes: usize| {
env.client
.put_object()
.bucket(&env.bucket_name)
.key(key)
.content_encoding("aws-chunked")
.body(aws_sdk_s3::primitives::ByteStream::from(vec![0u8; size_bytes]))
.send()
};
// No quota configured: the declared aws-chunked PUT must be admitted.
put_aws_chunked("no-quota.bin", 512)
.await
.expect("declared aws-chunked PUT without quota must succeed");
assert!(env.object_exists("no-quota.bin").await?);
// Hard quota configured: a within-quota declared aws-chunked PUT is admitted
// against its wire Content-Length.
env.set_bucket_quota(4 * 1024).await?;
put_aws_chunked("within-quota.bin", 1024)
.await
.expect("declared aws-chunked PUT within quota must succeed");
assert!(env.object_exists("within-quota.bin").await?);
// An over-quota declared aws-chunked PUT is rejected by quota admission —
// not with UnexpectedContent.
let err = put_aws_chunked("over-quota.bin", 16 * 1024)
.await
.expect_err("declared aws-chunked PUT over quota must be rejected");
let err_debug = format!("{err:?}");
assert!(
!err_debug.contains("UnexpectedContent"),
"over-quota rejection must be the quota error, not UnexpectedContent: {err_debug}"
);
assert!(!env.object_exists("over-quota.bin").await?);
env.clear_bucket_quota().await?;
env.cleanup_bucket().await?;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_quota_update_and_clear() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
@@ -400,6 +400,20 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("lock-only test server"))
}
async fn prepare_part_transaction(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::PreparePartTransactionRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::PreparePartTransactionResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn settle_part_transaction(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::SettlePartTransactionRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::SettlePartTransactionResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn rename_file(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::RenameFileRequest>,
@@ -798,6 +812,13 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("lock-only test server"))
}
async fn scanner_activity(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::ScannerActivityRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::ScannerActivityResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn background_heal_status(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::BackgroundHealStatusRequest>,
+138 -3
View File
@@ -38,7 +38,7 @@ use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
BucketLifecycleConfiguration, BucketVersioningStatus, ExpirationStatus, LifecycleExpiration, LifecycleRule,
LifecycleRuleFilter, VersioningConfiguration,
LifecycleRuleFilter, NoncurrentVersionExpiration, VersioningConfiguration,
};
use std::time::Duration as StdDuration;
use time::OffsetDateTime;
@@ -98,7 +98,10 @@ async fn put_object_with_backdated_mtime(
/// still succeeds. Any other error is surfaced.
async fn object_is_gone(client: &Client, bucket: &str, key: &str) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
match client.get_object().bucket(bucket).key(key).send().await {
Ok(_) => Ok(false),
Ok(output) => {
output.body.collect().await?;
Ok(false)
}
Err(e) => {
if let Some(service_error) = e.as_service_error() {
if service_error.is_no_such_key() {
@@ -132,6 +135,39 @@ async fn wait_for_object_expired(client: &Client, bucket: &str, key: &str, deadl
}
}
async fn version_is_absent_from_listing(
client: &Client,
bucket: &str,
key: &str,
version_id: &str,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let versions = client.list_object_versions().bucket(bucket).prefix(key).send().await?;
Ok(!versions.versions().iter().any(|v| v.version_id() == Some(version_id)))
}
async fn wait_for_version_expired(
client: &Client,
bucket: &str,
key: &str,
version_id: &str,
deadline: StdDuration,
) -> TestResult {
let start = std::time::Instant::now();
loop {
if version_is_absent_from_listing(client, bucket, key, version_id).await? {
return Ok(());
}
if start.elapsed() >= deadline {
return Err(format!(
"object version {bucket}/{key}?versionId={version_id} was not expired by the lifecycle scanner within {}s",
deadline.as_secs()
)
.into());
}
tokio::time::sleep(StdDuration::from_millis(500)).await;
}
}
/// Build a prefix-scoped `Days`-based expiration rule.
fn expiration_rule(id: &str, prefix: &str, days: i32) -> Result<LifecycleRule, Box<dyn std::error::Error + Send + Sync>> {
let rule = LifecycleRule::builder()
@@ -143,6 +179,20 @@ fn expiration_rule(id: &str, prefix: &str, days: i32) -> Result<LifecycleRule, B
Ok(rule)
}
fn noncurrent_expiration_rule(
id: &str,
prefix: &str,
days: i32,
) -> Result<LifecycleRule, Box<dyn std::error::Error + Send + Sync>> {
let rule = LifecycleRule::builder()
.id(id)
.filter(LifecycleRuleFilter::builder().prefix(prefix).build())
.noncurrent_version_expiration(NoncurrentVersionExpiration::builder().noncurrent_days(days).build())
.status(ExpirationStatus::Enabled)
.build()?;
Ok(rule)
}
async fn put_expiration_config(client: &Client, bucket: &str, rule: LifecycleRule) -> TestResult {
let lifecycle = BucketLifecycleConfiguration::builder().rules(rule).build()?;
client
@@ -277,9 +327,94 @@ async fn test_lifecycle_versioned_current_version_expiry_creates_delete_marker()
Ok(())
}
/// `NoncurrentVersionExpiration NoncurrentDays=1` on a versioned bucket,
/// accelerated with `RUSTFS_ILM_DEBUG_DAY_SECS`. Proves the scanner purges the
/// noncurrent data version from `ListObjectVersions` while preserving the
/// latest version as the normal readable object and without creating a delete
/// marker.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lifecycle_noncurrent_version_expiry_removes_only_old_version() -> TestResult {
let mut env = RustFSTestEnvironment::new().await?;
let mut extra_env = fast_lifecycle_env();
extra_env.push(("RUSTFS_ILM_DEBUG_DAY_SECS", "2"));
env.start_rustfs_server_with_env(vec![], &extra_env).await?;
let client = env.create_s3_client();
let bucket = "ilm3-noncurrent";
client.create_bucket().bucket(bucket).send().await?;
client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
let key = "versioned/noncurrent.txt";
let first_put = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"old payload"))
.send()
.await?;
let old_version_id = first_put
.version_id()
.map(str::to_string)
.expect("first versioned PUT returns a version id");
let second_put = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"latest payload"))
.send()
.await?;
let latest_version_id = second_put
.version_id()
.map(str::to_string)
.expect("second versioned PUT returns a version id");
assert!(
!version_is_absent_from_listing(&client, bucket, key, &old_version_id).await?,
"old noncurrent version must be readable before lifecycle is installed"
);
put_expiration_config(&client, bucket, noncurrent_expiration_rule("expire-noncurrent", "versioned/", 1)?).await?;
wait_for_version_expired(&client, bucket, key, &old_version_id, StdDuration::from_secs(90)).await?;
let latest = client.get_object().bucket(bucket).key(key).send().await?;
assert_eq!(latest.version_id(), Some(latest_version_id.as_str()));
assert_eq!(latest.body.collect().await?.into_bytes().as_ref(), b"latest payload");
let versions = client.list_object_versions().bucket(bucket).prefix(key).send().await?;
let data_versions = versions.versions();
assert!(
data_versions
.iter()
.any(|v| v.version_id() == Some(latest_version_id.as_str())),
"latest data version {latest_version_id} must remain, got: {data_versions:?}"
);
assert!(
!data_versions.iter().any(|v| v.version_id() == Some(old_version_id.as_str())),
"old noncurrent version {old_version_id} must be removed, got: {data_versions:?}"
);
assert!(
versions.delete_markers().is_empty(),
"noncurrent version expiry must not create delete markers, got: {:?}",
versions.delete_markers()
);
Ok(())
}
/// `Days=0` expiration is invalid per S3 semantics (`Days` must be a positive
/// integer >= 1). A `PutBucketLifecycleConfiguration` carrying a zero-day rule
/// must be rejected with `InvalidArgument` (HTTP 400) see crates/lifecycle
/// must be rejected with `InvalidArgument` (HTTP 400) - see crates/lifecycle
/// `validate()` and the PutBucketLifecycleConfiguration handler. This is the
/// self-managed counterpart of the localhost-only
/// `test_bucket_lifecycle_rejects_zero_days` unit test.
File diff suppressed because it is too large Load Diff
+287 -46
View File
@@ -1331,6 +1331,42 @@ async fn assert_managed_sse_replication_fails_explicitly(label: &str, kms: bool)
Ok(())
}
async fn wait_for_source_delete_marker_replication_failed(
env: &RustFSTestEnvironment,
bucket: &str,
key: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
let url = format!(
"{}/rustfs/admin/v3/replication/diff?bucket={}&prefix={}",
env.url,
urlencoding::encode(bucket),
urlencoding::encode(key)
);
loop {
let response = signed_request(http::Method::POST, &url, &env.access_key, &env.secret_key, None, None).await?;
if response.status() != StatusCode::OK {
return Err(format!("replication diff failed with status {}", response.status()).into());
}
let diff: serde_json::Value = response.json().await?;
let failed = diff["Entries"].as_array().is_some_and(|entries| {
entries.iter().any(|entry| {
entry["Object"].as_str() == Some(key)
&& entry["IsDeleteMarker"].as_bool() == Some(true)
&& entry["ReplicationStatus"].as_str() == Some("FAILED")
})
});
if failed {
return Ok(());
}
if tokio::time::Instant::now() >= deadline {
return Err(format!("source delete marker {key} never reported FAILED; last diff={diff}").into());
}
sleep(Duration::from_millis(200)).await;
}
}
/// Return the `LastModified` of the (single) delete marker for `key`, if present.
async fn delete_marker_last_modified(
client: &Client,
@@ -1935,7 +1971,10 @@ async fn wait_for_site_replication_enabled(
) -> Result<SiteReplicationInfo, Box<dyn Error + Send + Sync>> {
for _ in 0..40 {
let info = site_replication_info(env).await?;
if info.enabled && info.sites.len() == expected_sites {
if info.enabled
&& info.sites.len() == expected_sites
&& info.sites.iter().all(|peer| peer.sync_state == SyncStatus::Enable)
{
return Ok(info);
}
sleep(Duration::from_millis(250)).await;
@@ -2074,11 +2113,31 @@ async fn build_replication_pair(
async fn test_replication_check_succeeds_with_remote_target() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let (_source_env, _target_env, source_bucket) = build_replication_pair(true).await?;
let response = run_replication_check(&_source_env, &source_bucket).await?;
let (source_env, target_env, source_bucket) = build_replication_pair(true).await?;
let response = run_replication_check(&source_env, &source_bucket).await?;
assert_eq!(response.status(), StatusCode::OK);
assert!(response.text().await?.is_empty());
let payload: serde_json::Value = response.json().await?;
assert_eq!(payload["Status"], "OK");
assert_eq!(payload["ActiveMutation"], true);
assert_eq!(payload["Targets"].as_array().map(Vec::len), Some(1));
assert_eq!(payload["Targets"][0]["Status"], "OK");
assert_eq!(payload["Targets"][0]["Phases"]["Put"]["Status"], "OK");
assert_eq!(payload["Targets"][0]["Phases"]["DeleteMarker"]["Status"], "OK");
assert_eq!(payload["Targets"][0]["Phases"]["VersionDelete"]["Status"], "OK");
assert_eq!(payload["Targets"][0]["Phases"]["Cleanup"]["Status"], "OK");
let target_client = target_env.create_s3_client();
let versions = target_client
.list_object_versions()
.bucket("replication-check-dst")
.prefix(".rustfs.sys/replication-check/")
.send()
.await?;
assert!(
versions.versions().is_empty() && versions.delete_markers().is_empty(),
"successful check must remove every probe version and delete marker"
);
Ok(())
}
@@ -2120,9 +2179,19 @@ async fn test_replication_check_rejects_target_without_object_lock() -> Result<(
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("object lock"), "unexpected response: {body}");
assert_eq!(status, StatusCode::OK);
let payload: serde_json::Value = serde_json::from_str(&body)?;
assert_eq!(payload["Status"], "FAILED");
assert_eq!(payload["Targets"][0]["Status"], "FAILED");
assert_eq!(payload["Targets"][0]["Phases"]["ObjectLock"]["Status"], "FAILED");
assert!(
payload["Targets"][0]["Phases"]["ObjectLock"]["Error"]
.as_str()
.unwrap_or_default()
.contains("object lock"),
"unexpected response: {body}"
);
assert_eq!(payload["Targets"][0]["Phases"]["Put"]["Status"], "SKIPPED");
Ok(())
}
@@ -3691,23 +3760,9 @@ async fn test_bucket_replication_replays_failed_entries_after_source_restart() -
Ok(())
}
/// backlog#1147 repl-5, scenario (c) — replayed delete marker keeps the source
/// mtime (mirrors backlog#867).
///
/// A delete marker is created while the target is down; the source is then
/// restarted (data preserved) and the target brought back, so the marker
/// replicates through the failure-replay path. The replayed marker must carry
/// the SOURCE's `LastModified`, not the replay time. A deliberate gap before
/// recovery makes any regression (replay-time stamping) obvious.
///
/// The source restart is load-bearing, not just paranoia: on a live
/// (never-restarted) source, the failed delete-marker replication wedges the
/// per-object `/[replicate]/<key>` namespace lock and the marker never
/// replicates even after the target recovers — tracked as backlog#1278. Once
/// that is fixed, a restart-free variant of this scenario should be added.
#[tokio::test]
#[serial]
async fn test_bucket_replication_replayed_delete_marker_preserves_source_mtime() -> TestResult {
async fn test_bucket_replication_replayed_delete_marker_preserves_source_mtime_without_source_restart() -> TestResult {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
@@ -3759,13 +3814,11 @@ async fn test_bucket_replication_replayed_delete_marker_preserves_source_mtime()
.await?
.ok_or("source has no delete marker after DELETE")?;
wait_for_source_delete_marker_replication_failed(&source_env, source_bucket, object_key).await?;
// Widen the gap so a replay-time-stamping regression is unmistakable.
sleep(Duration::from_secs(3)).await;
// Restart the source (see the doc comment: live-source replay is wedged by
// backlog#1278), then bring the target back; the restarted source's scanner
// heal pass replays the failed delete marker.
source_env.restart_server_preserving_data(vec![], &source_env_vars).await?;
target_env.restart_server_preserving_data(vec![], &[]).await?;
let target_mtime = wait_for_target_delete_marker(&target_client, target_bucket, object_key).await?;
@@ -4025,17 +4078,23 @@ async fn test_site_replication_allows_private_ca_https_with_ca_cert_pem_real_dua
#[tokio::test]
#[serial]
async fn test_site_replication_resync_start_cancel_restart_real_dual_node() -> Result<(), Box<dyn Error + Send + Sync>> {
async fn test_site_replication_resync_lifecycle_survives_real_server_restart() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let resync_process_env = [
("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", "true"),
// Verbose server logging can block startup when this focused test is run
// through a captured test process rather than nextest.
("RUST_LOG", "error"),
];
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
source_env.capture_log_path = Some(format!("{}/server.log", source_env.temp_dir));
source_env.start_rustfs_server_with_env(vec![], &resync_process_env).await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.capture_log_path = Some(format!("{}/server.log", target_env.temp_dir));
target_env
.start_rustfs_server_without_cleanup_with_env(LOOPBACK_REPLICATION_TARGET_ENV)
.start_rustfs_server_without_cleanup_with_env(&resync_process_env)
.await?;
let source_bucket = "site-repl-resync-src";
@@ -4083,12 +4142,12 @@ async fn test_site_replication_resync_start_cancel_restart_real_dual_node() -> R
wait_for_bucket_on_target(&source_client, source_bucket).await?;
let target_arn = wait_for_remote_target_arn(&source_env, source_bucket).await?;
for idx in 0..32 {
for idx in 0..96 {
source_client
.put_object()
.bucket(source_bucket)
.key(format!("resync-object-{idx:02}"))
.body(ByteStream::from(vec![b'x'; 256 * 1024]))
.body(ByteStream::from(vec![b'x'; 512 * 1024]))
.send()
.await?;
}
@@ -4096,18 +4155,31 @@ async fn test_site_replication_resync_start_cancel_restart_real_dual_node() -> R
let started = site_replication_resync_op(&source_env, "start", &remote_peer).await?;
assert_eq!(started.status, "success", "unexpected start result: {:?}", started);
assert!(
started
.buckets
.iter()
.any(|bucket| bucket.bucket == source_bucket && matches!(bucket.status.as_str(), "started" | "success")),
started.buckets.iter().any(|bucket| {
bucket.bucket == source_bucket && matches!(bucket.status.as_str(), "started" | "running" | "completed" | "success")
}),
"source bucket start status missing: {:?}",
started
);
assert!(!started.resync_id.is_empty(), "start response omitted the resync id: {:?}", started);
let started_reset_id = started.resync_id.clone();
assert!(
matches!(started.state.as_str(), "pending" | "running"),
"the fixture must keep the first generation active long enough to test duplicate start: {:?}",
started
);
let duplicate_err = site_replication_resync_op(&source_env, "start", &remote_peer)
.await
.expect_err("duplicate start must be rejected while a generation is active");
assert!(
duplicate_err.to_string().contains("already active"),
"unexpected duplicate start error: {duplicate_err}"
);
let canceled = site_replication_resync_op(&source_env, "cancel", &remote_peer).await?;
assert_eq!(canceled.status, "success", "unexpected cancel result: {:?}", canceled);
assert_eq!(canceled.state, "canceled");
assert!(
canceled
.buckets
@@ -4116,34 +4188,56 @@ async fn test_site_replication_resync_start_cancel_restart_real_dual_node() -> R
"source bucket cancel status missing: {:?}",
canceled
);
let canceled_again = site_replication_resync_op(&source_env, "cancel", &remote_peer).await?;
assert_eq!(canceled_again.resync_id, canceled.resync_id, "repeated cancel must be idempotent");
assert_eq!(canceled_again.state, "canceled");
let canceled_target =
wait_for_replication_reset_target(&source_env, source_bucket, &target_arn, |target| target.status == "Canceled").await?;
assert_eq!(canceled_target.status, "Canceled");
assert_eq!(canceled_target.reset_id, started_reset_id);
let restarted = site_replication_resync_op(&source_env, "start", &remote_peer).await?;
assert_eq!(restarted.status, "success", "unexpected restart result: {:?}", restarted);
assert_ne!(restarted.resync_id, started_reset_id);
assert!(
matches!(restarted.state.as_str(), "pending" | "running"),
"the second generation must be active before the process restart: {:?}",
restarted
.buckets
.iter()
.any(|bucket| bucket.bucket == source_bucket && matches!(bucket.status.as_str(), "started" | "success")),
"source bucket restart status missing: {:?}",
restarted
);
let restarted_reset_id = restarted.resync_id.clone();
source_env.restart_server_preserving_data(vec![], &resync_process_env).await?;
wait_for_site_replication_enabled(&source_env, 2).await?;
let after_restart = site_replication_resync_op(&source_env, "status", &remote_peer).await?;
assert_eq!(
after_restart.resync_id, restarted_reset_id,
"server restart changed the durable resync id"
);
assert_eq!(after_restart.generation, restarted.generation);
assert_eq!(after_restart.created_at, restarted.created_at);
assert!(
matches!(after_restart.state.as_str(), "pending" | "running" | "completed" | "failed"),
"unexpected recovered lifecycle state: {:?}",
after_restart
);
assert!(
after_restart.buckets.iter().any(|bucket| bucket.bucket == source_bucket),
"durable status lost the source bucket after restart: {:?}",
after_restart
);
let restart_snapshot = get_replication_reset_status(&source_env, source_bucket, &target_arn).await?;
let restarted_target = wait_for_replication_reset_target(&source_env, source_bucket, &target_arn, |target| {
!target.reset_id.is_empty() && target.reset_id != started_reset_id
target.reset_id == restarted_reset_id
})
.await
.map_err(|err| {
format!(
"restart ids: start={} restart={} snapshot={:?}; {err}",
started_reset_id, restarted.resync_id, restart_snapshot.targets
started_reset_id, restarted_reset_id, restart_snapshot.targets
)
})?;
assert_ne!(restarted_target.reset_id, started_reset_id);
assert_eq!(restarted_target.reset_id, restarted_reset_id);
Ok(())
}
@@ -4709,6 +4803,153 @@ async fn test_site_replication_replicates_object_with_bucket_versioning_real_dua
Ok(())
}
/// Re-applying a site's own replication config must not disable the peer's reverse direction.
///
/// `PutBucketReplication` broadcasts the config to every peer — the console's replication
/// Save button, `mc replicate import`, and a bucket-metadata import all go through it. The
/// receiver used to overwrite its rules with the sender's, whose destination ARN names the
/// receiver itself. No bucket target can satisfy that ARN, so every object written on the
/// receiver was dropped with only a debug line, while `replicate status` still reported
/// "1/1 Buckets in sync" because both configs were byte-identical.
#[tokio::test]
#[serial]
async fn test_site_replication_config_broadcast_keeps_reverse_direction_real_dual_node() -> TestResult {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env
.start_rustfs_server_without_cleanup_with_env(LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
let bucket = "site-repl-config-broadcast";
let add_status = site_replication_add(
&source_env,
&[
PeerSite {
name: "broadcast-source".to_string(),
endpoint: source_env.url.clone(),
access_key: source_env.access_key.clone(),
secret_key: source_env.secret_key.clone(),
..Default::default()
},
PeerSite {
name: "broadcast-target".to_string(),
endpoint: target_env.url.clone(),
access_key: target_env.access_key.clone(),
secret_key: target_env.secret_key.clone(),
..Default::default()
},
],
)
.await?;
assert!(add_status.success, "unexpected site add result: {add_status:?}");
wait_for_site_replication_enabled(&source_env, 2).await?;
wait_for_site_replication_enabled(&target_env, 2).await?;
source_client.create_bucket().bucket(bucket).send().await?;
wait_for_bucket_on_target(&target_client, bucket).await?;
// Both directions work before the broadcast.
source_client
.put_object()
.bucket(bucket)
.key("from-source.txt")
.body(ByteStream::from_static(b"written on the initiating site"))
.send()
.await?;
assert_eq!(
wait_for_object_on_target(&target_client, bucket, "from-source.txt").await?,
b"written on the initiating site".to_vec(),
);
target_client
.put_object()
.bucket(bucket)
.key("from-target.txt")
.body(ByteStream::from_static(b"written on the joined site"))
.send()
.await?;
assert_eq!(
wait_for_object_on_target(&source_client, bucket, "from-target.txt").await?,
b"written on the joined site".to_vec(),
);
// Round-trip the source's own config through PutBucketReplication, exactly what the
// console does when an operator opens the bucket's replication page and saves it.
let source_config = source_client
.get_bucket_replication()
.bucket(bucket)
.send()
.await?
.replication_configuration
.ok_or("source bucket has no replication configuration")?;
source_client
.put_bucket_replication()
.bucket(bucket)
.replication_configuration(source_config)
.send()
.await?;
let target_config = wait_for_site_replication_rule(&target_client, bucket).await?;
let target_deployment_id = site_replication_info(&target_env)
.await?
.sites
.iter()
.find(|peer| peer.endpoint == target_env.url)
.map(|peer| peer.deployment_id.clone())
.ok_or("joined site missing from its own replication info")?;
for rule in &target_config.rules {
let destination = rule
.destination
.as_ref()
.map(|destination| destination.bucket.as_str())
.unwrap_or_default();
assert!(
!destination.contains(&target_deployment_id),
"joined site adopted a rule pointing at itself: {destination}"
);
}
target_client
.put_object()
.bucket(bucket)
.key("from-target-after-broadcast.txt")
.body(ByteStream::from_static(b"written after the config broadcast"))
.send()
.await?;
assert_eq!(
wait_for_object_on_target(&source_client, bucket, "from-target-after-broadcast.txt").await?,
b"written after the config broadcast".to_vec(),
"config broadcast made replication one-directional"
);
Ok(())
}
async fn wait_for_site_replication_rule(
client: &aws_sdk_s3::Client,
bucket: &str,
) -> Result<aws_sdk_s3::types::ReplicationConfiguration, Box<dyn Error + Send + Sync>> {
for _ in 0..40 {
if let Ok(response) = client.get_bucket_replication().bucket(bucket).send().await
&& let Some(config) = response.replication_configuration
&& !config.rules.is_empty()
{
return Ok(config);
}
sleep(Duration::from_millis(250)).await;
}
Err(format!("bucket {bucket} never reported a replication rule").into())
}
#[tokio::test]
#[serial]
async fn test_site_replication_active_active_converges_without_loops_real_dual_node() -> TestResult {
+471
View File
@@ -0,0 +1,471 @@
// 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.
//! Black-box SSE-C CopyObject and multipart-copy regression coverage (backlog#1467).
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::config::interceptors::{BeforeDeserializationInterceptorContextRef, BeforeTransmitInterceptorContextRef};
use aws_sdk_s3::config::{ConfigBag, Credentials, Intercept, Region, RuntimeComponents};
use aws_sdk_s3::error::BoxError;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, VersioningConfiguration};
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
use base64::Engine;
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
const SSE_CUSTOMER_ALGORITHM_HEADER: &str = "x-amz-server-side-encryption-customer-algorithm";
const SSE_CUSTOMER_KEY_MD5_HEADER: &str = "x-amz-server-side-encryption-customer-key-md5";
struct CustomerKey {
raw: String,
encoded: String,
md5: String,
}
struct InvalidSsec<'a> {
algorithm: Option<&'a str>,
key: Option<&'a str>,
md5: Option<&'a str>,
}
#[derive(Clone, Debug, Default)]
struct ResponseHeaderCapture {
headers: Arc<Mutex<HashMap<String, String>>>,
abort_attempts: Arc<AtomicUsize>,
}
impl ResponseHeaderCapture {
fn snapshot(&self) -> Result<HashMap<String, String>, BoxError> {
self.headers
.lock()
.map(|headers| headers.clone())
.map_err(|_| std::io::Error::other("response header capture mutex was poisoned").into())
}
fn abort_attempts(&self) -> usize {
self.abort_attempts.load(Ordering::SeqCst)
}
}
impl Intercept for ResponseHeaderCapture {
fn name(&self) -> &'static str {
"ssec-copy-response-header-capture"
}
fn read_before_deserialization(
&self,
context: &BeforeDeserializationInterceptorContextRef<'_>,
_runtime_components: &RuntimeComponents,
_cfg: &mut ConfigBag,
) -> Result<(), BoxError> {
let mut captured = self
.headers
.lock()
.map_err(|_| std::io::Error::other("response header capture mutex was poisoned"))?;
captured.clear();
for name in [SSE_CUSTOMER_ALGORITHM_HEADER, SSE_CUSTOMER_KEY_MD5_HEADER] {
if let Some(value) = context.response().headers().get(name) {
captured.insert(name.to_owned(), value.to_owned());
}
}
Ok(())
}
fn read_before_transmit(
&self,
context: &BeforeTransmitInterceptorContextRef<'_>,
_runtime_components: &RuntimeComponents,
_cfg: &mut ConfigBag,
) -> Result<(), BoxError> {
let request = context.request();
if request.method() == "DELETE" && request.uri().contains("uploadId=") {
self.abort_attempts.fetch_add(1, Ordering::SeqCst);
}
Ok(())
}
}
fn customer_key(byte: u8) -> CustomerKey {
let raw = [byte; 32];
CustomerKey {
raw: String::from_utf8_lossy(&raw).into_owned(),
encoded: base64::engine::general_purpose::STANDARD.encode(raw),
md5: base64::engine::general_purpose::STANDARD.encode(md5::compute(raw).0),
}
}
fn assert_secret_absent(error: &str, keys: &[&CustomerKey]) {
for key in keys {
assert!(!error.contains(&key.raw), "error exposed a raw SSE-C key");
assert!(!error.contains(&key.encoded), "error exposed an encoded SSE-C key");
assert!(!error.contains(&key.md5), "error exposed an SSE-C key MD5");
}
}
fn invalid_ssec_cases<'a>(correct_key: &'a CustomerKey, wrong_key: &'a CustomerKey) -> [InvalidSsec<'a>; 5] {
[
InvalidSsec {
algorithm: None,
key: Some(&correct_key.encoded),
md5: Some(&correct_key.md5),
},
InvalidSsec {
algorithm: Some("AES256"),
key: None,
md5: Some(&correct_key.md5),
},
InvalidSsec {
algorithm: Some("AES256"),
key: Some(&correct_key.encoded),
md5: None,
},
InvalidSsec {
algorithm: Some("AES256"),
key: Some(&wrong_key.encoded),
md5: Some(&wrong_key.md5),
},
InvalidSsec {
algorithm: Some("AES256"),
key: Some(&correct_key.encoded),
md5: Some(&wrong_key.md5),
},
]
}
#[tokio::test]
async fn copy_object_rotates_ssec_key_and_drops_source_encryption_metadata() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(Vec::new()).await?;
let client = env.create_s3_client();
let bucket = "ssec-copy-object";
let source = "source.bin";
let plaintext_copy = "plaintext-copy.bin";
let rotated_copy = "rotated-copy.bin";
let source_key = customer_key(0x41);
let destination_key = customer_key(0x42);
let wrong_key = customer_key(0x43);
let body = b"backlog-1467 versioned SSE-C copy payload";
env.create_test_bucket(bucket).await?;
client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
let put = client
.put_object()
.bucket(bucket)
.key(source)
.sse_customer_algorithm("AES256")
.sse_customer_key(&source_key.encoded)
.sse_customer_key_md5(&source_key.md5)
.body(ByteStream::from_static(body))
.send()
.await?;
let source_version = put.version_id().ok_or("versioned PUT returned no version ID")?;
let copy_source = format!("{bucket}/{source}?versionId={source_version}");
let plaintext = client
.copy_object()
.bucket(bucket)
.key(plaintext_copy)
.copy_source(&copy_source)
.copy_source_sse_customer_algorithm("AES256")
.copy_source_sse_customer_key(&source_key.encoded)
.copy_source_sse_customer_key_md5(&source_key.md5)
.send()
.await?;
assert_eq!(plaintext.copy_source_version_id(), Some(source_version));
let plaintext_body = client
.get_object()
.bucket(bucket)
.key(plaintext_copy)
.send()
.await?
.body
.collect()
.await?
.into_bytes();
assert_eq!(plaintext_body.as_ref(), body);
let rotated = client
.copy_object()
.bucket(bucket)
.key(rotated_copy)
.copy_source(&copy_source)
.copy_source_sse_customer_algorithm("AES256")
.copy_source_sse_customer_key(&source_key.encoded)
.copy_source_sse_customer_key_md5(&source_key.md5)
.sse_customer_algorithm("AES256")
.sse_customer_key(&destination_key.encoded)
.sse_customer_key_md5(&destination_key.md5)
.send()
.await?;
assert_eq!(rotated.sse_customer_algorithm(), Some("AES256"));
assert_eq!(rotated.sse_customer_key_md5(), Some(destination_key.md5.as_str()));
let wrong_key_error = client
.get_object()
.bucket(bucket)
.key(rotated_copy)
.sse_customer_algorithm("AES256")
.sse_customer_key(&source_key.encoded)
.sse_customer_key_md5(&source_key.md5)
.send()
.await
.expect_err("the source key must not read a copy encrypted with the destination key");
assert_secret_absent(&format!("{wrong_key_error:?}"), &[&source_key, &destination_key]);
let rotated_body = client
.get_object()
.bucket(bucket)
.key(rotated_copy)
.sse_customer_algorithm("AES256")
.sse_customer_key(&destination_key.encoded)
.sse_customer_key_md5(&destination_key.md5)
.send()
.await?
.body
.collect()
.await?
.into_bytes();
assert_eq!(rotated_body.as_ref(), body);
for (case_index, case) in invalid_ssec_cases(&source_key, &wrong_key).iter().enumerate() {
let failed_target = format!("failed-copy-{case_index}.bin");
let mut request = client
.copy_object()
.bucket(bucket)
.key(&failed_target)
.copy_source(&copy_source);
if let Some(algorithm) = case.algorithm {
request = request.copy_source_sse_customer_algorithm(algorithm);
}
if let Some(key) = case.key {
request = request.copy_source_sse_customer_key(key);
}
if let Some(md5) = case.md5 {
request = request.copy_source_sse_customer_key_md5(md5);
}
let error = request
.send()
.await
.expect_err("invalid source SSE-C parameters must reject CopyObject");
assert_secret_absent(&format!("{error:?}"), &[&source_key, &wrong_key]);
assert!(
client.head_object().bucket(bucket).key(&failed_target).send().await.is_err(),
"a rejected CopyObject must not create its target"
);
}
env.stop_server();
Ok(())
}
#[tokio::test]
async fn multipart_copy_requires_keys_on_every_stage_and_abort_leaves_no_object() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(Vec::new()).await?;
let response_headers = ResponseHeaderCapture::default();
let credentials = Credentials::new(&env.access_key, &env.secret_key, None, None, "ssec-copy-e2e");
let config = aws_sdk_s3::Config::builder()
.credentials_provider(credentials)
.region(Region::new("us-east-1"))
.endpoint_url(&env.url)
.force_path_style(true)
.behavior_version_latest()
.http_client(SmithyHttpClientBuilder::new().build_http())
.interceptor(response_headers.clone())
.build();
let client = aws_sdk_s3::Client::from_conf(config);
let bucket = "ssec-multipart-copy";
let source = "source.bin";
let destination = "destination.bin";
let aborted_destination = "aborted.bin";
let source_key = customer_key(0x51);
let destination_key = customer_key(0x52);
let wrong_key = customer_key(0x53);
let part_size = 5 * 1024 * 1024;
let body: Vec<u8> = (0..part_size * 2).map(|index| (index % 251) as u8).collect();
env.create_test_bucket(bucket).await?;
client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
let source_put = client
.put_object()
.bucket(bucket)
.key(source)
.sse_customer_algorithm("AES256")
.sse_customer_key(&source_key.encoded)
.sse_customer_key_md5(&source_key.md5)
.body(ByteStream::from(body.clone()))
.send()
.await?;
let source_version = source_put
.version_id()
.ok_or("versioned multipart-copy source returned no version ID")?;
let create = client
.create_multipart_upload()
.bucket(bucket)
.key(destination)
.sse_customer_algorithm("AES256")
.sse_customer_key(&destination_key.encoded)
.sse_customer_key_md5(&destination_key.md5)
.send()
.await?;
assert_eq!(create.sse_customer_algorithm(), Some("AES256"));
assert_eq!(create.sse_customer_key_md5(), Some(destination_key.md5.as_str()));
let upload_id = create.upload_id().ok_or("CreateMultipartUpload returned no upload ID")?;
let mut completed = Vec::new();
for part_number in 1..=2 {
let first = (part_number - 1) * part_size;
let last = part_number * part_size - 1;
let copied = client
.upload_part_copy()
.bucket(bucket)
.key(destination)
.upload_id(upload_id)
.part_number(part_number)
.copy_source(format!("{bucket}/{source}"))
.copy_source_range(format!("bytes={first}-{last}"))
.copy_source_sse_customer_algorithm("AES256")
.copy_source_sse_customer_key(&source_key.encoded)
.copy_source_sse_customer_key_md5(&source_key.md5)
.sse_customer_algorithm("AES256")
.sse_customer_key(&destination_key.encoded)
.sse_customer_key_md5(&destination_key.md5)
.send()
.await?;
assert_eq!(
copied.copy_source_version_id(),
Some(source_version),
"UploadPartCopy must return the actual latest source version"
);
let etag = copied
.copy_part_result()
.and_then(|result| result.e_tag())
.ok_or("UploadPartCopy returned no ETag")?;
completed.push(CompletedPart::builder().part_number(part_number).e_tag(etag).build());
}
client
.complete_multipart_upload()
.bucket(bucket)
.key(destination)
.upload_id(upload_id)
.sse_customer_algorithm("AES256")
.sse_customer_key(&destination_key.encoded)
.sse_customer_key_md5(&destination_key.md5)
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed)).build())
.send()
.await?;
let completed_headers = response_headers.snapshot()?;
assert_eq!(completed_headers.get(SSE_CUSTOMER_ALGORITHM_HEADER).map(String::as_str), Some("AES256"));
assert_eq!(
completed_headers.get(SSE_CUSTOMER_KEY_MD5_HEADER).map(String::as_str),
Some(destination_key.md5.as_str())
);
let downloaded = client
.get_object()
.bucket(bucket)
.key(destination)
.sse_customer_algorithm("AES256")
.sse_customer_key(&destination_key.encoded)
.sse_customer_key_md5(&destination_key.md5)
.send()
.await?
.body
.collect()
.await?
.into_bytes();
assert_eq!(downloaded.as_ref(), body.as_slice());
for (case_index, case) in invalid_ssec_cases(&destination_key, &wrong_key).iter().enumerate() {
let failed_target = format!("{aborted_destination}-{case_index}");
let failed_create = client
.create_multipart_upload()
.bucket(bucket)
.key(&failed_target)
.sse_customer_algorithm("AES256")
.sse_customer_key(&destination_key.encoded)
.sse_customer_key_md5(&destination_key.md5)
.send()
.await?;
let failed_upload_id = failed_create
.upload_id()
.ok_or("CreateMultipartUpload returned no upload ID")?;
let mut request = client
.upload_part_copy()
.bucket(bucket)
.key(&failed_target)
.upload_id(failed_upload_id)
.part_number(1)
.copy_source(format!("{bucket}/{source}"))
.copy_source_sse_customer_algorithm("AES256")
.copy_source_sse_customer_key(&source_key.encoded)
.copy_source_sse_customer_key_md5(&source_key.md5);
if let Some(algorithm) = case.algorithm {
request = request.sse_customer_algorithm(algorithm);
}
if let Some(key) = case.key {
request = request.sse_customer_key(key);
}
if let Some(md5) = case.md5 {
request = request.sse_customer_key_md5(md5);
}
let error = request
.send()
.await
.expect_err("invalid destination SSE-C parameters must reject UploadPartCopy");
assert_secret_absent(&format!("{error:?}"), &[&source_key, &destination_key, &wrong_key]);
let abort_attempts_before = response_headers.abort_attempts();
client
.abort_multipart_upload()
.bucket(bucket)
.key(&failed_target)
.upload_id(failed_upload_id)
.send()
.await?;
assert_eq!(
response_headers.abort_attempts(),
abort_attempts_before + 1,
"each failed multipart copy must issue exactly one wire-level abort attempt"
);
assert!(
client.head_object().bucket(bucket).key(&failed_target).send().await.is_err(),
"an aborted failed multipart copy must leave no completed object"
);
}
env.stop_server();
Ok(())
}
+13
View File
@@ -16,6 +16,8 @@
pub(crate) use rustfs_ecstore::api::bucket::bucket_target_sys::BucketTargetSys;
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::disk::{VolumeInfo, WalkDirOptions};
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::rpc::{TONIC_RPC_PREFIX, gen_signature_headers, gen_tonic_signature_headers};
pub(crate) use rustfs_ecstore::api::rpc::{TonicInterceptor, node_service_time_out_client_no_auth};
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::rpc::{gen_tonic_signature_interceptor, node_service_time_out_client};
@@ -31,6 +33,17 @@ pub(crate) mod grpc_lock {
pub(crate) use super::{TonicInterceptor, node_service_time_out_client_no_auth};
}
/// Signing/transport surface used by the cross-process internode RPC signature
/// acceptance tests (backlog#1327). The signing helpers are what let a test mint
/// the one legitimately signed request an on-path attacker is assumed to have
/// captured; every attack in that suite then only *reuses* those bytes.
#[cfg(test)]
pub(crate) mod internode_rpc_signature {
pub(crate) use super::{
TONIC_RPC_PREFIX, gen_signature_headers, gen_tonic_signature_headers, node_service_time_out_client_no_auth,
};
}
#[cfg(test)]
pub(crate) mod replication_extension {
pub(crate) use super::BucketTargetSys;
@@ -0,0 +1,428 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Truthful storage-class write and discovery contract regressions.
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{ObjectAttributes, StorageClass};
use http::header::HOST;
use reqwest::StatusCode;
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4;
use s3s::Body;
use serde_json::Value;
use std::error::Error;
use std::path::Path;
const UNSUPPORTED_AWS_CLASSES: [&str; 9] = [
"DEEP_ARCHIVE",
"EXPRESS_ONEZONE",
"GLACIER",
"GLACIER_IR",
"INTELLIGENT_TIERING",
"ONEZONE_IA",
"OUTPOSTS",
"SNOW",
"STANDARD_IA",
];
async fn assert_object_storage_class(
client: &Client,
bucket: &str,
key: &str,
expected: &str,
body: &[u8],
) -> Result<(), Box<dyn Error + Send + Sync>> {
let head = client.head_object().bucket(bucket).key(key).send().await?;
let expected_head = (expected != "STANDARD").then_some(expected);
assert_eq!(
head.storage_class().map(StorageClass::as_str),
expected_head,
"HeadObject must omit implicit STANDARD and report RRS"
);
let listed = client.list_objects_v2().bucket(bucket).prefix(key).send().await?;
let object = listed
.contents()
.iter()
.find(|object| object.key() == Some(key))
.ok_or("object missing from ListObjectsV2")?;
assert_eq!(object.storage_class().map(|storage_class| storage_class.as_str()), Some(expected));
let get = client.get_object().bucket(bucket).key(key).send().await?;
assert_eq!(
get.storage_class().map(StorageClass::as_str),
expected_head,
"GetObject must report the same effective storage class as HeadObject"
);
let downloaded = get.body.collect().await?.into_bytes();
assert_eq!(downloaded.as_ref(), body, "storage-class selection must not alter object bytes");
Ok(())
}
async fn mutate_xl_meta(
root: &str,
bucket: &str,
key: &str,
mutate: impl FnOnce(&mut rustfs_filemeta::MetaObject),
) -> Result<(), Box<dyn Error + Send + Sync>> {
let path = Path::new(root).join(bucket).join(key).join("xl.meta");
let bytes = tokio::fs::read(&path).await?;
let mut file_meta = rustfs_filemeta::FileMeta::load(&bytes)?;
let (index, mut version) = file_meta.find_version(None)?;
let object = version.object.as_mut().ok_or("fixture version is not an object")?;
mutate(object);
file_meta.versions[index] = rustfs_filemeta::FileMetaShallowVersion::try_from(version)?;
tokio::fs::write(path, file_meta.marshal_msg()?).await?;
Ok(())
}
async fn signed_admin_get(
env: &RustFSTestEnvironment,
path: &str,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let url = format!("{}{path}", env.url);
let uri = url.parse::<http::Uri>()?;
let authority = uri.authority().ok_or("admin URL missing authority")?.to_string();
let request = http::Request::builder()
.method(http::Method::GET)
.uri(uri)
.header(HOST, authority)
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD)
.body(Body::empty())?;
let signed = sign_v4(request, 0, &env.access_key, &env.secret_key, "", "us-east-1");
let mut request = local_http_client().get(&url);
for (name, value) in signed.headers() {
request = request.header(name, value);
}
Ok(request.send().await?)
}
#[tokio::test]
async fn standard_and_rrs_are_supported_across_put_copy_and_multipart() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(Vec::new()).await?;
let client = env.create_s3_client();
let bucket = "storage-class-supported-contract";
env.create_test_bucket(bucket).await?;
client
.put_object()
.bucket(bucket)
.key("copy-source")
.body(ByteStream::from_static(b"copy-source-body"))
.send()
.await?;
for storage_class in [StorageClass::Standard, StorageClass::ReducedRedundancy] {
let class_name = storage_class.as_str().to_string();
let put_key = format!("put-{class_name}");
let put_body = format!("put-body-{class_name}").into_bytes();
client
.put_object()
.bucket(bucket)
.key(&put_key)
.storage_class(storage_class.clone())
.body(ByteStream::from(put_body.clone()))
.send()
.await?;
assert_object_storage_class(&client, bucket, &put_key, &class_name, &put_body).await?;
let copy_key = format!("copy-{class_name}");
client
.copy_object()
.bucket(bucket)
.key(&copy_key)
.copy_source(format!("{bucket}/copy-source"))
.storage_class(storage_class.clone())
.send()
.await?;
assert_object_storage_class(&client, bucket, &copy_key, &class_name, b"copy-source-body").await?;
let multipart_key = format!("multipart-{class_name}");
let created = client
.create_multipart_upload()
.bucket(bucket)
.key(&multipart_key)
.storage_class(storage_class)
.send()
.await?;
let upload_id = created.upload_id().ok_or("CreateMultipartUpload returned no upload ID")?;
let parts = client
.list_parts()
.bucket(bucket)
.key(&multipart_key)
.upload_id(upload_id)
.send()
.await?;
assert_eq!(parts.storage_class().map(StorageClass::as_str), Some(class_name.as_str()));
client
.abort_multipart_upload()
.bucket(bucket)
.key(&multipart_key)
.upload_id(upload_id)
.send()
.await?;
}
Ok(())
}
#[tokio::test]
async fn label_only_aws_classes_fail_before_put_copy_or_multipart_mutation() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(Vec::new()).await?;
let client = env.create_s3_client();
let bucket = "storage-class-unsupported-contract";
env.create_test_bucket(bucket).await?;
for key in ["put-guard", "copy-source", "copy-guard"] {
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(format!("original-{key}").into_bytes()))
.send()
.await?;
}
for unsupported in UNSUPPORTED_AWS_CLASSES {
let put_error = client
.put_object()
.bucket(bucket)
.key("put-guard")
.storage_class(StorageClass::from(unsupported))
.body(ByteStream::from(format!("rejected-put-{unsupported}").into_bytes()))
.send()
.await
.expect_err("label-only PUT storage class must be rejected");
assert_eq!(
put_error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("InvalidStorageClass"),
"PUT returned a different error for {unsupported}"
);
let copy_error = client
.copy_object()
.bucket(bucket)
.key("copy-guard")
.copy_source(format!("{bucket}/copy-source"))
.storage_class(StorageClass::from(unsupported))
.send()
.await
.expect_err("label-only CopyObject storage class must be rejected");
assert_eq!(
copy_error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("InvalidStorageClass"),
"CopyObject returned a different error for {unsupported}"
);
let multipart_key = format!("multipart-{unsupported}");
let multipart_error = client
.create_multipart_upload()
.bucket(bucket)
.key(&multipart_key)
.storage_class(StorageClass::from(unsupported))
.send()
.await
.expect_err("label-only CreateMultipartUpload storage class must be rejected");
assert_eq!(
multipart_error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("InvalidStorageClass"),
"CreateMultipartUpload returned a different error for {unsupported}"
);
}
let put_guard = client
.get_object()
.bucket(bucket)
.key("put-guard")
.send()
.await?
.body
.collect()
.await?
.into_bytes();
assert_eq!(put_guard.as_ref(), b"original-put-guard");
let copy_guard = client
.get_object()
.bucket(bucket)
.key("copy-guard")
.send()
.await?
.body
.collect()
.await?
.into_bytes();
assert_eq!(copy_guard.as_ref(), b"original-copy-guard");
let uploads = client.list_multipart_uploads().bucket(bucket).send().await?;
assert!(uploads.uploads().is_empty(), "unsupported classes must not create multipart sessions");
Ok(())
}
#[tokio::test]
async fn historical_label_only_metadata_is_standard_without_hiding_a_real_transition_tier()
-> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(Vec::new()).await?;
let client = env.create_s3_client();
let bucket = "storage-class-historical-contract";
let legacy_key = "legacy-label-only";
let transitioned_key = "real-transition-tier";
env.create_test_bucket(bucket).await?;
for key in [legacy_key, transitioned_key] {
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"fixture-body"))
.send()
.await?;
}
env.stop_server();
mutate_xl_meta(&env.temp_dir, bucket, legacy_key, |object| {
object
.meta_user
.insert("x-amz-storage-class".to_string(), "STANDARD_IA".to_string());
})
.await?;
mutate_xl_meta(&env.temp_dir, bucket, transitioned_key, |object| {
object.set_transition(&rustfs_filemeta::FileInfo {
transition_status: rustfs_filemeta::TRANSITION_COMPLETE.to_string(),
transition_tier: "STANDARD_IA".to_string(),
..Default::default()
});
})
.await?;
env.restart_server_preserving_data(Vec::new(), &[]).await?;
assert_object_storage_class(&client, bucket, legacy_key, "STANDARD", b"fixture-body").await?;
let legacy_attributes = client
.get_object_attributes()
.bucket(bucket)
.key(legacy_key)
.object_attributes(ObjectAttributes::StorageClass)
.send()
.await?;
assert_eq!(legacy_attributes.storage_class().map(StorageClass::as_str), Some("STANDARD"));
let versions = client.list_object_versions().bucket(bucket).prefix(legacy_key).send().await?;
let legacy_version = versions
.versions()
.iter()
.find(|version| version.key() == Some(legacy_key))
.ok_or("legacy fixture missing from ListObjectVersions")?;
assert_eq!(legacy_version.storage_class().map(|class| class.as_str()), Some("STANDARD"));
let transitioned_head = client.head_object().bucket(bucket).key(transitioned_key).send().await?;
assert_eq!(transitioned_head.storage_class().map(StorageClass::as_str), Some("STANDARD_IA"));
let transitioned_attributes = client
.get_object_attributes()
.bucket(bucket)
.key(transitioned_key)
.object_attributes(ObjectAttributes::StorageClass)
.send()
.await?;
assert_eq!(transitioned_attributes.storage_class().map(StorageClass::as_str), Some("STANDARD_IA"));
let transitioned_list = client
.list_objects_v2()
.bucket(bucket)
.prefix(transitioned_key)
.send()
.await?;
assert_eq!(
transitioned_list.contents()[0].storage_class().map(|class| class.as_str()),
Some("STANDARD_IA")
);
let transitioned_versions = client
.list_object_versions()
.bucket(bucket)
.prefix(transitioned_key)
.send()
.await?;
assert_eq!(
transitioned_versions.versions()[0]
.storage_class()
.map(|class| class.as_str()),
Some("STANDARD_IA")
);
Ok(())
}
#[tokio::test]
async fn authenticated_runtime_capabilities_publish_the_versioned_storage_class_contract()
-> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(Vec::new()).await?;
let path = "/rustfs/admin/v4/runtime/capabilities";
let unsigned = local_http_client().get(format!("{}{path}", env.url)).send().await?;
assert_eq!(unsigned.status(), StatusCode::FORBIDDEN);
let unsigned_body = unsigned.text().await?;
assert!(
!unsigned_body.contains("supported_write_classes"),
"the capability contract must not bypass admin authentication"
);
assert!(
!unsigned_body.contains("manual_transition_jobs"),
"manual transition job capabilities must not bypass admin authentication"
);
let response = signed_admin_get(&env, path).await?;
assert_eq!(response.status(), StatusCode::OK);
let body: Value = response.json().await?;
assert_eq!(body["storage_classes"]["contract_version"], 1);
assert_eq!(
body["storage_classes"]["supported_write_classes"],
serde_json::json!(["STANDARD", "REDUCED_REDUNDANCY"])
);
assert_eq!(body["storage_classes"]["unsupported_write_error"], "InvalidStorageClass");
assert_eq!(body["storage_classes"]["legacy_label_behavior"], "normalized_to_effective_class");
assert_eq!(body["summary"]["manual_transition_jobs"]["state"], "supported");
assert_eq!(body["manual_transition_jobs"]["contract_version"], 1);
assert_eq!(body["manual_transition_jobs"]["status"]["state"], "supported");
assert_eq!(body["manual_transition_jobs"]["modes"], serde_json::json!(["enqueue_only", "async"]));
assert_eq!(body["manual_transition_jobs"]["run_route"], "/rustfs/admin/v3/ilm/transition/run");
assert_eq!(
body["manual_transition_jobs"]["status_route"],
"/rustfs/admin/v3/ilm/transition/jobs/{job_id}"
);
assert_eq!(
body["manual_transition_jobs"]["cancel_route"],
"/rustfs/admin/v3/ilm/transition/jobs/{job_id}"
);
assert_eq!(body["manual_transition_jobs"]["job_id_format"], "uuid");
assert_eq!(body["manual_transition_jobs"]["admission_scope"], "bucket");
assert_eq!(
body["manual_transition_jobs"]["mixed_version_policy"],
"fail_closed_when_capability_unknown_or_unsupported"
);
Ok(())
}
}
@@ -0,0 +1,229 @@
// 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 crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use aws_sdk_sts::config::retry::RetryConfig;
use aws_sdk_sts::config::{Credentials, Region};
use aws_sdk_sts::error::ProvideErrorMetadata;
use aws_sdk_sts::operation::RequestId;
use aws_sdk_sts::{Client, Config};
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
use http::header::{CONTENT_TYPE, HOST};
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4;
use s3s::Body;
use serial_test::serial;
use std::error::Error;
type BoxError = Box<dyn Error + Send + Sync>;
type TestResult = Result<(), BoxError>;
fn sts_client(url: &str, access_key: &str, secret_key: &str, session_token: Option<&str>) -> Client {
let mut config = Config::builder()
.credentials_provider(Credentials::new(
access_key,
secret_key,
session_token.map(str::to_owned),
None,
"e2e-sts-query-compat",
))
.region(Region::new("us-east-1"))
.endpoint_url(url)
.retry_config(RetryConfig::standard().with_max_attempts(1))
.behavior_version_latest();
if url.starts_with("http://") {
config = config.http_client(SmithyHttpClientBuilder::new().build_http());
}
Client::from_conf(config.build())
}
async fn create_root_service_account(env: &RustFSTestEnvironment) -> Result<(String, String), BoxError> {
let path = "/rustfs/admin/v3/add-service-accounts";
let url = format!("{}{path}", env.url);
let uri = url.parse::<http::Uri>()?;
let authority = uri.authority().ok_or("admin URL missing authority")?.to_string();
let body = serde_json::json!({ "targetUser": env.access_key.clone() }).to_string();
let request = http::Request::builder()
.method(http::Method::PUT)
.uri(uri)
.header(HOST, authority)
.header(CONTENT_TYPE, "application/json")
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD)
.body(Body::empty())?;
let content_length = i64::try_from(body.len()).map_err(|_| "service account request body is too large")?;
let signed = sign_v4(request, content_length, &env.access_key, &env.secret_key, "", "us-east-1");
let mut request = local_http_client().put(&url);
for (name, value) in signed.headers() {
request = request.header(name, value);
}
let response = request.body(body).send().await?;
let status = response.status();
let body = response.text().await?;
if !status.is_success() {
return Err(format!("create service account failed: {status} {body}").into());
}
let response: serde_json::Value = serde_json::from_str(&body)?;
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_chaining_denied(client: &Client, credential_kind: &str) -> TestResult {
let error = client
.assume_role()
.role_arn("arn:aws:iam::123456789012:role/test")
.role_session_name("sts-query-compat-e2e")
.send()
.await
.expect_err("credential chaining must be denied");
let service_error = error
.as_service_error()
.ok_or_else(|| format!("{credential_kind} denial should deserialize as an STS service error: {error:?}"))?;
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(403));
assert_eq!(service_error.code(), Some("AccessDenied"));
assert_eq!(service_error.message(), Some("Access Denied"));
assert!(
error.request_id().is_some_and(|request_id| !request_id.is_empty()),
"{credential_kind} denial should include a request ID"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_sts_query_responses_are_aws_sdk_compatible() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let assumed = sts_client(&env.url, &env.access_key, &env.secret_key, None)
.assume_role()
.role_arn("arn:aws:iam::123456789012:role/test")
.role_session_name("sts-query-compat-e2e")
.send()
.await?;
assert!(
assumed.request_id().is_some_and(|request_id| !request_id.is_empty()),
"successful AssumeRole should include a request ID"
);
let temporary = assumed
.credentials()
.ok_or("successful AssumeRole response should contain credentials")?;
let invalid_signature = sts_client(&env.url, &env.access_key, "incorrect-secret-key", None)
.assume_role()
.role_arn("arn:aws:iam::123456789012:role/test")
.role_session_name("sts-query-invalid-signature")
.send()
.await
.expect_err("an invalid signature must be rejected");
assert_eq!(invalid_signature.raw_response().map(|response| response.status().as_u16()), Some(403));
let invalid_signature_service_error = invalid_signature
.as_service_error()
.ok_or_else(|| format!("invalid signature should deserialize as an STS service error: {invalid_signature:?}"))?;
assert_eq!(invalid_signature_service_error.code(), Some("SignatureDoesNotMatch"));
assert!(
invalid_signature_service_error
.message()
.is_some_and(|message| message.starts_with("The request signature we calculated does not match")),
"signature rejection should preserve the canonical error message"
);
assert!(
invalid_signature
.request_id()
.is_some_and(|request_id| !request_id.is_empty()),
"signature rejection should include a request ID"
);
assert_chaining_denied(
&sts_client(
&env.url,
temporary.access_key_id(),
temporary.secret_access_key(),
Some(temporary.session_token()),
),
"temporary credential",
)
.await?;
let (service_access_key, service_secret_key) = create_root_service_account(&env).await?;
assert_chaining_denied(&sts_client(&env.url, &service_access_key, &service_secret_key, None), "service account").await?;
env.stop_server();
Ok(())
}
#[tokio::test]
#[serial]
async fn test_sts_query_rate_limit_error_is_aws_sdk_compatible() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(
vec![],
&[
("RUSTFS_API_RATE_LIMIT_ENABLE", "true"),
("RUSTFS_API_RATE_LIMIT_RPM", "60"),
("RUSTFS_API_RATE_LIMIT_BURST", "1"),
],
)
.await?;
let client = sts_client(&env.url, &env.access_key, &env.secret_key, None);
let mut throttled = None;
let request = || {
client
.assume_role()
.role_arn("arn:aws:iam::123456789012:role/test")
.role_session_name("sts-query-rate-limit")
.send()
};
let (first, second, third, fourth) = tokio::join!(request(), request(), request(), request());
for result in [first, second, third, fourth] {
if let Err(error) = result
&& error.raw_response().map(|response| response.status().as_u16()) == Some(429)
{
throttled = Some(error);
break;
}
}
let error = throttled.ok_or("at least one concurrent STS request should be throttled at burst one")?;
let service_error = error
.as_service_error()
.ok_or_else(|| format!("rate limit response should deserialize as an STS service error: {error:?}"))?;
assert_eq!(service_error.code(), Some("TooManyRequests"));
assert!(
service_error
.message()
.is_some_and(|message| message.starts_with("Request rate limit exceeded")),
"rate limit response should preserve the server message"
);
assert!(
error.request_id().is_some_and(|request_id| !request_id.is_empty()),
"rate limit response should include a request ID"
);
env.stop_server();
Ok(())
}
+2 -1
View File
@@ -61,6 +61,7 @@ rustfs-kms.workspace = true
rustfs-s3-types = { workspace = true }
rustfs-data-usage.workspace = true
rustfs-object-capacity.workspace = true
arc-swap.workspace = true
async-trait.workspace = true
bytes = { workspace = true, features = ["serde"] }
byteorder = { workspace = true }
@@ -122,7 +123,7 @@ libc.workspace = true
# (backlog#1178); "fs" comes from the workspace default.
rustix = { workspace = true, features = ["process", "fs"] }
rustfs-madmin.workspace = true
reqwest = { workspace = true, default-features = false, features = ["rustls", "http2", "system-proxy"] }
reqwest = { workspace = true }
aes-gcm = { workspace = true, features = ["rand_core"] }
chacha20poly1305.workspace = true
aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
+88 -44
View File
@@ -43,10 +43,27 @@ pub mod bucket {
pub mod bucket_lifecycle_ops {
pub use crate::bucket::lifecycle::bucket_lifecycle_ops::{
ExpiryState, LifecycleOps, RestoreRequestOps, TransitionState, TransitionedObject, apply_expiry_rule,
ExpiryState, LifecycleOps, ManualTransitionCancelCheck, ManualTransitionProgressSink,
ManualTransitionQueueSnapshot, ManualTransitionRunExecution, ManualTransitionRunOptions,
ManualTransitionRunReport, RestoreRequestOps, TransitionState, TransitionedObject, apply_expiry_rule,
apply_transition_rule, enqueue_expiry_for_existing_objects, enqueue_transition_for_existing_objects,
enqueue_transition_for_existing_objects_scoped, enqueue_transition_for_existing_objects_scoped_with_cancel,
enqueue_transition_immediate, expire_transitioned_object, get_global_expiry_state, get_global_transition_state,
init_background_expiry, post_restore_opts, run_stale_multipart_upload_cleanup_once, validate_transition_tier,
init_background_expiry, manual_transition_queue_snapshot, post_restore_opts,
run_stale_multipart_upload_cleanup_once, validate_transition_tier,
};
}
pub mod manual_transition_job {
pub use crate::bucket::lifecycle::manual_transition_job::{
ManualTransitionJobRecord, ManualTransitionJobState, ManualTransitionScopeAdmission,
ManualTransitionScopeAdmissionClaim, claim_manual_transition_scope_admission,
delete_manual_transition_scope_admission_if_current, load_manual_transition_job_record,
load_manual_transition_job_record_with_etag, load_manual_transition_scope_admission,
manual_transition_job_lease_expired, manual_transition_scope_admission_lease_expired,
manual_transition_scope_key, persist_manual_transition_job_progress, renew_manual_transition_job_lease,
request_manual_transition_job_cancel, save_manual_transition_job_record,
save_manual_transition_job_record_if_current, save_manual_transition_scope_admission_if_absent,
};
}
@@ -67,7 +84,11 @@ pub mod bucket {
}
pub mod tier_delete_journal {
pub use crate::bucket::lifecycle::tier_delete_journal::persist_tier_delete_journal_entry;
#[cfg(feature = "test-util")]
pub use crate::bucket::lifecycle::tier_delete_journal::recover_tier_delete_journal_entries;
pub use crate::bucket::lifecycle::tier_delete_journal::{
persist_tier_delete_journal_entry, record_tier_delete_journal_backend_identity,
};
}
pub mod tier_last_day_stats {
@@ -101,12 +122,13 @@ pub mod bucket {
pub mod metadata_sys {
pub use crate::bucket::metadata_sys::{
BucketMetadataSys, delete, get, get_accelerate_config, get_bucket_policy, get_bucket_policy_raw,
get_bucket_targets_config, get_config_from_disk, get_cors_config, get_durability_config,
BucketMetadataSys, acquire_bucket_targets_transaction_lock, delete, get, get_accelerate_config, get_bucket_policy,
get_bucket_policy_raw, get_bucket_targets_config, get_config_from_disk, get_cors_config, get_durability_config,
get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config, get_notification_config,
get_object_lock_config, get_public_access_block_config, get_quota_config, get_replication_config,
get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config, get_website_config,
init_bucket_metadata_sys, list_bucket_targets, remove_bucket_metadata, set_bucket_metadata, update,
update_bucket_targets_under_transaction_lock, update_config_with,
};
}
@@ -134,7 +156,7 @@ pub mod bucket {
}
pub mod quota {
pub use crate::bucket::quota::{BucketQuota, QuotaError, QuotaOperation};
pub use crate::bucket::quota::{BucketQuota, QuotaCheckResult, QuotaError, QuotaOperation};
pub mod checker {
pub use crate::bucket::quota::checker::QuotaChecker;
@@ -143,18 +165,19 @@ pub mod bucket {
pub mod replication {
pub use crate::bucket::replication::{
BucketReplicationResyncStatus, BucketStats, DeletedObjectReplicationInfo, DynReplicationPool, MustReplicateOptions,
ObjectOpts, REPLICATE_INCOMING_DELETE, ReplicateDecision, ReplicateObjectInfo, ReplicationConfig,
ReplicationConfigurationExt, ReplicationDeleteScheduleInput, ReplicationDeleteStateSource,
ReplicationHealQueueResult, ReplicationObjectBridge, ReplicationObjectIO, ReplicationOperation, ReplicationPoolTrait,
ReplicationPriority, ReplicationQueueAdmission, ReplicationScannerBridge, ReplicationState, ReplicationStats,
ReplicationStatusType, ReplicationStorage, ReplicationTargetValidationError, ReplicationType, ResyncOpts,
ResyncStatusType, TargetReplicationResyncStatus, VersionPurgeStatusType, delete_replication_state_from_config,
delete_replication_version_id, get_global_replication_pool, get_global_replication_stats,
init_background_replication, replication_state_to_filemeta, replication_status_to_filemeta, replication_statuses_map,
replication_target_arns, should_remove_replication_target, should_schedule_delete_replication,
should_use_existing_delete_replication_info, should_use_existing_delete_replication_source,
validate_replication_config_target_arns, version_purge_status_to_filemeta,
BucketReplicationResyncStatus, BucketStats, DeletedObjectReplicationInfo, DurableMrfBacklog, DynReplicationPool,
MrfOpKind, MrfReplicateEntry, MustReplicateOptions, ObjectOpts, REPLICATE_INCOMING_DELETE, ReplicateDecision,
ReplicateObjectInfo, ReplicationConfig, ReplicationConfigurationExt, ReplicationDeleteScheduleInput,
ReplicationDeleteStateSource, ReplicationHealQueueResult, ReplicationObjectBridge, ReplicationObjectIO,
ReplicationOperation, ReplicationPoolTrait, ReplicationPriority, ReplicationQueueAdmission, ReplicationScannerBridge,
ReplicationState, ReplicationStats, ReplicationStatusType, ReplicationStorage, ReplicationTargetValidationError,
ReplicationType, ResyncOpts, ResyncStatusType, TargetReplicationResyncStatus, VersionPurgeStatusType,
delete_replication_state_from_config, delete_replication_version_id, get_global_replication_pool,
get_global_replication_stats, init_background_replication, read_durable_mrf_backlog, replication_state_to_filemeta,
replication_status_to_filemeta, replication_statuses_map, replication_target_arns, resync_start_conflict_id,
should_remove_replication_target, should_schedule_delete_replication, should_use_existing_delete_replication_info,
should_use_existing_delete_replication_source, validate_replication_config_target_arns,
version_purge_status_to_filemeta,
};
}
@@ -236,18 +259,23 @@ 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, delete_config, is_server_config_corrupt_error, lookup_configs, read_config,
read_config_no_lock, read_config_with_metadata, read_config_without_migrate, save_config, save_config_with_opts,
save_server_config, try_migrate_server_config,
ServerConfigCorruptError, ServerConfigSnapshot, delete_config, is_server_config_corrupt_error, lookup_configs,
read_config, read_config_no_lock, read_config_with_metadata, read_config_without_migrate,
read_config_without_migrate_no_lock, read_existing_server_config_no_lock, read_server_config_snapshot, save_config,
save_config_no_lock, save_config_with_opts, save_server_config, save_server_config_no_lock,
save_server_config_snapshot, server_config_path, try_migrate_server_config, with_config_object_read_lock,
with_config_object_write_lock, with_server_config_read_lock, with_server_config_write_lock,
};
}
pub mod storageclass {
pub use crate::config::storageclass::{
CLASS_RRS, CLASS_STANDARD, Config, DEEP_ARCHIVE, DEFAULT_INLINE_BLOCK, DEFAULT_KVS, DEFAULT_RRS_PARITY,
EXPRESS_ONEZONE, GLACIER, GLACIER_IR, INLINE_BLOCK, INLINE_BLOCK_ENV, INTELLIGENT_TIERING, MIN_PARITY_DRIVES,
ONEZONE_IA, OPTIMIZE, OPTIMIZE_ENV, OUTPOSTS, RRS, RRS_ENV, SCHEME_PREFIX, SNOW, STANDARD, STANDARD_ENV, STANDARD_IA,
StorageClass, default_parity_count, lookup_config, parse_storage_class, validate_parity, validate_parity_inner,
CAPABILITY_CONTRACT_VERSION, CLASS_RRS, CLASS_STANDARD, Config, DEEP_ARCHIVE, DEFAULT_INLINE_BLOCK, DEFAULT_KVS,
DEFAULT_RRS_PARITY, EXPRESS_ONEZONE, GLACIER, GLACIER_IR, INLINE_BLOCK, INLINE_BLOCK_ENV, INTELLIGENT_TIERING,
LEGACY_LABEL_BEHAVIOR, MIN_PARITY_DRIVES, ONEZONE_IA, OPTIMIZE, OPTIMIZE_ENV, OUTPOSTS, RRS, RRS_ENV, SCHEME_PREFIX,
SNOW, STANDARD, STANDARD_ENV, STANDARD_IA, SUPPORTED_WRITE_CLASSES, StorageClass, UNSUPPORTED_WRITE_ERROR,
default_parity_count, effective_class, is_supported_write_class, lookup_config, lookup_config_for_pools,
parse_storage_class, validate_parity, validate_parity_inner,
};
}
@@ -258,12 +286,14 @@ pub mod config {
pub mod data_usage {
pub use crate::data_usage::{
DATA_USAGE_CACHE_NAME, apply_bucket_usage_memory_overlay, init_compression_total_memory_from_backend,
load_compression_total_from_memory, load_data_usage_from_backend, record_bucket_delete_marker_memory,
record_bucket_object_delete_memory, record_bucket_object_version_write_memory, record_bucket_object_write_memory,
record_bucket_object_write_unknown_previous_memory, record_compression_total_memory,
DATA_USAGE_CACHE_NAME, apply_bucket_usage_memory_overlay, compute_bucket_usage,
init_compression_total_memory_from_backend, invalidate_data_usage_snapshot_cache, live_bucket_usage_computations,
load_compression_total_from_memory, load_data_usage_from_backend, load_data_usage_from_backend_cached,
record_bucket_delete_marker_memory, record_bucket_object_delete_memory, record_bucket_object_version_write_memory,
record_bucket_object_write_memory, record_bucket_object_write_unknown_previous_memory, record_compression_total_memory,
refresh_bucket_usage_from_object_layer, refresh_versioned_bucket_usage_from_object_layer,
remove_bucket_usage_from_backend, replace_bucket_usage_memory_from_info, store_compression_total_in_backend,
store_data_usage_in_backend,
};
}
@@ -273,9 +303,9 @@ pub mod disk {
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, OldCurrentSize, RUSTFS_META_BUCKET, ReadMultipleReq,
ReadMultipleResp, ReadOptions, RenameDataResp, STORAGE_FORMAT_FILE, UpdateMetadataOpts, VolumeInfo, WalkDirOptions,
new_disk, validate_batch_read_version_item_count,
FileInfoVersions, FileReader, FileWriter, HEALING_MARKER_PATH, NsScannerOpenRequest, OldCurrentSize,
PartTransactionAction, RUSTFS_META_BUCKET, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp,
STORAGE_FORMAT_FILE, UpdateMetadataOpts, VolumeInfo, WalkDirOptions, new_disk, validate_batch_read_version_item_count,
};
pub use bytes::Bytes;
pub use endpoint::Endpoint;
@@ -308,8 +338,8 @@ pub mod error {
pub mod erasure {
pub use crate::erasure::coding::{
BitrotReader, BitrotWriter, BitrotWriterWrapper, CustomWriter, Erasure, ReedSolomonEncoder, calc_shard_size,
calc_shard_size_legacy,
BitrotReader, BitrotWriter, BitrotWriterWrapper, CustomWriter, Erasure, ErasureConstructionError, ReedSolomonEncoder,
calc_shard_size, calc_shard_size_legacy,
};
}
@@ -351,9 +381,12 @@ pub mod notification {
pub mod object {
pub use crate::object_api::{
BLOCK_SIZE_V2, ERASURE_ALGORITHM, GetObjectBodyCacheHook, GetObjectReader, ObjectInfo, ObjectMutationHook, ObjectOptions,
PutObjReader, RangedDecompressReader, StreamConsumer, register_get_object_body_cache_hook, register_object_mutation_hook,
BLOCK_SIZE_V2, ERASURE_ALGORITHM, GetObjectBodyCacheHook, GetObjectBodyCacheHookLookup, GetObjectBodySource,
GetObjectReader, ObjectInfo, ObjectMutationHook, ObjectOptions, PutObjReader, RangedDecompressReader, StreamConsumer,
get_object_body_cache_plaintext_len, 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 mod rebalance {
@@ -373,9 +406,13 @@ pub mod rio {
pub mod rpc {
pub use crate::cluster::rpc::{
LocalPeerS3Client, PEER_RESTSIGNAL, PEER_RESTSUB_SYS, PeerRestClient, PeerS3Client, SERVICE_SIGNAL_REFRESH_CONFIG,
SERVICE_SIGNAL_RELOAD_DYNAMIC, TONIC_RPC_PREFIX, TonicInterceptor, gen_tonic_signature_interceptor,
node_service_time_out_client, node_service_time_out_client_no_auth, verify_rpc_signature,
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_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, verify_rpc_signature,
verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest, verify_tonic_rpc_response_proof,
verify_tonic_rpc_signature,
};
}
@@ -402,7 +439,7 @@ pub mod tier {
pub use crate::services::tier::tier::{
ERR_TIER_BACKEND_IN_USE, ERR_TIER_BACKEND_NOT_EMPTY, ERR_TIER_INVALID_CONFIG, ERR_TIER_MISSING_CREDENTIALS,
ERR_TIER_TYPE_UNSUPPORTED, TIER_CONFIG_FILE, TIER_CONFIG_FORMAT, TIER_CONFIG_V1, TIER_CONFIG_VERSION, TierConfigMgr,
is_err_config_not_found, try_migrate_tiering_config,
TierConfigUpdateError, is_err_config_not_found, try_migrate_tiering_config,
};
}
@@ -413,7 +450,7 @@ pub mod tier {
pub mod tier_config {
pub use crate::services::tier::tier_config::{
ServicePrincipalAuth, TierAliyun, TierAzure, TierConfig, TierGCS, TierHuaweicloud, TierMinIO, TierR2, TierRustFS,
TierS3, TierTencent, TierType,
TierS3, TierTencent, TierType, TierWasabi,
};
}
@@ -424,6 +461,13 @@ pub mod tier {
};
}
pub mod tier_mutation_peer {
pub use crate::services::tier::tier_mutation_peer::{
MAX_TIER_MUTATION_PEER_COMMIT_ETAG_SIZE, TierMutationPeerError, TierMutationPeerOutcome, TierMutationPeerResult,
TierMutationPeerState, handle_tier_mutation_peer_request,
};
}
pub mod warm_backend {
pub use crate::services::tier::warm_backend::{
WarmBackend, WarmBackendGetOpts, WarmBackendImpl, build_transition_put_options, check_warm_backend, new_warm_backend,
@@ -433,9 +477,9 @@ pub mod tier {
#[cfg(feature = "test-util")]
pub mod test_util {
pub use crate::services::tier::test_util::{
FaultConfig, MockStoredObject, MockWarmBackend, MockWarmOp, TransitionMeta, assert_transition_meta_consistent,
free_version_count, read_transition_meta, register_mock_tier, register_mock_tier_backend,
wait_for_free_version_absence,
FaultConfig, MockStoredObject, MockWarmBackend, MockWarmOp, TransitionCleanupStoreBarrier, TransitionMeta,
assert_transition_meta_consistent, free_version_count, read_transition_meta, register_mock_tier,
register_mock_tier_backend, wait_for_free_version_absence,
};
}
}
+36 -10
View File
@@ -280,6 +280,7 @@ pub struct BucketTargetSys {
pub hc_client: Arc<HttpClient>,
pub a_mutex: Arc<Mutex<HashMap<String, ArnErrs>>>,
pub arn_errs_map: Arc<RwLock<HashMap<String, ArnErrs>>>,
heartbeat_started: OnceLock<()>,
}
impl BucketTargetSys {
@@ -295,9 +296,20 @@ impl BucketTargetSys {
hc_client: Arc::new(HttpClient::new()),
a_mutex: Arc::new(Mutex::new(HashMap::new())),
arn_errs_map: Arc::new(RwLock::new(HashMap::new())),
heartbeat_started: OnceLock::new(),
}
}
pub(crate) fn start_heartbeat(&'static self) {
if self.heartbeat_started.set(()).is_err() {
return;
}
tokio::spawn(async move {
self.heartbeat().await;
});
}
pub async fn is_offline(&self, url: &Url) -> bool {
let key = endpoint_health_key(url);
{
@@ -367,7 +379,7 @@ impl BucketTargetSys {
async fn check_endpoint_health(&self, endpoint: &str, scheme: &str) -> bool {
let scheme = if scheme.is_empty() { "https" } else { scheme };
let url = format!("{scheme}://{endpoint}/");
match self.hc_client.head(url).timeout(Duration::from_secs(3)).send().await {
match self.hc_client.get(url).timeout(Duration::from_secs(3)).send().await {
Ok(response) => response.status().as_u16() < 500,
Err(_) => false,
}
@@ -795,15 +807,29 @@ impl BucketTargetSys {
&& !new_targets.is_empty()
{
for target in &new_targets.targets {
if let Ok(client) = self.get_remote_target_client_internal(target).await {
arn_remotes_map.insert(
target.arn.clone(),
ArnTarget {
client: Some(Arc::new(client)),
last_refresh: OffsetDateTime::now_utc(),
},
);
self.update_bandwidth_limit(bucket, &target.arn, target.bandwidth_limit);
match self.get_remote_target_client_internal(target).await {
Ok(client) => {
arn_remotes_map.insert(
target.arn.clone(),
ArnTarget {
client: Some(Arc::new(client)),
last_refresh: OffsetDateTime::now_utc(),
},
);
self.update_bandwidth_limit(bucket, &target.arn, target.bandwidth_limit);
}
// The target stays in `targets_map`, so it keeps showing up in
// `bucket remote ls` while no client exists to replicate through it —
// replication then drops every object for this ARN. Without this the
// rejection (loopback endpoint, bad CA, unparseable URL) left no trace
// anywhere.
Err(err) => warn!(
bucket = %bucket,
arn = %target.arn,
endpoint = %target.endpoint,
error = %err,
"replication target client unavailable; objects for this ARN will not replicate"
),
}
}
targets_map.insert(bucket.to_string(), new_targets.targets.clone());
File diff suppressed because it is too large Load Diff
@@ -18,10 +18,11 @@ use http::HeaderMap;
use rustfs_filemeta::FileInfo;
use crate::config::com;
use crate::disk::RUSTFS_META_BUCKET;
use crate::error::{Error, Result};
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
use crate::storage_api_contracts::{
object::{DeletedObject, ObjectIO, ObjectOperations, ObjectToDelete},
object::{DeletedObject, HTTPPreconditions, ObjectIO, ObjectOperations, ObjectToDelete},
range::HTTPRangeSpec,
};
@@ -40,6 +41,21 @@ where
com::read_config(api, file).await
}
pub(crate) async fn read_config_with_metadata<S>(api: Arc<S>, file: &str, opts: &ObjectOptions) -> Result<(Vec<u8>, ObjectInfo)>
where
S: ObjectIO<
Error = Error,
RangeSpec = HTTPRangeSpec,
HeaderMap = HeaderMap,
ObjectOptions = ObjectOptions,
ObjectInfo = ObjectInfo,
GetObjectReader = GetObjectReader,
PutObjectReader = PutObjReader,
>,
{
com::read_config_with_metadata(api, file, opts).await
}
pub(crate) async fn save_config<S>(api: Arc<S>, file: &str, data: Vec<u8>) -> Result<()>
where
S: ObjectIO<
@@ -55,6 +71,21 @@ where
com::save_config(api, file, data).await
}
pub(crate) async fn save_config_with_opts<S>(api: Arc<S>, file: &str, data: Vec<u8>, opts: &ObjectOptions) -> Result<()>
where
S: ObjectIO<
Error = Error,
RangeSpec = HTTPRangeSpec,
HeaderMap = HeaderMap,
ObjectOptions = ObjectOptions,
ObjectInfo = ObjectInfo,
GetObjectReader = GetObjectReader,
PutObjectReader = PutObjReader,
>,
{
com::save_config_with_opts(api, file, data, opts).await
}
pub(crate) async fn delete_config<S>(api: Arc<S>, file: &str) -> Result<()>
where
S: ObjectOperations<
@@ -68,3 +99,39 @@ where
{
com::delete_config(api, file).await
}
pub(crate) async fn delete_config_if_match<S>(api: Arc<S>, file: &str, etag: &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 {
http_preconditions: Some(HTTPPreconditions {
if_match: Some(etag.to_string()),
..Default::default()
}),
..Default::default()
},
)
.await
{
Ok(_) => Ok(()),
Err(err) => {
if err == Error::FileNotFound || matches!(err, Error::ObjectNotFound(_, _)) {
Err(Error::ConfigNotFound)
} else {
Err(err)
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -17,6 +17,7 @@ pub mod bucket_lifecycle_ops;
mod config_boundary;
pub mod core;
pub mod evaluator;
pub mod manual_transition_job;
mod metadata_boundary;
mod object_lock_boundary;
pub use self::core as lifecycle;
@@ -28,3 +29,4 @@ pub mod tier_delete_journal;
pub mod tier_free_version_recovery;
pub mod tier_last_day_stats;
pub mod tier_sweeper;
pub mod transition_transaction;
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use std::sync::Arc;
use std::{future::Future, sync::Arc, time::Duration};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
@@ -20,10 +20,11 @@ use tokio_util::sync::CancellationToken;
use tracing::{debug, warn};
use crate::bucket::lifecycle::config_boundary;
use crate::bucket::lifecycle::tier_sweeper::{Jentry, delete_object_from_remote_tier_idempotent};
use crate::bucket::lifecycle::tier_sweeper::{Jentry, delete_object_from_remote_tier_idempotent_with_manager_and_identity};
use crate::disk::RUSTFS_META_BUCKET;
use crate::error::{Error, Result};
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
use crate::services::tier::tier::tier_destination_id_from_metadata;
use crate::storage_api_contracts::{
list::ListOperations as _,
object::{DeletedObject, ObjectIO, ObjectOperations, ObjectToDelete},
@@ -37,8 +38,11 @@ const LOG_SUBSYSTEM_LIFECYCLE: &str = "lifecycle";
const EVENT_LIFECYCLE_TIER_DELETE_JOURNAL: &str = "lifecycle_tier_delete_journal";
pub const DEFAULT_TIER_DELETE_JOURNAL_RECOVERY_LIMIT: usize = 1_000;
const TIER_DELETE_JOURNAL_VERSION: u8 = 1;
const TIER_DELETE_JOURNAL_PREFIX: &str = "ilm/tier-delete-journal/";
const TIER_DELETE_JOURNAL_RECOVERY_INTERVAL: Duration = Duration::from_secs(60);
const TIER_DELETE_JOURNAL_RECOVERY_TIMEOUT: Duration = Duration::from_secs(300);
const TIER_DELETE_JOURNAL_VERSION: u8 = 2;
const TIER_DELETE_JOURNAL_EXACT_VERSION: u8 = 3;
pub(crate) const TIER_DELETE_JOURNAL_PREFIX: &str = "ilm/tier-delete-journal/";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
@@ -47,22 +51,31 @@ struct PersistedTierDeleteJournalEntry {
obj_name: String,
version_id: String,
tier_name: String,
#[serde(default)]
backend_identity: Option<[u8; 32]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
version_id_exact: Option<bool>,
}
impl PersistedTierDeleteJournalEntry {
fn from_jentry(je: &Jentry) -> Self {
Self {
version: TIER_DELETE_JOURNAL_VERSION,
version: if je.version_id_exact {
TIER_DELETE_JOURNAL_EXACT_VERSION
} else if je.backend_identity.is_some() {
TIER_DELETE_JOURNAL_VERSION
} else {
1
},
obj_name: je.obj_name.clone(),
version_id: je.version_id.clone(),
tier_name: je.tier_name.clone(),
backend_identity: je.backend_identity,
version_id_exact: je.version_id_exact.then_some(true),
}
}
fn into_jentry(self) -> Result<Jentry> {
if self.version != TIER_DELETE_JOURNAL_VERSION {
return Err(Error::other(format!("unsupported tier delete journal version {}", self.version)));
}
// Empty `version_id` is a legal sentinel for objects transitioned to an
// unversioned remote tier (see CLAUDE.md: a tier version of `None`/`""`
// means the tier bucket is unversioned, so the remote delete is issued
@@ -71,10 +84,40 @@ impl PersistedTierDeleteJournalEntry {
if self.obj_name.is_empty() || self.tier_name.is_empty() {
return Err(Error::other("tier delete journal entry is incomplete"));
}
if self.version != TIER_DELETE_JOURNAL_EXACT_VERSION && self.version_id_exact.unwrap_or(false) {
return Err(Error::other(
"legacy tier delete journal entry has an unsupported exact version constraint",
));
}
let (backend_identity, version_id_exact) = match self.version {
1 => (None, false),
TIER_DELETE_JOURNAL_VERSION => (
Some(
self.backend_identity
.ok_or_else(|| Error::other("tier delete journal v2 entry is missing its backend identity"))?,
),
false,
),
TIER_DELETE_JOURNAL_EXACT_VERSION => {
if self.version_id.is_empty() || self.version_id_exact != Some(true) {
return Err(Error::other("tier delete journal v3 entry is missing its exact version constraint"));
}
(
Some(
self.backend_identity
.ok_or_else(|| Error::other("tier delete journal v3 entry is missing its backend identity"))?,
),
true,
)
}
version => return Err(Error::other(format!("unsupported tier delete journal version {version}"))),
};
Ok(Jentry {
obj_name: self.obj_name,
version_id: self.version_id,
tier_name: self.tier_name,
backend_identity,
version_id_exact,
})
}
}
@@ -95,23 +138,41 @@ pub(crate) fn tier_delete_journal_object_name(je: &Jentry) -> String {
hasher.update(je.obj_name.as_bytes());
hasher.update([0]);
hasher.update(je.version_id.as_bytes());
if let Some(backend_identity) = je.backend_identity {
hasher.update([0]);
hasher.update(backend_identity);
}
if je.version_id_exact {
hasher.update([0]);
hasher.update(b"exact-version-id");
}
format!(
"{TIER_DELETE_JOURNAL_PREFIX}{}.json",
rustfs_utils::crypto::hex(hasher.finalize().as_slice())
)
}
fn decode_tier_delete_journal_entry(data: &[u8]) -> Result<Jentry> {
pub(crate) fn decode_tier_delete_journal_entry(data: &[u8]) -> Result<Jentry> {
let persisted: PersistedTierDeleteJournalEntry =
serde_json::from_slice(data).map_err(|err| Error::other(format!("decode tier delete journal failed: {err}")))?;
persisted.into_jentry()
}
fn encode_tier_delete_journal_entry(je: &Jentry) -> Result<Vec<u8>> {
pub(crate) fn encode_tier_delete_journal_entry(je: &Jentry) -> Result<Vec<u8>> {
serde_json::to_vec(&PersistedTierDeleteJournalEntry::from_jentry(je))
.map_err(|err| Error::other(format!("encode tier delete journal failed: {err}")))
}
pub fn record_tier_delete_journal_backend_identity(
je: &mut Jentry,
metadata: &std::collections::HashMap<String, String>,
) -> std::io::Result<()> {
if let Some(identity) = tier_destination_id_from_metadata(metadata)? {
je.backend_identity = Some(identity);
}
Ok(())
}
pub async fn persist_tier_delete_journal_entry<S>(api: Arc<S>, je: &Jentry) -> std::io::Result<()>
where
S: ObjectIO<
@@ -148,7 +209,18 @@ where
}
pub async fn process_tier_delete_journal_entry(api: Arc<ECStore>, je: &Jentry) -> std::io::Result<()> {
delete_object_from_remote_tier_idempotent(&je.obj_name, &je.version_id, &je.tier_name).await?;
let backend_identity = je
.backend_identity
.ok_or_else(|| std::io::Error::other("legacy tier delete journal has no durable backend identity"))?;
delete_object_from_remote_tier_idempotent_with_manager_and_identity(
&je.obj_name,
&je.version_id,
&je.tier_name,
backend_identity,
&api.tier_config_mgr(),
je.version_id_exact,
)
.await?;
remove_tier_delete_journal_entry(api, je).await
}
@@ -218,6 +290,21 @@ pub async fn recover_tier_delete_journal_entries(
}
};
if je.backend_identity.is_none() {
stats.failed += 1;
warn!(
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
journal_object = %object.name,
remote_object = %je.obj_name,
remote_version_id = %je.version_id,
tier = %je.tier_name,
"Legacy tier delete journal entry has no durable backend identity and will be retained"
);
continue;
}
match process_tier_delete_journal_entry(api.clone(), &je).await {
Ok(()) => stats.deleted += 1,
Err(err) => {
@@ -241,16 +328,33 @@ pub async fn recover_tier_delete_journal_entries(
}
pub async fn run_tier_delete_journal_recovery_loop(api: Arc<ECStore>, cancel_token: CancellationToken) {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
let mut interval = tokio::time::interval(TIER_DELETE_JOURNAL_RECOVERY_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut marker: Option<String> = None;
loop {
#[cfg(test)]
tokio::select! {
biased;
_ = cancel_token.cancelled() => return,
_ = interval.tick() => {}
_ = interval.tick() => {},
_ = api.ctx.wait_for_tier_delete_journal_recovery() => {},
}
#[cfg(not(test))]
tokio::select! {
biased;
_ = cancel_token.cancelled() => return,
_ = interval.tick() => {},
}
match recover_tier_delete_journal_entries(api.clone(), DEFAULT_TIER_DELETE_JOURNAL_RECOVERY_LIMIT, marker.clone()).await {
let recovery =
recover_tier_delete_journal_entries(api.clone(), DEFAULT_TIER_DELETE_JOURNAL_RECOVERY_LIMIT, marker.clone());
let Some(result) =
await_tier_delete_journal_recovery(&cancel_token, TIER_DELETE_JOURNAL_RECOVERY_TIMEOUT, recovery).await
else {
return;
};
match result {
Ok(stats) => {
marker = stats.next_marker;
debug!(
@@ -279,16 +383,44 @@ pub async fn run_tier_delete_journal_recovery_loop(api: Arc<ECStore>, cancel_tok
}
}
async fn await_tier_delete_journal_recovery<T, F>(
cancel_token: &CancellationToken,
timeout: Duration,
recovery: F,
) -> Option<Result<T>>
where
F: Future<Output = Result<T>>,
{
tokio::select! {
_ = cancel_token.cancelled() => None,
result = tokio::time::timeout(timeout, recovery) => Some(match result {
Ok(result) => result,
Err(_) => Err(Error::other(format!(
"tier delete journal recovery timed out after {} seconds",
timeout.as_secs()
))),
}),
}
}
#[cfg(test)]
mod tests {
use super::{decode_tier_delete_journal_entry, encode_tier_delete_journal_entry, tier_delete_journal_object_name};
use super::{
TIER_DELETE_JOURNAL_EXACT_VERSION, await_tier_delete_journal_recovery, decode_tier_delete_journal_entry,
encode_tier_delete_journal_entry, record_tier_delete_journal_backend_identity, tier_delete_journal_object_name,
};
use crate::bucket::lifecycle::tier_sweeper::Jentry;
use crate::error::Result;
use std::time::Duration;
use tokio_util::sync::CancellationToken;
fn journal_entry() -> Jentry {
Jentry {
obj_name: "remote/object".to_string(),
version_id: "remote-version".to_string(),
tier_name: "WARM".to_string(),
backend_identity: Some([7; 32]),
version_id_exact: false,
}
}
@@ -302,6 +434,83 @@ mod tests {
assert_eq!(decoded.obj_name, je.obj_name);
assert_eq!(decoded.version_id, je.version_id);
assert_eq!(decoded.tier_name, je.tier_name);
assert_eq!(decoded.backend_identity, je.backend_identity);
assert_eq!(decoded.version_id_exact, je.version_id_exact);
}
#[test]
fn tier_delete_journal_roundtrips_exact_put_response_constraint() {
let mut exact = journal_entry();
exact.version_id = uuid::Uuid::nil().to_string();
exact.version_id_exact = true;
let mut normalized = exact.clone();
normalized.version_id_exact = false;
let encoded = encode_tier_delete_journal_entry(&exact).expect("exact journal entry should encode");
let persisted: serde_json::Value = serde_json::from_slice(&encoded).expect("exact journal JSON should decode");
let decoded = decode_tier_delete_journal_entry(&encoded).expect("exact journal entry should decode");
assert_eq!(persisted["version"], TIER_DELETE_JOURNAL_EXACT_VERSION);
assert_eq!(persisted["version_id_exact"], true);
assert!(decoded.version_id_exact);
assert_ne!(tier_delete_journal_object_name(&exact), tier_delete_journal_object_name(&normalized));
}
#[test]
fn tier_delete_journal_rejects_invalid_exact_version_constraints() {
let identity = vec![7_u8; 32];
let invalid = [
serde_json::json!({
"version": 1,
"obj_name": "remote/object",
"version_id": "exact-version",
"tier_name": "WARM",
"version_id_exact": true,
}),
serde_json::json!({
"version": 2,
"obj_name": "remote/object",
"version_id": "exact-version",
"tier_name": "WARM",
"backend_identity": identity,
"version_id_exact": true,
}),
serde_json::json!({
"version": TIER_DELETE_JOURNAL_EXACT_VERSION,
"obj_name": "remote/object",
"version_id": "",
"tier_name": "WARM",
"backend_identity": identity,
"version_id_exact": true,
}),
serde_json::json!({
"version": TIER_DELETE_JOURNAL_EXACT_VERSION,
"obj_name": "remote/object",
"version_id": "exact-version",
"tier_name": "WARM",
"backend_identity": identity,
}),
serde_json::json!({
"version": TIER_DELETE_JOURNAL_EXACT_VERSION,
"obj_name": "remote/object",
"version_id": "exact-version",
"tier_name": "WARM",
"backend_identity": identity,
"version_id_exact": false,
}),
serde_json::json!({
"version": TIER_DELETE_JOURNAL_EXACT_VERSION,
"obj_name": "remote/object",
"version_id": "exact-version",
"tier_name": "WARM",
"version_id_exact": true,
}),
];
for persisted in invalid {
let encoded = serde_json::to_vec(&persisted).expect("invalid journal fixture should encode");
decode_tier_delete_journal_entry(&encoded).expect_err("invalid exact journal constraint must fail closed");
}
}
#[test]
@@ -317,6 +526,63 @@ mod tests {
assert!(!first.contains("remote/object"));
}
#[test]
fn tier_delete_journal_paths_separate_legacy_and_backend_identities() {
let mut legacy = journal_entry();
legacy.backend_identity = None;
let mut backend_a = journal_entry();
backend_a.backend_identity = Some([1; 32]);
let mut backend_b = journal_entry();
backend_b.backend_identity = Some([2; 32]);
assert_eq!(
tier_delete_journal_object_name(&legacy),
"ilm/tier-delete-journal/5ba6a7eb6338412b771613a6845a42ae5b8e26b5d201323eb01b38c5b42ff300.json"
);
assert_ne!(tier_delete_journal_object_name(&legacy), tier_delete_journal_object_name(&backend_a));
assert_ne!(tier_delete_journal_object_name(&backend_a), tier_delete_journal_object_name(&backend_b));
}
#[test]
fn tier_delete_journal_v2_requires_backend_identity() {
let payload = br#"{"version":2,"obj_name":"remote/object","version_id":"v1","tier_name":"WARM"}"#;
let err = decode_tier_delete_journal_entry(payload).expect_err("v2 entry without identity must fail closed");
assert!(err.to_string().contains("backend identity"));
}
#[test]
fn tier_delete_journal_uses_persisted_transition_destination_identity() {
let mut je = journal_entry();
je.backend_identity = None;
let identity = [9_u8; 32];
let mut metadata = std::collections::HashMap::new();
rustfs_utils::http::metadata_compat::insert_str(
&mut metadata,
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
rustfs_utils::crypto::hex(identity),
);
record_tier_delete_journal_backend_identity(&mut je, &metadata).expect("persisted transition identity should decode");
let encoded = encode_tier_delete_journal_entry(&je).expect("identity-bound journal should encode");
let decoded = decode_tier_delete_journal_entry(&encoded).expect("identity-bound journal should decode");
assert_eq!(decoded.backend_identity, Some(identity));
}
#[test]
fn tier_delete_journal_without_transition_identity_stays_legacy() {
let mut je = journal_entry();
je.backend_identity = None;
let encoded = encode_tier_delete_journal_entry(&je).expect("legacy journal should remain encodable");
let persisted: serde_json::Value = serde_json::from_slice(&encoded).expect("journal JSON should decode");
assert_eq!(persisted["version"], 1);
assert!(persisted["backend_identity"].is_null());
}
#[test]
fn tier_delete_journal_rejects_incomplete_entry() {
let payload = br#"{"version":1,"obj_name":"","version_id":"v1","tier_name":"WARM"}"#;
@@ -339,6 +605,7 @@ mod tests {
assert_eq!(decoded.obj_name, "remote/object");
assert!(decoded.version_id.is_empty());
assert_eq!(decoded.tier_name, "WARM");
assert_eq!(decoded.backend_identity, None);
}
#[test]
@@ -360,4 +627,29 @@ mod tests {
assert!(err.to_string().contains("decode tier delete journal failed"));
}
#[tokio::test]
async fn tier_delete_journal_recovery_has_a_hard_outer_timeout() {
let result = await_tier_delete_journal_recovery(
&CancellationToken::new(),
Duration::from_millis(10),
std::future::pending::<Result<()>>(),
)
.await
.expect("an elapsed timeout should return a recovery error")
.expect_err("a permanently pending recovery must time out");
assert!(result.to_string().contains("recovery timed out"), "{result}");
}
#[tokio::test]
async fn tier_delete_journal_recovery_drops_in_flight_work_on_shutdown() {
let cancel = CancellationToken::new();
cancel.cancel();
let result =
await_tier_delete_journal_recovery(&cancel, Duration::from_secs(30), std::future::pending::<Result<()>>()).await;
assert!(result.is_none(), "shutdown must cancel the in-flight recovery future");
}
}
@@ -13,11 +13,14 @@
// limitations under the License.
use std::sync::Arc;
#[cfg(test)]
use std::sync::Mutex;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::bucket::lifecycle::bucket_lifecycle_ops::enqueue_recovered_free_version;
use crate::disk::RUSTFS_META_BUCKET;
use crate::error::Result;
use crate::object_api::ObjectInfo;
@@ -30,11 +33,93 @@ use rustfs_filemeta::FileInfo;
pub const DEFAULT_FREE_VERSION_RECOVERY_LIMIT: usize = 1_000;
const DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT: usize = 10_000;
const BACKGROUND_WALKDIR_TIMEOUT: Duration = Duration::from_secs(60);
#[cfg(not(test))]
const BACKGROUND_WALK_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
#[cfg(test)]
const BACKGROUND_WALK_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(100);
type ObjectInfoOrErr = StorageObjectInfoOrErr<ObjectInfo, crate::error::Error>;
type WalkOptions = StorageWalkOptions<fn(&FileInfo) -> bool>;
fn recovery_walk_options(limit: usize, marker: Option<String>) -> WalkOptions {
WalkOptions {
include_free_versions: true,
limit,
marker,
// Total walk time scales with bucket size, so it is left unbounded
// (Duration::ZERO disables the wall-clock budget). Per-call progress
// stalls stay bounded by the drive-level stall budget inherited from
// `RUSTFS_DRIVE_WALKDIR_STALL_TIMEOUT_SECS`.
walkdir_timeout: Some(Duration::ZERO),
walkdir_stall_timeout: None,
..Default::default()
}
}
#[cfg(test)]
pub(super) enum RecoveryWalkTestAction {
SendItemsThenError(Vec<ObjectInfo>, crate::error::Error),
SendItemsThenHang(Vec<ObjectInfo>, Arc<tokio::sync::Notify>),
SendItemsUntilReceiverCloses(Arc<tokio::sync::Notify>),
ReturnError(crate::error::Error),
WaitForCancellation(Arc<tokio::sync::Notify>),
}
#[cfg(test)]
type RecoveryWalkTestHook = Box<dyn Fn(&str) -> Option<RecoveryWalkTestAction> + Send + Sync>;
#[cfg(test)]
static RECOVERY_WALK_TEST_HOOK: Mutex<Option<RecoveryWalkTestHook>> = Mutex::new(None);
#[cfg(test)]
static RECOVERY_BUCKET_LIST_WAIT_HOOK: Mutex<Option<Arc<tokio::sync::Notify>>> = Mutex::new(None);
#[cfg(test)]
pub(super) struct RecoveryWalkHookGuard;
#[cfg(test)]
impl Drop for RecoveryWalkHookGuard {
fn drop(&mut self) {
let mut hook = RECOVERY_WALK_TEST_HOOK
.lock()
.expect("recovery walk test hook lock should not poison");
*hook = None;
}
}
#[cfg(test)]
pub(super) fn set_recovery_walk_test_hook(
hook_fn: impl Fn(&str) -> Option<RecoveryWalkTestAction> + Send + Sync + 'static,
) -> RecoveryWalkHookGuard {
let mut hook = RECOVERY_WALK_TEST_HOOK
.lock()
.expect("recovery walk test hook lock should not poison");
*hook = Some(Box::new(hook_fn));
RecoveryWalkHookGuard
}
#[cfg(test)]
pub(super) struct RecoveryBucketListWaitHookGuard;
#[cfg(test)]
impl Drop for RecoveryBucketListWaitHookGuard {
fn drop(&mut self) {
let mut hook = RECOVERY_BUCKET_LIST_WAIT_HOOK
.lock()
.expect("recovery bucket-list test hook lock should not poison");
*hook = None;
}
}
#[cfg(test)]
pub(super) fn set_recovery_bucket_list_wait_hook(started: Arc<tokio::sync::Notify>) -> RecoveryBucketListWaitHookGuard {
let mut hook = RECOVERY_BUCKET_LIST_WAIT_HOOK
.lock()
.expect("recovery bucket-list test hook lock should not poison");
*hook = Some(started);
RecoveryBucketListWaitHookGuard
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FreeVersionRecoveryStats {
pub scanned: usize,
@@ -68,12 +153,22 @@ pub async fn recover_tier_free_versions(
limit: usize,
bucket_marker: Option<String>,
object_marker: Option<String>,
) -> Result<FreeVersionRecoveryStats> {
recover_tier_free_versions_with_cancel(api, limit, bucket_marker, object_marker, CancellationToken::new()).await
}
pub(super) async fn recover_tier_free_versions_with_cancel(
api: Arc<ECStore>,
limit: usize,
bucket_marker: Option<String>,
object_marker: Option<String>,
cancel_token: CancellationToken,
) -> Result<FreeVersionRecoveryStats> {
if limit == 0 {
return Err(std::io::Error::other("free-version recovery limit must be greater than zero").into());
}
let page = list_tier_free_versions(api, limit, bucket_marker.clone(), object_marker.clone()).await?;
let page = list_tier_free_versions(api, limit, bucket_marker.clone(), object_marker.clone(), cancel_token.clone()).await?;
let mut stats = FreeVersionRecoveryStats {
scanned: 0,
enqueued: 0,
@@ -87,8 +182,11 @@ pub async fn recover_tier_free_versions(
let mut retry_cursor = RetryCursor::new(bucket_marker, object_marker);
for oi in page.items {
if cancel_token.is_cancelled() {
return Err(tier_free_version_recovery_cancelled());
}
retry_cursor.visit(&oi);
if !record_recovered_free_version_enqueue(&mut stats, queue_recovered_free_version(oi).await) {
if !record_recovered_free_version_enqueue(&mut stats, enqueue_recovered_free_version(oi).await) {
let (bucket_marker, object_marker) = retry_cursor.retry_markers();
stats.truncated = true;
stats.next_bucket_marker = bucket_marker;
@@ -100,6 +198,18 @@ pub async fn recover_tier_free_versions(
Ok(stats)
}
fn tier_free_version_recovery_cancelled() -> crate::error::Error {
std::io::Error::new(std::io::ErrorKind::Interrupted, "tier free-version recovery cancelled").into()
}
fn tier_free_version_recovery_walk_shutdown_timed_out() -> crate::error::Error {
std::io::Error::new(
std::io::ErrorKind::TimedOut,
"tier free-version recovery walk did not stop after cancellation",
)
.into()
}
fn record_recovered_free_version_enqueue(stats: &mut FreeVersionRecoveryStats, queued: bool) -> bool {
stats.scanned += 1;
if queued {
@@ -111,10 +221,6 @@ fn record_recovered_free_version_enqueue(stats: &mut FreeVersionRecoveryStats, q
}
}
async fn queue_recovered_free_version(oi: ObjectInfo) -> bool {
crate::bucket::lifecycle::bucket_lifecycle_ops::enqueue_recovered_free_version(oi).await
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RetryCursor {
input_bucket_marker: Option<String>,
@@ -160,11 +266,12 @@ impl RetryCursor {
}
}
async fn list_tier_free_versions(
pub(super) async fn list_tier_free_versions(
api: Arc<ECStore>,
limit: usize,
bucket_marker: Option<String>,
object_marker: Option<String>,
cancel_token: CancellationToken,
) -> Result<FreeVersionRecoveryPage> {
let mut page = FreeVersionRecoveryPage {
items: Vec::new(),
@@ -179,21 +286,42 @@ async fn list_tier_free_versions(
return Ok(page);
}
let buckets = api.list_bucket(&BucketOptions::default()).await?;
let bucket_options = BucketOptions::default();
let list_buckets = async {
#[cfg(test)]
let wait_hook = RECOVERY_BUCKET_LIST_WAIT_HOOK
.lock()
.expect("recovery bucket-list test hook lock should not poison")
.clone();
#[cfg(test)]
if let Some(started) = wait_hook {
started.notify_one();
std::future::pending::<()>().await;
}
api.list_bucket(&bucket_options).await
};
tokio::pin!(list_buckets);
let buckets = tokio::select! {
biased;
_ = cancel_token.cancelled() => return Err(tier_free_version_recovery_cancelled()),
result = &mut list_buckets => result?,
};
let mut bucket_seen = bucket_marker.is_none();
let mut truncated_after: Option<RecoveryCursor> = None;
let walk_scan_limit = recovery_walk_scan_limit(limit);
for bucket in buckets {
if cancel_token.is_cancelled() {
return Err(tier_free_version_recovery_cancelled());
}
if bucket.name == RUSTFS_META_BUCKET {
continue;
}
if !bucket_seen {
if bucket_marker.as_deref() == Some(bucket.name.as_str()) {
bucket_seen = true;
} else {
if bucket_marker.as_deref().is_some_and(|marker| bucket.name.as_str() < marker) {
continue;
}
bucket_seen = true;
}
page.buckets_scanned += 1;
@@ -204,74 +332,181 @@ async fn list_tier_free_versions(
};
let (tx, mut rx) = mpsc::channel::<ObjectInfoOrErr>(100);
let cancel = CancellationToken::new();
let cancel = cancel_token.child_token();
let mut draining_after_truncation = false;
let mut drain_deadline = None;
let mut last_seen_object: Option<String> = None;
let mut scanned_objects = 0usize;
let walk = tokio::spawn({
let mut walk = tokio::spawn({
let api = api.clone();
let bucket_name = bucket.name.clone();
let object_marker = bucket_object_marker.clone();
let cancel = cancel.clone();
async move {
api.walk(
cancel,
&bucket_name,
"",
tx,
WalkOptions {
include_free_versions: true,
limit: walk_scan_limit,
marker: object_marker,
..Default::default()
#[cfg(test)]
let test_action = {
let hook = RECOVERY_WALK_TEST_HOOK
.lock()
.expect("recovery walk test hook lock should not poison");
hook.as_ref().and_then(|hook| hook(&bucket_name))
};
#[cfg(test)]
if let Some(action) = test_action {
match action {
RecoveryWalkTestAction::SendItemsThenError(items, err) => {
for item in items {
if tx
.send(ObjectInfoOrErr {
item: Some(item),
err: None,
})
.await
.is_err()
{
return Ok(());
}
}
let _ = tx
.send(ObjectInfoOrErr {
item: None,
err: Some(err),
})
.await;
return Ok(());
}
RecoveryWalkTestAction::SendItemsThenHang(items, started) => {
for item in items {
if tx
.send(ObjectInfoOrErr {
item: Some(item),
err: None,
})
.await
.is_err()
{
return Ok(());
}
}
started.notify_one();
return std::future::pending().await;
}
RecoveryWalkTestAction::SendItemsUntilReceiverCloses(started) => {
started.notify_one();
let mut index = 0usize;
loop {
if tx
.send(ObjectInfoOrErr {
item: Some(ObjectInfo {
bucket: bucket_name.clone(),
name: format!("nonrecoverable-{index:08}"),
..Default::default()
}),
err: None,
})
.await
.is_err()
{
return Ok(());
}
index = index.saturating_add(1);
}
}
RecoveryWalkTestAction::ReturnError(err) => return Err(err),
RecoveryWalkTestAction::WaitForCancellation(started) => {
started.notify_one();
cancel.cancelled().await;
return Err(tier_free_version_recovery_cancelled());
}
}
.with_walkdir_timeouts(BACKGROUND_WALKDIR_TIMEOUT),
)
.await
}
api.walk(cancel, &bucket_name, "", tx, recovery_walk_options(walk_scan_limit, object_marker))
.await
}
});
while let Some(item) = rx.recv().await {
let mut receive_error = None;
loop {
let item = tokio::select! {
biased;
_ = cancel_token.cancelled() => {
cancel.cancel();
receive_error = Some(tier_free_version_recovery_cancelled());
break;
}
_ = async {
if let Some(deadline) = drain_deadline {
tokio::time::sleep_until(deadline).await;
} else {
std::future::pending::<()>().await;
}
}, if drain_deadline.is_some() => {
receive_error = Some(tier_free_version_recovery_walk_shutdown_timed_out());
break;
}
item = rx.recv() => match item {
Some(item) => item,
None => break,
},
};
page.scanned_entries += 1;
if draining_after_truncation {
continue;
}
if let Some(err) = item.err {
cancel.cancel();
walk.await.map_err(|err| std::io::Error::other(err.to_string()))??;
return Err(err);
receive_error = Some(err);
break;
}
if draining_after_truncation {
continue;
}
let Some(oi) = item.item else {
continue;
};
record_scanned_object(&mut last_seen_object, &mut scanned_objects, &oi.name);
if let Some(cursor) = &truncated_after
&& cursor.object != oi.name
&& (cursor.bucket.as_str() != bucket.name.as_str() || cursor.object.as_str() != oi.name.as_str())
{
page.truncated = true;
cancel.cancel();
draining_after_truncation = true;
drain_deadline = Some(tokio::time::Instant::now() + BACKGROUND_WALK_SHUTDOWN_TIMEOUT);
continue;
}
if is_recoverable_tier_free_version(&oi) {
let cursor = RecoveryCursor {
let current_cursor = RecoveryCursor {
bucket: bucket.name.clone(),
object: oi.name.clone(),
};
page.items.push(oi);
page.next_bucket_marker = Some(cursor.bucket.clone());
page.next_object_marker = Some(cursor.object.clone());
page.next_bucket_marker = Some(current_cursor.bucket.clone());
page.next_object_marker = Some(current_cursor.object.clone());
if page.items.len() >= limit && truncated_after.is_none() {
truncated_after = Some(cursor);
truncated_after = Some(current_cursor);
}
}
}
walk.await.map_err(|err| std::io::Error::other(err.to_string()))??;
drop(rx);
let walk_shutdown_timeout = drain_deadline
.map(|deadline| deadline.saturating_duration_since(tokio::time::Instant::now()))
.unwrap_or(BACKGROUND_WALK_SHUTDOWN_TIMEOUT);
let walk_result = match tokio::time::timeout(walk_shutdown_timeout, &mut walk).await {
Ok(result) => result.map_err(|err| std::io::Error::other(err.to_string()))?,
Err(_) => {
walk.abort();
let _ = walk.await;
if let Some(err) = receive_error {
return Err(err);
}
return Err(tier_free_version_recovery_walk_shutdown_timed_out());
}
};
if let Some(err) = receive_error {
return Err(err);
}
walk_result?;
mark_scan_truncated_if_needed(&mut page, scanned_objects, walk_scan_limit, &bucket.name, last_seen_object.as_deref());
if page.truncated {
page.next_bucket_marker = Some(bucket.name.clone());
break;
}
}
@@ -462,6 +697,16 @@ mod tests {
);
}
#[test]
fn recovery_walk_disables_total_timeout_and_inherits_stall_timeout() {
let opts = recovery_walk_options(123, Some("marker".to_string()));
assert_eq!(opts.limit, 123);
assert_eq!(opts.marker.as_deref(), Some("marker"));
assert_eq!(opts.walkdir_timeout, Some(Duration::ZERO));
assert_eq!(opts.walkdir_stall_timeout, None);
}
#[test]
fn scan_truncation_keeps_marker_after_nonrecoverable_window() {
let mut page = FreeVersionRecoveryPage {
@@ -23,6 +23,7 @@ use crate::bucket::lifecycle::bucket_lifecycle_ops::ExpiryOp;
use crate::bucket::lifecycle::lifecycle::{self, ObjectOpts};
use crate::bucket::lifecycle::tier_delete_journal::persist_tier_delete_journal_entry;
use crate::client::signer_error::error_chain_contains_signer_header_marker;
use crate::services::tier::tier::{TierConfigMgr, TierDestinationId, TierOperationLease};
use crate::storage_api_contracts::lifecycle::TransitionedObject;
use crate::store::ECStore;
use rustfs_utils::get_env_usize;
@@ -247,6 +248,8 @@ impl ObjSweeper {
obj_name: self.remote_object.clone(),
version_id: self.transition_version_id.clone(),
tier_name: self.transition_tier.clone(),
backend_identity: None,
version_id_exact: false,
});
}
None
@@ -281,6 +284,8 @@ pub struct Jentry {
pub(crate) obj_name: String,
pub(crate) version_id: String,
pub(crate) tier_name: String,
pub(crate) backend_identity: Option<TierDestinationId>,
pub(crate) version_id_exact: bool,
}
impl ExpiryOp for Jentry {
@@ -312,6 +317,30 @@ async fn delete_object_from_remote_tier_raw(obj_name: &str, rv_id: &str, tier_na
return result;
}
let tier_config_mgr = runtime_sources::tier_config_mgr_handle();
delete_object_from_remote_tier_raw_with_manager(obj_name, rv_id, tier_name, &tier_config_mgr).await
}
async fn delete_object_from_remote_tier_raw_with_manager(
obj_name: &str,
rv_id: &str,
tier_name: &str,
tier_config_mgr: &Arc<tokio::sync::RwLock<TierConfigMgr>>,
) -> Result<(), std::io::Error> {
let lease = TierConfigMgr::acquire_operation_lease(&tier_config_mgr, tier_name)
.await
.map_err(std::io::Error::other)?;
delete_object_from_remote_tier_raw_with_lease(obj_name, rv_id, &lease, false).await
}
async fn delete_object_from_remote_tier_raw_with_lease(
obj_name: &str,
rv_id: &str,
lease: &TierOperationLease,
version_id_exact: bool,
) -> Result<(), std::io::Error> {
lease.validate_remote_version_id(rv_id)?;
if remote_delete_breaker_is_open(Instant::now()).await {
metrics::counter!(METRIC_DELETE_REMOTE_BREAKER_TOTAL).increment(1);
return Err(std::io::Error::other(ERR_REMOTE_DELETE_BREAKER_OPEN));
@@ -323,13 +352,11 @@ async fn delete_object_from_remote_tier_raw(obj_name: &str, rv_id: &str, tier_na
.map_err(|_| std::io::Error::other(ERR_REMOTE_DELETE_LIMITER_CLOSED))?;
let _inflight = RemoteDeleteInflightGuard::new();
let tier_config_mgr = runtime_sources::tier_config_mgr_handle();
let mut config_mgr = tier_config_mgr.write().await;
let w = match config_mgr.get_driver(tier_name).await {
Ok(w) => w,
Err(e) => return Err(std::io::Error::other(e)),
};
w.remove(obj_name, rv_id).await
if version_id_exact {
lease.remove_exact(obj_name, rv_id).await
} else {
lease.remove(obj_name, rv_id).await
}
}
#[cfg(test)]
@@ -341,6 +368,30 @@ fn run_remote_tier_delete_test_hook(obj_name: &str, rv_id: &str, tier_name: &str
.map(|hook| hook(obj_name, rv_id, tier_name))
}
#[cfg(test)]
pub(super) struct RemoteTierDeleteHookGuard;
#[cfg(test)]
impl Drop for RemoteTierDeleteHookGuard {
fn drop(&mut self) {
let mut hook = REMOTE_TIER_DELETE_TEST_HOOK
.lock()
.expect("remote tier delete test hook lock should not poison");
*hook = None;
}
}
#[cfg(test)]
pub(super) fn set_remote_tier_delete_test_hook(
hook_fn: impl Fn(&str, &str, &str) -> std::io::Result<()> + Send + Sync + 'static,
) -> RemoteTierDeleteHookGuard {
let mut hook = REMOTE_TIER_DELETE_TEST_HOOK
.lock()
.expect("remote tier delete test hook lock should not poison");
*hook = Some(Box::new(hook_fn));
RemoteTierDeleteHookGuard
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoteTierDeleteOutcome {
Deleted,
@@ -364,6 +415,38 @@ pub async fn delete_object_from_remote_tier_idempotent(
}
}
pub(crate) async fn delete_object_from_remote_tier_idempotent_with_manager_and_identity(
obj_name: &str,
rv_id: &str,
tier_name: &str,
backend_identity: TierDestinationId,
tier_config_mgr: &Arc<tokio::sync::RwLock<TierConfigMgr>>,
version_id_exact: bool,
) -> Result<RemoteTierDeleteOutcome, std::io::Error> {
let lease = TierConfigMgr::acquire_operation_lease_for_backend_identity(tier_config_mgr, tier_name, backend_identity)
.await
.map_err(std::io::Error::other)?;
delete_object_from_remote_tier_with_lease_idempotent(obj_name, rv_id, &lease, version_id_exact).await
}
pub(crate) async fn delete_object_from_remote_tier_with_lease_idempotent(
obj_name: &str,
rv_id: &str,
lease: &TierOperationLease,
version_id_exact: bool,
) -> Result<RemoteTierDeleteOutcome, std::io::Error> {
match delete_object_from_remote_tier_raw_with_lease(obj_name, rv_id, lease, version_id_exact).await {
Ok(()) => Ok(RemoteTierDeleteOutcome::Deleted),
Err(err) if is_remote_tier_not_found_error(&err) => Ok(RemoteTierDeleteOutcome::AlreadyRemoved),
Err(err) => {
if should_record_remote_delete_failure(&err) {
record_remote_delete_failure(&err, Instant::now()).await;
}
Err(err)
}
}
}
pub(crate) fn is_remote_tier_not_found_error(err: &std::io::Error) -> bool {
let message = err.to_string();
message.contains("NoSuchKey")
@@ -401,6 +484,8 @@ pub fn transitioned_force_delete_journal_entry(transitioned: &TransitionedObject
obj_name: transitioned.name.clone(),
version_id: transitioned.version_id.clone(),
tier_name: transitioned.tier.clone(),
backend_identity: None,
version_id_exact: false,
})
}
@@ -409,34 +494,14 @@ mod test {
use crate::client::signer_error::invalid_utf8_header_error;
use super::{
ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED, REMOTE_TIER_DELETE_TEST_HOOK, RemoteDeleteBreaker,
RemoteTierDeleteOutcome, delete_object_from_remote_tier_idempotent, is_remote_tier_not_found_error,
is_signer_header_error, should_record_remote_delete_failure,
ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED, RemoteDeleteBreaker, RemoteTierDeleteOutcome,
delete_object_from_remote_tier_idempotent, delete_object_from_remote_tier_idempotent_with_manager_and_identity,
is_remote_tier_not_found_error, is_signer_header_error, set_remote_tier_delete_test_hook,
should_record_remote_delete_failure,
};
use std::io::{Error, ErrorKind};
use std::time::{Duration, Instant};
struct RemoteTierDeleteHookGuard;
impl Drop for RemoteTierDeleteHookGuard {
fn drop(&mut self) {
let mut hook = REMOTE_TIER_DELETE_TEST_HOOK
.lock()
.expect("remote tier delete test hook lock should not poison");
*hook = None;
}
}
fn set_remote_tier_delete_test_hook(
hook_fn: impl Fn(&str, &str, &str) -> std::io::Result<()> + Send + Sync + 'static,
) -> RemoteTierDeleteHookGuard {
let mut hook = REMOTE_TIER_DELETE_TEST_HOOK
.lock()
.expect("remote tier delete test hook lock should not poison");
*hook = Some(Box::new(hook_fn));
RemoteTierDeleteHookGuard
}
#[test]
fn signer_header_error_detection_matches_utf8_failures() {
let err = Error::new(
@@ -506,6 +571,99 @@ mod test {
assert!(err.to_string().contains("driver not found"));
}
#[cfg(feature = "test-util")]
#[tokio::test]
async fn journal_delete_rejects_backend_identity_mismatch() {
let manager = crate::services::tier::tier::TierConfigMgr::new();
crate::services::tier::test_util::register_mock_tier(&manager, "WARM").await;
let lease = crate::services::tier::tier::TierConfigMgr::acquire_operation_lease(&manager, "WARM")
.await
.expect("test tier lease should be available");
let mut mismatched = lease.backend_identity();
mismatched[0] ^= 1;
drop(lease);
let err = delete_object_from_remote_tier_idempotent_with_manager_and_identity(
"remote/object",
"remote-version",
"WARM",
mismatched,
&manager,
false,
)
.await
.expect_err("journal recovery must fail closed when the tier name was rebound");
assert!(err.to_string().contains("identity no longer matches"));
}
#[cfg(feature = "test-util")]
#[tokio::test]
async fn journal_delete_dispatches_an_exact_version_constraint() {
let manager = crate::services::tier::tier::TierConfigMgr::new();
let backend = crate::services::tier::test_util::register_mock_tier(&manager, "WARM").await;
let lease = crate::services::tier::tier::TierConfigMgr::acquire_operation_lease(&manager, "WARM")
.await
.expect("test tier lease should be available");
let identity = lease.backend_identity();
drop(lease);
let outcome = delete_object_from_remote_tier_idempotent_with_manager_and_identity(
"remote/object",
"exact-version",
"WARM",
identity,
&manager,
true,
)
.await
.expect("an exact journal delete should reach the backend");
assert_eq!(outcome, RemoteTierDeleteOutcome::Deleted);
assert_eq!(backend.exact_remove_count(), 1);
assert_eq!(backend.remove_count().await, 1);
}
#[cfg(feature = "test-util")]
#[tokio::test]
async fn journal_delete_rejects_nonempty_remote_version_before_backend_io() {
let manager = crate::services::tier::tier::TierConfigMgr::new();
let backend = crate::services::tier::test_util::register_mock_tier(&manager, "WARM").await;
let lease = crate::services::tier::tier::TierConfigMgr::acquire_operation_lease(&manager, "WARM")
.await
.expect("test tier lease should be available");
let identity = lease.backend_identity();
drop(lease);
backend.set_reject_non_empty_remote_versions(true);
let err = delete_object_from_remote_tier_idempotent_with_manager_and_identity(
"remote/object",
"remote-version",
"WARM",
identity,
&manager,
true,
)
.await
.expect_err("a provider that rejects a versioned delete must fail before remote IO");
assert!(err.to_string().contains("requires an unversioned remote object"));
assert_eq!(backend.remove_count().await, 0);
delete_object_from_remote_tier_idempotent_with_manager_and_identity(
"remote/object",
"",
"WARM",
identity,
&manager,
false,
)
.await
.expect("unversioned remote delete should continue without a version ID");
assert_eq!(backend.remove_versions().await, vec![("remote/object".to_string(), String::new())]);
}
#[test]
fn breaker_opens_at_threshold_and_recovers_after_window() {
let mut breaker = RemoteDeleteBreaker::new(3, Duration::from_secs(30));
File diff suppressed because it is too large Load Diff
+43 -7
View File
@@ -649,7 +649,7 @@ impl BucketMetadata {
Ok(())
}
fn default_timestamps(&mut self) {
pub(crate) fn default_timestamps(&mut self) {
if self.policy_config_updated_at == OffsetDateTime::UNIX_EPOCH {
self.policy_config_updated_at = self.created
}
@@ -737,6 +737,9 @@ impl BucketMetadata {
}
BUCKET_TAGGING_CONFIG => {
self.tagging_config_xml = data;
// Drop the parsed form (like lifecycle above) so clearing the
// payload can't leave stale parsed tags to be cached.
self.tagging_config = None;
self.tagging_config_updated_at = updated;
}
BUCKET_QUOTA_CONFIG_FILE => {
@@ -1093,16 +1096,25 @@ pub async fn load_bucket_metadata(api: Arc<ECStore>, bucket: &str) -> Result<Buc
}
pub async fn load_bucket_metadata_parse(api: Arc<ECStore>, bucket: &str, parse: bool) -> Result<BucketMetadata> {
let mut bm = match read_bucket_metadata(api.clone(), bucket).await {
Ok(res) => res,
Ok(load_bucket_metadata_parse_with_presence(api, bucket, parse).await?.0)
}
/// The returned `bool` reports whether the metadata was actually read from
/// persisted storage; `false` means no metadata exists for this bucket on this
/// store and the returned value is a fabricated in-memory default.
pub(crate) async fn load_bucket_metadata_parse_with_presence(
api: Arc<ECStore>,
bucket: &str,
parse: bool,
) -> Result<(BucketMetadata, bool)> {
let (mut bm, persisted) = match read_bucket_metadata(api.clone(), bucket).await {
Ok(res) => (res, true),
Err(err) => {
if err != Error::ConfigNotFound {
return Err(err);
}
// info!("bucketmeta {} not found with err {:?}, start to init ", bucket, &err);
BucketMetadata::new(bucket)
(BucketMetadata::new(bucket), false)
}
};
@@ -1112,7 +1124,7 @@ pub async fn load_bucket_metadata_parse(api: Arc<ECStore>, bucket: &str, parse:
bm.parse_all_configs()?;
}
Ok(bm)
Ok((bm, persisted))
}
async fn read_bucket_metadata(api: Arc<ECStore>, bucket: &str) -> Result<BucketMetadata> {
@@ -1309,6 +1321,30 @@ mod test {
assert!(bm.lifecycle_config.is_none());
}
/// Companion to the lifecycle case above. `parse_all_configs` skips empty
/// XML rather than clearing, so without the explicit reset a cleared
/// tagging config would keep serving the previously parsed tags.
#[test]
fn tagging_update_config_clears_parsed_config_on_delete() {
let mut bm = BucketMetadata::new("test-bucket");
let tagging_xml = br#"<Tagging><TagSet><Tag><Key>env</Key><Value>prod</Value></Tag></TagSet></Tagging>"#;
bm.update_config(BUCKET_TAGGING_CONFIG, tagging_xml.to_vec())
.expect("tagging config should update");
bm.parse_all_configs().expect("tagging config should parse");
assert!(bm.tagging_config.is_some());
bm.update_config(BUCKET_TAGGING_CONFIG, Vec::new())
.expect("tagging config delete should update metadata");
assert!(bm.tagging_config_xml.is_empty());
assert!(bm.tagging_config.is_none());
// A re-parse must not resurrect them either.
bm.parse_all_configs().expect("cleared tagging should parse");
assert!(bm.tagging_config.is_none());
}
#[tokio::test]
async fn marshal_msg_complete_example() {
// Create a complete BucketMetadata with various configurations
+422 -32
View File
@@ -12,15 +12,17 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::metadata::{BucketMetadata, load_bucket_metadata};
use super::metadata::{BUCKET_TARGETS_FILE, BucketMetadata, load_bucket_metadata};
use super::quota::BucketQuota;
use super::target::BucketTargets;
use crate::bucket::bucket_target_sys::BucketTargetSys;
use crate::bucket::metadata::load_bucket_metadata_parse;
use crate::bucket::metadata::{load_bucket_metadata_parse, load_bucket_metadata_parse_with_presence};
use crate::bucket::utils::is_meta_bucketname;
use crate::disk::RUSTFS_META_BUCKET;
use crate::error::{Error, Result, is_err_bucket_not_found};
use crate::runtime::sources as runtime_sources;
use crate::storage_api_contracts::heal::HealOperations as _;
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
use crate::store::ECStore;
use futures::future::join_all;
use rustfs_common::heal_channel::HealOpts;
@@ -188,7 +190,7 @@ pub async fn get(bucket: &str) -> Result<Arc<BucketMetadata>> {
// instance cell is not initialized yet (early startup) they fall back to the
// ambient default — the single-instance legacy behavior.
fn bucket_metadata_sys_of(ctx: &crate::runtime::instance::InstanceContext) -> Result<Arc<RwLock<BucketMetadataSys>>> {
pub(crate) fn bucket_metadata_sys_of(ctx: &crate::runtime::instance::InstanceContext) -> Result<Arc<RwLock<BucketMetadataSys>>> {
if let Some(sys) = ctx.bucket_metadata_sys() {
return Ok(sys);
}
@@ -221,11 +223,64 @@ pub(crate) async fn remove_bucket_metadata_in(ctx: &crate::runtime::instance::In
pub async fn update(bucket: &str, config_file: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let _targets_guard = if config_file == BUCKET_TARGETS_FILE {
Some(acquire_bucket_targets_transaction_lock(bucket).await?)
} else {
None
};
let mut bucket_meta_sys = bucket_meta_sys_lock.write().await;
bucket_meta_sys.update(bucket, config_file, data).await
}
pub async fn update_bucket_targets_under_transaction_lock(bucket: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let mut bucket_meta_sys = bucket_meta_sys_lock.write().await;
bucket_meta_sys.update(bucket, BUCKET_TARGETS_FILE, data).await
}
/// Read-modify-write one bucket config file under the metadata system's
/// outer write guard.
///
/// `mutate` sees the freshly loaded on-disk metadata and returns the
/// replacement payload for `config_file` (empty clears it, like
/// [`delete`]). Both the read and the persisted write happen inside the
/// same guard that [`update`] uses, so within this process the rewrite can
/// neither clobber a concurrent update to another config file nor lose a
/// concurrent write to the same one — unlike caching a mutated clone of
/// previously read metadata.
///
/// This guard is process-local. Writers on other nodes still race, exactly
/// as they do for [`update`]: each rewrites the whole metadata file, so the
/// later save wins. What this narrows is the window — from "as stale as the
/// local cache" down to a single metadata read plus write.
pub async fn update_config_with<F>(bucket: &str, config_file: &str, mutate: F) -> Result<OffsetDateTime>
where
F: FnOnce(&BucketMetadata) -> Result<Vec<u8>> + Send,
{
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let _targets_guard = if config_file == BUCKET_TARGETS_FILE {
Some(acquire_bucket_targets_transaction_lock(bucket).await?)
} else {
None
};
let mut bucket_meta_sys = bucket_meta_sys_lock.write().await;
bucket_meta_sys.update_config_with(bucket, config_file, mutate).await
}
pub async fn acquire_bucket_targets_transaction_lock(bucket: &str) -> Result<rustfs_lock::NamespaceLockGuard> {
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let api = bucket_meta_sys_lock.read().await.object_store();
let lock = api
.new_ns_lock(RUSTFS_META_BUCKET, &bucket_targets_transaction_lock_key(bucket))
.await?;
Ok(lock.get_write_lock(crate::set_disk::get_lock_acquire_timeout()).await?)
}
fn bucket_targets_transaction_lock_key(bucket: &str) -> String {
format!("bucket-targets/{bucket}/transaction.lock")
}
pub async fn delete(bucket: &str, config_file: &str) -> Result<OffsetDateTime> {
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let mut bucket_meta_sys = bucket_meta_sys_lock.write().await;
@@ -396,9 +451,23 @@ pub async fn list_bucket_targets(bucket: &str) -> Result<BucketTargets> {
bucket_meta_sys.get_bucket_targets_config(bucket).await
}
/// Bound and lifetime of the negative cache for buckets with no persisted
/// metadata. Entries are invalidated the moment real metadata is cached, so
/// the TTL only bounds staleness for out-of-band creations whose reload
/// notification was lost; the capacity bounds memory under bogus-name floods.
const ABSENT_BUCKET_METADATA_TTL: Duration = Duration::from_secs(30);
const ABSENT_BUCKET_METADATA_MAX_ENTRIES: u64 = 10_000;
#[derive(Debug)]
pub struct BucketMetadataSys {
metadata_map: RwLock<HashMap<String, Arc<BucketMetadata>>>,
/// Buckets recently observed to have no persisted metadata. Serving the
/// fabricated default from here (instead of re-reading disk) keeps the
/// per-request cost of repeated lookups for such names bounded — without
/// this, every request naming a nonexistent bucket pays a namespace-lock
/// acquisition plus a full erasure-set metadata fanout (reachable
/// pre-auth via CORS preflight, and per-key in DeleteObjects).
absent_metadata: moka::future::Cache<String, ()>,
api: Arc<ECStore>,
initialized: RwLock<bool>,
}
@@ -407,11 +476,19 @@ impl BucketMetadataSys {
pub fn new(api: Arc<ECStore>) -> Self {
Self {
metadata_map: RwLock::new(HashMap::new()),
absent_metadata: moka::future::Cache::builder()
.max_capacity(ABSENT_BUCKET_METADATA_MAX_ENTRIES)
.time_to_live(ABSENT_BUCKET_METADATA_TTL)
.build(),
api,
initialized: RwLock::new(false),
}
}
pub(crate) fn object_store(&self) -> Arc<ECStore> {
self.api.clone()
}
pub async fn init(&mut self, buckets: Vec<String>) {
let _ = self.init_internal(buckets).await;
}
@@ -457,7 +534,7 @@ impl BucketMetadataSys {
},
)
.await;
load_bucket_metadata(self.api.clone(), bucket.as_str()).await
load_bucket_metadata_parse_with_presence(self.api.clone(), bucket.as_str(), true).await
});
}
@@ -465,9 +542,24 @@ impl BucketMetadataSys {
for (idx, res) in results.into_iter().enumerate() {
match res {
Ok(res) => {
Ok((bm, persisted)) => {
if let Some(bucket) = buckets.get(idx) {
self.set(bucket.clone(), Arc::new(res)).await;
if persisted {
self.set(bucket.clone(), Arc::new(bm)).await;
} else {
// A fabricated default (no persisted metadata
// readable right now) must never REPLACE an
// existing entry: the periodic refresh would
// otherwise downgrade a lock-enabled bucket to an
// authoritative "no lock" default on a transient
// ConfigNotFound, disabling the object-lock
// delete gate and wiping its target/durability
// sync state. Insert-if-vacant keeps the startup
// behavior for legacy buckets without a metadata
// file, atomically under the map write lock.
let mut map = self.metadata_map.write().await;
map.entry(bucket.clone()).or_insert_with(|| Arc::new(bm));
}
}
}
Err(e) => {
@@ -498,6 +590,8 @@ impl BucketMetadataSys {
let mut map = self.metadata_map.write().await;
map.insert(bucket.clone(), bm.clone());
drop(map);
// Real metadata supersedes any recorded absence immediately.
self.absent_metadata.invalidate(&bucket).await;
sync_bucket_target_sys(&bucket, &bm).await;
sync_bucket_durability(&bucket, &bm);
}
@@ -538,24 +632,7 @@ impl BucketMetadataSys {
return Err(Error::other("errServerNotInitialized"));
};
if is_meta_bucketname(bucket) {
return Err(Error::other("errInvalidArgument"));
}
let mut bm = match load_bucket_metadata_parse(store, bucket, parse).await {
Ok(res) => res,
Err(err) => {
if !runtime_sources::setup_is_erasure().await
&& !runtime_sources::setup_is_dist_erasure().await
&& is_err_bucket_not_found(&err)
{
BucketMetadata::new(bucket)
} else {
error!("load bucket metadata failed: {}", err);
return Err(err);
}
}
};
let mut bm = Self::load_bucket_metadata_for_update(store, bucket, parse).await?;
let updated = bm.update_config(config_file, data)?;
@@ -564,6 +641,49 @@ impl BucketMetadataSys {
Ok(updated)
}
/// See the free [`update_config_with`]: same load-mutate-persist cycle as
/// [`Self::update`], with the payload computed from the loaded metadata
/// instead of supplied up front. Loads through this system's own store so
/// the read and the persisted write target the same instance.
async fn update_config_with<F>(&mut self, bucket: &str, config_file: &str, mutate: F) -> Result<OffsetDateTime>
where
F: FnOnce(&BucketMetadata) -> Result<Vec<u8>> + Send,
{
let mut bm = Self::load_bucket_metadata_for_update(self.api.clone(), bucket, true).await?;
let data = mutate(&bm)?;
let updated = bm.update_config(config_file, data)?;
self.save(bm).await?;
Ok(updated)
}
/// Load a bucket's on-disk metadata as the base of a config rewrite.
/// Outside erasure setups a missing metadata file degrades to a fresh
/// default (legacy buckets without one); erasure setups fail instead of
/// fabricating state that a quorum may still hold.
async fn load_bucket_metadata_for_update(store: Arc<ECStore>, bucket: &str, parse: bool) -> Result<BucketMetadata> {
if is_meta_bucketname(bucket) {
return Err(Error::other("errInvalidArgument"));
}
match load_bucket_metadata_parse(store, bucket, parse).await {
Ok(res) => Ok(res),
Err(err) => {
if !runtime_sources::setup_is_erasure().await
&& !runtime_sources::setup_is_dist_erasure().await
&& is_err_bucket_not_found(&err)
{
Ok(BucketMetadata::new(bucket))
} else {
error!("load bucket metadata failed: {}", err);
Err(err)
}
}
}
}
async fn save(&self, bm: BucketMetadata) -> Result<()> {
if is_meta_bucketname(&bm.name) {
return Err(Error::other("errInvalidArgument"));
@@ -600,13 +720,22 @@ impl BucketMetadataSys {
pub async fn get_config(&self, bucket: &str) -> Result<(Arc<BucketMetadata>, bool)> {
let has_bm = {
let map = self.metadata_map.read().await;
map.get(&bucket.to_string()).cloned()
map.get(bucket).cloned()
};
if let Some(bm) = has_bm {
Ok((bm, false))
} else {
let bm = match load_bucket_metadata(self.api.clone(), bucket).await {
// A recent lookup already established there is no persisted
// metadata: serve the fabricated default without another
// namespace-lock + erasure-set fanout.
if self.absent_metadata.get(bucket).await.is_some() {
let mut bm = BucketMetadata::new(bucket);
bm.default_timestamps();
return Ok((Arc::new(bm), true));
}
let (bm, persisted) = match load_bucket_metadata_parse_with_presence(self.api.clone(), bucket, true).await {
Ok(res) => res,
Err(err) => {
return if *self.initialized.read().await {
@@ -617,13 +746,27 @@ impl BucketMetadataSys {
}
};
let mut map = self.metadata_map.write().await;
let bm = Arc::new(bm);
map.insert(bucket.to_string(), bm.clone());
drop(map);
sync_bucket_target_sys(bucket, &bm).await;
sync_bucket_durability(bucket, &bm);
// This lazy path caches only metadata that actually exists on
// this store. A fabricated default must not enter the map:
// `get()` is map-only and fail-closed — the object-lock delete
// gate (`object_lock_delete_check_required`) skips its per-object
// protection stat exactly when the map serves metadata saying the
// bucket has no Object Lock, so caching a fabricated default here
// would turn a metadata miss into an authoritative "no lock"
// answer. (Startup `concurrent_load` still caches fabricated
// defaults for buckets listed on disk — legacy buckets without a
// metadata file — but never lets one replace an existing entry.)
if persisted {
let mut map = self.metadata_map.write().await;
map.insert(bucket.to_string(), bm.clone());
drop(map);
sync_bucket_target_sys(bucket, &bm).await;
sync_bucket_durability(bucket, &bm);
} else {
self.absent_metadata.insert(bucket.to_string(), ()).await;
}
Ok((bm, true))
}
@@ -653,6 +796,8 @@ impl BucketMetadataSys {
if let Some(config) = &bm.policy_config {
Ok((config.clone(), bm.policy_config_updated_at))
} else if !bm.policy_config_json.is_empty() {
Ok((serde_json::from_slice(&bm.policy_config_json)?, bm.policy_config_updated_at))
} else {
Err(Error::ConfigNotFound)
}
@@ -843,13 +988,258 @@ impl BucketMetadataSys {
}
}
/// Test-only fixture shared with sibling modules (e.g. the quota checker
/// tests): a 4-disk `ECStore` on an isolated instance context, so tests
/// exercising the metadata system never touch ambient process state.
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
use crate::disk::endpoint::Endpoint;
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
use crate::runtime::instance::InstanceContext;
use crate::store::init_local_disks_with_instance_ctx;
pub(crate) async fn isolated_store_over_temp_disks() -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
let mut dirs = Vec::with_capacity(4);
let mut endpoints = Vec::with_capacity(4);
for disk_idx in 0..4 {
let dir = tempfile::tempdir().expect("tempdir should be created");
let mut endpoint =
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
endpoint.set_disk_index(disk_idx);
dirs.push(dir);
endpoints.push(endpoint);
}
let endpoint_pools = EndpointServerPools(vec![PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: 4,
endpoints: Endpoints::from(endpoints),
cmd_line: "metadata-sys-cache-test".to_string(),
platform: "test".to_string(),
}]);
let instance_ctx = Arc::new(InstanceContext::new());
init_local_disks_with_instance_ctx(&instance_ctx, endpoint_pools.clone())
.await
.expect("local disks should initialize");
let ecstore = ECStore::new_with_instance_ctx(
"127.0.0.1:0".parse().expect("test address"),
endpoint_pools,
CancellationToken::new(),
instance_ctx,
)
.await
.expect("ECStore should initialize");
(dirs, ecstore)
}
}
#[cfg(test)]
mod tests {
use super::test_support::isolated_store_over_temp_disks;
use super::*;
use crate::bucket::target::{BucketTarget, BucketTargetType, Credentials};
use serial_test::serial;
use tokio::time::timeout;
/// Pins the fail-closed caching contract of the lazy `get_config` path
/// and the refresh no-replace rule: fabricated defaults are returned but
/// never served by the map-only `get()`, persisted metadata is cached on
/// lazy load (superseding a recorded absence), and a refresh-load miss
/// never replaces an existing entry.
#[tokio::test]
async fn get_config_never_caches_fabricated_defaults_as_authoritative() {
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
let sys = BucketMetadataSys::new(ecstore);
// (a) Miss: the fabricated default is returned but not cached.
let (bm, _) = sys
.get_config("absent-bucket")
.await
.expect("fabricated default should be returned");
assert!(bm.object_lock_config_xml.is_empty());
assert!(
sys.get("absent-bucket").await.is_err(),
"a fabricated default must never be served by the map-only get()"
);
// The repeat lookup is served from the negative cache, same answer.
let (bm, _) = sys
.get_config("absent-bucket")
.await
.expect("negative-cached default should be returned");
assert!(bm.object_lock_config_xml.is_empty());
assert!(sys.get("absent-bucket").await.is_err());
// (b) Persisting real metadata supersedes the recorded absence, and a
// lazy reload after a map wipe re-caches it.
let mut persisted = BucketMetadata::new("absent-bucket");
persisted.policy_config_json = b"persisted-marker".to_vec();
sys.persist_and_set(persisted).await.expect("metadata should persist");
sys.metadata_map.write().await.clear();
let _ = sys
.get_config("absent-bucket")
.await
.expect("persisted metadata should lazily reload");
let cached = sys
.get("absent-bucket")
.await
.expect("lazily loaded persisted metadata must be cached");
assert_eq!(cached.policy_config_json, b"persisted-marker".to_vec());
// (c) A refresh-load miss (no persisted metadata readable) must not
// replace an existing entry.
let mut kept = BucketMetadata::new("kept-bucket");
kept.policy_config_json = b"kept-marker".to_vec();
sys.set("kept-bucket".to_string(), Arc::new(kept)).await;
let mut failed = HashSet::new();
let refresh_targets = vec!["kept-bucket".to_string()];
sys.concurrent_load(&refresh_targets, &mut failed).await;
let kept = sys
.get("kept-bucket")
.await
.expect("existing entry must survive a refresh miss");
assert_eq!(
kept.policy_config_json,
b"kept-marker".to_vec(),
"a fabricated refresh default must not replace real metadata"
);
}
#[tokio::test]
async fn get_bucket_policy_rejects_malformed_cached_policy() {
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
let sys = BucketMetadataSys::new(ecstore);
let mut metadata = BucketMetadata::new("malformed-policy");
metadata.policy_config_json = b"{".to_vec();
sys.set("malformed-policy".to_string(), Arc::new(metadata)).await;
let err = sys
.get_bucket_policy("malformed-policy")
.await
.expect_err("malformed persisted policy must not be treated as missing");
assert!(matches!(err, Error::Io(_)), "malformed persisted policy must surface its parse failure");
}
/// A tagging rewrite through `update_config_with` (the Swift metadata
/// POST path) is persisted: it survives a metadata reload from disk, and
/// an emptied rewrite clears the config in the cached copy too instead of
/// leaving stale parsed tags behind.
#[tokio::test]
async fn update_config_with_persists_tagging_rewrite_across_disk_reload() {
use crate::bucket::metadata::BUCKET_TAGGING_CONFIG;
use s3s::dto::Tag;
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
let mut sys = BucketMetadataSys::new(ecstore);
let bucket = "swift-tagging-bucket";
sys.persist_and_set(BucketMetadata::new(bucket))
.await
.expect("initial metadata should persist");
let tagging = Tagging {
tag_set: vec![Tag {
key: Some("swift-meta-color".to_string()),
value: Some("blue".to_string()),
}],
};
let xml = crate::bucket::utils::serialize::<Tagging>(&tagging).expect("tagging should serialize");
sys.update_config_with(bucket, BUCKET_TAGGING_CONFIG, move |bm| {
assert!(bm.tagging_config.is_none(), "rewrite must see the on-disk state");
Ok(xml)
})
.await
.expect("tagging rewrite should persist");
// Simulate the disk-truth reload that used to lose Swift writes: drop
// the cached entry and lazily re-load from the metadata file.
sys.metadata_map.write().await.clear();
let (tags, _) = sys
.get_tagging_config(bucket)
.await
.expect("tagging must survive a reload from disk");
assert_eq!(tags.tag_set.len(), 1);
assert_eq!(tags.tag_set[0].key.as_deref(), Some("swift-meta-color"));
assert_eq!(tags.tag_set[0].value.as_deref(), Some("blue"));
// An emptied rewrite clears the config everywhere.
sys.update_config_with(bucket, BUCKET_TAGGING_CONFIG, |bm| {
assert!(bm.tagging_config.is_some(), "rewrite must see the persisted tags");
Ok(Vec::new())
})
.await
.expect("clearing rewrite should persist");
assert_eq!(
sys.get_tagging_config(bucket).await.unwrap_err(),
Error::ConfigNotFound,
"cleared tagging must not be served from the cache"
);
sys.metadata_map.write().await.clear();
assert_eq!(
sys.get_tagging_config(bucket).await.unwrap_err(),
Error::ConfigNotFound,
"cleared tagging must not reappear after a reload from disk"
);
}
/// The load and the persisted write share one write guard, so concurrent
/// rewrites of the same config compose instead of clobbering each other.
/// Moving the load outside that guard loses all but the last tag.
#[tokio::test]
async fn concurrent_update_config_with_calls_do_not_lose_writes() {
use crate::bucket::metadata::BUCKET_TAGGING_CONFIG;
use s3s::dto::Tag;
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
let sys = Arc::new(RwLock::new(BucketMetadataSys::new(ecstore)));
let bucket = "swift-tagging-concurrent";
sys.read()
.await
.persist_and_set(BucketMetadata::new(bucket))
.await
.expect("initial metadata should persist");
const WRITERS: usize = 8;
let mut handles = Vec::with_capacity(WRITERS);
for idx in 0..WRITERS {
let sys = sys.clone();
handles.push(tokio::spawn(async move {
sys.write()
.await
.update_config_with(bucket, BUCKET_TAGGING_CONFIG, move |bm| {
// Each writer merges its own tag onto whatever is
// currently persisted — the Swift rewrite shape.
let mut tagging = bm.tagging_config.clone().unwrap_or_else(|| Tagging { tag_set: vec![] });
tagging.tag_set.push(Tag {
key: Some(format!("swift-meta-key{idx}")),
value: Some(idx.to_string()),
});
crate::bucket::utils::serialize::<Tagging>(&tagging).map_err(|e| Error::other(e.to_string()))
})
.await
}));
}
for handle in handles {
handle
.await
.expect("writer task should join")
.expect("rewrite should persist");
}
let (tags, _) = sys
.read()
.await
.get_tagging_config(bucket)
.await
.expect("tagging should be readable");
assert_eq!(tags.tag_set.len(), WRITERS, "every concurrent rewrite must survive: {tags:?}");
}
fn target(bucket: &str, id: &str) -> BucketTarget {
BucketTarget {
source_bucket: bucket.to_string(),

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