Compare commits

...

260 Commits

Author SHA1 Message Date
唐小鸭 f9eddf14b1 feat(obs): enhance observability with tracing spans and metrics integration 2026-07-19 22:03:03 +08:00
唐小鸭 e7e40007ab Add observability identities and operation spans 2026-07-14 15:22:20 +08:00
Zhengchao An f710f51687 fix(logging): rate limit the GetObject I/O queue congestion WARN (#4790) 2026-07-13 12:41:11 +08:00
Zhengchao An 6096bb189d fix(ecstore): demote reliable_rename NotFound WARN to debug (#4789) 2026-07-13 12:07:49 +08:00
Zhengchao An 535d672b1f fix(admin): report heal runtime state (#4786) 2026-07-13 11:02:13 +08:00
houseme 2e85709634 chore: refresh workspace lockfile (#4785)
* chore: refresh workspace lockfile

* chore: bump uuid to 1.23.5

* chore: bump pollster and path-absolutize
2026-07-13 01:03:01 +00:00
houseme 1553dc3f62 Address P2 follow-ups from the 2026-07-10..12 merged-PR review (backlog#1210-1220) (#4783)
* fix(obs): open cleaner compression source with O_NOFOLLOW

The compressor opened the source log via File::open, which follows a
symlink at the final path component. Between the scanner selecting a
regular file and this open, an attacker with write access to the log
directory could swap the entry for a symlink (TOCTOU) pointing at, say,
/etc/shadow, whose contents would then be copied into an archive. Open
the source with O_NOFOLLOW on Unix so such a swap fails with ELOOP; the
temp/archive path already refused symlinks, this closes the source side.

Refs rustfs/backlog#1210
Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs): recompress instead of trusting leftover cleaner archives

archive_header_ok only checked the first 2-4 magic bytes before treating
an existing .gz/.zst as a completed prior result and letting the caller
delete the source log. A file with valid magic but a truncated or forged
body passes that check, so an attacker with write access to the log
directory (or a crashed prior run) could plant such a stub and make the
cleaner delete the real log without ever producing a usable archive —
silent audit-data loss.

Chosen fix: stop trusting cross-process leftovers entirely and always
recompress the source in this pass, rather than fully decoding every
leftover to validate it. Full-decode validation would add real CPU cost
and decode-bug surface for a rare crash-recovery case; the existing
atomic create_new+rename already overwrites whatever sits at the archive
path (a planted symlink is replaced, never followed) with a freshly
written, fsync'd archive, so a partial/forged leftover can never gate
source deletion. This is the lowest-regression option.

Refs rustfs/backlog#1211
Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(object-data-cache): cap memory-gate reservation at cache growth headroom

The memory gate subtracts `admitted_since_refresh` from the snapshot's
available bytes so a burst arriving faster than the 5 s refresh cannot
over-allocate. That counter is GROSS: it only rolls over on the refresh and
never rolls back when a fill is later evicted, cancelled, or loses the
invalidation race. Under sustained high-throughput churn (net footprint flat
and far below `max_capacity`) the raw counter balloons past the memory the
cache actually holds, so `effective_available` collapses and the gate reports
false memory pressure — skipping the hottest fills with SkippedMemoryPressure
until the next 5 s refresh. This only lowers hit rate; it never returns wrong
data and self-heals each refresh.

Fix direction 1 (minimal regression): cap the reservation deduction at the
cache's own growth headroom (`max_capacity - weighted_size()`) instead of
letting the unbounded gross counter shrink the system-available budget. The
cache can never hold more than `max_capacity`, so a burst adds at most that
headroom of real memory before moka evicts to stay bounded (net-zero churn
beyond that point) — capping the deduction there keeps the reservation honest
without treating gross churn as growth. Chosen over net-accounting (direction
2, releasing bytes on every failure/cancel/eviction path) because that only
plugs the leak on failed fills and would not address the core defect: churn of
*successful* insert/evict fills over the 5 s window. It also touches only the
gate plus one call site rather than every failure path in moka_backend.

The cap only ever raises `effective_available`, so real memory pressure (a low
snapshot at refresh) still suppresses fills; when the cache is at capacity the
headroom is 0 and the deduction vanishes, correctly reflecting net-zero churn.
`MokaBackend` now stores `max_capacity` and passes the live headroom into
`allows_fill`. Adds targeted gate tests: gross churn far above headroom no
longer falsely suppresses, yet the reservation still bounds a burst while the
cache can genuinely grow.

Refs rustfs/backlog#1212
Co-Authored-By: heihutu <heihutu@gmail.com>

* test(ecstore): assert native O_DIRECT path runs in uring read test

uring_preserves_o_direct_for_eligible_reads only compared bytes through
LocalDisk::read_file_mmap_copy. On a filesystem that rejects O_DIRECT the
read silently degrades to the buffered StdBackend fallback and the byte
check still passes, so the test could go green without the native
read_at_direct path ever executing -- a vacuous pass.

Add a per-disk native_direct_reads counter on UringBackend, incremented
only when pread_uring_direct completes, and rebuild the test to drive a
real UringBackend's pread_bytes and assert the counter is non-zero (every
eligible read went through the native tier). When io_uring or O_DIRECT is
unavailable on the host filesystem (restricted CI runners, tmpfs), the
test skips loudly via eprintln instead of asserting a tautology, while
still checking byte-correctness on whatever tier served the read.

The counter also gives a gray release a positive signal that the O_DIRECT
tier is serving reads, not just a fallback count.

Refs rustfs/backlog#1213
Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(ecstore): warn + count read-time EINVAL on native O_DIRECT reads

classify_direct_read_error is only reached from the read side: the
O_DIRECT open in pread_uring_direct already succeeded (an open-time
refusal is handled earlier as DirectOpenError::ODirectRefused). So an
EINVAL/EOPNOTSUPP arriving here is a read-time error on an fd the kernel
accepted for O_DIRECT -- far more likely an alignment bug in the aligned
read path than an unsupported filesystem. The old code latched the disk's
native path off with only a once-per-disk debug trace, hiding a potential
correctness regression behind a silent buffered-read downgrade.

Diagnostics only: the fallback behaviour is unchanged (the native path is
still latched off and the caller still reads via StdBackend). This adds a
rustfs_io_uring_direct_read_einval_total counter and promotes the
once-per-disk trace from debug to warn so an operator can see an alignment
regression instead of an unexplained latency/CPU shift.

Refs rustfs/backlog#1214
Co-Authored-By: heihutu <heihutu@gmail.com>

* docs(ecstore): document data-blocks-first default and its tail-latency cost

DEFAULT_RUSTFS_GET_DATA_BLOCKS_FIRST_READER_SETUP is true and must stay
true: deferred-parity is the deliberate, already-rolled-out full-object
GET default from backlog#1159/#923. Flipping it back to false in code
would silently revert that rollout for every deployment that has not set
the env var, so this commit only documents -- no behaviour change.

The added notes explain what data-blocks-first does (schedule data shards
up front, engage parity lazily on a missing/corrupt data shard), the known
trade-off (parity is engaged late, so a slow-but-not-dead data drive
raises GET p99 because the faster parity shards are not raced against it
until a data shard is declared missing), and the operational rollback
switch (RUSTFS_GET_DATA_BLOCKS_FIRST_READER_SETUP=false), which is
intentionally an env override rather than a code default change.

No metric was added: the low-risk observability hook for "slow data drive
engaged deferred parity" would live at the deferred-stripe engage point,
which is out of this file's scope; this change stays documentation-only to
avoid touching the hot GET path.

Refs rustfs/backlog#1215
Co-Authored-By: heihutu <heihutu@gmail.com>

* docs(ecstore): document wide-directory walk stall hazard and tuning

list_dir enumerates a whole directory in one os::read_dir call (count =
-1), and the walk caller bounds that entire enumeration with the per-read
stall budget (default 5s) as if it were a single read. For a wide, flat
prefix -- one directory holding millions of immediate children -- a single
readdir can exceed the budget on a healthy disk, trip DiskError::Timeout,
and surface as a ListObjects 500 quorum failure though the drive is fine
(a #2999 sub-class).

This is documented, not rewritten: turning the one-shot readdir into a
streaming/batched enumeration that refreshes the stall deadline between
chunks is an architecture-level change with high regression surface
(ordering, the count contract, quorum merge) and belongs in a separate
follow-up. The supported mitigation today is operational, so the comments
point wide-directory deployments at RUSTFS_DRIVE_WALKDIR_STALL_TIMEOUT_SECS
and the high-latency drive-timeout profile, which widen the budget with no
code change. Notes were added at list_dir, the scan_dir call site, and
get_drive_walkdir_stall_timeout. No behaviour change.

Refs rustfs/backlog#1216
Co-Authored-By: heihutu <heihutu@gmail.com>

* docs(ecstore): document consumer-peek vs producer-stall coupling

In list_path_raw the consumer's peek_timeout is drawn from the same source
and same value (walkdir_stall_timeout, default 5s) as the producer-side
walk stall budget, but the two measure different things: the producer
stall bounds a single drive read, while the consumer peek bounds the gap
between two ADJACENT entries arriving from a reader. Because they share a
value, the consumer cannot wait meaningfully longer for the next entry
than the producer is allowed to spend producing one. Walking a region
dense with non-listable internal items can make a HEALTHY drive miss the
budget between visible entries; the consumer then declares it stalled and
detaches it, dropping a good drive from the merge and capping the "large
prefix succeeds" guarantee.

Documented, not decoupled: giving the consumer peek an independent,
strictly-larger budget would cut these false detaches but equally delays
detaching a genuinely dead drive and shifts listing tail-latency
semantics, so it wants soak data before changing the default. The comment
records the invariant any such follow-up must keep -- consumer peek >=
producer stall, never stricter -- so it can never fail a drive before the
producer would. No behaviour change.

Refs rustfs/backlog#1217
Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(io-metrics): add time-based trigger for low-IOPS latency percentiles

Percentiles were recomputed only every 128 IOs and seeded to 0, so a
low-traffic deployment exported p95/p99 = 0/stale for a long time after
startup. Add a 10s wall-clock trigger alongside the count throttle so the
first recompute can fire before 128 samples accrue. Hot-path per-op mean
update is unchanged.

Refs rustfs/backlog#1218
Co-Authored-By: heihutu <heihutu@gmail.com>

* test(e2e): cover codec-streaming parity under fault injection and NoSuchKey

The codec-streaming compat A/B previously ran only against a healthy
4-disk EC set with successful full GETs: the DiskFaultHarness was
constructed but never faulted, the error path was untested, and the
range assertion silently compared legacy-vs-legacy (ranges always fall
back to the duplex path), overstating what it proved.

Add two genuinely-failable scenarios reusing the existing harness and
fixtures:

- Parity reconstruction A/B: take one data disk offline and re-run the
  full object matrix on both phases while the EC 2+2 set rebuilds each
  large object from the surviving shards. Assert codec == legacy
  byte-for-byte (sha256) and header-for-header, and assert the codec
  phase served the reconstructed objects with zero duplex-pipe fallback
  (the reader gate is drive-health-independent, so the codec fast path
  is really exercised through reconstruction).
- NoSuchKey negative path: compare the HTTP status + S3 error code of a
  missing-key GET across the legacy and codec phases and require them to
  be identical (404/NoSuchKey), guarding against the codec env
  perturbing the error path.

Also clarify the range-phase comment so it is not misread as
codec-range correctness coverage: both sides are served by the same
legacy range path, so the assertion only proves ranges keep working and
keep falling back to legacy with the gates open.

Verified: cargo check/--no-run pass and the test passes locally
(1 passed; dup_codec=0 confirms the codec path ran).

Refs rustfs/backlog#1219
Co-Authored-By: heihutu <heihutu@gmail.com>

* ci(ecstore): exercise native O_DIRECT read path on an ext4 loopback

The uring-integration leg ran on the runner's default TMPDIR, which may sit
on tmpfs/overlayfs where open(O_DIRECT) fails and the native read_at_direct
path silently latches off to the aligned StdBackend fallback. Mount a
dedicated ext4 loopback and point TMPDIR at it so the real io_uring dep
(bumped git->0.1.0->0.2.0->0.2.1) and the native O_DIRECT read path are
actually covered rather than validated only by signature diffing.

Refs rustfs/backlog#1220
Co-Authored-By: heihutu <heihutu@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-12 16:03:28 +00:00
houseme 63b4568f85 fix(ecstore): reclaim orphan data dirs on the healthy heal path (#4781)
PR #4356 wired `reclaim_orphan_data_dirs` only into `heal_object`'s
post-heal tail, which runs after the `disks_to_heal_count == 0` early
return. That early return is exactly the state of the objects the sweep
targets: a valid `xl.meta` with all shards present plus a leaked
pre-#3510 data dir needs no shard healing, so a healthy heal returned
before reclaim and swept nothing. On a healthy deployment (single node,
no degraded disks) the reclaim was therefore dead code — an admin heal
walked the objects, "healed" them, and reclaimed no leaked space.

Run the best-effort reclaim on the `disks_to_heal_count == 0` path as
well, gated on `!opts.dry_run`. The shared match+log block is factored
into `reclaim_orphan_data_dirs_best_effort` so both exits behave
identically. A reclaim failure still never fails the heal.

Adds an end-to-end regression: put a healthy non-inline object, plant an
unreferenced UUID data dir under it on every disk that holds the object,
then drive `heal_object`. A dry-run heal must leave the stray in place; a
real heal must reclaim it while preserving the live data dirs, `xl.meta`,
and object contents. The test fails against the pre-fix control flow.

Refs #3231, #3191, #4356.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-12 14:19:47 +00:00
Zhengchao An 53c5cbed6e docs: update security advisory lessons (#4780) 2026-07-12 22:19:16 +08:00
Zhengchao An cf142e7fdd fix(ci): widen Days=5 expiration poll window to 8*lc_interval (#4779)
Follow-up to #4772. After forcing every-cycle ILM evaluation the Days=1
plateau is reliable, but test_lifecycle_expiration / test_lifecyclev2_expiration
still flaked intermittently on the *second* assertion (`assert 4 == 2`):
the Days=5 (expire3) objects were not expired within their poll window.

Cause: _wait_for_lifecycle_count for expire3 starts its N*lc_interval
deadline only after the Days=1 poll returns (~1 debug-day in). With N=5
and lc_interval == debug_day, the 5*debug_day terms cancel and the slack
past the Days=5 due time is only ~1 debug-day (~9s) -- which a single slow
scanner cycle (observed ~13s spacing under CI load) can exceed, leaving the
count stalled at 4. Bumping the two expire3 windows to 8*lc_interval raises
the slack to ~39s, comfortably above the observed cycle jitter.

Test-only, lane-scoped: does not touch RUSTFS_ILM_DEBUG_DAY_SECS or the
4*lc_interval < 5*debug_day plateau invariant. The expire3 target count (2)
is terminal (nothing expires after it), so a wider window can never
over-expire. Also documents the #4772 RUSTFS_DATA_USAGE_UPDATE_DIR_CYCLES=1
knob in lifecycle_behavior_tests.txt.
2026-07-12 14:15:05 +00:00
Zhengchao An 418f505a81 fix(ecstore): allow concurrent directory scans (#4778) 2026-07-12 13:17:50 +00:00
Zhengchao An f84ba243a1 chore(ci): guard against committed planning docs and remove re-added ones (#4777)
chore(ci): guard against committed planning docs; remove re-added ones

PR #4771 removed the docs/superpowers planning-doc archive and added a rule,
but #4765 force-added two more (git add -f bypasses .gitignore):
docs/superpowers/plans/2026-07-12-observability-single-writer.md and
docs/superpowers/specs/2026-07-12-observability-single-writer-design.md — an
agentic implementation plan and its design spec. Delete both.

Close the enforcement gap so this cannot recur:
- New scripts/check_no_planning_docs.sh fails if anything is tracked under
  docs/superpowers/, regardless of how it was added.
- Wire it into make pre-commit/pre-pr/dev-check.
- Run it in CI: ci.yml for code/mixed PRs, and ci-docs-only.yml (which was a
  green stub) so docs-only PRs can no longer slip a planning doc past the
  required 'Test and Lint' check.
- Document the guard in AGENTS.md and the arch-checks skill.
2026-07-12 20:19:06 +08:00
houseme 3b139e5267 fix(obs/cleaner): harden log cleaner durability, symlink safety, and retention (audit OLC-01..14) (#4776)
* fix(obs/cleaner): fsync archive and log dir before deleting source logs

OLC-01: the compression path flushed the BufWriter but never synced the
archive data or the parent directory before renaming, and the source was
then unlinked with no durability barrier. A crash after rename but before
the page cache reached disk could leave a truncated/zero-length archive
while the source was already gone — permanent log/audit data loss. Because
the archive is always renamed to a brand-new name (guaranteed by the
existing exists() guard), ext4 auto_da_alloc does not mask this.

Hand the underlying File back from the writer closure, sync_all() it before
rename, fsync the parent directory, and fsync the log directory after the
unlinks so a delete cannot be reordered ahead of the archive it justified.
Guard the temp file with an RAII cleanup so an early return or panic cannot
leak a *.tmp orphan.

Ref: rustfs/backlog#1194 (audit rustfs/backlog#1193)

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

* fix(obs/cleaner): create compression temp file with O_EXCL and O_NOFOLLOW

OLC-03: the temp archive was opened with File::create at a predictable
`<source>.gz.tmp` path with no O_EXCL/O_NOFOLLOW, so an actor with write
access to the log directory could pre-plant that path as a symlink and have
the compressor follow it — truncating and overwriting an arbitrary external
file, then chmod-ing it to the source log's mode. This mirrors the symlink
refusal already enforced on the deletion path (secure_delete).

Route temp creation through create_tmp_archive(), which uses create_new
(O_CREAT|O_EXCL) to refuse a pre-existing entry and, on Unix, O_NOFOLLOW to
refuse a symlink at the final path component.

Ref: rustfs/backlog#1196 (audit rustfs/backlog#1193)

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

* fix(obs/cleaner): create compression temp file with restrictive mode

OLC-06: File::create left the temp archive world-readable (0644 & ~umask)
for the entire duration of compressing a large log, exposing the full
plaintext of a possibly-0600 audit log on shared hosts until the mode was
copied only after the write completed. Pass the source mode into
create_tmp_archive and open the temp file with it (default 0600) so it is
restrictive from creation; the post-write chmod still tightens/matches the
source mode exactly.

Ref: rustfs/backlog#1199 (audit rustfs/backlog#1193)

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

* fix(obs/cleaner): validate existing archive before skipping recompression

OLC-02: the idempotency guard used Path::exists() (which follows symlinks)
and trusted whatever it found, then the caller deleted the source. A planted
`<archive>.gz` symlink, or a zero-length/truncated archive left by a crashed
run (OLC-01), would green-light deleting the source with no valid backup —
data loss / log destruction.

Replace exists() with symlink_metadata (no follow) and only treat the entry
as a completed prior result when it is a regular, non-empty file whose header
matches the codec magic (gzip 1f 8b / zstd 28 b5 2f fd). Anything else falls
through to recompression, whose atomic create_new+rename replaces the bad
entry (a symlink is replaced, never followed or deleted through).

Ref: rustfs/backlog#1195 (audit rustfs/backlog#1193)

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

* fix(obs/cleaner): stop dry-run overstating reclaimed bytes for compression

OLC-08: in dry-run, compress_with_writer returned output_bytes = 0, so
projected_freed_bytes = input and delete_files reported the full input as
freed. A real run keeps the archive on disk (freed = input - archive), so
dry-run overstated reclaim by the whole archive footprint. Estimate the
archive with a deliberately conservative ratio so the projection never
exceeds what a real run reclaims.

Ref: rustfs/backlog#1201 (audit rustfs/backlog#1193)

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

* fix(obs/cleaner): make freed-byte accounting resilient; document steal metric

OLC-12: input/output byte sizes were read via metadata().unwrap_or(0), which
silently reports 0 on failure and skews freed-byte metrics (input - 0 = full
input, overstating reclaim). Use the copy() byte count as the authoritative
input size and, when the archive metadata read fails, conservatively assume
no savings instead of 0. Also document that the steal_success_rate counts
only victim steals (batch = one success), so it reads as a relative
rebalancing signal, not absolute task acquisition.

Ref: rustfs/backlog#1205 (audit rustfs/backlog#1193)

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

* fix(obs/cleaner): preserve level-0 semantics, allow zstd 22, log effective levels

OLC-09: build() and the codec calls clamped gzip/zstd levels to [1,9]/[1,21],
silently rewriting gzip level 0 (store) and zstd level 0 (codec default) to 1
and blocking the legal zstd maximum of 22. Clamp to [0,9]/[0,22] so those
meanings survive, and echo the effective (post-clamp) levels in the startup
log via new effective_gzip_level()/effective_zstd_level() getters so the log
matches what actually runs.

Ref: rustfs/backlog#1202 (audit rustfs/backlog#1193)

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

* fix(obs/cleaner): bound compressed archives by byte cap; warn on retention=0

OLC-04: archive expiry was gated on compressed_file_retention_days > 0, so
retention=0 disabled it entirely while compression kept producing archives,
and max_total_size_bytes only ever bounded uncompressed logs — unbounded disk
growth. Replace select_expired_compressed with select_archives_to_delete,
which applies age expiry (when retention is on) and, regardless of retention,
trims the oldest archives until the set fits under max_total_size_bytes. Also
warn at startup when compression is on with retention=0 so the "keep forever"
semantics are not mistaken for "delete immediately".

Ref: rustfs/backlog#1197 (audit rustfs/backlog#1193)

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

* fix(obs/cleaner): warn on invalid exclude glob instead of dropping silently

OLC-05: build() dropped unparseable exclude globs via filter_map(...ok()), so
a typo (or a literal comma splitting a char-class in the config string) turned
"protect this file" into "delete this file" with no signal. Log a warning per
rejected pattern with the raw string and parse error so the misconfiguration
is visible.

Ref: rustfs/backlog#1198 (audit rustfs/backlog#1193)

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

* perf(obs/cleaner): backoff idle workers, cap worker count, lower small-host floor

OLC-11: the work-stealing loop re-spun on Steal::Retry with no yield and used
yield_now on the empty path, burning CPU during redistribution windows;
worker_count had no upper bound so a mis-set parallel_workers over a directory
of thousands of logs could spawn thousands of threads; and
default_parallel_workers forced >=4 workers even on 1-2 vCPU hosts. Use
crossbeam_utils::Backoff (spin->yield, reset on work) on the idle paths, clamp
worker_count to MAX_PARALLEL_COMPRESS_WORKERS, and lower the default floor to 1.

Ref: rustfs/backlog#1204 (audit rustfs/backlog#1193)

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

* fix(obs/cleaner): warn when active-file guard is disabled by empty filename

OLC-13 (defense-in-depth): the scanner protects the live log purely by exact
filename equality against active_filename. An empty active_filename silently
disables that protection, so a non-empty file_pattern could make the live log
a deletion candidate via the public builder. Warn in build() when that unsafe
combination is configured. The audit's "never delete the newest match"
structural guard is intentionally not implemented: it would conflict with the
legitimate keep_files=0 semantics (purge all rotated logs). The naming
contract is instead locked by regression tests (OLC-14).

Ref: rustfs/backlog#1206 (audit rustfs/backlog#1193)

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

* fix(obs/cleaner): warn on unknown algorithm/match_mode, echo match_mode

OLC-07: from_config_str silently fell back to defaults for unrecognized
compression algorithm and match mode (any non-"prefix" value became Suffix),
hiding operator typos like "prefixx" that could make the cleaner match no
rotated logs. Warn on a non-empty unrecognized value in both parsers, and
echo the resolved match_mode in the startup log alongside the algorithm.

Ref: rustfs/backlog#1200 (audit rustfs/backlog#1193)

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

* fix(obs/cleaner): derive orphan .tmp suffixes and exempt them from min age

OLC-10: orphan `*.gz.tmp`/`*.zst.tmp` cleanup was gated by min_file_age_seconds
(default 3600), so crash-left orphans lingered up to an hour, and the tmp
suffix list was hardcoded rather than derived from compressed_suffixes() — a
new codec would leave `*.<ext>.tmp` orphans the scanner never recognizes.
Derive the temp suffix from CompressionAlgorithm::compressed_suffixes(), and
gate orphan removal on a small fixed grace window instead of min_file_age
(orphans are never live-written after the rename that would promote them).

Ref: rustfs/backlog#1203 (audit rustfs/backlog#1193)

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

* test(obs/cleaner): cover symlink, archive expiry, idempotency, and edge cases

OLC-14: add regression tests for the previously-untested safety/correctness
branches — symlink rejection (external target never deleted), archive age
expiry vs fresh retention, archive byte-cap trim with retention disabled,
gz/zst classification, max_single_file_size selection, min_age protecting a
fresh non-empty log, active-file exclusion when the active name also matches
the pattern, invalid exclude glob not aborting build, dry-run + compression
creating no archive, gzip round-trip validity, and the idempotent-archive
branch trusting a valid prior archive.

Ref: rustfs/backlog#1207 (audit rustfs/backlog#1193)

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

* style(obs/cleaner): apply rustfmt and collapse nested if (clippy)

Formatting-only cleanup over the audit fix series: rustfmt normalization of the
multi-line expressions introduced in compress.rs/core.rs, plus collapsing the
delete_files directory-fsync into a single let-chain to satisfy
clippy::collapsible_if. No behavior change.

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

* refactor(obs/cleaner): collapse redundant source stats in compression path

The per-issue fixes to compress_with_writer accumulated three metadata()
syscalls on the source in the real compression path: an input_bytes read that
was immediately shadowed by the copied byte count (a dead read), a source_mode
read (OLC-06), and the pre-OLC-06 post-write chmod re-reading the same mode.
Collapse to a single fd-based read — move the dry-run input_bytes read into
the dry-run branch, read source_mode from the already-open fd (no path stat,
no TOCTOU), and reuse it for the post-write chmod. Behavior is unchanged
(same inode's mode, written for input size); 3 source stats -> 1.

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

* chore(obs/cleaner): fix typo flagged by CI (mis-set -> misconfigured)

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-12 12:01:52 +00:00
houseme 13bdca6762 build(toolchain): switch Rust channel to stable (#4775)
* Change Rust toolchain channel to stable

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

* style: apply clippy --fix and cargo fix lint suggestions

Run `cargo clippy --fix --all-targets --all-features` and
`cargo fix --lib --all-targets` across the workspace, then resolve the
remaining warnings by hand:

- collapse needless borrows in `format!` args, prefer `?` over explicit
  early returns, and use `.values()` / `.flatten()` iterator adapters
- rewrite the `Md5` scan loop via `manual_flatten` and re-indent the
  `select!` macro body (rustfmt skips macro interiors)
- annotate the intentional dead-code `Md5` inherent methods (constructed
  only by the test factory) with `#[allow(dead_code)]`

Behavior is unchanged.

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

---------

Signed-off-by: houseme <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-12 18:59:43 +08:00
houseme a766271246 chore(deps): update flake.lock (#4770)
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/d333699' (2026-07-02)
  → 'github:NixOS/nixpkgs/716c7a2' (2026-07-11)
• Updated input 'rust-overlay':
    'github:oxalica/rust-overlay/fe5aee0' (2026-07-04)
  → 'github:oxalica/rust-overlay/a286e5b' (2026-07-12)
2026-07-12 08:30:05 +00:00
cxymds d8e69a3adf fix(logging): enforce single-writer sinks and bound tracing (#4765)
* fix(obs): prevent rolling log stdout aliasing

* fix(ecstore): bound hot-path tracing payloads

* test(logging): guard service and disk log invariants

* fix(obs): silence useless_conversion on st_dev for Linux clippy

rustix's Stat.st_dev is u64 on Linux/glibc, making u64::try_from a no-op
that trips clippy::useless_conversion under -D warnings. The conversion is
still needed on macOS/BSD where st_dev is a signed dev_t, so suppress the
lint on that line rather than dropping the portable fallible conversion.

* fix(logging): keep stdout sink validation portable (#4769)

* fix(obs): keep stdout device conversion portable

* docs(logging): update single-writer plan status

---------

Co-authored-by: overtrue <anzhengchao@gmail.com>
Co-authored-by: houseme <housemecn@gmail.com>
2026-07-12 16:03:01 +08:00
houseme 2ddafb4ed9 test(ecstore): bound file sync probe waits (#4767)
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-07-12 16:01:09 +08:00
Zhengchao An 4c9431704c fix(ecstore): cancel orphaned listing walks (#4773) 2026-07-12 15:21:21 +08:00
Zhengchao An 71497ba39b fix(ci): evaluate ILM every scanner cycle in lifecycle behavior lane (#4772)
The s3-tests lifecycle expiration cases (test_lifecycle_expiration,
test_lifecyclev2_expiration, test_lifecycle_deletemarker_expiration)
flaked with counts stalling one plateau behind (e.g. `assert 6 == 4`).

Root cause: a compacted directory is only re-descended -- and its
objects re-evaluated against ILM rules -- once every
DATA_USAGE_UPDATE_DIR_CYCLES scanner cycles (default 16). At the lane's
accelerated RUSTFS_SCANNER_CYCLE=2 that is ~32s between evaluations, so
a Days=1 object due at debug_day (10s) is not actually expired until the
next ~32s boundary (~42s) -- just past the test's 4*lc_interval (40s)
poll window, so the list still returns the pre-expiry count.

Set RUSTFS_DATA_USAGE_UPDATE_DIR_CYCLES=1 for this debug-only lane so
compacted directories are re-descended every cycle and ILM fires within
~2s of the due time, comfortably inside the poll window. This does not
touch the 4*lc_interval < 5*debug_day plateau invariant.
2026-07-12 15:20:22 +08:00
Zhengchao An c4c198670d docs: remove agent-generated planning docs and forbid committing them (#4771)
docs: remove agent-generated planning docs, forbid committing them

Delete one-shot planning/progress artifacts that were checked into the tree:
the 14 superpowers plan/tracker docs under docs/superpowers/plans/, plus
issue-scoped implementation plans, optimization conclusions, and dated
benchmark-result snapshots under docs/ (issue-4003 ListObjectsV2 plans,
get-small-file conclusion, issue824/issue829 benchmark results, issue-713
>1GiB GET baseline summary and ops guide).

Codify the rule so they do not come back:
- .gitignore drops the docs/superpowers whitelist, so anything new under
  docs/ stays ignored unless force-added.
- AGENTS.md gains an explicit 'do not commit planning-type documents' rule
  scoping version control to the durable architecture/operations/testing sets.
- docs/architecture/README.md, overview.md, arch-checks SKILL.md, and
  check_doc_paths.sh drop their references to the removed archive.
2026-07-12 14:14:15 +08:00
Zhengchao An b235762fdb fix(ci): unblock e2e-s3tests startup and create disk dirs for distributed volumes (#4768)
The scheduled e2e-s3tests sweep failed at "Wait for RustFS ready" in both
topologies because the server never started (issue #4762).

Two independent startup faults, both surfaced now that the local
physical-disk-independence guard is enforced:

- single: RUSTFS_VOLUMES=/data/rustfs{0...3} all live on one physical
  device on the runner, so the guard aborts startup. Set the
  CI-sanctioned RUSTFS_UNSAFE_BYPASS_DISK_CHECK=true (what the guard's own
  error message and the e2e tests already use).

- multi: the entrypoint's process_data_volumes skipped every non-absolute
  entry, so the distributed URL form
  "http://rustfs{1...4}:9000/data/rustfs{0...3}" never created
  /data/rustfs0..3. LocalDisk init then aborts with VolumeNotFound because
  resolve_local_disk_root no longer auto-creates the disk root. This also
  broke the shipped .docker/compose/docker-compose.cluster.yaml for real
  distributed docker deployments.

entrypoint.sh now:
  1. Expands multiple {N...M} ranges per token (the URL form carries two).
     The previous single-pass expander collapsed it to "http://rustfs1"
     and dropped the disk path; it now re-scans until no ranges remain,
     operating on only the first brace to keep multi-range tokens intact.
  2. Creates the local filesystem path for URL-form endpoints (stripping
     scheme://host:port) without appending them as CLI args — rustfs reads
     the distributed form from RUSTFS_VOLUMES directly.

Absolute-path and default (/data) inputs expand byte-identically to before.
The multi compose also gets the CI disk-check bypass, since the four disks
share one device inside each node's container.
2026-07-12 13:41:46 +08:00
Zhengchao An e3533a4611 test(replication): cover version deletion convergence (#4764)
test(replication): cover version delete convergence
2026-07-12 13:07:32 +08:00
Zhengchao An 0ed0760fa2 fix(ci): restore lifecycle debug day to 10s to fix flaky expiration test (#4766) 2026-07-12 13:01:04 +08:00
Zhengchao An 5a4cf1d4b5 fix: repair flaky moka clear drain loop from #4759 (#4763)
fix(cache): drain pending removals until entry_count reaches zero in clear()

The previous clear() implementation used a single run_pending_tasks()
call after invalidate_all(), which was insufficient under concurrent
fill pressure — moka processes invalidations lazily in batches, so
entries can linger after a single maintenance pass.

Replace the fixed single call with a drain loop (up to 256 rounds) that
calls run_pending_tasks() and yields between iterations until
entry_count() reaches zero. This ensures the concurrency storm test
(moka_backend_concurrency_storm_leaves_no_leaked_state) passes reliably.

The earlier fix (#4759) added a pre-invalidate_all drain and an 8-pass
loop but reordered operations in a way that introduced a new race. This
commit keeps the original invalidate_all-first ordering and only adds
the drain loop after the initial run_pending_tasks() call.
2026-07-12 04:26:38 +00:00
houseme 46e43f608f feat(observability): add Grafana dashboard for the object data cache (#4761)
The GET body cache exports 11 `rustfs_object_data_cache_*` metrics but no
bundled dashboard visualized them. This adds one so operators can see the
cache's behaviour without hand-writing PromQL.

New `grafana-object-data-cache.json` (auto-provisioned from the dashboards
directory, `${DS_PROMETHEUS}`, schema 38) with 19 panels covering every metric:
hit ratio and lookup outcomes, plan decisions and cacheable ratio, fill
outcomes and fill-duration quantiles, hit-vs-fill byte throughput, entries and
weighted bytes vs capacity, in-flight fills, memory-pressure skips,
invalidations by reason/outcome, and size-class breakdowns. PromQL matches each
metric's type — rate() for counters, histogram_quantile() for the fill-duration
histogram, direct reads for gauges.

The observability README (EN + ZH) dashboard table gains a matching row.

Refs: backlog#1107

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-12 11:45:32 +08:00
Zhengchao An 631d93092e docs(obs): align OtelConfig doc defaults with actual constants (#4760)
Six doc-comments on `OtelConfig` fields stated defaults that disagreed
with the constants the runtime actually applies in
`extract_otel_config_from_env`. Correct them to match:

- profiling_export_enabled: false -> true (DEFAULT_OBS_PROFILING_EXPORT_ENABLED)
- sample_ratio: 0.1 -> 1.0 (SAMPLE_RATIO)
- meter_interval: 15 -> 30 (METER_INTERVAL)
- logger_level: info -> error (DEFAULT_LOG_LEVEL)
- log_rotation_time: daily -> hourly (DEFAULT_LOG_ROTATION_TIME), and
  document the full minutely/hourly/daily set plus the daily fallback
- log_cleanup_interval_seconds: 21600 -> 1800 (DEFAULT_OBS_LOG_CLEANUP_INTERVAL_SECONDS)

Doc-comment only; no behavior change.
2026-07-12 11:10:42 +08:00
Zhengchao An 3832f1d270 fix(cache): drain pending removals during clear (#4759) 2026-07-12 09:46:13 +08:00
Zhengchao An b540c7e2d0 test(ecstore): cover list marker key stripping (#4757) 2026-07-12 06:01:44 +08:00
Zhengchao An a5765274fc fix: auto-repair test_rename_data_shares_file_sync_limit hang on macOS (#4758)
fix(test): use canonicalized disk root for file_sync_probe in rename_data test

On macOS, tempfile::tempdir returns /var/folders/... while LocalDisk
resolves the root to /private/var/folders/... via dunce::canonicalize.
The file_sync_probe::enter() path check uses starts_with(), so passing
the non-canonical tempdir path caused the probe to never activate,
making wait_for_active() hang indefinitely.

Use disk.root (already canonicalized) for the probe instead.
2026-07-12 06:01:39 +08:00
Zhengchao An 676f2276b4 fix(replication): refresh targets after site endpoint edits (#4756)
* fix(replication): refresh targets after site endpoint edits

* fix(replication): serialize site bucket lifecycle
2026-07-12 05:03:17 +08:00
Zhengchao An af831bde4b fix(cache): drain entries before clear returns (#4751) 2026-07-12 04:32:21 +08:00
Zhengchao An b449af160c test(ci): stabilize lifecycle behavior checks (#4755) 2026-07-12 04:02:01 +08:00
houseme 5088a6cde4 perf(obs): trim per-collection-cycle waste in report_metrics (#4748)
`report_metrics` runs on every metrics collection cycle and did three things it
did not need to, for every metric, every cycle:

- interned `metric.name`/`metric.help` through a `Mutex<HashMap>` even when the
  `Cow` was already `Borrowed(&'static str)` (the common case for statically
  named metrics);
- re-ran `describe_*!` (which re-locks the recorder's metadata map) although the
  metadata never changes;
- allocated a fresh `Vec<(String, String)>`, cloning every label key and value,
  even though `metric.labels` is already `[(&'static str, Cow<'static, str>)]`.

Now: names/help resolve to `&'static str` without touching the intern cache when
already borrowed; each metric is described once (tracked in a `HashSet`); and the
recorder is fed `&metric.labels` directly, removing the per-cycle label clone.

No metric names, help, label keys/values, or emitted values change. Verified by
building rustfs-obs and running the report unit tests (the `metrics` macro
accepts the borrowed label slice directly).

Addresses rustfs/backlog#1185 (P3, report path).

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 17:46:13 +00:00
houseme 6886dca7d1 feat(ecstore): make GET codec-streaming a single rollout switch (backlog#1183) (#4752)
backlog#1183, staged rollout step. Simplify enabling the zero-duplex GET
codec-streaming fast path to a single switch — RUSTFS_GET_CODEC_STREAMING_ROLLOUT
(default "off") — now that body/header parity is proven (parity e2e net + bench
A/B on backlog#1183).

- Add a clean production rollout token "on" (aliases "full"/"production"); the
  legacy "internal"/"benchmark" tokens remain accepted.
- Flip DEFAULT_RUSTFS_GET_CODEC_STREAMING_ENABLE and the two ..._COMPAT_CONFIRMED
  defaults to true. They are retained as emergency kill-switches (set any to
  false to force the fast path off) but no longer gate enablement — the rollout
  switch does.

No production behavior change: with no env set the rollout switch defaults to
"off", so GET stays on the legacy duplex path exactly as before. Flipping the
hard default to on is deferred to a follow-up after a production soak.

Note (intentional semantics change): with the rollout switch opted in
("on"/"internal"/"benchmark"), codec streaming now activates without also
setting the two ..._COMPAT_CONFIRMED vars — compatibility is confirmed, so those
confirmations are baked in.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 17:36:58 +00:00
Ramakrishna Chilaka 028ba6a675 perf(ecstore): parallelize multipart shard syncing (#4734)
Bound large shard-directory syncs per disk and process while preserving small-directory and durability behavior.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-12 01:36:24 +08:00
houseme 633c131cef perf(io-metrics): cache handles for hot label-less metric emitters (#4750)
The `metrics` macros re-run the recorder's `register_*` on every emission — a
`RwLock` read, a name-key hash, and an `Arc` clone — even for a metric that
never varies its key. For the hot, label-less recorders on the per-IO path
(`record_data_transfer`, `record_io_latency`, `record_io_latency_p95/p99`,
`record_io_queue_congestion`) that lookup is pure overhead once observability is
on.

Add `counter_increment_cached!` / `gauge_set_cached!` / `histogram_record_cached!`
that resolve the handle once via `LazyLock` in production and reuse it. Under
`cfg(test)` they re-resolve on every call, because the `metrics` crate resolves
against a thread-local recorder that `with_local_recorder` swaps per test — a
process-global cached handle would bind to whichever recorder was active first
and break test capture. The macros only wrap FIXED (label-less) keys, and the
`metrics_enabled()` gate still short-circuits before any emission when disabled.

Verified: the only callers of these functions are the collector (io-metrics'
own cfg(test) tests, which re-resolve) and production code; no cross-crate test
captures them. rustfs-io-metrics builds on both cfg paths, 147 unit + 4 doctests
pass, clippy clean.

Addresses rustfs/backlog#1185 (P3, per-emission handle caching).

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-12 01:33:30 +08:00
houseme 89557a7ffe perf(ecstore): cache io_uring fallback root label (#4747)
`record_uring_fallback` is called at multiple sites in the io_uring read
path whenever a read falls back to `StdBackend`. It formatted
`self.root.display().to_string()` on every call, heap-allocating a
`String` from a `Path` that never changes after construction — pure
per-read waste when io_uring is degraded.

Cache the label once in `UringBackend::try_new` as a `String` field
(`root_label`) and clone it per emission. The metric name and the
`"root"` label value are unchanged; only the redundant `Path` formatting
is removed. The clone is a single alloc of an already-short string.

Refs: rustfs/backlog#1185

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 17:25:06 +00:00
houseme 5282c71f86 perf(io-metrics): make internode peer-health read-mostly with an RwLock (#4746)
`cluster_peer_should_bypass` is called before every internode RPC when the
offline-bypass feature is enabled (remote disk `get_client`, remote locker), and
it took a single process-global `Mutex<HashMap>` even for the overwhelmingly
common case of an online or unknown peer, which is read-only. That serialized
all concurrent internode client acquisitions on one lock.

Switch `CLUSTER_PEER_HEALTH` to a `RwLock`:
- the hot check takes a shared read lock and returns immediately for unknown or
  online peers, so concurrent RPCs no longer serialize;
- only an offline peer (rare) drops to the write lock to record a re-probe, with
  a re-fetch/re-check because the state can flip back online between releasing the
  read lock and taking the write lock;
- the write paths (dial reachable/unreachable) and the read-only
  `cluster_peer_is_offline` move to `write()`/`read()` accordingly.

Behavior is unchanged on every path (online/unknown -> not bypassed; offline ->
bypass with one re-probe per interval); `Instant::now()` now runs only on the
offline path. Poison recovery is preserved. All internode unit tests pass.

Addresses rustfs/backlog#1185 (P2).

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 17:10:35 +00:00
houseme 53bd4bb300 test(e2e): pin GET codec-streaming body/header parity vs legacy duplex (backlog#1183) (#4745)
backlog#1183 tracks flipping the default GET data path from the legacy
tokio::io::duplex double-copy to the zero-duplex codec-streaming fast path.
That flip is gated behind RUSTFS_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED /
..._HEADER_COMPAT_CONFIRMED, which need empirical evidence the two paths are
byte- and header-identical.

Add an e2e regression net that runs the same object matrix twice against the
same on-disk EC shards, changing only the codec-streaming env gates: phase A
(default) takes the legacy duplex path, phase B (gates opened) takes codec
streaming. It asserts byte-for-byte (sha256) and header-for-header equality
across inline / small / multi-block (1.5M/3M/5M+) / multipart objects, plus a
ranged GET (which falls back to legacy by gate design). Path confirmation is
not assumed: the legacy path logs "Created duplex pipe ..." per full GET, so
the test counts that marker per phase and asserts the codec phase created zero
duplex pipes, proving the fast path actually ran rather than silently falling
back to the path it is compared against.

To capture the child server's logs for that assertion, add an optional
RustFSTestEnvironment.capture_log_path (default None = inherit stdio,
backward compatible) that redirects the spawned server's stdout+stderr to a file.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 16:33:57 +00:00
Zhengchao An 763f246f8a test(ecstore): add shared MockWarmBackend test utility for lifecycle and tier tests (#4716)
* test(ecstore): extract shared MockWarmBackend into a test-util feature (backlog#1148 ilm-6)

The tier/lifecycle integration tests carried two byte-for-byte copies of an
in-memory WarmBackend mock — one in crates/scanner/tests and one in
rustfs/src/app — plus duplicated register_mock_tier and polling helpers. Both
implemented the same ecstore WarmBackend trait.

Consolidate them into ecstore behind a new `test-util` feature, exposed via the
`rustfs_ecstore::api::tier::test_util` facade:

- MockWarmBackend: in-memory WarmBackend with an operation log (for ordering
  assertions such as "local delete precedes remote remove") and fault injection
  (FaultConfig): unreachable, HTTP 5xx, credential rejection, injected latency,
  plus external_remove to simulate an out-of-band remote deletion.
- register_mock_tier / register_mock_tier_backend: register the mock into any
  TierConfigMgr handle (the global manager used by scanner tests or a
  per-instance one used by the app tests).
- xl.meta transition assertion helpers: read_transition_meta,
  assert_transition_meta_consistent (cross-shard consistency of the
  status/tier/remote-key/remote-version-id tuple plus free-version count), and
  free_version_count.
- polling helpers: wait_for_remote_absence, wait_for_object_count,
  wait_for_free_version_absence.

Both existing copies now consume this single definition; `rg 'struct
MockWarmBackend'` collapses to one. The feature is enabled only from
[dev-dependencies], so it never links into the production binary (resolver 3).

Designed for downstream ilm-8 (restore lifecycle) and ilm-11 (tier fault
injection matrix). Coordinates with #4706 (ilm-2), which adds op-logging to the
scanner mock — that op-logging is now part of this shared surface, so #4706
should rebase onto it.

Refs rustfs/backlog#1148 (ilm-6), rustfs/backlog#1155.

* test(ecstore): fix shared MockWarmBackend usage after main merge

- Access stored objects via MockWarmBackend::contains() instead of the now
  private inner objects map (fixes E0609 after the shared test-util refactor).
- Drop dead ReadCloser/ReaderImpl/DiskAPI imports and the unused
  transition_api test re-exports the mock extraction left behind.
- Reword the scanner/rustfs test-util dependency comments so they no longer
  embed the literal rustfs_ecstore:: path that trips the ECStore
  architecture-migration guard.
2026-07-12 00:20:45 +08:00
houseme ce7d3119b2 perf(io-metrics): throttle collector percentile sort off the per-IO path (#4744)
`MetricsCollector::record_io_operation` recomputed P50/P95/P99 on every disk IO
by taking a read lock, collecting up to `max_latency_samples` (1000) into a
`Vec<u128>` and fully sorting it — an O(n log n) alloc+sort per operation on the
GET read path (gated by stage metrics). Both consumers sample only periodically:
the autotuner reads `avg_io_latency_us` on its tuning tick, and the P95/P99
values are never read internally — they only feed OTEL export. Nothing needs
per-IO freshness.

Split the two costs:
- The window mean (stored in `avg_io_latency_us`, read by the autotuner) is now
  maintained in O(1) via a running sum kept in step with the window's push/pop,
  and refreshed on every op — no sort, no warm-up regression.
- The P95/P99 sort is throttled to once per `PERCENTILE_RECOMPUTE_INTERVAL`
  (128) operations. A bounded lag is invisible to the periodic autotuner tick and
  OTEL export while the per-op sort cost is amortised ~128x away.

Semantics are otherwise unchanged: same sliding window, same percentile indices,
same mean value. Tests updated — the percentile test forces a recompute to
exercise the math directly, and a new test asserts the mean tracks every op while
the percentiles only recompute at the interval boundary.

Addresses rustfs/backlog#1185 (P1).

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 16:19:12 +00:00
Zhengchao An 96e5699568 test(ilm): self-managed lifecycle expiry e2e + backdated-mtime helper (backlog#1148 ilm-3) (#4710)
Convert the two reliant lifecycle expiry tests from #[ignore] + a hardcoded
localhost:9000 server to self-managed RustFSTestEnvironment tests (random port,
isolated temp dir) and wire them into the PR e2e-smoke subset.

Time control is chosen per test and documented in-module:
- test_lifecycle_expiry_backdated_mtime: mod_time back-dating via the internal
  source-replication backdoor (new put_object_with_backdated_mtime helper) so a
  Days=1 rule is already due, with RUSTFS_ILM_PROCESS_TIME=1 shrinking the
  rounding boundary. Asserts the backdated matching-prefix object expires and a
  non-matching-prefix object with the same backdating survives (isolates the
  prefix filter). Proves the backdoor does not trip the replication gate.
- test_lifecycle_versioned_current_version_expiry_creates_delete_marker:
  RUSTFS_ILM_DEBUG_DAY_SECS=2 (ilm-5) accelerates the day length; asserts a
  latest delete marker is created, the original data version is retained and
  still readable by version id.
- test_lifecycle_zero_day_expiry: Days=0 immediate expiry; matching object
  deleted, non-matching survives.

All three use RUSTFS_SCANNER_CYCLE=1 so the scanner runs every second; tests poll
for the terminal state instead of sleeping fixed wall-clock. Ignore count for
reliant/lifecycle.rs goes 2 -> 0.

CI wiring: added a distinct 'test(/^reliant::lifecycle::/)' clause to the
existing profile.e2e-smoke default-filter in .config/nextest.toml (the single
sanctioned e2e wiring mechanism per crates/e2e_test/README.md; no new e2e job).

Refs rustfs/backlog#1148 (ilm-3), rustfs/backlog#1155
2026-07-11 15:40:29 +00:00
houseme 264b2dd480 perf(metrics): drop needless per-emission work on hot metric paths (#4743)
* perf(metrics): drop needless per-emission work on hot metric paths

Audit of the metrics hot paths surfaced four low-risk wins where emission did
work it did not need to:

- `record_file_cache_reclaim_success/error` (disk/local.rs) called `.to_string()`
  on `kind` (already `&'static str`) and on the `"ok"`/`"err"` literals, heap-
  allocating up to four `String`s per page-cache reclaim window — which runs per
  read-stream reclaim. The `metrics` macros accept `&'static str` label values
  directly, so pass them as-is.
- `record_read_repair_dedup` (set_disk/core/io_primitives.rs) likewise
  `.to_string()`-ed an already-`&'static str` `reason`.
- `SetDisks::get_object_reader` (set_disk/ops/object.rs) captured `Instant::now()`
  and emitted the `rustfs.lock.acquire.*` counter and histogram unconditionally
  on every GET, right beside an already-gated stage timer. Gate them behind
  `get_stage_metrics_enabled()` too, so an inactive observability config pays no
  per-GET clock read or recorder lookups.
- The per-response-body-chunk counter in server/http.rs re-ran the `counter!`
  registry lookup on every chunk (a streamed GET emits many). Resolve the
  label-less handle once into a `LazyLock<metrics::Counter>`; the global recorder
  is installed at startup before any response streams, so the cached handle binds
  to the final recorder.

No metric names or label values change. The only behavior change is that the
`rustfs.lock.acquire.*` GET-path metrics now follow the GET stage-metrics flag,
consistent with the neighbouring stage timings.

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

* perf(metrics): gate page-cache reclaim metrics behind metrics_enabled()

`record_file_cache_reclaim_success/error` run per read-stream reclaim window on
large-object reads and emitted unconditionally. When general metrics are
disabled the `counter!`/`histogram!` macros still construct three metric keys
per call for nothing. Skip the emission behind `rustfs_io_metrics::metrics_enabled()`,
matching how the io-metrics free functions self-gate. The serial reclaim-metrics
test now enables the flag (save/restore) alongside the existing stage gate.

Left ungated deliberately: `record_read_repair_dedup` (rare read-repair path,
and its non-serial test would need a global-flag toggle), and the HTTP body-chunk
counter (its cached handle already makes the disabled case a no-op increment).

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 15:27:15 +00:00
houseme e742a540a4 test(cache): guard the body-cache eligibility gate against deny-list regressions (#1146) (#4742)
`full_object_plaintext_len` decides whether a body-cache hook hit may serve
bytes in place of the erasure read. It is a fail-closed allow-list: it excludes
every read whose `ReadPlan::build` applies some other transform (ranged/part,
raw/data-movement, restore, encrypted, remote) with an early `return None`,
then returns a `Some(..)` length only for the whole-plaintext cases. A newly
added `ReadPlan` branch that nobody teaches this gate about falls through to
`None` and safely bypasses the cache. Flip it to a deny-list and the same new
branch silently serves bytes in the wrong representation — the backlog#1108 /
#1109 / #1146 class of bug.

The existing unit and e2e tests only cover the branches that exist today. This
adds `scripts/check_body_cache_whitelist.sh`, a structural guard wired into
pre-commit / pre-pr / dev-check and CI, that asserts every exclusion predicate
and a `return None` still precede the first `Some(..)`. Reordering a predicate,
dropping one, moving the positive return ahead of the gate, or renaming/removing
the function all fail; wording, formatting, and adding a new exclusion in the
same gate do not. Mutation-tested against all four regression shapes.

This machine-enforces the structural invariant that backlog#1146 was kept open
to guard by hand.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 14:51:24 +00:00
Zhengchao An fa27bef532 test: quarantine embedded delete flake (#4741) 2026-07-11 14:29:29 +00:00
houseme 1ab66e124c test(ecstore): pin the remaining io_uring fd-cache invariants (#1180) (#4739)
Close the three test-completeness items in rustfs/backlog#1180 that the earlier
hardening (rustfs/rustfs#4726, #4729) left unpinned; the other two of the five
(sharded cancel routing, bailout-handle error) already landed in rustfs/uring.

- rename_data end-to-end invalidation: drive the real `LocalDisk::rename_data`
  commit (non-inline part, so `invalidate_part_paths` is non-empty) and assert
  the destination part descriptor is dropped — not merely `rename_file`/`delete`.
  Both production `rename_data` call sites share this invalidation, so a
  "fix one copy, miss the other" regression is now caught. The test is
  non-vacuous: it seeds the cache, removes the on-disk data dir out of band (the
  cached fd keeps the old inode alive and clears the path for the directory
  rename `rename_data` performs), and asserts a read still returns the OLD bytes
  before the commit — which fails outright if the cache is off, so it cannot pass
  without a live cache.
- FD_CACHE_TTL backstop: an injected short TTL proves the cache self-evicts a
  descriptor with no explicit invalidation; a static check pins the 5s value.
- zero-length read bounds parity on the cache-HIT path: a `length == 0` read
  past EOF must be rejected identically to the miss path and StdBackend, pinning
  the #1173 fix against regression.

Refactors `FdCache::new` to delegate to a private `with_ttl(ttl)` helper so the
TTL backstop can be exercised with a short TTL instead of a multi-second wait.

Verified: `cargo test -p rustfs-ecstore` on Linux with real io_uring
(seccomp=unconfined, RLIMIT_NOFILE raised, RUSTFS_URING_TESTS_MUST_RUN=1 so a
degraded skip fails rather than passing vacuously).

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 13:50:48 +00:00
Zhengchao An 313d282880 fix(ecstore): gate gauge import on Linux (#4737) 2026-07-11 13:29:08 +00:00
houseme 793b2a06e2 perf(ecstore): snapshot per-IO env config on local disk read/write paths (#4736) 2026-07-11 13:26:26 +00:00
houseme 9d64d71bd7 fix(ecstore): reduce GET reader setup shard fanout (#4735) 2026-07-11 21:22:40 +08:00
houseme a8eed1c44d perf(io-metrics): add a global switch to gate general metric emission (#4733)
The io-metrics crate already had per-stage GET/PUT switches, but the request
headers/summaries and ~40 general recorders (I/O scheduler, bytes-pool,
zero-copy, bandwidth, system-resource, error/timeout/retry) emitted
unconditionally, paying their label allocations and arithmetic even when no
metric recorder is installed.

Add `METRICS_ENABLED: AtomicBool` (default false) with `set_metrics_enabled()`
/ `metrics_enabled()`, isomorphic to the existing stage switches, and gate:

- GET headers/summaries via `get_stage_metrics_enabled()`:
  record_get_object_request_start / _request_started / _request_result /
  _timeout / _completion / _total_duration_with_path, plus legacy
  record_get_object.
- PUT headers via `put_stage_metrics_enabled()`:
  record_put_object_request_start / _request_result, plus legacy
  record_put_object.
- 41 general recorders via `metrics_enabled()`.

Startup enables all three switches together under
`observability_metric_enabled()` (startup_observability.rs), with a passthrough
in startup_runtime_sources.rs. The global recorder is itself only installed
when observability metric export is on, so gating off is pure cost savings when
export is disabled and a no-op when it is enabled.

Deliberately NOT gated (would break function, not just observation): the EC
encode in-flight and GET buffered-bytes accounting guards
(add/remove_ec_encode_inflight_bytes, track_get_object_buffered_bytes) whose
values are read back by current_*; and the stateful sibling modules
(lock/deadlock/backpressure/autotuner/adaptive_ttl/collector/internode). The
console realtime metrics endpoint (/admin/v3/metrics) samples system state and
internode metrics directly, so it does not depend on the gated recorders.

Adds a toggle test exercising both the enabled and disabled paths, serialized
on the existing METRICS_FLAG_LOCK to stay robust under nextest's shared-process
model.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 12:11:54 +00:00
houseme c79366d42c refactor(ecstore): tidy io_uring read backend (docs + fd-cache handle) (#4732)
Two behavior-preserving cleanups to the io_uring local read backend that
accumulated as later work layered onto it:

- Reunite the `UringBackend` struct doc comment. The backlog#1145 fd-cache
  constants were inserted into the middle of the struct's doc block, so its
  opening sentences were orphaned onto `ENV_RUSTFS_IO_URING_FD_CACHE` and the
  struct itself was documented by a sentence fragment starting mid-clause with
  "through rustfs-uring's...". Move the opening back onto the struct and give
  the constant its own one-line doc.

- Fold the fd-cache handle and its lookup key into a single
  `Option<(&FdCache, FdKey)>` in `pread_uring`, so the get and the
  insert_if_fresh sites stop re-deriving `self.fd_cache.as_ref()` and
  re-matching presence. Semantics are identical, including the backlog#1176
  generation guard (`gen_at_open` snapshot before open, insert only when the
  generation is unchanged).

No functional change. The borrow/move pattern was validated against a
host-compilable reduction (the cfg(linux) path cannot be built from macOS);
CI compiles the Linux backend.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 12:08:47 +00:00
houseme 4f29dcdcec chore(deps): bump rustfs-uring to 0.2.1 (#4731)
chore(deps): bump rustfs-uring to 0.2.1 from crates.io

rustfs-uring 0.2.1 routes the driver's runtime diagnostics through `tracing`
with structured fields instead of `eprintln!` (rustfs/uring#13). No public API
change — UringDriver::probe_and_start_sharded, read_at, and read_at_direct keep
their signatures — and the read path / cancel-safety ownership model are
untouched, so this is a drop-in patch bump of the version requirement plus the
lockfile entry.

0.2.1 adds a `tracing` dependency; it is recorded in the rustfs-uring lock
entry. tracing is already in the workspace graph, so nothing new is pulled in.

The Cargo.lock change is scoped to the rustfs-uring package only; unrelated
lockfile drift is left for its own change.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 12:01:42 +00:00
houseme ffca98cdbf fix(ecstore): harden io_uring integration (#4726)
* fix(ecstore): close the fd-cache open-then-insert race with a generation guard (rustfs/backlog#1176)

pread_uring's miss path opened a descriptor on the blocking pool and only then
inserted it into the moka cache. moka's invalidations cover only entries present
at call time, so a heal/delete commit that invalidated between the open and the
insert could not stop the just-opened stale inode from being cached afterwards —
serving the pre-heal/pre-delete inode for up to the TTL and defeating the heal.

Add an invalidation generation to FdCache, bumped by invalidate_exact and
invalidate_under before they touch moka. The read path snapshots the generation
before opening and inserts via insert_if_fresh, which refuses the insert if the
generation moved during the open and, with a post-insert re-check, removes the
entry if an invalidation raced the insert itself. Reads that never miss are
unaffected.

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

* fix(ecstore): invalidate the fd cache on the primary object-delete paths (rustfs/backlog#1175)

The fd-cache invalidation contract was only wired into DiskAPI::delete,
rename_file and rename_data, but object deletion almost never goes through
LocalDisk::delete — DeleteObject(s) reach delete_version, delete_versions ->
delete_versions_internal, and delete_paths, all of which remove a version's data
dir (move_to_trash / rename_all staging) with no invalidation. A cached io_uring
descriptor kept the deleted part.N inode readable for up to the TTL, so a GET in
that window could still return deleted data.

Invalidate every cached fd under the removed data dir at each site: in
delete_version and delete_versions_internal the data_dir uuid and object path are
in hand (invalidate_cached_fds_under(volume, "{path}/{uuid}")); delete_paths
invalidates under each removed path. A later rollback that restores a data dir
just causes the next read to re-open it.

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

* fix(ecstore): close remaining fd-cache invalidation gaps (rustfs/backlog#1177)

Three residual paths could keep serving a stale descriptor:

- delete_volume removed the whole bucket tree (remove_dir_all/remove_dir) with no
  invalidation, and the cache-hit read path skips the volume-access check, so a
  cached fd kept a removed object readable. Add invalidate_cached_fds_for_volume
  (a per-volume moka predicate) and call it after the bucket is removed.

- A retired LocalDisk instance (renew_disk on reconnect builds a fresh one) kept
  its populated cache alive while still referenced by in-flight ops, so
  invalidations through the new instance never reached it. close() now clears the
  backend's cache via clear_cached_fds.

- rename_data's post-commit rollback (a commit-metadata fsync failure under
  strict durability) restored the old data dir without dropping fds cached during
  the committed window; the streaming branch now invalidates the dst part fds on
  those rollback paths. The inline branch's rollback runs inside spawn_blocking
  and is left to the TTL backstop.

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

* fix(ecstore): narrow the io_uring latch classes to match StdBackend (rustfs/backlog#1171)

The runtime degradation classification reused the probe-time restriction errnos,
which the driver's C7 contract explicitly warns against, so a single per-file
error could latch a whole disk off io_uring:

- is_io_uring_unsupported no longer includes EACCES: at read time on an
  already-open fd it is per-file (an LSM hooks security_file_permission on every
  read) and StdBackend hits the same denial, so falling back masks nothing and a
  full-disk latch would be wrong. ENOSYS and EPERM (seccomp/LSM applied after
  startup) remain. EOPNOTSUPP is now classified per-path by the caller.

- pread_uring_direct's read-error arm now mirrors StdBackend: an O_DIRECT-shape
  error (EINVAL/EOPNOTSUPP) latches only direct_uring.supported so eligible reads
  take StdBackend's aligned path, instead of over-latching the whole io_uring
  backend or never latching a read-side EINVAL at all.

- try_new only negative-caches genuine restriction-class probe failures in
  URING_UNSUPPORTED_DISKS; an unexpected (possibly transient) probe failure now
  falls back without latching, so the next reconnect re-probes.

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

* feat(ecstore): log when a disk latches io_uring off at runtime (rustfs/backlog#1172)

A probe-gated gray release was flying blind: the permanent per-disk `active`
latch flipped with no log and no metric, so the only message operators ever saw
was the startup "io_uring read backend enabled" line — which stayed true on
dashboards even after the very first read latched the disk back to StdBackend
forever.

Add latch_active_off, which flips the latch with `swap` and logs the true->false
transition exactly once at warn with a dedicated event constant, disk root, and
errno. Both the buffered and O_DIRECT read paths use it. A fallback/latch metric
counter and periodic export of the driver StatsSnapshot (cq_overflow,
cancel_already) remain as follow-ups that need rustfs_io_metrics plumbing.

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

* chore(audit): correct the stale rustfs-uring license-allow rationale (rustfs/backlog#1181)

The dependency-review allow said rustfs-uring is "pulled as a git dependency",
but ecstore now pins it from crates.io. Update the rationale and scope the allow
to the exact pinned version (pkg:cargo/rustfs-uring@0.1.0) so a future version
bump forces a conscious re-review of the license/provenance claim instead of
being waved through on an outdated justification.

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

* fix(ecstore): offload io_uring driver teardown off the tokio worker (rustfs/backlog#1170)

UringBackend held Arc<UringDriver> and had no Drop, so when the last LocalDisk
reference dropped in async context (disk reconnect via renew_disk, or shutdown),
UringDriver's own Drop ran on that thread — sending Shutdown and joining each
shard thread, which can block up to the bounded-drain timeout (5s) on a hung /
D-state disk, stalling a tokio worker.

Wrap the driver in ManuallyDrop (deref is transparent, so read call sites are
unchanged) and add a Drop that takes the Arc and, when a runtime is present,
drops it on a blocking thread so the potentially-blocking join never runs on a
runtime worker. Off-runtime it drops inline.

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

* fix(ecstore): restore StdBackend read parity on the uring paths (rustfs/backlog#1173)

Two byte-for-byte parity breaks against StdBackend on the io_uring read paths:

- A zero-length read on an fd-cache hit returned Ok(empty) without any bounds
  check, while StdBackend and the uring miss path return FileCorrupt for an
  offset past EOF. Fstat the cached descriptor on the length==0 path and match.

- reclaim_read_range fadvise(DONTNEED)'d the raw unaligned [offset, offset+len)
  range, but fadvise only drops fully-covered pages, so the head partial page
  stayed resident — whereas StdBackend's mmap path reclaims the page-aligned
  superset. Bitrot shards' 32-byte block headers keep offsets off page
  boundaries, so this diverged on the common case. Page-align the reclaim window
  to match the mmap path exactly.

(The third parity item from the audit — a failed reclaim fadvise failing the
read — is already parity: StdBackend's mmap path propagates the same fadvise
error with `?`, so no change is needed.)

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

* fix(ecstore): bound worst-case in-flight memory by chunking huge uring reads (rustfs/backlog#1174)

The driver's backpressure permits count operations, not bytes, and it zero-fills
a full-size buffer per op, so a single unbounded read could pin ~length bytes per
permit (128 permits x shards x up to ~2 GiB). ecstore passes a whole part's shard
range as one pread_bytes with no upstream chunking.

On the buffered path, split reads larger than URING_MAX_OP_LEN (128 MiB) into
sequential chunks, awaited one at a time, so worst-case in-flight memory is
bounded by permits x URING_MAX_OP_LEN per shard. The threshold is high enough
that ordinary shard reads keep the single-op, zero-copy fast path unchanged. The
O_DIRECT path (opt-in, alignment-constrained) is left for a follow-up.

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

* fix(ecstore): gate the io_uring fd cache on RLIMIT_NOFILE headroom (rustfs/backlog#1178)

The fd cache holds up to FD_CACHE_CAPACITY (512) descriptors per disk, but
try_new cannot know the disk count and nothing checked the process fd budget. On
a bare-metal / non-systemd run with the common 1024 soft RLIMIT_NOFILE, two disks
would already exhaust fds with EMFILE surfacing on reads and probes.

Check the soft limit at try_new: enable the cache only with ample headroom
(>= 16384), otherwise log a warning once and fall back to open-per-read. The
packaged systemd unit sets 1,048,576, so tuned deployments are unaffected.

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

* test(ecstore): make io_uring test skips visible and gate non-vacuity (rustfs/backlog#1179)

The ecstore io_uring tests degrade to a silent pass when io_uring is unavailable
(bare `return`s or plain eprintlns), so a CI leg on a restricted runner never
exercises the real UringBackend/FdCache/latch paths yet still goes green — an
integration regression could merge unseen.

Add uring_test_skip: it emits a grep-able `SKIP <name>` line and, when
RUSTFS_URING_TESTS_MUST_RUN is set (a CI leg that guarantees io_uring, e.g. a
seccomp=unconfined container), panics instead of skipping. Route the silent-skip
sites through it. Wiring a dedicated CI leg that sets that env on a capable
runner is tracked in the issue; this provides the enforcement mechanism.

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

* test(ecstore): cover delete_paths fd-cache invalidation (rustfs/backlog#1180)

Add an end-to-end test that seeds the descriptor cache with a read, removes the
part via disk.delete_paths (one of the primary object-delete entry points that
does not go through LocalDisk::delete), and asserts the next read no longer
returns the removed inode — pinning the invalidation added in #1175. The sharded
cancel-routing half of #1180 is covered in the rustfs-uring PR.

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

* io_uring audit follow-ups: O_DIRECT chunking, inline invalidation, metrics, CI leg (backlog#1160) (#4729)

* fix(ecstore): chunk large O_DIRECT reads too, bounding in-flight memory (rustfs/backlog#1174)

The buffered read path already splits reads above URING_MAX_OP_LEN into
sequential chunks; do the same for the O_DIRECT path, which was left for a
follow-up. read_at_direct aligns each chunk's sub-range internally, and chunk
sizes are a multiple of URING_MAX_OP_LEN so a boundary re-read is at most one
block. Extract classify_direct_read_error so the single-op and chunked paths
share one copy of the EINVAL/EOPNOTSUPP-vs-subsystem latch classification rather
than duplicating it.

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

* fix(ecstore): invalidate cached fds on the inline rename_data rollback (rustfs/backlog#1177)

The streaming rename_data branch invalidates cached part fds on its post-commit
rollback paths, but the inline branch runs its commit and rollback inside a
single spawn_blocking closure where the async invalidate cannot be called, so it
was left to the TTL backstop.

Capture the closure's result instead of `??`-propagating it: on error (a
commit-metadata fsync failure under strict durability rolls the committed rename
back), invalidate the dst part paths at the async level before returning. Inline
objects keep their data in xl.meta rather than separate part inodes, so this is
largely defensive, but it removes the caveat and keeps the two branches
consistent.

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

* feat(ecstore): export io_uring latch/fallback and driver stats metrics (rustfs/backlog#1172)

Complete the gray-release observability. Beyond the warn log added earlier, emit
metrics so a dashboard can answer "how much traffic is on io_uring vs falling
back, and is any disk degrading":

- rustfs_io_uring_latch_off_total — a disk latching io_uring off at runtime.
- rustfs_io_uring_read_fallback_total — each io_uring -> StdBackend read
  fallback (latched-off short-circuit, O_DIRECT error, buffered error).
- a low-frequency per-disk exporter of the driver StatsSnapshot as gauges
  (in_flight, cq_overflow, cancel_already), spawned in try_new. It holds only a
  Weak reference so it never keeps the driver alive, and drops any temporary
  strong reference on the blocking pool so a last-reference UringDriver::Drop
  join never runs on an async worker (rustfs/backlog#1170).

submit_errors is deliberately not exported yet: it is a field added in the
unreleased rustfs-uring 0.2.0, and ecstore still pins 0.1.0. It lands once the
dependency is bumped (rustfs/backlog#1181).

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

* ci: add a real-io_uring integration leg on ubuntu-latest (rustfs/backlog#1179)

The existing self-hosted sm-standard runners cannot guarantee io_uring is
available (a container seccomp filter can block io_uring_setup), so the ecstore
uring tests degrade to a silent skip and never exercise the real
UringBackend/FdCache/latch paths in CI.

Add a job on GitHub-hosted ubuntu-latest, which runs a recent kernel with no
container seccomp filter, running the uring-named ecstore tests with
RUSTFS_IO_URING_READ_ENABLE=true and RUSTFS_URING_TESTS_MUST_RUN=1 — the
non-vacuity gate makes the leg fail rather than skip if io_uring is unavailable,
so an integration regression can no longer merge green behind a vacuous pass.

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

---------

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 11:15:29 +00:00
Zhengchao An 3f1acfe8a7 fix(docker): warn instead of rejecting default or missing credentials (#4728)
* fix(docker): warn instead of rejecting default or missing credentials

The entrypoint hard-reject from #4278 broke the container-first UX and
the repo's own scheduled e2e lanes (e2e-s3tests, mint boot rustfs-ci
images with default credentials and died at startup). Maintainer
decision: ship no baked-in credentials, warn instead of block.

- missing credentials: warn and start; wording accounts for the
  RUSTFS_ROOT_*/MINIO_* alias sources the entrypoint does not inspect
- default rustfsadmin via env/CLI/file: warn and start; the warning
  notes all-default pairs cannot derive an internode RPC secret
- malformed config stays fatal: source conflicts, unreadable files,
  empty or whitespace-only values, flags missing their argument
- present-but-empty env vars now hit the empty-value hard failure
  instead of running the binary with an empty root credential
- empty/default checks trim CR and blanks like the binary; files
  without a trailing newline are no longer falsely rejected as empty
- the no-baked-credentials guard covers all four Dockerfiles, and the
  test harness refuses hosts where /usr/bin/rustfs exists
- e2e-s3tests/mint move to non-default credentials (rustfsadmin-ci),
  which also restores RPC-secret derivation for the multi-node lane

GHSA-68cw fail-closed RPC derivation (#4402) is untouched; helm stays
fail-closed by design.

* chore(docker): reword entrypoint comment flagged by typos check
2026-07-11 19:13:04 +08:00
houseme ab60244d7d chore(deps): bump rustfs-uring to 0.2.0 (#4730)
chore(deps): bump rustfs-uring to 0.2.0 from crates.io

rustfs-uring 0.2.0 is published on crates.io. It carries the read-driver
hardening from the backlog#1160 adversarial audit (cancel-safety and
graceful-degradation fixes on the Linux io_uring read path). The public
API ecstore consumes is unchanged — UringDriver::probe_and_start_sharded,
read_at, read_at_direct all keep their 0.1.0 signatures — so this is a
drop-in bump of the version requirement plus the lockfile entry.

The Cargo.lock change is intentionally scoped to the rustfs-uring package
only; unrelated lockfile drift is left for its own change.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 11:12:15 +00:00
Zhengchao An 67a88f3feb test(replication): cover multipart fanout integrity (#4727) 2026-07-11 09:07:54 +00:00
Zhengchao An bec5227018 test(e2e): add negative presigned URL SigV4 coverage (#4714)
test(e2e): negative presigned-URL SigV4 suite (backlog#1151 sec-2)

Add crates/e2e_test/src/presigned_negative_test.rs, the query-string
SigV4 sibling of the header-SigV4 suite (sec-1, #4708). Presigned URLs
were only exercised on the happy path, so nothing pinned our end-to-end
enforcement of expiry or query-signature verification.

Against a live single-node RustFSTestEnvironment (random port, isolated
temp dir), the suite asserts HTTP status + S3 error <Code> for:

  - expired presigned GET -> 403 AccessDenied (Request has expired)
  - tampered X-Amz-Signature -> 403 SignatureDoesNotMatch
  - wrong secret key -> 403 SignatureDoesNotMatch
  - tampered target key (swapped after signing) -> 403 SignatureDoesNotMatch
  - tampered presigned PUT -> 403 SignatureDoesNotMatch + object not stored
  - positive controls: valid presigned GET (body match) and PUT (HEAD verify)

Expiry is controlled without real waiting via the AWS SDK presigner's
start_time (sign as of one hour ago with a 60s window). s3s checks
expiry before the signature, so the expired case surfaces AccessDenied.

Wired into the e2e-smoke nextest profile (fast, single-node, no external
deps) so it runs on every PR per the crates/e2e_test/README.md admission
criteria.

Refs rustfs/backlog#1151 (sec-2), rustfs/backlog#1155.
2026-07-11 16:07:41 +08:00
Zhengchao An d6e3aa9140 test(admin-auth): add unit and e2e coverage for the admin authorization gate (#4717)
test(admin-auth): unit + e2e coverage for the admin authorization gate

Adds the first tests for the central admin authorization gate
`rustfs/src/admin/auth.rs`, which previously had zero coverage
(backlog#1151 sec-4, master plan #1155).

Unit tests (rustfs/src/admin/auth.rs):
- Refactor the two `validate_admin_request*` entry points to share a
  single generic decision core `evaluate_admin_actions<S: Store>` /
  `check_admin_request_auth<S: Store>` (removes the duplicated
  action-loop and makes the gate testable without a running cluster).
- Cover: owner/root credential allowed; authenticated non-admin
  credential denied with AccessDenied; missing credential denied; and
  the multi-action loop grants on any permitted action, denies when
  none pass. Backed by an in-memory empty IamSys so the owner path
  short-circuits to allow and every other principal resolves to deny.

E2E (crates/e2e_test/src/admin_auth_test.rs, raw SigV4 over HTTP, no
awscurl dependency):
- non_admin_credential_denied_on_admin_api: a limited IAM user gets
  403 AccessDenied on GET /rustfs/admin/v3/info while root succeeds.
- root_credential_rotation_takes_effect: restart with rotated
  --access-key/--secret-key; new credential works and the stale one is
  rejected, on both the admin plane and the S3 data plane.
- default_credentials_emit_startup_warning: booting with the default
  rustfsadmin credentials emits the default-credentials warning.

Refs rustfs/backlog#1151 (sec-4), #1155.
2026-07-11 16:07:36 +08:00
houseme 2ebe8e561b fix(replication): allow loopback replication targets under an explicit test opt-in (#4725)
* fix(replication): allow loopback replication targets under an explicit test opt-in

Commit 5c7c757a3 (#4712) activated the previously-dormant replication e2e
suite (they had never run anywhere). All 9 fast tests then failed on main
because the SSRF egress guard rejects the 127.0.0.1 targets the e2e harness
configures: `target endpoint is not allowed: outbound URL host '127.0.0.1'
is not allowed: loopback address`. The whole harness runs on loopback, so
every replication test hit this before reaching its actual assertion.

Loopback is a genuine SSRF vector and must stay rejected in production, so
this does not relax the guard. Instead `validate_replication_target_endpoint`
gains an off-by-default opt-in (`RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET`)
that re-enables loopback targets (127.0.0.1 / ::1 / localhost) for single-host
multi-instance dev and the e2e harness. Private addresses stay unconditionally
allowed as before; the opt-in does not widen into link-local or the cloud
metadata endpoint. The e2e harness sets the env for every server it spawns
(single-node and cluster paths), overridable via extra_env.

Verified end-to-end: all 9 previously-failing replication_extension_test
smoke tests pass against a locally built binary. New unit tests in
bucket_target_sys pin the matrix — public/private always allowed, loopback
gated on the opt-in in both IP and hostname forms, and metadata/link-local
still rejected even with the opt-in on.

Refs: backlog#1147

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

* test(replication): rename optin -> opt_in to satisfy typos check

Pure rename of three unit-test function names; no behaviour change.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 07:49:39 +00:00
Zhengchao An f63af3df63 chore: retire completed-migration scaffolding, wire orphaned boundary check (#4719)
The ecstore/global-state migrations are done (backlog#815, #939, #1052 all
closed). Review of every migration-era test/gate measure found three things
actually retirable or broken — everything else is a live anti-regression
guard and stays.

Remove:
- scripts/check_metrics_migration_refs.sh — guards a migration that
  finished: rustfs_metrics:: has zero hits, the metrics crate no longer
  exists, and the script was never wired into CI or make (only reference
  was one line in config-model-boundary-adr.md, also removed).
- crates/obs init_metrics_collectors — the "backward-compatible alias kept
  during migration" the removed script was guarding. Zero callers; pure
  delegate to init_metrics_runtime.

Archive (docs/superpowers/plans/, continuing the 2026-07 convention,
with the standard archived banner):
- startup-timeline.md, scheduler-baseline.md,
  profiling-numa-capability-inventory.md,
  kms-development-defaults-inventory.md — one-shot snapshots whose only
  consumer is the already-archived migration-progress ledger (their
  same-dir links there start resolving again after the move); zero script
  pins; fed the closed backlog#660/#665 architecture-review ledger.
  Fixed the one outbound link (startup-timeline -> readiness-matrix) that
  the move would have broken — check_doc_paths.sh deliberately does not
  scan plans/, so nothing else would have caught it.

Wire (found orphaned by the same review):
- scripts/check_extension_schema_boundaries.sh guards a live contract
  crate but was never invoked anywhere. Add lint-fmt.mak target, include
  in pre-commit/pre-pr/dev-check, add ci.yml Quick Checks step (job
  already installs ripgrep), sync the CONTRIBUTING.md enumerated list,
  and harden the script against a silently-passing rg probe when src/
  is missing.

Keep (verified live, documented so the next cleanup pass does not repeat
this analysis):
- scripts/check_architecture_migration_rules.sh — added a header stating
  it is a permanent boundary guard, not retirable migration scaffolding;
  'migration' in the name is historical.
- check_migration_gate_count.sh + floor, delete-marker e2e proof, all
  pinned docs, compat-cleanup-register sync, remaining inventories
  (referenced by live docs).

Verification: all 7 guard scripts pass, actionlint clean,
cargo check --workspace (excl e2e) clean, cargo fmt --check clean.
Adversarially reviewed by two independent skeptic passes; their 7
findings (alias left behind, broken outbound link, missing banners,
wrong backlog attribution, CONTRIBUTING drift, rg exit-2 hole, missing
header rationale) are all folded in.
2026-07-11 13:42:56 +08:00
Zhengchao An a97f3a9c52 fix(test): isolate health tests from minimal-response env var race (#4702)
Three health handler tests assert on payload fields (degradedReasons,
details) that are absent when RUSTFS_HEALTH_MINIMAL_RESPONSE_ENABLE is
true. The minimal-mode sibling test sets that env var via
temp_env::with_var, which leaks across parallel test threads.

Wrap the three affected tests with their own with_var guard pinning the
env var to false so they are deterministic regardless of thread order.
2026-07-11 13:39:34 +08:00
Zhengchao An 5650dcdc5d ci: pull replication tests out of e2e-smoke (loopback targets fail every PR) (#4724)
ci: pull replication tests out of e2e-smoke — loopback targets fail every PR

repl-1 (#4712) added 20 bucket-replication admin-path tests to the
e2e-smoke PR lane. They set a remote replication target at a loopback
endpoint (127.0.0.1, a second local server), which RustFS's target SSRF
guard rejects by default ('outbound URL host 127.0.0.1 is not allowed:
loopback address'). repl-1's local verification was inconclusive under
machine load, so this shipped broken and failed End-to-End Tests on
every PR based on post-repl-1 main (#4707 etc).

Move all replication e2e tests to the e2e-repl-nightly lane (now the full
replication_extension_test set, no allowlist split) to un-block PRs. The
nightly job needs loopback-target-allow server config for these to pass;
tracked as a repl-1 follow-up (backlog#1147). e2e-smoke returns to the
17 functional modules from ci-4 (63 tests).
2026-07-11 13:39:15 +08:00
houseme 9bf102f965 fix(cache): reserve admitted bytes in the memory gate to bound burst overshoot (#4718)
The fill gate compared each request against a snapshot refreshed at most every
5 s, with no accounting for what it had already let through. A burst arriving
while the snapshot still read high therefore all passed the same check-then-act
test and over-allocated far past the real headroom before the next refresh — a
gap the burst stress test could expose but not close.

Track admitted bytes since the last refresh in the shared snapshot cell and
subtract them from available memory in `allows_fill`, reserving the request's
size on each admission. Cumulative admission is now bounded to the real budget
even though every fill reads the same stale snapshot; the refresh resets the
counter because the fresh reading already reflects those allocations. The
`min_free_memory_percent == 0` opt-out still short-circuits first, and the
already-low-memory path is unchanged.

New test `moka_backend_gate_reservation_bounds_burst_under_stale_snapshot`:
20 concurrent 40 KiB fills against a 500 KiB stale-high snapshot (300 KiB
budget) admit only a bounded handful, not the whole storm. Passed 10/10 runs;
mutation-verified — dropping the reservation admits all 20 and fails the test.

Refs: backlog#1107

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 05:36:20 +00:00
Zhengchao An 05fae6f939 test(ecstore): add TierConfigMgr state-machine unit coverage (#4713)
* test(ecstore): unit-test TierConfigMgr add/edit/remove/verify state machine (backlog#1148 ilm-4)

Covers the tier config state machine and persistence paths that previously
had only 4 codec tests and none for tier_config.rs:

- add: non-uppercase name, duplicate name, unsupported type, missing
  backend payload, and a regression anchor documenting that AWS-reserved
  names (STANDARD) are not currently rejected.
- edit: unknown tier, missing-credentials rejection for RustFS and MinIO.
- remove: idempotent unknown-tier no-op, in-use rejection, empty-backend
  success, force skips the in_use probe, and probe-error surfacing.
- verify: unknown tier, healthy backend, unhealthy backend.
- pure query helpers (empty/is_tier_valid/tier_type/get/list_tiers).
- persistence: JSON marshal/unmarshal roundtrip, external tier-config.bin
  roundtrip for Azure and GCS payload mapping, truncated/unknown-format/
  unknown-version rejection, legacy v1 version-word acceptance, and encode
  failure on missing payload.

Tests are hermetic: error paths return before backend construction, and a
MockWarmBackend injected into driver_cache exercises remove/verify without
any real remote. Refs backlog#1155.

Co-authored-by: overtrue <anzhengchao@gmail.com>

* fix(tier): reject reserved names STANDARD/RRS in TierConfigMgr::add (backlog#1148 ilm-4) (#4721)
2026-07-11 05:31:30 +00:00
Zhengchao An 846aa95c32 test(security): GHSA-named regression tests for 3p3x and r5qv (backlog#1151 sec-6) (#4707)
test(security): add GHSA-named regression tests for 3p3x and r5qv (backlog#1151 sec-6)

Anchor the two fixed advisories to discoverable, named regression tests so
`rg -i "ghsa|3p3x|r5qv"` finds a guard for each, and future fixes are forced
to update pinned behavior (red -> green).

GHSA-3p3x-734c-h5vx (constant-time WebDAV/FTPS secret comparison, rustfs#4403):
- ftps_core.rs: new `assert_ftps_ghsa_3p3x_wrong_credentials_rejected` drives
  the `ct_eq` reject branch in FtpsAuthenticator::authenticate; asserts wrong
  password and unknown user are both rejected (530) and indistinguishable.
- webdav_core.rs: the auth-failure block now sends a valid access key with a
  wrong secret (exercising the `ct_eq` branch, not just the unknown-access-key
  path) plus an unknown user, asserting both 401 and indistinguishable.
- Module doc comments map advisory -> tests -> fix PR on both files.

GHSA-r5qv-rc46-hv8q (internode RPC fail-closed, rustfs#4402):
- http_auth.rs: renamed the default-fallback rejection test to
  `ghsa_r5qv_resolve_shared_secret_rejects_default_fallback` (and broadened it
  to cover default env secret + blank secrets), and added
  `ghsa_r5qv_verify_rpc_signature_fails_closed_on_missing_or_invalid_auth`
  pinning the exact advisory scenario (missing/forged/cross-URL signature is
  rejected; a correctly signed request still passes). File-level doc maps the
  advisory.

Docs: new docs/testing/security-regressions.md with the advisory -> test
mapping table and where each layer runs; linked from docs/testing/README.md.
sec-14 will formalize the written policy in AGENTS.md.

The unit-level ghsa_r5qv_* tests run in the default CI pass. The WebDAV/FTPS
e2e live in the protocols suite (fixed ports, --test-threads=1); they cannot
join the e2e-smoke profile and are wired into CI by sec-5.

Refs: rustfs/backlog#1151 (sec-6), rustfs/backlog#1155
2026-07-11 05:18:27 +00:00
Zhengchao An 4b83efaf36 ci(ilm): add gated s3-tests lane for lifecycle expiration cases (#4715)
* ci(ilm): move first batch of 5 s3-tests lifecycle expiration cases into a gated behavior lane (backlog#1148 ilm-10)

The 20 lifecycle cases in excluded_tests.txt were labeled "vendor-specific"
but the real blocker was the absence of a Ceph lc_debug_interval equivalent.
RUSTFS_ILM_DEBUG_DAY_SECS (ilm-5) now provides that, so Days>=1 expiration
behavior is testable in seconds.

These cases assert that objects/versions/uploads are actually removed by the
background scanner and the stale-multipart cleanup loop. They cannot join the
default single-server s3-implemented-tests gate: it disables the scanner, and a
global RUSTFS_ILM_DEBUG_DAY_SECS also shrinks the x-amz-expiration header that
the already-passing test_lifecycle_expiration_header_* cases assert on. So this
adds an isolated lane instead of putting them in implemented_tests.txt.

First batch (5), each verified against the pinned upstream source and the
RustFS evaluator/scanner/stale-multipart execution paths:
  - test_lifecycle_expiration          (prefix Days=1/Days=5, scanner delete)
  - test_lifecyclev2_expiration        (same via ListObjectsV2)
  - test_lifecycle_noncur_expiration   (NoncurrentVersionExpiration)
  - test_lifecycle_deletemarker_expiration (ExpiredObjectDeleteMarker cascade)
  - test_lifecycle_multipart_expiration    (AbortIncompleteMultipartUpload)

Dropped from the batch, kept excluded with reason:
  - test_lifecycle_expiration_days0: RustFS accepts Expiration{Days:0} (returns
    200; only Days<0 is rejected in crates/lifecycle/src/core.rs validate()),
    while AWS/this test expect InvalidArgument. Real validation gap.

Changes:
  - scripts/s3-tests/lifecycle_behavior_tests.txt: new exact-node-id run set.
  - scripts/s3-tests/run.sh: honor an IMPLEMENTED_TESTS_FILE override so a lane
    can point at an alternate whitelist.
  - .github/workflows/ci.yml: new s3-lifecycle-behavior-tests PR-gate job that
    starts rustfs with RUSTFS_ILM_DEBUG_DAY_SECS=10, scanner enabled (cycle 2s,
    no start delay) and a 2s stale-multipart cleanup interval, running the new
    list serially.
  - scripts/s3-tests/report_compat.py: recognize the behavior list so the weekly
    scope=all sweep (plain server) does not misclassify expected failures.
  - scripts/s3-tests/excluded_tests.txt: remove the 5 enabled cases; re-annotate
    the remaining 15 lifecycle exclusions with real reasons + batch-2 plan.
  - docs/architecture/s3-compatibility-matrix.md: sync counts (implemented 451,
    excluded 274, behavior 5) and document the lane.

Refs backlog#1148 (ilm-10), master plan backlog#1155.

* fix(lifecycle): reject zero-day expiration/noncurrent/abort rules (backlog#1148 ilm-10) (#4722)
2026-07-11 05:16:55 +00:00
houseme 4de73c0653 fix(docker): use non-default credentials in cluster compose/scripts (#4720) 2026-07-11 12:41:02 +08:00
Zhengchao An abe6c41227 test(e2e): negative header-SigV4 rejection suite (backlog#1151 sec-1) (#4708)
test(e2e): add negative header-SigV4 rejection suite (backlog#1151 sec-1)

RustFS delegates SigV4 verification to the s3s dependency, so nothing in
this repo pins OUR end-to-end wiring of it. Add negative_sigv4_test.rs
sending REJECTED header-SigV4 requests against a live RustFSTestEnvironment
and asserting the HTTP status plus the S3 error code XML:

- valid_header_sigv4_request_succeeds (positive control so the negatives
  cannot pass for the wrong reason)
- tampered_signature_returns_signature_does_not_match -> 403 SignatureDoesNotMatch
- wrong_secret_key_returns_signature_does_not_match -> 403 SignatureDoesNotMatch
- tampered_payload_is_rejected (body != signed x-amz-content-sha256) -> not 200
- skewed_date_returns_request_time_too_skewed (>15min) -> 403 RequestTimeTooSkewed
- malformed_authorization_header_returns_clean_4xx (present-not-missing) -> 4xx, never 5xx

Signatures are hand-built via rustfs_signer::request_signature_v4 primitives
(get_signing_key/get_signature/get_scope) so the test controls the timestamp,
secret, signed payload hash, and final signature bytes. Missing-credential
negatives are intentionally not duplicated (covered by multipart_auth_test /
anonymous_access_test).

Refs backlog#1151 (sec-1), master plan backlog#1155.
2026-07-11 03:28:56 +00:00
Zhengchao An ac646cfbe4 test(e2e): large-object degraded-read EOF truncation regression net (dist-13) (#4709)
Adds an S3-API-level e2e regression net proving that a large-object GET on a
degraded EC set never returns a silently truncated body under a full
Content-Length -- the historical "unexpected EOF" bug fixed on main by
rustfs#4594 (short-body GetObject stream -> UnexpectedEof), rustfs#4560
(in-place per-part legacy degradation for the lazy multipart reader), and
rustfs#4585 (DARE package-boundary truncation detection). Those fixes each
ship a *unit* regression; this covers the layer they do not -- the full HTTP
GET path streaming a real body reconstructed from real on-disk EC shards.

New file crates/e2e_test/src/degraded_read_eof_regression_test.rs, single-node
4-disk (EC 2+2) DiskFaultHarness, three scenarios:

  (a) one disk offline: a 6 MiB single object (>=2 EC stripes) and a 3x5 MiB
      multipart object GET back byte-identical with the correct Content-Length.
  (b) mid-stream bitrot within quorum: 2 of 4 shards corrupted mid-file on a
      large multipart object still reconstructs the full, hash-matching body.
  (c) beyond read quorum (the heart of the net): 3 of 4 shards corrupted
      mid-file -- the read MUST fail cleanly (non-2xx or a mid-stream body
      error), NEVER close with a truncated body under the full Content-Length.

The shared get_checked() helper panics on the forbidden outcome (a clean 2xx
whose collected body is shorter than the advertised Content-Length), so the
truncation bug can never be silently tolerated.

CI placement: these spawn a 4-disk server per test and are resource-heavy, so
they stay OUT of the fast PR e2e-smoke filter. A new e2e-reliability nextest
test-group (max-threads=1) serializes them (and the existing
reliability_disk_fault_test) across nextest's process boundary; ci-7's nightly
full-e2e run picks them up. Visible via `cargo nextest list -p e2e_test`.

Refs rustfs/backlog#1150 (dist-13), rustfs/backlog#1155, rustfs#4594,
rustfs#4560, rustfs#4585, rustfs#2955.
2026-07-11 02:49:48 +00:00
houseme d5d6f1160d test(object-data-cache): add concurrency stress tests and GET benchmarks (#4711)
* test(object-data-cache): add concurrency stress tests for the fill machinery

The race coverage so far pins specific interleavings with an injected barrier.
These add sustained, real-parallelism contention to catch what pinned tests
cannot — a leaked singleflight leader, a stranded semaphore permit, an
index/cache divergence that only surfaces under volume:

- moka_backend_concurrency_storm_leaves_no_leaked_state: 48 tasks x 400 mixed
  fill/lookup/invalidate ops on a 6-key pool over 4 worker threads, then
  asserts inflight_fills == 0 (no leaked in-flight fill), a clean post-storm
  fill/hit/invalidate sequence still works (state not corrupted), and clear()
  drains the cache.
- moka_backend_gate_bounds_admission_under_concurrent_burst: a low-memory
  snapshot must reject an entire 32-fill burst — admission is bounded, not a
  check-then-act race that lets the burst through. (This does not cover the
  separate 5s-stale-snapshot overshoot, which needs byte-based reservation.)

Both are mutation-verified: neutering invalidate_object fails the storm's
"invalidation must win" assertion, and making allows_fill always-true fails the
burst's full-rejection assertion. The storm passed 20/20 runs. `rt-multi-thread`
is added to dev-deps so the tasks run on real worker threads.

Refs: backlog#1107

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

* bench(object-data-cache): measure the GET-path cost the audit argued statically

Adds a criterion benchmark driving the public ObjectDataCache facade, so the
"hot path is clean / high throughput" claim rests on numbers, not analysis:

- plan_get      ~104-112 ns  (per-GET planning + metric label-set hash)
- lookup_hit    ~143-147 ns  (a resident-body hit: moka get + Bytes clone + metric)
- fill_distinct ~2.2-2.4 us  (cold fill: plan + singleflight + index + moka + inner spawn)

A hit at ~143 ns against a multi-millisecond erasure read is the ~1000x saving
the cache exists for; the per-GET metric cost is real but ~100 ns, not a
bottleneck; the fill cost (incl. the cancellation-safety spawn kept on purpose)
is negligible on a background task. Run with
`cargo bench -p rustfs-object-data-cache`.

Refs: backlog#1107

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 02:30:19 +00:00
Zhengchao An ce53feee87 test(lifecycle): regression test for expire/GET race (#3491) (#4706)
Adds a serial-lane ILM integration regression test that pins the
local-first ordering contract established by rustfs#3491, where
`expire_transitioned_object` deletes local metadata BEFORE any remote
tier cleanup so a concurrent GET can never observe live local metadata
pointing at an already-removed remote tier version (user-visible
`NoSuchVersion`).

`serial_tests::test_expire_transitioned_object_never_races_concurrent_get`
in `crates/scanner/tests/lifecycle_integration_test.rs`:

- Transitions an object to a mock warm tier, then runs a tight
  concurrent GET loop while calling `expire_transitioned_object`; every
  GET must return a full, correct body or a clean object/version-not-found
  -- never a tier-fetch failure.
- Deterministic ordering assertion (revert-proof): immediately after
  expiry the remote tier object is still present and the mock recorded
  zero remote `remove` calls, proving remote cleanup is deferred to
  free-version recovery. Reverting to remote-first ordering turns this
  assertion red.

Supporting changes:
- Re-export `expire_transitioned_object` from `api::bucket::lifecycle`.
- Record remote-tier mutating ops in the scanner test `MockWarmBackend`.
- Update tier-ilm-debugging.md to reference the new regression test.

Runs in the CI ILM Integration (serial) lane (ci.yml
test-ilm-integration-serial), picked up by the existing
binary(lifecycle_integration_test) filter.

Refs: rustfs/backlog#1148 (ilm-2), rustfs/backlog#1155
2026-07-11 01:53:24 +00:00
Zhengchao An 5c7c757a30 ci(repl): wire replication e2e suite into nextest profiles (backlog#1147 repl-1) (#4712)
Activate the 36 dormant replication e2e tests in
crates/e2e_test/src/replication_extension_test.rs (zero ran anywhere before).
Split via the ci-4 nextest profile mechanism, no hand-rolled cargo-test lane:

- PR smoke (profile.e2e-smoke, existing e2e-tests job): the 20 fast
  bucket-replication tests (target-registration / replication-check / list /
  remove / delete admin paths) that validate config synchronously and never
  wait for async convergence. Each spawns its own single-node rustfs server(s)
  on random ports with isolated temp dirs, so parallel-safe by construction
  (serial_test's #[serial] is a no-op under nextest's process-per-test model;
  no test-group needed).
- Nightly (profile.e2e-repl-nightly + .github/workflows/e2e-replication-nightly.yml):
  the remaining 16 = 6 slow data-plane tests + 9 _real_dual_node + 1
  _real_single_node. Defined as 'replication module MINUS the PR allowlist' so
  new replication tests default to nightly and are never silently unrun.

The nightly workflow builds the binary once, installs awscurl so the STS
dual-node test runs (skips gracefully with a visible log line otherwise), and
routes scheduled failures through .github/actions/schedule-failure-issue
(ci-8). Explicit division of labor with ci-5 e2e-full: these run only here.

Counts (cargo nextest list): e2e-smoke 83 (63 + 20), e2e-repl-nightly 16.
Docs updated: e2e-suite-inventory.md, e2e_test/README.md.

Refs backlog#1147 repl-1, backlog#1155.
2026-07-11 09:45:47 +08:00
Zhengchao An c41a813a31 docs(e2e): contributor guide for the e2e_test crate (backlog#1153 infra-10) (#4705)
docs(e2e): write contributor guide for e2e_test crate (backlog#1153 infra-10)

Turn the e2e_test README skeleton into a dense, link-heavy contributor guide:
crate overview + grouped module map, how to run (whole crate / one module /
e2e-smoke profile / ILM serial lane / protocols suite), #[ignore] semantics as
a pointer to live sources, how to add a test (RustFSTestEnvironment +
RustFSTestClusterEnvironment, common.rs/chaos.rs helper inventory, isolation
rules, #[serial]-vs-nextest reality), a CI subset map table, and a
troubleshooting section (stale binary / RUSTFS_TEST_PORT / orphan processes).

Keeps the ci-4 "CI smoke subset" section intact and restructures around it.
Adds a reciprocal link from crates/e2e_test/AGENTS.md.

Refs rustfs/backlog#1153 (infra-10), #1155.
2026-07-11 09:27:30 +08:00
Henry Guo 3f25426534 fix(ecstore): reject incomplete listing usage refreshes (#4698)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-07-11 08:47:49 +08:00
houseme 7437f99c45 fix(cache): key the object body cache on data_dir for write-uniqueness (#4703)
* refactor(object-data-cache): derive Default for ObjectDataCacheGetRequest

The GET request literal is hand-listed field-by-field across ~13 test sites in
two crates. Adding `mod_time_unix_nanos` in backlog#1111 had to touch every one
and still missed a literal, producing a compile error caught only in a later
CI lane. Derive `Default` and spread the engine-crate literals so the next
field addition is absorbed rather than fanned out.

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

* fix(cache): key the object body cache on data_dir for write-uniqueness

The cache key's correctness rested on being write-unique, but its only
write-scoped component was `mod_time` — a wall-clock timestamp that is not
monotonic and can be absent. Two writes that collide on MD5 (same etag+size)
and land on an equal mod_time (clock skew, same-tick, or absent) derived the
same key, so a node that never saw the overwrite could serve the previous body
for up to the TTL, and the same collision turned the fill-after-invalidation
race into a serving bug. This is the store's strong-read-after-write guarantee
leaning on a probabilistic argument.

Add `data_dir` — the xl.meta directory UUID ecstore regenerates on every body
write — as the primary write-unique anchor:
- surface `data_dir: Option<Uuid>` on ObjectInfo, copied from FileInfo in the
  single GET-path constructor `from_file_info`;
- carry it into ObjectDataCacheKey as `data_dir_u128` (held as u128 to keep the
  engine crate free of a uuid dependency), derived in the one planner site both
  the ecstore hook and the usecase layer share, so both produce an identical
  key by construction;
- keep `mod_time` as a second anchor and `etag+size` as belt-and-braces; an
  absent data_dir falls back to the prior behavior — strict improvement, no
  regression.

Two writes distinct only by data_dir now derive different keys even under an
MD5 collision with identical mod_time — the case mod_time alone cannot cover.

Blast radius is compiler-guarded: ObjectInfo derives Default and every real
construction site uses `..Default::default()`, so only from_file_info and one
full-literal test needed the field. The three P0 body_cache_hook_e2e
regressions, engine (80), and app (36) suites pass unchanged; a mutation that
severs the planner wiring fails planner_key_changes_with_data_dir.

Refs: backlog#1111, backlog#1118

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

* fix(cache): thread data_dir field through the merged mutation-hook test

Merging main (which landed the object-mutation-hook work, backlog#1131) brought
in a GetRequest test literal that predates the data_dir field. Spread it via
`..Default::default()` — the derive(Default) added here means this is the last
such hand-listed literal to need touching.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 08:00:52 +08:00
Zhengchao An a35ebb92b8 fix(test): add missing mod_time_unix_nanos field in mutation_hook test helper
The recent addition of ObjectDataCacheGetRequest.mod_time_unix_nanos
(backlog#1111 / ODC-06) missed the test helper in mutation_hook.rs,
causing a compile error under clippy --all-targets.
2026-07-11 07:01:56 +08:00
Zhengchao An a32af463ed ci: quarantine walk_dir stall-budget flake under the ci profile (#4691)
ci: quarantine walk_dir stall-budget flake under the ci profile (rustfs#4690)

First application of the flake policy from docs/testing/README.md: the
test failed on a zero-Rust-diff PR (#4674, run 29099382470) — timing
windows in the stall-budget accounting stretch under CI load. retries=2
under profile.ci only; local default profile still never retries.
Tracked by rustfs#4690 (30-day fix-or-delete deadline).
2026-07-11 04:10:32 +08:00
houseme 85fd824581 feat(object-data-cache): close write-side invalidation gaps and add an admin surface (#4694)
* feat(object-data-cache): close write/delete-side invalidation gaps

The object data cache exposed only a single per-(bucket,object)
invalidation primitive and no write-side ecstore hook, so several
delete paths left dead bodies resident until TTL (hygiene/capacity, not
stale-serving: lookups follow a fresh metadata quorum and cannot serve a
gone object). This adds the missing primitives and wires them in.

ODC-26 (backlog#1131): add an `ObjectMutationHook` trait beside the GET
body hook, registered next to it at startup, and call it from the
ecstore-internal delete paths (`apply_expiry_on_non_transitioned_objects`,
`expire_transitioned_object` including the restored-copy branch, and
`delete_object_versions`). The app impl is one `invalidate_object` call
under a new `AfterLifecycleExpiry` reason.

ODC-27 (backlog#1132): force prefix delete now invalidates the whole
prefix, not just the prefix string. `store.delete_object(delete_prefix)`
returns no deleted-name list, so this uses a new prefix primitive rather
than the batch path.

ODC-28 (backlog#1133): DeleteBucket now flushes the bucket via a new
bucket-scope primitive (covers force and non-force, which share the
delete_bucket call).

ODC-C2 (backlog#1143): add `ObjectDataCache::clear()` and two admin
handlers (GET stats, POST flush) routed through admin runtime_sources.

The starshard identity index gains a single `remove_matching` full-scan
API backing prefix/bucket/clear; it is documented as admin/delete-path
only and never runs on the GET or fill hot path. New invalidation
reasons and metric labels added; outcome (removed/noop) labelling kept
correct for every new primitive.

Also fixes a pre-existing broken intra-doc link in memory.rs.

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

* refactor(ecstore): extract the shared HookSlot behind both cache hooks

This PR introduced object_mutation_hook.rs by mirroring body_cache_hook.rs,
which left two process-global registration slots whose register/get/clear
bodies were line-for-line identical except the trait type and the WARN string:
a RwLock<Option<Arc<dyn _>>>, an Arc::ptr_eq "different instance" warning, the
poison-recovery closure, and the same read-lock-and-clone read. Two copies of
the same swap-vs-warn logic can drift apart under maintenance.

Hoist it into a generic HookSlot<T: ?Sized> that owns the logic once. Each hook
module keeps its `static HOOK: HookSlot<dyn XxxHook>` and its thin, unchanged
public wrappers (register_/get_/clear_), so the crate's public surface and
every call site are untouched — this is an internal consolidation, not a
contract change.

The load-bearing #1126 guarantee (newest registration wins, so a rebuilt
AppContext is never stranded on a first-wins slot) previously had no direct
test — the hook tests only covered register-then-notify. HookSlot now has its
own unit tests including re_registration_swaps_to_the_latest_instance;
mutation-testing confirms a first-wins regression fails exactly that test.

No behavior change: the two hooks' existing tests, the P0 body_cache_hook_e2e
regressions, and the app-layer mutation-hook tests all pass unchanged.

Refs: backlog#1126, backlog#1131

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

* fix(admin): register the object-data-cache routes in the policy inventory

This PR added GET /object-data-cache/stats and POST /object-data-cache/flush
but did not list them in the two registries that must account for every
admin route: the route-policy inventory (route_policy.rs) and the route
matrix (route_registration_test.rs). Their coverage tests —
route_policy_inventory_covers_registered_routes and
test_admin_route_matrix_matches_registered_routes — failed on CI because a
registered route had no policy/matrix entry.

These two tests are not part of `make pre-commit` (which runs fmt + arch +
quick-check, not the full suite), so the gap passed local pre-commit and
only surfaced in the CI Test-and-Lint lane.

stats is a read (ServerInfoAdminAction, Sensitive); flush mutates
(ConfigUpdateAdminAction, High) — matching the actions the handlers already
enforce. The MinIO-alias matrix test is unaffected: these are native rustfs
endpoints with no MinIO equivalent.

Refs: backlog#1143

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 20:04:05 +00:00
houseme f9874b591a [DO NOT MERGE until published] chore(ecstore): switch rustfs-uring to crates.io 0.1.0 (#4701)
* chore(ecstore): switch rustfs-uring from the git rev to crates.io 0.1.0

Prepared ahead of the rustfs-uring 0.1.0 crates.io release (rustfs/uring):
flip ecstore's dependency from the pinned git revision to the published
`0.1.0`, and drop the now-unneeded git-source allowance in deny.toml.

DO NOT MERGE until rustfs-uring 0.1.0 is on crates.io. Until then cargo
cannot resolve `rustfs-uring = "0.1.0"`, so this branch will not build and
CI will be red — that is expected, not a defect in the change.

After the crate is published:
  1. `cargo update -p rustfs-uring --precise 0.1.0` to regenerate Cargo.lock
     (deliberately not touched here — the registry entry, with its checksum,
     cannot be written before the crate exists);
  2. push the Cargo.lock;
  3. CI goes green; merge.

No code change. The guard `scripts/check_no_tokio_io_uring.sh` is unaffected
(it bans only tokio's io-uring runtime feature, not an explicit rustfs-uring
dependency). `audit.yml`'s `allow-dependencies-licenses` for rustfs-uring is
left in place — it is a first-party Apache-2.0 crate either way.

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

* chore: refresh rustfs-uring lockfile

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 19:53:23 +00:00
houseme 48bdf87e53 refactor(obs): drop the dial9 S3 config left stranded by the upload revert (#4700)
#4663 added a full S3 upload path (config fields + env parsing + wiring), then
reverted the wiring when its dependency turned out to carry RUSTSEC advisories
(D9-14). The revert stopped at enabled.rs and left the config layer half torn
down: `Dial9Config` still carried `s3_bucket`/`s3_prefix` with no consumer, plus
a dead `s3_upload_requested()`, and `Dial9SessionGuard::config()` was likewise
never called. All three are zero-consumer dead code.

Since S3 upload cannot be built at all, the config type should not model it.
Drop the two fields and both accessors. `from_env` still reads the S3 env vars
and warns about them (an operator who set them deserves to know why they do
nothing — that warning is deliberate and stays), but drops the values instead
of storing configuration nothing reads.

While here, collapse `from_env`'s two `record_config` calls into one by
extracting `from_env_enabled`, so the enabled/disabled paths share a single
publish point.

No behavior change: the S3 warning fires identically, and every other field is
untouched. Net -13 lines, and `Dial9Config` drops from 8 fields to 6.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 19:44:07 +00:00
houseme 95819cb986 docs(object-data-cache): state the correctness boundary and timing channel (#4695)
docs(object-data-cache): state the correctness boundary and the timing channel

The crate doc still described itself as "a minimal skeleton for the initial
rollout phase", which stopped being true several releases ago, and neither
the crate nor the operator-facing env constant said anything about what the
cache does or does not guarantee.

Record two things a reader has to know.

The correctness boundary: a hit is sound because the key matched metadata the
caller just resolved from a read quorum, not because invalidation ran.
Process-local invalidation is hygiene that frees capacity; the key is what
keeps a stale body from being served. Anyone tempted to weaken the key should
meet this sentence first.

The timing side channel: a hit skips the erasure read, bitrot verify and
decode, so it is reliably faster than a miss. A principal authorized to read
an object can time a single GET and learn whether someone read that object
within the entry's lifetime. This crosses no authorization boundary — the
probe needs read access to that exact object, checked before the cache is
consulted — but it does disclose a co-tenant's recent access pattern. Say so
where operators look: on RUSTFS_OBJECT_DATA_CACHE_ENABLE. Timing noise would
cost precisely the latency the cache exists to save, so the mitigation is to
leave the cache off for buckets where access-pattern confidentiality matters.

Refs: backlog#1139

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-11 02:37:34 +08:00
houseme 6780140318 fix(object-data-cache): make the GET key write-unique and dedup the lookup (#4693)
fix(object-data-cache): make GET body cache key write-unique and dedup lookups

Address four object-data-cache GET-path findings (backlog#1107 batch):

ODC-06 (backlog#1111): the cache key was content-unique, not write-unique.
Extend ObjectDataCacheKey with the resolved version's modification time
(i128 unix nanoseconds, None -> 0), derived once in the shared planner so the
ecstore hook and the usecase layer produce an identical key. An unversioned
overwrite advances mod_time, so a stale node can no longer serve old bytes for
up to the TTL under an MD5 collision; etag + size stay as belt-and-braces.

ODC-16 (backlog#1121): every cacheable GET planned and looked up twice (once in
the ecstore hook, once in the usecase layer), double-counting hits, hit_bytes
and lookups. GetObjectReader now carries a GetObjectBodySource marker
(Unprobed / HookMissed / HookServed); the hook stamps it, and
build_get_object_body_with_cache serves a hook-served body directly and skips
its lookup whenever the hook already probed. One hook-served GET now records
exactly one lookup.

ODC-19 (backlog#1124): ENABLE=true with no explicit mode defaulted to HitOnly,
which never fills and keeps a permanent 0% hit rate. Default to
FillBufferedOnly, log the resolved mode at startup, and warn when HitOnly is
selected explicitly.

ODC-24 (backlog#1129): max_entry_bytes above the in-memory GET fill limits was
silently inert. Clamp the planner's size eligibility to
min(max_entry_bytes, seek-support threshold, 64 MiB buffer cap) so ineligible
sizes plan SkipTooLarge instead of being reported eligible, and warn at startup
when the excess is inert.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 18:22:20 +00:00
Zhengchao An d0ca14d8df test(ecstore): cover post-commit hard-crash in rename_data crash harness (#4689)
The rename_data crash-consistency harness (backlog#935/#878) only armed
pre-commit crash points (AfterDataRename, AfterBackupBeforeMetaCommit),
which assert the *old* version survives. The other half of the
"partial commit -> old or new, never mixed" invariant — a hard power loss
*after* the xl.meta commit rename must leave the *new* version — had no
crash-point coverage. The existing after-metadata-commit failpoint only
exercises the graceful in-process rollback (restores old), a different
path from a hard crash (no rollback runs, commit stays on disk).

Add a RenameDataCrashPoint::AfterMetaCommit injection right after the
commit rename (cfg(test), compiles to a const-false no-op in production,
like the existing points) and overwrite/fresh x strict/relaxed tests
asserting the object reads back as the new version. The fresh case pins
that the point is genuinely post-commit: a pre-commit misplacement would
leave no readable object and fail the assertion.

cargo test -p rustfs-ecstore crash_consistency: 9 passed. clippy -D
warnings clean; fmt and arch guards clean.
2026-07-11 02:21:07 +08:00
houseme d715cb5c34 refactor(ecstore): single-source the bitrot read/verify path (backlog#1159) (#4697)
P-A (`read_appending`) and P-C (the in-memory fast path) each copied the
hashed-read logic, so `BitrotReader` ended up with the hash verification, the
short-read error, and the scratch-buffer fill written three times across
`read` and `read_appending`'s two branches. That is patch-on-patch: a change
to the bitrot contract would have to be made in three places and kept in
sync by hand.

Collapse the duplication onto three single-source pieces:
- `split_and_verify` — a free function that splits `[hash][data]`, verifies
  (unless skip_verify), and returns the data slice plus the hash time. Free
  rather than a method so it can run while `self` is borrowed for the block.
- `read_scratch_block` — the single-pass fill of `self.buf` with the
  short-read-to-UnexpectedEof contract.
- `short_shard_read` / `begin_read` — the shared error and preamble.

`read` and `read_appending` now differ only in what they must: how the block
is acquired (caller's slice vs `try_take_block` vs scratch fill) and where the
verified shard lands (`copy_from_slice` vs `extend_from_slice`).

This also fixes a latent inconsistency the duplication hid: the old `read`
did `copy_from_slice` *before* verifying, so on a hash mismatch it left the
corrupt bytes in the caller's buffer before returning the error, while
`read_appending` verified first. Both now verify before writing, so a shard
that fails the hash never reaches the caller's buffer on either method — the
stronger of the two behaviors.

Net -19 lines; behavior otherwise unchanged. Verified: `erasure::` 215
passed, 0 failed (including the fast-path equivalence and corrupt-shard tests,
and the existing `test_bitrot_read_hash_mismatch`); clippy --all-targets
-D warnings clean.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 18:08:07 +00:00
houseme 60ad15a7f9 fix(obs): remove the dial9 task-dump switch that could never work; correct measured claims (#4688)
* fix(obs): remove the dial9 task-dump switch that could never do anything

Measured on a bench host (Linux x86_64) against the code merged in #4663: with
`RUSTFS_RUNTIME_DIAL9_TASK_DUMP_ENABLED=true`, a `dial9-taskdump` build, and
`--cfg tokio_taskdump`, dial9 recorded **zero** TaskDump events.

dial9 captures a task dump only for futures it wrapped itself — those spawned
through `dial9_tokio_telemetry::spawn`, which is where `TaskDumped<F>` gets
applied. `tokio::spawn` gets no wrapper, and RustFS spawns with `tokio::spawn`
throughout. Same workload, same binary, only the spawner changed:

    tokio::spawn   ->      0 dumps
    dial9::spawn   ->  14709 dumps, all with callchains

Upstream documents this (README line 151) and tracks the doc gap at
dial9-rs/dial9#477. I did not read it before wiring `with_task_dumps` in #4663,
and so shipped exactly the kind of lying configuration knob that PR set out to
delete. Remove it: the two environment variables, the config fields, the
`with_task_dumps` call, and the `dial9-taskdump` feature — whose only effect was
to constrain the build to Linux while recording nothing.

Re-adding it only makes sense together with migrating the paths under
investigation to dial9's spawner. Tracked as D9-16 in rustfs/backlog#1157.

Also drop the `--cfg tokio_taskdump` requirement from the Makefile. Measured:
dumps are captured with and without it (14709 vs 14674, within noise), and
upstream never asked for it. That requirement was mine, invented and untested.

Cargo.lock loses tokio's `backtrace` dependency, which `tokio/taskdump` pulled in.

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

* docs(obs): replace guessed dial9 retention numbers with measured ones

Three corrections, all to claims I wrote in #4663 without measuring them.

"Under a high poll rate that budget can wrap in minutes" was a guess. Measured on
a single-node 4-drive cluster under warp mixed (66 MiB/s, 110 obj/s, 32 concurrent):
13023 events/s, 0.16 MiB/s, so the default 1 GiB budget wraps after roughly 108
minutes. Even at ten times the throughput that is ~11 minutes. State the measured
rate and how to scale it instead.

dial9 was described as the tool for drive stalls. It is not. RustFS does disk I/O
on the blocking pool and through io_uring, never on an async worker, so a slow
drive never lengthens a poll. Injecting 200 ms of latency on one of four drives
cut throughput by 64% and left the poll distribution unchanged (polls >= 5 ms:
49 -> 56; p999: 2.67 ms -> 2.75 ms). Enabling dial9's CPU and sched profilers
does not help: sched events are per-worker only, and the CPU profiler samples
on-CPU while a stalled drive is an off-CPU wait. Say so plainly, and point at
the `rustfs_io_*` metrics instead.

What dial9 *is* good for, on the same traces: single polls of 418-625 ms with no
fault injected at all — real worker stalls nothing else in the obs stack surfaces.
Lead with that.

Also link the two upstream issues filed for the gaps we documented:
dial9-rs/dial9#658 (writer death unobservable) and #659 (worker-s3 CVEs).

Measurements: rustfs/backlog#1157 (D9-11, D9-13, D9-18).

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 17:34:59 +00:00
houseme 6f05a740b3 refactor(ecstore): extract the shared io_uring read preamble (backlog#1145) (#4696)
`pread_uring` and `pread_uring_direct` accumulated across seven rounds
(#4632/#4635/#4645/#4649/#4653/#4658/#4662) and each carried its own copy of
the same open preamble: resolve the bucket path, run the volume access check,
resolve the object path, and check the path length. The only real difference
is what happens after — one opens buffered and errors as `DiskError`, the
other opens `O_DIRECT` and errors as `DirectOpenError` so it can latch an
O_DIRECT refusal.

Extract that shared sequence into `resolve_uring_object_path`, a blocking
helper called inside both `spawn_blocking` closures. Each caller keeps exactly
what differs — its open flags, its error type, its metadata/bounds/align
handling — and no longer restates the resolution. No behavior change: the same
checks run in the same order and produce the same errors (the O_DIRECT path
still maps them through `DirectOpenError::Disk`).

Net -12 lines. Verified on a real Linux host (16-core, real io_uring):
clippy -p rustfs-ecstore --all-targets -D warnings clean; disk::local tests
144 passed, 0 failed — including the io_uring, O_DIRECT, fd-cache, and
page-cache-reclaim cases that exercise both read paths.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 17:03:34 +00:00
Zhengchao An ca1463a6c5 ci: run e2e smoke subset via nextest e2e-smoke profile (#4674)
ci: run e2e smoke subset via nextest e2e-smoke profile (backlog#1149 ci-4)

The e2e-tests job previously ran a single test (delete_marker_migration
_semantics) out of ~400 in the e2e_test crate. Define a nextest
profile.e2e-smoke whose default-filter selects 17 fast single-node
dependency-free modules (63 tests) and run it in the job, reusing the
downloaded debug binary. The profile is the single wiring mechanism for
e2e tests in CI; admission criteria live in crates/e2e_test/README.md,
and docs/testing/e2e-suite-inventory.md records authoritative per-module
counts from cargo nextest list.
2026-07-11 01:03:03 +08:00
Zhengchao An 3169982623 fix(make): align clippy-check with CI to fix macOS pre-pr gate (#4692)
CI runs `cargo clippy --all-targets -- -D warnings` without
`--all-features`, but the Makefile's clippy-check used `--all-features`.
After PR #4663 made the dial9 telemetry feature opt-in and removed
`--cfg tokio_unstable` from .cargo/config.toml, `--all-features`
activated dial9 (which requires tokio_unstable) and dial9-taskdump
(which requires tokio/taskdump, Linux-only), breaking local
`make pre-pr` on macOS.

Align the Makefile with CI by dropping `--all-features` from clippy-check.
2026-07-10 16:23:59 +00:00
houseme c2362bca14 perf(ecstore): slice in-memory shards instead of copying them twice (#4687)
* perf(ecstore): slice in-memory shards instead of copying them twice (backlog#1159)

The GET path reads a shard out of the page cache into a `Bytes`, then
`open_disk_reader` erased it behind `Box<dyn AsyncRead>` by wrapping it in
a `Cursor`. Downstream, `BitrotReader` could only get it back by copying:
once out of the `Cursor` into its scratch buffer, and once from there into
the caller's buffer. CPU profiling of a cached 1 MiB GET (device reads = 0)
attributed 8.23% of the whole server to `Cursor::poll_read` alone — a copy
of data that was already sitting in memory.

Keep the source concrete instead of erasing it. `ShardReader` is an enum of
`InMemory(Cursor<Bytes>)` and `Stream(Box<dyn AsyncRead ...>)`, and the new
`ShardSource::try_take_block(n)` lets an in-memory source hand over the
`[hash][data]` block as a slice. `read_appending` uses it to verify the hash
on the slice and `extend_from_slice` the shard straight into the caller's
buffer: one copy instead of two.

`try_take_block` defaults to `None`, so a streaming source keeps the old
path byte for byte, along with its short-read and EOF semantics. A source
that cannot serve `n` bytes declines rather than truncating, so a partial
block still becomes UnexpectedEof rather than a short shard. The hash is
still checked before anything is appended, so a corrupt shard never reaches
the caller's buffer on either path. The deferred parity reader opens its
source lazily and stays on the streaming path; parity is only read when a
data shard fails.

Tests gate equivalence and non-vacuity:
  * `try_take_block` fires for `Cursor<Bytes>`, advances the position exactly
    as a read of the same length would, declines when fewer than `n` bytes
    remain, and returns `None` for a non-`Bytes` source — without this the
    equivalence test below would silently compare one path against itself;
  * both paths return identical bytes for the same shard;
  * a corrupt shard fails on the fast path too, appending nothing.

Verified: clippy --tests -D warnings clean; `erasure::` 215 passed, 0 failed;
`set_disk::core::io_primitives` 49 and `io_support::` 22 pass.

Stacked on #4681 (`read_appending`), which this builds on.

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

* fix(ecstore): implement ShardSource for Cursor<&[u8]> used by the erasure bench

`crates/ecstore/benches/erasure_benchmark.rs` builds
`BitrotReader<Cursor<&[u8]>>`, which the new `ShardSource` bound on
`ParallelReader`/`decode` does not accept. `cargo clippy --tests` does not
compile bench targets, so this only surfaced in CI's `--all-targets` run.

A borrowed slice carries no `Bytes` to hand out, so it takes the default
`try_take_block` and keeps the old streaming copy path — no behavior change.

Verified with the same target set CI uses:
`cargo clippy -p rustfs-ecstore --all-targets -- -D warnings` clean.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 15:57:10 +00:00
Zhengchao An b9e5e818a9 ci: fix migration-gate count guard failing on every PR (nextest JSON counting) (#4686)
ci: count migration-gate tests via nextest JSON, not human-format indentation

The count-floor guard (#4673) parsed nextest's human list format with
grep -c '^    '. CI's newer nextest emits a different indentation, so the
count collapsed to 0 and the gate failed on every PR based on current
main (first observed on #4683, then #4674/#4677/#4680). Count via
--message-format json + jq instead, which is stable across versions, and
fail loudly if the JSON shape ever changes.
2026-07-10 22:19:17 +08:00
Zhengchao An f13e98eb81 fix(perf): keep build_ref_binary stdout clean and fail fast on missing baseline (#4683)
git worktree add prints 'HEAD is now at …' on stdout, which polluted the
path captured from build_ref_binary and made the rig exec a two-line
string as the baseline binary (dispatch run 29087503110 died 1s after
spawn with 'No such file or directory'). Route command output to stderr,
verify the baseline binary exists before returning (exempt in dry-run),
and fix the 'mis-reports' typo flagged by the Typos check.
2026-07-10 21:58:00 +08:00
Zhengchao An 13f8768e7f feat(ecstore): make replication timing intervals env-overridable for tests (#4680)
Introduce RUSTFS_REPL_HEALTH_CHECK_INTERVAL_MS, RUSTFS_REPL_MRF_FLUSH_INTERVAL_MS
and RUSTFS_REPL_RESYNC_POLL_MAX_MS so tests and operators can shorten the
replication background loops. Defaults are unchanged; invalid values fall
back with a warn and values below 10ms are clamped to avoid busy-spin.
Add replication_fast_env() e2e helper (backlog#1147 repl-4).
2026-07-10 21:57:43 +08:00
Zhengchao An 40875026df feat(ecstore): add RUSTFS_ILM_DEBUG_DAY_SECS lifecycle time-acceleration for tests (#4677)
feat(ecstore): add RUSTFS_ILM_DEBUG_DAY_SECS lifecycle time-acceleration for tests (backlog#1148 ilm-5)

Model a Ceph rgw_lc_debug_interval-style switch: when RUSTFS_ILM_DEBUG_DAY_SECS
is set to a positive integer N, lifecycle Days-based rule evaluation treats one
'day' as N seconds instead of 86400, so Days>=1 expiration/transition/noncurrent
rules become exercisable in seconds. Unset => byte-identical to production.

All Days->deadline conversions funnel through expected_expiry_time(); the new
ilm_day_secs() helper (OnceLock-cached in production, env-read under cfg(test))
rescales both the day offset and the default rounding boundary. An explicit
RUSTFS_ILM_PROCESS_TIME still wins for the rounding boundary. Absolute Date-based
rules are deliberately NOT rescaled. Activation emits a WARN; test/debug only.

Refs rustfs/backlog#1148 (ilm-5), rustfs/backlog#1155.
2026-07-10 21:57:19 +08:00
Zhengchao An 6fed0a4067 docs: fix 'mis-reports' typo breaking the Typos check on main (#4685)
docs: fix 'mis-reports' typo failing the Typos check on every PR

Merged via #4669 into docs/operations/hotpath-warp-ab-runbook.md:132; the
Typos CI check now fails on all PRs based on current main.
2026-07-10 21:06:57 +08:00
Zhengchao An 515e14cd42 test(ilm): activate ignored ILM integration tests via serial CI lane (#4682)
Implements ilm-1 from the ILM test-strategy plan: the 33 #[ignore]d ILM
integration tests (14 in crates/scanner/tests/lifecycle_integration_test.rs,
19 in rustfs/src/app/lifecycle_transition_api_test.rs) never ran anywhere.
This adds a dedicated serialized CI job and repairs the app-layer suite,
activating 29 of them.

- ci.yml: new test-ilm-integration-serial job running the two ILM test
  surfaces with 'cargo nextest run -j1 --run-ignored ignored-only'. The
  tests share process-global singletons and fixed ports (9002/9003);
  serial_test's #[serial] does not serialize across nextest's
  process-per-test boundary, so -j1 is the serialization mechanism.
- app suite repair: the 19 app tests rotted after the InstanceContext
  migration (usecases resolve the store via AppContext; the test env never
  installed one, so every store-touching call failed with InternalError
  'Not init'). Add a test-only install_test_app_context helper behind the
  sanctioned context/runtime_sources boundaries and switch the tests from
  without_context() to from_global(). All 19 now pass.
- delete test_lifecycle_transition_basic (+3 orphaned helpers): required
  an external MinIO at localhost:9000 and slept 1200s; superseded by the
  mock-tier tests in the same file.
- fix a timing flake: poll free-version metadata removal instead of
  asserting a single read right after remote-object absence.
- 3 scanner tests fail on main for product reasons (multipart restore
  UnexpectedEof; noncurrent transition/expiry after immediate compensation
  transition on versioned buckets); they keep #[ignore] with a backlog
  reference and are excluded from the lane by name.

Lane: 29 tests, ~43s test time, green twice locally.

Refs rustfs/backlog#1148 (ilm-1), rustfs/backlog#1155.
2026-07-10 21:01:37 +08:00
Zhengchao An 12c44abce0 ci: add count-floor guard to migration-critical test gate (#4673)
The migration-proof step in ci.yml selects tests by name substring
(data_movement / rebalance / decommission / source_cleanup /
delete_marker), so a rename can silently thin the gate to zero with no
CI signal. Move the filter expression into
scripts/check_migration_gate_count.sh as its single source of truth:
the script counts the selected tests with cargo nextest list, fails if
the count drops below the committed floor in
.config/migration-gate-floor.txt, and then runs the gate with the same
filter so the check and the run cannot drift.

The floor equals the exact selected-test count at landing (571), so any
reduction fails CI and intentional shrinks must edit the floor file in
the same PR, keeping gate changes visible in diffs.

Refs rustfs/backlog#1153 (infra-12), rustfs/backlog#1155.

Signed-off-by: Zhengchao An <anzhengchao@gmail.com>
2026-07-10 20:55:52 +08:00
Zhengchao An 173e5ddc06 build(make): require cargo-nextest for make test with explicit escape hatch (#4672)
build(make): require cargo-nextest for make test with explicit escape hatch (backlog#1153 infra-14)

cargo-nextest changes test semantics — it runs each test in its own process
(so serial_test's #[serial] mutex does not serialize across tests) and is the
only runner that honours .config/nextest.toml [test-groups] (e.g. the
ecstore-serial-flaky serialization guard). CI installs and runs nextest, but
`make test` only warned when it was missing and then silently fell back to
`cargo test`, which runs with different serialization behaviour than CI and can
mask or invent flakes.

Make cargo-nextest a hard dependency of `make test`:

- Missing nextest now fails `make test` with a clear message and install
  instructions (`cargo install cargo-nextest --locked` or a prebuilt binary
  via https://nexte.st/docs/installation/).
- Set RUSTFS_ALLOW_CARGO_TEST_FALLBACK=1 to opt into the plain `cargo test`
  fallback anyway; it prints a loud warning that serialization semantics differ
  from CI and .config/nextest.toml test-groups will NOT apply.
- The check stays cheap (command -v). The warn-only test-deps prerequisite is
  dropped from check.mak in favour of recipe-level gating.

Install docs live in the make-layer error message plus a header comment in
tests.mak, with a single-line note in CONTRIBUTING.md (kept minimal to avoid
conflicting with #4667 / infra-9, which rewrites the verification sections).

No CI change: .github/workflows/ci.yml already runs cargo nextest and
.github/actions/setup/action.yml already installs it.

Refs rustfs/backlog#1153 (infra-14), master plan #1155.

Signed-off-by: Zhengchao An <anzhengchao@gmail.com>
2026-07-10 20:54:40 +08:00
Zhengchao An 4310b55238 ci: add schedule-failure-issue composite action and wire nightly alerts (#4671)
Scheduled workflow failures previously went unnoticed (15 consecutive red
weekly s3-tests sweeps, silent perf nightly failures). Add a reusable
composite action that opens a tracking issue titled
"[scheduled-failure] <workflow name>" — or appends a comment to the
existing open one (dedupe key = workflow name) — with the run URL, run
attempt, and failed job names, labeled "infrastructure".

Wire it into the scheduled paths of e2e-s3tests, mint, fuzz (nightly) and
performance-ab via a final alert-on-failure job gated by
"always() && github.event_name == 'schedule' &&
contains(needs.*.result, 'failure')", with issues:write scoped to that
job only. e2e-s3tests and mint previously had no permissions key; they now
get workflow-level contents:read, reducing every other job's token.

Add a dispatch-only drill workflow that forces a job failure and runs the
action through the exact consumer wiring, for end-to-end verification.
The ci-7 e2e nightly does not exist yet; it is recorded as a pending
consumer in the action README.

Refs: rustfs/backlog#1149 (ci-8), rustfs/backlog#1155
2026-07-10 20:53:44 +08:00
Zhengchao An fd18b1df49 ci(perf): fix nightly warp A/B startup failure, make it diagnosable, add small-object + 10MiB cells (#4669)
Both nightly runs of the warp A/B rig failed at server bring-up:
`endpoint http://127.0.0.1:9000/health did not become healthy` — exactly 60s
after start. The server binds its public listener only after startup converges
(erasure-format load + IAM); its own budget
(RUSTFS_STARTUP_READINESS_MAX_WAIT_SECS) defaults to 120s, so the rig's 60s
health poll gave up before a slow cold start on the shared sm-standard-2 runner
finished. The rustfs.log lived under /tmp (not the uploaded target/hotpath-ab/),
so the root cause was invisible in the artifact.

Root-cause + diagnosability (scripts/run_hotpath_warp_ab.sh):
- Health poll is configurable via --health-timeout, default 180s (> the 120s
  server startup budget). Fails fast if the local server process exits before
  becoming healthy instead of polling out the full window.
- Server logs + a startup-env file now write under OUT_DIR/server-logs/ so they
  ride along in the CI artifact. On a health failure the rig dumps the last 50
  log lines to the job log.

Workflow (.github/workflows/performance-ab.yml):
- On every run, write gate.md (or, on a pre-gate failure, the failing phase's
  server-log tails) into $GITHUB_STEP_SUMMARY; short artifact retention.
- Short measurement matrix (--duration/--rounds/--cooldown) so the expanded
  matrix fits; timeout-minutes 90 -> 120 as a Phase-0 stopgap until perf-3
  caches the baseline binary (the ~65min double build dominates).
- TODO(ci-8/perf-2) hook comment for the failure-alert composite action.

Workload cells (absorbed perf-4): add put-4kib/get-4kib (the #4221 fsync
regression size, ~-10% @4KiB, previously invisible) and get-10mib (historical
large-GET EOF size) to both the PR-labeled and nightly matrices — 6 workloads
x 2 phases x 2 drive-sync = 24 cells. A 1KiB cell is deferred to protect the
budget until perf-3.

Refs rustfs/backlog#1152 (perf-1, absorbed perf-4), rustfs/backlog#1155.
2026-07-10 20:52:25 +08:00
Zhengchao An 7cfd08d4f5 ci(mint): restore multi-SDK pipeline on ubuntu-latest + pin mint digest (#4668)
The mint multi-SDK compatibility pipeline had exactly one recorded run
(28730530597, 2026-07-05), which died at "Enable buildx": the self-hosted
sm-standard-4 pod it landed on had no /var/run/docker.sock, so
`docker buildx create` failed to connect to the Docker API before any test
ran. Same heterogeneous-fleet root cause ci-1 fixed for the weekly s3-tests
sweep.

- Pin the job to GitHub-hosted ubuntu-latest, which reliably ships a running
  Docker daemon + buildx + python3. Header note records the diagnosis and the
  dind-sm-standard-2 tradeoff.
- Pin MINT_IMAGE default to the linux/amd64 digest for minio/mint:edge
  resolved 2026-07-10 (was the rolling :edge tag); workflow_dispatch can still
  override. Header comment records the refresh command.
- Quote shellcheck-flagged vars and drop an unused loop var so actionlint
  passes with zero findings.
- Left report-only (ci-3 adds baseline gating later, per adjudication G4) and
  a TODO(ci-8) alerting hook on the scheduled job.

Refs: rustfs/backlog#1149 (ci-2), rustfs/backlog#1155
2026-07-10 20:51:27 +08:00
houseme 5f1a475c56 perf(ecstore): stop zeroing pooled shard buffers on the GET path (backlog#1159) (#4681)
`ShardBufferPool::take` handed out a `resize(len, 0)`-ed buffer, and the
reader then overwrote every byte of it. CPU profiling of a cached 1 MiB
GET (device reads = 0, so all cost is CPU) attributed 4.81% of the whole
server to that memset — a buffer pool exists to reuse an allocation, and
memsetting it gives the saving straight back.

The zeroing was load-bearing only because `BitrotReader::read` takes
`&mut [u8]`, which must be initialized. But the reader never reads what
the caller put there, and never returns a partially filled buffer: both
the hashed and the no-hash path either fill the whole shard or fail with
UnexpectedEof, and a hash mismatch is an error rather than a short read.
So the initialization bought nothing observable.

Add `BitrotReader::read_appending(&mut Vec<u8>, want)`, which appends into
the buffer's spare capacity instead of demanding initialized bytes:

  * hashed path — unchanged single copy, `extend_from_slice(data)` in place
    of `copy_from_slice` into a pre-zeroed buffer, and only after the hash
    verifies, so corrupt bytes never reach the caller's buffer;
  * no-hash path — `read_buf` writes straight into the spare capacity and
    advances the length only over bytes the reader actually wrote, so an
    uninitialized tail can never be exposed.

`ShardBufferPool::take` now yields an empty buffer with capacity, and
`read_shard` no longer needs to `truncate`. `read` keeps its old signature
for the remaining callers.

Four tests gate the contract rather than the call:
  * `read_appending` is byte-for-byte identical to `read` on both paths;
  * a truncated shard is UnexpectedEof, never a partially filled buffer;
  * bytes that fail the bitrot hash never reach the caller's buffer;
  * `want > shard_size` is rejected;
plus the pool test now asserts the allocation is reused (same pointer) and
never zeroed.

Verified: `erasure::` 213 passed, 0 failed; on a real Linux host
`erasure::` 209 and `disk::local::` 143 pass serially, and the failures
seen in a parallel full-suite run reproduce identically on unmodified main
(they are ENOSPC from a full root filesystem plus pre-existing flakes).

Not claimed: an end-to-end throughput number. The A/B on the bench host was
too noisy to attribute (one rep pair was not fully cached, and its root
filesystem filled mid-run); what is measured is that the removed memset was
4.81% of GET CPU in the pre-change profile.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 12:34:45 +00:00
Zhengchao An 80ddd8fa7e fix(ecstore): roll back delete on disks that staged then errored (#4676)
fix(ecstore): roll back delete on disks that staged then errored (backlog#1158)

#4300 rolls back a failed delete only on disks that returned Ok, skipping any
disk that staged its rollback backup, applied the delete, and then errored --
leaving that disk deleted while its peers are restored. On rollback, fan the
undo out to every online disk instead; the disk-side restore_delete_rollback
is already idempotent (Ok no-op when nothing was staged), so unstaged disks
are unaffected. The err.is_some() skip now applies only to the success/cleanup
path. Covers both single-object and batch delete.

Refs backlog#1158.
2026-07-10 20:34:19 +08:00
houseme b604074217 perf(object-data-cache): take fill off the GET path and fix the metrics (#4678)
* fix(object-data-cache): make cache metrics one-increment-per-GET

Rework the object data cache observability so each counter carries one
clear meaning, and drop dead per-entry state.

ODC-17 (backlog#1122): split requests_total, which was incremented by
both plan_get and lookup_body, into plan_total{decision,reason,size_class}
(emitted only by plan_get) and lookup_total{result,size_class} (emitted
only by lookup_body). Each is now incremented exactly once per GET per
layer; help text states this.

ODC-18 (backlog#1123): add a JoinedInflightFill result (label
joined_inflight) so a singleflight waiter is no longer counted as an
insert. record_fill_result now always counts the outcome but records
fill bytes only when non-zero and the duration histogram only when
present, so joined_inflight / skipped_by_mode / skipped_size_mismatch
no longer inflate fill_bytes_total or add non-fill duration samples.
The waiter->JoinedInflightFill mapping lives in MokaBackend (owned by a
concurrent branch); cache.rs handles the variant already.

ODC-29 (backlog#1134): stop refreshing the cache-state gauge on every
lookup, and debounce it on fill/invalidate to at most once per second
via an AtomicU64 millis timestamp, since moka's entry_count is a
settling approximation.

ODC-36 (backlog#1141): give invalidations_total an outcome label
(removed|noop) and skip the gauge refresh on the no-op path. Extend
ObjectDataCacheInvalidationResult with Removed{keys}/NoOp (Success kept
as a transitional variant until MokaBackend reports the removal count);
NoopBackend now reports NoOp.

ODC-30 (backlog#1141): drop the dead content_length/etag/inserted_at
fields (and getters) from ObjectDataCacheEntry, which are redundant with
the moka key identity; the constructor keeps its arity so the
out-of-scope MokaBackend caller still compiles. Gate is_null_version
behind cfg(test).

Tests use a thread-local metrics recorder to avoid the global-singleton
flakiness. Fill-enabled test configs set min_free_memory_percent=0 so
the cgroup gate does not refuse fills in CI pods.

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

* fix(object-data-cache): move fill off GET path, bound & harden the fill

Implements five object-data-cache audit findings.

ODC-15 (backlog#1120): the GET response no longer blocks on cache fill.
The buffered/materialized body is already in hand, so the fill now runs
in a detached task and the response is built immediately. Singleflight is
made non-blocking: `try_acquire` returns Leader or Busy, and a non-leader
skips its own fill (SkippedSingleflightBusy) instead of waiting on another
request's leader. The inner fill spawn is KEPT: once the index key is
registered, the recheck/undo must complete even if the enclosing fill
future is aborted, so removing it would weaken the cancellation-safety
guarantee (ODC-13 test) and the invalidation-race undo.

ODC-11 (backlog#1116): enforce the fill-concurrency knobs. MokaBackend
now holds a Semaphore sized min(per_cpu * parallelism, max), acquired
after winning leadership and before the memory gate. On saturation it
rejects (try_acquire_owned) with SkippedFillConcurrency rather than
queueing, so the fill path never reintroduces GET-latency coupling.

ODC-14 (backlog#1119): the memory gate no longer does a blocking sysinfo
refresh under a mutex on the async fill path. A dedicated periodic
refresher (tokio interval + spawn_blocking) updates an atomic snapshot
every 5s; allows_fill is now lock-free. The min_free_memory_percent == 0
short-circuit still runs before any snapshot read. No runtime at
construction (sync unit tests) simply keeps the seed snapshot.

ODC-32 (backlog#1137): singleflight no longer emits metrics while holding
the map mutex. try_acquire/remove_entry capture the map length under the
guard, drop it, then emit the gauge/counter.

ODC-07 (backlog#1112): the materialize read is bounded via
take(capacity + 1) so an over-long stream cannot grow the buffer past the
in-memory GET threshold, and any length mismatch is now a hard error
matching the direct-memory GET path, instead of warn-and-serve.

cache.rs is owned by a concurrent branch; this commit only appends the
SkippedFillConcurrency and SkippedSingleflightBusy variants + label arms.

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

* fix(object-data-cache): report JoinedInflightFill and invalidation outcome

Reconciles the moka_backend half of two metrics-semantics findings after
merging fix/odc-metrics-semantics (which landed the cache.rs half).

ODC-18 (backlog#1123): the singleflight non-leader path now returns
JoinedInflightFill instead of counting as an insert, so N concurrent GETs
of one cold key record one insert, not N. This composes with the ODC-15
redesign: the non-leader does NOT wait for the leader (it already owns the
body and never re-serves from the fill result), so no blocking wait is
reintroduced. Drops the redundant local SkippedSingleflightBusy variant in
favor of the canonical JoinedInflightFill (mapped to no bytes / no duration
by the facade). SkippedFillConcurrency (ODC-11) is retained.

ODC-36 (backlog#1141): MokaBackend::invalidate_object now returns
Removed { keys } / NoOp instead of the transitional Success, so the facade
labels invalidations_total{outcome=removed|noop} and skips the cache-state
gauge refresh on the no-op path.

Tests: duplicate-fill test asserts JoinedInflightFill (bites when reverted
to Inserted); new no-op invalidation test; matching-identity test asserts
Removed { keys: 1 }.

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

* fix(object-data-cache): drop unused mut on materialize stream binding

The ODC-07 change moves `final_stream` into `AsyncReadExt::take`, so the
`build_get_object_body_with_cache` parameter is no longer mutated in place.

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

* refactor(object-data-cache): close the cross-branch coordination seams

Merging the metrics and hot-path branches left four transitional shims that
only existed because each side could not edit the other's files. With both
sides present they are dead weight, and two of them actively misreport.

- ObjectDataCacheEntry::new took content_length and etag only to keep the
  caller in moka_backend.rs compiling, then discarded both. Drop them; the
  entry is a Bytes wrapper and the caller now passes only the body.

- SkippedSingleflightClosed lost its only producer when the waiter model was
  replaced by Leader/Busy election. Remove the variant.

- The fill-accounting match ended in a wildcard that charged fill_bytes and a
  duration to every non-Inserted outcome, so a fill rejected by the memory
  gate or the concurrency semaphore — which never touched the backend and
  wrote nothing — still inflated fill_bytes_total. Enumerate every variant
  explicitly: only outcomes that wrote the body report bytes and duration.
  Exhaustiveness also forces a future variant to state its accounting rather
  than inherit a wrong default.

- InvalidationResult::Success mapped a no-op to outcome=removed, the exact lie
  backlog#1141 set out to fix, and its doc comment claimed MokaBackend still
  returned it after that backend had been widened. It has no producer left.
  Remove it, and tighten the app-layer test from "any successful variant" to
  the real contract: the pre-mutation call reports Removed{keys:1}, the
  post-delete call NoOp.

Refs: backlog#1123, backlog#1141, backlog#1135

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 12:25:23 +00:00
houseme 8039a4ceae test(cache): end-to-end regressions for the body-cache hook P0s (#4675)
fix(ecstore): make body-cache hook re-registrable + add e2e regressions

ODC-21 (backlog#1126): the GET body-cache hook lived in a first-wins
OnceLock. When AppContext is rebuilt (config reload, test re-init) a fresh
ObjectDataCacheAdapter is constructed and re-registered, but the OnceLock
kept ecstore's GET probe pointed at adapter #1 while every usecase-layer
fill and invalidation targeted adapter #2 — silently degrading the feature
to a 0% hit rate with no error, log, or metric, and stranding entries in the
unreachable cache until their TTL.

Replace the slot with RwLock<Option<Arc<dyn GetObjectBodyCacheHook>>> so
re-registration atomically swaps to the newest adapter, and log at WARN when
a swap replaces a *different* instance (Arc::ptr_eq). RwLock over
ArcSwapOption because arc-swap's RefCnt is impl<T> (Sized, thin *mut T) and
cannot hold an Arc<dyn Trait> without a sized newtype wrapper; the probe
reads the slot once per full-object GET but only clones an Arc, negligible
next to the metadata quorum fan-out already done before the probe. Add a
test-only clear_get_object_body_cache_hook so tests register/unregister
deterministically.

With the hook now re-registrable, add true end-to-end regressions that drive
get_object_reader (not the full_object_plaintext_len predicate) against a
real erasure-coded, genuinely-compressed object via the blackbox
make_local_set_disks harness, with a stand-in hook playing the app-layer
cache (the injection point production uses; the adapter itself lives above
ecstore). These close the gap the predicate-only tests left — a caller that
opens a new shortcut serving the cached body directly, the original form of
both P0s:

- backlog#1108: a raw_data_movement_read must yield the STORED (compressed)
  bytes, never the cached plaintext.
- backlog#1109: a compressed cache hit must publish the DECOMPRESSED length
  as object_info.size (the UploadPartCopy invariant), with the streamed
  length matching.
- backlog#1146: a restore read (restore_request.days) must serve STORED
  bytes, not the cache.

Mutation-verified each e2e test bites: dropping the raw_data_movement_read
gate serves plaintext (fails #1108); removing the hit-site size republication
publishes 2972 vs 660000 (fails #1109); dropping the restore gate serves
plaintext (fails #1146).

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 12:17:20 +00:00
Zhengchao An d26e06bf46 docs: make CONTRIBUTING and Makefile help match real verification gates (#4667)
CONTRIBUTING.md and the Makefile HEADER advertised nonexistent targets
(make clippy, make check) and claimed 'make pre-commit' runs
format + clippy + check + test, while .config/make/pre-commit.mak
actually runs fmt-check + guard scripts + quick-check with no clippy
and no tests. CONTRIBUTING.md also claimed the repository ships an
automated pre-commit git hook, but no hook is versioned.

- Replace phantom targets with the real ones (clippy-check,
  quick-check, compilation-check)
- Document exactly what pre-commit and pre-pr run, gate by gate,
  with an explicit note that pre-commit runs no clippy and no tests
- Point contributors to 'make pre-pr' as the full pre-PR gate
- Rewrite the git-hooks section to say hooks are optional and not
  versioned; setup-hooks only marks an existing hook executable

No make targets were added or changed; docs/help truth only.

Refs: rustfs/backlog#1153 (infra-9), rustfs/backlog#1155
2026-07-10 20:08:37 +08:00
Zhengchao An 89ea931ee1 test(ci): strict nextest ci profile with quarantine + flake policy (#4666)
test(ci): add strict nextest ci profile with quarantine + flake policy

Formalize the existing ecstore-serial-flaky mechanism into a strict CI gate
(ci-10, absorbs infra-15; backlog#1149).

- .config/nextest.toml: add [profile.ci] with global retries=0 (never mask a
  new race's first occurrence), fail-fast=false, and JUnit output at
  target/nextest/ci/junit.xml. Add a quarantine section where flaky tests get
  retries=2 under the ci profile only; each entry links one OPEN issue. First
  members are the two backlog#937 ecstore groups
  (concurrent_resend_same_part_commits_one_generation and
  store::bucket::tests::bucket_delete_*), which keep their existing
  ecstore-serial-flaky test-group serialization. Local default profile still
  never retries.
- .github/workflows/ci.yml: run the main test step with --profile ci and upload
  the JUnit report (if: always(), 3-day retention, run-number in name). The
  migration-proof step stays on the default profile to avoid clobbering the ci
  JUnit artifact (its tests are not quarantined).
- docs/testing/README.md: new skeleton (owned by backlog#1153 infra-11) holding
  the flake policy: discover -> open issue within 24h -> quarantine with issue
  link -> fix or delete within 30 days. AGENTS.md points to it.

Refs: rustfs/backlog#1149, rustfs/backlog#937, rustfs/backlog#1155
2026-07-10 20:08:31 +08:00
Zhengchao An f0b1202b65 ci(s3tests): fix weekly sweep runner infra (pin ubuntu-latest + setup-python) (#4665)
The weekly full ceph/s3-tests sweep (e2e-s3tests.yml, schedule path) has
failed every recorded run. The 2026-07-05 run (rustfs/rustfs #28727691591)
died at "Install Python tools" with `No module named pip`: the self-hosted
`sm-standard-4` label is served by heterogeneous runners and the scheduled
job landed on a minimal pod with neither `pip` (bare python3) nor
`docker.sock`, so no test ever executed.

Make the infrastructure assumptions explicit instead of trusting drifting
self-hosted runner state:
- Pin the job to GitHub-hosted `ubuntu-latest`, which reliably ships Docker,
  docker compose, python3 and pip.
- Add an explicit actions/setup-python step before any pip usage so the
  workflow stays correct across runner-image drift.
- Document the fix and rationale in the workflow header comment.

Also quote the shell variables flagged by shellcheck (SC2086) in the
Docker build/run steps and drop the unused loop variable (SC2034) so
actionlint passes with zero findings on the changed file.

The PR gate (ci.yml s3-implemented-tests) is unaffected: it avoids Docker
via DEPLOY_MODE=binary and defers pip setup to run.sh's self-bootstrap.

Refs: rustfs/backlog#1149 (ci-1), rustfs/backlog#1155
2026-07-10 20:08:26 +08:00
Zhengchao An 2d1323d2f0 ci(fuzz): narrow PR triggers and cover all fuzz targets (#4664)
Re-enable the Fuzz workflow (disabled_manually) with two changes that
address the congestion that likely caused it to be disabled:

- Narrow the PR trigger paths to fuzz/**, scripts/fuzz/** and
  fuzz.yml only. The broad crate paths (crates/ecstore|filemeta|utils)
  queued a ~45min fuzz-build on nearly every PR; coverage of those
  crates moves to the nightly schedule, which fuzzes against main.
- Expand the smoke matrix, nightly matrix, run.sh default set and the
  fuzz-build staging/upload steps from 3 to all 5 buildable targets:
  archive_extract, bucket_validation, local_metadata, path_containment,
  policy_ingress. archive_extract and policy_ingress were previously
  in no matrix.

The two *_storage_api.rs files are `mod` submodules of their parent
targets (bucket_validation, path_containment), not standalone bins, so
fuzz/Cargo.toml registers exactly 5 fuzz bins.

Smoke jobs run one target per parallel matrix job (-max_total_time=60),
so wall-clock stays per-target, well under the 15min budget for a
fuzz-only PR. A TODO(ci-8) hook marks where scheduled-failure alerting
will attach on the nightly job.

Refs rustfs/backlog#1149 (ci-9, absorbed sec-13), rustfs/backlog#1155
2026-07-10 20:08:03 +08:00
Zhengchao An 36428bc490 fix(rustfs): allow drop_non_drop on dial9 guard for non-Drop feature sets (#4679)
Under feature sets where the dial9 session guard carries no Drop impl (e.g.
--features sftp, after #4663), `drop(dial9_guard)` in the startup entrypoint
trips clippy::drop_non_drop and fails the sftp Test and Lint job. The drop is
an intentional flush point where the guard does implement Drop; annotate it so
it compiles clean across all feature combinations.
2026-07-10 20:00:22 +08:00
houseme 00536da80c refactor(obs): make dial9 telemetry opt-in and actually record events (#4663)
* refactor(obs): make dial9 telemetry opt-in and actually record events

The dial9 Tokio-runtime profiler was disabled by default, yet every build
paid for it, and enabling it produced trace files with no events in them.

Recorded empty traces
---------------------
`build_traced_runtime` called `TracedRuntime::builder()...build(..)`, but dial9
only starts recording in `build_and_start*`. `build` still returns a live guard
whose `is_enabled()` reports true, and still creates and seals segment files —
they just contain a header and no events. It also skipped `with_trace_path`, so
the background worker driving the segment pipeline was never spawned.

Measured on the new smoke example: 310 bytes of bare segment header, against
5640 bytes for the same workload once recording actually starts.

Switch to `with_trace_path(..).build_and_start(..)`.

Cost was unconditional
----------------------
`--cfg tokio_unstable` was a global `[build] rustflags` entry and `rustfs-obs`
depended on `dial9-tokio-telemetry` unconditionally, so all builds depended on
Tokio's non-semver API. Worse, an environment `RUSTFLAGS` replaces (never
appends to) the config-file value, so any caller exporting their own RUSTFLAGS
silently dropped the flag — the long comment in build.yml was a scar from that.

dial9 is now an opt-in feature (`dial9`, plus `dial9-s3` and `dial9-taskdump`),
the global rustflag is gone, and `crates/obs/build.rs` fails the compile if the
feature is on without the flag. Telemetry builds go through `make build-profiling`.

Metrics that could not lie
--------------------------
`rustfs_dial9_{events_total,bytes_written_total,rotations_total,cpu_overhead_percent}`
were hard-coded to zero — a Counter pinned at 0 reads as "nothing happened".
Removed. `rustfs_dial9_enabled` was sourced from the environment, so it read 1
even when the traced runtime failed and the process fell back to a standard
runtime; it is replaced by `rustfs_dial9_supported` (compile-time),
`rustfs_dial9_configured` (intent) and `rustfs_dial9_active_sessions` (reality).

No `writer_healthy` gauge is exported: dial9's `RotatingWriter` can enter its
`Finished` state and stop writing, but exposes no way to observe that, so the
gauge could only ever be hard-coded to 1. Documented as a known gap instead.

Final events were lost
----------------------
The `TelemetryGuard` lived in a `static OnceLock`, which is never dropped, so
buffered events were never flushed at exit. `build_tokio_runtime` now returns
the guard and `run_process` drops it before any exit path.

Also
----
- `disk_usage_bytes` was a `read_dir` + per-file `stat` on the metrics
  collection path. It is now sampled by a background task into an atomic.
- `SAMPLING_RATE`/`S3_BUCKET`/`S3_PREFIX` were parsed, warned about, and
  discarded. S3 upload is now wired to dial9's `with_s3_uploader` behind
  `dial9-s3`; `SAMPLING_RATE` has no upstream equivalent and is removed.
- Wire `with_task_dumps` (async backtraces of stalled tasks), configurable via
  `RUSTFS_RUNTIME_DIAL9_TASK_DUMP_{ENABLED,IDLE_THRESHOLD_MS}`.
- Split `telemetry/dial9.rs` into `config`/`state`/`enabled`/`disabled`; the
  stub keeps the public API identical so callers need no `#[cfg]`.
- Drop four print-only examples and the manual test bin that exercised the
  removed `init_session` scaffolding.

Verified: cargo check/clippy/test across default, `dial9`, and `dial9-s3`;
build.rs correctly rejects `dial9` without `--cfg tokio_unstable`;
`make pre-commit` passes.

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

* docs(obs): document dial9 as an on-demand profiler

scripts/run.sh advertised a `SAMPLING_RATE` knob that was never passed to dial9,
and claimed "CPU overhead < 5% (with sampling rate 1.0)" and "lower values reduce
CPU overhead" on the strength of it. The knob is gone; the guidance built on it
had to go too.

Replace it with what is actually true: dial9 needs a `make build-profiling`
binary, its disk budget evicts oldest-first (so a high poll rate can overwrite
the incident you are chasing), and it cannot be toggled without a restart.

Add docs/operations/dial9-runtime-profiling.md covering the build variants, an
investigation walkthrough, the configuration table, how to read the three
supported/configured/active_sessions gauges against each other, and the upstream
gap that makes writer death only indirectly observable.

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

* test(obs): add a dial9 smoke example that proves events are recorded

The bug this guards against is invisible to every existing signal: with
`build` instead of `build_and_start`, dial9 creates the trace file, seals
segments, and reports `TelemetryGuard::is_enabled() == true` — it simply
records no events. Only the segment's byte count tells the two apart.

Measured on this workload: 5640 bytes when recording, 310 bytes (a bare
segment header) when not. The example asserts >= 2048 bytes, and was verified
to fail with the `build` call restored.

Also correct the comment on the `is_enabled` check in `finish_traced_runtime`.
It claimed to catch "recording silently off"; it does not. It only rejects the
inert guard a lenient config yields after a build failure. Recording is
guaranteed by `build_and_start`, not by that check.

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

* test(rustfs): accept Unsupported runtime telemetry capability

A binary built without the `dial9` feature now reports the runtime-telemetry
capability as `Unsupported` rather than `Disabled`. The distinction matters to
operators: `Disabled` implies the capability can be switched on by setting an
environment variable, which is not true here — telemetry needs a rebuild.

Widen the assertion and pin the new semantics: when `dial9::is_supported()` is
false, the state must be exactly `Unsupported`.

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

* fix(obs): drop the dial9-s3 feature, its TLS stack is vulnerable

CI's Dependency Review and `cargo deny` both reject the branch: dial9's
`worker-s3` feature depends on aws-sdk-s3-transfer-manager 0.1.3, which pins
aws-smithy-http-client onto hyper-rustls 0.24 and rustls-webpki 0.101.7. That
webpki carries RUSTSEC-2026-0098, -0099 and -0104.

0.1.3 is the latest release of the transfer manager, and 1.2.0 the latest of the
smithy client, so there is nothing to upgrade to. Cargo's feature unification can
add features but cannot drop a transitive dependency, so it cannot be worked
around from here either — the rest of the workspace already resolves to the safe
rustls-webpki 0.103 / hyper-rustls 0.27.

Remove the `dial9-s3` feature and the `with_s3_uploader` wiring. The two S3
environment variables stay parsed and warned about, now naming the real reason
rather than a missing build feature. Trace segments are collected from the output
directory instead. Tracked as D9-14 in rustfs/backlog#1157.

With this, Cargo.lock is byte-identical to main: the PR no longer touches the
dependency graph at all.

Also correct the `dial9-taskdump` documentation. It claimed the feature "compiles
to a no-op elsewhere"; in fact `tokio/taskdump` raises a `compile_error!` on any
target other than linux/{aarch64,x86,x86_64}. Verified by trying to build it on
macOS, which is how the claim was found to be wrong.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 10:52:48 +00:00
houseme f83f9ada13 fix(ecstore): reclaim page cache after io_uring reads (#4662)
fix(ecstore): io_uring reads must reclaim the page cache like StdBackend (backlog#1145)

`StdBackend::pread_bytes` calls `fadvise(DONTNEED)` over the range it just
read whenever `should_reclaim_file_cache_after_read(length)` holds —
`RUSTFS_OBJECT_FILE_CACHE_RECLAIM_READ_ENABLE` (on by default) above a 4 MiB
threshold. Large object reads are usually cold, and leaving them resident
evicts everything else, so this is a deliberate policy rather than a side
effect of how StdBackend happens to read.

`pread_uring` and `pread_uring_direct` never did it. Enabling io_uring on a
disk therefore turned the policy off silently: shard reads at or above the
threshold stayed resident and grew the page cache without bound. This is the
same class of mistake as the O_DIRECT loss fixed earlier — copying the read
itself while dropping the policy attached to it.

It also badly distorts any benchmark. An end-to-end warp GET A/B on a
16-core host (4 MiB objects, conc 64) showed io_uring at 8782 MiB/s against
StdBackend's 116 MiB/s. That is not a 76x speedup: with io_uring the run
issued *zero* device reads and grew the page cache, while StdBackend read
2950 MiB from the device and shrank it. The two legs were not running the
same policy. (Disabling the mmap read path changed nothing — 1.01x — so the
mmap-vs-pread difference was not the cause either.)

Both io_uring read paths now reclaim the range they read, with the same gate,
the same range, and the same error handling as StdBackend. O_DIRECT should
leave nothing resident anyway, but a filesystem that quietly buffered the read
still has to honour the policy, so `pread_uring_direct` reclaims too.

The test measures the policy, not the call: it reads an 8 MiB file through
each backend and asks `mincore(2)` how much stayed resident. Crucially it also
asserts the reclaim-disabled case leaves the range resident — without that
gate, a backend that never reclaimed would pass silently. Verified: with the
fix removed, the test fails with "uring: reclaim is on ... 2048/2048 pages
remain"; with the fix it passes.

Verified on a real Linux host (16-core, real io_uring): clippy --tests
-D warnings clean; disk::local tests 143 passed, 0 failed.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 18:50:47 +08:00
Henry Guo 99eef032c6 fix(ecstore): restore Windows async read trait import (#4652)
* fix(ecstore): restore Windows async read trait import

* fix(ecstore): gate async read import to non-Unix
2026-07-10 18:28:29 +08:00
Henry Guo f16878ef23 fix(table-catalog): pass PyIceberg live smoke (#4637)
* fix(table-catalog): pass PyIceberg live smoke

* fix(table-catalog): satisfy live smoke clippy

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-07-10 18:28:15 +08:00
houseme 904554b417 fix(object-data-cache): make memory container-aware and bound cache config (#4655)
* fix(object-data-cache): bound cache config and make memory container-aware

Harden the object data cache configuration path so a single bad input
degrades to the disabled adapter instead of OOM-killing pods, panicking
at boot, or silently mis-sizing the cache (backlog#1110/1113/1114/1115/
1127/1140/1130).

- ODC-05: resolve capacity and the fill gate from the effective
  (container-aware) memory. Prefer sysinfo cgroup_limits() and use
  min(host, cgroup) as the total plus cgroup-derived availability, falling
  back to host values when no cgroup limit constrains. Log the resolved
  capacity and its basis once at startup.
- ODC-08: cap ttl/time_to_idle at 30 days in validate() so an unbounded
  Duration can no longer trip moka's ~1000-year builder assertion.
- ODC-09: drop the max_entry_bytes floor from the derived-capacity clamp so
  MAX_ENTRY_BYTES can no longer inflate total capacity above the safety
  clamp; reject an entry larger than the resolved capacity instead.
- ODC-10: require an explicit max_bytes to clear max_entry_bytes plus the
  weigher overhead so a fillable-but-unretainable cache is rejected.
- ODC-22: reject max_entry_bytes at/above the u32 weigher boundary.
- ODC-35: warn (not reject) when time_to_idle exceeds ttl and document the
  min(ttl, time_to_idle) expiry interaction.
- ODC-25: give numeric env overrides two-valued semantics via a new
  rustfs_utils::get_env_parse_outcome; a malformed value now disables the
  whole cache with one aggregated warning instead of silently keeping
  defaults.

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

* fix(object-data-cache): require identity_keys_max of at least 2

The identity index admits a new key by evicting the oldest one, so a
budget of 1 evicts the previous key on every fill and can never hold two
live keys of one object at once — a versioned bucket alternating two
versions then hits a permanent 0% hit rate.

Reject the degenerate value at config validation time, where the adapter
turns it into a disabled cache with a warning, since the bounded-eviction
policy cannot rescue it at runtime.

Handed off from the identity-index batch (backlog#1128), which changed the
overflow policy but could not touch validate().

Refs: backlog#1115, backlog#1128

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

* fix(object-data-cache): let a zero free-memory floor opt out of the gate

Making the memory gate container-aware turned it live in CI: the runners
are Kubernetes pods, so the gate now reads the pod's cgroup free memory,
finds it below the 20% floor, and refuses the fill. Tests that assert a
fill succeeds then failed on CI while passing on a developer host, which
has no cgroup and falls back to host memory.

The gate is behaving correctly — the tests were the ones depending on a
live memory reading. Treat min_free_memory_percent = 0 as a deliberate
opt-out rather than an invalid value: allows_fill returns early before it
touches any snapshot, so admission becomes independent of where the suite
runs. Operators gain the same escape hatch.

Every test that requires a fill to succeed now sets the floor to 0. The
tests that exercise the gate keep it enabled via memory_gated_config, so
the ODC-05 coverage they provide is preserved rather than short-circuited.

Refs: backlog#1110

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

* fix(object-data-cache): opt the usecase fill tests out of the memory gate

The previous commit exempted the fill-dependent tests it could find, but
missed the six adapters built inside object_usecase.rs. Fixing the first
batch moved nextest's fail-fast point forward and CI surfaced them:
build_get_object_body_with_cache_materializes_once_and_hits_later and
..._uses_cached_body_without_reader_preread both assert a fill lands, and
both ran with the default 20% free-memory floor.

Set the floor to 0 on all six, matching the sibling test modules. Verified
by pinning the gate's snapshot to 0% available — harsher than any CI pod —
and confirming these tests still pass, which shows the exemption path never
reads memory at all.

An exhaustive sweep over every fill-enabled ObjectDataCacheConfig in the
tree now shows no remaining site: the only unexempted ones are
fill_enabled()'s matches! arm and two adapter tests that assert the adapter
is disabled and therefore never fill.

Refs: backlog#1110

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 09:17:32 +00:00
houseme 15b8b13698 feat(ecstore): cache part-file descriptors for io_uring reads (#4658)
* feat(ecstore): run one io_uring ring per shard on each disk (backlog#1145)

A buffered read that hits the page cache completes inline inside
`io_uring_enter`, so the thread driving a ring performs that read's
memcpy. One ring per disk therefore capped cache-hit reads at a single
core's memory bandwidth: measured on a 16-core host, one driver thread sat
pinned at 100% CPU while throughput stayed flat at ~5 GB/s regardless of
read size, against 50 GB/s for the blocking-pool baseline.

rustfs/uring#6 taught the driver to hold N independent rings, each with its
own thread, pending table, backpressure semaphore, and eventfd. Wire it up:
`UringBackend::try_new` now calls `probe_and_start_sharded`, and
`RUSTFS_IO_URING_SHARDS` selects the count per disk.

The default is a quarter of the available parallelism clamped to `1..=4`,
because the cost is `disks × shards` driver threads (each normally blocked
in `poll(2)`). Any override is clamped to `1..=16`, so a mistyped value can
neither disable the driver (0) nor spawn threads without bound; an
unparseable value falls back to the default.

Effect (warm page cache, 16-core, rustfs/uring's concurrent_pread_bench):

  1 MiB, conc 8:    1 shard  4911 MB/s -> 8 shards 47361 MB/s (9.6x);
                    the blocking-pool baseline is 50662 MB/s
  64 KiB, conc 32:  StdBackend 153678 IOPS, p999 3030 us
                    8 shards   345402 IOPS, p999  897 us
  64 KiB, conc 128: StdBackend 135155 IOPS, p999 10716 us
                    8 shards    389047 IOPS, p999  4092 us

Sharding removes the throughput deficit *and* keeps io_uring's tail-latency
advantage, rather than trading one for the other.

Unchanged: io_uring read stays gray-off by default
(`RUSTFS_IO_URING_READ_ENABLE`), reads are byte-for-byte identical to
StdBackend, the per-disk degradation latches and probe cache (backlog#1101)
and the O_DIRECT tiered fallback (backlog#1102) all still apply. Rings stay
per-disk, so a stalled disk cannot starve another disk's rings
(backlog#1055). Bumps the rustfs-uring pin to the merged #6 commit.

Verified on a real Linux host (16-core, real io_uring): cargo clippy
--tests -D warnings clean; disk::local tests 132 passed, 0 failed —
including the existing io_uring and O_DIRECT cases now running on the
sharded driver, plus a new test covering the shard-count default, override,
and clamping.

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

* feat(ecstore): cache part-file descriptors for io_uring reads (backlog#1145)

`pread_uring` opened the file on the blocking pool for every read, so each
read paid a `spawn_blocking` round trip — the very thread hop io_uring
exists to avoid. Sharding the driver (backlog#1145) removed the previous
ceiling and left this as the binding cost. Measured on a 16-core host with
a 4-shard driver, warm page cache:

  64 KiB, conc 8:   143942 -> 263054 IOPS (+83%),  p999 240 -> 65 us
  64 KiB, conc 32:  150128 -> 204876 IOPS (+36%),  p999 2508 -> 871 us
  64 KiB, conc 128: 129172 -> 361287 IOPS (+180%), p999 15329 -> 3046 us
  1 MiB,  conc 32:   33875 ->  42301 IOPS (+25%)

At 64 KiB / conc 128 the open is what masked io_uring entirely: with it,
io_uring beat StdBackend by 3.5%; without it, by 189%.

Add a bounded per-disk descriptor cache used by the io_uring read path.
A hit takes no `open` and no `spawn_blocking`, so the read never leaves the
runtime worker.

Why caching a part-file descriptor is safe:
  * only `<object>/<data_dir>/part.N` reaches this backend's `pread_bytes`;
    `xl.meta` — the one path replaced in place — is read through `read_all`
    / `read_metadata` and never gets here;
  * part files are never rewritten in place. A replacement is always
    write-new-tmp then `rename`, which swaps the inode, so a cached
    descriptor can never observe a torn shard.

Why invalidation is nevertheless REQUIRED: heal reuses the existing
version's `data_dir` and renames a rebuilt shard onto the SAME part path.
A cached descriptor would keep serving the pre-heal (corrupt) inode,
defeating the heal and eroding read quorum. `delete` likewise unlinks a
part that a cached descriptor would keep readable. So `rename_data`,
`rename_file`, and `delete` all call the new
`LocalIoBackend::invalidate_cached_fds` after they mutate, and a 5s TTL
bounds the blast radius should a future mutation path forget to.

Two preamble checks the miss path runs are not silently lost on a hit:
  * bounds — the driver only short-reads at EOF (it resubmits otherwise),
    so `bytes.len() != length` is exactly the old `meta.len() < end_offset`
    check, and now yields the same `FileCorrupt`;
  * volume access — skipped while an entry is live. An unreachable disk
    keeps serving already-open descriptors for at most the TTL, after which
    the re-open re-runs the check. Disk health is tracked independently of
    this per-read probe.

Scope: buffered io_uring reads only. The O_DIRECT path keeps opening per
read (its reads are >= 4 MiB, so the open is a small fraction), and
StdBackend is untouched — it must take the blocking hop for the pread
regardless, so caching would buy it only 2-6% while carrying the same
invalidation risk. `RUSTFS_IO_URING_FD_CACHE=false` restores open-per-read.

Verified on a real Linux host (16-core, real io_uring): clippy --tests
-D warnings clean; disk::local tests 135 passed, 0 failed. The new
heal-staleness test first asserts a read still returns the PRE-heal bytes —
proving the cache is live and the hazard real — then that invalidation makes
the healed shard visible. A second test drives `rename_file` and `delete`
through `LocalDisk` to prove those paths actually invalidate, and a unit
test pins prefix invalidation to component boundaries (`a/b` must not drop
`a/bc`).

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 08:33:50 +00:00
Zhengchao An aaffd3ede8 fix(ecstore): align Deep verifier shard geometry (#4656) 2026-07-10 08:06:38 +00:00
houseme d29e637890 fix(object-data-cache): close identity-index races in fill/invalidate (#4657)
fix(object-data-cache): close identity-index races in fill/invalidate (backlog#1117/#1118/#1125/#1128/#1136)

The object body cache's fill-vs-invalidation contract (index.insert before
cache.insert, then re-check index membership and undo the fill on mismatch)
had several races where the identity index and the cache could drift, either
poisoning the race metric, breaking invalidation silently, or letting a
principal evict a hot body on demand.

ODC-12 (backlog#1117) — generation-aware index removal.
The eviction listener removed (identity, key) by key equality with no way to
tell an evicted old entry from a freshly refilled one under the same key.
Verified against moka 0.12.15: an insert over a TTL/TTI-expired but not yet
purged entry runs do_insert_with_hash inline during insert().await, and
do_post_update_steps awaits notify_upsert INLINE with cause Expired
(was_evicted() == true), so the listener deleted the index key the refill just
registered and the post-insert recheck then self-destructed the fresh entry
(SkippedInvalidationRace). A deferred Size notification for an old generation
had the same effect on a cleanly refilled key.
Chose Option A (generation token) over Option B (re-insert after
cache.contains_key): B removes the key first and only reconciles afterward,
which reopens a narrow window where a concurrent invalidate_object misses the
key while it is transiently absent from the index. A never removes a key whose
generation does not match, so the decision is atomic under the shard lock. The
token is the cache entry's Arc pointer captured at fill time (readable from the
value moka hands the listener), so no change to the entry type is needed.

ODC-13 (backlog#1118) — cancellation-safe insert/recheck/undo.
The recheck+undo sat after two awaits inside the S3 GET future; a client
disconnect could cancel the fill at the recheck await after cache.insert made
the entry visible, stranding a stale body with no index entry. The
register/insert/recheck/undo sequence now runs in a spawned task whose
JoinHandle the leader awaits: cancelling the await detaches the task, which
still finishes the recheck and undo. The dropped leader unblocks waiters as
before (SkippedSingleflightClosed), so no waiter is stranded.

ODC-20 (backlog#1125) — do not prune in-flight sibling keys.
prune_missing could remove another in-flight fill's index key (a different key
of the same identity that had registered but not yet published its cache
entry), making that fill's recheck fail and delete its own valid entry. Added a
read-only ObjectDataCacheSingleflight::has_inflight and consult it in the prune
predicate alongside cache.contains_key.

ODC-23 (backlog#1128) — bounded eviction instead of clear-and-reject.
Exceeding identity_keys_max drained the whole key set and rejected the incoming
key, so a principal able to GET identity_keys_max+1 distinct versions could
evict an object's hot body on demand, and identity_keys_max=1 on a versioned
bucket meant a permanent 0% hit rate. The key set now evicts only the oldest
key(s) (the Vec preserves insertion order) and inserts the new key. The insert
result carries evicted_keys so the backend removes exactly those from the cache
and still caches the newest body. The Overflow variant and the now-unused
SkippedIdentityOverflow construction are gone.
NOTE: the audit also asks for identity_keys_max >= 2 validation; that lives in
config.rs (out of scope here) and is deferred to the config batch.

ODC-31 (backlog#1136) — deterministic race coverage.
Added a #[cfg(test)] fill barrier between the index insert and the cache insert
so the fill-vs-invalidation recheck can be driven deterministically, plus a
companion test asserting the identity-budget eviction drops the evicted keys'
cache entries.

New tests:
- index: key_set_bounded_eviction_evicts_oldest_and_inserts_new,
  key_set_removes_only_matching_generation_token,
  key_set_duplicate_refreshes_generation_token
- starshard_index: identity_index_bounded_eviction_evicts_oldest,
  identity_index_stale_eviction_preserves_refreshed_key,
  identity_index_matching_eviction_removes_key
- moka_backend: moka_backend_refill_after_expiry_without_maintenance_inserts,
  moka_backend_concurrent_fills_same_identity_both_insert,
  moka_backend_identity_budget_evicts_oldest_and_caches_newest,
  moka_backend_fill_loses_race_to_invalidation,
  moka_backend_cancelled_fill_still_undoes_lost_race

cargo fmt --all, cargo check/clippy/test -p rustfs-object-data-cache all pass
(44 tests). Verified the refill-after-expiry and concurrent-fill tests fail
without their respective fixes.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 08:04:57 +00:00
houseme 8c76efead2 fix(cache): stop the GET body-cache hook from bypassing ReadPlan (#4654)
* fix(cache): gate GET body-cache hook to preserve ReadPlan output

The ecstore GET body-cache hook serves cached full-object plaintext
directly, bypassing ReadPlan/ReadTransform. That is only sound when the
normal read path returns that same plaintext byte-for-byte. Two probe
conditions were missing, plus two usecase-layer planner gaps.

ODC-01 (backlog#1108): raw/data-movement reads. ReadPlan::build returns
the STORED representation for raw_data_movement_read (e.g. compressed
bytes, length = oi.size), but the cache holds the post-decompression
body. Decommission (raw_data_movement_read: true) would receive
decompressed plaintext where raw compressed bytes are required, silently
corrupting the destination pool.

ODC-02 (backlog#1109): compressed objects. ReadTransform::Compressed
rewrites object_info.size to the decompressed length; on a hook hit
object_info is returned unchanged, so object_info.size is the compressed
size while the stream carries the decompressed body. UploadPartCopy then
uses src_info.size as the copy length and truncates the part.

Fix: gate the hook probe with should_probe_body_cache_hook, refusing
raw_data_movement_read, data_movement, and compressed objects, mirroring
the conditions get_small_object_direct_memory_decision already applies.

ODC-33 (backlog#1138): build_get_object_body_cache_plan lacked the
is_remote() exclusion the ecstore hook enforces; add it so transitioned
(remote-tier) objects are excluded uniformly.

ODC-C1 (backlog#1142): zero-length bodies save no I/O (ecstore returns an
empty body before the hook probe) yet the planner admitted them; change
the guard to response_content_length <= 0 so they plan Skip, mirroring
should_buffer_get_object_in_memory_with_threshold.

Tests: body_cache_hook_gate_tests (4) cover plain-probe plus
raw/data-movement/compressed skips; planner gains
plan_skips_remote_transitioned_objects and plan_skips_zero_length_objects.

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

* fix(cache): allow compressed bodies via a fail-closed read allow-list

The body-cache hook probe was gated by a deny-list that refused compressed
objects outright, which cost the cache every compressed body — a growing
share of stored data. Replace it with an allow-list that returns the exact
plaintext length a hit may serve, or None.

full_object_plaintext_len() answers a single question: would the normal
ReadPlan produce this object's complete plaintext, and under which size?
Compressed objects now qualify, and the hit site publishes the returned
length as object_info.size, reproducing the contract ReadTransform::
Compressed establishes. A hit whose body length disagrees is refused and
falls through to the erasure read.

This also closes a gate the deny-list only covered by accident: a restore
read forces ReadPlan down the Plain branch, so a compressed object yields
STORED bytes under its compressed size. Refusing compressed objects hid
that; admitting them exposes it, so restore reads are refused explicitly.

Being fail-closed, a newly added ReadPlan branch bypasses the cache by
default rather than silently serving the wrong representation — the
structural defect behind both backlog#1108 and backlog#1109.

Refs: backlog#1108, backlog#1109

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 08:01:10 +00:00
GatewayJ a269f8df05 fix(ecstore): require write quorum for metadata early stop (#4300) 2026-07-10 15:57:09 +08:00
Zhengchao An 40cf94f243 docs(tier-ilm): note local-first invariant for expire/GET race fix (#4651) 2026-07-10 15:56:54 +08:00
houseme fe682e16e9 feat(ecstore): run one io_uring ring per shard on each disk (backlog#1145) (#4653)
A buffered read that hits the page cache completes inline inside
`io_uring_enter`, so the thread driving a ring performs that read's
memcpy. One ring per disk therefore capped cache-hit reads at a single
core's memory bandwidth: measured on a 16-core host, one driver thread sat
pinned at 100% CPU while throughput stayed flat at ~5 GB/s regardless of
read size, against 50 GB/s for the blocking-pool baseline.

rustfs/uring#6 taught the driver to hold N independent rings, each with its
own thread, pending table, backpressure semaphore, and eventfd. Wire it up:
`UringBackend::try_new` now calls `probe_and_start_sharded`, and
`RUSTFS_IO_URING_SHARDS` selects the count per disk.

The default is a quarter of the available parallelism clamped to `1..=4`,
because the cost is `disks × shards` driver threads (each normally blocked
in `poll(2)`). Any override is clamped to `1..=16`, so a mistyped value can
neither disable the driver (0) nor spawn threads without bound; an
unparseable value falls back to the default.

Effect (warm page cache, 16-core, rustfs/uring's concurrent_pread_bench):

  1 MiB, conc 8:    1 shard  4911 MB/s -> 8 shards 47361 MB/s (9.6x);
                    the blocking-pool baseline is 50662 MB/s
  64 KiB, conc 32:  StdBackend 153678 IOPS, p999 3030 us
                    8 shards   345402 IOPS, p999  897 us
  64 KiB, conc 128: StdBackend 135155 IOPS, p999 10716 us
                    8 shards    389047 IOPS, p999  4092 us

Sharding removes the throughput deficit *and* keeps io_uring's tail-latency
advantage, rather than trading one for the other.

Unchanged: io_uring read stays gray-off by default
(`RUSTFS_IO_URING_READ_ENABLE`), reads are byte-for-byte identical to
StdBackend, the per-disk degradation latches and probe cache (backlog#1101)
and the O_DIRECT tiered fallback (backlog#1102) all still apply. Rings stay
per-disk, so a stalled disk cannot starve another disk's rings
(backlog#1055). Bumps the rustfs-uring pin to the merged #6 commit.

Verified on a real Linux host (16-core, real io_uring): cargo clippy
--tests -D warnings clean; disk::local tests 132 passed, 0 failed —
including the existing io_uring and O_DIRECT cases now running on the
sharded driver, plus a new test covering the shard-count default, override,
and clamping.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 07:41:11 +00:00
houseme baf7e899b9 fix(ecstore): bound every walk filesystem call by the stall budget (#4650)
The listing path no longer has a wrapper-level total timeout, so any
filesystem call the walk makes without a stall bound would have no liveness
bound at all. The first pass covered list_dir and read_metadata but left four
reads unbounded: the volume access() probe and fs::metadata() in walk_dir, the
xl.meta read in its dir-object branch, and is_empty_dir() in scan_dir.

Split the helper so reads that do not yield a Result go through the same
budget, and route the four remaining calls through it. A hung drive now fails
those reads with DiskError::Timeout instead of parking the walk until the
merge consumer's own stall timeout aborts it.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 05:16:55 +00:00
houseme 8b233cdd94 fix(ecstore): keep O_DIRECT for eligible reads via native io_uring (backlog#1102) (#4649)
fix(ecstore): keep O_DIRECT for eligible reads via native io_uring (rustfs/backlog#1102)

Wire ecstore's io_uring read backend to rustfs-uring's native aligned
O_DIRECT read (`read_at_direct`, merged in rustfs/uring#3), so an
O_DIRECT-eligible read keeps BOTH io_uring's async submission AND
O_DIRECT's page-cache bypass instead of trading one for the other.

`UringBackend::pread_bytes` now selects the read shape by a tiered,
per-disk-latched ladder — never a blanket downgrade:

  1. io_uring latched off for this disk (backlog#1101) -> StdBackend.
  2. O_DIRECT-eligible + native path usable -> pread_uring_direct: open
     the file O_DIRECT, probe the device alignment once (cached), and
     read via read_at_direct. Best path: async + no cache pollution.
  3. O_DIRECT-eligible but the fs refused O_DIRECT earlier (latched)
     -> StdBackend aligned path (which itself degrades to buffered).
  4. non-O_DIRECT read -> buffered pread_uring.

Latching keeps a failure from being re-attempted per-read: an fs that
refuses O_DIRECT (EINVAL/EOPNOTSUPP on open) latches the native direct
path off for that disk; a restriction-class errno from the read latches
io_uring off wholesale (backlog#1101). Any other error falls back for
that one read without masking a genuine data problem as a permanent
downgrade. The alignment comes from the existing probe_direct_io_align
(statx STATX_DIOALIGN, default 4096) and is cached in a per-disk
OnceLock so the probe runs at most once.

This replaces the interim guard that routed every O_DIRECT-eligible read
to StdBackend (which disabled io_uring on the hottest shard reads); the
native path is now the default and StdBackend is only a fallback tier.
Bumps the rustfs-uring pin to the merged #3 commit.

Verified on a real Linux host (16-core, real io_uring + O_DIRECT):
cargo check --tests, clippy -D warnings, and disk::local tests all pass
(128 passed, 0 failed), including uring_preserves_o_direct_for_eligible_reads
across unaligned ranges.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 04:47:46 +00:00
houseme 399461c33e fix(ecstore): bound listing walks by drive stall, not total duration (#4647)
Foreground S3 listings wrapped the entire walk_dir stream in a single
wall-clock timeout (RUSTFS_DRIVE_WALKDIR_TIMEOUT_SECS, default 5s). That
budget measured how much data the walk had to produce rather than whether
the drive was still answering, so a healthy but large prefix on slow media
returned 500 InternalError / "Io error: timeout" to the client. The timer
also kept running while the walk was blocked writing to a slow consumer,
charging merge-side backpressure to the producer.

WalkDirOptions::stall_timeout_ms already carried the right semantics but was
only honored by the remote-disk RPC walk; LocalDisk::walk_dir ignored it
entirely. A single-drive deployment therefore had no way to distinguish a
hung drive from a big directory.

Teach LocalDisk::walk_dir to bound each individual drive read with the stall
budget, defaulting it from RUSTFS_DRIVE_WALKDIR_STALL_TIMEOUT_SECS when the
caller does not pin one, and let the foreground listing path skip the
wrapper-level total timeout. A walk that keeps making progress now runs to
completion; a drive that stops answering still fails with DiskError::Timeout.
Time spent blocked on the consumer stays outside the budget.

Heal and rebalance walks already skipped the total timeout and previously ran
unbounded on local drives. Give them an explicit, generous 60s stall budget so
this change does not tighten them from "no bound" to the 5s default.

Fixes #4644
Refs #2999, #3001

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 12:20:54 +08:00
houseme 0ad4a26056 fix(ecstore): keep O_DIRECT for eligible reads when io_uring is enabled (backlog#1102) (#4645)
fix(ecstore): keep O_DIRECT for eligible reads when io_uring is enabled (rustfs/backlog#1102)

UringBackend::pread_uring opens the file buffered, so with io_uring enabled an
O_DIRECT-eligible read (RUSTFS_OBJECT_DIRECT_IO_READ_ENABLE + length >= the
threshold) silently lost O_DIRECT and polluted the page cache — a behavior
change the moment io_uring was turned on for a disk that uses O_DIRECT. The
io_uring backend is gray-off by default, so no deployment is affected, but the
gap had to close before it can be enabled anywhere O_DIRECT is in use.

Route O_DIRECT-eligible reads back through StdBackend's aligned path (which
itself falls back to buffered when the filesystem rejects O_DIRECT). A native
aligned O_DIRECT read in the driver remains part of rustfs/backlog#1102.

Adds uring_preserves_o_direct_for_eligible_reads: with BOTH flags on and the
threshold at 1, unaligned ranges still return exactly the requested bytes.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 03:43:47 +00:00
Zhengchao An dc3099bf0f feat(ecstore): run bucket operations on the store's own instance context (#4642)
backlog#1052 S7 — the final piece: full bucket-namespace isolation
between embedded servers in one process.

Server B's requests resolved B's own ECStore (per-server dispatch landed
earlier), but the store's bucket operations still went through ambient
process facades, so both servers effectively operated on the FIRST
server's disks and metadata:

- LocalPeerS3Client::local_disks_for_pools() called all_local_disk()
  (the ambient disk registry = the first published store's context), so
  list/make/delete/heal bucket scanned and wrote the wrong volumes.
- BucketMetadata::save() persisted through the ambient object handle, so
  a second server's bucket metadata landed in the first server's
  .rustfs.sys; set/remove/get/created_at all used the ambient metadata
  system.

Now the whole chain is bound to the owning store's InstanceContext:

- S3PeerSys/LocalPeerS3Client gain *_with_instance_ctx constructors and
  operate on that context's registered disks; ECStore::new (and the test
  store builder) pass the store's context. The legacy constructors keep
  the bootstrap default.
- BucketMetadata::save_with_store persists through an explicit store;
  BucketMetadataSys::persist_and_set uses the system's own api handle.
- metadata_sys gains instance-scoped variants (get_in / created_at_in /
  set_bucket_metadata_in / remove_bucket_metadata_in) that resolve the
  context's metadata system and fall back to the ambient default before
  the instance cell is initialized (early startup, unchanged behavior).
- The store's bucket handlers (make/get_info/list/delete + the
  table-bucket delete guard and emptiness check) use the per-context
  variants and this instance's disks.

Acceptance (e2e): two embedded servers with different credentials are now
isolated end to end — each authenticates only its own key, neither sees
the other's buckets or objects, and both data planes stay intact. The
embedded module doc drops the shared-IAM caveat.

579 ecstore bucket/metadata/peer regressions plus the embedded basic and
deferred-IAM e2e stay green.
2026-07-10 10:52:51 +08:00
houseme e6e4aef45b test(ecstore): cover the io_uring per-disk probe cache hit (backlog#1101) (#4641)
test(ecstore): cover the io_uring per-disk probe cache hit (rustfs/backlog#1101)

Follow-up to #4635. The per-disk negative probe cache was implemented but had
no dedicated test. Add uring_probe_cache_skips_known_unsupported_disk: a root
recorded in URING_UNSUPPORTED_DISKS makes try_new return None without a fresh
probe. Completes the #1101 acceptance ("cache hit does not re-probe").

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 02:08:56 +00:00
houseme 8ffff47529 chore(deps): update workspace dependencies (#4643)
Run `cargo update` followed by `cargo upgrade --exclude ratelimit` on the
latest main to pull the newest compatible versions.

Manifest bumps (Cargo.toml):
- md5 0.8.0 -> 0.8.1
- regex 1.12.4 -> 1.13.0
- sysinfo 0.39.5 -> 0.39.6

Lockfile-only bumps: der 0.8.1, dial9-tokio-telemetry 0.3.14, lru 0.18.1,
regex-automata 0.4.15, symbolic-common/symbolic-demangle 13.9.0, and
zlib-rs 0.6.6. Resolver re-selection moves crypto-common 0.1.7 -> 0.1.6
and generic-array 0.14.7 -> 0.14.9 (both semver-compatible). `ratelimit`
is held at 0.10.1 as requested; `pollster` 1.0.0 is an incompatible major
bump and is intentionally not taken.

Verification: cargo check --workspace --all-targets, cargo clippy
--all-features -D warnings, cargo nextest run --all --exclude e2e_test
(8467 passed), cargo test --all --doc, and cargo deny check
advisories/sources/bans/licenses all pass.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 01:59:02 +00:00
houseme d1daa97ac5 fix(ecstore): keep local path startup fail-fast under Kubernetes (#4640)
The startup topology convergence auto-detection (added in #4631) checked
Kubernetes before endpoint style, so a local path deployment (single or
multi-drive, non-distributed) running inside Kubernetes was classified as
orchestrated with an effectively unbounded wait window. Path-style
endpoints have no hostnames to resolve, so the wait never actually
triggers and runtime behavior is unchanged, but the startup log then
advertised mode=orchestrated / wait_timeout=MAX for a purely local
deployment, which is misleading and contradicts the documented policy
(local path endpoints -> fail-fast).

Order the auto-detection by endpoint style first: only distributed URL
endpoints, which resolve hostnames, pick orchestrated (Kubernetes) or
bounded (otherwise); local path endpoints stay fail-fast regardless of
the platform. Update the doc comment to match and add a policy case
locking Kubernetes + local path to fail-fast.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 01:34:10 +00:00
Henry Guo 24d0c09347 fix(admin): keep datausage live refresh stable (#4630)
fix(ecstore): keep fuller data usage overlay

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-07-10 09:17:35 +08:00
houseme 7dc03603da feat(ecstore): add io_uring degradation latch and per-disk probe cache (#4635)
feat(ecstore): io_uring runtime degradation latch + per-disk probe cache (rustfs/backlog#1101)

Follow-up to #1104. Add the runtime half of the io_uring degradation contract:

- Runtime latch: UringBackend gains an `active` flag (mirrors
  DirectIoReadState::supported). A read that returns a restriction-class errno
  (EPERM/EACCES/ENOSYS/EOPNOTSUPP) — io_uring became unusable on this disk —
  trips the latch, and every further read goes straight to StdBackend with no
  more per-read io_uring attempts. Data errors (EIO), missing files, and
  parameter errors do NOT latch, since StdBackend would hit them too.
- Per-disk probe cache: a process-wide negative cache of disks whose probe
  failed, so try_new skips re-probing (creating a ring + driver thread) on
  reconnect.

Tests: the errno classifier (restriction-only) and a latched-off read that
still returns correct bytes via StdBackend; the #1104 differential test still
passes under real io_uring.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 08:54:59 +08:00
Zhengchao An cefbe2ccc9 fix(test): allow InstanceContext bucket_metadata_sys reinitialization for tests (#4638)
The OnceLock introduced in 5fbd49a80 for bucket_metadata_sys panics when
multiple test modules initialize their own ECStore against the shared
bootstrap context. Switch to Mutex<Option> so test fixtures can
reinitialize safely.

Extract shared gating test environment to gating_test_env.rs to avoid
duplicate ECStore setup across delete_objects_stat_gating_test and
put_prelookup_gating_test.

Fixes 8 test failures:
- capacity_dirty_scope_test (2 tests)
- delete_objects_stat_gating_test (3 tests)
- put_prelookup_gating_test (3 tests)
2026-07-10 04:03:26 +08:00
Zhengchao An 5e80980698 test(ecstore): pin sorted scan_dir stream for dir-marker with children (backlog#1068) (#4626) 2026-07-10 03:34:21 +08:00
唐小鸭 7ab1abee80 fix(admin): allow site replication peer edits (#4623)
* fix(site-replication): align IAM and bucket metadata replication

* fix(admin): allow site replication peer edits
2026-07-10 02:09:42 +08:00
Zhengchao An f403ed1c2e feat(embedded): allow multiple embedded servers to coexist in one process
* feat(embedded): allow multiple embedded servers to coexist in one process

backlog#1052 S5: turn the embedded startup guard into a sequential lock
and make every write-once shared cell tolerate a second embedded start.
Two RustFSServers on different ports and volumes now start, run, and
shut down independently in the same process.

- EMBEDDED_SERVER_STARTED is released once each startup hands off; a
  second startup that runs after the first is no longer rejected.
- The second embedded server constructs its own InstanceContext instead
  of adopting the process bootstrap one, so region/endpoints/deployment
  id land on that context (the first server keeps adopting bootstrap to
  keep single-instance ambient facades unchanged).
- Startup-time tolerant paths:
  - action_credentials publish treats AlreadyInitialized as success
    (per-server ActionCredentialHandle already holds the real creds).
  - GLOBAL_RUSTFS_PORT warns instead of panicking on a second set.
  - Observability install returns Ok when the process subscriber is
    already set — the second server reuses it.
- New acceptance test proves two servers start, respond on their own
  ports, and one can be shut down without disturbing the other; the
  survivor keeps serving S3 requests. IAM and root-credential lookup
  still share a process domain (a second server whose creds differ from
  the first will fail signature validation), tracked as a follow-up.
- Embedded doc rewritten: 'Limitations' → 'Multi-instance status'; the
  AlreadyStarted error is now scoped to concurrent startups only.

The remaining work in #1052 is the auth path per-server dispatch and the
matching data-plane routing so two servers with different credentials
serve independent buckets end-to-end.

* feat(app): per-server auth and application context for multiple embedded servers (#4633)

backlog#1052 S6: each embedded server now authenticates against — and
its request path resolves — its OWN application context, so two servers
with different root credentials each accept their own access key and
reject the other's.

- AppContext is per-server: ensure_startup_after_iam constructs a fresh
  context around this server's store + IAM + KMS and installs it into the
  server's own ServerContextSlot, then publishes it as the process
  default first-writer-wins (publish_global_app_context) for legacy
  ambient readers. The old 'reuse the global if present' path is gone.
- FS::check (the S3 data-plane access gate) resolves auth against
  self.server_ctx's context: check_key_valid gains a _with_context
  variant that takes the root credentials and IAM system from an explicit
  context (None = ambient, unchanged for all 140+ existing callers). The
  region and the server context slot are published into the request
  extensions for downstream handlers.
- Each embedded server seeds its own root credentials into its context
  (ActionCredentialHandle.publish) at startup, so credential validation
  no longer falls back to the first server's process-global identity.
- The bucket/object/multipart use-cases resolve their store from the
  server's context (bucket_usecase_for/object_usecase_for/... take &FS).

New acceptance test: two servers with distinct credentials each
authenticate with their own key and reject the other's.

KNOWN FOLLOW-UP: full bucket-namespace isolation still requires threading
the instance context through the lower ecstore data plane (peer_sys /
disk registry / bucket-metadata reads still resolve via the process
GLOBAL_OBJECT_API), so the two servers do not yet present independent
bucket listings even though each holds its own store. That deeper pass —
a continuation of the #939 object-graph ctx threading — is the remaining
work on #1052.

Stacked on the S5 guard change.
2026-07-10 02:00:59 +08:00
houseme 6a81353785 feat(ecstore): converge startup topology under a wait-mode policy (#4631)
Multi-node startups (Kubernetes StatefulSets, bare-metal, VMs) can hit
transient DNS/local-host resolution failures while peers and headless
service records are still coming up. Previously endpoint resolution
retried DNS for a fixed 90s window and then returned an error, which
propagated out of run() and exited the process; in Kubernetes this drove
repeated kubelet restarts even though the cluster eventually converged.

Introduce a startup topology convergence policy with three modes:

- orchestrated: wait effectively indefinitely for recoverable
  DNS/topology errors so the process stays Running (readiness stays
  false) instead of exiting; defer the DNS-IP cross-port validation
  because headless-service records can still be flapping.
- bounded: wait a finite window (default 3m) then fail with an
  action-oriented error listing host/stage/elapsed and remediation hints.
- fail-fast: fail on the first non-transient resolution error.

Mode is auto-detected (Kubernetes -> orchestrated, distributed URL
endpoints -> bounded, local path endpoints -> fail-fast) and can be
overridden via RUSTFS_STARTUP_TOPOLOGY_WAIT_MODE,
RUSTFS_STARTUP_TOPOLOGY_WAIT_TIMEOUT and
RUSTFS_STARTUP_TOPOLOGY_RETRY_MAX_DELAY.

Also normalize divergent local/remote verdicts for endpoints that share
a host:port, throttle retry warnings, and keep the DNS-independent local
same-path checks in every mode. Configuration error handling is
unchanged: malformed volumes, mixed styles/schemes, duplicate or root
paths, and non-transient errors still fail fast.

Read the topology env vars through rustfs_utils::get_env_opt_str for
consistent alias handling, and simplify the log_once poisoned-lock branch
to unwrap_or_else(PoisonError::into_inner).

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-09 17:57:13 +00:00
houseme da755eb66b feat(ecstore): runtime-probed io_uring read backend, gray-off by default (backlog#1104) (#4632)
feat(ecstore): add runtime-probed io_uring read backend, gray-off by default (rustfs/backlog#1104)

Wire the standalone rustfs-uring crate (https://github.com/rustfs/uring) into
LocalIoBackend as an opt-in read backend. When RUSTFS_IO_URING_READ_ENABLE is
set AND the per-disk io_uring probe succeeds, positioned reads go through the
cancel-safe UringDriver; otherwise — default, or on a restricted host, or on
any per-read driver error — reads fall back to StdBackend byte-for-byte, so the
default build is unchanged.

- Cargo.toml: rustfs-uring as a Linux-only git dependency (pinned rev). The
  guard scripts/check_no_tokio_io_uring.sh allows an explicit io-uring
  integration; only the tokio "io-uring" runtime feature is banned.
- UringBackend mirrors StdBackend::pread_bytes's resolution/access/bounds
  preamble and only swaps the raw byte read; the other three trait methods
  delegate to the inner StdBackend.
- Backend selection at LocalDisk construction via build_local_io_backend.
- Differential test uring_backend_reads_match_std: with the flag set, every
  positioned read returns byte-identical data (driver-served when io_uring is
  available, StdBackend fallback otherwise).

Verified: cargo check -p rustfs-ecstore on Linux; the differential test passes
under real io_uring (seccomp=unconfined). The runtime errno degradation latch,
per-disk probe cache, and productionization are tracked in
rustfs/backlog#1101/#1102.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-09 16:57:17 +00:00
houseme d90e354b11 chore(experiments): remove io_uring spike, migrated to rustfs/uring (#4629)
The io_uring cancel-safety spike (imported in #4625) has been migrated to a
dedicated repository, https://github.com/rustfs/uring (crate `rustfs-uring`),
with the full per-issue commit history preserved.

The standalone repo has independent CI that this workspace cannot run: it
excludes the crate from the build graph, so its checks passed without ever
building or testing it. rustfs/uring CI is green on fmt + clippy, a native leg
that runs the suite with real io_uring and fails on any skip, and a docker leg
exercising both the graceful-degradation and real-io_uring paths.

Removing the in-tree copy keeps rustfs/rustfs clean; the crate will be wired in
later as a git dependency behind runtime probing (rustfs/backlog#1051).

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-10 00:06:15 +08:00
Zhengchao An 6eb238ce75 refactor(app): give each context's credential handle its own copy (#4627)
backlog#1052 S3, fifth slice (also part of what the plan called S4).

ActionCredentialHandle was a unit struct forwarding get to the process
singleton (rustfs_credentials' GLOBAL_ACTIVE_CRED), so every context
served the same credentials regardless of which server it belonged to.
The handle now keeps its own copy and gains publish():

- publish() lands on the owning context and tries to publish the process
  global; readers prefer the owned copy and fall back to the global while
  the initial startup publish still lands there — ambient callers (iam
  token signing, request-signature validation) keep working.
- Single instance: both cells are written together, so reads are
  unchanged; two contexts that both published stay isolated even though
  the global remembers only the first (covered by a new test).
- The publish return signals "took effect": true if this handle newly
  holds credentials OR the global was newly initialized, matching the
  pre-existing init_global_action_credentials fail-fast contract, now
  scoped to the handle.

The interface trait gains a default-body-friendly signature; the test
double implements it as a no-op. The startup publish path (S4) still
calls init_global_action_credentials directly; wiring it through the
handle is a follow-up.
2026-07-10 00:00:47 +08:00
houseme bf81a9bab0 fix(experiments): import io_uring cancel-safety spike and apply backlog#1051 audit remediation (#4625)
* chore(experiments): import io_uring cancel-safety spike as audit baseline (backlog#894)

Import the Spike 0 io_uring cancel-safety prototype from the closed PR #4381
branch (houseme/p2-spike0-uring-cancel-safety) as the baseline for the
backlog#1051 audit remediation. This crate is a standalone workspace and is
deliberately kept out of the main Cargo.lock/build graph (NOT production code).

Subsequent commits apply the fixes tracked in backlog#1051 sub-issues, one
commit per issue.

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

* fix(experiments/uring): drain probe SQE to its CQE before releasing buffer (rustfs/backlog#1053)

The probe path had no pending-table backstop: after pushing the read SQE,
any early return (`submit_and_wait` error, missing CQE) dropped the probe
buffer and file while the read could still be in flight in io-wq, and the
caller dropped/unmapped the ring on the error path. If the kernel then wrote
the 512-byte result into that freed heap block, it was a use-after-free — the
exact bug class this spike exists to prevent, living in its own probe path.

Fix: once the SQE is pushed, drain to its CQE via `drain_probe_cqe`, retrying
the WAIT on EINTR without re-pushing (the kernel consumed the SQE atomically
before the wait). A bounded attempt count prevents a probe against a hung
device from blocking forever; on any drain failure the buffer (and file) are
`mem::forget`-ed ("leak over UAF") so the kernel can never write into freed
memory. Unmapping the ring on its own is safe; only the user buffer must
survive.

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

* fix(experiments/uring): split probe/runtime/transient errno classes, guard offset (rustfs/backlog#1059)

`is_expected_restriction` folded EINVAL into the "environment restricted"
class, but at runtime EINVAL is triple-meaning — offset > i64::MAX (signed
loff_t), O_DIRECT misalignment, and setup entries over the cap. Implementing
the rustfs/backlog#1048 permanent-degradation latch literally against this
class would fault a healthy disk off io_uring on one alignment retry or
offset-arithmetic bug.

Document that the class is probe-time ONLY and that P2 must split errnos into
probe-restriction / runtime-parameter-error / transient (EINTR/EAGAIN). Add a
concrete guard: `submit` rejects offset > i64::MAX with an InvalidInput error
instead of letting it reach the kernel as a runtime EINVAL. The probe EINTR
half of this issue is already handled by the drain loop from rustfs/backlog#1053
(retry the wait, never re-push).

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

* fix(experiments/uring): abort on driver-thread panic instead of freeing in-flight buffers (rustfs/backlog#1054)

The ownership model's "CQE is the only reclamation point" invariant held only
while the driver thread never unwound. On a panic inside drive(), Rust drop
order freed the `pending` table (every in-flight buffer) before the ring,
while the kernel could still be writing into those buffers → mass UAF.
`catch_unwind` cannot fix this: the destructors run during the unwind, before
the catch boundary.

Move ring + pending + backlog into a `DriverState` whose `Drop` checks
`thread::panicking()` and calls `process::abort()` BEFORE any field destructor
runs — leaving the ring mapped and the buffers allocated (leak over UAF). The
capacity-overflow panic that made this reachable (caller-controlled `len`) is
closed at the source in the len-guard commit (rustfs/backlog#1057).

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

* fix(experiments/uring): reject reads above MAX_RW_COUNT to stop u32 truncation (rustfs/backlog#1057)

The SQE length field is `len as u32`, so len == 4 GiB became a 0-byte read the
kernel answered with res=0 → an Ok(empty) the caller decodes as a false EOF
(and len > 4 GiB read only the low 32 bits). Silent truncation (CWE-197),
forbidden by the repo's rust-code-quality rules.

`submit` now rejects len > MAX_RW_COUNT (2 GiB - 4 KiB) with InvalidInput; the
`len as u32` cast in the driver is consequently lossless. This also closes the
caller-controlled capacity-overflow panic feeding rustfs/backlog#1054. P2 must
chunk reads larger than the cap.

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

* fix(experiments/uring): resubmit short reads to satisfy the whole-range contract (rustfs/backlog#1058)

CQE res >= 0 was truncated and delivered as final with no resubmit loop, and
Pending did not even store the requested length. io_uring can legally
short-read a regular file (io-wq signal interruption, NOWAIT partial page
cache, O_DIRECT tail blocks), while LocalIoBackend::pread_bytes is a whole-
range contract — a short shard fed to EC bitrot verification surfaces as
intermittent, hard-to-attribute integrity/quorum errors.

Track offset/nread in Pending and drive a resubmit loop: a short non-EOF read
re-queues the remainder into buf[nread..], keeping the entry (and its buffer,
and in_flight) until the FINAL CQE of the logical read; res == 0 is treated as
a real EOF. The resubmitted SQE reuses the op's user_data, so a late
ASYNC_CANCEL from a dropped future still cancels the logical read cleanly.

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

* fix(experiments/uring): bound the shutdown drain and record cancel outcomes (rustfs/backlog#1055)

Shutdown made "drain to in_flight == 0" a hard precondition for unmapping the
ring, but ASYNC_CANCEL is best-effort: it cannot interrupt a regular-file read
already executing in io-wq, so on a D-state/NFS-hung disk the CQE may never
arrive and drain-to-zero never terminates — the driver loops forever and
shutdown()/Drop join blocks the caller (and any tokio worker) permanently.
This is an internal contradiction (safe unmap needs drain; a bad disk makes
drain unbounded) in the very environment io_uring exists to handle.

Add a bounded-drain escape hatch: after DRAIN_TIMEOUT with ops still in flight,
leak the whole DriverState (ring stays mapped, buffers stay allocated — leak
over UAF) and exit so shutdown() returns. Soften shutdown()'s hard assert to a
warning for that degraded path; clean-drain tests still assert in_flight == 0
themselves. Also record the ASYNC_CANCEL three-state result
(succeeded/not-found/already-executing) so the hung-disk signal is observable
instead of discarded.

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

* fix(experiments/uring): assert NODROP, monitor CQ overflow, handle EBUSY (rustfs/backlog#1056)

In-flight had no upper bound and could exceed CQ capacity (entries=64 → CQ=128)
with zero overflow handling: no NODROP check, no overflow read, no EBUSY
handling. A lost CQE means its pending entry is never reclaimed, drain never
completes and shutdown hangs — and the spike only avoided this by accidental
reliance on the io-uring crate's auto-flush + NODROP kernel + poll cadence,
all of which P2's eventfd/AsyncFd reaping removes.

Assert the NODROP feature at probe (degrade via ENOSYS otherwise), monitor the
kernel CQ-overflow counter each turn and surface a non-zero value as fatal, and
handle submit() EBUSY as CQ-overflow backpressure (keep the backlog, reap this
turn) instead of swallowing it. The hard in-flight bound (permits ≤ CQ capacity)
lands with the backpressure work in rustfs/backlog#1060.

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

* fix(experiments/uring): add backpressure with permits released at the CQE (rustfs/backlog#1060)

Submission was unbounded (unbounded mpsc + uncapped pending/backlog), so a
concurrent large-object read storm had no memory ceiling. The subtler trap:
the planned SQ-depth semaphore, implemented the natural RAII way (permit held
by the ReadHandle/future), would release permits at future drop while orphan
buffers stay resident in the pending table awaiting slow-disk CQEs —
decoupling the permit count from resident memory and reopening the DoS surface
exactly in the EC quorum-drop hot path.

Add a `Backpressure` semaphore sized to the SQ depth (entries < CQ capacity, so
CQ overflow is structurally unreachable). `submit` acquires before handing the
op to the driver; the driver releases the permit at the CQE (pending-table
removal), NOT at future drop, tying the in-flight/memory bound to actual kernel
residency. Permits are balanced on the shutting-down reject and send-failure
paths, and the driver wakes all waiters on exit.

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

* fix(experiments/uring): open the probe file via O_TMPFILE instead of a predictable path (rustfs/backlog#1061)

The probe wrote a predictable temp path (uring-spike-probe-{pid}-{seq}) with
std::fs::write (O_CREAT|O_TRUNC, no O_EXCL/O_NOFOLLOW): a local attacker could
pre-plant a symlink there and have the process — often root — truncate and
overwrite an arbitrary target (CWE-59/377), with a TOCTOU window between write
and open (CWE-367). This probe is the direct blueprint for P2's per-disk
startup probe, so copied verbatim it becomes a production vulnerability.

Open via O_TMPFILE (anonymous inode, no name → nothing to plant a symlink at,
no TOCTOU, no leftover), falling back to O_CREAT|O_EXCL|O_NOFOLLOW + 0600 +
per-process nonce + immediate unlink on filesystems without O_TMPFILE. P2's
per-disk probe should create inside the tested data-disk directory the same
way, which also validates that disk's filesystem + io_uring combination.

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

* docs(experiments/uring): correct invariant 2 mechanism, add invariants 6/7/8 (rustfs/backlog#1063)

Invariant 2 (the spike's flagship finding) mis-described the fd-reuse hazard:
it claimed the danger window is submission→CQE and that the kernel would
"write into someone else's file". Both are wrong — a submitted op holds a
struct file reference and is immune to fd close/reuse; the real window is SQE
construction (as_raw_fd) → io_uring_enter (backlog residency), and for a READ
the consequence is reading the WRONG file, not writing. A P2 optimization
reasoning from the false premise (drop Arc<File> after submit / registered-file
table) would step straight into it.

Correct the mechanism and add the invariants this audit hardened: driver-thread
unwind safety (6), backpressure permit released at the CQE (7), reused-buffer
content hygiene (8, detailed in rustfs/backlog#1062), plus the errno three-class
contract, bounded-drain escape hatch, and short-read resubmit responsibility.
Mark the now-remediated items in the leftover list.

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

* docs(experiments/uring): pin the reused-buffer content-hygiene invariant for P3 (rustfs/backlog#1062)

The spike leaks nothing today (fresh zeroed buffer per op + truncate to res),
but rustfs/backlog#1048's P3 constraint mandates a driver-owned aligned slab
whose buffers are reused across requests as dirty memory. Both the SPIKE
invariants and the #1048 constraint address only buffer LIFETIME (UAF), not
content hygiene: once buffers are reused, any path that forgets to bound the
caller-visible bytes to cqe.res (O_DIRECT full-block read sliced upstream,
error path returning the whole buffer) discloses a previous tenant's object
data (CWE-226) in an S3 store.

Pin invariant 8: reused-buffer bytes visible to the caller must be strictly
⊆ [0, cqe.res). Documented in SPIKE.md and marked at the delivery point in the
driver so P3 preserves it; needs a dirty-buffer + short-read regression test.

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

* test(experiments/uring): pin fd ownership and orphan-integrity directly (rustfs/backlog#1064)

The memory-safety assertions were all counter proxies, and invariant 2 (fd
owned by the pending table) had zero coverage — deleting Pending.file compiled
and left every test green because each test kept its own Arc<File> alive.

Add two direct observations: pending_table_owns_fd_after_caller_drop drops the
caller's Arc while the op is in flight and asserts F_GETFD still succeeds (only
the pending table's clone keeps the fd open; removing that field would close it
→ EBADF). orphan_in_flight_does_not_corrupt_delivered_reads keeps an orphaned
buffer in flight while 64 delivered reads must return byte-exact, asserting the
orphan buffer is not reclaimed early and its kernel writes corrupt nothing. A
driver-level poison/canary leg is noted as a P2 acceptance-matrix item (ASAN
cannot see a kernel write into a freed buffer).

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

* test(experiments/uring): cover CQ-overflow safety and read boundaries (rustfs/backlog#1065)

The suite never approached CQ capacity and never touched EOF/len boundaries.
Add no_cq_overflow_under_load (300 ops through a CQ of 128 with backpressure
capping in-flight at 64, asserting cq_overflow stays 0 and all deliver),
boundary_reads (len==0, read at EOF, a cross-EOF short read delivered to a
live receiver exercising the positioned resubmit path, and the rejected
huge-len/huge-offset guards), and pipe_half_close_reads_eof (a closed write
end surfaces res==0 EOF).

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

* test(experiments/uring): cover Drop-without-shutdown and de-flake cancel_stress (rustfs/backlog#1066)

All tests ended via explicit shutdown(), so the UringDriver Drop impl's
live-thread branch (send Shutdown before join) was never exercised; add
drop_without_shutdown_drains_and_cancels which drops the driver with ops in
flight and asserts the held futures resolve to ECANCELED (a join-first
regression or unbounded hang makes it hang).

Also de-flake cancel_stress: the exact assert delivered == OPS/2 raced the
driver — an even-i read can complete between read_at returning and drop(handle),
delivering to the still-live receiver and flipping the split. Relax to the
deterministic conservation identity plus delivered >= OPS/2.

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

* test(experiments/uring): make run-docker.sh assert each leg's real path (rustfs/backlog#1067)

Both legs ran the identical cargo test and checked only the exit code, and a
skip is indistinguishable from a real pass at that level: leg 1 depended on the
host Docker's default seccomp "usually" blocking io_uring, and leg 2 printed
"both legs passed" even if every test skipped (vacuous pass — zero real
io_uring coverage).

Add an explicit seccomp profile (seccomp-block-uring.json) that returns EPERM
for io_uring_setup/enter/register so leg 1 deterministically hits the
graceful-degradation path regardless of host defaults, and assert leg 1
actually degraded (SKIP lines present) while leg 2 did NOT skip a single test
(io_uring really ran). Either violation now fails the harness.

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

* style(experiments/uring): apply repo rustfmt (max_width=130) to the audit changes

Normalize the formatting of the remediation code to the repo rustfmt.toml.
Pure formatting; no behavior change. clippy --all-targets -D warnings is clean.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-09 23:00:57 +08:00
Zhengchao An 008b872414 refactor(iam): hand the built IAM system to the AppContext explicitly (#4624)
backlog#1052 S3, fourth slice. The AppContext's IamHandle already owned an
Arc<IamSys>, but the value it held was read back from the process
singleton after init — so a future second server's context would silently
bind to the first server's IAM domain (credentials, policies, users all
belong to a specific store's .rustfs.sys).

- rustfs_iam gains build_iam_sys(store): construct an IAM system bound to
  a store without touching the singleton. init_iam_sys wraps it (build,
  publish first-wins, return the handle — previously Result<()>).
- Startup threads the built handle: the inline bootstrap path passes the
  Arc it just created into ensure_startup_after_iam, which constructs the
  AppContext from the passed value instead of re-resolving the global.
  The deferred-recovery path resolves the freshly published global
  directly (context-first resolution cannot be used there: the AppContext
  does not exist yet — creating it is the finalizer's job; caught by the
  embedded deferred-IAM e2e).
- IamHandle::is_ready() reports the held system's readiness instead of
  consulting the process singleton; the dead global re-resolver is
  removed. token_signing_key stays on the credentials global until S4.

157 iam tests + embedded e2e (basic + deferred recovery) green.
2026-07-09 22:35:00 +08:00
Zhengchao An 5fbd49a800 refactor(ecstore): move the bucket metadata system into InstanceContext (#4622)
backlog#1052 S3, third slice — the hard blocker for a second embedded
server's service stage. GLOBAL_BUCKET_METADATA_SYS was a process-global
OnceLock whose init ran .set().unwrap(): a second instance's service
startup panicked the whole process. The system it guards is inherently
per-store (it holds the store handle and that store's bucket→metadata
cache), so it now lives on the store's own InstanceContext:

- init_bucket_metadata_sys writes the cell of the store it was handed
  (api.ctx) — no signature change — and keeps the double-init fail-fast,
  now scoped to the instance (assert on the per-context set). The
  dist-erasure check for the refresh loop reads the same context.
- get_global_bucket_metadata_sys / the crate-private getter behind the
  ~20 get_*_config helpers resolve through the current_ctx() facade —
  the published store's context, or bootstrap before that — so every
  existing reader keeps its exact single-instance behavior.
- New acceptance test: two real stores in one process each initialize
  their own metadata system (the old global cell panicked right there),
  plus a shared builder extracted from the store-graph test.

201 bucket/metadata regression tests green.
2026-07-09 21:48:50 +08:00
Zhengchao An cae6189744 test(e2e): disable embedded console on raw rustfs spawn sites (#4617) 2026-07-09 12:37:18 +00:00
Henry Guo 52529403a6 test(table-catalog): record live conformance evidence (#4606)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-07-09 20:15:11 +08:00
Zhengchao An 476352106e refactor(app): per-context server-config handle and Arc-owned notification system (#4621)
* refactor(app): give each context's server-config handle its own copy

backlog#1052 S3, first slice: establish the per-context state pattern on
the smallest subsystem. ServerConfigHandle was a unit struct forwarding
both get and set to the process-global GLOBAL_SERVER_CONFIG, so every
AppContext shared one config regardless of which context a caller held.

The handle now keeps its own copy: a set lands on the owning context and
on the process global (ambient readers — the config loader path, the
scanner — keep working), and a get prefers the owned copy, falling back
to the global while the initial load still publishes there. Single
instance: the owned copy and the global are written together, so reads
are unchanged. Two contexts that both published stay isolated even though
the shared global only remembers the last write (covered by a new test).

The global write in set() is the transitional bridge for ambient readers;
it goes away when those readers migrate and multi-instance flips on
(backlog#1052 S5).

* refactor(notify): hand out the notification system as an owned Arc

backlog#1052 S3, second slice. GLOBAL_NOTIFICATION_SYS stored the
NotificationSys by value and every accessor returned
Option<&'static NotificationSys> — a process-lifetime borrow that a
per-server AppContext can never hold. The global now stores an Arc and
the accessor chain (ecstore runtime sources, ECStore::notification_system,
the iam forwarders, the rustfs shims, NotificationSystemInterface and the
AppContext resolvers) hands out owned Arcs instead.

Call sites are unchanged apart from two borrow adjustments in the
rebalance admin handler (pass &Arc where a reference is expected; borrow
with as_ref() so the handle survives to the second use). Behavior is
identical — same single global instance, first init still wins — but the
type now permits a future per-server context to own its notification
system (backlog#1052 S3 follow-up).
2026-07-09 20:07:09 +08:00
Zhengchao An 90c0deff58 refactor(admin): resolve the object store through the injected server context slot (#4620)
backlog#1052 S2, second slice: admin request dispatch.

Admin operations are registered as &'static dyn Operation, so unlike FS
they cannot carry per-server state. Instead, the (per-server) S3Router now
holds the server's ServerContextSlot and injects it into every request's
extensions at both dispatch points (check_access and call) — the same
extensions channel already used for ReqInfo/RemoteAddr.

- make_admin_route binds the slot; the HTTP builder passes the same slot
  the S3 service (FS) uses, so both paths dispatch to the same server.
- admin/runtime_sources gains object_store_from_req / an extensions-based
  variant for handlers that have already moved request fields out; both
  fall back to the ambient process context when no slot was injected
  (direct handler tests, non-router paths) — single-instance unchanged.
- 27 handler resolution sites across 12 files now resolve per-request;
  helper functions without request access (11 sites: site_replication
  state helpers, quota/table_catalog internals, object_zip_download
  workers) stay on the ambient path until app subsystems become
  per-server (backlog#1052 S3).
- One site (site-replication resync) resolves before the request body is
  consumed, keeping the original error precedence.

New test: the router injects its slot into request extensions by pointer
identity. Embedded e2e (basic S3 + deferred-IAM recovery) still green.
2026-07-09 19:42:52 +08:00
houseme 071a4600bc fix(admin): make server_info retry re-dial and match peer disks by resolved host (#4618)
Two follow-up fixes to #4607 (both verified real bugs, each with a regression
test):

1. The in-call server_info retry did not re-dial on network errors. A
   network-like first failure runs through `finalize_result`, which sets the
   client's `offline` gate; the retry then called `evict_connection` and
   re-invoked `server_info`, but `get_client` short-circuits on that gate and
   returns "temporarily offline" without dialing. Only the async recovery
   monitor would clear it. So the retry re-dialed only in the timeout branch and
   was a no-op in the transport/half-open branch it was meant to cover. Add
   `PeerRestClient::prepare_retry` (evict + clear the offline gate) and use it
   before the retry.

2. Synthesized/degraded drive lists went empty on hostname deployments.
   `PeerRestClient::host` is an `XHost` that `hosts_sorted` builds via
   `XHost::try_from` -> `to_socket_addrs`, so it is the resolved `IP:port`; but
   `synthesized_disks`/`peer_disk_health` compared it against
   `Endpoint::host_port()`, which is the raw `hostname:port`. On hostname
   clusters the compare missed, the drive list came back empty, and
   `unknownDisks` stayed 0 — reproducing the "drives vanish from the summary"
   regression #4607 fixed (also affected the pre-existing offline path). Compare
   through a shared `endpoint_host_matches` helper that canonicalizes the
   endpoint side through the same `XHost` resolution.

Tests: `peer_rest_client_prepare_retry_clears_offline_gate`,
`endpoint_host_matches_direct_and_canonicalized` (uses localhost, no external
DNS).

Follow-up to rustfs/rustfs#4607; tracked in rustfs/backlog#1049.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-09 10:50:00 +00:00
Zhengchao An f8ca79d54b refactor(server): dispatch S3 requests through a per-server context slot (#4615) 2026-07-09 10:40:14 +00:00
GatewayJ d238b2d24e fix(admin): support external OIDC browser redirects (#4280) 2026-07-09 18:07:49 +08:00
Zhengchao An c8348f8b6f fix(ecstore): merge listing channels in canonical byte order (#4610)
fix(ecstore): merge listing channels in canonical byte order (backlog#1046)
2026-07-09 17:14:57 +08:00
Zhengchao An d7b19131d8 fix(ecstore): read the persisted SSE-C nonce in the GET decrypt resolver (unbreaks S3 CI) (#4612)
fix(ecstore): read the persisted SSE-C nonce in the GET decrypt resolver

#4576 moved SSE-C encryption to a random per-encryption nonce persisted
in object metadata and taught the rustfs-layer decrypt resolver to read
it back — but the byte-level GET decryption resolves its material in
crates/ecstore object_api/readers.rs, a duplicated twin that still
recomputed the deterministic legacy nonce. Encrypt with the random
nonce, decrypt with the deterministic one: the first AEAD block fails
and every SSE-C GET aborts its body after the response headers
(IncompleteRead(0 bytes) on clients; all 8 SSE-C cases in the
implemented s3-tests whitelist), which is what has been driving the
"S3 Implemented Tests" CI job into its 60-minute timeout since
2026-07-09.

Resolve the base nonce like the encrypt side: prefer the persisted IV
(x-rustfs-encryption-iv, then the case-insensitive MinIO interop key),
fall back to the deterministic nonce only for legacy objects with no
stored IV or an undecodable value. Full implemented s3-tests whitelist
is back to 451 passed / 0 failed against the fixed binary.
2026-07-09 17:07:43 +08:00
Zhengchao An 91a5c87132 refactor(startup): thread explicit InstanceContext through the storage startup path (#4611) 2026-07-09 16:53:41 +08:00
Rohmilchkaese 8bcffd8a04 feat(helm): support multiple server pools for capacity expansion (#3325)
* feat(helm): support multiple server pools for capacity expansion

The server already understands multiple pools (RUSTFS_VOLUMES is split on
spaces, one pool expression each; rc admin pool/expand/rebalance/
decommission exist), but the chart could only render a single StatefulSet.

Add an opt-in pools mode:

- pools.enabled=false (default) renders byte-identical output to the
  current chart (verified against five values variants)
- pools.list renders one StatefulSet per entry; entries may set
  replicaCount (4 or 16) and a storageclass block, everything else is
  inherited from the top-level values
- pool 0 keeps the legacy StatefulSet/pod/PVC names and its (immutable)
  selector, so existing single-pool deployments can be expanded in place
  without renaming or data loss; additional pools render as
  <fullname>-poolN with a rustfs.com/pool label in their selector
- RUSTFS_VOLUMES and RUSTFS_SERVER_DOMAINS enumerate every pool
- pod anti-affinity is scoped per pool so pools may share nodes while
  each pool still spreads its own pods across distinct nodes
- template validation fails fast on unsupported replicaCount, an empty
  pools.list, or pools in standalone mode
- pools are append-only by design (list index = identity), documented in
  values.yaml and the README

* fix(helm): truncate pool names DNS-safely, document PDB pool scope

Truncate <fullname>-poolN to 63 chars by shortening the base name rather
than the suffix, so the pool index always survives and two pools of a
max-length release cannot collide. Document that the single
PodDisruptionBudget deliberately spans all pools.

* fix(helm): pool-mode anti-affinity must be preferred, not required

Found by live-testing in-place expansion on a 5-node cluster (4-replica
pool 0 + 4-replica pool 1): with requiredDuringScheduling anti-affinity
the expansion deadlocks in a cycle that cannot self-heal -

  1. the not-yet-rolled pool-0 pods still carry the unscoped required
     rule and repel every rustfs pod from their nodes, so part of pool 1
     stays Pending (no IP, no headless-DNS record);
  2. every pod that already has the new RUSTFS_VOLUMES exits fatally on
     'failed to lookup address information' for those Pending peers;
  3. the StatefulSet rolling update is gated on the crashing pod going
     Ready, so the remaining pool-0 pods never roll and keep repelling.

Because StatefulSets assign revisions per ordinal, even a subsequent
template fix cannot rescue a wedged rollout (Pending ordinals are only
recreated with the new template after the higher ordinals go Ready) -
the new pool's StatefulSet has to be recreated. Shipping the soft
affinity from the start avoids the state entirely; expansion was
re-tested end-to-end with it and converges.

Pool-mode rendering only - pools.enabled=false still renders the
existing required anti-affinity byte-identically.

---------

Co-authored-by: cxymds <Cxymds@qq.com>
Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: cxymds <cxymds@gmail.com>
2026-07-09 07:58:06 +00:00
Zhengchao An 10ba2273c4 test(e2e): align object-lock overwrite tests with AWS semantics (#4605)
test(e2e): align object-lock overwrite tests with AWS new-version semantics

PR #3179 made put-like writes (PUT, CopyObject, multipart create/complete)
on versioned object-lock buckets create a new version instead of being
blocked by the current version's legal hold or COMPLIANCE retention, which
is the correct AWS behavior. Six e2e assertions in object_lock_test.rs
still encoded the old blocking semantics and, because e2e_test is excluded
from the CI test run, stayed red unnoticed since 2026-06-03.

Verified against a real server built from current main: all six failed at
their "should be blocked" assertions. Rewritten to assert the AWS
contract: the write succeeds with a distinct new version id, the new
content is current, the protected version keeps its content and lock
metadata, and version-targeted deletion of the protected version stays
blocked (for COMPLIANCE even with bypass_governance). Full object_lock
module is green again (33/33).
2026-07-09 06:52:51 +00:00
houseme 9152da5206 chore: drop unused wildmatch workspace dependency (#4609)
PR #4468 removed the last use of wildmatch (crates/notify swapped it for a
hand-rolled star-only glob matcher), but the workspace dependency declaration
in the root Cargo.toml was left behind as an orphan. cargo shear flags it as
not used by any workspace member. Drop the stray line.

Also refresh the comments in crates/notify/src/rules/pattern.rs that still
referred to the removed wildmatch crate, so they describe the current
hand-rolled matcher instead of a dependency that no longer exists.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-09 06:08:49 +00:00
houseme c0c7b9b074 chore(deps): update workspace dependencies (#4608)
Run `cargo update` followed by `cargo upgrade --exclude ratelimit` to pull
the latest compatible versions across the workspace.

Manifest bumps (Cargo.toml):
- aws-config 1.8.18 -> 1.9.0, aws-credential-types 1.2.14 -> 1.3.0
- aws-sdk-s3 1.137.0 -> 1.138.0, aws-smithy-http-client 1.1.13 -> 1.2.0
- aws-smithy-runtime-api 1.12.3 -> 1.13.0, aws-smithy-types 1.5.0 -> 1.6.1
- bytes 1.12.0 -> 1.12.1, jiff 0.2.31 -> 0.2.32
- rust-embed 8.11.0 -> 8.12.0, pyroscope 2.0.6 -> 2.1.0

Lockfile-only bumps include the arrow 59.1.0 and aws-smithy families,
metrique, memchr, zerocopy, and parquet. `crypto-common` 0.1.6 -> 0.1.7
re-pins the transitive `generic-array` 0.14.x line to 0.14.7 (patch,
semver-compatible). `ratelimit` is held at 0.10.1 (2.0.0 available) as
requested; its 2.0 API is a breaking change tracked separately.

Verification: cargo check --workspace --all-targets, cargo clippy
--all-features -D warnings, cargo nextest run --all --exclude e2e_test
(8428 passed), cargo test --all --doc, and cargo deny check
advisories/sources/bans/licenses all pass.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-09 05:28:48 +00:00
houseme cb977c4c5a fix(admin): report unreachable info members as unknown with balanced drive counts (#4607)
fix(admin): report unreachable info members as `unknown` with balanced drive counts

`GET /rustfs/admin/v3/info` synthesizes an entry for every peer, but a peer
whose properties RPC failed below the offline threshold with no cached snapshot
was reported as `initializing` with an empty drive list. That drive list being
empty meant its drives disappeared from the backend summary entirely, so
`onlineDisks + offlineDisks` no longer equaled `totalDrivesPerSet` (an operator
sees `onlineDisks: 3, offlineDisks: 0` for a 4-drive set), and `initializing` is
a misleading state for a member that has been running for hours — it reads like
a node was ejected when nothing is wrong (upstream rustfs/rustfs#4566).

Changes:
- madmin: add `ItemState::Unknown` ("unknown") and an `unknownDisks` bucket on
  `ErasureBackend` (serde `default`, so older payloads still deserialize).
- ecstore diagnostics: `get_online_offline_disks_stats` now returns a third
  `unknown` bucket instead of folding those drives into `offline`, keeping
  `online + offline + unknown == totalDrivesPerSet`.
- ecstore notification_sys: a probe miss below the failure threshold with no
  cache is now reported as `unknown` (not `initializing`) and carries the host's
  drives from the pool topology (tagged `unknown`) so the summary stays balanced;
  drive synthesis is factored into `synthesized_disks(host, endpoints, state)`,
  shared by the offline and unknown paths.

Tracked in rustfs/backlog#1049 (P1-A + P0-A).

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-09 04:50:20 +00:00
Zhengchao An d8c32d3754 fix(ecstore): retry idempotent reads to survive read-after-write races (#4597) 2026-07-09 04:16:43 +00:00
Zhengchao An 0c040999b1 fix(rustfs): fail GetObject stream on short body instead of truncating (#4594) 2026-07-09 04:14:25 +00:00
Zhengchao An 4f999bb6b8 perf(ecstore): backfill rename_data old size and gate the PUT prelookup (#4598) 2026-07-09 12:08:01 +08:00
Zhengchao An 579cdf52dc fix(ecstore): harden object-prefix probe and parse markers before forward_past (#4600) 2026-07-09 12:07:37 +08:00
Zhengchao An 76306d7b02 chore: rename unparseable to unparsable for the typos gate (#4602) 2026-07-09 12:07:26 +08:00
Zhengchao An 79d6de860c test(e2e): avoid console port 9001 clash and fail fast on dead server (#4603) 2026-07-09 12:07:15 +08:00
houseme 073628e8be feat(utils): make DNS resolver cache TTL configurable via RUSTFS_DNS_CACHE_TTL_SECS (#4604)
* feat(utils): make DNS resolver cache TTL configurable via RUSTFS_DNS_CACHE_TTL_SECS

The resolver cache in crates/utils/src/net.rs had a hardcoded 300s TTL with
no way to tune or disable it. On orchestrated networks (Docker Swarm,
Kubernetes) a peer's DNS name is its only durable identity while its container
IP changes on reschedule, and the platform DNS always serves the current
answer. A fixed 300s application-side cache can keep a member dialing a dead IP
for up to five minutes after a peer restarts. Rust has no runtime DNS cache, so
on musl images this was the only cache in the stack and the only one that could
not be turned off.

Read the TTL from RUSTFS_DNS_CACHE_TTL_SECS (u64, default 300), matching the
existing RUSTFS_INTERNODE_* transport knobs. `0` disables caching entirely so
every get_host_ip consults the system resolver. The resolved value is logged
once at first use, removing an invisible cache from the triage surface. Change
is backward-compatible and confined to net.rs.

Also drop the two per-lookup info! logs on the resolve path: they would flood
the log when caching is disabled and added hot-path noise otherwise.

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

* fix(helm): derive mTLS secret names from chart fullname (#4601)

* fix(helm): mtls certificate for server and client hardcode

* fix hardcode issue in deployment

* update typo yaml file

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: majinghe <42570491+majinghe@users.noreply.github.com>
2026-07-09 03:26:06 +00:00
houseme e0eee89a3b fix(ecstore): emit CommonPrefix for object with same-named prefix in non-recursive listings (#4596)
fix(ecstore): emit CommonPrefix for object with same-named prefix in non-recursive listings (backlog#1042)

On single-disk / consistent deployments a non-recursive scan_dir that finds a/xl.meta classifies `a` as an object and does not descend, so the source never emits the prefix `a/`, dropping the CommonPrefix from delimiter listings even when `a/b` exists. This is the single-disk follow-up to backlog#880 (the multi-disk merge path was fixed in #4563).

Fix: in the scan_dir Ok branch, for a non-recursive listing of a plain object, probe whether `a/` holds a listing entry other than the object itself (new object_prefix_has_sibling_listing_entry, reusing directory_has_visible_listing_entry and skipping the object's own xl.meta and data dirs); if so, additionally schedule the prefix dir `a/`. The upstream fold in from_meta_cache_entries_sorted_infos already emits object `a` as Contents and dir `a/` as a CommonPrefix, so nothing changes there. Leaf objects (the common case) pay a single list_dir and return false immediately.

Verification: new scan_dir unit test (positive: `a` + `a/b` coexist; negative: leaf `c` produces no spurious `c/`); new end-to-end e2e (object `a` + `a/b` with delimiter "/" -> Contents `a` + CommonPrefix `a/`); existing list_objects_duplicates e2e (#1797 / #2439 regressions) stays green; set_disk::read 114 passed.

Note: lib-test compilation depends on #4573 (now merged), which added the allow_inplace_legacy_fallback argument to the codec streaming arity tests after #4560 / backlog#879.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-09 01:45:25 +00:00
houseme cb9edd59f5 test(ecstore): serialize bucket_delete tests to fix cargo-test global race (#4595)
The three store::bucket::tests::bucket_delete_* tests drive make_bucket /
delete_bucket through the process-global local-disk registry
(GLOBAL_LOCAL_DISK_MAP) and lock client, and through Sets::new which reads the
process-global erasure mode. Under `cargo test` (single process, many threads)
they race other global-touching tests and fail deterministically with
InsufficientWriteQuorum.

serial_test's #[serial] with the crate default key serializes them across the
in-process suite. Measured at --test-threads=16: baseline fails all three every
round; with #[serial] all three pass every round. nextest's per-test processes
are covered separately by the ecstore-serial-flaky test-group in
.config/nextest.toml (#4558). Full instance-level isolation remains a backlog
#939 (InstanceContext) concern and is not attempted here.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-09 01:37:53 +00:00
Zhengchao An 19a0a73fed fix(rustfs): sanitize user-metadata in metadata=true object listing (#4592)
fix(rustfs): sanitize user-metadata in metadata=true object listing (#2743)

The console listing path (`list-type=2&metadata=true`) serializes each
object's user-metadata key as a raw XML *element name* and its value as
XML text without validation. User-metadata keys derived from HTTP
headers can legally contain characters that are illegal in an XML `Name`
(space, `$`, `%`, `#`, a leading digit, control bytes), and values can
contain C0 control characters that are illegal in XML 1.0 text.

A single object carrying such a key or value produced a malformed
`ListBucketResult` document, which the console's XML parser rejected
wholesale — so every object under that prefix vanished from the Web UI,
while plain `ListObjectsV2` (which never serializes user metadata) kept
working. The breakage appeared the moment any one object in a prefix had
XML-unsafe metadata and cleared once that object was removed, matching
the report.

Guard the serialization in `ObjectMetadataExtension::serialize_content`
(shared by the list-objects and list-versions metadata outputs): skip
entries whose key is not a valid XML element name, and strip
XML-1.0-illegal control characters from values. A single poison object
can no longer corrupt the whole listing document.
2026-07-09 09:36:10 +08:00
Zhengchao An e0f04f4621 fix(ecstore): only evict remote lock channel on transport failures (#4591)
fix(ecstore): only evict remote lock channel on transport failures (#4567)

The remote lock RPC path evicted the cached gRPC channel on *any*
`tonic::Status`. Genuine transport failures (connect refused/reset,
keepalive/deadline) surface that way, but so do server-produced
application statuses on a perfectly healthy channel: `Unauthenticated`
/`PermissionDenied` from the signature interceptor, `Internal`
/`FailedPrecondition` when the peer lock service is not ready yet,
`InvalidArgument`, `ResourceExhausted`, `Unimplemented`, and so on.

Evicting the channel for those cannot help — the channel is fine — and
each spurious eviction re-dials the connection and advances the peer
toward the offline threshold via `record_peer_unreachable`, producing
background channel churn on an otherwise healthy cluster.

Classify the tonic status with `is_transport_failure` (underlying
transport source present, or one of `Unavailable`/`DeadlineExceeded`
/`Unknown`/`Cancelled`) and only evict the cached channel when it is a
real transport failure. The client-side timeout arm still evicts. This
mirrors the transport-vs-application distinction already used on the
remote-disk RPC path.
2026-07-09 09:35:48 +08:00
Zhengchao An ce6bc30b26 test(ecstore): harden validation gate and EC coverage tests (#4590) 2026-07-09 07:18:18 +08:00
Zhengchao An 359bdc0f1f refactor(ecstore): migrate the background-services cancel token into InstanceContext (Phase 5 Slice 13) (#4586)
* refactor(ecstore): migrate the background-services cancel token into InstanceContext (Phase 5 Slice 13)

Phase 5 Slice 13 (backlog#939): move the background-services cancellation token
out of the process static into the per-instance InstanceContext, so cancelling
one instance's background workers (scanner/heal/tier/lifecycle) no longer
touches another instance.

- InstanceContext gains `background_cancel_token: OnceLock<CancellationToken>`
  with `init_background_cancel_token` (set-once) and `background_cancel_token()`
  returning an owned clone.
- global.rs `init_/get_/create_/shutdown_` helpers keep their signatures and
  route through the current instance's context; the static is removed. The
  getter now returns an owned `Option<CancellationToken>` instead of a
  `Option<&'static _>`, which is what lets the token live in the context.
- Callers adapt to the owned token: the metadata-refresh loop drops `.cloned()`;
  the lifecycle worker/loops take the owned token (a shared fallback is cloned
  when the token is somehow uninitialized). No Arc cycle is introduced —
  workers hold a token clone, not the instance context.

Single-instance behavior is unchanged: startup creates one token in the
bootstrap context the ECStore adopts, and shutdown cancels that same token.

Tests: the token is set-once and cancelling one instance's token leaves a
distinct instance uninitialized.

Verification: cargo test -p rustfs-ecstore (23 instance-context tests green),
cargo clippy -p rustfs-ecstore --all-targets (clean), make pre-commit (pass).

Refs: backlog#939 (Phase 5, Slice 13)

* test(ecstore): prove multi-instance isolation; document embedded guard retention (Phase 5 Slice 14) (#4588)

Phase 5 (backlog#939) capstone. The prior 13 slices moved every piece of
per-instance runtime state out of process globals into ECStore's
InstanceContext. This slice proves the result and records the remaining work.

- Add `two_instances_isolate_all_migrated_state`: an end-to-end acceptance test
  that constructs two independent InstanceContexts and verifies NONE of the
  migrated state is shared — erasure setup, lock manager, region, deployment id,
  the four service handles (tier/notifier/expiry/transition), the local disk
  registry, the bucket monitor, and the background cancel token. This is the
  object-graph isolation carrier working end to end.
- Document why the embedded single-instance guard (EMBEDDED_SERVER_STARTED) is
  intentionally retained: storage startup still publishes into the process-level
  bootstrap context (write-once region/endpoints/deployment id) and the single
  GLOBAL_OBJECT_API handle, so a second startup would fail-fast on that shared
  state. Lifting the guard requires threading a per-instance context through
  startup — a follow-up beyond migrating the globals. The guard is NOT removed:
  rejecting the second start is safer than the panic it would otherwise become.

Verification: cargo test -p rustfs-ecstore (acceptance test + all instance-context
tests green), cargo clippy -p rustfs-ecstore --all-targets (clean), make
pre-commit (pass).

Refs: backlog#939 (Phase 5, Slice 14). Stacked on Slice 13 (#4586).
2026-07-08 22:06:31 +00:00
Zhengchao An c0e2c02e51 fix(rio): warn once when internode HTTP/2 tuning is inert on plaintext (backlog#805-C3) (#4587)
The internode HTTP/2 window/keepalive tuning (apply_http_client_tuning +
http2_keep_alive_* in build_http_client) only takes effect when the connection
negotiates HTTP/2, which happens via TLS-ALPN. Over plaintext internode
transport the connection is HTTP/1.1 and all the tuning is silently inert,
with no signal to the operator.

Make this honest without changing protocol behavior (no h2c, no
prior_knowledge, no opt-in flag):
- InternodeHttpClientTuning gains h2_tuning_explicit, true when the operator
  set a window size, a non-default tuning profile, or the keepalive env.
- Add pure predicate should_warn_h2_inert(negotiated_is_http2,
  h2_tuning_explicit, already_warned) and maybe_warn_h2_inert, gated by a
  process-lifetime AtomicBool so the warn fires at most once.
- Call maybe_warn_h2_inert at both record_internode_http_version sites using
  the actual negotiated resp.version().
- Document the TLS-ALPN requirement at apply_http_client_tuning and near the
  ENV_INTERNODE_HTTP2_* constants.

Tests cover the should_warn_h2_inert truth table and that h2_tuning_explicit
reflects explicit windows and a non-default profile.
2026-07-09 05:51:07 +08:00
Zhengchao An b06cbd335e fix(rio-v2): detect DARE stream truncation at package boundary (#4585) 2026-07-09 05:44:32 +08:00
Zhengchao An e029b77c09 fix(server): count in-flight HTTP requests with an RAII guard (exactly-once) (backlog#806) (#4583)
fix(server): count in-flight HTTP requests with an RAII guard (backlog#806-35)

The active-requests gauge was maintained with tower-http TraceLayer hooks:
on_request (+1), on_response (-1) and on_failure (-1). With the default
ServerErrorsAsFailures classifier a 5xx response fires BOTH on_response and
on_failure, so every 5xx decremented the gauge twice (net -1); a streaming
200 that failed mid-body did the same. The gauge drifted downward and
saturated at zero, corrupting the readiness busy-protection signal
(alias_busy_threshold_exceeded).

Replace the hook arithmetic with an InFlightGuard (RAII): InFlightLayer wraps
each request future, incrementing on entry and decrementing exactly once when
the future resolves (any status) or errors/cancels. Removed the +1 from both
on_request hooks and the -1 from trace_on_response and both on_failure hooks,
keeping their tracing and failure-metric work intact. Applied to both the
external and internode stacks.

Added a test driving the layer for 2xx, 5xx and a no-response service error,
asserting the gauge nets to zero in every case (the 5xx path is the fix).
2026-07-09 05:44:05 +08:00
Zhengchao An 37c1153c1e fix(ecstore): age LastMinuteLatency samples individually to fix the 1-minute window (backlog#806) (#4581)
fix(ecstore): age LastMinuteLatency samples individually (backlog#806-16)

The rolling one-minute latency window stored bare Duration samples plus a
single, never-updated start_time. Its retain predicate ignored the element
and evaluated the constant now.duration_since(start_time) < 60s, so once the
window had existed for 60s every add() dropped ALL samples and the average
degenerated to the latest single sample.

Each sample now carries its own Instant and is retained by its individual
age. The type backs only in-memory EpHealth + admin display (the wire shape
is target::LatencyStat with curr/avg/max), so Serialize/Deserialize is kept
only to satisfy the enclosing LatencyStat derive; the sample Instant is
serde(skip) and restarts aging on reload.
2026-07-09 05:42:51 +08:00
Zhengchao An ed36d1ed97 fix(io-metrics): drop client-controlled bucket label from s3 ops counter (backlog#806) (#4580)
fix(io-metrics): drop client-controlled bucket label from rustfs_s3_operations_total (backlog#806-22)

The bucket dimension on the rustfs_s3_operations_total counter is
client-controlled and unbounded, letting any client explode metric
cardinality (a Prometheus/OTEL cardinality DoS that grows the in-process
OTEL aggregation store even without a scrape). Drop the bucket label so the
series is keyed by the bounded op dimension only (<=122 variants), matching
MinIO which never labels its default operation counters with bucket.

BREAKING: rustfs_s3_operations_total no longer carries a "bucket" label.

record_s3_op(op, bucket) -> record_s3_op(op); all call sites in
rustfs/src/storage/{ecfs.rs,helper.rs} updated (bucket bindings retained
where still used by audit/notify paths). Add metrics-util debugging recorder
dev-dep and tests asserting the series carries only the op label and that
series count equals distinct-op count.
2026-07-09 05:42:27 +08:00
Zhengchao An e4f75c4856 fix(admin): fail closed when DeleteServiceAccount can't load the target (backlog#806) (#4579)
DeleteServiceAccount looked up the target service account and, on any
get_service_account error OTHER than not-found (e.g. a stored-credential decode
error), set svc_account = None and continued. The non-admin owner guard then
used svc_account.is_some_and(|v| v.parent_user != user), which is FALSE for a
None target, so the guard was skipped and the code fell through to the delete —
a non-owner could delete a service account whose stored credential failed to
decode (authz bypass, fail-open).

Extract a fail-closed helper non_admin_may_delete_service_account(caller, target)
that returns true only when the target loaded AND is owned by the caller; a None
target (unverifiable ownership) or an owner mismatch now denies. Admins (holding
RemoveServiceAccount) are unaffected. + unit test.

Refs rustfs/backlog#806
2026-07-09 05:41:57 +08:00
Zhengchao An da67d6d695 fix(rio): use typed-first internode HTTP error classification (#4578)
fix(rio): typed-first internode HTTP error classification (backlog#805)

classify_reqwest_error matched reqwest/hyper error text by English
substrings, brittle to library/OS-locale wording. Refactor to a pure,
testable classify_transport_error(err, is_timeout, is_connect, is_body)
that trusts structured signals first: caller timeout, then the io::Error
kind found anywhere in the source chain (typed wins over any string/body
signal so a real ConnectionRefused is never mislabeled DnsResolutionFailed),
then a DNS-only string heuristic gated behind is_connect, then body, then
Unknown.

Flip DnsResolutionFailed to retryable, mirroring MinIO IsNetworkOrHostDown
(*net.DNSError => network-or-host-down => retryable); RustFS already retries
transient DNS at endpoints.rs, and bounded retries cost at most one extra
attempt on permanent NXDOMAIN.

Swap the ecstore remote_disk non-retryable exemplar from DnsResolutionFailed
to Unknown accordingly.
2026-07-09 05:41:25 +08:00
Zhengchao An f38006868f docs(agents): add adversarial validation policy and role playbooks (#4589)
Add a default-on multi-role adversarial validation policy to the root
AGENTS.md (risk tiers, six reviewer roles, findings protocol, exit
criteria), a shared adversarial-validation skill with repo-specific
attack probes grounded in shipped bugs, seven audit fixes to the root
instruction files, and an actionlint gate for workflow changes.
2026-07-09 05:40:01 +08:00
Zhengchao An 3ac3c27e39 fix(sse-c): use a random per-encryption nonce and persist it (#4576)
In the default (non-rio-v2) build, SSE-C encrypted with AES-256-GCM using a deterministic nonce derived from MD5("{bucket}-{key}"), and decrypt recomputed it. Overwriting the same object under the same SSE-C key reused an identical (key, nonce) pair, breaking AES-GCM confidentiality and integrity. The base nonce was never persisted.

Generate a fresh random 12-byte nonce for every single-PUT and multipart session, persist it under both x-rustfs-encryption-iv and the MinIO interop key, and resolve it from stored metadata on decrypt and multipart part encryption. Objects written before this change carry no stored IV and fall back to generate_ssec_nonce, so existing objects still decrypt.

Refs rustfs/backlog#1039
2026-07-09 05:33:23 +08:00
Zhengchao An ddb9d8ca3e fix(object-capacity): harden write timing, interval bounds, and scope expiry (#4575)
fix(object-capacity): harden clock handling in write window, background intervals and scope registry

Three low-severity robustness gaps (S32+S33+#35):

- WriteRecord keyed its 60-bucket write window by wall-clock unix
  seconds while the debounce used Instant. An NTP step backwards marked
  recent buckets as future and silently suppressed write-triggered
  refreshes until the wall clock caught up; a forward step aged the
  whole window at once. Bucket keys now derive from a monotonic
  per-record epoch, immune to clock steps.
- Background refresh/metrics intervals were only clamped at zero;
  RUSTFS_CAPACITY_SCHEDULED_INTERVAL=u64::MAX overflowed
  Instant + Duration and panicked the spawned task, silently killing
  scheduled refreshes. Intervals now clamp into [1s, 30 days] via one
  helper with a structured warn.
- record_capacity_scope merged new disks into an expired entry and
  refreshed its recorded_at, resurrecting a scope take_capacity_scope
  would have discarded (and the merge path never pruned). Expired
  entries are now replaced, not merged into.

Ref: rustfs/backlog#1022 (S32+S33+#35 from audit rustfs/backlog#1010)
2026-07-09 05:27:36 +08:00
Zhengchao An c92d47df97 chore(security): exclude test/dev paths from secret scanning (#4584) 2026-07-09 05:23:22 +08:00
Zhengchao An 2ae1e8ad05 ci: right-size pipelines, fix prerelease latest.json overwrite (#4582)
- build.yml: update latest.json only for stable release tags
  (alpha/beta/rc tags previously overwrote the stable pointer with
  release_type "stable"); drop the placeholder .asc files; build the
  Linux targets only on main pushes (tags/schedule/dispatch keep the
  full matrix); remove the unreachable --build clause and a needless
  full-history clone
- ci.yml: event-scoped concurrency so merges stop cancelling the
  weekly scheduled run; drop the redundant skip-duplicate-actions
  gate job; run clippy before tests; trim the unused toolchain from
  the typos job
- ci-docs-only.yml (new): satisfy the required "Test and Lint" check
  on docs-only PRs that ci.yml skips via paths-ignore
- audit.yml: drop cargo-audit (cargo-deny advisories covers the same
  RustSec database); same event-scoped concurrency fix
- docker.yml: job-level short-circuit for per-merge dev builds; send
  the Trivy SARIF to code scanning; unify the upload-artifact pin
- performance-ab.yml: add concurrency; only re-run on labeled events
  when the added label is perf-ab
- stagger the Sunday crons (build 01:00, audit 03:00,
  nix-flake-update 05:00, mint 06:00)
- delete performance.yml: disabled since 2025-07; idle-server
  profiling, no benchmark baseline, stale ecstore package name
2026-07-09 05:22:39 +08:00
houseme 506ded59ad fix(metrics): servers_offline_total counts each peer once (normalize peer-health key) (#4572)
fix(metrics): normalize peer-health key so servers_offline_total counts each peer once

rustfs_cluster_servers_offline_total is the count of distinct keys in the
addr-keyed CLUSTER_PEER_HEALTH map. The data path keys a peer by
endpoint.grid_host() (scheme://host:port) while the lock path keys it by
url::Url::to_string() (scheme://host:port/, trailing slash), so one physical
peer becomes two health entries and a single downed node reports 2 (2N for N).

Normalize the address (trim trailing slashes) at the map boundary in
record_peer_reachable/record_peer_unreachable/cluster_peer_is_offline/
cluster_peer_should_bypass so both forms collapse to one canonical key.
Pure observability fix; peer selection and quorum are unchanged.

Fixes rustfs/backlog#1043

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 20:56:08 +00:00
Zhengchao An cf2da0c44d refactor(object-capacity): remove the decorative SymlinkTracker and its no-op depth knob (#4571)
The tracker never influenced traversal: walkdir's follow behavior is
fixed up front by follow_links(), and should_follow() only gated the
tracker's own bookkeeping. Its 'depth limit' compared tree depth (not
symlink chain depth), so RUSTFS_CAPACITY_MAX_SYMLINK_DEPTH was a
complete no-op while its telemetry claimed symlinks were skipped that
walkdir had in fact followed and counted; record_symlink was always
called with size 0, so tracked_bytes never left zero (S12).

Remove the tracker, its skipped/summary events, the symlink metric and
the depth env knob end to end (the env's 'as u8' truncation goes with
it), and document the real semantics at the walker: follow_links(true)
counts targets with walkdir's ancestor-loop detection breaking cycles,
follow_links(false) — the default — counts no symlink targets. The scan
root itself is pre-resolved since backlog#1015.

Ref: rustfs/backlog#1018 (S12 from audit rustfs/backlog#1010)
2026-07-09 04:55:09 +08:00
Zhengchao An 2dfad181a0 fix(admin): include accountinfo bucket quota
Expose accountinfo bucket quota only when a bucket quota is configured while preserving the existing response shape for buckets without quotas.
2026-07-09 04:52:52 +08:00
Zhengchao An e468648f0f fix(utils): map remaining Linux uapi filesystem magic values
Map the remaining stable Linux uapi filesystem magic constants and keep values without a stable uapi source explicitly unknown.
2026-07-09 04:52:43 +08:00
Zhengchao An 05890d6e25 fix(ecstore): pass the new allow_inplace_legacy_fallback arg in codec streaming arity tests (#4573)
PR #4560 added a 15th parameter (allow_inplace_legacy_fallback) to
build_codec_streaming_part_reader while the negative arity tests from a
concurrently developed branch still call it with 14 arguments — the two
changes merged cleanly textually but broke the workspace test build on
main (E0061 in set_disk/read.rs), failing CI for every open PR.

Pass false at both call sites: these tests assert Err outcomes
(oversized part, missing quorum) that are independent of the fallback
mode, and false preserves the historical whole-request fallback
semantics used by eager first-part reads.
2026-07-08 20:23:29 +00:00
Zhengchao An 90d769167b test(ecstore): pass codec fallback flag in tests
Pass the explicit allow_inplace_legacy_fallback flag in codec streaming reader tests so CI builds compile after the signature change.
2026-07-09 04:22:19 +08:00
Henry Guo 91a23361ee fix(ecstore): tolerate completed metacache producers (#4531)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-07-09 04:10:57 +08:00
Henry Guo c6d054245f fix(admin): refresh datausage live bucket usage (#4490)
* fix(admin): refresh datausage live bucket usage

* fix(admin): reapply datausage memory overlay

* fix(ecstore): avoid runtime lookup for empty shard costs

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-07-09 04:09:40 +08:00
Zhengchao An bf287ec5c6 fix(admin): notify site replication after bucket metadata import (#4562)
fix(admin): replicate imported bucket metadata
2026-07-09 04:01:44 +08:00
Zhengchao An 32b1094ec3 fix(object-capacity): resolve a symlinked scan root instead of silently counting zero (#4564)
walkdir defaults to follow_root_links=true precisely so a root that is
itself a symlink still descends, but the scanner overrode it with the
general follow_symlinks flag (default false). For the common indirection
layout (/data/rustfs0 -> /mnt/vol0) the walk yielded a single skipped
symlink entry and returned used_bytes=0, file_count=0, is_estimated=false
with no error and no log — an exact 0 committed straight into the
per-disk baseline (S05).

Canonicalize the scan root before walking (also covering nested
indirection and keeping the dedicated-mount statvfs check on the real
path); resolution failures surface as scan errors instead of a silent
zero. Inner-symlink policy is unchanged.

Ref: rustfs/backlog#1015 (S05 from audit rustfs/backlog#1010)
2026-07-09 03:43:05 +08:00
houseme 7e1f7f242e fix(ecstore): preserve CommonPrefixes when an object and same-named prefix dir coexist in merge (backlog#880) (#4563)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 19:40:13 +00:00
houseme 8fc75e88c8 test(ecstore): multi-disk regression for read-before-write tagging under early-stop (backlog#881) (#4561)
test(ecstore): multi-disk regression for read-before-write tagging under early-stop

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 19:22:06 +00:00
houseme 9636989fef perf(ecstore): in-place per-part legacy degradation for lazy multipart codec reader (backlog#879) (#4560)
perf(ecstore):  in-place per-part legacy degradation for lazy multipart codec reader

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 19:16:32 +00:00
Zhengchao An 787cc77a7e fix(object-capacity): clamp zero capacity env values to safe defaults (#4559)
RUSTFS_CAPACITY_MAX_FILES_THRESHOLD=0 made every file count as
overflow with an empty exact prefix, so disks with fewer files than the
sample rate committed 0 bytes as the cluster total; STAT_TIMEOUT=0 with
dynamic timeout disabled made every scan fail at the first entry. Both
were accepted unvalidated, unlike the already-clamped sample_rate and
interval values.

Zero values for the threshold and the stat/min/max timeouts now fall
back to their defaults with a single structured warn at config load
(S11). The symlink-depth knob is left untouched here: it is removed
entirely by the backlog#1018 fix.

Ref: rustfs/backlog#1019 (S11 from audit rustfs/backlog#1010)
2026-07-09 03:14:11 +08:00
Zhengchao An ed81d2f6b8 test(ecstore): complete EC validation coverage gate
* test(ecstore): complete EC validation coverage gate

* test(ecstore): stabilize validation suite after rebase

* test(ecstore): fix rio-v2 clippy lint
2026-07-09 03:12:48 +08:00
houseme 7c701d9f2c test(ecstore): serialize load-sensitive flaky tests via nextest test-group (#4558)
Two ecstore test groups pass in isolation but flake under the full parallel
nextest suite, producing CI noise on every in-flight PR (backlog #937):

- store::bucket::tests::bucket_delete_* race make_bucket into
  InsufficientWriteQuorum because they share global disk-registry/lock-client
  state with other concurrently running tests.
- bucket_lifecycle_ops::tests::concurrent_resend_same_part_commits_one_generation
  exceeds its already-maxed 60s lock-acquire deadline only when the suite
  saturates disk I/O.

serial_test's #[serial] does not serialize these across nextest's per-test
process boundary. Add a nextest test-group (max-threads = 1) that matches both
groups so they run serialized, removing the load-induced flake. No test or
production code changes; long-term global-singleton isolation is tracked
separately.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 19:12:06 +00:00
Zhengchao An 1bd0f49c7a fix(admin): populate accountinfo bucket details (#4547) 2026-07-09 03:02:19 +08:00
Zhengchao An 68c3278efd test(utils): unignore drive stats platform test (#4549) 2026-07-09 03:02:13 +08:00
Zhengchao An 276a4f24c0 test(utils): extend Linux fs type mappings (#4556)
test(utils): extend linux fs type mappings
2026-07-09 03:01:57 +08:00
Zhengchao An 0ebe00b383 fix(object-capacity): drive scan progress checks by visited entries and remove unreachable stall detector (#4557)
Progress/timeout checks only ran when file_count advanced, so trees of
directory-only or error-dense entries (dirs, traversal errors, symlinks
never increment file_count) bypassed the entire cooperative time budget
and held the blocking thread for unbounded time, while each error entry
logged its own warn — permission-dense trees flooded the log (S13).
The stall detector was structurally unreachable: it fired on 'no file
progress between checks', but checks themselves only ran when file
progress happened, so RUSTFS_CAPACITY_STALL_TIMEOUT never did anything
since its introduction (S09).

- Progress checks (timeout + early-sampling entry) are now driven by a
  visited-entry counter that also advances on directories and errors, so
  every tree shape reaches the budget checks.
- Remove the unreachable stall detector and its plumbing end to end
  (ProgressMonitor fields, ScanLimits, config getter, env const, stall
  metric). Genuine walker wedges are handled by the hard outer
  wall-clock budget from backlog#1017; no decorative protection is left.
- Cap per-entry error warns at 10 per scan with an explicit suppression
  notice; had_partial_errors still records the condition.

Ref: rustfs/backlog#1016 (S09+S13 from audit rustfs/backlog#1010)
2026-07-09 02:47:17 +08:00
houseme d232a46b4d perf(ecstore): wire legacy decode stripe prefetch behind default-off gate (HP-9 step 2) (#4542)
perf(ecstore): wire legacy decode stripe prefetch / bitrot-decode overlap (backlog#930 HP-9 step 2)

The legacy GET decode duplex loop (default GET path + every Range request)
was strictly serial: read a stripe, reconstruct it, emit it, and only then
begin reading the next stripe. Two switches introduced by PR#3972 to overlap
this work — RUSTFS_GET_DECODE_STRIPE_PREFETCH_COUNT and
RUSTFS_GET_BITROT_DECODE_OVERLAP_ENABLE — were config-only with zero call
sites since introduction.

Wire both into the loop as a depth-1 stripe prefetch: while the current
stripe is reconstructed and emitted, the next stripe's shard reads (including
bitrot verification) run concurrently, hiding read latency under the emit /
duplex-backpressure stage. ParallelReader::read is inherently serial (it takes
&mut self and advances a shared stripe cursor), so at most one read can be in
flight; a prefetch count above 1 therefore collapses to the same
single-stripe-ahead pipeline rather than reading several stripes ahead. Both
switches feed one gate, legacy_stripe_prefetch_enabled().

Default (count == 1, overlap == false) keeps the loop on the pre-existing
strictly-serial path, byte for byte: the prefetch pipeline is a separate branch
and the serial branch is unchanged. The reconstruct/emit body is factored into
a shared emit_decoded_stripe helper used by both branches so error attribution,
read-quorum handling, reconstruction verification and stage metrics cannot
drift between them.

Correctness guarantees preserved under prefetch:
- A speculatively prefetched read for stripe N+1 is only consumed when the loop
  reaches N+1; if stripe N stops the loop, the in-flight read is awaited and
  dropped, so its errors never surface against stripe N.
- Bitrot (HighwayHash) verification runs inside each stripe read and is not
  bypassed or reordered; a corrupt shard is still rejected and, when
  unrecoverable, the read fails without emitting garbage.
- Shard buffers are recycled only after the overlapping next read has claimed
  its own — one extra stripe of memory (double buffering) with buffer reuse
  preserved at a one-stripe lag.
- Per-stripe exact length advance (backlog#799 B2), lockstep reconstruction
  verification (backlog#832) and the hash_size == 0 pass-through are unchanged.

Adds regression tests exercising serial-default, count>1 and overlap configs:
full/range reads byte-exact, degraded (missing-shard) reconstruction, corrupt
shard rejected-but-recovered, unrecoverable corruption erroring with no output,
late-stripe failure attributed correctly with stripe 0 still emitted,
hash_size == 0 pass-through, and the gate defaulting off.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 18:35:11 +00:00
houseme e3e5693e60 fix(ecstore): correct heal result drive-record reporting (#4555)
Two report-only defects on the heal result-reporting surface (they do not
affect the data path or heal decisions):

- default_heal_result (set_disk/ops/heal.rs): the offline-disk branch pushed
  an Offline record but fell through into the unconditional push, emitting a
  second (Corrupt) record for the same disk. This grew before/after.drives to
  disk_count + offline_count and misaligned every entry after the first offline
  slot. Add `continue` after the offline push, drive `disk_len` and the loop
  from a single `self.disks` snapshot, and assert `errs.len() == disk_len`.

- Sets::heal_format (core/sets.rs): the before/after drive lists were
  pre-filled with N default placeholders and then N real entries were pushed,
  yielding a 2N list whose healed status updates (indexed 0..N) landed on the
  blank placeholder half. Assign the lists directly from formats_to_drives_info
  (mirroring the set-level heal_format) so the healed updates hit the real
  entries.

Add regression tests covering offline/online record alignment and the
NoHealRequired and heal paths of the pool-level heal_format.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 18:29:54 +00:00
houseme 608ab14d7d perf(ecstore): fold metadata read open+fstat+read into a single spawn_blocking (HP-12 item 1) (#4554)
Sub-change A only: the two local-disk metadata read paths
(`read_metadata_with_dmtime`, `read_all_data_with_dmtime` in
crates/ecstore/src/disk/local.rs) previously dispatched open, fstat and the
xl.meta read as three separate async fs hops (three spawn_blocking round-trips
under tokio::fs). Each is now folded into a single tokio::task::spawn_blocking
closure using std::fs, cutting per-metadata-read dispatch from 3 to 1.

This does NOT touch sub-change B (removing the entry-point access() call).

Correctness first: this is the hottest metadata read path, so the refactor
preserves byte-for-byte-equivalent Results. Every error mapping is kept
identical:
  - open failure     -> to_file_error
  - is_dir           -> Error::FileNotFound (not to_file_error(EISDIR))
  - metadata failure -> to_file_error
  - xl.meta parse    -> propagated verbatim from the parser
  - try_reserve      -> Error::other
  - read_to_end      -> to_file_error
For read_all_data_with_dmtime the async NotFound -> access(volume_dir) ->
VolumeNotFound fallback (and its warn! event) is preserved on the async side:
the closure returns the raw open error unmapped; only open() can yield ENOENT
once the fd is valid, so gating the fallback on the open error is equivalent to
the original open-arm-only fallback.

To run the parser inside a blocking closure, filemeta gains a synchronous twin
`read_xl_meta_no_data_sync` (+ `read_more_sync`) in
crates/filemeta/src/filemeta/version.rs that line-for-line mirrors the async
version, differing only in std vs tokio read_exact. A new equivalence test
(`read_xl_meta_sync_equivalence_tests`) feeds identical buffers to both the
async and sync readers and asserts equal Ok bytes / equal Err variants across:
v1.0; v1.1/v1.2/v1.3; large meta triggering read_more; header truncation ->
UnexpectedEof; CRC-trailer truncation -> FileCorrupt; unknown major/minor ->
InvalidData; and want boundaries (exact fit, inline-data drop, read_more EOF).

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 18:28:57 +00:00
houseme f7d2b25638 fix(ecstore): propagate disk delete/rename failures instead of swallowing them (#4546)
Two disk-layer correctness fixes in crates/ecstore/src/disk/local.rs.

move_to_trash (#948, ECA-07) previously handled only DiskFull in its
tail error block and let every other rename failure (EIO/EACCES/ENOTDIR/
cross-device) fall through to `return Ok(())`, so a delete that never
landed on a faulty disk was reported as success and the drive fault was
hidden from heal/offline logic. It now keeps the DiskFull in-place-remove
fallback, treats a missing source (FileNotFound) as benign, and
propagates every other error (already mapped by to_file_error, e.g.
I/O -> FaultyDisk, permission -> FileAccessDenied), matching MinIO's
deleteFile.

rename_file and rename_part (#960, ECA-19) directory (trailing-slash)
branch unconditionally removed the destination before rename and
propagated the resulting NotFound, so renaming a directory to a new
location always failed. Both now tolerate ErrorKind::NotFound on that
pre-rename remove and continue, matching MinIO's RenameFile.

Adds regression tests covering benign-missing-source, real-error
propagation, the unchanged move happy path, and directory rename to a
missing destination for both rename_file and rename_part.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 18:27:11 +00:00
houseme 726f3dc185 fix(ecstore): accept empty remote version_id in tier recovery paths (#4552)
An object transitioned to an unversioned remote tier legally records an
empty remote version_id (per CLAUDE.md: a tier version of `None`/`""`
means the tier bucket is unversioned, so remote GET/DELETE must omit the
versionId). Two recovery paths wrongly treated that empty sentinel as an
incomplete/unrecoverable record while the persist/encode and worker
paths accept it, producing permanent leaks in the crash-recovery window.

- tier_delete_journal::into_jentry rejected entries with an empty
  version_id as "incomplete", so unversioned-tier WAL entries could never
  be decoded during recovery: the remote object was orphaned and the
  journal file leaked forever. Drop the version_id emptiness check; keep
  the obj_name/tier_name checks. Truncated payloads are still rejected at
  JSON deserialization (all fields are required, non-Option, no default,
  with deny_unknown_fields).

- tier_free_version_recovery::is_recoverable_tier_free_version required a
  non-empty version_id, silently filtering out free versions of
  unversioned-tier objects so a first enqueue failure leaked the remote
  object and local free version permanently. Drop the version_id check;
  keep free_version + name + tier checks.

Both downstream paths already issue a versionless remote delete for an
empty version_id, so no further changes are needed. Adds regression
tests covering the empty-version_id case and the preserved guards.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 18:26:29 +00:00
houseme f96314a1d5 docs(ecstore): pin streaming-only bitrot layout invariant (ECA-18) (#4553)
bitrot_shard_file_size only counts per-block checksum bytes for the two
streaming Highway variants, while BitrotWriter::write interleaves a hash
for any hash_algo.size() > 0 and bitrot_verify's read loop assumes an
interleaved hash per block. The three disagree for non-streaming
algorithms (SHA256/HighwayHash256/BLAKE2b512/Md5), but the divergence is
unreachable in production: every write path hardcodes HighwayHash256S and
ErasureInfo::get_checksum_info defaults to HighwayHash256S.

Per the audit decision (backlog#959), do NOT change the size formula:
it is a byte-for-byte port of MinIO's bitrotShardFileSize and its bare
return for non-streaming algorithms is correct for MinIO whole-file
bitrot; changing it would break legacy interop. Instead, document the
per-algorithm layout contract at bitrot_shard_file_size, BitrotWriter,
and bitrot_verify, and add regression tests that pin the invariants:
get_checksum_info defaults to HighwayHash256S, and the size formula
counts per-block hash bytes for streaming variants only while returning
the bare size for non-streaming ones. No disk layout or formula change.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 18:25:05 +00:00
houseme 20d61c73bc fix(ecstore): stop reduce_errs leaking nil placeholder as dominant error (#4551)
reduce_errs seeds best_err with a synthetic Error::other("nil") placeholder
via unwrap_or. When the error slice is empty or every error is ignored,
err_counts is empty and nil_count is 0, so both nil tie-break conditions are
false and the else branch returned (0, Some(Error::other("nil"))). That
placeholder leaked to build_write_quorum_failure_summary, where dominant_error
became Some("nil"), skipping the nil_dominated early return and falling back to
the "other_error" metric label. Quorum decisions were unaffected (max_count 0
still fails any positive quorum), but write-quorum failure metric labels and
tracing fields were misclassified.

Add an explicit short-circuit: when best_count == 0 and nil_count == 0, return
the (0, None) sentinel so the placeholder never escapes. Add regression tests
for the all-ignored slice, empty slice, and the summary label path, which the
existing test_build_write_quorum_failure_summary (nil_count > 0) did not cover.

Fixes #957

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 18:22:55 +00:00
Zhengchao An 72d76dc9ca fix(object-capacity): make dirty-disk lifecycle race-free and prune ghost entries (#4550)
Two dirty-mark lifecycle gaps (S19+S30):

- A commit cleared every dirty mark for the disks it scanned, even marks
  recorded while the walker was already past the written prefix, so the
  next dirty-subset refresh served stale cached bytes as exact until the
  next full scan. Dirty marks now carry the instant they were last
  recorded and a commit only clears marks that predate its scan start
  (CapacityUpdate::scan_started_at, set by refresh_capacity_with_scope).
- The write side marks every disk of an EC set dirty, including remote
  peers, while scans and clearing only cover local disks — remote or
  removed disks stayed marked forever, keeping the dirty-disk gauge
  permanently non-zero with bounded memory stuck behind it. The refresh
  disk selection now prunes dirty entries outside the current local
  topology before consulting them.

Ref: rustfs/backlog#1020 (S19+S30 from audit rustfs/backlog#1010)
2026-07-09 02:22:09 +08:00
houseme c77c5f047a fix(ecstore): defer multipart part.N.meta cleanup until after commit (#4548)
complete_multipart_upload deleted every part.N.meta via
cleanup_multipart_path *before* the authoritative rename_data commit.
If rename_data then failed write quorum, the upload directory was left
with data files but no part metadata, so a retried CompleteMultipartUpload
could never read the parts again and the upload became permanently
uncompletable (client must abort and re-upload all parts).

Move cleanup_multipart_path to run only after rename_data returns Ok,
matching the existing "clean up only after commit" pattern already used
for the old data-dir GC and the upload-dir delete_all. On a failed commit
the staging part.N.meta now survive so the retry can complete.

Add a hermetic regression test that forces rename_data to fail on every
disk (destination bucket dir made read-only) and asserts the staging
part.N.meta are preserved for retry.

Fixes #946.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 18:06:42 +00:00
Zhengchao An d342d33632 fix(tls): poll reloads for watch mode (#4532) 2026-07-09 02:01:47 +08:00
Zhengchao An 7fa3d0d4ba fix(io-core): correct available_buffers pool gauge drift (#4534)
fix(io-core): decrement available_buffers gauge on pooled-buffer reuse (backlog#806)

return_buffer does a fetch_add(1) on push but take_or_allocate_buffer's
available.pop() reuse path had no matching fetch_sub, so the available_buffers
gauge only ever grew and never reflected the real pool size. Decrement it on
reuse; + regression test.
2026-07-09 02:01:42 +08:00
Zhengchao An 16a91c35ec perf(iam): load IAM lists in fixed chunks to avoid O(n^2) startup load (backlog#806) (#4537)
load_all fetched policies/users/user-policies by passing the FULL remaining
list to load_*_concurrent every iteration and then split_off(32), so each item
was re-fetched once per preceding chunk — O(n^2) redundant loads on startup for
>32 items. Replace the split_off loops with chunks(32) so each item is fetched
exactly once. Behavior-preserving (cache inserts were already idempotent).
2026-07-09 02:01:36 +08:00
houseme 47c1e730c7 fix(ecstore): make erasure heal write quorum best-effort per target (#4545)
Erasure heal set write_quorum to the count of available target writers,
so every replacement disk had to succeed writing every block or the whole
object heal aborted. A single flapping replacement disk failing one block
made MultiWriter::write return Err, heal propagate Err, and the ops layer
delete the entire tmp staging dir — leaving the other healthy replacement
disks unhealed and blocking redundancy recovery indefinitely.

Set the heal write_quorum to 1, matching upstream MinIO's writeQuorum=1
for heal. MultiWriter already marks a failed writer as None, and the ops
layer (set_disk/ops/heal.rs) already drops the failed writer while
committing the survivors; the read-side quorum check and parity
cross-checks that guarantee reconstruction correctness are unchanged.

Add regression tests: one healthy-and-one-failing target still heals the
healthy targets and drops the failing one; all-targets-failing still
returns Err so nothing is falsely committed.

Fixes #947

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-09 01:56:03 +08:00
Zhengchao An f73d4121ad fix(object-capacity): close singleflight guard gaps in background refresh and no-runtime drop (#4543)
Two defensive gaps left the refresh singleflight vulnerable to a
permanent wedge (running=true forever: scheduled refreshes silently
stop, admin capacity joiners hang until restart):

- spawn_refresh_if_needed's spawned task had no leader guard, unlike
  refresh_or_join: a panic after the refresh future completes (commit,
  metrics) killed the task before the reset block. The task now holds
  the same RAII RefreshLeaderGuard, disarmed only after the state is
  reset and the result published (S20).
- refresh_fn() was evaluated before AssertUnwindSafe wrapping in both
  paths, so a panic while constructing the future escaped catch_unwind;
  construction now happens inside the wrapped future.
- RefreshLeaderGuard::drop silently skipped the reset when try_lock was
  contended and no tokio runtime was current; it now falls back to a
  blocking reset, which is safe precisely because there is no executor
  to stall in that context (S31).

Ref: rustfs/backlog#1021 (S20+S31 from audit rustfs/backlog#1010)
2026-07-09 01:51:13 +08:00
houseme f262fcfce0 perf(hotpath): add fine-grained PUT-path stage guards (HP-14) (#4541)
Close the ~10ms instrumentation residual on the PUT success path that the
existing coarse writer_setup/encode/rename stage metrics do not attribute.
Adds `hp_guard!` measurement scopes to the previously uninstrumented
sub-stages called out in backlog#935 item 2:

- SetDisks::acquire_read_lock / acquire_write_lock (namespace lock acquire)
- S3::put_object_prelookup (pre-write get_object_info lookup)
- MultiWriter::shutdown (bitrot writer flush/close)
- SetDisks::commit_rename_data_dir (old data-dir reclaim)
- S3Access::put_object (S3 authorization segment)

Instrumentation only: `hp_guard!` expands to nothing without the `hotpath`
feature, so this is a pure-observation change with zero behavior impact and
zero cost in default builds. The pre-lookup site wraps only the lookup call
in a scoped block so the guard measures that slice exactly while preserving
the existing match and control flow.

Verified: cargo check -p rustfs-ecstore (default and --features hotpath) and
cargo check -p rustfs (default and --features hotpath) all pass.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 17:47:14 +00:00
houseme 0866a8e6a0 fix(ecstore): route rebalance delete markers to store and count lifecycle expiries (#4540)
fix(ecstore): route rebalance delete markers to store and count expiries

Rebalance migrated delete markers via SetDisks::delete_object (single set,
no cross-pool routing), which silently rewrote the marker back onto the
source set. The subsequent source cleanup then deleted the whole entry,
losing the delete marker and reviving logically-deleted objects (#942, P0).
Route delete-marker migration through ECStore::delete_object via a store-
level closure that mirrors the existing transfer closure, so the marker
lands on the cross-pool target honouring data_movement/src_pool_idx/
delete_marker; on failure it is not counted as moved, so the source entry
is not cleaned up. The unused MigrationBackend::delete_object_for_migration
footgun is removed.

Rebalance source cleanup also ignored lifecycle-expired versions: the gate
used rebalanced == total_versions and passed an empty allowed_missing, so
any entry with an expired version could never be cleaned up, leaking the
migrated versions in the source pool (#950). Mirror decommission: gate on
rebalanced + expired == total_versions and feed expired version identities
into the cleanup preflight allowed_missing, plus a source_retained warning
for parity with decommission diagnostics.

Adds regression tests for delete-marker store routing and the expiry-aware
cleanup gate.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 17:45:54 +00:00
houseme d91f4d4557 fix(ecstore): report truncation when delimiter list re-folds a full page (#4538)
Non-slash delimiter ListObjects/ListObjectVersions force a recursive walk and
defer common-prefix folding to from_meta_cache_entries_sorted_infos. When a page
of max_keys+1 raw candidates collapses into fewer than max_keys common prefixes,
list_objects_paginate left is_truncated=false with no next_marker even though the
walker had filled its raw candidate limit (disk_has_more=true). Clients were told
listing was complete and silently lost every key beyond the scan window, a
data-loss bug for backup/sync/mirror/reconcile consumers (backlog ECA-03 / #944).

Add a delimiter re-fold guard: when disk_has_more is true but the folded visible
entries are fewer than max_keys, set is_truncated=true and continue from the last
RAW scanned key (captured before folding via last_scanned_entry_name). Using the
last raw key rather than a folded prefix is required for correctness: forward_past
advances on name > marker, and a folded prefix such as "data-" is lexicographically
smaller than its own keys ("data-0001"), so it would re-list and re-fold the same
keys forever. A real scanned key strictly advances past the scanned window and
keeps pagination finite. The slash-delimiter and no-delimiter paths fold during
the walk and are unaffected.

Regression tests cover the direct re-fold case, the disk-exhausted no-marker case,
the no-delimiter path guard, and an end-to-end 5000 data-* + 100 other-* paging
loop asserting bounded termination and full common-prefix coverage.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 17:42:12 +00:00
houseme e0c8b3dca6 fix(obs): evict retired metric cache entries (#4539)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 17:41:10 +00:00
houseme e0619e355f fix(ecstore): stop treating DiskNotFound as object not-found in listing (#4536)
is_all_not_found and is_all_volume_not_found delegated to
is_err_bucket_not_found, which matches StorageError::DiskNotFound. When
every erasure set in every pool was unreachable, the per-set aggregate
DiskNotFound slice was classified as "all not found", so list_merged
returned Ok(empty) and walk_result_from_set_errors was fed an
availability failure disguised as an empty listing. Clients saw a
successful empty ListObjects and mistook a full outage for an empty
bucket.

Introduce is_err_strict_not_found (FileNotFound / VolumeNotFound /
FileVersionNotFound / ObjectNotFound / VersionNotFound, excluding
DiskNotFound) for is_all_not_found, mirroring MinIO's isAllNotFound and
DiskError::is_all_not_found. Give is_all_volume_not_found its own
is_err_strict_volume_not_found (VolumeNotFound / BucketNotFound, minus
DiskNotFound) so genuine volume/bucket absence still surfaces while a
missing object under an existing volume is not escalated. Add
is_all_disk_not_found and a pre-check in walk_result_from_set_errors so
an all-offline slice surfaces DiskNotFound instead of being swallowed as
an empty listing, while partial outages (a healthy set present) stay
tolerated as before.

Regression tests cover all-DiskNotFound rejection, genuine not-found
acceptance, volume-not-found semantics, partial/empty short-circuit, and
the walk full-offline vs partial-offline paths.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 17:38:36 +00:00
houseme 15808254d3 fix(ecstore): correct codec-streaming byte accounting and partNumber routing (#4535)
Two correctness defects on the opt-in codec-streaming GET path.

ECA-02 (#943): ErasureDecodeReader only decremented `remaining` for the
main fill buffer. Under the default DualInFlight policy each fill also
produces a queued stripe that is delivered to the client via
`prefetched_bufs.pop_front()` without touching `remaining`, so any object
larger than one erasure block finished with `remaining > 0` and the GET
terminated with LessData despite delivering all bytes. The inflated
`remaining` was also fed back into the fill worker, which used it to trim
the final stripe and to decide whether to read past EOF. Account for the
queued-stripe bytes when they enter the prefetch queue; queued buffers
come only from `Ok(true)` decodes so they are non-empty and bounded by
`remaining - main_buf.len()`, ruling out underflow.

ECA-04 (#945): the codec-streaming gate did not inspect `opts.part_number`.
A partNumber GET carries `range == None`, so it was not classified as a
Range request and reached the full-object codec-streaming reader, which
drops the storage offset/length returned by GetObjectReader::new. A
partNumber >= 2 request would then stream the whole object. Mirror the
direct-memory part_number fallback and route any partNumber request back
to the legacy duplex path, which applies the offset/length correctly.

Regression tests: DualInFlight read_to_end on a multi-block object and on
a non-block-aligned object; SingleInFlight vs DualInFlight byte-identical
output; gate fallback on partNumber requests.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 17:35:39 +00:00
Zhengchao An 5a372557e5 fix(object-capacity): bound wedged scans with a hard outer timeout and cap joiner waits (#4533)
The whole disk traversal runs inside spawn_blocking with no timeout
anywhere on the async side; the in-scan ProgressMonitor checks are
cooperative and only run between walker entries. A stat/readdir blocked
on a dying disk or hung NFS mount therefore never returns: the blocking
thread leaks, the refresh singleflight stays running forever, every
subsequent scheduled refresh is skipped as inflight, and every admin
capacity query joins an unbounded wait until process restart (S02).

- Wrap each disk scan in tokio::time::timeout with a hard wall-clock
  ceiling of 2x the cooperative budget (min 5s). On expiry the caller
  fails the disk scan (releasing the singleflight through the normal
  error path, where the degraded/partial machinery from backlog#1014
  keeps the failed disk's last-known value) and a shared AtomicBool asks
  the blocking walker to exit at its next entry, bounding the thread
  leak to the single wedged syscall.
- Bound refresh_or_join joiner waits at 5 minutes so admin queries
  degrade into a clear error instead of hanging if the leader wedges in
  a way the drop/panic guards don't cover.

Ref: rustfs/backlog#1017 (S02 from audit rustfs/backlog#1010)
2026-07-09 01:22:22 +08:00
Zhengchao An 05833063c7 refactor(concurrency): remove zero-caller facade modules, fix feature build (#4530)
refactor(concurrency): remove zero-caller facade modules and fix no-default-features build (backlog#1025)

The audit in rustfs/backlog#1010 (consistent with #805) established that most of crates/concurrency was a decorative facade with zero production callers; the real runtime concurrency control lives in rustfs/src/storage/*. This deletes the dead facades and keeps only what the workspace actually consumes.

Deleted (zero callers verified by workspace-wide grep):
- manager.rs: ConcurrencyManager, lifecycle start/stop, misleading 'started' lifecycle logs
- config.rs: ConcurrencyConfig, ConcurrencyFeatures, from_env
- timeout.rs: TimeoutManager, TimeoutGuard, TimeoutManagerPolicy
- lock.rs: LockManager, LockScopeGuard, OptimizedLockGuard
- scheduler.rs: SchedulerManager, SchedulerPolicy, IoStrategy
- deadlock.rs facade: DeadlockManager, RequestTracker
- backpressure.rs facade: BackpressureManager, BackpressurePipe
- the prelude module, unused io-core re-exports, and all feature flags

Kept (real callers in ecstore/heal/rustfs):
- workload.rs admission contract types (unchanged)
- workers.rs Workers pool (unchanged, retained per #4498)
- GetObjectQueueSnapshot (moved from manager.rs to new queue.rs)
- PipeBackpressurePolicy (used by rustfs/src/storage/backpressure.rs)
- DeadlockMonitorPolicy (used by rustfs/src/storage/deadlock_detector.rs)
- OperationProgress re-export (used by rustfs/src/storage/timeout_wrapper.rs)

Removing the feature flags fixes the previously broken cargo check -p rustfs-concurrency --no-default-features (E0432). Docs and the logging guardrail file list are updated to match.

Ref: rustfs/backlog#1025
2026-07-09 01:12:43 +08:00
Zhengchao An a7b9659e75 fix(ecstore): hide encrypted object checksums (#4529) 2026-07-09 01:08:00 +08:00
Zhengchao An 3531abb34a feat(heal): disk-walk UNION enumeration to heal sub-quorum versions (backlog#920) (#4527)
B5 switched heal enumeration to list_object_versions, which only reflects the
read-quorum metadata view: a version present on fewer than read-quorum disks was
never enumerated, so it was never healed. Add a per-erasure-set disk-walk UNION
enumerator (mirrors MinIO global-heal.go objQuorum=1 listPathRaw +
mergeXLV2Versions) that surfaces every (object, version) present on ANY disk and
feeds each to the existing per-version heal_object.

- filemeta: MetaCacheEntries::resolve_union (dir_quorum=1/obj_quorum=1) yields the
  cross-disk version union at one tested seam.
- ecstore: SetDisks::heal_walk_versions_page (list_path_raw fan-out, min_disks=1,
  dual object/version page bound, inclusive-forward de-overlap) + ECStore delegator
  + HealWalkVersion.
- ecstore data-safety guard: before dangling-delete, try_regenerate_recoverable_meta
  physically probes part files via check_parts; when >= data_blocks data shards
  survive (meta lost but data recoverable) it regenerates xl.meta from a surviving
  FileInfo with the correct per-disk shard index instead of dangling-deleting.
  Genuine torn writes (< data_blocks) keep the current behavior — no resurrection.
- heal: dw1: forward-marker cursor codec (reuses ResumeState.resume_cursor,
  idempotent restart on foreign tokens); list_versions_for_heal_page_disk_walk
  trait method (default falls back to the B5 read-quorum path); heal_bucket_with_resume
  selects the disk-walk enumerator when scan_mode==Deep || source==AutoHeal, else
  the unchanged B5 path; anti-loop guard aborts on (empty && truncated).

Closes rustfs/backlog#920
2026-07-09 01:07:54 +08:00
Zhengchao An 2055044cb4 fix(ecstore): paginate ListMultipartUploads across pools (#4522) 2026-07-09 01:07:46 +08:00
Zhengchao An 7b87d4d13b fix(lock): stop waiter-count leak and dead pool recycle (#4520)
Make OptimizedNotify::wait_for_read/wait_for_write cancellation-safe with an
RAII decrement guard so the waiter counter is decremented even when the future
is dropped at the await (capped-wait timeout or task abort), preventing the
counter from leaking upward on a hot lock.

Fix cleanup_expired_batch so recycling into the object pool actually works:
Arc::try_unwrap on a clone could never succeed, so no state was ever recycled.
Collect keys during retain, remove them afterward to obtain the owned Arc, and
try_unwrap that before returning the state to the pool.

Refs rustfs/backlog#1035
2026-07-09 01:07:41 +08:00
Zhengchao An c41062f278 fix(protocols): constant-time FormPost signature check and guard DLO/SLO range underflow (#4519) 2026-07-09 01:07:36 +08:00
Zhengchao An 7ead413599 refactor(ecstore): migrate the local disk registry into InstanceContext (Phase 5 Slice 12) (#4518)
Phase 5 Slice 12 (backlog#939): move the local disk registry — the
path->disk map, the disk-id->endpoint map, and the pool/set/drive layout —
out of the three GLOBAL_LOCAL_DISK_* process statics into the per-instance
InstanceContext.

This is the migration the owned-guard prerequisite (#4501) unblocked.

- InstanceContext gains three `Arc<RwLock<..>>` registry fields, constructed
  eagerly (empty), plus pub(crate) handle accessors.
- The three runtime-source handle functions (`local_disk_map_handle` etc.) now
  return the current instance's registry via `current_ctx()`; every direct
  `GLOBAL_LOCAL_DISK_*` access in runtime::sources (~15 sites) routes through
  those handles instead. Accessors that hold a write guard across `.await`
  (`record_local_disks`, `initialize_local_disk_maps`, `replace_local_disk_id`)
  bind the handle first, so the same locks are held across the same awaits.
- The three statics are removed; the test-reset helper clears the current
  context's registry.

Construction window: the registry is populated during storage startup, before
the ECStore exists, via the bootstrap context that `ECStore::new` then adopts —
so startup writes and post-construction reads share one registry (no
split-brain). Single-instance behavior is byte-for-byte unchanged; the
GLOBAL_LOCAL_DISK_* boundary arch guard passes with the statics gone.

Verification: cargo test -p rustfs-ecstore (32 disk/instance tests green) and
-p rustfs-heal (201 green), cargo clippy -p rustfs-ecstore -p rustfs-heal
--all-targets (clean), make pre-commit (pass).

Refs: backlog#939 (Phase 5, Slice 12; disk-registry migration)
2026-07-09 01:07:28 +08:00
Zhengchao An 87357a0fdd fix(swift): escape static-website listing to prevent stored/reflected XSS (#4515) 2026-07-09 01:07:19 +08:00
Zhengchao An 3c53854345 fix(common): sum forwarded slots in LastMinuteLatency::merge (#4514)
In the self.last_sec > o.last_sec branch, merge forwarded a clone of o to
age out stale ring-buffer slots but then summed the un-forwarded o.totals
instead of the forwarded copy, so aged-out windows leaked into the result
and the forwarded clone was a dead write. Read both operands in forwarded
form and align the summation on wrapping_add to match AccElem::merge.
2026-07-09 01:07:15 +08:00
Zhengchao An afaf8c681a fix(checksums): reject md5 instead of silently returning crc32 (#4513)
The UnknownChecksumAlgorithmError message omitted crc64nvme and advertised
the deprecated md5. Parsing "md5" also returned a Crc32 algorithm and its
into_impl produced a 4-byte CRC32, so a caller asking for MD5 silently got
the wrong digest.

Enumerate the accepted algorithms (crc32, crc32c, crc64nvme, sha1, sha256)
in the error message, remove the unused Md5 enum variant, and make parsing
"md5" fail loudly. Add tests covering the message contents and the md5
parse error.
2026-07-09 01:07:11 +08:00
Zhengchao An 77eb91bf8d fix(s3-types): cover all removed events and fix scanner round-trip (#4512) 2026-07-09 01:07:06 +08:00
Zhengchao An 765e719aaf fix(s3select): configure session partitions (#4511) 2026-07-09 01:07:01 +08:00
Zhengchao An f9f2e5b4b8 fix(zip): validate CRC32 on small-entry extract fast path (#4510) 2026-07-09 01:06:55 +08:00
Zhengchao An 8bfb00bc03 fix(filemeta): guard get_idx bound and fix sorts_before tie-break (#4509) 2026-07-09 01:06:44 +08:00
Zhengchao An d1245ca33c fix(config): default trusted proxies to loopback-only (#4508) 2026-07-09 01:06:11 +08:00
Zhengchao An 5bbbe0008e fix(data-usage): sum all sub-buckets for 1KiB-1MiB histogram rollup (#4507) 2026-07-09 01:06:03 +08:00
houseme e44bece00d fix(audit): harden reload ordering, start race, paused drops, and batch observability (#4497)
Follow-up hardening for the audit control plane. The ABBA deadlock
(backlog#961), dispatch failure propagation (backlog#962), and credential
header redaction (backlog#963) already landed on main; this change addresses
the remaining audit-side findings.

- backlog#970: reload/commit now shuts down existing replay workers and closes
  old targets *before* activating the replacement set, so old and new workers
  never drain the same store concurrently and re-deliver entries. Extracted a
  state-neutral `shutdown_runtime_targets` helper (registry-then-cancellers
  lock order preserved).
- backlog#978: `start()` claims the `Starting` transition atomically under the
  state lock, closing the check-then-act race that could double-activate;
  `dispatch()` no longer returns Ok while paused, it surfaces an explicit
  `AuditError::Paused` so the audit trail is not silently corrupted.
- backlog#984 (audit part): `dispatch_audit_log` performs a single state read
  and interprets not-running/paused as a deliberate skip while surfacing real
  delivery failures; `dispatch_batch` records the same observability signals as
  single dispatch; documented the unordered cross-target fan-out.

Adds unit tests: paused dispatch returns Err, concurrent start does not hang or
double-activate, commit closes old targets before installing new, and
dispatch/dispatch_batch delivery-outcome coverage (all-fail -> Err, partial ->
Ok, all-success -> Ok).

Relates to rustfs/backlog#970
Relates to rustfs/backlog#978
Relates to rustfs/backlog#984

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 17:05:21 +00:00
houseme e008cc5dae fix(targets): harden Postgres/MySQL SQL backends (pool timeouts, error classification, idempotency) (#4500)
fix(targets): harden Postgres/MySQL SQL backends (pools, error class, idempotency)

Postgres (postgres.rs):
- Add deadpool wait/create/recycle timeouts + tokio-postgres connect_timeout,
  and wrap every pool.get() in a Tokio hard-limit timeout so an unreachable
  broker/DB can no longer block send_body/probe_table/is_active forever;
  checkout timeouts map to TargetError::Timeout to trigger store replay.
- namespace format now DELETEs the row on s3:ObjectRemoved:* events instead of
  UPSERTing, keeping the table consistent with the object lifecycle.
- map_pg_error: SQLSTATE class 40 (serialization_failure 40001,
  deadlock_detected 40P01, transaction rollback) is now transient (Timeout,
  retryable) instead of a permanent Request. Extracted map_pg_sqlstate for
  unit-testable classification.

MySQL (mysql.rs):
- Wrap pool.get_conn() in a Tokio checkout timeout across insert/init/liveness
  paths so an unreachable server cannot block the delivery thread.
- Add an event_id idempotency key: tables created by the target now carry an
  event_id VARCHAR(255) PRIMARY KEY, inserts use ON DUPLICATE KEY UPDATE, and
  replays reuse the stable store key so a lost-ack replay no longer appends a
  duplicate audit row. Legacy two-column tables are detected and fall back to
  the non-idempotent insert with a warning (backward compatible).
- redact_mysql_dsn splits on the last '@' so a password containing '@' no
  longer leaks its tail into Debug output.
- Disconnect the stale pool before dropping it on inline TLS reload instead of
  leaking its connections.
- Cache TLS file mtimes and only recompute the inline fingerprint when a cert
  file changes, avoiding a 3-file read+hash on every checkout.

Adds unit tests for SQLSTATE classification, namespace delete SQL, removal-event
detection, DSN redaction with '@' in the password, and the MySQL insert/DDL
builders.

Relates to rustfs/backlog#976
Relates to rustfs/backlog#973
Relates to rustfs/backlog#983

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 17:00:44 +00:00
Zhengchao An 73a30178f5 fix(object-capacity): make timeout fallback keep accumulated work and enter sampling within the time budget (#4517)
The capacity scan's timeout fallback required the exact prefix
(threshold + sample_rate files, default ~200k) to be fully enumerated
before any sample existed. Cold-cache HDDs cannot stat that many files
inside the 15s budget, so large slow disks — exactly the disks sampling
was designed for — always timed out with sampled_count == 0, discarded
the accumulated exact-prefix work with a hard error, and re-ran 15s of
useless full-disk I/O every 120s (S03). When a sample did exist, the
estimate only extrapolated over files the walker had seen, silently
under-reporting disks whose tail was never reached (S10).

- Enter sampling early: once half the (possibly dynamic) time budget is
  consumed and the exact prefix hasn't filled, freeze the threshold at
  the current position so the remaining budget collects samples and the
  timeout fallback is always reachable.
- Compensate the unseen tail: when the scan root is a dedicated mount
  point (stat st_dev differs from the parent), take the max of the
  seen-files extrapolation and the filesystem-level used bytes. On
  shared filesystems (multi-disk dev layouts) statvfs would overcount,
  so it is not trusted and behavior is unchanged.
- sampled_count == 0 at timeout no longer hard-fails when a dedicated
  mount provides filesystem usage; the exact prefix is kept as a floor.
  Without any estimator the original error still surfaces.
- Extract ScanLimits from env reading so the blocking scanner is
  parameterizable in tests.

Ref: rustfs/backlog#1013 (S03+S10 from audit rustfs/backlog#1010)
2026-07-08 16:53:35 +00:00
houseme 6ab8636734 fix(obs): count scanned versions independent of ILM (#4516)
versions_scanned for both the scanner and ILM collectors was read from
the Lifecycle work source's `checked` counter, which is never recorded on
the production scan path — so rustfs_scanner_versions_scanned_total and
rustfs_ilm_versions_scanned_total sat at zero even while objects_scanned
climbed. The two metrics also have distinct intended meanings that were
conflated: "versions scanned" (all versions, any bucket) vs "versions
checked for ILM actions" (lifecycle-configured buckets only).

Add a lifetime `versions_scanned` counter recorded for every version the
scanner walks (independent of ILM), and record the Lifecycle source's
`checked` counter from the ILM evaluator so the ILM metric reflects the
real checked subset. The scanner collector now reports total scanned
versions; the ILM collector keeps the ILM-checked subset.

Closes backlog#995 (OBS-09).

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 16:50:51 +00:00
Zhengchao An e829430d89 fix(concurrency): rewrite Workers on tokio Semaphore to fix lost wakeups (#4498)
fix(concurrency): rewrite Workers on tokio Semaphore to fix lost wakeups (backlog#1023)
2026-07-09 00:31:06 +08:00
houseme 2df315baf0 fix(ecstore): fsync ancestor dirs for first object writes (#4493)
* fix(ecstore): fsync new object's ancestor dirs to close a power-loss gap

reliable_mkdir_all creates an object's directory (and any missing prefix dirs)
with plain mkdir and never fsyncs the parent chain. The commit-point fsync in
rename_data persists the object dir's *contents* (its xl.meta and data dir),
but not the object dir's own entry in the bucket/prefix directory. So on the
first PUT of an object, a power loss after the write is acknowledged could drop
the whole object directory even though its contents were durable — an
acknowledged write silently lost (rustfs/backlog#922 step 4).

For a new object (no prior xl.meta) under a durability tier that syncs commit
metadata, fsync the ancestor chain from the object dir's parent up to and
including the bucket after the commit rename, so the newly created directory
entries survive power loss. A starts_with guard bounds the walk to the bucket
subtree. Overwrites already have a durable object dir and are unaffected;
relaxed/none accept the wider window like the existing commit fsync.

Durability regressions are invisible to ordinary behavior tests, so the new
tests assert directly (via the fsync_dir recorder) that a first PUT under a new
prefix fsyncs both the prefix and bucket dirs, and that relaxed does not.

Scope: the non-inline (erasure) commit path. The inline branch has the same
gap and is a separate follow-up.

Refs: rustfs/backlog#922 (HP-1 step 4), rustfs/backlog#936

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

* fix(ecstore): fsync new inline object's ancestor dirs too

Extend the backlog#922 step 4 mkdir-gap fix to the inline commit branch. Like
the non-inline path, a first PUT of an inline object creates its directory
(and any missing prefix dirs) whose entry in the parent chain reliable_mkdir_all
never fsynced; the commit fsync persists the object dir's contents, not its own
entry. For a new inline object under a commit-metadata-syncing tier, fsync the
ancestor chain up to and including the bucket after the commit rename, using the
same starts_with-bounded walk (via the synchronous os::fsync_dir_std inside the
inline spawn_blocking closure).

Adds a test asserting a new inline object under a new prefix fsyncs both the
prefix and bucket dirs. rename_data now closes the ack'd-write power-loss gap on
both the erasure and inline paths.

Refs: rustfs/backlog#922 (HP-1 step 4), rustfs/backlog#936

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 16:31:00 +00:00
houseme 08e44b95f8 fix(targets): make persistent queue store crash-safe and replay lifecycle correct (#4505)
* fix(targets): make persistent queue store crash-safe and replay lifecycle correct

Harden the target notification persistent queue (store.rs) and the replay
worker lifecycle (runtime) against data loss, silent truncation, ordering
drift, orphaned tasks, and a few low-risk robustness gaps.

store.rs
- Atomic, durable writes: write to a per-key temp file, fsync (sync_all),
  then rename into place; best-effort parent-dir fsync. A crash mid-write
  can no longer lose an acknowledged event or leave a half-written payload
  that reads as a valid entry.
- open() now removes leftover .tmp residue and zero-byte files, and only
  indexes files matching the queue extension, so ghosts/foreign files are
  never replayed.
- FIFO ordering is derived from time-ordered UUIDv7 entry names instead of
  coarse, clock-dependent file mtimes, so replay order is stable and
  identical after a restart.
- Clamp HashMap/Vec pre-allocation derived from untrusted inputs
  (entry_limit, batch item_count) to avoid capacity-overflow panics / giant
  allocations.

target/mod.rs
- QueuedPayload::decode validates body length against the recorded
  payload_len, rejecting torn/truncated writes instead of delivering a
  silently truncated body.
- send_from_store purges a NotFound/empty entry (index + file) instead of
  skipping it, so it cannot occupy a queue slot and be replayed forever.
- sanitize_queue_dir_component appends a stable hash suffix when the id was
  lossy, so distinct target ids can no longer collapse onto the same queue
  directory; path-safe ids are unchanged (no migration).

runtime
- Replay backoff, idle waits, and inter-scan pauses are now cancel-aware, so
  reload/shutdown is not blocked for the full retry delay.
- ReplayWorkerManager::stop_all signals cancellation and then awaits each
  worker's exit (bounded, with abort fallback), preventing orphaned tasks
  and overlapping drain of the same store.
- Fix the always-true replay flush condition so batching is real
  (size/timeout based, one semaphore permit per batch) rather than one
  permit per entry; dedup keys already pending in the batch.
- clear_and_close aggregates and reports per-target close failures instead
  of swallowing them; explicit shutdown surfaces them.

Relates to rustfs/backlog#966
Relates to rustfs/backlog#967
Relates to rustfs/backlog#975
Relates to rustfs/backlog#970
Relates to rustfs/backlog#983

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

* style(targets): apply rustfmt to replay batch dedup guard

Fixes the Quick Checks rustfmt failure on the multi-line `.iter().any(...)`
closure in the replay batch dedup guard.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 16:27:34 +00:00
Zhengchao An c6d07ffc59 fix(object-capacity): mark partial-failure refreshes degraded and merge over disk cache (#4499)
A full refresh with partial disk failures used to commit the surviving
subset's sum as a fresh exact cluster total (no field carried the
partial-failure fact), while the complete disk cache kept the failed
disk's old value — so the reported capacity oscillated between the
partial sum and the cache-merged total on alternating refreshes.

- Add a degraded flag to CapacityUpdate/CachedCapacity, set when the
  scan behind the update had partial errors; expose it in refresh logs,
  admin capacity logs and a new rustfs_capacity_degraded_readings_total
  counter.
- On a degraded full refresh, surface only the disks whose own scan
  fully succeeded and merge them over a complete disk cache, so failed
  disks keep their last-known values and the published total no longer
  dips and bounces back. The disk cache is never replaced from a
  degraded refresh.
- Without a complete cache, keep the partial sum (unchanged #805
  non-pollution behavior) but mark the reading degraded.

Ref: rustfs/backlog#1014 (S06 from audit rustfs/backlog#1010)
2026-07-09 00:23:15 +08:00
houseme 7d93a7596c fix(targets): harden messaging backend delivery and error classification (#4506)
Address a batch of correctness/security audit findings in the messaging
notification backends (mqtt/nats/kafka/amqp/redis/pulsar/webhook).

MQTT (backlog#971): wait for broker acknowledgement via publish_tracked +
wait_completion_async (PUBACK/PUBCOMP for QoS>=1, flush for QoS0) with a
30s timeout before reporting success, instead of treating the enqueue as
delivered. Replace substring error matching with typed ClientError /
PublishNoticeError classification.

NATS (backlog#971, #973, #983): flush() after publish to confirm the broker
received the message before the durable copy is deleted; classify publish
and flush failures by typed error kind; warn when credentials are sent
without TLS.

Kafka (backlog#973, #983): use Error::is_retriable() so transient broker
states (NotLeaderForPartition, LeaderNotAvailable, RequestTimedOut, ...) are
retried instead of dropped as permanent; add a bucket/object message key for
per-object partition ordering; build the producer without holding the cache
lock across the connect await.

AMQP (backlog#973, #980): classify permanent broker protocol errors
(404/403/406, NOT_ALLOWED, ...) as request-level errors rather than
connectivity errors to avoid reconnect storms; bound publish and
publisher-confirm waits with a timeout; check is_enabled in is_active; run
full close() cleanup even when the broker close fails; warn when
mandatory=false may silently drop unroutable messages.

Redis (backlog#982): warn when PUBLISH reaches 0 subscribers; reuse the
cached ConnectionManager for health probes instead of a fresh handshake;
warn when tls_allow_insecure disables certificate verification.

Pulsar (backlog#983): replace std::sync::Mutex + unwrap with parking_lot
Mutex so a panic while holding the guard cannot poison later accesses.

Webhook (backlog#983): drain the response body so the connection can be
reused (keep-alive). The #974 redirect-follow SSRF fix already landed.

Relates to rustfs/backlog#971
Relates to rustfs/backlog#973
Relates to rustfs/backlog#974
Relates to rustfs/backlog#980
Relates to rustfs/backlog#982
Relates to rustfs/backlog#983

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 16:20:38 +00:00
houseme c9dba2c6c2 fix(notify): close core notify correctness and safety gaps (#4502)
Land the remaining notify-crate audit fixes.

backlog#979(b): remove_target now enforces the same bucket-binding guard as
remove_target_config, refusing to delete a target still referenced by a bucket
rule so notification rules are not left orphaned.

backlog#984:
- event.rs: an unversioned object omits versionId entirely instead of
  serializing versionId:"" (empty object/request versions treated as "no
  version").
- notifier.rs: RUSTFS_NOTIFY_SEND_CONCURRENCY=0 coerces back to the default
  instead of building a zero-permit semaphore that deadlocks every dispatch;
  init_bucket_targets_shared closes the replaced targets instead of dropping
  them without close() (connection leak).
- subscriber_index.rs: store_snapshot uses an atomic compute_if_absent upsert,
  removing the get-then-insert TOCTOU that could clobber a concurrent
  first-writer's snapshot cell.
- pipeline.rs: send_event assigns the history sequence and broadcasts to live
  subscribers under one critical section so broadcast order matches recorded
  sequence order.
- xml_config.rs: filter value length is bounded by character count, not byte
  length, so valid multi-byte keys are no longer wrongly rejected.
- global.rs: a losing initialize() race shuts the just-initialized system down
  instead of leaking its targets/replay workers.

backlog#970 (notify part): reload_config stops the running replay workers
before activating the new ones, so old and new workers do not concurrently
drain the same persisted stores. The full signal+join shutdown lives in the
targets crate under the same issue.

Tests: added regression coverage for each fix.
cargo build -p rustfs-notify, cargo test -p rustfs-notify --lib (98 passed),
cargo clippy -p rustfs-notify --all-targets (clean).

Relates to rustfs/backlog#979
Relates to rustfs/backlog#984
Relates to rustfs/backlog#970

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 16:20:02 +00:00
houseme cd327c81f5 fix(targets): harden control-plane, TLS reload, and runtime extension points (#4504)
Addresses security/correctness audit findings in the target-plugin control
plane, TLS reload coordinator, and runtime extension registries.

control_plane (backlog#977):
- Install now preserves the currently installed revision as previous_revision
  so Rollback actually restores the prior version instead of a no-op.
- Split the gate: circuit-breaker and runtime-activation checks apply only to
  Install/Enable; Disable and Rollback stay available as break-glass
  remediation while the breaker is open.
- Enforce the sidecar runtime protocol version for every external transport
  (previously skippable by declaring a non-gRPC transport) and validate the
  plugin api_compatibility_version at planning time.
- Download/signature/provenance host allowlisting now matches the full
  host authority, so an allowlisted host never implicitly authorizes a
  different host:port.

TLS reload coordinator/validate (backlog#981, #970-coordinator):
- Compute the fingerprint before building material in register() to remove the
  TOCTOU that could permanently pin an old certificate; rotation self-heals.
- Always start a detection loop when reload is enabled; Watch mode no longer
  returns success without any loop.
- validate_cert_key_pairing now verifies the private key matches the
  certificate's public key instead of only parsing both files.
- Normalize a zero poll interval to a positive minimum to avoid a panic that
  silently killed the poll loop.
- Serialize reload cycles with a per-target mutex; a first-step fingerprint
  read failure now records last_error and a failure metric.
- Duplicate label registration stops and joins the previous loop before
  publishing the replacement (stop-before-start), preventing orphaned loops.

runtime extension points (backlog#983 runtime subset):
- ops_profiler/ops_diagnostics authorize before probing the registry so an
  unauthorized caller cannot learn whether a backend/surface exists.
- s3_hooks dispatch_post_auth actually traverses the registered hooks for the
  point instead of unconditionally returning Continue.
- sidecar send_with_timeout redacts errors and uses the configurable failure
  threshold via policy, and a successful send resets the breaker accounting.

Relates to rustfs/backlog#977
Relates to rustfs/backlog#981
Relates to rustfs/backlog#970
Relates to rustfs/backlog#983

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 16:18:30 +00:00
houseme c9292688d3 fix(obs): wire dial9 runtime telemetry (#4491)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 16:16:06 +00:00
Zhengchao An 20447422cf fix: harden io-core and concurrency boundaries against panics (#4503)
fix(io-core,concurrency): harden boundary validation and arithmetic against panics (backlog#1024)
2026-07-09 00:15:36 +08:00
houseme 6cb47049e8 fix(obs): isolate process sampler windows (#4492)
* fix(obs): isolate process sampler windows

Refs rustfs/backlog#1004
Refs rustfs/backlog#986

- add a reusable ProcessSampler so callers can own independent sysinfo refresh windows
- wire separate sampler instances for obs metrics scheduling and memory observability
- keep compatibility helpers while avoiding cross-task CPU and disk delta interference

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

* fix(obs): import process sampler bundle helper

Refs rustfs/backlog#1004
Refs rustfs/backlog#986

- import collect_process_metric_bundle_with in the metrics scheduler
- drop the stale collect_process_metric_bundle import after switching scheduler sampling to independent process samplers

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

* fix(obs): move process sampler into blocking task

Refs rustfs/backlog#1004
Refs rustfs/backlog#986

- move the memory observability process sampler into the spawn_blocking closure
- satisfy the closure static lifetime required by tokio while keeping the isolated sampler design intact

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 15:51:34 +00:00
Zhengchao An 6bdfdd164a refactor(ecstore,heal): return the local disk map as an owned read guard (Phase 5 disk-registry prep) (#4501)
refactor(ecstore,heal): return the local disk map as an owned read guard (Phase 5 prep)

Prerequisite for the Phase 5 disk-registry migration (backlog#939): the disk
map cannot move from the process global into the per-instance InstanceContext
while callers depend on a `'static` read guard borrowed from the global.

Change `local_disk_map_read` to return an owned guard
(`OwnedRwLockReadGuard`) via `Arc::read_owned` instead of
`RwLockReadGuard<'static, _>`:

- ecstore `runtime::sources::local_disk_map_read` now returns
  `OwnedRwLockReadGuard<..>` (holds an Arc clone of the lock), not a `'static`
  borrow of `GLOBAL_LOCAL_DISK_MAP`.
- heal's forwarding accessor and its callers (which hold the guard across
  `.await` while clearing/writing per-disk markers) keep identical behavior —
  the owned guard derefs to the same map, so iteration is unchanged.

This decouples the heal crate from the global's `'static` lifetime so a later
PR can source the map from the current instance's context. Single-instance
behavior is byte-for-byte unchanged; the same read lock is held across the same
awaits.

Verification: cargo test -p rustfs-heal (201 tests green), cargo clippy -p
rustfs-ecstore -p rustfs-heal --all-targets (clean), make pre-commit (pass).

Refs: backlog#939 (Phase 5, disk-registry prerequisite)
2026-07-08 23:47:00 +08:00
houseme 7048a9a3ed test(ecstore): run shard_read_costs empty test under a Tokio runtime (#4496)
shard_read_costs_for_empty_disk_set_are_empty was a plain sync #[test], but
shard_read_costs_for_disks consults process-global topology state
(local_endpoint_hosts_for_shard_costs) whose fast-lock manager lazily spawns a
background cleanup task on first access. When this test was the first in a
process to touch that global — as under nextest's per-test isolation — the
tokio::spawn panicked with a TryCurrentError because no runtime was present,
making the test order-dependent flaky in CI (it passes only when some sibling
tokio test initializes the manager first).

Run it under a Tokio runtime like the sibling reservation tests
(#[tokio::test]), so the lazy init's spawn always has a runtime. Test-only
change; no production behavior change.

Verified failing before the change and passing after under both
`cargo test` and `cargo nextest run` in isolation.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 15:42:59 +00:00
Zhengchao An ca63a6cced refactor(ecstore): migrate background replication pool/stats into InstanceContext (Phase 5 Slice 11) (#4495)
Phase 5 Slice 11 (backlog#939): move the background replication pool and stats —
the last service handles, and the only async ones — out of the process statics
into the per-instance InstanceContext.

- InstanceContext gains `replication_stats: OnceCell<Arc<ReplicationStats>>` and
  `replication_pool: OnceCell<Arc<DynReplicationPool>>` (tokio async OnceCell),
  with sync read accessors (`replication_stats`/`replication_pool`/
  `replication_initialized`) and pub(crate) cell accessors for the async init.
- `init_background_replication` initializes the current instance's cells via the
  same `get_or_init(async {…}).await` (workers still spawned once on first
  init). The lifecycle owner helpers and the runtime-source accessors keep their
  signatures and route through the current instance's context; the two statics
  (and the now-unused lazy_static import) are removed. The replication-boundary
  arch guard still passes.

Single-instance: init materializes one shared pool/stats via the bootstrap
context — byte-for-byte the same as the eager statics.

Tests: replication state is None until set and independent across instances.

Verification: cargo test -p rustfs-ecstore (22 instance-context tests green),
cargo clippy -p rustfs-ecstore --all-targets (clean), make pre-commit (pass).

Refs: backlog#939 (Phase 5, Slice 11). Stacked on Slice 10 (#4494).
2026-07-08 23:15:01 +08:00
Zhengchao An db8ba9e2e5 refactor(ecstore): migrate the bucket bandwidth monitor into InstanceContext (Phase 5 Slice 10, re-submit) (#4494)
refactor(ecstore): migrate the bucket bandwidth monitor into InstanceContext (Phase 5 Slice 10)

Phase 5 Slice 10 (backlog#939): move the bucket bandwidth monitor out of the
process static into the per-instance InstanceContext.

- InstanceContext gains `bucket_monitor: OnceLock<Arc<Monitor>>` with
  `init_bucket_monitor(num_nodes)` (set-once; returns false if already set) and
  `bucket_monitor() -> Option<..>`.
- global.rs `init_global_bucket_monitor` / `get_global_bucket_monitor` keep
  their signatures and route through the current instance's context; the static
  is removed. The ignore-and-warn-on-reinit behavior is preserved (the warn
  stays in global.rs).

Tests: the monitor is None until initialized, set-once (second init is a no-op),
and independent across instances.

Verification: cargo test -p rustfs-ecstore (21 instance-context tests green),
cargo clippy -p rustfs-ecstore --all-targets (clean), make pre-commit (pass).

Refs: backlog#939 (Phase 5, Slice 10).
2026-07-08 22:56:43 +08:00
houseme 83bacd5b84 fix(obs): harden cleaner file matching (#4486)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 22:44:34 +08:00
houseme 651ccac130 perf(ecstore): parallelize tmp xl.meta write and shard fdatasync on commit (#4487)
The non-inline rename_data commit path made the tmp xl.meta durable and then
fdatasynced the shard files in two sequential awaits, each its own blocking
round-trip. The two operations touch disjoint paths (the tmp xl.meta under the
tmp bucket vs the shard data dir) and have no ordering constraint between them:
both only need to be durable before the commit renames that follow.

Run them concurrently with tokio::join!, dropping a blocking round-trip from
the PUT commit critical path (backlog#922 step 2). The commit ordering is
unchanged — both futures complete before any rename, and a failure in either
aborts before the rename exactly as the sequential version did (tmp-meta error
is still surfaced first). Payload and metadata durability semantics are
identical under strict and relaxed tiers.

Validated by the rename_data crash-consistency harness (backlog#935): a crash
at any pre-commit point still reopens as old-or-new, never mixed. Existing
rename_data / durability-tier / disk::os tests are unchanged and pass.

Refs: rustfs/backlog#922 (HP-1 step 2), rustfs/backlog#936

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 22:22:33 +08:00
Zhengchao An 9e162f224b refactor(ecstore): move lifecycle expiry and transition state into InstanceContext (#4488) 2026-07-08 22:15:22 +08:00
492 changed files with 56266 additions and 25595 deletions
@@ -0,0 +1,266 @@
---
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.
---
# Adversarial Validation Playbooks
The policy — risk tiers, role list, protocol, exit criteria — lives in the
root `AGENTS.md` under "Adversarial Validation (Default On)". Read it first;
this skill does not restate it. This file adds the RustFS-specific probe
playbook for each role: concrete attacks, where they apply, and the real
shipped bug or rule that earns each probe its place.
## How to run a role
1. Pick the tier and the applicable roles per the root `AGENTS.md`.
2. Run each role as an independent pass over the final diff — a parallel
reviewer agent where the tooling supports it, otherwise a fresh
sequential pass that starts from the diff and the nearest scoped
`AGENTS.md`, discarding the writing session's assumptions.
3. Within a role, execute the probes whose domain the diff touches, plus any
attack the diff obviously invites that no probe lists — the playbook is a
floor, not a ceiling.
4. Report findings (concrete failure scenario or named missing test, with
file:line) or the role's null report: "attacked X, Y, Z — no break
found". A bare pass is not a result.
## Role playbooks
### Correctness adversary
- For any change touching error aggregation or quorum decisions, build the exact disk-error slice at the quorum boundary: N disks where successes == quorum, then flip one success to an error (quorum-1) and separately inject None/nil placeholder entries into the slice. Trace whether reduce_errs (or the new equivalent) picks the placeholder as the dominant error or lets quorum-1 pass as success. Also check heal/write paths: does a per-target failure at quorum-1 return an explicit error, or silently degrade to success?
- Where: crates/ecstore/src/disk/error_reduce.rs; crates/ecstore/src/set_disk/{core,ops}; crates/heal
- Evidence: Commit 20d61c73b 'stop reduce_errs leaking nil placeholder as dominant error' (#4551) and 47c1e730c 'make erasure heal write quorum best-effort per target' (#4545); crates/ecstore/AGENTS.md: 'Do not weaken quorum checks... Prefer explicit failure over silent data corruption or implicit success.'
- For any change in EC read/reconstruct/streaming code, trace the mid-stream error path: the first K shards read fine, then a shard turns out bitrot-corrupt or inconsistent after N bytes have already been sent to the client. Verify the error propagates as a stream error (client sees failure), not a clean end-of-body — a silently truncated GET body is data corruption. Also re-check byte accounting: sum of per-part bytes vs object size, and partNumber-to-offset routing for the first and last part.
- Where: crates/ecstore/src/set_disk/read.rs (reconstruct-read, inconsistent_source_indexes handling ~line 3827); codec streaming paths in crates/ecstore/src/erasure_coding
- Evidence: Known live bug: EC reconstruct-read failing on inconsistent shards mid-stream silently truncates GET body → client 'unexpected EOF'; commit 15808254d 'correct codec-streaming byte accounting and partNumber routing' (#4535).
- For any listing/pagination change, construct the exact-boundary inputs: (a) exactly max_keys/max_uploads matching entries — assert the response contains max and is_truncated=false, then max+1 entries — assert exactly max returned with is_truncated=true and a correct continuation marker; (b) a delimiter listing where folding into CommonPrefixes re-fills a full page; (c) an object 'a' coexisting with prefix dir 'a/'. Off-by-one and dropped-truncation bugs live exactly at these boundaries.
- Where: crates/ecstore listing/merge paths (metacache, list_objects, list_multipart_uploads); rustfs/src/storage
- Evidence: Three recent real bugs: fefa70b31 'stop ListMultipartUploads from returning one upload past max-uploads' (#4447), d91f4d455 'report truncation when delimiter list re-folds a full page' (#4538), 7e1f7f242 'preserve CommonPrefixes when an object and same-named prefix dir coexist' (#4563).
- Run the diff's logic with a directory object key (trailing slash, e.g. 'pre/dir/') as input. Check which layer encodes/decodes __XLDIR__ — set_disk never sees the trailing slash, so any trailing-slash branch added below the store layer is dead code and a wrong-layer bug. For delete paths, check whether options force a nil versionId onto directory keys: the resulting miss surfaces as version-not-found, not object-not-found, so callers matching only ObjectNotFound leak ghost directory entries.
- Where: crates/ecstore/src/store*.rs (store layer) vs crates/ecstore/src/set_disk/*; delete option construction (del_opts) and its callers
- Evidence: trailing-slash branches must live at store layer; del_opts injects nil version for dir keys, PR#4220 ghost-directory cleanup never fired on the real path (rustfs#4307, backlog#798 still OPEN).
- Feed every new binary-UUID metadata read the three degenerate values: key absent, zero-length bytes, and 16 zero bytes (nil UUID). All three must mean 'no value' — any path that produces Uuid::nil() and then acts on it (e.g. sends it as a versionId) is a finding. For tier code specifically: with remote-tier version None or "", assert the tier GET/DELETE request carries no versionId parameter at all — sending versionId="" or nil yields NoSuchVersion against unversioned tier buckets.
- Where: crates/filemeta/src/filemeta/version.rs; crates/ecstore/src/bucket/lifecycle/; crates/ecstore/src/services/tier/; any new consumer of crates/utils/src/http/metadata_compat.rs
- Evidence: AGENTS.md Cross-Cutting Domain Invariants (defensive Uuid read pattern + unversioned-tier rule); docs/operations/tier-ilm-debugging.md ('nil transition_ver_id = corrupt legacy write-back, readers must filter'); commit 726f3dc18 'accept empty remote version_id in tier recovery paths' (#4552).
- For each match/if-let on an error or algorithm enum touched by the diff, enumerate what the wildcard/else arm swallows. Inject the variants the author didn't think of — DiskNotFound during listing, an unsupported checksum algorithm, an Err from a cleanup rename/delete — and trace whether they degrade into 'not found', a wrong-but-plausible value, or silent success. Any error path that converges with the success path without logging and propagating is a finding.
- Where: crates/ecstore (listing, delete/rename cleanup); crates/checksums; error-mapping layers in rustfs/src/storage
- Evidence: Three recent real bugs of this exact shape: e0619e355 'stop treating DiskNotFound as object not-found in listing' (#4536), afaf8c681 'reject md5 instead of silently returning crc32' (#4513), f7d2b2563 'propagate disk delete/rename failures instead of swallowing them' (#4546).
- For any change to version ordering, index lookup, or shard/part indexing: (a) call the accessor with index == len() and len()-1 — a get_idx-style bound must reject, not panic or wrap; (b) construct two versions with identical mod-times and check the sort tie-break is total and deterministic (equal keys must not compare as both before each other); (c) for EC shard math, compute shard size for object sizes 0, 1, blockSize-1, blockSize, blockSize+1 and cross-check total reconstructed length against the object size.
- Where: crates/filemeta/src/filemeta/*.rs (version sort, get_idx); crates/ecstore/src/erasure_coding shard-size math
- Evidence: Commit 8bfb00bc0 'guard get_idx bound and fix sorts_before tie-break' (#4509) — both bug classes shipped before; ecstore AGENTS.md high-risk designation for read/write/repair correctness.
- 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."
### Security reviewer
- For every admin handler in the diff, grep the exact AdminAction constant it passes to validate_admin_request and confirm it names the operation the handler actually performs. Construct the escalation: a low-privileged user whose policy grants the wrong-but-adjacent action (e.g. Export while the handler Imports, or Update while it Lists) — if the mismatched constant lets them through, that is the bug. Also confirm read-only endpoints (metrics, list, server-info, diagnostics) still call an operation-specific admin authz path and not a mere 'credentials exist' check.
- Where: rustfs/src/admin/handlers/**, rustfs/src/admin/router registration; check_permissions / validate_admin_request / AdminAction::* call sites
- Evidence: GHSA-vcwh-pff9-64cc (ImportIam checked ExportIAMAction), GHSA-mm2q-qcmx-gw4w (ListServiceAccount used UpdateServiceAccountAdminAction), GHSA-f5cv-v44x-2xgf (/admin/v3/metrics accepted any authenticated IAM user). advisory-patterns.md 'Admin authorization and route exposure'; rustfs/src/admin/AGENTS.md 'route registration, whitelist, handler authz must agree'.
- If the diff touches service-account or IAM import/update, treat parent, claims, accessKey, secretKey, status, policy names, and groups as attacker-controlled. Construct an ImportIam/create payload where parent points at root (or another user) and prove the code writes credentials without proving caller ownership or root authority. Separately, set deny_only=true (or 'no explicit deny') on a restricted account and check it does not skip the required allow check, letting it mint an unrestricted child.
- Where: crates/iam/, rustfs/src/admin/handlers (service account / import IAM), rustfs/src/auth.rs
- Evidence: GHSA-566f-q62r-wcr8 (attacker-controlled parent/claims/accessKey/secretKey → persistent backdoor under root), GHSA-xgr5-qc6w-vcg9 (deny_only=true skipped allow checks → privilege creation). crates/iam/AGENTS.md security boundaries; advisory-patterns.md 'IAM import, service accounts'.
- For a changed protocol-frontend handler (FTP/FTPS/SFTP/WebDAV/gateway), enumerate ALL sibling command handlers in the same driver — not just the changed one. For each, confirm it calls the per-operation IAM authorize hook mapped to the correct S3 action (RETR→GetObject, SIZE/MDTM→HeadObject, MKD→CreateBucket, bucket probe→ListBucket/HeadBucket) BEFORE touching storage. Construct a denied-authz case and prove the storage backend is never reached.
- Where: crates/protocols/ (FtpsDriver, SftpDriver, WebDAV), authorize_operation call sites
- Evidence: GHSA-3g29-xff2-92vp (FTP RETR/SIZE/MDTM authenticated but skipped IAM), GHSA-g3vq-vv42-f647 (FTPS MKD called create_bucket without s3:CreateBucket). advisory-patterns.md: 'RustFS advisories show mixed guarded and unguarded siblings in the same driver.'
- 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 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).
- If the diff touches RPC/NodeService authentication, verify the HMAC payload binds the EXACT concrete gRPC method path (not a service prefix), the HTTP method surrogate, and a fresh timestamp. Construct captured valid metadata for method A and replay it to method B within the timestamp window — if it authorizes, the signature is under-bound. Also test stale timestamp, wrong path, wrong method, wrong secret.
- Where: crates/ecstore/src/rpc/, verify_rpc_signature / NodeServiceServer, x-rustfs-signature handling
- Evidence: GHSA-c667-rgrv-99vj (signed service prefix instead of concrete method path → cross-method replay in timestamp window). advisory-patterns.md 'RPC input validation and panic safety'; Minimum Regression Test Expectations lists replay across two methods.
- For any RPC/gRPC handler or deserialization touched, feed empty bytes, truncated MessagePack/protobuf, invalid enum discriminants, and stale timestamps. Grep the deserialization path for unwrap()/expect()/panic-prone decode and prove malformed attacker payloads return a typed error, not a panic (remote DoS). Weak internode auth makes reachability worse, so combine with the RPC-secret probe.
- Where: crates/ecstore/src/rpc/, any #[derive(Deserialize)] decoded from wire bytes, RPC handler bodies
- Evidence: GHSA-gw2x-q739-qhcr (malformed GetMetrics reached unwrap() → remote DoS). advisory-patterns.md 'Treat all RPC payload bytes as attacker-controlled.' rust-code-quality skill: unwrap abuse.
- Take any object key, RPC disk path, or archive/tar/zip entry name introduced or handled in the diff and construct traversal payloads: '../', URL-encoded '%2e%2e%2f', absolute paths, platform separators, empty components. Trace the value through parse → authz check → final storage path and prove (a) authz and storage normalize the SAME way, and (b) the canonicalized path cannot escape the bucket/prefix root. Attack the case where authz sees the raw attacker bucket but storage cleaning crosses into a victim bucket.
- Where: crates/ecstore (path join/canonicalize), rustfs/src/storage/, Snowball auto-extract / normalize_extract_entry_key, rpc read_file_stream
- Evidence: GHSA-pq29-69jg-9mxc (read_file_stream joined untrusted paths, no canonical boundary check), GHSA-8r6f-hmq2-28rg (traversal object keys bypassed authz), GHSA-f4vq-9ffr-m8m3 (Snowball '../victim-bucket/object' authorized raw path then storage crossed boundary). Note __XLDIR__ trailing-slash encoding is store-layer only.
- If the diff touches multipart/copy or presigned POST, verify UploadPartCopy enforces source GetObject AND destination PutObject semantics equivalent to CopyObject, including copy-source policy conditions (not just independent source-read + dest-write). Construct a cross-bucket UploadPartCopy from a bucket the caller cannot read. For presigned POST, submit an upload that violates content-length-range, key prefix, or exact content-type and prove the server rejects it.
- Where: rustfs/src/storage / S3 API handlers: upload_part_copy, CompleteMultipartUpload, PostObject policy enforcement
- Evidence: GHSA-mx42-j6wv-px98 (UploadPartCopy missed source authz → cross-bucket exfil), GHSA-wfxj-ph3v-7mjf (missed destination copy-source policy constraint), GHSA-w5fh-f8xh-5x3p (presigned POST didn't enforce signed policy conditions). advisory-patterns.md 'S3 copy, multipart, and upload policy validation'.
- Grep the diff for debug!/trace!/info!/error! and any ?value / {:?} on structs or response bodies that can carry secret_key, session_token, JWT claims, HMAC secrets, expected signatures, access keys beyond safe identifiers, or raw credential-bearing responses. Check custom Debug impls and merged-config dumps too. Construct the error path (invalid signature, failed auth) and confirm it does not log the secret or the derived authenticator. Verify audit/notify entries redact credential request headers.
- Where: rustfs/src/**, crates/iam/, crates/audit/, crates/notify/, crates/targets/, RPC signature error paths
- Evidence: GHSA-r54g (STS creds logged at info), GHSA-8cm2 (debug logs leaked tokens/secrets/JWT claims/raw STS bodies), GHSA-333v (invalid RPC signature log included HMAC secret + expected signature). Fix commit ee6f79110 (redact credential request headers from audit/notify, backlog#963). rustfs-logging-governance skill.
- For any struct in the diff deserialized from untrusted input (S3 XML/JSON, lifecycle rules, bucket policy, replication config, RPC payload), check for #[serde(deny_unknown_fields)]. Construct a payload with a typo'd field (e.g. 'NoncurentDays') or an extra field and prove it is rejected, not silently ignored. Flag #[serde(default)] on security-critical fields (retention days, limits, permissions) lacking explicit post-deserialize validation, and any user-controlled integer cast with `as` (i32 as u32) — feed a negative value and check it doesn't wrap to a huge positive.
- Where: crates/policy/ (bucket policy), crates/ecstore lifecycle/ILM config, replication config structs, crates/protocols XML parsing
- Evidence: AGENTS.md 'Serde Safety'; advisory-patterns.md 'Serde deserialization' (no deny_unknown_fields found repo-wide; 'NoncurentDays' typo silently accepted; i32 as u32 wrap). Fix commit 1acd47f15 (SSE crash-loop DoS + credential reserved-char bypass, backlog#806).
- If the diff touches SSE / encryption reader-writer composition, do not trust API metadata claiming encryption. Trace the reader wrapper order (HashReader / EncryptReader / compression / warp) and confirm EncryptReader is actually in the chain that writes to disk — construct the case where a helper unwraps a nested reader and bypasses encryption, storing plaintext. Require a regression test that inspects the ACTUAL stored bytes on disk, not just read-back. Also check encrypted-object checksums are not exposed.
- Where: rustfs/src/storage/ecfs.rs, crates/rio/ (reader wrappers), crates/kms/, SSE-C replication
- Evidence: GHSA-xrrf-67jm-3c2r (SSE metadata reported encryption while composition bypassed EncryptReader, stored plaintext). Fix commits a7b9659e7 (hide encrypted object checksums, #4529), 80cc3b1fc (preserve SSE-C checksum state, #4410). advisory-patterns.md 'SSE and on-disk storage invariants'.
- If the diff touches CORS or the console/browser/object-preview surface: confirm default CORS does not reflect an arbitrary Origin while also sending Access-Control-Allow-Credentials: true — construct a request with a spoofed Origin and check the response. For preview, confirm attacker-controlled object content is origin-isolated (not rendered in a same-origin iframe with console creds), served with nosniff/CSP, and that preview trust derives from validated content-type + sandboxing, NOT from object name/extension (.pdf, .html). Separately, if aws:SourceIp is evaluated, spoof X-Forwarded-For / X-Real-IP as a direct (non-trusted-proxy) client and confirm the socket peer IP is used instead.
- Where: rustfs/src/server/layer.rs (CORS), console preview/auth code, aws:SourceIp / policy condition evaluation, X-Forwarded-For handling
- Evidence: GHSA-x5xv-223c-8vm7 (default CORS reflected arbitrary origins with credentials), GHSA-v9fg-3cr2-277j (preview rendered attacker HTML same-origin, exposed localStorage creds), GHSA-7gcx-wg4x-q9x6 (extension-based PDF detection bypassed sandbox), GHSA-fc6g-2gcp-2qrq (aws:SourceIp trusted client XFF). advisory-patterns.md 'Browser, CORS' + 'Trusted proxy'.
Null report example: "Attacked admin action-constant matching in the two changed handlers (both call validate_admin_request with the exact AdminAction), the FTPS RETR/SIZE authz parity, and the new lifecycle struct's serde surface (has deny_unknown_fields) — no break found."
### Concurrency/durability reviewer
- For every new or moved lock acquisition, enumerate all other code paths that take any overlapping subset of those locks and construct the concrete ABBA interleaving (thread 1 holds A wants B, thread 2 holds B wants A). If the diff acquires 2+ locks without a comment documenting acquisition order, that alone is a finding.
- Where: crates/ecstore/** (namespace locks, set_disk, disk registry), crates/audit/**, crates/lock/**, any Mutex/RwLock pair in a diff
- Evidence: crates/ecstore/AGENTS.md 'Lock Ordering' (document order; same set in different orders = deadlock); real ABBA deadlock fixed in c0d5f938f (#4421, audit registry vs stream_cancellers)
- If the diff touches the object write/commit path or a lock guard's lifetime, construct the timeline where the distributed lock is lost (heartbeat refresh fails / expiry) after shard writes but before the xl.meta rename commit — verify the commit is fenced on guard.is_lock_lost() (set_disk/ops/object.rs:874-880) and the diff does not move the commit outside the fenced region or drop the guard early.
- Where: crates/ecstore/src/set_disk/ops/object.rs, ops/multipart.rs, crates/lock/**
- Evidence: 1e6207c08 (#4406) fence write commit on lock loss; ddf197ba5 (#4388) heartbeat lock refresh; backlog#899 fencing comment in object.rs
- For any change to file creation or write-then-rename: write out the exact syscall order (write tmp -> fdatasync tmp -> rename -> fsync parent dir -> fsync ancestor dirs on first object under a prefix) and simulate a power cut after each step. Flag any dropped/reordered sync, and check the change honors the durability gate (RUSTFS_DRIVE_SYNC_ENABLE, strict/relaxed/none modes, per-bucket overrides) instead of hardcoding one mode. The 'skip tmp parent fsync' optimization is only sound when the file is renamed out of tmp — verify that precondition still holds.
- Where: crates/ecstore/src/disk/local.rs, disk/os.rs, disk/fs.rs, crates/ecstore/src/bucket/durability.rs, set_disk/core/io_primitives.rs
- Evidence: PR #4221 (the repo previously had no fsync anywhere); 2df315baf/c081586e7 (#4493) fsync ancestor dirs; 062a68d15 (#4387) tmp-parent-fsync skip is rename-conditional; eaff17cad (#4397) durability modes; 54872d52d (#4478) rename_data crash harness exists — extend it for the diff
- If the diff touches quorum counting or per-disk error aggregation, construct adversarial error vectors for reduce_errs: nil/placeholder entries, DiskNotFound mixed with FileNotFound, exactly quorum-1 agreeing errors — and show which dominant error wins. Specifically attack the case where offline-disk errors get counted as 'object does not exist', flipping a read/heal decision into data loss. Also check quorum monotonicity: a retry or heal pass must never conclude with a LOWER quorum than the original write.
- Where: crates/ecstore/src/disk/error_reduce.rs, crates/ecstore/src/api/mod.rs, set_disk read/heal paths
- Evidence: 20d61c73b (#4551) reduce_errs leaked nil placeholder as dominant error; e0619e355 (#4536) DiskNotFound treated as object-not-found in listing; quorum monotonicity flagged as open follow-up to #4221 (#4221 follow-up list); crates/ecstore/AGENTS.md 'Do not weaken quorum checks'
- For any multi-disk fan-out (delete, rename, heal write, cleanup), trace each per-disk Result: find any `let _ =`, `.ok()`, or best-effort collapse that keeps a failed disk out of the quorum math. Construct the run where exactly write_quorum-1 disks succeed and prove the op still returns success — that is the bug. Conversely, for heal writes, check one bad target cannot fail the whole heal (best-effort per target).
- Where: crates/ecstore/src/set_disk/ops/*.rs, disk/disk_store.rs, crates/heal/**
- Evidence: f7d2b2563 (#4546) disk delete/rename failures were swallowed; 47c1e730c (#4545) heal write quorum made best-effort per target; 2b063b0c4 (#4400) disk-replacement heal missed versions
- For every new .await placed between a state mutation and its cleanup/commit (or inside select!/timeout/spawned task that can be aborted), construct the cancellation point: client disconnects and the future is dropped exactly there. Enumerate what is left behind — tmp files, incremented counters never decremented, half-written xl.meta, a held permit/waiter — and verify cleanup runs in Drop or the state is re-entrant. Background loops the diff adds must have a hard outer timeout so a wedged awaitee cannot pin them forever.
- Where: crates/ecstore/** write paths, crates/object-capacity/** scanners, crates/audit/**, anything using tokio::select! or spawn+abort
- Evidence: d608e320f io_uring cancel-safety spike (cancellation known-hard here); 5a372557e (#4533) wedged scans needed hard outer timeout; 7b87d4d13 (#4520) waiter-count leak; e44bece00 (#4497) audit start race + paused drops
- If the diff touches metacache/list producers or cursor resume, construct the interleaving where the producer task completes (or errors) while a reader with a saved cursor comes back for the next page — verify a completed producer is tolerated (no error, no hang) and that a re-folded/full page reports truncation instead of silently ending the listing early. Also feed a corrupt/oversized length prefix into any metacache decode the diff touches.
- Where: crates/ecstore/src/cache_value/metacache_set.rs, crates/ecstore/src/store/list_objects.rs, crates/filemeta/**
- Evidence: 91a23361e (#4531) tolerate completed metacache producers; d91f4d455 (#4538) delimiter re-fold dropped truncation flag; d2c100fd3 (#4226) corrupt length-prefix guard
- For multipart changes, construct concurrent operations on the SAME uploadId: put_object_part racing put_object_part (same part number), abort racing complete between the parts listing and the commit rename, and list-parts racing cleanup. Verify every metadata read/list/abort holds the per-uploadId lock, and that part.N.meta cleanup is deferred until AFTER the commit rename — cleanup before commit loses parts on a crash between the two.
- Where: crates/ecstore/src/set_disk/ops/multipart.rs, rustfs/src/storage/
- Evidence: 3bc8d79fe (#4329) serialize put_object_part per uploadId; 7fb95d4fc (#4428) unlocked upload metadata reads/aborts; 93ffbdb9b (#4437) unlocked part listings; c77c5f047 (#4548) part.N.meta cleanup moved after commit
- Any cleanup/rollback logic added near a commit: verify it runs strictly AFTER the commit is durable, is best-effort (its failure must not fail an already-committed write), and is safe under retry — i.e., re-running it after a partial first attempt must never delete the newly-committed data dir or the last surviving copy.
- Where: crates/ecstore/src/set_disk/ops/object.rs (rename_data tail), ops/multipart.rs, disk cleanup helpers
- Evidence: afc7f1d6f/d908243e6 (#4386, backlog#898) post-commit old-data-dir cleanup had to be made best-effort; e7cc719c1 (#4389) speculative tmp cleanup moved off hot path
- If the diff does read-modify-write on any persisted shared state (bucket metadata, notify/target config, queue store), construct two concurrent writers: show whether the second write silently discards the first (lost update) — RMW must be serialized or CAS-guarded. For persisted queues/replay, construct crash-mid-replay and prove entries are neither lost nor delivered twice without an idempotency key.
- Where: crates/notify/**, crates/targets/** (queue store, SQL backends), crates/ecstore/src/bucket/metadata_sys.rs
- Evidence: 2490d4ee2 (#4425) persisted config RMW lost updates; 08e44b95f (#4505) queue store crash-safety + replay lifecycle; e008cc5da (#4500) SQL backend idempotency
- If the diff touches erasure decode/reconstruct or streaming GET, construct the failure mid-stream: shards become inconsistent (or a disk read fails) after N bytes of the body have already been sent — verify the stream surfaces an error to the client instead of ending cleanly at a truncated length. Silent truncation on a 200 response is the known failure mode.
- Where: crates/ecstore/src/set_disk/read.rs (reconstruct-read validation, historically ~line 3117), crates/ecstore/src/erasure/coding/decode.rs
- Evidence: Known open bug: EC reconstruct-read 'inconsistent shards' mid-GET silently truncates body -> client unexpected EOF; crates/ecstore/AGENTS.md 'explicit failure over silent corruption'
Null report example: "Attacked lock ordering on the new disk-registry mutex pair, lock-loss fencing across the moved commit, power-cut points around the added rename, and cancellation at the two new awaits — no break found; quorum math and metacache paths untouched by this diff."
### Compatibility reviewer
- Grep the diff for raw 'x-rustfs-internal-' or 'x-minio-internal-' string literals used with map.insert/remove/get instead of the metadata_compat helpers. If found, construct the MinIO-written object case: a metadata map containing ONLY 'X-Minio-Internal-<suffix>' (mixed case, no RustFS key) and trace the diff's read path — does it miss the value? Then construct the removal case: does remove leave the twin key behind so a stale MinIO-key value resurrects on next read? Also check the value-type trap: get_bytes has NO case-insensitive fallback (unlike get_str), so a diff that moves a suffix from FileInfo.metadata (String) to meta_sys (Vec<u8>) silently loses mixed-case MinIO keys.
- Where: Any code touching FileInfo.metadata / user_defined / meta_sys: crates/ecstore/, crates/filemeta/, rustfs/src/storage/, crates/utils/src/http/metadata_compat.rs
- Evidence: Repo-wide invariant in AGENTS.md 'Cross-Cutting Domain Invariants' and CLAUDE.md; helpers and the asymmetry are pinned by tests test_str_lookup_accepts_minio_metadata_case and test_get_bytes_no_case_insensitive_fallback in crates/utils/src/http/metadata_compat.rs
- For any diff reading a binary UUID from internal metadata (transitioned-versionID, tier-free-versionID, data_dir), trace the three degenerate inputs — key absent, value empty (b""), value nil UUID — through to the outgoing tier/S3 request. The bug shape to hunt: unwrap_or_default() or Uuid::from_slice(..).unwrap_or(Uuid::nil()) turning 'no value' into Uuid::nil(), which then gets serialized as ?versionId=00000000-... and the remote tier returns NoSuchVersion. The required pattern is .and_then(|v| Uuid::from_slice(&v).ok()).filter(|u| !u.is_nil()).
- Where: crates/ecstore/src/bucket/lifecycle/ (bucket_lifecycle_ops.rs, tier_sweeper.rs), crates/ecstore/src/services/tier/warm_backend_*.rs, crates/filemeta/src/filemeta/version.rs
- Evidence: Historical production bug documented in docs/operations/tier-ilm-debugging.md ('Nil-UUID versionId sent to tier'); regression tests live in crates/filemeta/src/filemeta/version.rs; follow-up fix 726f3dc18 'accept empty remote version_id in tier recovery paths' (#4552)
- For any diff touching tier or replication GET/DELETE against a remote S3 target, enumerate BOTH directions of the versionId contract and trace each: (a) remote version None/"" means the tier bucket is unversioned — the request must carry NO versionId parameter at all (not an empty one, not nil); (b) remote version Some(v) on a versioned target — the versionId MUST be sent, especially on version-purge deletes, or the delete lands on the wrong version / creates a delete marker instead of purging. Check whether the diff collapses these cases through a single Option/String conversion that loses the distinction.
- Where: crates/ecstore/src/services/tier/warm_backend*.rs, crates/ecstore/src/bucket/lifecycle/tier_sweeper.rs, replication code under crates/ecstore/src/bucket/
- Evidence: Invariant in AGENTS.md and docs/operations/tier-ilm-debugging.md; real bug fixed by 0fad35645 'send versionId on version-purge deletes to generic S3 targets' (#4401) — the versioned direction, and #4552 — the unversioned direction
- If the diff changes xl.meta encoding (adds/reorders msgpack header fields, touches FileMeta::marshal_msg or codec.rs encode paths), attack downgrade and cross-vendor parse: encode an object with the new code and decode it with (a) the meta_ver<=3 read path and (b) the real-MinIO fixture tests from #4377. Then check the header signature: is it recomputed over the new bytes, or copied/hardcoded? MinIO validates it; a stale or zero signature makes MinIO reject the file. Finally verify XL_META_VERSION was not silently bumped — old RustFS/MinIO nodes reject meta_ver > 3 during a rolling upgrade.
- Where: crates/filemeta/src/filemeta.rs (XL_HEADER_VERSION/XL_META_VERSION, lines ~46-54), crates/filemeta/src/filemeta/codec.rs (check_xl2_v1, decode_xl_headers)
- Evidence: Real bug 073bc9675 'compute header signature instead of hardcoding zero' (#4343); format contract pinned in docs/architecture/minio-file-format-compat.md (write meta_ver 3, read <=3, XL2 magic); parity fixtures from a91d9cefc (#4377)
- If the diff touches xl.meta / FileInfo decode (into_fileinfo, version parsing, part arrays), construct hostile foreign input: a MinIO- or corruption-shaped msgpack with a parts-count that disagrees with the etags/sizes array lengths, missing optional fields, and a meta_ver 2 object with legacy checksum. Trace whether the new code indexes past an array, panics, or fabricates default values instead of returning a decode error. Run the pinned legacy fixtures (test_issue_2265_legacy_meta_v2_object_compatibility, test_issue_2288) plus the #4377 real-MinIO xl.meta parse tests against the diff.
- Where: crates/filemeta/src/ (fileinfo.rs, filemeta.rs, filemeta/codec.rs, filemeta/version.rs)
- Evidence: Real bug 7efacbdf9 'validate part array lengths in into_fileinfo' (#4382); legacy meta_ver 2 regression fixtures at crates/filemeta/src/filemeta.rs (~:1130-:1174) cited by docs/architecture/minio-file-format-compat.md
- If the diff 'corrects' a formula, constant, or layout that is a byte-for-byte MinIO port (shard-size math, bitrot hash interleaving, erasure distribution, inline-data prefix), treat the correction itself as the bug: verify against legacy on-disk data before accepting. Concretely: run crates/ecstore/tests/legacy_bitrot_read_test.rs and the ECA-18 pinning tests; check whether existing objects written by old RustFS or MinIO still verify byte-for-byte. Known trap examples: bitrot_shard_file_size's bare return for non-streaming algorithms is CORRECT MinIO whole-file behavior, and the 32-byte prefix on inline data is the HighwayHash256 bitrot hash, not corruption.
- Where: crates/ecstore/src/erasure/coding/bitrot.rs, crates/ecstore/src/io_support/bitrot.rs, crates/filemeta/src/fileinfo.rs
- Evidence: f96314a1d 'pin streaming-only bitrot layout invariant (ECA-18)' (#4553) — audit explicitly decided NOT to change the formula because it breaks legacy interop; #4377 proved the inline-data prefix is the bitrot hash
- For any diff that copies object metadata into an S3-client-visible surface (GET/HEAD response headers, notification event userMetadata, ListObjects/replication payloads, copy-object metadata directives), construct an object carrying internal keys under BOTH prefixes and in non-canonical casing ('X-Minio-Internal-Compression') and confirm every one is stripped via is_internal_key (which is case-insensitive) — not by an exact-match filter on one prefix. Leaked internal keys are an API-semantics break and an information leak.
- Where: rustfs/src/storage/, crates/notify/, replication and copy_object paths in crates/ecstore/
- Evidence: Real bug cf8929189 'strip rustfs/minio internal metadata from event userMetadata' (#4419); is_internal_key contract in crates/utils/src/http/metadata_compat.rs
- If the diff touches crates/protos (node.proto, models.fbs) or internode RPC request/response structs, attack the rolling-upgrade interleaving: an old node sends a message without the new field to a new node, and a new node sends the extended message to an old node. Verify proto field numbers are only appended (never reused/renumbered), FlatBuffers tables are only extended at the end, and that an absent new field decodes to a safe default on the receiving side — 'safe' meaning it must not be interpreted as success/authorization (RPC errors fail closed) and must not flip a quorum decision.
- Where: crates/protos/ (node.proto, models.fbs, generated/), gRPC transport and dispatch in the internode layer
- Evidence: 6f613317f 'optimize gRPC transport' (#4337) shows the wire layer churns; security advisory 68cw fixed by PR#4402 established RPC fail-closed as a repo rule (see .agents/skills/security-advisory-lessons)
- For S3 handler diffs, replay the request shapes real clients actually send, not just the canonical one: mc and aws-sdk differ on path normalization (root '//' ListBuckets), virtual-host vs path style, and header casing. Then attack every pagination boundary the diff touches: request exactly max-keys/max-uploads/max-parts items and verify the response returns exactly N (not N+1), sets IsTruncated correctly, and yields a NextMarker/KeyMarker that resumes without skipping or duplicating — construct the N+1st-item case explicitly.
- Where: rustfs/src/storage/ S3 handlers, listing paths in crates/ecstore/src/store/ and set_disk/
- Evidence: Real bugs 511ad31ba 'normalize root double-slash ListBuckets requests' (#4336) and fefa70b31 'stop ListMultipartUploads from returning one upload past max-uploads' (#4447); docs/architecture/s3-compatibility-matrix.md is the compat source of truth
- If the diff changes bucket-metadata (.metadata.bin) or IAM/config parsing structs, run it against the real MinIO RELEASE.2025-07-23 fixtures: the msgpack blob uses PascalCase field names, so any serde rename, field-type change, or derive tweak silently drops MinIO-written fields instead of erroring. Verify parse_all_configs still loads all ten config types from the fixture without loss, and that drop-in migration still decrypts MinIO-encrypted IAM/server config rather than treating ciphertext as corrupt.
- Where: crates/ecstore/src/bucket/metadata*, crates/ecstore/src/bucket/migration.rs, IAM/config load paths in crates/iam/ and crates/config/
- Evidence: Fixture parity test parses_real_minio_bucket_metadata_blob_without_loss from a91d9cefc (#4377); real bug 717cdd2ab 'decrypt MinIO IAM & server config on drop-in migration' (#4358); format matrix in docs/architecture/minio-file-format-compat.md
- If the diff adds a compatibility shim, legacy fallback, wrapper, or old-endpoint alias (grep the diff for 'legacy', 'fallback', 'compat', 'deprecated'), verify two things: (1) it carries a RUSTFS_COMPAT_TODO(<task-id>) marker with an exact removal condition and a matching entry in the register — an unmarked shim becomes permanent dead weight; (2) the fallback's default direction is safe for old data: e.g. a new decode path must fall back to the legacy decode for old objects by default, not gate legacy reads behind an opt-in flag that makes existing data unreadable after upgrade.
- Where: Anywhere in the diff; register at docs/architecture/compat-cleanup-register.md; recent example: allow_inplace_legacy_fallback flag in the ecstore erasure codec streaming path
- Evidence: docs/architecture/compat-cleanup-register.md review checklist; d232a46b4 wired legacy decode prefetch behind a default-OFF gate while keeping legacy reads working (#4542), with arity fallout fixed in 05890d6e2 (#4573)
Null report example: "Attacked dual-key metadata writes/removals against MinIO-only-key objects, nil/empty transitioned-versionID paths to the tier, xl.meta encode against meta_ver<=3 decoders and the #4377 real-MinIO fixtures, and proto field-number evolution for old-node/new-node RPC — no compatibility break found."
### Performance reviewer
- For every `.clone()` the diff adds or moves onto a per-request/per-object path, open the cloned type and count heap fields (String, Vec, HashMap, Bytes). If >5 heap fields or it contains an EC block buffer, construct the cost: N concurrent PUTs x M objects -> N*M deep copies per second. Demand Arc-wrapping of heavy fields or pass-by-reference; also flag new `String` allocations in header/path/signature parsing where `&str`/`Cow<str>` suffices.
- Where: crates/ecstore/src/set_disk/**, crates/ecstore/src/store*.rs, rustfs/src/storage/, crates/filemeta/, request handlers in rustfs/src/
- Evidence: crates/ecstore/AGENTS.md 'Allocation Discipline in Hot Paths' (no Clone on >5-heap-field structs, Arc for large buffers, &str/Cow for temporary computations); .agents/skills/rust-code-quality/SKILL.md ranks 'unnecessary clone in hot path' as P1 must-fix
- For every new sync_all/sync_data/fdatasync/flush/File::sync call in the diff, trace the call chain to DurabilityMode / RUSTFS_DRIVE_SYNC_ENABLE resolution (crates/ecstore/src/disk/local.rs:291 DurabilityMode, :347 resolve_durability_mode) and to per-bucket durability overrides. Construct the run where the operator sets mode=none (or legacy RUSTFS_DRIVE_SYNC_ENABLE=false) and the new fsync still fires — that is an ungated durability cost and a regression on 4KiB writes.
- Where: crates/ecstore/src/disk/local.rs, crates/ecstore/src/bucket/durability.rs, crates/ecstore/src/set_disk/** (rename_data/commit paths), any crate doing tokio::fs or std::fs writes
- Evidence: #4221 fsync work caused a measured -10% 4KiB write regression (#814 investigation), later gated; durability modes added in eaff17cad (#4397), per-bucket tier overrides in 13e48d93a (#4407); 2df315baf (#4493) shows even ancestor-dir fsyncs are routed through the gate
- Attack blocking-work placement from both directions: (a) find new synchronous fs calls, hashing, or EC encode/decode executed directly on an async runtime thread without spawn_blocking/block_in_place — construct the stall (a slow disk blocks a worker thread and every task queued on it); (b) find new code that splits one logical disk operation into multiple spawn_blocking hops per object — each hop is a threadpool round-trip, so K hops x N objects multiplies latency. Demand the author justify the placement with the size of the work, not habit.
- Where: crates/ecstore/src/disk/local.rs, crates/ecstore/src/erasure_coding/, crates/ecstore/src/bitrot/, crates/rio/
- Evidence: 608ab14d7 (#4554, HP-12) folded metadata open+fstat+read into a single spawn_blocking because per-op hops were measurably slow; 8fc637fb1 (#4484) moved the short EC encode inline because block_in_place cost exceeded the work — direction depends on measured work size
- For each lock acquisition the diff adds or relocates, mark the guard's live range and list every .await and disk/RPC call inside it. Construct the contention interleaving: N concurrent requests serialize on the guard while the holder waits on IO; for namespace/multipart commit locks, compute worst-case hold time (fsync + rename per disk) against the lock's timeout. Also diff the acquisition order against other paths taking the same locks (ABBA).
- Where: crates/ecstore/src/set_disk/** (commit/rename paths), crates/lock/, crates/audit/ registry, any RwLock/Mutex in per-request paths
- Evidence: crates/ecstore/AGENTS.md 'Lock Ordering'; c0d5f938f (#4421) fixed a real ABBA deadlock between registry and stream_cancellers; #4370 history: fsync-heavy serial cross-disk commits held a lock long enough to blow test timeouts (#4370)
- Trace exactly what executes inside the PUT commit critical section (under the object write lock, between tmp write and rename_data completion) before vs after the diff. Any newly added work there — cleanup, extra stat, additional rename, O_DIRECT write, logging — is an attack target: construct the per-PUT latency delta and demand it be moved off the critical section or parallelized across disks.
- Where: crates/ecstore/src/set_disk/ops/*, crates/ecstore/src/disk/local.rs rename_data path
- Evidence: Three real optimizations removed exactly this class of regression: e7cc719c1 (#4389) moved speculative PUT-tail tmp cleanup off the hot path, 92c8c6db7 (#4411) moved O_DIRECT shard-writes off the commit critical section, 651ccac13 (#4487) parallelized tmp xl.meta write and shard fdatasync on commit
- Find any new loop in a batch API that performs a per-item stat/read/RPC sequentially. Construct the concrete blowup: a 1000-key DeleteObjects or a full listing page -> 1000 serial round-trips added by the diff. Demand either a gate (skip when not needed) or bounded parallelism; for startup/load paths, check for accidental O(n^2) (re-scanning the full list per item).
- Where: crates/ecstore/src/store_delete*.rs / batch object APIs, listing/metacache paths, crates/iam/ store loading, crates/heal/
- Evidence: a413729b1 (#4398) had to gate and parallelize the DeleteObjects per-object stat fanout after it shipped serial; 16a91c35e (#4537) fixed O(n^2) IAM startup load by chunking — both were diff-introduced fanouts of this exact shape
- For every buffer the diff allocates on the encode/decode/shard path, check: is it sized with with_capacity to the EC-expanded block (not the logical size, not default-grown)? Does it copy into a fresh Vec where Bytes::slice/clone (refcount) or the io-core buffer pool would avoid the copy? Does the diff read hash and data in separate passes where one pass suffices? Construct the per-block byte-copy count before vs after. If the diff touches the io-core pool, verify gauge accounting still balances.
- Where: crates/ecstore/src/erasure_coding/, crates/ecstore/src/bitrot/, crates/io-core/src/pool.rs, crates/rio/
- Evidence: 92bf55ce6 (#4396) fixed a real regression by right-sizing BytesMut encode ingest capacity to the EC-expanded block; 47bee8b31 (#4475) merged bitrot hash+data into one read pass; 7fa3d0d4b (#4534) shows pool gauge accounting is easy to drift when touching buffer reuse
- For every logging or instrumentation statement the diff adds, classify the call site frequency: per-request, per-object, per-shard, or per-block. Anything info!/warn!/error! at per-object frequency or higher is a finding — construct the flood (one listing under client cancellation, one 10k-object heal) and count emitted lines. New metrics/timers on the data path must be feature-gated, not always-on. Run scripts/check_logging_guardrails.sh on the diff.
- Where: any per-request/per-object code, especially crates/ecstore listing and heal loops, rustfs/src/storage/ handlers; scripts/check_logging_guardrails.sh
- Evidence: .agents/skills/rustfs-logging-governance/SKILL.md (trace level for hot-path/repetitive success events); d25ddb0e1 (#4372) fixed real listing-cancellation error-log noise; hotpath instrumentation is deliberately feature-gated (3f13d098b #4394, f262fcfce #4541 HP-14)
- Count how many times the diff's request path parses or fetches the same metadata: xl.meta/FileMeta decoded more than once per object, bucket metadata (metadata_sys) re-fetched inside a per-object loop, or the dual x-rustfs-internal/x-minio-internal key lookup re-run repeatedly on the same map. Construct the per-request parse count before vs after; a second full FileMeta decode per GET is a finding.
- Where: crates/filemeta/, crates/ecstore/src/set_disk/** read paths, crates/ecstore/src/bucket/metadata_sys.rs, crates/utils/src/http/metadata_compat.rs
- Evidence: 608ab14d7 (#4554) exists because redundant metadata-read syscall sequences per object were measurable; CLAUDE.md dual-key metadata convention makes repeated get_bytes lookups an easy hidden double-parse
- If the diff touches PUT/GET/commit/erasure paths and claims 'no perf impact', demand numbers, not assertion: run the criterion benches (cargo bench -p ecstore — comparison_benchmark, erasure_benchmark, rename_data_meta_benchmark, single_block_non_inline_benchmark per crates/ecstore/benches/) against origin/main, and for end-to-end paths the warp A/B relative-budget gate (scripts/run_hotpath_warp_ab.sh --baseline-ref origin/main, as .github/workflows/performance-ab.yml runs it). Probe specifically at 4KiB object size — that is where the last real regression hid.
- Where: crates/ecstore/benches/, .github/workflows/performance-ab.yml, scripts/run_hotpath_warp_ab.sh
- Evidence: crates/ecstore/AGENTS.md: 'Benchmark-sensitive changes should include measurable rationale'; performance-ab.yml (215747022 #4480) is the repo's own relative-budget gate; the #4221 regression was only visible at 4KiB writes (#814 bisect)
Null report example: "Attacked the new rename_data commit-section work, durability-gate routing of the added fdatasync, guard live-range across the shard-write awaits, and per-object clone count in the PUT path; ran comparison_benchmark + rename_data_meta_benchmark vs origin/main (4KiB delta within noise) — no break found."
### Test-coverage skeptic
- For every behavior claim in the PR description, revert that hunk (git stash / manual undo of the changed lines) and name the exact test (`cargo test -p <crate> <test_name>`) that fails. If no test fails on revert, the behavior is untested — file a finding, not a note. Especially verify the test exercises the REAL production call path, not a lookalike helper.
- Where: All crates; highest value in crates/ecstore, rustfs/src/storage, crates/heal
- Evidence: AGENTS.md exit criterion 'Every behavior change has a test that fails without it'. Real bug: PR #4220 (ghost-directory cleanup) merged with green tests but its fix never executed on the real delete path — required follow-up rustfs#4307, backlog#798 stayed OPEN. The tests exercised a path the production flow never took.
- Read each added/modified test and confirm it asserts the real outcome (returned value, stored bytes, error variant), not merely 'call succeeded' or 'no panic'. Flag any test whose only observable is that the function returned, and any `assert!(result.is_err())` that never checks WHICH error. Then check: does the test prove the exploit/failure form is denied, or only that the intended form still works?
- Where: crates/e2e_test (security_boundary_test.rs pattern), and every #[cfg(test)] module in the diff
- Evidence: Commit dee8e4e63 (#4466) had to rewrite 277 lines of crates/e2e_test/src/security_boundary_test.rs because 'security boundary tests' passed without asserting real outcomes. .agents/skills/rust-code-quality/SKILL.md checklist: 'Every test function has at least one assert!'; .agents/skills/security-advisory-lessons/SKILL.md: 'Does the test prove the exploit form is denied, or only that the intended form still works?'
- When the diff adds a boolean/mode parameter or config flag, find the test that fails if the flag's effect is INVERTED inside the changed function. Tests that were mechanically updated to pass `false`/default at every call site assert nothing about the new behavior. Execute the check: flip the flag's branch in the source and confirm at least one test goes red for each branch.
- Where: crates/ecstore/src/set_disk/ (e.g. build_codec_streaming_part_reader), any function gaining a parameter
- Evidence: Commit 05890d6e2 (#4573): PR #4560 added a 15th param allow_inplace_legacy_fallback; the arity tests were fixed by passing `false` everywhere — they assert Err outcomes independent of the flag, so the fallback behavior itself has no revert-detecting test at those sites.
- Mutation spot-check on error propagation: for each newly added `?`, `return Err`, or error-mapping line, mentally replace it with `Ok(default)`/ignore and ask which test fails. The swallowed-error bug class recurs in this repo and always ships with green tests — a fix that propagates errors needs a test that injects the failure (faulty disk, failed rename, dispatch error) and asserts the caller sees Err.
- Where: crates/ecstore (disk delete/rename, reduce_errs), crates/audit, crates/notify, crates/targets
- Evidence: Three recent fixes for the same class: f7d2b2563 (#4546, disk delete/rename failures swallowed), dbc628f16 (#4424, audit dispatch failures swallowed), 20d61c73b (#4551, reduce_errs leaking nil placeholder as dominant error). All existed while tests were green.
- Any test touching GET/read/reconstruct/stream paths must assert the FULL body content and exact length against a known value, not status-ok or first-bytes. Construct the degraded-read case (missing/inconsistent shards forcing EC reconstruction) and assert byte-for-byte equality; a mid-stream failure that truncates the body passes every test that only checks headers or the first chunk.
- Where: crates/ecstore/src/set_disk/read.rs and ops/, crates/rio, crates/e2e_test GET scenarios
- Evidence: Known live bug: EC reconstruct-read verification failure mid-GET at set_disk read path silently truncates the body → client 'unexpected EOF'; version-independent, undetected by existing suites because none assert full-body integrity under shard inconsistency.
- For on-disk / on-wire format changes (xl.meta, .metadata.bin, bitrot framing), reject round-trip-only tests: a struct serialized and deserialized by the same code under test cannot catch format drift. Demand the test parse a REAL captured fixture from crates/filemeta/tests/fixtures, crates/ecstore/tests/fixtures, or crates/rio-v2/tests/minio_fixture_lab — or capture a new one from a single-disk MinIO instance (RELEASE.2025-07-23 procedure from #4377).
- Where: crates/filemeta, crates/ecstore (headers, msgpack bucket metadata), crates/rio-v2, migration code
- Evidence: Commit 073bc9675 (#4343): filemeta header signature was hardcoded to zero — round-trip tests passed for months. Commit a91d9cefc (#4377) established the real-MinIO fixture convention (inline/versioned/multipart xl.meta, HighwayHash256-prefixed inline bodies) precisely because synthetic fixtures proved nothing about interop.
- Attack new concurrency tests for flakiness-by-construction: grep the added tests for `sleep(`, fixed timeouts under ~30s on lock acquisition, and use of shared global state (disk registry, lock client, GLOBAL_*). Serialized cross-disk commits exceed small lock timeouts under full-suite CI disk load. If the test shares global state or saturates IO, it must join the `ecstore-serial-flaky` nextest test-group in .config/nextest.toml (note: serial_test's #[serial] does NOT work — nextest runs each test in its own process). Require readiness polling, never fixed sleeps.
- Where: crates/ecstore tests, crates/e2e_test, .config/nextest.toml
- Evidence: Commit 2dfa3d3c3 (#4370): concurrent_resend test flaked with Lock(Timeout 5s) on CI — six legitimate serialized cross-disk commits under IO pressure needed 30s. Commit 7c701d9f2 (#4558) created the nextest test-group after bucket_delete_* raced make_bucket into InsufficientWriteQuorum. 65849740f (#4213) deflaked global-state contamination. crates/e2e_test/AGENTS.md: 'readiness checks and explicit polling over fixed sleep-based timing'.
- 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.
- 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.
- Where: crates/ecstore listing paths (list_objects, ListMultipartUploads, metacache), S3 handlers in rustfs/src/storage
- Evidence: Two shipped boundary bugs: fefa70b31 (#4447) ListMultipartUploads returned one upload past max-uploads; d91f4d455 (#4538) delimiter re-fold of a full page lost the truncation flag. Both survived existing tests because no test pinned n == max exactly.
- Green `cargo test -p <crate>` on the touched crate is not a coverage verdict for the diff's test code itself: run `cargo clippy --all-targets -p <crate>` and a workspace-wide test BUILD (`cargo check --workspace --all-targets` at minimum) before accepting the tests as evidence. Test-only code that doesn't compile workspace-wide or fails clippy has repeatedly broken main and masked whether tests ran at all.
- Where: All crates; especially concurrent-branch merges into crates/ecstore
- Evidence: #4322 broke main because only cargo test ran (field_reassign_with_default is clippy-only). b06f3df6b (#4441) and 05890d6e2 (#4573): test code broke the workspace test build (E0061) on main after textually-clean merges, failing CI for every open PR.
Null report example: "Attacked revert-detection for all 3 claimed behaviors (each has a named test that fails on revert), flag-inversion on the new fallback parameter (both branches covered in codec_streaming tests), full-body assertions on the changed GET path, and n==max pagination boundary — no coverage gap found."
## Sources and maintenance
Probes are distilled from shipped bugs in git history (commit/PR references
above), GitHub security advisories (see the security-advisory-lessons
skill), scoped `AGENTS.md` rules, and invariants under `docs/architecture/`
and `docs/operations/`. Line numbers drift; when a cited location no longer
matches, trust the invariant and re-locate the code. When a new bug class
ships, add a probe with its evidence here rather than growing the policy
section in `AGENTS.md`.
+9 -2
View File
@@ -47,5 +47,12 @@ consider adding it to the script's `checked_files` list.
Instruction/architecture docs (`AGENTS.md`, `CLAUDE.md`, `ARCHITECTURE.md`,
`docs/architecture/*.md`) must not reference repo file paths that no longer
exist. If your refactor moved code, update the docs that point at it — the
error message lists `doc -> stale-path` pairs. Historical plans under
`docs/superpowers/plans/` are exempt.
error message lists `doc -> stale-path` pairs.
## `check_no_planning_docs.sh`
Planning-type documents must not be committed (see AGENTS.md "Sources of
Truth"). The guard fails if anything is tracked under `docs/superpowers/`
`.gitignore` already ignores it, but `git add -f` bypasses that, so this closes
the hole. Fix by removing the listed file(s) with `git rm`; keep the plan or
spec in the issue tracker or a local worktree instead.
@@ -26,7 +26,7 @@ For the full pattern map, read [advisory-patterns.md](references/advisory-patter
### 1. Scope the change
- Identify touched routes, protocol frontends, handlers, storage paths, credentials, logs, browser surfaces, CI/release code, and policy checks.
- Treat these paths as security-sensitive by default: `rustfs/src/admin/`, `rustfs/src/storage/`, `rustfs/src/auth.rs`, `rustfs/src/server/layer.rs`, `crates/iam/`, `crates/policy/`, `crates/credentials/`, `crates/ecstore/src/rpc/`, `crates/protocols/`, `crates/rio/`, and console preview/auth code.
- Treat these paths as security-sensitive by default: `rustfs/src/admin/`, `rustfs/src/storage/`, `rustfs/src/auth.rs`, `rustfs/src/server/layer.rs`, `crates/iam/`, `crates/policy/`, `crates/credentials/`, `crates/ecstore/src/rpc/`, `crates/protocols/`, `crates/rio/`, OIDC/STS federation code, and console preview/auth code.
### 2. Map to advisory classes
- Read [advisory-patterns.md](references/advisory-patterns.md) for matching GHSA lessons.
@@ -59,10 +59,16 @@ 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.
- 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`.
- 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.
- Public OIDC bootstrap and callback routes must treat `Host`, `X-Forwarded-Proto`, redirect targets, `state`, and callback parameters as untrusted; credential-bearing redirects require a configured, allowlisted origin.
- OIDC discovery and validation URLs are SSRF sinks. Resolve and classify hostnames at connection time, reject rebinding to loopback/private/link-local ranges, and do not rely on literal string checks.
### S3 copy, multipart, and presigned POST
- Multipart copy must enforce source `GetObject` and destination `PutObject` semantics equivalent to `CopyObject`, including copy-source and policy conditions.
- Do not let `CreateMultipartUpload`, `UploadPartCopy`, `CompleteMultipartUpload`, or `AbortMultipartUpload` return success without authorization.
@@ -113,6 +119,7 @@ For the full pattern map, read [advisory-patterns.md](references/advisory-patter
### Trusted proxy and network identity
- Only honor `X-Forwarded-For` or `X-Real-IP` when the request came from a configured trusted proxy.
- Apply the same trusted-proxy rule to scheme and host derivation; direct clients must not control security-sensitive redirects through `Host`, `X-Forwarded-Host`, or `X-Forwarded-Proto`.
- Direct clients must use the socket peer address for `aws:SourceIp` and policy condition evaluation.
- Add tests for direct spoofed headers and trusted-proxy headers.
@@ -130,6 +137,8 @@ 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?
- Can this STS/OIDC path issue credentials without SigV4, trusted issuer validation, allowlisted redirects, or trusted-proxy host/scheme handling?
- Does this outbound validation path resolve attacker-supplied hostnames and reject private, loopback, link-local, and rebound addresses at the actual connection boundary?
- Is an archive entry, object key, or policy resource normalized differently between authorization and storage?
- Is the same operation implemented in multiple paths, such as `CopyObject` vs `UploadPartCopy`, and do all paths enforce the same security contract?
- Does a preview or browser-surface fix preserve the original security invariant when adding alternate viewers or file-type detection?
@@ -27,9 +27,17 @@ 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-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-9pjf-w3c2-m32r`, `GHSA-4x2q-cpx9-9h26`, and `GHSA-xvpm-p3f7-34c3`: public OIDC authorize/callback flows trusted request `Host` or forwarded scheme when building credential-bearing redirects. Lesson: OIDC redirects must use configured allowlisted origins and trusted-proxy handling; never derive the post-login credential destination from direct client headers.
- `GHSA-m479-9x88-94w6`, `GHSA-frwq-mfqx-83p8`, `GHSA-q9q8-rf9r-fg9f`, and `GHSA-j5c2-hhf7-6gf5`: OIDC validation accepted attacker-controlled discovery URLs because hostname checks rejected only literal forbidden IPs, allowing DNS rebinding SSRF. Lesson: outbound federation URL validation must resolve and classify hostnames at the connection boundary and reject loopback, private, link-local, and rebound addresses.
### S3 copy, multipart, and upload policy validation
- `GHSA-mx42-j6wv-px98`: `UploadPartCopy` missed source authorization and allowed cross-bucket object exfiltration. Lesson: multipart copy must enforce the same source and destination contract as `CopyObject`.
@@ -50,7 +58,7 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
### Secrets, defaults, and cryptographic misuse
- `GHSA-j59h-h7q5-q348`: RustFS shipped known default root credentials that could authenticate to S3, admin APIs, IAM, KMS, and console 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`, 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-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.
+17 -10
View File
@@ -14,13 +14,20 @@
# RustFS Cargo configuration
# Enable tokio_unstable cfg for dial9-tokio-telemetry support
# This allows dial9 to hook into Tokio's internal runtime events
[build]
# Enable Tokio unstable features required by dial9-tokio-telemetry for runtime tracing.
# See: https://docs.rs/tokio/latest/tokio/#unstable-features
rustflags = ["--cfg", "tokio_unstable"]
# Enable frame pointers for CPU profiling (Linux only, optional but recommended)
# Uncomment the following line for better CPU profiling data
# rustflags = ["--cfg", "tokio_unstable", "-C", "force-frame-pointers=yes"]
# NOTE: `--cfg tokio_unstable` is deliberately NOT set here.
#
# It used to be a global `[build] rustflags` entry so that the (default-off)
# `dial9` telemetry feature could compile. That made every build depend on
# Tokio's unstable, non-semver API, and it broke silently whenever a caller
# exported their own RUSTFLAGS — an environment RUSTFLAGS replaces the value
# from this file rather than appending to it.
#
# The flag is now scoped to telemetry builds, which opt in explicitly:
#
# make build-profiling
# RUSTFLAGS="--cfg tokio_unstable" cargo build --features dial9
#
# `crates/obs/build.rs` fails the compile if the `dial9` feature is on without
# the flag, so the two can no longer drift apart unnoticed.
#
# For CPU profiling, add `-C force-frame-pointers=yes` to that RUSTFLAGS value.
+19
View File
@@ -35,6 +35,25 @@ build-gnu-arm64: ## Build aarch64 GNU version
./build-rustfs.sh --platform aarch64-unknown-linux-gnu
## —— Profiling build (dial9 Tokio runtime telemetry) ------------------------------------------
# dial9 hooks Tokio's unstable runtime instrumentation, so it needs
# `--cfg tokio_unstable`. That flag is deliberately absent from
# .cargo/config.toml: it is not free, and release binaries do not carry it.
# Setting RUSTFLAGS here replaces (never appends to) the config-file value, and
# crates/obs/build.rs fails the build if the feature and the flag disagree.
#
# There are no task-dump or S3-upload features — see the notes in
# crates/obs/Cargo.toml for why.
DIAL9_FEATURES ?= dial9
DIAL9_RUSTFLAGS ?= --cfg tokio_unstable
.PHONY: build-profiling
build-profiling: ## Build RustFS with dial9 Tokio runtime telemetry (diagnostic builds only)
@echo "🔬 Building RustFS with dial9 telemetry (features: $(DIAL9_FEATURES))..."
@echo "⚠️ Diagnostic build: telemetry writes trace segments to disk continuously."
RUSTFLAGS="$(DIAL9_RUSTFLAGS)" cargo build --release --bin rustfs --features $(DIAL9_FEATURES)
.PHONY: build-cross-all
build-cross-all: core-deps ## Build binaries for all architectures
@echo "🔧 Building all target architectures..."
+5 -3
View File
@@ -11,14 +11,16 @@ check-%:
# Warning-only check
# Checks for optional dependencies and issues a warning if not found
# (e.g., cargo-nextest for enhanced testing)
warn-%:
@command -v $* >/dev/null 2>&1 || { \
echo >&2 "⚠️ '$*' is not installed."; \
}
# For checking dependencies use check-<dep-name> or warn-<dep-name>
.PHONY: core-deps fmt-deps test-deps
#
# NOTE: cargo-nextest is a HARD dependency of `make test`, gated inside the
# test recipe itself (with a RUSTFS_ALLOW_CARGO_TEST_FALLBACK=1 escape hatch)
# rather than a warn-only prerequisite here — see .config/make/tests.mak.
.PHONY: core-deps fmt-deps
core-deps: check-cargo ## Check core dependencies
fmt-deps: check-rustfmt ## Check lint and formatting dependencies
test-deps: warn-cargo-nextest ## Check tests dependencies
+11 -1
View File
@@ -15,7 +15,7 @@ fmt-check: core-deps fmt-deps ## Check code formatting
.PHONY: clippy-check
clippy-check: core-deps ## Run clippy checks
@echo "🔍 Running clippy checks..."
cargo clippy --all-targets --all-features -- -D warnings
cargo clippy --all-targets -- -D warnings
.PHONY: clippy-fix
clippy-fix: core-deps ## Apply clippy fixes
@@ -50,6 +50,16 @@ tokio-io-uring-check: ## Check tokio io-uring runtime feature stays removed
@echo "🚫 Checking tokio io-uring feature guard..."
./scripts/check_no_tokio_io_uring.sh
.PHONY: extension-schema-check
extension-schema-check: ## Check extension-schema stays a lightweight contract crate
@echo "🧩 Checking extension schema boundaries..."
./scripts/check_extension_schema_boundaries.sh
.PHONY: body-cache-whitelist-check
body-cache-whitelist-check: ## Check the body-cache eligibility gate stays a fail-closed allow-list
@echo "🧱 Checking body-cache whitelist guard..."
./scripts/check_body_cache_whitelist.sh
.PHONY: compilation-check
compilation-check: core-deps ## Run compilation check
@echo "🔨 Running compilation check..."
+8 -3
View File
@@ -13,14 +13,19 @@ doc-paths-check: ## Check that instruction/architecture docs reference existing
@echo "📄 Checking doc path references..."
./scripts/check_doc_paths.sh
.PHONY: planning-docs-check
planning-docs-check: ## Check that no planning-type documents are committed
@echo "📄 Checking for committed planning docs..."
./scripts/check_no_planning_docs.sh
.PHONY: pre-commit
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check doc-paths-check quick-check ## Run fast pre-commit checks without clippy/full tests
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
@echo "✅ All pre-commit checks passed!"
.PHONY: pre-pr
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check doc-paths-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 clippy-check test ## Run full pre-PR checks with clippy and tests
@echo "✅ All pre-PR checks passed!"
.PHONY: dev-check
dev-check: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check doc-paths-check quick-check ## Run fast local development checks
dev-check: 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 quick-check ## Run fast local development checks
@echo "✅ Fast development checks passed!"
+47 -3
View File
@@ -2,6 +2,25 @@
TEST_THREADS ?= 1
# cargo-nextest is a HARD dependency of `make test`.
#
# nextest changes test semantics vs plain `cargo test`: it runs every test in
# its own process (so serial_test's #[serial] mutex does not serialize across
# tests) and it is the only runner that honours .config/nextest.toml
# [test-groups] (e.g. the ecstore-serial-flaky serialization guard). CI runs
# nextest (.github/actions/setup/action.yml installs it), so a silent fallback
# to `cargo test` would run with different serialization behaviour than CI and
# mask (or invent) flakes.
#
# Installing cargo-nextest:
# cargo install cargo-nextest --locked # from source
# # or a prebuilt binary (faster) via the taiki-e installer / get.nexte.st:
# # https://nexte.st/docs/installation/
#
# Escape hatch: set RUSTFS_ALLOW_CARGO_TEST_FALLBACK=1 to run the plain
# `cargo test` fallback anyway. Results are NOT authoritative — semantics
# differ from CI and .config/nextest.toml test-groups will NOT apply.
.PHONY: script-tests
script-tests: ## Run shell script tests
@echo "Running script tests..."
@@ -9,13 +28,38 @@ script-tests: ## Run shell script tests
./scripts/test_entrypoint_credentials.sh
.PHONY: test
test: core-deps test-deps script-tests ## Run all tests
test: core-deps script-tests ## Run all tests (needs cargo-nextest; RUSTFS_ALLOW_CARGO_TEST_FALLBACK=1 to override)
@echo "🧪 Running tests..."
@if command -v cargo-nextest >/dev/null 2>&1; then \
cargo nextest run --all --exclude e2e_test; \
else \
echo " cargo-nextest not found; falling back to 'cargo test'"; \
elif [ "$${RUSTFS_ALLOW_CARGO_TEST_FALLBACK:-0}" = "1" ]; then \
echo >&2 " ============================================================================"; \
echo >&2 "⚠️ cargo-nextest NOT found — running the 'cargo test' fallback (opt-in)."; \
echo >&2 "⚠️ TEST SEMANTICS DIFFER FROM CI; results are NOT authoritative:"; \
echo >&2 "⚠️ * nextest runs each test in its own process; 'cargo test' does not,"; \
echo >&2 "⚠️ so serial_test #[serial] serialization behaves differently."; \
echo >&2 "⚠️ * .config/nextest.toml [test-groups] will NOT apply (e.g. the"; \
echo >&2 "⚠️ ecstore-serial-flaky group), so load-sensitive tests may flake here"; \
echo >&2 "⚠️ but pass on CI (or vice versa)."; \
echo >&2 "⚠️ Install cargo-nextest and re-run before trusting these results."; \
echo >&2 "⚠️ ============================================================================"; \
cargo test --workspace --exclude e2e_test -- --nocapture --test-threads="$(TEST_THREADS)"; \
else \
echo >&2 "❌ cargo-nextest is required for 'make test' but was not found."; \
echo >&2 ""; \
echo >&2 " RustFS tests run under cargo-nextest (process-per-test isolation)."; \
echo >&2 " CI runs nextest and .config/nextest.toml [test-groups] only take effect"; \
echo >&2 " under nextest. Plain 'cargo test' has different serialization semantics"; \
echo >&2 " and is NOT a faithful substitute."; \
echo >&2 ""; \
echo >&2 " Install it with either:"; \
echo >&2 " cargo install cargo-nextest --locked"; \
echo >&2 " or a prebuilt binary (faster) — see https://nexte.st/docs/installation/"; \
echo >&2 ""; \
echo >&2 " To run the plain 'cargo test' fallback anyway (results NOT authoritative;"; \
echo >&2 " serialization semantics differ from CI), re-run with:"; \
echo >&2 " RUSTFS_ALLOW_CARGO_TEST_FALLBACK=1 make test"; \
exit 1; \
fi
cargo test --all --doc
+10
View File
@@ -0,0 +1,10 @@
# Committed floor for the number of tests selected by the migration-critical
# CI gate (see scripts/check_migration_gate_count.sh, backlog#1153 infra-12).
#
# The floor equals the exact count of rustfs-ecstore --lib tests matching the
# gate filter (name substrings: data_movement, rebalance, decommission,
# source_cleanup, delete_marker) at the time this file was last updated.
# CI fails if the selected count drops below this number, so renames or
# removals that thin the gate must update this file in the same PR.
# Adding tests does not require a bump, but bumping keeps the guard tight.
571
+201
View File
@@ -0,0 +1,201 @@
# 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:
#
# * 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.
#
# serial_test's #[serial] attribute does NOT serialize these across runs:
# nextest executes each test in its own process, where the in-process
# serial_test mutex has no effect. A nextest test-group with max-threads = 1 is
# the mechanism that actually serializes across nextest's process boundary.
#
# ---------------------------------------------------------------------------
# Profiles
# ---------------------------------------------------------------------------
# The `default` profile is what local `cargo nextest run` uses. It NEVER
# retries: a red test locally means a real failure to investigate, not noise to
# paper over. The `ci` profile (below) is the strict CI gate: global
# retries = 0 so a new race's first occurrence is never masked, plus a
# narrowly-scoped quarantine list (retries = 2) for tests with a tracked OPEN
# flake issue. Flake policy lives in docs/testing/README.md.
[test-groups]
ecstore-serial-flaky = { max-threads = 1 }
# Reliability / fault-injection e2e tests each spawn a single-node 4-disk RustFS
# server and manipulate its disk directories at runtime (crates/e2e_test:
# reliability_disk_fault_test, degraded_read_eof_regression_test / dist-13). They
# are correct in isolation but resource-heavy; serialize them under nextest's
# process boundary (serial_test's #[serial] does not cross it) so several 4-disk
# 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 }
# --- 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)/))'
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.
[[profile.default.overrides]]
filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression)_test::/)'
test-group = 'e2e-reliability'
# ---------------------------------------------------------------------------
# ci profile — the strict CI gate (ci.yml `cargo nextest run --profile ci`)
# ---------------------------------------------------------------------------
[profile.ci]
# Strict: a new race must fail on its first occurrence, never be retried away.
retries = 0
# Report every failure in one run instead of bailing on the first.
fail-fast = false
[profile.ci.junit]
# Emitted to target/nextest/ci/junit.xml; uploaded as a CI artifact.
# Tests that pass only after a quarantine retry are marked `flaky` here — that
# marker is the observable signal the flake policy is built around.
path = "junit.xml"
# ===========================================================================
# QUARANTINE — flaky tests granted retries = 2 under the ci profile ONLY.
#
# RULES (enforced by review, see docs/testing/README.md):
# * Every entry MUST link exactly one OPEN issue tracking the flake.
# * An entry stays until the issue is fixed (test made robust) or the test is
# deleted — 30-day policy. No entry may exist without a live issue link.
#
# Each entry also re-declares the `ecstore-serial-flaky` test-group so the
# serialization holds under the ci profile (nextest evaluates a named
# 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)/)'
test-group = 'ecstore-serial-flaky'
retries = 2
# 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).
[[profile.ci.overrides]]
filter = 'package(rustfs-ecstore) & test(walk_dir_does_not_charge_consumer_backpressure_to_the_stall_budget)'
retries = 2
# Serialize the 4-disk reliability / degraded-read e2e tests under the ci
# profile too (see the e2e-reliability test-group note near the top). Not a
# quarantine: no retries, just single-threaded so several 4-disk servers never
# run concurrently when ci-7's nightly runs the full e2e suite.
[[profile.ci.overrides]]
filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression)_test::/)'
test-group = 'e2e-reliability'
# ---------------------------------------------------------------------------
# e2e-smoke profile — PR smoke subset of the e2e_test crate (backlog#1149 ci-4)
# ---------------------------------------------------------------------------
# PR smoke subset of the e2e_test crate (backlog#1149 ci-4). This profile is
# the single wiring mechanism for e2e tests in CI: other suites join by
# extending this filter (or a sibling profile), never by adding ad-hoc e2e
# jobs to ci.yml. Admission criteria (see crates/e2e_test/README.md): fast,
# single-node topology, no external dependencies (no awscurl / Vault / fixed
# ports / pre-started server), no #[ignore].
#
# 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 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
# that validate config synchronously and never wait for asynchronous
# replication convergence. Each spawns its own single-node rustfs server(s) on
# random ports (source, plus an independent single-node target for the pair
# checks — NOT a cluster), so the subset stays parallel-safe and single-digit
# seconds. The data-plane tests that poll for convergence and all
# `_real_dual_node` / `_real_single_node`
# site-replication tests run in the [profile.e2e-repl-nightly] lane below, NOT
# here. This allowlist is the single source of truth for the PR/nightly split:
# 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 + 18 nightly = 38 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
# SSRF egress guard rejected on every PR after repl-1 (#4712). That is fixed —
# the guard now honours an off-by-default opt-in and this suite's source servers
# set it (RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET) — so the allowlist below is
# restored.
[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)_test::/)
| 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::/)
)
"""
fail-fast = false
# ---------------------------------------------------------------------------
# e2e-repl-nightly profile — scheduled full replication e2e lane (repl-1)
# ---------------------------------------------------------------------------
# backlog#1147 repl-1 (deps: ci-4). Runs the SLOW / cross-process replication
# tests that are unfit for the per-PR e2e-smoke gate:
#
# * 8 bucket-replication data-plane tests — they PUT/delete objects and poll
# until source and target converge; two replicate over HTTPS.
# * 9 `_real_dual_node` site-replication tests — each spawns TWO full rustfs
# servers and drives the cross-process site-replication control plane.
# * 1 `_real_single_node` service-account round-trip test.
#
# The set is defined as "everything in replication_extension_test that is NOT
# in the e2e-smoke PR allowlist above" (the negated clause is byte-identical to
# the allowlist), so a newly added replication test automatically runs here
# until it is explicitly promoted to the fast PR subset — no replication test
# is ever silently left out of CI.
#
# #[serial] does NOT serialize under nextest (process-per-test; see the file
# header). These tests need no cross-test serialization: each spawns its own
# server(s) on random ports with isolated temp dirs, so they are parallel-safe
# by construction — the same property the e2e-smoke subset relies on. If load
# on the runner surfaces a real flake, quarantine the specific test with an
# OPEN issue link (ci-10 / backlog#937 policy), never blanket-retry or exclude.
#
# Wired by .github/workflows/e2e-replication-nightly.yml (schedule +
# workflow_dispatch), which builds the rustfs binary once, installs awscurl so
# the STS dual-node test actually exercises its path (it skips gracefully with
# a visible log line when awscurl is absent), and routes scheduled failures
# through .github/actions/schedule-failure-issue (ci-8). Explicit division of
# labor with ci-5's future e2e-full merge gate: these tests run ONLY here, not
# double-run there. TODO(ci-7): fold this interim repl-owned lane into the ci
# domain's consolidated scheduled e2e workflow once it exists.
[profile.e2e-repl-nightly]
default-filter = """
package(e2e_test)
& test(/^replication_extension_test::/)
& !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)$/)
"""
fail-fast = false
[profile.e2e-repl-nightly.junit]
# Emitted to target/nextest/e2e-repl-nightly/junit.xml; uploaded by the nightly
# workflow as the failure-triage artifact.
path = "junit.xml"
@@ -23,8 +23,8 @@ services:
- RUSTFS_VOLUMES=${RUSTFS_VOLUMES:-http://node{1...4}:9000/data/rustfs{0...3}}
- RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin}
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
@@ -46,7 +46,7 @@ services:
- RUSTFS_INTERNODE_OFFLINE_BYPASS=${RUSTFS_INTERNODE_OFFLINE_BYPASS:-false}
- RUSTFS_INTERNODE_OFFLINE_REPROBE_SECS=${RUSTFS_INTERNODE_OFFLINE_REPROBE_SECS:-5}
- RUSTFS_INTERNODE_OFFLINE_FAILURE_THRESHOLD=${RUSTFS_INTERNODE_OFFLINE_FAILURE_THRESHOLD:-3}
- RUSTFS_INTERNODE_IDEMPOTENT_READ_RETRIES=${RUSTFS_INTERNODE_IDEMPOTENT_READ_RETRIES:-0}
- RUSTFS_INTERNODE_IDEMPOTENT_READ_RETRIES=${RUSTFS_INTERNODE_IDEMPOTENT_READ_RETRIES:-1}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
@@ -69,8 +69,8 @@ services:
- RUSTFS_VOLUMES=${RUSTFS_VOLUMES:-http://node{1...4}:9000/data/rustfs{0...3}}
- RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin}
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
@@ -92,7 +92,7 @@ services:
- RUSTFS_INTERNODE_OFFLINE_BYPASS=${RUSTFS_INTERNODE_OFFLINE_BYPASS:-false}
- RUSTFS_INTERNODE_OFFLINE_REPROBE_SECS=${RUSTFS_INTERNODE_OFFLINE_REPROBE_SECS:-5}
- RUSTFS_INTERNODE_OFFLINE_FAILURE_THRESHOLD=${RUSTFS_INTERNODE_OFFLINE_FAILURE_THRESHOLD:-3}
- RUSTFS_INTERNODE_IDEMPOTENT_READ_RETRIES=${RUSTFS_INTERNODE_IDEMPOTENT_READ_RETRIES:-0}
- RUSTFS_INTERNODE_IDEMPOTENT_READ_RETRIES=${RUSTFS_INTERNODE_IDEMPOTENT_READ_RETRIES:-1}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
@@ -115,8 +115,8 @@ services:
- RUSTFS_VOLUMES=${RUSTFS_VOLUMES:-http://node{1...4}:9000/data/rustfs{0...3}}
- RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin}
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
@@ -138,7 +138,7 @@ services:
- RUSTFS_INTERNODE_OFFLINE_BYPASS=${RUSTFS_INTERNODE_OFFLINE_BYPASS:-false}
- RUSTFS_INTERNODE_OFFLINE_REPROBE_SECS=${RUSTFS_INTERNODE_OFFLINE_REPROBE_SECS:-5}
- RUSTFS_INTERNODE_OFFLINE_FAILURE_THRESHOLD=${RUSTFS_INTERNODE_OFFLINE_FAILURE_THRESHOLD:-3}
- RUSTFS_INTERNODE_IDEMPOTENT_READ_RETRIES=${RUSTFS_INTERNODE_IDEMPOTENT_READ_RETRIES:-0}
- RUSTFS_INTERNODE_IDEMPOTENT_READ_RETRIES=${RUSTFS_INTERNODE_IDEMPOTENT_READ_RETRIES:-1}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
@@ -161,8 +161,8 @@ services:
- RUSTFS_VOLUMES=${RUSTFS_VOLUMES:-http://node{1...4}:9000/data/rustfs{0...3}}
- RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin}
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
@@ -184,7 +184,7 @@ services:
- RUSTFS_INTERNODE_OFFLINE_BYPASS=${RUSTFS_INTERNODE_OFFLINE_BYPASS:-false}
- RUSTFS_INTERNODE_OFFLINE_REPROBE_SECS=${RUSTFS_INTERNODE_OFFLINE_REPROBE_SECS:-5}
- RUSTFS_INTERNODE_OFFLINE_FAILURE_THRESHOLD=${RUSTFS_INTERNODE_OFFLINE_FAILURE_THRESHOLD:-3}
- RUSTFS_INTERNODE_IDEMPOTENT_READ_RETRIES=${RUSTFS_INTERNODE_IDEMPOTENT_READ_RETRIES:-0}
- RUSTFS_INTERNODE_IDEMPOTENT_READ_RETRIES=${RUSTFS_INTERNODE_IDEMPOTENT_READ_RETRIES:-1}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
+8 -8
View File
@@ -21,8 +21,8 @@ services:
- RUSTFS_VOLUMES=http://node{0...3}:9000/data/rustfs{0...3}
- RUSTFS_ADDRESS=0.0.0.0:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ACCESS_KEY=rustfsadmin
- RUSTFS_SECRET_KEY=rustfsadmin
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
platform: linux/amd64
ports:
- "9000:9000" # Map port 9001 of the host to port 9000 of the container
@@ -38,8 +38,8 @@ services:
- RUSTFS_VOLUMES=http://node{0...3}:9000/data/rustfs{0...3}
- RUSTFS_ADDRESS=0.0.0.0:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ACCESS_KEY=rustfsadmin
- RUSTFS_SECRET_KEY=rustfsadmin
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
platform: linux/amd64
ports:
- "9001:9000" # Map port 9002 of the host to port 9000 of the container
@@ -55,8 +55,8 @@ services:
- RUSTFS_VOLUMES=http://node{0...3}:9000/data/rustfs{0...3}
- RUSTFS_ADDRESS=0.0.0.0:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ACCESS_KEY=rustfsadmin
- RUSTFS_SECRET_KEY=rustfsadmin
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
platform: linux/amd64
ports:
- "9002:9000" # Map port 9003 of the host to port 9000 of the container
@@ -72,8 +72,8 @@ services:
- RUSTFS_VOLUMES=http://node{0...3}:9000/data/rustfs{0...3}
- RUSTFS_ADDRESS=0.0.0.0:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ACCESS_KEY=rustfsadmin
- RUSTFS_SECRET_KEY=rustfsadmin
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
platform: linux/amd64
ports:
- "9003:9000" # Map port 9004 of the host to port 9000 of the container
+1
View File
@@ -45,6 +45,7 @@ Three pre-built Grafana dashboards are included for monitoring RustFS GET perfor
| **GET Rollout Health** | `grafana-get-rollout-health.json` | Monitors optimization rollout: latency by reader path, early-stop hit rate, codec streaming usage, pipeline failures |
| **GET Data Integrity** | `grafana-get-data-integrity.json` | Monitors data safety: bitrot verify failures, decode errors, short reads, shard read outcomes |
| **GET Resource Impact** | `grafana-get-resource-impact.json` | Monitors resource usage: concurrent requests, IO queue utilization, disk permit wait, RSS trend |
| **Object Data Cache** | `grafana-object-data-cache.json` | Monitors the GET body cache (`rustfs_object_data_cache_*`): hit ratio, lookup/plan/fill outcomes, fill duration quantiles, hit vs fill throughput, entries/weighted bytes, inflight fills, memory-pressure skips, invalidations, and size-class breakdowns |
### Prometheus Alert Rules
+1
View File
@@ -45,6 +45,7 @@
| **GET 发布健康度** | `grafana-get-rollout-health.json` | 监控优化发布:按 reader path 的延迟、early-stop 命中率、codec streaming 使用率、pipeline 失败率 |
| **GET 数据完整性** | `grafana-get-data-integrity.json` | 监控数据安全:bitrot 校验失败、decode 错误、short read、shard 读取结果 |
| **GET 资源影响** | `grafana-get-resource-impact.json` | 监控资源使用:并发请求数、IO 队列利用率、disk permit 等待、RSS 趋势 |
| **对象数据缓存** | `grafana-object-data-cache.json` | 监控 GET body 缓存(`rustfs_object_data_cache_*`):命中率、查找/规划/填充结果、填充耗时分位、命中 vs 填充吞吐、条目数/加权字节、在途填充、内存压力拒绝、失效、按尺寸档拆分 |
### Prometheus 告警规则
File diff suppressed because it is too large Load Diff
+20 -13
View File
@@ -1,21 +1,28 @@
# GitHub Workflow Instructions
Applies to `.github/` and repository pull-request operations.
Applies to `.github/`.
## Pull Requests
- PR titles and descriptions must be in English.
- Use `.github/pull_request_template.md` for every PR body.
- Keep all template section headings.
- Use `N/A` for non-applicable sections.
- Include verification commands in the PR details.
- For `gh pr create` and `gh pr edit`, always write markdown body to a file and pass `--body-file`.
- Do not use multiline inline `--body`; backticks and shell expansion can corrupt content or trigger unintended commands.
- Recommended pattern:
- `cat > /tmp/pr_body.md <<'EOF'`
- `...markdown...`
- `EOF`
- `gh pr create ... --body-file /tmp/pr_body.md`
PR conventions (English title/body, template usage, `--body-file` with the
heredoc pattern, review-thread etiquette) live in the root `AGENTS.md` under
"Git and PR Baseline" — that section is the single normative home; do not
duplicate its rules here.
## Workflow Changes
- Any change touching `.github/workflows/` must pass `actionlint` (run from
the repo root) with zero findings before commit/PR. Install via
`brew install actionlint`, or the download script in the actionlint repo
(rhysd/actionlint); `make pre-pr` does not cover workflow files, so this is
the required check for them.
- Custom self-hosted runner labels (`sm-standard-*`, `dind-*`) are declared
in `.github/actionlint.yaml`. When introducing a new `runs-on:` label,
declare it there in the same change. Never use the config or `-ignore` to
silence real findings — fix the workflow (root `AGENTS.md`: make checks
pass by fixing the cause, not by weakening the gate).
- If `shellcheck` is installed, actionlint also lints `run:` scripts; treat
those findings as part of the gate.
## CI Alignment
+7
View File
@@ -0,0 +1,7 @@
self-hosted-runner:
# Custom labels of this repository's self-hosted runners. Keep in sync with
# the labels actually used in `runs-on:` across .github/workflows/.
labels:
- sm-standard-2
- sm-standard-4
- dind-sm-standard-2
@@ -0,0 +1,74 @@
# schedule-failure-issue
Opens (or updates) a GitHub issue when a **scheduled** workflow run fails.
This is the single alerting mechanism for all timed pipelines
(rustfs/backlog#1149 ci-8, arbitration G3): scheduled workflows must consume
this action instead of inventing their own notification paths.
## Behavior
- Issue title: `[scheduled-failure] <workflow name>` — the workflow name is
the dedupe key.
- If an **open** issue with that exact title exists, the failure is appended
as a comment; otherwise a new issue is created (labeled `infrastructure` by
default). Closing the issue resets the cycle: the next failure opens a
fresh one.
- The issue body / comment includes the run URL, run attempt, event, ref and
the names of the failed (or timed-out/cancelled) jobs of the current run
attempt.
- Requires `gh` and `jq` on the runner (both preinstalled on GitHub-hosted
runners such as `ubuntu-latest`).
## Usage
Add a final job to the workflow. `always()` is required — without it the job
is skipped when a needed job fails; `contains(needs.*.result, 'failure')`
makes it act only when something actually failed. Scope `issues: write` to
this job only; no other job may gain permissions.
```yaml
alert-on-failure:
name: Alert on scheduled failure
needs: [<the jobs to watch>]
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Open or update failure-tracking issue
uses: ./.github/actions/schedule-failure-issue
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
```
Notes:
- Upstream jobs that are *cancelled* (e.g. a manually cancelled run) do not
trigger the alert — a human was already looking. Job `timeout-minutes`
expiry surfaces as `failure` and does alert.
- Manual `workflow_dispatch` runs never alert; a human is watching those.
## Current consumers
- `.github/workflows/e2e-s3tests.yml` (weekly full s3-tests sweep)
- `.github/workflows/mint.yml` (weekly multi-SDK mint run)
- `.github/workflows/fuzz.yml` (nightly fuzz corpus jobs)
- `.github/workflows/performance-ab.yml` (nightly warp A/B gate)
- **Pending:** the ci-7 e2e nightly-full workflow does not exist yet
(backlog#1149 ci-7). When it lands, wire it up with the snippet above.
## Drill
`.github/workflows/schedule-failure-alert-drill.yml` is a manual-only
workflow that forces a job failure and runs this action through the exact
consumer wiring. Use it to verify the alert path end to end:
```bash
gh workflow run schedule-failure-alert-drill.yml -R rustfs/rustfs --ref <branch>
```
Run it twice to verify both paths (issue creation, then comment dedupe), and
close the `[scheduled-failure] Schedule Failure Alert Drill` issue afterwards.
@@ -0,0 +1,104 @@
# 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.
name: "Schedule Failure Issue"
description: >-
Open (or update) a tracking issue when a scheduled workflow run fails.
Dedupes by workflow name: if an open issue titled
"[scheduled-failure] <workflow name>" already exists, the failure is
appended as a comment; otherwise a new issue is created. This is the
single alerting mechanism for all scheduled pipelines (backlog#1149 ci-8).
inputs:
github-token:
description: >-
Token with issues:write on the repository. Pass secrets.GITHUB_TOKEN
from a job that declares `permissions: issues: write` (scope the
permission to the alert job only, never workflow-wide).
required: true
workflow-name:
description: "Workflow name used for the issue title (the dedupe key)."
required: false
default: ${{ github.workflow }}
label:
description: >-
Label applied when a new issue is created. Must already exist in the
repository; if applying it fails, the issue is created without a label.
Set to an empty string to skip labeling.
required: false
default: "infrastructure"
runs:
using: "composite"
steps:
- name: Open or update failure-tracking issue
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
WORKFLOW_NAME: ${{ inputs.workflow-name }}
ISSUE_LABEL: ${{ inputs.label }}
run: |
set -euo pipefail
title="[scheduled-failure] ${WORKFLOW_NAME}"
run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
# Failed job names for this run attempt. The alert job runs while the
# run as a whole is still in progress, so inspect the jobs that have
# already completed with a non-success conclusion.
failed_jobs="$(gh api \
"repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/attempts/${GITHUB_RUN_ATTEMPT}/jobs" \
--paginate \
--jq '.jobs[]
| select(.conclusion == "failure" or .conclusion == "timed_out" or .conclusion == "cancelled")
| "- `\(.name)` (\(.conclusion))"')"
if [ -z "${failed_jobs}" ]; then
failed_jobs="- (failed job not recorded yet — see the run page)"
fi
body="$(cat <<EOF
Scheduled run of **${WORKFLOW_NAME}** failed.
- Run: ${run_url} (attempt ${GITHUB_RUN_ATTEMPT})
- Event: \`${GITHUB_EVENT_NAME}\`
- Ref: \`${GITHUB_REF_NAME}\` @ \`${GITHUB_SHA}\`
Failed jobs:
${failed_jobs}
EOF
)"
# Dedupe by exact title among OPEN issues (a closed issue means the
# earlier breakage was resolved; a new failure gets a fresh issue).
# GitHub search strips the [] characters, so search loosely and match
# the exact title with jq.
existing="$(gh issue list --repo "${GITHUB_REPOSITORY}" --state open --limit 100 \
--search "\"scheduled-failure\" in:title" --json number,title \
| jq -r --arg title "${title}" 'map(select(.title == $title)) | (.[0].number // empty)')"
if [ -n "${existing}" ]; then
echo "Appending comment to existing open issue #${existing}"
gh issue comment "${existing}" --repo "${GITHUB_REPOSITORY}" --body "${body}"
exit 0
fi
echo "Creating new issue: ${title}"
if [ -n "${ISSUE_LABEL}" ]; then
if gh issue create --repo "${GITHUB_REPOSITORY}" \
--title "${title}" --body "${body}" --label "${ISSUE_LABEL}"; then
exit 0
fi
echo "::warning::issue creation with label '${ISSUE_LABEL}' failed (missing label?); retrying without a label"
fi
gh issue create --repo "${GITHUB_REPOSITORY}" --title "${title}" --body "${body}"
+14
View File
@@ -0,0 +1,14 @@
# GitHub secret scanning path exclusions.
# Secrets detected under these paths are auto-closed as "ignored by
# configuration" and are not blocked by push protection.
#
# Deliberately NOT excluded: production source files (e.g. rustfs/src/**,
# crates/*/src/**) even when a hit sits inside a #[cfg(test)] module or a
# doc-comment example — excluding the file would also stop scanning the
# production code in it. Close those alerts manually as "used in tests".
paths-ignore:
- "crates/e2e_test/**" # dedicated e2e test crate, test credentials throughout
- "**/tests/**" # Rust integration-test dirs (crates/*/tests, rustfs/tests)
- "**/benches/**" # benchmark harnesses
- ".docker/**" # local docker-compose dev configs (e.g. mqtt vm.args cookie)
- ".vscode/**" # local editor launch configs
+13 -29
View File
@@ -35,15 +35,17 @@ on:
- '.github/workflows/**'
- 'scripts/security/check_workflow_pins.sh'
schedule:
- cron: '0 0 * * 0' # Weekly on Sunday at midnight UTC
- cron: '0 3 * * 0' # Weekly on Sunday 03:00 UTC (staggered after the midnight ci/build crons)
workflow_dispatch:
permissions:
contents: read
# Event-scoped groups: merges to main must not cancel the weekly scheduled
# run (same rationale as ci.yml).
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name != 'schedule' }}
env:
CARGO_TERM_COLOR: always
@@ -57,32 +59,9 @@ jobs:
- name: Explain cancellation run
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
security-audit:
name: Security Audit
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Install cargo-audit
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
with:
tool: cargo-audit
- name: Run security audit
run: |
cargo audit -D warnings --json | tee audit-results.json
- name: Upload audit results
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: security-audit-results-${{ github.run_number }}
path: audit-results.json
retention-days: 30
# RustSec advisory scanning is covered by the `advisories` check of
# cargo-deny below; a separate cargo-audit job would duplicate the same
# database lookup with a second ignore list to maintain.
cargo-deny:
name: Cargo Deny
if: github.event_name != 'pull_request' || github.event.action != 'closed'
@@ -133,4 +112,9 @@ jobs:
with:
fail-on-severity: moderate
allow-ghsas: GHSA-2f9f-gq7v-9h6m
# rustfs-uring is a first-party Apache-2.0 crate, now published on
# crates.io (backlog#1104) rather than pulled as a git dependency.
# Scope the allow to the exact pinned version so a version bump forces a
# conscious re-review of the license/provenance claim (backlog#1181).
allow-dependencies-licenses: pkg:cargo/rustfs-uring@0.1.0
comment-summary-in-pr: always
+28 -18
View File
@@ -21,6 +21,11 @@
# 2. Upload binaries to OSS storage
# 3. Trigger docker.yml to build and push images using the uploaded binaries
#
# Platform scope:
# - Pushes to main (development builds) build the Linux targets only — they
# feed the dev download channel and Docker dev images. Tags, the weekly
# schedule and manual dispatch build the full platform matrix.
#
# Manual Parameters:
# - build_docker: Build and push Docker images (default: true)
# - platforms: Comma-separated platform IDs or 'all' (default: all)
@@ -46,7 +51,7 @@ on:
- ".gitignore"
- ".dockerignore"
schedule:
- cron: "0 0 * * 0" # Weekly on Sunday at midnight UTC
- cron: "0 1 * * 0" # Weekly on Sunday 01:00 UTC (staggered after the ci.yml midnight cron)
workflow_dispatch:
inputs:
build_docker:
@@ -87,8 +92,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 0
- name: Determine build strategy
id: check
@@ -125,8 +128,7 @@ jobs:
version="dev-${short_sha}"
echo "🛠️ Development build detected"
elif [[ "${{ github.event_name }}" == "schedule" ]] || \
[[ "${{ github.event_name }}" == "workflow_dispatch" ]] || \
[[ "${{ contains(github.event.head_commit.message, '--build') }}" == "true" ]]; then
[[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
# Scheduled or manual build
should_build=true
build_type="development"
@@ -150,6 +152,7 @@ jobs:
# Build RustFS binaries
prepare-platform-matrix:
name: Prepare Platform Matrix
needs: build-check
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.select.outputs.matrix }}
@@ -167,6 +170,18 @@ jobs:
selected="all"
fi
# Per-merge development builds only feed the dev download channel
# and the Docker dev images, which consume the Linux binaries; at
# the usual merge cadence most per-merge builds are cancelled by the
# next merge anyway. macOS and Windows stay covered by tag builds,
# the weekly scheduled build and manual dispatch.
if [[ "${selected}" == "all" \
&& "${{ github.event_name }}" == "push" \
&& "${{ needs.build-check.outputs.build_type }}" == "development" ]]; then
selected="linux-x86_64-musl,linux-aarch64-musl,linux-x86_64-gnu,linux-aarch64-gnu"
echo "Development (main push) build: restricting to Linux targets"
fi
all='{"include":[
{"target_id":"linux-x86_64-musl","os":"sm-standard-2","target":"x86_64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":""},
{"target_id":"linux-aarch64-musl","os":"sm-standard-2","target":"aarch64-unknown-linux-musl","cross":true,"platform":"linux","rustflags":""},
@@ -209,11 +224,11 @@ jobs:
timeout-minutes: 90
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
# Always enable Tokio unstable features (required by dial9-tokio-telemetry).
# The RUSTFLAGS env var takes precedence over .cargo/config.toml [build] rustflags,
# so we must include --cfg tokio_unstable here explicitly; otherwise an empty
# RUSTFLAGS value would shadow the config-file flag and silently break tracing.
RUSTFLAGS: "--cfg tokio_unstable ${{ matrix.rustflags }}"
# Release binaries ship without dial9 telemetry and therefore do not need
# --cfg tokio_unstable. Telemetry builds opt in explicitly (see
# `make build-profiling`); crates/obs/build.rs fails the compile if the
# `dial9` feature is enabled without the flag.
RUSTFLAGS: "${{ matrix.rustflags }}"
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.prepare-platform-matrix.outputs.matrix) }}
@@ -783,12 +798,6 @@ jobs:
sha512sum *.zip > SHA512SUMS
fi
# Create signature placeholder files
for file in *.zip; do
echo "# Signature for $file" > "${file}.asc"
echo "# GPG signature will be added in future versions" >> "${file}.asc"
done
cd ..
python3 scripts/security/generate_release_supply_chain_assets.py \
--asset-dir ./release-assets \
@@ -826,11 +835,12 @@ jobs:
echo "✅ All assets uploaded successfully"
# Update latest.json for stable releases only
# Update latest.json for stable releases only: prerelease tags (alpha/beta/
# rc) must never overwrite the stable version pointer.
update-latest-version:
name: Update Latest Version
needs: [ build-check, upload-release-assets ]
if: startsWith(github.ref, 'refs/tags/')
if: startsWith(github.ref, 'refs/tags/') && needs.build-check.outputs.build_type == 'release'
runs-on: ubuntu-latest
steps:
- name: Update latest.json
+70
View File
@@ -0,0 +1,70 @@
# 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.
# Companion to ci.yml for the required "Test and Lint" status check.
#
# ci.yml skips docs-only pull requests via paths-ignore, but the branch
# ruleset requires a check named "Test and Lint" — without this workflow a
# docs-only PR would wait on that check forever. This workflow triggers on
# exactly the paths ci.yml ignores and reports an instant success under the
# same job name. Mixed PRs trigger both workflows and the real check still
# gates: a required check with any failing run blocks the merge.
# https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/troubleshooting-required-status-checks#handling-skipped-but-required-checks
#
# Keep the paths list below in sync with the pull_request paths-ignore list
# in ci.yml.
name: Continuous Integration (docs only)
on:
pull_request:
types: [ opened, synchronize, reopened ]
branches: [ main ]
paths:
- "**.md"
- "docs/**"
- "deploy/**"
- "scripts/dev_*.sh"
- "scripts/probe.sh"
- "LICENSE*"
- ".gitignore"
- ".dockerignore"
- "README*"
- "**/*.png"
- "**/*.jpg"
- "**/*.svg"
- ".github/workflows/build.yml"
- ".github/workflows/docker.yml"
- ".github/workflows/audit.yml"
permissions:
contents: read
jobs:
test-and-lint:
name: Test and Lint
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
# Docs-only PRs skip the full code CI, but they are exactly where a
# planning-type document could be slipped in (git add -f bypasses
# .gitignore). Run the guard here so the required "Test and Lint" check
# stays meaningful for docs-only changes.
- name: Check no planning docs committed
run: ./scripts/check_no_planning_docs.sh
- name: Satisfy required check for docs-only changes
run: echo "Docs-only change — code CI is skipped by paths-ignore; planning-docs guard passed, reporting success for the required 'Test and Lint' check."
+232 -60
View File
@@ -33,10 +33,11 @@ on:
- ".github/workflows/build.yml"
- ".github/workflows/docker.yml"
- ".github/workflows/audit.yml"
- ".github/workflows/performance.yml"
pull_request:
types: [ opened, synchronize, reopened, closed ]
branches: [ main ]
# Keep this list in sync with the `paths` list in ci-docs-only.yml, which
# reports the required "Test and Lint" check for PRs skipped here.
paths-ignore:
- "**.md"
- "docs/**"
@@ -53,7 +54,6 @@ on:
- ".github/workflows/build.yml"
- ".github/workflows/docker.yml"
- ".github/workflows/audit.yml"
- ".github/workflows/performance.yml"
merge_group:
types: [ checks_requested ]
schedule:
@@ -63,9 +63,13 @@ on:
permissions:
contents: read
# Concurrency groups are scoped per event so different triggers never cancel
# each other: PR pushes cancel the previous run of that PR, main pushes keep
# latest-wins semantics among themselves, and scheduled runs always complete
# (a shared group used to let every merge kill the weekly scheduled run).
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name != 'schedule' }}
env:
CARGO_TERM_COLOR: always
@@ -81,33 +85,12 @@ jobs:
- name: Explain cancellation run
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
skip-check:
name: Skip Duplicate Actions
if: github.event_name != 'pull_request' || github.event.action != 'closed'
permissions:
actions: write
contents: read
runs-on: ubuntu-latest
outputs:
should_skip: ${{ steps.skip_check.outputs.should_skip }}
steps:
- name: Skip duplicate actions
id: skip_check
uses: fkirc/skip-duplicate-actions@f75f66ce1886f00957d99748a42c724f4330bdcf # v5
with:
concurrent_skipping: "same_content_newer"
cancel_others: true
paths_ignore: '["*.md", "docs/**", "deploy/**"]'
do_not_skip: '["workflow_dispatch", "schedule", "merge_group", "release", "push"]'
typos:
name: Typos
needs: skip-check
if: needs.skip-check.outputs.should_skip != 'true'
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Typos check with custom config file
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
@@ -115,8 +98,7 @@ jobs:
# ~1 minute instead of waiting for the full test job.
quick-checks:
name: Quick Checks
needs: skip-check
if: needs.skip-check.outputs.should_skip != 'true'
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
@@ -146,10 +128,18 @@ jobs:
- name: Check tokio io-uring feature guard
run: ./scripts/check_no_tokio_io_uring.sh
- name: Check extension schema boundaries
run: ./scripts/check_extension_schema_boundaries.sh
- name: Check body-cache whitelist guard
run: ./scripts/check_body_cache_whitelist.sh
- name: Check no planning docs committed
run: ./scripts/check_no_planning_docs.sh
test-and-lint:
name: Test and Lint
needs: skip-check
if: needs.skip-check.outputs.should_skip != 'true'
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: sm-standard-4
timeout-minutes: 60
env:
@@ -166,26 +156,84 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }}
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
# Clippy runs before the test pass: lint failures are the most common
# CI-only breakage and should surface in minutes, not after 20+ minutes
# of tests.
- name: Run clippy lints
run: cargo clippy --all-targets -- -D warnings
- name: Run tests
run: |
cargo nextest run --all --exclude e2e_test
cargo nextest run --profile ci --all --exclude e2e_test
cargo test --all --doc
- name: Upload test junit report
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: junit-test-and-lint-${{ github.run_number }}
path: target/nextest/ci/junit.xml
retention-days: 3
if-no-files-found: ignore
# Explicit gate for migration-critical suites. These tests already ran in
# the full nextest pass above; a single filtered nextest invocation keeps
# the named gate without rebuilding or re-running them one package at a time.
#
# The gate selects tests by name substring (data_movement / rebalance /
# decommission / source_cleanup / delete_marker), so renames can silently
# thin it. The script owns the filter expression and first verifies the
# selected-test count against the committed floor in
# .config/migration-gate-floor.txt before running the gate; renames or
# removals must update that file consciously (see the script header).
# Kept on the default profile (no --profile ci): a second --profile ci run
# would clobber target/nextest/ci/junit.xml, and none of these tests are
# quarantined so they gain nothing from the ci profile's retry overrides.
- name: Run rebalance/decommission migration proofs
run: |
cargo nextest run -p rustfs-ecstore --lib \
-E 'test(data_movement) or test(rebalance) or test(decommission) or test(source_cleanup) or test(delete_marker)'
run: ./scripts/check_migration_gate_count.sh
- name: Run clippy lints
run: cargo clippy --all-targets -- -D warnings
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
# drive the object layer through process-global singletons (the GLOBAL_ENV
# ECStore, the global tier-config manager, background-expiry workers) and bind
# fixed ports (9002 for the scanner suite, 9003 for the app suite), so they are
# marked #[ignore] to keep them out of the default parallel `cargo nextest run
# --all` pass. Run them here explicitly with `--run-ignored ignored-only` and
# `-j1` so no two of them share global state or race for a port at once.
# serial_test's #[serial] does NOT serialize across nextest's process-per-test
# boundary (see .config/nextest.toml); `-j1` is what actually serializes them.
# See rustfs/backlog#1148 (ilm-1) and #1155.
test-ilm-integration-serial:
name: ILM Integration (serial)
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: sm-standard-4
timeout-minutes: 45
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
rust-version: stable
cache-shared-key: ci-ilm-serial
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).
- 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))'
test-and-lint-rio-v2:
name: Test and Lint (rio-v2)
needs: skip-check
if: needs.skip-check.outputs.should_skip != 'true'
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: sm-standard-4
timeout-minutes: 60
env:
@@ -202,18 +250,17 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }}
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Run rio-v2 clippy lints
run: cargo clippy -p rustfs -p rustfs-ecstore --all-targets --features rio-v2 -- -D warnings
- name: Run rio-v2 feature tests
run: |
cargo nextest run -p rustfs -p rustfs-ecstore --features rio-v2
cargo test -p rustfs --doc --features rio-v2
- name: Run rio-v2 clippy lints
run: cargo clippy -p rustfs -p rustfs-ecstore --all-targets --features rio-v2 -- -D warnings
test-and-lint-protocols:
name: "Test and Lint (${{ matrix.features.name }})"
needs: skip-check
if: needs.skip-check.outputs.should_skip != 'true'
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: sm-standard-4
timeout-minutes: 60
strategy:
@@ -238,18 +285,17 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }}
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Run tests with ${{ matrix.features.name }}
run: |
cargo nextest run -p rustfs -p rustfs-protocols ${{ matrix.features.flags }}
- name: Run clippy with ${{ matrix.features.name }}
run: |
cargo clippy -p rustfs -p rustfs-protocols --all-targets ${{ matrix.features.flags }} -- -D warnings
- name: Run tests with ${{ matrix.features.name }}
run: |
cargo nextest run -p rustfs -p rustfs-protocols ${{ matrix.features.flags }}
build-rustfs-debug-binary:
name: Build RustFS Debug Binary
needs: skip-check
if: needs.skip-check.outputs.should_skip != 'true'
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: sm-standard-4
timeout-minutes: 30
env:
@@ -279,8 +325,7 @@ jobs:
build-rustfs-debug-binary-rio-v2:
name: Build RustFS Debug Binary (rio-v2)
needs: skip-check
if: needs.skip-check.outputs.should_skip != 'true'
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: sm-standard-4
timeout-minutes: 30
env:
@@ -308,17 +353,67 @@ jobs:
if-no-files-found: error
retention-days: 1
uring-integration:
name: io_uring Integration (real)
# GitHub-hosted ubuntu-latest runs a recent kernel with io_uring and, unlike
# a container, applies no seccomp filter that would block io_uring_setup — so
# the probe succeeds and the tests exercise the real UringBackend/FdCache/
# latch paths instead of the StdBackend fallback (rustfs/backlog#1179). The
# self-hosted sm-standard runners cannot guarantee this.
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
rust-version: stable
cache-shared-key: ci-uring
github-token: ${{ secrets.GITHUB_TOKEN }}
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
# ext4 supports O_DIRECT; the runner's default TMPDIR may sit on tmpfs or
# overlayfs, where open(O_DIRECT) returns EINVAL/EOPNOTSUPP and the native
# read_at_direct path silently latches off to the aligned StdBackend
# fallback. A dedicated ext4 loopback guarantees the native O_DIRECT read
# path is actually exercised, not just the fallback (rustfs/backlog#1220).
- name: Prepare ext4 loopback for O_DIRECT coverage
run: |
set -euo pipefail
dd if=/dev/zero of=/tmp/rustfs-odirect.img bs=1M count=1024
mkfs.ext4 -q -F /tmp/rustfs-odirect.img
sudo mkdir -p /mnt/rustfs-odirect
sudo mount -o loop /tmp/rustfs-odirect.img /mnt/rustfs-odirect
sudo chmod 1777 /mnt/rustfs-odirect
mount | grep rustfs-odirect
# RUSTFS_URING_TESTS_MUST_RUN=1 makes the io_uring tests fail instead of
# silently skipping if io_uring is unavailable, so this leg can never pass
# vacuously. RUSTFS_IO_URING_READ_ENABLE=true selects the UringBackend.
# TMPDIR points every tempfile-based test fixture at the ext4 loopback so
# eligible reads keep O_DIRECT and the native path is covered end to end.
- name: Run ecstore io_uring integration tests on real io_uring
env:
RUSTFS_IO_URING_READ_ENABLE: "true"
RUSTFS_URING_TESTS_MUST_RUN: "1"
TMPDIR: /mnt/rustfs-odirect
run: cargo test -p rustfs-ecstore uring_ -- --test-threads=1 --nocapture
e2e-tests:
name: End-to-End Tests
needs: [ skip-check, build-rustfs-debug-binary ]
if: needs.skip-check.outputs.should_skip != 'true'
needs: [ build-rustfs-debug-binary ]
runs-on: sm-standard-2
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
# Full setup with dependency caching: the migration-proof step below
# Full setup with dependency caching: the smoke-suite step below
# compiles the e2e_test crate, which pulls in most of the workspace.
# Without a restored cache this was a cold multi-GB build on every run.
- name: Setup Rust environment
@@ -340,8 +435,13 @@ jobs:
- name: Make binary executable
run: chmod +x ./target/debug/rustfs
- name: Run delete-marker migration proof
run: cargo test -p e2e_test delete_marker_migration_semantics -- --nocapture --test-threads=1
# PR smoke subset of the in-repo e2e suite (backlog#1149 ci-4). The
# profile.e2e-smoke default-filter in .config/nextest.toml is the single
# wiring mechanism for e2e tests in CI — extend that filter instead of
# adding new e2e jobs here. Each test spawns its own rustfs server on a
# random port and reuses the downloaded debug binary above.
- name: Run e2e smoke suite
run: cargo nextest run --profile e2e-smoke -p e2e_test
- name: Install s3s-e2e test tool
uses: taiki-e/cache-cargo-install-action@7447f04c51f2ba27ca35e7f1e28fab848c5b3ba7 # v2
@@ -370,8 +470,7 @@ jobs:
e2e-tests-rio-v2:
name: End-to-End Tests (rio-v2)
needs: [ skip-check, build-rustfs-debug-binary-rio-v2 ]
if: needs.skip-check.outputs.should_skip != 'true'
needs: [ build-rustfs-debug-binary-rio-v2 ]
runs-on: sm-standard-2
timeout-minutes: 30
steps:
@@ -417,8 +516,7 @@ jobs:
s3-implemented-tests:
name: S3 Implemented Tests
needs: [ skip-check, build-rustfs-debug-binary, e2e-tests ]
if: needs.skip-check.outputs.should_skip != 'true'
needs: [ build-rustfs-debug-binary, e2e-tests ]
runs-on: sm-standard-4
timeout-minutes: 60
steps:
@@ -457,3 +555,77 @@ jobs:
path: artifacts/s3tests-single/**
if-no-files-found: ignore
retention-days: 3
# Dedicated lifecycle behavior lane (backlog#1148 ilm-10, master plan #1155).
#
# These ceph/s3-tests cases assert that objects/versions/uploads are actually
# removed by the background scanner and the stale-multipart cleanup loop, so
# they need a debug-accelerated day plus an enabled scanner. That configuration
# cannot be applied to the s3-implemented-tests lane above because a global
# RUSTFS_ILM_DEBUG_DAY_SECS also shrinks the x-amz-expiration header those tests
# assert on (test_lifecycle_expiration_header_*). Hence a separate server here.
#
# RUSTFS_ILM_DEBUG_DAY_SECS MUST stay equal to the s3-tests lc_debug_interval
# default (10s). test_lifecycle_expiration polls the Days=1 rule with a
# 4*lc_interval window while the Days=5 rule fires at 5*debug_day; observing
# the intermediate 4-object plateau requires 4*lc_interval < 5*debug_day, i.e.
# debug_day > 8. A smaller value lets the Days=5 rule fire inside the Days=1
# poll window so the count collapses straight to 2 and the test fails (#4764).
#
# RUSTFS_DATA_USAGE_UPDATE_DIR_CYCLES=1 is also required. A compacted directory
# is only re-descended (and its objects re-evaluated for ILM) once every
# DATA_USAGE_UPDATE_DIR_CYCLES scanner cycles (default 16). At the accelerated
# RUSTFS_SCANNER_CYCLE=2 that is ~32s between evaluations, so a Days=1 object
# that becomes due at debug_day (10s) is not actually expired until the next
# ~32s boundary (~42s), landing just past the 4*lc_interval (40s) poll window
# and making the count stall at 6 (#4767). Forcing every-cycle re-descent
# evaluates ILM within ~2s of the due time, well inside the poll window.
s3-lifecycle-behavior-tests:
name: S3 Lifecycle Behavior Tests
needs: [ build-rustfs-debug-binary ]
runs-on: sm-standard-4
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Download debug binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: rustfs-debug-binary
path: target/debug
- name: Make binary executable
run: chmod +x ./target/debug/rustfs
- name: Run lifecycle behavior s3-tests
run: |
RUN_ROOT="${RUNNER_TEMP}/rustfs-s3tests-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${GITHUB_JOB}"
mkdir -p "${RUN_ROOT}"
S3_PORT="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')"
DEPLOY_MODE=binary \
RUSTFS_BINARY=./target/debug/rustfs \
TEST_MODE=single \
MAXFAIL=0 \
XDIST=0 \
IMPLEMENTED_TESTS_FILE=scripts/s3-tests/lifecycle_behavior_tests.txt \
RUSTFS_ILM_DEBUG_DAY_SECS=10 \
RUSTFS_ILM_PROCESS_TIME=1 \
RUSTFS_SCANNER_ENABLED=true \
RUSTFS_SCANNER_CYCLE=2 \
RUSTFS_DATA_USAGE_UPDATE_DIR_CYCLES=1 \
RUSTFS_SCANNER_START_DELAY_SECS=0 \
RUSTFS_API_STALE_UPLOADS_CLEANUP_INTERVAL=2s \
S3_PORT="${S3_PORT}" \
DATA_ROOT="${RUN_ROOT}" \
S3TESTS_CONF=artifacts/s3tests-single/s3tests.conf \
./scripts/s3-tests/run.sh
- name: Upload s3 test artifacts
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: s3tests-lifecycle-behavior-${{ github.run_number }}
path: artifacts/s3tests-single/**
if-no-files-found: ignore
retention-days: 3
+23 -4
View File
@@ -70,9 +70,19 @@ env:
DOCKER_PLATFORMS: linux/amd64,linux/arm64
jobs:
# Check if we should build Docker images
# Check if we should build Docker images.
# Short-circuit at job level so per-merge development builds (workflow_run
# from a push to main) skip the whole run without spawning a checkout job:
# images are only published for tag builds — build.yml push triggers cover
# only main and tags, so a non-main head_branch is a tag name — and for
# manual dispatch.
build-check:
name: Docker Build Check
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch != 'main')
runs-on: ubuntu-latest
outputs:
should_build: ${{ steps.check.outputs.should_build }}
@@ -86,7 +96,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 0
# For workflow_run events, checkout the specific commit that triggered the workflow
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
@@ -421,6 +430,9 @@ jobs:
needs: [ build-check, build-docker ]
if: needs.build-check.outputs.should_build == 'true' && needs.build-check.outputs.should_push == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
@@ -450,9 +462,16 @@ jobs:
severity: CRITICAL,HIGH
exit-code: "0"
# Surface findings in the Security tab; an artifact alone is write-only
# reporting nobody reviews.
- name: Upload scan results to code scanning
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
sarif_file: trivy-${{ matrix.variant }}.sarif
category: container-image-${{ matrix.variant }}
- name: Upload container scan report
# actions/upload-artifact v7.0.1
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: container-image-scan-${{ matrix.variant }}
path: trivy-${{ matrix.variant }}.sarif
@@ -0,0 +1,121 @@
# 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.
# Nightly full replication e2e lane (backlog#1147 repl-1, deps: ci-4).
#
# The per-PR gate (ci.yml `e2e-tests` job, `--profile e2e-smoke`) runs the 20
# FAST bucket-replication tests. This scheduled lane runs the remaining 18
# heavier replication e2e tests that are unfit for a per-PR gate:
#
# * 8 bucket-replication data-plane tests (PUT/delete + poll for convergence;
# two replicate over HTTPS).
# * 9 `_real_dual_node` site-replication tests (each spawns TWO rustfs
# servers and drives the cross-process site-replication control plane).
# * 1 `_real_single_node` service-account round-trip test.
#
# The selection is the [profile.e2e-repl-nightly] default-filter in
# .config/nextest.toml — the single wiring mechanism (repl-1 / ci-4). Do NOT
# add ad-hoc cargo-test steps here; change the filterset instead.
#
# Explicit division of labor: these 18 tests run ONLY here, never double-run
# in ci-5's future e2e-full merge gate. TODO(ci-7): once the ci domain's
# consolidated scheduled e2e workflow exists, fold this interim repl-owned lane
# into it rather than growing a second scheduled entrypoint.
name: e2e-replication-nightly
on:
workflow_dispatch:
schedule:
# 04:00 UTC nightly — staggered clear of fuzz/e2e-s3tests (02:00),
# stale (01:30) and performance-ab (06:00).
- cron: "0 4 * * *"
# Only alert-on-failure needs more than read access; it declares its own
# job-level `issues: write`.
permissions:
contents: read
jobs:
repl-nightly:
name: Replication e2e (nightly)
# dual-node tests spawn two full rustfs servers each; the extra headroom
# over the per-PR sm-standard-2 e2e job keeps the parallel process fan-out
# from starving the runner. Mirrors the ILM serial lane's runner class.
runs-on: sm-standard-4
timeout-minutes: 45
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
rust-version: stable
cache-shared-key: ci-e2e-repl
github-token: ${{ secrets.GITHUB_TOKEN }}
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
# awscurl lets the STS dual-node test actually exercise its path. Without
# it the test skips gracefully with a visible log line
# (`awscurl_available()` in crates/e2e_test/src/common.rs), so the lane
# still passes — installing it just upgrades that one test from skip to
# real coverage.
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.12"
- name: Install awscurl
run: python3 -m pip install --user --upgrade pip awscurl
# Build the rustfs binary once up front. The e2e tests spawn it as a
# child process (crates/e2e_test/src/common.rs) and will build it on
# demand otherwise, but a single explicit build avoids several parallel
# nextest test processes racing to build it at once.
- name: Build rustfs binary
run: cargo build -p rustfs --bins
- name: Run replication e2e nightly suite
run: cargo nextest run --profile e2e-repl-nightly -p e2e_test
- name: Upload nextest junit report
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: e2e-replication-nightly-junit-${{ github.run_number }}
path: target/nextest/e2e-repl-nightly/junit.xml
retention-days: 7
if-no-files-found: ignore
alert-on-failure:
name: Alert on scheduled failure
needs: [repl-nightly]
# Only scheduled runs open/append the tracking issue (backlog#1149 ci-8);
# manual workflow_dispatch runs stay quiet so a debugging run never files a
# spurious alert.
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Open or update failure-tracking issue
uses: ./.github/actions/schedule-failure-issue
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+71 -10
View File
@@ -28,6 +28,22 @@
# truth, also used locally and by the PR gate). This workflow only provisions
# the server topology: a single node, or a real 4-node distributed cluster
# (erasure-coded across nodes) behind an HAProxy round-robin load balancer.
#
# Runner infrastructure (ci-1, backlog#1149): this sweep needs BOTH a working
# Docker daemon (to build the source image and run the single/multi topologies)
# AND a Python toolchain with pip (for awscurl/tox and the pytest harness).
# It previously ran on the self-hosted `sm-standard-4` label, which is served
# by heterogeneous runners: some scheduled runs landed on minimal pods that
# had neither `pip` (bare python3, `No module named pip`) nor `docker.sock`,
# so "Install Python tools" died before any test executed (all 15 recorded
# runs failed). Two changes make the infra assumptions explicit instead of
# trusting drifting runner state:
# 1. Pin to GitHub-hosted `ubuntu-latest`, which reliably ships Docker,
# docker compose, python3 and pip (no self-hosted fleet drift).
# 2. Set up Python explicitly (actions/setup-python) before any pip usage,
# so the workflow stays correct even if the runner image changes.
# The PR gate (ci.yml s3-implemented-tests) is unaffected: it avoids Docker
# via DEPLOY_MODE=binary and defers all pip setup to run.sh's self-bootstrap.
name: e2e-s3tests
@@ -69,8 +85,8 @@ on:
env:
# main user
S3_ACCESS_KEY: rustfsadmin
S3_SECRET_KEY: rustfsadmin
S3_ACCESS_KEY: rustfsadmin-ci
S3_SECRET_KEY: rustfssecret-ci
# alt user (must be different from main for many s3-tests)
S3_ALT_ACCESS_KEY: rustfsalt
S3_ALT_SECRET_KEY: rustfsalt
@@ -93,13 +109,22 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.inputs['test-mode'] || 'single' }}
cancel-in-progress: true
# Only alert-on-failure needs more than read access; it declares its own
# job-level `issues: write`.
permissions:
contents: read
defaults:
run:
shell: bash
jobs:
s3tests:
runs-on: sm-standard-4
# GitHub-hosted: reliably provides Docker + docker compose + python3/pip.
# See the header note (ci-1) for why the self-hosted sm-standard-4 label
# was abandoned. TODO(ci-8): scheduled-failure alerting (auto-open issue)
# is added by the ci-8 composite action; do not implement it here.
runs-on: ubuntu-latest
timeout-minutes: 180
strategy:
fail-fast: false
@@ -111,6 +136,14 @@ jobs:
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
# Provision Python explicitly rather than trusting the runner image to
# ship a working pip (ci-1: a bare python3 without pip is what broke the
# scheduled sweep). Keeps the workflow correct across runner drift.
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.12"
- name: Cache pip downloads
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
@@ -130,9 +163,9 @@ jobs:
- name: Build RustFS image (source, cached)
run: |
DOCKER_BUILDKIT=1 docker buildx build --load \
--platform ${PLATFORM} \
--cache-from type=gha,scope=${BUILDX_CACHE_SCOPE} \
--cache-to type=gha,mode=max,scope=${BUILDX_CACHE_SCOPE} \
--platform "${PLATFORM}" \
--cache-from "type=gha,scope=${BUILDX_CACHE_SCOPE}" \
--cache-to "type=gha,mode=max,scope=${BUILDX_CACHE_SCOPE}" \
-t rustfs-ci \
-f Dockerfile.source .
@@ -141,13 +174,18 @@ jobs:
run: |
docker network inspect rustfs-net >/dev/null 2>&1 || docker network create rustfs-net
docker rm -f rustfs-single >/dev/null 2>&1 || true
# The four disks share one physical device on the runner (a single
# loopback filesystem), so the local physical-disk-independence guard
# would refuse to start. Bypass it — this is the CI use case the guard
# explicitly sanctions via RUSTFS_UNSAFE_BYPASS_DISK_CHECK.
docker run -d --name rustfs-single \
--network rustfs-net \
-p ${S3_PORT}:9000 \
-p "${S3_PORT}:9000" \
-e RUSTFS_ADDRESS=0.0.0.0:9000 \
-e RUSTFS_ACCESS_KEY=${S3_ACCESS_KEY} \
-e RUSTFS_SECRET_KEY=${S3_SECRET_KEY} \
-e RUSTFS_ACCESS_KEY="${S3_ACCESS_KEY}" \
-e RUSTFS_SECRET_KEY="${S3_SECRET_KEY}" \
-e RUSTFS_VOLUMES="/data/rustfs{0...3}" \
-e RUSTFS_UNSAFE_BYPASS_DISK_CHECK=true \
-v /tmp/rustfs-single:/data \
rustfs-ci
@@ -167,6 +205,10 @@ jobs:
RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY}
RUSTFS_SECRET_KEY: ${S3_SECRET_KEY}
RUSTFS_VOLUMES: "http://rustfs{1...4}:9000/data/rustfs{0...3}"
# Each node's four disks share one physical device inside its
# container, so bypass the local physical-disk-independence guard
# (the CI use case it explicitly sanctions).
RUSTFS_UNSAFE_BYPASS_DISK_CHECK: "true"
services:
rustfs1:
@@ -226,7 +268,7 @@ jobs:
- name: Wait for RustFS ready
run: |
for i in {1..120}; do
for _ in {1..120}; do
if curl -sf "http://${S3_HOST}:${S3_PORT}/health" >/dev/null 2>&1; then
echo "RustFS is ready"
exit 0
@@ -297,3 +339,22 @@ jobs:
with:
name: s3tests-${{ env.TEST_MODE }}
path: artifacts/**
alert-on-failure:
name: Alert on scheduled failure
needs: [s3tests]
# `always()` is required: without it this job is skipped when a needed
# job fails. Alerts only for scheduled runs (backlog#1149 ci-8) — manual
# dispatch failures are already watched by a human.
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Open or update failure-tracking issue
uses: ./.github/actions/schedule-failure-issue
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+46 -8
View File
@@ -17,13 +17,16 @@ name: Fuzz
on:
pull_request:
types: [ opened, synchronize, reopened, closed ]
# PR trigger is intentionally narrow: only changes to the fuzz harness
# itself gate a PR. Broad crate paths (ecstore/filemeta/utils/policy/…)
# are covered by the nightly `schedule` run below, which fuzzes against
# whatever landed on main. Widening these paths previously queued a
# ~45min fuzz-build on nearly every PR and is why this workflow was
# disabled; do not re-add crate paths here.
paths:
- "fuzz/**"
- "scripts/fuzz/**"
- ".github/workflows/fuzz.yml"
- "crates/ecstore/**"
- "crates/filemeta/**"
- "crates/utils/**"
schedule:
- cron: "0 2 * * *"
workflow_dispatch:
@@ -48,7 +51,8 @@ concurrency:
env:
CARGO_TERM_COLOR: always
CARGO_BUILD_TARGET: x86_64-unknown-linux-gnu
RUSTFLAGS: "--cfg tokio_unstable -C target-feature=-crt-static"
# Fuzz targets build without the dial9 feature, so tokio_unstable is not needed.
RUSTFLAGS: "-C target-feature=-crt-static"
jobs:
cancel-closed-pr-runs:
@@ -96,7 +100,11 @@ jobs:
run: |
binary_root="fuzz/prebuilt/${CARGO_BUILD_TARGET}/release"
mkdir -p "${binary_root}"
for target in path_containment bucket_validation local_metadata; do
# Keep this list in sync with the smoke/nightly matrices and
# scripts/fuzz/run.sh default target set (all 5 buildable bins in
# fuzz/Cargo.toml). The *_storage_api.rs files are `mod` submodules
# of their parent targets, not standalone bins.
for target in archive_extract bucket_validation local_metadata path_containment policy_ingress; do
install -Dm755 "fuzz/target/${CARGO_BUILD_TARGET}/release/${target}" "${binary_root}/${target}"
done
@@ -105,9 +113,11 @@ jobs:
with:
name: fuzz-prebuilt-binaries-${{ github.run_number }}
path: |
fuzz/prebuilt/${{ env.CARGO_BUILD_TARGET }}/release/path_containment
fuzz/prebuilt/${{ env.CARGO_BUILD_TARGET }}/release/archive_extract
fuzz/prebuilt/${{ env.CARGO_BUILD_TARGET }}/release/bucket_validation
fuzz/prebuilt/${{ env.CARGO_BUILD_TARGET }}/release/local_metadata
fuzz/prebuilt/${{ env.CARGO_BUILD_TARGET }}/release/path_containment
fuzz/prebuilt/${{ env.CARGO_BUILD_TARGET }}/release/policy_ingress
if-no-files-found: error
retention-days: 1
compression-level: 0
@@ -126,8 +136,10 @@ jobs:
timeout-minutes: 30
strategy:
fail-fast: false
# One job per target runs in parallel, so wall-clock time is per-target
# (~60s fuzz + download/chmod overhead), not the sum across the matrix.
matrix:
target: [path_containment, bucket_validation, local_metadata]
target: [archive_extract, bucket_validation, local_metadata, path_containment, policy_ingress]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
@@ -169,6 +181,10 @@ jobs:
nightly-fuzz-corpus:
name: "Nightly / ${{ matrix.target }}"
needs: fuzz-build
# TODO(ci-8): when the schedule-failure-issue composite action lands,
# add a step here (or a dependent job) that opens/updates a GitHub issue
# on nightly failure. ci-8 is the single alerting mechanism for all
# scheduled workflows; do not self-roll alerting in this workflow.
if: >
github.event_name == 'schedule' ||
(github.event_name == 'workflow_dispatch' &&
@@ -178,7 +194,7 @@ jobs:
strategy:
fail-fast: false
matrix:
target: [path_containment, bucket_validation, local_metadata]
target: [archive_extract, bucket_validation, local_metadata, path_containment, policy_ingress]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
@@ -213,3 +229,25 @@ jobs:
fuzz/corpus/${{ matrix.target }}/**
if-no-files-found: ignore
retention-days: 30
# ──────────────────────────────────────────────────────────────
# Nightly alerting: open/update a tracking issue on failure.
# ──────────────────────────────────────────────────────────────
alert-on-failure:
name: Alert on scheduled failure
needs: [fuzz-build, nightly-fuzz-corpus]
# `always()` is required: without it this job is skipped when a needed
# job fails. Alerts only for scheduled (nightly) runs (backlog#1149
# ci-8); PR and manual dispatch failures are already watched by a human.
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Open or update failure-tracking issue
uses: ./.github/actions/schedule-failure-issue
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+70 -15
View File
@@ -26,7 +26,24 @@
# the run red. The job fails only when mint produces no results at all
# (infrastructure error). Once a suite passes reliably, tightening this into a
# baseline gate (like implemented_tests.txt for s3-tests) is the natural next
# step.
# step. Tightening this into a per-suite baseline gate is tracked as ci-3
# (backlog#1149) and is intentionally NOT done here.
#
# Runner infrastructure (ci-2, backlog#1149): this job needs a working Docker
# daemon to (a) build the RustFS source image with buildx and (b) run both the
# RustFS server and the mint image. It previously ran on the self-hosted
# `sm-standard-4` label, which is served by heterogeneous runners; its single
# recorded run (28730530597, 2026-07-05) landed on a minimal pod with no
# `/var/run/docker.sock`, so "Enable buildx" died at `docker buildx create`
# ("failed to connect to the docker API at unix:///var/run/docker.sock") before
# any test executed -- the same root cause ci-1 fixed for the weekly s3-tests
# sweep. The fix is to pin to GitHub-hosted `ubuntu-latest`, which reliably
# ships a running Docker daemon + buildx + python3, instead of trusting the
# drifting self-hosted fleet. Tradeoff: the source Rust build is heavier on a
# GitHub-hosted 4-vCPU runner than on a warm self-hosted host, but it stays
# well inside the 120-minute budget and buys deterministic infra. The
# docker-capable self-hosted `dind-sm-standard-2` label was the alternative but
# has fewer cores and reintroduces fleet-state risk for no reliability gain.
name: mint
@@ -50,12 +67,13 @@ on:
required: false
default: "minio/mint:edge"
schedule:
# Weekly, after the Sunday s3-tests full sweep.
- cron: "0 4 * * 0"
# Weekly, after the Sunday s3-tests full sweep (starts 02:00 UTC, up to
# 3h) has finished, so the two never contend for the same runner pool.
- cron: "0 6 * * 0"
env:
S3_ACCESS_KEY: rustfsadmin
S3_SECRET_KEY: rustfsadmin
S3_ACCESS_KEY: rustfsadmin-ci
S3_SECRET_KEY: rustfssecret-ci
S3_REGION: us-east-1
RUST_LOG: info
@@ -64,21 +82,39 @@ env:
MINT_SUITES: ${{ github.event.inputs.suites || '' }}
MINT_MODE: ${{ github.event.inputs.mode || 'core' }}
# NOTE: minio/mint:edge is a rolling tag. If sweeps get noisy from upstream
# mint changes, pin this to a digest known to work.
MINT_IMAGE: ${{ github.event.inputs.mint-image || 'minio/mint:edge' }}
# minio/mint:edge is a rolling tag, so an unpinned run is not reproducible and
# can go red purely from an upstream mint change (ci-2, backlog#1149). Pin the
# default to the linux/amd64 digest resolved on 2026-07-10. workflow_dispatch
# can still override via the `mint-image` input (e.g. to test a newer edge).
# Refresh: docker manifest inspect minio/mint:edge --verbose | grep digest
# (or, without a docker daemon:)
# TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:minio/mint:pull" | jq -r .token)
# curl -sI -H "Authorization: Bearer $TOKEN" \
# -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
# https://registry-1.docker.io/v2/minio/mint/manifests/edge | grep -i docker-content-digest
MINT_IMAGE: ${{ github.event.inputs.mint-image || 'minio/mint:edge@sha256:08a05e68893c68be2a83b6f79556853ed6aa3c6c9e64c823a00853e4e55d2200' }}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Only alert-on-failure needs more than read access; it declares its own
# job-level `issues: write`.
permissions:
contents: read
defaults:
run:
shell: bash
jobs:
mint:
runs-on: sm-standard-4
# GitHub-hosted: reliably provides a running Docker daemon + buildx +
# python3. See the header note (ci-2) for why the self-hosted sm-standard-4
# label was abandoned (no docker.sock on the pod its only run landed on).
# TODO(ci-8): scheduled-failure alerting (auto-open issue on a red weekly
# run) is added by the ci-8 composite action; do not implement it here.
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
@@ -89,9 +125,9 @@ jobs:
- name: Build RustFS image (source, cached)
run: |
DOCKER_BUILDKIT=1 docker buildx build --load \
--platform ${PLATFORM} \
--cache-from type=gha,scope=${BUILDX_CACHE_SCOPE} \
--cache-to type=gha,mode=max,scope=${BUILDX_CACHE_SCOPE} \
--platform "${PLATFORM}" \
--cache-from "type=gha,scope=${BUILDX_CACHE_SCOPE}" \
--cache-to "type=gha,mode=max,scope=${BUILDX_CACHE_SCOPE}" \
-t rustfs-ci \
-f Dockerfile.source .
@@ -103,15 +139,15 @@ jobs:
--network rustfs-net \
-p 9000:9000 \
-e RUSTFS_ADDRESS=0.0.0.0:9000 \
-e RUSTFS_ACCESS_KEY=${S3_ACCESS_KEY} \
-e RUSTFS_SECRET_KEY=${S3_SECRET_KEY} \
-e RUSTFS_ACCESS_KEY="${S3_ACCESS_KEY}" \
-e RUSTFS_SECRET_KEY="${S3_SECRET_KEY}" \
-e RUSTFS_VOLUMES="/data/rustfs{0...3}" \
-v /tmp/rustfs-mint:/data \
rustfs-ci
- name: Wait for RustFS ready
run: |
for i in {1..60}; do
for _ in {1..60}; do
if curl -sf http://127.0.0.1:9000/health >/dev/null 2>&1; then
echo "RustFS is ready"
exit 0
@@ -207,3 +243,22 @@ jobs:
with:
name: mint
path: artifacts/**
alert-on-failure:
name: Alert on scheduled failure
needs: [mint]
# `always()` is required: without it this job is skipped when a needed
# job fails. Alerts only for scheduled runs (backlog#1149 ci-8) — manual
# dispatch failures are already watched by a human.
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Open or update failure-tracking issue
uses: ./.github/actions/schedule-failure-issue
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -17,7 +17,7 @@ name: Update Nix Flake
on:
workflow_dispatch:
schedule:
- cron: '0 0 * * 0' # Weekly on Sundays
- cron: '0 5 * * 0' # Weekly on Sunday 05:00 UTC (staggered after the midnight ci/build crons)
permissions:
contents: write
+108 -9
View File
@@ -30,9 +30,9 @@ on:
workflow_dispatch:
inputs:
duration:
description: "warp duration per round"
description: "warp duration per round (short by default to fit the double-build budget)"
required: false
default: "30s"
default: "12s"
type: string
allow_regression:
description: "Pass the gate despite a FAIL (deliberate tradeoff)"
@@ -46,6 +46,13 @@ permissions:
contents: read
pull-requests: write
# Per-PR: a new push cancels the previous (up to 90-minute) A/B run instead of
# stacking them. Nightly schedule and manual dispatch get a unique group and
# always run to completion.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
@@ -53,13 +60,21 @@ env:
jobs:
warp-ab:
name: Warp A/B budget gate
# Opt-in on PRs: only run when the `perf-ab` label is present. Always run on
# schedule / manual dispatch.
# Opt-in on PRs: only run when the `perf-ab` label is present, and for
# `labeled` events only when the label being added is `perf-ab` itself —
# adding an unrelated label to an opted-in PR must not re-run the gate.
# Always run on schedule / manual dispatch.
if: >-
github.event_name != 'pull_request' ||
contains(github.event.pull_request.labels.*.name, 'perf-ab')
(contains(github.event.pull_request.labels.*.name, 'perf-ab') &&
(github.event.action != 'labeled' || github.event.label.name == 'perf-ab'))
runs-on: sm-standard-2
timeout-minutes: 90
# Phase-0 stopgap: the baseline+candidate release double-build alone is
# ~65min on this runner, so 90min left no room for a real full-matrix
# measurement (the earlier nightly runs only ever failed *before*
# measuring). 120min gives the 24-cell short matrix headroom until perf-3
# caches the baseline binary and restores a tighter budget.
timeout-minutes: 120
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
@@ -99,7 +114,17 @@ jobs:
id: ab
run: |
set -euo pipefail
args=(--baseline-ref origin/main --duration "${{ github.event.inputs.duration || '30s' }}")
# Budget note: the baseline+candidate release double-build (~65 min,
# cached away later by perf-3) dominates the 90-min job, so the
# measurement runs a short warp matrix — duration/rounds/cooldown are
# kept small to fit all 24 cells (6 workloads x 2 phases x 2 drive-sync)
# under budget rather than dropping cells. --health-timeout 180 outlasts
# the server's own 120s startup-readiness budget, which is what the
# rig's previous 60s health poll undershot (the first two nightly
# failures). perf-6 will recalibrate these once the pipeline is green.
duration="${{ github.event.inputs.duration || '12s' }}"
args=(--baseline-ref origin/main
--duration "$duration" --rounds 2 --cooldown 5 --health-timeout 180)
if [[ "${{ steps.exempt.outputs.allow_regression }}" == "true" ]]; then
args+=(--allow-regression --exemption-reason "labeled perf-deliberate-tradeoff / dispatch override")
fi
@@ -109,7 +134,14 @@ jobs:
bash scripts/run_hotpath_warp_ab.sh "${args[@]}"
echo "status=$?" >> "$GITHUB_OUTPUT"
set -e
# Locate the newest gate.md for the comment/artifact steps.
# Locate the newest run dir + gate.md for the summary/comment/artifact
# steps. On a startup failure there is no gate.md, but the run dir still
# holds server-logs/ for diagnosis.
# Run dirs are UTC-timestamp names (no special chars); ls is safe here.
# shellcheck disable=SC2012
run_dir="$(ls -td target/hotpath-ab/*/ 2>/dev/null | head -n1 || true)"
echo "run_dir=${run_dir%/}" >> "$GITHUB_OUTPUT"
# shellcheck disable=SC2012
gate_md="$(ls -t target/hotpath-ab/*/gate.md 2>/dev/null | head -n1 || true)"
echo "gate_md=$gate_md" >> "$GITHUB_OUTPUT"
@@ -118,8 +150,49 @@ jobs:
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: hotpath-warp-ab-${{ github.run_number }}
# Includes per-cell median_summary.csv / baseline_compare.csv, gate.md,
# and server-logs/ (rustfs.log + startup env per phase) so a failed run
# is diagnosable. Short retention: this is churny nightly debug data.
path: target/hotpath-ab/
if-no-files-found: warn
retention-days: 14
- name: Write gate summary
if: always()
run: |
set -euo pipefail
status="${{ steps.ab.outputs.status }}"
gate_md="${{ steps.ab.outputs.gate_md }}"
run_dir="${{ steps.ab.outputs.run_dir }}"
{
echo "## Hotpath warp A/B — run ${{ github.run_number }}"
echo
if [[ "$status" == "0" ]]; then
echo "Rig/gate exit: \`0\` (pass or warn)."
else
echo "Rig/gate exit: \`${status:-unknown}\` — **FAILED**."
fi
echo
if [[ -n "$gate_md" && -f "$gate_md" ]]; then
cat "$gate_md"
else
echo "No \`gate.md\` produced — the rig failed **before** the gate"
echo "(most likely server startup / health). Failing phase(s) below;"
echo "full logs in the \`hotpath-warp-ab-${{ github.run_number }}\` artifact."
if [[ -n "$run_dir" && -d "$run_dir/server-logs" ]]; then
for f in "$run_dir"/server-logs/*.log; do
[[ -f "$f" ]] || continue
echo
echo "<details><summary>$(basename "$f")</summary>"
echo
echo '```'
tail -n 30 "$f"
echo '```'
echo "</details>"
done
fi
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Comment gate result on PR
if: always() && github.event_name == 'pull_request' && steps.ab.outputs.gate_md != ''
@@ -128,12 +201,38 @@ jobs:
run: |
gh pr comment "${{ github.event.pull_request.number }}" --body-file "${{ steps.ab.outputs.gate_md }}"
# TODO(ci-8/perf-2): scheduled/dispatch failures are currently silent. Once
# ci-8 lands the .github/actions/schedule-failure-issue composite action,
# perf-2 adds a step here guarded by
# if: failure() && github.event_name != 'pull_request'
# that calls it (label perf-nightly-failure, append to an existing open
# issue) instead of hand-rolling gh CLI dedup. Do not implement it here.
- name: Enforce gate
if: always()
run: |
status="${{ steps.ab.outputs.status }}"
if [[ "$status" != "0" ]]; then
echo "::error::warp A/B budget gate failed (exit $status). See the PR comment / gate.md artifact." >&2
echo "::error::warp A/B budget gate failed (exit $status). See the step summary / PR comment / gate.md artifact." >&2
exit "$status"
fi
echo "warp A/B budget gate passed."
alert-on-failure:
name: Alert on scheduled failure
needs: [warp-ab]
# `always()` is required: without it this job is skipped when a needed
# job fails. Alerts only for scheduled (nightly) runs (backlog#1149
# ci-8); PR and manual dispatch failures are already watched by a human.
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Open or update failure-tracking issue
uses: ./.github/actions/schedule-failure-issue
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
-146
View File
@@ -1,146 +0,0 @@
# Copyright 2024 RustFS Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: Performance Testing
on:
push:
branches: [ main ]
paths:
- "**/*.rs"
- "**/Cargo.toml"
- "**/Cargo.lock"
- ".github/workflows/performance.yml"
workflow_dispatch:
inputs:
profile_duration:
description: "Profiling duration in seconds"
required: false
default: "120"
type: string
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
performance-profile:
name: Performance Profiling
runs-on: sm-standard-2
timeout-minutes: 30
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
rust-version: nightly
cache-shared-key: perf-${{ hashFiles('**/Cargo.lock') }}
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install additional nightly components
run: rustup component add llvm-tools-preview
- name: Install samply profiler
uses: taiki-e/cache-cargo-install-action@7447f04c51f2ba27ca35e7f1e28fab848c5b3ba7 # v2
with:
tool: samply
- name: Configure kernel for profiling
run: echo '1' | sudo tee /proc/sys/kernel/perf_event_paranoid
- name: Prepare test environment
run: |
# Create test volumes
for i in {0..4}; do
mkdir -p ./target/volume/test$i
done
# Set environment variables
echo "RUSTFS_VOLUMES=./target/volume/test{0...4}" >> $GITHUB_ENV
echo "RUST_LOG=rustfs=info,ecstore=info,s3s=info,iam=info,rustfs-obs=info" >> $GITHUB_ENV
- name: Verify console static assets
run: |
# Console static assets are already embedded in the repository
echo "Console static assets size: $(du -sh rustfs/static/)"
echo "Console static assets are embedded via rust-embed, no external download needed"
- name: Build with profiling optimizations
run: |
RUSTFLAGS="-C force-frame-pointers=yes -C debug-assertions=off --cfg tokio_unstable" \
cargo +nightly build --profile profiling -p rustfs --bins
- name: Run performance profiling
id: profiling
run: |
DURATION="${{ github.event.inputs.profile_duration || '120' }}"
echo "Running profiling for ${DURATION} seconds..."
timeout "${DURATION}s" samply record \
--output samply-profile.json \
./target/profiling/rustfs ${RUSTFS_VOLUMES} || true
if [ -f "samply-profile.json" ]; then
echo "profile_generated=true" >> $GITHUB_OUTPUT
echo "Profile generated successfully"
else
echo "profile_generated=false" >> $GITHUB_OUTPUT
echo "::warning::Profile data not generated"
fi
- name: Upload profile data
if: steps.profiling.outputs.profile_generated == 'true'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: performance-profile-${{ github.run_number }}
path: samply-profile.json
retention-days: 30
benchmark:
name: Benchmark Tests
runs-on: sm-standard-2
timeout-minutes: 45
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
rust-version: stable
cache-shared-key: bench-${{ hashFiles('**/Cargo.lock') }}
github-token: ${{ secrets.GITHUB_TOKEN }}
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Run benchmarks
run: |
cargo bench --package ecstore --bench comparison_benchmark -- --output-format json | \
tee benchmark-results.json
- name: Upload benchmark results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: benchmark-results-${{ github.run_number }}
path: benchmark-results.json
retention-days: 7
@@ -0,0 +1,62 @@
# Copyright 2024 RustFS Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Manual drill for the scheduled-failure alerting path (backlog#1149 ci-8).
#
# Forces a job failure and then runs .github/actions/schedule-failure-issue
# through the exact `needs` + `always() && contains(needs.*.result,
# 'failure')` wiring used by the real consumers (e2e-s3tests, mint, fuzz,
# performance-ab). Dispatch it twice to verify both the issue-creation and
# the dedupe-comment paths, then close the resulting
# "[scheduled-failure] Schedule Failure Alert Drill" issue.
#
# The run itself is expected to end red (the forced failure); only the
# alert-on-failure job result matters.
name: Schedule Failure Alert Drill
on:
workflow_dispatch:
permissions:
contents: read
jobs:
forced-failure:
name: Forced failure
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Fail on purpose
run: |
echo "Deliberate failure so alert-on-failure exercises the real consumer wiring."
exit 1
alert-on-failure:
name: Alert on scheduled failure
needs: [forced-failure]
# Mirrors the consumer wiring, minus the schedule-event guard (this
# workflow is dispatch-only by design).
if: always() && contains(needs.*.result, 'failure')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Open or update failure-tracking issue
uses: ./.github/actions/schedule-failure-issue
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+2 -2
View File
@@ -55,8 +55,8 @@ docs/*
!docs/architecture/**
!docs/operations/
!docs/operations/**
!docs/superpowers/
!docs/superpowers/**
!docs/testing/
!docs/testing/**
docs/heal-scanner-logging-governance.md
docs/benchmark/rustfs-target-bench/
docs/benchmark/*.md
+39 -1
View File
@@ -1,7 +1,45 @@
{
"version": "0.2.0",
"configurations": [
{
{
"name": "Debug RustFS observability (OTLP)",
"type": "lldb",
"request": "launch",
"cargo": {
"args": [
"build",
"--bin=rustfs",
"--package=rustfs"
],
"filter": {
"name": "rustfs",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}",
"env": {
"RUST_LOG": "rustfs=debug,ecstore=info,s3s=info,iam=info",
"RUST_BACKTRACE": "full",
"RUSTFS_ACCESS_KEY": "rustfsadmin",
"RUSTFS_SECRET_KEY": "rustfsadmin",
"RUSTFS_VOLUMES": "./target/observability/data{1...4}",
"RUSTFS_ADDRESS": ":9000",
"RUSTFS_CONSOLE_ENABLE": "true",
"RUSTFS_CONSOLE_ADDRESS": "127.0.0.1:9001",
"RUSTFS_UNSAFE_BYPASS_DISK_CHECK": "true",
"RUSTFS_OBS_ENDPOINT": "http://127.0.0.1:4318",
"RUSTFS_OBS_TRACES_EXPORT_ENABLED": "true",
"RUSTFS_OBS_METRICS_EXPORT_ENABLED": "true",
"RUSTFS_OBS_LOGS_EXPORT_ENABLED": "true",
"RUSTFS_OBS_USE_STDOUT": "true",
"RUSTFS_OBS_LOG_DIRECTORY": "./target/observability/logs",
"RUSTFS_OBS_METER_INTERVAL": "5",
"RUSTFS_OBS_SERVICE_NAME": "rustfs-observability-local",
"RUSTFS_OBS_ENVIRONMENT": "development"
}
},
{
"type": "lldb",
"request": "launch",
"name": "Debug(only) executable 'rustfs'",
+138 -8
View File
@@ -19,7 +19,7 @@ If repo-level instructions conflict, follow the nearest file and keep behavior a
- 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.
- A good result is a minimal diff with clear assumptions, no over-engineering, and independent verification that survives Adversarial Validation (below).
## Communication and Language
@@ -59,17 +59,35 @@ If repo-level instructions conflict, follow the nearest file and keep behavior a
- High-level architecture and crate map: `ARCHITECTURE.md`
- Migration guardrails, readiness contracts, support matrices:
`docs/architecture/README.md` (routes by audience)
- Historical implementation plans and trackers: `docs/superpowers/plans/`
- Shared agent skills (all tools): `.agents/skills/` — Claude Code reads them
through the `.claude/skills` symlink; add new skills to `.agents/skills/`
only, never as separate copies per tool
- Shared agent skills (all tools): `.agents/skills/` — each `SKILL.md` carries
a frontmatter `description` stating when it applies. Scan the descriptions
before starting a task and follow any skill that matches, even if your tool
does not auto-load skills:
`grep -m1 '^description:' .agents/skills/*/SKILL.md`
Claude Code reads them through the `.claude/skills` symlink; add new skills
to `.agents/skills/` only, never as separate copies per tool
Avoid duplicating long crate lists or command matrices in instruction files.
Reference the source files above instead.
Do not commit planning-type documents — one-shot implementation/optimization
plans, task trackers, migration-progress ledgers, phase/PR templates,
issue-scoped benchmark-result snapshots or optimization conclusions, or
agent-generated working notes (e.g. anything a `superpowers`/scratch workflow
produces). Keep that work in the issue tracker or your local worktree, not in
the repository. Only durable reference — the architecture set under
`docs/architecture/`, repeatable operational runbooks under `docs/operations/`,
and the test-suite references under `docs/testing/` — belongs in version
control; `.gitignore` ignores everything else under `docs/` by default, so a new
plan file will not be tracked unless someone force-adds it — don't.
`scripts/check_no_planning_docs.sh` (wired into `make pre-commit`/`pre-pr` and
CI) fails the build if anything is committed under `docs/superpowers/`, even via
`git add -f`.
## Verification Before PR
Convert changes into independently verifiable outcomes. Prefer focused tests for behavior changes and run the relevant checks before declaring completion.
Non-exempt changes must also pass Adversarial Validation (next section) before the checks below count as completion.
For code changes, run and pass the following before opening a PR:
@@ -94,19 +112,119 @@ Before pushing code changes, make sure formatting is clean:
- Run `cargo fmt --all --check` and ensure no files are modified unexpectedly.
If `make` is unavailable, run the equivalent checks defined under `.config/make/`.
Documentation-only or instruction-only changes are exempt from the verification commands above (including the `.config/make/` equivalents), though any installed git pre-commit hooks (for example, from `make setup-hooks`) may still run on commit unless explicitly skipped.
Documentation-only or instruction-only changes are exempt from the verification commands above (including the `.config/make/` equivalents), though any locally installed git pre-commit hooks may still run on commit unless explicitly skipped.
After build-based verification completes, clean generated build artifacts before wrapping up to avoid unnecessary disk usage.
Do not open a PR with code changes when the required checks fail.
Make a failing check pass by fixing the cause, never by weakening the gate:
do not loosen or skip a guard script, add entries to a baseline or allowance
list, suppress a lint with `#[allow]`, mark a failing test `#[ignore]`, or
delete or relax a failing assertion to get green. If a check itself is wrong,
change it deliberately and state the rationale in the PR.
For flaky tests, do not paper over them with retries. Follow the flake policy
in [docs/testing/README.md](docs/testing/README.md) (open an issue within 24h,
quarantine with an issue link, fix or delete within 30 days); the local
`default` nextest profile never retries.
## Adversarial Validation (Default On)
Every non-exempt output (see Risk tiers) — code change, bug fix, or
design/solution proposal — passes multi-role adversarial review before it
counts as done.
Author confidence is not evidence: each role's job is to refute the change,
not to bless it.
### Risk tiers
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.
- **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`),
persistence/fsync, IAM/KMS/auth, on-disk or on-wire formats, or
S3 API-visible behavior.
### Roles
Run each applicable role as an independent pass over the final diff (or
proposal text) — parallel reviewer agents where the tooling supports them,
otherwise sequential passes that each start fresh from the diff and the
nearest scoped `AGENTS.md`, discarding the writing session's assumptions.
Each role either produces findings or reports "attacked X, Y, Z — no break
found"; a bare pass is not a result. Repo-specific attack probes for every
role live in `.agents/skills/adversarial-validation/` — run them, they
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).
- **Security reviewer** — authn/authz bypass, injection, secret leakage,
untrusted deserialization (see Serde Safety), path traversal, timing leaks.
- **Concurrency/durability reviewer** — lock ordering, races, cancellation,
partial failure, retry/idempotency, crash and power-loss ordering.
- **Compatibility reviewer** — S3 API surface, MinIO interop, on-disk and
on-wire formats, mixed-version upgrade/downgrade paths.
- **Performance reviewer** — allocation and cloning on hot paths, lock hold
time across IO, sync or CPU-heavy work on async runtime threads, added
fsync/flush outside the durability gate, hot-path logging noise. A
measurable regression on a per-request or per-object path is a finding.
- **Test-coverage skeptic** — for each claimed behavior, name the test that
fails if the change is reverted; then name a changed line that could be
wrong while all tests stay green — if one exists, coverage is insufficient.
A missing test is a finding, not a note.
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.
### Protocol
1. A finding states a concrete failure scenario (input/state → wrong
outcome) or names a missing test, with severity and file:line. "Looks
risky" is not a finding.
2. Resolve every finding: fix it, or rebut it with evidence — a test, a
traced code path, or a cited invariant. Restated intent and "unlikely"
are not rebuttals.
3. After non-trivial fixes, re-run the roles whose domain the fix touched.
4. For proposals with no diff, roles attack assumptions, failure modes,
migration/rollback, and testability instead — including the simplest
rejected alternative and the blast radius when the design fails.
### Exit criteria
- Every applicable role has run; every finding is fixed or rebutted with
evidence.
- Every behavior change has a test that fails without it.
- The Verification Before PR gates pass — adversarial review supplements
those gates, never replaces them.
- High risk only: record a one-line verdict per role in the PR description.
## Git and PR Baseline
- Use feature branches based on the latest `main`.
- Assume other agent sessions work this repository concurrently. Never commit
in a shared checkout; do all work on a dedicated feature branch, preferably
in a dedicated worktree.
- Immediately before branching, fetch `origin/main` and branch from it;
confirm the target issue is not already fixed there before writing code.
- Follow Conventional Commits, with subject length <= 72 characters.
- Keep PR title and description in English.
- Use `.github/pull_request_template.md` and keep all section headings.
- Use `N/A` for non-applicable template sections.
- Include verification commands in the PR description.
- When using `gh pr create`/`gh pr edit`, use `--body-file` instead of inline `--body` for multiline markdown.
- When using `gh pr create`/`gh pr edit`, write the markdown body to a file
and pass `--body-file`; multiline inline `--body` is unsafe — backticks and
shell expansion can corrupt content or trigger unintended commands.
Pattern: `cat > /tmp/pr_body.md <<'EOF' ... EOF`, then
`--body-file /tmp/pr_body.md` (keep the file outside the checkout).
- Do not include the literal sequence `\n` in any GitHub issue, pull request, or discussion comment.
- Do not hard-wrap prose in PR/issue/discussion bodies; write each paragraph as a
single line and let it reflow. GitHub renders single newlines inside a paragraph
@@ -138,11 +256,23 @@ cargo run -p rustfs-filemeta --example dump_fileinfo -- "/path/to/file/xl.meta"
- When `deny_unknown_fields` is impractical (backward compatibility), at minimum log unknown fields at `warn` level.
- Never use `#[serde(default)]` on security-critical fields without explicit validation of the resulting value.
## Cross-Cutting Domain Invariants
- Write internal object metadata under **both** `x-rustfs-internal-<suffix>`
and `x-minio-internal-<suffix>` keys (MinIO interop). Use the helpers in
`crates/utils/src/http/metadata_compat.rs` (`get_bytes` prefers the RustFS
key); never write only one of the two.
- Read binary UUID metadata defensively:
`.and_then(|v| Uuid::from_slice(&v).ok()).filter(|u| !u.is_nil())`
absent, empty, and nil all mean "no value", never `Uuid::nil()`.
- A remote-tier version of `None`/`""` means the tier bucket is unversioned:
send **no** `versionId` on tier GET/DELETE.
## Naming Conventions
- Follow Rust API Guidelines for naming: `SCREAMING_SNAKE_CASE` for statics and constants, `snake_case` for functions and variables, `PascalCase` for types.
- Do not use camelCase or Hungarian notation (e.g., `globalDeploymentIDPtr``GLOBAL_DEPLOYMENT_ID`).
- If existing code violates naming conventions, do not widen the violation in new code. Fix opportunistically when touching the surrounding area.
- If existing code violates naming conventions, do not widen the violation in new code. Do not rename existing symbols as part of an unrelated task; mention the violation instead (see Change Style for Existing Logic).
## Scoped Guidance in This Repository
+1 -1
View File
@@ -141,7 +141,7 @@ Depth 8 — TOP:
| `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` | 1.8K | Concurrency control wrappers over io-core |
| `concurrency` | 0.8K | Shared concurrency contract types: workload admission snapshots, worker-slot pool, policy types (runtime control lives in `rustfs/src/storage`) |
**Storage Engine:**
+4 -9
View File
@@ -36,12 +36,7 @@ make build-docker BUILD_OS=ubuntu22.04
## Domain conventions worth knowing up front
- Internal object metadata is written under **both** `x-rustfs-internal-<suffix>`
and `x-minio-internal-<suffix>` keys for MinIO interop
(`crates/utils/src/http/metadata_compat.rs`; `get_bytes` prefers the RustFS
key). Never write only one of the two.
- Binary metadata values (UUIDs) must be read defensively:
`.and_then(|v| Uuid::from_slice(&v).ok()).filter(|u| !u.is_nil())`
absent/empty/nil all mean "no value", not `Uuid::nil()`.
- A remote-tier version of `None`/`""` means the tier bucket is unversioned:
send **no** `versionId` on tier GET/DELETE.
Repo-wide domain invariants (dual internal metadata keys, defensive UUID
reads, unversioned tier buckets) live in [AGENTS.md](AGENTS.md) under
"Cross-Cutting Domain Invariants" — read them before touching metadata or
tiering code.
+47 -19
View File
@@ -8,9 +8,9 @@ This guide covers the local development environment and the checks expected befo
**MANDATORY**: All code must be properly formatted before committing. This project enforces strict formatting standards to maintain code consistency and readability.
#### Pre-commit Requirements
#### Verification Requirements
Before every commit, you **MUST**:
Before submitting your changes for review, you **MUST**:
1. **Format your code**:
@@ -47,33 +47,57 @@ make fmt
# Check if code is properly formatted
make fmt-check
# Run clippy checks
make clippy
# Run clippy checks (all targets, all features, -D warnings)
make clippy-check
# Run compilation check
make check
# Fast workspace compilation check (excludes e2e_test)
make quick-check
# Run tests
# Full compilation check (cargo check --all-targets)
make compilation-check
# Run tests (shell script tests + workspace tests + doc tests)
make test
# Run all pre-commit checks (format + clippy + check + test)
# Fast pre-commit gate — see below for exactly what it runs
make pre-commit
# Setup git hooks (one-time setup)
make setup-hooks
# Full pre-PR gate (pre-commit gates + clippy + tests)
make pre-pr
```
> `make test` requires [cargo-nextest](https://nexte.st) (CI runs it and only nextest honours `.config/nextest.toml` test-groups). Install it with `cargo install cargo-nextest --locked` or a prebuilt binary (see https://nexte.st/docs/installation/). To run the plain `cargo test` fallback anyway (results not authoritative — serialization semantics differ from CI), set `RUSTFS_ALLOW_CARGO_TEST_FALLBACK=1`.
### 🔒 Automated Pre-commit Hooks
#### What `make pre-commit` and `make pre-pr` actually run
This project includes a pre-commit hook that automatically runs before each commit to ensure:
`make pre-commit` is the **fast** gate. It runs, in order
(see `.config/make/pre-commit.mak`):
- ✅ Code is properly formatted (`cargo fmt --all --check`)
- ✅ No clippy warnings (`cargo clippy --all-targets --all-features -- -D warnings`)
- ✅ Code compiles successfully (`cargo check --all-targets`)
1. `fmt-check` — `cargo fmt --all --check`
2. `unsafe-code-check` — `./scripts/check_unsafe_code_allowances.sh`
3. `architecture-migration-check` — `./scripts/check_architecture_migration_rules.sh`
4. `logging-guardrails-check` — `./scripts/check_logging_guardrails.sh`
5. `tokio-io-uring-check` — `./scripts/check_no_tokio_io_uring.sh`
6. `extension-schema-check` — `./scripts/check_extension_schema_boundaries.sh`
7. `doc-paths-check` — `./scripts/check_doc_paths.sh`
8. `quick-check` — `cargo check --workspace --exclude e2e_test`
#### Setting Up Pre-commit Hooks
**`make pre-commit` does NOT run clippy and does NOT run any tests.**
A green `make pre-commit` is not enough to open a pull request.
Run this command once after cloning the repository:
`make pre-pr` is the **full** gate: it runs all of the guard checks above,
then `clippy-check` (`cargo clippy --all-targets --all-features -- -D warnings`)
and `test` (shell script tests, workspace tests excluding `e2e_test`, and doc
tests). Run `make pre-pr` before opening or updating a pull request — this is
what CI enforces.
### 🔒 Git Pre-commit Hooks (optional)
Git hooks are **not** versioned in this repository, so a fresh clone has no
active pre-commit hook. If you add your own `.git/hooks/pre-commit` (a good
choice is a one-liner that runs `make pre-commit`), you can mark it executable
with:
```bash
make setup-hooks
@@ -85,6 +109,9 @@ Or manually:
chmod +x .git/hooks/pre-commit
```
With or without a hook, the expectation is the same: run `make pre-commit`
before committing and `make pre-pr` before opening a pull request.
### 📝 Formatting Configuration
The project uses the following rustfmt configuration (defined in `rustfmt.toml`):
@@ -97,7 +124,7 @@ single_line_let_else_max_width = 100
### 🚫 Commit Prevention
If your code doesn't meet the formatting requirements, the pre-commit hook will:
If you set up a pre-commit hook and your code doesn't meet the formatting requirements, the hook will:
1. **Block the commit** and show clear error messages
2. **Provide exact commands** to fix the issues
@@ -119,9 +146,10 @@ Example output when formatting fails:
1. **Make your changes**
2. **Format your code**: `make fmt` or `cargo fmt --all`
3. **Run pre-commit checks**: `make pre-commit`
3. **Run the fast gate**: `make pre-commit` (no clippy, no tests)
4. **Commit your changes**: `git commit -m "your message"`
5. **Push to your branch**: `git push`
5. **Run the full gate before opening/updating a PR**: `make pre-pr` (clippy + tests)
6. **Push to your branch**: `git push`
### 🛠️ IDE Integration
Generated
+246 -221
View File
File diff suppressed because it is too large Load Diff
+16 -17
View File
@@ -142,7 +142,7 @@ futures = "0.3.32"
futures-core = "0.3.32"
futures-lite = "2.6.1"
futures-util = "0.3.32"
pollster = "0.4.0"
pollster = "1.0.1"
pulsar = { version = "6.8.0", default-features = false, features = ["tokio-rustls-runtime", "telemetry"] }
lapin = { version = "4.10.0", default-features = false, features = ["tokio", "rustls", "rustls--aws_lc_rs"] }
hyper = { version = "1.10.1", features = ["http2", "http1", "server"] }
@@ -168,7 +168,7 @@ tower-http = { version = "0.7.0", features = ["cors"] }
# Serialization and Data Formats
apache-avro = "0.21.0"
bytes = { version = "1.12.0", features = ["serde"] }
bytes = { version = "1.12.1", features = ["serde"] }
bytesize = "2.4.2"
byteorder = "1.5.0"
flatbuffers = "25.12.19"
@@ -207,7 +207,7 @@ zeroize = { version = "1.9.0", features = ["derive"] }
# Time and Date
chrono = { version = "0.4.45", features = ["serde"] }
humantime = "2.4.0"
jiff = { version = "0.2.31", features = ["serde"] }
jiff = { version = "0.2.32", features = ["serde"] }
time = { version = "0.3.53", features = ["std", "parsing", "formatting", "macros", "serde"] }
# Database
@@ -221,12 +221,12 @@ arc-swap = "1.9.2"
astral-tokio-tar = "0.6.3"
atoi = "3.1.0"
atomic_enum = "0.3.0"
aws-config = { version = "1.8.18" }
aws-credential-types = { version = "1.2.14" }
aws-sdk-s3 = { version = "1.137.0", default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
aws-smithy-http-client = { version = "1.1.13", default-features = false, features = ["default-client", "rustls-aws-lc"] }
aws-smithy-runtime-api = { version = "1.12.3", features = ["http-1x"] }
aws-smithy-types = { version = "1.5.0" }
aws-config = { version = "1.9.0" }
aws-credential-types = { version = "1.3.0" }
aws-sdk-s3 = { version = "1.138.0", default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
aws-smithy-http-client = { version = "1.2.0", default-features = false, features = ["default-client", "rustls-aws-lc"] }
aws-smithy-runtime-api = { version = "1.13.0", features = ["http-1x"] }
aws-smithy-types = { version = "1.6.1" }
base64 = "0.22.1"
base64-simd = "0.8.0"
brotli = "8.0.4"
@@ -259,14 +259,14 @@ memmap2 = "0.9.11"
lz4 = "1.28.1"
matchit = "0.9.2"
md-5 = "0.11.0"
md5 = "0.8.0"
md5 = "0.8.1"
mime_guess = "2.0.5"
moka = { version = "0.12.15", features = ["future"] }
netif = "0.1.6"
num_cpus = { version = "1.17.0" }
nvml-wrapper = "0.12.1"
parking_lot = "0.12.5"
path-absolutize = "3.1.1"
path-absolutize = "4.0.1"
path-clean = "1.0.1"
percent-encoding = "2.3.2"
pin-project-lite = "0.2.17"
@@ -277,11 +277,11 @@ rayon = "1.12.0"
reed-solomon-erasure = { package = "rustfs-erasure-codec", version = "7.0.1", features = ["simd-accel"] }
#reed-solomon-erasure = { version = "6.0", features = ["simd-accel"], git = "https://github.com/houseme/reed-solomon-erasure",rev = "main" }
reed-solomon-simd = "3.1.0"
regex = { version = "1.12.4" }
regex = { version = "1.13.0" }
rumqttc = { package = "rumqttc-next", version = "0.33.2", features = ["websocket"] }
redis = { version = "1.3.0", features = ["connection-manager", "tokio-rustls-comp", "tls-rustls-insecure"] }
rustix = { version = "1.1.4", features = ["fs"] }
rust-embed = { version = "8.11.0" }
rust-embed = { version = "8.12.0" }
rustc-hash = { version = "2.1.3" }
s3s = { version = "0.14.1", features = ["minio"] }
serial_test = "3.5.0"
@@ -292,7 +292,7 @@ smartstring = "1.0.1"
snap = "1.1.1"
starshard = { version = "2.2.1", features = ["rayon", "async", "serde"] }
strum = { version = "0.28.0", features = ["derive"] }
sysinfo = "0.39.5"
sysinfo = "0.39.6"
temp-env = "0.3.6"
tempfile = "3.27.0"
test-case = "3.3.1"
@@ -305,10 +305,9 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter", "time"] }
transform-stream = "0.3.1"
url = "2.5.8"
urlencoding = "2.1.3"
uuid = { version = "1.23.4", features = ["v4", "fast-rng", "macro-diagnostics"] }
uuid = { version = "1.23.5", features = ["v4", "fast-rng", "macro-diagnostics"] }
vaultrs = { version = "0.8.0" }
walkdir = "2.5.0"
wildmatch = { version = "2.6.1", features = ["serde"] }
windows = { version = "0.62.2" }
xxhash-rust = { version = "0.8.16", features = ["xxh64", "xxh3"] }
zip = "8.6.0"
@@ -323,7 +322,7 @@ opentelemetry-otlp = { version = "0.32.0", features = ["gzip-http", "reqwest-rus
opentelemetry_sdk = { version = "0.32.1", features = ["rt-tokio"] }
opentelemetry-semantic-conventions = { version = "0.32.1", features = ["semconv_experimental"] }
opentelemetry-stdout = { version = "0.32.0" }
pyroscope = { version = "2.0.6", features = ["backend-pprof-rs"] }
pyroscope = { version = "2.1.0", features = ["backend-pprof-rs"] }
# FTP and SFTP
libunftp = { version = "0.23.0", features = ["experimental"] }
+5 -4
View File
@@ -64,10 +64,11 @@ How to use me:
🔧 Code Quality:
make fmt # Format code
make clippy # Run clippy checks
make test # Run tests
make pre-commit # Run fast pre-commit checks
make pre-pr # Run full pre-PR checks
make clippy-check # Run clippy (all targets, all features, -D warnings)
make quick-check # Fast workspace compile check (excludes e2e_test)
make test # Script tests + workspace tests + doc tests
make pre-commit # Fast gate: fmt-check + guard scripts + quick-check (NO clippy, NO tests)
make pre-pr # Full pre-PR gate: pre-commit gates + clippy-check + test
🚀 Quick Start:
make build # Build RustFS binary
+2
View File
@@ -45,6 +45,8 @@ nonexisted = "nonexisted"
consts = "consts"
# Swift API - company/product names
Hashi = "Hashi" # HashiCorp
# Accept alternate spelling used in parser/XML comments.
unparseable = "unparseable"
[files]
extend-exclude = []
+3
View File
@@ -35,6 +35,9 @@ pub enum AuditError {
#[error("System already initialized")]
AlreadyInitialized,
#[error("Audit system is paused; entry was not accepted")]
Paused,
#[error("Storage not available: {0}")]
StorageNotAvailable(String),
+23 -14
View File
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::{AuditEntry, AuditResult, AuditSystem, system::AuditTargetMetricSnapshot};
use crate::{AuditEntry, AuditError, AuditResult, AuditSystem, system::AuditTargetMetricSnapshot};
use rustfs_config::server_config::Config;
use std::sync::{Arc, OnceLock};
use tracing::{debug, error, trace};
@@ -78,10 +78,27 @@ pub async fn resume_audit_system() -> AuditResult<()> {
/// Dispatch an audit log entry to all targets
pub async fn dispatch_audit_log(entry: Arc<AuditEntry>) -> AuditResult<()> {
if let Some(system) = audit_system() {
if system.is_running().await {
system.dispatch(entry).await
} else {
let Some(system) = audit_system() else {
debug!(
event = EVENT_AUDIT_ENTRY_DROPPED,
component = LOG_COMPONENT_AUDIT,
subsystem = LOG_SUBSYSTEM_GLOBAL,
reason = "system_not_initialized",
"Dropped audit entry"
);
return Ok(());
};
// Single state read (backlog#984): the previous code checked `is_running()`
// and then called `dispatch()`, which re-read the state. Between the two
// reads the system could transition (e.g. Running -> Stopping) and
// `dispatch()` would return an error the caller never expected. Let
// `dispatch()` be the single authority on the current state and interpret
// its "not accepting" errors as a deliberate skip, while still surfacing
// real delivery failures (backlog#962).
match system.dispatch(entry).await {
Ok(()) => Ok(()),
Err(AuditError::NotInitialized(_)) | Err(AuditError::Paused) => {
trace!(
event = EVENT_AUDIT_ENTRY_DROPPED,
component = LOG_COMPONENT_AUDIT,
@@ -91,15 +108,7 @@ pub async fn dispatch_audit_log(entry: Arc<AuditEntry>) -> AuditResult<()> {
);
Ok(())
}
} else {
debug!(
event = EVENT_AUDIT_ENTRY_DROPPED,
component = LOG_COMPONENT_AUDIT,
subsystem = LOG_SUBSYSTEM_GLOBAL,
reason = "system_not_initialized",
"Dropped audit entry"
);
Ok(())
Err(e) => Err(e),
}
}
+152 -2
View File
@@ -45,6 +45,12 @@ impl AuditPipeline {
Self { registry }
}
/// Fans an audit entry out to every configured target concurrently.
///
/// Delivery across targets is unordered: the per-target `save()` calls run
/// via `join_all` and may complete in any order. Ordering of entries within
/// a single target is preserved by that target's own store/queue, not by
/// this fan-out.
pub async fn dispatch(&self, entry: Arc<AuditEntry>) -> AuditResult<()> {
let start_time = std::time::Instant::now();
@@ -190,8 +196,14 @@ impl AuditPipeline {
data: (*entry).clone(),
};
match target.save(Arc::new(entity_target)).await {
Ok(_) => success_count += 1,
Err(e) => errors.push(e),
Ok(_) => {
success_count += 1;
observability::record_target_success();
}
Err(e) => {
observability::record_target_failure();
errors.push(e);
}
}
}
(target.id().to_string(), success_count, errors)
@@ -251,6 +263,12 @@ impl AuditPipeline {
));
}
// Record the aggregate event outcome so batch dispatch reports the same
// observability signal as single dispatch (backlog#984): full success or
// partial failure both count as a delivered audit event here, since at
// least one target accepted every entry that reached this point.
observability::record_audit_success(dispatch_time);
Ok(())
}
@@ -541,3 +559,135 @@ impl AuditRuntimeFacade {
self.runtime_adapter.stop_replay_workers(&mut replay_workers).await;
}
}
#[cfg(test)]
mod tests {
use super::AuditPipeline;
use crate::{AuditEntry, AuditError, AuditRegistry};
use async_trait::async_trait;
use rustfs_targets::arn::TargetID;
use rustfs_targets::store::{Key, Store};
use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
use rustfs_targets::{StoreError, Target, TargetError};
use std::sync::Arc;
use tokio::sync::Mutex;
/// Mock target whose `save()` outcome is fixed at construction so tests can
/// force full-success / full-failure / partial-failure fan-outs.
#[derive(Clone)]
struct MockTarget {
id: TargetID,
fail: bool,
}
impl MockTarget {
fn new(id: &str, fail: bool) -> Self {
Self {
id: TargetID::new(id.to_string(), "webhook".to_string()),
fail,
}
}
}
#[async_trait]
impl<E> Target<E> for MockTarget
where
E: rustfs_targets::PluginEvent,
{
fn id(&self) -> TargetID {
self.id.clone()
}
async fn is_active(&self) -> Result<bool, TargetError> {
Ok(true)
}
async fn save(&self, _event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
if self.fail {
Err(TargetError::Configuration("forced save failure".to_string()))
} else {
Ok(())
}
}
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
Ok(())
}
async fn close(&self) -> Result<(), TargetError> {
Ok(())
}
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
None
}
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
Box::new(self.clone())
}
fn is_enabled(&self) -> bool {
true
}
}
fn pipeline_with(targets: Vec<MockTarget>) -> AuditPipeline {
let mut registry = AuditRegistry::new();
for target in targets {
registry.add_target(target.id.to_string(), Box::new(target));
}
AuditPipeline::new(Arc::new(Mutex::new(registry)))
}
fn entry() -> Arc<AuditEntry> {
Arc::new(AuditEntry::default())
}
// backlog#962: when every target rejects the event it is lost outright, so
// dispatch must return Err rather than swallowing the failures as Ok.
#[tokio::test]
async fn dispatch_returns_err_when_all_targets_fail() {
let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", true), MockTarget::new("b:webhook", true)]);
let result = pipeline.dispatch(entry()).await;
assert!(matches!(result, Err(AuditError::Target(_))), "expected Err, got {result:?}");
}
// A partially-successful fan-out means the entry reached at least one sink,
// so dispatch reports success (degradation is logged, not propagated).
#[tokio::test]
async fn dispatch_returns_ok_on_partial_failure() {
let pipeline = pipeline_with(vec![MockTarget::new("ok:webhook", false), MockTarget::new("bad:webhook", true)]);
pipeline.dispatch(entry()).await.expect("partial success should return Ok");
}
#[tokio::test]
async fn dispatch_returns_ok_when_all_targets_succeed() {
let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", false), MockTarget::new("b:webhook", false)]);
pipeline.dispatch(entry()).await.expect("all-success should return Ok");
}
// No configured targets is a benign no-op, not a failure.
#[tokio::test]
async fn dispatch_returns_ok_with_no_targets() {
let pipeline = pipeline_with(vec![]);
pipeline.dispatch(entry()).await.expect("no targets should return Ok");
}
// backlog#962: dispatch_batch must mirror dispatch and propagate a
// whole-batch loss instead of returning Ok.
#[tokio::test]
async fn dispatch_batch_returns_err_when_all_targets_fail() {
let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", true)]);
let result = pipeline.dispatch_batch(vec![entry(), entry()]).await;
assert!(matches!(result, Err(AuditError::Target(_))), "expected Err, got {result:?}");
}
#[tokio::test]
async fn dispatch_batch_returns_ok_when_all_targets_succeed() {
let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", false), MockTarget::new("b:webhook", false)]);
pipeline
.dispatch_batch(vec![entry(), entry()])
.await
.expect("all-success batch should return Ok");
}
}
+143 -24
View File
@@ -89,14 +89,20 @@ impl AuditSystem {
registry.create_audit_targets_from_config(config).await
}
/// Stops any active replay workers and closes the currently installed
/// targets without touching the system state. Lock order is `registry`
/// then `stream_cancellers` to stay consistent with every other path that
/// holds both locks (see `runtime_status_snapshot`, backlog#961).
async fn shutdown_runtime_targets(&self) -> AuditResult<()> {
let mut registry = self.registry.lock().await;
let mut replay_workers = self.stream_cancellers.write().await;
self.runtime_facade()
.shutdown_runtime(&mut registry, &mut replay_workers)
.await
}
async fn clear_runtime_targets(&self) -> AuditResult<()> {
{
let mut registry = self.registry.lock().await;
let mut replay_workers = self.stream_cancellers.write().await;
self.runtime_facade()
.shutdown_runtime(&mut registry, &mut replay_workers)
.await?;
}
self.shutdown_runtime_targets().await?;
let mut state = self.state.write().await;
*state = AuditSystemState::Stopped;
@@ -123,6 +129,16 @@ impl AuditSystem {
"audit system state"
);
// Stop-before-start (backlog#970): tear down the existing replay workers
// and close the currently installed targets *before* activating the new
// set. Activation spawns fresh replay workers for store-backed targets,
// so if the old workers were still running they would drain the same
// persistent queue concurrently with the new ones and re-deliver
// entries. Shutting the old runtime down first keeps at most one active
// worker per store across a reload. `replace_targets` performs a second
// (now no-op) shutdown before installing, which is idempotent.
self.shutdown_runtime_targets().await?;
let activation = self.runtime_facade().activate_targets_with_replay(targets).await;
self.runtime_facade().replace_targets(activation).await?;
@@ -139,21 +155,30 @@ impl AuditSystem {
/// # Returns
/// * `AuditResult<()>` - Result indicating success or failure
pub async fn start(&self, config: Config) -> AuditResult<()> {
let state = self.state.write().await;
// Claim the `Starting` transition atomically while holding the write
// lock (backlog#978): the previous code released the lock after the
// check and re-acquired it later to set `Starting`, so two concurrent
// `start()` calls (or `start()` racing `reload`) could both pass the
// check and double-activate. Transitioning to `Starting` before
// dropping the guard makes a concurrent caller observe `Starting` and
// return early instead.
{
let mut state = self.state.write().await;
match *state {
AuditSystemState::Running => {
return Err(AuditError::AlreadyInitialized);
match *state {
AuditSystemState::Running => {
return Err(AuditError::AlreadyInitialized);
}
AuditSystemState::Starting => {
warn_audit_state("starting", Some("already_starting"));
return Ok(());
}
_ => {}
}
AuditSystemState::Starting => {
warn_audit_state("starting", Some("already_starting"));
return Ok(());
}
_ => {}
*state = AuditSystemState::Starting;
}
drop(state);
info!(
event = EVENT_AUDIT_SYSTEM_STATE,
component = LOG_COMPONENT_AUDIT,
@@ -173,11 +198,7 @@ impl AuditSystem {
match self.create_targets_from_config(&config).await {
Ok(targets) => {
{
let mut state = self.state.write().await;
*state = AuditSystemState::Starting;
}
// State is already `Starting` (claimed atomically above).
self.commit_runtime_targets(targets, AuditSystemState::Running).await?;
info_audit_state("running", None, None);
Ok(())
@@ -318,7 +339,13 @@ impl AuditSystem {
match *state {
AuditSystemState::Running => {}
AuditSystemState::Paused => {
return Ok(());
// Do not silently return Ok while paused (backlog#978): the
// entry is neither delivered nor persisted, so reporting success
// would corrupt the audit trail. Surface an explicit `Paused`
// error and let the caller apply its policy (the global helper
// treats this as a deliberate skip; direct API callers can
// decide otherwise).
return Err(AuditError::Paused);
}
_ => {
return Err(AuditError::NotInitialized("Audit system is not running".to_string()));
@@ -548,6 +575,7 @@ fn warn_audit_state(state: &str, reason: Option<&str>) {
#[cfg(test)]
mod tests {
use super::{AuditSystem, AuditSystemState};
use crate::{AuditEntry, AuditError};
use async_trait::async_trait;
use rustfs_targets::ReplayWorkerManager;
use rustfs_targets::arn::TargetID;
@@ -705,4 +733,95 @@ mod tests {
.await
.expect("audit lock paths deadlocked (backlog#961 regression)");
}
/// backlog#978: a paused system must not report success while silently
/// dropping the entry. `dispatch` should surface an explicit `Paused` error.
#[tokio::test]
async fn dispatch_while_paused_returns_error_not_ok() {
let system = AuditSystem::new();
{
let mut state = system.state.write().await;
*state = AuditSystemState::Paused;
}
let result = system.dispatch(Arc::new(AuditEntry::default())).await;
assert!(
matches!(result, Err(AuditError::Paused)),
"paused dispatch must return Err(Paused), got {result:?}"
);
}
/// backlog#978: `start()` now claims the `Starting` transition atomically
/// under the state lock, so racing `start()` calls cannot both pass the
/// check and double-activate. Hammer concurrent starts and assert the
/// workload completes (no deadlock/panic) and converges to a consistent
/// final state.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_start_does_not_hang_or_double_activate() {
use std::time::Duration;
let system = AuditSystem::new();
let mut handles = Vec::new();
for _ in 0..8 {
let s = system.clone();
handles.push(tokio::spawn(async move {
// Empty config activates no targets, so a completed start settles
// the system back to `Stopped`.
let _ = s.start(rustfs_config::server_config::Config(HashMap::new())).await;
}));
}
let workload = async {
for handle in handles {
handle.await.expect("start task panicked");
}
};
tokio::time::timeout(Duration::from_secs(30), workload)
.await
.expect("concurrent start deadlocked (backlog#978 regression)");
assert_eq!(system.get_state().await, AuditSystemState::Stopped);
}
/// backlog#970: a reload/commit must tear down the previous replay workers
/// and close the old targets before activating the replacement set, so the
/// old and new workers never drain the same store concurrently. Seed an old
/// target plus a replay worker, commit a new target, and assert the old one
/// was closed and its worker stopped while the new one is installed.
#[tokio::test]
async fn commit_closes_old_targets_before_installing_new() {
let system = AuditSystem::new();
let old = TestTarget::new("old", "webhook");
let old_close = Arc::clone(&old.close_calls);
{
let mut registry = system.registry.lock().await;
registry.add_target("old:webhook".to_string(), Box::new(old));
}
{
let mut replay_workers = system.stream_cancellers.write().await;
let (cancel_tx, _cancel_rx) = mpsc::channel(1);
replay_workers.insert("old:webhook".to_string(), cancel_tx);
}
{
let mut state = system.state.write().await;
*state = AuditSystemState::Running;
}
let new = TestTarget::new("new", "webhook");
let new_close = Arc::clone(&new.close_calls);
system
.commit_runtime_targets(vec![Box::new(new)], AuditSystemState::Running)
.await
.expect("commit should succeed");
// Old target closed exactly once during the pre-install shutdown.
assert_eq!(old_close.load(Ordering::SeqCst), 1);
// New target installed and left open.
assert_eq!(new_close.load(Ordering::SeqCst), 0);
assert_eq!(system.list_targets().await, vec!["new:webhook".to_string()]);
// Old replay worker stopped; the store-less new target adds none.
assert_eq!(system.runtime_status_snapshot().await.replay_worker_count, 0);
assert_eq!(system.get_state().await, AuditSystemState::Running);
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ impl fmt::Display for UnknownChecksumAlgorithmError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
r#"unknown checksum algorithm "{}", please pass a known algorithm name ("crc32", "crc32c", "sha1", "sha256", "md5")"#,
r#"unknown checksum algorithm "{}", please pass a known algorithm name ("crc32", "crc32c", "crc64nvme", "sha1", "sha256")"#,
self.checksum_algorithm
)
}
+20 -9
View File
@@ -43,8 +43,6 @@ pub enum ChecksumAlgorithm {
#[default]
Crc32,
Crc32c,
#[deprecated]
Md5,
Sha1,
Sha256,
Crc64Nvme,
@@ -62,9 +60,6 @@ impl FromStr for ChecksumAlgorithm {
Ok(Self::Sha1)
} else if checksum_algorithm.eq_ignore_ascii_case(SHA_256_NAME) {
Ok(Self::Sha256)
} else if checksum_algorithm.eq_ignore_ascii_case(MD5_NAME) {
// MD5 is now an alias for the default Crc32 since it is deprecated
Ok(Self::Crc32)
} else if checksum_algorithm.eq_ignore_ascii_case(CRC_64_NVME_NAME) {
Ok(Self::Crc64Nvme)
} else {
@@ -79,8 +74,6 @@ impl ChecksumAlgorithm {
Self::Crc32 => Box::<Crc32>::default(),
Self::Crc32c => Box::<Crc32c>::default(),
Self::Crc64Nvme => Box::<Crc64Nvme>::default(),
#[allow(deprecated)]
Self::Md5 => Box::<Crc32>::default(),
Self::Sha1 => Box::<Sha1>::default(),
Self::Sha256 => Box::<Sha256>::default(),
}
@@ -91,8 +84,6 @@ impl ChecksumAlgorithm {
Self::Crc32 => CRC_32_NAME,
Self::Crc32c => CRC_32_C_NAME,
Self::Crc64Nvme => CRC_64_NVME_NAME,
#[allow(deprecated)]
Self::Md5 => MD5_NAME,
Self::Sha1 => SHA_1_NAME,
Self::Sha256 => SHA_256_NAME,
}
@@ -300,6 +291,7 @@ struct Md5 {
hasher: md5::Md5,
}
#[allow(dead_code)]
impl Md5 {
fn update(&mut self, bytes: &[u8]) {
use md5::Digest;
@@ -444,4 +436,23 @@ mod tests {
.expect_err("it should error");
assert_eq!("some invalid checksum algorithm", error.checksum_algorithm());
}
#[test]
fn test_unknown_algorithm_error_message_lists_supported_algorithms() {
let error = "nope".parse::<ChecksumAlgorithm>().expect_err("it should error");
let message = error.to_string();
assert!(message.contains("crc64nvme"), "message should advertise crc64nvme: {message}");
assert!(!message.contains("md5"), "message should not advertise the unsupported md5: {message}");
}
#[test]
fn test_md5_is_not_a_supported_checksum_algorithm() {
// MD5 is not an accepted S3 checksum algorithm here: parsing it must fail
// loudly rather than silently substituting a CRC32 hasher.
let error = "md5".parse::<ChecksumAlgorithm>().expect_err("md5 should not parse");
assert_eq!("md5", error.checksum_algorithm());
let error = "MD5".parse::<ChecksumAlgorithm>().expect_err("md5 should not parse");
assert_eq!("MD5", error.checksum_algorithm());
}
}
+105 -3
View File
@@ -144,11 +144,14 @@ impl LastMinuteLatency {
merged.last_sec = o.last_sec;
}
// Both operands must be read in their forwarded form so aged-out
// ring-buffer slots stay zeroed: `x` is the forwarded copy of `o`,
// and `self` is forwarded in place in the `else` branch above.
for i in 0..merged.totals.len() {
merged.totals[i] = AccElem {
total: self.totals[i].total + o.totals[i].total,
n: self.totals[i].n + o.totals[i].n,
size: self.totals[i].size + o.totals[i].size,
total: self.totals[i].total.wrapping_add(x.totals[i].total),
n: self.totals[i].n.wrapping_add(x.totals[i].n),
size: self.totals[i].size.wrapping_add(x.totals[i].size),
}
}
merged
@@ -372,6 +375,105 @@ mod tests {
assert_eq!(merged.totals[0].total, 30);
}
#[test]
fn test_last_minute_latency_merge_ages_out_stale_slots_self_newer() {
// self.last_sec > o.last_sec branch: `o` is forwarded to self.last_sec,
// which zeroes the ring-buffer slots for seconds 1001..=1010, i.e.
// indices 41..=50. Data parked in one of those slots is stale and must
// be excluded from the merged result.
let mut newer = LastMinuteLatency::default();
let mut older = LastMinuteLatency::default();
newer.last_sec = 1010;
older.last_sec = 1000;
// Stale slot: second 1005 -> index 45, cleared by forward_to(1010).
let stale_idx = (1005 % 60) as usize;
older.totals[stale_idx].total = 111;
older.totals[stale_idx].n = 5;
older.totals[stale_idx].size = 999;
// In-window slot: older's own last_sec (1000 -> index 40) is kept.
let kept_idx = (1000 % 60) as usize;
older.totals[kept_idx].total = 3;
older.totals[kept_idx].n = 1;
older.totals[kept_idx].size = 30;
// newer's current data (1010 -> index 50) must survive.
let newer_idx = (1010 % 60) as usize;
newer.totals[newer_idx].total = 7;
newer.totals[newer_idx].n = 1;
newer.totals[newer_idx].size = 70;
let merged = newer.merge(&older);
assert_eq!(merged.last_sec, 1010);
// The stale older slot is aged out -> excluded from the sum.
assert_eq!(merged.totals[stale_idx].total, 0);
assert_eq!(merged.totals[stale_idx].n, 0);
assert_eq!(merged.totals[stale_idx].size, 0);
// In-window older data is retained.
assert_eq!(merged.totals[kept_idx].total, 3);
assert_eq!(merged.totals[kept_idx].n, 1);
// newer data is retained.
assert_eq!(merged.totals[newer_idx].total, 7);
}
#[test]
fn test_last_minute_latency_merge_ages_out_of_window_self_newer() {
// self.last_sec > o.last_sec with a full-window gap (>= 60s): all of
// `o` is aged out and only `self`'s data remains.
let mut newer = LastMinuteLatency::default();
let mut older = LastMinuteLatency::default();
newer.last_sec = 1070;
older.last_sec = 1000; // gap of 70 >= 60 -> all of older is aged out
// 1070 % 60 == 1000 % 60 == 10, so both write the same slot; the fix
// must yield exactly newer's value, not newer + stale older.
let idx = (1070 % 60) as usize;
older.totals[idx].total = 111;
older.totals[idx].n = 5;
older.totals[idx].size = 999;
newer.totals[idx].total = 7;
newer.totals[idx].n = 1;
newer.totals[idx].size = 70;
let merged = newer.merge(&older);
assert_eq!(merged.last_sec, 1070);
assert_eq!(merged.totals[idx].total, 7);
assert_eq!(merged.totals[idx].n, 1);
assert_eq!(merged.totals[idx].size, 70);
}
#[test]
fn test_last_minute_latency_merge_ages_out_of_window_o_newer() {
// Mirror of the above for the else branch: `self` is older and is
// forwarded to o.last_sec, aging out self's out-of-window data.
let mut older = LastMinuteLatency::default();
let mut newer = LastMinuteLatency::default();
older.last_sec = 1000;
newer.last_sec = 1070; // gap of 70 >= 60 -> all of older (self) is aged out
let idx = (1070 % 60) as usize; // 1000 % 60 == 1070 % 60 == 10
older.totals[idx].total = 111;
older.totals[idx].n = 5;
older.totals[idx].size = 999;
newer.totals[idx].total = 7;
newer.totals[idx].n = 1;
newer.totals[idx].size = 70;
let merged = older.merge(&newer);
assert_eq!(merged.last_sec, 1070);
// self's stale data aged out; only newer's data remains.
assert_eq!(merged.totals[idx].total, 7);
assert_eq!(merged.totals[idx].n, 1);
assert_eq!(merged.totals[idx].size, 70);
}
#[test]
fn test_last_minute_latency_window_wraparound() {
let mut latency = LastMinuteLatency::default();
+65
View File
@@ -750,6 +750,11 @@ pub struct Metrics {
last_scan_cycle_duration_millis: AtomicU64,
last_scan_cycle_objects_scanned: AtomicU64,
last_scan_cycle_directories_scanned: AtomicU64,
/// Lifetime count of object versions walked by the data scanner, counted
/// for every version regardless of whether any lifecycle rule applies.
/// This is the honest source for `rustfs_scanner_versions_scanned_total`;
/// ILM-checked versions are tracked separately on the `Lifecycle` source.
lifetime_versions_scanned: AtomicU64,
last_scan_cycle_bucket_drive_scans: AtomicU64,
last_scan_cycle_bucket_drive_failures: AtomicU64,
last_scan_cycle_yield_events: AtomicU64,
@@ -1106,6 +1111,9 @@ pub struct ScannerMetricsReport {
pub oldest_active_path_age_seconds: u64,
pub life_time_ops: HashMap<String, u64>,
pub life_time_ilm: HashMap<String, u64>,
/// Lifetime object versions scanned, independent of ILM configuration.
#[serde(default)]
pub versions_scanned: u64,
pub last_minute: ScannerLastMinute,
pub active_paths: Vec<String>,
pub current_scan_mode: String,
@@ -1704,6 +1712,7 @@ impl Metrics {
last_scan_cycle_duration_millis: AtomicU64::new(0),
last_scan_cycle_objects_scanned: AtomicU64::new(0),
last_scan_cycle_directories_scanned: AtomicU64::new(0),
lifetime_versions_scanned: AtomicU64::new(0),
last_scan_cycle_bucket_drive_scans: AtomicU64::new(0),
last_scan_cycle_bucket_drive_failures: AtomicU64::new(0),
last_scan_cycle_yield_events: AtomicU64::new(0),
@@ -2112,6 +2121,17 @@ impl Metrics {
self.record_scanner_source_work(source, ScannerSourceWorkUpdate::checked(count));
}
/// Record `count` object versions walked by the data scanner.
///
/// Every version the scanner visits is counted here, independent of
/// lifecycle configuration, so `rustfs_scanner_versions_scanned_total`
/// reflects real scan coverage even on clusters with no ILM rules. ILM
/// evaluation coverage is recorded separately via the `Lifecycle` source's
/// `checked` counter and surfaces as `rustfs_ilm_versions_scanned_total`.
pub fn record_scanner_versions_scanned(&self, count: u64) {
self.lifetime_versions_scanned.fetch_add(count, Ordering::Relaxed);
}
pub fn record_scanner_source_queued(&self, source: ScannerWorkSource, count: u64) {
self.record_scanner_source_work(source, ScannerSourceWorkUpdate::queued(count));
}
@@ -2695,6 +2715,7 @@ impl Metrics {
.map(|(disk, state)| format!("{disk}/{}", state.path))
.collect();
m.current_scan_mode = self.current_scan_mode().as_str().to_string();
m.versions_scanned = self.lifetime_versions_scanned.load(Ordering::Relaxed);
m.leader_lock_state = self.scanner_leader_lock_state.read().await.clone();
m.leader_lock_held_by_this_process = self.scanner_leader_lock_held.load(Ordering::Relaxed);
m.leader_lock_last_error = self.scanner_leader_lock_last_error.read().await.clone();
@@ -3054,6 +3075,50 @@ mod tests {
assert_eq!(report.current_disk_bucket_scans_active, 0);
}
#[tokio::test]
async fn report_counts_scanned_versions_independent_of_lifecycle() {
let metrics = Metrics::new();
// No ILM configuration touched: scanned versions must still accrue so
// rustfs_scanner_versions_scanned_total reflects real coverage.
metrics.record_scanner_versions_scanned(3);
metrics.record_scanner_versions_scanned(5);
let report = metrics.report().await;
assert_eq!(report.versions_scanned, 8);
// ILM-checked versions live on the Lifecycle source and stay zero when
// no lifecycle evaluation ran.
let lifecycle_checked = report
.source_work
.iter()
.find(|s| s.source == "lifecycle")
.map(|s| s.checked)
.unwrap_or_default();
assert_eq!(lifecycle_checked, 0);
}
#[tokio::test]
async fn report_tracks_lifecycle_checked_versions_separately() {
let metrics = Metrics::new();
// Simulate the scanner walking versions and, on a lifecycle-configured
// bucket, handing a subset to the ILM evaluator.
metrics.record_scanner_versions_scanned(10);
metrics.record_scanner_source_checked(ScannerWorkSource::Lifecycle, 4);
let report = metrics.report().await;
assert_eq!(report.versions_scanned, 10, "total scanned versions independent of ILM");
let lifecycle_checked = report
.source_work
.iter()
.find(|s| s.source == "lifecycle")
.map(|s| s.checked)
.unwrap_or_default();
assert_eq!(lifecycle_checked, 4, "ILM-checked versions tracked on the Lifecycle source");
}
#[tokio::test]
async fn report_derives_scanner_pacing_pressure() {
let metrics = Metrics::new();
+4 -23
View File
@@ -6,22 +6,17 @@ license.workspace = true
repository.workspace = true
rust-version.workspace = true
homepage.workspace = true
description = "Concurrency management for RustFS - timeout, locking, backpressure, and I/O scheduling"
keywords = ["rustfs", "concurrency", "timeout", "backpressure", "scheduling"]
description = "Shared concurrency contract types for RustFS - workload admission, queue snapshots, worker pools, and policy types"
keywords = ["rustfs", "concurrency", "admission", "backpressure", "workers"]
categories = ["concurrency", "filesystem"]
[dependencies]
# Internal crates
rustfs-io-core = { workspace = true }
rustfs-io-metrics = { workspace = true }
serde = { workspace = true }
# Async runtime
tokio = { workspace = true, features = ["sync", "time", "rt"] }
tokio-util = { workspace = true }
# Error handling
thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
# Logging
tracing = { workspace = true }
@@ -29,18 +24,4 @@ tracing = { workspace = true }
[dev-dependencies]
insta = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["test-util","macros","rt-multi-thread"] }
[features]
default = ["timeout", "lock", "deadlock", "backpressure", "scheduler"]
# Feature modules
timeout = []
lock = []
deadlock = []
backpressure = []
scheduler = []
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
tokio = { workspace = true, features = ["test-util", "macros", "rt-multi-thread"] }
+21 -181
View File
@@ -12,17 +12,15 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! Backpressure management
//! Shared backpressure policy type.
//!
//! The runtime backpressure implementation (byte-watermark pipes and
//! monitors) lives in `rustfs/src/storage/backpressure.rs`; this module only
//! carries the watermark policy type that implementation shares.
use rustfs_io_core::{
BackpressureConfig as CoreBackpressureConfig, BackpressureMonitor as CoreBackpressureMonitor, BackpressureState,
};
use rustfs_io_metrics::backpressure_metrics;
use std::sync::Arc;
use std::time::Instant;
use tokio::io::{DuplexStream, duplex};
use rustfs_io_core::BackpressureConfig as CoreBackpressureConfig;
/// Facade policy for duplex-pipe watermark backpressure.
/// Watermark policy for duplex-pipe backpressure.
#[derive(Debug, Clone, Copy)]
pub struct PipeBackpressurePolicy {
/// Buffer size in bytes
@@ -54,9 +52,9 @@ impl PipeBackpressurePolicy {
(self.buffer_size as u64 * self.low_watermark as u64 / 100) as usize
}
/// Convert the facade policy into the reusable io-core admission-pressure config.
/// Convert the policy into the reusable io-core admission-pressure config.
///
/// The concurrency layer still owns duplex buffer sizing, but the shared
/// The caller still owns duplex buffer sizing, but the shared
/// overload/admission primitive lives in `io-core`.
pub fn to_core_config(&self) -> CoreBackpressureConfig {
CoreBackpressureConfig {
@@ -69,157 +67,28 @@ impl PipeBackpressurePolicy {
}
}
/// Backpressure manager
pub struct BackpressureManager {
config: PipeBackpressurePolicy,
core_config: CoreBackpressureConfig,
monitor: Arc<CoreBackpressureMonitor>,
}
impl BackpressureManager {
/// Create a new backpressure manager
pub fn new(buffer_size: usize, high_watermark: u32, low_watermark: u32) -> Self {
Self::from_policy(PipeBackpressurePolicy {
buffer_size,
high_watermark,
low_watermark,
})
}
/// Create a new backpressure manager from the facade policy type.
pub fn from_policy(config: PipeBackpressurePolicy) -> Self {
let core_config = config.to_core_config();
Self {
config,
core_config: core_config.clone(),
monitor: Arc::new(CoreBackpressureMonitor::new(core_config)),
}
}
/// Get the configuration
pub fn config(&self) -> &PipeBackpressurePolicy {
&self.config
}
/// Get the derived io-core admission-pressure configuration.
pub fn core_config(&self) -> &CoreBackpressureConfig {
&self.core_config
}
/// Get the monitor
pub fn monitor(&self) -> Arc<CoreBackpressureMonitor> {
self.monitor.clone()
}
/// Create a backpressure pipe
pub fn create_pipe(&self) -> BackpressurePipe {
BackpressurePipe::new(self.config, self.monitor.clone())
}
/// Get current state
pub fn state(&self) -> BackpressureState {
self.monitor.state()
}
/// Check if backpressure is active
pub fn is_active(&self) -> bool {
self.monitor.is_active()
}
}
/// Backpressure pipe wrapping tokio's duplex
pub struct BackpressurePipe {
reader: DuplexStream,
writer: DuplexStream,
config: PipeBackpressurePolicy,
monitor: Arc<CoreBackpressureMonitor>,
created_at: Instant,
}
/// Shared pipe metadata snapshot for facade-level backpressure pipes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BackpressurePipeMeta {
/// Configured duplex buffer capacity in bytes.
pub buffer_capacity: usize,
/// Current backpressure state reported by the shared core monitor.
pub state: BackpressureState,
/// Age of the pipe since creation.
pub age: std::time::Duration,
}
impl BackpressurePipe {
fn new(config: PipeBackpressurePolicy, monitor: Arc<CoreBackpressureMonitor>) -> Self {
let (reader, writer) = duplex(config.buffer_size);
Self {
reader,
writer,
config,
monitor,
created_at: Instant::now(),
}
}
/// Get the reader end
pub fn reader(&mut self) -> &mut DuplexStream {
&mut self.reader
}
/// Get the writer end
pub fn writer(&mut self) -> &mut DuplexStream {
&mut self.writer
}
/// Split into reader and writer
pub fn into_split(self) -> (DuplexStream, DuplexStream) {
(self.reader, self.writer)
}
/// Get the configuration
pub fn config(&self) -> &PipeBackpressurePolicy {
&self.config
}
/// Get current state
pub fn state(&self) -> BackpressureState {
self.monitor.state()
}
/// Get the age of this pipe
pub fn age(&self) -> std::time::Duration {
self.created_at.elapsed()
}
/// Get a compact metadata snapshot for the pipe.
pub fn meta(&self) -> BackpressurePipeMeta {
BackpressurePipeMeta {
buffer_capacity: self.config.buffer_size,
state: self.state(),
age: self.age(),
}
}
/// Check if should apply backpressure
pub fn should_apply_backpressure(&self) -> bool {
let should = self.monitor.should_apply_backpressure();
if should {
backpressure_metrics::record_backpressure_activation();
}
should
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_backpressure_config() {
fn test_backpressure_policy_defaults() {
let config = PipeBackpressurePolicy::default();
assert_eq!(config.buffer_size, 4 * 1024 * 1024);
assert!(config.high_watermark > config.low_watermark);
}
#[test]
fn test_backpressure_policy_watermark_bytes() {
let config = PipeBackpressurePolicy {
buffer_size: 1000,
high_watermark: 80,
low_watermark: 50,
};
assert_eq!(config.high_watermark_bytes(), 800);
assert_eq!(config.low_watermark_bytes(), 500);
}
#[test]
fn test_backpressure_policy_to_core_config() {
let policy = PipeBackpressurePolicy::default();
@@ -228,33 +97,4 @@ mod tests {
assert_eq!(core.low_water_mark, policy.low_watermark as f64 / 100.0);
assert!(core.enabled);
}
#[test]
fn test_backpressure_manager() {
let manager = BackpressureManager::new(1024, 80, 50);
assert_eq!(manager.state(), BackpressureState::Normal);
}
#[test]
fn test_backpressure_pipe() {
let manager = BackpressureManager::new(1024, 80, 50);
let pipe = manager.create_pipe();
assert_eq!(pipe.state(), BackpressureState::Normal);
assert_eq!(pipe.meta().buffer_capacity, 1024);
}
#[test]
fn test_backpressure_pipe_meta_is_read_only() {
let manager = BackpressureManager::new(2048, 80, 50);
let pipe = manager.create_pipe();
let first = pipe.meta();
let second = pipe.meta();
assert_eq!(first.buffer_capacity, 2048);
assert_eq!(first.state, BackpressureState::Normal);
assert_eq!(second.buffer_capacity, first.buffer_capacity);
assert_eq!(second.state, first.state);
assert!(second.age >= first.age);
assert_eq!(manager.state(), BackpressureState::Normal);
}
}
-242
View File
@@ -1,242 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Configuration for concurrency management
use crate::{
backpressure::PipeBackpressurePolicy, deadlock::DeadlockMonitorPolicy, scheduler::SchedulerPolicy,
timeout::TimeoutManagerPolicy,
};
use std::time::Duration;
/// Feature flags for concurrency modules
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ConcurrencyFeatures {
/// Enable timeout control
pub timeout: bool,
/// Enable lock optimization
pub lock: bool,
/// Enable deadlock detection
pub deadlock: bool,
/// Enable backpressure management
pub backpressure: bool,
/// Enable I/O scheduling
pub scheduler: bool,
}
impl Default for ConcurrencyFeatures {
fn default() -> Self {
Self {
timeout: cfg!(feature = "timeout"),
lock: cfg!(feature = "lock"),
deadlock: cfg!(feature = "deadlock"),
backpressure: cfg!(feature = "backpressure"),
scheduler: cfg!(feature = "scheduler"),
}
}
}
impl ConcurrencyFeatures {
/// Create with all features enabled
pub fn all() -> Self {
Self {
timeout: true,
lock: true,
deadlock: true,
backpressure: true,
scheduler: true,
}
}
/// Create with no features enabled
pub fn none() -> Self {
Self {
timeout: false,
lock: false,
deadlock: false,
backpressure: false,
scheduler: false,
}
}
/// Check if any feature is enabled
pub fn any_enabled(&self) -> bool {
self.timeout || self.lock || self.deadlock || self.backpressure || self.scheduler
}
}
/// Facade policy for lock manager behavior.
#[derive(Debug, Clone, Copy)]
pub struct LockManagerPolicy {
/// Enable lock optimization.
pub enabled: bool,
/// Lock acquisition timeout.
pub acquire_timeout: Duration,
}
impl Default for LockManagerPolicy {
fn default() -> Self {
Self {
enabled: true,
acquire_timeout: Duration::from_secs(5),
}
}
}
/// Main configuration for concurrency management
#[derive(Debug, Clone, Default)]
pub struct ConcurrencyConfig {
/// Feature flags
pub features: ConcurrencyFeatures,
/// Timeout facade policy.
pub timeout_policy: TimeoutManagerPolicy,
/// Lock facade policy.
pub lock_policy: LockManagerPolicy,
/// Deadlock facade policy.
pub deadlock_policy: DeadlockMonitorPolicy,
/// Backpressure facade policy.
pub backpressure_policy: PipeBackpressurePolicy,
/// Scheduler facade policy.
pub scheduler_policy: SchedulerPolicy,
}
impl ConcurrencyConfig {
/// Create configuration from environment variables
pub fn from_env() -> Self {
let mut config = Self::default();
// Read from environment if available
if let Ok(val) = std::env::var("RUSTFS_TIMEOUT_DEFAULT")
&& let Ok(secs) = val.parse::<u64>()
{
config.timeout_policy.default_timeout = Duration::from_secs(secs);
}
if let Ok(val) = std::env::var("RUSTFS_TIMEOUT_MAX")
&& let Ok(secs) = val.parse::<u64>()
{
config.timeout_policy.max_timeout = Duration::from_secs(secs);
}
if let Ok(val) = std::env::var("RUSTFS_BACKPRESSURE_BUFFER_SIZE")
&& let Ok(size) = val.parse::<usize>()
{
config.backpressure_policy.buffer_size = size;
}
if let Ok(val) = std::env::var("RUSTFS_IO_BUFFER_SIZE")
&& let Ok(size) = val.parse::<usize>()
{
config.scheduler_policy.base_buffer_size = size;
}
config
}
/// Validate configuration
pub fn validate(&self) -> Result<(), ConfigError> {
if self.timeout_policy.default_timeout > self.timeout_policy.max_timeout {
return Err(ConfigError::InvalidTimeout("default_timeout cannot exceed max_timeout".to_string()));
}
if self.timeout_policy.min_timeout > self.timeout_policy.max_timeout {
return Err(ConfigError::InvalidTimeout("min_timeout cannot exceed max_timeout".to_string()));
}
if self.backpressure_policy.high_watermark <= self.backpressure_policy.low_watermark
|| self.backpressure_policy.high_watermark > 100
{
return Err(ConfigError::InvalidBackpressure(
"high_watermark must be > low_watermark and <= 100".to_string(),
));
}
if self.scheduler_policy.base_buffer_size > self.scheduler_policy.max_buffer_size {
return Err(ConfigError::InvalidScheduler(
"base_buffer_size cannot exceed max_buffer_size".to_string(),
));
}
Ok(())
}
}
/// Configuration error
#[allow(clippy::enum_variant_names)]
#[derive(Debug, Clone, thiserror::Error)]
pub enum ConfigError {
/// Invalid timeout configuration
#[error("Invalid timeout config: {0}")]
InvalidTimeout(String),
/// Invalid backpressure configuration
#[error("Invalid backpressure config: {0}")]
InvalidBackpressure(String),
/// Invalid scheduler configuration
#[error("Invalid scheduler config: {0}")]
InvalidScheduler(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = ConcurrencyConfig::default();
assert!(config.validate().is_ok());
}
#[test]
fn test_invalid_timeout() {
let config = ConcurrencyConfig {
timeout_policy: TimeoutManagerPolicy {
default_timeout: Duration::from_secs(100),
max_timeout: Duration::from_secs(50),
enable_dynamic: true,
..Default::default()
},
..Default::default()
};
assert!(
config.validate().is_err(),
"validate() should return an error when default_timeout > max_timeout"
);
}
#[test]
fn test_invalid_min_timeout() {
let config = ConcurrencyConfig {
timeout_policy: TimeoutManagerPolicy {
min_timeout: Duration::from_secs(100),
max_timeout: Duration::from_secs(50),
..Default::default()
},
..Default::default()
};
assert!(
config.validate().is_err(),
"validate() should return an error when min_timeout > max_timeout"
);
}
#[test]
fn test_features() {
let features = ConcurrencyFeatures::all();
assert!(features.any_enabled());
let features = ConcurrencyFeatures::none();
assert!(!features.any_enabled());
}
}
+12 -191
View File
@@ -12,15 +12,16 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! Deadlock detection management
//! Shared deadlock-monitor policy type.
//!
//! The runtime request-hang / deadlock detection loop lives in
//! `rustfs/src/storage/deadlock_detector.rs`; this module only carries the
//! monitor policy type that implementation shares.
use rustfs_io_core::{DeadlockDetector as CoreDeadlockDetector, DeadlockDetectorConfig as CoreDeadlockConfig, LockType};
use rustfs_io_metrics::deadlock_metrics;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use rustfs_io_core::DeadlockDetectorConfig as CoreDeadlockConfig;
use std::time::Duration;
/// Facade policy for the concurrency-layer deadlock monitor.
/// Policy for the request-hang deadlock monitor.
#[derive(Debug, Clone, Copy)]
pub struct DeadlockMonitorPolicy {
/// Enable deadlock detection
@@ -42,7 +43,7 @@ impl Default for DeadlockMonitorPolicy {
}
impl DeadlockMonitorPolicy {
/// Convert the facade policy into the reusable io-core deadlock config.
/// Convert the policy into the reusable io-core deadlock config.
pub fn to_core_config(&self) -> CoreDeadlockConfig {
CoreDeadlockConfig {
enabled: self.enabled,
@@ -52,182 +53,14 @@ impl DeadlockMonitorPolicy {
}
}
/// Deadlock manager
pub struct DeadlockManager {
config: DeadlockMonitorPolicy,
detector: Arc<CoreDeadlockDetector>,
running: Arc<tokio::sync::Mutex<bool>>,
}
impl DeadlockManager {
/// Create a new deadlock manager
pub fn new(enabled: bool, check_interval: Duration, hang_threshold: Duration) -> Self {
Self::from_policy(DeadlockMonitorPolicy {
enabled,
check_interval,
hang_threshold,
})
}
/// Create a new deadlock manager from the facade policy type.
pub fn from_policy(config: DeadlockMonitorPolicy) -> Self {
let core_config = config.to_core_config();
Self {
config,
detector: Arc::new(CoreDeadlockDetector::new(core_config)),
running: Arc::new(tokio::sync::Mutex::new(false)),
}
}
/// Get the configuration
pub fn config(&self) -> &DeadlockMonitorPolicy {
&self.config
}
/// Get the core detector
pub fn detector(&self) -> Arc<CoreDeadlockDetector> {
self.detector.clone()
}
/// Start the deadlock detection background task
pub async fn start(&self) {
if !self.config.enabled {
return;
}
let mut running = self.running.lock().await;
if *running {
return;
}
*running = true;
drop(running);
tracing::info!(
event = "deadlock_monitor.lifecycle",
component = "concurrency",
subsystem = "deadlock",
state = "started",
check_interval_ms = self.config.check_interval.as_millis(),
hang_threshold_ms = self.config.hang_threshold.as_millis(),
"deadlock monitor state changed"
);
}
/// Stop the deadlock detection
pub async fn stop(&self) {
let mut running = self.running.lock().await;
*running = false;
tracing::info!(
event = "deadlock_monitor.lifecycle",
component = "concurrency",
subsystem = "deadlock",
state = "stopped",
check_interval_ms = self.config.check_interval.as_millis(),
hang_threshold_ms = self.config.hang_threshold.as_millis(),
"deadlock monitor state changed"
);
}
/// Create a request tracker
pub fn track_request(&self, request_id: String, description: String) -> RequestTracker {
RequestTracker::new(request_id, description, self.detector.clone())
}
/// Register a lock
pub fn register_lock(&self, lock_type: LockType) -> u64 {
self.detector.register_lock(lock_type)
}
/// Unregister a lock
pub fn unregister_lock(&self, lock_id: u64) {
self.detector.unregister_lock(lock_id);
}
/// Detect deadlock
pub fn detect_deadlock(&self) -> Option<Vec<u64>> {
let result = self.detector.detect_deadlock();
if let Some(ref cycle) = result {
deadlock_metrics::record_deadlock_detected(cycle.len());
}
result
}
}
/// Lightweight compatibility wrapper for request-scoped deadlock bookkeeping.
///
/// This type intentionally stays minimal in the concurrency layer. Rich
/// request-level lock/resource diagnostics belong to
/// `rustfs::storage::deadlock_detector::RequestResourceTracker`.
pub struct RequestTracker {
request_id: String,
description: String,
start_time: Instant,
resources: HashMap<String, Vec<String>>,
detector: Arc<CoreDeadlockDetector>,
}
impl RequestTracker {
fn new(request_id: String, description: String, detector: Arc<CoreDeadlockDetector>) -> Self {
let start_time = Instant::now();
detector.register_request(&request_id, 1); // Use placeholder thread ID
Self {
request_id,
description,
start_time,
resources: HashMap::new(),
detector,
}
}
/// Get the request ID
pub fn request_id(&self) -> &str {
&self.request_id
}
/// Get the description
pub fn description(&self) -> &str {
&self.description
}
/// Get the elapsed time
pub fn elapsed(&self) -> Duration {
self.start_time.elapsed()
}
/// Record a lock acquisition
pub fn record_lock_acquire(&mut self, lock_id: u64, resource: String) {
self.resources.entry("locks".to_string()).or_default().push(resource);
self.detector.record_acquire(lock_id, 1); // Use placeholder thread ID
deadlock_metrics::record_lock_acquisition("read");
}
/// Return a read-only view of tracked resource names.
pub fn resources(&self) -> &HashMap<String, Vec<String>> {
&self.resources
}
/// Record a lock release
pub fn record_lock_release(&mut self, lock_id: u64) {
self.detector.record_release(lock_id);
}
}
impl Drop for RequestTracker {
fn drop(&mut self) {
self.detector.unregister_request(&self.request_id);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deadlock_manager_creation() {
let manager = DeadlockManager::new(false, Duration::from_secs(10), Duration::from_secs(60));
assert!(!manager.config().enabled);
fn test_deadlock_policy_defaults_disabled() {
let policy = DeadlockMonitorPolicy::default();
assert!(!policy.enabled);
}
#[test]
@@ -238,16 +71,4 @@ mod tests {
assert_eq!(core.detection_interval, policy.check_interval);
assert_eq!(core.max_hold_time, policy.hang_threshold);
}
#[tokio::test]
async fn test_request_tracker() {
let manager = DeadlockManager::new(true, Duration::from_secs(10), Duration::from_secs(60));
let mut tracker = manager.track_request("req-1".to_string(), "test request".to_string());
let lock_id = manager.register_lock(LockType::Mutex);
tracker.record_lock_acquire(lock_id, "bucket/key".to_string());
assert_eq!(tracker.request_id(), "req-1");
assert_eq!(tracker.description(), "test request");
assert_eq!(tracker.resources().get("locks").map(Vec::len), Some(1));
}
}
+21 -148
View File
@@ -12,167 +12,40 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! # RustFS Concurrency Management
//! # RustFS Concurrency Contracts
//!
//! This crate provides comprehensive concurrency management for RustFS,
//! including timeout control, lock optimization, deadlock detection,
//! backpressure management, and I/O scheduling.
//! Shared concurrency contract types for RustFS:
//!
//! ## Features
//! - [`workload`]: workload admission snapshot/contract types consumed by
//! ecstore, heal, and the server for admission-aware throttling.
//! - [`GetObjectQueueSnapshot`]: disk permit queue usage snapshot for
//! GetObject orchestration.
//! - [`workers`]: a bounded semaphore-backed worker-slot pool.
//! - [`PipeBackpressurePolicy`] / [`DeadlockMonitorPolicy`]: policy types
//! shared with the runtime implementations.
//!
//! All features are controlled by feature flags and can be enabled/disabled at compile time:
//!
//! - **timeout**: Dynamic timeout calculation based on data size and transfer rate
//! - **lock**: Early lock release to reduce contention
//! - **deadlock**: Request tracking and cycle detection
//! - **backpressure**: Buffer-based flow control
//! - **scheduler**: Adaptive buffer sizing and priority queuing
//!
//! ## Architecture
//!
//! ```text
//! rustfs-concurrency (Business Layer)
//! ├── timeout (Timeout Control)
//! ├── lock (Lock Optimization)
//! ├── deadlock (Deadlock Detection)
//! ├── backpressure (Backpressure Management)
//! └── scheduler (I/O Scheduling)
//! │
//! ├── rustfs-io-core (Core Algorithms)
//! └── rustfs-io-metrics (Metrics Collection)
//! ```
//!
//! ## Usage
//!
//! ```rust,no_run
//! use rustfs_concurrency::{ConcurrencyConfig, ConcurrencyManager};
//!
//! # #[tokio::main]
//! # async fn main() {
//! // Create manager with all features enabled
//! let config = ConcurrencyConfig::default();
//! let manager = ConcurrencyManager::new(config);
//!
//! // Start services
//! manager.start().await;
//!
//! // Use timeout control (if enabled)
//! if manager.is_timeout_enabled() {
//! let timeout_manager = manager.timeout();
//! let _ = timeout_manager;
//! }
//!
//! // Use lock optimization (if enabled)
//! if manager.is_lock_enabled() {
//! let lock_manager = manager.lock();
//! let _ = lock_manager;
//! }
//!
//! // Stop services
//! manager.stop().await;
//! # }
//! ```
//! The actual runtime concurrency control — size-aware timeouts, byte-watermark
//! backpressure, request-hang/deadlock detection, and I/O scheduling — is
//! implemented in `rustfs/src/storage/*` on top of the `rustfs-io-core`
//! primitives. This crate only carries the data and contract types those
//! implementations share; it does not run any background tasks itself.
#![deny(missing_docs)]
#![deny(unsafe_code)]
#![cfg_attr(docsrs, feature(doc_cfg))]
// Re-export core types from io-core
pub use rustfs_io_core::{
// Backpressure types
BackpressureConfig as CoreBackpressureConfig,
BackpressureMonitor as CoreBackpressureMonitor,
BackpressureState,
// Re-exported io-core type used by downstream timeout implementations.
pub use rustfs_io_core::OperationProgress;
// Deadlock types
DeadlockDetector as CoreDeadlockDetector,
IoLoadLevel,
IoLoadMetrics,
IoPriority,
// Scheduler types
IoScheduler,
IoSchedulingContext,
LockInfo,
LockOptimizer as CoreLockOptimizer,
// Lock types
LockStats as CoreLockStats,
LockType,
// Timeout types
OperationProgress,
TimeoutError,
TimeoutStats,
WaitGraphEdge,
calculate_adaptive_timeout,
estimate_bytes_per_second,
};
// Module declarations with feature gates
#[cfg(feature = "timeout")]
mod timeout;
#[cfg(feature = "lock")]
mod lock;
#[cfg(feature = "deadlock")]
mod deadlock;
#[cfg(feature = "backpressure")]
mod backpressure;
#[cfg(feature = "scheduler")]
mod scheduler;
mod deadlock;
mod queue;
pub mod workers;
pub mod workload;
// Public module exports with feature gates
#[cfg(feature = "timeout")]
pub use timeout::{TimeoutGuard, TimeoutManager, TimeoutManagerPolicy};
#[cfg(feature = "lock")]
pub use lock::{LockConfig, LockManager, LockScopeGuard, OptimizedLockGuard};
#[cfg(feature = "deadlock")]
pub use deadlock::{DeadlockManager, DeadlockMonitorPolicy, RequestTracker};
#[cfg(feature = "backpressure")]
pub use backpressure::{BackpressureManager, BackpressurePipe, PipeBackpressurePolicy};
#[cfg(feature = "scheduler")]
pub use scheduler::{IoStrategy, SchedulerManager, SchedulerPolicy};
// Configuration
mod config;
pub use config::{ConcurrencyConfig, ConcurrencyFeatures};
// Manager
mod manager;
pub use manager::{ConcurrencyManager, GetObjectQueueSnapshot};
pub use backpressure::PipeBackpressurePolicy;
pub use deadlock::DeadlockMonitorPolicy;
pub use queue::GetObjectQueueSnapshot;
pub use workload::{
AdmissionState, WorkloadAdmissionRegistrySnapshot, WorkloadAdmissionSnapshot, WorkloadAdmissionSnapshotProvider,
WorkloadClass,
};
// Prelude for convenient imports
pub mod prelude {
//! Prelude module for convenient imports
#[cfg(feature = "timeout")]
pub use crate::timeout::{TimeoutGuard, TimeoutManager, TimeoutManagerPolicy};
#[cfg(feature = "lock")]
pub use crate::lock::{LockConfig, LockManager, LockScopeGuard, OptimizedLockGuard};
#[cfg(feature = "deadlock")]
pub use crate::deadlock::{DeadlockManager, DeadlockMonitorPolicy, RequestTracker};
#[cfg(feature = "backpressure")]
pub use crate::backpressure::{BackpressureManager, BackpressurePipe, PipeBackpressurePolicy};
#[cfg(feature = "scheduler")]
pub use crate::scheduler::{IoStrategy, SchedulerManager, SchedulerPolicy};
pub use crate::{AdmissionState, ConcurrencyConfig, ConcurrencyFeatures, ConcurrencyManager, WorkloadAdmissionSnapshot};
}
-227
View File
@@ -1,227 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Lock optimization management
use rustfs_io_core::{LockOptimizer as CoreLockOptimizer, LockStats};
use rustfs_io_metrics::lock_metrics;
use std::sync::Arc;
use std::time::{Duration, Instant};
/// Lock configuration
#[derive(Debug, Clone)]
pub struct LockConfig {
/// Enable lock optimization
pub enabled: bool,
/// Lock acquisition timeout
pub acquire_timeout: Duration,
}
impl Default for LockConfig {
fn default() -> Self {
Self {
enabled: true,
acquire_timeout: Duration::from_secs(5),
}
}
}
/// Lock manager
pub struct LockManager {
config: LockConfig,
optimizer: Arc<CoreLockOptimizer>,
}
impl LockManager {
/// Create a new lock manager
pub fn new(enabled: bool, acquire_timeout: Duration) -> Self {
let config = LockConfig {
enabled,
acquire_timeout,
};
let core_config = rustfs_io_core::LockOptimizeConfig {
enabled,
acquire_timeout,
max_hold_time_warning: Duration::from_millis(100),
adaptive_spin: true,
max_spin_iterations: 1000,
};
Self {
config,
optimizer: Arc::new(CoreLockOptimizer::new(core_config)),
}
}
/// Get the configuration
pub fn config(&self) -> &LockConfig {
&self.config
}
/// Get the core optimizer
pub fn optimizer(&self) -> Arc<CoreLockOptimizer> {
self.optimizer.clone()
}
/// Get lock statistics
pub fn stats(&self) -> &LockStats {
self.optimizer.stats()
}
/// Optimize a lock guard
pub fn optimize<G>(&self, guard: G, resource: impl Into<String>) -> OptimizedLockGuard<G> {
OptimizedLockGuard::new(guard, resource, self.optimizer.clone())
}
/// Check if optimization is enabled
pub fn is_enabled(&self) -> bool {
self.config.enabled
}
}
/// Optimized lock guard with early release support
pub struct OptimizedLockGuard<G> {
guard: Option<G>,
acquire_time: Instant,
released: bool,
resource: String,
optimizer: Arc<CoreLockOptimizer>,
}
impl<G> OptimizedLockGuard<G> {
fn new(guard: G, resource: impl Into<String>, optimizer: Arc<CoreLockOptimizer>) -> Self {
optimizer.on_acquire();
lock_metrics::record_lock_optimization_enabled(optimizer.config().enabled);
Self {
guard: Some(guard),
acquire_time: Instant::now(),
released: false,
resource: resource.into(),
optimizer,
}
}
/// Get the lock hold time
pub fn hold_time(&self) -> Duration {
self.acquire_time.elapsed()
}
/// Check if the lock has been released
pub fn is_released(&self) -> bool {
self.released
}
/// Release the lock early
pub fn early_release(&mut self) {
if self.released {
return;
}
let hold_time = self.hold_time();
self.guard.take();
self.released = true;
self.optimizer.on_release(hold_time);
lock_metrics::record_lock_hold_time(hold_time);
tracing::debug!(
event = "lock_guard.release",
component = "concurrency",
subsystem = "lock",
release_mode = "early",
resource = %self.resource,
hold_time_ms = hold_time.as_millis(),
"lock guard released"
);
}
/// Get a reference to the underlying guard
pub fn as_ref(&self) -> Option<&G> {
if self.released { None } else { self.guard.as_ref() }
}
}
impl<G> Drop for OptimizedLockGuard<G> {
fn drop(&mut self) {
if !self.released {
let hold_time = self.hold_time();
self.guard.take();
self.released = true;
self.optimizer.on_release(hold_time);
lock_metrics::record_lock_hold_time(hold_time);
tracing::debug!(
event = "lock_guard.release",
component = "concurrency",
subsystem = "lock",
release_mode = "drop",
resource = %self.resource,
hold_time_ms = hold_time.as_millis(),
"lock guard released"
);
}
}
}
/// Lock scope guard for RAII semantics
pub struct LockScopeGuard<G> {
guard: Option<G>,
}
impl<G> LockScopeGuard<G> {
/// Create a new scope guard
pub fn new(guard: G) -> Self {
Self { guard: Some(guard) }
}
/// Get a reference to the guard
pub fn as_ref(&self) -> Option<&G> {
self.guard.as_ref()
}
}
impl<G> Drop for LockScopeGuard<G> {
fn drop(&mut self) {
self.guard.take();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[test]
fn test_lock_manager_creation() {
let manager = LockManager::new(true, Duration::from_secs(5));
assert!(manager.is_enabled());
}
#[test]
fn test_optimized_lock_guard() {
let manager = LockManager::new(true, Duration::from_secs(5));
let mutex = Mutex::new(42);
let guard = mutex.lock().unwrap();
let optimized = manager.optimize(guard, "test_resource");
assert!(!optimized.is_released());
let mut optimized = optimized;
optimized.early_release();
assert!(optimized.is_released());
}
}
-397
View File
@@ -1,397 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Main concurrency manager
use crate::config::{ConcurrencyConfig, ConfigError};
use std::sync::Arc;
/// Snapshot of disk permit queue usage for GetObject orchestration.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GetObjectQueueSnapshot {
/// Total permits configured for disk reads.
pub total_permits: usize,
/// Permits currently in use.
pub permits_in_use: usize,
}
impl GetObjectQueueSnapshot {
/// Create a queue snapshot from total and available permits.
pub fn from_available_permits(total_permits: usize, available_permits: usize) -> Self {
Self {
total_permits,
permits_in_use: total_permits.saturating_sub(available_permits),
}
}
/// Return currently available permits.
pub fn permits_available(&self) -> usize {
self.total_permits.saturating_sub(self.permits_in_use)
}
/// Return queue utilization percentage in the 0-100 range.
pub fn utilization_percent(&self) -> f64 {
if self.total_permits == 0 {
0.0
} else {
(self.permits_in_use as f64 / self.total_permits as f64) * 100.0
}
}
/// Return whether the queue is considered congested.
pub fn is_congested(&self, threshold_percent: f64) -> bool {
self.utilization_percent() > threshold_percent
}
}
/// Main concurrency manager that provides access to all concurrency features
pub struct ConcurrencyManager {
config: ConcurrencyConfig,
#[cfg(feature = "timeout")]
timeout: Arc<crate::timeout::TimeoutManager>,
#[cfg(feature = "lock")]
lock: Arc<crate::lock::LockManager>,
#[cfg(feature = "deadlock")]
deadlock: Arc<crate::deadlock::DeadlockManager>,
#[cfg(feature = "backpressure")]
backpressure: Arc<crate::backpressure::BackpressureManager>,
#[cfg(feature = "scheduler")]
scheduler: Arc<crate::scheduler::SchedulerManager>,
}
impl ConcurrencyManager {
fn build(config: ConcurrencyConfig) -> Self {
Self {
#[cfg(feature = "timeout")]
timeout: Arc::new(crate::timeout::TimeoutManager::from_policy(config.timeout_policy)),
#[cfg(feature = "lock")]
lock: Arc::new(crate::lock::LockManager::new(
config.lock_policy.enabled,
config.lock_policy.acquire_timeout,
)),
#[cfg(feature = "deadlock")]
deadlock: Arc::new(crate::deadlock::DeadlockManager::from_policy(config.deadlock_policy)),
#[cfg(feature = "backpressure")]
backpressure: Arc::new(crate::backpressure::BackpressureManager::from_policy(config.backpressure_policy)),
#[cfg(feature = "scheduler")]
scheduler: Arc::new(crate::scheduler::SchedulerManager::from_policy(config.scheduler_policy)),
config,
}
}
/// Try to create a new concurrency manager with the given configuration.
pub fn try_new(config: ConcurrencyConfig) -> Result<Self, ConfigError> {
config.validate()?;
Ok(Self::build(config))
}
/// Create a new concurrency manager with the given configuration.
///
/// Invalid configurations are downgraded to the default configuration instead of
/// panicking so startup/runtime callers can remain fail-safe in production paths.
pub fn new(config: ConcurrencyConfig) -> Self {
match Self::try_new(config) {
Ok(manager) => manager,
Err(err) => {
tracing::warn!(
event = "concurrency_manager.invalid_config_fallback",
component = "concurrency",
subsystem = "manager",
error = %err,
"Invalid concurrency configuration detected; falling back to defaults"
);
Self::build(ConcurrencyConfig::default())
}
}
}
/// Create with default configuration
pub fn with_defaults() -> Self {
Self::build(ConcurrencyConfig::default())
}
/// Try to create a manager from environment-derived configuration.
pub fn try_from_env() -> Result<Self, ConfigError> {
Self::try_new(ConcurrencyConfig::from_env())
}
/// Create from environment variables
pub fn from_env() -> Self {
match Self::try_from_env() {
Ok(manager) => manager,
Err(err) => {
tracing::warn!(
event = "concurrency_manager.invalid_env_fallback",
component = "concurrency",
subsystem = "manager",
error = %err,
"Invalid environment-derived concurrency configuration detected; falling back to defaults"
);
Self::build(ConcurrencyConfig::default())
}
}
}
/// Get the configuration
pub fn config(&self) -> &ConcurrencyConfig {
&self.config
}
// ============================================
// Feature enable checks
// ============================================
/// Check if timeout feature is enabled
pub fn is_timeout_enabled(&self) -> bool {
#[cfg(feature = "timeout")]
{
self.config.features.timeout
}
#[cfg(not(feature = "timeout"))]
{
false
}
}
/// Check if lock feature is enabled
pub fn is_lock_enabled(&self) -> bool {
#[cfg(feature = "lock")]
{
self.config.features.lock
}
#[cfg(not(feature = "lock"))]
{
false
}
}
/// Check if deadlock feature is enabled
pub fn is_deadlock_enabled(&self) -> bool {
#[cfg(feature = "deadlock")]
{
self.config.features.deadlock
}
#[cfg(not(feature = "deadlock"))]
{
false
}
}
/// Check if backpressure feature is enabled
pub fn is_backpressure_enabled(&self) -> bool {
#[cfg(feature = "backpressure")]
{
self.config.features.backpressure
}
#[cfg(not(feature = "backpressure"))]
{
false
}
}
/// Check if scheduler feature is enabled
pub fn is_scheduler_enabled(&self) -> bool {
#[cfg(feature = "scheduler")]
{
self.config.features.scheduler
}
#[cfg(not(feature = "scheduler"))]
{
false
}
}
// ============================================
// Feature accessors
// ============================================
/// Get timeout manager
#[cfg(feature = "timeout")]
pub fn timeout(&self) -> Arc<crate::timeout::TimeoutManager> {
self.timeout.clone()
}
/// Get lock manager
#[cfg(feature = "lock")]
pub fn lock(&self) -> Arc<crate::lock::LockManager> {
self.lock.clone()
}
/// Get deadlock manager
#[cfg(feature = "deadlock")]
pub fn deadlock(&self) -> Arc<crate::deadlock::DeadlockManager> {
self.deadlock.clone()
}
/// Get backpressure manager
#[cfg(feature = "backpressure")]
pub fn backpressure(&self) -> Arc<crate::backpressure::BackpressureManager> {
self.backpressure.clone()
}
/// Get scheduler manager
#[cfg(feature = "scheduler")]
pub fn scheduler(&self) -> Arc<crate::scheduler::SchedulerManager> {
self.scheduler.clone()
}
// ============================================
// Lifecycle management
// ============================================
/// Start all enabled services (e.g., deadlock detection background task)
pub async fn start(&self) {
#[cfg(feature = "deadlock")]
{
if self.config.deadlock_policy.enabled {
self.deadlock.start().await;
}
}
tracing::info!(
event = "concurrency_manager.lifecycle",
component = "concurrency",
subsystem = "manager",
state = "started",
timeout_enabled = self.is_timeout_enabled(),
lock_enabled = self.is_lock_enabled(),
deadlock_enabled = self.is_deadlock_enabled(),
backpressure_enabled = self.is_backpressure_enabled(),
scheduler_enabled = self.is_scheduler_enabled(),
"concurrency manager state changed"
);
}
/// Stop all services
pub async fn stop(&self) {
#[cfg(feature = "deadlock")]
{
self.deadlock.stop().await;
}
tracing::info!(
event = "concurrency_manager.lifecycle",
component = "concurrency",
subsystem = "manager",
state = "stopped",
"concurrency manager state changed"
);
}
}
impl Default for ConcurrencyManager {
fn default() -> Self {
Self::with_defaults()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_queue_snapshot() {
let snapshot = GetObjectQueueSnapshot::from_available_permits(64, 16);
assert_eq!(snapshot.permits_in_use, 48);
assert_eq!(snapshot.permits_available(), 16);
assert!(snapshot.is_congested(70.0));
}
#[test]
fn test_queue_snapshot_clamps_over_available_permits() {
let snapshot = GetObjectQueueSnapshot::from_available_permits(64, 96);
assert_eq!(snapshot.permits_in_use, 0);
assert_eq!(snapshot.permits_available(), 64);
assert_eq!(snapshot.utilization_percent(), 0.0);
assert!(!snapshot.is_congested(0.0));
}
#[test]
fn test_queue_snapshot_handles_zero_total_permits() {
let snapshot = GetObjectQueueSnapshot::from_available_permits(0, 0);
assert_eq!(snapshot.permits_in_use, 0);
assert_eq!(snapshot.permits_available(), 0);
assert_eq!(snapshot.utilization_percent(), 0.0);
assert!(!snapshot.is_congested(0.0));
}
#[test]
fn test_manager_creation() {
let manager = ConcurrencyManager::with_defaults();
assert!(manager.config().validate().is_ok());
}
#[test]
fn test_try_new_returns_error_for_invalid_config() {
let config = ConcurrencyConfig {
timeout_policy: crate::TimeoutManagerPolicy {
default_timeout: std::time::Duration::from_secs(10),
max_timeout: std::time::Duration::from_secs(1),
..Default::default()
},
..Default::default()
};
let result = ConcurrencyManager::try_new(config);
assert!(matches!(result, Err(ConfigError::InvalidTimeout(_))));
}
#[test]
fn test_new_falls_back_to_default_for_invalid_config() {
let config = ConcurrencyConfig {
timeout_policy: crate::TimeoutManagerPolicy {
default_timeout: std::time::Duration::from_secs(10),
max_timeout: std::time::Duration::from_secs(1),
..Default::default()
},
..Default::default()
};
let manager = ConcurrencyManager::new(config);
assert!(manager.config().validate().is_ok());
assert_eq!(
manager.config().timeout_policy.default_timeout,
ConcurrencyConfig::default().timeout_policy.default_timeout
);
}
#[tokio::test]
async fn test_manager_lifecycle() {
let manager = ConcurrencyManager::with_defaults();
manager.start().await;
manager.stop().await;
}
#[test]
fn test_feature_checks() {
let manager = ConcurrencyManager::with_defaults();
// These should return the feature flag status
let _ = manager.is_timeout_enabled();
let _ = manager.is_lock_enabled();
let _ = manager.is_deadlock_enabled();
let _ = manager.is_backpressure_enabled();
let _ = manager.is_scheduler_enabled();
}
}
+84
View File
@@ -0,0 +1,84 @@
// 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.
//! Disk permit queue snapshot for GetObject orchestration.
/// Snapshot of disk permit queue usage for GetObject orchestration.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GetObjectQueueSnapshot {
/// Total permits configured for disk reads.
pub total_permits: usize,
/// Permits currently in use.
pub permits_in_use: usize,
}
impl GetObjectQueueSnapshot {
/// Create a queue snapshot from total and available permits.
pub fn from_available_permits(total_permits: usize, available_permits: usize) -> Self {
Self {
total_permits,
permits_in_use: total_permits.saturating_sub(available_permits),
}
}
/// Return currently available permits.
pub fn permits_available(&self) -> usize {
self.total_permits.saturating_sub(self.permits_in_use)
}
/// Return queue utilization percentage in the 0-100 range.
pub fn utilization_percent(&self) -> f64 {
if self.total_permits == 0 {
0.0
} else {
(self.permits_in_use as f64 / self.total_permits as f64) * 100.0
}
}
/// Return whether the queue is considered congested.
pub fn is_congested(&self, threshold_percent: f64) -> bool {
self.utilization_percent() > threshold_percent
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_queue_snapshot() {
let snapshot = GetObjectQueueSnapshot::from_available_permits(64, 16);
assert_eq!(snapshot.permits_in_use, 48);
assert_eq!(snapshot.permits_available(), 16);
assert!(snapshot.is_congested(70.0));
}
#[test]
fn test_queue_snapshot_clamps_over_available_permits() {
let snapshot = GetObjectQueueSnapshot::from_available_permits(64, 96);
assert_eq!(snapshot.permits_in_use, 0);
assert_eq!(snapshot.permits_available(), 64);
assert_eq!(snapshot.utilization_percent(), 0.0);
assert!(!snapshot.is_congested(0.0));
}
#[test]
fn test_queue_snapshot_handles_zero_total_permits() {
let snapshot = GetObjectQueueSnapshot::from_available_permits(0, 0);
assert_eq!(snapshot.permits_in_use, 0);
assert_eq!(snapshot.permits_available(), 0);
assert_eq!(snapshot.utilization_percent(), 0.0);
assert!(!snapshot.is_congested(0.0));
}
}
-278
View File
@@ -1,278 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! I/O scheduler management
use rustfs_io_core::{
IoLoadLevel, IoPriority, IoScheduler as CoreIoScheduler, IoSchedulingContext,
io_profile::{AccessPattern, StorageMedia},
};
use rustfs_io_metrics::io_metrics;
use std::sync::Arc;
use std::time::Duration;
/// Facade policy for the concurrency-layer scheduler manager.
#[derive(Debug, Clone, Copy)]
pub struct SchedulerPolicy {
/// Base buffer size
pub base_buffer_size: usize,
/// Maximum buffer size
pub max_buffer_size: usize,
/// High priority threshold
pub high_priority_threshold: usize,
/// Low priority threshold
pub low_priority_threshold: usize,
}
impl Default for SchedulerPolicy {
fn default() -> Self {
Self {
base_buffer_size: 64 * 1024, // 64KB
max_buffer_size: 4 * 1024 * 1024, // 4MB
high_priority_threshold: 1024 * 1024, // 1MB
low_priority_threshold: 10 * 1024 * 1024, // 10MB
}
}
}
impl SchedulerPolicy {
/// Convert facade policy to io-core scheduler config.
pub fn to_core_config(&self) -> rustfs_io_core::IoSchedulerConfig {
rustfs_io_core::IoSchedulerConfig {
base_buffer_size: self.base_buffer_size,
max_buffer_size: self.max_buffer_size,
high_priority_size_threshold: self.high_priority_threshold,
low_priority_size_threshold: self.low_priority_threshold,
..rustfs_io_core::IoSchedulerConfig::default()
}
}
}
/// Scheduler manager
pub struct SchedulerManager {
config: SchedulerPolicy,
core_config: rustfs_io_core::IoSchedulerConfig,
scheduler: Arc<CoreIoScheduler>,
}
impl SchedulerManager {
/// Create a new scheduler manager
pub fn new(
base_buffer_size: usize,
max_buffer_size: usize,
high_priority_threshold: usize,
low_priority_threshold: usize,
) -> Self {
Self::from_policy(SchedulerPolicy {
base_buffer_size,
max_buffer_size,
high_priority_threshold,
low_priority_threshold,
})
}
/// Create a scheduler manager from facade policy.
pub fn from_policy(config: SchedulerPolicy) -> Self {
let core_config = config.to_core_config();
Self {
config,
core_config: core_config.clone(),
scheduler: Arc::new(CoreIoScheduler::new(core_config)),
}
}
/// Get the configuration
pub fn config(&self) -> &SchedulerPolicy {
&self.config
}
/// Get the derived io-core scheduler config.
pub fn core_config(&self) -> &rustfs_io_core::IoSchedulerConfig {
&self.core_config
}
/// Get the scheduler
pub fn scheduler(&self) -> Arc<CoreIoScheduler> {
self.scheduler.clone()
}
/// Create an I/O strategy
pub fn create_strategy(&self) -> IoStrategy {
IoStrategy::new(self.config, self.scheduler.clone())
}
/// Calculate buffer size
pub fn calculate_buffer_size(
&self,
file_size: i64,
media: StorageMedia,
pattern: AccessPattern,
load: IoLoadLevel,
concurrent: usize,
) -> usize {
let strategy = self.create_strategy();
strategy.calculate_buffer_size(file_size, media, pattern, load, concurrent)
}
/// Get I/O priority
pub fn get_priority(&self, size: i64) -> IoPriority {
IoPriority::from_size(size, self.config.high_priority_threshold, self.config.low_priority_threshold)
}
}
/// I/O strategy
pub struct IoStrategy {
config: SchedulerPolicy,
scheduler: Arc<CoreIoScheduler>,
}
impl IoStrategy {
fn new(config: SchedulerPolicy, scheduler: Arc<CoreIoScheduler>) -> Self {
Self { config, scheduler }
}
/// Calculate buffer size with multi-factor strategy
pub fn calculate_buffer_size(
&self,
file_size: i64,
media: StorageMedia,
pattern: AccessPattern,
load: IoLoadLevel,
concurrent: usize,
) -> usize {
// Create scheduling context
let _ctx = IoSchedulingContext::new(file_size, self.config.base_buffer_size)
.with_sequential(matches!(pattern, AccessPattern::Sequential))
.with_media(media);
// Get base buffer size from core scheduler
let permit_wait = Duration::from_millis(10); // Default wait time
let is_sequential = matches!(pattern, AccessPattern::Sequential);
let core_strategy = self.scheduler.calculate_strategy(file_size, permit_wait, is_sequential);
let base_size = core_strategy.buffer_size;
// Apply multi-factor adjustments
let adjusted_size = self.apply_adjustments(base_size, media, pattern, load, concurrent);
// Record metrics
io_metrics::record_io_scheduler_decision(adjusted_size, load.as_str(), pattern.as_str());
adjusted_size.min(self.config.max_buffer_size)
}
fn apply_adjustments(
&self,
base_size: usize,
media: StorageMedia,
pattern: AccessPattern,
load: IoLoadLevel,
concurrent: usize,
) -> usize {
let mut size = base_size;
// Media adjustment
size = match media {
StorageMedia::Nvme => (size as f64 * 1.5) as usize,
StorageMedia::Ssd => (size as f64 * 1.2) as usize,
StorageMedia::Hdd => size,
_ => size,
};
// Pattern adjustment
size = match pattern {
AccessPattern::Sequential => (size as f64 * 1.5) as usize,
AccessPattern::Random => (size as f64 * 0.5) as usize,
_ => size,
};
// Load adjustment
size = match load {
IoLoadLevel::Low => (size as f64 * 1.2) as usize,
IoLoadLevel::Medium => size,
IoLoadLevel::High => (size as f64 * 0.7) as usize,
IoLoadLevel::Critical => (size as f64 * 0.5) as usize,
};
// Concurrency adjustment
if concurrent > 10 {
size = (size as f64 * 0.8) as usize;
}
size
}
/// Get the configuration
pub fn config(&self) -> &SchedulerPolicy {
&self.config
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scheduler_config() {
let config = SchedulerPolicy::default();
assert!(config.base_buffer_size < config.max_buffer_size);
}
#[test]
fn test_scheduler_policy_to_core_config() {
let policy = SchedulerPolicy::default();
let core = policy.to_core_config();
assert_eq!(core.base_buffer_size, policy.base_buffer_size);
assert_eq!(core.max_buffer_size, policy.max_buffer_size);
assert_eq!(core.high_priority_size_threshold, policy.high_priority_threshold);
assert_eq!(core.low_priority_size_threshold, policy.low_priority_threshold);
}
#[test]
fn test_scheduler_default_thresholds_remain_stable() {
let policy = SchedulerPolicy::default();
assert_eq!(policy.base_buffer_size, 64 * 1024);
assert_eq!(policy.max_buffer_size, 4 * 1024 * 1024);
assert_eq!(policy.high_priority_threshold, 1024 * 1024);
assert_eq!(policy.low_priority_threshold, 10 * 1024 * 1024);
}
#[test]
fn test_scheduler_priority_boundaries_remain_stable() {
let policy = SchedulerPolicy::default();
let manager = SchedulerManager::from_policy(policy);
assert_eq!(manager.get_priority(1_048_575), IoPriority::High);
assert_eq!(manager.get_priority(1_048_576), IoPriority::Normal);
assert_eq!(manager.get_priority(10_485_760), IoPriority::Normal);
assert_eq!(manager.get_priority(10_485_761), IoPriority::Low);
}
#[test]
fn test_scheduler_manager() {
let manager = SchedulerManager::new(1024, 4096, 512, 2048);
let priority = manager.get_priority(100);
assert!(priority.is_high());
}
#[test]
fn test_io_strategy() {
let manager = SchedulerManager::new(1024, 4096, 512, 2048);
let strategy = manager.create_strategy();
let size = strategy.calculate_buffer_size(1024 * 1024, StorageMedia::Ssd, AccessPattern::Sequential, IoLoadLevel::Low, 1);
assert!(size > 0);
}
}
-222
View File
@@ -1,222 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Timeout management for operations
use rustfs_io_core::{TimeoutConfig as CoreTimeoutConfig, TimeoutError, calculate_adaptive_timeout};
use std::time::{Duration, Instant};
use tokio_util::sync::CancellationToken;
/// Facade policy for the concurrency-layer timeout manager.
#[derive(Debug, Clone, Copy)]
pub struct TimeoutManagerPolicy {
/// Default timeout duration
pub default_timeout: Duration,
/// Maximum timeout duration
pub max_timeout: Duration,
/// Minimum timeout floor (prevents dynamic calculation from going too low).
pub min_timeout: Duration,
/// Enable dynamic timeout calculation
pub enable_dynamic: bool,
}
impl Default for TimeoutManagerPolicy {
fn default() -> Self {
Self {
default_timeout: Duration::from_secs(30),
max_timeout: Duration::from_secs(300),
min_timeout: Duration::from_secs(5),
enable_dynamic: true,
}
}
}
impl TimeoutManagerPolicy {
/// Convert the facade policy into the reusable io-core timeout configuration.
///
/// This keeps the concurrency layer explicitly wired to the shared core
/// timeout primitives without changing the facade's public behavior.
pub fn to_core_config(&self) -> CoreTimeoutConfig {
CoreTimeoutConfig {
base_timeout: self.default_timeout,
timeout_per_mb: Duration::ZERO,
max_timeout: self.max_timeout,
min_timeout: self.min_timeout,
get_object_timeout: self.default_timeout,
put_object_timeout: self.max_timeout,
list_objects_timeout: self.default_timeout,
enable_dynamic_timeout: self.enable_dynamic,
}
}
}
/// Timeout manager
pub struct TimeoutManager {
config: TimeoutManagerPolicy,
core_config: CoreTimeoutConfig,
}
impl TimeoutManager {
/// Create a new timeout manager
pub fn new(default_timeout: Duration, max_timeout: Duration, enable_dynamic: bool) -> Self {
let min_timeout = default_timeout.min(max_timeout);
Self::from_policy(TimeoutManagerPolicy {
default_timeout,
max_timeout,
min_timeout,
enable_dynamic,
})
}
/// Create a new timeout manager from the facade policy type.
pub fn from_policy(config: TimeoutManagerPolicy) -> Self {
let config = TimeoutManagerPolicy {
// Guard clamp(min, max) from panic when callers provide an
// out-of-order policy (or very small max_timeout).
min_timeout: config.min_timeout.min(config.max_timeout),
..config
};
let core_config = config.to_core_config();
Self { config, core_config }
}
/// Get the configuration
pub fn config(&self) -> &TimeoutManagerPolicy {
&self.config
}
/// Get the derived io-core timeout configuration.
pub fn core_config(&self) -> &CoreTimeoutConfig {
&self.core_config
}
/// Calculate timeout for a given size
pub fn calculate_timeout(&self, size: u64, _history: &[Duration]) -> Duration {
if !self.config.enable_dynamic {
return self.config.default_timeout;
}
calculate_adaptive_timeout(self.core_config.base_timeout, None, 0, size)
.clamp(self.core_config.min_timeout, self.core_config.max_timeout)
}
/// Wrap an operation with timeout control
pub async fn wrap_operation<F, T, E>(&self, operation: F, timeout: Option<Duration>) -> Result<T, TimeoutError>
where
F: std::future::Future<Output = Result<T, E>>,
E: Into<TimeoutError>,
{
let timeout = timeout.unwrap_or(self.config.default_timeout);
match tokio::time::timeout(timeout, operation).await {
Ok(Ok(result)) => Ok(result),
Ok(Err(e)) => Err(e.into()),
Err(_) => Err(TimeoutError::TimedOut(timeout)),
}
}
/// Create a timeout guard for manual timeout control
pub fn create_guard(&self, timeout: Option<Duration>) -> TimeoutGuard {
TimeoutGuard::new(timeout.unwrap_or(self.core_config.base_timeout))
}
}
/// Timeout guard for manual timeout control
pub struct TimeoutGuard {
timeout: Duration,
start: Instant,
cancel_token: CancellationToken,
}
impl TimeoutGuard {
fn new(timeout: Duration) -> Self {
Self {
timeout,
start: Instant::now(),
cancel_token: CancellationToken::new(),
}
}
/// Check if timeout has elapsed
pub fn is_timed_out(&self) -> bool {
self.start.elapsed() > self.timeout
}
/// Get remaining time
pub fn remaining(&self) -> Duration {
self.timeout.saturating_sub(self.start.elapsed())
}
/// Get the cancellation token
pub fn cancel_token(&self) -> CancellationToken {
self.cancel_token.clone()
}
/// Cancel the operation
pub fn cancel(&self) {
self.cancel_token.cancel();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_timeout_config() {
let config = TimeoutManagerPolicy::default();
assert!(config.default_timeout < config.max_timeout);
}
#[test]
fn test_timeout_policy_to_core_config() {
let policy = TimeoutManagerPolicy::default();
let core = policy.to_core_config();
assert_eq!(core.base_timeout, policy.default_timeout);
assert_eq!(core.max_timeout, policy.max_timeout);
assert_eq!(core.min_timeout, policy.min_timeout);
assert_eq!(core.get_object_timeout, policy.default_timeout);
assert!(core.enable_dynamic_timeout);
}
#[test]
fn test_timeout_manager_new_sanitizes_min_timeout_with_small_max_timeout() {
let manager = TimeoutManager::new(Duration::from_secs(1), Duration::from_secs(1), true);
let timeout = manager.calculate_timeout(1024, &[]);
assert_eq!(timeout, Duration::from_secs(1));
}
#[test]
fn test_timeout_manager_from_policy_sanitizes_min_timeout() {
let manager = TimeoutManager::from_policy(TimeoutManagerPolicy {
default_timeout: Duration::from_secs(30),
max_timeout: Duration::from_secs(1),
min_timeout: Duration::from_secs(5),
enable_dynamic: true,
});
assert_eq!(manager.config().min_timeout, Duration::from_secs(1));
assert_eq!(manager.core_config().min_timeout, Duration::from_secs(1));
}
#[tokio::test]
async fn test_wrap_operation_success() {
let manager = TimeoutManager::new(Duration::from_secs(5), Duration::from_secs(10), true);
let result = manager.wrap_operation(async { Ok::<_, TimeoutError>(42) }, None).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), 42);
}
}
+111 -62
View File
@@ -15,14 +15,19 @@
//! Worker slot limiter used by long-running background workflows.
use std::sync::Arc;
use tokio::sync::{Mutex, Notify};
use tokio::sync::{Semaphore, watch};
use tracing::{debug, trace};
/// Cooperative worker-slot controller for async tasks.
///
/// Slot acquisition is backed by a fair FIFO [`Semaphore`], while drain
/// waiters observe the in-use count through a dedicated [`watch`] channel.
/// The two wakeup sources are deliberately separate so a drain waiter can
/// never consume a wakeup destined for a pending [`take`](Self::take).
pub struct Workers {
available: Mutex<usize>, // Available working slots
notify: Notify, // Used to notify waiting tasks
limit: usize, // Maximum number of concurrent jobs
semaphore: Semaphore, // Available working slots
in_use: watch::Sender<usize>, // Slots currently held; drain waiters watch it
limit: usize, // Maximum number of concurrent jobs
}
impl Workers {
@@ -31,86 +36,70 @@ impl Workers {
if n == 0 {
return Err("n must be > 0");
}
if n > Semaphore::MAX_PERMITS {
return Err("n exceeds the maximum supported number of slots");
}
Ok(Arc::new(Self {
available: Mutex::new(n),
notify: Notify::new(),
semaphore: Semaphore::new(n),
in_use: watch::Sender::new(0),
limit: n,
}))
}
/// Acquire a worker slot, waiting until one becomes available.
pub async fn take(&self) {
loop {
let mut available = self.available.lock().await;
if *available == 0 {
trace!(
event = "worker_slot.acquire",
component = "concurrency",
subsystem = "workers",
state = "waiting",
available_slots = *available,
total_slots = self.limit,
"worker slot pending"
);
drop(available);
self.notify.notified().await;
} else {
*available -= 1;
trace!(
event = "worker_slot.acquire",
component = "concurrency",
subsystem = "workers",
state = "granted",
available_slots = *available,
total_slots = self.limit,
permits_in_use = self.limit.saturating_sub(*available),
"worker slot updated"
);
break;
}
}
let permit = match self.semaphore.acquire().await {
Ok(permit) => permit,
// The semaphore is owned by `self` and never closed.
Err(_) => unreachable!("worker semaphore is never closed"),
};
permit.forget(); // returned manually via give()
self.in_use.send_modify(|held| *held += 1);
trace!(
event = "worker_slot.acquire",
component = "concurrency",
subsystem = "workers",
state = "granted",
available_slots = self.semaphore.available_permits(),
total_slots = self.limit,
permits_in_use = *self.in_use.borrow(),
"worker slot updated"
);
}
/// Release a worker slot.
///
/// A `give()` that is not paired with a prior [`take`](Self::take) is
/// clamped: it never inflates capacity beyond `limit`.
pub async fn give(&self) {
let mut available = self.available.lock().await;
*available = (*available).saturating_add(1).min(self.limit); // avoid over-release beyond limit
let mut released = false;
self.in_use.send_modify(|held| {
if *held > 0 {
*held -= 1;
released = true;
}
});
if released {
self.semaphore.add_permits(1);
}
trace!(
event = "worker_slot.release",
component = "concurrency",
subsystem = "workers",
state = "released",
available_slots = *available,
available_slots = self.semaphore.available_permits(),
total_slots = self.limit,
permits_in_use = self.limit.saturating_sub(*available),
permits_in_use = *self.in_use.borrow(),
"worker slot updated"
);
self.notify.notify_one(); // Notify a waiting task
}
/// Wait until all worker slots are released.
pub async fn wait(&self) {
loop {
{
let available = self.available.lock().await;
if *available == self.limit {
break;
}
trace!(
event = "worker_slot.wait",
component = "concurrency",
subsystem = "workers",
state = "waiting",
available_slots = *available,
total_slots = self.limit,
permits_in_use = self.limit.saturating_sub(*available),
"worker drain pending"
);
}
// Wait until all slots are freed
self.notify.notified().await;
}
let mut rx = self.in_use.subscribe();
// The sender is owned by `self`, so it outlives this borrow.
let _ = rx.wait_for(|&held| held == 0).await;
debug!(
event = "worker_slot.wait",
component = "concurrency",
@@ -125,7 +114,7 @@ impl Workers {
/// Return the current number of available worker slots.
pub async fn available(&self) -> usize {
*self.available.lock().await
self.limit.saturating_sub(*self.in_use.borrow())
}
}
@@ -133,7 +122,7 @@ impl Workers {
mod tests {
use super::*;
use std::time::Duration;
use tokio::time::sleep;
use tokio::time::{sleep, timeout};
#[tokio::test]
async fn test_workers() {
@@ -168,4 +157,64 @@ mod tests {
assert_eq!(workers.available().await, 2);
}
/// Regression for S08: with limit=1, a pending drain waiter must never
/// steal the wakeup destined for a pending taker.
#[tokio::test]
async fn test_drain_waiter_does_not_steal_take_wakeup() {
let workers = Workers::new(1).unwrap();
workers.take().await;
let taker = {
let workers = workers.clone();
tokio::spawn(async move { workers.take().await })
};
let drainer = {
let workers = workers.clone();
tokio::spawn(async move { workers.wait().await })
};
// Let both tasks block before releasing the slot.
sleep(Duration::from_millis(50)).await;
workers.give().await;
timeout(Duration::from_secs(1), taker)
.await
.expect("taker must be woken by give()")
.unwrap();
workers.give().await;
timeout(Duration::from_secs(1), drainer)
.await
.expect("drain waiter must complete once all slots are free")
.unwrap();
assert_eq!(workers.available().await, 1);
}
/// Regression for S17: two rapid give() calls must wake two pending
/// takers; permits must not merge into a single wakeup.
#[tokio::test]
async fn test_rapid_gives_wake_all_pending_takers() {
let workers = Workers::new(2).unwrap();
workers.take().await;
workers.take().await;
let takers: Vec<_> = (0..2)
.map(|_| {
let workers = workers.clone();
tokio::spawn(async move { workers.take().await })
})
.collect();
// Let both takers queue up before any slot is released.
sleep(Duration::from_millis(50)).await;
workers.give().await;
workers.give().await;
for taker in takers {
timeout(Duration::from_secs(1), taker)
.await
.expect("every pending taker must be woken")
.unwrap();
}
assert_eq!(workers.available().await, 0);
}
}
+4
View File
@@ -62,6 +62,10 @@ Current guidance:
- `RUSTFS_CORS_ALLOWED_ORIGINS` defaults to empty, so the S3 endpoint emits no generic CORS headers unless configured. Set `*` for wildcard origins without credentials, or a comma-separated allow-list for credentialed explicit origins.
- `RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS` defaults to `*` for the console service.
## Browser redirect environment variables
- `RUSTFS_BROWSER_REDIRECT_URL` sets the externally reachable browser origin used for OIDC callback, console success redirect, and logout fallback URLs. Configure it to the public scheme and authority without a path, for example `https://console.example.com`. In load-balancer deployments, keep OIDC authorize and callback requests on the same backend node because the in-flight OIDC `state` is local to the RustFS node.
## Scanner environment aliases
- `RUSTFS_SCANNER_SPEED` (canonical, also accepts `MINIO_SCANNER_SPEED`)
+42
View File
@@ -143,6 +143,41 @@ pub const ENV_MINIO_CI: &str = "MINIO_CI";
/// Default flag value for bypassing local physical disk independence checks.
pub const DEFAULT_UNSAFE_BYPASS_DISK_CHECK: bool = false;
/// Environment variable selecting how startup waits for DNS/topology
/// convergence in multi-node setups. One of `auto` (default), `orchestrated`,
/// `bounded`, or `fail-fast`.
///
/// - `orchestrated`: wait effectively indefinitely for recoverable DNS/topology
/// errors so the process stays Running (readiness stays false) instead of
/// exiting and triggering a Kubernetes pod restart loop.
/// - `bounded`: wait for a finite window (see
/// `ENV_STARTUP_TOPOLOGY_WAIT_TIMEOUT`) then fail with an actionable error.
/// - `fail-fast`: fail on the first non-transient resolution error (CI/local).
/// - `auto`: distributed URL endpoints use orchestrated on Kubernetes and
/// bounded otherwise; local path endpoints use fail-fast (no DNS to await).
pub const ENV_STARTUP_TOPOLOGY_WAIT_MODE: &str = "RUSTFS_STARTUP_TOPOLOGY_WAIT_MODE";
/// Environment variable bounding how long startup waits for topology/DNS
/// convergence before failing in `bounded` mode. Accepts durations such as
/// `3m`, `90s`, `500ms`, or a bare number of seconds.
pub const ENV_STARTUP_TOPOLOGY_WAIT_TIMEOUT: &str = "RUSTFS_STARTUP_TOPOLOGY_WAIT_TIMEOUT";
/// Environment variable capping the per-attempt backoff delay while retrying
/// startup topology/DNS convergence. Accepts durations such as `8s`.
pub const ENV_STARTUP_TOPOLOGY_RETRY_MAX_DELAY: &str = "RUSTFS_STARTUP_TOPOLOGY_RETRY_MAX_DELAY";
/// Environment variable injected into every Kubernetes pod; its presence marks
/// an orchestrated deployment for startup topology auto-detection.
pub const ENV_KUBERNETES_SERVICE_HOST: &str = "KUBERNETES_SERVICE_HOST";
/// Default bounded-mode wait window (seconds) for non-Kubernetes distributed
/// deployments before startup topology convergence is declared failed.
pub const DEFAULT_STARTUP_TOPOLOGY_WAIT_TIMEOUT_SECS: u64 = 180;
/// Default per-attempt backoff cap (seconds) while retrying startup
/// topology/DNS convergence.
pub const DEFAULT_STARTUP_TOPOLOGY_RETRY_MAX_DELAY_SECS: u64 = 8;
/// Environment variable for server access key.
pub const ENV_RUSTFS_ACCESS_KEY: &str = "RUSTFS_ACCESS_KEY";
@@ -174,6 +209,12 @@ pub const ENV_RUSTFS_CONSOLE_ENABLE: &str = "RUSTFS_CONSOLE_ENABLE";
/// Environment variable for console server address.
pub const ENV_RUSTFS_CONSOLE_ADDRESS: &str = "RUSTFS_CONSOLE_ADDRESS";
/// Public browser entrypoint used to build OIDC callback and console redirects.
///
/// This should be the externally reachable scheme and authority, without a path.
/// Example: `RUSTFS_BROWSER_REDIRECT_URL=https://console.example.com`.
pub const ENV_RUSTFS_BROWSER_REDIRECT_URL: &str = "RUSTFS_BROWSER_REDIRECT_URL";
/// Environment variable for server tls path.
pub const ENV_RUSTFS_TLS_PATH: &str = "RUSTFS_TLS_PATH";
@@ -348,6 +389,7 @@ mod tests {
RUSTFS_TLS_CERT,
DEFAULT_ADDRESS,
DEFAULT_CONSOLE_ADDRESS,
ENV_RUSTFS_BROWSER_REDIRECT_URL,
];
for constant in &string_constants {
-16
View File
@@ -45,9 +45,6 @@ pub const ENV_CAPACITY_METRICS_INTERVAL: &str = "RUSTFS_CAPACITY_METRICS_INTERVA
/// Environment variable for following symbolic links during capacity calculation
pub const ENV_CAPACITY_FOLLOW_SYMLINKS: &str = "RUSTFS_CAPACITY_FOLLOW_SYMLINKS";
/// Environment variable for maximum symlink follow depth
pub const ENV_CAPACITY_MAX_SYMLINK_DEPTH: &str = "RUSTFS_CAPACITY_MAX_SYMLINK_DEPTH";
/// Environment variable for enabling dynamic timeout calculation
pub const ENV_CAPACITY_ENABLE_DYNAMIC_TIMEOUT: &str = "RUSTFS_CAPACITY_ENABLE_DYNAMIC_TIMEOUT";
@@ -57,9 +54,6 @@ pub const ENV_CAPACITY_MIN_TIMEOUT: &str = "RUSTFS_CAPACITY_MIN_TIMEOUT";
/// Environment variable for maximum capacity calculation timeout
pub const ENV_CAPACITY_MAX_TIMEOUT: &str = "RUSTFS_CAPACITY_MAX_TIMEOUT";
/// Environment variable for progress stall detection timeout
pub const ENV_CAPACITY_STALL_TIMEOUT: &str = "RUSTFS_CAPACITY_STALL_TIMEOUT";
// ============================================================================
// Default Values
// ============================================================================
@@ -100,10 +94,6 @@ pub const DEFAULT_CAPACITY_METRICS_INTERVAL_SECS: u64 = 600;
/// Default: false (disabled for safety)
pub const DEFAULT_CAPACITY_FOLLOW_SYMLINKS: bool = false;
/// Maximum symlink follow depth
/// Default: 3 levels
pub const DEFAULT_CAPACITY_MAX_SYMLINK_DEPTH: u8 = 3;
/// Enable dynamic timeout calculation based on directory characteristics
/// Default: true (enabled)
pub const DEFAULT_CAPACITY_ENABLE_DYNAMIC_TIMEOUT: bool = true;
@@ -116,10 +106,6 @@ pub const DEFAULT_CAPACITY_MIN_TIMEOUT_SECS: u64 = 2;
/// Default: 15 seconds
pub const DEFAULT_CAPACITY_MAX_TIMEOUT_SECS: u64 = 15;
/// Progress stall detection timeout in seconds
/// Default: 20 seconds
pub const DEFAULT_CAPACITY_STALL_TIMEOUT_SECS: u64 = 20;
// ============================================================================
// Tests
// ============================================================================
@@ -139,10 +125,8 @@ mod tests {
assert_eq!(ENV_CAPACITY_SAMPLE_RATE, "RUSTFS_CAPACITY_SAMPLE_RATE");
assert_eq!(ENV_CAPACITY_METRICS_INTERVAL, "RUSTFS_CAPACITY_METRICS_INTERVAL");
assert_eq!(ENV_CAPACITY_FOLLOW_SYMLINKS, "RUSTFS_CAPACITY_FOLLOW_SYMLINKS");
assert_eq!(ENV_CAPACITY_MAX_SYMLINK_DEPTH, "RUSTFS_CAPACITY_MAX_SYMLINK_DEPTH");
assert_eq!(ENV_CAPACITY_ENABLE_DYNAMIC_TIMEOUT, "RUSTFS_CAPACITY_ENABLE_DYNAMIC_TIMEOUT");
assert_eq!(ENV_CAPACITY_MIN_TIMEOUT, "RUSTFS_CAPACITY_MIN_TIMEOUT");
assert_eq!(ENV_CAPACITY_MAX_TIMEOUT, "RUSTFS_CAPACITY_MAX_TIMEOUT");
assert_eq!(ENV_CAPACITY_STALL_TIMEOUT, "RUSTFS_CAPACITY_STALL_TIMEOUT");
}
}
+18
View File
@@ -41,6 +41,24 @@ pub const ENV_ILM_PROCESS_TIME_DEPRECATED: &str = "_RUSTFS_ILM_PROCESS_TIME";
/// Default ILM process boundary in seconds (24h).
pub const DEFAULT_ILM_PROCESS_TIME_SECS: i32 = 86400;
/// **TEST/DEBUG ONLY** — lifecycle "day" length override, in seconds.
///
/// Modeled on Ceph RGW's `rgw_lc_debug_interval`. When set to a positive
/// integer `N`, lifecycle rule evaluation treats one Days-based "day" as `N`
/// seconds instead of [`DEFAULT_ILM_DAY_SECS`] (86400), so `Days`-based
/// expiration/transition/noncurrent rules become exercisable in seconds under
/// test. When unset (or set to `0`/an invalid value), behavior is byte-for-byte
/// identical to production.
///
/// This accelerates data deletion and MUST NEVER be set in a production
/// deployment. Enabling it emits a `WARN` log. It only rescales relative
/// `Days` deadlines; absolute `Date`-based rules are unaffected.
pub const ENV_ILM_DEBUG_DAY_SECS: &str = "RUSTFS_ILM_DEBUG_DAY_SECS";
/// Number of seconds in one lifecycle "day" (86400) used as the default when
/// [`ENV_ILM_DEBUG_DAY_SECS`] is unset.
pub const DEFAULT_ILM_DAY_SECS: u32 = 86400;
/// Medium-drawn lines separator
/// This is used to separate words in environment variable names.
pub const ENV_WORD_DELIMITER_DASH: &str = "-";
+18 -7
View File
@@ -148,14 +148,18 @@ pub const DEFAULT_INTERNODE_OFFLINE_REPROBE_SECS: u64 = 5;
const _: () = assert!(!DEFAULT_INTERNODE_PREWARM);
const _: () = assert!(!DEFAULT_INTERNODE_OFFLINE_BYPASS);
/// Extra attempts for idempotent, read-only/reentrant control-plane RPCs (e.g. `DiskInfo`) on
/// transient network failures, with exponential backoff (grpc-optimization P3-3).
/// Extra attempts for idempotent, read-only/reentrant control-plane and object-read RPCs
/// (`DiskInfo`, `ReadAll`, `ReadMetadata`, `ReadVersion`) on transient network failures, with
/// exponential backoff (grpc-optimization P3-3).
///
/// Defaults to `0` (disabled) — retries change latency-on-failure behavior and are opt-in. **Only**
/// idempotent reads use this; write/lock RPCs (`WriteAll`/`RenameData`/`Delete*`/`Lock`/`UnLock`)
/// must never auto-retry, to preserve quorum and idempotency semantics (see `CLAUDE.md`).
/// Defaults to `1`: a freshly-committed object lives on only `write_quorum` disks until heal
/// reconstructs the rest, so a single reset-by-peer on a metadata read during that
/// read-after-write window can erode the read quorum and surface as a spurious
/// `InsufficientReadQuorum` (issue #2761). One bounded retry absorbs that transient failure.
/// **Only** idempotent reads use this; write/lock RPCs (`WriteAll`/`RenameData`/`Delete*`/`Lock`
/// /`UnLock`) must never auto-retry, to preserve quorum and idempotency semantics (see `CLAUDE.md`).
pub const ENV_INTERNODE_IDEMPOTENT_READ_RETRIES: &str = "RUSTFS_INTERNODE_IDEMPOTENT_READ_RETRIES";
pub const DEFAULT_INTERNODE_IDEMPOTENT_READ_RETRIES: usize = 0;
pub const DEFAULT_INTERNODE_IDEMPOTENT_READ_RETRIES: usize = 1;
// ── Control/bulk channel isolation (P1) ──
// Large `bytes`-carrying unary RPCs (ReadAll/WriteAll/ReadMultiple/BatchReadVersion) otherwise
@@ -194,6 +198,12 @@ pub const ENV_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST: &str = "RUSTFS_INTERNODE_HT
pub const ENV_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS: &str = "RUSTFS_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS";
/// Internode HTTP/2 initial stream window size in bytes.
///
/// NOTE (backlog#805-C3): all `RUSTFS_INTERNODE_HTTP2_*` tuning (window sizes,
/// adaptive window, keepalive timeout) only takes effect when the internode
/// connection actually negotiates HTTP/2, which happens via TLS-ALPN. Over
/// plaintext internode transport the connection is HTTP/1.1 and these settings
/// are silently inert — enable internode TLS to use them.
pub const ENV_INTERNODE_HTTP2_INITIAL_STREAM_WINDOW_SIZE: &str = "RUSTFS_INTERNODE_HTTP2_INITIAL_STREAM_WINDOW_SIZE";
/// Internode HTTP/2 initial connection window size in bytes.
@@ -277,7 +287,8 @@ mod tests {
fn internode_p3_lifecycle_defaults_are_opt_in() {
// The opt-in (default-off) invariants are asserted at compile time next to the definitions.
assert_eq!(DEFAULT_INTERNODE_OFFLINE_REPROBE_SECS, 5);
assert_eq!(DEFAULT_INTERNODE_IDEMPOTENT_READ_RETRIES, 0);
// One bounded retry for idempotent reads to absorb read-after-write transient failures (#2761).
assert_eq!(DEFAULT_INTERNODE_IDEMPOTENT_READ_RETRIES, 1);
assert_eq!(ENV_INTERNODE_PREWARM, "RUSTFS_INTERNODE_PREWARM");
assert_eq!(ENV_INTERNODE_OFFLINE_BYPASS, "RUSTFS_INTERNODE_OFFLINE_BYPASS");
assert_eq!(ENV_INTERNODE_OFFLINE_REPROBE_SECS, "RUSTFS_INTERNODE_OFFLINE_REPROBE_SECS");
+4 -2
View File
@@ -53,8 +53,10 @@ pub const DEFAULT_TRUSTED_PROXY_LOG_FAILED_VALIDATIONS: bool = true;
// ==================== Trusted Proxy Networks ====================
/// Environment variable for the list of trusted proxy networks (comma-separated IP/CIDR).
pub const ENV_TRUSTED_PROXY_PROXIES: &str = "RUSTFS_TRUSTED_PROXY_NETWORKS";
/// Default trusted networks include localhost and common private ranges.
pub const DEFAULT_TRUSTED_PROXY_PROXIES: &str = "127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fd00::/8";
/// Default trusted networks are loopback-only. Private/RFC1918 ranges are not
/// trusted by default: an operator must explicitly add the specific proxy
/// addresses in front of this server to have forwarding headers honored.
pub const DEFAULT_TRUSTED_PROXY_PROXIES: &str = "127.0.0.1,::1";
/// Environment variable for additional trusted proxy networks (production specific).
pub const ENV_TRUSTED_PROXY_EXTRA_PROXIES: &str = "RUSTFS_TRUSTED_PROXY_EXTRA_NETWORKS";
+27 -2
View File
@@ -33,9 +33,16 @@ pub const ENV_RUNTIME_DIAL9_OUTPUT_DIR: &str = "RUSTFS_RUNTIME_DIAL9_OUTPUT_DIR"
pub const ENV_RUNTIME_DIAL9_FILE_PREFIX: &str = "RUSTFS_RUNTIME_DIAL9_FILE_PREFIX";
pub const ENV_RUNTIME_DIAL9_MAX_FILE_SIZE: &str = "RUSTFS_RUNTIME_DIAL9_MAX_FILE_SIZE";
pub const ENV_RUNTIME_DIAL9_ROTATION_COUNT: &str = "RUSTFS_RUNTIME_DIAL9_ROTATION_COUNT";
/// Accepted but not honoured: dial9's S3 uploader depends on a vulnerable
/// rustls-webpki. Setting this logs a warning. See rustfs/backlog#1157.
pub const ENV_RUNTIME_DIAL9_S3_BUCKET: &str = "RUSTFS_RUNTIME_DIAL9_S3_BUCKET";
/// Accepted but not honoured; see [`ENV_RUNTIME_DIAL9_S3_BUCKET`].
pub const ENV_RUNTIME_DIAL9_S3_PREFIX: &str = "RUSTFS_RUNTIME_DIAL9_S3_PREFIX";
pub const ENV_RUNTIME_DIAL9_SAMPLING_RATE: &str = "RUSTFS_RUNTIME_DIAL9_SAMPLING_RATE";
// Note: there are deliberately no task-dump knobs. dial9 only captures task
// dumps for futures spawned through `dial9_tokio_telemetry::spawn`, and RustFS
// spawns with `tokio::spawn` throughout, so the switch could never do anything.
// Measured: 0 dumps via tokio::spawn vs 14709 via dial9::spawn on an identical
// workload. See rustfs/backlog#1157 (D9-16).
// Default values for Tokio runtime
pub const DEFAULT_WORKER_THREADS: usize = 16;
@@ -56,7 +63,6 @@ pub const DEFAULT_RUNTIME_DIAL9_OUTPUT_DIR: &str = "/var/log/rustfs/telemetry";
pub const DEFAULT_RUNTIME_DIAL9_FILE_PREFIX: &str = "rustfs-tokio";
pub const DEFAULT_RUNTIME_DIAL9_MAX_FILE_SIZE: u64 = 100 * 1024 * 1024; // 100MB
pub const DEFAULT_RUNTIME_DIAL9_ROTATION_COUNT: usize = 10;
pub const DEFAULT_RUNTIME_DIAL9_SAMPLING_RATE: f64 = 1.0; // 100% sampling
// Note: S3 bucket/prefix have no default; absence means upload is disabled (modeled as Option<String>)
/// Maximum transition workers used as a local fallback when runtime env is unset.
@@ -112,6 +118,25 @@ pub const ENV_OBJECT_SEEK_SUPPORT_THRESHOLD: &str = "RUSTFS_OBJECT_SEEK_SUPPORT_
pub const DEFAULT_OBJECT_SEEK_SUPPORT_THRESHOLD: usize = 10 * 1024 * 1024;
// Object data cache configuration
/// Enables the in-memory object body cache, which serves whole-object GET
/// responses without an erasure read.
///
/// # Timing side channel
///
/// A cache hit skips the erasure read, bitrot verification and decode, so it is
/// reliably faster than a miss. Any principal authorized to read an object can
/// therefore infer, by timing a single GET, whether *someone* read that object
/// within the entry's lifetime (see `RUSTFS_OBJECT_DATA_CACHE_TTL_SECS` and
/// `..._TIME_TO_IDLE_SECS`).
///
/// This never crosses an authorization boundary — the probe requires read
/// access to the exact bucket and object, checked before the cache is consulted
/// — but it does disclose co-tenants' recent access patterns on objects the
/// observer may already read. Leave the cache disabled where access-pattern
/// confidentiality matters, such as a bucket shared read-only between competing
/// tenants. Timing noise is not a viable mitigation: it would cost exactly the
/// latency the cache exists to save.
pub const ENV_OBJECT_DATA_CACHE_ENABLE: &str = "RUSTFS_OBJECT_DATA_CACHE_ENABLE";
pub const ENV_OBJECT_DATA_CACHE_MODE: &str = "RUSTFS_OBJECT_DATA_CACHE_MODE";
pub const ENV_OBJECT_DATA_CACHE_MAX_BYTES: &str = "RUSTFS_OBJECT_DATA_CACHE_MAX_BYTES";
+6
View File
@@ -37,6 +37,10 @@ pub const ENV_OBS_METER_INTERVAL: &str = "RUSTFS_OBS_METER_INTERVAL";
pub const ENV_OBS_SERVICE_NAME: &str = "RUSTFS_OBS_SERVICE_NAME";
pub const ENV_OBS_SERVICE_VERSION: &str = "RUSTFS_OBS_SERVICE_VERSION";
pub const ENV_OBS_ENVIRONMENT: &str = "RUSTFS_OBS_ENVIRONMENT";
/// Stable OpenTelemetry service instance identity for this RustFS process.
pub const ENV_OBS_INSTANCE_ID: &str = "RUSTFS_OBS_INSTANCE_ID";
/// Optional stable RustFS deployment identity used to correlate cluster telemetry.
pub const ENV_OBS_CLUSTER_ID: &str = "RUSTFS_OBS_CLUSTER_ID";
/// Per-signal enable/disable flags (default: true)
pub const ENV_OBS_TRACES_EXPORT_ENABLED: &str = "RUSTFS_OBS_TRACES_EXPORT_ENABLED";
@@ -131,6 +135,8 @@ mod tests {
assert_eq!(ENV_OBS_SERVICE_NAME, "RUSTFS_OBS_SERVICE_NAME");
assert_eq!(ENV_OBS_SERVICE_VERSION, "RUSTFS_OBS_SERVICE_VERSION");
assert_eq!(ENV_OBS_ENVIRONMENT, "RUSTFS_OBS_ENVIRONMENT");
assert_eq!(ENV_OBS_INSTANCE_ID, "RUSTFS_OBS_INSTANCE_ID");
assert_eq!(ENV_OBS_CLUSTER_ID, "RUSTFS_OBS_CLUSTER_ID");
assert_eq!(ENV_OBS_LOGGER_LEVEL, "RUSTFS_OBS_LOGGER_LEVEL");
assert_eq!(ENV_OBS_LOG_STDOUT_ENABLED, "RUSTFS_OBS_LOG_STDOUT_ENABLED");
assert_eq!(ENV_OBS_LOG_DIRECTORY, "RUSTFS_OBS_LOG_DIRECTORY");
+49 -6
View File
@@ -284,6 +284,24 @@ impl SizeHistogram {
}
pub fn to_map(&self) -> HashMap<String, u64> {
// Numeric interval bounds, kept in lockstep with `add` above. The
// rollup for the v1-compat `BETWEEN_1024B_AND_1_MB` bucket is derived
// from these bounds rather than the display names to avoid undercounting
// the sub-ranges in [1 KiB, 512 KiB).
const ONE_MIB: u64 = 1024 * 1024;
let intervals = [
(0, 1024), // LESS_THAN_1024_B
(1024, 64 * 1024 - 1), // BETWEEN_1024_B_AND_64_KB
(64 * 1024, 256 * 1024 - 1), // BETWEEN_64_KB_AND_256_KB
(256 * 1024, 512 * 1024 - 1), // BETWEEN_256_KB_AND_512_KB
(512 * 1024, ONE_MIB - 1), // BETWEEN_512_KB_AND_1_MB
(1024, ONE_MIB - 1), // BETWEEN_1024B_AND_1_MB (v1-compat rollup)
(ONE_MIB, 10 * ONE_MIB - 1), // BETWEEN_1_MB_AND_10_MB
(10 * ONE_MIB, 64 * ONE_MIB - 1), // BETWEEN_10_MB_AND_64_MB
(64 * ONE_MIB, 128 * ONE_MIB - 1), // BETWEEN_64_MB_AND_128_MB
(128 * ONE_MIB, 512 * ONE_MIB - 1), // BETWEEN_128_MB_AND_512_MB
(512 * ONE_MIB, u64::MAX), // GREATER_THAN_512_MB
];
let names = [
"LESS_THAN_1024_B",
"BETWEEN_1024_B_AND_64_KB",
@@ -298,14 +316,21 @@ impl SizeHistogram {
"GREATER_THAN_512_MB",
];
// Sum every sub-bucket whose interval lies entirely within [1024, 1 MiB),
// excluding the compat bucket itself, to form the v1-compat rollup.
let compat_rollup: u64 = self
.0
.iter()
.zip(intervals.iter())
.zip(names.iter())
.filter(|((_, (start, end)), name)| name != &&"BETWEEN_1024B_AND_1_MB" && *start >= 1024 && *end < ONE_MIB)
.map(|((count, _), _)| *count)
.sum();
let mut res = HashMap::new();
let mut spl_count = 0;
for (count, name) in self.0.iter().zip(names.iter()) {
if name == &"BETWEEN_1024B_AND_1_MB" {
res.insert(name.to_string(), spl_count);
} else if name.starts_with("BETWEEN_") && name.contains("_KB_") && name.contains("_MB") {
spl_count += count;
res.insert(name.to_string(), *count);
res.insert(name.to_string(), compat_rollup);
} else {
res.insert(name.to_string(), *count);
}
@@ -409,7 +434,7 @@ impl ReplicationAllStats {
if self.replica_size != 0 && self.replica_count != 0 {
return false;
}
for (_, v) in self.targets.iter() {
for v in self.targets.values() {
if !v.empty() {
return false;
}
@@ -1318,6 +1343,24 @@ mod tests {
assert_eq!(summary1.versions, 15);
}
#[test]
fn test_size_histogram_compat_rollup_sums_all_sub_buckets() {
let mut hist = SizeHistogram::default();
// One object in each of the four sub-ranges within [1024, 1 MiB).
hist.add(32 * 1024); // [1024, 64 KiB)
hist.add(128 * 1024); // [64 KiB, 256 KiB)
hist.add(384 * 1024); // [256 KiB, 512 KiB)
hist.add(768 * 1024); // [512 KiB, 1 MiB)
let map = hist.to_map();
assert_eq!(map["BETWEEN_1024B_AND_1_MB"], 4);
assert_eq!(map["BETWEEN_1024_B_AND_64_KB"], 1);
assert_eq!(map["BETWEEN_64_KB_AND_256_KB"], 1);
assert_eq!(map["BETWEEN_256_KB_AND_512_KB"], 1);
assert_eq!(map["BETWEEN_512_KB_AND_1_MB"], 1);
}
#[test]
fn test_data_usage_cache_merge_adds_missing_child() {
let mut base = DataUsageCache::default();
+5
View File
@@ -2,6 +2,11 @@
Applies to `crates/e2e_test/`.
Contributor guide (how to run suites, add a test, the harness/fixture
inventory, `#[ignore]` classes, and the CI map) lives in
[`README.md`](README.md). This file holds the crate rules that agents must
follow.
## Test Reliability
- Keep end-to-end tests deterministic and environment-aware.
+276
View File
@@ -0,0 +1,276 @@
# e2e_test
End-to-end test suite for RustFS. Each test spawns a **real `rustfs` binary**
(built on demand from the workspace) and drives it over the network with the
AWS SDK (`aws-sdk-s3`), raw HTTP (`reqwest` / `awscurl`), or a protocol client
(FTPS / WebDAV / SFTP). This is the black-box integration layer: exhaustive
end-to-end behavior lives here, unit behavior stays in the source crates
(see [`AGENTS.md`](AGENTS.md)).
The harness lives in [`src/common.rs`](src/common.rs) (single-node +
cluster environments, S3 client construction, `awscurl` helpers) and
[`src/chaos.rs`](src/chaos.rs) (in-process disk fault injection). Crate-wide
test conventions and environment-safety rules are in
[`AGENTS.md`](AGENTS.md); this file is the contributor guide.
## Module map (~50 modules)
Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
| Group | Location | What it covers |
| --- | --- | --- |
| **functional** | top-level `*_test.rs` | S3 data plane: `list_objects_*`, `copy_object_*`, `delete_objects_versioning`, `head_object_*`, `checksum_upload`, `compression`, `content_encoding`, `special_chars`, `leading_slash_key`, `create_bucket_region`, `quota`, `data_usage`, `snowball_auto_extract`, `mc_mirror_small_bucket`, `archive_download_integrity`, `version_id_regression`, `delete_marker_migration_semantics` |
| **object_lock** | [`src/object_lock/`](src/object_lock) | Retention / legal-hold / WORM semantics |
| **kms** | [`src/kms/`](src/kms) | SSE-S3 / SSE-KMS / SSE-C, local + Vault backends, multipart encryption. Own guide: [`src/kms/README.md`](src/kms/README.md) |
| **policy** | [`src/policy/`](src/policy), `existing_object_tag_policy_test`, `bucket_policy_check_test`, `anonymous_access_test`, `security_boundary_test`, `multipart_auth_test` | IAM / bucket-policy / STS session policy, policy variables, anonymous access, DoS/SSRF boundaries. Own guide: [`src/policy/README.md`](src/policy/README.md) |
| **protocols** | [`src/protocols/`](src/protocols) | FTPS, WebDAV, SFTP compliance. Fixed ports, own guide: [`src/protocols/README.md`](src/protocols/README.md) |
| **reliant** | [`src/reliant/`](src/reliant) | Tests that reuse an **externally started** server (SQL/select, conditional writes, lifecycle, deleted-object reads, node-interact). Run via [`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh); see [`src/reliant/README.md`](src/reliant/README.md) |
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
## How to run
All commands assume repo root. `cargo test` triggers an on-demand build of the
`rustfs` binary from [`src/common.rs`](src/common.rs) (`rustfs_binary_path`) on
first use — the first invocation is slow, later ones reuse the binary.
```bash
# Whole crate (default = ignored tests skipped)
cargo nextest run -p e2e_test
# One module
cargo nextest run -p e2e_test -E 'test(list_objects_v2_pagination_test)'
# PR smoke subset (see "CI smoke subset" below)
cargo nextest run --profile e2e-smoke -p e2e_test
# ILM serial lane — ignored lifecycle tests, single-threaded (mirrors CI)
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))'
# Protocols suite — fixed ports, MUST be single-threaded, gated by build features
RUSTFS_BUILD_FEATURES=ftps,webdav,sftp \
cargo test -p e2e_test test_protocol_core_suite -- --test-threads=1 --nocapture
```
The protocols suite has its own contract (fixed bind ports 90229301,
`--test-threads=1`, feature-gated scheduling) documented in
[`src/protocols/README.md`](src/protocols/README.md). `RUSTFS_BUILD_FEATURES`
selects which features the spawned binary is built with; leave it unset to run
every protocol entry.
### `#[ignore]` semantics
Ignored tests are excluded from the default `cargo nextest run` pass because
they need something the default runner does not provide. **Do not maintain a
static count here** — it rots (the set shrinks as ci-13 / ilm-3 activate
suites). Read the live sources instead:
```bash
rg -n '#\[ignore' crates/e2e_test/src # every ignore + its reason string
```
The reason string on each attribute is the classifier. Current classes:
- **Needs a pre-started server** — `"requires running RustFS server at
localhost:9000"` / `"Connects to existing rustfs server"`. These are the
`reliant/*` and `policy/test_runner` tests; start a server first (e.g.
[`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh)) or use
`--run-ignored`.
- **Heavy / external tool** — `"Starts a rustfs server; enable when running
full E2E"`, `"requires awscurl and spawns a real RustFS server"`. Spawn their
own server and/or need `awscurl` on `PATH`.
- **Serial / global-state (ILM lane)** — lifecycle tests bind fixed ports and
share process-global singletons; run via the ILM serial lane above.
## How to add a test
### Single-node (the common case)
Use `RustFSTestEnvironment` from [`src/common.rs`](src/common.rs). It picks a
**random free port** and a **unique temp dir** per instance, so tests are
parallel-safe by construction and clean up on `Drop`:
```rust
use crate::common::{RustFSTestEnvironment, TEST_BUCKET};
#[tokio::test]
async fn my_case() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?; // waits for readiness
let client = env.create_s3_client(); // aws-sdk-s3 Client
env.create_test_bucket(TEST_BUCKET).await?;
// ... drive `client` ...
Ok(())
}
```
Register the module in [`src/lib.rs`](src/lib.rs) under `#[cfg(test)]`.
### Cluster
Use `RustFSTestClusterEnvironment::new(node_count)` then `.start()`; it spawns
`node_count` servers over a shared erasure set and hands out per-node S3 clients
via `create_s3_client(idx)` / `create_all_clients()`. See
`cluster_concurrency_test.rs` and `namespace_lock_quorum_test.rs` for patterns.
### Fixture / helper inventory (`src/common.rs`)
| Helper | Purpose |
| --- | --- |
| `RustFSTestEnvironment::new` / `with_address` | Single-node env; random or fixed address |
| `start_rustfs_server` / `_with_env` / `_without_cleanup` | Spawn the server (optional extra args / env vars / no pre-cleanup) |
| `wait_for_server_ready` | Poll readiness before issuing requests |
| `create_s3_client` / `create_test_bucket` / `delete_test_bucket` | aws-sdk-s3 client + bucket lifecycle |
| `find_available_port` | Random free port (isolation primitive) |
| `rustfs_binary_path` / `_with_features` | Locate/build the binary; honors `RUSTFS_BUILD_FEATURES` |
| `requested_rustfs_build_features` / `rustfs_build_feature_enabled` | Feature-gate a test to what the binary was built with |
| `awscurl_available` + `execute_awscurl` / `awscurl_post` / `_get` / `_put` / `_delete` / `awscurl_post_sts_form_urlencoded` | Admin/STS API calls via `awscurl` (skip gracefully when absent) |
| `replication_fast_env` | Env vars that shrink replication timers (from repl-4); pass to `start_rustfs_server_with_env` |
| `local_http_client` / `init_logging` | Loopback HTTP client; idempotent tracing init |
| `RustFSTestClusterEnvironment` (`new`/`start`/`start_node`/`stop_node`/`create_all_clients`) | Multi-node harness |
| Constants: `DEFAULT_ACCESS_KEY`, `DEFAULT_SECRET_KEY`, `TEST_BUCKET`, `ENV_RUSTFS_BUILD_FEATURES` | Shared credentials / bucket name / env-var name |
Fault injectors live in [`src/chaos.rs`](src/chaos.rs): `DiskFaultHarness`
(`take_disk_offline`, `bring_disk_online`, `replace_disk_with_empty`,
`corrupt_object_shard`, `object_metadata_exists_on_disk`, `kill_server` /
`restart_server`) plus `signed_admin_post`.
### Isolation rules
- **Port**: never hard-code a port for single-node tests — `new()` allocates a
random one. Fixed ports (protocols, ILM lane) force `--test-threads=1` / a
serial CI lane.
- **Temp dir**: each env owns a temp dir cleaned on `Drop`; do not write under
a shared path.
- **Orphans**: `RustFSTestEnvironment` kills its child on `Drop`, but a panicked
or `kill -9`'d run can leak a `rustfs` process holding a port — see
Troubleshooting.
### `#[serial]` vs nextest reality
`serial_test`'s `#[serial]` uses an **in-process** mutex. Under nextest each
test runs in its **own process**, so `#[serial]` does **not** serialize across
tests there — see the header of [`.config/nextest.toml`](../../.config/nextest.toml).
Real cross-test serialization comes from a nextest test-group (`max-threads =
1`) or a `-j1` CI lane. Single-node e2e tests should instead be parallel-safe by
construction (random port + isolated temp dir) and need no serialization.
## CI map
`e2e_test` is **excluded** from the main `cargo nextest run --profile ci --all`
pass ([`.github/workflows/ci.yml`](../../.github/workflows/ci.yml) line 158,
`--exclude e2e_test`) — the whole crate is too slow to gate every PR. Subsets
join CI through the nextest profile system only (never as ad-hoc jobs):
| Suite | Runs where | Status |
| --- | --- | --- |
| Smoke subset (`e2e-smoke` profile) | `e2e-tests` job, every PR | **Active** (backlog#1149 ci-4) |
| `s3s-e2e` black-box | `e2e-tests` + `e2e-tests-rio-v2` jobs | **Active** (external conformance tool) |
| ILM / lifecycle (ignored) | `test-ilm-integration-serial` lane, `-j1` | **Active** (backlog#1148 ilm-1) |
| KMS suite | — | Not in CI yet (backlog#1149 ci-5) |
| Protocols (FTPS/WebDAV/SFTP) | — | Not in CI yet (backlog#1149 ci-7) |
| Replication (fast subset) | `e2e-smoke` profile, `e2e-tests` job, every PR | **Active** (backlog#1147 repl-1) |
| Replication (slow + dual-node) | `e2e-repl-nightly` profile, scheduled workflow | **Active** (backlog#1147 repl-1) |
| `reliant/*` (pre-started server) | — | Manual only |
Links: [`ci.yml`](../../.github/workflows/ci.yml) `e2e-tests` (line 347),
`test-ilm-integration-serial` (line 196). The `e2e-smoke` `default-filter` in
[`.config/nextest.toml`](../../.config/nextest.toml) is the **single wiring
mechanism** — extend that filter (or add a sibling profile) to admit more
tests; do not add e2e jobs to `ci.yml`. repl-1 / ilm-3 are landing in parallel
and may add lanes; keep the table above easy to extend.
## Troubleshooting
**Reproduce a CI failure locally** — run the exact profile/lane:
```bash
# Smoke (e2e-tests job) — includes the 20 fast replication tests
cargo nextest run --profile e2e-smoke -p e2e_test
# Replication nightly lane (16 slow + dual-node tests; install awscurl for the
# STS dual-node test, else it skips gracefully)
cargo nextest run --profile e2e-repl-nightly -p e2e_test
# ILM serial lane
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))'
# s3s-e2e black box
./scripts/e2e-run.sh ./target/debug/rustfs /tmp/rustfs-e2e-data
```
**Stale binary.** Tests build the `rustfs` binary once and reuse it. To avoid
rebuilding while iterating on tests, `common.rs` reuses an existing binary when
running *inside* the e2e test process even if sources changed
(`can_reuse_inside_e2e`, [`src/common.rs`](src/common.rs) line 98). Downside: if
you changed **server** code, force a rebuild with
`cargo build -p rustfs` (or `touch` a source file outside the reuse window)
before re-running, or CI's freshly built artifact will diverge from your local
one.
**Port already in use / orphan processes.** A hard-killed run can leak a
`rustfs` child holding its port. Find and kill it:
```bash
pkill -f 'target/debug/rustfs' ; pkill -f 'target/release/rustfs'
```
The `s3s-e2e` CI job selects a random `RUSTFS_TEST_PORT` (see the `e2e-tests`
job) to dodge this; local single-node tests already use random ports, so a
lingering orphan is usually the cause of a spurious bind failure.
**`awscurl` not found.** `awscurl`-dependent tests skip gracefully with a
visible log line (`awscurl_available()`); install `awscurl` to actually run
them.
## Related
- Crate rules & environment safety: [`AGENTS.md`](AGENTS.md)
- Sub-suite guides: [`src/kms/README.md`](src/kms/README.md),
[`src/policy/README.md`](src/policy/README.md),
[`src/protocols/README.md`](src/protocols/README.md),
[`src/reliant/README.md`](src/reliant/README.md)
- Authoritative per-module counts:
[`docs/testing/e2e-suite-inventory.md`](../../docs/testing/e2e-suite-inventory.md)
- Test pyramid & flake policy: [`docs/testing/README.md`](../../docs/testing/README.md)
## CI smoke subset (`--profile e2e-smoke`)
A subset of this crate runs on every PR via the `e2e-tests` job:
```bash
cargo nextest run --profile e2e-smoke -p e2e_test
```
The selection lives in `.config/nextest.toml` under `[profile.e2e-smoke]`
(`default-filter`). That filter is the **single wiring mechanism** for e2e
tests in CI — extend it (or add a sibling profile) instead of adding new e2e
jobs to `ci.yml`.
### Admission criteria for the smoke subset
A test module may join the smoke filter only if every test in it is:
1. **Fast** — single-digit seconds per test; the whole subset must keep the
`e2e-tests` job ≤ 20 minutes.
2. **Single-node** — spawns its own server via
`RustFSTestEnvironment`/`start_rustfs_server` on a random port with an
isolated temp dir. No `RustFSTestClusterEnvironment`, no fixed ports.
3. **Dependency-free** — no pre-started server at `localhost:9000`, no Vault,
no fixed protocol ports. Tools that may be absent on the runner (e.g.
`awscurl`) are acceptable only when the test skips gracefully with a
visible log line (see `bucket_policy_check_test.rs`).
4. **Not `#[ignore]`** — ignored tests are activation work (backlog#1149
ci-13 / backlog#1148 ilm-3), not smoke candidates.
Note on `#[serial]`: nextest runs each test in its own process, so
`serial_test`'s in-process mutex does **not** serialize across tests there
(see the header of `.config/nextest.toml`). Smoke tests must therefore be
parallel-safe by construction (random port + isolated temp dir), which the
current subset is.
### Authoritative test inventory
`docs/testing/e2e-suite-inventory.md` records the per-module test counts as
listed by `cargo nextest list -p e2e_test`. Regenerate it when adding or
moving e2e tests so acceptance numbers in the test-strategy issues
(backlog#1147#1155) stay auditable.
+319
View File
@@ -0,0 +1,319 @@
// 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.
//! Negative / lifecycle e2e coverage for the central admin authorization gate
//! (`rustfs/src/admin/auth.rs`), tracking rustfs/backlog#1151 sec-4.
//!
//! These tests exercise the gate end-to-end against a real server, using raw
//! SigV4-signed HTTP (no `awscurl` dependency) so the exact HTTP status and S3
//! error code are asserted:
//!
//! 1. A fully authenticated but non-admin credential is rejected with
//! `403 AccessDenied` on an admin API (`GET /rustfs/admin/v3/info`), while
//! the root credential is accepted — proving the rejection is authorization,
//! not a broken endpoint.
//! 2. Root-credential rotation takes effect across a restart: the new
//! credential is accepted and the old credential is rejected, on both the
//! S3 data plane and the admin plane.
//! 3. Starting with the default `rustfsadmin` credentials emits the
//! default-credentials startup warning (observable operator signal).
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client, rustfs_binary_path};
use aws_sdk_s3::config::{Credentials, Region};
use aws_sdk_s3::{Client, Config};
use http::header::HOST;
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4;
use s3s::Body;
use serial_test::serial;
use std::error::Error;
use std::io::Read;
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
const ADMIN_INFO_PATH: &str = "/rustfs/admin/v3/info";
/// Send a SigV4-signed request to `path` (optionally with a JSON `body`) and
/// return `(status, body)`. Uses the `UNSIGNED_PAYLOAD` content hash so a
/// request body can be attached without the caller pre-hashing it — the
/// server verifies the signature against the same sentinel, exactly as the
/// AWS SDKs / MinIO client do for streaming/unsigned payloads.
async fn signed_request(
base_url: &str,
method: http::Method,
path: &str,
body: Option<&str>,
access_key: &str,
secret_key: &str,
) -> Result<(reqwest::StatusCode, String), Box<dyn Error + Send + Sync>> {
let url = format!("{base_url}{path}");
let uri = url.parse::<http::Uri>()?;
let authority = uri.authority().ok_or("missing authority")?.to_string();
let body_bytes = body.map(|b| b.as_bytes().to_vec()).unwrap_or_default();
// The signature is computed over `UNSIGNED_PAYLOAD`, so the body bytes do
// not participate in the SigV4 hash — sign over an empty body and attach
// the real payload to the wire request below.
let request = http::Request::builder()
.method(method.clone())
.uri(uri)
.header(HOST, authority)
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
let signed = sign_v4(request.body(Body::empty())?, 0, access_key, secret_key, "", "us-east-1");
let client = local_http_client();
let mut rb = client.request(method, url.as_str());
for (name, value) in signed.headers() {
rb = rb.header(name, value);
}
if !body_bytes.is_empty() {
rb = rb.body(body_bytes);
}
let resp = rb.send().await?;
let status = resp.status();
let text = resp.text().await?;
Ok((status, text))
}
/// Build an S3 client bound to explicit credentials (used to exercise the S3
/// data plane with rotated / stale root credentials).
fn s3_client_with(env: &RustFSTestEnvironment, access_key: &str, secret_key: &str) -> Client {
let credentials = Credentials::new(access_key, secret_key, None, None, "sec4-admin-auth");
let config = Config::builder()
.credentials_provider(credentials)
.region(Region::new("us-east-1"))
.endpoint_url(&env.url)
.force_path_style(true)
.behavior_version_latest()
.build();
Client::from_conf(config)
}
/// Create a non-admin IAM user via the admin `add-user` API using the root
/// credentials. The user is created with no policy attached, so it is a
/// valid (authenticatable) principal that is nonetheless unauthorized for
/// admin actions.
async fn create_limited_user(
env: &RustFSTestEnvironment,
access_key: &str,
secret_key: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let path = format!("/rustfs/admin/v3/add-user?accessKey={access_key}");
let body = serde_json::json!({ "secretKey": secret_key, "status": "enabled" }).to_string();
let (status, resp) =
signed_request(&env.url, http::Method::PUT, &path, Some(&body), &env.access_key, &env.secret_key).await?;
assert!(status.is_success(), "add-user should succeed (status={status}, body={resp})");
Ok(())
}
/// A fully authenticated but non-admin credential must be rejected with
/// `403 AccessDenied` on an admin API, while the root credential succeeds.
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn non_admin_credential_denied_on_admin_api() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let user_ak = "sec4limited";
let user_sk = "sec4limitedsecret";
create_limited_user(&env, user_ak, user_sk).await?;
// Root credential is authorized: proves the endpoint works.
let (root_status, root_body) =
signed_request(&env.url, http::Method::GET, ADMIN_INFO_PATH, None, &env.access_key, &env.secret_key).await?;
assert_eq!(
root_status,
reqwest::StatusCode::OK,
"root credential must be authorized on the admin API, body: {root_body}"
);
// Non-admin credential is rejected by the admin authorization gate.
let (status, body) = signed_request(&env.url, http::Method::GET, ADMIN_INFO_PATH, None, user_ak, user_sk).await?;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"non-admin credential must get 403 on the admin API, body: {body}"
);
assert!(
body.contains("AccessDenied"),
"admin gate 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
/// plane and the admin plane.
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn root_credential_rotation_takes_effect() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
let old_ak = env.access_key.clone();
let old_sk = env.secret_key.clone();
// --- Boot with the original root credential. ---
env.start_rustfs_server(vec![]).await?;
// Admin plane accepts the original root credential.
let (status, body) = signed_request(&env.url, http::Method::GET, ADMIN_INFO_PATH, None, &old_ak, &old_sk).await?;
assert_eq!(status, reqwest::StatusCode::OK, "original root must reach admin API, body: {body}");
// S3 plane accepts the original root credential.
s3_client_with(&env, &old_ak, &old_sk)
.list_buckets()
.send()
.await
.expect("original root must be able to list buckets on the S3 plane");
env.stop_server();
// --- Rotate: restart on the same data dir with a new root credential. ---
let new_ak = "rotatedroot";
let new_sk = "rotatedrootsecret";
env.access_key = new_ak.to_string();
env.secret_key = new_sk.to_string();
env.start_rustfs_server(vec![]).await?;
// New root credential works on both planes.
let (status, body) = signed_request(&env.url, http::Method::GET, ADMIN_INFO_PATH, None, new_ak, new_sk).await?;
assert_eq!(status, reqwest::StatusCode::OK, "rotated root must reach admin API, body: {body}");
s3_client_with(&env, new_ak, new_sk)
.list_buckets()
.send()
.await
.expect("rotated root must be able to list buckets on the S3 plane");
// Old root credential is now rejected on both planes.
let (status, body) = signed_request(&env.url, http::Method::GET, ADMIN_INFO_PATH, None, &old_ak, &old_sk).await?;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"stale root must be rejected on the admin API after rotation, body: {body}"
);
let s3_old = s3_client_with(&env, &old_ak, &old_sk).list_buckets().send().await;
assert!(
s3_old.is_err(),
"stale root must be rejected on the S3 plane after rotation, got: {s3_old:?}"
);
env.stop_server();
Ok(())
}
/// Booting with the default `rustfsadmin` credentials must emit the
/// default-credentials startup warning so operators get an observable
/// signal. The predicate + message text are unit-tested in
/// `rustfs::startup_server`; this pins that the warning actually fires at
/// runtime. We capture the child's stdout/stderr directly (the shared
/// harness inherits stdio) and poll for the warning until it appears.
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn default_credentials_emit_startup_warning() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let port = RustFSTestEnvironment::find_available_port().await?;
let address = format!("127.0.0.1:{port}");
let temp_dir = std::env::temp_dir().join(format!("rustfs_sec4_defcred_{}_{}", std::process::id(), port));
std::fs::create_dir_all(&temp_dir)?;
let temp_dir_str = temp_dir.display().to_string();
let mut child = Command::new(rustfs_binary_path())
.env("RUST_LOG", "rustfs=info")
.env("RUSTFS_CONSOLE_ENABLE", "false")
.args([
"--address",
&address,
// Explicitly the compiled-in defaults, so the warning must fire.
"--access-key",
"rustfsadmin",
"--secret-key",
"rustfsadmin",
&temp_dir_str,
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
// Drain stdout + stderr concurrently into a shared buffer so a full
// pipe never blocks the child.
let captured = Arc::new(Mutex::new(String::new()));
let mut readers = Vec::new();
for stream in [
child.stdout.take().map(|s| Box::new(s) as Box<dyn Read + Send>),
child.stderr.take().map(|s| Box::new(s) as Box<dyn Read + Send>),
]
.into_iter()
.flatten()
{
let sink = Arc::clone(&captured);
readers.push(std::thread::spawn(move || {
let mut stream = stream;
let mut buf = [0u8; 4096];
loop {
match stream.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
if let Ok(mut guard) = sink.lock() {
guard.push_str(&String::from_utf8_lossy(&buf[..n]));
}
}
}
}
}));
}
const WARNING_NEEDLE: &str = "Detected default root credentials";
let deadline = Instant::now() + Duration::from_secs(30);
let mut seen = false;
while Instant::now() < deadline {
if let Ok(Some(status)) = child.try_wait() {
// Process exited; make a final check of what we captured.
seen = captured.lock().map(|g| g.contains(WARNING_NEEDLE)).unwrap_or(false);
assert!(
seen,
"server exited ({status}) before emitting the default-credentials warning; captured: {}",
captured.lock().map(|g| g.clone()).unwrap_or_default()
);
break;
}
if captured.lock().map(|g| g.contains(WARNING_NEEDLE)).unwrap_or(false) {
seen = true;
break;
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
let _ = child.kill();
let _ = child.wait();
for r in readers {
let _ = r.join();
}
let _ = std::fs::remove_dir_all(&temp_dir);
assert!(
seen,
"starting with default credentials must emit the default-credentials warning; captured: {}",
captured.lock().map(|g| g.clone()).unwrap_or_default()
);
Ok(())
}
}
@@ -62,6 +62,9 @@ mod tests {
let binary_path = rustfs_binary_path();
let mut command = Command::new(&binary_path);
command.env("RUST_LOG", "rustfs=info,rustfs_notify=debug");
// Keep the embedded console off its fixed default port :9001, which may
// already be taken by unrelated local services (e.g. Docker Desktop).
command.env("RUSTFS_CONSOLE_ENABLE", "false");
for (key, value) in extra_env {
command.env(key, value);
}
+51 -2
View File
@@ -28,7 +28,7 @@ use reqwest::Client as HttpClient;
use std::ffi::OsStr;
use std::fs as stdfs;
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::process::{Child, Command, Stdio};
use std::sync::Once;
use std::time::Duration;
use tokio::fs;
@@ -293,6 +293,10 @@ pub struct RustFSTestEnvironment {
pub access_key: String,
pub secret_key: String,
pub process: Option<Child>,
/// When set, the spawned server's stdout+stderr are redirected (append) to
/// this file instead of being inherited by the test process. Lets a test
/// grep the child's logs (e.g. to confirm which GET reader path ran).
pub capture_log_path: Option<String>,
}
impl RustFSTestEnvironment {
@@ -313,6 +317,7 @@ impl RustFSTestEnvironment {
access_key: DEFAULT_ACCESS_KEY.to_string(),
secret_key: DEFAULT_SECRET_KEY.to_string(),
process: None,
capture_log_path: None,
})
}
@@ -330,6 +335,7 @@ impl RustFSTestEnvironment {
access_key: DEFAULT_ACCESS_KEY.to_string(),
secret_key: DEFAULT_SECRET_KEY.to_string(),
process: None,
capture_log_path: None,
})
}
@@ -391,9 +397,20 @@ impl RustFSTestEnvironment {
let binary_path = rustfs_binary_path();
let mut command = Command::new(&binary_path);
command.env("RUST_LOG", "rustfs=info,rustfs_notify=debug");
// The embedded console would bind the fixed default port :9001, which
// collides with unrelated local services (e.g. Docker Desktop). Tests
// that need the console can still override this via extra_env.
command.env("RUSTFS_CONSOLE_ENABLE", "false");
for (key, value) in extra_env {
command.env(key, value);
}
// Optionally capture the child's stdout+stderr to a file so the test can
// grep server logs (e.g. to confirm which GET reader path was taken).
if let Some(log_path) = &self.capture_log_path {
let file = stdfs::OpenOptions::new().create(true).append(true).open(log_path)?;
let stderr_file = file.try_clone()?;
command.stdout(Stdio::from(file)).stderr(Stdio::from(stderr_file));
}
let process = command.args(&args).spawn()?;
self.process = Some(process);
@@ -435,11 +452,20 @@ impl RustFSTestEnvironment {
/// connections before the S3 stack is fully initialized, which causes
/// early requests to fail intermittently. Treat readiness as "S3 API
/// responds successfully" instead.
pub async fn wait_for_server_ready(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
///
/// Fails immediately if the spawned server process has already exited
/// (e.g. a port bind failure) instead of polling out the full timeout.
pub async fn wait_for_server_ready(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Waiting for RustFS server to be ready on {}", self.address);
let client = self.create_s3_client();
for i in 0..60 {
if let Some(process) = self.process.as_mut()
&& let Ok(Some(status)) = process.try_wait()
{
return Err(format!("RustFS server process exited before becoming ready: {status}").into());
}
if TcpStream::connect(&self.address).await.is_ok() && client.list_buckets().send().await.is_ok() {
info!("✅ RustFS server is ready after {} attempts", i + 1);
return Ok(());
@@ -501,6 +527,29 @@ impl Drop for RustFSTestEnvironment {
}
}
/// Short-interval replication timing overrides for fast e2e runs
/// (backlog#1147 repl-4).
///
/// Returns the full set of replication background-loop env vars with
/// millisecond values so failure-recovery scenarios converge in seconds
/// instead of the production defaults (health check 5s, MRF flush 10s,
/// resync retry poll up to 60s). Pass to
/// [`RustFSTestEnvironment::start_rustfs_server_with_env`] as
/// `&replication_fast_env()`. The server reads each value once at background
/// task startup, so the vars must be set before the process starts. Values
/// below 10ms are clamped server-side to avoid busy-spin.
pub fn replication_fast_env() -> Vec<(&'static str, &'static str)> {
// Env var names mirror `crates/ecstore/src/bucket/replication/replication_timing.rs`
// (this is a process-boundary contract: the vars are passed to the spawned
// server, not consumed in-process). The names are pinned by the
// `env_var_names_are_a_cross_process_contract` test in that module.
vec![
("RUSTFS_REPL_HEALTH_CHECK_INTERVAL_MS", "200"),
("RUSTFS_REPL_MRF_FLUSH_INTERVAL_MS", "100"),
("RUSTFS_REPL_RESYNC_POLL_MAX_MS", "250"),
]
}
async fn execute_awscurl_with_service(
url: &str,
method: &str,
+1
View File
@@ -56,6 +56,7 @@ async fn start_rustfs_with_compression(env: &mut RustFSTestEnvironment) -> Resul
let binary_path = rustfs_binary_path();
let process = Command::new(&binary_path)
.env("RUSTFS_CONSOLE_ENABLE", "false")
.env("RUSTFS_COMPRESSION_ENABLED", "true")
.args([
"--address",
@@ -0,0 +1,443 @@
// 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 regression net for the large-object degraded-read "unexpected EOF"
//! silent-truncation bug (dist-13, backlog#1150 / master plan backlog#1155).
//!
//! The bug: an EC reconstruct-read that failed *mid-stream* (a later block or
//! part could not be reconstructed) used to close the HTTP body cleanly under a
//! full `Content-Length`, so the client saw a `200` with a **truncated body**
//! and treated it as complete (e.g. `mc mirror` persisted the short file).
//!
//! It was fixed in three layers on `main`, each with its own *unit* regression:
//! * rustfs#4594 — `GetObjectStreamingReader::poll_read` now returns
//! `UnexpectedEof` on a short body instead of a clean `Ok(())`
//! (`rustfs/src/app/object_usecase.rs`,
//! `app::object_usecase::tests::get_object_streaming_reader_errors_on_short_eof`).
//! * rustfs#4560 — the lazy multipart codec reader degrades a later part to
//! the legacy per-part decode in place, and surfaces reconstruction errors
//! instead of silently truncating
//! (`crates/ecstore/src/set_disk/read.rs`, `set_disk::read` tests ~:4379).
//! * rustfs#4585 — DARE (`rio-v2`) detects stream truncation at a package
//! boundary (`crates/rio-v2/src/encrypt_reader.rs`).
//!
//! Those unit tests cover the reader layers in isolation. This suite covers the
//! layer they do not: the **full HTTP GET path** through a real spawned server,
//! streaming a real body reconstructed from real on-disk EC shards. The single
//! load-bearing invariant asserted everywhere below is:
//!
//! **A `2xx` GET response whose body collects without error MUST deliver the
//! complete object — its length equals the advertised `Content-Length` and
//! its bytes hash to the original. A short-but-clean body is the historical
//! bug and fails the test loudly. A degraded read that cannot be served must
//! fail (non-2xx, or a mid-stream body error), never truncate silently.**
//!
//! Topology: single-node 4-disk EC set (default 2 data + 2 parity), driven by
//! the in-process `DiskFaultHarness` (see `chaos.rs`). Objects are sized well
//! above the inline threshold and span multiple 1 MiB EC blocks so every read
//! exercises real shard reconstruction and a genuine mid-stream failure window.
#[cfg(test)]
mod tests {
use crate::chaos::DiskFaultHarness;
use crate::common::init_logging;
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
use serial_test::serial;
use sha2::{Digest, Sha256};
use std::error::Error;
use tokio::time::{Duration, timeout};
use tracing::info;
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
const GET_TIMEOUT: Duration = Duration::from_secs(90);
const PUT_TIMEOUT: Duration = Duration::from_secs(90);
/// 1 MiB EC block (`BLOCK_SIZE_V2`). A "≥2 EC stripes" object is anything
/// spanning more than one block; we use 6 MiB single objects and 5 MiB
/// multipart parts so every read crosses several block boundaries.
const MIB: usize = 1024 * 1024;
fn sha256_hex(data: &[u8]) -> String {
Sha256::digest(data).iter().map(|b| format!("{b:02x}")).collect()
}
/// Deterministic pseudo-random payload so corruption offsets and hashes are
/// reproducible across the 3x local flake runs.
fn payload(len: usize, seed: u8) -> Vec<u8> {
(0..len)
.map(|i| (i as u64).wrapping_mul(2654435761).wrapping_add(seed as u64) as u8)
.collect()
}
/// Render an error and its full source chain (SDK errors nest the useful
/// detail below the terse top-level `Display`).
fn render_err(err: &(dyn Error + 'static)) -> String {
let mut out = err.to_string();
let mut source = err.source();
while let Some(cause) = source {
out.push_str(" -> ");
out.push_str(&cause.to_string());
source = cause.source();
}
out
}
/// Outcome of a truncation-checked GET. The forbidden fourth possibility —
/// a `2xx` with a clean but short body — never reaches a variant: it panics
/// inside [`get_checked`] so it can never be silently tolerated.
#[derive(Debug)]
enum GetOutcome {
/// `2xx` and the fully collected body matches the expected length + hash.
CompleteMatch,
/// The transfer failed cleanly: a non-2xx status / send error, or a
/// mid-stream body error such as the `UnexpectedEof` from rustfs#4594.
/// Acceptable only for reads degraded beyond the read quorum.
CleanFailure(String),
}
/// Perform a GET and enforce the no-silent-truncation invariant.
///
/// Returns [`GetOutcome::CompleteMatch`] on a full, correct body, or
/// [`GetOutcome::CleanFailure`] on any clean failure. It **panics** if the
/// server returns a `2xx` whose body collects cleanly but is shorter than
/// the advertised `Content-Length` / expected length — that is exactly the
/// dist-13 bug.
async fn get_checked(
client: &Client,
bucket: &str,
key: &str,
expected_len: usize,
expected_sha256: &str,
phase: &str,
) -> Result<GetOutcome, Box<dyn Error + Send + Sync>> {
let send = timeout(GET_TIMEOUT, client.get_object().bucket(bucket).key(key).send())
.await
.map_err(|_| format!("GET {key} send timed out during {phase}"))?;
let response = match send {
Ok(response) => response,
Err(err) => {
return Ok(GetOutcome::CleanFailure(format!(
"GET {key} failed before body during {phase}: {}",
render_err(&err)
)));
}
};
// The advertised body length: this is the `expected` value that the
// fixed streaming reader (rustfs#4594) checks its emitted bytes against.
let advertised = response.content_length();
let collected = timeout(GET_TIMEOUT, response.body.collect())
.await
.map_err(|_| format!("GET {key} body collect timed out during {phase}"))?;
let bytes = match collected {
Ok(aggregated) => aggregated.into_bytes(),
Err(err) => {
// Fixed behavior for a beyond-quorum read: the stream aborts
// mid-body instead of ending cleanly short.
return Ok(GetOutcome::CleanFailure(format!(
"GET {key} body errored mid-stream during {phase}: {}",
render_err(&err)
)));
}
};
// Heart of the regression net: a 2xx body that collected without error
// MUST be the whole object. A short clean body under a full
// Content-Length is the historical silent truncation — fail loudly.
if let Some(advertised_len) = advertised {
assert_eq!(
bytes.len() as i64,
advertised_len,
"SILENT TRUNCATION during {phase}: GET {key} returned 2xx advertising \
Content-Length {advertised_len} but the body collected cleanly at {} bytes \
(dist-13 regression: rustfs#4594/#4560/#4585)",
bytes.len()
);
}
assert_eq!(
bytes.len(),
expected_len,
"GET {key} during {phase} returned a clean 2xx body of {} bytes, expected {expected_len} \
(silent truncation regression)",
bytes.len()
);
assert_eq!(
sha256_hex(&bytes),
expected_sha256,
"GET {key} during {phase} returned a full-length body whose hash does not match the original"
);
Ok(GetOutcome::CompleteMatch)
}
/// PUT a single-part object and return `(expected_len, sha256)`.
async fn put_object(
client: &Client,
bucket: &str,
key: &str,
body: Vec<u8>,
) -> Result<(usize, String), Box<dyn Error + Send + Sync>> {
let len = body.len();
let digest = sha256_hex(&body);
timeout(
PUT_TIMEOUT,
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(body))
.send(),
)
.await
.map_err(|_| format!("PUT {key} timed out"))??;
Ok((len, digest))
}
/// Complete a multipart upload from the given parts and return
/// `(total_len, sha256_of_concatenation)`.
async fn put_multipart(
client: &Client,
bucket: &str,
key: &str,
parts: Vec<Vec<u8>>,
) -> Result<(usize, String), Box<dyn Error + Send + Sync>> {
let full: Vec<u8> = parts.iter().flatten().copied().collect();
let total_len = full.len();
let digest = sha256_hex(&full);
let create = client.create_multipart_upload().bucket(bucket).key(key).send().await?;
let upload_id = create.upload_id().ok_or("missing upload id")?.to_string();
let mut completed = Vec::with_capacity(parts.len());
for (index, part_body) in parts.into_iter().enumerate() {
let part_number = (index + 1) as i32;
let uploaded = timeout(
PUT_TIMEOUT,
client
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.part_number(part_number)
.body(ByteStream::from(part_body))
.send(),
)
.await
.map_err(|_| format!("upload_part {part_number} for {key} timed out"))??;
completed.push(
CompletedPart::builder()
.part_number(part_number)
.e_tag(uploaded.e_tag().ok_or("missing part etag")?)
.build(),
);
}
timeout(
PUT_TIMEOUT,
client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed)).build())
.send(),
)
.await
.map_err(|_| format!("complete_multipart_upload {key} timed out"))??;
Ok((total_len, digest))
}
/// Scenario (a) — one disk offline: a large single-part object (≥2 EC
/// stripes) and a multipart object (3 parts × 5 MiB) must GET back as a
/// full, byte-identical body with the correct Content-Length. No early EOF.
#[tokio::test]
#[serial]
async fn degraded_read_large_objects_with_one_disk_offline_return_full_body() -> TestResult {
init_logging();
info!("dist-13 (a): large-object degraded read with one of four disks offline");
let mut harness = DiskFaultHarness::new(4).await?;
harness.start_server().await?;
let client = harness.env.create_s3_client();
let bucket = "dist13-degraded-offline";
client.create_bucket().bucket(bucket).send().await?;
// 6 MiB single object: 6 EC blocks, far above the inline threshold.
let single_key = "eof/large-single.bin";
let (single_len, single_sha) = put_object(&client, bucket, single_key, payload(6 * MIB, 41)).await?;
// Multipart: 3 parts × 5 MiB (the S3 minimum part size).
let multi_key = "eof/large-multipart.bin";
let (multi_len, multi_sha) = put_multipart(
&client,
bucket,
multi_key,
vec![payload(5 * MIB, 42), payload(5 * MIB, 43), payload(5 * MIB, 44)],
)
.await?;
// Baseline with every disk online.
assert!(matches!(
get_checked(&client, bucket, single_key, single_len, &single_sha, "baseline single").await?,
GetOutcome::CompleteMatch
));
assert!(matches!(
get_checked(&client, bucket, multi_key, multi_len, &multi_sha, "baseline multipart").await?,
GetOutcome::CompleteMatch
));
// One disk offline: EC 2+2 still has 3 shards, a full read quorum.
harness.take_disk_offline(0)?;
assert!(
matches!(
get_checked(&client, bucket, single_key, single_len, &single_sha, "degraded single").await?,
GetOutcome::CompleteMatch
),
"6 MiB single object must reconstruct fully with disk0 offline"
);
assert!(
matches!(
get_checked(&client, bucket, multi_key, multi_len, &multi_sha, "degraded multipart").await?,
GetOutcome::CompleteMatch
),
"15 MiB multipart object must reconstruct fully with disk0 offline"
);
Ok(())
}
/// Scenario (b) — bitrot within the read quorum: two shards of a large
/// multipart object are silently corrupted mid-file (2 of 4 in a 2+2 set,
/// leaving exactly a quorum). The GET must reconstruct the object and return
/// the complete, byte-identical body. Because the corruption sits in the
/// middle of the part file, block 0 reads clean, the `200` + full
/// Content-Length are already committed, and the bad block is only hit
/// mid-stream — the exact window the fixes had to reconstruct through rather
/// than truncate.
#[tokio::test]
#[serial]
async fn degraded_read_reconstructs_through_midstream_bitrot_within_quorum() -> TestResult {
init_logging();
info!("dist-13 (b): mid-stream bitrot within quorum must reconstruct a full body");
let mut harness = DiskFaultHarness::new(4).await?;
harness.start_server().await?;
let client = harness.env.create_s3_client();
let bucket = "dist13-bitrot-quorum";
client.create_bucket().bucket(bucket).send().await?;
let key = "eof/bitrot-multipart.bin";
let (len, sha) = put_multipart(
&client,
bucket,
key,
vec![payload(5 * MIB, 51), payload(5 * MIB, 52), payload(5 * MIB, 53)],
)
.await?;
assert!(matches!(
get_checked(&client, bucket, key, len, &sha, "baseline before corruption").await?,
GetOutcome::CompleteMatch
));
// Corrupt two shards (disks 0 and 1). A 2+2 set reconstructs from any
// two of four shards, so two clean shards remain and the read must
// still deliver the whole object.
harness.corrupt_object_shard(0, bucket, key)?;
harness.corrupt_object_shard(1, bucket, key)?;
assert!(
matches!(
get_checked(&client, bucket, key, len, &sha, "read through 2-shard bitrot").await?,
GetOutcome::CompleteMatch
),
"2-shard bitrot in a 2+2 set is within quorum and must reconstruct the full body"
);
// A repeat read confirms reconstruction is stable, not a one-shot.
assert!(matches!(
get_checked(&client, bucket, key, len, &sha, "second read through bitrot").await?,
GetOutcome::CompleteMatch
));
Ok(())
}
/// Scenario (c) — degraded beyond the read quorum (THE HEART OF THE NET):
/// three shards of a large multipart object are corrupted mid-file (3 of 4
/// in a 2+2 set → only one clean shard, below the 2-shard quorum). Block 0
/// still reads clean, so the server commits `200` + the full Content-Length
/// and starts streaming; the corrupted middle block then cannot be
/// reconstructed. The read MUST fail — a non-2xx status or a mid-stream body
/// error — and MUST NOT close cleanly with a truncated body under the full
/// Content-Length. `get_checked` panics on that forbidden outcome, so this
/// test fails loudly if the truncation bug ever returns.
#[tokio::test]
#[serial]
async fn beyond_quorum_degraded_read_never_silently_truncates() -> TestResult {
init_logging();
info!("dist-13 (c): beyond-quorum degraded read must fail, never 200+truncated");
let mut harness = DiskFaultHarness::new(4).await?;
harness.start_server().await?;
let client = harness.env.create_s3_client();
let bucket = "dist13-beyond-quorum";
client.create_bucket().bucket(bucket).send().await?;
let key = "eof/beyond-quorum-multipart.bin";
let (len, sha) = put_multipart(
&client,
bucket,
key,
vec![payload(5 * MIB, 61), payload(5 * MIB, 62), payload(5 * MIB, 63)],
)
.await?;
assert!(matches!(
get_checked(&client, bucket, key, len, &sha, "baseline before over-corruption").await?,
GetOutcome::CompleteMatch
));
// Corrupt three of four shards: below the 2-shard read quorum, so the
// corrupted block cannot be reconstructed.
harness.corrupt_object_shard(0, bucket, key)?;
harness.corrupt_object_shard(1, bucket, key)?;
harness.corrupt_object_shard(2, bucket, key)?;
// The invariant is enforced inside get_checked (it panics on a clean
// short 2xx body). A CleanFailure here is the correct, fixed behavior;
// an (implausible) CompleteMatch would also be acceptable. The only
// failing outcome is the silent truncation the net exists to catch.
match get_checked(&client, bucket, key, len, &sha, "beyond-quorum read").await? {
GetOutcome::CleanFailure(reason) => {
info!("beyond-quorum read failed cleanly as required: {reason}");
}
GetOutcome::CompleteMatch => {
info!("beyond-quorum read unexpectedly reconstructed a full body; not a truncation, accepted");
}
}
Ok(())
}
}
@@ -0,0 +1,535 @@
// 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.
//! GET codec-streaming fast-path body/header compatibility net (backlog#1183).
//!
//! backlog#1183 tracks flipping the default GET data path from the legacy
//! `tokio::io::duplex` double-copy (`GET_OBJECT_PATH_LEGACY_DUPLEX`) to the
//! zero-duplex codec-streaming fast path (`GET_OBJECT_PATH_CODEC_STREAMING`,
//! `crates/ecstore/src/set_disk/ops/object.rs`). That flip is gated behind two
//! deliberate safety confirmations — `RUSTFS_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED`
//! and `..._HEADER_COMPAT_CONFIRMED` (`crates/ecstore/src/set_disk/mod.rs`) —
//! because it rewrites the GET hot path's data flow and any divergence is a
//! data-availability incident.
//!
//! This suite provides the empirical evidence those two gates ask for. It runs
//! the SAME object matrix twice against the SAME on-disk EC shards, changing
//! only the codec-streaming env gates between runs, and asserts that the codec
//! path is **byte-for-byte and header-for-header identical** to the legacy
//! duplex path:
//!
//! * Phase A (baseline): default env → GETs take `GET_OBJECT_PATH_LEGACY_DUPLEX`.
//! * Phase B (codec): gates opened → GETs take `GET_OBJECT_PATH_CODEC_STREAMING`.
//!
//! Path confirmation is not assumed: the legacy path emits a
//! `"Created duplex pipe for object data transfer"` debug line per full GET, so
//! the test captures each phase's server log and asserts the baseline phase
//! created duplex pipes for the large objects while the codec phase created
//! **zero** — proving the codec path actually ran rather than silently falling
//! back to the very path it is being compared against.
//!
//! Beyond the all-healthy happy path, the suite also drives the codec/legacy
//! A/B under two conditions the `DiskFaultHarness` makes reachable:
//!
//! * 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.
//! * 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.
//!
//! Topology: single-node 4-disk EC set (default 2 data + 2 parity) via the
//! in-process `DiskFaultHarness` (see `chaos.rs`). Small objects are inlined
//! into `xl.meta` (served identically on both paths); large objects span one or
//! more 1 MiB EC blocks so real shard reconstruction runs.
#[cfg(test)]
mod tests {
use crate::chaos::DiskFaultHarness;
use crate::common::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};
use serial_test::serial;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::error::Error;
use tokio::time::{Duration, sleep};
use tracing::info;
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
const MIB: usize = 1024 * 1024;
const BUCKET: &str = "codec-streaming-compat";
const CONTENT_TYPE: &str = "application/x-rustfs-compat";
/// A key that is never uploaded — used to compare the NoSuchKey negative
/// path across the legacy and codec phases.
const MISSING_KEY: &str = "does-not-exist/ghost.bin";
/// Marker the legacy duplex GET path logs once per full-object read
/// (`crates/ecstore/src/set_disk/ops/object.rs`). Its presence/absence in a
/// phase's captured server log tells us which reader path actually ran.
const DUPLEX_MARKER: &str = "Created duplex pipe for object data transfer";
fn sha256_hex(data: &[u8]) -> String {
Sha256::digest(data).iter().map(|b| format!("{b:02x}")).collect()
}
/// Deterministic pseudo-random payload so hashes are reproducible.
fn payload(len: usize, seed: u8) -> Vec<u8> {
(0..len)
.map(|i| (i as u64).wrapping_mul(2654435761).wrapping_add(seed as u64) as u8)
.collect()
}
/// A comparable projection of the GET response headers we require the codec
/// path to reproduce exactly. Stored in a `BTreeMap` for a stable, readable
/// diff on mismatch.
#[derive(Debug, Clone, PartialEq, Eq)]
struct GetView {
sha256: String,
len: usize,
headers: BTreeMap<String, String>,
}
fn header_projection(resp: &aws_sdk_s3::operation::get_object::GetObjectOutput) -> BTreeMap<String, String> {
let mut m = BTreeMap::new();
m.insert("content-length".into(), resp.content_length().unwrap_or(-1).to_string());
m.insert("etag".into(), resp.e_tag().unwrap_or("<none>").to_string());
m.insert("content-type".into(), resp.content_type().unwrap_or("<none>").to_string());
m.insert("accept-ranges".into(), resp.accept_ranges().unwrap_or("<none>").to_string());
m.insert("content-encoding".into(), resp.content_encoding().unwrap_or("<none>").to_string());
m.insert("content-disposition".into(), resp.content_disposition().unwrap_or("<none>").to_string());
m.insert("cache-control".into(), resp.cache_control().unwrap_or("<none>").to_string());
m.insert("content-range".into(), resp.content_range().unwrap_or("<none>").to_string());
m.insert("version-id".into(), resp.version_id().unwrap_or("<none>").to_string());
m.insert(
"last-modified".into(),
resp.last_modified()
.map(|t| t.secs().to_string())
.unwrap_or_else(|| "<none>".into()),
);
// User metadata (x-amz-meta-*), order-independent.
if let Some(meta) = resp.metadata() {
let mut sorted: BTreeMap<&String, &String> = BTreeMap::new();
for (k, v) in meta {
sorted.insert(k, v);
}
for (k, v) in sorted {
m.insert(format!("meta:{k}"), v.clone());
}
}
m
}
/// Full-object GET, returning body hash/len + the header projection.
async fn get_full(client: &Client, key: &str) -> Result<GetView, Box<dyn Error + Send + Sync>> {
let resp = client.get_object().bucket(BUCKET).key(key).send().await?;
let headers = header_projection(&resp);
let body = resp.body.collect().await?.into_bytes();
Ok(GetView {
sha256: sha256_hex(&body),
len: body.len(),
headers,
})
}
/// Ranged GET, returning body hash/len + header projection.
async fn get_range(client: &Client, key: &str, range: &str) -> Result<GetView, Box<dyn Error + Send + Sync>> {
let resp = client.get_object().bucket(BUCKET).key(key).range(range).send().await?;
let headers = header_projection(&resp);
let body = resp.body.collect().await?.into_bytes();
Ok(GetView {
sha256: sha256_hex(&body),
len: body.len(),
headers,
})
}
/// GET a key expected to be absent, projecting the wire-visible error
/// semantics — HTTP status code plus the S3 error code — so the legacy and
/// codec phases can be asserted to reject a missing object identically. A
/// missing object fails during metadata resolution, before the reader-path
/// gate is consulted, so both phases MUST agree; a divergence here would
/// mean the codec env perturbed the negative path.
async fn missing_key_semantics(client: &Client, key: &str) -> Result<(u16, String), Box<dyn Error + Send + Sync>> {
match client.get_object().bucket(BUCKET).key(key).send().await {
Ok(_) => Err(format!("GET {key} unexpectedly succeeded; expected a NoSuchKey error").into()),
Err(err) => {
let status = err.raw_response().map(|r| r.status().as_u16()).unwrap_or(0);
let code = err.as_service_error().and_then(|e| e.code()).unwrap_or("<none>").to_string();
Ok((status, code))
}
}
}
/// Env that opens every codec-streaming gate to 100% for the codec phase.
/// Mirrors the exact knobs `get_codec_streaming_reader_gate` inspects
/// (`crates/ecstore/src/set_disk/mod.rs`).
fn codec_env() -> Vec<(&'static str, &'static str)> {
vec![
("RUSTFS_GET_CODEC_STREAMING_ENABLE", "true"),
("RUSTFS_GET_CODEC_STREAMING_ROLLOUT", "internal"),
("RUSTFS_GET_CODEC_STREAMING_ROLLOUT_PCT", "100"),
("RUSTFS_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED", "true"),
("RUSTFS_GET_CODEC_STREAMING_HEADER_COMPAT_CONFIRMED", "true"),
// Lower the min-size floor so every non-inline object below is eligible.
("RUSTFS_GET_CODEC_STREAMING_MIN_SIZE", "4096"),
// Route multipart objects through per-part codec streaming too.
("RUSTFS_GET_CODEC_STREAMING_MULTIPART_ENABLE", "true"),
// Lock optimization is on by default, but pin it so the gate's
// `LockOptimizationDisabled` fallback can never mask the codec path.
("RUSTFS_OBJECT_LOCK_OPTIMIZATION_ENABLE", "true"),
]
}
async fn put_plain(client: &Client, key: &str, data: &[u8]) -> TestResult {
client
.put_object()
.bucket(BUCKET)
.key(key)
.content_type(CONTENT_TYPE)
.metadata("compat", "yes")
.metadata("shape", "single-part")
.body(ByteStream::from(data.to_vec()))
.send()
.await?;
Ok(())
}
/// Upload a 2-part multipart object; returns the concatenated payload.
async fn put_multipart(
client: &Client,
key: &str,
part_len: usize,
seed: u8,
) -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
let create = client
.create_multipart_upload()
.bucket(BUCKET)
.key(key)
.content_type(CONTENT_TYPE)
.metadata("compat", "yes")
.metadata("shape", "multipart")
.send()
.await?;
let upload_id = create.upload_id().ok_or("missing upload id")?.to_string();
let mut whole = Vec::new();
let mut completed = Vec::new();
for part_number in 1..=2i32 {
let part = payload(part_len, seed.wrapping_add(part_number as u8));
whole.extend_from_slice(&part);
let resp = client
.upload_part()
.bucket(BUCKET)
.key(key)
.upload_id(&upload_id)
.part_number(part_number)
.body(ByteStream::from(part))
.send()
.await?;
completed.push(
CompletedPart::builder()
.part_number(part_number)
.e_tag(resp.e_tag().unwrap_or_default())
.build(),
);
}
client
.complete_multipart_upload()
.bucket(BUCKET)
.key(key)
.upload_id(&upload_id)
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed)).build())
.send()
.await?;
Ok(whole)
}
fn count_marker(log_path: &str, marker: &str) -> usize {
std::fs::read_to_string(log_path)
.map(|s| s.lines().filter(|l| l.contains(marker)).count())
.unwrap_or(0)
}
/// Object shapes exercised. `expect_large` marks objects that are stored as
/// real EC shards (not inlined), i.e. the ones that take the duplex path in
/// the baseline phase and must switch to codec streaming in the codec phase.
struct Shape {
key: &'static str,
expect_large: bool,
}
#[tokio::test]
#[serial]
async fn codec_streaming_matches_legacy_duplex_body_and_headers() -> TestResult {
init_logging();
let scratch = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".into());
let run_id = uuid::Uuid::new_v4();
let base_log = format!("{scratch}/codec_compat_baseline_{run_id}.log");
let codec_log = format!("{scratch}/codec_compat_codec_{run_id}.log");
let mut harness = DiskFaultHarness::new(4).await?;
// Capture ecstore debug logs so we can count the legacy duplex marker.
harness.set_env("RUST_LOG", "rustfs=info,rustfs_ecstore=debug");
// ---- Phase A: baseline (default env → legacy duplex) ----
harness.env.capture_log_path = Some(base_log.clone());
harness.start_server().await?;
let client = harness.env.create_s3_client();
client.create_bucket().bucket(BUCKET).send().await?;
// Object matrix: sizes crossing the inline boundary, the codec min-size
// and the 1 MiB EC-block boundary, plus a multipart object.
let plain: &[(Shape, Vec<u8>)] = &[
(
Shape {
key: "inline-1kib",
expect_large: false,
},
payload(1024, 1),
),
(
Shape {
key: "small-64kib",
expect_large: false,
},
payload(64 * 1024, 2),
),
(
Shape {
key: "mid-1_5mib",
expect_large: true,
},
payload(MIB + MIB / 2, 3),
),
(
Shape {
key: "large-3mib",
expect_large: true,
},
payload(3 * MIB, 4),
),
(
Shape {
key: "large-5mib-plus",
expect_large: true,
},
payload(5 * MIB + 12345, 5),
),
];
for (shape, data) in plain {
put_plain(&client, shape.key, data).await?;
}
let multipart_key = "multipart-2x5mib";
let multipart_body = put_multipart(&client, multipart_key, 5 * MIB, 40).await?;
// Full-object baseline GETs.
let mut baseline: BTreeMap<String, GetView> = BTreeMap::new();
for (shape, data) in plain {
let view = get_full(&client, shape.key).await?;
assert_eq!(view.sha256, sha256_hex(data), "baseline body mismatch for {}", shape.key);
assert_eq!(view.len, data.len(), "baseline length mismatch for {}", shape.key);
baseline.insert(shape.key.to_string(), view);
}
let mp_view = get_full(&client, multipart_key).await?;
assert_eq!(mp_view.sha256, sha256_hex(&multipart_body), "baseline multipart body mismatch");
baseline.insert(multipart_key.to_string(), mp_view);
// Range GET baseline (a range that starts mid-first-block and crosses a
// block boundary) on a large object.
let range_spec = "bytes=1048570-2097160";
let baseline_range = get_range(&client, "large-3mib", range_spec).await?;
// Flush + snapshot the baseline duplex count.
sleep(Duration::from_millis(300)).await;
let num_large = plain.iter().filter(|(s, _)| s.expect_large).count() + 1; // + multipart
let dup_base = count_marker(&base_log, DUPLEX_MARKER);
info!(dup_base, num_large, "baseline duplex marker count");
assert!(
dup_base >= num_large,
"baseline phase should have used the legacy duplex path for the {num_large} large objects, but only saw {dup_base} duplex markers in {base_log}"
);
// Legacy negative path: a GET for an absent key must fail with a
// well-formed NoSuchKey (404). Captured now so Phase B can prove the
// codec env returns the identical error semantics.
let legacy_missing = missing_key_semantics(&client, MISSING_KEY).await?;
assert_eq!(
legacy_missing,
(404, "NoSuchKey".to_string()),
"legacy GET of a missing key should be 404/NoSuchKey, got {legacy_missing:?}"
);
// ---- Phase A degraded: pull one data disk, force parity reconstruction ----
// With disk0 offline the EC 2+2 set must rebuild every large object's
// data from the surviving data+parity shards. Record the legacy-duplex
// bytes and headers produced under reconstruction so Phase B can prove
// the codec path reconstructs the same bytes and headers. The duplex
// marker snapshot (`dup_base`) is already taken, so these extra reads do
// not affect the path-confirmation assertion above.
harness.take_disk_offline(0)?;
let mut baseline_degraded: BTreeMap<String, GetView> = BTreeMap::new();
for (shape, data) in plain {
let view = get_full(&client, shape.key).await?;
assert_eq!(view.sha256, sha256_hex(data), "degraded baseline body mismatch for {}", shape.key);
assert_eq!(view.len, data.len(), "degraded baseline length mismatch for {}", shape.key);
baseline_degraded.insert(shape.key.to_string(), view);
}
let mp_view = get_full(&client, multipart_key).await?;
assert_eq!(mp_view.sha256, sha256_hex(&multipart_body), "degraded baseline multipart body mismatch");
baseline_degraded.insert(multipart_key.to_string(), mp_view);
// Restore the disk so Phase B restarts from a clean, complete disk set.
harness.bring_disk_online(0)?;
// ---- Phase B: codec streaming (gates opened) ----
harness.kill_server();
for (k, v) in codec_env() {
harness.set_env(k, v);
}
harness.env.capture_log_path = Some(codec_log.clone());
harness.restart_server().await?;
let client = harness.env.create_s3_client();
// Full-object codec GETs — compare byte-for-byte and header-for-header.
let mut codec: BTreeMap<String, GetView> = BTreeMap::new();
for (shape, data) in plain {
let view = get_full(&client, shape.key).await?;
assert_eq!(view.sha256, sha256_hex(data), "codec body mismatch for {}", shape.key);
codec.insert(shape.key.to_string(), view);
}
let mp_view = get_full(&client, multipart_key).await?;
assert_eq!(mp_view.sha256, sha256_hex(&multipart_body), "codec multipart body mismatch");
codec.insert(multipart_key.to_string(), mp_view);
// Negative-path equivalence: the codec env must return the exact same
// status + error code as the legacy phase for a missing key.
let codec_missing = missing_key_semantics(&client, MISSING_KEY).await?;
assert_eq!(
codec_missing, legacy_missing,
"NoSuchKey error semantics diverged between codec and legacy phases: codec={codec_missing:?} legacy={legacy_missing:?}"
);
// Snapshot the codec-phase duplex count BEFORE issuing the ranged GET
// (range falls back to the duplex path by design and would pollute it).
sleep(Duration::from_millis(300)).await;
let dup_codec = count_marker(&codec_log, DUPLEX_MARKER);
info!(dup_codec, "codec phase duplex marker count (full GETs only)");
// Header + body equivalence: codec == baseline for every object.
for key in baseline.keys() {
let b = &baseline[key];
let c = &codec[key];
assert_eq!(c.sha256, b.sha256, "body hash diverged for {key}");
assert_eq!(c.len, b.len, "body length diverged for {key}");
assert_eq!(
c.headers, b.headers,
"response headers diverged for {key}\nbaseline={:#?}\ncodec={:#?}",
b.headers, c.headers
);
}
// Path confirmation: the codec phase must NOT have created any duplex
// pipe for the full-object GETs — otherwise it silently fell back to the
// legacy path and the equivalence above proves nothing.
assert_eq!(
dup_codec, 0,
"codec phase created {dup_codec} duplex pipe(s) for full GETs; the codec-streaming fast path was not exercised (see {codec_log})"
);
// Range GET while codec streaming is enabled. NOTE ON COVERAGE: the
// reader gate unconditionally routes every ranged request back to the
// legacy duplex path (`GetCodecStreamingFallbackReason::Range`), so both
// `baseline_range` and `codec_range` are produced by the SAME legacy
// path. This assertion therefore only verifies that ranged GETs keep
// working (and keep falling back to legacy) with the codec gates open —
// it does NOT exercise or validate a codec-streaming range reader, which
// does not exist. It must not be read as codec range-correctness
// coverage.
let codec_range = get_range(&client, "large-3mib", range_spec).await?;
assert_eq!(
codec_range.sha256, baseline_range.sha256,
"ranged GET body diverged with codec streaming enabled (both served by the legacy range path)"
);
assert_eq!(
codec_range.len, baseline_range.len,
"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.
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();
for (shape, data) in plain {
let view = get_full(&client, shape.key).await?;
assert_eq!(view.sha256, sha256_hex(data), "degraded codec body mismatch for {}", shape.key);
codec_degraded.insert(shape.key.to_string(), view);
}
let mp_view = get_full(&client, multipart_key).await?;
assert_eq!(mp_view.sha256, sha256_hex(&multipart_body), "degraded codec multipart body mismatch");
codec_degraded.insert(multipart_key.to_string(), mp_view);
harness.bring_disk_online(0)?;
// A/B under parity reconstruction: codec == legacy, byte-for-byte and
// header-for-header, for every object in the matrix.
for key in baseline_degraded.keys() {
let b = &baseline_degraded[key];
let c = &codec_degraded[key];
assert_eq!(c.sha256, b.sha256, "degraded body hash diverged for {key} (parity reconstruction)");
assert_eq!(c.len, b.len, "degraded body length diverged for {key} (parity reconstruction)");
assert_eq!(
c.headers, b.headers,
"degraded response headers diverged for {key} (parity reconstruction)\nbaseline={:#?}\ncodec={:#?}",
b.headers, c.headers
);
}
// 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.
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!(
objects = baseline.len(),
"codec streaming produced byte- and header-identical GET responses vs legacy duplex (healthy + parity-reconstructed + NoSuchKey)"
);
// Best-effort cleanup of the capture logs.
let _ = std::fs::remove_file(&base_log);
let _ = std::fs::remove_file(&codec_log);
Ok(())
}
}
+28
View File
@@ -27,6 +27,16 @@ pub mod chaos;
#[cfg(test)]
mod reliability_disk_fault_test;
// dist-13 (backlog#1150/#1155): e2e regression net proving a large-object
// degraded EC read never returns a silently truncated body (rustfs#4594/#4560/#4585).
#[cfg(test)]
mod degraded_read_eof_regression_test;
// backlog#1183: GET codec-streaming fast path must be byte/header identical to
// the legacy duplex path before its rollout gates can be flipped on by default.
#[cfg(test)]
mod get_codec_streaming_compat_test;
#[cfg(test)]
mod version_id_regression_test;
@@ -46,6 +56,10 @@ mod list_objects_duplicates_test;
#[cfg(test)]
mod quota_test;
// Harness regression tests: console port isolation + fail-fast startup
#[cfg(test)]
mod server_startup_failfast_test;
#[cfg(test)]
mod bucket_policy_check_test;
@@ -53,6 +67,10 @@ mod bucket_policy_check_test;
#[cfg(test)]
mod security_boundary_test;
// Admin authorization gate: non-admin denial + root-credential lifecycle (backlog#1151 sec-4)
#[cfg(test)]
mod admin_auth_test;
/// IAM / bucket / STS session policy with `s3:ExistingObjectTag` conditions (E2E).
#[cfg(test)]
mod existing_object_tag_policy_test;
@@ -156,6 +174,16 @@ mod bucket_logging_test;
#[cfg(test)]
mod multipart_auth_test;
// Negative presigned-URL (query-string SigV4) regression suite (backlog#1151
// sec-2): expired, tampered signature, wrong secret, tampered target.
#[cfg(test)]
mod presigned_negative_test;
// Negative header-SigV4 regression suite (backlog#1151 sec-1): tampered
// signature, wrong secret, skewed date, malformed Authorization.
#[cfg(test)]
mod negative_sigv4_test;
#[cfg(test)]
mod stale_multipart_cleanup_cluster_test;
@@ -198,4 +198,121 @@ mod tests {
env.stop_server();
}
/// Test ensuring that a plain object and a same-named prefix coexist in
/// delimiter listings on a single-disk deployment.
///
/// Bug Reference: backlog#880 / backlog#1042
/// On a single disk, object `a` and its children `a/...` share one backing
/// directory, so the non-recursive scan used to classify `a` as an object
/// and never produce the prefix entry `a/`. Delimiter="/" listings then
/// returned Contents `a` but silently dropped CommonPrefix `a/`.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_object_and_same_named_prefix_coexist() {
init_logging();
info!("Starting test: ListObjectsV2 should return both object `a` and CommonPrefix `a/`");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
.await
.expect("Failed to start RustFS");
let client = create_s3_client(&env);
let bucket = "test-list-object-prefix-coexist";
create_bucket(&client, bucket).await.expect("Failed to create bucket");
for (key, body) in [
("a", ByteStream::from_static(b"object body")),
("a/b", ByteStream::from_static(b"child body")),
("plain", ByteStream::from_static(b"no children")),
] {
client
.put_object()
.bucket(bucket)
.key(key)
.body(body)
.send()
.await
.unwrap_or_else(|err| panic!("Failed to create test object {key}: {err}"));
}
let result = client
.list_objects_v2()
.bucket(bucket)
.delimiter("/")
.send()
.await
.expect("Failed to list objects");
let keys: Vec<&str> = result.contents().iter().filter_map(|object| object.key()).collect();
let prefixes: Vec<&str> = result.common_prefixes().iter().filter_map(|prefix| prefix.prefix()).collect();
info!("Contents: {:?}, CommonPrefixes: {:?}", keys, prefixes);
assert_eq!(keys, vec!["a", "plain"], "objects `a` and `plain` must both stay in Contents");
assert_eq!(
prefixes,
vec!["a/"],
"prefix `a/` must be listed and `plain/` must not appear as a phantom prefix"
);
// Children are still reachable under the prefix.
let nested = client
.list_objects_v2()
.bucket(bucket)
.prefix("a/")
.delimiter("/")
.send()
.await
.expect("Failed to list objects under prefix");
let nested_keys: Vec<&str> = nested.contents().iter().filter_map(|object| object.key()).collect();
assert_eq!(nested_keys, vec!["a/b"]);
// Pagination must keep both entries across page boundaries: `a` sorts
// before `a/`, so a one-key page splits them.
let page1 = client
.list_objects_v2()
.bucket(bucket)
.delimiter("/")
.max_keys(1)
.send()
.await
.expect("Failed to list first page");
let page1_keys: Vec<&str> = page1.contents().iter().filter_map(|object| object.key()).collect();
assert_eq!(page1_keys, vec!["a"]);
assert_eq!(page1.is_truncated(), Some(true), "one-key first page must be truncated");
let mut token = page1.next_continuation_token().map(ToOwned::to_owned);
let mut remaining_keys = Vec::new();
let mut remaining_prefixes = Vec::new();
while let Some(continuation) = token {
let page = client
.list_objects_v2()
.bucket(bucket)
.delimiter("/")
.max_keys(1)
.continuation_token(continuation)
.send()
.await
.expect("Failed to list continuation page");
remaining_keys.extend(
page.contents()
.iter()
.filter_map(|object| object.key().map(ToOwned::to_owned)),
);
remaining_prefixes.extend(
page.common_prefixes()
.iter()
.filter_map(|prefix| prefix.prefix().map(ToOwned::to_owned)),
);
token = page.next_continuation_token().map(ToOwned::to_owned);
}
assert_eq!(remaining_prefixes, vec!["a/".to_string()], "prefix `a/` must survive pagination");
assert_eq!(remaining_keys, vec!["plain".to_string()]);
env.stop_server();
}
}
@@ -1029,4 +1029,67 @@ mod tests {
env.stop_server();
}
/// Test that continuation pages do not skip keys sorting between the marker
/// and the marker plus the cursor tag.
///
/// Bug Reference: backlog#1047
/// The V2 continuation token appends a `[rustfs_cache:...]` cursor tag to the
/// last returned key. The orchestration layer passed that raw string to
/// `forward_past`, so any key whose byte after the shared stem sorts below
/// `[` (0x5B) — `.`, `/`, `-`, digits, uppercase — was silently dropped on
/// the next page: with keys `a`, `a.txt`, `zz` and max_keys=1, page 2
/// returned `zz` and `a.txt` was never listed.
#[tokio::test]
#[serial]
async fn test_list_objects_v2_continuation_keeps_keys_after_marker_stem() {
init_logging();
info!("Starting test: continuation must not skip keys sorting below the cursor tag");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
.await
.expect("Failed to start RustFS");
let client = create_s3_client(&env);
let bucket = "test-continuation-marker-stem";
create_bucket(&client, bucket).await.expect("Failed to create bucket");
let expected_keys = ["a", "a.txt", "zz"];
for key in expected_keys {
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"content"))
.send()
.await
.unwrap_or_else(|err| panic!("Failed to create test object {key}: {err}"));
}
let mut collected: Vec<String> = Vec::new();
let mut token: Option<String> = None;
loop {
let mut request = client.list_objects_v2().bucket(bucket).max_keys(1);
if let Some(continuation) = token.take() {
request = request.continuation_token(continuation);
}
let page = request.send().await.expect("Failed to list objects");
collected.extend(
page.contents()
.iter()
.filter_map(|object| object.key().map(ToOwned::to_owned)),
);
token = page.next_continuation_token().map(ToOwned::to_owned);
if token.is_none() {
break;
}
assert!(collected.len() <= expected_keys.len() + 1, "pagination did not converge: {collected:?}");
}
assert_eq!(collected, expected_keys, "every key must survive pagination in order");
env.stop_server();
}
}
+382
View File
@@ -0,0 +1,382 @@
// 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.
//! Negative header-SigV4 regression suite (backlog#1151 sec-1).
//!
//! RustFS delegates SigV4 verification to the `s3s` dependency, so nothing in
//! this repository pins OUR end-to-end wiring of it: a future dependency swap
//! or misconfiguration could silently start accepting forged header
//! signatures. These tests send REJECTED SigV4 header-auth requests against a
//! live server and assert the HTTP status plus the S3 error code in the
//! response XML, guarding the rejection contract regardless of who performs
//! the underlying verification.
//!
//! Signatures are hand-built (rather than produced by the AWS SDK, which
//! cannot emit an invalid signature) by reusing the primitive HMAC/scope
//! helpers from `rustfs_signer::request_signature_v4`. This gives the test
//! full control over the timestamp, secret key, signed payload hash, and the
//! final signature bytes.
//!
//! Missing-credential negatives are intentionally NOT duplicated here: those
//! are already covered by `multipart_auth_test` (anonymous / no-credential
//! cases) and `anonymous_access_test`. This module covers only PRESENT but
//! rejected header-SigV4 requests.
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use aws_sdk_s3::primitives::ByteStream;
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::request_signature_v4::{SIGN_V4_ALGORITHM, get_scope, get_signature, get_signing_key};
use serial_test::serial;
use std::fmt::Write as _;
use time::macros::format_description;
use time::{Duration, OffsetDateTime};
use tracing::info;
const REGION: &str = "us-east-1";
const BUCKET: &str = "negative-sigv4-bucket";
/// Lowercase hex encoding (matches SigV4 canonical hex format).
fn hex_lower(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(out, "{b:02x}");
}
out
}
fn sha256_hex(data: &[u8]) -> String {
use sha2::{Digest, Sha256};
hex_lower(&Sha256::digest(data))
}
fn amz_datetime(t: OffsetDateTime) -> String {
let fmt = format_description!("[year][month][day]T[hour][minute][second]Z");
t.format(&fmt).expect("format x-amz-date")
}
/// A minimal hand-rolled SigV4 header signer with full control over every
/// input, so tests can deliberately produce forged / stale / mismatched
/// requests. Always signs exactly `host;x-amz-content-sha256;x-amz-date`.
struct SigV4 {
access_key: String,
secret_key: String,
host: String,
time: OffsetDateTime,
}
struct SignedHeaders {
authorization: String,
amz_date: String,
content_sha256: String,
}
impl SigV4 {
fn new(env: &RustFSTestEnvironment) -> Self {
Self {
access_key: env.access_key.clone(),
secret_key: env.secret_key.clone(),
host: env.address.clone(),
time: OffsetDateTime::now_utc(),
}
}
/// Build the Authorization header (and the companion `x-amz-date` /
/// `x-amz-content-sha256` header values) for a request.
///
/// `content_sha256` is the value placed in the `x-amz-content-sha256`
/// header AND folded into the canonical request — pass the hash of the
/// body you *claim* to send, which may differ from what you actually send.
fn sign(&self, method: &str, path: &str, canonical_query: &str, content_sha256: &str) -> SignedHeaders {
let amz_date = amz_datetime(self.time);
let signed_headers = "host;x-amz-content-sha256;x-amz-date";
let canonical_headers = format!(
"host:{host}\nx-amz-content-sha256:{sha}\nx-amz-date:{date}\n",
host = self.host,
sha = content_sha256,
date = amz_date,
);
let canonical_request =
format!("{method}\n{path}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{content_sha256}");
let scope = get_scope(REGION, self.time, "s3");
let string_to_sign = format!("{SIGN_V4_ALGORITHM}\n{amz_date}\n{scope}\n{}", sha256_hex(canonical_request.as_bytes()));
let signing_key = get_signing_key(&self.secret_key, REGION, self.time, "s3");
let signature = get_signature(signing_key, &string_to_sign);
let credential = format!("{}/{scope}", self.access_key);
let authorization =
format!("{SIGN_V4_ALGORITHM} Credential={credential}, SignedHeaders={signed_headers}, Signature={signature}");
SignedHeaders {
authorization,
amz_date,
content_sha256: content_sha256.to_string(),
}
}
}
/// Send a request carrying explicit SigV4 headers. `reqwest` populates `Host`
/// (matching the signed host) and `Content-Length` automatically.
async fn send_signed(
env: &RustFSTestEnvironment,
method: reqwest::Method,
path: &str,
headers: &SignedHeaders,
body: Option<Vec<u8>>,
) -> reqwest::Result<reqwest::Response> {
let url = format!("{}{}", env.url, path);
let mut builder = local_http_client()
.request(method, &url)
.header("x-amz-date", &headers.amz_date)
.header("x-amz-content-sha256", &headers.content_sha256)
.header("authorization", &headers.authorization);
if let Some(body) = body {
builder = builder.body(body);
}
builder.send().await
}
/// Send a request with a raw (possibly malformed) Authorization header while
/// keeping the other SigV4 headers well-formed.
async fn send_raw_authorization(
env: &RustFSTestEnvironment,
method: reqwest::Method,
path: &str,
authorization: &str,
) -> reqwest::Result<reqwest::Response> {
let url = format!("{}{}", env.url, path);
local_http_client()
.request(method, &url)
.header("x-amz-date", amz_datetime(OffsetDateTime::now_utc()))
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD)
.header("authorization", authorization)
.send()
.await
}
fn assert_error_code(body: &str, code: &str) {
assert!(
body.contains(&format!("<Code>{code}</Code>")),
"expected S3 error code <Code>{code}</Code> in response body, got:\n{body}"
);
}
async fn setup(env: &mut RustFSTestEnvironment) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
env.start_rustfs_server(vec![]).await?;
env.create_test_bucket(BUCKET).await?;
Ok(())
}
/// Positive control: a correctly hand-signed request must succeed. Without
/// this, every negative assertion below could pass for the wrong reason (a
/// broken signer that never produces a valid signature).
#[tokio::test]
#[serial]
async fn valid_header_sigv4_request_succeeds() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let key = "valid-control.txt";
let expected = b"valid-sigv4-control-body";
env.create_s3_client()
.put_object()
.bucket(BUCKET)
.key(key)
.body(ByteStream::from_static(expected))
.send()
.await?;
let path = format!("/{BUCKET}/{key}");
let signer = SigV4::new(&env);
let headers = signer.sign("GET", &path, "", UNSIGNED_PAYLOAD);
let resp = send_signed(&env, reqwest::Method::GET, &path, &headers, None).await?;
assert_eq!(resp.status().as_u16(), 200, "correctly signed GET should succeed");
let bytes = resp.bytes().await?;
assert_eq!(bytes.as_ref(), expected, "GET body must match stored object");
info!("valid header SigV4 control passed");
Ok(())
}
/// (a) Tampering the `Signature=` component must be rejected with
/// SignatureDoesNotMatch / 403.
#[tokio::test]
#[serial]
async fn tampered_signature_returns_signature_does_not_match() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let path = format!("/{BUCKET}/any-key.txt");
let signer = SigV4::new(&env);
let mut headers = signer.sign("GET", &path, "", UNSIGNED_PAYLOAD);
// Flip bytes inside the Signature= hex without changing its length/shape.
let marker = "Signature=";
let idx = headers
.authorization
.find(marker)
.expect("authorization must carry Signature=")
+ marker.len();
let (head, sig) = headers.authorization.split_at(idx);
let tampered: String = sig
.chars()
.map(|c| match c {
'0' => 'f',
'a' => '0',
other => other,
})
.collect();
assert_ne!(sig, tampered, "tamper must actually change the signature hex");
headers.authorization = format!("{head}{tampered}");
let resp = send_signed(&env, reqwest::Method::GET, &path, &headers, None).await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status.as_u16(), 403, "tampered signature must be 403, body:\n{body}");
assert_error_code(&body, "SignatureDoesNotMatch");
Ok(())
}
/// (b) A valid AccessKeyId paired with the wrong secret key must be rejected
/// with SignatureDoesNotMatch / 403.
#[tokio::test]
#[serial]
async fn wrong_secret_key_returns_signature_does_not_match() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let path = format!("/{BUCKET}/any-key.txt");
let mut signer = SigV4::new(&env);
// Correct, existing access key; wrong (but validly-shaped) secret.
signer.secret_key = "totally-wrong-secret-key".to_string();
let headers = signer.sign("GET", &path, "", UNSIGNED_PAYLOAD);
let resp = send_signed(&env, reqwest::Method::GET, &path, &headers, None).await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status.as_u16(), 403, "wrong secret must be 403, body:\n{body}");
assert_error_code(&body, "SignatureDoesNotMatch");
Ok(())
}
/// (c) A correctly signed request whose actual body differs from the signed
/// `x-amz-content-sha256` must NOT be accepted (must not return 200). The
/// signature itself is valid (it covers the *declared* hash), so the server is
/// forced to detect the payload/hash mismatch while streaming the body.
#[tokio::test]
#[serial]
async fn tampered_payload_is_rejected() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let path = format!("/{BUCKET}/tampered-payload.txt");
let claimed_body = b"the-body-i-claim-to-send";
let actual_body = b"the-body-i-really-send!!";
assert_eq!(claimed_body.len(), actual_body.len(), "keep content-length stable for the mismatch");
let signer = SigV4::new(&env);
// Sign over the hash of the CLAIMED body (single-chunk payload hash), then
// send a different body of equal length.
let headers = signer.sign("PUT", &path, "", &sha256_hex(claimed_body));
let result = send_signed(&env, reqwest::Method::PUT, &path, &headers, Some(actual_body.to_vec())).await;
match result {
Ok(resp) => {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
assert_ne!(status.as_u16(), 200, "payload mismatch must not succeed, body:\n{body}");
assert!(
status.is_client_error() || status.is_server_error(),
"payload mismatch must be an error status, got {status}, body:\n{body}"
);
info!(%status, "tampered payload rejected with error status");
}
// A mid-stream hash-mismatch abort surfacing as a transport error is
// also a valid rejection (definitely not a 200 success).
Err(err) => info!(%err, "tampered payload rejected via transport error"),
}
Ok(())
}
/// (e) A request whose `x-amz-date` is skewed beyond the server's tolerance
/// (s3s default 900s / 15 min) must be rejected with RequestTimeTooSkewed /
/// 403. The signature is otherwise valid: the credential-scope date and
/// x-amz-date both derive from the same skewed timestamp, so skew — not a
/// signature mismatch — is the failure.
#[tokio::test]
#[serial]
async fn skewed_date_returns_request_time_too_skewed() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let path = format!("/{BUCKET}/any-key.txt");
let mut signer = SigV4::new(&env);
signer.time = OffsetDateTime::now_utc() - Duration::minutes(20); // > 15 min window
let headers = signer.sign("GET", &path, "", UNSIGNED_PAYLOAD);
let resp = send_signed(&env, reqwest::Method::GET, &path, &headers, None).await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status.as_u16(), 403, "skewed date must be 403, body:\n{body}");
assert_error_code(&body, "RequestTimeTooSkewed");
Ok(())
}
/// (d) Malformed (but PRESENT, not missing) Authorization headers must produce
/// clean 4xx errors — never a 5xx and never a panic/hang. Each variant is a
/// structurally invalid SigV4 header that must be rejected before any
/// credential/service handling.
#[tokio::test]
#[serial]
async fn malformed_authorization_header_returns_clean_4xx() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let path = format!("/{BUCKET}/any-key.txt");
let variants = [
// Algorithm token only, nothing else.
"AWS4-HMAC-SHA256",
// Well-formed algorithm but unparseable remainder.
"AWS4-HMAC-SHA256 total-garbage-not-sigv4",
// Missing the Signature= component entirely.
"AWS4-HMAC-SHA256 Credential=rustfsadmin/20240101/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date",
// Credential scope is not the required access/date/region/service/aws4_request shape.
"AWS4-HMAC-SHA256 Credential=not-a-valid-scope, SignedHeaders=host, Signature=deadbeef",
// Empty value.
"AWS4-HMAC-SHA256 ",
];
for variant in variants {
let resp = send_raw_authorization(&env, reqwest::Method::GET, &path, variant).await?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
assert!(
status.is_client_error(),
"malformed Authorization {variant:?} must yield a 4xx (got {status}); body:\n{body}"
);
assert!(
!status.is_server_error(),
"malformed Authorization {variant:?} must never yield a 5xx (got {status})"
);
info!(%status, variant, "malformed Authorization rejected with clean 4xx");
}
Ok(())
}
@@ -470,11 +470,15 @@ async fn test_get_object_retention_returns_configured_values() {
// Put/Copy/Multipart Legal Hold Tests
// ============================================================================
// AWS semantics (PR #3179): Object Lock buckets are always versioned, so a
// plain PUT/copy/multipart write to a held or retained key succeeds by
// creating a new current version. The lock protects the existing version
// from deletion; it never blocks new versions.
#[tokio::test]
#[serial]
async fn test_put_object_overwrite_blocked_by_legal_hold() {
async fn test_put_object_overwrite_creates_new_version_under_legal_hold() {
init_logging();
info!("🧪 Test: PutObject overwrite blocked by Legal Hold");
info!("🧪 Test: PutObject overwrite of a legal-hold version creates a new version");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
@@ -486,25 +490,73 @@ async fn test_put_object_overwrite_blocked_by_legal_hold() {
let client = env.s3_client();
put_object_with_legal_hold(&client, bucket, key, b"locked-body", ObjectLockLegalHoldStatus::On)
let held_version_id = put_object_with_legal_hold(&client, bucket, key, b"locked-body", ObjectLockLegalHoldStatus::On)
.await
.unwrap();
let overwrite_result = client
let overwrite_output = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(b"replacement-body".to_vec()))
.send()
.await;
.await
.expect("PutObject overwrite should succeed by creating a new version");
assert!(overwrite_result.is_err(), "PutObject overwrite should fail while legal hold is ON");
let new_version_id = overwrite_output
.version_id()
.expect("versioned put should return a version id")
.to_string();
assert_ne!(new_version_id, held_version_id, "overwrite must create a distinct version");
let error_str = format!("{:?}", overwrite_result.unwrap_err());
assert!(
error_str.to_lowercase().contains("legal") || error_str.to_lowercase().contains("hold"),
"overwrite error should mention legal hold, got: {error_str}"
let current_body = client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await
.unwrap()
.body
.collect()
.await
.unwrap()
.into_bytes();
assert_eq!(current_body.as_ref(), b"replacement-body");
let held_body = client
.get_object()
.bucket(bucket)
.key(key)
.version_id(&held_version_id)
.send()
.await
.unwrap()
.body
.collect()
.await
.unwrap()
.into_bytes();
assert_eq!(held_body.as_ref(), b"locked-body", "held version must keep its original content");
let held_legal_hold = client
.get_object_legal_hold()
.bucket(bucket)
.key(key)
.version_id(&held_version_id)
.send()
.await
.unwrap();
assert_eq!(
held_legal_hold
.legal_hold()
.and_then(|value| value.status())
.map(|value| value.as_str()),
Some("ON"),
"held version must keep its legal hold after the overwrite"
);
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&held_version_id), false).await;
assert!(delete_result.is_err(), "held version must stay delete-protected after the overwrite");
}
#[tokio::test]
@@ -655,9 +707,9 @@ async fn test_copy_object_does_not_inherit_source_legal_hold() {
#[tokio::test]
#[serial]
async fn test_copy_object_overwrite_blocked_by_legal_hold() {
async fn test_copy_object_overwrite_creates_new_version_under_legal_hold() {
init_logging();
info!("🧪 Test: CopyObject overwrite blocked by Legal Hold");
info!("🧪 Test: CopyObject overwrite of a legal-hold destination creates a new version");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
@@ -678,28 +730,59 @@ async fn test_copy_object_overwrite_blocked_by_legal_hold() {
.await
.unwrap();
put_object_with_legal_hold(&client, bucket, dst_key, b"locked-destination", ObjectLockLegalHoldStatus::On)
.await
.unwrap();
let held_version_id =
put_object_with_legal_hold(&client, bucket, dst_key, b"locked-destination", ObjectLockLegalHoldStatus::On)
.await
.unwrap();
let copy_result = client
let copy_output = client
.copy_object()
.copy_source(format!("{bucket}/{src_key}"))
.bucket(bucket)
.key(dst_key)
.send()
.await;
.await
.expect("CopyObject overwrite should succeed by creating a new destination version");
assert!(
copy_result.is_err(),
"CopyObject overwrite should fail while destination legal hold is ON"
let new_version_id = copy_output
.version_id()
.expect("versioned copy should return a version id")
.to_string();
assert_ne!(new_version_id, held_version_id, "copy must create a distinct destination version");
let current_body = client
.get_object()
.bucket(bucket)
.key(dst_key)
.send()
.await
.unwrap()
.body
.collect()
.await
.unwrap()
.into_bytes();
assert_eq!(current_body.as_ref(), b"copy-source");
let held_legal_hold = client
.get_object_legal_hold()
.bucket(bucket)
.key(dst_key)
.version_id(&held_version_id)
.send()
.await
.unwrap();
assert_eq!(
held_legal_hold
.legal_hold()
.and_then(|value| value.status())
.map(|value| value.as_str()),
Some("ON"),
"held destination version must keep its legal hold after the copy"
);
let error_str = format!("{:?}", copy_result.unwrap_err());
assert!(
error_str.to_lowercase().contains("legal") || error_str.to_lowercase().contains("hold"),
"copy overwrite error should mention legal hold, got: {error_str}"
);
let delete_result = delete_object_with_bypass(&client, bucket, dst_key, Some(&held_version_id), false).await;
assert!(delete_result.is_err(), "held destination version must stay delete-protected");
}
#[tokio::test]
@@ -770,9 +853,9 @@ async fn test_create_multipart_upload_applies_requested_legal_hold() {
#[tokio::test]
#[serial]
async fn test_create_multipart_upload_blocked_by_compliance_retention() {
async fn test_create_multipart_upload_creates_new_version_under_compliance_retention() {
init_logging();
info!("🧪 Test: CreateMultipartUpload blocked by COMPLIANCE retention");
info!("🧪 Test: CreateMultipartUpload over a COMPLIANCE-retained key creates a new version");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
@@ -783,7 +866,7 @@ async fn test_create_multipart_upload_blocked_by_compliance_retention() {
env.create_object_lock_bucket(bucket).await.unwrap();
let client = env.s3_client();
put_object_with_retention(
let retained_version_id = put_object_with_retention(
&client,
bucket,
key,
@@ -794,17 +877,57 @@ async fn test_create_multipart_upload_blocked_by_compliance_retention() {
.await
.unwrap();
let create_result = client.create_multipart_upload().bucket(bucket).key(key).send().await;
let create_output = client
.create_multipart_upload()
.bucket(bucket)
.key(key)
.send()
.await
.expect("CreateMultipartUpload should succeed; completion will create a new version");
assert!(
create_result.is_err(),
"CreateMultipartUpload should fail while destination is under active COMPLIANCE retention"
);
let upload_id = create_output.upload_id().unwrap();
let upload_part_output = client
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from(b"multipart-body".to_vec()))
.send()
.await
.unwrap();
let error_str = format!("{:?}", create_result.unwrap_err());
let completed_upload = CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.e_tag(upload_part_output.e_tag().unwrap_or_default())
.build(),
)
.build();
let complete_output = client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.multipart_upload(completed_upload)
.send()
.await
.expect("CompleteMultipartUpload should succeed by creating a new version");
let new_version_id = complete_output
.version_id()
.expect("versioned multipart completion should return a version id")
.to_string();
assert_ne!(new_version_id, retained_version_id, "completion must create a distinct version");
// COMPLIANCE retention on the previous version survives the overwrite and
// cannot be bypassed.
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true).await;
assert!(
error_str.to_lowercase().contains("retention") || error_str.to_lowercase().contains("compliance"),
"multipart create error should mention retention, got: {error_str}"
delete_result.is_err(),
"retained version must stay delete-protected even with governance bypass"
);
}
@@ -932,9 +1055,9 @@ async fn test_delete_completed_multipart_object_blocked_by_retention() {
#[tokio::test]
#[serial]
async fn test_complete_multipart_upload_blocked_when_legal_hold_added_after_create() {
async fn test_complete_multipart_upload_creates_new_version_under_legal_hold() {
init_logging();
info!("🧪 Test: CompleteMultipartUpload blocked when Legal Hold appears after MPU creation");
info!("🧪 Test: CompleteMultipartUpload creates a new version when the current version is under Legal Hold");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
@@ -959,9 +1082,10 @@ async fn test_complete_multipart_upload_blocked_when_legal_hold_added_after_crea
.await
.unwrap();
put_object_with_legal_hold(&client, bucket, key, b"locked-current-version", ObjectLockLegalHoldStatus::On)
.await
.unwrap();
let held_version_id =
put_object_with_legal_hold(&client, bucket, key, b"locked-current-version", ObjectLockLegalHoldStatus::On)
.await
.unwrap();
let completed_upload = CompletedMultipartUpload::builder()
.parts(
@@ -972,29 +1096,48 @@ async fn test_complete_multipart_upload_blocked_when_legal_hold_added_after_crea
)
.build();
let complete_result = client
let complete_output = client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.multipart_upload(completed_upload)
.send()
.await;
.await
.expect("CompleteMultipartUpload should succeed by creating a new version over the held one");
assert!(complete_result.is_err(), "CompleteMultipartUpload should fail once legal hold is enabled");
let new_version_id = complete_output
.version_id()
.expect("versioned multipart completion should return a version id")
.to_string();
assert_ne!(new_version_id, held_version_id, "completion must create a distinct version");
let error_str = format!("{:?}", complete_result.unwrap_err());
assert!(
error_str.to_lowercase().contains("legal") || error_str.to_lowercase().contains("hold"),
"complete error should mention legal hold, got: {error_str}"
let held_legal_hold = client
.get_object_legal_hold()
.bucket(bucket)
.key(key)
.version_id(&held_version_id)
.send()
.await
.unwrap();
assert_eq!(
held_legal_hold
.legal_hold()
.and_then(|value| value.status())
.map(|value| value.as_str()),
Some("ON"),
"held version must keep its legal hold after multipart completion"
);
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&held_version_id), false).await;
assert!(delete_result.is_err(), "held version must stay delete-protected");
}
#[tokio::test]
#[serial]
async fn test_complete_multipart_upload_blocked_when_compliance_retention_added_after_create() {
async fn test_complete_multipart_upload_creates_new_version_under_compliance_retention() {
init_logging();
info!("🧪 Test: CompleteMultipartUpload blocked when COMPLIANCE retention appears after MPU creation");
info!("🧪 Test: CompleteMultipartUpload creates a new version when the current version is under COMPLIANCE retention");
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
env.start_rustfs().await.unwrap();
@@ -1019,7 +1162,7 @@ async fn test_complete_multipart_upload_blocked_when_compliance_retention_added_
.await
.unwrap();
put_object_with_retention(
let retained_version_id = put_object_with_retention(
&client,
bucket,
key,
@@ -1039,24 +1182,28 @@ async fn test_complete_multipart_upload_blocked_when_compliance_retention_added_
)
.build();
let complete_result = client
let complete_output = client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.multipart_upload(completed_upload)
.send()
.await;
.await
.expect("CompleteMultipartUpload should succeed by creating a new version over the retained one");
assert!(
complete_result.is_err(),
"CompleteMultipartUpload should fail once COMPLIANCE retention is enabled"
);
let new_version_id = complete_output
.version_id()
.expect("versioned multipart completion should return a version id")
.to_string();
assert_ne!(new_version_id, retained_version_id, "completion must create a distinct version");
let error_str = format!("{:?}", complete_result.unwrap_err());
// COMPLIANCE retention on the previous version survives the overwrite and
// cannot be bypassed.
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true).await;
assert!(
error_str.to_lowercase().contains("retention") || error_str.to_lowercase().contains("compliance"),
"complete error should mention retention, got: {error_str}"
delete_result.is_err(),
"retained version must stay delete-protected even with governance bypass"
);
}
@@ -1864,7 +2011,7 @@ async fn test_copy_object_retention_uses_destination_policy() {
assert!(no_default_retention.mode().is_none());
assert!(no_default_retention.retain_until_date().is_none());
put_object_with_retention(
let retained_version_id = put_object_with_retention(
&client,
dst_bucket,
"locked-destination",
@@ -1875,16 +2022,30 @@ async fn test_copy_object_retention_uses_destination_policy() {
.await
.unwrap();
let overwrite_result = client
let overwrite_output = client
.copy_object()
.copy_source(format!("{src_bucket}/{src_key}"))
.bucket(dst_bucket)
.key("locked-destination")
.send()
.await;
.await
.expect("CopyObject overwrite should succeed by creating a new destination version");
let overwrite_version_id = overwrite_output
.version_id()
.expect("versioned copy should return a version id")
.to_string();
assert_ne!(
overwrite_version_id, retained_version_id,
"copy must create a distinct destination version"
);
// COMPLIANCE retention on the previous destination version survives the
// overwrite and cannot be bypassed.
let delete_result =
delete_object_with_bypass(&client, dst_bucket, "locked-destination", Some(&retained_version_id), true).await;
assert!(
overwrite_result.is_err(),
"CopyObject overwrite should not bypass active destination retention"
delete_result.is_err(),
"retained destination version must stay delete-protected even with governance bypass"
);
}
@@ -0,0 +1,354 @@
// 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.
//! Negative presigned-URL (query-string SigV4) regression suite (backlog#1151
//! sec-2).
//!
//! Presigned URLs are the query-string SigV4 surface: the signature and its
//! scope/expiry travel as `X-Amz-*` query parameters rather than in an
//! `Authorization` header. Until now this repository only exercised presigned
//! URLs on the *happy* path (e.g. `head_object_consistency_test`), so nothing
//! pinned OUR end-to-end wiring of expiry enforcement or query-signature
//! verification — a future dependency swap or misconfiguration could silently
//! start honouring expired or forged presigned URLs. These tests send REJECTED
//! presigned requests against a live server and assert the HTTP status plus the
//! S3 error `<Code>` in the response XML, guarding the rejection contract
//! regardless of who performs the underlying verification.
//!
//! This is the query-string sibling of `negative_sigv4_test` (sec-1, header
//! SigV4); the two cover distinct attacker-controlled auth surfaces and share
//! no test cases.
//!
//! Expiry is controlled WITHOUT real waiting: the AWS SDK presigner accepts an
//! explicit `start_time`, so an already-expired URL is produced by signing with
//! a timestamp far enough in the past that `start_time + X-Amz-Expires` is
//! already behind the server clock. Forged variants are produced by presigning
//! a valid URL and then mutating the query (`X-Amz-Signature`) or the signed
//! target (object key) after the fact, and by presigning with the wrong secret.
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use aws_sdk_s3::config::{Credentials, Region};
use aws_sdk_s3::presigning::{PresignedRequest, PresigningConfig};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::{Client, Config};
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
use serial_test::serial;
use std::time::{Duration, SystemTime};
use tracing::info;
const REGION: &str = "us-east-1";
const BUCKET: &str = "presigned-negative-bucket";
/// Object that `setup` stores; positive-control GETs read it back.
const CANONICAL_KEY: &str = "canonical-object.txt";
const CANONICAL_BODY: &[u8] = b"presigned-negative-canonical-body";
/// Build an S3 client bound to this environment but with a caller-chosen secret
/// key (mirrors `common::build_test_s3_config`, which is private). Used to
/// presign with the WRONG secret while keeping the real access key id.
fn s3_client_with_secret(env: &RustFSTestEnvironment, secret: &str) -> Client {
let credentials = Credentials::new(&env.access_key, secret, None, None, "e2e-presigned-negative");
let mut config = Config::builder()
.credentials_provider(credentials)
.region(Region::new(REGION))
.endpoint_url(&env.url)
.force_path_style(true)
.behavior_version_latest();
if env.url.starts_with("http://") {
config = config.http_client(SmithyHttpClientBuilder::new().build_http());
}
Client::from_conf(config.build())
}
/// A presigning config that is ALREADY expired the moment it is produced:
/// signed as of one hour ago with a 60s validity window, so the server sees a
/// request whose `X-Amz-Date + X-Amz-Expires` is ~59 minutes in the past. No
/// real waiting, no flakiness.
fn expired_config() -> PresigningConfig {
PresigningConfig::builder()
.start_time(SystemTime::now() - Duration::from_secs(3600))
.expires_in(Duration::from_secs(60))
.build()
.expect("valid presigning config")
}
/// A generous, valid presigning window for positive controls / pre-tamper URLs.
fn valid_config() -> PresigningConfig {
PresigningConfig::expires_in(Duration::from_secs(300)).expect("valid presigning config")
}
/// Flip bytes inside the `X-Amz-Signature=` query value without changing its
/// length, producing a structurally valid but incorrect signature.
fn tamper_signature(uri: &str) -> String {
let marker = "X-Amz-Signature=";
let idx = uri.find(marker).expect("presigned uri must carry X-Amz-Signature") + marker.len();
let (head, rest) = uri.split_at(idx);
let end = rest.find('&').unwrap_or(rest.len());
let (sig, tail) = rest.split_at(end);
let tampered: String = sig
.chars()
.map(|c| match c {
'0' => 'f',
'a' => '0',
other => other,
})
.collect();
assert_ne!(sig, tampered, "tamper must actually change the signature hex");
format!("{head}{tampered}{tail}")
}
fn assert_error_code(body: &str, code: &str) {
assert!(
body.contains(&format!("<Code>{code}</Code>")),
"expected S3 error code <Code>{code}</Code> in response body, got:\n{body}"
);
}
/// Replay a `PresignedRequest` faithfully: same method, same URI, forward every
/// signed header, attach an optional body. `reqwest` derives `Host` from the
/// URI (matching the signed host).
async fn send_presigned(pr: &PresignedRequest, body: Option<Vec<u8>>) -> reqwest::Result<reqwest::Response> {
send_raw(pr.method(), pr.uri(), pr.headers(), body).await
}
/// Replay against an ARBITRARY (possibly tampered) URI while keeping the signed
/// method/headers of the original presigned request.
async fn send_raw<'a>(
method: &str,
uri: &str,
headers: impl Iterator<Item = (&'a str, &'a str)>,
body: Option<Vec<u8>>,
) -> reqwest::Result<reqwest::Response> {
let method = reqwest::Method::from_bytes(method.as_bytes()).expect("valid HTTP method");
let mut builder = local_http_client().request(method, uri);
for (k, v) in headers {
builder = builder.header(k, v);
}
if let Some(body) = body {
builder = builder.body(body);
}
builder.send().await
}
async fn setup(env: &mut RustFSTestEnvironment) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
env.start_rustfs_server(vec![]).await?;
env.create_test_bucket(BUCKET).await?;
env.create_s3_client()
.put_object()
.bucket(BUCKET)
.key(CANONICAL_KEY)
.body(ByteStream::from_static(CANONICAL_BODY))
.send()
.await?;
Ok(())
}
/// Positive control (GET): a valid presigned GET must succeed and return the
/// stored bytes. Without this, every negative assertion could pass for the
/// wrong reason (a server that rejects all presigned URLs).
#[tokio::test]
#[serial]
async fn valid_presigned_get_succeeds() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let pr = env
.create_s3_client()
.get_object()
.bucket(BUCKET)
.key(CANONICAL_KEY)
.presigned(valid_config())
.await?;
let resp = send_presigned(&pr, None).await?;
assert_eq!(resp.status().as_u16(), 200, "valid presigned GET should succeed");
let bytes = resp.bytes().await?;
assert_eq!(bytes.as_ref(), CANONICAL_BODY, "presigned GET body must match stored object");
info!("valid presigned GET control passed");
Ok(())
}
/// Positive control (PUT): a valid presigned PUT must store the object, which we
/// verify with a follow-up authenticated HEAD.
#[tokio::test]
#[serial]
async fn valid_presigned_put_succeeds() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let key = "presigned-put-ok.txt";
let body = b"stored-via-presigned-put".to_vec();
let pr = env
.create_s3_client()
.put_object()
.bucket(BUCKET)
.key(key)
.presigned(valid_config())
.await?;
let resp = send_presigned(&pr, Some(body.clone())).await?;
assert!(resp.status().is_success(), "valid presigned PUT should succeed, got {}", resp.status());
let head = env.create_s3_client().head_object().bucket(BUCKET).key(key).send().await?;
assert_eq!(head.content_length(), Some(body.len() as i64), "stored object length must match");
info!("valid presigned PUT control passed");
Ok(())
}
/// (a) An already-expired presigned GET must be rejected with 403 / AccessDenied
/// ("Request has expired"). s3s checks expiry BEFORE the signature, so the
/// signature here is otherwise valid — only the elapsed window is at fault.
#[tokio::test]
#[serial]
async fn expired_presigned_get_is_rejected() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let pr = env
.create_s3_client()
.get_object()
.bucket(BUCKET)
.key(CANONICAL_KEY)
.presigned(expired_config())
.await?;
let resp = send_presigned(&pr, None).await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status.as_u16(), 403, "expired presigned GET must be 403, body:\n{body}");
assert_error_code(&body, "AccessDenied");
Ok(())
}
/// (b) Tampering the `X-Amz-Signature` query value must be rejected with 403 /
/// SignatureDoesNotMatch.
#[tokio::test]
#[serial]
async fn tampered_signature_returns_signature_does_not_match() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let pr = env
.create_s3_client()
.get_object()
.bucket(BUCKET)
.key(CANONICAL_KEY)
.presigned(valid_config())
.await?;
let tampered_uri = tamper_signature(pr.uri());
let resp = send_raw(pr.method(), &tampered_uri, pr.headers(), None).await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status.as_u16(), 403, "tampered presigned signature must be 403, body:\n{body}");
assert_error_code(&body, "SignatureDoesNotMatch");
Ok(())
}
/// (c) A presigned URL generated with the WRONG secret (but the real access key
/// id) must be rejected with 403 / SignatureDoesNotMatch.
#[tokio::test]
#[serial]
async fn wrong_secret_key_returns_signature_does_not_match() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let bad_client = s3_client_with_secret(&env, "totally-wrong-secret-key");
let pr = bad_client
.get_object()
.bucket(BUCKET)
.key(CANONICAL_KEY)
.presigned(valid_config())
.await?;
let resp = send_presigned(&pr, None).await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status.as_u16(), 403, "wrong-secret presigned URL must be 403, body:\n{body}");
assert_error_code(&body, "SignatureDoesNotMatch");
Ok(())
}
/// (d) Changing the signed target (the object key in the path) AFTER signing
/// must be rejected with 403 / SignatureDoesNotMatch: the presented request no
/// longer matches the canonical request the signature covers. The signature
/// check runs during auth, before any object lookup, so the swapped key need
/// not even exist.
#[tokio::test]
#[serial]
async fn tampered_target_key_returns_signature_does_not_match() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let signed_key = "signed-target.txt";
let served_key = "served-target.txt";
let pr = env
.create_s3_client()
.get_object()
.bucket(BUCKET)
.key(signed_key)
.presigned(valid_config())
.await?;
// Swap the object key in the path while leaving the (now stale) signature
// and its scope untouched.
let signed_segment = format!("/{signed_key}?");
let served_segment = format!("/{served_key}?");
let uri = pr.uri();
assert!(uri.contains(&signed_segment), "presigned uri must contain the signed key path: {uri}");
let tampered_uri = uri.replace(&signed_segment, &served_segment);
let resp = send_raw(pr.method(), &tampered_uri, pr.headers(), None).await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status.as_u16(), 403, "tampered target key must be 403, body:\n{body}");
assert_error_code(&body, "SignatureDoesNotMatch");
Ok(())
}
/// (e / acceptance 4 negative half) Tampering the signature of a presigned PUT
/// must be rejected with 403 / SignatureDoesNotMatch — the write must not land.
#[tokio::test]
#[serial]
async fn tampered_presigned_put_returns_signature_does_not_match() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let key = "presigned-put-tampered.txt";
let pr = env
.create_s3_client()
.put_object()
.bucket(BUCKET)
.key(key)
.presigned(valid_config())
.await?;
let tampered_uri = tamper_signature(pr.uri());
let resp = send_raw(pr.method(), &tampered_uri, pr.headers(), Some(b"should-not-be-stored".to_vec())).await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status.as_u16(), 403, "tampered presigned PUT must be 403, body:\n{body}");
assert_error_code(&body, "SignatureDoesNotMatch");
// The rejected write must not have created the object.
let head = env.create_s3_client().head_object().bucket(BUCKET).key(key).send().await;
assert!(head.is_err(), "tampered presigned PUT must not store the object");
Ok(())
}
+99 -17
View File
@@ -13,6 +13,23 @@
// limitations under the License.
//! Core FTPS tests
//!
//! # Security regression coverage
//!
//! GHSA-3p3x-734c-h5vx (constant-time secret comparison on the WebDAV/FTPS
//! password login path, fixed in rustfs/rustfs#4403) is anchored end-to-end
//! by [`assert_ftps_ghsa_3p3x_wrong_credentials_rejected`], invoked from
//! [`test_ftps_core_operations`]. It exercises the `ct_eq` rejection branch in
//! `FtpsAuthenticator::authenticate` (`crates/protocols/src/ftps/server.rs`),
//! proving that a wrong password and an unknown user are both rejected and are
//! indistinguishable at the protocol layer (both return FTP `530 Not logged
//! in`). The sibling WebDAV assertion lives in `webdav_core.rs`; the internode
//! RPC fail-closed advisory (GHSA-r5qv-rc46-hv8q, rustfs/rustfs#4402) is
//! anchored by the `ghsa_r5qv_*` unit tests in
//! `crates/ecstore/src/cluster/rpc/http_auth.rs`. See
//! `docs/testing/security-regressions.md` for the full advisory -> test map.
//!
//! Advisory: <https://github.com/rustfs/rustfs/security/advisories/GHSA-3p3x-734c-h5vx>
use crate::common::rustfs_binary_path_with_features;
use crate::protocols::test_env::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY, ProtocolTestEnvironment};
@@ -24,8 +41,8 @@ use rustls::{ClientConfig, DigitallySignedStruct, Error as RustlsError, Signatur
use std::io::Cursor;
use std::path::PathBuf;
use std::sync::Arc;
use suppaftp::RustlsConnector;
use suppaftp::RustlsFtpStream;
use suppaftp::types::Response;
use suppaftp::{FtpError, RustlsConnector, RustlsFtpStream, Status};
use tokio::process::Command;
use tracing::info;
@@ -73,6 +90,78 @@ impl ServerCertVerifier for AcceptAnyServerCertVerifier {
}
}
/// Open a fresh, unauthenticated FTPS control connection to the test server
/// and upgrade it to TLS. The caller is responsible for logging in.
///
/// Assumes the process-wide rustls crypto provider has already been installed
/// (done once at the top of [`test_ftps_core_operations`]).
fn ftps_connect_secure() -> Result<RustlsFtpStream> {
let config = ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(AcceptAnyServerCertVerifier))
.with_no_client_auth();
let tls_connector = RustlsConnector::from(Arc::new(config));
let ftp_stream = RustlsFtpStream::connect(FTPS_ADDRESS).map_err(|e| anyhow::anyhow!("Failed to connect: {}", e))?;
ftp_stream
.into_secure(tls_connector, "127.0.0.1")
.map_err(|e| anyhow::anyhow!("Failed to upgrade to TLS: {}", e))
}
/// Extract the FTP reply status from a failed login, asserting the failure is a
/// protocol-level rejection (`FtpError::UnexpectedResponse`) rather than a
/// transport error.
fn login_rejection_status(user: &str, password: &str) -> Result<Status> {
let mut ftp_stream = ftps_connect_secure()?;
match ftp_stream.login(user, password) {
Ok(()) => anyhow::bail!("login unexpectedly succeeded for user '{user}'"),
Err(FtpError::UnexpectedResponse(Response { status, .. })) => Ok(status),
Err(other) => anyhow::bail!("expected a protocol rejection, got transport error: {other}"),
}
}
/// Security regression for GHSA-3p3x-734c-h5vx.
///
/// FTPS password verification must reject invalid credentials via the
/// constant-time `ct_eq` branch in `FtpsAuthenticator::authenticate`
/// (`crates/protocols/src/ftps/server.rs`, fixed in rustfs/rustfs#4403). This
/// asserts, purely on behavior (no timing assertions):
///
/// 1. A valid user with a wrong password is rejected (`530`), exercising the
/// secret-mismatch branch that the advisory hardened.
/// 2. An unknown user is rejected (`530`).
/// 3. The two failures are indistinguishable at the protocol layer — both
/// return the same FTP status, so an attacker cannot use the reply code to
/// tell "user exists, wrong password" from "no such user".
async fn assert_ftps_ghsa_3p3x_wrong_credentials_rejected() -> Result<()> {
info!("Testing FTPS (GHSA-3p3x): wrong password is rejected");
let wrong_password_status = login_rejection_status(DEFAULT_ACCESS_KEY, "definitely-not-the-secret")?;
assert_eq!(
wrong_password_status,
Status::NotLoggedIn,
"valid user + wrong password must be rejected with 530, got {wrong_password_status:?}"
);
info!("PASS: FTPS wrong password rejected with 530");
info!("Testing FTPS (GHSA-3p3x): unknown user is rejected");
let unknown_user_status = login_rejection_status("no-such-access-key", DEFAULT_SECRET_KEY)?;
assert_eq!(
unknown_user_status,
Status::NotLoggedIn,
"unknown user must be rejected with 530, got {unknown_user_status:?}"
);
info!("PASS: FTPS unknown user rejected with 530");
assert_eq!(
wrong_password_status, unknown_user_status,
"invalid-user and invalid-password failures must be indistinguishable at the protocol layer"
);
info!("PASS: FTPS invalid-user and invalid-password failures are indistinguishable");
Ok(())
}
/// Test FTPS: put, ls, mkdir, rmdir, delete operations
pub async fn test_ftps_core_operations() -> Result<()> {
let env = ProtocolTestEnvironment::new().map_err(|e| anyhow::anyhow!("{}", e))?;
@@ -102,6 +191,7 @@ pub async fn test_ftps_core_operations() -> Result<()> {
info!("Starting FTPS server on {}", FTPS_ADDRESS);
let binary_path = rustfs_binary_path_with_features(Some("ftps,webdav"));
let mut server_process = Command::new(&binary_path)
.env("RUSTFS_CONSOLE_ENABLE", "false")
.env("RUSTFS_FTPS_ENABLE", "true")
.env("RUSTFS_FTPS_ADDRESS", FTPS_ADDRESS)
.env("RUSTFS_FTPS_CERTS_DIR", cert_dir.to_str().unwrap())
@@ -115,26 +205,18 @@ pub async fn test_ftps_core_operations() -> Result<()> {
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
// Build ServerConfig with SNI support
// Install the default crypto provider once for this process before any
// TLS handshake. Subsequent connections reuse it via `ftps_connect_secure`.
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.map_err(|e| anyhow::anyhow!("Failed to install crypto provider: {:?}", e))?;
let config = ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(AcceptAnyServerCertVerifier))
.with_no_client_auth();
// Security regression (GHSA-3p3x-734c-h5vx): wrong credentials must be
// rejected before any successful login is attempted below.
assert_ftps_ghsa_3p3x_wrong_credentials_rejected().await?;
// Wrap in suppaftp's RustlsConnector
let tls_connector = RustlsConnector::from(Arc::new(config));
// Connect to FTPS server
let ftp_stream = RustlsFtpStream::connect(FTPS_ADDRESS).map_err(|e| anyhow::anyhow!("Failed to connect: {}", e))?;
// Upgrade to secure connection
let mut ftp_stream = ftp_stream
.into_secure(tls_connector, "127.0.0.1")
.map_err(|e| anyhow::anyhow!("Failed to upgrade to TLS: {}", e))?;
// Connect and log in with valid credentials for the functional flow.
let mut ftp_stream = ftps_connect_secure()?;
ftp_stream.login(DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY)?;
info!("Testing FTPS: mkdir bucket");
@@ -192,6 +192,7 @@ pub(crate) async fn spawn_compliance_rustfs(
.to_str()
.ok_or_else(|| anyhow!("host key dir path is not utf-8: {}", host_key_dir.display()))?;
let child = Command::new(&binary_path)
.env(ENV_CONSOLE_ENABLE, "false")
.env(ENV_SFTP_ENABLE, "true")
.env(ENV_SFTP_ADDRESS, sftp_address)
.env(ENV_SFTP_HOST_KEY_DIR, host_key_dir_str)
+4 -2
View File
@@ -27,8 +27,8 @@ use russh::client::{self, Handle};
use russh_sftp::client::SftpSession;
use russh_sftp::protocol::{FileAttributes, OpenFlags};
use rustfs_config::{
ENV_RUSTFS_ADDRESS, ENV_SFTP_ADDRESS, ENV_SFTP_ENABLE, ENV_SFTP_HOST_KEY_DIR, ENV_SFTP_IDLE_TIMEOUT, ENV_SFTP_PART_SIZE,
ENV_SFTP_READ_ONLY,
ENV_CONSOLE_ENABLE, ENV_RUSTFS_ADDRESS, ENV_SFTP_ADDRESS, ENV_SFTP_ENABLE, ENV_SFTP_HOST_KEY_DIR, ENV_SFTP_IDLE_TIMEOUT,
ENV_SFTP_PART_SIZE, ENV_SFTP_READ_ONLY,
};
use sha2::{Digest, Sha256};
use std::path::PathBuf;
@@ -152,6 +152,7 @@ pub async fn test_sftp_core_operations() -> Result<()> {
.ok_or_else(|| anyhow!("host key dir path is not utf-8"))?;
let mut server_process = ServerProcess::new(
Command::new(&binary_path)
.env(ENV_CONSOLE_ENABLE, "false")
.env(ENV_SFTP_ENABLE, "true")
.env(ENV_SFTP_ADDRESS, SFTP_ADDRESS)
.env(ENV_SFTP_HOST_KEY_DIR, host_key_dir_str)
@@ -504,6 +505,7 @@ pub async fn test_sftp_idle_timeout_disconnects() -> Result<()> {
.ok_or_else(|| anyhow!("host key dir path is not utf-8"))?;
let mut server_process = ServerProcess::new(
Command::new(&binary_path)
.env(ENV_CONSOLE_ENABLE, "false")
.env(ENV_SFTP_ENABLE, "true")
.env(ENV_SFTP_ADDRESS, IDLE_SFTP_ADDRESS)
.env(ENV_SFTP_HOST_KEY_DIR, host_key_dir_str)
+47 -6
View File
@@ -13,6 +13,23 @@
// limitations under the License.
//! Core WebDAV tests
//!
//! # Security regression coverage
//!
//! GHSA-3p3x-734c-h5vx (constant-time secret comparison on the WebDAV/FTPS
//! password login path, fixed in rustfs/rustfs#4403) is anchored end-to-end by
//! the authentication-failure block in [`test_webdav_core_operations`], marked
//! with a `GHSA-3p3x` comment. It drives the `ct_eq` rejection branch in
//! `WebDavAuth::authenticate` (`crates/protocols/src/webdav/server.rs`): a
//! valid access key with a wrong secret is rejected (the exact branch the
//! advisory hardened), and an unknown user is rejected with the same status so
//! the two failures are indistinguishable. The sibling FTPS assertion lives in
//! `ftps_core.rs`; the internode RPC fail-closed advisory
//! (GHSA-r5qv-rc46-hv8q, rustfs/rustfs#4402) is anchored by the `ghsa_r5qv_*`
//! unit tests in `crates/ecstore/src/cluster/rpc/http_auth.rs`. See
//! `docs/testing/security-regressions.md` for the full advisory -> test map.
//!
//! Advisory: <https://github.com/rustfs/rustfs/security/advisories/GHSA-3p3x-734c-h5vx>
use crate::common::local_http_client;
use crate::common::rustfs_binary_path_with_features;
@@ -151,6 +168,7 @@ pub async fn test_webdav_core_operations() -> Result<()> {
let mut server_process = Command::new(&binary_path)
.arg("--address")
.arg(S3_TEST_ADDRESS)
.env("RUSTFS_CONSOLE_ENABLE", "false")
.env("RUSTFS_WEBDAV_ENABLE", "true")
.env("RUSTFS_WEBDAV_ADDRESS", WEBDAV_ADDRESS)
.env("RUSTFS_WEBDAV_TLS_ENABLED", "false") // No TLS for testing
@@ -652,15 +670,38 @@ pub async fn test_webdav_core_operations() -> Result<()> {
);
info!("PASS: DELETE bucket '{}' successful", bucket_name);
// Test authentication failure
info!("Testing WebDAV: Authentication failure");
let resp = client
// Security regression (GHSA-3p3x-734c-h5vx): constant-time secret
// comparison on the WebDAV password login path (fixed in
// rustfs/rustfs#4403). A valid access key with a wrong secret must be
// rejected via the `ct_eq` branch in `WebDavAuth::authenticate`, and an
// unknown user must be rejected with the same status so the two are
// indistinguishable. Behavior-only assertion (no timing checks).
info!("Testing WebDAV (GHSA-3p3x): valid access key + wrong secret is rejected");
let wrong_secret_resp = client
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &base_url)
.header("Authorization", "Basic aW52YWxpZDppbnZhbGlk") // invalid:invalid
.header("Authorization", basic_auth_header_for(DEFAULT_ACCESS_KEY, "definitely-not-the-secret"))
.send()
.await?;
assert_eq!(resp.status().as_u16(), 401, "Invalid auth should return 401, got: {}", resp.status());
info!("PASS: Authentication failure test successful");
let wrong_secret_status = wrong_secret_resp.status().as_u16();
assert_eq!(
wrong_secret_status, 401,
"valid access key + wrong secret should return 401, got: {wrong_secret_status}"
);
info!("PASS: WebDAV wrong secret rejected with 401");
info!("Testing WebDAV (GHSA-3p3x): unknown user is rejected");
let unknown_user_resp = client
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &base_url)
.header("Authorization", basic_auth_header_for("no-such-access-key", "definitely-not-the-secret"))
.send()
.await?;
let unknown_user_status = unknown_user_resp.status().as_u16();
assert_eq!(unknown_user_status, 401, "unknown user should return 401, got: {unknown_user_status}");
assert_eq!(
wrong_secret_status, unknown_user_status,
"invalid-user and invalid-secret failures must be indistinguishable at the protocol layer"
);
info!("PASS: WebDAV authentication failure (GHSA-3p3x) test successful");
info!("WebDAV core tests passed");
Ok(())
+271 -144
View File
@@ -13,175 +13,302 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use aws_config::meta::region::RegionProviderChain;
//! Self-managed lifecycle *expiry* end-to-end tests (backlog#1148 ilm-3).
//!
//! Each test spawns its own `rustfs` binary on a random port with an isolated
//! temp dir via [`RustFSTestEnvironment`], so there is no dependency on a
//! pre-started `localhost:9000` server and no `#[ignore]`. Expiry is driven to
//! completion in seconds using two independent, per-test time-control tools:
//!
//! * **`mod_time` back-dating** (see [`put_object_with_backdated_mtime`]): write
//! an object whose stored `mod_time` is already in the past, so a `Days`-based
//! rule is due immediately without accelerating the day length. Used by
//! [`test_lifecycle_expiry_backdated_mtime`].
//! * **`RUSTFS_ILM_DEBUG_DAY_SECS`** (ilm-5): compress one lifecycle "day" to a
//! couple of seconds so a `Days=1` rule fires shortly after a normal PUT. Used
//! by [`test_lifecycle_versioned_current_version_expiry_creates_delete_marker`].
//!
//! In every case the scanner must actually *run* for expiry to apply, so each
//! server is started with `RUSTFS_SCANNER_CYCLE=1` (1-second scan cycle) and a
//! small `RUSTFS_ILM_PROCESS_TIME` rounding boundary. Tests poll for the
//! terminal state instead of sleeping a fixed wall-clock interval.
use crate::common::RustFSTestEnvironment;
use aws_sdk_s3::Client;
use aws_sdk_s3::config::{Credentials, Region};
use bytes::Bytes;
use serial_test::serial;
use std::error::Error;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
BucketLifecycleConfiguration, BucketVersioningStatus, ExpirationStatus, LifecycleExpiration, LifecycleRule,
LifecycleRuleFilter, VersioningConfiguration,
};
use std::time::Duration as StdDuration;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
const ENDPOINT: &str = "http://localhost:9000";
const ACCESS_KEY: &str = "rustfsadmin";
const SECRET_KEY: &str = "rustfsadmin";
const BUCKET: &str = "test-basic-bucket";
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
async fn create_aws_s3_client() -> Result<Client, Box<dyn Error>> {
let region_provider = RegionProviderChain::default_provider().or_else(Region::new("us-east-1"));
let shared_config = aws_config::defaults(aws_config::BehaviorVersion::latest())
.region(region_provider)
.credentials_provider(Credentials::new(ACCESS_KEY, SECRET_KEY, None, None, "static"))
.endpoint_url(ENDPOINT)
.load()
.await;
/// Internal replication headers that back-date a written object's `mod_time`.
///
/// These are the RustFS side of the MinIO-compatible replication protocol
/// (`crates/utils/src/http/header_compat.rs`; consumed in
/// `rustfs/src/storage/options.rs::put_opts_from_headers`). Sending
/// `x-rustfs-source-replication-request: true` routes the PUT through the
/// replica branch and, when `x-rustfs-source-mtime` (RFC3339) is present, forces
/// the stored `mod_time` to that value.
const HDR_SOURCE_REPLICATION_REQUEST: &str = "x-rustfs-source-replication-request";
const HDR_SOURCE_MTIME: &str = "x-rustfs-source-mtime";
let client = Client::from_conf(
aws_sdk_s3::Config::from(&shared_config)
.to_builder()
.force_path_style(true)
.build(),
);
Ok(client)
/// PUT an object whose stored `mod_time` is back-dated to `mtime`.
///
/// # Side effects / caveats
///
/// This uses the internal source-replication backdoor, so the write sets
/// `ObjectOptions::replication_request = true` and takes the replica code path.
/// That flag by itself does **not** set the object's replication *status* to
/// `Pending`/`Failed` — only an `x-amz-bucket-replication-status: replica`
/// header would set `REPLICA` status, and the test bucket has no replication
/// configuration — so lifecycle expiry is **not** gated
/// (`crates/lifecycle/src/evaluator.rs::replication_status_blocks_lifecycle`
/// only blocks on `Pending`/`Failed`). The passing
/// [`test_lifecycle_expiry_backdated_mtime`] is the end-to-end proof that a
/// back-dated object is still expired by the scanner.
async fn put_object_with_backdated_mtime(
client: &Client,
bucket: &str,
key: &str,
body: &[u8],
mtime: OffsetDateTime,
) -> TestResult {
let mtime_rfc3339 = mtime.format(&Rfc3339)?;
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(body.to_vec()))
.customize()
.mutate_request(move |req| {
req.headers_mut().insert(HDR_SOURCE_REPLICATION_REQUEST, "true");
req.headers_mut().insert(HDR_SOURCE_MTIME, mtime_rfc3339.clone());
})
.send()
.await?;
Ok(())
}
async fn setup_test_bucket(client: &Client) -> Result<(), Box<dyn Error>> {
match client.create_bucket().bucket(BUCKET).send().await {
Ok(_) => {}
/// Returns `true` once `GET bucket/key` fails with `NoSuchKey`, `false` while it
/// 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),
Err(e) => {
let error_str = e.to_string();
if !error_str.contains("BucketAlreadyOwnedByYou") && !error_str.contains("BucketAlreadyExists") {
return Err(e.into());
if let Some(service_error) = e.as_service_error() {
if service_error.is_no_such_key() {
return Ok(true);
}
return Err(format!("expected NoSuchKey, got: {e:?}").into());
}
Err(format!("expected a service error, got: {e:?}").into())
}
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
#[ignore = "requires running RustFS server at localhost:9000"]
async fn test_bucket_lifecycle_configuration() -> Result<(), Box<dyn std::error::Error>> {
use aws_sdk_s3::types::{BucketLifecycleConfiguration, LifecycleExpiration, LifecycleRule, LifecycleRuleFilter};
use chrono::{Duration as ChronoDuration, Utc};
use tokio::time::Duration;
let client = create_aws_s3_client().await?;
setup_test_bucket(&client).await?;
// Upload test object first
let test_content = "Test object for lifecycle expiration";
let lifecycle_object_key = "lifecycle-test-object.txt";
let untouched_object_key = "keep-object.txt";
client
.put_object()
.bucket(BUCKET)
.key(lifecycle_object_key)
.body(Bytes::from(test_content.as_bytes()).into())
.send()
.await?;
client
.put_object()
.bucket(BUCKET)
.key(untouched_object_key)
.body(Bytes::from("should-stay".as_bytes()).into())
.send()
.await?;
// Verify object exists initially
let resp = client.get_object().bucket(BUCKET).key(lifecycle_object_key).send().await?;
assert!(resp.content_length().unwrap_or(0) > 0);
let untouched_resp = client.get_object().bucket(BUCKET).key(untouched_object_key).send().await?;
assert!(untouched_resp.content_length().unwrap_or(0) > 0);
// Use a past midnight UTC date to trigger immediate lifecycle expiry without requiring days=0.
let yesterday_midnight_utc = Utc::now()
.date_naive()
.and_hms_opt(0, 0, 0)
.expect("midnight should always be valid")
- ChronoDuration::days(1);
let expiration = LifecycleExpiration::builder()
.date(aws_sdk_s3::primitives::DateTime::from_secs(yesterday_midnight_utc.and_utc().timestamp()))
.build();
let filter = LifecycleRuleFilter::builder().prefix(lifecycle_object_key).build();
let rule = LifecycleRule::builder()
.id("expire-test-object")
.filter(filter)
.expiration(expiration)
.status(aws_sdk_s3::types::ExpirationStatus::Enabled)
.build()?;
let lifecycle = BucketLifecycleConfiguration::builder().rules(rule).build()?;
client
.put_bucket_lifecycle_configuration()
.bucket(BUCKET)
.lifecycle_configuration(lifecycle)
.send()
.await?;
// Verify lifecycle configuration was set
let resp = client.get_bucket_lifecycle_configuration().bucket(BUCKET).send().await?;
let rules = resp.rules();
assert!(rules.iter().any(|r| r.id().unwrap_or("") == "expire-test-object"));
// Poll for deletion instead of using a fixed sleep to keep the test deterministic.
// Default scanner cycle interval is 60s with jitter, so allow enough time for one full cycle.
let deadline = tokio::time::Instant::now() + Duration::from_secs(150);
/// Poll until `GET bucket/key` returns `NoSuchKey`, or fail after `deadline`.
///
/// The scanner cycle is 1s under test, so expiry normally lands within a few
/// seconds; the deadline is a generous safety net, not the expected wall-clock.
async fn wait_for_object_expired(client: &Client, bucket: &str, key: &str, deadline: StdDuration) -> TestResult {
let start = std::time::Instant::now();
loop {
let get_result = client.get_object().bucket(BUCKET).key(lifecycle_object_key).send().await;
match get_result {
Ok(_) => {
if tokio::time::Instant::now() >= deadline {
panic!("Expected object to be deleted by lifecycle rule within 150s, but it still exists");
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
Err(e) => {
if let Some(service_error) = e.as_service_error() {
if service_error.is_no_such_key() {
println!("Lifecycle configuration test completed - object was successfully deleted by lifecycle rule");
break;
}
panic!("Expected NoSuchKey error, but got: {e:?}");
} else {
panic!("Expected service error, but got: {e:?}");
}
}
if object_is_gone(client, bucket, key).await? {
return Ok(());
}
if start.elapsed() >= deadline {
return Err(format!(
"object {bucket}/{key} was not expired by the lifecycle scanner within {}s",
deadline.as_secs()
)
.into());
}
tokio::time::sleep(StdDuration::from_millis(500)).await;
}
println!("Lifecycle configuration test completed.");
// Non-matching prefix object should remain available.
let untouched_after = client.get_object().bucket(BUCKET).key(untouched_object_key).send().await?;
assert!(untouched_after.content_length().unwrap_or(0) > 0);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
#[ignore = "requires running RustFS server at localhost:9000"]
async fn test_bucket_lifecycle_accepts_zero_days() -> Result<(), Box<dyn std::error::Error>> {
use aws_sdk_s3::types::{BucketLifecycleConfiguration, LifecycleExpiration, LifecycleRule, LifecycleRuleFilter};
let client = create_aws_s3_client().await?;
setup_test_bucket(&client).await?;
let expiration = LifecycleExpiration::builder().days(0).build();
let filter = LifecycleRuleFilter::builder().prefix("zero-days/").build();
/// 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()
.id("expire-zero-days")
.filter(filter)
.expiration(expiration)
.status(aws_sdk_s3::types::ExpirationStatus::Enabled)
.id(id)
.filter(LifecycleRuleFilter::builder().prefix(prefix).build())
.expiration(LifecycleExpiration::builder().days(days).build())
.status(ExpirationStatus::Enabled)
.build()?;
let lifecycle = BucketLifecycleConfiguration::builder().rules(rule).build()?;
Ok(rule)
}
async fn put_expiration_config(client: &Client, bucket: &str, rule: LifecycleRule) -> TestResult {
let lifecycle = BucketLifecycleConfiguration::builder().rules(rule).build()?;
client
.put_bucket_lifecycle_configuration()
.bucket(BUCKET)
.bucket(bucket)
.lifecycle_configuration(lifecycle)
.send()
.await?;
Ok(())
}
/// Env applied to every server in this module: run the scanner every second and
/// shrink the ILM processing/rounding boundary so `Days`-based deadlines are not
/// rounded up to the next real day.
fn fast_lifecycle_env() -> Vec<(&'static str, &'static str)> {
vec![("RUSTFS_SCANNER_CYCLE", "1"), ("RUSTFS_ILM_PROCESS_TIME", "1")]
}
/// `Days=1` expiry driven purely by `mod_time` back-dating (no day-length
/// acceleration). Proves:
/// * a matching-prefix object whose `mod_time` is 25h old is expired (the rule's
/// 1-day deadline is already in the past);
/// * the `put_object_with_backdated_mtime` backdoor does not trip the
/// replication gate that would otherwise block expiry;
/// * a non-matching-prefix object with the **same** back-dated `mod_time`
/// survives, isolating the prefix filter (not recency) as the cause.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lifecycle_expiry_backdated_mtime() -> TestResult {
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(vec![], &fast_lifecycle_env()).await?;
let client = env.create_s3_client();
let bucket = "ilm3-backdated";
client.create_bucket().bucket(bucket).send().await?;
let matched_key = "expire/object.txt";
let survivor_key = "keep/object.txt";
// 25h in the past: older than the 1-day rule with a normal 86400s day length.
let backdated = OffsetDateTime::now_utc() - time::Duration::hours(25);
put_object_with_backdated_mtime(&client, bucket, matched_key, b"expire me", backdated).await?;
put_object_with_backdated_mtime(&client, bucket, survivor_key, b"keep me", backdated).await?;
// Both objects exist before the lifecycle rule is installed.
assert!(!object_is_gone(&client, bucket, matched_key).await?);
assert!(!object_is_gone(&client, bucket, survivor_key).await?);
put_expiration_config(&client, bucket, expiration_rule("expire-backdated", "expire/", 1)?).await?;
// Matching, back-dated object is expired by the scanner.
wait_for_object_expired(&client, bucket, matched_key, StdDuration::from_secs(90)).await?;
// Negative control: identical back-dating, non-matching prefix -> survives.
assert!(
!object_is_gone(&client, bucket, survivor_key).await?,
"non-matching-prefix object must not be expired by a prefix-scoped rule"
);
Ok(())
}
/// `Days=1` current-version expiry on a **versioned** bucket, accelerated with
/// `RUSTFS_ILM_DEBUG_DAY_SECS`. Proves that current-version expiry inserts a
/// delete marker (rather than permanently removing the object): after expiry a
/// plain `GET` returns `NoSuchKey`, `ListObjectVersions` shows a latest delete
/// marker, and the original data version is retained.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lifecycle_versioned_current_version_expiry_creates_delete_marker() -> TestResult {
let mut env = RustFSTestEnvironment::new().await?;
// One lifecycle "day" == 2s, plus the fast scanner/rounding env.
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-versioned";
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/object.txt";
let put = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"versioned payload"))
.send()
.await?;
let data_version_id = put
.version_id()
.map(str::to_string)
.expect("versioned PUT returns a version id");
put_expiration_config(&client, bucket, expiration_rule("expire-versioned", "versioned/", 1)?).await?;
// Current version expiry adds a delete marker; a plain GET now 404s.
wait_for_object_expired(&client, bucket, key, StdDuration::from_secs(90)).await?;
// ListObjectVersions: original data version retained, latest is a delete marker.
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(data_version_id.as_str())),
"original data version {data_version_id} must be retained, got: {data_versions:?}"
);
let delete_markers = versions.delete_markers();
assert!(
delete_markers.iter().any(|m| m.is_latest() == Some(true)),
"expiry on a versioned bucket must create a latest delete marker, got: {delete_markers:?}"
);
// The retained data version is still directly readable by version id.
let by_version = client
.get_object()
.bucket(bucket)
.key(key)
.version_id(&data_version_id)
.send()
.await?;
assert_eq!(by_version.body.collect().await?.into_bytes().as_ref(), b"versioned payload");
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
/// `validate()` and the PutBucketLifecycleConfiguration handler. This is the
/// self-managed counterpart of the localhost-only
/// `test_bucket_lifecycle_rejects_zero_days` unit test.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lifecycle_rejects_zero_day_rule() -> TestResult {
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(vec![], &fast_lifecycle_env()).await?;
let client = env.create_s3_client();
let bucket = "ilm3-zero-day";
client.create_bucket().bucket(bucket).send().await?;
let lifecycle = BucketLifecycleConfiguration::builder()
.rules(expiration_rule("expire-zero-days", "zero-days/", 0)?)
.build()?;
let err = client
.put_bucket_lifecycle_configuration()
.bucket(bucket)
.lifecycle_configuration(lifecycle)
.send()
.await
.expect_err("zero-day expiration lifecycle rule should be rejected");
let service_error = err.as_service_error().expect("expected an S3 service error");
assert_eq!(
service_error.meta().code(),
Some("InvalidArgument"),
"expected InvalidArgument for zero-day expiration, got: {err:?}"
);
Ok(())
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
// 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.
//! Regression tests for e2e harness server-startup behavior.
//!
//! The harness spawns servers with the embedded console disabled so they never
//! contend for its fixed default port :9001 (often held by unrelated local
//! services such as Docker Desktop), and `wait_for_server_ready` must report a
//! server that dies during startup immediately instead of polling out the full
//! readiness timeout.
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path};
use serial_test::serial;
use std::net::TcpListener;
use std::time::{Duration, Instant};
/// Force the console back on (extra_env overrides the harness default)
/// while :9001 is occupied: the server exits at startup, and the harness
/// must surface that promptly rather than waiting out the 60s timeout.
#[tokio::test]
#[serial]
async fn test_start_fails_fast_when_server_exits_during_startup() {
init_logging();
// Occupy :9001 on both stacks; a failed bind means another local
// process already holds the port, which serves equally well.
let _guard_v6 = TcpListener::bind(("::", 9001)).ok();
let _guard_v4 = TcpListener::bind(("0.0.0.0", 9001)).ok();
// Resolve (and possibly build) the binary before starting the timer.
let _ = rustfs_binary_path();
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
let started = Instant::now();
let result = env
.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "true")])
.await;
let elapsed = started.elapsed();
let err = result.expect_err("server start should fail while :9001 is occupied");
assert!(err.to_string().contains("exited before becoming ready"), "unexpected start error: {err}");
assert!(
elapsed < Duration::from_secs(30),
"startup failure should be detected fast, took {elapsed:?}"
);
}
}
+13 -1
View File
@@ -34,6 +34,10 @@ workspace = true
default = []
rio-v2 = ["dep:rustfs-rio-v2"]
hotpath = ["dep:hotpath", "hotpath/hotpath", "rustfs-filemeta/hotpath", "rustfs-rio/hotpath"]
# Exposes shared lifecycle/tier test utilities (MockWarmBackend, fault
# injection, xl.meta transition assertions) via `api::tier::test_util`.
# Enable only from `[dev-dependencies]` (rustfs/backlog#1148 ilm-6).
test-util = []
[dependencies]
hotpath = { workspace = true, optional = true }
@@ -114,7 +118,9 @@ pin-project-lite.workspace = true
md-5.workspace = true
memmap2 = { workspace = true }
libc.workspace = true
rustix = { workspace = true }
# "process" adds getrlimit for the io_uring fd-cache RLIMIT_NOFILE gate
# (backlog#1178); "fs" comes from the workspace default.
rustix = { workspace = true, features = ["process"] }
rustfs-madmin.workspace = true
reqwest = { workspace = true }
aes-gcm.workspace = true
@@ -140,6 +146,12 @@ aws-smithy-http-client.workspace = true
# Observability and Metrics
metrics = { workspace = true }
# Runtime-probed io_uring read backend (backlog#1104). Linux-only, from
# crates.io. The guard scripts/check_no_tokio_io_uring.sh allows an explicit
# io-uring integration; only the tokio "io-uring" runtime feature is banned.
[target.'cfg(target_os = "linux")'.dependencies]
rustfs-uring = "0.2.1"
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util"] }
criterion = { workspace = true, features = ["html_reports"] }
+24 -12
View File
@@ -45,8 +45,8 @@ pub mod bucket {
pub use crate::bucket::lifecycle::bucket_lifecycle_ops::{
ExpiryState, LifecycleOps, RestoreRequestOps, TransitionState, TransitionedObject, apply_expiry_rule,
apply_transition_rule, enqueue_expiry_for_existing_objects, enqueue_transition_for_existing_objects,
enqueue_transition_immediate, get_global_expiry_state, get_global_transition_state, init_background_expiry,
post_restore_opts, run_stale_multipart_upload_cleanup_once, validate_transition_tier,
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,
};
}
@@ -261,9 +261,9 @@ pub mod 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_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,
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,
};
}
@@ -273,9 +273,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, 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, OldCurrentSize, 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;
@@ -326,6 +326,7 @@ pub mod global {
}
pub mod runtime {
pub use crate::runtime::instance::{InstanceContext, bootstrap_ctx};
pub use crate::runtime::sources::{
boot_time, bucket_monitor, deployment_id, endpoint_pools, expiry_state_handle, first_cluster_node_is_local,
global_lock_client, global_lock_clients, global_tier_config_mgr, local_disk_map_read, object_store_handle, region,
@@ -350,8 +351,8 @@ pub mod notification {
pub mod object {
pub use crate::object_api::{
BLOCK_SIZE_V2, ERASURE_ALGORITHM, GetObjectBodyCacheHook, GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader,
RangedDecompressReader, StreamConsumer, register_get_object_body_cache_hook,
BLOCK_SIZE_V2, ERASURE_ALGORITHM, GetObjectBodyCacheHook, GetObjectReader, ObjectInfo, ObjectMutationHook, ObjectOptions,
PutObjReader, RangedDecompressReader, StreamConsumer, register_get_object_body_cache_hook, register_object_mutation_hook,
};
}
@@ -387,9 +388,11 @@ pub mod store_list {
}
pub mod storage {
pub use crate::store::HealWalkVersion;
pub use crate::store::{
ECStore, all_local_disk, all_local_disk_path, find_local_disk_by_ref, init_local_disks, init_lock_clients,
prewarm_local_disk_id_map,
ECStore, all_local_disk, all_local_disk_path, find_local_disk_by_ref, init_local_disks,
init_local_disks_with_instance_ctx, init_lock_clients, prewarm_local_disk_id_map,
prewarm_local_disk_id_map_with_instance_ctx,
};
}
@@ -426,4 +429,13 @@ pub mod tier {
WarmBackend, WarmBackendGetOpts, WarmBackendImpl, build_transition_put_options, check_warm_backend, new_warm_backend,
};
}
#[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,
};
}
}
+195 -17
View File
@@ -78,7 +78,6 @@ use tracing::warn;
use url::Url;
use uuid::Uuid;
const DEFAULT_HEALTH_CHECK_DURATION: Duration = Duration::from_secs(5);
const DEFAULT_HEALTH_CHECK_RELOAD_DURATION: Duration = Duration::from_secs(30 * 60);
const REDACTED_CREDENTIAL: &str = "<redacted>";
@@ -119,24 +118,43 @@ pub struct ArnErrs {
pub bucket: String,
}
/// A single latency sample tagged with the instant it was recorded.
///
/// Only `dur` participates in (de)serialization; `at` is not `Serialize`
/// (`Instant` isn't) and is reconstructed as `Instant::now()` on deserialize.
/// A reloaded window therefore simply restarts aging, which is acceptable for
/// the in-memory endpoint health stats this type backs (see backlog#806-16).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LastMinuteLatency {
times: Vec<Duration>,
struct LatencySample {
dur: Duration,
#[serde(skip, default = "instant_now")]
start_time: Instant,
at: Instant,
}
fn instant_now() -> Instant {
Instant::now()
}
impl Default for LastMinuteLatency {
fn default() -> Self {
Self {
times: Vec::new(),
start_time: Instant::now(),
}
}
/// A rolling one-minute latency window.
///
/// backlog#806-16: the previous implementation stored bare `Vec<Duration>`
/// samples plus a single, never-updated `start_time`, and its `retain`
/// predicate ignored the element entirely — it evaluated the constant
/// `now.duration_since(self.start_time) < 60s`. Once the window had existed
/// for 60s, that predicate became `false` for every element, so each `add`
/// dropped ALL samples and the "last minute" average degenerated to the most
/// recent single sample. Each sample now carries its own timestamp and is
/// retained by its individual age.
///
/// This type is only used for in-memory endpoint health (`EpHealth`) and
/// admin-API display; it is not persisted or wire-serialized across versions
/// (the on-the-wire latency shape is `crate::bucket::target::LatencyStat`,
/// which carries only `curr`/`avg`/`max`). The `Serialize`/`Deserialize`
/// derives are retained solely so the enclosing `LatencyStat` keeps deriving
/// them; only the `Duration` part of each sample is serialized.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LastMinuteLatency {
times: Vec<LatencySample>,
}
impl LastMinuteLatency {
@@ -145,11 +163,16 @@ impl LastMinuteLatency {
}
pub fn add(&mut self, duration: Duration) {
let now = Instant::now();
// Remove entries older than 1 minute
self.add_at(Instant::now(), duration);
}
/// Records a sample at an explicit instant, dropping samples older than one
/// minute relative to `now`. Split out from `add` so the aging logic can be
/// exercised with a synthetic clock in tests.
fn add_at(&mut self, now: Instant, duration: Duration) {
self.times
.retain(|_| now.duration_since(self.start_time) < Duration::from_secs(60));
self.times.push(duration);
.retain(|sample| now.duration_since(sample.at) < Duration::from_secs(60));
self.times.push(LatencySample { dur: duration, at: now });
}
pub fn get_total(&self) -> LatencyAverage {
@@ -158,7 +181,7 @@ impl LastMinuteLatency {
avg: Duration::from_secs(0),
};
}
let total: Duration = self.times.iter().sum();
let total: Duration = self.times.iter().map(|sample| sample.dur).sum();
LatencyAverage {
avg: total / self.times.len() as u32,
}
@@ -311,7 +334,9 @@ impl BucketTargetSys {
}
pub async fn heartbeat(&self) {
let mut interval = tokio::time::interval(DEFAULT_HEALTH_CHECK_DURATION);
// Probe interval: `RUSTFS_REPL_HEALTH_CHECK_INTERVAL_MS` (default 5000ms,
// clamped to >=10ms), read once when the heartbeat task starts.
let mut interval = tokio::time::interval(crate::bucket::replication::replication_timing::health_check_interval());
loop {
interval.tick().await;
@@ -941,13 +966,37 @@ fn has_custom_ca_pem(target: &BucketTarget) -> bool {
!target.ca_cert_pem.trim().is_empty()
}
/// Env opt-in that re-enables loopback replication targets. Loopback (`127.0.0.1`,
/// `::1`, `localhost`) is a classic SSRF vector and stays rejected by default, but
/// single-host multi-instance dev setups and the e2e harness legitimately replicate
/// over loopback. Never set this in production.
const ALLOW_LOOPBACK_REPLICATION_TARGET_ENV: &str = "RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET";
fn loopback_replication_targets_allowed() -> bool {
std::env::var(ALLOW_LOOPBACK_REPLICATION_TARGET_ENV)
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
.unwrap_or(false)
}
fn validate_replication_target_endpoint(url: &Url) -> Result<(), OutboundUrlError> {
validate_replication_target_endpoint_inner(url, loopback_replication_targets_allowed())
}
fn validate_replication_target_endpoint_inner(url: &Url, allow_loopback: bool) -> Result<(), OutboundUrlError> {
match validate_outbound_url(url) {
Ok(()) => Ok(()),
// Replication targets are trusted infrastructure the operator configures, and
// legitimately live on private networks, so private addresses are always allowed.
Err(OutboundUrlError::ForbiddenHost {
reason: "private address",
..
}) => Ok(()),
// Loopback is far higher SSRF risk, so it is allowed only under the explicit,
// off-by-default opt-in above (single-host multi-instance / the e2e harness).
Err(OutboundUrlError::ForbiddenHost {
reason: "loopback address" | "loopback host",
..
}) if allow_loopback => Ok(()),
Err(err) => Err(err),
}
}
@@ -1881,6 +1930,76 @@ mod tests {
assert!(!replication_target_versioning_enabled(None));
}
fn parse_url(raw: &str) -> Url {
Url::parse(raw).expect("test URL should parse")
}
#[test]
fn replication_endpoint_always_allows_public_and_private() {
// Public hosts and private-network targets are allowed regardless of the
// loopback opt-in — replication commonly runs across trusted private infra.
for allow_loopback in [false, true] {
assert!(validate_replication_target_endpoint_inner(&parse_url("https://s3.example.com"), allow_loopback).is_ok());
assert!(validate_replication_target_endpoint_inner(&parse_url("http://10.0.0.5:9000"), allow_loopback).is_ok());
assert!(validate_replication_target_endpoint_inner(&parse_url("http://192.168.1.20"), allow_loopback).is_ok());
}
}
#[test]
fn replication_endpoint_rejects_loopback_without_opt_in() {
// Default (production) behaviour: loopback IP and localhost host both rejected.
let err = validate_replication_target_endpoint_inner(&parse_url("http://127.0.0.1:9000"), false)
.expect_err("loopback IP must be rejected by default");
assert!(matches!(
err,
OutboundUrlError::ForbiddenHost {
reason: "loopback address",
..
}
));
let err = validate_replication_target_endpoint_inner(&parse_url("http://localhost:9000"), false)
.expect_err("localhost must be rejected by default");
assert!(matches!(
err,
OutboundUrlError::ForbiddenHost {
reason: "loopback host",
..
}
));
}
#[test]
fn replication_endpoint_allows_loopback_with_opt_in() {
// e2e harness / single-host multi-instance: opt-in re-enables loopback in
// both IP (127.0.0.1, ::1) and hostname (localhost) forms.
assert!(validate_replication_target_endpoint_inner(&parse_url("http://127.0.0.1:9000"), true).is_ok());
assert!(validate_replication_target_endpoint_inner(&parse_url("http://[::1]:9000"), true).is_ok());
assert!(validate_replication_target_endpoint_inner(&parse_url("http://localhost:9000"), true).is_ok());
}
#[test]
fn replication_endpoint_opt_in_does_not_open_other_ssrf_targets() {
// The loopback opt-in must not widen into link-local / metadata endpoints.
let err = validate_replication_target_endpoint_inner(&parse_url("http://169.254.169.254/latest/meta-data"), true)
.expect_err("metadata endpoint must stay rejected even with loopback opt-in");
assert!(matches!(
err,
OutboundUrlError::ForbiddenHost {
reason: "metadata endpoint",
..
}
));
let err = validate_replication_target_endpoint_inner(&parse_url("http://[fe80::1]:9000"), true)
.expect_err("link-local must stay rejected even with loopback opt-in");
assert!(matches!(
err,
OutboundUrlError::ForbiddenHost {
reason: "link-local address",
..
}
));
}
#[test]
fn remote_target_connection_error_display_redacts_access_key() {
let err = BucketTargetError::RemoteTargetConnectionErr {
@@ -2147,4 +2266,63 @@ mod tests {
assert!(err.to_string().contains("invalid target CA PEM"));
}
// backlog#806-16 regression tests for the rolling one-minute latency window.
#[test]
fn last_minute_latency_averages_only_samples_within_window() {
let base = Instant::now();
let mut window = LastMinuteLatency::new();
// Two samples that will fall outside the 60s window once the fresh
// sample is added, plus one fresh sample.
window.add_at(base, Duration::from_millis(100));
window.add_at(base + Duration::from_secs(10), Duration::from_millis(200));
// 61s after `base`: both earlier samples are now >60s old relative to
// the 200ms sample? No — measured against `now`. Add a fresh sample far
// in the future so the two old samples age out.
window.add_at(base + Duration::from_secs(61), Duration::from_millis(400));
// Only the fresh sample survives: 100ms (age 61s) and 200ms (age 51s)
// vs the fresh one — 51s is still < 60s, so the 200ms sample stays.
// Assert the window kept exactly the in-window samples.
assert_eq!(window.get_total().avg, Duration::from_millis(300)); // (200 + 400) / 2
}
#[test]
fn last_minute_latency_drops_all_stale_samples() {
let base = Instant::now();
let mut window = LastMinuteLatency::new();
// Two stale samples, then one sample far enough in the future that both
// are strictly older than 60s.
window.add_at(base, Duration::from_millis(100));
window.add_at(base + Duration::from_secs(5), Duration::from_millis(300));
window.add_at(base + Duration::from_secs(120), Duration::from_millis(500));
// Both old samples aged out (>=60s); only the fresh 500ms remains. Under
// the OLD all-or-nothing bug the result would still have been the last
// single sample by coincidence, so also verify the mixed-window case below.
assert_eq!(window.get_total().avg, Duration::from_millis(500));
}
#[test]
fn last_minute_latency_two_fresh_samples_average_both() {
let base = Instant::now();
let mut window = LastMinuteLatency::new();
window.add_at(base, Duration::from_millis(100));
window.add_at(base + Duration::from_secs(1), Duration::from_millis(300));
// Both within the window -> average of both. The OLD bug degenerated the
// window to a single sample after 60s; here samples are close in time so
// the correct behaviour is a genuine two-sample average.
assert_eq!(window.get_total().avg, Duration::from_millis(200));
}
#[test]
fn last_minute_latency_empty_window_is_zero() {
let window = LastMinuteLatency::new();
assert_eq!(window.get_total().avg, Duration::from_secs(0));
}
}
@@ -51,7 +51,6 @@ use crate::store::ECStore;
use async_channel::{Receiver as A_Receiver, Sender as A_Sender, bounded};
use futures::Future;
use http::HeaderMap;
use lazy_static::lazy_static;
use rustfs_common::metrics::{
IlmAction, Metrics, ScannerLifecycleExpiryStateUpdate, ScannerLifecycleTransitionStateUpdate, global_metrics,
};
@@ -119,17 +118,15 @@ const DEFAULT_STALE_UPLOADS_CLEANUP_INTERVAL: StdDuration = StdDuration::from_se
const DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS: i64 = 5;
const EXPIRY_WORKER_QUEUE_CAPACITY: usize = 1000;
lazy_static! {
pub static ref GLOBAL_EXPIRY_STATE: Arc<RwLock<ExpiryState>> = ExpiryState::new();
pub static ref GLOBAL_TRANSITION_STATE: Arc<TransitionState> = TransitionState::new();
}
// Phase 5 (backlog#939): lifecycle expiry/transition state moved into the
// per-instance `InstanceContext`; these owner helpers forward to the current
// instance's context (lazily materialized, shared for single-instance).
pub fn get_global_expiry_state() -> Arc<RwLock<ExpiryState>> {
GLOBAL_EXPIRY_STATE.clone()
crate::runtime::global::current_ctx().expiry_state()
}
pub fn get_global_transition_state() -> Arc<TransitionState> {
GLOBAL_TRANSITION_STATE.clone()
crate::runtime::global::current_ctx().transition_state()
}
fn resolve_transition_worker_count() -> (i64, i64, i64) {
@@ -621,7 +618,7 @@ impl ExpiryState {
async fn worker(rx: &mut Receiver<Option<ExpiryOpType>>, api: Arc<ECStore>, stats: Arc<ExpiryStats>) {
let cancel_token = runtime_sources::background_services_cancel_token().unwrap_or_else(|| {
static FALLBACK: std::sync::OnceLock<tokio_util::sync::CancellationToken> = std::sync::OnceLock::new();
FALLBACK.get_or_init(tokio_util::sync::CancellationToken::new)
FALLBACK.get_or_init(tokio_util::sync::CancellationToken::new).clone()
});
loop {
@@ -1355,9 +1352,7 @@ fn spawn_tier_free_version_recovery_once(api: Arc<ECStore>) {
}
tokio::spawn(async move {
let cancel_token = runtime_sources::background_services_cancel_token()
.cloned()
.unwrap_or_else(CancellationToken::new);
let cancel_token = runtime_sources::background_services_cancel_token().unwrap_or_default();
let mut interval = tokio::time::interval(StdDuration::from_secs(60));
let mut bucket_marker: Option<String> = None;
let mut object_marker: Option<String> = None;
@@ -1430,9 +1425,7 @@ fn spawn_tier_delete_journal_recovery_once(api: Arc<ECStore>) {
}
tokio::spawn(async move {
let cancel_token = runtime_sources::background_services_cancel_token()
.cloned()
.unwrap_or_else(CancellationToken::new);
let cancel_token = runtime_sources::background_services_cancel_token().unwrap_or_default();
run_tier_delete_journal_recovery_loop(api, cancel_token).await;
});
}
@@ -2268,6 +2261,9 @@ pub async fn expire_transitioned_object(
opts.transition.expire_restored = true;
return match api.delete_object(&oi.bucket, &oi.name, opts).await {
Ok(dobj) => {
// Drop any cached restored-copy body so it does not sit resident
// until TTL after the copy is expired (ODC-26).
crate::object_api::notify_object_mutation(&oi.bucket, &oi.name).await;
//audit_log_lifecycle(*oi, ILMExpiry, tags, traceFn);
Ok(dobj)
}
@@ -2300,6 +2296,10 @@ pub async fn expire_transitioned_object(
schedule_lifecycle_replication_delete_if_needed(oi, &dobj).await;
// The transitioned version is gone; evict any cached body for this object
// so it does not linger until TTL (ODC-26).
crate::object_api::notify_object_mutation(&oi.bucket, &oi.name).await;
//audit_log_lifecycle(oi, ILMExpiry, tags);
emit_transitioned_expiration_event(oi, &dobj);
@@ -2312,7 +2312,7 @@ pub fn gen_transition_objname(bucket: &str) -> Result<String, Error> {
let mut hasher = Sha256::new();
hasher.update(format!("{}/{}", runtime_sources::deployment_id().unwrap_or_default(), bucket).as_bytes());
let hash = rustfs_utils::crypto::hex(hasher.finalize().as_slice());
let obj = format!("{}/{}/{}/{}", &hash[0..16], &us[0..2], &us[2..4], &us);
let obj = format!("{}/{}/{}/{}", &hash[0..16], &us[0..2], &us[2..4], us);
Ok(obj)
}
@@ -2809,6 +2809,13 @@ pub async fn apply_expiry_on_non_transitioned_objects(
}
};
schedule_lifecycle_replication_delete_if_needed(oi, &dobj).await;
// The object (or all its versions, for delete_all) was expired; evict any
// cached body so dead bytes do not sit resident until TTL (ODC-26). The
// cache identity is the decoded object name used by GET, not the
// encode_dir_object form passed to delete_object.
crate::object_api::notify_object_mutation(&oi.bucket, &oi.name).await;
//debug!("dobj: {:?}", dobj);
if dobj.name.is_empty() {
dobj = oi.clone();
@@ -35,7 +35,7 @@ pub(crate) fn tier_config_mgr_handle() -> Arc<RwLock<TierConfigMgr>> {
sources::tier_config_mgr_handle()
}
pub(crate) fn background_services_cancel_token() -> Option<&'static CancellationToken> {
pub(crate) fn background_services_cancel_token() -> Option<CancellationToken> {
sources::background_services_cancel_token()
}
@@ -63,7 +63,12 @@ impl PersistedTierDeleteJournalEntry {
if self.version != TIER_DELETE_JOURNAL_VERSION {
return Err(Error::other(format!("unsupported tier delete journal version {}", self.version)));
}
if self.obj_name.is_empty() || self.version_id.is_empty() || self.tier_name.is_empty() {
// 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
// without a versionId). Only reject entries missing the object or tier
// name, which are always populated for a TRANSITION_COMPLETE object.
if self.obj_name.is_empty() || self.tier_name.is_empty() {
return Err(Error::other("tier delete journal entry is incomplete"));
}
Ok(Jentry {
@@ -320,4 +325,39 @@ mod tests {
assert!(err.to_string().contains("incomplete"));
}
#[test]
fn tier_delete_journal_recovers_unversioned_tier_entry() {
// A remote tier that is unversioned records an empty `version_id`. Such a
// WAL entry must decode successfully so recovery can drive a versionless
// remote delete, otherwise the remote object is orphaned and the journal
// file leaks forever.
let payload = br#"{"version":1,"obj_name":"remote/object","version_id":"","tier_name":"WARM"}"#;
let decoded = decode_tier_delete_journal_entry(payload).expect("unversioned tier entry should decode");
assert_eq!(decoded.obj_name, "remote/object");
assert!(decoded.version_id.is_empty());
assert_eq!(decoded.tier_name, "WARM");
}
#[test]
fn tier_delete_journal_rejects_missing_tier_name() {
let payload = br#"{"version":1,"obj_name":"remote/object","version_id":"v1","tier_name":""}"#;
let err = decode_tier_delete_journal_entry(payload).expect_err("entry missing tier name should be rejected");
assert!(err.to_string().contains("incomplete"));
}
#[test]
fn tier_delete_journal_rejects_truncated_payload() {
// A partially written journal file fails at JSON deserialization, so
// relaxing the empty-version_id check does not admit truncated records.
let payload = br#"{"version":1,"obj_name":"remote/object","version_id":""#;
let err = decode_tier_delete_journal_entry(payload).expect_err("truncated journal payload should be rejected");
assert!(err.to_string().contains("decode tier delete journal failed"));
}
}

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