mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-29 09:38:59 +00:00
Compare commits
374 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c917d4b0ac | |||
| 4c13ddd17a | |||
| fc254f6318 | |||
| 95c1022c80 | |||
| 49f07aff39 | |||
| bbb479aec0 | |||
| b912ef977f | |||
| fa4e76172b | |||
| 0cec476966 | |||
| 9555899a96 | |||
| a0efd8a447 | |||
| 37d157b3ad | |||
| 237564b82d | |||
| f7affffa58 | |||
| 4a31d64f0d | |||
| 05e3db6674 | |||
| 08d2907f72 | |||
| 94a5e27114 | |||
| 775279b6fd | |||
| c442b0f449 | |||
| 60abc2c074 | |||
| bfccbe1088 | |||
| 58305bf479 | |||
| eff1d0c219 | |||
| c22d9d316b | |||
| bf985f58bc | |||
| 9b2953ee5d | |||
| 3d63a755a9 | |||
| b3b3eb57af | |||
| 4839096440 | |||
| eb755e2b97 | |||
| 4d8088ddbd | |||
| 7f146fc5de | |||
| 5426237a49 | |||
| d7afa4e38e | |||
| a3f5a8eaf9 | |||
| 547c678eed | |||
| df945b275a | |||
| 2e78a49c95 | |||
| 7ad0e726db | |||
| 45c3386b68 | |||
| 7af92b4f54 | |||
| daa627ee0e | |||
| 5c3d3a8220 | |||
| 6bc5fc77b5 | |||
| e822fc1552 | |||
| 2f6115e058 | |||
| 882e1a71b1 | |||
| b432f31c2c | |||
| 6e0640444e | |||
| 4fb9b0dc7f | |||
| 2216f00cfd | |||
| fd2a87d47e | |||
| 007cb7ea38 | |||
| c15a148c50 | |||
| 8e591e64d9 | |||
| 821e056f70 | |||
| a6695f6d30 | |||
| 5db7330d2a | |||
| 837286f959 | |||
| f65ac198dc | |||
| 1e10d752b9 | |||
| 362f6026ac | |||
| 5cedab09ab | |||
| d385cea7c6 | |||
| d5df66ac4f | |||
| 5a768d3a44 | |||
| 6d3ce90c0f | |||
| 0e42a3d1d9 | |||
| c3aac2279f | |||
| 300beff970 | |||
| 0a2370c024 | |||
| 0c9d721910 | |||
| f6c227628f | |||
| 0cbfa1ac90 | |||
| ab5aa54035 | |||
| 464bf45e15 | |||
| bc2d8e0c60 | |||
| 24cf2cdb78 | |||
| e076e8cc6e | |||
| 9d84056d7b | |||
| f566b382a0 | |||
| a909f2f27b | |||
| 5af997ce5f | |||
| 4bd8cc1369 | |||
| 3500f2e5ee | |||
| e0e2eb30a9 | |||
| 467fb0a15c | |||
| 0902538ceb | |||
| fec09968b9 | |||
| 4b09239ceb | |||
| cc3c39da5c | |||
| 07f6d5cda6 | |||
| e79337bb5c | |||
| 683ff52a7b | |||
| 63e57378d6 | |||
| b2a376c2d2 | |||
| 887868e7cd | |||
| 0ea7f17fd7 | |||
| 5532510e42 | |||
| fc6dfa891a | |||
| 994678cfcf | |||
| cee5009c57 | |||
| bb130e2655 | |||
| 0711a6f4fe | |||
| 0a25d25e68 | |||
| 009dd93788 | |||
| 7cacd1f558 | |||
| c3242f83ba | |||
| e0bd18bd50 | |||
| 7f0c42e2bb | |||
| 09a991e735 | |||
| 14c249c266 | |||
| 5acc52b7f9 | |||
| 26663e0d5d | |||
| caeaa4bf34 | |||
| 05f52beea3 | |||
| 25cf7922f5 | |||
| e6706bb94a | |||
| a609b92b3c | |||
| 4e353fb3c8 | |||
| 058f81c61f | |||
| b4e3c7117e | |||
| 23f4683f20 | |||
| a539b33583 | |||
| 4e63ee962f | |||
| 12b7f22fca | |||
| a047bbfcfb | |||
| 556f8ed62f | |||
| 02ad75e552 | |||
| 21c85481b8 | |||
| d5409845e2 | |||
| 7667b7aaf8 | |||
| 29b4a98e74 | |||
| fa7499ce1c | |||
| 1397a4e7ca | |||
| afaea2a3ec | |||
| 78dd2d40d3 | |||
| 342f9f94bc | |||
| d887e7e31d | |||
| 0eb9dd5bdc | |||
| 50c4dcca4f | |||
| bdf3f0484d | |||
| e897b2d7bb | |||
| 92f72c3912 | |||
| 6fa2d06731 | |||
| 18ff36c22d | |||
| 03af8e472b | |||
| 42fc840630 | |||
| 3fe935982f | |||
| 6d8e196f36 | |||
| 5c99ca1328 | |||
| 44d2c3bd34 | |||
| 7f19e9a465 | |||
| 92f83bfe15 | |||
| 21787a4742 | |||
| d874831cec | |||
| 6da69180d8 | |||
| 258b7d6f06 | |||
| 05886a2c3c | |||
| 99e1f5fbd2 | |||
| c984bc7251 | |||
| 233865d172 | |||
| c5eb1c69ba | |||
| 77b5e1b64c | |||
| 23a5db4012 | |||
| 516f7fecc1 | |||
| 1e95e6d311 | |||
| 3cbe3d6b94 | |||
| 61d4e04d65 | |||
| 6974963e20 | |||
| 7ab0955f8b | |||
| 2cf5fd6bfc | |||
| de2c337fae | |||
| 5988606e68 | |||
| e73ed4f2a1 | |||
| ffde6c43ee | |||
| f52dde87d1 | |||
| 8e83087ba4 | |||
| a63b79004c | |||
| 0364523dad | |||
| 2dc4d0b651 | |||
| 2ee111ad8b | |||
| 9ddb30139d | |||
| ffd1b94e1f | |||
| ce41adfa9b | |||
| 59361ed786 | |||
| dfb0a20048 | |||
| 7320d7fab2 | |||
| 28d19db9fc | |||
| e257573962 | |||
| 45b675c641 | |||
| 05caec0bd5 | |||
| eaa17e0441 | |||
| 2b6cc0ee08 | |||
| 938f7296f9 | |||
| 866ac5073d | |||
| 187a060919 | |||
| cda443bd81 | |||
| 44b1916103 | |||
| 9d1b10144f | |||
| d7f30fe0a2 | |||
| 20c4ea864a | |||
| d1c2c42c90 | |||
| fc43b149c5 | |||
| fa235e9018 | |||
| dd46de0945 | |||
| bf6f0e5e81 | |||
| beb807ae2b | |||
| d8426dc459 | |||
| f46ea6e14f | |||
| fa26b6730d | |||
| 7876319811 | |||
| ea417b6a32 | |||
| 4963412265 | |||
| 8ac618e6c2 | |||
| 1c88aa43c1 | |||
| a5a73610b6 | |||
| 9eaf5fc8e3 | |||
| 3132637294 | |||
| 358caa23cb | |||
| 6765aca3f9 | |||
| 4133fbe0fc | |||
| 6f6d8a4d3e | |||
| cb344a3c77 | |||
| 36e97aba26 | |||
| a2fc6e15df | |||
| 0269c47bc6 | |||
| d26adc29ca | |||
| 5131ba8271 | |||
| 14f31b797a | |||
| 9f61bad94f | |||
| 8e214104f3 | |||
| 6bab9e421b | |||
| d9e0a25174 | |||
| 1c8088d0b2 | |||
| ffcdab900a | |||
| b94bf874bf | |||
| 7d96ffd7fb | |||
| 027a749646 | |||
| 6c23b8506e | |||
| 8166561702 | |||
| 9866f68d86 | |||
| 0e8f1a187c | |||
| b32bd1f8a9 | |||
| c9848a6096 | |||
| 6e88ab2a25 | |||
| 05d4480f08 | |||
| abee09dad9 | |||
| a044d11443 | |||
| e1e6a8b020 | |||
| 5cfe4ccc7d | |||
| df2db15ce8 | |||
| 0321e9350d | |||
| cd9a2eecb1 | |||
| 8b09634e62 | |||
| 1ede77b1c1 | |||
| 666e251b78 | |||
| 65ba138c27 | |||
| 92ae19b340 | |||
| 4607c3be53 | |||
| e0bac66941 | |||
| 31dc78eab0 | |||
| daca7294c7 | |||
| 1655f3192e | |||
| 0adb3c5ea1 | |||
| 68e156a5c5 | |||
| 3f60cc743e | |||
| 35af4a611f | |||
| a8e7cce5e1 | |||
| 9469dfa5b8 | |||
| f1d2af698c | |||
| d5f8c6c044 | |||
| f5303bad95 | |||
| cb0d4ffa76 | |||
| 5b61b030a4 | |||
| 0fbb5ba87b | |||
| f6e8ce4639 | |||
| 937b311316 | |||
| 62c2f81afd | |||
| eed1e97967 | |||
| 97b618bc2b | |||
| 7805cf5ae6 | |||
| bb7bba3237 | |||
| 17f0bd2637 | |||
| 7f569b67cb | |||
| d13345dc65 | |||
| 79d745413e | |||
| 9f25858b05 | |||
| a4e7dd70a6 | |||
| d6d22afc6e | |||
| 75381d4ffe | |||
| f32597bdb0 | |||
| 1fac7a5871 | |||
| 0e2e01d060 | |||
| 4f133eb95f | |||
| 302dd42d38 | |||
| 48b2f3d6e3 | |||
| 376b90f61f | |||
| b6838b262f | |||
| 28fdcc87be | |||
| 35f3599992 | |||
| b44e82fef1 | |||
| 7ddaae397b | |||
| 9e4c5e949f | |||
| 7cc211ae17 | |||
| a27fe2f56c | |||
| bd978bed2d | |||
| fe67af3524 | |||
| 26573622bc | |||
| eeafc355d4 | |||
| 955577b66f | |||
| 1cff6f20c9 | |||
| 2abfdd8261 | |||
| 908ca548bb | |||
| c92e99ba95 | |||
| a774bc07da | |||
| 69f543568b | |||
| 2269896f5e | |||
| 67c4e3e60e | |||
| 4f0be83ea5 | |||
| db3b08b612 | |||
| 998c3f561c | |||
| f42fc54362 | |||
| 8ebedddfa1 | |||
| a73f4c345f | |||
| ebc0aa0365 | |||
| aec2ee9ec1 | |||
| 4290f390dd | |||
| 056ebcee38 | |||
| 18f0c161dd | |||
| 1ac0841f6f | |||
| 133499c2d5 | |||
| b0c6c4cbce | |||
| 21049401fa | |||
| 83d73b34f3 | |||
| f9e8440a04 | |||
| 7f5873dac8 | |||
| 3ed682be42 | |||
| 15f4e75870 | |||
| 4faea7fcbc | |||
| 9b197fc1c2 | |||
| 53728a03d3 | |||
| 04bfd48eb1 | |||
| 2c113542f8 | |||
| 825bf0e2d8 | |||
| 0346108ae4 | |||
| 79509aad2d | |||
| d7d880b37d | |||
| cf9e9c6fd5 | |||
| 361334ab08 | |||
| 889a45ad4d | |||
| 5ec124bf23 | |||
| 906805568b | |||
| 230e5fc31a | |||
| 40c089f31b | |||
| 569fa3ec87 | |||
| edfb3c134f | |||
| 314c17205e | |||
| 814682d6bb | |||
| 87128682b0 | |||
| 4589148f48 | |||
| accd312465 | |||
| 627c396649 | |||
| c818177b54 | |||
| ec47c20ced | |||
| 1e14c05cf0 | |||
| 7b2cc1f427 | |||
| 97edb2e5cf | |||
| e1fc4b12ea | |||
| e279a4f48a | |||
| 8ace340694 | |||
| 701c3eee5b | |||
| a9e3613cfd |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: adversarial-validation
|
||||
description: Execute the Adversarial Validation policy from the root AGENTS.md — run the six reviewer roles (correctness, security, concurrency/durability, compatibility, performance, test coverage) with RustFS-specific attack probes. Use on every behavior-affecting code change, bug fix, or design proposal before declaring it done.
|
||||
description: Execute the Adversarial Validation policy from the root AGENTS.md — run the seven reviewer roles (correctness, simplicity, security, concurrency/durability, compatibility, performance, test coverage) with RustFS-specific attack probes. Use on every behavior-affecting code change, bug fix, or design proposal before declaring it done.
|
||||
---
|
||||
|
||||
# Adversarial Validation Playbooks
|
||||
@@ -53,14 +53,22 @@ shipped bug or rule that earns each probe its place.
|
||||
- Exercise the zero/empty end of every new size or count parameter: zero-length object PUT then GET (body must be empty, not error), part count 0, empty Vec of disks/entries into aggregation functions, and env/config values of 0 (must clamp or reject, never divide-by-zero or 'scan nothing and report zero usage'). Anywhere the diff computes a ratio, capacity, or progress percentage, plug in 0 and the max value.
|
||||
- Where: crates/ecstore aggregation and scanner paths; crates/object-capacity; config/env parsing in touched crates
|
||||
- Evidence: Commits 787cc77a7 'clamp zero capacity env values to safe defaults' (#4559) and 32b1094ec 'resolve a symlinked scan root instead of silently counting zero' (#4564) — zero-as-silent-wrong-answer is a recurring repo bug class.
|
||||
- Smaller-diff attack: rewrite the diff's change mentally (or actually, in scratch) as the minimal in-place edit and compare. Flag as findings: a helper function with exactly one caller introduced by this diff; a file rewrite where a 3-line edit inside the existing control flow suffices; reshaped control flow in init/locking/metadata/quorum paths beyond what the fix requires; new string literals duplicating existing constants (grep the token first); #[path] module inclusion. If the smaller diff achieves identical behavior, report it with the concrete replacement.
|
||||
- Where: Any diff; extra scrutiny for crates/ecstore, crates/lock, rustfs/src/storage where 'preserve the existing control-flow shape' is an explicit rule
|
||||
- Evidence: AGENTS.md 'Change Style for Existing Logic' (one-off helper ban, preserve control-flow shape in distributed/locking/metadata paths, no #[path]) and 'Constant and String Usage'; Adversarial Validation section names the smaller-diff clause as a correctness-adversary finding.
|
||||
- For any diff touching multipart or object commit paths, order the operations on paper and attack the failure point between them: kill the process (or return Err) after the commit rename but before cleanup, and after cleanup but before commit. Verify the earlier-failure case leaves the object readable and the later-failure case leaves no half-visible object; part meta files must never be deleted before the commit is durable.
|
||||
- Where: crates/ecstore multipart commit/cleanup (set_disk/ops); rustfs/src/storage multipart handlers
|
||||
- Evidence: Commit c77c5f047 'defer multipart part.N.meta cleanup until after commit' (#4548) — cleanup-before-commit ordering already caused a real data-loss window; the #4221 durability work shows fsync/ordering bugs are endemic here.
|
||||
|
||||
Null report example: "Attacked quorum-1 error reduction, exact max-keys listing boundary, trailing-slash dir keys, nil-UUID tier versionId, mid-stream reconstruct error propagation, and a minimal-diff rewrite — no break found; diff is already the minimal in-place edit."
|
||||
Null report example: "Attacked quorum-1 error reduction, exact max-keys listing boundary, trailing-slash dir keys, nil-UUID tier versionId, and mid-stream reconstruct error propagation — no break found."
|
||||
|
||||
### Simplicity adversary
|
||||
|
||||
- Smaller-diff attack: rewrite the diff's change mentally (or actually, in scratch) as the minimal in-place edit and compare. Flag as findings: a helper function with exactly one caller introduced by this diff; a file rewrite where a 3-line edit inside the existing control flow suffices; reshaped control flow in init/locking/metadata/quorum paths beyond what the fix requires; new string literals duplicating existing constants (grep the token first); #[path] module inclusion. If the smaller diff achieves identical behavior, report it with the concrete replacement.
|
||||
- Where: Any diff; extra scrutiny for crates/ecstore, crates/lock, rustfs/src/storage where 'preserve the existing control-flow shape' is an explicit rule
|
||||
- Evidence: AGENTS.md 'Change Style for Existing Logic' (one-off helper ban, preserve control-flow shape in distributed/locking/metadata paths, no #[path]) and 'Reuse Before You Write' (constants clause); the Adversarial Validation roles list charters the simplicity adversary with exactly this attack.
|
||||
- Reuse-and-necessity attack: for each new helper the diff introduces, run `ls crates/utils/src crates/common/src` and `rg -i 'fn \w*<term>'` over those dirs plus the touched crate (snake_case signatures — a full-text single-word grep drowns, a multi-word phrase returns nothing). A reimplementation of an existing workspace utility, or of plain std/tokio behavior no wrapper refines, is a finding — but so is forced reuse with mismatched semantics (normalization such as `clean` resolving `.`/`..` against raw S3 keys, error type, backoff, durability gating). For each new defensive branch, demand the nameable trigger and flag re-validation of what a validated upstream layer on the SAME path already guarantees — excluding the Cross-Cutting Domain Invariant patterns (nil/empty/absent UUID, dual metadata keys, unversioned-tier versionId) and re-checks before destructive actions, which are load-bearing even when redundant on the happy path. For each new test, flag near-duplicates pinning the same code path AND poison-value class as an existing test — boundary companions (n==max vs max+1, absent vs empty vs nil UUID, MetaObject vs MetaDeleteMarker) are never near-duplicates; the test-coverage skeptic playbook below mandates them.
|
||||
- Where: Any diff adding helpers, branches on decoded/peer data, or tests; helper checks against crates/utils, crates/common, and the touched crate
|
||||
- Evidence: AGENTS.md 'Reuse Before You Write' and 'Necessary Code Only'; GHSA-f4vq-9ffr-m8m3 (normalization-asymmetry traversal — why forced reuse of normalizing helpers on raw keys is itself an attack); docs/operations/tier-ilm-debugging.md nil-versionId incident (why boundary re-checks are load-bearing).
|
||||
|
||||
Null report example: "Rewrote the diff as an in-place edit (no smaller equivalent exists), grepped both new helpers against crates/utils, crates/common, and the touched crate (no existing equivalent; call-site semantics checked), verified the two new defensive branches name concrete corrupt-input triggers, and checked the added tests against the existing suite (each pins a distinct poison-value class) — no break found."
|
||||
|
||||
### Security reviewer
|
||||
|
||||
@@ -76,6 +84,9 @@ Null report example: "Attacked quorum-1 error reduction, exact max-keys listing
|
||||
- For any secret/token/signature/password comparison in the diff, check it uses a constant-time compare (e.g. subtle/constant_time_eq), not == or early-return byte loops. Then check the failure-response paths: construct an invalid-user request and an invalid-secret request and confirm they are indistinguishable (same error, no early length short-circuit) so an attacker cannot enumerate valid users or time-side-channel the secret.
|
||||
- Where: crates/protocols/ (FTPS/WebDAV/FormPost auth), crates/credentials/, rustfs/src/auth.rs, RPC signature verification
|
||||
- Evidence: GHSA-3p3x-734c-h5vx (FTPS/WebDAV early-return string equality + distinguishable invalid-user vs invalid-password). Fix commits 3c3113619 (constant-time FTPS/WebDAV) and c41062f27 (constant-time FormPost signature). 3p3x was fixed by PR #4403.
|
||||
- If the diff parses or transports secret-bearing config (env vars, key files, connection strings), grep every error-construction and format site on that value's path (`format!` feeding `Error::other`/`configuration_error`/`panic!`/`expect`) for interpolation of the raw value or of variables named like secret material. Construct the likeliest misconfiguration: the operator supplies the bare secret without the expected `<name>:` prefix (or with a stray newline) — if the parse-failure hint echoes the input, the secret lands in startup logs. Error strings are log content; the hint may name the env var and expected format, never the value. If the diff re-implements an existing parse helper, diff the two error paths — the duplicate is where the leak hides.
|
||||
- Where: rustfs/src/init.rs (env plumbing), crates/kms/src/config.rs, crates/credentials/, any from_env/parse on secret values; mechanical backstop in scripts/check_logging_guardrails.sh (secret-interpolation check)
|
||||
- Evidence: PR #5222 introduced `got: {secret_str}` in build_static_kms_config's format-hint error — a bare base64 key (the secret itself) would have been echoed into startup logs; fixed by PR #5243. The parallel parse in KmsConfig::from_env already omitted the value: the leak lived only in the duplicated copy (AGENTS.md 'Reuse Before You Write').
|
||||
- If the diff touches internode/RPC auth secret handling, trace whether the RPC HMAC secret can fall back to a public default (e.g. 'rustfsadmin', 'rustfs rpc') or be derived deterministically from the S3 root credentials. Construct the case where RUSTFS_RPC_SECRET is unset and confirm the code fails closed rather than silently using a default or a root-derived key. Verify RPC signing keys are independent random secrets, not reused across S3-root/RPC-HMAC/STS-JWT roles.
|
||||
- Where: crates/credentials/, crates/ecstore/src/rpc/, internode auth setup
|
||||
- Evidence: GHSA-r5qv-rc46-hv8q (fell back to 'rustfsadmin'), GHSA-75fx/68cw (RPC secret derivable from root creds → forgeable signatures), GHSA-h956 (hard-coded 'rustfs rpc'), GHSA-m77q (STS JWT reused root secret). Fix commit 7b2055405 (fail closed when deriving RPC secret from default credentials, PR#4402).
|
||||
@@ -243,7 +254,7 @@ Null report example: "Attacked the new rename_data commit-section work, durabili
|
||||
- If the diff writes internal object metadata, run the dual-key mutation: delete the `x-minio-internal-<suffix>` write (keeping only `x-rustfs-internal-`) and check whether any test fails. Because `get_bytes` prefers the RustFS key, every read-back test stays green while MinIO interop is silently broken — coverage must include an assertion that BOTH keys are present in the stored metadata map.
|
||||
- Where: crates/utils/src/http/metadata_compat.rs and all its callers in crates/ecstore and rustfs/src/storage
|
||||
- Evidence: CLAUDE.md domain convention: metadata must be written under both x-rustfs-internal- and x-minio-internal- keys for MinIO interop; get_bytes prefers the RustFS key, making the MinIO-key half of the invariant invisible to read-back tests.
|
||||
- For changed quorum/version/UUID logic, name the tests covering the specific poison values: quorum−1 disks, nil UUID, absent vs empty vs nil-serialized UUID bytes, and remote-tier version_id of None/"" (unversioned tier bucket → no versionId sent). Mutation check: remove a `.filter(|u| !u.is_nil())` guard from the diff and confirm a test fails; if none does, the nil-UUID class is uncovered.
|
||||
- For changed quorum/version/UUID logic, name the tests covering the specific poison values: quorum−1 disks, nil UUID, absent vs empty vs nil-serialized UUID bytes, remote-tier version_id of None/"" (unversioned tier bucket → no versionId sent), and the same metadata read on both MetaObject and MetaDeleteMarker version types. Mutation check: remove a `.filter(|u| !u.is_nil())` guard from the diff and confirm a test fails; if none does, the nil-UUID class is uncovered.
|
||||
- Where: crates/ecstore (tier recovery, heal, quorum paths), crates/filemeta, code reading UUIDs from xl.meta metadata
|
||||
- Evidence: Commit 726f3dc18 (#4552) fixed rejection of empty remote version_id in tier recovery. CLAUDE.md invariant: absent/empty/nil UUID all mean 'no value', not Uuid::nil(). docs/operations/tier-ilm-debugging.md: None/"" tier version means unversioned bucket. df9cbc4ed (#4427): unvalidated distribution values caused shuffle index panic — edge values reached production untested.
|
||||
- For any pagination/limit/truncation change, construct the exact-boundary test: result count == max (page exactly full), max+1, and a delimiter re-fold that lands precisely on the page boundary — assert both the item count AND the is_truncated/continuation marker. Off-by-one at the page boundary is a recurring shipped bug here.
|
||||
|
||||
@@ -43,16 +43,7 @@ Use this skill to review code changes consistently before merge, before release,
|
||||
|
||||
#### Rust-specific checks (apply to all Rust changes)
|
||||
|
||||
- **unwrap/expect in production**: Search changed files for `.unwrap()` and `.expect(` outside test modules. Every `unwrap()` in production code must have a justification comment or be replaced with `?`.
|
||||
- **Silent type truncation**: Search for `as u8/u16/u32/u64/usize/i8/i16/i32/i64/isize` casts. Every `as` cast must be justified; negative-to-unsigned and large-to-small are bugs by default. Use `try_into()` or explicit clamping.
|
||||
- **Unnecessary cloning**: Check `.clone()` calls in loops, per-request paths, and on structs with >5 heap-allocated fields. Consider `Arc`, references, or `Cow<str>`.
|
||||
- **Lock ordering**: If the change acquires multiple locks, verify the order matches all other call sites. Document the order in a comment.
|
||||
- **Locks across .await**: Flag any `tokio::sync::RwLock`/`Mutex` guard held across an `.await` point without bounded hold time.
|
||||
- **Recursion depth**: If the change adds or modifies a recursive function, verify it has a depth limit or uses iterative traversal with an explicit stack.
|
||||
- **Error types**: Flag `Result<_, String>`, `Box<dyn Error>`, and missing `Error::source()` implementations in public APIs.
|
||||
- **Test assertions**: Every test function must have at least one `assert!`. Flag tests that only call code without verifying results.
|
||||
- **println/eprintln**: Search changed files for `println!`/`eprintln!` outside test modules. Production code must use `tracing` macros.
|
||||
- **Serde safety**: Structs deserialized from untrusted input (S3 API, user config) should have `#[serde(deny_unknown_fields)]`.
|
||||
Run the full checklist in [rust-code-quality](../rust-code-quality/SKILL.md) — the canonical Rust review checklist for the unwrap/casting/cloning/locking/recursion/error-type/serde/test rules and the reuse-and-necessity checks (duplicated helpers, defensive branches without a nameable trigger, redundant error wrapping). Do not restate those rules here; carry its P0–P3 ratings over unchanged and use this skill's output format.
|
||||
|
||||
### 4) Findings-first output
|
||||
- Order findings by severity:
|
||||
|
||||
@@ -36,6 +36,9 @@ rg -n 'println!\|eprintln!' <changed-files> | grep -v test
|
||||
|
||||
# 6. Ordering::Relaxed usage (verify each is intentional)
|
||||
rg -n 'Ordering::Relaxed' <changed-files>
|
||||
|
||||
# 7. Default substituted for a possibly-required value (judge each: is the value optional by domain?)
|
||||
rg -n 'unwrap_or_default\(\)|unwrap_or\(' <changed-files>
|
||||
```
|
||||
|
||||
## Manual Review Checklist
|
||||
@@ -55,8 +58,8 @@ For every Rust code change, verify:
|
||||
- [ ] No `f64 as usize` without prior clamping
|
||||
|
||||
### Concurrency
|
||||
- [ ] Lock acquisition order is documented when multiple locks are used
|
||||
- [ ] No `tokio::sync` write guards held across `.await` without bounded hold time
|
||||
- [ ] Lock acquisition order is documented when multiple locks are used, and matches every other call site taking any overlapping subset (ABBA check)
|
||||
- [ ] No `tokio::sync` lock guard (read or write) held across `.await` without bounded hold time — long-lived read guards wedge writers (#4195)
|
||||
- [ ] Concurrent counters use `compare_exchange` loops, not load-then-store
|
||||
- [ ] `std::sync::Mutex` in async context is held only briefly, never across `.await`
|
||||
|
||||
@@ -84,12 +87,19 @@ For every Rust code change, verify:
|
||||
- [ ] No camelCase statics or Hungarian notation
|
||||
- [ ] New string literals don't duplicate existing constants
|
||||
|
||||
### Reuse and Necessity
|
||||
- [ ] No new helper duplicating an existing workspace utility (`crates/utils`, `crates/common`, the touched crate) or plain std/tokio behavior no wrapper refines; reused helpers match the call site's semantics (normalization, error type, backoff, durability gating)
|
||||
- [ ] No branch without a nameable concrete trigger; no re-validation of what a validated upstream layer on the same path already guarantees (Cross-Cutting Domain Invariant patterns and pre-destructive-action re-checks are load-bearing — keep them)
|
||||
- [ ] Error context attached once where actionable, not re-wrapped at every hop; no typed→generic error conversion below aggregation/quorum layers
|
||||
- [ ] No comments narrating the next line, restating a signature, or describing the change itself (invariant comments — lock ordering, `SAFETY`, unwrap justification — are not narration)
|
||||
- [ ] No near-duplicate test pinning the same code path and poison-value class as an existing test (boundary companions — n==max vs max+1, absent/empty/nil UUID — are never near-duplicates)
|
||||
|
||||
## Severity Classification
|
||||
|
||||
- **P0 (Block merge)**: `unwrap()` in request hot path, silent truncation on user input, lock ordering violation, recursion without depth limit
|
||||
- **P1 (Must fix)**: `Result<_, String>` in public API, unnecessary clone in hot path, `Box<dyn Error>` in trait method
|
||||
- **P2 (Should fix)**: Missing `assert!` in test, `println!` in production, missing `with_capacity`
|
||||
- **P3 (Nice to fix)**: Naming convention violation, missing doc comment, `as_ptr()` vs `Arc::ptr_eq`
|
||||
- **P1 (Must fix)**: `Result<_, String>` in public API, unnecessary clone in hot path, `Box<dyn Error>` in trait method, `unwrap_or_default()` on a domain-required value (metadata, quorum, version id)
|
||||
- **P2 (Should fix)**: Missing `assert!` in test, `println!` in production, missing `with_capacity`, new helper duplicating an existing workspace utility, defensive branch with no nameable trigger (corrupt or stale persisted/peer data is always a nameable trigger for boundary-crossing values), near-duplicate test, redundant error re-wrapping
|
||||
- **P3 (Nice to fix)**: Naming convention violation, missing doc comment, `as_ptr()` vs `Arc::ptr_eq`, narrating comment
|
||||
|
||||
## Output Template
|
||||
|
||||
|
||||
@@ -60,12 +60,14 @@ For the full pattern map, read [advisory-patterns.md](references/advisory-patter
|
||||
### IAM and service accounts
|
||||
- Treat imported IAM payload fields as attacker-controlled: `parent`, `claims`, `accessKey`, `secretKey`, status, policy names, and groups.
|
||||
- For service account create/update/import, prove parent ownership or root/admin authority before writing credentials or claims; an action permission alone must not allow choosing root or another user as `target_user`.
|
||||
- Treat IAM export packages as credential disclosure surfaces; never include plaintext user or service-account secret keys unless the caller is allowed to recover those secrets and the export format is intentionally sealed.
|
||||
- Do not let `deny_only` or "no explicit deny" become an allow decision that skips required allow checks.
|
||||
- Test cross-user list/update/import flows with wrong, correct, self, parent, and root identities.
|
||||
|
||||
### STS, OIDC, and federation flows
|
||||
- Every STS endpoint must have an explicit authentication story: SigV4 where required, OIDC token verification for web identity, and role/session policy validation before issuing credentials.
|
||||
- JWT session tokens must be signed and verified by a trusted issuer/key path, not by service-account-controlled material or a reused root secret.
|
||||
- JWT verification must enforce required claims and expiration for every bearer token path; "allow missing exp" is never acceptable for user-presented credentials.
|
||||
- Public OIDC bootstrap and callback routes must treat `Host`, `X-Forwarded-Proto`, redirect targets, `state`, and callback parameters as untrusted; credential-bearing redirects require a configured, allowlisted origin.
|
||||
- OIDC discovery and validation URLs are SSRF sinks. Resolve and classify hostnames at connection time, reject rebinding to loopback/private/link-local ranges, and do not rely on literal string checks.
|
||||
|
||||
@@ -97,6 +99,8 @@ For the full pattern map, read [advisory-patterns.md](references/advisory-patter
|
||||
### Logging and debug output
|
||||
- Logs must never include access keys beyond safe identifiers, secret keys, session tokens, JWT claims, HMAC secrets, expected signatures, license secrets, or raw response bodies containing credentials.
|
||||
- Treat `Debug` implementations, `?value` tracing, merged config dumps, and dependency-level HTTP body logging as leak surfaces.
|
||||
- Error and panic messages are log content: they propagate through `?` and get printed by `error!`/startup logging far from where they were constructed. Never interpolate a raw config or credential value into an error string.
|
||||
- A value that fails secret-format parsing is usually the secret itself (e.g. a bare base64 key missing its `<name>:` prefix), so a parse-failure hint must name the env var or file and the expected format, never echo the input. Redacting `Debug` impls does not cover this channel.
|
||||
- Add log-capture tests or targeted unit tests for redaction wrappers when changing credential structs or response bodies.
|
||||
|
||||
### RPC, parsing, and panic safety
|
||||
@@ -137,7 +141,10 @@ Use these prompts while reviewing a diff:
|
||||
- Does a public/default/empty config change security behavior from fail-closed to fail-open?
|
||||
- Is any attacker-controlled value later used as a path, policy condition, credential identity, log field, URL, Origin, or response body?
|
||||
- Does this response contain stored replication, remote target, or service credentials that need redaction or stricter authorization?
|
||||
- Does any error constructor or `format!` interpolate a variable that can hold secret material, including a config parse error that echoes the raw input?
|
||||
- Does an IAM export/import path expose or trust plaintext credential secrets beyond the caller's intended authority?
|
||||
- Can this STS/OIDC path issue credentials without SigV4, trusted issuer validation, allowlisted redirects, or trusted-proxy host/scheme handling?
|
||||
- Can a service-account or STS token omit `exp`, forge `sessionPolicy`, or use a principal-controlled key as signing authority?
|
||||
- Does this outbound validation path resolve attacker-supplied hostnames and reject private, loopback, link-local, and rebound addresses at the actual connection boundary?
|
||||
- Is an archive entry, object key, or policy resource normalized differently between authorization and storage?
|
||||
- Is the same operation implemented in multiple paths, such as `CopyObject` vs `UploadPartCopy`, and do all paths enforce the same security contract?
|
||||
|
||||
@@ -27,14 +27,15 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
|
||||
### IAM import, service accounts, and privilege boundaries
|
||||
|
||||
- `GHSA-566f-q62r-wcr8`: `ImportIam` accepted attacker-controlled service account `parent`, `claims`, `accessKey`, and `secretKey`, enabling persistent backdoor accounts under root. Lesson: imported IAM payloads are untrusted data and must be validated against privilege boundaries.
|
||||
- `GHSA-3495-h8r9-gfqg`: `ExportIAM` wrote regular-user and service-account secret keys into exported ZIP data. Lesson: IAM export is a credential-disclosure boundary; redact, seal, or strictly justify every exported secret before treating export permission as safe.
|
||||
- `GHSA-5354-r3w2-34m8`: `AddServiceAccount` checked `CreateServiceAccountAdminAction` but trusted caller-supplied `target_user`, allowing service accounts under the root parent. Lesson: service-account create paths must validate parent ownership or root/admin authority, not only the create action.
|
||||
- `GHSA-xgr5-qc6w-vcg9`: `deny_only=true` skipped allow checks and let restricted service accounts mint unrestricted children. Lesson: deny-only logic must never become implicit allow for privilege creation.
|
||||
- `GHSA-mm2q-qcmx-gw4w`: leaked service account access keys plus update-without-ownership formed an escalation chain. Lesson: service-account identifiers are security-sensitive because update APIs consume them.
|
||||
|
||||
### STS, OIDC, and federation flows
|
||||
|
||||
- `GHSA-5qfg-mf7r-jp3w`: `AssumeRoleWithWebIdentity` was reachable without the required request authentication and could issue temporary credentials from crafted web identity input. Lesson: every STS route needs explicit SigV4 or trusted identity-provider validation before role assumption.
|
||||
- `GHSA-ccrv-v8v9-ch9q`: service-account-controlled material could self-sign JWT session tokens with forged policy claims. Lesson: session tokens must be signed by a trusted issuer/key path and validation must reject self-signed or principal-controlled tokens.
|
||||
- `GHSA-5qfg-mf7r-jp3w` and `GHSA-3473-5353-xhwh`: `AssumeRoleWithWebIdentity` was reachable through unauthenticated `POST /` routing and could issue temporary credentials from crafted web identity input. Lesson: every STS route needs explicit SigV4 or trusted identity-provider validation before role assumption, and unauthenticated exemptions must be narrowed to the exact action with uniform failure responses.
|
||||
- `GHSA-ccrv-v8v9-ch9q` and `GHSA-48rf-7j3q-3hfv`: service-account-controlled material could self-sign JWT session tokens with forged policy claims, and missing `exp` was accepted for service-account tokens. Lesson: session tokens must be signed by a trusted issuer/key path, enforce required claims and expiration, and reject self-signed or principal-controlled tokens.
|
||||
- `GHSA-9pjf-w3c2-m32r`, `GHSA-4x2q-cpx9-9h26`, and `GHSA-xvpm-p3f7-34c3`: public OIDC authorize/callback flows trusted request `Host` or forwarded scheme when building credential-bearing redirects. Lesson: OIDC redirects must use configured allowlisted origins and trusted-proxy handling; never derive the post-login credential destination from direct client headers.
|
||||
- `GHSA-m479-9x88-94w6`, `GHSA-frwq-mfqx-83p8`, `GHSA-q9q8-rf9r-fg9f`, and `GHSA-j5c2-hhf7-6gf5`: OIDC validation accepted attacker-controlled discovery URLs because hostname checks rejected only literal forbidden IPs, allowing DNS rebinding SSRF. Lesson: outbound federation URL validation must resolve and classify hostnames at the connection boundary and reject loopback, private, link-local, and rebound addresses.
|
||||
|
||||
@@ -58,7 +59,7 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
|
||||
|
||||
### Secrets, defaults, and cryptographic misuse
|
||||
|
||||
- `GHSA-j59h-h7q5-q348`, `GHSA-3wm5-wpm5-hmfm`, `GHSA-6wc8-xm48-qhmx`, and `GHSA-9gf3-jx4p-4xxf`: RustFS shipped known default root credentials that could authenticate to S3, admin APIs, IAM, KMS, console, and token-signing surfaces. Lesson: root credentials must be operator-provided or generated per install; known defaults and warnings are not acceptable for network-reachable deployments.
|
||||
- `GHSA-j59h-h7q5-q348`, `GHSA-3wm5-wpm5-hmfm`, `GHSA-6wc8-xm48-qhmx`, `GHSA-9gf3-jx4p-4xxf`, and `GHSA-63xc-c3w3-m2cf`: RustFS shipped known default root credentials that could authenticate to S3, admin APIs, IAM, KMS, console, and token-signing surfaces. Lesson: root credentials must be operator-provided or generated per install; known defaults and warnings are not acceptable for network-reachable deployments.
|
||||
- `GHSA-h956-rh7x-ppgj`: gRPC used the hard-coded token `rustfs rpc` on both client and server. Lesson: source-visible shared tokens are authentication bypasses.
|
||||
- `GHSA-r5qv-rc46-hv8q`: internode RPC HMAC secret fell back to the public default `rustfsadmin`. Lesson: RPC/internode auth must fail closed instead of silently using public defaults.
|
||||
- `GHSA-75fx-qg6f-8rm7` and `GHSA-68cw-96m3-h2cf`: internode RPC secrets were derivable from known root credentials, making raw storage RPC signatures forgeable when explicit RPC secrets were unset. Lesson: RPC auth keys must be independent random secrets, never derived from S3 root credentials, and raw storage RPC should not share the public S3 listener without an internode-only boundary.
|
||||
@@ -122,6 +123,7 @@ rg -n "deny_unknown_fields|serde.default|as u32|as usize|as i32" rustfs crates
|
||||
- Copy/upload fixes: include cross-bucket, cross-user, source-denied, destination-denied, copy-source-condition, and multipart completion cases.
|
||||
- Path fixes: include encoded traversal, absolute path, nested traversal, archive entries with `..`, valid object keys that resemble traversal text but should be rejected, and canonical bucket/prefix boundary checks.
|
||||
- Logging fixes: assert redacted output for structs and response bodies that may contain credentials.
|
||||
- IAM export fixes: assert exported archives omit plaintext user and service-account secrets unless the format deliberately encrypts or seals them.
|
||||
- RPC auth fixes: include captured metadata replay across two concrete methods, stale timestamps, wrong path, wrong method surrogate, wrong secret, and valid same-method calls.
|
||||
- Browser/CORS fixes: assert no credentials on reflected/default origins, correct behavior for explicit allowlists, and no same-origin script execution for previewed object content.
|
||||
- SSE fixes: inspect stored bytes and verify API metadata, read-back behavior, and on-disk ciphertext together.
|
||||
|
||||
@@ -60,6 +60,11 @@ body-cache-whitelist-check: ## Check the body-cache eligibility gate stays a fai
|
||||
@echo "🧱 Checking body-cache whitelist guard..."
|
||||
./scripts/check_body_cache_whitelist.sh
|
||||
|
||||
.PHONY: log-analyzer-rules-check
|
||||
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
|
||||
@echo "🩺 Checking log-analyzer rule anchors..."
|
||||
./scripts/check_log_analyzer_rules.sh
|
||||
|
||||
.PHONY: compilation-check
|
||||
compilation-check: core-deps ## Run compilation check
|
||||
@echo "🔨 Running compilation check..."
|
||||
|
||||
@@ -23,7 +23,7 @@ pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-gua
|
||||
@echo "✅ All pre-commit checks passed!"
|
||||
|
||||
.PHONY: pre-pr
|
||||
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check doc-paths-check planning-docs-check clippy-check test ## Run full pre-PR checks with clippy and tests
|
||||
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check doc-paths-check planning-docs-check log-analyzer-rules-check clippy-check test ## Run full pre-PR checks with clippy and tests
|
||||
@echo "✅ All pre-PR checks passed!"
|
||||
|
||||
.PHONY: dev-check
|
||||
|
||||
@@ -26,6 +26,14 @@ script-tests: ## Run shell script tests
|
||||
@echo "Running script tests..."
|
||||
./scripts/test_build_rustfs_options.sh
|
||||
./scripts/test_entrypoint_credentials.sh
|
||||
./scripts/test_internode_grpc_ab_bench.sh
|
||||
./scripts/test_object_batch_bench_enhanced.sh
|
||||
./scripts/test_exact_1mib_handoff_abba.sh
|
||||
./scripts/test_pinned_paired_abba_bench.sh
|
||||
./scripts/test_manual_transition_runbooks.sh
|
||||
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
|
||||
python3 ./scripts/check_object_data_cache_follower_samples.py --self-test
|
||||
./scripts/validate_object_data_cache_cold_stampede.sh --self-test
|
||||
|
||||
.PHONY: test
|
||||
test: core-deps script-tests ## Run all tests (needs cargo-nextest; RUSTFS_ALLOW_CARGO_TEST_FALLBACK=1 to override)
|
||||
|
||||
+26
-16
@@ -39,10 +39,11 @@ ecstore-serial-flaky = { max-threads = 1 }
|
||||
# servers never run at once. ci-7's nightly picks these up via the e2e suite;
|
||||
# they are deliberately NOT in the fast PR `e2e-smoke` filter.
|
||||
e2e-reliability = { max-threads = 1 }
|
||||
e2e-inline-boundaries = { max-threads = 1 }
|
||||
|
||||
# --- default profile (local): serialize the flaky groups, never retry --------
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & (test(concurrent_resend_same_part_commits_one_generation) | test(/^store::bucket::tests::bucket_delete_(mark_delete_marks|purge_removes|default_s3_delete)/))'
|
||||
filter = 'package(rustfs-ecstore) & (test(concurrent_resend_same_part_commits_one_generation) | test(/^store::bucket::tests::bucket_delete_(mark_delete|purge_removes|default_s3_delete)/))'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Serialize the multipart crash-consistency scenarios (dist-2, backlog#1150):
|
||||
@@ -54,6 +55,12 @@ test-group = 'ecstore-serial-flaky'
|
||||
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::multipart::tests::crash_consistency::/)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Serialize the durable manual-transition checkpoint test across nextest's
|
||||
# process boundary; it mutates bucket lifecycle metadata and is not quarantined.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & test(manual_transition_page_checkpoint_persists_durable_job_progress)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Serialize the 4-disk reliability / degraded-read e2e tests (see the
|
||||
# e2e-reliability test-group note above). The matching ci-profile override is at
|
||||
# the end of the file, after [profile.ci] is declared.
|
||||
@@ -61,6 +68,10 @@ test-group = 'ecstore-serial-flaky'
|
||||
filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression)_test::/)'
|
||||
test-group = 'e2e-reliability'
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
|
||||
test-group = 'e2e-inline-boundaries'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ci profile — the strict CI gate (ci.yml `cargo nextest run --profile ci`)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -99,7 +110,7 @@ retries = 2
|
||||
# QUARANTINE: OPEN backlog#937 — store::bucket::tests::bucket_delete_* race
|
||||
# make_bucket into InsufficientWriteQuorum via shared global state under load.
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & test(/^store::bucket::tests::bucket_delete_(mark_delete_marks|purge_removes|default_s3_delete)/)'
|
||||
filter = 'package(rustfs-ecstore) & test(/^store::bucket::tests::bucket_delete_(mark_delete|purge_removes|default_s3_delete)/)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
retries = 2
|
||||
|
||||
@@ -125,6 +136,12 @@ test-group = 'e2e-reliability'
|
||||
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::multipart::tests::crash_consistency::/)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Serialize the durable manual-transition checkpoint test under the ci profile
|
||||
# too. No retries: failures stay visible.
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & test(manual_transition_page_checkpoint_persists_durable_job_progress)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# e2e-smoke profile — PR smoke subset of the e2e_test crate (backlog#1149 ci-4)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -156,7 +173,7 @@ test-group = 'ecstore-serial-flaky'
|
||||
# the nightly profile derives its set as "the replication module MINUS this
|
||||
# allowlist", so any new replication test lands in nightly by default (never
|
||||
# silently unrun) until it is explicitly blessed as fast here. Keep the two
|
||||
# regexes byte-identical. Count invariant: 20 here + 27 nightly = 47 total
|
||||
# regexes byte-identical. Count invariant: 20 here + 28 nightly = 48 total
|
||||
# (authority: `cargo nextest list`; docs/testing/e2e-suite-inventory.md).
|
||||
# HISTORY (2026-07-11): the 20 fast tests were briefly pulled out of this lane
|
||||
# (#4724) because they set a loopback (127.0.0.1) replication target that the
|
||||
@@ -192,7 +209,7 @@ test-group = 'ecstore-serial-flaky'
|
||||
[profile.e2e-smoke]
|
||||
default-filter = """
|
||||
package(e2e_test) & (
|
||||
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_source_invalid_date|content_encoding|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud)_test::|^fake_s3_target::/)
|
||||
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat)_test::|^fake_s3_target::/)
|
||||
| test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/)
|
||||
| test(/^reliant::lifecycle::/)
|
||||
| test(/^reliant::tiering::/)
|
||||
@@ -211,7 +228,7 @@ fail-fast = false
|
||||
# and poll until source and target converge; two replicate over HTTPS, two
|
||||
# pin active SSE failure contracts, and one guards event/history observers.
|
||||
# The SSE-S3 contract remains ignored under backlog#1291.
|
||||
# * 11 `_real_dual_node` site-replication tests — each spawns TWO full rustfs
|
||||
# * 12 `_real_dual_node` site-replication tests — each spawns TWO full rustfs
|
||||
# servers and drives the cross-process site-replication control plane.
|
||||
# * 1 `_real_three_node` site-replication test.
|
||||
# * 1 `_real_single_node` service-account round-trip test.
|
||||
@@ -286,14 +303,8 @@ path = "junit.xml"
|
||||
# ci-profile quarantine (docs/testing/README.md): every entry MUST cite one
|
||||
# OPEN issue, and the fixing PR MUST delete the exclusion. The passing
|
||||
# negative-path siblings of each family stay in as regression guards.
|
||||
# * rustfs#4842 — extract/snowball expand pipeline 500s (mtime=0
|
||||
# OffsetDateTime deserialization + same-path failures).
|
||||
# * rustfs#4843 — over-limit archive entry paths hard-reject the whole
|
||||
# archive even under ignore-errors semantics.
|
||||
# * rustfs#4844 — anonymous POST-object with SSE-S3 / bucket-default SSE
|
||||
# returns 500.
|
||||
# * rustfs#4845 — 403 on allowed anonymous POST object-lock fields and on
|
||||
# the list metadata=true extension.
|
||||
# * rustfs#4846 — distributed-lock quorum tests misclassify as timeout
|
||||
# under parallel load (multi-node in-process clusters; natural home is
|
||||
# ci-7's nightly cluster lane).
|
||||
@@ -303,13 +314,8 @@ default-filter = """
|
||||
& !test(/^protocols::/)
|
||||
& !test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
|
||||
& !test(/^replication_extension_test::/)
|
||||
& !test(/^multipart_auth_test::test_signed_put_object_extract_(accepts_compat_header|expands_tar_entries_with_prefix_headers|expands_tar_gz_archive|expands_tbz2_archive|expands_tgz_archive|expands_txz_archive|expands_tzst_archive|normalizes_prefix_header_value|preserves_directory_markers_by_default|preserves_object_lock_legal_hold|preserves_object_lock_retention|preserves_pax_metadata_and_version_id|preserves_request_metadata_on_extracted_objects|preserves_sse_c|preserves_sse_s3_and_redirect|preserves_storage_class|uses_bucket_default_sse_s3)$/)
|
||||
& !test(/^snowball_auto_extract_test::tests::snowball_auto_extract_(prefers_exact_minio_prefix_over_suffix_fallback|supports_minio_prefix_and_directory_markers)$/)
|
||||
& !test(/^multipart_auth_test::test_signed_put_object_extract_skips_invalid_entry_when_ignore_errors_enabled$/)
|
||||
& !test(/^snowball_auto_extract_test::tests::snowball_auto_extract_(ignores_invalid_entries_when_requested|supports_standard_headers_with_combined_extract_options)$/)
|
||||
& !test(/^multipart_auth_test::test_anonymous_post_object_(accepts_sse_s3|rejects_sse_s3_missing_from_policy_conditions|uses_bucket_default_sse_kms|uses_bucket_default_sse_s3)$/)
|
||||
& !test(/^multipart_auth_test::test_anonymous_post_object_(accepts_object_lock_legal_hold_field|accepts_object_lock_retention_fields)$/)
|
||||
& !test(/^list_object(s_v2|_versions)_metadata_extension_test::/)
|
||||
& !test(/^reliant::lock::test_distributed_lock_(2_nodes_grpc_read_survives_failed_node|4_nodes_grpc_read_write_quorum_split_with_two_failed_nodes)$/)
|
||||
"""
|
||||
fail-fast = false
|
||||
@@ -325,3 +331,7 @@ path = "junit.xml"
|
||||
[[profile.e2e-full.overrides]]
|
||||
filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression)_test::/)'
|
||||
test-group = 'e2e-reliability'
|
||||
|
||||
[[profile.e2e-full.overrides]]
|
||||
filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
|
||||
test-group = 'e2e-inline-boundaries'
|
||||
|
||||
@@ -1744,7 +1744,7 @@
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum by (bucket) (rustfs_bucket_api_objects_total{job=~\"$job\", bucket=~\"$bucket\"})",
|
||||
"expr": "max by (job, bucket) (rustfs_cluster_usage_buckets_objects_count{job=~\"$job\", bucket=~\"$bucket\"})",
|
||||
"legendFormat": "{{bucket}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
@@ -1844,7 +1844,7 @@
|
||||
"uid": "${datasource}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum by (bucket) (rustfs_bucket_api_usage_bytes{job=~\"$job\", bucket=~\"$bucket\"})",
|
||||
"expr": "max by (job, bucket) (rustfs_cluster_usage_buckets_total_bytes{job=~\"$job\", bucket=~\"$bucket\"})",
|
||||
"legendFormat": "{{bucket}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
@@ -11583,7 +11583,7 @@
|
||||
"text": "All",
|
||||
"value": "$__all"
|
||||
},
|
||||
"definition": "label_values(rustfs_bucket_api_objects_total,bucket)",
|
||||
"definition": "label_values(rustfs_cluster_usage_buckets_objects_count,bucket)",
|
||||
"includeAll": true,
|
||||
"label": "Bucket",
|
||||
"multi": true,
|
||||
@@ -11591,7 +11591,7 @@
|
||||
"options": [],
|
||||
"query": {
|
||||
"qryType": 1,
|
||||
"query": "label_values(rustfs_bucket_api_objects_total,bucket)",
|
||||
"query": "label_values(rustfs_cluster_usage_buckets_objects_count,bucket)",
|
||||
"refId": "PrometheusVariableQueryEditor-VariableQuery"
|
||||
},
|
||||
"refresh": 2,
|
||||
|
||||
@@ -18,6 +18,7 @@ set -eu
|
||||
ACCESS_KEY="${RUSTFS_SITE_REPL_ACCESS_KEY:-rustfsadmin}"
|
||||
SECRET_KEY="${RUSTFS_SITE_REPL_SECRET_KEY:-rustfsadmin}"
|
||||
BUCKET="${RUSTFS_SITE_REPL_FLOW_BUCKET:-site-repl-flow-check}"
|
||||
DELETE_BUCKET="${RUSTFS_SITE_REPL_DELETE_BUCKET:-site-repl-delete-$(date +%Y%m%d-%H%M%S)-$$}"
|
||||
PREFIX="${RUSTFS_SITE_REPL_FLOW_PREFIX:-flow-$(date +%Y%m%d-%H%M%S)}"
|
||||
WAIT_ATTEMPTS="${RUSTFS_SITE_REPL_WAIT_ATTEMPTS:-90}"
|
||||
WAIT_SLEEP_SECONDS="${RUSTFS_SITE_REPL_WAIT_SLEEP_SECONDS:-2}"
|
||||
@@ -85,17 +86,39 @@ wait_for_object() {
|
||||
|
||||
wait_for_bucket() {
|
||||
site="$1"
|
||||
bucket="${2:-$BUCKET}"
|
||||
attempt=1
|
||||
|
||||
while [ "$attempt" -le "$WAIT_ATTEMPTS" ]; do
|
||||
if mc stat "$site/$BUCKET" >/dev/null 2>&1; then
|
||||
if mc stat "$site/$bucket" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep "$WAIT_SLEEP_SECONDS"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
echo "bucket was not replicated in time: $site/$BUCKET" >&2
|
||||
echo "bucket was not replicated in time: $site/$bucket" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_bucket_delete() {
|
||||
site="$1"
|
||||
bucket="$2"
|
||||
attempt=1
|
||||
|
||||
while [ "$attempt" -le "$WAIT_ATTEMPTS" ]; do
|
||||
if result="$(mc stat --json "$site/$bucket" 2>&1)"; then
|
||||
:
|
||||
else
|
||||
case "$result" in
|
||||
*NoSuchBucket*) return 0 ;;
|
||||
esac
|
||||
fi
|
||||
sleep "$WAIT_SLEEP_SECONDS"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
echo "bucket deletion was not replicated in time: $site/$bucket" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -186,6 +209,20 @@ EOF
|
||||
echo "verified replicated downloads for $object_name"
|
||||
done
|
||||
|
||||
echo "creating empty bucket for replicated delete check: $DELETE_BUCKET"
|
||||
mc mb "site1/$DELETE_BUCKET" >/dev/null
|
||||
|
||||
for site in site1 site2 site3; do
|
||||
wait_for_bucket "$site" "$DELETE_BUCKET"
|
||||
done
|
||||
|
||||
echo "deleting empty bucket on site1: $DELETE_BUCKET"
|
||||
mc rb "site1/$DELETE_BUCKET" >/dev/null
|
||||
|
||||
for site in site1 site2 site3; do
|
||||
wait_for_bucket_delete "$site" "$DELETE_BUCKET"
|
||||
done
|
||||
|
||||
echo "site replication object flow check passed"
|
||||
echo "bucket: $BUCKET"
|
||||
echo "prefix: $PREFIX"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
+2
-2
@@ -15,8 +15,8 @@
|
||||
enabled: true
|
||||
|
||||
document:
|
||||
version: v1
|
||||
url: https://github.com/rustfs/cla/blob/main/cla/v1.md
|
||||
version: v2
|
||||
url: https://github.com/rustfs/cla/blob/main/cla/v2.md
|
||||
|
||||
signing:
|
||||
mode: comment
|
||||
|
||||
@@ -33,4 +33,4 @@ documentation impact. Use N/A when there is no expected impact.
|
||||
|
||||
---
|
||||
|
||||
Thank you for your contribution! Please ensure your PR follows the community standards ([CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)). If this is your first contribution, review the [CLA document](https://github.com/rustfs/cla/blob/main/cla/v1.md) and sign it by commenting `I have read and agree to the CLA.` on the PR.
|
||||
Thank you for your contribution! Please ensure your PR follows the community standards ([CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)). If this is your first contribution, review the [CLA document](https://github.com/rustfs/cla/blob/main/cla/v2.md) and sign it by commenting `I have read and agree to the CLA.` on the PR.
|
||||
|
||||
+19
-18
@@ -141,7 +141,7 @@ jobs:
|
||||
name: Test and Lint
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
@@ -167,6 +167,13 @@ jobs:
|
||||
cargo nextest run --profile ci --all --exclude e2e_test
|
||||
cargo test --all --doc
|
||||
|
||||
# rustfs/backlog#1289: fail if a seed rule's log anchor no longer exists
|
||||
# verbatim in the source tree (log message drifted without updating the
|
||||
# rule). Placed here where the workspace — including the la-dump-anchors
|
||||
# bin — is already built by the clippy/test steps above.
|
||||
- name: Check log-analyzer rule anchors
|
||||
run: ./scripts/check_log_analyzer_rules.sh
|
||||
|
||||
- name: Upload test junit report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
@@ -221,29 +228,23 @@ jobs:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
# The #4877 restore self-deadlock is fixed in this PR, which re-enabled
|
||||
# test_multipart_restore_preserves_parts_and_etag. The remaining exclusions
|
||||
# each hit a DIFFERENT, independent issue (all tracked under
|
||||
# rustfs/backlog#1148; they keep #[ignore] with a backlog reference):
|
||||
# test_transition_and_restore_flows was re-enabled by rustfs/backlog#1303:
|
||||
# its "missing xl.meta on disk2" was a test-util bug (open_disk hardcoded
|
||||
# disk_index 0), not an EC metadata-distribution issue.
|
||||
# restore_object_usecase_reports_ongoing_conflict_and_completion was
|
||||
# re-enabled by backlog#1304 (restore accepts serialize on a short CAS
|
||||
# guard; the copy-back no longer holds the #4877 whole-copy-back lock,
|
||||
# so the mid-restore ongoing read and fast 409 rejection it asserts are
|
||||
# the implemented contract). The remaining exclusions each hit a
|
||||
# DIFFERENT, independent issue (all tracked under rustfs/backlog#1148;
|
||||
# they keep #[ignore] with a backlog reference):
|
||||
# - test_noncurrent_{expiry,transition}_still_works_after_immediate_compensation_transition:
|
||||
# noncurrent transition/expiry after an immediate compensation transition.
|
||||
# - test_transition_and_restore_flows: transition metadata is missing on
|
||||
# one drive (assert_transition_meta_consistent: "missing xl.meta ... on
|
||||
# disk2") - an EC metadata-distribution issue, not the restore lock.
|
||||
# - test_restore_chain_local_read_expiry_keeps_remote_and_allows_re_restore:
|
||||
# DeleteRestoredAction sets opts.transition.expire_restored, but no
|
||||
# delete path reads that flag, so cleanup deletes the whole object
|
||||
# instead of only the local restored copy (ObjectNotFound afterwards).
|
||||
# The expire_restored delete semantics are unimplemented.
|
||||
# - restore_object_usecase_reports_ongoing_conflict_and_completion: asserts
|
||||
# a concurrent get_object_info observes ongoing-request=true mid-restore,
|
||||
# which #4877's read-vs-restore serialization rules out (see backlog#1148
|
||||
# ilm-8 criterion 1 - an API-semantics decision, not a bug).
|
||||
- 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) or test(restore_object_usecase_reports_ongoing_conflict_and_completion) or test(test_restore_chain_local_read_expiry_keeps_remote_and_allows_re_restore))'
|
||||
-E '(binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))) and not (test(test_noncurrent_expiry_still_works_after_immediate_compensation_transition) or test(test_noncurrent_transition_still_works_after_immediate_compensation_transition))'
|
||||
|
||||
test-and-lint-rio-v2:
|
||||
name: Test and Lint (rio-v2)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Star History
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: star-history
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: overtrue/repo-visuals-action@72f34d24769ff5d341956da2f23952594ef2f1e2 # v1.3.0
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
output-branch: star-history
|
||||
output-path: .
|
||||
chart-style: gradient
|
||||
animate: "true"
|
||||
contributors: "true"
|
||||
Vendored
+35
-8
@@ -172,7 +172,7 @@
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Debug executable target/debug/rustfs with sse",
|
||||
"name": "Debug executable target/debug/rustfs with sse kms",
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/target/debug/rustfs",
|
||||
@@ -200,7 +200,7 @@
|
||||
// 2. kms local backend test key
|
||||
// "RUSTFS_KMS_ENABLE": "true",
|
||||
// "RUSTFS_KMS_BACKEND": "local",
|
||||
// "RUSTFS_KMS_KEY_DIR": "./target/kms-key-dir",
|
||||
// "RUSTFS_KMS_KEY_DIR": "/tmp/kms-key-dir",
|
||||
// "RUSTFS_KMS_LOCAL_MASTER_KEY": "my-secret-key", // Some Password
|
||||
// "RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
|
||||
|
||||
@@ -212,13 +212,40 @@
|
||||
// "RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
|
||||
|
||||
// 4. kms vault transit backend test key
|
||||
// "RUSTFS_KMS_ENABLE": "true",
|
||||
// "RUSTFS_KMS_BACKEND": "vault-transit",
|
||||
// "RUSTFS_KMS_VAULT_ADDRESS": "http://127.0.0.1:8200",
|
||||
// "RUSTFS_KMS_VAULT_TOKEN": "Dev Token",
|
||||
// "RUSTFS_KMS_VAULT_MOUNT_PATH": "transit",
|
||||
// "RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
|
||||
|
||||
// 5、kms static backend test key
|
||||
"RUSTFS_KMS_ENABLE": "true",
|
||||
"RUSTFS_KMS_BACKEND": "vault-transit",
|
||||
"RUSTFS_KMS_VAULT_ADDRESS": "http://127.0.0.1:8200",
|
||||
"RUSTFS_KMS_VAULT_TOKEN": "Dev Token",
|
||||
"RUSTFS_KMS_VAULT_MOUNT_PATH": "transit",
|
||||
"RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
|
||||
|
||||
"RUSTFS_KMS_BACKEND": "static",
|
||||
"RUSTFS_KMS_STATIC_SECRET_KEY": "rustfs-master-key:2dfNXGHlsEflGVCxb+5DIdGEl1sIvtwX+QfmYasi5QM="
|
||||
},
|
||||
"sourceLanguages": [
|
||||
"rust"
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Debug executable target/debug/rustfs with local sse",
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/target/debug/rustfs",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"RUSTFS_ACCESS_KEY": "rustfsadmin",
|
||||
"RUSTFS_SECRET_KEY": "rustfsadmin",
|
||||
"RUSTFS_VOLUMES": "./target/volumes/test{1...4}",
|
||||
"RUSTFS_ADDRESS": ":9000",
|
||||
"RUSTFS_CONSOLE_ENABLE": "true",
|
||||
"RUSTFS_CONSOLE_ADDRESS": "127.0.0.1:9001",
|
||||
"RUSTFS_OBS_LOG_DIRECTORY": "./target/logs",
|
||||
"RUSTFS_UNSAFE_BYPASS_DISK_CHECK": "true",
|
||||
"RUSTFS_SSE_S3_MASTER_KEY": "xGb3aYSp825j2tPpg8JrUzghiXsIkfdOtmrsJ/iafiM=",
|
||||
"RUST_LOG": "rustfs=debug,ecstore=debug,s3s=debug,iam=debug",
|
||||
},
|
||||
"sourceLanguages": [
|
||||
"rust"
|
||||
|
||||
@@ -14,13 +14,19 @@ If repo-level instructions conflict, follow the nearest file and keep behavior a
|
||||
|
||||
## Execution Discipline
|
||||
|
||||
- Read the relevant existing code, tests, and local guidance before changing behavior.
|
||||
- Read the relevant existing code, tests, and local guidance before changing behavior. For new helpers or test setup, that read includes `crates/utils`, `crates/common`, and the touched crate's own `test_util`/fixtures (see Reuse Before You Write).
|
||||
- State assumptions when they affect the implementation or verification path.
|
||||
- If a task has multiple plausible interpretations, list the options briefly and choose the narrowest reasonable path; ask when the ambiguity would make the change risky.
|
||||
- For multi-step work, keep the plan minimal and tied to verifiable outcomes.
|
||||
- Avoid redundant file reads, repeated commands, and unnecessary exploratory work once enough context is available.
|
||||
- A good result is a minimal diff with clear assumptions, no over-engineering, and independent verification that survives Adversarial Validation (below).
|
||||
|
||||
## Autonomy and Approval Boundaries
|
||||
|
||||
- Inquiry tasks (answer, explain, review, diagnose, plan): report findings; do not change files unless a fix is explicitly requested.
|
||||
- Action tasks (change, build, fix): make in-scope local changes without asking for approval.
|
||||
- Ask for confirmation before destructive or hard-to-reverse operations (force-pushes, history rewrites, deleting data or branches), merging a PR (reviewer approval required), or any material expansion of the requested scope.
|
||||
|
||||
## Communication and Language
|
||||
|
||||
- Respond in the same language used by the requester.
|
||||
@@ -41,14 +47,27 @@ If repo-level instructions conflict, follow the nearest file and keep behavior a
|
||||
- Do not refactor existing code only to make it easier to unit test.
|
||||
- Keep fixes narrowly aligned with the requested behavior; avoid semantic-adjacent rewrites while touching sensitive paths.
|
||||
- Keep code elegant, concise, and direct. Prefer minimal, readable implementations over over-engineering and excessive abstraction. Use comments to clarify non-obvious intent and invariants, not to compensate for unclear code.
|
||||
- Do not write comments that narrate what the next line does, restate a signature, or describe the change you just made — that commentary belongs in the PR description, not the code. Required invariant comments — lock ordering, `SAFETY`, unwrap justification, `#[allow(dead_code)]` rationale, `RUSTFS_COMPAT_TODO` — are never narration.
|
||||
- Mention unrelated issues when useful, but do not fix them as part of a narrow task.
|
||||
|
||||
## Constant and String Usage
|
||||
## Reuse Before You Write
|
||||
|
||||
- Before introducing new string literals, search for existing constants/enums that already represent the same semantic value.
|
||||
- Reuse existing constants for protocol labels, error identifiers, header keys, event names, metric names, command tags, and similar fixed tokens.
|
||||
- If a new string is truly unique, define a local constant near related logic and avoid scattering the literal across multiple sites.
|
||||
- When changing existing behavior, keep naming and format consistency by aligning with established project constants.
|
||||
Search for an existing implementation before writing a new one; extend what exists instead of duplicating it:
|
||||
|
||||
- **Helpers and utilities** (path/string handling, hashing, retry, env parsing, IO wrappers): check `ls crates/utils/src` first — file names map to operations (`retry.rs`, `envs.rs`, `hash.rs`, `path.rs`, `string.rs`, `io.rs`) — plus `crates/common` (shared structures/globals), then `rg -i 'fn \w*<term>' crates/utils/src crates/common/src <touched-crate>/src` for signatures. Helpers are snake_case: a full-text single-word grep over a large crate drowns you and a multi-word phrase returns nothing. Reimplementing an existing workspace helper — or hand-rolling what `std`, `tokio`, or an existing workspace dependency already provides — is a review finding, not a style preference.
|
||||
- **Reuse requires matching semantics, not a matching name**: before adopting a helper, check its normalization (`clean` resolves `.`/`..` — never apply it to raw S3 object keys), error type, backoff/deadline behavior, and durability gating against the call site. When semantics differ, a new narrowly-named helper with a comment naming the rejected lookalike is the correct outcome. The inverse also holds: workspace wrappers exist because raw `std`/`tokio` semantics were insufficient (durability gates, retries) — prefer the wrapper over the raw call.
|
||||
- **Constants and fixed tokens** (protocol labels, error identifiers, header keys, event names, metric names, command tags): search for existing constants/enums that already represent the same semantic value and reuse them. If a value is truly new, define one local constant near related logic; never scatter the literal across sites. When changing existing behavior, align naming and format with the established constants.
|
||||
- **Test scaffolding**: reuse existing test utilities and fixtures (the touched crate's own `test_util` module and `tests/fixtures`, or `crates/test-utils`) instead of writing new setup code — run `rg -l '<fn-under-test>' <crate>/src <crate>/tests` before writing a test. A new test must pin a failure mode no existing test covers. Near-duplicate means same code path AND same poison-value class: this repo's boundary companions (n==max vs max+1, absent vs empty vs nil UUID bytes, MetaObject vs MetaDeleteMarker) are distinct by definition and must all be written.
|
||||
|
||||
## Necessary Code Only
|
||||
|
||||
Net-new code — files, types, branches, comments — is cost to justify, not progress:
|
||||
|
||||
- Validate at the trust boundary — untrusted client input, bytes read from disk, RPC payloads, config (see Serde Safety and Cross-Cutting Domain Invariants) — then trust the type: do not re-check what the type system or a validated upstream layer already guarantees, and cite the establishing check (`file:line`) when the guarantee is not obvious.
|
||||
- The exception is load-bearing: a value that crossed a persistence, RPC, or version boundary is never guaranteed by the code on the other side — a peer may be older or buggy, disk bytes may be corrupt — so the Cross-Cutting Domain Invariant patterns apply at every consumer, and re-checks immediately before a destructive action (delete, overwrite, quorum decision) stay. Deleting an existing guard is a behavior change requiring adversarial review, not cleanup.
|
||||
- Every new branch needs a nameable trigger: a concrete input, state, or failure that reaches it — for boundary-crossing values, corrupt or stale persisted/peer data is always nameable. If you cannot name one, do not write the branch. If the case is truly unreachable, encode the invariant in the type; where that is impossible, return a typed internal error (fail closed). `debug_assert!` is acceptable only for pure internal arithmetic on values that never crossed a disk/RPC/config boundary — never as the sole guard on decoded or peer-supplied data.
|
||||
- Never substitute a default where the value is required (e.g. `unwrap_or_default()` on metadata that must exist) — that converts corruption into a wrong answer. Return the typed error instead: explicit failure over implicit success.
|
||||
- Attach error context once, at the layer where it is actionable: re-wrapping equivalent context at every hop is noise, and expanding a fallible chain into nested `match` blocks where `?` or a combinator suffices is a finding. Never add context by converting a typed error into a generic variant below an error-aggregation or quorum layer (`reduce_errs` classifies by variant equality) — context there belongs in a `tracing` event, not the error value.
|
||||
|
||||
## Sources of Truth
|
||||
|
||||
@@ -141,7 +160,7 @@ Pick the tier from the riskiest file touched; when in doubt, pick the higher.
|
||||
- **Exempt:** docs/comments/instruction-only changes, formatting, typos with
|
||||
no runtime surface. Skip this section.
|
||||
- **Mechanical:** pure renames, file moves, test-only or tooling changes —
|
||||
correctness adversary only.
|
||||
correctness and simplicity adversaries only.
|
||||
- **Standard (the default):** any change that affects behavior.
|
||||
- **High risk:** touches locking, erasure coding, quorum/heal, replication,
|
||||
multipart, RPC, lifecycle/tiering, metadata formats (`xl.meta`),
|
||||
@@ -161,9 +180,8 @@ encode this repo's shipped bugs.
|
||||
|
||||
- **Correctness adversary** — construct a concrete input/state/interleaving
|
||||
that yields wrong output, data loss, or a crash. Probe error paths and edge
|
||||
values (empty, nil UUID, zero-length, quorum−1, missing version). For code
|
||||
diffs, a materially smaller or more idiomatic diff achieving the same
|
||||
behavior is also a finding (see Change Style for Existing Logic).
|
||||
values (empty, nil UUID, zero-length, quorum−1, missing version).
|
||||
- **Simplicity adversary** — same behavior, less code. Hunt the materially smaller or more idiomatic diff (see Change Style for Existing Logic, Reuse Before You Write, and Necessary Code Only): reimplemented workspace helpers, one-caller extractions, rewrites where an in-place edit suffices, defensive branches with no nameable trigger, redundant error wrapping, near-duplicate tests, narration comments. A smaller diff achieving identical behavior is a finding, reported with the concrete replacement; forced reuse of a helper with mismatched semantics is equally a finding.
|
||||
- **Security reviewer** — authn/authz bypass, injection, secret leakage,
|
||||
untrusted deserialization (see Serde Safety), path traversal, timing leaks.
|
||||
- **Concurrency/durability reviewer** — lock ordering, races, cancellation,
|
||||
@@ -179,11 +197,12 @@ encode this repo's shipped bugs.
|
||||
wrong while all tests stay green — if one exists, coverage is insufficient.
|
||||
A missing test is a finding, not a note.
|
||||
|
||||
Standard tier: correctness adversary + test-coverage skeptic, plus every
|
||||
role whose domain the diff touches (async or shared-state code →
|
||||
concurrency; parsing of untrusted input → security; public crate API shape
|
||||
→ compatibility; per-request or per-object hot paths → performance).
|
||||
High risk: all six roles.
|
||||
Standard tier: correctness adversary + simplicity adversary + test-coverage
|
||||
skeptic, plus every role whose domain the diff touches (async or
|
||||
shared-state code → concurrency; parsing of untrusted input → security;
|
||||
public crate API shape → compatibility; per-request or per-object hot paths
|
||||
→ performance).
|
||||
High risk: all seven roles.
|
||||
|
||||
### Protocol
|
||||
|
||||
|
||||
+18
-117
@@ -41,7 +41,7 @@ The repository is a Cargo workspace with a flat `crates/` layout:
|
||||
|
||||
```
|
||||
rustfs/ # Workspace root (virtual manifest)
|
||||
├── rustfs/ # Main binary + library crate (75K lines)
|
||||
├── rustfs/ # Main binary + library crate
|
||||
│ └── src/
|
||||
│ ├── main.rs # Entry point, startup sequence
|
||||
│ ├── lib.rs # Module tree root
|
||||
@@ -53,7 +53,7 @@ rustfs/ # Workspace root (virtual manifest)
|
||||
│ ├── config/ # CLI args, config parsing, workload profiles
|
||||
│ └── ...
|
||||
├── crates/ # library crates (authoritative list: Cargo.toml [workspace].members)
|
||||
│ ├── ecstore/ # Erasure-coded storage engine (⚠️ 87K lines)
|
||||
│ ├── ecstore/ # Erasure-coded storage engine
|
||||
│ ├── rio/ # Reader I/O pipeline (encrypt, compress, hash)
|
||||
│ ├── io-core/ # Zero-copy I/O, scheduling, buffer pool
|
||||
│ ├── io-metrics/ # I/O metrics collection
|
||||
@@ -83,124 +83,25 @@ A request flows **downward** through the layers. No layer should reach upward
|
||||
|
||||
### Crate Reference
|
||||
|
||||
> Depth levels, line counts, and crate counts in this section are a
|
||||
> point-in-time snapshot and drift with refactors. Treat them as orders of
|
||||
> magnitude; `Cargo.toml` and `cargo tree` are the source of truth.
|
||||
|
||||
Crates are organized in a dependency DAG with 9 depth levels (0 = leaf, 8 = top):
|
||||
|
||||
```
|
||||
Depth 0 — LEAF (no internal deps):
|
||||
appauth, checksums, config, credentials, crypto, io-metrics,
|
||||
madmin, s3-common, workers, zip
|
||||
|
||||
Depth 1:
|
||||
io-core (→ io-metrics)
|
||||
policy (→ config, credentials, crypto)
|
||||
utils (historical → config edge removed; now effectively leaf)
|
||||
|
||||
Depth 2:
|
||||
concurrency, filemeta, keystone, kms, lock, obs,
|
||||
signer, targets, trusted-proxies
|
||||
|
||||
Depth 3:
|
||||
common (historical → filemeta/madmin edges removed; now effectively leaf)
|
||||
|
||||
Depth 4:
|
||||
object-capacity, protos, rio
|
||||
|
||||
Depth 5 — CORE:
|
||||
ecstore (16 internal deps, 11 dependents — the architectural heart)
|
||||
|
||||
Depth 6:
|
||||
audit, heal, iam, metrics, notify, s3select-api, scanner
|
||||
|
||||
Depth 7:
|
||||
object-io, protocols, s3select-query
|
||||
|
||||
Depth 8 — TOP:
|
||||
rustfs (35 internal deps — the binary, depends on almost everything)
|
||||
```
|
||||
`Cargo.toml` is the authoritative workspace membership and `cargo tree` is the
|
||||
authoritative dependency graph. This overview deliberately avoids line-count
|
||||
and dependency-depth snapshots because both quickly become stale during
|
||||
refactors.
|
||||
|
||||
#### By Domain
|
||||
|
||||
**Core Infrastructure:**
|
||||
| Domain | Current workspace crates | Responsibility |
|
||||
|--------|--------------------------|----------------|
|
||||
| Foundation | `checksums`, `common`, `config`, `data-usage`, `utils` | Shared configuration, data-usage models, utilities, and checksums. |
|
||||
| I/O and storage | `concurrency`, `ecstore`, `filemeta`, `heal`, `io-core`, `io-metrics`, `lifecycle`, `lock`, `object-capacity`, `object-data-cache`, `replication`, `rio`, `rio-v2`, `scanner`, `storage-api` | Erasure-coded object storage, metadata, recovery, lifecycle, replication, locking, cache, and I/O pipelines. |
|
||||
| Security and identity | `credentials`, `crypto`, `iam`, `keystone`, `kms`, `policy`, `security-governance`, `signer`, `tls-runtime`, `trusted-proxies` | Credentials, authentication, authorization, encryption, key management, TLS, and security contracts. |
|
||||
| Protocols and contracts | `extension-schema`, `madmin`, `protos`, `protocols`, `s3-ops`, `s3-types`, `s3select-api`, `s3select-query` | Admin, inter-node, S3, S3 Select, and optional protocol contracts. |
|
||||
| Operations and integration | `audit`, `notify`, `obs`, `targets`, `zip` | Auditing, observability, event delivery, notification targets, and archive support. |
|
||||
| Test support | `e2e_test`, `test-utils` | End-to-end validation and shared test bootstrap utilities. |
|
||||
|
||||
| Crate | Lines | Purpose |
|
||||
|-------|-------|---------|
|
||||
| `config` | 3.3K | Configuration types and environment parsing |
|
||||
| `utils` | 8.7K | Pure utilities (paths, compression, network, retry) |
|
||||
| `common` | 4.4K | Shared runtime state, globals, data usage types, metrics |
|
||||
| `madmin` | 5.5K | Admin API request/response types |
|
||||
|
||||
**I/O Pipeline:**
|
||||
|
||||
| Crate | Lines | Purpose |
|
||||
|-------|-------|---------|
|
||||
| `io-core` | 6.5K | Zero-copy I/O, buffer pool, direct I/O, scheduling, backpressure |
|
||||
| `io-metrics` | 4.5K | I/O operation metrics and counters |
|
||||
| `rio` | 6.9K | Composable reader chain (encrypt → compress → hash → limit) |
|
||||
| `object-io` | 2.4K | High-level object read/write using rio + ecstore |
|
||||
| `concurrency` | 0.8K | Shared concurrency contract types: workload admission snapshots, worker-slot pool, policy types (runtime control lives in `rustfs/src/storage`) |
|
||||
|
||||
**Storage Engine:**
|
||||
|
||||
| Crate | Lines | Purpose |
|
||||
|-------|-------|---------|
|
||||
| `ecstore` | 87K | ⚠️ Erasure-coded storage: disks, pools, buckets, replication, lifecycle |
|
||||
| `filemeta` | 10K | File/object metadata types and versioning |
|
||||
| `checksums` | 732 | Checksum computation |
|
||||
| `lock` | 7.1K | Distributed lock manager |
|
||||
| `heal` | 5.9K | Data healing / bitrot repair |
|
||||
| `scanner` | 5.4K | Background data usage scanner |
|
||||
| `object-capacity` | 2.5K | Capacity tracking and management |
|
||||
|
||||
**Security & Auth:**
|
||||
|
||||
| Crate | Lines | Purpose |
|
||||
|-------|-------|---------|
|
||||
| `crypto` | 1.6K | Encryption primitives |
|
||||
| `credentials` | 713 | Credential types (access key / secret key) |
|
||||
| `signer` | 1.4K | S3 v4 request signing |
|
||||
| `iam` | 9.0K | Identity and access management |
|
||||
| `policy` | 8.8K | Policy engine (S3 bucket/IAM policies) |
|
||||
| `kms` | 8.1K | Key management service integration |
|
||||
| `keystone` | 1.9K | OpenStack Keystone auth |
|
||||
| `appauth` | 143 | Application-level auth tokens |
|
||||
|
||||
**Protocol & API:**
|
||||
|
||||
| Crate | Lines | Purpose |
|
||||
|-------|-------|---------|
|
||||
| `protos` | 5.7K | Protobuf/gRPC definitions for inter-node RPC |
|
||||
| `protocols` | 18K | FTP/FTPS, WebDAV, Swift API support |
|
||||
| `s3-common` | 738 | Shared S3 types |
|
||||
| `s3select-api` | 1.9K | S3 Select interface |
|
||||
| `s3select-query` | 3.6K | S3 Select query engine |
|
||||
|
||||
**Observability:**
|
||||
|
||||
| Crate | Lines | Purpose |
|
||||
|-------|-------|---------|
|
||||
| `metrics` | 8.4K | Prometheus metric collectors |
|
||||
| `io-metrics` | 4.5K | I/O-specific metrics |
|
||||
| `obs` | 5.6K | OpenTelemetry tracing and telemetry |
|
||||
| `audit` | 2.4K | Audit logging |
|
||||
|
||||
**Events:**
|
||||
|
||||
| Crate | Lines | Purpose |
|
||||
|-------|-------|---------|
|
||||
| `notify` | 5.5K | Event notification system |
|
||||
| `targets` | 3.2K | Notification targets (Kafka, AMQP, webhook, etc.) |
|
||||
|
||||
**Other:**
|
||||
|
||||
| Crate | Lines | Purpose |
|
||||
|-------|-------|---------|
|
||||
| `trusted-proxies` | 4.0K | Trusted proxy / IP forwarding |
|
||||
| `zip` | 986 | ZIP archive support for bulk downloads |
|
||||
| `workers` | 136 | Simple worker abstraction |
|
||||
The `rustfs` binary crate composes these libraries into the running server.
|
||||
`ecstore` remains the storage engine at the architectural center; its internal
|
||||
module split is tracked under `docs/architecture/`.
|
||||
|
||||
## Architecture Invariants
|
||||
|
||||
@@ -212,7 +113,7 @@ Depth 8 — TOP:
|
||||
No upward imports.
|
||||
|
||||
2. **Leaf crates have zero internal dependencies.** `config`, `credentials`, `crypto`,
|
||||
`io-metrics`, `madmin`, `s3-common` should depend only on external crates.
|
||||
`io-metrics`, and `madmin` should depend only on external crates.
|
||||
- ✅ RESOLVED: the historical `utils → config` and `common → filemeta`/`madmin`
|
||||
edges were removed; do not reintroduce them (see Known Structural Issues).
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
- **Helm Ingress**: `customAnnotations` are now merged with class-specific annotations (nginx/traefik) instead of being ignored when `ingress.className` is set.
|
||||
- **Per-pool erasure parity**: Erasure parity (STANDARD and reduced-redundancy) is now resolved independently for every pool instead of reusing the first pool's value. A heterogeneous topology — for example a 4-drive pool plus a 2-drive pool created during expansion — previously inherited the first pool's parity and could resolve to zero data shards in the smaller pool, panicking Reed-Solomon construction on write. Automatic parity now resolves per pool (for example `2+2` in the 4-drive pool and `1+1` in the 2-drive pool). Fixes #4801.
|
||||
|
||||
### Added
|
||||
- **NATS JetStream Publish Path**: Opt-in at-least-once delivery for the NATS notify and audit targets. A NATS Core publish flushes to the connection without awaiting a broker acknowledgement, so an event can be lost across a broker restart or a reconnect after the send queue has already cleared it. A queued event now clears only after the JetStream `PublishAck`, so bucket notifications survive those interruptions. Off by default and byte-identical to the NATS Core path when disabled.
|
||||
@@ -38,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
- **HTTP Server Stack**: Integrated `KeystoneAuthLayer` middleware from `rustfs-keystone` crate into service stack (positioned after ReadinessGateLayer)
|
||||
- **Storage-class validation on startup (upgrade note)**: A persisted explicit storage class (`RUSTFS_STORAGE_CLASS_STANDARD` / `RUSTFS_STORAGE_CLASS_RRS`, for example `EC:2`) is now validated against the actual per-pool drive counts at startup and rejected when a pool cannot satisfy it. This is fail-closed and correct, but a cluster that persisted a storage class larger than a small or heterogeneous pool can hold (for example `EC:2` alongside a 2-drive pool), which earlier releases accepted and silently resolved to an invalid layout, will now refuse to start after upgrade. To recover, unset `RUSTFS_STORAGE_CLASS_STANDARD` so the server derives a valid per-pool default automatically, or set it to a value every pool can satisfy.
|
||||
- **IAMAuth**: Enhanced `get_secret_key()` to return empty secret for Keystone credentials (bypasses signature validation)
|
||||
- **Auth Module**: Modified `check_key_valid()` to retrieve Keystone credentials from task-local storage and determine admin status
|
||||
- **`StorageBackend` trait**: extended with multipart upload methods (`create_multipart_upload`, `upload_part`, `complete_multipart_upload`, `abort_multipart_upload`) plus `upload_part_copy`. Streaming-upload code path is now available to FTPS, WebDAV, and Swift drivers as well.
|
||||
|
||||
Generated
+525
-399
File diff suppressed because it is too large
Load Diff
+91
-103
@@ -31,6 +31,7 @@ members = [
|
||||
"crates/lifecycle", # Lifecycle rule evaluation contracts
|
||||
"crates/kms", # Key Management Service
|
||||
"crates/lock", # Distributed locking implementation
|
||||
"crates/log-analyzer", # Offline log fault-analysis core (rustfs diagnose)
|
||||
"crates/madmin", # Management dashboard and admin API interface
|
||||
"crates/notify", # Notification system for events
|
||||
"crates/obs", # Observability utilities
|
||||
@@ -67,8 +68,8 @@ resolver = "3"
|
||||
edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/rustfs/rustfs"
|
||||
rust-version = "1.96.0"
|
||||
version = "1.0.0-beta.10"
|
||||
rust-version = "1.97.1"
|
||||
version = "1.0.0-beta.11"
|
||||
homepage = "https://rustfs.com"
|
||||
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
|
||||
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
|
||||
@@ -85,51 +86,52 @@ redundant_clone = "warn"
|
||||
|
||||
[workspace.dependencies]
|
||||
# RustFS Internal Crates
|
||||
rustfs = { path = "./rustfs", version = "1.0.0-beta.10" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-beta.10" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-beta.10" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-beta.10" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.0-beta.10" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-beta.10" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.0-beta.10" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-beta.10" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-beta.10" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-beta.10" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-beta.10" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-beta.10" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.0-beta.10" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-beta.10" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-beta.10" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.0-beta.10" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.0-beta.10" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-beta.10" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.0-beta.10" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-beta.10" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-beta.10" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-beta.10" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-beta.10" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.0-beta.10" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.0-beta.10" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.0-beta.10" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-beta.10" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.0-beta.10" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.0-beta.10" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-beta.10" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-beta.10" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-beta.10" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-beta.10" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-beta.10" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-beta.10" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-beta.10" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-beta.10" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.0-beta.10" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-beta.10" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-beta.10" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.0-beta.10" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-beta.10" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-beta.10" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.0-beta.10" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.0-beta.10" }
|
||||
rustfs = { path = "./rustfs", version = "1.0.0-beta.11" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-beta.11" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-beta.11" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-beta.11" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.0-beta.11" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-beta.11" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.0-beta.11" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-beta.11" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-beta.11" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-beta.11" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-beta.11" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-beta.11" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.0-beta.11" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-beta.11" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-beta.11" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.0-beta.11" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.0-beta.11" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-beta.11" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.0-beta.11" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-beta.11" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-beta.11" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-beta.11" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-beta.11" }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-beta.11" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.0-beta.11" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.0-beta.11" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.0-beta.11" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-beta.11" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.0-beta.11" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.0-beta.11" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-beta.11" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-beta.11" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-beta.11" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-beta.11" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-beta.11" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-beta.11" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-beta.11" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-beta.11" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.0-beta.11" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-beta.11" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-beta.11" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.0-beta.11" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-beta.11" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-beta.11" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.0-beta.11" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.0-beta.11" }
|
||||
|
||||
# Async Runtime and Networking
|
||||
async-channel = "2.5.0"
|
||||
@@ -137,31 +139,31 @@ async_zip = { default-features = false, version = "0.0.18" }
|
||||
mysql_async = { default-features = false, version = "0.37" }
|
||||
async-compression = { version = "0.4.42" }
|
||||
async-recursion = "1.1.1"
|
||||
async-trait = "0.1.89"
|
||||
async-nats = "0.49.1"
|
||||
async-trait = "0.1.91"
|
||||
async-nats = { version = "0.50.0", default-features = false }
|
||||
axum = "0.8.9"
|
||||
futures = "0.3.32"
|
||||
futures-core = "0.3.32"
|
||||
futures = "0.3.33"
|
||||
futures-core = "0.3.33"
|
||||
futures-lite = "2.6.1"
|
||||
futures-util = "0.3.32"
|
||||
futures-util = "0.3.33"
|
||||
pollster = "1.0.1"
|
||||
pulsar = { default-features = false, version = "6.8.0" }
|
||||
lapin = { default-features = false, version = "4.10.0" }
|
||||
hyper = { version = "1.10.1" }
|
||||
hyper = { version = "1.11.0" }
|
||||
hyper-rustls = { default-features = false, version = "0.27.9" }
|
||||
hyper-util = { version = "0.1.20" }
|
||||
http = "1.4.2"
|
||||
http-body = "1.1.0"
|
||||
http-body-util = "0.1.4"
|
||||
minlz = "1.2.3"
|
||||
reqwest = { default-features = false, version = "0.13.4" }
|
||||
reqwest = "0.13.4"
|
||||
rustfs-kafka-async = { version = "1.2.0" }
|
||||
socket2 = { version = "0.6.5" }
|
||||
tokio = { version = "1.52.3" }
|
||||
tokio = { version = "1.53.1" }
|
||||
tokio-rustls = { default-features = false, version = "0.26.4" }
|
||||
tokio-stream = { version = "0.1.18" }
|
||||
tokio-stream = { version = "0.1.19" }
|
||||
tokio-test = "0.4.5"
|
||||
tokio-util = { version = "0.7.18" }
|
||||
tokio-util = { version = "0.7.19" }
|
||||
tonic = { version = "0.14.6" }
|
||||
tonic-prost = { version = "0.14.6" }
|
||||
tonic-prost-build = { version = "0.14.6" }
|
||||
@@ -179,8 +181,8 @@ prost = "0.14.4"
|
||||
quick-xml = "0.41.0"
|
||||
rmp = { version = "0.8.15" }
|
||||
rmp-serde = { version = "1.3.1" }
|
||||
serde = { version = "1.0.228" }
|
||||
serde_json = { version = "1.0.150" }
|
||||
serde = { version = "1.0.229" }
|
||||
serde_json = { version = "1.0.151" }
|
||||
serde_urlencoded = "0.7.1"
|
||||
|
||||
# Cryptography and Security
|
||||
@@ -194,13 +196,13 @@ blake2 = "=0.11.0-rc.6"
|
||||
chacha20poly1305 = { version = "=0.11.0" }
|
||||
crc-fast = "1.10.0"
|
||||
hmac = { version = "0.13.0" }
|
||||
jsonwebtoken = { version = "10.4.0" }
|
||||
jsonwebtoken = { version = "11.0.0" }
|
||||
openidconnect = { default-features = false, version = "4.0" }
|
||||
pbkdf2 = "0.13.0"
|
||||
rsa = { version = "=0.10.0-rc.18" }
|
||||
rustls = { default-features = false, version = "0.23.42" }
|
||||
rustls-native-certs = "0.8"
|
||||
rustls-pki-types = "1.15.0"
|
||||
rustls-pki-types = "1.15.1"
|
||||
sha1 = "0.11.0"
|
||||
sha2 = "0.11.0"
|
||||
subtle = "2.6"
|
||||
@@ -209,8 +211,8 @@ zeroize = { version = "1.9.0" }
|
||||
# Time and Date
|
||||
chrono = { version = "0.4.45" }
|
||||
humantime = "2.4.0"
|
||||
jiff = { version = "0.2.32" }
|
||||
time = { version = "0.3.53" }
|
||||
jiff = { version = "0.2.35" }
|
||||
time = { version = "0.3.54" }
|
||||
|
||||
# Database
|
||||
deadpool-postgres = { version = "0.14" }
|
||||
@@ -218,21 +220,22 @@ tokio-postgres = { default-features = false, version = "0.7.18" }
|
||||
tokio-postgres-rustls = "0.14.0"
|
||||
|
||||
# Utilities and Tools
|
||||
anyhow = "1.0.103"
|
||||
anyhow = "1.0.104"
|
||||
arc-swap = "1.9.2"
|
||||
astral-tokio-tar = "0.6.3"
|
||||
astral-tokio-tar = "0.6.4"
|
||||
atoi = "3.1.0"
|
||||
atomic_enum = "0.3.0"
|
||||
aws-config = { version = "1.9.0" }
|
||||
aws-config = { version = "1.10.1" }
|
||||
aws-credential-types = { version = "1.3.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.138.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.140.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.110.0" }
|
||||
aws-smithy-http-client = { default-features = false, version = "1.2.0" }
|
||||
aws-smithy-runtime-api = { version = "1.13.0" }
|
||||
aws-smithy-runtime-api = { version = "1.14.0" }
|
||||
aws-smithy-types = { version = "1.6.1" }
|
||||
base64 = "0.22.1"
|
||||
base64 = "0.23.0"
|
||||
base64-simd = "0.8.0"
|
||||
brotli = "8.0.4"
|
||||
clap = { version = "4.6.2" }
|
||||
clap = { version = "4.6.4" }
|
||||
const-str = { version = "1.1.0" }
|
||||
convert_case = "0.11.0"
|
||||
criterion = { version = "0.8" }
|
||||
@@ -241,11 +244,12 @@ crossbeam-channel = "0.5.16"
|
||||
crossbeam-deque = "0.8.7"
|
||||
crossbeam-utils = "0.8.22"
|
||||
datafusion = { default-features = false, git = "https://github.com/apache/datafusion.git", rev = "dae03ee062b2abf986de8df12ea82fb1578a2d99" }
|
||||
#datafusion = { default-features = false, version = "54.1.0" }
|
||||
derive_builder = "0.20.2"
|
||||
enumset = "1.1.13"
|
||||
enumset = "1.1.14"
|
||||
faster-hex = "0.10.0"
|
||||
flate2 = "1.1.9"
|
||||
glob = "0.3.3"
|
||||
glob = "0.3.4"
|
||||
google-cloud-storage = "1.16.0"
|
||||
google-cloud-auth = "1.14.0"
|
||||
hashbrown = { version = "0.17.1" }
|
||||
@@ -254,7 +258,7 @@ hex-simd = "0.8.0"
|
||||
highway = { version = "1.3.0" }
|
||||
ipnetwork = { version = "0.21.1" }
|
||||
lazy_static = "1.5.0"
|
||||
libc = "0.2.186"
|
||||
libc = "0.2.189"
|
||||
libsystemd = "0.7.2"
|
||||
local-ip-address = "0.6.13"
|
||||
memmap2 = "0.9.11"
|
||||
@@ -276,16 +280,16 @@ pretty_assertions = "1.4.1"
|
||||
rand = { version = "0.10.2" }
|
||||
ratelimit = "0.10.1"
|
||||
rayon = "1.12.0"
|
||||
reed-solomon-erasure = { package = "rustfs-erasure-codec", version = "8.0.0" }
|
||||
reed-solomon-erasure = { package = "rustfs-erasure-codec", version = "8.0.2" }
|
||||
reed-solomon-simd = "3.1.0"
|
||||
regex = { version = "1.13.1" }
|
||||
rumqttc = { package = "rumqttc-next", version = "0.33.2" }
|
||||
redis = { version = "1.4.0" }
|
||||
rumqttc = { package = "rumqttc-next", version = "0.33.3" }
|
||||
redis = { version = "1.4.1" }
|
||||
rustix = { version = "1.1.4" }
|
||||
rust-embed = { version = "8.12.0" }
|
||||
rustc-hash = { version = "2.1.3" }
|
||||
s3s = { git = "https://github.com/s3s-project/s3s.git", rev = "ce69c3f10824535c7c24b2f71cdb2aaa4dffb5e0" }
|
||||
serial_test = "3.5.0"
|
||||
s3s = { git = "https://github.com/cxymds/s3s.git", rev = "fe3941d91fa1c69956f209a9145995c9f0235bff" }
|
||||
serial_test = "4.0.1"
|
||||
shadow-rs = { default-features = false, version = "2.0.0" }
|
||||
siphasher = "1.0.3"
|
||||
smallvec = { version = "1.15.2" }
|
||||
@@ -297,7 +301,7 @@ sysinfo = "0.39.6"
|
||||
temp-env = "0.3.6"
|
||||
tempfile = "3.27.0"
|
||||
test-case = "3.3.1"
|
||||
thiserror = "2.0.18"
|
||||
thiserror = "2.0.19"
|
||||
tracing = { version = "0.1.44" }
|
||||
tracing-appender = "0.2.5"
|
||||
tracing-error = "0.2.1"
|
||||
@@ -308,9 +312,10 @@ url = "2.5.8"
|
||||
urlencoding = "2.1.3"
|
||||
uuid = { version = "1.24.0" }
|
||||
vaultrs = { version = "0.8.0" }
|
||||
tar = "0.4.46"
|
||||
walkdir = "2.5.0"
|
||||
windows = { version = "0.62.2" }
|
||||
xxhash-rust = { version = "0.8.17" }
|
||||
xxhash-rust = { version = "0.8.18" }
|
||||
zip = "8.6.0"
|
||||
zstd = "0.13.3"
|
||||
|
||||
@@ -320,25 +325,26 @@ dial9-tokio-telemetry = "0.3"
|
||||
opentelemetry = { version = "0.32.0" }
|
||||
opentelemetry-appender-tracing = { version = "0.32.0" }
|
||||
opentelemetry-otlp = { version = "0.32.0" }
|
||||
opentelemetry-proto = { version = "0.32.0", default-features = false, features = ["metrics", "gen-tonic-messages"] }
|
||||
opentelemetry_sdk = { version = "0.32.1" }
|
||||
opentelemetry-semantic-conventions = { version = "0.32.1" }
|
||||
opentelemetry-stdout = { version = "0.32.0" }
|
||||
pyroscope = { version = "2.1.0" }
|
||||
pyroscope = { version = "2.1.1" }
|
||||
|
||||
# FTP and SFTP
|
||||
libunftp = { version = "0.23.0" }
|
||||
unftp-core = "0.1.0"
|
||||
suppaftp = { version = "10.0.1" }
|
||||
rcgen = "0.14.8"
|
||||
russh = { version = "0.62.2" }
|
||||
rcgen = { version = "0.14.8", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
||||
russh = { version = "0.62.4" }
|
||||
russh-sftp = "2.3.0"
|
||||
|
||||
# WebDAV
|
||||
dav-server = "0.11.0"
|
||||
|
||||
# Performance Analysis and Memory Profiling
|
||||
mimalloc = "0.1"
|
||||
hotpath = "0.21"
|
||||
mimalloc = "0.1.52"
|
||||
hotpath = "0.22.0"
|
||||
# Snapshot testing for output format regression detection
|
||||
insta = { version = "1.48" }
|
||||
|
||||
@@ -367,21 +373,3 @@ inherits = "release"
|
||||
inherits = "release"
|
||||
debug = true
|
||||
strip = "none"
|
||||
|
||||
# Pin hyper to a revision that carries the HTTP/1 "flush buffered data before
|
||||
# shutdown" fix (hyperium/hyper#4018, commit 72046cc7). This lands as a
|
||||
# `[patch.crates-io]` entry — not on the `hyper` workspace dependency — so that
|
||||
# every consumer in the tree, including the transitive `hyper-util` server path
|
||||
# (`conn::auto` / `GracefulShutdown`) that actually drives our connections,
|
||||
# resolves to the fixed hyper rather than the buggy crates.io copy.
|
||||
#
|
||||
# hyper <= 1.10.1 can call `poll_shutdown()` on the socket while response bytes
|
||||
# are still buffered (a prior `poll_flush()` returned `Poll::Pending` and the
|
||||
# result was discarded). A backpressured / slow-reading peer then receives a
|
||||
# graceful FIN before the full Content-Length body is flushed, which standard S3
|
||||
# clients (minio-go / warp) report as `unexpected EOF` on large-object GET under
|
||||
# load. The fix is not in any crates.io release yet as of hyper 1.10.1; drop
|
||||
# this patch once a released version (> 1.10.1) contains commit 72046cc7.
|
||||
# See rustfs/backlog#1232.
|
||||
[patch.crates-io]
|
||||
hyper = { git = "https://github.com/hyperium/hyper.git", rev = "ccc1e850dc0cda3e71b0acd11f60ca3d48d09034" }
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
FROM rust:1.97-trixie
|
||||
FROM rust:1.97.1-trixie
|
||||
|
||||
RUN set -eux; \
|
||||
export DEBIAN_FRONTEND=noninteractive; \
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ ARG RUSTFS_BUILD_FEATURES=""
|
||||
# -----------------------------
|
||||
# Build stage
|
||||
# -----------------------------
|
||||
FROM rust:1.97-trixie AS builder
|
||||
FROM rust:1.97.1-trixie AS builder
|
||||
|
||||
# Re-declare args after FROM
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
@@ -116,7 +116,7 @@ chown -R 10001:10001 data logs
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||
|
||||
# Using specific version
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.10
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.11
|
||||
```
|
||||
|
||||
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
|
||||
@@ -163,6 +163,7 @@ docker run -d --name rustfs -p 9000:9000 \
|
||||
-e RUSTFS_NOTIFY_WEBHOOK_ENABLE_PRIMARY=on \
|
||||
-e RUSTFS_NOTIFY_WEBHOOK_ENDPOINT_PRIMARY=http://<host-ip>:3020/webhook \
|
||||
-e RUSTFS_NOTIFY_WEBHOOK_QUEUE_DIR_PRIMARY=/tmp/rustfs-events \
|
||||
-e RUSTFS_OUTBOUND_ALLOW_ORIGINS=http://<host-ip>:3020 \
|
||||
rustfs/rustfs:latest
|
||||
```
|
||||
|
||||
@@ -171,6 +172,11 @@ Notes:
|
||||
- For ARN `arn:rustfs:sqs::primary:webhook`, use instance-scoped env vars with `_PRIMARY`.
|
||||
- If queue dir is omitted, default is `/opt/rustfs/events`; ensure it is writable by the container runtime user.
|
||||
- `RUSTFS_NOTIFY_WEBHOOK_SKIP_TLS_VERIFY_PRIMARY` defaults to `false`; enabling it skips webhook TLS certificate verification, allows MITM attacks, and emits a startup warning. Prefer `RUSTFS_NOTIFY_WEBHOOK_CLIENT_CA_PRIMARY` for private CAs.
|
||||
- Since `1.0.0-beta.11`, webhook endpoints on private or container networks
|
||||
(`Docker Compose service names`, `host.docker.internal`, RFC 1918 addresses) are
|
||||
blocked unless their exact `scheme://host:port` origin is listed in
|
||||
`RUSTFS_OUTBOUND_ALLOW_ORIGINS` (the origin only, without the path). See
|
||||
[Outbound Connection Policy](docs/operations/outbound-connection-policy.md).
|
||||
|
||||
**NOTE**: We recommend reviewing the `docker-compose.yml` file before running. It defines several services including Grafana, Prometheus, and Jaeger, which are helpful for RustFS observability. If you wish to start Redis or Nginx containers, you can specify the corresponding profiles.
|
||||
|
||||
@@ -262,7 +268,7 @@ rustfs --help
|
||||
2. **Create a Bucket**: Use the console to create a new bucket for your objects.
|
||||
3. **Upload Objects**: You can upload files directly through the console or use S3-compatible APIs/clients to interact with your RustFS instance.
|
||||
|
||||
**NOTE**: To access the RustFS instance via `https`, please refer to the [TLS Configuration Docs](https://docs.rustfs.com/integration/tls-configured.html).
|
||||
**NOTE**: To access the RustFS instance via `https`, please refer to the [TLS Configuration Docs](https://docs.rustfs.com/integration/tls-configured).
|
||||
|
||||
### OIDC Roles Claim (Microsoft Entra ID)
|
||||
|
||||
@@ -338,12 +344,18 @@ If you have any questions or need assistance:
|
||||
RustFS is a community-driven project, and we appreciate all contributions. Check out the [Contributors](https://github.com/rustfs/rustfs/graphs/contributors) page to see the amazing people who have helped make RustFS better.
|
||||
|
||||
<a href="https://github.com/rustfs/rustfs/graphs/contributors">
|
||||
<img src="https://opencollective.com/rustfs/contributors.svg?width=890&limit=500&button=false" alt="Contributors" />
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/rustfs/rustfs/star-history/contributors-dark.svg">
|
||||
<img src="https://raw.githubusercontent.com/rustfs/rustfs/star-history/contributors-light.svg" alt="RustFS contributors">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#rustfs/rustfs&type=date&legend=top-left)
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/rustfs/rustfs/star-history/star-history-dark.svg">
|
||||
<img src="https://raw.githubusercontent.com/rustfs/rustfs/star-history/star-history-light.svg" alt="RustFS star history chart">
|
||||
</picture>
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+10
-4
@@ -113,7 +113,7 @@ chown -R 10001:10001 data logs
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||
|
||||
# 使用指定版本运行
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.10
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.11
|
||||
```
|
||||
|
||||
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
||||
@@ -214,7 +214,7 @@ rustfs --help
|
||||
2. **创建存储桶**: 使用控制台为您的对象创建一个新的存储桶 (Bucket)。
|
||||
3. **上传对象**: 您可以直接通过控制台上传文件,或使用 S3 兼容的 API/客户端与您的 RustFS 实例进行交互。
|
||||
|
||||
**注意**: 如果您希望通过 `https` 访问 RustFS 实例,请参考 [TLS 配置文档](https://docs.rustfs.com/integration/tls-configured.html)。
|
||||
**注意**: 如果您希望通过 `https` 访问 RustFS 实例,请参考 [TLS 配置文档](https://docs.rustfs.com/integration/tls-configured)。
|
||||
|
||||
## 文档
|
||||
|
||||
@@ -247,12 +247,18 @@ rustfs --help
|
||||
RustFS 是一个社区驱动的项目,我们感谢所有的贡献。请查看 [贡献者](https://github.com/rustfs/rustfs/graphs/contributors) 页面,看看那些让 RustFS 变得更好的了不起的人们。
|
||||
|
||||
<a href="https://github.com/rustfs/rustfs/graphs/contributors">
|
||||
<img src="https://opencollective.com/rustfs/contributors.svg?width=890&limit=500&button=false" alt="Contributors" />
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/rustfs/rustfs/star-history/contributors-dark.svg">
|
||||
<img src="https://raw.githubusercontent.com/rustfs/rustfs/star-history/contributors-light.svg" alt="RustFS 贡献者">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Star 历史
|
||||
|
||||
[](https://www.star-history.com/#rustfs/rustfs&type=date&legend=top-left)
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/rustfs/rustfs/star-history/star-history-dark.svg">
|
||||
<img src="https://raw.githubusercontent.com/rustfs/rustfs/star-history/star-history-light.svg" alt="RustFS Star 历史图表">
|
||||
</picture>
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
@@ -292,8 +292,8 @@ impl AuditPipeline {
|
||||
}
|
||||
|
||||
pub async fn snapshot_target_health(&self) -> Vec<rustfs_targets::RuntimeTargetHealthSnapshot> {
|
||||
let registry = self.registry.lock().await;
|
||||
registry.runtime_manager().health_snapshots().await
|
||||
let targets = self.registry.lock().await.list_target_values();
|
||||
rustfs_targets::health_snapshots_for_targets(targets).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,7 +570,7 @@ mod tests {
|
||||
use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
|
||||
use rustfs_targets::{StoreError, Target, TargetError};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::{Mutex, Notify};
|
||||
|
||||
/// Mock target whose `save()` outcome is fixed at construction so tests can
|
||||
/// force full-success / full-failure / partial-failure fan-outs.
|
||||
@@ -578,6 +578,7 @@ mod tests {
|
||||
struct MockTarget {
|
||||
id: TargetID,
|
||||
fail: bool,
|
||||
health_gate: Option<(Arc<Notify>, Arc<Notify>)>,
|
||||
}
|
||||
|
||||
impl MockTarget {
|
||||
@@ -585,8 +586,14 @@ mod tests {
|
||||
Self {
|
||||
id: TargetID::new(id.to_string(), "webhook".to_string()),
|
||||
fail,
|
||||
health_gate: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_health_gate(mut self, started: Arc<Notify>, release: Arc<Notify>) -> Self {
|
||||
self.health_gate = Some((started, release));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -599,6 +606,10 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn is_active(&self) -> Result<bool, TargetError> {
|
||||
if let Some((started, release)) = &self.health_gate {
|
||||
started.notify_one();
|
||||
release.notified().await;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -673,6 +684,24 @@ mod tests {
|
||||
pipeline.dispatch(entry()).await.expect("no targets should return Ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_probe_does_not_hold_the_registry_lock() {
|
||||
let started = Arc::new(Notify::new());
|
||||
let release = Arc::new(Notify::new());
|
||||
let pipeline = pipeline_with(vec![MockTarget::new("blocked", false).with_health_gate(started.clone(), release.clone())]);
|
||||
let registry = Arc::clone(&pipeline.registry);
|
||||
let snapshot_task = tokio::spawn(async move { pipeline.snapshot_target_health().await });
|
||||
started.notified().await;
|
||||
|
||||
let guard = tokio::time::timeout(std::time::Duration::from_secs(1), registry.lock())
|
||||
.await
|
||||
.expect("network health probe must not retain the audit registry lock");
|
||||
drop(guard);
|
||||
release.notify_one();
|
||||
|
||||
assert_eq!(snapshot_task.await.expect("snapshot task should finish").len(), 1);
|
||||
}
|
||||
|
||||
// backlog#962: dispatch_batch must mirror dispatch and propagate a
|
||||
// whole-batch loss instead of returning Ok.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -54,6 +54,15 @@ pub async fn get_global_local_node_name() -> String {
|
||||
GLOBAL_LOCAL_NODE_NAME.read().await.clone()
|
||||
}
|
||||
|
||||
/// Read the local node name without waiting for initialization or a writer.
|
||||
pub fn try_get_global_local_node_name() -> Option<String> {
|
||||
GLOBAL_LOCAL_NODE_NAME
|
||||
.try_read()
|
||||
.ok()
|
||||
.map(|name| name.clone())
|
||||
.filter(|name| !name.is_empty())
|
||||
}
|
||||
|
||||
/// Set the global RustFS initialization time to the current UTC time.
|
||||
pub async fn set_global_init_time_now() {
|
||||
let now = Utc::now();
|
||||
|
||||
@@ -243,6 +243,19 @@ pub enum HealAdmissionResult {
|
||||
Dropped(HealAdmissionDropReason),
|
||||
}
|
||||
|
||||
/// Admission decision together with the canonical task identifier.
|
||||
///
|
||||
/// A merged request must return the identifier of the task that already owns
|
||||
/// the work instead of exposing the discarded request identifier as a new
|
||||
/// client token.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HealAdmissionReceipt {
|
||||
/// Admission decision for the submitted request.
|
||||
pub result: HealAdmissionResult,
|
||||
/// Canonical identifier of the accepted or merged task.
|
||||
pub task_id: String,
|
||||
}
|
||||
|
||||
impl HealAdmissionResult {
|
||||
pub fn result_label(self) -> &'static str {
|
||||
match self {
|
||||
@@ -382,8 +395,25 @@ pub type HealChannelSender = mpsc::UnboundedSender<HealChannelCommand>;
|
||||
/// Heal channel receiver
|
||||
pub type HealChannelReceiver = mpsc::UnboundedReceiver<HealChannelCommand>;
|
||||
|
||||
/// Canonical-receipt start command kept separate from the legacy public enum.
|
||||
#[derive(Debug)]
|
||||
pub struct HealReceiptCommand {
|
||||
/// Heal request to admit.
|
||||
pub request: HealChannelRequest,
|
||||
/// Completion channel for the admission receipt.
|
||||
pub response_tx: oneshot::Sender<Result<HealAdmissionReceipt, String>>,
|
||||
}
|
||||
|
||||
/// Canonical-receipt command receiver.
|
||||
pub type HealReceiptReceiver = mpsc::UnboundedReceiver<HealReceiptCommand>;
|
||||
|
||||
struct HealChannelSenders {
|
||||
command: HealChannelSender,
|
||||
receipt: mpsc::UnboundedSender<HealReceiptCommand>,
|
||||
}
|
||||
|
||||
/// Global heal channel sender
|
||||
static GLOBAL_HEAL_CHANNEL_SENDER: OnceLock<HealChannelSender> = OnceLock::new();
|
||||
static GLOBAL_HEAL_CHANNEL_SENDERS: OnceLock<HealChannelSenders> = OnceLock::new();
|
||||
|
||||
type HealResponseSender = broadcast::Sender<HealChannelResponse>;
|
||||
|
||||
@@ -392,17 +422,24 @@ static GLOBAL_HEAL_RESPONSE_SENDER: OnceLock<HealResponseSender> = OnceLock::new
|
||||
|
||||
/// Initialize global heal channel
|
||||
pub fn init_heal_channel() -> Result<HealChannelReceiver, &'static str> {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
if GLOBAL_HEAL_CHANNEL_SENDER.set(tx).is_ok() {
|
||||
Ok(rx)
|
||||
} else {
|
||||
Err("Heal channel sender already initialized")
|
||||
}
|
||||
let (receiver, receipt_receiver) = init_heal_channels()?;
|
||||
drop(receipt_receiver);
|
||||
Ok(receiver)
|
||||
}
|
||||
|
||||
/// Initialize the legacy command and canonical-receipt channels atomically.
|
||||
pub fn init_heal_channels() -> Result<(HealChannelReceiver, HealReceiptReceiver), &'static str> {
|
||||
let (command, command_receiver) = mpsc::unbounded_channel();
|
||||
let (receipt, receipt_receiver) = mpsc::unbounded_channel();
|
||||
GLOBAL_HEAL_CHANNEL_SENDERS
|
||||
.set(HealChannelSenders { command, receipt })
|
||||
.map_err(|_| "Heal channel sender already initialized")?;
|
||||
Ok((command_receiver, receipt_receiver))
|
||||
}
|
||||
|
||||
/// Get global heal channel sender
|
||||
pub fn get_heal_channel_sender() -> Option<&'static HealChannelSender> {
|
||||
GLOBAL_HEAL_CHANNEL_SENDER.get()
|
||||
GLOBAL_HEAL_CHANNEL_SENDERS.get().map(|senders| &senders.command)
|
||||
}
|
||||
|
||||
/// Send heal command through global channel
|
||||
@@ -436,6 +473,21 @@ pub fn subscribe_heal_responses() -> broadcast::Receiver<HealChannelResponse> {
|
||||
heal_response_sender().subscribe()
|
||||
}
|
||||
|
||||
/// Send heal start request and wait for structured admission feedback.
|
||||
pub async fn send_heal_request_with_receipt(request: HealChannelRequest) -> Result<HealAdmissionReceipt, String> {
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
let senders = GLOBAL_HEAL_CHANNEL_SENDERS
|
||||
.get()
|
||||
.ok_or_else(|| "Heal channel not initialized".to_string())?;
|
||||
senders
|
||||
.receipt
|
||||
.send(HealReceiptCommand { request, response_tx })
|
||||
.map_err(|err| format!("Failed to send heal receipt command: {err}"))?;
|
||||
response_rx
|
||||
.await
|
||||
.map_err(|e| format!("Failed to receive heal admission response: {e}"))?
|
||||
}
|
||||
|
||||
/// Send heal start request and wait for structured admission feedback.
|
||||
pub async fn send_heal_request_with_admission(request: HealChannelRequest) -> Result<HealAdmissionResult, String> {
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
|
||||
+188
-17
@@ -768,6 +768,7 @@ pub struct Metrics {
|
||||
last_scan_cycle_replication_checks: AtomicU64,
|
||||
last_scan_cycle_usage_saves: AtomicU64,
|
||||
failed_scan_cycles: AtomicU64,
|
||||
superseded_scan_cycles: AtomicU64,
|
||||
partial_scan_cycles_unknown: AtomicU64,
|
||||
partial_scan_cycles_runtime: AtomicU64,
|
||||
partial_scan_cycles_objects: AtomicU64,
|
||||
@@ -785,6 +786,9 @@ pub struct Metrics {
|
||||
scanner_expiry_queue_missed: AtomicU64,
|
||||
scanner_expiry_queued_total: AtomicU64,
|
||||
scanner_expiry_missed_total: AtomicU64,
|
||||
scanner_expiry_blocked_total: AtomicU64,
|
||||
scanner_expiry_not_enqueued_total: AtomicU64,
|
||||
scanner_expiry_delete_failed_total: AtomicU64,
|
||||
scanner_transition_queue_capacity: AtomicU64,
|
||||
scanner_transition_queued: AtomicU64,
|
||||
scanner_transition_active: AtomicU64,
|
||||
@@ -833,10 +837,12 @@ const SCAN_CYCLE_RESULT_UNKNOWN: u8 = 0;
|
||||
const SCAN_CYCLE_RESULT_SUCCESS: u8 = 1;
|
||||
const SCAN_CYCLE_RESULT_ERROR: u8 = 2;
|
||||
const SCAN_CYCLE_RESULT_PARTIAL: u8 = 3;
|
||||
const SCAN_CYCLE_RESULT_SUPERSEDED: u8 = 4;
|
||||
const SCAN_CYCLE_RESULT_UNKNOWN_LABEL: &str = "unknown";
|
||||
const SCAN_CYCLE_RESULT_SUCCESS_LABEL: &str = "success";
|
||||
const SCAN_CYCLE_RESULT_ERROR_LABEL: &str = "error";
|
||||
const SCAN_CYCLE_RESULT_PARTIAL_LABEL: &str = "partial";
|
||||
const SCAN_CYCLE_RESULT_SUPERSEDED_LABEL: &str = "superseded";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ScanCyclePartialReason {
|
||||
@@ -1048,6 +1054,12 @@ pub struct ScannerLifecycleExpirySnapshot {
|
||||
pub queue_missed: u64,
|
||||
pub scanner_queued: u64,
|
||||
pub scanner_missed: u64,
|
||||
#[serde(default)]
|
||||
pub scanner_blocked: u64,
|
||||
#[serde(default)]
|
||||
pub scanner_not_enqueued: u64,
|
||||
#[serde(default)]
|
||||
pub delete_failed: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
@@ -1208,6 +1220,8 @@ pub struct ScannerMetricsReport {
|
||||
pub last_cycle_usage_saves: u64,
|
||||
pub failed_cycles: u64,
|
||||
#[serde(default)]
|
||||
pub superseded_cycles: u64,
|
||||
#[serde(default)]
|
||||
pub partial_cycles_unknown: u64,
|
||||
#[serde(default)]
|
||||
pub partial_cycles_runtime: u64,
|
||||
@@ -1310,6 +1324,7 @@ fn scan_cycle_result_label(result: u8) -> &'static str {
|
||||
SCAN_CYCLE_RESULT_SUCCESS => SCAN_CYCLE_RESULT_SUCCESS_LABEL,
|
||||
SCAN_CYCLE_RESULT_ERROR => SCAN_CYCLE_RESULT_ERROR_LABEL,
|
||||
SCAN_CYCLE_RESULT_PARTIAL => SCAN_CYCLE_RESULT_PARTIAL_LABEL,
|
||||
SCAN_CYCLE_RESULT_SUPERSEDED => SCAN_CYCLE_RESULT_SUPERSEDED_LABEL,
|
||||
_ => SCAN_CYCLE_RESULT_UNKNOWN_LABEL,
|
||||
}
|
||||
}
|
||||
@@ -1633,6 +1648,11 @@ pub fn emit_scan_cycle_partial_with_source(
|
||||
metrics::counter!(OTEL_SCANNER_CYCLES, "result" => SCAN_CYCLE_RESULT_PARTIAL_LABEL).increment(1);
|
||||
}
|
||||
|
||||
pub fn emit_scan_cycle_superseded(duration: Duration) {
|
||||
global_metrics().record_scan_cycle_superseded(duration);
|
||||
metrics::counter!(OTEL_SCANNER_CYCLES, "result" => SCAN_CYCLE_RESULT_SUPERSEDED_LABEL).increment(1);
|
||||
}
|
||||
|
||||
pub fn emit_scan_bucket_drive_complete(success: bool, bucket: &str, disk: &str, duration: Duration) {
|
||||
let result = if success { "success" } else { "error" };
|
||||
metrics::counter!(
|
||||
@@ -1726,6 +1746,7 @@ impl Metrics {
|
||||
last_scan_cycle_replication_checks: AtomicU64::new(0),
|
||||
last_scan_cycle_usage_saves: AtomicU64::new(0),
|
||||
failed_scan_cycles: AtomicU64::new(0),
|
||||
superseded_scan_cycles: AtomicU64::new(0),
|
||||
partial_scan_cycles_unknown: AtomicU64::new(0),
|
||||
partial_scan_cycles_runtime: AtomicU64::new(0),
|
||||
partial_scan_cycles_objects: AtomicU64::new(0),
|
||||
@@ -1743,6 +1764,9 @@ impl Metrics {
|
||||
scanner_expiry_queue_missed: AtomicU64::new(0),
|
||||
scanner_expiry_queued_total: AtomicU64::new(0),
|
||||
scanner_expiry_missed_total: AtomicU64::new(0),
|
||||
scanner_expiry_blocked_total: AtomicU64::new(0),
|
||||
scanner_expiry_not_enqueued_total: AtomicU64::new(0),
|
||||
scanner_expiry_delete_failed_total: AtomicU64::new(0),
|
||||
scanner_transition_queue_capacity: AtomicU64::new(0),
|
||||
scanner_transition_queued: AtomicU64::new(0),
|
||||
scanner_transition_active: AtomicU64::new(0),
|
||||
@@ -1973,9 +1997,18 @@ impl Metrics {
|
||||
self.scanner_expiry_queued_total.fetch_add(count, Ordering::Relaxed);
|
||||
} else {
|
||||
self.scanner_expiry_missed_total.fetch_add(count, Ordering::Relaxed);
|
||||
self.scanner_expiry_not_enqueued_total.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_scanner_expiry_blocked(&self, count: u64) {
|
||||
self.scanner_expiry_blocked_total.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn record_scanner_expiry_delete_failed(&self, count: u64) {
|
||||
self.scanner_expiry_delete_failed_total.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn record_scanner_transition_enqueue_result(&self, count: u64, queued: bool) {
|
||||
self.record_scanner_ilm_enqueue_result(count, queued);
|
||||
if queued {
|
||||
@@ -2349,6 +2382,18 @@ impl Metrics {
|
||||
.store(duration_millis_saturated(duration), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn record_scan_cycle_superseded(&self, duration: Duration) {
|
||||
self.record_scanner_cycle_end_time();
|
||||
self.superseded_scan_cycles.fetch_add(1, Ordering::Relaxed);
|
||||
self.last_scan_cycle_result
|
||||
.store(SCAN_CYCLE_RESULT_SUPERSEDED, Ordering::Relaxed);
|
||||
self.last_scan_cycle_partial_reason
|
||||
.store(ScanCyclePartialReason::Unknown as u8, Ordering::Relaxed);
|
||||
self.last_scan_cycle_partial_source.store(0, Ordering::Relaxed);
|
||||
self.last_scan_cycle_duration_millis
|
||||
.store(duration_millis_saturated(duration), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn record_scan_cycle_partial(&self, duration: Duration, reason: ScanCyclePartialReason) {
|
||||
self.record_scan_cycle_partial_with_source(duration, reason, None);
|
||||
}
|
||||
@@ -2434,6 +2479,18 @@ impl Metrics {
|
||||
self.current_scan_cycle_work_active.store(false, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn current_scan_cycle_has_unresolved_heal_work(&self) -> bool {
|
||||
if !self.current_scan_cycle_work_active.load(Ordering::Relaxed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let source_work = self.scanner_source_work_since(&self.current_scan_cycle_source_work_start_values());
|
||||
[ScannerWorkSource::Heal, ScannerWorkSource::Bitrot]
|
||||
.into_iter()
|
||||
.filter_map(|source| source_work.get(source.index()))
|
||||
.any(|work| work.queued > 0 || work.skipped > 0 || work.failed > 0 || work.missed > 0)
|
||||
}
|
||||
|
||||
fn scan_cycle_work_snapshot(&self) -> ScanCycleWorkSnapshot {
|
||||
ScanCycleWorkSnapshot {
|
||||
objects_scanned: self.lifetime(Metric::ScanObject),
|
||||
@@ -2790,6 +2847,7 @@ impl Metrics {
|
||||
m.last_cycle_replication_repair =
|
||||
self.scanner_replication_repair_work_counter_snapshots(&self.last_scan_cycle_replication_repair_work);
|
||||
m.failed_cycles = self.failed_scan_cycles.load(Ordering::Relaxed);
|
||||
m.superseded_cycles = self.superseded_scan_cycles.load(Ordering::Relaxed);
|
||||
m.partial_cycles_unknown = self.partial_scan_cycles_unknown.load(Ordering::Relaxed);
|
||||
m.partial_cycles_runtime = self.partial_scan_cycles_runtime.load(Ordering::Relaxed);
|
||||
m.partial_cycles_objects = self.partial_scan_cycles_objects.load(Ordering::Relaxed);
|
||||
@@ -2813,6 +2871,9 @@ impl Metrics {
|
||||
queue_missed: self.scanner_expiry_queue_missed.load(Ordering::Relaxed),
|
||||
scanner_queued: self.scanner_expiry_queued_total.load(Ordering::Relaxed),
|
||||
scanner_missed: self.scanner_expiry_missed_total.load(Ordering::Relaxed),
|
||||
scanner_blocked: self.scanner_expiry_blocked_total.load(Ordering::Relaxed),
|
||||
scanner_not_enqueued: self.scanner_expiry_not_enqueued_total.load(Ordering::Relaxed),
|
||||
delete_failed: self.scanner_expiry_delete_failed_total.load(Ordering::Relaxed),
|
||||
};
|
||||
m.lifecycle_transition = ScannerLifecycleTransitionSnapshot {
|
||||
current_queue_capacity: self.scanner_transition_queue_capacity.load(Ordering::Relaxed),
|
||||
@@ -2938,19 +2999,15 @@ pub type CloseDiskFn = Arc<dyn Fn() -> Pin<Box<dyn Future<Output = ()> + Send>>
|
||||
|
||||
/// Register a new disk in the global path tracker and return two callbacks:
|
||||
/// one to update the current path and one to deregister the disk when done.
|
||||
pub fn current_path_updater(disk: &str, initial: &str) -> (UpdateCurrentPathFn, CloseDiskFn) {
|
||||
pub async fn current_path_updater(disk: &str, initial: &str) -> (UpdateCurrentPathFn, CloseDiskFn) {
|
||||
let tracker = Arc::new(CurrentPathTracker::new(initial.to_string()));
|
||||
let disk_name = disk.to_string();
|
||||
|
||||
let tracker_clone = Arc::clone(&tracker);
|
||||
let disk_insert = disk_name.clone();
|
||||
tokio::spawn(async move {
|
||||
global_metrics()
|
||||
.current_paths
|
||||
.write()
|
||||
.await
|
||||
.insert(disk_insert, tracker_clone);
|
||||
});
|
||||
global_metrics()
|
||||
.current_paths
|
||||
.write()
|
||||
.await
|
||||
.insert(disk_name.clone(), Arc::clone(&tracker));
|
||||
|
||||
let update_fn: UpdateCurrentPathFn = {
|
||||
let tracker = Arc::clone(&tracker);
|
||||
@@ -2978,23 +3035,28 @@ pub fn current_path_updater(disk: &str, initial: &str) -> (UpdateCurrentPathFn,
|
||||
// CloseDiskGuard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct CloseDiskGuard(CloseDiskFn);
|
||||
pub struct CloseDiskGuard(Option<CloseDiskFn>);
|
||||
|
||||
impl CloseDiskGuard {
|
||||
pub fn new(close_disk: CloseDiskFn) -> Self {
|
||||
Self(close_disk)
|
||||
Self(Some(close_disk))
|
||||
}
|
||||
|
||||
pub async fn close(&self) {
|
||||
self.0().await;
|
||||
pub async fn close(&mut self) {
|
||||
let Some(close_disk) = self.0.clone() else {
|
||||
return;
|
||||
};
|
||||
close_disk().await;
|
||||
self.0 = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CloseDiskGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
let close_fn = self.0.clone();
|
||||
handle.spawn(async move { close_fn().await });
|
||||
if let Some(close_disk) = self.0.take()
|
||||
&& let Ok(handle) = tokio::runtime::Handle::try_current()
|
||||
{
|
||||
handle.spawn(close_disk());
|
||||
}
|
||||
// If there is no runtime we are in a test or shutdown path; skip cleanup.
|
||||
}
|
||||
@@ -3004,6 +3066,61 @@ impl Drop for CloseDiskGuard {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_disk_guard_runs_cleanup_when_an_early_return_drops_it() {
|
||||
let (closed_tx, closed_rx) = tokio::sync::oneshot::channel();
|
||||
let closed_tx = Arc::new(std::sync::Mutex::new(Some(closed_tx)));
|
||||
let close_disk: CloseDiskFn = {
|
||||
let closed_tx = Arc::clone(&closed_tx);
|
||||
Arc::new(move || {
|
||||
let closed_tx = closed_tx.lock().expect("close callback lock").take();
|
||||
Box::pin(async move {
|
||||
if let Some(closed_tx) = closed_tx {
|
||||
let _ = closed_tx.send(());
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
let guard = CloseDiskGuard::new(close_disk);
|
||||
drop(guard);
|
||||
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), closed_rx)
|
||||
.await
|
||||
.expect("drop cleanup should run")
|
||||
.expect("drop cleanup should signal");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_disk_guard_runs_explicit_cleanup_once() {
|
||||
let close_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let close_disk: CloseDiskFn = {
|
||||
let close_count = Arc::clone(&close_count);
|
||||
Arc::new(move || {
|
||||
close_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
Box::pin(std::future::ready(()))
|
||||
})
|
||||
};
|
||||
|
||||
let mut guard = CloseDiskGuard::new(close_disk);
|
||||
guard.close().await;
|
||||
drop(guard);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
|
||||
assert_eq!(close_count.load(std::sync::atomic::Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn current_path_updater_registers_before_return() {
|
||||
let disk = format!("test-disk-{}", uuid::Uuid::new_v4());
|
||||
let (_update_path, close_disk) = current_path_updater(&disk, "bucket-a").await;
|
||||
|
||||
assert!(global_metrics().current_paths.read().await.contains_key(&disk));
|
||||
|
||||
close_disk().await;
|
||||
assert!(!global_metrics().current_paths.read().await.contains_key(&disk));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_counts_active_scan_paths() {
|
||||
let metrics = Metrics::new();
|
||||
@@ -3292,6 +3409,8 @@ mod tests {
|
||||
});
|
||||
metrics.record_scanner_expiry_enqueue_result(6, true);
|
||||
metrics.record_scanner_expiry_enqueue_result(2, false);
|
||||
metrics.record_scanner_expiry_blocked(4);
|
||||
metrics.record_scanner_expiry_delete_failed(1);
|
||||
|
||||
let report = metrics.report().await;
|
||||
|
||||
@@ -3302,6 +3421,9 @@ mod tests {
|
||||
assert_eq!(report.lifecycle_expiry.queue_missed, 3);
|
||||
assert_eq!(report.lifecycle_expiry.scanner_queued, 6);
|
||||
assert_eq!(report.lifecycle_expiry.scanner_missed, 2);
|
||||
assert_eq!(report.lifecycle_expiry.scanner_blocked, 4);
|
||||
assert_eq!(report.lifecycle_expiry.scanner_not_enqueued, 2);
|
||||
assert_eq!(report.lifecycle_expiry.delete_failed, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3361,6 +3483,40 @@ mod tests {
|
||||
metrics.finish_scan_cycle_work(start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_heal_work_only_reflects_the_active_cycle() {
|
||||
let metrics = Metrics::new();
|
||||
assert!(!metrics.current_scan_cycle_has_unresolved_heal_work());
|
||||
|
||||
let start = metrics.start_scan_cycle_work();
|
||||
metrics.record_scanner_source_missed(ScannerWorkSource::Heal, 1);
|
||||
assert!(metrics.current_scan_cycle_has_unresolved_heal_work());
|
||||
|
||||
metrics.finish_scan_cycle_work(start);
|
||||
assert!(!metrics.current_scan_cycle_has_unresolved_heal_work());
|
||||
|
||||
let start = metrics.start_scan_cycle_work();
|
||||
metrics.record_scanner_source_queued(ScannerWorkSource::Heal, 1);
|
||||
assert!(metrics.current_scan_cycle_has_unresolved_heal_work());
|
||||
metrics.finish_scan_cycle_work(start);
|
||||
|
||||
let start = metrics.start_scan_cycle_work();
|
||||
metrics.record_scanner_source_work(
|
||||
ScannerWorkSource::Bitrot,
|
||||
ScannerSourceWorkUpdate {
|
||||
skipped: 1,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(metrics.current_scan_cycle_has_unresolved_heal_work());
|
||||
metrics.finish_scan_cycle_work(start);
|
||||
|
||||
let start = metrics.start_scan_cycle_work();
|
||||
metrics.record_scanner_source_failed(ScannerWorkSource::Bitrot, 1);
|
||||
assert!(metrics.current_scan_cycle_has_unresolved_heal_work());
|
||||
metrics.finish_scan_cycle_work(start);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_marks_transition_failures_as_blocked_lifecycle_control() {
|
||||
let metrics = Metrics::new();
|
||||
@@ -3804,6 +3960,21 @@ mod tests {
|
||||
assert_eq!(report.failed_cycles, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_tracks_superseded_cycle_without_failed_increment() {
|
||||
let metrics = Metrics::new();
|
||||
metrics.record_scan_cycle_superseded(Duration::from_millis(750));
|
||||
|
||||
let report = metrics.report().await;
|
||||
|
||||
assert_eq!(report.last_cycle_result, SCAN_CYCLE_RESULT_SUPERSEDED_LABEL);
|
||||
assert_eq!(report.last_cycle_result_code, u64::from(SCAN_CYCLE_RESULT_SUPERSEDED));
|
||||
assert_eq!(report.last_cycle_duration_seconds, 0.75);
|
||||
assert_eq!(report.failed_cycles, 0);
|
||||
assert_eq!(report.superseded_cycles, 1);
|
||||
assert_eq!(report.partial_cycles, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_tracks_successful_scan_cycle_without_failed_increment() {
|
||||
let metrics = Metrics::new();
|
||||
|
||||
@@ -50,3 +50,43 @@ pub const ENV_API_RATE_LIMIT_BURST: &str = "RUSTFS_API_RATE_LIMIT_BURST";
|
||||
|
||||
/// Default for `RUSTFS_API_RATE_LIMIT_BURST` (`0` = same as RPM).
|
||||
pub const DEFAULT_API_RATE_LIMIT_BURST: u32 = 0;
|
||||
|
||||
/// Sustained S3 API request budget per addressed bucket, in requests per
|
||||
/// minute — a collective ceiling shared by all clients of that bucket.
|
||||
///
|
||||
/// Complements the per-client-IP dimension: it protects the server from one
|
||||
/// hot bucket regardless of how many client IPs the traffic comes from. `0`
|
||||
/// disables the bucket dimension. Requires `RUSTFS_API_RATE_LIMIT_ENABLE`.
|
||||
/// Environment variable: RUSTFS_API_RATE_LIMIT_BUCKET_RPM
|
||||
/// Example: RUSTFS_API_RATE_LIMIT_BUCKET_RPM=60000
|
||||
pub const ENV_API_RATE_LIMIT_BUCKET_RPM: &str = "RUSTFS_API_RATE_LIMIT_BUCKET_RPM";
|
||||
|
||||
/// Default for `RUSTFS_API_RATE_LIMIT_BUCKET_RPM` (`0` = dimension disabled).
|
||||
pub const DEFAULT_API_RATE_LIMIT_BUCKET_RPM: u32 = 0;
|
||||
|
||||
/// Burst capacity per bucket (maximum tokens in the bucket-dimension bucket).
|
||||
///
|
||||
/// `0` means "same as `RUSTFS_API_RATE_LIMIT_BUCKET_RPM`".
|
||||
/// Environment variable: RUSTFS_API_RATE_LIMIT_BUCKET_BURST
|
||||
/// Example: RUSTFS_API_RATE_LIMIT_BUCKET_BURST=2000
|
||||
pub const ENV_API_RATE_LIMIT_BUCKET_BURST: &str = "RUSTFS_API_RATE_LIMIT_BUCKET_BURST";
|
||||
|
||||
/// Default for `RUSTFS_API_RATE_LIMIT_BUCKET_BURST` (`0` = same as bucket RPM).
|
||||
pub const DEFAULT_API_RATE_LIMIT_BUCKET_BURST: u32 = 0;
|
||||
|
||||
/// Maximum concurrently served connections on the main API listener.
|
||||
///
|
||||
/// `0` (the default) means unlimited. When set, the accept loop stops
|
||||
/// accepting once the cap is reached and lets the kernel backlog absorb
|
||||
/// bursts, releasing capacity as connections close. This bounds file
|
||||
/// descriptor and memory usage under a connection flood.
|
||||
///
|
||||
/// The cap covers everything on the main listener — S3, admin, console,
|
||||
/// and internode gRPC — so size it well above peer-node count plus the
|
||||
/// expected client concurrency.
|
||||
/// Environment variable: RUSTFS_API_MAX_CONNECTIONS
|
||||
/// Example: RUSTFS_API_MAX_CONNECTIONS=10000
|
||||
pub const ENV_API_MAX_CONNECTIONS: &str = "RUSTFS_API_MAX_CONNECTIONS";
|
||||
|
||||
/// Default for `RUSTFS_API_MAX_CONNECTIONS` (`0` = unlimited).
|
||||
pub const DEFAULT_API_MAX_CONNECTIONS: usize = 0;
|
||||
|
||||
@@ -28,6 +28,15 @@ pub const MAX_ADMIN_REQUEST_BODY_SIZE: usize = 1024 * 1024; // 1 MB
|
||||
/// Rationale: ZIP archives with hundreds of IAM entities. 10MB allows ~10,000 small configs.
|
||||
pub const MAX_IAM_IMPORT_SIZE: usize = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
/// Maximum total size the members of an IAM import ZIP may expand to (100 MB).
|
||||
/// Used for: bounding decompression of `ImportIam` archive members.
|
||||
/// Rationale: `MAX_IAM_IMPORT_SIZE` caps the *compressed* upload only. Deflate
|
||||
/// reaches ratios far above 100:1, so without a separate budget a 10 MB archive
|
||||
/// can expand without bound. 100 MB keeps a 10x headroom over the compressed cap
|
||||
/// — ample for legitimate IAM exports, which are small JSON documents — while
|
||||
/// keeping the worst case bounded.
|
||||
pub const MAX_IAM_IMPORT_EXPANDED_SIZE: u64 = 100 * 1024 * 1024; // 100 MB
|
||||
|
||||
/// Maximum size for bucket metadata import operations (100 MB)
|
||||
/// Used for: Bucket metadata import containing configurations for many buckets
|
||||
/// Rationale: Large deployments may have thousands of buckets with various configs.
|
||||
@@ -54,3 +63,12 @@ pub const MAX_HEAL_REQUEST_SIZE: usize = 1024 * 1024; // 1 MB
|
||||
/// 10MB provides generous headroom for legitimate responses while preventing
|
||||
/// memory exhaustion from malicious or misconfigured remote services.
|
||||
pub const MAX_S3_CLIENT_RESPONSE_SIZE: usize = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
/// Maximum size for OIDC provider response bodies (1 MB)
|
||||
/// Used for: discovery documents, JWKS documents and token endpoint responses
|
||||
/// Rationale: a hostile or compromised identity provider must not be able to exhaust
|
||||
/// memory through an arbitrarily large or endless response body.
|
||||
/// - Discovery documents: typically < 10KB
|
||||
/// - JWKS documents: typically < 50KB
|
||||
/// - Token responses: typically < 10KB
|
||||
pub const MAX_OIDC_RESPONSE_SIZE: usize = 1024 * 1024; // 1 MB
|
||||
|
||||
@@ -39,6 +39,11 @@ pub const DEFAULT_DRIVE_WALKDIR_TIMEOUT_SECS: u64 = 5;
|
||||
pub const ENV_DRIVE_WALKDIR_STALL_TIMEOUT_SECS: &str = "RUSTFS_DRIVE_WALKDIR_STALL_TIMEOUT_SECS";
|
||||
pub const DEFAULT_DRIVE_WALKDIR_STALL_TIMEOUT_SECS: u64 = 5;
|
||||
|
||||
/// Maximum time the metacache merge consumer waits for the next visible
|
||||
/// `walk_dir()` entry from a reader before detaching it from the merge.
|
||||
pub const ENV_DRIVE_WALKDIR_PEEK_TIMEOUT_SECS: &str = "RUSTFS_DRIVE_WALKDIR_PEEK_TIMEOUT_SECS";
|
||||
pub const DEFAULT_DRIVE_WALKDIR_PEEK_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
/// Interval in seconds between active health probes for local and remote drives.
|
||||
pub const ENV_DRIVE_ACTIVE_CHECK_INTERVAL_SECS: &str = "RUSTFS_DRIVE_ACTIVE_CHECK_INTERVAL_SECS";
|
||||
pub const DEFAULT_DRIVE_ACTIVE_CHECK_INTERVAL_SECS: u64 = 15;
|
||||
|
||||
@@ -97,19 +97,80 @@ pub const ENV_INTERNODE_RPC_MAX_MESSAGE_SIZE: &str = "RUSTFS_INTERNODE_RPC_MAX_M
|
||||
pub const ENV_INTERNODE_RPC_LARGE_PAYLOAD_WARN_BYTES: &str = "RUSTFS_INTERNODE_RPC_LARGE_PAYLOAD_WARN_BYTES";
|
||||
pub const DEFAULT_INTERNODE_RPC_LARGE_PAYLOAD_WARN_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// Stop dual-writing the JSON compatibility strings on internode metadata RPCs and send only the
|
||||
/// Request stopping the JSON compatibility strings on internode metadata RPCs and sending only the
|
||||
/// msgpack `_bin` payloads (grpc-optimization P2-1).
|
||||
///
|
||||
/// Defaults to `false` (dual-write, byte-for-byte legacy behavior). This is a rollout lever, not a
|
||||
/// wire-format change: it may only be enabled **after** the JSON-fallback counter
|
||||
/// (`rustfs_system_network_internode_msgpack_json_fallback_total`) has read zero across a release
|
||||
/// window fleet-wide, confirming every peer decodes `_bin` first. Single-env rollback. See
|
||||
/// Defaults to `false` (dual-write, byte-for-byte legacy behavior). This is only a request; RustFS
|
||||
/// keeps JSON compatibility fields unless [`ENV_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED`] is also
|
||||
/// true after the release-window convergence and rollback gates pass. See
|
||||
/// `docs/operations/internode-msgpack-json-convergence-runbook.md`.
|
||||
pub const ENV_INTERNODE_RPC_MSGPACK_ONLY: &str = "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY";
|
||||
pub const DEFAULT_INTERNODE_RPC_MSGPACK_ONLY: bool = false;
|
||||
|
||||
// Compile-time invariant: dual-write by default so the base build is byte-for-byte legacy behavior.
|
||||
/// Explicit fleet-wide confirmation gate for [`ENV_INTERNODE_RPC_MSGPACK_ONLY`].
|
||||
///
|
||||
/// This separate default-off guard prevents a single legacy flag from accidentally emptying JSON
|
||||
/// fields in a mixed-version fleet where an older peer still reads the JSON field.
|
||||
pub const ENV_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED: &str = "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED";
|
||||
pub const DEFAULT_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED: bool = false;
|
||||
|
||||
// Compile-time invariants: dual-write by default so the base build is byte-for-byte legacy behavior.
|
||||
const _: () = assert!(!DEFAULT_INTERNODE_RPC_MSGPACK_ONLY);
|
||||
const _: () = assert!(!DEFAULT_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED);
|
||||
|
||||
/// Require target-bound v2 signatures on every internode gRPC request, rejecting the legacy
|
||||
/// constant-target fallback instead of accepting it (<https://github.com/rustfs/backlog/issues/1327>).
|
||||
///
|
||||
/// Defaults to `false` (fail-open): a request without any v2 auth headers keeps authenticating
|
||||
/// through the legacy signature, so legacy-only peers survive rolling upgrades with byte-for-byte
|
||||
/// the pre-gate acceptance behavior. This is a rollout lever, not a wire-format change: it may only
|
||||
/// be enabled **after** the v1-fallback counter
|
||||
/// (`rustfs_system_network_internode_signature_v1_fallback_total`) has read zero across a release
|
||||
/// window fleet-wide, confirming every peer already sends v2 authentication on every internode gRPC
|
||||
/// request. Single-env rollback. Requests that do carry v2 headers are unaffected by this switch:
|
||||
/// they are always verified as v2 with no downgrade, strict or not.
|
||||
pub const ENV_INTERNODE_RPC_SIGNATURE_STRICT: &str = "RUSTFS_INTERNODE_RPC_SIGNATURE_STRICT";
|
||||
pub const DEFAULT_INTERNODE_RPC_SIGNATURE_STRICT: bool = false;
|
||||
|
||||
// Compile-time invariant: fail-open by default so legacy-only peers keep authenticating during
|
||||
// rolling upgrades until the fleet-wide v1-fallback counter reads zero.
|
||||
const _: () = assert!(!DEFAULT_INTERNODE_RPC_SIGNATURE_STRICT);
|
||||
|
||||
/// Require a signature-bound canonical body digest on every mutating internode disk RPC
|
||||
/// (RenameData, DeleteVersion, DeleteVersions, WriteMetadata, UpdateMetadata, WriteAll, Delete,
|
||||
/// DeletePaths, RenameFile, RenamePart, DeleteVolume, MakeVolume, MakeVolumes), rejecting requests
|
||||
/// that authenticate without one (<https://github.com/rustfs/backlog/issues/1327>).
|
||||
///
|
||||
/// Defaults to `false` (fail-open): a mutating request without a body digest keeps authenticating
|
||||
/// through the method-bound v2 (or legacy) signature, so peers from releases that predate
|
||||
/// body-digest signing survive rolling upgrades unchanged. Requests that do carry a digest are
|
||||
/// always verified with no downgrade, strict or not — the digest value is part of the signed v2
|
||||
/// scope, so an on-path attacker cannot strip it without invalidating the signature. This is a
|
||||
/// rollout lever gated on the body-digest fallback counter
|
||||
/// (`rustfs_system_network_internode_body_digest_fallback_total`) reading zero across a release
|
||||
/// window fleet-wide. Single-env rollback. It is deliberately separate from
|
||||
/// [`ENV_INTERNODE_RPC_SIGNATURE_STRICT`]: the two enforcement flips converge on different
|
||||
/// counters and must not gate each other.
|
||||
pub const ENV_INTERNODE_RPC_BODY_DIGEST_STRICT: &str = "RUSTFS_INTERNODE_RPC_BODY_DIGEST_STRICT";
|
||||
pub const DEFAULT_INTERNODE_RPC_BODY_DIGEST_STRICT: bool = false;
|
||||
|
||||
// Compile-time invariant: fail-open by default so digestless peers keep authenticating during
|
||||
// rolling upgrades until the fleet-wide body-digest fallback counter reads zero.
|
||||
const _: () = assert!(!DEFAULT_INTERNODE_RPC_BODY_DIGEST_STRICT);
|
||||
|
||||
/// Capacity (distinct nonces) of the process-local internode RPC replay cache that enforces
|
||||
/// one-time consumption of body-bound v2 signatures.
|
||||
///
|
||||
/// The cache retains each nonce for the ~10-minute signature freshness envelope, so the steady
|
||||
/// state holds roughly `mutating RPS x 601s` entries; the default sustains ~1,700 body-bound
|
||||
/// mutating RPCs per second (about 120 MiB worst case, allocated only under sustained load).
|
||||
/// Overflow fails closed — legitimate signed traffic is the only thing that can fill the cache
|
||||
/// (replays are rejected before insertion, and an attacker cannot mint valid nonces without the
|
||||
/// shared secret) — and increments
|
||||
/// `rustfs_system_network_internode_replay_cache_overflow_total`, so a sustained non-zero overflow
|
||||
/// counter means this capacity is undersized for the node's peak mutation rate.
|
||||
pub const ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY: &str = "RUSTFS_INTERNODE_RPC_REPLAY_CACHE_CAPACITY";
|
||||
pub const DEFAULT_INTERNODE_RPC_REPLAY_CACHE_CAPACITY: usize = 1_048_576;
|
||||
|
||||
/// Consecutive-failure threshold after which an internode peer is marked offline (grpc-optimization
|
||||
/// P3 observability).
|
||||
@@ -273,8 +334,30 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn internode_msgpack_only_env_name_is_stable() {
|
||||
// The dual-write-by-default invariant is asserted at compile time next to the definition.
|
||||
// The dual-write-by-default invariants are asserted at compile time next to the definitions.
|
||||
assert_eq!(ENV_INTERNODE_RPC_MSGPACK_ONLY, "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY");
|
||||
assert_eq!(
|
||||
ENV_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED,
|
||||
"RUSTFS_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internode_signature_strict_env_name_is_stable() {
|
||||
// The fail-open default invariant is asserted at compile time next to the definition.
|
||||
assert_eq!(ENV_INTERNODE_RPC_SIGNATURE_STRICT, "RUSTFS_INTERNODE_RPC_SIGNATURE_STRICT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internode_body_digest_strict_env_name_is_stable() {
|
||||
// The fail-open default invariant is asserted at compile time next to the definition.
|
||||
assert_eq!(ENV_INTERNODE_RPC_BODY_DIGEST_STRICT, "RUSTFS_INTERNODE_RPC_BODY_DIGEST_STRICT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internode_replay_cache_capacity_defaults_and_env_name() {
|
||||
assert_eq!(ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY, "RUSTFS_INTERNODE_RPC_REPLAY_CACHE_CAPACITY");
|
||||
assert_eq!(DEFAULT_INTERNODE_RPC_REPLAY_CACHE_CAPACITY, 1_048_576);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -116,6 +116,27 @@ pub const ENV_OBJECT_GET_SKIP_BITROT_VERIFY: &str = "RUSTFS_OBJECT_GET_SKIP_BITR
|
||||
/// Default: bitrot verification is enabled on GetObject reads (do not skip).
|
||||
pub const DEFAULT_OBJECT_GET_SKIP_BITROT_VERIFY: bool = false;
|
||||
|
||||
/// Request writing the complete remote-tier version state into object metadata.
|
||||
///
|
||||
/// This remains ineffective until
|
||||
/// [`ENV_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED`] is also enabled.
|
||||
pub const ENV_TIER_REMOTE_VERSION_STATE_WRITE: &str = "RUSTFS_TIER_REMOTE_VERSION_STATE_WRITE";
|
||||
pub const DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE: bool = false;
|
||||
|
||||
/// Operator-attested fleet-wide confirmation for
|
||||
/// [`ENV_TIER_REMOTE_VERSION_STATE_WRITE`].
|
||||
///
|
||||
/// This flag is an operational contract, not automatic capability discovery.
|
||||
/// Operators may enable it only after every node that can write or read
|
||||
/// transitioned object metadata supports the remote version-state schema and
|
||||
/// semantics. Keeping the confirmation separate makes a single-node request or
|
||||
/// a writer whose local opt-in is removed fail closed.
|
||||
pub const ENV_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED: &str = "RUSTFS_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED";
|
||||
pub const DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED: bool = false;
|
||||
|
||||
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE);
|
||||
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED);
|
||||
|
||||
// =============================================================================
|
||||
// Concurrent Request Fix - Timeout and Backpressure Configuration
|
||||
// =============================================================================
|
||||
@@ -617,3 +638,15 @@ pub const ENV_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY: &str = "RUSTFS_OBJ
|
||||
|
||||
/// Default read-ahead disable concurrency threshold: 4.
|
||||
pub const DEFAULT_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY: usize = 4;
|
||||
|
||||
#[cfg(test)]
|
||||
mod remote_version_state_tests {
|
||||
#[test]
|
||||
fn remote_version_state_gate_uses_stable_environment_names() {
|
||||
assert_eq!(super::ENV_TIER_REMOTE_VERSION_STATE_WRITE, "RUSTFS_TIER_REMOTE_VERSION_STATE_WRITE");
|
||||
assert_eq!(
|
||||
super::ENV_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED,
|
||||
"RUSTFS_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
// OIDC configuration field keys (used in KVS)
|
||||
pub const OIDC_CONFIG_URL: &str = "config_url";
|
||||
pub const OIDC_ISSUER: &str = "issuer";
|
||||
pub const OIDC_CLIENT_ID: &str = "client_id";
|
||||
pub const OIDC_CLIENT_SECRET: &str = "client_secret";
|
||||
pub const OIDC_SCOPES: &str = "scopes";
|
||||
@@ -33,6 +34,7 @@ pub const OIDC_HIDE_FROM_UI: &str = "hide_from_ui";
|
||||
// Environment variable names for OIDC
|
||||
pub const ENV_IDENTITY_OPENID_ENABLE: &str = "RUSTFS_IDENTITY_OPENID_ENABLE";
|
||||
pub const ENV_IDENTITY_OPENID_CONFIG_URL: &str = "RUSTFS_IDENTITY_OPENID_CONFIG_URL";
|
||||
pub const ENV_IDENTITY_OPENID_ISSUER: &str = "RUSTFS_IDENTITY_OPENID_ISSUER";
|
||||
pub const ENV_IDENTITY_OPENID_CLIENT_ID: &str = "RUSTFS_IDENTITY_OPENID_CLIENT_ID";
|
||||
pub const ENV_IDENTITY_OPENID_CLIENT_SECRET: &str = "RUSTFS_IDENTITY_OPENID_CLIENT_SECRET";
|
||||
pub const ENV_IDENTITY_OPENID_SCOPES: &str = "RUSTFS_IDENTITY_OPENID_SCOPES";
|
||||
@@ -50,9 +52,10 @@ pub const ENV_IDENTITY_OPENID_USERNAME_CLAIM: &str = "RUSTFS_IDENTITY_OPENID_USE
|
||||
pub const ENV_IDENTITY_OPENID_HIDE_FROM_UI: &str = "RUSTFS_IDENTITY_OPENID_HIDE_FROM_UI";
|
||||
|
||||
/// List of all environment variable keys for an OIDC provider.
|
||||
pub const ENV_IDENTITY_OPENID_KEYS: &[&str; 17] = &[
|
||||
pub const ENV_IDENTITY_OPENID_KEYS: &[&str; 18] = &[
|
||||
ENV_IDENTITY_OPENID_ENABLE,
|
||||
ENV_IDENTITY_OPENID_CONFIG_URL,
|
||||
ENV_IDENTITY_OPENID_ISSUER,
|
||||
ENV_IDENTITY_OPENID_CLIENT_ID,
|
||||
ENV_IDENTITY_OPENID_CLIENT_SECRET,
|
||||
ENV_IDENTITY_OPENID_SCOPES,
|
||||
@@ -74,6 +77,7 @@ pub const ENV_IDENTITY_OPENID_KEYS: &[&str; 17] = &[
|
||||
pub const IDENTITY_OPENID_KEYS: &[&str] = &[
|
||||
crate::ENABLE_KEY,
|
||||
OIDC_CONFIG_URL,
|
||||
OIDC_ISSUER,
|
||||
OIDC_CLIENT_ID,
|
||||
OIDC_CLIENT_SECRET,
|
||||
OIDC_SCOPES,
|
||||
|
||||
@@ -57,6 +57,7 @@ pub const ENV_WEBDAV_CERTS_DIR: &str = "RUSTFS_WEBDAV_CERTS_DIR";
|
||||
pub const ENV_WEBDAV_CA_FILE: &str = "RUSTFS_WEBDAV_CA_FILE";
|
||||
pub const ENV_WEBDAV_MAX_BODY_SIZE: &str = "RUSTFS_WEBDAV_MAX_BODY_SIZE";
|
||||
pub const ENV_WEBDAV_REQUEST_TIMEOUT: &str = "RUSTFS_WEBDAV_REQUEST_TIMEOUT";
|
||||
pub const ENV_WEBDAV_MAX_CONNECTIONS: &str = "RUSTFS_WEBDAV_MAX_CONNECTIONS";
|
||||
|
||||
/// Default SFTP server bind address.
|
||||
pub const DEFAULT_SFTP_ADDRESS: &str = "0.0.0.0:2222";
|
||||
|
||||
@@ -220,12 +220,10 @@ pub const ENV_SCANNER_YIELD_EVERY_N_OBJECTS: &str = "RUSTFS_SCANNER_YIELD_EVERY_
|
||||
pub const DEFAULT_SCANNER_IDLE_MODE: bool = true;
|
||||
|
||||
/// Default set scan concurrency budget.
|
||||
/// `0` means no additional limit beyond deployment topology.
|
||||
pub const DEFAULT_SCANNER_MAX_CONCURRENT_SET_SCANS: usize = 0;
|
||||
pub const DEFAULT_SCANNER_MAX_CONCURRENT_SET_SCANS: usize = 4;
|
||||
|
||||
/// Default disk scan concurrency budget.
|
||||
/// `0` means no additional limit beyond available disks in the set.
|
||||
pub const DEFAULT_SCANNER_MAX_CONCURRENT_DISK_SCANS: usize = 0;
|
||||
pub const DEFAULT_SCANNER_MAX_CONCURRENT_DISK_SCANS: usize = 4;
|
||||
|
||||
/// Default object interval for cooperative scanner yields.
|
||||
pub const DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS: u64 = 128;
|
||||
|
||||
@@ -142,6 +142,10 @@ pub const DEFAULT_H2_KEEP_ALIVE_TIMEOUT: u64 = 10;
|
||||
/// proxy's upstream idle-keepalive, or lower the proxy's keepalive below this
|
||||
/// value. Environments that expose RustFS directly to untrusted slow clients and
|
||||
/// want tighter slowloris protection can lower it via the env var below.
|
||||
///
|
||||
/// The same budget bounds the TLS handshake on the listener, so an unauthenticated
|
||||
/// peer cannot park an accept task and its socket indefinitely by opening a
|
||||
/// connection and then stalling the handshake.
|
||||
pub const ENV_HTTP1_HEADER_READ_TIMEOUT: &str = "RUSTFS_HTTP1_HEADER_READ_TIMEOUT";
|
||||
pub const DEFAULT_HTTP1_HEADER_READ_TIMEOUT: u64 = 75;
|
||||
|
||||
|
||||
@@ -56,3 +56,33 @@ pub const DEFAULT_OBJECT_MMAP_READ_ENABLE: bool = true;
|
||||
///
|
||||
/// Prefer [`DEFAULT_OBJECT_MMAP_READ_ENABLE`].
|
||||
pub const DEFAULT_OBJECT_ZERO_COPY_ENABLE: bool = DEFAULT_OBJECT_MMAP_READ_ENABLE;
|
||||
|
||||
/// Environment variable capping the byte length a single mmap-copy read may
|
||||
/// materialize in memory.
|
||||
///
|
||||
/// The mmap-copy read path returns the whole requested range as one owned
|
||||
/// allocation before the first byte is served. GET/heal shard reads request
|
||||
/// the entire part span in one call, so for a large single-part object
|
||||
/// (e.g. a multi-gigabyte non-multipart upload) an uncapped mmap-copy read
|
||||
/// allocates the whole shard in memory — stalling first-byte latency past the
|
||||
/// disk-read timeout and OOM-killing memory-limited deployments
|
||||
/// (<https://github.com/rustfs/rustfs/issues/5123>). Reads longer than this
|
||||
/// cap fall back to the bounded streaming reader instead.
|
||||
///
|
||||
/// - Purpose: Bound per-shard-read memory for mmap-based reads
|
||||
/// - Acceptable values: byte count as an unsigned integer; `0` disables
|
||||
/// mmap-copy for all non-empty reads (every read streams)
|
||||
/// - Example: `export RUSTFS_OBJECT_MMAP_READ_MAX_LENGTH=8388608`
|
||||
pub const ENV_OBJECT_MMAP_READ_MAX_LENGTH: &str = "RUSTFS_OBJECT_MMAP_READ_MAX_LENGTH";
|
||||
|
||||
/// Default mmap-copy read length cap: 32 MiB per shard read.
|
||||
///
|
||||
/// Large enough that typical multipart part shards (parts up to a few hundred
|
||||
/// megabytes across the erasure set) keep the mmap fast path, small enough
|
||||
/// that whole-part reads of huge single-part objects stream instead of
|
||||
/// materializing gigabytes per shard.
|
||||
///
|
||||
/// The cap bounds memory per shard reader, so a single part read can still
|
||||
/// materialize up to `data_shards x cap` bytes; raising the cap raises that
|
||||
/// per-request bound proportionally.
|
||||
pub const DEFAULT_OBJECT_MMAP_READ_MAX_LENGTH: usize = 32 * 1024 * 1024;
|
||||
|
||||
@@ -260,13 +260,10 @@ fn resolve_rpc_secret(env_secret: Option<&str>, global_access: Option<&str>, glo
|
||||
|
||||
match (global_access, global_secret) {
|
||||
(Some(access_key), Some(secret_key)) => {
|
||||
// Fail closed: never derive the RPC secret while the default secret
|
||||
// key is in effect. The derivation uses `secret_key` as the HMAC key,
|
||||
// so a public default secret yields a publicly computable RPC secret
|
||||
// that any network peer can use to forge internode RPC signatures.
|
||||
// Operators running with default credentials must configure
|
||||
// RUSTFS_RPC_SECRET (or set a non-default RUSTFS_SECRET_KEY) instead.
|
||||
if secret_key.trim() == DEFAULT_SECRET_KEY {
|
||||
// Fail closed when either half of the active credential pair still
|
||||
// uses the public default. Operators must configure both custom
|
||||
// credentials or provide RUSTFS_RPC_SECRET explicitly.
|
||||
if access_key.trim() == DEFAULT_ACCESS_KEY || secret_key.trim() == DEFAULT_SECRET_KEY {
|
||||
return None;
|
||||
}
|
||||
derive_rpc_secret(access_key, secret_key)
|
||||
@@ -589,18 +586,11 @@ mod tests {
|
||||
fn test_resolve_rpc_secret_rejects_default_credentials_for_derivation() {
|
||||
assert!(resolve_rpc_secret(None, None, None).is_none());
|
||||
|
||||
// Fail closed: the default secret key must not yield a derivable RPC
|
||||
// secret, otherwise the derived value is publicly computable and any
|
||||
// network peer can forge internode RPC signatures.
|
||||
// Fail closed when either half of the credential pair uses the public
|
||||
// default.
|
||||
assert!(resolve_rpc_secret(None, Some(DEFAULT_ACCESS_KEY), Some(DEFAULT_SECRET_KEY)).is_none());
|
||||
|
||||
// A default access key paired with a non-default secret key is still
|
||||
// safe to derive: the HMAC key (the secret key) is not public.
|
||||
let expected = derive_rpc_secret(DEFAULT_ACCESS_KEY, "custom-global-secret").expect("secret should derive");
|
||||
assert_eq!(
|
||||
resolve_rpc_secret(None, Some(DEFAULT_ACCESS_KEY), Some("custom-global-secret")).as_deref(),
|
||||
Some(expected.as_str())
|
||||
);
|
||||
assert!(resolve_rpc_secret(None, Some(DEFAULT_ACCESS_KEY), Some("custom-global-secret")).is_none());
|
||||
assert!(resolve_rpc_secret(None, Some("custom-access"), Some(DEFAULT_SECRET_KEY)).is_none());
|
||||
|
||||
assert!(resolve_rpc_secret(Some(DEFAULT_SECRET_KEY), Some("custom-access"), Some("custom-global-secret")).is_none());
|
||||
}
|
||||
@@ -635,6 +625,10 @@ mod tests {
|
||||
resolve_rpc_secret(Some("custom-rpc-secret"), None, None).as_deref(),
|
||||
Some("custom-rpc-secret")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_rpc_secret(Some("custom-rpc-secret"), Some(DEFAULT_ACCESS_KEY), Some(DEFAULT_SECRET_KEY)).as_deref(),
|
||||
Some("custom-rpc-secret")
|
||||
);
|
||||
let expected = derive_rpc_secret("custom-access", "custom-global-secret").expect("secret should derive");
|
||||
assert_eq!(
|
||||
resolve_rpc_secret(None, Some("custom-access"), Some("custom-global-secret")).as_deref(),
|
||||
|
||||
@@ -18,9 +18,41 @@ use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
path::Path,
|
||||
time::SystemTime,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
/// Maximum amount a persisted `last_update` may lead the local wall clock before the
|
||||
/// persisted timestamp is treated as untrustworthy.
|
||||
///
|
||||
/// Invariant: the "skip stale usage update" monotonicity check (incoming `last_update`
|
||||
/// <= existing `last_update` => skip persisting) is only valid while the existing
|
||||
/// timestamp could plausibly have been produced by a healthy clock. If the on-disk
|
||||
/// snapshot is future-dated beyond this tolerance (NTP step-back, or scanner
|
||||
/// leadership moving to a node with a slower clock), the comparison would skip every
|
||||
/// save forever and freeze admin usage stats; callers must bypass the skip instead.
|
||||
pub const USAGE_LAST_UPDATE_FUTURE_TOLERANCE: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
/// Cluster-wide usage snapshot written by coordinated scanners.
|
||||
///
|
||||
/// `usage_snapshot_complete` is an additive JSON field: older readers ignore
|
||||
/// it, while current readers treat snapshots from older writers as unknown.
|
||||
/// Keeping the existing object name preserves rolling-upgrade and rollback
|
||||
/// compatibility without allowing an ambiguous snapshot to become authoritative.
|
||||
pub const DATA_USAGE_OBJECT_NAME: &str = ".usage.v2.json";
|
||||
|
||||
/// Usage snapshot written by scanner implementations predating distributed
|
||||
/// leadership fencing. It is read only when neither authoritative snapshot
|
||||
/// copy exists.
|
||||
// RUSTFS_COMPAT_TODO(scanner-usage-v2): keep .usage.json readable and removable during rolling upgrades from pre-v2 scanners. Remove after supported direct-upgrade sources all write .usage.v2.json.
|
||||
pub const LEGACY_DATA_USAGE_OBJECT_NAME: &str = ".usage.json";
|
||||
|
||||
/// Returns true when `existing_last_update` is ahead of `now` by more than
|
||||
/// [`USAGE_LAST_UPDATE_FUTURE_TOLERANCE`], i.e. the persisted timestamp cannot be
|
||||
/// trusted for staleness comparisons and a fresh snapshot save must be allowed.
|
||||
pub fn usage_last_update_is_untrusted_future(existing_last_update: SystemTime, now: SystemTime) -> bool {
|
||||
existing_last_update > now + USAGE_LAST_UPDATE_FUTURE_TOLERANCE
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TierStats {
|
||||
pub total_size: u64,
|
||||
@@ -77,7 +109,7 @@ impl AllTierStats {
|
||||
}
|
||||
|
||||
/// Bucket target usage info provides replication statistics
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct BucketTargetUsageInfo {
|
||||
pub replication_pending_size: u64,
|
||||
pub replication_failed_size: u64,
|
||||
@@ -89,7 +121,7 @@ pub struct BucketTargetUsageInfo {
|
||||
}
|
||||
|
||||
/// Bucket usage info provides bucket-level statistics
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct BucketUsageInfo {
|
||||
pub size: u64,
|
||||
// Following five fields suffixed with V1 are here for backward compatibility
|
||||
@@ -115,7 +147,7 @@ pub struct BucketUsageInfo {
|
||||
}
|
||||
|
||||
/// DataUsageInfo represents data usage stats of the underlying storage
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DataUsageInfo {
|
||||
/// Total capacity
|
||||
pub total_capacity: u64,
|
||||
@@ -127,6 +159,22 @@ pub struct DataUsageInfo {
|
||||
/// LastUpdate is the timestamp of when the data usage info was last updated
|
||||
pub last_update: Option<SystemTime>,
|
||||
|
||||
/// Monotonic scanner cycle that produced this complete snapshot.
|
||||
///
|
||||
/// Older snapshots omit this field and continue to use `last_update` for
|
||||
/// compatibility. New scanner snapshots use the cycle to fence stale
|
||||
/// leaders independently of wall-clock skew.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub scanner_cycle: Option<u64>,
|
||||
|
||||
/// Persisted scanner leadership epoch that produced this snapshot.
|
||||
///
|
||||
/// The epoch is claimed through the cycle-state CAS before scanning. It
|
||||
/// orders snapshots from different leaders even when their wall clocks or
|
||||
/// cycle counters coincide.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub scanner_epoch: Option<u64>,
|
||||
|
||||
/// Objects total count across all buckets
|
||||
pub objects_total_count: u64,
|
||||
/// Versions total count across all buckets
|
||||
@@ -142,6 +190,12 @@ pub struct DataUsageInfo {
|
||||
pub buckets_count: u64,
|
||||
/// Buckets usage info provides following information across all buckets
|
||||
pub buckets_usage: HashMap<String, BucketUsageInfo>,
|
||||
/// Whether this snapshot covers the complete bucket namespace.
|
||||
///
|
||||
/// Legacy snapshots default to `false`. A complete snapshot contains an
|
||||
/// explicit entry for every bucket, including confirmed-empty buckets.
|
||||
#[serde(default)]
|
||||
pub usage_snapshot_complete: bool,
|
||||
/// Deprecated kept here for backward compatibility reasons
|
||||
pub bucket_sizes: HashMap<String, u64>,
|
||||
/// Per-disk snapshot information when available
|
||||
@@ -150,7 +204,7 @@ pub struct DataUsageInfo {
|
||||
}
|
||||
|
||||
/// Metadata describing the status of a disk-level data usage snapshot.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DiskUsageStatus {
|
||||
pub disk_id: String,
|
||||
pub pool_index: Option<usize>,
|
||||
@@ -250,12 +304,30 @@ impl DataUsageHash {
|
||||
pub type DataUsageHashMap = HashSet<String>;
|
||||
|
||||
/// Size histogram for object size distribution
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
const SIZE_HISTOGRAM_LEN: usize = 11;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct SizeHistogram(Vec<u64>);
|
||||
|
||||
impl Default for SizeHistogram {
|
||||
fn default() -> Self {
|
||||
Self(vec![0; 11]) // DATA_USAGE_BUCKET_LEN = 11
|
||||
Self(vec![0; SIZE_HISTOGRAM_LEN])
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SizeHistogram {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let values = Vec::<u64>::deserialize(deserializer)?;
|
||||
if values.len() != SIZE_HISTOGRAM_LEN {
|
||||
return Err(serde::de::Error::invalid_length(
|
||||
values.len(),
|
||||
&"exactly 11 object-size histogram buckets",
|
||||
));
|
||||
}
|
||||
Ok(Self(values))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,7 +397,7 @@ impl SizeHistogram {
|
||||
.zip(names.iter())
|
||||
.filter(|((_, (start, end)), name)| name != &&"BETWEEN_1024B_AND_1_MB" && *start >= 1024 && *end < ONE_MIB)
|
||||
.map(|((count, _), _)| *count)
|
||||
.sum();
|
||||
.fold(0, u64::saturating_add);
|
||||
|
||||
let mut res = HashMap::new();
|
||||
for (count, name) in self.0.iter().zip(names.iter()) {
|
||||
@@ -346,12 +418,30 @@ impl SizeHistogram {
|
||||
}
|
||||
|
||||
/// Versions histogram for version count distribution
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
const VERSIONS_HISTOGRAM_LEN: usize = 7;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct VersionsHistogram(Vec<u64>);
|
||||
|
||||
impl Default for VersionsHistogram {
|
||||
fn default() -> Self {
|
||||
Self(vec![0; 7]) // DATA_USAGE_VERSION_LEN = 7
|
||||
Self(vec![0; VERSIONS_HISTOGRAM_LEN])
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for VersionsHistogram {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let values = Vec::<u64>::deserialize(deserializer)?;
|
||||
if values.len() != VERSIONS_HISTOGRAM_LEN {
|
||||
return Err(serde::de::Error::invalid_length(
|
||||
values.len(),
|
||||
&"exactly 7 object-version histogram buckets",
|
||||
));
|
||||
}
|
||||
Ok(Self(values))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,13 +607,74 @@ impl DataUsageEntry {
|
||||
}
|
||||
}
|
||||
|
||||
for (i, v) in other.obj_sizes.0.iter().enumerate() {
|
||||
self.obj_sizes.0[i] += v;
|
||||
}
|
||||
self.obj_sizes.merge_from(&other.obj_sizes);
|
||||
self.obj_versions.merge_from(&other.obj_versions);
|
||||
}
|
||||
|
||||
for (i, v) in other.obj_versions.0.iter().enumerate() {
|
||||
self.obj_versions.0[i] += v;
|
||||
pub fn checked_merge(&mut self, other: &DataUsageEntry) -> bool {
|
||||
let scalar_counts_fit = self.objects.checked_add(other.objects).is_some()
|
||||
&& self.versions.checked_add(other.versions).is_some()
|
||||
&& self.delete_markers.checked_add(other.delete_markers).is_some()
|
||||
&& self.size.checked_add(other.size).is_some()
|
||||
&& self.failed_objects.checked_add(other.failed_objects).is_some();
|
||||
let histograms_fit = self.obj_sizes.0.len() == SIZE_HISTOGRAM_LEN
|
||||
&& other.obj_sizes.0.len() == SIZE_HISTOGRAM_LEN
|
||||
&& self.obj_versions.0.len() == VERSIONS_HISTOGRAM_LEN
|
||||
&& other.obj_versions.0.len() == VERSIONS_HISTOGRAM_LEN
|
||||
&& self
|
||||
.obj_sizes
|
||||
.0
|
||||
.iter()
|
||||
.zip(other.obj_sizes.0.iter())
|
||||
.all(|(left, right)| left.checked_add(*right).is_some())
|
||||
&& self
|
||||
.obj_versions
|
||||
.0
|
||||
.iter()
|
||||
.zip(other.obj_versions.0.iter())
|
||||
.all(|(left, right)| left.checked_add(*right).is_some());
|
||||
let replication_fits = match (&self.replication_stats, &other.replication_stats) {
|
||||
(_, None) | (None, Some(_)) => true,
|
||||
(Some(left), Some(right)) => {
|
||||
left.replica_size.checked_add(right.replica_size).is_some()
|
||||
&& left.replica_count.checked_add(right.replica_count).is_some()
|
||||
&& right.targets.iter().all(|(target, right_stats)| {
|
||||
left.targets.get(target).is_none_or(|left_stats| {
|
||||
left_stats.pending_size.checked_add(right_stats.pending_size).is_some()
|
||||
&& left_stats.replicated_size.checked_add(right_stats.replicated_size).is_some()
|
||||
&& left_stats.failed_size.checked_add(right_stats.failed_size).is_some()
|
||||
&& left_stats.failed_count.checked_add(right_stats.failed_count).is_some()
|
||||
&& left_stats.pending_count.checked_add(right_stats.pending_count).is_some()
|
||||
&& left_stats
|
||||
.missed_threshold_size
|
||||
.checked_add(right_stats.missed_threshold_size)
|
||||
.is_some()
|
||||
&& left_stats
|
||||
.after_threshold_size
|
||||
.checked_add(right_stats.after_threshold_size)
|
||||
.is_some()
|
||||
&& left_stats
|
||||
.missed_threshold_count
|
||||
.checked_add(right_stats.missed_threshold_count)
|
||||
.is_some()
|
||||
&& left_stats
|
||||
.after_threshold_count
|
||||
.checked_add(right_stats.after_threshold_count)
|
||||
.is_some()
|
||||
&& left_stats
|
||||
.replicated_count
|
||||
.checked_add(right_stats.replicated_count)
|
||||
.is_some()
|
||||
})
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
if !scalar_counts_fit || !histograms_fit || !replication_fits {
|
||||
return false;
|
||||
}
|
||||
self.merge(other);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,6 +687,12 @@ pub struct DataUsageCacheInfo {
|
||||
pub skip_healing: bool,
|
||||
#[serde(default)]
|
||||
pub failed_objects: HashMap<String, u64>,
|
||||
/// Whether this per-set cache was produced by a completed scanner pass.
|
||||
///
|
||||
/// Older cache writers omit this field and therefore deserialize as
|
||||
/// incomplete instead of exposing partial set totals as confirmed zeros.
|
||||
#[serde(default)]
|
||||
pub snapshot_complete: bool,
|
||||
}
|
||||
|
||||
/// Data usage cache
|
||||
@@ -855,6 +1012,7 @@ impl DataUsageCache {
|
||||
objects_total_size: flat.size as u64,
|
||||
buckets_count: u64::try_from(buckets.len()).unwrap_or(u64::MAX),
|
||||
buckets_usage,
|
||||
usage_snapshot_complete: self.info.snapshot_complete,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -933,6 +1091,13 @@ impl DataUsageInfo {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Whether this snapshot authoritatively covers every reported bucket.
|
||||
pub fn is_complete_bucket_usage_snapshot(&self) -> bool {
|
||||
self.usage_snapshot_complete
|
||||
&& self.last_update.is_some()
|
||||
&& u64::try_from(self.buckets_usage.len()).ok() == Some(self.buckets_count)
|
||||
}
|
||||
|
||||
/// Add object metadata to data usage statistics
|
||||
pub fn add_object(&mut self, object_path: &str, meta_object: &rustfs_filemeta::MetaObject) {
|
||||
// This method is kept for backward compatibility
|
||||
@@ -1297,6 +1462,51 @@ pub struct CompressionTotalInfo {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LegacyUsageReader {
|
||||
buckets_count: u64,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completeness_marker_is_additive_for_legacy_named_readers() {
|
||||
let current = DataUsageInfo {
|
||||
last_update: Some(SystemTime::UNIX_EPOCH),
|
||||
usage_snapshot_complete: true,
|
||||
..Default::default()
|
||||
};
|
||||
let encoded = rmp_serde::to_vec_named(¤t).expect("encode current data usage snapshot");
|
||||
let legacy: LegacyUsageReader = rmp_serde::from_slice(&encoded).expect("legacy reader should ignore additive fields");
|
||||
|
||||
assert_eq!(legacy.buckets_count, 0);
|
||||
assert!(current.is_complete_bucket_usage_snapshot());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completeness_marker_requires_a_snapshot_timestamp() {
|
||||
let untimestamped = DataUsageInfo {
|
||||
usage_snapshot_complete: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!untimestamped.is_complete_bucket_usage_snapshot());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_usage_last_update_future_tolerance_boundary() {
|
||||
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
|
||||
|
||||
// Within tolerance (including the exact boundary) the timestamp is trusted.
|
||||
assert!(!usage_last_update_is_untrusted_future(now, now));
|
||||
assert!(!usage_last_update_is_untrusted_future(now - Duration::from_secs(60), now));
|
||||
assert!(!usage_last_update_is_untrusted_future(now + USAGE_LAST_UPDATE_FUTURE_TOLERANCE, now));
|
||||
|
||||
// Beyond tolerance the persisted timestamp is untrustworthy.
|
||||
assert!(usage_last_update_is_untrusted_future(
|
||||
now + USAGE_LAST_UPDATE_FUTURE_TOLERANCE + Duration::from_secs(1),
|
||||
now
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_data_usage_info_creation() {
|
||||
let mut info = DataUsageInfo::new();
|
||||
@@ -1361,6 +1571,17 @@ mod tests {
|
||||
assert_eq!(map["BETWEEN_512_KB_AND_1_MB"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_size_histogram_compat_rollup_saturates_on_corrupt_counts() {
|
||||
let mut hist = SizeHistogram::default();
|
||||
hist.0[1] = u64::MAX;
|
||||
hist.0[2] = 1;
|
||||
|
||||
let map = hist.to_map();
|
||||
|
||||
assert_eq!(map["BETWEEN_1024B_AND_1_MB"], u64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_data_usage_cache_merge_adds_missing_child() {
|
||||
let mut base = DataUsageCache::default();
|
||||
@@ -1674,4 +1895,86 @@ mod tests {
|
||||
assert!(cache.find("bucket/large/a").is_some());
|
||||
assert!(cache.find("bucket/large/b").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_merge_rejects_scalar_and_replication_overflow_without_mutation() {
|
||||
let mut entry = DataUsageEntry {
|
||||
objects: usize::MAX,
|
||||
replication_stats: Some(ReplicationAllStats {
|
||||
replica_size: 7,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let other = DataUsageEntry {
|
||||
objects: 1,
|
||||
replication_stats: Some(ReplicationAllStats {
|
||||
replica_size: u64::MAX,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!entry.checked_merge(&other));
|
||||
assert_eq!(entry.objects, usize::MAX);
|
||||
assert_eq!(entry.replication_stats.as_ref().map(|stats| stats.replica_size), Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_merge_accepts_valid_usage() {
|
||||
let mut entry = DataUsageEntry {
|
||||
objects: 2,
|
||||
size: 20,
|
||||
..Default::default()
|
||||
};
|
||||
let other = DataUsageEntry {
|
||||
objects: 3,
|
||||
size: 30,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(entry.checked_merge(&other));
|
||||
assert_eq!(entry.objects, 5);
|
||||
assert_eq!(entry.size, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_deserialization_rejects_noncanonical_lengths() {
|
||||
let invalid_sizes =
|
||||
rmp_serde::to_vec(&vec![0_u64; SIZE_HISTOGRAM_LEN + 1]).expect("encode invalid object-size histogram fixture");
|
||||
let invalid_versions =
|
||||
rmp_serde::to_vec(&vec![0_u64; VERSIONS_HISTOGRAM_LEN - 1]).expect("encode invalid object-version histogram fixture");
|
||||
|
||||
assert!(rmp_serde::from_slice::<SizeHistogram>(&invalid_sizes).is_err());
|
||||
assert!(rmp_serde::from_slice::<VersionsHistogram>(&invalid_versions).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replication_target_deserialization_preserves_large_historical_maps() {
|
||||
let mut stats = ReplicationAllStats::default();
|
||||
for index in 0..=1024 {
|
||||
stats.targets.insert(format!("target-{index}"), ReplicationStats::default());
|
||||
}
|
||||
let encoded = rmp_serde::to_vec_named(&stats).expect("large replication target fixture should encode");
|
||||
let decoded = rmp_serde::from_slice::<ReplicationAllStats>(&encoded)
|
||||
.expect("historical replication target maps must remain readable");
|
||||
|
||||
assert_eq!(decoded.targets.len(), stats.targets.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_merge_rejects_noncanonical_histograms_without_mutation() {
|
||||
let mut entry = DataUsageEntry {
|
||||
objects: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let other = DataUsageEntry {
|
||||
objects: 3,
|
||||
obj_sizes: SizeHistogram(vec![0; SIZE_HISTOGRAM_LEN + 1]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!entry.checked_merge(&other));
|
||||
assert_eq!(entry.objects, 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,9 +30,11 @@ sftp = []
|
||||
|
||||
[dependencies]
|
||||
rustfs-config = { workspace = true, features = ["constants"] }
|
||||
rustfs-credentials.workspace = true
|
||||
rustfs-ecstore.workspace = true
|
||||
rustfs-data-usage.workspace = true
|
||||
rustfs-rio.workspace = true
|
||||
rustfs-utils = { workspace = true, features = ["egress"] }
|
||||
flatbuffers.workspace = true
|
||||
futures.workspace = true
|
||||
rustfs-lock.workspace = true
|
||||
@@ -48,6 +50,7 @@ rustfs-filemeta.workspace = true
|
||||
bytes = { workspace = true, features = ["serde"] }
|
||||
serial_test = { workspace = true }
|
||||
aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
|
||||
aws-sdk-sts = { workspace = true, default-features = false, features = ["default-https-client", "rt-tokio"] }
|
||||
aws-config = { workspace = true }
|
||||
aws-smithy-http-client = { workspace = true, default-features = false, features = ["rustls-aws-lc"] }
|
||||
async-compression = { workspace = true, features = ["tokio", "bzip2", "xz"] }
|
||||
@@ -57,7 +60,7 @@ http.workspace = true
|
||||
http-body-util.workspace = true
|
||||
hyper = { workspace = true, features = ["http2", "http1", "server"] }
|
||||
hyper-util = { workspace = true, features = ["tokio", "server-auto", "server-graceful", "tracing"] }
|
||||
reqwest = { workspace = true, default-features = false, features = ["rustls", "charset", "http2", "system-proxy", "stream", "json", "multipart"] }
|
||||
reqwest = { workspace = true, features = ["json", "multipart", "stream"] }
|
||||
rustfs-signer.workspace = true
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter", "time"] }
|
||||
@@ -68,6 +71,8 @@ base64 = { workspace = true }
|
||||
rand = { workspace = true, features = ["serde"] }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
md5 = { workspace = true }
|
||||
opentelemetry-proto = { workspace = true }
|
||||
prost.workspace = true
|
||||
sha2 = { workspace = true }
|
||||
astral-tokio-tar = { workspace = true }
|
||||
s3s = { workspace = true, features = ["minio"] }
|
||||
|
||||
@@ -46,6 +46,45 @@ mod tests {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const ADMIN_INFO_PATH: &str = "/rustfs/admin/v3/info";
|
||||
const ADMIN_MANUAL_TRANSITION_BUCKET: &str = "auth-deny-manual-transition";
|
||||
const ADMIN_MANUAL_TRANSITION_PATH: &str =
|
||||
"/rustfs/admin/v3/ilm/transition/run?bucket=auth-deny-manual-transition&maxObjects=1&mode=async";
|
||||
|
||||
fn assert_no_raw_manual_transition_markers(body: &str, context: &str) {
|
||||
assert!(
|
||||
!body.contains("\"marker\"") && !body.contains("\"versionMarker\"") && !body.contains("\"version_marker\""),
|
||||
"{context} must not expose raw manual transition resume markers, body: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
async fn wait_for_terminal_manual_transition_job(
|
||||
env: &RustFSTestEnvironment,
|
||||
status_endpoint: &str,
|
||||
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
let (status, body) =
|
||||
signed_request(&env.url, http::Method::GET, status_endpoint, None, &env.access_key, &env.secret_key).await?;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::OK,
|
||||
"root credential must query manual transition job status, body: {body}"
|
||||
);
|
||||
assert_no_raw_manual_transition_markers(&body, "manual transition status response");
|
||||
let value: serde_json::Value = serde_json::from_str(&body)?;
|
||||
let job_status = value
|
||||
.get("status")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or("manual transition job status response must include status")?;
|
||||
if matches!(job_status, "completed" | "partial" | "cancelled" | "failed" | "unknown") {
|
||||
return Ok(body);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(format!("manual transition job did not reach terminal status within 30s; last={body}").into());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a SigV4-signed request to `path` (optionally with a JSON `body`) and
|
||||
/// return `(status, body)`. Uses the `UNSIGNED_PAYLOAD` content hash so a
|
||||
@@ -158,6 +197,130 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn non_admin_credential_denied_on_manual_transition_run() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let user_ak = "ilmtransitionlimited";
|
||||
let user_sk = "ilmtransitionlimitedsecret";
|
||||
create_limited_user(&env, user_ak, user_sk).await?;
|
||||
env.create_s3_client()
|
||||
.create_bucket()
|
||||
.bucket(ADMIN_MANUAL_TRANSITION_BUCKET)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let (root_status, root_body) = signed_request(
|
||||
&env.url,
|
||||
http::Method::POST,
|
||||
ADMIN_MANUAL_TRANSITION_PATH,
|
||||
None,
|
||||
&env.access_key,
|
||||
&env.secret_key,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
root_status,
|
||||
reqwest::StatusCode::ACCEPTED,
|
||||
"root credential must reach the manual transition handler, body: {root_body}"
|
||||
);
|
||||
assert!(
|
||||
root_body.contains("\"mode\":\"durable_job\""),
|
||||
"root response should be the durable manual transition JSON contract, body: {root_body}"
|
||||
);
|
||||
assert_no_raw_manual_transition_markers(&root_body, "manual transition run response");
|
||||
let root_value: serde_json::Value = serde_json::from_str(&root_body)?;
|
||||
let job_id = root_value
|
||||
.get("job_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or("manual transition async response must include job_id")?;
|
||||
let status_endpoint = root_value
|
||||
.get("status_endpoint")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or("manual transition async response must include status_endpoint")?;
|
||||
let cancel_endpoint = root_value
|
||||
.get("cancel_endpoint")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or("manual transition async response must include cancel_endpoint")?;
|
||||
assert_eq!(
|
||||
cancel_endpoint, status_endpoint,
|
||||
"manual transition durable jobs currently use the same status/cancel endpoint"
|
||||
);
|
||||
assert!(
|
||||
status_endpoint.ends_with(job_id),
|
||||
"status endpoint must address the returned job id, job_id={job_id}, status_endpoint={status_endpoint}"
|
||||
);
|
||||
|
||||
let terminal_body = wait_for_terminal_manual_transition_job(&env, status_endpoint).await?;
|
||||
let terminal: serde_json::Value = serde_json::from_str(&terminal_body)?;
|
||||
assert_eq!(terminal.get("job_id").and_then(serde_json::Value::as_str), Some(job_id));
|
||||
assert_eq!(
|
||||
terminal
|
||||
.get("report")
|
||||
.and_then(|report| report.get("bucket"))
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some(ADMIN_MANUAL_TRANSITION_BUCKET)
|
||||
);
|
||||
|
||||
let (root_status, root_body) =
|
||||
signed_request(&env.url, http::Method::DELETE, status_endpoint, None, &env.access_key, &env.secret_key).await?;
|
||||
assert_eq!(
|
||||
root_status,
|
||||
reqwest::StatusCode::OK,
|
||||
"root credential must cancel/query a terminal manual transition job idempotently, body: {root_body}"
|
||||
);
|
||||
assert_no_raw_manual_transition_markers(&root_body, "manual transition cancel response");
|
||||
let root_cancel: serde_json::Value = serde_json::from_str(&root_body)?;
|
||||
assert_eq!(root_cancel.get("job_id").and_then(serde_json::Value::as_str), Some(job_id));
|
||||
assert!(
|
||||
matches!(
|
||||
root_cancel.get("status").and_then(serde_json::Value::as_str),
|
||||
Some("completed" | "partial" | "failed" | "unknown")
|
||||
),
|
||||
"terminal cancel must not rewrite the job into cancelled state, body: {root_body}"
|
||||
);
|
||||
|
||||
let (status, body) =
|
||||
signed_request(&env.url, http::Method::POST, ADMIN_MANUAL_TRANSITION_PATH, None, user_ak, user_sk).await?;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"non-admin credential must get 403 on manual transition run, body: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("AccessDenied"),
|
||||
"manual transition rejection must carry the AccessDenied S3 error code, body: {body}"
|
||||
);
|
||||
let (status, body) = signed_request(&env.url, http::Method::GET, status_endpoint, None, user_ak, user_sk).await?;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"non-admin credential must get 403 on manual transition status, body: {body}"
|
||||
);
|
||||
assert_no_raw_manual_transition_markers(&body, "manual transition status rejection");
|
||||
assert!(
|
||||
body.contains("AccessDenied"),
|
||||
"manual transition status rejection must carry the AccessDenied S3 error code, body: {body}"
|
||||
);
|
||||
let (status, body) = signed_request(&env.url, http::Method::DELETE, status_endpoint, None, user_ak, user_sk).await?;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"non-admin credential must get 403 on manual transition cancel, body: {body}"
|
||||
);
|
||||
assert_no_raw_manual_transition_markers(&body, "manual transition cancel rejection");
|
||||
assert!(
|
||||
body.contains("AccessDenied"),
|
||||
"manual transition cancel rejection must carry the AccessDenied S3 error code, body: {body}"
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rotating the root credentials (restart with new `--access-key` /
|
||||
/// `--secret-key` on the same data directory) takes effect: the new
|
||||
/// credential is accepted and the old one is rejected, on both the S3 data
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||
use http::header::HOST;
|
||||
use reqwest::StatusCode;
|
||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
use rustfs_signer::sign_v4;
|
||||
use s3s::Body;
|
||||
use serde::Deserialize;
|
||||
use std::error::Error;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PoolListItem {
|
||||
id: usize,
|
||||
cmdline: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
async fn signed_admin_get(env: &RustFSTestEnvironment, path: &str) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
|
||||
let url = format!("{}{path}", env.url);
|
||||
let uri = url.parse::<http::Uri>()?;
|
||||
let authority = uri.authority().ok_or("admin URL missing authority")?.to_string();
|
||||
let request = http::Request::builder()
|
||||
.method(http::Method::GET)
|
||||
.uri(uri)
|
||||
.header(HOST, authority)
|
||||
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD)
|
||||
.body(Body::empty())?;
|
||||
let signed = sign_v4(request, 0, &env.access_key, &env.secret_key, "", "us-east-1");
|
||||
|
||||
let mut request = local_http_client().get(&url);
|
||||
for (name, value) in signed.headers() {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
Ok(request.send().await?)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_drive_pools_list_succeeds_without_enabling_decommission_status() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let response = signed_admin_get(&env, "/rustfs/admin/v3/pools/list").await?;
|
||||
let status = response.status();
|
||||
let body = response.bytes().await?;
|
||||
|
||||
assert_eq!(status, StatusCode::OK, "pools list failed: {}", String::from_utf8_lossy(&body));
|
||||
let pools: Vec<PoolListItem> = serde_json::from_slice(&body)?;
|
||||
assert_eq!(pools.len(), 1);
|
||||
assert_eq!(pools[0].id, 0);
|
||||
assert_eq!(pools[0].cmdline, env.temp_dir);
|
||||
assert_eq!(pools[0].status, "active");
|
||||
|
||||
let response = signed_admin_get(&env, "/rustfs/admin/v3/decommission/status").await?;
|
||||
let status = response.status();
|
||||
let body = response.text().await?;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"decommission status changed for a single pool: {body}"
|
||||
);
|
||||
assert!(body.contains("NotImplemented"), "unexpected decommission error body: {body}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -170,3 +170,81 @@ async fn test_anonymous_access_allowed_when_restrict_public_buckets_disabled()
|
||||
info!("Test passed: anonymous access allowed with RestrictPublicBuckets=false");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A policy granting anonymous `s3:ListBucket` also permits ListObjectVersions.
|
||||
/// That grant must still be subject to RestrictPublicBuckets: the versions listing
|
||||
/// reaches authorization through a fallback branch, and that branch has to apply the
|
||||
/// same public-access gate as a direct grant.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn ghsa_x298_anonymous_list_object_versions_denied_when_restrict_public_buckets_enabled()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
info!("Starting test: anonymous ListObjectVersions denied with RestrictPublicBuckets=true...");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let bucket_name = "anon-test-restrict-versions";
|
||||
let admin_client = env.create_s3_client();
|
||||
admin_client.create_bucket().bucket(bucket_name).send().await?;
|
||||
|
||||
let policy_json = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowAnonymousListBucket",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:ListBucket"],
|
||||
"Resource": [format!("arn:aws:s3:::{}", bucket_name)]
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
admin_client
|
||||
.put_bucket_policy()
|
||||
.bucket(bucket_name)
|
||||
.policy(&policy_json)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
admin_client
|
||||
.put_object()
|
||||
.bucket(bucket_name)
|
||||
.key("test.txt")
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"hello anonymous"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// Without the public-access block the fallback grant is expected to work.
|
||||
let versions_url = format!("{}/{}?versions=", env.url, bucket_name);
|
||||
let resp = local_http_client().get(&versions_url).send().await?;
|
||||
assert_eq!(
|
||||
resp.status().as_u16(),
|
||||
200,
|
||||
"Anonymous ListObjectVersions should succeed via the s3:ListBucket grant"
|
||||
);
|
||||
|
||||
admin_client
|
||||
.put_public_access_block()
|
||||
.bucket(bucket_name)
|
||||
.public_access_block_configuration(
|
||||
PublicAccessBlockConfiguration::builder()
|
||||
.restrict_public_buckets(true)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let resp = local_http_client().get(&versions_url).send().await?;
|
||||
assert_eq!(
|
||||
resp.status().as_u16(),
|
||||
403,
|
||||
"Anonymous ListObjectVersions must be denied when RestrictPublicBuckets is true"
|
||||
);
|
||||
|
||||
info!("Test passed: anonymous ListObjectVersions denied with RestrictPublicBuckets=true");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -86,6 +86,52 @@ async fn api_rate_limit_enforces_429_with_retry_after_when_enabled() -> TestResu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn api_rate_limit_bucket_dimension_throttles_per_bucket() -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
// Bucket dimension only: the readiness-poll ListBuckets calls hit "/"
|
||||
// (no bucket) and therefore do not consume any budget.
|
||||
env.start_rustfs_server_with_env(
|
||||
vec![],
|
||||
&[
|
||||
("RUSTFS_API_RATE_LIMIT_ENABLE", "true"),
|
||||
("RUSTFS_API_RATE_LIMIT_BUCKET_RPM", "60"),
|
||||
("RUSTFS_API_RATE_LIMIT_BUCKET_BURST", "5"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let client = local_http_client();
|
||||
|
||||
// Unauthenticated GETs are still counted arrivals (403, not 429, while
|
||||
// within budget); the sixth rapid hit on the same bucket must throttle.
|
||||
let mut throttled = false;
|
||||
for i in 0..6 {
|
||||
let response = client.get(format!("{}/hot-bucket/object-{i}", env.url)).send().await?;
|
||||
if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
throttled = true;
|
||||
assert!(
|
||||
response.headers().contains_key(reqwest::header::RETRY_AFTER),
|
||||
"bucket-dimension 429 must carry Retry-After"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(throttled, "6 rapid requests against burst 5 must trip the bucket dimension");
|
||||
|
||||
// A different bucket has its own budget.
|
||||
let other = client.get(format!("{}/cold-bucket/object", env.url)).send().await?;
|
||||
assert_ne!(
|
||||
other.status(),
|
||||
reqwest::StatusCode::TOO_MANY_REQUESTS,
|
||||
"an unrelated bucket must not be throttled"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn api_rate_limit_stays_inert_by_default() -> TestResult {
|
||||
|
||||
@@ -892,6 +892,7 @@ pub struct RustFSTestClusterEnvironment {
|
||||
pub access_key: String,
|
||||
pub secret_key: String,
|
||||
pub extra_env: Vec<(String, String)>,
|
||||
pub node_extra_env: Vec<Vec<(String, String)>>,
|
||||
pub topology: ClusterTopology,
|
||||
}
|
||||
|
||||
@@ -979,6 +980,7 @@ impl RustFSTestClusterEnvironment {
|
||||
}
|
||||
|
||||
let mut extra_env = Vec::new();
|
||||
extra_env.push(("RUSTFS_RPC_SECRET".to_string(), String::new()));
|
||||
if multidrive {
|
||||
extra_env.push(("RUSTFS_UNSAFE_BYPASS_DISK_CHECK".to_string(), "true".to_string()));
|
||||
}
|
||||
@@ -986,9 +988,10 @@ impl RustFSTestClusterEnvironment {
|
||||
Ok(Self {
|
||||
nodes,
|
||||
temp_dir,
|
||||
access_key: DEFAULT_ACCESS_KEY.to_string(),
|
||||
secret_key: DEFAULT_SECRET_KEY.to_string(),
|
||||
access_key: "rustfs-cluster-test-access".to_string(),
|
||||
secret_key: "rustfs-cluster-test-secret".to_string(),
|
||||
extra_env,
|
||||
node_extra_env: vec![Vec::new(); topology.node_count],
|
||||
topology,
|
||||
})
|
||||
}
|
||||
@@ -1002,6 +1005,22 @@ impl RustFSTestClusterEnvironment {
|
||||
self.extra_env.push((key.into(), value.into()));
|
||||
}
|
||||
|
||||
/// Add an extra environment variable applied to a single cluster node.
|
||||
pub fn set_node_env<K, V>(
|
||||
&mut self,
|
||||
node_idx: usize,
|
||||
key: K,
|
||||
value: V,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
where
|
||||
K: Into<String>,
|
||||
V: Into<String>,
|
||||
{
|
||||
self.ensure_node_index(node_idx)?;
|
||||
self.node_extra_env[node_idx].push((key.into(), value.into()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_node_index(&self, node_idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
if node_idx >= self.nodes.len() {
|
||||
return Err(format!("node_idx {node_idx} is invalid").into());
|
||||
@@ -1088,6 +1107,9 @@ impl RustFSTestClusterEnvironment {
|
||||
for (key, value) in &self.extra_env {
|
||||
command.env(key, value);
|
||||
}
|
||||
for (key, value) in &self.node_extra_env[i] {
|
||||
command.env(key, value);
|
||||
}
|
||||
|
||||
let process = command.current_dir(&node.data_dir).spawn()?;
|
||||
|
||||
@@ -1129,6 +1151,9 @@ impl RustFSTestClusterEnvironment {
|
||||
for (key, value) in &self.extra_env {
|
||||
command.env(key, value);
|
||||
}
|
||||
for (key, value) in &self.node_extra_env[node_idx] {
|
||||
command.env(key, value);
|
||||
}
|
||||
|
||||
let process = command.current_dir(&node.data_dir).spawn()?;
|
||||
node.process = Some(process);
|
||||
@@ -1370,6 +1395,7 @@ mod tests {
|
||||
access_key: DEFAULT_ACCESS_KEY.to_string(),
|
||||
secret_key: DEFAULT_SECRET_KEY.to_string(),
|
||||
extra_env: Vec::new(),
|
||||
node_extra_env: vec![Vec::new(); topology.node_count],
|
||||
topology,
|
||||
}
|
||||
}
|
||||
@@ -1454,4 +1480,24 @@ mod tests {
|
||||
assert!(ClusterTopology::single_pool_multidrive(4, 4).validate().is_ok());
|
||||
assert!(ClusterTopology::single_pool_multidrive(1, 1).validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_node_env_supports_per_node_overrides() {
|
||||
let mut env = fake_cluster(ClusterTopology::single_pool(4));
|
||||
env.set_node_env(2, "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY", "true").unwrap();
|
||||
assert_eq!(
|
||||
env.node_extra_env[2].as_slice(),
|
||||
[("RUSTFS_INTERNODE_RPC_MSGPACK_ONLY".to_string(), "true".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_node_env_rejects_invalid_index() {
|
||||
let mut env = fake_cluster(ClusterTopology::single_pool(4));
|
||||
let err = env
|
||||
.set_node_env(4, "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY", "true")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("invalid"), "unexpected error: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! E2E coverage for the opt-in global connection cap on the main API listener
|
||||
//! (backlog#1191 follow-up, `RUSTFS_API_MAX_CONNECTIONS`): permits must be
|
||||
//! released when connections close (no leak), and the cap must actually bound
|
||||
//! concurrency — a queued connection is served only after a held one closes.
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use serial_test::serial;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::timeout;
|
||||
|
||||
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
/// Open a TCP connection and write one unauthenticated `GET /` (any response,
|
||||
/// e.g. 403, proves the connection was accepted and served).
|
||||
async fn open_and_request(addr: &str, connection: &str) -> std::io::Result<TcpStream> {
|
||||
let mut stream = TcpStream::connect(addr).await?;
|
||||
let request = format!("GET / HTTP/1.1\r\nHost: {addr}\r\nConnection: {connection}\r\n\r\n");
|
||||
stream.write_all(request.as_bytes()).await?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
/// Read until the response head is complete, or `None` on timeout/close —
|
||||
/// a `None` on an open socket means the connection sits unaccepted in the
|
||||
/// kernel backlog behind the cap.
|
||||
async fn read_response_head(stream: &mut TcpStream, dur: Duration) -> Option<String> {
|
||||
let deadline = tokio::time::Instant::now() + dur;
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let mut collected = String::new();
|
||||
loop {
|
||||
let remaining = deadline.checked_duration_since(tokio::time::Instant::now())?;
|
||||
match timeout(remaining, stream.read(&mut buf)).await {
|
||||
Ok(Ok(0)) | Ok(Err(_)) | Err(_) => return None,
|
||||
Ok(Ok(n)) => {
|
||||
collected.push_str(&String::from_utf8_lossy(&buf[..n]));
|
||||
if collected.contains("\r\n\r\n") {
|
||||
return Some(collected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn connection_cap_releases_permits_on_close() -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_API_MAX_CONNECTIONS", "2")])
|
||||
.await?;
|
||||
|
||||
// Ten sequential connections against cap 2: if permits leaked, the third
|
||||
// request would already hang in the backlog and time out.
|
||||
for i in 0..10 {
|
||||
let mut stream = open_and_request(&env.address, "close").await?;
|
||||
let head = read_response_head(&mut stream, Duration::from_secs(10))
|
||||
.await
|
||||
.unwrap_or_else(|| panic!("request {i} got no response — a connection permit leaked"));
|
||||
assert!(head.starts_with("HTTP/1.1"), "request {i} unexpected response: {head}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open a TCP connection and send an INCOMPLETE request head. Once accepted
|
||||
/// it pins a connection permit: hyper waits for the rest of the head (75s
|
||||
/// default header timeout) until we close the socket.
|
||||
async fn open_and_stall(addr: &str) -> std::io::Result<TcpStream> {
|
||||
let mut stream = TcpStream::connect(addr).await?;
|
||||
stream
|
||||
.write_all(format!("GET / HTTP/1.1\r\nHost: {addr}\r\n").as_bytes())
|
||||
.await?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn connection_cap_blocks_excess_connections_until_permits_free() -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_API_MAX_CONNECTIONS", "2")])
|
||||
.await?;
|
||||
// Let the readiness poller's pooled connection close and free its permit.
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
|
||||
// Two stalled connections saturate cap 2 (a served-and-closed connection
|
||||
// would release its permit immediately, so stalling is what makes the
|
||||
// occupancy deterministic).
|
||||
let stalled_a = open_and_stall(&env.address).await?;
|
||||
let stalled_b = open_and_stall(&env.address).await?;
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
|
||||
// A complete request now sits in the kernel backlog: connect() succeeds
|
||||
// but no permit is available, so no response arrives.
|
||||
let mut blocked = open_and_request(&env.address, "close").await?;
|
||||
assert!(
|
||||
read_response_head(&mut blocked, Duration::from_secs(3)).await.is_none(),
|
||||
"cap 2 with two stalled connections must leave the third unserved"
|
||||
);
|
||||
|
||||
// Dropping the stalled connections releases their permits (hyper sees
|
||||
// EOF while reading the head); the queued request's bytes already sit in
|
||||
// the socket buffer, so it must now be accepted and served.
|
||||
drop(stalled_a);
|
||||
drop(stalled_b);
|
||||
let head = read_response_head(&mut blocked, Duration::from_secs(10))
|
||||
.await
|
||||
.expect("queued connection must be served after permits are released");
|
||||
assert!(head.starts_with("HTTP/1.1"), "unexpected response: {head}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -80,6 +80,25 @@ mod tests {
|
||||
assert_eq!(head_resp.content_encoding(), Some("zstd"), "HEAD should return Content-Encoding: zstd");
|
||||
assert_eq!(head_resp.content_type(), Some("text/plain"), "HEAD should return correct Content-Type");
|
||||
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect("DELETE object failed");
|
||||
client
|
||||
.delete_bucket()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("DELETE bucket failed");
|
||||
client
|
||||
.list_buckets()
|
||||
.send()
|
||||
.await
|
||||
.expect("RustFS must remain available after deleting a bucket");
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,708 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! CopyObject checksum compatibility tests. Covers all supported algorithms,
|
||||
//! source-checksum preservation, explicit override, and fail-closed handling of
|
||||
//! unsupported algorithms before destination mutation.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::config::{Credentials, Region, RequestChecksumCalculation};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{
|
||||
BucketVersioningStatus, ChecksumAlgorithm, ChecksumMode, ChecksumType, CompletedMultipartUpload, CompletedPart,
|
||||
VersioningConfiguration,
|
||||
};
|
||||
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
|
||||
use serial_test::serial;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tracing::info;
|
||||
|
||||
async fn create_versioned_bucket(client: &aws_sdk_s3::Client, bucket: &str) {
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create bucket");
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to enable versioning");
|
||||
}
|
||||
|
||||
fn create_s3_client_no_auto_checksum(env: &RustFSTestEnvironment) -> aws_sdk_s3::Client {
|
||||
let credentials = Credentials::new(&env.access_key, &env.secret_key, None, None, "copy-checksum-e2e");
|
||||
let config = aws_sdk_s3::Config::builder()
|
||||
.credentials_provider(credentials)
|
||||
.region(Region::new("us-east-1"))
|
||||
.endpoint_url(format!("http://{}", env.address))
|
||||
.force_path_style(true)
|
||||
.behavior_version_latest()
|
||||
.request_checksum_calculation(RequestChecksumCalculation::WhenRequired)
|
||||
.http_client(SmithyHttpClientBuilder::new().build_http())
|
||||
.build();
|
||||
aws_sdk_s3::Client::from_conf(config)
|
||||
}
|
||||
|
||||
fn algorithms() -> [(ChecksumAlgorithm, RioChecksumType); 10] {
|
||||
[
|
||||
(ChecksumAlgorithm::Crc32, RioChecksumType::CRC32),
|
||||
(ChecksumAlgorithm::Crc32C, RioChecksumType::CRC32C),
|
||||
(ChecksumAlgorithm::Crc64Nvme, RioChecksumType::CRC64_NVME),
|
||||
(ChecksumAlgorithm::Sha1, RioChecksumType::SHA1),
|
||||
(ChecksumAlgorithm::Sha256, RioChecksumType::SHA256),
|
||||
(ChecksumAlgorithm::Md5, RioChecksumType::MD5),
|
||||
(ChecksumAlgorithm::Sha512, RioChecksumType::SHA512),
|
||||
(ChecksumAlgorithm::Xxhash3, RioChecksumType::XXHASH3),
|
||||
(ChecksumAlgorithm::Xxhash64, RioChecksumType::XXHASH64),
|
||||
(ChecksumAlgorithm::Xxhash128, RioChecksumType::XXHASH128),
|
||||
]
|
||||
}
|
||||
|
||||
fn result_checksums(result: &aws_sdk_s3::types::CopyObjectResult) -> [Option<&str>; 10] {
|
||||
[
|
||||
result.checksum_crc32(),
|
||||
result.checksum_crc32_c(),
|
||||
result.checksum_crc64_nvme(),
|
||||
result.checksum_sha1(),
|
||||
result.checksum_sha256(),
|
||||
result.checksum_md5(),
|
||||
result.checksum_sha512(),
|
||||
result.checksum_xxhash3(),
|
||||
result.checksum_xxhash64(),
|
||||
result.checksum_xxhash128(),
|
||||
]
|
||||
}
|
||||
|
||||
fn head_checksums(output: &aws_sdk_s3::operation::head_object::HeadObjectOutput) -> [Option<&str>; 10] {
|
||||
[
|
||||
output.checksum_crc32(),
|
||||
output.checksum_crc32_c(),
|
||||
output.checksum_crc64_nvme(),
|
||||
output.checksum_sha1(),
|
||||
output.checksum_sha256(),
|
||||
output.checksum_md5(),
|
||||
output.checksum_sha512(),
|
||||
output.checksum_xxhash3(),
|
||||
output.checksum_xxhash64(),
|
||||
output.checksum_xxhash128(),
|
||||
]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_copy_supports_all_checksum_algorithms() {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client_no_auto_checksum(&env);
|
||||
let src_bucket = "copy-all-checksums-src";
|
||||
let dst_bucket = "copy-all-checksums-dst";
|
||||
let src_key = "objects/source.bin";
|
||||
let content = b"deterministic CopyObject payload for all ten checksum algorithms";
|
||||
|
||||
create_versioned_bucket(&client, src_bucket).await;
|
||||
create_versioned_bucket(&client, dst_bucket).await;
|
||||
client
|
||||
.put_object()
|
||||
.bucket(src_bucket)
|
||||
.key(src_key)
|
||||
.body(ByteStream::from_static(content))
|
||||
.send()
|
||||
.await
|
||||
.expect("PUT source failed");
|
||||
|
||||
for (index, (sdk_algorithm, rio_algorithm)) in algorithms().into_iter().enumerate() {
|
||||
let expected = Checksum::new_from_data(rio_algorithm, content)
|
||||
.expect("supported checksum must be computable")
|
||||
.encoded;
|
||||
let dst_key = format!("objects/destination-{index}.bin");
|
||||
let copy = client
|
||||
.copy_object()
|
||||
.bucket(dst_bucket)
|
||||
.key(&dst_key)
|
||||
.copy_source(format!("{src_bucket}/{src_key}"))
|
||||
.checksum_algorithm(sdk_algorithm)
|
||||
.send()
|
||||
.await
|
||||
.expect("CopyObject with supported checksum must succeed");
|
||||
let result = copy.copy_object_result().expect("CopyObject result");
|
||||
let checksums = result_checksums(result);
|
||||
assert_eq!(checksums[index], Some(expected.as_str()), "{rio_algorithm}: response checksum");
|
||||
assert_eq!(
|
||||
checksums.iter().filter(|checksum| checksum.is_some()).count(),
|
||||
1,
|
||||
"{rio_algorithm}: only the requested checksum may be returned"
|
||||
);
|
||||
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(dst_bucket)
|
||||
.key(&dst_key)
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD destination failed");
|
||||
let checksums = head_checksums(&head);
|
||||
assert_eq!(checksums[index], Some(expected.as_str()), "{rio_algorithm}: persisted checksum");
|
||||
assert_eq!(
|
||||
checksums.iter().filter(|checksum| checksum.is_some()).count(),
|
||||
1,
|
||||
"{rio_algorithm}: destination must persist only the requested checksum"
|
||||
);
|
||||
|
||||
let body = client
|
||||
.get_object()
|
||||
.bucket(dst_bucket)
|
||||
.key(&dst_key)
|
||||
.send()
|
||||
.await
|
||||
.expect("GET destination failed")
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.expect("collect destination body")
|
||||
.into_bytes();
|
||||
assert_eq!(body.as_ref(), content, "{rio_algorithm}: full copied body");
|
||||
}
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_copy_without_algorithm_preserves_every_supported_source_checksum() {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client_no_auto_checksum(&env);
|
||||
let src_bucket = "copy-preserve-all-src";
|
||||
let dst_bucket = "copy-preserve-all-dst";
|
||||
let content = b"source checksum preservation payload for all ten algorithms";
|
||||
|
||||
create_versioned_bucket(&client, src_bucket).await;
|
||||
create_versioned_bucket(&client, dst_bucket).await;
|
||||
|
||||
for (index, (_sdk_algorithm, rio_algorithm)) in algorithms().into_iter().enumerate() {
|
||||
let expected = Checksum::new_from_data(rio_algorithm, content)
|
||||
.expect("supported checksum must be computable")
|
||||
.encoded;
|
||||
let checksum_header = rio_algorithm.key().expect("supported checksum header");
|
||||
let request_checksum = expected.clone();
|
||||
let src_key = format!("objects/source-{index}.bin");
|
||||
let dst_key = format!("objects/destination-{index}.bin");
|
||||
client
|
||||
.put_object()
|
||||
.bucket(src_bucket)
|
||||
.key(&src_key)
|
||||
.body(ByteStream::from_static(content))
|
||||
.customize()
|
||||
.mutate_request(move |request| {
|
||||
request.headers_mut().insert(checksum_header, request_checksum.clone());
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.expect("PUT checksummed source failed");
|
||||
|
||||
let copy = client
|
||||
.copy_object()
|
||||
.bucket(dst_bucket)
|
||||
.key(&dst_key)
|
||||
.copy_source(format!("{src_bucket}/{src_key}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("CopyObject without algorithm must succeed");
|
||||
let result = copy.copy_object_result().expect("CopyObject result");
|
||||
let checksums = result_checksums(result);
|
||||
assert_eq!(checksums[index], Some(expected.as_str()), "{rio_algorithm}: preserved response checksum");
|
||||
assert_eq!(checksums.iter().filter(|checksum| checksum.is_some()).count(), 1);
|
||||
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(dst_bucket)
|
||||
.key(&dst_key)
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD destination failed");
|
||||
let checksums = head_checksums(&head);
|
||||
assert_eq!(checksums[index], Some(expected.as_str()), "{rio_algorithm}: preserved stored checksum");
|
||||
assert_eq!(checksums.iter().filter(|checksum| checksum.is_some()).count(), 1);
|
||||
}
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_copy_without_algorithm_preserves_composite_checksum_type() {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client_no_auto_checksum(&env);
|
||||
let bucket = "copy-preserve-composite";
|
||||
let source_key = "objects/multipart-source.bin";
|
||||
let destination_key = "objects/copied-multipart.bin";
|
||||
let content = b"multipart source checksum must remain composite";
|
||||
|
||||
create_versioned_bucket(&client, bucket).await;
|
||||
let created = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(source_key)
|
||||
.checksum_algorithm(ChecksumAlgorithm::Sha256)
|
||||
.send()
|
||||
.await
|
||||
.expect("CreateMultipartUpload failed");
|
||||
let upload_id = created.upload_id().expect("multipart upload ID");
|
||||
let checksum = Checksum::new_from_data(RioChecksumType::SHA256, content)
|
||||
.expect("SHA256 checksum")
|
||||
.encoded;
|
||||
let uploaded = client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(source_key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.checksum_sha256(&checksum)
|
||||
.body(ByteStream::from_static(content))
|
||||
.send()
|
||||
.await
|
||||
.expect("UploadPart failed");
|
||||
let completed_part = CompletedPart::builder()
|
||||
.part_number(1)
|
||||
.e_tag(uploaded.e_tag().expect("part ETag"))
|
||||
.checksum_sha256(uploaded.checksum_sha256().expect("part checksum"))
|
||||
.build();
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(source_key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().parts(completed_part).build())
|
||||
.send()
|
||||
.await
|
||||
.expect("CompleteMultipartUpload failed");
|
||||
|
||||
let source_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(source_key)
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD multipart source failed");
|
||||
let source_checksum = source_head.checksum_sha256().expect("multipart source checksum");
|
||||
assert_eq!(source_head.checksum_type(), Some(&ChecksumType::Composite));
|
||||
|
||||
let copied = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(destination_key)
|
||||
.copy_source(format!("{bucket}/{source_key}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("CopyObject without algorithm failed");
|
||||
let result = copied.copy_object_result().expect("CopyObject result");
|
||||
assert_eq!(result.checksum_sha256(), Some(source_checksum));
|
||||
assert_eq!(result.checksum_type(), Some(&ChecksumType::Composite));
|
||||
|
||||
let destination_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(destination_key)
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD copied multipart object failed");
|
||||
assert_eq!(destination_head.checksum_sha256(), Some(source_checksum));
|
||||
assert_eq!(destination_head.checksum_type(), Some(&ChecksumType::Composite));
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_copy_rejects_unknown_algorithm_without_destination_mutation() {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "copy-reject-unknown-checksum";
|
||||
let src_key = "objects/source.bin";
|
||||
let dst_key = "objects/destination.bin";
|
||||
let source = b"source must never replace destination";
|
||||
let destination = b"pre-existing destination must remain byte-for-byte unchanged";
|
||||
let expected = Checksum::new_from_data(RioChecksumType::SHA256, destination)
|
||||
.expect("SHA256 checksum")
|
||||
.encoded;
|
||||
|
||||
create_versioned_bucket(&client, bucket).await;
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(src_key)
|
||||
.body(ByteStream::from_static(source))
|
||||
.send()
|
||||
.await
|
||||
.expect("PUT source failed");
|
||||
let original = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(dst_key)
|
||||
.metadata("state", "original")
|
||||
.checksum_algorithm(ChecksumAlgorithm::Sha256)
|
||||
.body(ByteStream::from_static(destination))
|
||||
.send()
|
||||
.await
|
||||
.expect("PUT destination failed");
|
||||
let original_version = original.version_id().expect("versioned PUT must return a version id");
|
||||
|
||||
let missing_source_error = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(dst_key)
|
||||
.copy_source(format!("{bucket}/objects/missing-source.bin"))
|
||||
.checksum_algorithm(ChecksumAlgorithm::from("BLAKE3"))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("checksum validation must precede source lookup");
|
||||
assert_eq!(
|
||||
missing_source_error.as_service_error().and_then(|value| value.code()),
|
||||
Some("InvalidArgument")
|
||||
);
|
||||
assert_eq!(missing_source_error.raw_response().map(|response| response.status().as_u16()), Some(400));
|
||||
|
||||
let error = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(dst_key)
|
||||
.copy_source(format!("{bucket}/{src_key}"))
|
||||
.checksum_algorithm(ChecksumAlgorithm::from("BLAKE3"))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("unsupported checksum algorithm must fail");
|
||||
assert_eq!(error.as_service_error().and_then(|value| value.code()), Some("InvalidArgument"));
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(400));
|
||||
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(dst_key)
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD unchanged destination");
|
||||
assert_eq!(head.version_id(), Some(original_version));
|
||||
assert_eq!(
|
||||
head.metadata().and_then(|metadata| metadata.get("state").map(String::as_str)),
|
||||
Some("original")
|
||||
);
|
||||
assert_eq!(head.checksum_sha256(), Some(expected.as_str()));
|
||||
|
||||
let body = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(dst_key)
|
||||
.send()
|
||||
.await
|
||||
.expect("GET unchanged destination")
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.expect("collect unchanged destination")
|
||||
.into_bytes();
|
||||
assert_eq!(body.as_ref(), destination);
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
/// Requested algorithm: a CopyObject asking for SHA256 must compute it over the copied
|
||||
/// bytes, return it in `CopyObjectResult.ChecksumSHA256`, and persist it so a checksum-mode
|
||||
/// HEAD on the destination returns the identical value.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_copy_with_checksum_algorithm_returns_and_persists_sha256() {
|
||||
init_logging();
|
||||
info!("Issue #4996: CopyObject with ChecksumAlgorithm=SHA256 must return and persist the checksum");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let src_bucket = "copy-checksum-req-src";
|
||||
let dst_bucket = "copy-checksum-req-dst";
|
||||
let src_key = "objects/source.bin";
|
||||
let dst_key = "objects/dest.bin";
|
||||
|
||||
create_versioned_bucket(&client, src_bucket).await;
|
||||
create_versioned_bucket(&client, dst_bucket).await;
|
||||
|
||||
let content = b"deterministic synthetic payload for copy-object checksum #4996";
|
||||
let expected_sha256 = BASE64.encode(Sha256::digest(content));
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(src_bucket)
|
||||
.key(src_key)
|
||||
.body(ByteStream::from_static(content))
|
||||
.send()
|
||||
.await
|
||||
.expect("PUT source failed");
|
||||
|
||||
let copy_out = client
|
||||
.copy_object()
|
||||
.bucket(dst_bucket)
|
||||
.key(dst_key)
|
||||
.copy_source(format!("{src_bucket}/{src_key}"))
|
||||
.checksum_algorithm(ChecksumAlgorithm::Sha256)
|
||||
.send()
|
||||
.await
|
||||
.expect("CopyObject with ChecksumAlgorithm must succeed");
|
||||
|
||||
// (3) The response must carry the freshly computed SHA-256 of the copied bytes.
|
||||
let result = copy_out
|
||||
.copy_object_result()
|
||||
.expect("issue #4996: CopyObject must return a CopyObjectResult");
|
||||
assert_eq!(
|
||||
result.checksum_sha256(),
|
||||
Some(expected_sha256.as_str()),
|
||||
"issue #4996: CopyObjectResult.ChecksumSHA256 must equal the SHA-256 of the copied bytes"
|
||||
);
|
||||
|
||||
// (4) A checksum-mode HEAD on the destination must return the same SHA-256.
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(dst_bucket)
|
||||
.key(dst_key)
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD destination failed");
|
||||
assert_eq!(
|
||||
head.checksum_sha256(),
|
||||
Some(expected_sha256.as_str()),
|
||||
"issue #4996: destination checksum-mode HEAD must return the same SHA-256 the copy reported"
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
/// No algorithm requested: when the source object already carries a checksum, the copy must
|
||||
/// preserve it on the destination (AWS default), visible via a checksum-mode HEAD.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_copy_without_algorithm_preserves_source_checksum() {
|
||||
init_logging();
|
||||
info!("Issue #4996: CopyObject without ChecksumAlgorithm must preserve the source object's checksum");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let src_bucket = "copy-checksum-preserve-src";
|
||||
let dst_bucket = "copy-checksum-preserve-dst";
|
||||
let src_key = "objects/source.bin";
|
||||
let dst_key = "objects/dest.bin";
|
||||
|
||||
create_versioned_bucket(&client, src_bucket).await;
|
||||
create_versioned_bucket(&client, dst_bucket).await;
|
||||
|
||||
let content = b"another deterministic payload whose source checksum must survive the copy";
|
||||
let expected_sha256 = BASE64.encode(Sha256::digest(content));
|
||||
|
||||
// Store the source WITH a SHA-256 checksum so it has one to preserve.
|
||||
let put_src = client
|
||||
.put_object()
|
||||
.bucket(src_bucket)
|
||||
.key(src_key)
|
||||
.checksum_algorithm(ChecksumAlgorithm::Sha256)
|
||||
.body(ByteStream::from_static(content))
|
||||
.send()
|
||||
.await
|
||||
.expect("PUT source with checksum failed");
|
||||
assert_eq!(
|
||||
put_src.checksum_sha256(),
|
||||
Some(expected_sha256.as_str()),
|
||||
"source PUT must report the SHA-256 it stored"
|
||||
);
|
||||
|
||||
// Copy WITHOUT specifying a checksum algorithm.
|
||||
let copy_out = client
|
||||
.copy_object()
|
||||
.bucket(dst_bucket)
|
||||
.key(dst_key)
|
||||
.copy_source(format!("{src_bucket}/{src_key}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("CopyObject without ChecksumAlgorithm must succeed");
|
||||
|
||||
// The response should echo the preserved source checksum.
|
||||
let result = copy_out
|
||||
.copy_object_result()
|
||||
.expect("issue #4996: CopyObject must return a CopyObjectResult");
|
||||
assert_eq!(
|
||||
result.checksum_sha256(),
|
||||
Some(expected_sha256.as_str()),
|
||||
"issue #4996: a no-algorithm copy must preserve and report the source object's SHA-256"
|
||||
);
|
||||
|
||||
// And a checksum-mode HEAD on the destination must return that same preserved SHA-256.
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(dst_bucket)
|
||||
.key(dst_key)
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD destination failed");
|
||||
assert_eq!(
|
||||
head.checksum_sha256(),
|
||||
Some(expected_sha256.as_str()),
|
||||
"issue #4996: destination checksum-mode HEAD must return the preserved source SHA-256"
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
/// Requested algorithm differs from the source's: a source stored with SHA256, copied while
|
||||
/// requesting CRC32, must return/persist the freshly computed CRC32 and must NOT carry the
|
||||
/// source's SHA256 through. Guards the request-over-source precedence and the destination's
|
||||
/// checksum-not-inherited path, and exercises the CRC32 code path (a different branch of
|
||||
/// ChecksumType::from_string than SHA256).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_copy_requested_algorithm_overrides_source_checksum() {
|
||||
init_logging();
|
||||
info!("Issue #4996: a requested CopyObject checksum algorithm must override the source object's algorithm");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let src_bucket = "copy-checksum-override-src";
|
||||
let dst_bucket = "copy-checksum-override-dst";
|
||||
let src_key = "objects/source.bin";
|
||||
let ref_key = "objects/reference-crc32.bin";
|
||||
let dst_key = "objects/dest.bin";
|
||||
|
||||
create_versioned_bucket(&client, src_bucket).await;
|
||||
create_versioned_bucket(&client, dst_bucket).await;
|
||||
|
||||
let content = b"payload whose copy must be re-checksummed with a different algorithm";
|
||||
let expected_sha256 = BASE64.encode(Sha256::digest(content));
|
||||
|
||||
// Source is stored WITH a SHA-256 checksum.
|
||||
client
|
||||
.put_object()
|
||||
.bucket(src_bucket)
|
||||
.key(src_key)
|
||||
.checksum_algorithm(ChecksumAlgorithm::Sha256)
|
||||
.body(ByteStream::from_static(content))
|
||||
.send()
|
||||
.await
|
||||
.expect("PUT source with SHA256 failed");
|
||||
|
||||
// Establish the canonical CRC32 the server computes for this content via a reference PUT,
|
||||
// so the copy's CRC32 can be asserted against an exact server-computed value.
|
||||
let ref_put = client
|
||||
.put_object()
|
||||
.bucket(src_bucket)
|
||||
.key(ref_key)
|
||||
.checksum_algorithm(ChecksumAlgorithm::Crc32)
|
||||
.body(ByteStream::from_static(content))
|
||||
.send()
|
||||
.await
|
||||
.expect("reference PUT with CRC32 failed");
|
||||
let expected_crc32 = ref_put
|
||||
.checksum_crc32()
|
||||
.expect("reference PUT must report a CRC32")
|
||||
.to_string();
|
||||
|
||||
// Copy the SHA256 source while requesting CRC32.
|
||||
let copy_out = client
|
||||
.copy_object()
|
||||
.bucket(dst_bucket)
|
||||
.key(dst_key)
|
||||
.copy_source(format!("{src_bucket}/{src_key}"))
|
||||
.checksum_algorithm(ChecksumAlgorithm::Crc32)
|
||||
.send()
|
||||
.await
|
||||
.expect("CopyObject requesting a different algorithm must succeed");
|
||||
|
||||
let result = copy_out
|
||||
.copy_object_result()
|
||||
.expect("issue #4996: CopyObject must return a CopyObjectResult");
|
||||
// The requested CRC32 must be computed and returned.
|
||||
assert_eq!(
|
||||
result.checksum_crc32(),
|
||||
Some(expected_crc32.as_str()),
|
||||
"issue #4996: a requested CRC32 must be computed fresh over the copied bytes"
|
||||
);
|
||||
// The source's SHA256 must NOT leak through — the requested algorithm wins.
|
||||
assert_eq!(
|
||||
result.checksum_sha256(),
|
||||
None,
|
||||
"issue #4996: the source object's SHA256 must not be inherited when a different algorithm is requested"
|
||||
);
|
||||
assert_ne!(
|
||||
result.checksum_crc32(),
|
||||
Some(expected_sha256.as_str()),
|
||||
"sanity: CRC32 field must not carry the SHA256 value"
|
||||
);
|
||||
|
||||
// The destination must persist CRC32 (and only CRC32) for a checksum-mode HEAD.
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(dst_bucket)
|
||||
.key(dst_key)
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD destination failed");
|
||||
assert_eq!(
|
||||
head.checksum_crc32(),
|
||||
Some(expected_crc32.as_str()),
|
||||
"issue #4996: destination checksum-mode HEAD must return the requested CRC32"
|
||||
);
|
||||
assert_eq!(
|
||||
head.checksum_sha256(),
|
||||
None,
|
||||
"issue #4996: destination must not report the source's SHA256 after an override copy"
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
}
|
||||
@@ -17,14 +17,17 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::MetadataDirective;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::{ByteStream, DateTime, DateTimeFormat};
|
||||
use aws_sdk_s3::types::{
|
||||
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, MetadataDirective, StorageClass, VersioningConfiguration,
|
||||
};
|
||||
use serial_test::serial;
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_self_copy_replace_metadata_preserves_readable_object() {
|
||||
async fn copy_object_standard_metadata_copy_replace_and_clear() {
|
||||
init_logging();
|
||||
info!("Issue #2789: self-copy metadata replacement must preserve object data");
|
||||
|
||||
@@ -35,6 +38,14 @@ mod tests {
|
||||
let bucket = "self-copy-metadata-replace-test";
|
||||
let key = "assets/chunk-2F3R7JUG.js";
|
||||
let content = b"console.log('metadata replacement should keep object data readable');";
|
||||
let source_expires = DateTime::from_secs(1_893_456_000);
|
||||
let source_expires_http_date = source_expires
|
||||
.fmt(DateTimeFormat::HttpDate)
|
||||
.expect("Test timestamp should format as an HTTP date");
|
||||
let replacement_expires = DateTime::from_secs(1_924_992_000);
|
||||
let replacement_expires_http_date = replacement_expires
|
||||
.fmt(DateTimeFormat::HttpDate)
|
||||
.expect("Test timestamp should format as an HTTP date");
|
||||
|
||||
client
|
||||
.create_bucket()
|
||||
@@ -47,7 +58,14 @@ mod tests {
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.cache_control("max-age=60")
|
||||
.content_disposition("inline; filename=source.js")
|
||||
.content_encoding("br")
|
||||
.content_language("en-US")
|
||||
.content_type("text/javascript; charset=utf-8")
|
||||
.expires(source_expires)
|
||||
.website_redirect_location("/source.html")
|
||||
.storage_class(StorageClass::ReducedRedundancy)
|
||||
.metadata("mtime", "1777992333")
|
||||
.metadata("stale", "must-be-removed")
|
||||
.body(ByteStream::from_static(content))
|
||||
@@ -55,13 +73,120 @@ mod tests {
|
||||
.await
|
||||
.expect("PUT failed");
|
||||
|
||||
let copied_key = "assets/default-copy.js";
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(copied_key)
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("default CopyObject failed");
|
||||
|
||||
let copied_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(copied_key)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD failed after default copy");
|
||||
assert_eq!(copied_head.cache_control(), Some("max-age=60"));
|
||||
assert_eq!(copied_head.content_disposition(), Some("inline; filename=source.js"));
|
||||
assert_eq!(copied_head.content_encoding(), Some("br"));
|
||||
assert_eq!(copied_head.content_language(), Some("en-US"));
|
||||
assert_eq!(copied_head.content_type(), Some("text/javascript; charset=utf-8"));
|
||||
assert_eq!(copied_head.expires_string(), Some(source_expires_http_date.as_str()));
|
||||
assert_eq!(
|
||||
copied_head.storage_class(),
|
||||
None,
|
||||
"CopyObject without a storage class should write STANDARD"
|
||||
);
|
||||
assert_eq!(
|
||||
copied_head.website_redirect_location(),
|
||||
Some("/source.html"),
|
||||
"default CopyObject should preserve source metadata"
|
||||
);
|
||||
assert_eq!(
|
||||
copied_head.metadata().and_then(|metadata| metadata.get("stale")),
|
||||
Some(&"must-be-removed".to_string())
|
||||
);
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("assets/explicit-copy.js")
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.metadata_directive(MetadataDirective::Copy)
|
||||
.send()
|
||||
.await
|
||||
.expect("explicit COPY directive failed");
|
||||
let explicit_copy_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key("assets/explicit-copy.js")
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD failed after explicit COPY");
|
||||
assert_eq!(explicit_copy_head.cache_control(), Some("max-age=60"));
|
||||
assert_eq!(
|
||||
explicit_copy_head.website_redirect_location(),
|
||||
None,
|
||||
"explicit COPY does not inherit website redirect metadata"
|
||||
);
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("assets/explicit-copy-redirect.js")
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.metadata_directive(MetadataDirective::Copy)
|
||||
.website_redirect_location("/explicit-copy.html")
|
||||
.send()
|
||||
.await
|
||||
.expect("explicit COPY with redirect failed");
|
||||
let explicit_redirect_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key("assets/explicit-copy-redirect.js")
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD failed after explicit COPY with redirect");
|
||||
assert_eq!(explicit_redirect_head.website_redirect_location(), Some("/explicit-copy.html"));
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("assets/explicit-storage-class.js")
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.storage_class(StorageClass::ReducedRedundancy)
|
||||
.send()
|
||||
.await
|
||||
.expect("CopyObject with an explicit storage class failed");
|
||||
let explicit_storage_class_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key("assets/explicit-storage-class.js")
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD failed after explicit storage class copy");
|
||||
assert_eq!(
|
||||
explicit_storage_class_head.storage_class().map(StorageClass::as_str),
|
||||
Some("REDUCED_REDUNDANCY")
|
||||
);
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.metadata_directive(MetadataDirective::Replace)
|
||||
.content_type("text/javascript; charset=utf-8")
|
||||
.cache_control("no-cache")
|
||||
.content_disposition("attachment; filename=replaced.js")
|
||||
.content_encoding("gzip")
|
||||
.content_language("fr-FR")
|
||||
.content_type("application/javascript")
|
||||
.expires(replacement_expires)
|
||||
.website_redirect_location("/replaced.html")
|
||||
.metadata("mtime", "1777992348")
|
||||
.send()
|
||||
.await
|
||||
@@ -85,6 +210,14 @@ mod tests {
|
||||
None,
|
||||
"HEAD should not return metadata omitted by REPLACE"
|
||||
);
|
||||
assert_eq!(head_resp.cache_control(), Some("no-cache"));
|
||||
assert_eq!(head_resp.content_disposition(), Some("attachment; filename=replaced.js"));
|
||||
assert_eq!(head_resp.content_encoding(), Some("gzip"));
|
||||
assert_eq!(head_resp.content_language(), Some("fr-FR"));
|
||||
assert_eq!(head_resp.content_type(), Some("application/javascript"));
|
||||
assert_eq!(head_resp.expires_string(), Some(replacement_expires_http_date.as_str()));
|
||||
assert_eq!(head_resp.website_redirect_location(), Some("/replaced.html"));
|
||||
assert_eq!(head_resp.storage_class(), None, "REPLACE without a storage class should write STANDARD");
|
||||
|
||||
let get_resp = client
|
||||
.get_object()
|
||||
@@ -123,6 +256,13 @@ mod tests {
|
||||
None,
|
||||
"HEAD should not return metadata omitted by empty REPLACE"
|
||||
);
|
||||
assert_eq!(empty_head_resp.cache_control(), None);
|
||||
assert_eq!(empty_head_resp.content_disposition(), None);
|
||||
assert_eq!(empty_head_resp.content_encoding(), None);
|
||||
assert_eq!(empty_head_resp.content_language(), None);
|
||||
assert_eq!(empty_head_resp.content_type(), None);
|
||||
assert_eq!(empty_head_resp.expires_string(), None);
|
||||
assert_eq!(empty_head_resp.website_redirect_location(), None);
|
||||
|
||||
let empty_get_resp = client
|
||||
.get_object()
|
||||
@@ -141,4 +281,333 @@ mod tests {
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn copy_object_replace_accepts_each_standard_field_independently() {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "copy-object-metadata-fields";
|
||||
let source = "source.txt";
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create bucket");
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(source)
|
||||
.cache_control("source-cache")
|
||||
.content_disposition("inline")
|
||||
.content_encoding("br")
|
||||
.content_language("en")
|
||||
.content_type("text/source")
|
||||
.expires(DateTime::from_secs(1_893_456_000))
|
||||
.body(ByteStream::from_static(b"field-by-field"))
|
||||
.send()
|
||||
.await
|
||||
.expect("PUT failed");
|
||||
let replacement_expires = DateTime::from_secs(1_924_992_000);
|
||||
let replacement_expires_http_date = replacement_expires
|
||||
.fmt(DateTimeFormat::HttpDate)
|
||||
.expect("Test timestamp should format as an HTTP date");
|
||||
|
||||
for field in [
|
||||
"cache-control",
|
||||
"content-disposition",
|
||||
"content-encoding",
|
||||
"content-language",
|
||||
"content-type",
|
||||
"expires",
|
||||
"website-redirect",
|
||||
] {
|
||||
let destination = format!("{field}.txt");
|
||||
let request = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(&destination)
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.metadata_directive(MetadataDirective::Replace);
|
||||
let request = match field {
|
||||
"cache-control" => request.cache_control("field-cache"),
|
||||
"content-disposition" => request.content_disposition("attachment"),
|
||||
"content-encoding" => request.content_encoding("gzip"),
|
||||
"content-language" => request.content_language("de"),
|
||||
"content-type" => request.content_type("text/field"),
|
||||
"expires" => request.expires(replacement_expires),
|
||||
"website-redirect" => request.website_redirect_location("/field.html"),
|
||||
_ => unreachable!("field table contains only supported entries"),
|
||||
};
|
||||
request.send().await.expect("field-specific CopyObject failed");
|
||||
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(&destination)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD failed");
|
||||
assert_eq!(head.cache_control(), (field == "cache-control").then_some("field-cache"));
|
||||
assert_eq!(head.content_disposition(), (field == "content-disposition").then_some("attachment"));
|
||||
assert_eq!(head.content_encoding(), (field == "content-encoding").then_some("gzip"));
|
||||
assert_eq!(head.content_language(), (field == "content-language").then_some("de"));
|
||||
assert_eq!(head.content_type(), (field == "content-type").then_some("text/field"));
|
||||
assert_eq!(
|
||||
head.expires_string(),
|
||||
(field == "expires").then_some(replacement_expires_http_date.as_str())
|
||||
);
|
||||
assert_eq!(head.website_redirect_location(), (field == "website-redirect").then_some("/field.html"));
|
||||
}
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("user-metadata-collision.txt")
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.metadata_directive(MetadataDirective::Replace)
|
||||
.metadata("content-type", "user-content-type")
|
||||
.metadata("content-encoding", "user-content-encoding")
|
||||
.send()
|
||||
.await
|
||||
.expect("CopyObject should preserve user metadata namespaces");
|
||||
let collision_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key("user-metadata-collision.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD failed for metadata collision case");
|
||||
assert_eq!(collision_head.content_type(), None);
|
||||
assert_eq!(collision_head.content_encoding(), None);
|
||||
assert_eq!(
|
||||
collision_head.metadata().and_then(|metadata| metadata.get("content-type")),
|
||||
Some(&"user-content-type".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
collision_head
|
||||
.metadata()
|
||||
.and_then(|metadata| metadata.get("content-encoding")),
|
||||
Some(&"user-content-encoding".to_string())
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn copy_object_replace_handles_versioned_multipart_source() {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "copy-object-metadata-multipart";
|
||||
let source = "source.bin";
|
||||
let multipart_body = b"multipart historical source";
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create bucket");
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to enable versioning");
|
||||
|
||||
let upload = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(source)
|
||||
.content_type("application/source")
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create multipart upload");
|
||||
let upload_id = upload.upload_id().expect("Multipart upload should return an ID");
|
||||
let part = client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(source)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from_static(multipart_body))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to upload multipart part");
|
||||
let completed = client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(source)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.parts(
|
||||
CompletedPart::builder()
|
||||
.part_number(1)
|
||||
.e_tag(part.e_tag().expect("Uploaded part should return an ETag"))
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to complete multipart upload");
|
||||
let historical_version = completed
|
||||
.version_id()
|
||||
.expect("Versioned multipart upload should return a version ID")
|
||||
.to_string();
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(source)
|
||||
.body(ByteStream::from_static(b"new current version"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to write current version");
|
||||
|
||||
let copy = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("restored.bin")
|
||||
.copy_source(format!("{bucket}/{source}?versionId={historical_version}"))
|
||||
.metadata_directive(MetadataDirective::Replace)
|
||||
.content_type("application/replaced")
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to copy historical multipart version");
|
||||
assert_eq!(copy.copy_source_version_id(), Some(historical_version.as_str()));
|
||||
|
||||
let restored = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("restored.bin")
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to read copied multipart source");
|
||||
assert_eq!(restored.content_type(), Some("application/replaced"));
|
||||
assert_eq!(
|
||||
restored
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.expect("Failed to collect restored body")
|
||||
.into_bytes()
|
||||
.as_ref(),
|
||||
multipart_body
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn invalid_replacement_metadata_does_not_mutate_destination() {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_REJECT_ARCHIVE_CONTENT_ENCODING", "true")])
|
||||
.await
|
||||
.expect("Failed to start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "copy-object-invalid-metadata";
|
||||
let key = "destination.zip";
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create bucket");
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.content_type("application/zip")
|
||||
.metadata("state", "original")
|
||||
.body(ByteStream::from_static(b"original destination"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to write destination");
|
||||
|
||||
let error = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.metadata_directive(MetadataDirective::Replace)
|
||||
.content_type("application/zip")
|
||||
.content_encoding("gzip")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("Invalid replacement metadata should be rejected");
|
||||
assert_eq!(error.as_service_error().and_then(|err| err.code()), Some("InvalidArgument"));
|
||||
|
||||
let invalid_directive = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.customize()
|
||||
.mutate_request(|request| {
|
||||
request.headers_mut().insert("x-amz-metadata-directive", "UNKNOWN");
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.expect_err("Unknown metadata directives should be rejected");
|
||||
assert_eq!(
|
||||
invalid_directive.as_service_error().and_then(|error| error.code()),
|
||||
Some("InvalidArgument")
|
||||
);
|
||||
|
||||
let ignored_replacement = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.content_type("application/ignored")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("Replacement fields without REPLACE should be rejected");
|
||||
assert_eq!(
|
||||
ignored_replacement.as_service_error().and_then(|error| error.code()),
|
||||
Some("InvalidRequest")
|
||||
);
|
||||
|
||||
let unchanged = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect("Destination should remain readable");
|
||||
assert_eq!(unchanged.content_type(), Some("application/zip"));
|
||||
assert_eq!(
|
||||
unchanged.metadata().and_then(|metadata| metadata.get("state")),
|
||||
Some(&"original".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
unchanged
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.expect("Failed to collect destination body")
|
||||
.into_bytes()
|
||||
.as_ref(),
|
||||
b"original destination"
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! CopyObject tagging directive regression tests.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, MetadataDirective, TaggingDirective, VersioningConfiguration};
|
||||
use serial_test::serial;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
async fn object_tags(client: &Client, bucket: &str, key: &str) -> BTreeMap<String, String> {
|
||||
client
|
||||
.get_object_tagging()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect("GetObjectTagging should succeed")
|
||||
.tag_set()
|
||||
.iter()
|
||||
.map(|tag| (tag.key().to_string(), tag.value().to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn copy_object_applies_copy_replace_and_empty_tagging_directives() {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new()
|
||||
.await
|
||||
.expect("test environment should initialize");
|
||||
env.start_rustfs_server(vec![]).await.expect("RustFS should start");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "copy-object-tagging-directive";
|
||||
let source = "source.txt";
|
||||
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("bucket creation should succeed");
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("versioning should be enabled");
|
||||
|
||||
let first_version = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(source)
|
||||
.tagging("project=rustfs&stage=first")
|
||||
.body(ByteStream::from_static(b"first"))
|
||||
.send()
|
||||
.await
|
||||
.expect("first source version should be written")
|
||||
.version_id()
|
||||
.expect("versioned PUT should return a version ID")
|
||||
.to_string();
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(source)
|
||||
.tagging("project=rustfs&stage=current")
|
||||
.body(ByteStream::from_static(b"current"))
|
||||
.send()
|
||||
.await
|
||||
.expect("current source version should be written");
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("default-copy.txt")
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("default CopyObject should preserve current source tags");
|
||||
assert_eq!(
|
||||
object_tags(&client, bucket, "default-copy.txt").await,
|
||||
BTreeMap::from([
|
||||
("project".to_string(), "rustfs".to_string()),
|
||||
("stage".to_string(), "current".to_string()),
|
||||
])
|
||||
);
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("explicit-copy.txt")
|
||||
.copy_source(format!("{bucket}/{source}?versionId={first_version}"))
|
||||
.tagging_directive(TaggingDirective::Copy)
|
||||
.send()
|
||||
.await
|
||||
.expect("COPY should preserve the selected historical version's tags");
|
||||
assert_eq!(
|
||||
object_tags(&client, bucket, "explicit-copy.txt").await,
|
||||
BTreeMap::from([
|
||||
("project".to_string(), "rustfs".to_string()),
|
||||
("stage".to_string(), "first".to_string()),
|
||||
])
|
||||
);
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("replace.txt")
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.tagging_directive(TaggingDirective::Replace)
|
||||
.tagging("project=cli&label=copy%20test")
|
||||
.send()
|
||||
.await
|
||||
.expect("REPLACE should atomically apply requested tags");
|
||||
assert_eq!(
|
||||
object_tags(&client, bucket, "replace.txt").await,
|
||||
BTreeMap::from([
|
||||
("label".to_string(), "copy test".to_string()),
|
||||
("project".to_string(), "cli".to_string()),
|
||||
])
|
||||
);
|
||||
let replace_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key("replace.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD should succeed after tag replacement");
|
||||
assert_eq!(replace_head.tag_count(), Some(2));
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("empty-replace.txt")
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.tagging_directive(TaggingDirective::Replace)
|
||||
.send()
|
||||
.await
|
||||
.expect("REPLACE without Tagging should clear the destination tag set");
|
||||
assert!(object_tags(&client, bucket, "empty-replace.txt").await.is_empty());
|
||||
let empty_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key("empty-replace.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD should succeed after empty tag replacement");
|
||||
assert_eq!(empty_head.tag_count(), None);
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("metadata-replace-tag-copy.txt")
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.metadata_directive(MetadataDirective::Replace)
|
||||
.metadata("updated", "true")
|
||||
.send()
|
||||
.await
|
||||
.expect("metadata REPLACE must preserve tags under the default COPY directive");
|
||||
assert_eq!(
|
||||
object_tags(&client, bucket, "metadata-replace-tag-copy.txt").await,
|
||||
BTreeMap::from([
|
||||
("project".to_string(), "rustfs".to_string()),
|
||||
("stage".to_string(), "current".to_string()),
|
||||
])
|
||||
);
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("combined-replace.txt")
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.metadata_directive(MetadataDirective::Replace)
|
||||
.metadata("updated", "true")
|
||||
.tagging_directive(TaggingDirective::Replace)
|
||||
.tagging("project=combined")
|
||||
.send()
|
||||
.await
|
||||
.expect("metadata and tagging REPLACE directives must be independent");
|
||||
assert_eq!(
|
||||
object_tags(&client, bucket, "combined-replace.txt").await,
|
||||
BTreeMap::from([("project".to_string(), "combined".to_string())])
|
||||
);
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(source)
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.tagging_directive(TaggingDirective::Replace)
|
||||
.tagging("project=self-copy")
|
||||
.send()
|
||||
.await
|
||||
.expect("self-copy with tag replacement should update tags atomically");
|
||||
assert_eq!(
|
||||
object_tags(&client, bucket, source).await,
|
||||
BTreeMap::from([("project".to_string(), "self-copy".to_string())])
|
||||
);
|
||||
let self_copy_body = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(source)
|
||||
.send()
|
||||
.await
|
||||
.expect("self-copy destination should remain readable")
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.expect("self-copy body should be complete")
|
||||
.into_bytes();
|
||||
assert_eq!(self_copy_body.as_ref(), b"current", "tag-only self-copy must preserve the object body");
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("malformed.txt")
|
||||
.tagging("state=original")
|
||||
.body(ByteStream::from_static(b"original destination"))
|
||||
.send()
|
||||
.await
|
||||
.expect("preexisting malformed-test destination should be written");
|
||||
|
||||
let malformed = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("malformed.txt")
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.tagging_directive(TaggingDirective::Replace)
|
||||
.tagging("project=rustfs%ZZ")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("malformed tags must fail CopyObject");
|
||||
assert_eq!(malformed.as_service_error().and_then(ProvideErrorMetadata::code), Some("InvalidTag"));
|
||||
assert_eq!(
|
||||
object_tags(&client, bucket, "malformed.txt").await,
|
||||
BTreeMap::from([("state".to_string(), "original".to_string())])
|
||||
);
|
||||
let preserved_body = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("malformed.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect("malformed tags must not replace an existing destination")
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.expect("preserved destination body should be readable")
|
||||
.into_bytes();
|
||||
assert_eq!(
|
||||
preserved_body.as_ref(),
|
||||
b"original destination",
|
||||
"malformed tags must leave destination data unchanged"
|
||||
);
|
||||
|
||||
let discarded = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("discarded.txt")
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.tagging("project=must-not-be-discarded")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("Tagging without REPLACE must fail instead of discarding requested tags");
|
||||
assert_eq!(discarded.as_service_error().and_then(ProvideErrorMetadata::code), Some("InvalidRequest"));
|
||||
|
||||
let invalid_directive = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("invalid-directive.txt")
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.tagging_directive(TaggingDirective::from("UNKNOWN"))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("an unknown TaggingDirective must fail");
|
||||
assert_eq!(
|
||||
invalid_directive.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("InvalidArgument")
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn copy_object_tag_replacement_honors_request_tag_policy_denial() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
init_logging();
|
||||
let source_bucket = "copy-tags-policy-source";
|
||||
let destination_bucket = "copy-tags-policy-destination";
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let admin = env.create_s3_client();
|
||||
admin.create_bucket().bucket(source_bucket).send().await?;
|
||||
admin.create_bucket().bucket(destination_bucket).send().await?;
|
||||
admin
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key("source.txt")
|
||||
.tagging("source=allowed")
|
||||
.body(ByteStream::from_static(b"source"))
|
||||
.send()
|
||||
.await?;
|
||||
admin
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key("conditioned.txt")
|
||||
.body(ByteStream::from_static(b"conditioned source"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let source_policy = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": [format!("arn:aws:s3:::{source_bucket}/source.txt")]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": [format!("arn:aws:s3:::{source_bucket}/conditioned.txt")],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:RequestObjectTag/classification": "public"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
admin
|
||||
.put_bucket_policy()
|
||||
.bucket(source_bucket)
|
||||
.policy(source_policy)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let destination_policy = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": [format!("arn:aws:s3:::{destination_bucket}/*")]
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": [format!("arn:aws:s3:::{destination_bucket}/*")],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:RequestObjectTag/classification": "restricted"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
admin
|
||||
.put_bucket_policy()
|
||||
.bucket(destination_bucket)
|
||||
.policy(destination_policy)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let copy_source = format!("/{source_bucket}/source.txt");
|
||||
let allowed = local_http_client()
|
||||
.put(format!("{}/{destination_bucket}/allowed.txt", env.url))
|
||||
.header("x-amz-copy-source", ©_source)
|
||||
.header("x-amz-tagging-directive", "REPLACE")
|
||||
.header("x-amz-tagging", "classification=public")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
allowed.status(),
|
||||
reqwest::StatusCode::OK,
|
||||
"a tag set allowed by the request-tag policy should copy successfully"
|
||||
);
|
||||
assert_eq!(
|
||||
object_tags(&admin, destination_bucket, "allowed.txt").await,
|
||||
BTreeMap::from([("classification".to_string(), "public".to_string())])
|
||||
);
|
||||
|
||||
let denied = local_http_client()
|
||||
.put(format!("{}/{destination_bucket}/denied.txt", env.url))
|
||||
.header("x-amz-copy-source", copy_source)
|
||||
.header("x-amz-tagging-directive", "REPLACE")
|
||||
.header("x-amz-tagging", "classification=restricted")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
denied.status(),
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"CopyObject must honor a request-tag policy Deny"
|
||||
);
|
||||
|
||||
let source_condition_bypass = local_http_client()
|
||||
.put(format!("{}/{destination_bucket}/source-condition.txt", env.url))
|
||||
.header("x-amz-copy-source", format!("/{source_bucket}/conditioned.txt"))
|
||||
.header("x-amz-tagging-directive", "REPLACE")
|
||||
.header("x-amz-tagging", "classification=public")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
source_condition_bypass.status(),
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"destination request tags must not satisfy source GetObject policy conditions"
|
||||
);
|
||||
|
||||
let missing_destination = admin
|
||||
.head_object()
|
||||
.bucket(destination_bucket)
|
||||
.key("denied.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("an access-denied copy must not create a destination object");
|
||||
assert_eq!(
|
||||
missing_destination.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("NotFound")
|
||||
);
|
||||
let missing_bypass_destination = admin
|
||||
.head_object()
|
||||
.bucket(destination_bucket)
|
||||
.key("source-condition.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a source authorization denial must not create a destination object");
|
||||
assert_eq!(
|
||||
missing_bypass_destination
|
||||
.as_service_error()
|
||||
.and_then(ProvideErrorMetadata::code),
|
||||
Some("NotFound")
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -160,4 +160,146 @@ mod tests {
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
/// Regression test for Issue #4976: a versioned-source CopyObject must echo the exact source
|
||||
/// version copied via `x-amz-copy-source-version-id` (SDK `CopySourceVersionId`), kept distinct
|
||||
/// from the newly created destination `x-amz-version-id`.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_copy_of_non_latest_source_version_returns_copy_source_version_id() {
|
||||
init_logging();
|
||||
info!("Issue #4976: versioned CopyObject must return x-amz-copy-source-version-id for the exact source version");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let src_bucket = "copy-source-version-header-src";
|
||||
let dst_bucket = "copy-source-version-header-dst";
|
||||
let src_key = "reports/quarantined.bin";
|
||||
let dst_key = "reports/promoted.bin";
|
||||
|
||||
for bucket in [src_bucket, dst_bucket] {
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create bucket");
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to enable versioning");
|
||||
}
|
||||
|
||||
// Source version 1: the exact (non-latest) version we will copy.
|
||||
let v1_content = b"quarantined payload -- source version one (target of the copy)";
|
||||
let put_v1 = client
|
||||
.put_object()
|
||||
.bucket(src_bucket)
|
||||
.key(src_key)
|
||||
.body(ByteStream::from_static(v1_content))
|
||||
.send()
|
||||
.await
|
||||
.expect("PUT source v1 failed");
|
||||
let v1_id = put_v1.version_id().expect("source v1 must have a version id").to_string();
|
||||
|
||||
// Source version 2: becomes the latest, so v1 is deliberately NOT the current version.
|
||||
let v2_content = b"quarantined payload -- source version two (now current, must be ignored)";
|
||||
let put_v2 = client
|
||||
.put_object()
|
||||
.bucket(src_bucket)
|
||||
.key(src_key)
|
||||
.body(ByteStream::from_static(v2_content))
|
||||
.send()
|
||||
.await
|
||||
.expect("PUT source v2 failed");
|
||||
let v2_id = put_v2.version_id().expect("source v2 must have a version id").to_string();
|
||||
assert_ne!(v1_id, v2_id, "the two source puts must produce distinct versions");
|
||||
|
||||
// Copy the exact NON-LATEST source version into the destination bucket.
|
||||
let copy_out = client
|
||||
.copy_object()
|
||||
.bucket(dst_bucket)
|
||||
.key(dst_key)
|
||||
.copy_source(format!("{src_bucket}/{src_key}?versionId={v1_id}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("CopyObject of a versioned source must succeed");
|
||||
|
||||
// (3) The response must echo the exact source version copied.
|
||||
let copy_source_version_id = copy_out
|
||||
.copy_source_version_id()
|
||||
.expect("issue #4976: response must include x-amz-copy-source-version-id for a versioned source");
|
||||
assert_eq!(
|
||||
copy_source_version_id, v1_id,
|
||||
"x-amz-copy-source-version-id must equal the exact source version requested, not the latest source version"
|
||||
);
|
||||
assert_ne!(
|
||||
copy_source_version_id, v2_id,
|
||||
"x-amz-copy-source-version-id must not be the latest source version"
|
||||
);
|
||||
|
||||
// (4) The destination header must identify a distinct, newly created version.
|
||||
let dst_version_id = copy_out
|
||||
.version_id()
|
||||
.expect("destination copy must create a new version id")
|
||||
.to_string();
|
||||
assert!(!dst_version_id.is_empty(), "destination version id must be present");
|
||||
assert_ne!(dst_version_id, v1_id, "destination version must be distinct from the source version");
|
||||
assert_ne!(
|
||||
dst_version_id, v2_id,
|
||||
"destination version must be distinct from the source latest version"
|
||||
);
|
||||
|
||||
// (5) The destination must hold the exact bytes/size of the copied (v1) source version.
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(dst_bucket)
|
||||
.key(dst_key)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD destination failed");
|
||||
assert_eq!(
|
||||
head.content_length(),
|
||||
Some(v1_content.len() as i64),
|
||||
"destination size must equal the copied source version (v1)"
|
||||
);
|
||||
|
||||
let get_dst = client
|
||||
.get_object()
|
||||
.bucket(dst_bucket)
|
||||
.key(dst_key)
|
||||
.version_id(&dst_version_id)
|
||||
.send()
|
||||
.await
|
||||
.expect("GET destination failed");
|
||||
let dst_body = get_dst.body.collect().await.expect("collect destination body").into_bytes();
|
||||
assert_eq!(
|
||||
dst_body.as_ref(),
|
||||
v1_content,
|
||||
"destination bytes must exactly equal the copied source version (v1), not the latest (v2)"
|
||||
);
|
||||
|
||||
// (6) The source version copied from must remain present and independently readable.
|
||||
let get_src_v1 = client
|
||||
.get_object()
|
||||
.bucket(src_bucket)
|
||||
.key(src_key)
|
||||
.version_id(&v1_id)
|
||||
.send()
|
||||
.await
|
||||
.expect("GET source v1 failed after copy");
|
||||
let src_v1_body = get_src_v1.body.collect().await.expect("collect source v1 body").into_bytes();
|
||||
assert_eq!(src_v1_body.as_ref(), v1_content, "source v1 must remain intact after the copy");
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,10 +45,10 @@
|
||||
//! * Parity reconstruction: one data disk is taken offline
|
||||
//! (`take_disk_offline`) and the SAME object matrix is GET both ways while
|
||||
//! the EC 2+2 set rebuilds each large object from the surviving shards. The
|
||||
//! codec-streaming reader gate never inspects drive health, so the codec
|
||||
//! fast path is exercised end-to-end through reconstruction; the test
|
||||
//! asserts byte- and header-equality vs the legacy path AND that the codec
|
||||
//! phase never fell back to a duplex pipe while reconstructing.
|
||||
//! eager first/single-part setup may keep its conservative whole-request
|
||||
//! fallback when shard placement makes codec streaming unsafe, so this phase
|
||||
//! asserts byte- and header-equality vs the legacy path rather than requiring
|
||||
//! zero duplex fallbacks under degraded drive health.
|
||||
//! * Missing object: a GET for an absent key is compared across both phases
|
||||
//! to prove the error semantics (HTTP status + S3 error code) are identical
|
||||
//! — the codec env must not perturb the NoSuchKey negative path.
|
||||
@@ -475,15 +475,14 @@ mod tests {
|
||||
"ranged GET length diverged with codec streaming enabled"
|
||||
);
|
||||
|
||||
// ---- Phase B degraded: the same reconstruction, now on the codec path ----
|
||||
// Re-run the reconstruction A/B with the codec-streaming gates still
|
||||
// open. The reader gate decision is independent of drive health (it
|
||||
// never inspects disk state), so the codec fast path is exercised
|
||||
// end-to-end while the EC set rebuilds each large object from the
|
||||
// surviving shards — this is a real codec-vs-legacy reconstruction test,
|
||||
// not legacy-vs-legacy. Snapshot the duplex count first (the range GET
|
||||
// above already used the duplex path) so we can measure only the markers
|
||||
// these degraded codec GETs add.
|
||||
// ---- Phase B degraded: the same reconstruction, with codec gates open ----
|
||||
// Re-run the reconstruction A/B with codec-streaming enabled. If eager
|
||||
// first/single-part setup cannot prove the codec path is safe for the
|
||||
// surviving shards, the implementation intentionally preserves the
|
||||
// whole-request legacy fallback; later multipart parts can degrade in
|
||||
// place. This phase verifies parity-reconstructed bytes and headers,
|
||||
// while the healthy phase above remains the strict zero-duplex path
|
||||
// confirmation.
|
||||
let dup_codec_before_degraded = count_marker(&codec_log, DUPLEX_MARKER);
|
||||
harness.take_disk_offline(0)?;
|
||||
let mut codec_degraded: BTreeMap<String, GetView> = BTreeMap::new();
|
||||
@@ -511,16 +510,11 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Path confirmation under reconstruction: the codec fast path must have
|
||||
// served the reconstructed large objects without ever falling back to
|
||||
// the legacy duplex pipe. Without this, the equivalence above could be
|
||||
// legacy-vs-legacy and prove nothing about codec reconstruction.
|
||||
// Keep degraded duplex markers as diagnostic evidence only: eager setup
|
||||
// may fall back before streaming when shard safety cannot be proven.
|
||||
sleep(Duration::from_millis(300)).await;
|
||||
let dup_codec_degraded = count_marker(&codec_log, DUPLEX_MARKER).saturating_sub(dup_codec_before_degraded);
|
||||
assert_eq!(
|
||||
dup_codec_degraded, 0,
|
||||
"codec phase created {dup_codec_degraded} duplex pipe(s) while reconstructing large objects with disk0 offline; the codec fast path was not exercised under degraded reads (see {codec_log})"
|
||||
);
|
||||
info!(dup_codec_degraded, "codec phase degraded-read legacy duplex marker count");
|
||||
|
||||
info!(
|
||||
objects = baseline.len(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,573 @@
|
||||
#![cfg(test)]
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Cross-process replay / tamper acceptance for the internode NodeService v2 RPC
|
||||
//! signature (<https://github.com/rustfs/backlog/issues/1327>).
|
||||
//!
|
||||
//! # Why this exists on top of the in-process tests
|
||||
//!
|
||||
//! `http_auth.rs` unit-tests the signature algebra by calling the verifier
|
||||
//! directly. That proves the crypto, but it cannot prove that a *deployed*
|
||||
//! server actually reaches it: the request has to survive the hybrid HTTP/gRPC
|
||||
//! router, `check_auth`, tonic's own metadata handling, and finally the
|
||||
//! per-handler body-digest gate. A handler that forgets its
|
||||
//! `verify_disk_mutation_digest` call, or a router change that bypasses
|
||||
//! `check_auth`, is invisible in-process and wide open in production. These
|
||||
//! tests drive a real `rustfs` child process over a real TCP socket, so every
|
||||
//! one of those layers is in the path.
|
||||
//!
|
||||
//! # Attacker model
|
||||
//!
|
||||
//! The adversary is on-path: it observed one legitimately signed request and
|
||||
//! can resend, retarget, or edit those bytes — including individual headers.
|
||||
//! It does **not** hold the RPC secret. The test process does hold the secret,
|
||||
//! but uses it for exactly one purpose: minting the request that stands in for
|
||||
//! the captured one. Every attack then only *reuses or edits* an already-minted
|
||||
//! header set; no attack step ever re-signs. If any of these tests could pass
|
||||
//! by re-signing, it would be testing nothing.
|
||||
//!
|
||||
//! # Isolating one variable at a time
|
||||
//!
|
||||
//! Each rejection is paired with an acceptance that differs in exactly one
|
||||
//! respect, because a misconfigured harness (wrong audience, dead server,
|
||||
//! ambient strict env) would otherwise make every "rejected" assertion pass
|
||||
//! vacuously. Two pairings carry most of the weight:
|
||||
//!
|
||||
//! - Editing the body alone is caught by the *handler* (`PermissionDenied`);
|
||||
//! editing the body **and** repairing the digest header to match is caught by
|
||||
//! the *signature* (`Unauthenticated`). The second only fails closed if the
|
||||
//! digest is genuinely inside the signed scope, so the pair pins both layers.
|
||||
//! - Replaying a captured nonce is caught by the replay cache; swapping in a
|
||||
//! fresh nonce is caught by the signature. Again, only the pair proves the
|
||||
//! nonce is signed rather than merely cached.
|
||||
//!
|
||||
//! # Why `MakeVolume` against a non-existent disk
|
||||
//!
|
||||
//! Every covered handler checks the digest before touching storage, and
|
||||
//! `MakeVolume` resolves its disk *after* that check. Aiming at a disk that
|
||||
//! cannot exist gives three cleanly separable outcomes with zero side effects
|
||||
//! on the server's real data:
|
||||
//!
|
||||
//! - `Err(Unauthenticated)` — rejected by `check_auth` (signature layer).
|
||||
//! - `Err(PermissionDenied)` — rejected by the handler's body-digest gate.
|
||||
//! - `Ok(success: false)` — **authentication passed**; the request reached
|
||||
//! handler logic and only then failed on the bogus disk.
|
||||
//!
|
||||
//! # Coverage of the issue's acceptance matrix
|
||||
//!
|
||||
//! | Acceptance item | Test |
|
||||
//! |---|---|
|
||||
//! | replay a signature onto another method → reject | [`cross_method_signature_transplant_is_rejected`] |
|
||||
//! | replay same method + body after nonce consumed → reject | [`nonce_replay_of_a_captured_mutation_is_rejected`] |
|
||||
//! | nonce is signed, not just cached → reject a swapped nonce | [`swapping_in_a_fresh_nonce_is_rejected`] |
|
||||
//! | tamper one byte of the body → reject | [`tampered_mutation_body_is_rejected`] |
|
||||
//! | body digest is inside the signed scope → reject a repaired digest | [`rewriting_the_digest_to_match_a_tampered_body_is_rejected`] |
|
||||
//! | wrong destination node identity → reject | [`signature_minted_for_another_node_is_rejected`] |
|
||||
//! | mixed version: legacy-only still served, not blocked | [`legacy_only_signature_is_accepted_in_default_posture`] |
|
||||
//! | strict flip closes the signature downgrade | [`signature_strict_rejects_legacy_only_downgrade`] |
|
||||
//! | strict flip closes the body-digest downgrade, incl. v1 | [`body_digest_strict_rejects_digestless_mutation`] |
|
||||
//!
|
||||
//! Two acceptance items are deliberately left to the in-process tests. A stale
|
||||
//! timestamp cannot be forged from outside — it is inside the HMAC — so
|
||||
//! observing it would mean idling out the full freshness window. And the
|
||||
//! `signature_v1_fallback_total` / `body_digest_fallback_total` counter deltas
|
||||
//! that gate the strict flips are asserted directly in `http_auth.rs`; the
|
||||
//! legacy test below proves only the *accepted* half of that behaviour.
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use crate::storage_api::internode_rpc_signature::{
|
||||
TONIC_RPC_PREFIX, gen_signature_headers, gen_tonic_signature_headers, node_service_time_out_client_no_auth,
|
||||
};
|
||||
use http::{HeaderMap, Method};
|
||||
use rustfs_config::{
|
||||
ENV_INTERNODE_RPC_BODY_DIGEST_STRICT, ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY, ENV_INTERNODE_RPC_SIGNATURE_STRICT,
|
||||
};
|
||||
use rustfs_protos::canonical_make_volume_request_body;
|
||||
use rustfs_protos::proto_gen::node_service::{MakeVolumeRequest, MakeVolumeResponse};
|
||||
use serial_test::serial;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::error::Error;
|
||||
use tonic::{Code, Request, Status};
|
||||
use uuid::Uuid;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
/// Shared internode secret handed to both the child server and this process.
|
||||
///
|
||||
/// Must not be the default credential: `resolve_rpc_secret` fails closed on
|
||||
/// defaults (GHSA-r5qv), so a default here would break every request rather
|
||||
/// than test anything.
|
||||
const TEST_RPC_SECRET: &str = "rustfs-internode-signature-e2e-secret";
|
||||
|
||||
/// A disk path the server cannot possibly have configured, so a request that
|
||||
/// clears authentication stops harmlessly at `find_disk`.
|
||||
const ABSENT_DISK: &str = "/nonexistent/rustfs-signature-e2e-disk";
|
||||
|
||||
/// Wire names of the two v2 headers these tests edit. They are `pub(crate)` in
|
||||
/// ecstore, so they are repeated here rather than imported — [`overwrite_header`]
|
||||
/// asserts the header it replaces was actually present, which turns a rename
|
||||
/// into a loud failure instead of silently reducing an attack to a no-op.
|
||||
const CONTENT_SHA256_HEADER: &str = "x-rustfs-content-sha256";
|
||||
const NONCE_HEADER: &str = "x-rustfs-rpc-nonce";
|
||||
|
||||
/// gRPC service name carried in the signed scope, i.e. `TONIC_RPC_PREFIX`
|
||||
/// without its leading `/`.
|
||||
fn node_service_name() -> &'static str {
|
||||
TONIC_RPC_PREFIX.trim_start_matches('/')
|
||||
}
|
||||
|
||||
/// Make the RPC secret of this test process match the child server's.
|
||||
///
|
||||
/// The secret lands in a process-wide `OnceLock`, so the first writer wins for
|
||||
/// the whole test binary. Every test here uses the same constant, and the
|
||||
/// assertion turns a cross-test collision into an explicit failure instead of a
|
||||
/// confusing wall of signature rejections.
|
||||
fn align_rpc_secret_with_server() {
|
||||
let _ = rustfs_credentials::set_global_rpc_secret(TEST_RPC_SECRET.to_string());
|
||||
let effective = rustfs_credentials::try_get_rpc_token().expect("RPC secret must resolve in the test process");
|
||||
assert_eq!(
|
||||
effective, TEST_RPC_SECRET,
|
||||
"another test in this binary already fixed a different process-wide RPC secret; \
|
||||
the signature tests cannot mint requests the child server will accept"
|
||||
);
|
||||
}
|
||||
|
||||
/// Start a `rustfs` child process sharing [`TEST_RPC_SECRET`], with the rollout
|
||||
/// posture pinned explicitly.
|
||||
///
|
||||
/// The child inherits the ambient environment, so the strict gates and the
|
||||
/// replay-cache capacity are set here rather than assumed: a developer or CI
|
||||
/// runner exporting `RUSTFS_INTERNODE_RPC_*` would otherwise silently flip the
|
||||
/// posture and fail these tests for a non-security reason. `extra_env` is
|
||||
/// applied last so the strict tests can still override.
|
||||
///
|
||||
/// Uses the no-cleanup spawn so a `pkill` pattern cannot reap servers belonging
|
||||
/// to other tests running in the same binary.
|
||||
async fn start_server(extra_env: &[(&str, &str)]) -> Result<RustFSTestEnvironment, Box<dyn Error + Send + Sync>> {
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
let mut child_env = vec![
|
||||
("RUSTFS_RPC_SECRET", TEST_RPC_SECRET),
|
||||
(ENV_INTERNODE_RPC_SIGNATURE_STRICT, "false"),
|
||||
(ENV_INTERNODE_RPC_BODY_DIGEST_STRICT, "false"),
|
||||
(ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY, "1048576"),
|
||||
];
|
||||
child_env.extend_from_slice(extra_env);
|
||||
env.start_rustfs_server_without_cleanup_with_env(&child_env).await?;
|
||||
Ok(env)
|
||||
}
|
||||
|
||||
/// Stop the child and drop the cached gRPC channel for its address.
|
||||
///
|
||||
/// `node_service_time_out_client_no_auth` memoises channels in a process-global
|
||||
/// map keyed by URL, and ports handed out by `find_available_port` can recur
|
||||
/// within one test binary. Evicting here keeps a later test from inheriting a
|
||||
/// channel aimed at this test's dead server.
|
||||
async fn stop_server(mut env: RustFSTestEnvironment, url: &str) {
|
||||
env.stop_server();
|
||||
rustfs_protos::evict_failed_connection(url).await;
|
||||
}
|
||||
|
||||
/// The audience the server binds into the v2 signature: its own node authority.
|
||||
///
|
||||
/// A single-node server started with `--address 127.0.0.1:PORT` over filesystem
|
||||
/// endpoints has no URL peer set, so `init_local_peer` falls back to
|
||||
/// `host:port` — exactly the address we dialed. The positive controls below
|
||||
/// fail loudly if that ever stops holding.
|
||||
fn audience_of(env: &RustFSTestEnvironment) -> String {
|
||||
env.address.clone()
|
||||
}
|
||||
|
||||
fn hex_sha256(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes).iter().fold(String::new(), |mut acc, byte| {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(acc, "{byte:02x}");
|
||||
acc
|
||||
})
|
||||
}
|
||||
|
||||
fn make_volume_request(volume: &str) -> MakeVolumeRequest {
|
||||
MakeVolumeRequest {
|
||||
disk: ABSENT_DISK.to_string(),
|
||||
volume: volume.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_digest(request: &MakeVolumeRequest) -> String {
|
||||
hex_sha256(&canonical_make_volume_request_body(request).expect("canonical body must encode"))
|
||||
}
|
||||
|
||||
/// Mint a full v2 header set for `(audience, rpc_method, content_sha256)`.
|
||||
///
|
||||
/// This is the only place a signature is produced. Tests treat the returned map
|
||||
/// as an opaque captured artifact.
|
||||
fn mint_v2_headers(audience: &str, rpc_method: &str, content_sha256: Option<&str>) -> HeaderMap {
|
||||
gen_tonic_signature_headers(audience, node_service_name(), rpc_method, content_sha256)
|
||||
.expect("minting a v2 signature must succeed once the RPC secret is aligned")
|
||||
}
|
||||
|
||||
/// Mint the pre-v2 header set: a signature over the fixed
|
||||
/// `TONIC_RPC_PREFIX|GET|timestamp` constant, with no v2 headers at all. This is
|
||||
/// both what an un-upgraded peer sends and what an attacker sends to force a
|
||||
/// downgrade.
|
||||
fn mint_legacy_only_headers() -> HeaderMap {
|
||||
gen_signature_headers(TONIC_RPC_PREFIX, &Method::GET).expect("minting a legacy signature must succeed")
|
||||
}
|
||||
|
||||
/// Replace one header of a captured set, asserting it was there to begin with.
|
||||
fn overwrite_header(headers: &mut HeaderMap, name: &'static str, value: &str) {
|
||||
assert!(
|
||||
headers.contains_key(name),
|
||||
"minted headers must carry {name}; the wire contract changed and this attack would edit nothing"
|
||||
);
|
||||
headers.insert(name, value.parse().expect("header value must be valid"));
|
||||
}
|
||||
|
||||
/// Send `request` to the server's NodeService with exactly `headers` attached
|
||||
/// and nothing else — no interceptor adds or rewrites auth metadata, so the
|
||||
/// bytes on the wire are the ones the test chose.
|
||||
async fn call_make_volume(url: &str, request: MakeVolumeRequest, headers: HeaderMap) -> Result<MakeVolumeResponse, Status> {
|
||||
let mut client = node_service_time_out_client_no_auth(&url.to_string())
|
||||
.await
|
||||
.map_err(|err| Status::unavailable(format!("cannot reach the node service: {err}")))?;
|
||||
let mut rpc_request = Request::new(request);
|
||||
rpc_request.metadata_mut().as_mut().extend(headers);
|
||||
client.make_volume(rpc_request).await.map(|response| response.into_inner())
|
||||
}
|
||||
|
||||
/// Assert a call cleared authentication.
|
||||
///
|
||||
/// Receiving *any* `Ok` response is the load-bearing signal: both auth layers
|
||||
/// reject with a `Status`, so an `Ok` means the request reached handler logic.
|
||||
/// The failed disk lookup underneath is what keeps it side-effect free.
|
||||
fn assert_authenticated(result: Result<MakeVolumeResponse, Status>, context: &str) {
|
||||
match result {
|
||||
Ok(response) => {
|
||||
assert!(
|
||||
!response.success,
|
||||
"{context}: the absent disk {ABSENT_DISK} must not yield a successful volume creation"
|
||||
);
|
||||
assert!(
|
||||
response.error.is_some(),
|
||||
"{context}: expected the request to reach disk lookup and fail there, got no error"
|
||||
);
|
||||
}
|
||||
Err(status) => panic!(
|
||||
"{context}: the request must clear authentication, but was rejected with {:?}: {}",
|
||||
status.code(),
|
||||
status.message()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Assert a call was rejected, optionally pinning which check spoke.
|
||||
///
|
||||
/// `PermissionDenied` responses carry the reason on the wire, so the digest
|
||||
/// tests pin it and cannot be satisfied by an unrelated digest-gate failure.
|
||||
/// `Unauthenticated` is deliberately generic on the wire; those tests pin their
|
||||
/// cause structurally instead, by differing from a passing request in exactly
|
||||
/// one respect.
|
||||
fn assert_rejected(result: Result<MakeVolumeResponse, Status>, expected: Code, expected_message: Option<&str>, context: &str) {
|
||||
match result {
|
||||
Ok(response) => panic!(
|
||||
"{context}: the request must be rejected, but the server accepted it and ran the handler \
|
||||
(success={}, error={:?})",
|
||||
response.success, response.error
|
||||
),
|
||||
Err(status) => {
|
||||
assert_eq!(
|
||||
status.code(),
|
||||
expected,
|
||||
"{context}: expected {expected:?}, got {:?}: {}",
|
||||
status.code(),
|
||||
status.message()
|
||||
);
|
||||
if let Some(needle) = expected_message {
|
||||
assert!(
|
||||
status.message().contains(needle),
|
||||
"{context}: expected the rejection to cite {needle:?}, got {:?}",
|
||||
status.message()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Default posture (both strict gates off): the protections that hold without
|
||||
/// any operator flip.
|
||||
///
|
||||
/// Grouped into one server start because each case is independent and spawning
|
||||
/// a `rustfs` process per assertion would dominate the runtime.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn internode_rpc_signature_default_posture_e2e() -> TestResult {
|
||||
init_logging();
|
||||
align_rpc_secret_with_server();
|
||||
let env = start_server(&[]).await?;
|
||||
let url = env.url.clone();
|
||||
let audience = audience_of(&env);
|
||||
|
||||
signed_mutations_are_accepted(&url, &audience).await;
|
||||
unsigned_request_is_rejected(&url).await;
|
||||
cross_method_signature_transplant_is_rejected(&url, &audience).await;
|
||||
nonce_replay_of_a_captured_mutation_is_rejected(&url, &audience).await;
|
||||
swapping_in_a_fresh_nonce_is_rejected(&url, &audience).await;
|
||||
tampered_mutation_body_is_rejected(&url, &audience).await;
|
||||
rewriting_the_digest_to_match_a_tampered_body_is_rejected(&url, &audience).await;
|
||||
signature_minted_for_another_node_is_rejected(&url).await;
|
||||
legacy_only_signature_is_accepted_in_default_posture(&url).await;
|
||||
|
||||
stop_server(env, &url).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Baseline: correctly signed mutations are accepted, both with and without a
|
||||
/// body digest.
|
||||
///
|
||||
/// These anchor every rejection below. The body-bound case proves the audience
|
||||
/// the server verifies against really is the address we dialed. The digestless
|
||||
/// case is the control the transplant test needs: without it, a regression that
|
||||
/// rejected every `UNSIGNED-PAYLOAD` request would make the transplant
|
||||
/// assertion pass for entirely the wrong reason. It also documents that the
|
||||
/// default posture still serves digestless mutations.
|
||||
async fn signed_mutations_are_accepted(url: &str, audience: &str) {
|
||||
let bound = make_volume_request("signature-e2e-control-bound");
|
||||
let bound_headers = mint_v2_headers(audience, "MakeVolume", Some(&canonical_digest(&bound)));
|
||||
assert_authenticated(
|
||||
call_make_volume(url, bound, bound_headers).await,
|
||||
"a correctly signed body-bound mutation",
|
||||
);
|
||||
|
||||
let digestless = make_volume_request("signature-e2e-control-digestless");
|
||||
let digestless_headers = mint_v2_headers(audience, "MakeVolume", None);
|
||||
assert_authenticated(
|
||||
call_make_volume(url, digestless, digestless_headers).await,
|
||||
"a correctly signed digestless mutation in the default posture",
|
||||
);
|
||||
}
|
||||
|
||||
/// A request with no auth metadata at all must never reach a handler.
|
||||
async fn unsigned_request_is_rejected(url: &str) {
|
||||
let result = call_make_volume(url, make_volume_request("signature-e2e-unsigned"), HeaderMap::new()).await;
|
||||
assert_rejected(result, Code::Unauthenticated, None, "an entirely unsigned mutation");
|
||||
}
|
||||
|
||||
/// GHSA-c667 class: a signature captured from one gRPC method must not be
|
||||
/// replayable onto another.
|
||||
///
|
||||
/// Before method-path binding every NodeService call signed the same constant,
|
||||
/// so a captured `Ping` — the cheapest, least privileged call on the service —
|
||||
/// authenticated a `MakeVolume` just as well. The captured `Ping` signature is
|
||||
/// transplanted verbatim; the server recomputes the scope with
|
||||
/// `rpc_method = MakeVolume` and the HMAC no longer matches. It differs from the
|
||||
/// accepted digestless control above only in the method it was minted for.
|
||||
async fn cross_method_signature_transplant_is_rejected(url: &str, audience: &str) {
|
||||
let captured_ping = mint_v2_headers(audience, "Ping", None);
|
||||
let result = call_make_volume(url, make_volume_request("signature-e2e-transplant"), captured_ping).await;
|
||||
assert_rejected(
|
||||
result,
|
||||
Code::Unauthenticated,
|
||||
None,
|
||||
"a Ping signature transplanted onto a MakeVolume mutation",
|
||||
);
|
||||
}
|
||||
|
||||
/// A body-bound mutation must be consumable exactly once.
|
||||
///
|
||||
/// The first send establishes that the captured artifact is genuinely valid —
|
||||
/// without it, the second rejection could just mean the headers were malformed
|
||||
/// all along. The replay reuses the identical `(signature, timestamp, nonce)`
|
||||
/// well inside the freshness window, so only the server's replay cache can
|
||||
/// stop it.
|
||||
async fn nonce_replay_of_a_captured_mutation_is_rejected(url: &str, audience: &str) {
|
||||
let request = make_volume_request("signature-e2e-replay");
|
||||
let captured = mint_v2_headers(audience, "MakeVolume", Some(&canonical_digest(&request)));
|
||||
|
||||
let first = call_make_volume(url, request.clone(), captured.clone()).await;
|
||||
assert_authenticated(first, "the captured mutation on its first delivery");
|
||||
|
||||
let replayed = call_make_volume(url, request, captured).await;
|
||||
assert_rejected(
|
||||
replayed,
|
||||
Code::Unauthenticated,
|
||||
None,
|
||||
"the same captured mutation replayed after its nonce was consumed",
|
||||
);
|
||||
}
|
||||
|
||||
/// The nonce must be *signed*, not merely remembered.
|
||||
///
|
||||
/// A replay cache alone would be trivially defeated: swap in a fresh UUID and
|
||||
/// the cache has never seen it. This request is byte-identical to one the server
|
||||
/// would accept apart from that one header, so it can only be stopped by the
|
||||
/// nonce being inside the signed scope.
|
||||
async fn swapping_in_a_fresh_nonce_is_rejected(url: &str, audience: &str) {
|
||||
let request = make_volume_request("signature-e2e-nonce-swap");
|
||||
let mut captured = mint_v2_headers(audience, "MakeVolume", Some(&canonical_digest(&request)));
|
||||
overwrite_header(&mut captured, NONCE_HEADER, &Uuid::new_v4().to_string());
|
||||
|
||||
let result = call_make_volume(url, request, captured).await;
|
||||
assert_rejected(
|
||||
result,
|
||||
Code::Unauthenticated,
|
||||
None,
|
||||
"a captured mutation resent under a freshly minted nonce",
|
||||
);
|
||||
}
|
||||
|
||||
/// Editing the body of a captured request must invalidate it, in the default
|
||||
/// posture, with no operator flip required.
|
||||
///
|
||||
/// The headers are left byte-identical — including the signed digest of the
|
||||
/// original body — so `check_auth` still passes. Only the handler, recomputing
|
||||
/// the canonical body from the fields it actually received, can catch this. It
|
||||
/// is the test that fails if a handler ever loses its digest gate.
|
||||
async fn tampered_mutation_body_is_rejected(url: &str, audience: &str) {
|
||||
let signed = make_volume_request("signature-e2e-tamper-a");
|
||||
let captured = mint_v2_headers(audience, "MakeVolume", Some(&canonical_digest(&signed)));
|
||||
|
||||
// Exactly one byte of the volume name differs from what the digest covers.
|
||||
let tampered = make_volume_request("signature-e2e-tamper-b");
|
||||
let result = call_make_volume(url, tampered, captured).await;
|
||||
assert_rejected(
|
||||
result,
|
||||
Code::PermissionDenied,
|
||||
Some("RPC content SHA-256 mismatch"),
|
||||
"a mutation whose body was edited after signing",
|
||||
);
|
||||
}
|
||||
|
||||
/// The body digest must be *inside the signed scope*, not merely cross-checked
|
||||
/// by the handler.
|
||||
///
|
||||
/// This is the same tampered body as above, except the attacker also repairs the
|
||||
/// digest header so it matches what it sends — defeating the handler's
|
||||
/// comparison. The only thing left standing is the signature, which covers the
|
||||
/// digest header itself. Drop `content_sha256` from `update_signature_v2` and
|
||||
/// this is the test that goes green when it should not.
|
||||
async fn rewriting_the_digest_to_match_a_tampered_body_is_rejected(url: &str, audience: &str) {
|
||||
let signed = make_volume_request("signature-e2e-scope-a");
|
||||
let mut captured = mint_v2_headers(audience, "MakeVolume", Some(&canonical_digest(&signed)));
|
||||
|
||||
let tampered = make_volume_request("signature-e2e-scope-b");
|
||||
overwrite_header(&mut captured, CONTENT_SHA256_HEADER, &canonical_digest(&tampered));
|
||||
|
||||
let result = call_make_volume(url, tampered, captured).await;
|
||||
assert_rejected(
|
||||
result,
|
||||
Code::Unauthenticated,
|
||||
None,
|
||||
"a tampered mutation whose digest header was repaired to match",
|
||||
);
|
||||
}
|
||||
|
||||
/// A signature is bound to its destination node, so a request captured against
|
||||
/// one node cannot be aimed at another.
|
||||
///
|
||||
/// `127.0.0.1:1` stands in for a different peer; the audience is inside the
|
||||
/// HMAC, so the server's own authority no longer reproduces it.
|
||||
async fn signature_minted_for_another_node_is_rejected(url: &str) {
|
||||
let request = make_volume_request("signature-e2e-wrong-node");
|
||||
let headers = mint_v2_headers("127.0.0.1:1", "MakeVolume", Some(&canonical_digest(&request)));
|
||||
let result = call_make_volume(url, request, headers).await;
|
||||
assert_rejected(result, Code::Unauthenticated, None, "a signature minted for a different node");
|
||||
}
|
||||
|
||||
/// Rolling-upgrade compatibility: a peer that predates v2 must still be served
|
||||
/// while the strict gates are off.
|
||||
///
|
||||
/// This is the case the issue insists must not fail closed during an upgrade.
|
||||
/// It is also, honestly, the open downgrade window: an attacker can strip the
|
||||
/// v2 headers and land here too. That window is what
|
||||
/// [`signature_strict_rejects_legacy_only_downgrade`] closes.
|
||||
async fn legacy_only_signature_is_accepted_in_default_posture(url: &str) {
|
||||
let result = call_make_volume(url, make_volume_request("signature-e2e-legacy"), mint_legacy_only_headers()).await;
|
||||
assert_authenticated(result, "a legacy-only signature in the default posture");
|
||||
}
|
||||
|
||||
/// With `RUSTFS_INTERNODE_RPC_SIGNATURE_STRICT` on, the legacy downgrade lane is
|
||||
/// closed: the exact request accepted in the default posture is now refused.
|
||||
///
|
||||
/// The paired v2 positive control rules out "strict simply breaks everything".
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn signature_strict_rejects_legacy_only_downgrade() -> TestResult {
|
||||
init_logging();
|
||||
align_rpc_secret_with_server();
|
||||
let env = start_server(&[(ENV_INTERNODE_RPC_SIGNATURE_STRICT, "true")]).await?;
|
||||
let url = env.url.clone();
|
||||
let audience = audience_of(&env);
|
||||
|
||||
let downgraded = call_make_volume(&url, make_volume_request("signature-e2e-strict-legacy"), mint_legacy_only_headers()).await;
|
||||
assert_rejected(
|
||||
downgraded,
|
||||
Code::Unauthenticated,
|
||||
None,
|
||||
"a legacy-only signature once signature-strict is enabled",
|
||||
);
|
||||
|
||||
let request = make_volume_request("signature-e2e-strict-v2");
|
||||
let signed = mint_v2_headers(&audience, "MakeVolume", Some(&canonical_digest(&request)));
|
||||
assert_authenticated(
|
||||
call_make_volume(&url, request, signed).await,
|
||||
"a v2-signed mutation under signature-strict",
|
||||
);
|
||||
|
||||
stop_server(env, &url).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// With `RUSTFS_INTERNODE_RPC_BODY_DIGEST_STRICT` on, any mutation that arrives
|
||||
/// without a body digest is refused — including one that downgraded all the way
|
||||
/// to the legacy signature.
|
||||
///
|
||||
/// This gate converges independently of the signature gate, so it is exercised
|
||||
/// on its own server with signature-strict left off. Both rejected requests
|
||||
/// clear `check_auth` on their own terms (one is properly v2-signed, the other
|
||||
/// takes the still-open legacy lane), which is what pins the rejection to the
|
||||
/// handler's digest gate; the cited message confirms which check spoke.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn body_digest_strict_rejects_digestless_mutation() -> TestResult {
|
||||
init_logging();
|
||||
align_rpc_secret_with_server();
|
||||
let env = start_server(&[(ENV_INTERNODE_RPC_BODY_DIGEST_STRICT, "true")]).await?;
|
||||
let url = env.url.clone();
|
||||
let audience = audience_of(&env);
|
||||
|
||||
let digestless = mint_v2_headers(&audience, "MakeVolume", None);
|
||||
assert_rejected(
|
||||
call_make_volume(&url, make_volume_request("signature-e2e-digestless"), digestless).await,
|
||||
Code::PermissionDenied,
|
||||
Some("RPC mutation requires a body-bound v2 signature"),
|
||||
"a v2-signed but digestless mutation once body-digest-strict is enabled",
|
||||
);
|
||||
|
||||
assert_rejected(
|
||||
call_make_volume(&url, make_volume_request("signature-e2e-digestless-legacy"), mint_legacy_only_headers()).await,
|
||||
Code::PermissionDenied,
|
||||
Some("RPC mutation requires a body-bound v2 signature"),
|
||||
"a v1-downgraded mutation once body-digest-strict is enabled",
|
||||
);
|
||||
|
||||
let request = make_volume_request("signature-e2e-digest-bound");
|
||||
let bound = mint_v2_headers(&audience, "MakeVolume", Some(&canonical_digest(&request)));
|
||||
assert_authenticated(
|
||||
call_make_volume(&url, request, bound).await,
|
||||
"a body-bound mutation under body-digest-strict",
|
||||
);
|
||||
|
||||
stop_server(env, &url).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -29,6 +29,10 @@ use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::ServerSideEncryption;
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
||||
use http::header::{CONTENT_TYPE, HOST};
|
||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
use rustfs_signer::sign_v4;
|
||||
use s3s::Body;
|
||||
use serde_json;
|
||||
use std::process::{Child, Command};
|
||||
use std::time::Duration;
|
||||
@@ -67,6 +71,49 @@ pub fn sse_customer_key_md5_base64(key: &str) -> String {
|
||||
BASE64.encode(md5::compute(key).0)
|
||||
}
|
||||
|
||||
pub async fn kms_admin_request(
|
||||
base_url: &str,
|
||||
method: http::Method,
|
||||
path_and_query: &str,
|
||||
body: Option<&str>,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("{base_url}{path_and_query}");
|
||||
let uri = url.parse::<http::Uri>()?;
|
||||
let authority = uri.authority().ok_or("KMS admin URL missing authority")?.to_string();
|
||||
let mut builder = http::Request::builder()
|
||||
.method(method.clone())
|
||||
.uri(uri)
|
||||
.header(HOST, authority)
|
||||
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
|
||||
if body.is_some() {
|
||||
builder = builder.header(CONTENT_TYPE, "application/json");
|
||||
}
|
||||
|
||||
let content_len = match body {
|
||||
Some(value) => i64::try_from(value.len())?,
|
||||
None => 0,
|
||||
};
|
||||
let signed = sign_v4(builder.body(Body::empty())?, content_len, access_key, secret_key, "", "us-east-1");
|
||||
|
||||
let mut request = local_http_client().request(method.clone(), &url);
|
||||
for (name, value) in signed.headers() {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
if let Some(value) = body {
|
||||
request = request.body(value.to_owned());
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
let status = response.status();
|
||||
let response_body = response.text().await?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("{method} {path_and_query} failed with {status}: {response_body}").into());
|
||||
}
|
||||
Ok(response_body)
|
||||
}
|
||||
|
||||
// KMS-specific helper functions
|
||||
/// Configure KMS backend via admin API
|
||||
pub async fn configure_kms(
|
||||
@@ -75,8 +122,19 @@ pub async fn configure_kms(
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("{base_url}/rustfs/admin/v3/kms/configure");
|
||||
awscurl_post(&url, config_json, access_key, secret_key).await?;
|
||||
let response = kms_admin_request(
|
||||
base_url,
|
||||
http::Method::POST,
|
||||
"/rustfs/admin/v3/kms/configure",
|
||||
Some(config_json),
|
||||
access_key,
|
||||
secret_key,
|
||||
)
|
||||
.await?;
|
||||
let response: serde_json::Value = serde_json::from_str(&response)?;
|
||||
if response["success"] != true {
|
||||
return Err(format!("KMS configuration failed: {}", response["message"].as_str().unwrap_or("unknown error")).into());
|
||||
}
|
||||
info!("KMS configured successfully");
|
||||
Ok(())
|
||||
}
|
||||
@@ -87,8 +145,19 @@ pub async fn start_kms(
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("{base_url}/rustfs/admin/v3/kms/start");
|
||||
awscurl_post(&url, "{}", access_key, secret_key).await?;
|
||||
let response = kms_admin_request(
|
||||
base_url,
|
||||
http::Method::POST,
|
||||
"/rustfs/admin/v3/kms/start",
|
||||
Some("{}"),
|
||||
access_key,
|
||||
secret_key,
|
||||
)
|
||||
.await?;
|
||||
let response: serde_json::Value = serde_json::from_str(&response)?;
|
||||
if response["success"] != true {
|
||||
return Err(format!("KMS start failed: {}", response["message"].as_str().unwrap_or("unknown error")).into());
|
||||
}
|
||||
info!("KMS started successfully");
|
||||
Ok(())
|
||||
}
|
||||
@@ -99,8 +168,8 @@ pub async fn get_kms_status(
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("{base_url}/rustfs/admin/v3/kms/status");
|
||||
let status = awscurl_get(&url, access_key, secret_key).await?;
|
||||
let status =
|
||||
kms_admin_request(base_url, http::Method::GET, "/rustfs/admin/v3/kms/status", None, access_key, secret_key).await?;
|
||||
info!("KMS status retrieved: {}", status);
|
||||
Ok(status)
|
||||
}
|
||||
@@ -508,7 +577,8 @@ impl VaultTestEnvironment {
|
||||
},
|
||||
"mount_path": VAULT_TRANSIT_PATH,
|
||||
"default_key_id": VAULT_KEY_NAME,
|
||||
"skip_tls_verify": true
|
||||
"skip_tls_verify": true,
|
||||
"allow_insecure_dev_defaults": true
|
||||
})
|
||||
.to_string();
|
||||
|
||||
@@ -657,14 +727,19 @@ pub async fn test_multipart_upload_with_config(
|
||||
.build();
|
||||
|
||||
info!("🔗 Completing multipart upload");
|
||||
let complete_output = s3_client
|
||||
let mut complete_request = s3_client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&config.object_key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(completed_multipart_upload)
|
||||
.send()
|
||||
.await?;
|
||||
.multipart_upload(completed_multipart_upload);
|
||||
if let EncryptionType::SSEC { .. } = &config.encryption_type {
|
||||
complete_request = complete_request
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(sse_c_key_b64.as_ref().unwrap())
|
||||
.sse_customer_key_md5(sse_c_key_md5.as_ref().unwrap());
|
||||
}
|
||||
let complete_output = complete_request.send().await?;
|
||||
|
||||
debug!("Multipart upload finalized with ETag {:?}", complete_output.e_tag());
|
||||
|
||||
@@ -796,7 +871,8 @@ impl LocalKMSTestEnvironment {
|
||||
"backend_type": "Local",
|
||||
"key_dir": self.kms_keys_dir,
|
||||
"file_permissions": 0o600,
|
||||
"default_key_id": default_key_id
|
||||
"default_key_id": default_key_id,
|
||||
"allow_insecure_dev_defaults": true
|
||||
})
|
||||
.to_string();
|
||||
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Configured-backend validation for the KMS admin and SSE-KMS round-trip
|
||||
//! contract tracked by rustfs/backlog#1378.
|
||||
|
||||
use super::common::{
|
||||
LocalKMSTestEnvironment, VAULT_KEY_NAME, VaultTestEnvironment, configure_kms, get_kms_status, kms_admin_request, start_kms,
|
||||
};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, ServerSideEncryption, VersioningConfiguration};
|
||||
use serial_test::serial;
|
||||
use std::error::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
async fn assert_configured_status(
|
||||
base_url: &str,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
expected_backend: &str,
|
||||
expected_default_key: &str,
|
||||
) -> TestResult {
|
||||
let body = get_kms_status(base_url, access_key, secret_key).await?;
|
||||
let status: serde_json::Value = serde_json::from_str(&body)?;
|
||||
assert_eq!(status["backend_type"], expected_backend);
|
||||
assert_eq!(status["backend_status"], "healthy");
|
||||
assert_eq!(status["default_key_id"], expected_default_key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_and_verify_key(
|
||||
base_url: &str,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
let create_body = serde_json::json!({
|
||||
"key_usage": "EncryptDecrypt",
|
||||
"description": "configured KMS round-trip e2e key",
|
||||
"tags": {
|
||||
"test": "backlog-1378"
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
let created = kms_admin_request(
|
||||
base_url,
|
||||
http::Method::POST,
|
||||
"/rustfs/admin/v3/kms/keys",
|
||||
Some(&create_body),
|
||||
access_key,
|
||||
secret_key,
|
||||
)
|
||||
.await?;
|
||||
let created: serde_json::Value = serde_json::from_str(&created)?;
|
||||
assert_eq!(created["success"], true);
|
||||
let key_id = created["key_id"]
|
||||
.as_str()
|
||||
.ok_or("create KMS key response omitted key_id")?
|
||||
.to_string();
|
||||
|
||||
let described = kms_admin_request(
|
||||
base_url,
|
||||
http::Method::GET,
|
||||
&format!("/rustfs/admin/v3/kms/keys/{key_id}"),
|
||||
None,
|
||||
access_key,
|
||||
secret_key,
|
||||
)
|
||||
.await?;
|
||||
let described: serde_json::Value = serde_json::from_str(&described)?;
|
||||
assert_eq!(described["success"], true);
|
||||
assert_eq!(described["key_metadata"]["key_id"], key_id);
|
||||
assert_eq!(described["key_metadata"]["key_state"], "Enabled");
|
||||
|
||||
let listed =
|
||||
kms_admin_request(base_url, http::Method::GET, "/rustfs/admin/v3/kms/keys", None, access_key, secret_key).await?;
|
||||
let listed: serde_json::Value = serde_json::from_str(&listed)?;
|
||||
let keys = listed["keys"].as_array().ok_or("list KMS keys response omitted keys")?;
|
||||
assert!(keys.iter().any(|key| key["key_id"] == key_id), "created KMS key must appear in list");
|
||||
Ok(key_id)
|
||||
}
|
||||
|
||||
async fn assert_key_deletion_lifecycle(base_url: &str, access_key: &str, secret_key: &str, key_id: &str) -> TestResult {
|
||||
let scheduled = kms_admin_request(
|
||||
base_url,
|
||||
http::Method::DELETE,
|
||||
"/rustfs/admin/v3/kms/keys/delete",
|
||||
Some(
|
||||
&serde_json::json!({
|
||||
"key_id": key_id,
|
||||
"pending_window_in_days": 7,
|
||||
"force_immediate": false
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
access_key,
|
||||
secret_key,
|
||||
)
|
||||
.await?;
|
||||
let scheduled: serde_json::Value = serde_json::from_str(&scheduled)?;
|
||||
assert_eq!(scheduled["success"], true);
|
||||
assert!(scheduled["deletion_date"].is_string());
|
||||
|
||||
let cancelled = kms_admin_request(
|
||||
base_url,
|
||||
http::Method::POST,
|
||||
"/rustfs/admin/v3/kms/keys/cancel-deletion",
|
||||
Some(&serde_json::json!({ "key_id": key_id }).to_string()),
|
||||
access_key,
|
||||
secret_key,
|
||||
)
|
||||
.await?;
|
||||
let cancelled: serde_json::Value = serde_json::from_str(&cancelled)?;
|
||||
assert_eq!(cancelled["success"], true);
|
||||
assert_eq!(cancelled["key_metadata"]["key_state"], "Enabled");
|
||||
|
||||
let removed = kms_admin_request(
|
||||
base_url,
|
||||
http::Method::DELETE,
|
||||
"/rustfs/admin/v3/kms/keys/delete",
|
||||
Some(
|
||||
&serde_json::json!({
|
||||
"key_id": key_id,
|
||||
"force_immediate": true
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
access_key,
|
||||
secret_key,
|
||||
)
|
||||
.await?;
|
||||
let removed: serde_json::Value = serde_json::from_str(&removed)?;
|
||||
assert_eq!(removed["success"], true);
|
||||
|
||||
let listed =
|
||||
kms_admin_request(base_url, http::Method::GET, "/rustfs/admin/v3/kms/keys", None, access_key, secret_key).await?;
|
||||
let listed: serde_json::Value = serde_json::from_str(&listed)?;
|
||||
assert_eq!(listed["success"], true);
|
||||
let keys = listed["keys"]
|
||||
.as_array()
|
||||
.ok_or("list KMS keys response omitted keys after deletion")?;
|
||||
if let Some(key) = keys.iter().find(|key| key["key_id"] == key_id) {
|
||||
assert_eq!(key["status"], "PendingDeletion", "a retained force-deleted key must be pending deletion");
|
||||
let removed = kms_admin_request(
|
||||
base_url,
|
||||
http::Method::DELETE,
|
||||
"/rustfs/admin/v3/kms/keys/delete",
|
||||
Some(
|
||||
&serde_json::json!({
|
||||
"key_id": key_id,
|
||||
"force_immediate": true
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
access_key,
|
||||
secret_key,
|
||||
)
|
||||
.await?;
|
||||
let removed: serde_json::Value = serde_json::from_str(&removed)?;
|
||||
assert_eq!(removed["success"], true);
|
||||
}
|
||||
|
||||
let listed =
|
||||
kms_admin_request(base_url, http::Method::GET, "/rustfs/admin/v3/kms/keys", None, access_key, secret_key).await?;
|
||||
let listed: serde_json::Value = serde_json::from_str(&listed)?;
|
||||
assert_eq!(listed["success"], true);
|
||||
let keys = listed["keys"]
|
||||
.as_array()
|
||||
.ok_or("final list KMS keys response omitted keys after deletion")?;
|
||||
assert!(
|
||||
keys.iter().all(|key| key["key_id"] != key_id),
|
||||
"force-deleted KMS key must no longer appear in list"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_versioned_sse_kms_roundtrip_and_cleanup(
|
||||
env: &crate::common::RustFSTestEnvironment,
|
||||
key_id: &str,
|
||||
bucket_prefix: &str,
|
||||
) -> TestResult {
|
||||
let client = env.create_s3_client();
|
||||
let bucket = format!("{bucket_prefix}-{}", Uuid::new_v4().simple());
|
||||
let object = format!("configured-kms-probe/{}/object", Uuid::new_v4().simple());
|
||||
let first_body = b"configured KMS version one";
|
||||
let second_body = b"configured KMS version two";
|
||||
|
||||
client.create_bucket().bucket(&bucket).send().await?;
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(&bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let first = client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(&object)
|
||||
.server_side_encryption(ServerSideEncryption::AwsKms)
|
||||
.ssekms_key_id(key_id)
|
||||
.body(ByteStream::from_static(first_body))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(first.server_side_encryption(), Some(&ServerSideEncryption::AwsKms));
|
||||
assert_eq!(first.ssekms_key_id(), Some(key_id));
|
||||
let first_version = first.version_id().ok_or("first SSE-KMS PUT omitted version_id")?.to_string();
|
||||
|
||||
let second = client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(&object)
|
||||
.server_side_encryption(ServerSideEncryption::AwsKms)
|
||||
.ssekms_key_id(key_id)
|
||||
.body(ByteStream::from_static(second_body))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(second.server_side_encryption(), Some(&ServerSideEncryption::AwsKms));
|
||||
assert_eq!(second.ssekms_key_id(), Some(key_id));
|
||||
let second_version = second
|
||||
.version_id()
|
||||
.ok_or("second SSE-KMS PUT omitted version_id")?
|
||||
.to_string();
|
||||
assert_ne!(first_version, second_version);
|
||||
let storage_root = std::path::Path::new(&env.temp_dir);
|
||||
super::encryption_metadata_test::assert_storage_encrypted(storage_root, &bucket, &object, first_body);
|
||||
super::encryption_metadata_test::assert_storage_encrypted(storage_root, &bucket, &object, second_body);
|
||||
|
||||
for (version_id, expected) in [
|
||||
(&first_version, first_body.as_slice()),
|
||||
(&second_version, second_body.as_slice()),
|
||||
] {
|
||||
let response = client
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(&object)
|
||||
.version_id(version_id)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(response.server_side_encryption(), Some(&ServerSideEncryption::AwsKms));
|
||||
assert_eq!(response.ssekms_key_id(), Some(key_id));
|
||||
let actual = response.body.collect().await?.into_bytes();
|
||||
assert_eq!(actual.len(), expected.len());
|
||||
assert_eq!(actual.as_ref(), expected);
|
||||
}
|
||||
|
||||
let marker = client.delete_object().bucket(&bucket).key(&object).send().await?;
|
||||
assert_eq!(marker.delete_marker(), Some(true));
|
||||
assert!(marker.version_id().is_some(), "versioned delete must create a delete marker");
|
||||
|
||||
let before_cleanup = client.list_object_versions().bucket(&bucket).prefix(&object).send().await?;
|
||||
assert_eq!(
|
||||
before_cleanup
|
||||
.versions()
|
||||
.iter()
|
||||
.filter(|version| version.key() == Some(object.as_str()))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
before_cleanup
|
||||
.delete_markers()
|
||||
.iter()
|
||||
.filter(|delete_marker| delete_marker.key() == Some(object.as_str()))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(&bucket)
|
||||
.key(&object)
|
||||
.customize()
|
||||
.mutate_request(|request| {
|
||||
request.headers_mut().insert("x-rustfs-force-delete", "true");
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let after_cleanup = client.list_object_versions().bucket(&bucket).prefix(&object).send().await?;
|
||||
assert!(
|
||||
after_cleanup
|
||||
.versions()
|
||||
.iter()
|
||||
.all(|version| version.key() != Some(object.as_str())),
|
||||
"force cleanup must remove every encrypted object version"
|
||||
);
|
||||
assert!(
|
||||
after_cleanup
|
||||
.delete_markers()
|
||||
.iter()
|
||||
.all(|delete_marker| delete_marker.key() != Some(object.as_str())),
|
||||
"force cleanup must remove the delete marker"
|
||||
);
|
||||
let head_error = match client.head_object().bucket(&bucket).key(&object).send().await {
|
||||
Ok(_) => return Err("force-cleaned probe object remained readable".into()),
|
||||
Err(error) => error,
|
||||
};
|
||||
let service_error = head_error
|
||||
.as_service_error()
|
||||
.ok_or_else(|| format!("force-cleaned HEAD failed with a non-service error: {head_error}"))?;
|
||||
assert!(
|
||||
service_error.is_not_found(),
|
||||
"force-cleaned HEAD returned the wrong service error: {service_error:?}"
|
||||
);
|
||||
|
||||
client.delete_bucket().bucket(&bucket).send().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_configured_local_kms_admin_and_versioned_cleanup() -> TestResult {
|
||||
let mut env = LocalKMSTestEnvironment::new().await?;
|
||||
env.base_env.start_rustfs_server(Vec::new()).await?;
|
||||
|
||||
let start_error = match start_kms(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await {
|
||||
Ok(()) => return Err("unconfigured KMS start unexpectedly succeeded".into()),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(
|
||||
start_error.to_string().contains("no configuration provided"),
|
||||
"unconfigured KMS start returned the wrong business error: {start_error}"
|
||||
);
|
||||
|
||||
let insecure_config = serde_json::json!({
|
||||
"backend_type": "Local",
|
||||
"key_dir": env.kms_keys_dir,
|
||||
"file_permissions": 0o600,
|
||||
"default_key_id": "rustfs-e2e-test-default-key"
|
||||
})
|
||||
.to_string();
|
||||
let configure_error =
|
||||
match configure_kms(&env.base_env.url, &insecure_config, &env.base_env.access_key, &env.base_env.secret_key).await {
|
||||
Ok(()) => return Err("insecure Local KMS configuration unexpectedly succeeded".into()),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(
|
||||
configure_error.to_string().contains("requires a master key"),
|
||||
"invalid Local KMS configuration returned the wrong business error: {configure_error}"
|
||||
);
|
||||
|
||||
let default_key_id = env.configure_local_kms().await?;
|
||||
start_kms(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await?;
|
||||
assert_configured_status(
|
||||
&env.base_env.url,
|
||||
&env.base_env.access_key,
|
||||
&env.base_env.secret_key,
|
||||
"local",
|
||||
&default_key_id,
|
||||
)
|
||||
.await?;
|
||||
let key_id = create_and_verify_key(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await?;
|
||||
assert_versioned_sse_kms_roundtrip_and_cleanup(&env.base_env, &key_id, "kms-local-configured").await?;
|
||||
assert_key_deletion_lifecycle(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key, &key_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[ignore = "requires a Vault binary"]
|
||||
async fn test_configured_vault_kms_admin_and_versioned_cleanup() -> TestResult {
|
||||
let mut env = VaultTestEnvironment::new().await?;
|
||||
env.start_vault().await?;
|
||||
env.setup_vault_transit().await?;
|
||||
env.start_rustfs_for_vault().await?;
|
||||
env.configure_vault_transit_kms().await?;
|
||||
start_kms(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await?;
|
||||
assert_configured_status(
|
||||
&env.base_env.url,
|
||||
&env.base_env.access_key,
|
||||
&env.base_env.secret_key,
|
||||
"vault-transit",
|
||||
VAULT_KEY_NAME,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let key_id = create_and_verify_key(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await?;
|
||||
assert_ne!(key_id, VAULT_KEY_NAME, "key lifecycle test must create a distinct Vault key");
|
||||
assert_versioned_sse_kms_roundtrip_and_cleanup(&env.base_env, &key_id, "kms-vault-configured").await?;
|
||||
assert_key_deletion_lifecycle(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key, &key_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -42,7 +42,7 @@ fn assert_managed_encryption_metadata_hidden(metadata: Option<&HashMap<String, S
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_storage_encrypted(storage_root: &std::path::Path, bucket: &str, key: &str, plaintext: &[u8]) {
|
||||
pub(super) fn assert_storage_encrypted(storage_root: &std::path::Path, bucket: &str, key: &str, plaintext: &[u8]) {
|
||||
let mut stack = VecDeque::from([storage_root.to_path_buf()]);
|
||||
let mut scanned = 0;
|
||||
let mut plaintext_path: Option<std::path::PathBuf> = None;
|
||||
|
||||
@@ -625,6 +625,9 @@ async fn test_multipart_upload_with_sse_c(
|
||||
.bucket(bucket)
|
||||
.key(object_key)
|
||||
.upload_id(upload_id)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&key_b64)
|
||||
.sse_customer_key_md5(&key_md5)
|
||||
.multipart_upload(completed_multipart_upload)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
@@ -50,3 +50,6 @@ mod encryption_metadata_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod copy_object_version_restore_sse_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod configured_roundtrip_test;
|
||||
|
||||
@@ -566,14 +566,19 @@ async fn test_multipart_encryption_type(
|
||||
.set_parts(Some(completed_parts))
|
||||
.build();
|
||||
|
||||
let _complete_output = s3_client
|
||||
let mut complete_request = s3_client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(object_key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(completed_multipart_upload)
|
||||
.send()
|
||||
.await?;
|
||||
.multipart_upload(completed_multipart_upload);
|
||||
if matches!(encryption_type, EncryptionType::SSEC) {
|
||||
complete_request = complete_request
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(sse_c_key.as_ref().unwrap())
|
||||
.sse_customer_key_md5(sse_c_md5.as_ref().unwrap());
|
||||
}
|
||||
let _complete_output = complete_request.send().await?;
|
||||
|
||||
// Download and verify
|
||||
let mut get_request = s3_client.get_object().bucket(bucket).key(object_key);
|
||||
|
||||
@@ -79,10 +79,20 @@ mod bucket_policy_check_test;
|
||||
#[cfg(test)]
|
||||
mod security_boundary_test;
|
||||
|
||||
// Cross-process replay/tamper acceptance for the internode NodeService v2 RPC
|
||||
// signature (backlog#1327): method-path transplant, nonce replay, body tampering
|
||||
// and the two strict rollout flips, all against a real spawned server.
|
||||
#[cfg(test)]
|
||||
mod internode_rpc_signature_e2e_test;
|
||||
|
||||
// Opt-in per-client S3 API rate limiting (backlog#1191)
|
||||
#[cfg(test)]
|
||||
mod api_rate_limit_test;
|
||||
|
||||
// Opt-in global connection cap on the main listener (backlog#1191 follow-up)
|
||||
#[cfg(test)]
|
||||
mod connection_cap_test;
|
||||
|
||||
// Admin authorization gate: non-admin denial + root-credential lifecycle (backlog#1151 sec-4)
|
||||
#[cfg(test)]
|
||||
mod admin_auth_test;
|
||||
@@ -91,6 +101,9 @@ mod admin_auth_test;
|
||||
#[cfg(test)]
|
||||
mod existing_object_tag_policy_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod sts_query_compat_test;
|
||||
|
||||
// Regression tests for Issue #2036: anonymous access with PublicAccessBlock
|
||||
#[cfg(test)]
|
||||
mod anonymous_access_test;
|
||||
@@ -163,6 +176,10 @@ mod cluster_concurrency_test;
|
||||
#[cfg(test)]
|
||||
mod cluster_multidrive_pool_test;
|
||||
|
||||
// backlog#1433: real 4-node EC boundary gate for inline storage and GET paths.
|
||||
#[cfg(test)]
|
||||
mod inline_fast_path_cluster_test;
|
||||
|
||||
// PutObject / MultipartUpload with checksum (Content-MD5, x-amz-checksum-*)
|
||||
#[cfg(test)]
|
||||
mod checksum_upload_test;
|
||||
@@ -183,9 +200,24 @@ mod heal_erasure_disk_rebuild_test;
|
||||
#[cfg(test)]
|
||||
mod copy_object_metadata_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod copy_object_tagging_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod copy_object_version_restore_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod copy_object_checksum_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod ssec_copy_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod multipart_storage_class_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod storage_class_capability_test;
|
||||
|
||||
// S3 dummy-compat bucket API tests
|
||||
#[cfg(test)]
|
||||
mod bucket_logging_test;
|
||||
@@ -235,6 +267,9 @@ mod console_smoke_test;
|
||||
#[cfg(test)]
|
||||
mod admin_iam_crud_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod admin_pools_test;
|
||||
|
||||
// Replication extension end-to-end regression tests
|
||||
#[cfg(test)]
|
||||
mod replication_extension_test;
|
||||
|
||||
@@ -53,6 +53,17 @@ fn sse_customer_key_md5_base64(key: &str) -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode(md5::compute(key).0)
|
||||
}
|
||||
|
||||
/// Env var consumed by the local SSE-S3 DEK provider when KMS is not configured.
|
||||
///
|
||||
/// Since rustfs#3564 the server fails closed on managed SSE (SSE-S3 or
|
||||
/// bucket-default encryption) unless KMS is configured or this master key is
|
||||
/// provided, so tests exercising managed SSE on a bare server must seed it.
|
||||
const LOCAL_SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
|
||||
|
||||
fn local_sse_master_key_value() -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode([0x42u8; 32])
|
||||
}
|
||||
|
||||
async fn make_tar(files: &[(&str, &[u8])], dirs: &[&str]) -> Vec<u8> {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let mut builder = tokio_tar::Builder::new(buf);
|
||||
@@ -105,7 +116,11 @@ async fn make_tar_with_pax_entry(path: &str, data: &[u8], mtime: Option<u64>, pa
|
||||
pax_payload.extend(build_pax_record(key, value));
|
||||
}
|
||||
|
||||
let mut pax_header = tokio_tar::Header::new_gnu();
|
||||
// Pax extension entries must carry a POSIX ustar header — this is what real
|
||||
// tar writers emit, and the server-side reader rejects an XHeader typeflag on
|
||||
// GNU-format headers ("extension typeflag is not permitted on an unrecognized
|
||||
// header").
|
||||
let mut pax_header = tokio_tar::Header::new_ustar();
|
||||
pax_header.set_entry_type(tokio_tar::EntryType::XHeader);
|
||||
pax_header.set_size(pax_payload.len() as u64);
|
||||
pax_header.set_mode(0o644);
|
||||
@@ -926,7 +941,9 @@ async fn test_anonymous_post_object_accepts_sse_s3() -> Result<(), Box<dyn std::
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let master_key = local_sse_master_key_value();
|
||||
env.start_rustfs_server_with_env(vec![], &[(LOCAL_SSE_MASTER_KEY_ENV, master_key.as_str())])
|
||||
.await?;
|
||||
|
||||
let bucket = "anon-post-sse-s3";
|
||||
let object_key = "post-sse-s3-object.txt";
|
||||
@@ -982,7 +999,9 @@ async fn test_anonymous_post_object_uses_bucket_default_sse_s3() -> Result<(), B
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let master_key = local_sse_master_key_value();
|
||||
env.start_rustfs_server_with_env(vec![], &[(LOCAL_SSE_MASTER_KEY_ENV, master_key.as_str())])
|
||||
.await?;
|
||||
|
||||
let bucket = "anon-post-default-sse-s3";
|
||||
let object_key = "post-default-sse-s3-object.txt";
|
||||
@@ -1053,7 +1072,9 @@ async fn test_anonymous_post_object_uses_bucket_default_sse_kms() -> Result<(),
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let master_key = local_sse_master_key_value();
|
||||
env.start_rustfs_server_with_env(vec![], &[(LOCAL_SSE_MASTER_KEY_ENV, master_key.as_str())])
|
||||
.await?;
|
||||
|
||||
let bucket = "anon-post-default-sse-kms";
|
||||
let object_key = "post-default-sse-kms-object.txt";
|
||||
@@ -1172,15 +1193,24 @@ async fn test_anonymous_post_object_rejects_sse_s3_policy_mismatch() -> Result<(
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_sse_s3_missing_from_policy_conditions()
|
||||
async fn test_anonymous_post_object_accepts_sse_s3_missing_from_policy_conditions()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
// MinIO-compatible POST-policy validation (s3s-project/s3s#608) exempts the
|
||||
// x-amz-server-side-encryption* form fields from the "every form field must
|
||||
// appear in the policy conditions" rule, so an SSE-S3 field that the policy
|
||||
// does not mention is accepted and encryption is applied. When the policy
|
||||
// does cover the field, a value mismatch is still rejected — see
|
||||
// test_anonymous_post_object_rejects_sse_s3_policy_mismatch.
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let master_key = local_sse_master_key_value();
|
||||
env.start_rustfs_server_with_env(vec![], &[(LOCAL_SSE_MASTER_KEY_ENV, master_key.as_str())])
|
||||
.await?;
|
||||
|
||||
let bucket = "anon-post-sse-s3-missing";
|
||||
let object_key = "post-sse-s3-missing-object.txt";
|
||||
let expected_body = b"post-sse-s3-missing".to_vec();
|
||||
|
||||
let admin_client = env.create_s3_client();
|
||||
admin_client.create_bucket().bucket(bucket).send().await?;
|
||||
@@ -1198,7 +1228,7 @@ async fn test_anonymous_post_object_rejects_sse_s3_missing_from_policy_condition
|
||||
.text("x-amz-server-side-encryption", "AES256")
|
||||
.part(
|
||||
"file",
|
||||
reqwest::multipart::Part::bytes(b"post-sse-s3-missing".to_vec())
|
||||
reqwest::multipart::Part::bytes(expected_body.clone())
|
||||
.file_name("upload.txt")
|
||||
.mime_str("text/plain")?,
|
||||
);
|
||||
@@ -1212,11 +1242,15 @@ async fn test_anonymous_post_object_rejects_sse_s3_missing_from_policy_condition
|
||||
let status = post_resp.status();
|
||||
let response_body = post_resp.text().await?;
|
||||
|
||||
assert_eq!(status, reqwest::StatusCode::FORBIDDEN);
|
||||
assert!(
|
||||
response_body.contains("<Code>AccessDenied</Code>"),
|
||||
"response should contain AccessDenied code, got: {response_body}"
|
||||
);
|
||||
assert_eq!(status, reqwest::StatusCode::NO_CONTENT);
|
||||
assert!(response_body.is_empty(), "204 response should not contain a body, got: {response_body}");
|
||||
|
||||
let head = admin_client.head_object().bucket(bucket).key(object_key).send().await?;
|
||||
assert_eq!(head.server_side_encryption().map(|value| value.as_str()), Some("AES256"));
|
||||
|
||||
let uploaded = admin_client.get_object().bucket(bucket).key(object_key).send().await?;
|
||||
let uploaded = uploaded.body.collect().await?.into_bytes();
|
||||
assert_eq!(uploaded.as_ref(), expected_body.as_slice());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1233,7 +1267,7 @@ async fn test_anonymous_post_object_accepts_storage_class_exact_policy_match()
|
||||
let bucket = "anon-post-storage-class";
|
||||
let object_key = "post-storage-class-object.txt";
|
||||
let expected_body = b"post-storage-class-body".to_vec();
|
||||
let storage_class = "STANDARD_IA";
|
||||
let storage_class = "REDUCED_REDUNDANCY";
|
||||
|
||||
let admin_client = env.create_s3_client();
|
||||
admin_client.create_bucket().bucket(bucket).send().await?;
|
||||
@@ -5041,7 +5075,9 @@ async fn test_signed_put_object_extract_preserves_sse_s3_and_redirect() -> Resul
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key.as_str())])
|
||||
.await?;
|
||||
|
||||
let bucket = "signed-extract-sse-s3-redirect";
|
||||
let archive_key = "encrypted-metadata.tar";
|
||||
@@ -5102,7 +5138,7 @@ async fn test_signed_put_object_extract_preserves_storage_class() -> Result<(),
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(archive_key)
|
||||
.storage_class(aws_sdk_s3::types::StorageClass::StandardIa)
|
||||
.storage_class(aws_sdk_s3::types::StorageClass::ReducedRedundancy)
|
||||
.body(ByteStream::from(tar_bytes))
|
||||
.customize()
|
||||
.mutate_request(move |req| {
|
||||
@@ -5119,7 +5155,7 @@ async fn test_signed_put_object_extract_preserves_storage_class() -> Result<(),
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
assert_eq!(head.storage_class().map(|value| value.as_str()), Some("STANDARD_IA"));
|
||||
assert_eq!(head.storage_class().map(|value| value.as_str()), Some("REDUCED_REDUNDANCY"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -5318,7 +5354,9 @@ async fn test_signed_put_object_extract_uses_bucket_default_sse_s3() -> Result<(
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key.as_str())])
|
||||
.await?;
|
||||
|
||||
let bucket = "signed-extract-default-sse-s3";
|
||||
let archive_key = "default-encryption.tar";
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! CreateMultipartUpload storage-class persistence regression tests.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart, StorageClass};
|
||||
|
||||
const PART_SIZE: usize = 5 * 1024 * 1024;
|
||||
|
||||
async fn assert_completed_object(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
expected_storage_class: &str,
|
||||
expected_body: &[u8],
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let head = client.head_object().bucket(bucket).key(key).send().await?;
|
||||
let expected_head_class = (expected_storage_class != "STANDARD").then_some(expected_storage_class);
|
||||
assert_eq!(
|
||||
head.storage_class().map(StorageClass::as_str),
|
||||
expected_head_class,
|
||||
"HeadObject should use S3's implicit STANDARD representation"
|
||||
);
|
||||
|
||||
let listed = client.list_objects_v2().bucket(bucket).prefix(key).send().await?;
|
||||
let object = listed
|
||||
.contents()
|
||||
.iter()
|
||||
.find(|object| object.key() == Some(key))
|
||||
.ok_or("completed multipart object missing from ListObjectsV2")?;
|
||||
assert_eq!(
|
||||
object.storage_class().map(|storage_class| storage_class.as_str()),
|
||||
Some(expected_storage_class),
|
||||
"ListObjectsV2 should report the completed object's storage class"
|
||||
);
|
||||
|
||||
let body = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await?
|
||||
.body
|
||||
.collect()
|
||||
.await?
|
||||
.into_bytes();
|
||||
assert_eq!(body.as_ref(), expected_body, "completed multipart body should be byte-exact");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_upload_preserves_standard_and_rrs_across_retry_and_resume()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(Vec::new()).await?;
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "multipart-storage-class-retry";
|
||||
env.create_test_bucket(bucket).await?;
|
||||
|
||||
for storage_class in [StorageClass::Standard, StorageClass::ReducedRedundancy] {
|
||||
let class_name = storage_class.as_str();
|
||||
let key = format!("retry-{class_name}.bin");
|
||||
let create = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.storage_class(storage_class.clone())
|
||||
.content_type("application/octet-stream")
|
||||
.metadata("content-type", "user-content-type")
|
||||
.metadata("x-amz-storage-class", "user-storage-class")
|
||||
.send()
|
||||
.await?;
|
||||
let upload_id = create.upload_id().ok_or("CreateMultipartUpload returned no upload ID")?;
|
||||
|
||||
let original_part = vec![b'a'; PART_SIZE];
|
||||
let first_attempt = client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from(original_part))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let resumed_client = env.create_s3_client();
|
||||
let before_retry = resumed_client
|
||||
.list_parts()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(before_retry.storage_class().map(StorageClass::as_str), Some(class_name));
|
||||
assert_eq!(before_retry.parts().len(), 1, "resume should find the previously uploaded part");
|
||||
|
||||
let retried_part = vec![b'b'; PART_SIZE];
|
||||
let retry = resumed_client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from(retried_part.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
assert_ne!(
|
||||
first_attempt.e_tag(),
|
||||
retry.e_tag(),
|
||||
"retrying the same part number with different bytes should replace the part"
|
||||
);
|
||||
|
||||
let tail = format!("-tail-{class_name}").into_bytes();
|
||||
let second = resumed_client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(2)
|
||||
.body(ByteStream::from(tail.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
let after_retry = resumed_client
|
||||
.list_parts()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(after_retry.storage_class().map(StorageClass::as_str), Some(class_name));
|
||||
assert_eq!(after_retry.parts().len(), 2);
|
||||
assert_eq!(after_retry.parts()[0].e_tag(), retry.e_tag());
|
||||
|
||||
resumed_client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.parts(
|
||||
CompletedPart::builder()
|
||||
.part_number(1)
|
||||
.set_e_tag(retry.e_tag().map(str::to_owned))
|
||||
.build(),
|
||||
)
|
||||
.parts(
|
||||
CompletedPart::builder()
|
||||
.part_number(2)
|
||||
.set_e_tag(second.e_tag().map(str::to_owned))
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let mut expected_body = retried_part;
|
||||
expected_body.extend_from_slice(&tail);
|
||||
assert_completed_object(&resumed_client, bucket, &key, class_name, &expected_body).await?;
|
||||
let metadata_head = resumed_client.head_object().bucket(bucket).key(&key).send().await?;
|
||||
assert_eq!(metadata_head.content_type(), Some("application/octet-stream"));
|
||||
assert_eq!(
|
||||
metadata_head.metadata().and_then(|metadata| metadata.get("content-type")),
|
||||
Some(&"user-content-type".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
metadata_head
|
||||
.metadata()
|
||||
.and_then(|metadata| metadata.get("x-amz-storage-class")),
|
||||
Some(&"user-storage-class".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_copy_preserves_standard_and_rrs() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(Vec::new()).await?;
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "multipart-storage-class-copy";
|
||||
let source_key = "source.bin";
|
||||
let source_body = vec![b'c'; 1024 * 1024];
|
||||
env.create_test_bucket(bucket).await?;
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(source_key)
|
||||
.body(ByteStream::from(source_body.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
for storage_class in [StorageClass::Standard, StorageClass::ReducedRedundancy] {
|
||||
let class_name = storage_class.as_str();
|
||||
let key = format!("copy-{class_name}.bin");
|
||||
let create = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.storage_class(storage_class.clone())
|
||||
.send()
|
||||
.await?;
|
||||
let upload_id = create.upload_id().ok_or("CreateMultipartUpload returned no upload ID")?;
|
||||
let copied = client
|
||||
.upload_part_copy()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.copy_source(format!("{bucket}/{source_key}"))
|
||||
.send()
|
||||
.await?;
|
||||
let e_tag = copied
|
||||
.copy_part_result()
|
||||
.and_then(|result| result.e_tag())
|
||||
.ok_or("UploadPartCopy returned no ETag")?;
|
||||
|
||||
let parts = client
|
||||
.list_parts()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(parts.storage_class().map(StorageClass::as_str), Some(class_name));
|
||||
assert_eq!(parts.parts().len(), 1);
|
||||
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.parts(CompletedPart::builder().part_number(1).e_tag(e_tag).build())
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_completed_object(&client, bucket, &key, class_name, &source_body).await?;
|
||||
}
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_and_aborted_uploads_leave_no_session_or_object() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(Vec::new()).await?;
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "multipart-storage-class-errors";
|
||||
let invalid_key = "invalid.bin";
|
||||
let aborted_key = "aborted.bin";
|
||||
env.create_test_bucket(bucket).await?;
|
||||
|
||||
let invalid = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(invalid_key)
|
||||
.storage_class(StorageClass::from("INVALID"))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("invalid storage class should be rejected");
|
||||
assert_eq!(
|
||||
invalid.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("InvalidStorageClass")
|
||||
);
|
||||
let after_invalid = client
|
||||
.list_multipart_uploads()
|
||||
.bucket(bucket)
|
||||
.prefix(invalid_key)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
after_invalid.uploads().is_empty(),
|
||||
"validation failure must not create a multipart session"
|
||||
);
|
||||
|
||||
let create = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(aborted_key)
|
||||
.storage_class(StorageClass::ReducedRedundancy)
|
||||
.send()
|
||||
.await?;
|
||||
let upload_id = create.upload_id().ok_or("CreateMultipartUpload returned no upload ID")?;
|
||||
client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(aborted_key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from_static(b"aborted multipart part"))
|
||||
.send()
|
||||
.await?;
|
||||
let before_abort = client
|
||||
.list_parts()
|
||||
.bucket(bucket)
|
||||
.key(aborted_key)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(before_abort.storage_class().map(StorageClass::as_str), Some("REDUCED_REDUNDANCY"));
|
||||
|
||||
client
|
||||
.abort_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(aborted_key)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
let after_abort = client
|
||||
.list_parts()
|
||||
.bucket(bucket)
|
||||
.key(aborted_key)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("aborted upload should not be resumable");
|
||||
assert_eq!(after_abort.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchUpload"));
|
||||
let remaining_uploads = client
|
||||
.list_multipart_uploads()
|
||||
.bucket(bucket)
|
||||
.prefix(aborted_key)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(remaining_uploads.uploads().is_empty(), "abort should remove the multipart session");
|
||||
let aborted_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(aborted_key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("aborted upload should not create an object");
|
||||
assert_eq!(aborted_head.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -39,10 +39,17 @@ use local_ip_address::local_ip;
|
||||
use reqwest::StatusCode;
|
||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
use rustfs_signer::sign_v4;
|
||||
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
|
||||
use s3s::Body;
|
||||
use serde_json::Value;
|
||||
use serial_test::serial;
|
||||
use std::error::Error;
|
||||
use std::path::Path;
|
||||
use std::sync::{
|
||||
Arc, Once,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::thread;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::mpsc;
|
||||
@@ -64,6 +71,11 @@ fn target_arn(target_name: &str) -> String {
|
||||
format!("arn:rustfs:sqs:{NOTIFY_REGION}:{target_name}:webhook")
|
||||
}
|
||||
|
||||
fn endpoint_origin(endpoint: &str) -> Result<String, BoxError> {
|
||||
let parsed = reqwest::Url::parse(endpoint)?;
|
||||
Ok(parsed.origin().ascii_serialization())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-test HTTP event receiver
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -147,7 +159,175 @@ async fn spawn_event_collector() -> Result<(String, mpsc::UnboundedReceiver<Valu
|
||||
let endpoint_ip = local_ip()?;
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let handle = serve_event_collector(listener, tx);
|
||||
Ok((format!("http://{endpoint_ip}.nip.io:{port}/events"), rx, handle))
|
||||
Ok((format!("http://{}/events", std::net::SocketAddr::new(endpoint_ip, port)), rx, handle))
|
||||
}
|
||||
|
||||
struct HttpsEventCollector {
|
||||
endpoint: String,
|
||||
running: Arc<AtomicBool>,
|
||||
handle: Option<thread::JoinHandle<()>>,
|
||||
events: mpsc::UnboundedReceiver<Value>,
|
||||
}
|
||||
|
||||
impl HttpsEventCollector {
|
||||
fn endpoint(&self) -> &str {
|
||||
&self.endpoint
|
||||
}
|
||||
|
||||
fn events_mut(&mut self) -> &mut mpsc::UnboundedReceiver<Value> {
|
||||
&mut self.events
|
||||
}
|
||||
|
||||
fn shutdown(&mut self) -> TestResult {
|
||||
self.running.store(false, Ordering::Relaxed);
|
||||
if let Ok(parsed) = self.endpoint.parse::<reqwest::Url>()
|
||||
&& let Some(port) = parsed.port()
|
||||
{
|
||||
let _ = std::net::TcpStream::connect(("127.0.0.1", port));
|
||||
}
|
||||
if let Some(handle) = self.handle.take() {
|
||||
handle.join().map_err(|_| "https event collector thread panicked")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HttpsEventCollector {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_https_event_collector(ca_path: &Path) -> Result<HttpsEventCollector, BoxError> {
|
||||
use rustls::{
|
||||
ServerConfig,
|
||||
pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer},
|
||||
};
|
||||
use std::io::ErrorKind;
|
||||
use std::net::TcpListener as StdTcpListener;
|
||||
|
||||
static INSTALL_CRYPTO_PROVIDER: Once = Once::new();
|
||||
INSTALL_CRYPTO_PROVIDER.call_once(|| {
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
});
|
||||
|
||||
let listener = StdTcpListener::bind("0.0.0.0:0")?;
|
||||
listener.set_nonblocking(true)?;
|
||||
let addr = listener.local_addr()?;
|
||||
let endpoint_ip = local_ip()?;
|
||||
let endpoint_host = endpoint_ip.to_string();
|
||||
|
||||
let rcgen::CertifiedKey { cert, signing_key } = rcgen::generate_simple_self_signed(vec![endpoint_host])?;
|
||||
std::fs::write(ca_path, cert.pem())?;
|
||||
|
||||
let cert_chain = vec![cert.der().clone()];
|
||||
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(signing_key.serialize_der()));
|
||||
let server_config = Arc::new(
|
||||
ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(cert_chain, key_der)?,
|
||||
);
|
||||
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let server_running = Arc::clone(&running);
|
||||
let (tx, events) = mpsc::unbounded_channel();
|
||||
let handle = thread::spawn(move || {
|
||||
let mut connections = Vec::new();
|
||||
while server_running.load(Ordering::Relaxed) {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => {
|
||||
let config = Arc::clone(&server_config);
|
||||
let tx = tx.clone();
|
||||
connections.push(thread::spawn(move || {
|
||||
let _ = handle_https_request(stream, config, tx);
|
||||
}));
|
||||
}
|
||||
Err(err) if err.kind() == ErrorKind::WouldBlock => thread::sleep(Duration::from_millis(20)),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
for connection in connections {
|
||||
let _ = connection.join();
|
||||
}
|
||||
});
|
||||
|
||||
Ok(HttpsEventCollector {
|
||||
endpoint: format!("https://{}/events", std::net::SocketAddr::new(endpoint_ip, addr.port())),
|
||||
running,
|
||||
handle: Some(handle),
|
||||
events,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_sync_http_message<R: std::io::Read>(stream: &mut R) -> Result<(String, Vec<u8>), BoxError> {
|
||||
let mut buffer = Vec::new();
|
||||
let mut chunk = [0_u8; 4096];
|
||||
let header_end = loop {
|
||||
let read = stream.read(&mut chunk)?;
|
||||
if read == 0 {
|
||||
return Err("connection closed before request headers were complete".into());
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..read]);
|
||||
if let Some(pos) = buffer.windows(4).position(|window| window == b"\r\n\r\n") {
|
||||
break pos;
|
||||
}
|
||||
};
|
||||
|
||||
let header_text = std::str::from_utf8(&buffer[..header_end])?;
|
||||
let mut lines = header_text.split("\r\n");
|
||||
let method = lines
|
||||
.next()
|
||||
.and_then(|line| line.split_whitespace().next())
|
||||
.ok_or("missing request method")?
|
||||
.to_string();
|
||||
let mut content_length = 0usize;
|
||||
for line in lines {
|
||||
if let Some((name, value)) = line.split_once(':')
|
||||
&& name.trim().eq_ignore_ascii_case("content-length")
|
||||
{
|
||||
content_length = value.trim().parse()?;
|
||||
}
|
||||
}
|
||||
|
||||
let body_offset = header_end + 4;
|
||||
while buffer.len().saturating_sub(body_offset) < content_length {
|
||||
let read = stream.read(&mut chunk)?;
|
||||
if read == 0 {
|
||||
return Err("connection closed before request body was complete".into());
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
Ok((method, buffer[body_offset..body_offset + content_length].to_vec()))
|
||||
}
|
||||
|
||||
fn handle_https_request(
|
||||
stream: std::net::TcpStream,
|
||||
server_config: Arc<rustls::ServerConfig>,
|
||||
tx: mpsc::UnboundedSender<Value>,
|
||||
) -> Result<(), BoxError> {
|
||||
use std::io::Write;
|
||||
|
||||
stream.set_nonblocking(false)?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
|
||||
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
|
||||
let connection = rustls::ServerConnection::new(server_config)?;
|
||||
let mut tls_stream = rustls::StreamOwned::new(connection, stream);
|
||||
let (method, body) = read_sync_http_message(&mut tls_stream)?;
|
||||
let response = "HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n";
|
||||
tls_stream.write_all(response.as_bytes())?;
|
||||
tls_stream.flush()?;
|
||||
tls_stream.conn.send_close_notify();
|
||||
while tls_stream.conn.wants_write() {
|
||||
tls_stream.conn.write_tls(&mut tls_stream.sock)?;
|
||||
}
|
||||
let _ = tls_stream.sock.shutdown(std::net::Shutdown::Write);
|
||||
if method == "POST"
|
||||
&& !body.is_empty()
|
||||
&& let Ok(event) = serde_json::from_slice(&body)
|
||||
{
|
||||
let _ = tx.send(event);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decoded object key of the first record in an event envelope.
|
||||
@@ -284,13 +464,24 @@ async fn enable_notify_module(env: &RustFSTestEnvironment) -> TestResult {
|
||||
/// Registers a webhook notification target with a persistent queue directory, so
|
||||
/// delivery goes through the durable store-and-forward path.
|
||||
async fn configure_webhook_target(env: &RustFSTestEnvironment, target_name: &str, endpoint: &str) -> TestResult {
|
||||
configure_webhook_target_with_key_values(env, target_name, vec![("endpoint", endpoint.to_string())]).await
|
||||
}
|
||||
|
||||
async fn configure_webhook_target_with_key_values(
|
||||
env: &RustFSTestEnvironment,
|
||||
target_name: &str,
|
||||
mut key_values: Vec<(&str, String)>,
|
||||
) -> TestResult {
|
||||
let queue_dir = format!("{}/notify-queue-{target_name}", env.temp_dir);
|
||||
tokio::fs::create_dir_all(&queue_dir).await?;
|
||||
if !key_values.iter().any(|(key, _)| *key == "queue_dir") {
|
||||
key_values.push(("queue_dir", queue_dir));
|
||||
}
|
||||
let payload = serde_json::json!({
|
||||
"key_values": [
|
||||
{ "key": "endpoint", "value": endpoint },
|
||||
{ "key": "queue_dir", "value": queue_dir },
|
||||
]
|
||||
"key_values": key_values
|
||||
.into_iter()
|
||||
.map(|(key, value)| serde_json::json!({ "key": key, "value": value }))
|
||||
.collect::<Vec<_>>(),
|
||||
});
|
||||
let url = format!("{}/rustfs/admin/v3/target/notify_webhook/{target_name}", env.url);
|
||||
let response = signed_admin_request(env, http::Method::PUT, &url, Some(payload.to_string().into_bytes())).await?;
|
||||
@@ -321,6 +512,31 @@ async fn wait_for_target_registered(env: &RustFSTestEnvironment, target_name: &s
|
||||
Err(format!("target {target_name} was not registered in admin ARNs").into())
|
||||
}
|
||||
|
||||
async fn wait_for_target_online(env: &RustFSTestEnvironment, target_name: &str) -> TestResult {
|
||||
let url = format!("{}/rustfs/admin/v3/target/list", env.url);
|
||||
for _ in 0..40 {
|
||||
let response = signed_admin_request(env, http::Method::GET, &url, None).await?;
|
||||
if response.status() == StatusCode::OK {
|
||||
let body: Value = serde_json::from_slice(&response.bytes().await?)?;
|
||||
if body["notify_enabled"].as_bool() != Some(true) {
|
||||
return Err(format!("admin target list did not report notify_enabled=true: {body}").into());
|
||||
}
|
||||
let listed = body["notification_endpoints"].as_array().is_some_and(|endpoints| {
|
||||
endpoints.iter().any(|endpoint| {
|
||||
endpoint["account_id"].as_str() == Some(target_name)
|
||||
&& endpoint["service"].as_str() == Some("webhook")
|
||||
&& endpoint["status"].as_str() == Some("online")
|
||||
})
|
||||
});
|
||||
if listed {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
Err(format!("target {target_name} did not become online in admin targets").into())
|
||||
}
|
||||
|
||||
/// Binds a bucket to a webhook target for ObjectCreated:*/ObjectRemoved:* events,
|
||||
/// filtered to `prefix` + `suffix`.
|
||||
async fn put_notification_config(client: &Client, bucket: &str, target_name: &str, prefix: &str, suffix: &str) -> TestResult {
|
||||
@@ -367,6 +583,62 @@ fn trimmed_etag(value: Option<&str>) -> Option<String> {
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Regression for rustfs#5052: with the notify module enabled through
|
||||
/// RUSTFS_NOTIFY_ENABLE, an HTTPS webhook using a configured CA must become
|
||||
/// online and receive a real S3 event POST.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_https_webhook_target_delivers_event_with_notify_env_enabled() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
let ca_path = Path::new(&env.temp_dir).join("https-webhook-ca.pem");
|
||||
let mut collector = spawn_https_event_collector(&ca_path)?;
|
||||
let allowed_origin = endpoint_origin(collector.endpoint())?;
|
||||
env.start_rustfs_server_with_env(
|
||||
vec![],
|
||||
&[
|
||||
("RUSTFS_NOTIFY_ENABLE", "true"),
|
||||
(ENV_OUTBOUND_ALLOW_ORIGINS, allowed_origin.as_str()),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let target = "peri1https";
|
||||
let bucket = "peri1-https-events";
|
||||
let key = "uploads/https.dat";
|
||||
let client = env.create_s3_client();
|
||||
client.create_bucket().bucket(bucket).send().await?;
|
||||
|
||||
configure_webhook_target_with_key_values(
|
||||
&env,
|
||||
target,
|
||||
vec![
|
||||
("endpoint", collector.endpoint().to_string()),
|
||||
("client_ca", ca_path.to_string_lossy().into_owned()),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
wait_for_target_online(&env, target).await?;
|
||||
wait_for_target_registered(&env, target).await?;
|
||||
put_notification_config(&client, bucket, target, "uploads/", ".dat").await?;
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"https webhook event body"))
|
||||
.send()
|
||||
.await?;
|
||||
let event = wait_for_event(collector.events_mut(), key, "s3:ObjectCreated:", Duration::from_secs(20)).await?;
|
||||
assert_eq!(event["EventName"].as_str(), Some("s3:ObjectCreated:Put"));
|
||||
assert_eq!(event["Records"][0]["s3"]["bucket"]["name"].as_str(), Some(bucket));
|
||||
assert_eq!(event_key(&event).as_deref(), Some(key));
|
||||
|
||||
env.stop_server();
|
||||
collector.shutdown()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PUT / multipart-complete / DELETE each deliver one event with correct fields,
|
||||
/// and the prefix/suffix filter drops non-matching keys.
|
||||
#[tokio::test]
|
||||
@@ -375,9 +647,11 @@ async fn test_webhook_event_delivery_and_filtering() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let (endpoint, mut rx, handle) = spawn_event_collector().await?;
|
||||
let allowed_origin = endpoint_origin(&endpoint)?;
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
env.start_rustfs_server_with_env(vec![], &[(ENV_OUTBOUND_ALLOW_ORIGINS, allowed_origin.as_str())])
|
||||
.await?;
|
||||
enable_notify_module(&env).await?;
|
||||
|
||||
let bucket = "peri1-events";
|
||||
@@ -529,15 +803,6 @@ async fn test_webhook_event_delivery_and_filtering() -> TestResult {
|
||||
async fn test_webhook_redelivers_event_after_target_recovers() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
enable_notify_module(&env).await?;
|
||||
|
||||
let bucket = "peri1-redeliver";
|
||||
let target = "peri1redeliver";
|
||||
let client = env.create_s3_client();
|
||||
client.create_bucket().bucket(bucket).send().await?;
|
||||
|
||||
// Configure the target while its endpoint is reachable so activation and
|
||||
// ARN registration complete deterministically. Registering against a dead
|
||||
// endpoint stalls behind the reachability probe's timeout and flakes the
|
||||
@@ -546,10 +811,21 @@ async fn test_webhook_redelivers_event_after_target_recovers() -> TestResult {
|
||||
let listener = TcpListener::bind("0.0.0.0:0").await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let endpoint_ip = local_ip()?;
|
||||
let endpoint = format!("http://{endpoint_ip}.nip.io:{port}/events");
|
||||
let endpoint = format!("http://{}/events", std::net::SocketAddr::new(endpoint_ip, port));
|
||||
let allowed_origin = endpoint_origin(&endpoint)?;
|
||||
let (setup_tx, _setup_rx) = mpsc::unbounded_channel();
|
||||
let setup_handle = serve_event_collector(listener, setup_tx);
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(vec![], &[(ENV_OUTBOUND_ALLOW_ORIGINS, allowed_origin.as_str())])
|
||||
.await?;
|
||||
enable_notify_module(&env).await?;
|
||||
|
||||
let bucket = "peri1-redeliver";
|
||||
let target = "peri1redeliver";
|
||||
let client = env.create_s3_client();
|
||||
client.create_bucket().bucket(bucket).send().await?;
|
||||
|
||||
configure_webhook_target(&env, target, &endpoint).await?;
|
||||
wait_for_target_registered(&env, target).await?;
|
||||
put_notification_config(&client, bucket, target, "uploads/", ".dat").await?;
|
||||
|
||||
@@ -18,6 +18,7 @@ use http::header::{CONTENT_TYPE, HOST};
|
||||
use reqwest::StatusCode;
|
||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
use rustfs_signer::{pre_sign_v4, sign_v4};
|
||||
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
|
||||
use s3s::Body;
|
||||
use serial_test::serial;
|
||||
use std::collections::HashMap;
|
||||
@@ -49,7 +50,7 @@ fn find_header_terminator(buf: &[u8]) -> Option<usize> {
|
||||
|
||||
async fn read_http_request(
|
||||
stream: &mut tokio::net::TcpStream,
|
||||
) -> Result<(HashMap<String, String>, Vec<u8>), Box<dyn Error + Send + Sync>> {
|
||||
) -> Result<(String, HashMap<String, String>, Vec<u8>), Box<dyn Error + Send + Sync>> {
|
||||
let mut buffer = Vec::new();
|
||||
let mut chunk = [0_u8; 4096];
|
||||
|
||||
@@ -67,7 +68,7 @@ async fn read_http_request(
|
||||
let header_bytes = &buffer[..header_end];
|
||||
let header_text = std::str::from_utf8(header_bytes)?;
|
||||
let mut lines = header_text.split("\r\n");
|
||||
let _request_line = lines.next().ok_or("missing request line")?;
|
||||
let request_line = lines.next().ok_or("missing request line")?.to_string();
|
||||
let mut headers = HashMap::new();
|
||||
for line in lines {
|
||||
if line.is_empty() {
|
||||
@@ -79,8 +80,9 @@ async fn read_http_request(
|
||||
|
||||
let content_length = headers
|
||||
.get("content-length")
|
||||
.ok_or("missing content-length header")?
|
||||
.parse::<usize>()?;
|
||||
.map(|value| value.parse::<usize>())
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let body_offset = header_end + 4;
|
||||
while buffer.len().saturating_sub(body_offset) < content_length {
|
||||
let read = stream.read(&mut chunk).await?;
|
||||
@@ -90,7 +92,7 @@ async fn read_http_request(
|
||||
buffer.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
|
||||
Ok((headers, buffer[body_offset..body_offset + content_length].to_vec()))
|
||||
Ok((request_line, headers, buffer[body_offset..body_offset + content_length].to_vec()))
|
||||
}
|
||||
|
||||
async fn spawn_object_lambda_webhook_server() -> Result<
|
||||
@@ -130,9 +132,17 @@ async fn spawn_object_lambda_webhook_server_with_response(
|
||||
let handle = tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut stream, _) = listener.accept().await?;
|
||||
let Ok(Ok((headers, body))) = timeout(Duration::from_secs(2), read_http_request(&mut stream)).await else {
|
||||
let Ok(Ok((request_line, headers, body))) = timeout(Duration::from_secs(2), read_http_request(&mut stream)).await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if request_line == "HEAD / HTTP/1.1" {
|
||||
stream
|
||||
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
|
||||
.await?;
|
||||
stream.shutdown().await?;
|
||||
continue;
|
||||
}
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body)?;
|
||||
|
||||
let output_route = payload["getObjectContext"]["outputRoute"]
|
||||
@@ -412,9 +422,33 @@ async fn wait_for_target_absence(
|
||||
Err(format!("target {target_name} remained visible in admin APIs; targets={last_targets}, arns={last_arns:?}").into())
|
||||
}
|
||||
|
||||
async fn restart_rustfs_server(env: &mut RustFSTestEnvironment) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
fn endpoint_origin(endpoint: &str) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
Ok(reqwest::Url::parse(endpoint)?.origin().ascii_serialization())
|
||||
}
|
||||
|
||||
async fn start_rustfs_server_for_endpoint(
|
||||
env: &mut RustFSTestEnvironment,
|
||||
endpoint: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let origin = endpoint_origin(endpoint)?;
|
||||
env.start_rustfs_server_with_env(
|
||||
vec![],
|
||||
&[
|
||||
(ENV_OUTBOUND_ALLOW_ORIGINS, origin.as_str()),
|
||||
("RUSTFS_NOTIFY_ENABLE", "true"),
|
||||
],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn restart_rustfs_server(env: &mut RustFSTestEnvironment, endpoint: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let origin = endpoint_origin(endpoint)?;
|
||||
env.stop_server();
|
||||
env.start_rustfs_server_without_cleanup(vec![]).await
|
||||
env.start_rustfs_server_without_cleanup_with_env(&[
|
||||
(ENV_OUTBOUND_ALLOW_ORIGINS, origin.as_str()),
|
||||
("RUSTFS_NOTIFY_ENABLE", "true"),
|
||||
])
|
||||
.await
|
||||
}
|
||||
|
||||
async fn spawn_http_origin_probe_server() -> Result<
|
||||
@@ -521,7 +555,7 @@ async fn test_notification_target_persists_across_restart_and_delete() -> Result
|
||||
let (webhook_url, _request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?;
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
|
||||
|
||||
let target_name = "restart-target";
|
||||
configure_webhook_target(&env, target_name, &webhook_url, "secret-token").await?;
|
||||
@@ -535,7 +569,7 @@ async fn test_notification_target_persists_across_restart_and_delete() -> Result
|
||||
"target ARN missing after initial configure: {visible_arns:?}"
|
||||
);
|
||||
|
||||
restart_rustfs_server(&mut env).await?;
|
||||
restart_rustfs_server(&mut env, &webhook_url).await?;
|
||||
|
||||
let (targets_after_restart, arns_after_restart) = wait_for_target_visibility(&env, target_name).await?;
|
||||
assert!(notification_target_is_listed(&targets_after_restart, target_name));
|
||||
@@ -556,7 +590,7 @@ async fn test_notification_target_persists_across_restart_and_delete() -> Result
|
||||
"target ARN still visible after delete: {arns_after_delete:?}"
|
||||
);
|
||||
|
||||
restart_rustfs_server(&mut env).await?;
|
||||
restart_rustfs_server(&mut env, &webhook_url).await?;
|
||||
|
||||
let (targets_after_delete_restart, arns_after_delete_restart) = wait_for_target_absence(&env, target_name).await?;
|
||||
assert!(!notification_target_is_listed(&targets_after_delete_restart, target_name));
|
||||
@@ -581,8 +615,7 @@ async fn test_notification_target_with_path_is_online_via_transport_probe() -> R
|
||||
let (webhook_url, mut probe_rx, probe_handle) = spawn_http_origin_probe_server().await?;
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_NOTIFY_ENABLE", "true")])
|
||||
.await?;
|
||||
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
|
||||
|
||||
let target_name = "path-probe";
|
||||
configure_webhook_target(&env, target_name, &webhook_url, "secret-token").await?;
|
||||
@@ -615,7 +648,7 @@ async fn test_get_object_lambda_accepts_presigned_requests() -> Result<(), Box<d
|
||||
let (webhook_url, request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?;
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
|
||||
|
||||
let bucket = "object-lambda-e2e-presigned";
|
||||
let key = "input.txt";
|
||||
@@ -656,7 +689,7 @@ async fn test_get_object_lambda_accepts_named_webhook_target_arn() -> Result<(),
|
||||
let (webhook_url, request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?;
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
|
||||
|
||||
let bucket = "object-lambda-e2e-named-target";
|
||||
let key = "input.txt";
|
||||
@@ -696,7 +729,7 @@ async fn test_get_object_lambda_invokes_runtime_webhook_target() -> Result<(), B
|
||||
let (webhook_url, request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?;
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
|
||||
|
||||
let bucket = "object-lambda-e2e";
|
||||
let key = "input.txt";
|
||||
@@ -777,7 +810,7 @@ async fn test_get_object_lambda_passthroughs_non_success_webhook_response() -> R
|
||||
.await?;
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
|
||||
|
||||
let bucket = "object-lambda-e2e-failure";
|
||||
let key = "input.txt";
|
||||
@@ -832,7 +865,7 @@ async fn test_get_object_lambda_rejects_success_response_without_auth_headers()
|
||||
.await?;
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
|
||||
|
||||
let bucket = "object-lambda-e2e-missing-auth";
|
||||
let key = "input.txt";
|
||||
@@ -879,7 +912,7 @@ async fn test_get_object_lambda_rejects_success_response_with_mismatched_auth_he
|
||||
.await?;
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
|
||||
|
||||
let bucket = "object-lambda-e2e-mismatched-auth";
|
||||
let key = "input.txt";
|
||||
|
||||
@@ -277,6 +277,64 @@ mod integration_tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// backlog#1336 regression: a PUT that merely declares `Content-Encoding: aws-chunked`
|
||||
/// (no SigV4 streaming payload, so no `x-amz-decoded-content-length`) carries an unframed
|
||||
/// body whose wire Content-Length is the real object size. Quota admission must use that
|
||||
/// length — with and without a hard quota configured — instead of rejecting the request
|
||||
/// with 400 UnexpectedContent, and an over-quota aws-chunked PUT must still get the quota
|
||||
/// rejection.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_quota_admission_aws_chunked_declared_encoding() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
if skip_without_awscurl() {
|
||||
return Ok(());
|
||||
}
|
||||
let env = QuotaTestEnv::new().await?;
|
||||
env.create_bucket().await?;
|
||||
|
||||
let put_aws_chunked = |key: &'static str, size_bytes: usize| {
|
||||
env.client
|
||||
.put_object()
|
||||
.bucket(&env.bucket_name)
|
||||
.key(key)
|
||||
.content_encoding("aws-chunked")
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(vec![0u8; size_bytes]))
|
||||
.send()
|
||||
};
|
||||
|
||||
// No quota configured: the declared aws-chunked PUT must be admitted.
|
||||
put_aws_chunked("no-quota.bin", 512)
|
||||
.await
|
||||
.expect("declared aws-chunked PUT without quota must succeed");
|
||||
assert!(env.object_exists("no-quota.bin").await?);
|
||||
|
||||
// Hard quota configured: a within-quota declared aws-chunked PUT is admitted
|
||||
// against its wire Content-Length.
|
||||
env.set_bucket_quota(4 * 1024).await?;
|
||||
put_aws_chunked("within-quota.bin", 1024)
|
||||
.await
|
||||
.expect("declared aws-chunked PUT within quota must succeed");
|
||||
assert!(env.object_exists("within-quota.bin").await?);
|
||||
|
||||
// An over-quota declared aws-chunked PUT is rejected by quota admission —
|
||||
// not with UnexpectedContent.
|
||||
let err = put_aws_chunked("over-quota.bin", 16 * 1024)
|
||||
.await
|
||||
.expect_err("declared aws-chunked PUT over quota must be rejected");
|
||||
let err_debug = format!("{err:?}");
|
||||
assert!(
|
||||
!err_debug.contains("UnexpectedContent"),
|
||||
"over-quota rejection must be the quota error, not UnexpectedContent: {err_debug}"
|
||||
);
|
||||
assert!(!env.object_exists("over-quota.bin").await?);
|
||||
|
||||
env.clear_bucket_quota().await?;
|
||||
env.cleanup_bucket().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_quota_update_and_clear() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
|
||||
@@ -400,6 +400,20 @@ impl NodeService for MinimalLockNodeService {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn prepare_part_transaction(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::PreparePartTransactionRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::PreparePartTransactionResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn settle_part_transaction(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::SettlePartTransactionRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::SettlePartTransactionResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn rename_file(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::RenameFileRequest>,
|
||||
@@ -798,6 +812,13 @@ impl NodeService for MinimalLockNodeService {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn scanner_activity(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::ScannerActivityRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::ScannerActivityResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn background_heal_status(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::BackgroundHealStatusRequest>,
|
||||
|
||||
@@ -38,7 +38,7 @@ use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{
|
||||
BucketLifecycleConfiguration, BucketVersioningStatus, ExpirationStatus, LifecycleExpiration, LifecycleRule,
|
||||
LifecycleRuleFilter, VersioningConfiguration,
|
||||
LifecycleRuleFilter, NoncurrentVersionExpiration, VersioningConfiguration,
|
||||
};
|
||||
use std::time::Duration as StdDuration;
|
||||
use time::OffsetDateTime;
|
||||
@@ -98,7 +98,10 @@ async fn put_object_with_backdated_mtime(
|
||||
/// still succeeds. Any other error is surfaced.
|
||||
async fn object_is_gone(client: &Client, bucket: &str, key: &str) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
match client.get_object().bucket(bucket).key(key).send().await {
|
||||
Ok(_) => Ok(false),
|
||||
Ok(output) => {
|
||||
output.body.collect().await?;
|
||||
Ok(false)
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(service_error) = e.as_service_error() {
|
||||
if service_error.is_no_such_key() {
|
||||
@@ -132,6 +135,39 @@ async fn wait_for_object_expired(client: &Client, bucket: &str, key: &str, deadl
|
||||
}
|
||||
}
|
||||
|
||||
async fn version_is_absent_from_listing(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
version_id: &str,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let versions = client.list_object_versions().bucket(bucket).prefix(key).send().await?;
|
||||
Ok(!versions.versions().iter().any(|v| v.version_id() == Some(version_id)))
|
||||
}
|
||||
|
||||
async fn wait_for_version_expired(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
version_id: &str,
|
||||
deadline: StdDuration,
|
||||
) -> TestResult {
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
if version_is_absent_from_listing(client, bucket, key, version_id).await? {
|
||||
return Ok(());
|
||||
}
|
||||
if start.elapsed() >= deadline {
|
||||
return Err(format!(
|
||||
"object version {bucket}/{key}?versionId={version_id} was not expired by the lifecycle scanner within {}s",
|
||||
deadline.as_secs()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
tokio::time::sleep(StdDuration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a prefix-scoped `Days`-based expiration rule.
|
||||
fn expiration_rule(id: &str, prefix: &str, days: i32) -> Result<LifecycleRule, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let rule = LifecycleRule::builder()
|
||||
@@ -143,6 +179,20 @@ fn expiration_rule(id: &str, prefix: &str, days: i32) -> Result<LifecycleRule, B
|
||||
Ok(rule)
|
||||
}
|
||||
|
||||
fn noncurrent_expiration_rule(
|
||||
id: &str,
|
||||
prefix: &str,
|
||||
days: i32,
|
||||
) -> Result<LifecycleRule, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let rule = LifecycleRule::builder()
|
||||
.id(id)
|
||||
.filter(LifecycleRuleFilter::builder().prefix(prefix).build())
|
||||
.noncurrent_version_expiration(NoncurrentVersionExpiration::builder().noncurrent_days(days).build())
|
||||
.status(ExpirationStatus::Enabled)
|
||||
.build()?;
|
||||
Ok(rule)
|
||||
}
|
||||
|
||||
async fn put_expiration_config(client: &Client, bucket: &str, rule: LifecycleRule) -> TestResult {
|
||||
let lifecycle = BucketLifecycleConfiguration::builder().rules(rule).build()?;
|
||||
client
|
||||
@@ -277,9 +327,94 @@ async fn test_lifecycle_versioned_current_version_expiry_creates_delete_marker()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `NoncurrentVersionExpiration NoncurrentDays=1` on a versioned bucket,
|
||||
/// accelerated with `RUSTFS_ILM_DEBUG_DAY_SECS`. Proves the scanner purges the
|
||||
/// noncurrent data version from `ListObjectVersions` while preserving the
|
||||
/// latest version as the normal readable object and without creating a delete
|
||||
/// marker.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_lifecycle_noncurrent_version_expiry_removes_only_old_version() -> TestResult {
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
let mut extra_env = fast_lifecycle_env();
|
||||
extra_env.push(("RUSTFS_ILM_DEBUG_DAY_SECS", "2"));
|
||||
env.start_rustfs_server_with_env(vec![], &extra_env).await?;
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "ilm3-noncurrent";
|
||||
client.create_bucket().bucket(bucket).send().await?;
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let key = "versioned/noncurrent.txt";
|
||||
let first_put = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"old payload"))
|
||||
.send()
|
||||
.await?;
|
||||
let old_version_id = first_put
|
||||
.version_id()
|
||||
.map(str::to_string)
|
||||
.expect("first versioned PUT returns a version id");
|
||||
|
||||
let second_put = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"latest payload"))
|
||||
.send()
|
||||
.await?;
|
||||
let latest_version_id = second_put
|
||||
.version_id()
|
||||
.map(str::to_string)
|
||||
.expect("second versioned PUT returns a version id");
|
||||
|
||||
assert!(
|
||||
!version_is_absent_from_listing(&client, bucket, key, &old_version_id).await?,
|
||||
"old noncurrent version must be readable before lifecycle is installed"
|
||||
);
|
||||
|
||||
put_expiration_config(&client, bucket, noncurrent_expiration_rule("expire-noncurrent", "versioned/", 1)?).await?;
|
||||
|
||||
wait_for_version_expired(&client, bucket, key, &old_version_id, StdDuration::from_secs(90)).await?;
|
||||
|
||||
let latest = client.get_object().bucket(bucket).key(key).send().await?;
|
||||
assert_eq!(latest.version_id(), Some(latest_version_id.as_str()));
|
||||
assert_eq!(latest.body.collect().await?.into_bytes().as_ref(), b"latest payload");
|
||||
|
||||
let versions = client.list_object_versions().bucket(bucket).prefix(key).send().await?;
|
||||
let data_versions = versions.versions();
|
||||
assert!(
|
||||
data_versions
|
||||
.iter()
|
||||
.any(|v| v.version_id() == Some(latest_version_id.as_str())),
|
||||
"latest data version {latest_version_id} must remain, got: {data_versions:?}"
|
||||
);
|
||||
assert!(
|
||||
!data_versions.iter().any(|v| v.version_id() == Some(old_version_id.as_str())),
|
||||
"old noncurrent version {old_version_id} must be removed, got: {data_versions:?}"
|
||||
);
|
||||
assert!(
|
||||
versions.delete_markers().is_empty(),
|
||||
"noncurrent version expiry must not create delete markers, got: {:?}",
|
||||
versions.delete_markers()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `Days=0` expiration is invalid per S3 semantics (`Days` must be a positive
|
||||
/// integer >= 1). A `PutBucketLifecycleConfiguration` carrying a zero-day rule
|
||||
/// must be rejected with `InvalidArgument` (HTTP 400) — see crates/lifecycle
|
||||
/// must be rejected with `InvalidArgument` (HTTP 400) - see crates/lifecycle
|
||||
/// `validate()` and the PutBucketLifecycleConfiguration handler. This is the
|
||||
/// self-managed counterpart of the localhost-only
|
||||
/// `test_bucket_lifecycle_rejects_zero_days` unit test.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1331,6 +1331,42 @@ async fn assert_managed_sse_replication_fails_explicitly(label: &str, kms: bool)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_source_delete_marker_replication_failed(
|
||||
env: &RustFSTestEnvironment,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
|
||||
let url = format!(
|
||||
"{}/rustfs/admin/v3/replication/diff?bucket={}&prefix={}",
|
||||
env.url,
|
||||
urlencoding::encode(bucket),
|
||||
urlencoding::encode(key)
|
||||
);
|
||||
|
||||
loop {
|
||||
let response = signed_request(http::Method::POST, &url, &env.access_key, &env.secret_key, None, None).await?;
|
||||
if response.status() != StatusCode::OK {
|
||||
return Err(format!("replication diff failed with status {}", response.status()).into());
|
||||
}
|
||||
let diff: serde_json::Value = response.json().await?;
|
||||
let failed = diff["Entries"].as_array().is_some_and(|entries| {
|
||||
entries.iter().any(|entry| {
|
||||
entry["Object"].as_str() == Some(key)
|
||||
&& entry["IsDeleteMarker"].as_bool() == Some(true)
|
||||
&& entry["ReplicationStatus"].as_str() == Some("FAILED")
|
||||
})
|
||||
});
|
||||
if failed {
|
||||
return Ok(());
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(format!("source delete marker {key} never reported FAILED; last diff={diff}").into());
|
||||
}
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the `LastModified` of the (single) delete marker for `key`, if present.
|
||||
async fn delete_marker_last_modified(
|
||||
client: &Client,
|
||||
@@ -1935,7 +1971,10 @@ async fn wait_for_site_replication_enabled(
|
||||
) -> Result<SiteReplicationInfo, Box<dyn Error + Send + Sync>> {
|
||||
for _ in 0..40 {
|
||||
let info = site_replication_info(env).await?;
|
||||
if info.enabled && info.sites.len() == expected_sites {
|
||||
if info.enabled
|
||||
&& info.sites.len() == expected_sites
|
||||
&& info.sites.iter().all(|peer| peer.sync_state == SyncStatus::Enable)
|
||||
{
|
||||
return Ok(info);
|
||||
}
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
@@ -2074,11 +2113,31 @@ async fn build_replication_pair(
|
||||
async fn test_replication_check_succeeds_with_remote_target() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
let (_source_env, _target_env, source_bucket) = build_replication_pair(true).await?;
|
||||
let response = run_replication_check(&_source_env, &source_bucket).await?;
|
||||
let (source_env, target_env, source_bucket) = build_replication_pair(true).await?;
|
||||
let response = run_replication_check(&source_env, &source_bucket).await?;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(response.text().await?.is_empty());
|
||||
let payload: serde_json::Value = response.json().await?;
|
||||
assert_eq!(payload["Status"], "OK");
|
||||
assert_eq!(payload["ActiveMutation"], true);
|
||||
assert_eq!(payload["Targets"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(payload["Targets"][0]["Status"], "OK");
|
||||
assert_eq!(payload["Targets"][0]["Phases"]["Put"]["Status"], "OK");
|
||||
assert_eq!(payload["Targets"][0]["Phases"]["DeleteMarker"]["Status"], "OK");
|
||||
assert_eq!(payload["Targets"][0]["Phases"]["VersionDelete"]["Status"], "OK");
|
||||
assert_eq!(payload["Targets"][0]["Phases"]["Cleanup"]["Status"], "OK");
|
||||
|
||||
let target_client = target_env.create_s3_client();
|
||||
let versions = target_client
|
||||
.list_object_versions()
|
||||
.bucket("replication-check-dst")
|
||||
.prefix(".rustfs.sys/replication-check/")
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
versions.versions().is_empty() && versions.delete_markers().is_empty(),
|
||||
"successful check must remove every probe version and delete marker"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -2120,9 +2179,19 @@ async fn test_replication_check_rejects_target_without_object_lock() -> Result<(
|
||||
let status = response.status();
|
||||
let body = response.text().await?;
|
||||
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
|
||||
assert!(body.to_ascii_lowercase().contains("object lock"), "unexpected response: {body}");
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let payload: serde_json::Value = serde_json::from_str(&body)?;
|
||||
assert_eq!(payload["Status"], "FAILED");
|
||||
assert_eq!(payload["Targets"][0]["Status"], "FAILED");
|
||||
assert_eq!(payload["Targets"][0]["Phases"]["ObjectLock"]["Status"], "FAILED");
|
||||
assert!(
|
||||
payload["Targets"][0]["Phases"]["ObjectLock"]["Error"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("object lock"),
|
||||
"unexpected response: {body}"
|
||||
);
|
||||
assert_eq!(payload["Targets"][0]["Phases"]["Put"]["Status"], "SKIPPED");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -3691,23 +3760,9 @@ async fn test_bucket_replication_replays_failed_entries_after_source_restart() -
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// backlog#1147 repl-5, scenario (c) — replayed delete marker keeps the source
|
||||
/// mtime (mirrors backlog#867).
|
||||
///
|
||||
/// A delete marker is created while the target is down; the source is then
|
||||
/// restarted (data preserved) and the target brought back, so the marker
|
||||
/// replicates through the failure-replay path. The replayed marker must carry
|
||||
/// the SOURCE's `LastModified`, not the replay time. A deliberate gap before
|
||||
/// recovery makes any regression (replay-time stamping) obvious.
|
||||
///
|
||||
/// The source restart is load-bearing, not just paranoia: on a live
|
||||
/// (never-restarted) source, the failed delete-marker replication wedges the
|
||||
/// per-object `/[replicate]/<key>` namespace lock and the marker never
|
||||
/// replicates even after the target recovers — tracked as backlog#1278. Once
|
||||
/// that is fixed, a restart-free variant of this scenario should be added.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_replication_replayed_delete_marker_preserves_source_mtime() -> TestResult {
|
||||
async fn test_bucket_replication_replayed_delete_marker_preserves_source_mtime_without_source_restart() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut source_env = RustFSTestEnvironment::new().await?;
|
||||
@@ -3759,13 +3814,11 @@ async fn test_bucket_replication_replayed_delete_marker_preserves_source_mtime()
|
||||
.await?
|
||||
.ok_or("source has no delete marker after DELETE")?;
|
||||
|
||||
wait_for_source_delete_marker_replication_failed(&source_env, source_bucket, object_key).await?;
|
||||
|
||||
// Widen the gap so a replay-time-stamping regression is unmistakable.
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
|
||||
// Restart the source (see the doc comment: live-source replay is wedged by
|
||||
// backlog#1278), then bring the target back; the restarted source's scanner
|
||||
// heal pass replays the failed delete marker.
|
||||
source_env.restart_server_preserving_data(vec![], &source_env_vars).await?;
|
||||
target_env.restart_server_preserving_data(vec![], &[]).await?;
|
||||
|
||||
let target_mtime = wait_for_target_delete_marker(&target_client, target_bucket, object_key).await?;
|
||||
@@ -4025,17 +4078,23 @@ async fn test_site_replication_allows_private_ca_https_with_ca_cert_pem_real_dua
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_site_replication_resync_start_cancel_restart_real_dual_node() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
async fn test_site_replication_resync_lifecycle_survives_real_server_restart() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let resync_process_env = [
|
||||
("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", "true"),
|
||||
// Verbose server logging can block startup when this focused test is run
|
||||
// through a captured test process rather than nextest.
|
||||
("RUST_LOG", "error"),
|
||||
];
|
||||
|
||||
let mut source_env = RustFSTestEnvironment::new().await?;
|
||||
source_env
|
||||
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
|
||||
.await?;
|
||||
source_env.capture_log_path = Some(format!("{}/server.log", source_env.temp_dir));
|
||||
source_env.start_rustfs_server_with_env(vec![], &resync_process_env).await?;
|
||||
|
||||
let mut target_env = RustFSTestEnvironment::new().await?;
|
||||
target_env.capture_log_path = Some(format!("{}/server.log", target_env.temp_dir));
|
||||
target_env
|
||||
.start_rustfs_server_without_cleanup_with_env(LOOPBACK_REPLICATION_TARGET_ENV)
|
||||
.start_rustfs_server_without_cleanup_with_env(&resync_process_env)
|
||||
.await?;
|
||||
|
||||
let source_bucket = "site-repl-resync-src";
|
||||
@@ -4083,12 +4142,12 @@ async fn test_site_replication_resync_start_cancel_restart_real_dual_node() -> R
|
||||
wait_for_bucket_on_target(&source_client, source_bucket).await?;
|
||||
let target_arn = wait_for_remote_target_arn(&source_env, source_bucket).await?;
|
||||
|
||||
for idx in 0..32 {
|
||||
for idx in 0..96 {
|
||||
source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key(format!("resync-object-{idx:02}"))
|
||||
.body(ByteStream::from(vec![b'x'; 256 * 1024]))
|
||||
.body(ByteStream::from(vec![b'x'; 512 * 1024]))
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
@@ -4096,18 +4155,31 @@ async fn test_site_replication_resync_start_cancel_restart_real_dual_node() -> R
|
||||
let started = site_replication_resync_op(&source_env, "start", &remote_peer).await?;
|
||||
assert_eq!(started.status, "success", "unexpected start result: {:?}", started);
|
||||
assert!(
|
||||
started
|
||||
.buckets
|
||||
.iter()
|
||||
.any(|bucket| bucket.bucket == source_bucket && matches!(bucket.status.as_str(), "started" | "success")),
|
||||
started.buckets.iter().any(|bucket| {
|
||||
bucket.bucket == source_bucket && matches!(bucket.status.as_str(), "started" | "running" | "completed" | "success")
|
||||
}),
|
||||
"source bucket start status missing: {:?}",
|
||||
started
|
||||
);
|
||||
assert!(!started.resync_id.is_empty(), "start response omitted the resync id: {:?}", started);
|
||||
let started_reset_id = started.resync_id.clone();
|
||||
|
||||
assert!(
|
||||
matches!(started.state.as_str(), "pending" | "running"),
|
||||
"the fixture must keep the first generation active long enough to test duplicate start: {:?}",
|
||||
started
|
||||
);
|
||||
let duplicate_err = site_replication_resync_op(&source_env, "start", &remote_peer)
|
||||
.await
|
||||
.expect_err("duplicate start must be rejected while a generation is active");
|
||||
assert!(
|
||||
duplicate_err.to_string().contains("already active"),
|
||||
"unexpected duplicate start error: {duplicate_err}"
|
||||
);
|
||||
|
||||
let canceled = site_replication_resync_op(&source_env, "cancel", &remote_peer).await?;
|
||||
assert_eq!(canceled.status, "success", "unexpected cancel result: {:?}", canceled);
|
||||
assert_eq!(canceled.state, "canceled");
|
||||
assert!(
|
||||
canceled
|
||||
.buckets
|
||||
@@ -4116,34 +4188,56 @@ async fn test_site_replication_resync_start_cancel_restart_real_dual_node() -> R
|
||||
"source bucket cancel status missing: {:?}",
|
||||
canceled
|
||||
);
|
||||
let canceled_again = site_replication_resync_op(&source_env, "cancel", &remote_peer).await?;
|
||||
assert_eq!(canceled_again.resync_id, canceled.resync_id, "repeated cancel must be idempotent");
|
||||
assert_eq!(canceled_again.state, "canceled");
|
||||
|
||||
let canceled_target =
|
||||
wait_for_replication_reset_target(&source_env, source_bucket, &target_arn, |target| target.status == "Canceled").await?;
|
||||
assert_eq!(canceled_target.status, "Canceled");
|
||||
assert_eq!(canceled_target.reset_id, started_reset_id);
|
||||
|
||||
let restarted = site_replication_resync_op(&source_env, "start", &remote_peer).await?;
|
||||
assert_eq!(restarted.status, "success", "unexpected restart result: {:?}", restarted);
|
||||
assert_ne!(restarted.resync_id, started_reset_id);
|
||||
assert!(
|
||||
matches!(restarted.state.as_str(), "pending" | "running"),
|
||||
"the second generation must be active before the process restart: {:?}",
|
||||
restarted
|
||||
.buckets
|
||||
.iter()
|
||||
.any(|bucket| bucket.bucket == source_bucket && matches!(bucket.status.as_str(), "started" | "success")),
|
||||
"source bucket restart status missing: {:?}",
|
||||
restarted
|
||||
);
|
||||
let restarted_reset_id = restarted.resync_id.clone();
|
||||
|
||||
source_env.restart_server_preserving_data(vec![], &resync_process_env).await?;
|
||||
wait_for_site_replication_enabled(&source_env, 2).await?;
|
||||
|
||||
let after_restart = site_replication_resync_op(&source_env, "status", &remote_peer).await?;
|
||||
assert_eq!(
|
||||
after_restart.resync_id, restarted_reset_id,
|
||||
"server restart changed the durable resync id"
|
||||
);
|
||||
assert_eq!(after_restart.generation, restarted.generation);
|
||||
assert_eq!(after_restart.created_at, restarted.created_at);
|
||||
assert!(
|
||||
matches!(after_restart.state.as_str(), "pending" | "running" | "completed" | "failed"),
|
||||
"unexpected recovered lifecycle state: {:?}",
|
||||
after_restart
|
||||
);
|
||||
assert!(
|
||||
after_restart.buckets.iter().any(|bucket| bucket.bucket == source_bucket),
|
||||
"durable status lost the source bucket after restart: {:?}",
|
||||
after_restart
|
||||
);
|
||||
let restart_snapshot = get_replication_reset_status(&source_env, source_bucket, &target_arn).await?;
|
||||
let restarted_target = wait_for_replication_reset_target(&source_env, source_bucket, &target_arn, |target| {
|
||||
!target.reset_id.is_empty() && target.reset_id != started_reset_id
|
||||
target.reset_id == restarted_reset_id
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"restart ids: start={} restart={} snapshot={:?}; {err}",
|
||||
started_reset_id, restarted.resync_id, restart_snapshot.targets
|
||||
started_reset_id, restarted_reset_id, restart_snapshot.targets
|
||||
)
|
||||
})?;
|
||||
assert_ne!(restarted_target.reset_id, started_reset_id);
|
||||
assert_eq!(restarted_target.reset_id, restarted_reset_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -4709,6 +4803,153 @@ async fn test_site_replication_replicates_object_with_bucket_versioning_real_dua
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-applying a site's own replication config must not disable the peer's reverse direction.
|
||||
///
|
||||
/// `PutBucketReplication` broadcasts the config to every peer — the console's replication
|
||||
/// Save button, `mc replicate import`, and a bucket-metadata import all go through it. The
|
||||
/// receiver used to overwrite its rules with the sender's, whose destination ARN names the
|
||||
/// receiver itself. No bucket target can satisfy that ARN, so every object written on the
|
||||
/// receiver was dropped with only a debug line, while `replicate status` still reported
|
||||
/// "1/1 Buckets in sync" because both configs were byte-identical.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_site_replication_config_broadcast_keeps_reverse_direction_real_dual_node() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut source_env = RustFSTestEnvironment::new().await?;
|
||||
source_env
|
||||
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
|
||||
.await?;
|
||||
|
||||
let mut target_env = RustFSTestEnvironment::new().await?;
|
||||
target_env
|
||||
.start_rustfs_server_without_cleanup_with_env(LOOPBACK_REPLICATION_TARGET_ENV)
|
||||
.await?;
|
||||
|
||||
let source_client = source_env.create_s3_client();
|
||||
let target_client = target_env.create_s3_client();
|
||||
let bucket = "site-repl-config-broadcast";
|
||||
|
||||
let add_status = site_replication_add(
|
||||
&source_env,
|
||||
&[
|
||||
PeerSite {
|
||||
name: "broadcast-source".to_string(),
|
||||
endpoint: source_env.url.clone(),
|
||||
access_key: source_env.access_key.clone(),
|
||||
secret_key: source_env.secret_key.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
PeerSite {
|
||||
name: "broadcast-target".to_string(),
|
||||
endpoint: target_env.url.clone(),
|
||||
access_key: target_env.access_key.clone(),
|
||||
secret_key: target_env.secret_key.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
assert!(add_status.success, "unexpected site add result: {add_status:?}");
|
||||
wait_for_site_replication_enabled(&source_env, 2).await?;
|
||||
wait_for_site_replication_enabled(&target_env, 2).await?;
|
||||
|
||||
source_client.create_bucket().bucket(bucket).send().await?;
|
||||
wait_for_bucket_on_target(&target_client, bucket).await?;
|
||||
|
||||
// Both directions work before the broadcast.
|
||||
source_client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("from-source.txt")
|
||||
.body(ByteStream::from_static(b"written on the initiating site"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
wait_for_object_on_target(&target_client, bucket, "from-source.txt").await?,
|
||||
b"written on the initiating site".to_vec(),
|
||||
);
|
||||
target_client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("from-target.txt")
|
||||
.body(ByteStream::from_static(b"written on the joined site"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
wait_for_object_on_target(&source_client, bucket, "from-target.txt").await?,
|
||||
b"written on the joined site".to_vec(),
|
||||
);
|
||||
|
||||
// Round-trip the source's own config through PutBucketReplication, exactly what the
|
||||
// console does when an operator opens the bucket's replication page and saves it.
|
||||
let source_config = source_client
|
||||
.get_bucket_replication()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await?
|
||||
.replication_configuration
|
||||
.ok_or("source bucket has no replication configuration")?;
|
||||
source_client
|
||||
.put_bucket_replication()
|
||||
.bucket(bucket)
|
||||
.replication_configuration(source_config)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let target_config = wait_for_site_replication_rule(&target_client, bucket).await?;
|
||||
let target_deployment_id = site_replication_info(&target_env)
|
||||
.await?
|
||||
.sites
|
||||
.iter()
|
||||
.find(|peer| peer.endpoint == target_env.url)
|
||||
.map(|peer| peer.deployment_id.clone())
|
||||
.ok_or("joined site missing from its own replication info")?;
|
||||
for rule in &target_config.rules {
|
||||
let destination = rule
|
||||
.destination
|
||||
.as_ref()
|
||||
.map(|destination| destination.bucket.as_str())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
!destination.contains(&target_deployment_id),
|
||||
"joined site adopted a rule pointing at itself: {destination}"
|
||||
);
|
||||
}
|
||||
|
||||
target_client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("from-target-after-broadcast.txt")
|
||||
.body(ByteStream::from_static(b"written after the config broadcast"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
wait_for_object_on_target(&source_client, bucket, "from-target-after-broadcast.txt").await?,
|
||||
b"written after the config broadcast".to_vec(),
|
||||
"config broadcast made replication one-directional"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_site_replication_rule(
|
||||
client: &aws_sdk_s3::Client,
|
||||
bucket: &str,
|
||||
) -> Result<aws_sdk_s3::types::ReplicationConfiguration, Box<dyn Error + Send + Sync>> {
|
||||
for _ in 0..40 {
|
||||
if let Ok(response) = client.get_bucket_replication().bucket(bucket).send().await
|
||||
&& let Some(config) = response.replication_configuration
|
||||
&& !config.rules.is_empty()
|
||||
{
|
||||
return Ok(config);
|
||||
}
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
|
||||
Err(format!("bucket {bucket} never reported a replication rule").into())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_site_replication_active_active_converges_without_loops_real_dual_node() -> TestResult {
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Black-box SSE-C CopyObject and multipart-copy regression coverage (backlog#1467).
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::config::interceptors::{BeforeDeserializationInterceptorContextRef, BeforeTransmitInterceptorContextRef};
|
||||
use aws_sdk_s3::config::{ConfigBag, Credentials, Intercept, Region, RuntimeComponents};
|
||||
use aws_sdk_s3::error::BoxError;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, VersioningConfiguration};
|
||||
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||
use base64::Engine;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
const SSE_CUSTOMER_ALGORITHM_HEADER: &str = "x-amz-server-side-encryption-customer-algorithm";
|
||||
const SSE_CUSTOMER_KEY_MD5_HEADER: &str = "x-amz-server-side-encryption-customer-key-md5";
|
||||
|
||||
struct CustomerKey {
|
||||
raw: String,
|
||||
encoded: String,
|
||||
md5: String,
|
||||
}
|
||||
|
||||
struct InvalidSsec<'a> {
|
||||
algorithm: Option<&'a str>,
|
||||
key: Option<&'a str>,
|
||||
md5: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct ResponseHeaderCapture {
|
||||
headers: Arc<Mutex<HashMap<String, String>>>,
|
||||
abort_attempts: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl ResponseHeaderCapture {
|
||||
fn snapshot(&self) -> Result<HashMap<String, String>, BoxError> {
|
||||
self.headers
|
||||
.lock()
|
||||
.map(|headers| headers.clone())
|
||||
.map_err(|_| std::io::Error::other("response header capture mutex was poisoned").into())
|
||||
}
|
||||
|
||||
fn abort_attempts(&self) -> usize {
|
||||
self.abort_attempts.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
impl Intercept for ResponseHeaderCapture {
|
||||
fn name(&self) -> &'static str {
|
||||
"ssec-copy-response-header-capture"
|
||||
}
|
||||
|
||||
fn read_before_deserialization(
|
||||
&self,
|
||||
context: &BeforeDeserializationInterceptorContextRef<'_>,
|
||||
_runtime_components: &RuntimeComponents,
|
||||
_cfg: &mut ConfigBag,
|
||||
) -> Result<(), BoxError> {
|
||||
let mut captured = self
|
||||
.headers
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("response header capture mutex was poisoned"))?;
|
||||
captured.clear();
|
||||
for name in [SSE_CUSTOMER_ALGORITHM_HEADER, SSE_CUSTOMER_KEY_MD5_HEADER] {
|
||||
if let Some(value) = context.response().headers().get(name) {
|
||||
captured.insert(name.to_owned(), value.to_owned());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_before_transmit(
|
||||
&self,
|
||||
context: &BeforeTransmitInterceptorContextRef<'_>,
|
||||
_runtime_components: &RuntimeComponents,
|
||||
_cfg: &mut ConfigBag,
|
||||
) -> Result<(), BoxError> {
|
||||
let request = context.request();
|
||||
if request.method() == "DELETE" && request.uri().contains("uploadId=") {
|
||||
self.abort_attempts.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn customer_key(byte: u8) -> CustomerKey {
|
||||
let raw = [byte; 32];
|
||||
CustomerKey {
|
||||
raw: String::from_utf8_lossy(&raw).into_owned(),
|
||||
encoded: base64::engine::general_purpose::STANDARD.encode(raw),
|
||||
md5: base64::engine::general_purpose::STANDARD.encode(md5::compute(raw).0),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_secret_absent(error: &str, keys: &[&CustomerKey]) {
|
||||
for key in keys {
|
||||
assert!(!error.contains(&key.raw), "error exposed a raw SSE-C key");
|
||||
assert!(!error.contains(&key.encoded), "error exposed an encoded SSE-C key");
|
||||
assert!(!error.contains(&key.md5), "error exposed an SSE-C key MD5");
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_ssec_cases<'a>(correct_key: &'a CustomerKey, wrong_key: &'a CustomerKey) -> [InvalidSsec<'a>; 5] {
|
||||
[
|
||||
InvalidSsec {
|
||||
algorithm: None,
|
||||
key: Some(&correct_key.encoded),
|
||||
md5: Some(&correct_key.md5),
|
||||
},
|
||||
InvalidSsec {
|
||||
algorithm: Some("AES256"),
|
||||
key: None,
|
||||
md5: Some(&correct_key.md5),
|
||||
},
|
||||
InvalidSsec {
|
||||
algorithm: Some("AES256"),
|
||||
key: Some(&correct_key.encoded),
|
||||
md5: None,
|
||||
},
|
||||
InvalidSsec {
|
||||
algorithm: Some("AES256"),
|
||||
key: Some(&wrong_key.encoded),
|
||||
md5: Some(&wrong_key.md5),
|
||||
},
|
||||
InvalidSsec {
|
||||
algorithm: Some("AES256"),
|
||||
key: Some(&correct_key.encoded),
|
||||
md5: Some(&wrong_key.md5),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn copy_object_rotates_ssec_key_and_drops_source_encryption_metadata() -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(Vec::new()).await?;
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "ssec-copy-object";
|
||||
let source = "source.bin";
|
||||
let plaintext_copy = "plaintext-copy.bin";
|
||||
let rotated_copy = "rotated-copy.bin";
|
||||
let source_key = customer_key(0x41);
|
||||
let destination_key = customer_key(0x42);
|
||||
let wrong_key = customer_key(0x43);
|
||||
let body = b"backlog-1467 versioned SSE-C copy payload";
|
||||
|
||||
env.create_test_bucket(bucket).await?;
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let put = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(source)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&source_key.encoded)
|
||||
.sse_customer_key_md5(&source_key.md5)
|
||||
.body(ByteStream::from_static(body))
|
||||
.send()
|
||||
.await?;
|
||||
let source_version = put.version_id().ok_or("versioned PUT returned no version ID")?;
|
||||
let copy_source = format!("{bucket}/{source}?versionId={source_version}");
|
||||
|
||||
let plaintext = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(plaintext_copy)
|
||||
.copy_source(©_source)
|
||||
.copy_source_sse_customer_algorithm("AES256")
|
||||
.copy_source_sse_customer_key(&source_key.encoded)
|
||||
.copy_source_sse_customer_key_md5(&source_key.md5)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(plaintext.copy_source_version_id(), Some(source_version));
|
||||
let plaintext_body = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(plaintext_copy)
|
||||
.send()
|
||||
.await?
|
||||
.body
|
||||
.collect()
|
||||
.await?
|
||||
.into_bytes();
|
||||
assert_eq!(plaintext_body.as_ref(), body);
|
||||
|
||||
let rotated = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(rotated_copy)
|
||||
.copy_source(©_source)
|
||||
.copy_source_sse_customer_algorithm("AES256")
|
||||
.copy_source_sse_customer_key(&source_key.encoded)
|
||||
.copy_source_sse_customer_key_md5(&source_key.md5)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&destination_key.encoded)
|
||||
.sse_customer_key_md5(&destination_key.md5)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(rotated.sse_customer_algorithm(), Some("AES256"));
|
||||
assert_eq!(rotated.sse_customer_key_md5(), Some(destination_key.md5.as_str()));
|
||||
|
||||
let wrong_key_error = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(rotated_copy)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&source_key.encoded)
|
||||
.sse_customer_key_md5(&source_key.md5)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("the source key must not read a copy encrypted with the destination key");
|
||||
assert_secret_absent(&format!("{wrong_key_error:?}"), &[&source_key, &destination_key]);
|
||||
|
||||
let rotated_body = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(rotated_copy)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&destination_key.encoded)
|
||||
.sse_customer_key_md5(&destination_key.md5)
|
||||
.send()
|
||||
.await?
|
||||
.body
|
||||
.collect()
|
||||
.await?
|
||||
.into_bytes();
|
||||
assert_eq!(rotated_body.as_ref(), body);
|
||||
|
||||
for (case_index, case) in invalid_ssec_cases(&source_key, &wrong_key).iter().enumerate() {
|
||||
let failed_target = format!("failed-copy-{case_index}.bin");
|
||||
let mut request = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(&failed_target)
|
||||
.copy_source(©_source);
|
||||
if let Some(algorithm) = case.algorithm {
|
||||
request = request.copy_source_sse_customer_algorithm(algorithm);
|
||||
}
|
||||
if let Some(key) = case.key {
|
||||
request = request.copy_source_sse_customer_key(key);
|
||||
}
|
||||
if let Some(md5) = case.md5 {
|
||||
request = request.copy_source_sse_customer_key_md5(md5);
|
||||
}
|
||||
let error = request
|
||||
.send()
|
||||
.await
|
||||
.expect_err("invalid source SSE-C parameters must reject CopyObject");
|
||||
assert_secret_absent(&format!("{error:?}"), &[&source_key, &wrong_key]);
|
||||
assert!(
|
||||
client.head_object().bucket(bucket).key(&failed_target).send().await.is_err(),
|
||||
"a rejected CopyObject must not create its target"
|
||||
);
|
||||
}
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_copy_requires_keys_on_every_stage_and_abort_leaves_no_object() -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(Vec::new()).await?;
|
||||
let response_headers = ResponseHeaderCapture::default();
|
||||
let credentials = Credentials::new(&env.access_key, &env.secret_key, None, None, "ssec-copy-e2e");
|
||||
let config = aws_sdk_s3::Config::builder()
|
||||
.credentials_provider(credentials)
|
||||
.region(Region::new("us-east-1"))
|
||||
.endpoint_url(&env.url)
|
||||
.force_path_style(true)
|
||||
.behavior_version_latest()
|
||||
.http_client(SmithyHttpClientBuilder::new().build_http())
|
||||
.interceptor(response_headers.clone())
|
||||
.build();
|
||||
let client = aws_sdk_s3::Client::from_conf(config);
|
||||
let bucket = "ssec-multipart-copy";
|
||||
let source = "source.bin";
|
||||
let destination = "destination.bin";
|
||||
let aborted_destination = "aborted.bin";
|
||||
let source_key = customer_key(0x51);
|
||||
let destination_key = customer_key(0x52);
|
||||
let wrong_key = customer_key(0x53);
|
||||
let part_size = 5 * 1024 * 1024;
|
||||
let body: Vec<u8> = (0..part_size * 2).map(|index| (index % 251) as u8).collect();
|
||||
|
||||
env.create_test_bucket(bucket).await?;
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let source_put = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(source)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&source_key.encoded)
|
||||
.sse_customer_key_md5(&source_key.md5)
|
||||
.body(ByteStream::from(body.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
let source_version = source_put
|
||||
.version_id()
|
||||
.ok_or("versioned multipart-copy source returned no version ID")?;
|
||||
|
||||
let create = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(destination)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&destination_key.encoded)
|
||||
.sse_customer_key_md5(&destination_key.md5)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(create.sse_customer_algorithm(), Some("AES256"));
|
||||
assert_eq!(create.sse_customer_key_md5(), Some(destination_key.md5.as_str()));
|
||||
let upload_id = create.upload_id().ok_or("CreateMultipartUpload returned no upload ID")?;
|
||||
let mut completed = Vec::new();
|
||||
for part_number in 1..=2 {
|
||||
let first = (part_number - 1) * part_size;
|
||||
let last = part_number * part_size - 1;
|
||||
let copied = client
|
||||
.upload_part_copy()
|
||||
.bucket(bucket)
|
||||
.key(destination)
|
||||
.upload_id(upload_id)
|
||||
.part_number(part_number)
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.copy_source_range(format!("bytes={first}-{last}"))
|
||||
.copy_source_sse_customer_algorithm("AES256")
|
||||
.copy_source_sse_customer_key(&source_key.encoded)
|
||||
.copy_source_sse_customer_key_md5(&source_key.md5)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&destination_key.encoded)
|
||||
.sse_customer_key_md5(&destination_key.md5)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
copied.copy_source_version_id(),
|
||||
Some(source_version),
|
||||
"UploadPartCopy must return the actual latest source version"
|
||||
);
|
||||
let etag = copied
|
||||
.copy_part_result()
|
||||
.and_then(|result| result.e_tag())
|
||||
.ok_or("UploadPartCopy returned no ETag")?;
|
||||
completed.push(CompletedPart::builder().part_number(part_number).e_tag(etag).build());
|
||||
}
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(destination)
|
||||
.upload_id(upload_id)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&destination_key.encoded)
|
||||
.sse_customer_key_md5(&destination_key.md5)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed)).build())
|
||||
.send()
|
||||
.await?;
|
||||
let completed_headers = response_headers.snapshot()?;
|
||||
assert_eq!(completed_headers.get(SSE_CUSTOMER_ALGORITHM_HEADER).map(String::as_str), Some("AES256"));
|
||||
assert_eq!(
|
||||
completed_headers.get(SSE_CUSTOMER_KEY_MD5_HEADER).map(String::as_str),
|
||||
Some(destination_key.md5.as_str())
|
||||
);
|
||||
let downloaded = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(destination)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&destination_key.encoded)
|
||||
.sse_customer_key_md5(&destination_key.md5)
|
||||
.send()
|
||||
.await?
|
||||
.body
|
||||
.collect()
|
||||
.await?
|
||||
.into_bytes();
|
||||
assert_eq!(downloaded.as_ref(), body.as_slice());
|
||||
|
||||
for (case_index, case) in invalid_ssec_cases(&destination_key, &wrong_key).iter().enumerate() {
|
||||
let failed_target = format!("{aborted_destination}-{case_index}");
|
||||
let failed_create = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&failed_target)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&destination_key.encoded)
|
||||
.sse_customer_key_md5(&destination_key.md5)
|
||||
.send()
|
||||
.await?;
|
||||
let failed_upload_id = failed_create
|
||||
.upload_id()
|
||||
.ok_or("CreateMultipartUpload returned no upload ID")?;
|
||||
let mut request = client
|
||||
.upload_part_copy()
|
||||
.bucket(bucket)
|
||||
.key(&failed_target)
|
||||
.upload_id(failed_upload_id)
|
||||
.part_number(1)
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.copy_source_sse_customer_algorithm("AES256")
|
||||
.copy_source_sse_customer_key(&source_key.encoded)
|
||||
.copy_source_sse_customer_key_md5(&source_key.md5);
|
||||
if let Some(algorithm) = case.algorithm {
|
||||
request = request.sse_customer_algorithm(algorithm);
|
||||
}
|
||||
if let Some(key) = case.key {
|
||||
request = request.sse_customer_key(key);
|
||||
}
|
||||
if let Some(md5) = case.md5 {
|
||||
request = request.sse_customer_key_md5(md5);
|
||||
}
|
||||
let error = request
|
||||
.send()
|
||||
.await
|
||||
.expect_err("invalid destination SSE-C parameters must reject UploadPartCopy");
|
||||
assert_secret_absent(&format!("{error:?}"), &[&source_key, &destination_key, &wrong_key]);
|
||||
|
||||
let abort_attempts_before = response_headers.abort_attempts();
|
||||
client
|
||||
.abort_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&failed_target)
|
||||
.upload_id(failed_upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
response_headers.abort_attempts(),
|
||||
abort_attempts_before + 1,
|
||||
"each failed multipart copy must issue exactly one wire-level abort attempt"
|
||||
);
|
||||
assert!(
|
||||
client.head_object().bucket(bucket).key(&failed_target).send().await.is_err(),
|
||||
"an aborted failed multipart copy must leave no completed object"
|
||||
);
|
||||
}
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
@@ -16,6 +16,8 @@
|
||||
pub(crate) use rustfs_ecstore::api::bucket::bucket_target_sys::BucketTargetSys;
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::disk::{VolumeInfo, WalkDirOptions};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::rpc::{TONIC_RPC_PREFIX, gen_signature_headers, gen_tonic_signature_headers};
|
||||
pub(crate) use rustfs_ecstore::api::rpc::{TonicInterceptor, node_service_time_out_client_no_auth};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::rpc::{gen_tonic_signature_interceptor, node_service_time_out_client};
|
||||
@@ -31,6 +33,17 @@ pub(crate) mod grpc_lock {
|
||||
pub(crate) use super::{TonicInterceptor, node_service_time_out_client_no_auth};
|
||||
}
|
||||
|
||||
/// Signing/transport surface used by the cross-process internode RPC signature
|
||||
/// acceptance tests (backlog#1327). The signing helpers are what let a test mint
|
||||
/// the one legitimately signed request an on-path attacker is assumed to have
|
||||
/// captured; every attack in that suite then only *reuses* those bytes.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod internode_rpc_signature {
|
||||
pub(crate) use super::{
|
||||
TONIC_RPC_PREFIX, gen_signature_headers, gen_tonic_signature_headers, node_service_time_out_client_no_auth,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod replication_extension {
|
||||
pub(crate) use super::BucketTargetSys;
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Truthful storage-class write and discovery contract regressions.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{ObjectAttributes, StorageClass};
|
||||
use http::header::HOST;
|
||||
use reqwest::StatusCode;
|
||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
use rustfs_signer::sign_v4;
|
||||
use s3s::Body;
|
||||
use serde_json::Value;
|
||||
use std::error::Error;
|
||||
use std::path::Path;
|
||||
|
||||
const UNSUPPORTED_AWS_CLASSES: [&str; 9] = [
|
||||
"DEEP_ARCHIVE",
|
||||
"EXPRESS_ONEZONE",
|
||||
"GLACIER",
|
||||
"GLACIER_IR",
|
||||
"INTELLIGENT_TIERING",
|
||||
"ONEZONE_IA",
|
||||
"OUTPOSTS",
|
||||
"SNOW",
|
||||
"STANDARD_IA",
|
||||
];
|
||||
|
||||
async fn assert_object_storage_class(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
expected: &str,
|
||||
body: &[u8],
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let head = client.head_object().bucket(bucket).key(key).send().await?;
|
||||
let expected_head = (expected != "STANDARD").then_some(expected);
|
||||
assert_eq!(
|
||||
head.storage_class().map(StorageClass::as_str),
|
||||
expected_head,
|
||||
"HeadObject must omit implicit STANDARD and report RRS"
|
||||
);
|
||||
|
||||
let listed = client.list_objects_v2().bucket(bucket).prefix(key).send().await?;
|
||||
let object = listed
|
||||
.contents()
|
||||
.iter()
|
||||
.find(|object| object.key() == Some(key))
|
||||
.ok_or("object missing from ListObjectsV2")?;
|
||||
assert_eq!(object.storage_class().map(|storage_class| storage_class.as_str()), Some(expected));
|
||||
|
||||
let get = client.get_object().bucket(bucket).key(key).send().await?;
|
||||
assert_eq!(
|
||||
get.storage_class().map(StorageClass::as_str),
|
||||
expected_head,
|
||||
"GetObject must report the same effective storage class as HeadObject"
|
||||
);
|
||||
let downloaded = get.body.collect().await?.into_bytes();
|
||||
assert_eq!(downloaded.as_ref(), body, "storage-class selection must not alter object bytes");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mutate_xl_meta(
|
||||
root: &str,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
mutate: impl FnOnce(&mut rustfs_filemeta::MetaObject),
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let path = Path::new(root).join(bucket).join(key).join("xl.meta");
|
||||
let bytes = tokio::fs::read(&path).await?;
|
||||
let mut file_meta = rustfs_filemeta::FileMeta::load(&bytes)?;
|
||||
let (index, mut version) = file_meta.find_version(None)?;
|
||||
let object = version.object.as_mut().ok_or("fixture version is not an object")?;
|
||||
mutate(object);
|
||||
file_meta.versions[index] = rustfs_filemeta::FileMetaShallowVersion::try_from(version)?;
|
||||
tokio::fs::write(path, file_meta.marshal_msg()?).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn signed_admin_get(
|
||||
env: &RustFSTestEnvironment,
|
||||
path: &str,
|
||||
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
|
||||
let url = format!("{}{path}", env.url);
|
||||
let uri = url.parse::<http::Uri>()?;
|
||||
let authority = uri.authority().ok_or("admin URL missing authority")?.to_string();
|
||||
let request = http::Request::builder()
|
||||
.method(http::Method::GET)
|
||||
.uri(uri)
|
||||
.header(HOST, authority)
|
||||
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD)
|
||||
.body(Body::empty())?;
|
||||
let signed = sign_v4(request, 0, &env.access_key, &env.secret_key, "", "us-east-1");
|
||||
|
||||
let mut request = local_http_client().get(&url);
|
||||
for (name, value) in signed.headers() {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
Ok(request.send().await?)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standard_and_rrs_are_supported_across_put_copy_and_multipart() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(Vec::new()).await?;
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "storage-class-supported-contract";
|
||||
env.create_test_bucket(bucket).await?;
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("copy-source")
|
||||
.body(ByteStream::from_static(b"copy-source-body"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
for storage_class in [StorageClass::Standard, StorageClass::ReducedRedundancy] {
|
||||
let class_name = storage_class.as_str().to_string();
|
||||
let put_key = format!("put-{class_name}");
|
||||
let put_body = format!("put-body-{class_name}").into_bytes();
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(&put_key)
|
||||
.storage_class(storage_class.clone())
|
||||
.body(ByteStream::from(put_body.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
assert_object_storage_class(&client, bucket, &put_key, &class_name, &put_body).await?;
|
||||
|
||||
let copy_key = format!("copy-{class_name}");
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(©_key)
|
||||
.copy_source(format!("{bucket}/copy-source"))
|
||||
.storage_class(storage_class.clone())
|
||||
.send()
|
||||
.await?;
|
||||
assert_object_storage_class(&client, bucket, ©_key, &class_name, b"copy-source-body").await?;
|
||||
|
||||
let multipart_key = format!("multipart-{class_name}");
|
||||
let created = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&multipart_key)
|
||||
.storage_class(storage_class)
|
||||
.send()
|
||||
.await?;
|
||||
let upload_id = created.upload_id().ok_or("CreateMultipartUpload returned no upload ID")?;
|
||||
let parts = client
|
||||
.list_parts()
|
||||
.bucket(bucket)
|
||||
.key(&multipart_key)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(parts.storage_class().map(StorageClass::as_str), Some(class_name.as_str()));
|
||||
client
|
||||
.abort_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&multipart_key)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn label_only_aws_classes_fail_before_put_copy_or_multipart_mutation() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(Vec::new()).await?;
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "storage-class-unsupported-contract";
|
||||
env.create_test_bucket(bucket).await?;
|
||||
|
||||
for key in ["put-guard", "copy-source", "copy-guard"] {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(format!("original-{key}").into_bytes()))
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
|
||||
for unsupported in UNSUPPORTED_AWS_CLASSES {
|
||||
let put_error = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("put-guard")
|
||||
.storage_class(StorageClass::from(unsupported))
|
||||
.body(ByteStream::from(format!("rejected-put-{unsupported}").into_bytes()))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("label-only PUT storage class must be rejected");
|
||||
assert_eq!(
|
||||
put_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("InvalidStorageClass"),
|
||||
"PUT returned a different error for {unsupported}"
|
||||
);
|
||||
|
||||
let copy_error = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("copy-guard")
|
||||
.copy_source(format!("{bucket}/copy-source"))
|
||||
.storage_class(StorageClass::from(unsupported))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("label-only CopyObject storage class must be rejected");
|
||||
assert_eq!(
|
||||
copy_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("InvalidStorageClass"),
|
||||
"CopyObject returned a different error for {unsupported}"
|
||||
);
|
||||
|
||||
let multipart_key = format!("multipart-{unsupported}");
|
||||
let multipart_error = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&multipart_key)
|
||||
.storage_class(StorageClass::from(unsupported))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("label-only CreateMultipartUpload storage class must be rejected");
|
||||
assert_eq!(
|
||||
multipart_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("InvalidStorageClass"),
|
||||
"CreateMultipartUpload returned a different error for {unsupported}"
|
||||
);
|
||||
}
|
||||
|
||||
let put_guard = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("put-guard")
|
||||
.send()
|
||||
.await?
|
||||
.body
|
||||
.collect()
|
||||
.await?
|
||||
.into_bytes();
|
||||
assert_eq!(put_guard.as_ref(), b"original-put-guard");
|
||||
|
||||
let copy_guard = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("copy-guard")
|
||||
.send()
|
||||
.await?
|
||||
.body
|
||||
.collect()
|
||||
.await?
|
||||
.into_bytes();
|
||||
assert_eq!(copy_guard.as_ref(), b"original-copy-guard");
|
||||
|
||||
let uploads = client.list_multipart_uploads().bucket(bucket).send().await?;
|
||||
assert!(uploads.uploads().is_empty(), "unsupported classes must not create multipart sessions");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn historical_label_only_metadata_is_standard_without_hiding_a_real_transition_tier()
|
||||
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(Vec::new()).await?;
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "storage-class-historical-contract";
|
||||
let legacy_key = "legacy-label-only";
|
||||
let transitioned_key = "real-transition-tier";
|
||||
env.create_test_bucket(bucket).await?;
|
||||
|
||||
for key in [legacy_key, transitioned_key] {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"fixture-body"))
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
|
||||
env.stop_server();
|
||||
mutate_xl_meta(&env.temp_dir, bucket, legacy_key, |object| {
|
||||
object
|
||||
.meta_user
|
||||
.insert("x-amz-storage-class".to_string(), "STANDARD_IA".to_string());
|
||||
})
|
||||
.await?;
|
||||
mutate_xl_meta(&env.temp_dir, bucket, transitioned_key, |object| {
|
||||
object.set_transition(&rustfs_filemeta::FileInfo {
|
||||
transition_status: rustfs_filemeta::TRANSITION_COMPLETE.to_string(),
|
||||
transition_tier: "STANDARD_IA".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
})
|
||||
.await?;
|
||||
env.restart_server_preserving_data(Vec::new(), &[]).await?;
|
||||
|
||||
assert_object_storage_class(&client, bucket, legacy_key, "STANDARD", b"fixture-body").await?;
|
||||
|
||||
let legacy_attributes = client
|
||||
.get_object_attributes()
|
||||
.bucket(bucket)
|
||||
.key(legacy_key)
|
||||
.object_attributes(ObjectAttributes::StorageClass)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(legacy_attributes.storage_class().map(StorageClass::as_str), Some("STANDARD"));
|
||||
|
||||
let versions = client.list_object_versions().bucket(bucket).prefix(legacy_key).send().await?;
|
||||
let legacy_version = versions
|
||||
.versions()
|
||||
.iter()
|
||||
.find(|version| version.key() == Some(legacy_key))
|
||||
.ok_or("legacy fixture missing from ListObjectVersions")?;
|
||||
assert_eq!(legacy_version.storage_class().map(|class| class.as_str()), Some("STANDARD"));
|
||||
|
||||
let transitioned_head = client.head_object().bucket(bucket).key(transitioned_key).send().await?;
|
||||
assert_eq!(transitioned_head.storage_class().map(StorageClass::as_str), Some("STANDARD_IA"));
|
||||
let transitioned_attributes = client
|
||||
.get_object_attributes()
|
||||
.bucket(bucket)
|
||||
.key(transitioned_key)
|
||||
.object_attributes(ObjectAttributes::StorageClass)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(transitioned_attributes.storage_class().map(StorageClass::as_str), Some("STANDARD_IA"));
|
||||
let transitioned_list = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.prefix(transitioned_key)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
transitioned_list.contents()[0].storage_class().map(|class| class.as_str()),
|
||||
Some("STANDARD_IA")
|
||||
);
|
||||
let transitioned_versions = client
|
||||
.list_object_versions()
|
||||
.bucket(bucket)
|
||||
.prefix(transitioned_key)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
transitioned_versions.versions()[0]
|
||||
.storage_class()
|
||||
.map(|class| class.as_str()),
|
||||
Some("STANDARD_IA")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authenticated_runtime_capabilities_publish_the_versioned_storage_class_contract()
|
||||
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(Vec::new()).await?;
|
||||
let path = "/rustfs/admin/v4/runtime/capabilities";
|
||||
|
||||
let unsigned = local_http_client().get(format!("{}{path}", env.url)).send().await?;
|
||||
assert_eq!(unsigned.status(), StatusCode::FORBIDDEN);
|
||||
let unsigned_body = unsigned.text().await?;
|
||||
assert!(
|
||||
!unsigned_body.contains("supported_write_classes"),
|
||||
"the capability contract must not bypass admin authentication"
|
||||
);
|
||||
assert!(
|
||||
!unsigned_body.contains("manual_transition_jobs"),
|
||||
"manual transition job capabilities must not bypass admin authentication"
|
||||
);
|
||||
|
||||
let response = signed_admin_get(&env, path).await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body: Value = response.json().await?;
|
||||
assert_eq!(body["storage_classes"]["contract_version"], 1);
|
||||
assert_eq!(
|
||||
body["storage_classes"]["supported_write_classes"],
|
||||
serde_json::json!(["STANDARD", "REDUCED_REDUNDANCY"])
|
||||
);
|
||||
assert_eq!(body["storage_classes"]["unsupported_write_error"], "InvalidStorageClass");
|
||||
assert_eq!(body["storage_classes"]["legacy_label_behavior"], "normalized_to_effective_class");
|
||||
assert_eq!(body["summary"]["manual_transition_jobs"]["state"], "supported");
|
||||
assert_eq!(body["manual_transition_jobs"]["contract_version"], 1);
|
||||
assert_eq!(body["manual_transition_jobs"]["status"]["state"], "supported");
|
||||
assert_eq!(body["manual_transition_jobs"]["modes"], serde_json::json!(["enqueue_only", "async"]));
|
||||
assert_eq!(body["manual_transition_jobs"]["run_route"], "/rustfs/admin/v3/ilm/transition/run");
|
||||
assert_eq!(
|
||||
body["manual_transition_jobs"]["status_route"],
|
||||
"/rustfs/admin/v3/ilm/transition/jobs/{job_id}"
|
||||
);
|
||||
assert_eq!(
|
||||
body["manual_transition_jobs"]["cancel_route"],
|
||||
"/rustfs/admin/v3/ilm/transition/jobs/{job_id}"
|
||||
);
|
||||
assert_eq!(body["manual_transition_jobs"]["job_id_format"], "uuid");
|
||||
assert_eq!(body["manual_transition_jobs"]["admission_scope"], "bucket");
|
||||
assert_eq!(
|
||||
body["manual_transition_jobs"]["mixed_version_policy"],
|
||||
"fail_closed_when_capability_unknown_or_unsupported"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||
use aws_sdk_sts::config::retry::RetryConfig;
|
||||
use aws_sdk_sts::config::{Credentials, Region};
|
||||
use aws_sdk_sts::error::ProvideErrorMetadata;
|
||||
use aws_sdk_sts::operation::RequestId;
|
||||
use aws_sdk_sts::{Client, Config};
|
||||
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||
use http::header::{CONTENT_TYPE, HOST};
|
||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
use rustfs_signer::sign_v4;
|
||||
use s3s::Body;
|
||||
use serial_test::serial;
|
||||
use std::error::Error;
|
||||
|
||||
type BoxError = Box<dyn Error + Send + Sync>;
|
||||
type TestResult = Result<(), BoxError>;
|
||||
|
||||
fn sts_client(url: &str, access_key: &str, secret_key: &str, session_token: Option<&str>) -> Client {
|
||||
let mut config = Config::builder()
|
||||
.credentials_provider(Credentials::new(
|
||||
access_key,
|
||||
secret_key,
|
||||
session_token.map(str::to_owned),
|
||||
None,
|
||||
"e2e-sts-query-compat",
|
||||
))
|
||||
.region(Region::new("us-east-1"))
|
||||
.endpoint_url(url)
|
||||
.retry_config(RetryConfig::standard().with_max_attempts(1))
|
||||
.behavior_version_latest();
|
||||
if url.starts_with("http://") {
|
||||
config = config.http_client(SmithyHttpClientBuilder::new().build_http());
|
||||
}
|
||||
Client::from_conf(config.build())
|
||||
}
|
||||
|
||||
async fn create_root_service_account(env: &RustFSTestEnvironment) -> Result<(String, String), BoxError> {
|
||||
let path = "/rustfs/admin/v3/add-service-accounts";
|
||||
let url = format!("{}{path}", env.url);
|
||||
let uri = url.parse::<http::Uri>()?;
|
||||
let authority = uri.authority().ok_or("admin URL missing authority")?.to_string();
|
||||
let body = serde_json::json!({ "targetUser": env.access_key.clone() }).to_string();
|
||||
let request = http::Request::builder()
|
||||
.method(http::Method::PUT)
|
||||
.uri(uri)
|
||||
.header(HOST, authority)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD)
|
||||
.body(Body::empty())?;
|
||||
let content_length = i64::try_from(body.len()).map_err(|_| "service account request body is too large")?;
|
||||
let signed = sign_v4(request, content_length, &env.access_key, &env.secret_key, "", "us-east-1");
|
||||
let mut request = local_http_client().put(&url);
|
||||
for (name, value) in signed.headers() {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
let response = request.body(body).send().await?;
|
||||
let status = response.status();
|
||||
let body = response.text().await?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("create service account failed: {status} {body}").into());
|
||||
}
|
||||
|
||||
let response: serde_json::Value = serde_json::from_str(&body)?;
|
||||
let access_key = response["credentials"]["accessKey"]
|
||||
.as_str()
|
||||
.ok_or("service account response should contain credentials.accessKey")?
|
||||
.to_owned();
|
||||
let secret_key = response["credentials"]["secretKey"]
|
||||
.as_str()
|
||||
.ok_or("service account response should contain credentials.secretKey")?
|
||||
.to_owned();
|
||||
Ok((access_key, secret_key))
|
||||
}
|
||||
|
||||
async fn assert_chaining_denied(client: &Client, credential_kind: &str) -> TestResult {
|
||||
let error = client
|
||||
.assume_role()
|
||||
.role_arn("arn:aws:iam::123456789012:role/test")
|
||||
.role_session_name("sts-query-compat-e2e")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("credential chaining must be denied");
|
||||
let service_error = error
|
||||
.as_service_error()
|
||||
.ok_or_else(|| format!("{credential_kind} denial should deserialize as an STS service error: {error:?}"))?;
|
||||
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(403));
|
||||
assert_eq!(service_error.code(), Some("AccessDenied"));
|
||||
assert_eq!(service_error.message(), Some("Access Denied"));
|
||||
assert!(
|
||||
error.request_id().is_some_and(|request_id| !request_id.is_empty()),
|
||||
"{credential_kind} denial should include a request ID"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_sts_query_responses_are_aws_sdk_compatible() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let assumed = sts_client(&env.url, &env.access_key, &env.secret_key, None)
|
||||
.assume_role()
|
||||
.role_arn("arn:aws:iam::123456789012:role/test")
|
||||
.role_session_name("sts-query-compat-e2e")
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
assumed.request_id().is_some_and(|request_id| !request_id.is_empty()),
|
||||
"successful AssumeRole should include a request ID"
|
||||
);
|
||||
let temporary = assumed
|
||||
.credentials()
|
||||
.ok_or("successful AssumeRole response should contain credentials")?;
|
||||
|
||||
let invalid_signature = sts_client(&env.url, &env.access_key, "incorrect-secret-key", None)
|
||||
.assume_role()
|
||||
.role_arn("arn:aws:iam::123456789012:role/test")
|
||||
.role_session_name("sts-query-invalid-signature")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("an invalid signature must be rejected");
|
||||
assert_eq!(invalid_signature.raw_response().map(|response| response.status().as_u16()), Some(403));
|
||||
let invalid_signature_service_error = invalid_signature
|
||||
.as_service_error()
|
||||
.ok_or_else(|| format!("invalid signature should deserialize as an STS service error: {invalid_signature:?}"))?;
|
||||
assert_eq!(invalid_signature_service_error.code(), Some("SignatureDoesNotMatch"));
|
||||
assert!(
|
||||
invalid_signature_service_error
|
||||
.message()
|
||||
.is_some_and(|message| message.starts_with("The request signature we calculated does not match")),
|
||||
"signature rejection should preserve the canonical error message"
|
||||
);
|
||||
assert!(
|
||||
invalid_signature
|
||||
.request_id()
|
||||
.is_some_and(|request_id| !request_id.is_empty()),
|
||||
"signature rejection should include a request ID"
|
||||
);
|
||||
|
||||
assert_chaining_denied(
|
||||
&sts_client(
|
||||
&env.url,
|
||||
temporary.access_key_id(),
|
||||
temporary.secret_access_key(),
|
||||
Some(temporary.session_token()),
|
||||
),
|
||||
"temporary credential",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (service_access_key, service_secret_key) = create_root_service_account(&env).await?;
|
||||
assert_chaining_denied(&sts_client(&env.url, &service_access_key, &service_secret_key, None), "service account").await?;
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_sts_query_rate_limit_error_is_aws_sdk_compatible() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(
|
||||
vec![],
|
||||
&[
|
||||
("RUSTFS_API_RATE_LIMIT_ENABLE", "true"),
|
||||
("RUSTFS_API_RATE_LIMIT_RPM", "60"),
|
||||
("RUSTFS_API_RATE_LIMIT_BURST", "1"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let client = sts_client(&env.url, &env.access_key, &env.secret_key, None);
|
||||
let mut throttled = None;
|
||||
let request = || {
|
||||
client
|
||||
.assume_role()
|
||||
.role_arn("arn:aws:iam::123456789012:role/test")
|
||||
.role_session_name("sts-query-rate-limit")
|
||||
.send()
|
||||
};
|
||||
let (first, second, third, fourth) = tokio::join!(request(), request(), request(), request());
|
||||
for result in [first, second, third, fourth] {
|
||||
if let Err(error) = result
|
||||
&& error.raw_response().map(|response| response.status().as_u16()) == Some(429)
|
||||
{
|
||||
throttled = Some(error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let error = throttled.ok_or("at least one concurrent STS request should be throttled at burst one")?;
|
||||
let service_error = error
|
||||
.as_service_error()
|
||||
.ok_or_else(|| format!("rate limit response should deserialize as an STS service error: {error:?}"))?;
|
||||
assert_eq!(service_error.code(), Some("TooManyRequests"));
|
||||
assert!(
|
||||
service_error
|
||||
.message()
|
||||
.is_some_and(|message| message.starts_with("Request rate limit exceeded")),
|
||||
"rate limit response should preserve the server message"
|
||||
);
|
||||
assert!(
|
||||
error.request_id().is_some_and(|request_id| !request_id.is_empty()),
|
||||
"rate limit response should include a request ID"
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
@@ -61,6 +61,7 @@ rustfs-kms.workspace = true
|
||||
rustfs-s3-types = { workspace = true }
|
||||
rustfs-data-usage.workspace = true
|
||||
rustfs-object-capacity.workspace = true
|
||||
arc-swap.workspace = true
|
||||
async-trait.workspace = true
|
||||
bytes = { workspace = true, features = ["serde"] }
|
||||
byteorder = { workspace = true }
|
||||
@@ -122,7 +123,7 @@ libc.workspace = true
|
||||
# (backlog#1178); "fs" comes from the workspace default.
|
||||
rustix = { workspace = true, features = ["process", "fs"] }
|
||||
rustfs-madmin.workspace = true
|
||||
reqwest = { workspace = true, default-features = false, features = ["rustls", "http2", "system-proxy"] }
|
||||
reqwest = { workspace = true }
|
||||
aes-gcm = { workspace = true, features = ["rand_core"] }
|
||||
chacha20poly1305.workspace = true
|
||||
aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
|
||||
|
||||
@@ -43,10 +43,35 @@ pub mod bucket {
|
||||
|
||||
pub mod bucket_lifecycle_ops {
|
||||
pub use crate::bucket::lifecycle::bucket_lifecycle_ops::{
|
||||
ExpiryState, LifecycleOps, RestoreRequestOps, TransitionState, TransitionedObject, apply_expiry_rule,
|
||||
ExpiryState, LifecycleOps, ManualTransitionCancelCheck, ManualTransitionProgressSink,
|
||||
ManualTransitionQueueSnapshot, ManualTransitionRunExecution, ManualTransitionRunOptions,
|
||||
ManualTransitionRunReport, RestoreRequestOps, TransitionState, TransitionedObject, apply_expiry_rule,
|
||||
apply_transition_rule, enqueue_expiry_for_existing_objects, enqueue_transition_for_existing_objects,
|
||||
enqueue_transition_for_existing_objects_scoped, enqueue_transition_for_existing_objects_scoped_with_cancel,
|
||||
enqueue_transition_immediate, expire_transitioned_object, get_global_expiry_state, get_global_transition_state,
|
||||
init_background_expiry, post_restore_opts, run_stale_multipart_upload_cleanup_once, validate_transition_tier,
|
||||
init_background_expiry, manual_transition_queue_snapshot, post_restore_opts,
|
||||
run_stale_multipart_upload_cleanup_once, validate_transition_tier,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod manual_transition_job {
|
||||
pub use crate::bucket::lifecycle::manual_transition_job::{
|
||||
ManualTransitionJobRecord, ManualTransitionJobState, ManualTransitionScopeAdmission,
|
||||
ManualTransitionScopeAdmissionClaim, claim_manual_transition_scope_admission,
|
||||
delete_manual_transition_scope_admission_if_current, load_manual_transition_job_record,
|
||||
load_manual_transition_job_record_with_etag, load_manual_transition_scope_admission,
|
||||
manual_transition_job_lease_expired, manual_transition_scope_admission_lease_expired,
|
||||
manual_transition_scope_key, persist_manual_transition_job_progress, renew_manual_transition_job_lease,
|
||||
request_manual_transition_job_cancel, save_manual_transition_job_record,
|
||||
save_manual_transition_job_record_if_current, save_manual_transition_scope_admission_if_absent,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod transition_transaction {
|
||||
pub use crate::bucket::lifecycle::transition_transaction::{
|
||||
TransitionOperatorDeleteResult, TransitionOperatorError, TransitionOperatorProbe, TransitionOperatorStatus,
|
||||
delete_transition_candidate_for_operator, finalize_missing_transition_transaction_for_operator,
|
||||
inspect_transition_transaction_for_operator,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,7 +92,11 @@ pub mod bucket {
|
||||
}
|
||||
|
||||
pub mod tier_delete_journal {
|
||||
pub use crate::bucket::lifecycle::tier_delete_journal::persist_tier_delete_journal_entry;
|
||||
#[cfg(feature = "test-util")]
|
||||
pub use crate::bucket::lifecycle::tier_delete_journal::recover_tier_delete_journal_entries;
|
||||
pub use crate::bucket::lifecycle::tier_delete_journal::{
|
||||
persist_tier_delete_journal_entry, record_tier_delete_journal_backend_identity,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod tier_last_day_stats {
|
||||
@@ -101,12 +130,13 @@ pub mod bucket {
|
||||
|
||||
pub mod metadata_sys {
|
||||
pub use crate::bucket::metadata_sys::{
|
||||
BucketMetadataSys, delete, get, get_accelerate_config, get_bucket_policy, get_bucket_policy_raw,
|
||||
get_bucket_targets_config, get_config_from_disk, get_cors_config, get_durability_config,
|
||||
BucketMetadataSys, acquire_bucket_targets_transaction_lock, delete, get, get_accelerate_config, get_bucket_policy,
|
||||
get_bucket_policy_raw, get_bucket_targets_config, get_config_from_disk, get_cors_config, get_durability_config,
|
||||
get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config, get_notification_config,
|
||||
get_object_lock_config, get_public_access_block_config, get_quota_config, get_replication_config,
|
||||
get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config, get_website_config,
|
||||
init_bucket_metadata_sys, list_bucket_targets, remove_bucket_metadata, set_bucket_metadata, update,
|
||||
update_bucket_targets_under_transaction_lock,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -143,18 +173,19 @@ pub mod bucket {
|
||||
|
||||
pub mod replication {
|
||||
pub use crate::bucket::replication::{
|
||||
BucketReplicationResyncStatus, BucketStats, DeletedObjectReplicationInfo, DynReplicationPool, MustReplicateOptions,
|
||||
ObjectOpts, REPLICATE_INCOMING_DELETE, ReplicateDecision, ReplicateObjectInfo, ReplicationConfig,
|
||||
ReplicationConfigurationExt, ReplicationDeleteScheduleInput, ReplicationDeleteStateSource,
|
||||
ReplicationHealQueueResult, ReplicationObjectBridge, ReplicationObjectIO, ReplicationOperation, ReplicationPoolTrait,
|
||||
ReplicationPriority, ReplicationQueueAdmission, ReplicationScannerBridge, ReplicationState, ReplicationStats,
|
||||
ReplicationStatusType, ReplicationStorage, ReplicationTargetValidationError, ReplicationType, ResyncOpts,
|
||||
ResyncStatusType, TargetReplicationResyncStatus, VersionPurgeStatusType, delete_replication_state_from_config,
|
||||
delete_replication_version_id, get_global_replication_pool, get_global_replication_stats,
|
||||
init_background_replication, replication_state_to_filemeta, replication_status_to_filemeta, replication_statuses_map,
|
||||
replication_target_arns, should_remove_replication_target, should_schedule_delete_replication,
|
||||
should_use_existing_delete_replication_info, should_use_existing_delete_replication_source,
|
||||
validate_replication_config_target_arns, version_purge_status_to_filemeta,
|
||||
BucketReplicationResyncStatus, BucketStats, DeletedObjectReplicationInfo, DurableMrfBacklog, DynReplicationPool,
|
||||
MrfOpKind, MrfReplicateEntry, MustReplicateOptions, ObjectOpts, REPLICATE_INCOMING_DELETE, ReplicateDecision,
|
||||
ReplicateObjectInfo, ReplicationConfig, ReplicationConfigurationExt, ReplicationDeleteScheduleInput,
|
||||
ReplicationDeleteStateSource, ReplicationHealQueueResult, ReplicationObjectBridge, ReplicationObjectIO,
|
||||
ReplicationOperation, ReplicationPoolTrait, ReplicationPriority, ReplicationQueueAdmission, ReplicationScannerBridge,
|
||||
ReplicationState, ReplicationStats, ReplicationStatusType, ReplicationStorage, ReplicationTargetValidationError,
|
||||
ReplicationType, ResyncOpts, ResyncStatusType, TargetReplicationResyncStatus, VersionPurgeStatusType,
|
||||
delete_replication_state_from_config, delete_replication_version_id, get_global_replication_pool,
|
||||
get_global_replication_stats, init_background_replication, read_durable_mrf_backlog, replication_state_to_filemeta,
|
||||
replication_status_to_filemeta, replication_statuses_map, replication_target_arns, resync_start_conflict_id,
|
||||
should_remove_replication_target, should_schedule_delete_replication, should_use_existing_delete_replication_info,
|
||||
should_use_existing_delete_replication_source, validate_replication_config_target_arns,
|
||||
version_purge_status_to_filemeta,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -236,18 +267,23 @@ pub mod config {
|
||||
pub mod com {
|
||||
pub use crate::config::com::{
|
||||
COMMA_SEPARATED_LISTS, CONFIG_PREFIX, ENV_CONFIG_RECOVER_ON_CORRUPTION, STORAGE_CLASS_SUB_SYS,
|
||||
ServerConfigCorruptError, delete_config, is_server_config_corrupt_error, lookup_configs, read_config,
|
||||
read_config_no_lock, read_config_with_metadata, read_config_without_migrate, save_config, save_config_with_opts,
|
||||
save_server_config, try_migrate_server_config,
|
||||
ServerConfigCorruptError, ServerConfigSnapshot, delete_config, is_server_config_corrupt_error, lookup_configs,
|
||||
read_config, read_config_no_lock, read_config_with_metadata, read_config_without_migrate,
|
||||
read_config_without_migrate_no_lock, read_existing_server_config_no_lock, read_server_config_snapshot, save_config,
|
||||
save_config_no_lock, save_config_with_opts, save_server_config, save_server_config_no_lock,
|
||||
save_server_config_snapshot, server_config_path, try_migrate_server_config, with_config_object_read_lock,
|
||||
with_config_object_write_lock, with_server_config_read_lock, with_server_config_write_lock,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod storageclass {
|
||||
pub use crate::config::storageclass::{
|
||||
CLASS_RRS, CLASS_STANDARD, Config, DEEP_ARCHIVE, DEFAULT_INLINE_BLOCK, DEFAULT_KVS, DEFAULT_RRS_PARITY,
|
||||
EXPRESS_ONEZONE, GLACIER, GLACIER_IR, INLINE_BLOCK, INLINE_BLOCK_ENV, INTELLIGENT_TIERING, MIN_PARITY_DRIVES,
|
||||
ONEZONE_IA, OPTIMIZE, OPTIMIZE_ENV, OUTPOSTS, RRS, RRS_ENV, SCHEME_PREFIX, SNOW, STANDARD, STANDARD_ENV, STANDARD_IA,
|
||||
StorageClass, default_parity_count, lookup_config, parse_storage_class, validate_parity, validate_parity_inner,
|
||||
CAPABILITY_CONTRACT_VERSION, CLASS_RRS, CLASS_STANDARD, Config, DEEP_ARCHIVE, DEFAULT_INLINE_BLOCK, DEFAULT_KVS,
|
||||
DEFAULT_RRS_PARITY, EXPRESS_ONEZONE, GLACIER, GLACIER_IR, INLINE_BLOCK, INLINE_BLOCK_ENV, INTELLIGENT_TIERING,
|
||||
LEGACY_LABEL_BEHAVIOR, MIN_PARITY_DRIVES, ONEZONE_IA, OPTIMIZE, OPTIMIZE_ENV, OUTPOSTS, RRS, RRS_ENV, SCHEME_PREFIX,
|
||||
SNOW, STANDARD, STANDARD_ENV, STANDARD_IA, SUPPORTED_WRITE_CLASSES, StorageClass, UNSUPPORTED_WRITE_ERROR,
|
||||
default_parity_count, effective_class, is_supported_write_class, lookup_config, lookup_config_for_pools,
|
||||
parse_storage_class, validate_parity, validate_parity_inner,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -258,12 +294,14 @@ pub mod config {
|
||||
|
||||
pub mod data_usage {
|
||||
pub use crate::data_usage::{
|
||||
DATA_USAGE_CACHE_NAME, apply_bucket_usage_memory_overlay, init_compression_total_memory_from_backend,
|
||||
load_compression_total_from_memory, load_data_usage_from_backend, record_bucket_delete_marker_memory,
|
||||
record_bucket_object_delete_memory, record_bucket_object_version_write_memory, record_bucket_object_write_memory,
|
||||
record_bucket_object_write_unknown_previous_memory, record_compression_total_memory,
|
||||
DATA_USAGE_CACHE_NAME, apply_bucket_usage_memory_overlay, compute_bucket_usage,
|
||||
init_compression_total_memory_from_backend, invalidate_data_usage_snapshot_cache, live_bucket_usage_computations,
|
||||
load_compression_total_from_memory, load_data_usage_from_backend, load_data_usage_from_backend_cached,
|
||||
record_bucket_delete_marker_memory, record_bucket_object_delete_memory, record_bucket_object_version_write_memory,
|
||||
record_bucket_object_write_memory, record_bucket_object_write_unknown_previous_memory, record_compression_total_memory,
|
||||
refresh_bucket_usage_from_object_layer, refresh_versioned_bucket_usage_from_object_layer,
|
||||
remove_bucket_usage_from_backend, replace_bucket_usage_memory_from_info, store_compression_total_in_backend,
|
||||
store_data_usage_in_backend,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -273,9 +311,9 @@ pub mod disk {
|
||||
pub use crate::disk::{
|
||||
BATCH_READ_VERSION_MAX_ITEMS, BUCKET_META_PREFIX, BatchReadVersionItem, BatchReadVersionReq, BatchReadVersionResp,
|
||||
CheckPartsResp, DeleteOptions, Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption, DiskStore,
|
||||
FileInfoVersions, FileReader, FileWriter, HEALING_MARKER_PATH, OldCurrentSize, RUSTFS_META_BUCKET, ReadMultipleReq,
|
||||
ReadMultipleResp, ReadOptions, RenameDataResp, STORAGE_FORMAT_FILE, UpdateMetadataOpts, VolumeInfo, WalkDirOptions,
|
||||
new_disk, validate_batch_read_version_item_count,
|
||||
FileInfoVersions, FileReader, FileWriter, HEALING_MARKER_PATH, NsScannerOpenRequest, OldCurrentSize,
|
||||
PartTransactionAction, RUSTFS_META_BUCKET, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp,
|
||||
STORAGE_FORMAT_FILE, UpdateMetadataOpts, VolumeInfo, WalkDirOptions, new_disk, validate_batch_read_version_item_count,
|
||||
};
|
||||
pub use bytes::Bytes;
|
||||
pub use endpoint::Endpoint;
|
||||
@@ -308,8 +346,8 @@ pub mod error {
|
||||
|
||||
pub mod erasure {
|
||||
pub use crate::erasure::coding::{
|
||||
BitrotReader, BitrotWriter, BitrotWriterWrapper, CustomWriter, Erasure, ReedSolomonEncoder, calc_shard_size,
|
||||
calc_shard_size_legacy,
|
||||
BitrotReader, BitrotWriter, BitrotWriterWrapper, CustomWriter, Erasure, ErasureConstructionError, ReedSolomonEncoder,
|
||||
calc_shard_size, calc_shard_size_legacy,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -346,14 +384,18 @@ pub mod metrics {
|
||||
pub mod notification {
|
||||
pub use crate::services::notification_sys::{
|
||||
NotificationPeerErr, NotificationSys, get_global_notification_sys, new_global_notification_sys,
|
||||
start_remote_version_state_fleet_probe,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod object {
|
||||
pub use crate::object_api::{
|
||||
BLOCK_SIZE_V2, ERASURE_ALGORITHM, GetObjectBodyCacheHook, GetObjectReader, ObjectInfo, ObjectMutationHook, ObjectOptions,
|
||||
PutObjReader, RangedDecompressReader, StreamConsumer, register_get_object_body_cache_hook, register_object_mutation_hook,
|
||||
BLOCK_SIZE_V2, ERASURE_ALGORITHM, GetObjectBodyCacheHook, GetObjectBodyCacheHookLookup, GetObjectBodySource,
|
||||
GetObjectReader, ObjectInfo, ObjectMutationHook, ObjectOptions, PutObjReader, RangedDecompressReader, StreamConsumer,
|
||||
get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook, register_get_object_body_cache_hook,
|
||||
register_object_mutation_hook, unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
|
||||
};
|
||||
pub use crate::store::PreparedGetObjectReader;
|
||||
}
|
||||
|
||||
pub mod rebalance {
|
||||
@@ -373,9 +415,13 @@ pub mod rio {
|
||||
|
||||
pub mod rpc {
|
||||
pub use crate::cluster::rpc::{
|
||||
LocalPeerS3Client, PEER_RESTSIGNAL, PEER_RESTSUB_SYS, PeerRestClient, PeerS3Client, SERVICE_SIGNAL_REFRESH_CONFIG,
|
||||
SERVICE_SIGNAL_RELOAD_DYNAMIC, TONIC_RPC_PREFIX, TonicInterceptor, gen_tonic_signature_interceptor,
|
||||
node_service_time_out_client, node_service_time_out_client_no_auth, verify_rpc_signature,
|
||||
LocalPeerS3Client, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS, PeerRestClient, PeerS3Client, S3PeerSys,
|
||||
SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC, ScannerBucketListing, ScannerPeerActivity,
|
||||
TONIC_RPC_PREFIX, TonicInterceptor, gen_signature_headers, gen_tonic_signature_headers, gen_tonic_signature_interceptor,
|
||||
node_service_time_out_client, node_service_time_out_client_no_auth, normalize_tonic_rpc_audience,
|
||||
set_tonic_canonical_body_digest, sign_ns_scanner_capability, sign_tonic_rpc_response_proof, verify_rpc_signature,
|
||||
verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest, verify_tonic_rpc_response_proof,
|
||||
verify_tonic_rpc_signature,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -402,7 +448,7 @@ pub mod tier {
|
||||
pub use crate::services::tier::tier::{
|
||||
ERR_TIER_BACKEND_IN_USE, ERR_TIER_BACKEND_NOT_EMPTY, ERR_TIER_INVALID_CONFIG, ERR_TIER_MISSING_CREDENTIALS,
|
||||
ERR_TIER_TYPE_UNSUPPORTED, TIER_CONFIG_FILE, TIER_CONFIG_FORMAT, TIER_CONFIG_V1, TIER_CONFIG_VERSION, TierConfigMgr,
|
||||
is_err_config_not_found, try_migrate_tiering_config,
|
||||
TierConfigUpdateError, is_err_config_not_found, try_migrate_tiering_config,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -413,7 +459,7 @@ pub mod tier {
|
||||
pub mod tier_config {
|
||||
pub use crate::services::tier::tier_config::{
|
||||
ServicePrincipalAuth, TierAliyun, TierAzure, TierConfig, TierGCS, TierHuaweicloud, TierMinIO, TierR2, TierRustFS,
|
||||
TierS3, TierTencent, TierType,
|
||||
TierS3, TierTencent, TierType, TierWasabi,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -424,6 +470,13 @@ pub mod tier {
|
||||
};
|
||||
}
|
||||
|
||||
pub mod tier_mutation_peer {
|
||||
pub use crate::services::tier::tier_mutation_peer::{
|
||||
MAX_TIER_MUTATION_PEER_COMMIT_ETAG_SIZE, TierMutationPeerError, TierMutationPeerOutcome, TierMutationPeerResult,
|
||||
TierMutationPeerState, handle_tier_mutation_peer_request,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod warm_backend {
|
||||
pub use crate::services::tier::warm_backend::{
|
||||
WarmBackend, WarmBackendGetOpts, WarmBackendImpl, build_transition_put_options, check_warm_backend, new_warm_backend,
|
||||
@@ -433,9 +486,9 @@ pub mod tier {
|
||||
#[cfg(feature = "test-util")]
|
||||
pub mod test_util {
|
||||
pub use crate::services::tier::test_util::{
|
||||
FaultConfig, MockStoredObject, MockWarmBackend, MockWarmOp, TransitionMeta, assert_transition_meta_consistent,
|
||||
free_version_count, read_transition_meta, register_mock_tier, register_mock_tier_backend,
|
||||
wait_for_free_version_absence,
|
||||
FaultConfig, MockStoredObject, MockWarmBackend, MockWarmOp, TransitionCleanupStoreBarrier, TransitionMeta,
|
||||
assert_transition_meta_consistent, free_version_count, read_transition_meta, register_mock_tier,
|
||||
register_mock_tier_backend, wait_for_free_version_absence,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,6 +280,7 @@ pub struct BucketTargetSys {
|
||||
pub hc_client: Arc<HttpClient>,
|
||||
pub a_mutex: Arc<Mutex<HashMap<String, ArnErrs>>>,
|
||||
pub arn_errs_map: Arc<RwLock<HashMap<String, ArnErrs>>>,
|
||||
heartbeat_started: OnceLock<()>,
|
||||
}
|
||||
|
||||
impl BucketTargetSys {
|
||||
@@ -295,9 +296,20 @@ impl BucketTargetSys {
|
||||
hc_client: Arc::new(HttpClient::new()),
|
||||
a_mutex: Arc::new(Mutex::new(HashMap::new())),
|
||||
arn_errs_map: Arc::new(RwLock::new(HashMap::new())),
|
||||
heartbeat_started: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn start_heartbeat(&'static self) {
|
||||
if self.heartbeat_started.set(()).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
self.heartbeat().await;
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn is_offline(&self, url: &Url) -> bool {
|
||||
let key = endpoint_health_key(url);
|
||||
{
|
||||
@@ -367,7 +379,7 @@ impl BucketTargetSys {
|
||||
async fn check_endpoint_health(&self, endpoint: &str, scheme: &str) -> bool {
|
||||
let scheme = if scheme.is_empty() { "https" } else { scheme };
|
||||
let url = format!("{scheme}://{endpoint}/");
|
||||
match self.hc_client.head(url).timeout(Duration::from_secs(3)).send().await {
|
||||
match self.hc_client.get(url).timeout(Duration::from_secs(3)).send().await {
|
||||
Ok(response) => response.status().as_u16() < 500,
|
||||
Err(_) => false,
|
||||
}
|
||||
@@ -795,15 +807,29 @@ impl BucketTargetSys {
|
||||
&& !new_targets.is_empty()
|
||||
{
|
||||
for target in &new_targets.targets {
|
||||
if let Ok(client) = self.get_remote_target_client_internal(target).await {
|
||||
arn_remotes_map.insert(
|
||||
target.arn.clone(),
|
||||
ArnTarget {
|
||||
client: Some(Arc::new(client)),
|
||||
last_refresh: OffsetDateTime::now_utc(),
|
||||
},
|
||||
);
|
||||
self.update_bandwidth_limit(bucket, &target.arn, target.bandwidth_limit);
|
||||
match self.get_remote_target_client_internal(target).await {
|
||||
Ok(client) => {
|
||||
arn_remotes_map.insert(
|
||||
target.arn.clone(),
|
||||
ArnTarget {
|
||||
client: Some(Arc::new(client)),
|
||||
last_refresh: OffsetDateTime::now_utc(),
|
||||
},
|
||||
);
|
||||
self.update_bandwidth_limit(bucket, &target.arn, target.bandwidth_limit);
|
||||
}
|
||||
// The target stays in `targets_map`, so it keeps showing up in
|
||||
// `bucket remote ls` while no client exists to replicate through it —
|
||||
// replication then drops every object for this ARN. Without this the
|
||||
// rejection (loopback endpoint, bad CA, unparseable URL) left no trace
|
||||
// anywhere.
|
||||
Err(err) => warn!(
|
||||
bucket = %bucket,
|
||||
arn = %target.arn,
|
||||
endpoint = %target.endpoint,
|
||||
error = %err,
|
||||
"replication target client unavailable; objects for this ARN will not replicate"
|
||||
),
|
||||
}
|
||||
}
|
||||
targets_map.insert(bucket.to_string(), new_targets.targets.clone());
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,10 +18,11 @@ use http::HeaderMap;
|
||||
use rustfs_filemeta::FileInfo;
|
||||
|
||||
use crate::config::com;
|
||||
use crate::disk::RUSTFS_META_BUCKET;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
|
||||
use crate::storage_api_contracts::{
|
||||
object::{DeletedObject, ObjectIO, ObjectOperations, ObjectToDelete},
|
||||
object::{DeletedObject, HTTPPreconditions, ObjectIO, ObjectOperations, ObjectToDelete},
|
||||
range::HTTPRangeSpec,
|
||||
};
|
||||
|
||||
@@ -40,6 +41,21 @@ where
|
||||
com::read_config(api, file).await
|
||||
}
|
||||
|
||||
pub(crate) async fn read_config_with_metadata<S>(api: Arc<S>, file: &str, opts: &ObjectOptions) -> Result<(Vec<u8>, ObjectInfo)>
|
||||
where
|
||||
S: ObjectIO<
|
||||
Error = Error,
|
||||
RangeSpec = HTTPRangeSpec,
|
||||
HeaderMap = HeaderMap,
|
||||
ObjectOptions = ObjectOptions,
|
||||
ObjectInfo = ObjectInfo,
|
||||
GetObjectReader = GetObjectReader,
|
||||
PutObjectReader = PutObjReader,
|
||||
>,
|
||||
{
|
||||
com::read_config_with_metadata(api, file, opts).await
|
||||
}
|
||||
|
||||
pub(crate) async fn save_config<S>(api: Arc<S>, file: &str, data: Vec<u8>) -> Result<()>
|
||||
where
|
||||
S: ObjectIO<
|
||||
@@ -55,6 +71,21 @@ where
|
||||
com::save_config(api, file, data).await
|
||||
}
|
||||
|
||||
pub(crate) async fn save_config_with_opts<S>(api: Arc<S>, file: &str, data: Vec<u8>, opts: &ObjectOptions) -> Result<()>
|
||||
where
|
||||
S: ObjectIO<
|
||||
Error = Error,
|
||||
RangeSpec = HTTPRangeSpec,
|
||||
HeaderMap = HeaderMap,
|
||||
ObjectOptions = ObjectOptions,
|
||||
ObjectInfo = ObjectInfo,
|
||||
GetObjectReader = GetObjectReader,
|
||||
PutObjectReader = PutObjReader,
|
||||
>,
|
||||
{
|
||||
com::save_config_with_opts(api, file, data, opts).await
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_config<S>(api: Arc<S>, file: &str) -> Result<()>
|
||||
where
|
||||
S: ObjectOperations<
|
||||
@@ -68,3 +99,39 @@ where
|
||||
{
|
||||
com::delete_config(api, file).await
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_config_if_match<S>(api: Arc<S>, file: &str, etag: &str) -> Result<()>
|
||||
where
|
||||
S: ObjectOperations<
|
||||
Error = Error,
|
||||
ObjectInfo = ObjectInfo,
|
||||
ObjectOptions = ObjectOptions,
|
||||
FileInfo = FileInfo,
|
||||
ObjectToDelete = ObjectToDelete,
|
||||
DeletedObject = DeletedObject,
|
||||
>,
|
||||
{
|
||||
match api
|
||||
.delete_object(
|
||||
RUSTFS_META_BUCKET,
|
||||
file,
|
||||
ObjectOptions {
|
||||
http_preconditions: Some(HTTPPreconditions {
|
||||
if_match: Some(etag.to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => {
|
||||
if err == Error::FileNotFound || matches!(err, Error::ObjectNotFound(_, _)) {
|
||||
Err(Error::ConfigNotFound)
|
||||
} else {
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ pub mod bucket_lifecycle_ops;
|
||||
mod config_boundary;
|
||||
pub mod core;
|
||||
pub mod evaluator;
|
||||
pub mod manual_transition_job;
|
||||
mod metadata_boundary;
|
||||
mod object_lock_boundary;
|
||||
pub use self::core as lifecycle;
|
||||
@@ -28,3 +29,4 @@ pub mod tier_delete_journal;
|
||||
pub mod tier_free_version_recovery;
|
||||
pub mod tier_last_day_stats;
|
||||
pub mod tier_sweeper;
|
||||
pub mod transition_transaction;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::{future::Future, sync::Arc, time::Duration};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -20,10 +20,14 @@ use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::bucket::lifecycle::config_boundary;
|
||||
use crate::bucket::lifecycle::tier_sweeper::{Jentry, delete_object_from_remote_tier_idempotent};
|
||||
use crate::bucket::lifecycle::tier_sweeper::{
|
||||
Jentry, delete_confirmed_transition_candidate_exact_with_manager_and_identity,
|
||||
delete_object_from_remote_tier_idempotent_with_manager_and_identity,
|
||||
};
|
||||
use crate::disk::RUSTFS_META_BUCKET;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
|
||||
use crate::services::tier::tier::tier_destination_id_from_metadata;
|
||||
use crate::storage_api_contracts::{
|
||||
list::ListOperations as _,
|
||||
object::{DeletedObject, ObjectIO, ObjectOperations, ObjectToDelete},
|
||||
@@ -37,8 +41,12 @@ const LOG_SUBSYSTEM_LIFECYCLE: &str = "lifecycle";
|
||||
const EVENT_LIFECYCLE_TIER_DELETE_JOURNAL: &str = "lifecycle_tier_delete_journal";
|
||||
|
||||
pub const DEFAULT_TIER_DELETE_JOURNAL_RECOVERY_LIMIT: usize = 1_000;
|
||||
const TIER_DELETE_JOURNAL_VERSION: u8 = 1;
|
||||
const TIER_DELETE_JOURNAL_PREFIX: &str = "ilm/tier-delete-journal/";
|
||||
const TIER_DELETE_JOURNAL_RECOVERY_INTERVAL: Duration = Duration::from_secs(60);
|
||||
const TIER_DELETE_JOURNAL_RECOVERY_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
const TIER_DELETE_JOURNAL_VERSION: u8 = 2;
|
||||
const TIER_DELETE_JOURNAL_EXACT_VERSION: u8 = 3;
|
||||
const TIER_DELETE_JOURNAL_STATE_VERSION: u8 = 4;
|
||||
pub(crate) const TIER_DELETE_JOURNAL_PREFIX: &str = "ilm/tier-delete-journal/";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -47,22 +55,42 @@ struct PersistedTierDeleteJournalEntry {
|
||||
obj_name: String,
|
||||
version_id: String,
|
||||
tier_name: String,
|
||||
#[serde(default)]
|
||||
backend_identity: Option<[u8; 32]>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
version_id_exact: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
version_state: Option<rustfs_filemeta::TransitionVersionState>,
|
||||
}
|
||||
|
||||
impl PersistedTierDeleteJournalEntry {
|
||||
fn from_jentry(je: &Jentry) -> Self {
|
||||
Self {
|
||||
version: TIER_DELETE_JOURNAL_VERSION,
|
||||
fn from_jentry(je: &Jentry) -> Result<Self> {
|
||||
validate_version_state(je.version_state, &je.version_id, je.version_id_exact)?;
|
||||
let legacy_unknown = je.version_state == rustfs_filemeta::TransitionVersionState::Unknown;
|
||||
let version = if legacy_unknown {
|
||||
if je.backend_identity.is_some() {
|
||||
TIER_DELETE_JOURNAL_VERSION
|
||||
} else {
|
||||
1
|
||||
}
|
||||
} else {
|
||||
if je.backend_identity.is_none() {
|
||||
return Err(Error::other("new tier delete journal entry is missing its backend identity"));
|
||||
}
|
||||
TIER_DELETE_JOURNAL_STATE_VERSION
|
||||
};
|
||||
Ok(Self {
|
||||
version,
|
||||
obj_name: je.obj_name.clone(),
|
||||
version_id: je.version_id.clone(),
|
||||
tier_name: je.tier_name.clone(),
|
||||
}
|
||||
backend_identity: je.backend_identity,
|
||||
version_id_exact: je.version_id_exact.then_some(true),
|
||||
version_state: (!legacy_unknown).then_some(je.version_state),
|
||||
})
|
||||
}
|
||||
|
||||
fn into_jentry(self) -> Result<Jentry> {
|
||||
if self.version != TIER_DELETE_JOURNAL_VERSION {
|
||||
return Err(Error::other(format!("unsupported tier delete journal version {}", self.version)));
|
||||
}
|
||||
// Empty `version_id` is a legal sentinel for objects transitioned to an
|
||||
// unversioned remote tier (see CLAUDE.md: a tier version of `None`/`""`
|
||||
// means the tier bucket is unversioned, so the remote delete is issued
|
||||
@@ -71,14 +99,84 @@ impl PersistedTierDeleteJournalEntry {
|
||||
if self.obj_name.is_empty() || self.tier_name.is_empty() {
|
||||
return Err(Error::other("tier delete journal entry is incomplete"));
|
||||
}
|
||||
if self.version != TIER_DELETE_JOURNAL_EXACT_VERSION
|
||||
&& self.version != TIER_DELETE_JOURNAL_STATE_VERSION
|
||||
&& self.version_id_exact.unwrap_or(false)
|
||||
{
|
||||
return Err(Error::other(
|
||||
"legacy tier delete journal entry has an unsupported exact version constraint",
|
||||
));
|
||||
}
|
||||
let (backend_identity, version_id_exact, version_state) = match self.version {
|
||||
1 => (None, false, rustfs_filemeta::TransitionVersionState::Unknown),
|
||||
TIER_DELETE_JOURNAL_VERSION => (
|
||||
Some(
|
||||
self.backend_identity
|
||||
.ok_or_else(|| Error::other("tier delete journal v2 entry is missing its backend identity"))?,
|
||||
),
|
||||
false,
|
||||
rustfs_filemeta::TransitionVersionState::Unknown,
|
||||
),
|
||||
TIER_DELETE_JOURNAL_EXACT_VERSION => {
|
||||
if self.version_id.is_empty() || self.version_id_exact != Some(true) {
|
||||
return Err(Error::other("tier delete journal v3 entry is missing its exact version constraint"));
|
||||
}
|
||||
(
|
||||
Some(
|
||||
self.backend_identity
|
||||
.ok_or_else(|| Error::other("tier delete journal v3 entry is missing its backend identity"))?,
|
||||
),
|
||||
true,
|
||||
rustfs_filemeta::TransitionVersionState::Exact,
|
||||
)
|
||||
}
|
||||
TIER_DELETE_JOURNAL_STATE_VERSION => {
|
||||
let state = self
|
||||
.version_state
|
||||
.ok_or_else(|| Error::other("tier delete journal v4 entry is missing its version state"))?;
|
||||
let exact = self.version_id_exact.unwrap_or(false);
|
||||
validate_version_state(state, &self.version_id, exact)?;
|
||||
(
|
||||
Some(
|
||||
self.backend_identity
|
||||
.ok_or_else(|| Error::other("tier delete journal v4 entry is missing its backend identity"))?,
|
||||
),
|
||||
exact,
|
||||
state,
|
||||
)
|
||||
}
|
||||
version => return Err(Error::other(format!("unsupported tier delete journal version {version}"))),
|
||||
};
|
||||
Ok(Jentry {
|
||||
obj_name: self.obj_name,
|
||||
version_id: self.version_id,
|
||||
tier_name: self.tier_name,
|
||||
backend_identity,
|
||||
version_id_exact,
|
||||
version_state,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_version_state(
|
||||
state: rustfs_filemeta::TransitionVersionState,
|
||||
version_id: &str,
|
||||
version_id_exact: bool,
|
||||
) -> Result<()> {
|
||||
use rustfs_filemeta::TransitionVersionState::{Exact, KnownDisabled, SuspendedNull, Unknown};
|
||||
|
||||
let valid = match state {
|
||||
Unknown => !version_id_exact,
|
||||
KnownDisabled => version_id.is_empty() && !version_id_exact,
|
||||
SuspendedNull => version_id == "null" && version_id_exact,
|
||||
Exact => !version_id.is_empty() && version_id != "null" && version_id_exact,
|
||||
};
|
||||
if !valid {
|
||||
return Err(Error::other("tier delete journal version state conflicts with its version id"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TierDeleteJournalRecoveryStats {
|
||||
pub scanned: usize,
|
||||
@@ -95,23 +193,41 @@ pub(crate) fn tier_delete_journal_object_name(je: &Jentry) -> String {
|
||||
hasher.update(je.obj_name.as_bytes());
|
||||
hasher.update([0]);
|
||||
hasher.update(je.version_id.as_bytes());
|
||||
if let Some(backend_identity) = je.backend_identity {
|
||||
hasher.update([0]);
|
||||
hasher.update(backend_identity);
|
||||
}
|
||||
if je.version_id_exact {
|
||||
hasher.update([0]);
|
||||
hasher.update(b"exact-version-id");
|
||||
}
|
||||
format!(
|
||||
"{TIER_DELETE_JOURNAL_PREFIX}{}.json",
|
||||
rustfs_utils::crypto::hex(hasher.finalize().as_slice())
|
||||
)
|
||||
}
|
||||
|
||||
fn decode_tier_delete_journal_entry(data: &[u8]) -> Result<Jentry> {
|
||||
pub(crate) fn decode_tier_delete_journal_entry(data: &[u8]) -> Result<Jentry> {
|
||||
let persisted: PersistedTierDeleteJournalEntry =
|
||||
serde_json::from_slice(data).map_err(|err| Error::other(format!("decode tier delete journal failed: {err}")))?;
|
||||
persisted.into_jentry()
|
||||
}
|
||||
|
||||
fn encode_tier_delete_journal_entry(je: &Jentry) -> Result<Vec<u8>> {
|
||||
serde_json::to_vec(&PersistedTierDeleteJournalEntry::from_jentry(je))
|
||||
pub(crate) fn encode_tier_delete_journal_entry(je: &Jentry) -> Result<Vec<u8>> {
|
||||
serde_json::to_vec(&PersistedTierDeleteJournalEntry::from_jentry(je)?)
|
||||
.map_err(|err| Error::other(format!("encode tier delete journal failed: {err}")))
|
||||
}
|
||||
|
||||
pub fn record_tier_delete_journal_backend_identity(
|
||||
je: &mut Jentry,
|
||||
metadata: &std::collections::HashMap<String, String>,
|
||||
) -> std::io::Result<()> {
|
||||
if let Some(identity) = tier_destination_id_from_metadata(metadata)? {
|
||||
je.backend_identity = Some(identity);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn persist_tier_delete_journal_entry<S>(api: Arc<S>, je: &Jentry) -> std::io::Result<()>
|
||||
where
|
||||
S: ObjectIO<
|
||||
@@ -148,7 +264,35 @@ where
|
||||
}
|
||||
|
||||
pub async fn process_tier_delete_journal_entry(api: Arc<ECStore>, je: &Jentry) -> std::io::Result<()> {
|
||||
delete_object_from_remote_tier_idempotent(&je.obj_name, &je.version_id, &je.tier_name).await?;
|
||||
if je.version_state == rustfs_filemeta::TransitionVersionState::Unknown {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"tier delete journal remote version state is unknown",
|
||||
));
|
||||
}
|
||||
let backend_identity = je
|
||||
.backend_identity
|
||||
.ok_or_else(|| std::io::Error::other("legacy tier delete journal has no durable backend identity"))?;
|
||||
if je.version_id_exact {
|
||||
delete_confirmed_transition_candidate_exact_with_manager_and_identity(
|
||||
&je.obj_name,
|
||||
&je.version_id,
|
||||
&je.tier_name,
|
||||
backend_identity,
|
||||
&api.tier_config_mgr(),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
delete_object_from_remote_tier_idempotent_with_manager_and_identity(
|
||||
&je.obj_name,
|
||||
&je.version_id,
|
||||
&je.tier_name,
|
||||
backend_identity,
|
||||
&api.tier_config_mgr(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
remove_tier_delete_journal_entry(api, je).await
|
||||
}
|
||||
|
||||
@@ -218,6 +362,21 @@ pub async fn recover_tier_delete_journal_entries(
|
||||
}
|
||||
};
|
||||
|
||||
if je.backend_identity.is_none() {
|
||||
stats.failed += 1;
|
||||
warn!(
|
||||
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
journal_object = %object.name,
|
||||
remote_object = %je.obj_name,
|
||||
remote_version_id = %je.version_id,
|
||||
tier = %je.tier_name,
|
||||
"Legacy tier delete journal entry has no durable backend identity and will be retained"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
match process_tier_delete_journal_entry(api.clone(), &je).await {
|
||||
Ok(()) => stats.deleted += 1,
|
||||
Err(err) => {
|
||||
@@ -241,16 +400,33 @@ pub async fn recover_tier_delete_journal_entries(
|
||||
}
|
||||
|
||||
pub async fn run_tier_delete_journal_recovery_loop(api: Arc<ECStore>, cancel_token: CancellationToken) {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||
let mut interval = tokio::time::interval(TIER_DELETE_JOURNAL_RECOVERY_INTERVAL);
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
let mut marker: Option<String> = None;
|
||||
|
||||
loop {
|
||||
#[cfg(test)]
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel_token.cancelled() => return,
|
||||
_ = interval.tick() => {}
|
||||
_ = interval.tick() => {},
|
||||
_ = api.ctx.wait_for_tier_delete_journal_recovery() => {},
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel_token.cancelled() => return,
|
||||
_ = interval.tick() => {},
|
||||
}
|
||||
|
||||
match recover_tier_delete_journal_entries(api.clone(), DEFAULT_TIER_DELETE_JOURNAL_RECOVERY_LIMIT, marker.clone()).await {
|
||||
let recovery =
|
||||
recover_tier_delete_journal_entries(api.clone(), DEFAULT_TIER_DELETE_JOURNAL_RECOVERY_LIMIT, marker.clone());
|
||||
let Some(result) =
|
||||
await_tier_delete_journal_recovery(&cancel_token, TIER_DELETE_JOURNAL_RECOVERY_TIMEOUT, recovery).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
match result {
|
||||
Ok(stats) => {
|
||||
marker = stats.next_marker;
|
||||
debug!(
|
||||
@@ -279,16 +455,46 @@ pub async fn run_tier_delete_journal_recovery_loop(api: Arc<ECStore>, cancel_tok
|
||||
}
|
||||
}
|
||||
|
||||
async fn await_tier_delete_journal_recovery<T, F>(
|
||||
cancel_token: &CancellationToken,
|
||||
timeout: Duration,
|
||||
recovery: F,
|
||||
) -> Option<Result<T>>
|
||||
where
|
||||
F: Future<Output = Result<T>>,
|
||||
{
|
||||
tokio::select! {
|
||||
_ = cancel_token.cancelled() => None,
|
||||
result = tokio::time::timeout(timeout, recovery) => Some(match result {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(Error::other(format!(
|
||||
"tier delete journal recovery timed out after {} seconds",
|
||||
timeout.as_secs()
|
||||
))),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{decode_tier_delete_journal_entry, encode_tier_delete_journal_entry, tier_delete_journal_object_name};
|
||||
use super::{
|
||||
TIER_DELETE_JOURNAL_EXACT_VERSION, TIER_DELETE_JOURNAL_STATE_VERSION, await_tier_delete_journal_recovery,
|
||||
decode_tier_delete_journal_entry, encode_tier_delete_journal_entry, record_tier_delete_journal_backend_identity,
|
||||
tier_delete_journal_object_name,
|
||||
};
|
||||
use crate::bucket::lifecycle::tier_sweeper::Jentry;
|
||||
use crate::error::Result;
|
||||
use std::time::Duration;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
fn journal_entry() -> Jentry {
|
||||
Jentry {
|
||||
obj_name: "remote/object".to_string(),
|
||||
version_id: "remote-version".to_string(),
|
||||
tier_name: "WARM".to_string(),
|
||||
backend_identity: Some([7; 32]),
|
||||
version_id_exact: true,
|
||||
version_state: rustfs_filemeta::TransitionVersionState::Exact,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +508,124 @@ mod tests {
|
||||
assert_eq!(decoded.obj_name, je.obj_name);
|
||||
assert_eq!(decoded.version_id, je.version_id);
|
||||
assert_eq!(decoded.tier_name, je.tier_name);
|
||||
assert_eq!(decoded.backend_identity, je.backend_identity);
|
||||
assert_eq!(decoded.version_id_exact, je.version_id_exact);
|
||||
assert_eq!(decoded.version_state, je.version_state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_delete_journal_roundtrips_exact_put_response_constraint() {
|
||||
let mut exact = journal_entry();
|
||||
exact.version_id = uuid::Uuid::nil().to_string();
|
||||
exact.version_id_exact = true;
|
||||
let mut normalized = exact.clone();
|
||||
normalized.version_id_exact = false;
|
||||
|
||||
let encoded = encode_tier_delete_journal_entry(&exact).expect("exact journal entry should encode");
|
||||
let persisted: serde_json::Value = serde_json::from_slice(&encoded).expect("exact journal JSON should decode");
|
||||
let decoded = decode_tier_delete_journal_entry(&encoded).expect("exact journal entry should decode");
|
||||
|
||||
assert_eq!(persisted["version"], TIER_DELETE_JOURNAL_STATE_VERSION);
|
||||
assert_eq!(persisted["version_id_exact"], true);
|
||||
assert!(decoded.version_id_exact);
|
||||
assert_ne!(tier_delete_journal_object_name(&exact), tier_delete_journal_object_name(&normalized));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_delete_journal_rejects_invalid_exact_version_constraints() {
|
||||
let identity = vec![7_u8; 32];
|
||||
let invalid = [
|
||||
serde_json::json!({
|
||||
"version": 1,
|
||||
"obj_name": "remote/object",
|
||||
"version_id": "exact-version",
|
||||
"tier_name": "WARM",
|
||||
"version_id_exact": true,
|
||||
}),
|
||||
serde_json::json!({
|
||||
"version": 2,
|
||||
"obj_name": "remote/object",
|
||||
"version_id": "exact-version",
|
||||
"tier_name": "WARM",
|
||||
"backend_identity": identity,
|
||||
"version_id_exact": true,
|
||||
}),
|
||||
serde_json::json!({
|
||||
"version": TIER_DELETE_JOURNAL_EXACT_VERSION,
|
||||
"obj_name": "remote/object",
|
||||
"version_id": "",
|
||||
"tier_name": "WARM",
|
||||
"backend_identity": identity,
|
||||
"version_id_exact": true,
|
||||
}),
|
||||
serde_json::json!({
|
||||
"version": TIER_DELETE_JOURNAL_EXACT_VERSION,
|
||||
"obj_name": "remote/object",
|
||||
"version_id": "exact-version",
|
||||
"tier_name": "WARM",
|
||||
"backend_identity": identity,
|
||||
}),
|
||||
serde_json::json!({
|
||||
"version": TIER_DELETE_JOURNAL_EXACT_VERSION,
|
||||
"obj_name": "remote/object",
|
||||
"version_id": "exact-version",
|
||||
"tier_name": "WARM",
|
||||
"backend_identity": identity,
|
||||
"version_id_exact": false,
|
||||
}),
|
||||
serde_json::json!({
|
||||
"version": TIER_DELETE_JOURNAL_EXACT_VERSION,
|
||||
"obj_name": "remote/object",
|
||||
"version_id": "exact-version",
|
||||
"tier_name": "WARM",
|
||||
"version_id_exact": true,
|
||||
}),
|
||||
];
|
||||
|
||||
for persisted in invalid {
|
||||
let encoded = serde_json::to_vec(&persisted).expect("invalid journal fixture should encode");
|
||||
decode_tier_delete_journal_entry(&encoded).expect_err("invalid exact journal constraint must fail closed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_delete_journal_rejects_conflicting_v4_version_states() {
|
||||
let identity = vec![7_u8; 32];
|
||||
let invalid = [
|
||||
("known-disabled", "unexpected", false),
|
||||
("suspended-null", "", true),
|
||||
("suspended-null", "null", false),
|
||||
("exact", "", true),
|
||||
("exact", "null", true),
|
||||
("exact", "version", false),
|
||||
("unknown", "version", true),
|
||||
];
|
||||
|
||||
for (state, version_id, exact) in invalid {
|
||||
let persisted = serde_json::json!({
|
||||
"version": TIER_DELETE_JOURNAL_STATE_VERSION,
|
||||
"obj_name": "remote/object",
|
||||
"version_id": version_id,
|
||||
"tier_name": "WARM",
|
||||
"backend_identity": identity,
|
||||
"version_id_exact": exact.then_some(true),
|
||||
"version_state": state,
|
||||
});
|
||||
let encoded = serde_json::to_vec(&persisted).expect("invalid journal fixture should encode");
|
||||
decode_tier_delete_journal_entry(&encoded).expect_err("conflicting v4 version state must fail closed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_journals_decode_with_unknown_version_state() {
|
||||
let v1 = br#"{"version":1,"obj_name":"remote/object","version_id":"opaque","tier_name":"WARM"}"#;
|
||||
let v2 = br#"{"version":2,"obj_name":"remote/object","version_id":"opaque","tier_name":"WARM","backend_identity":[7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7]}"#;
|
||||
|
||||
for payload in [v1.as_slice(), v2.as_slice()] {
|
||||
let decoded = decode_tier_delete_journal_entry(payload).expect("legacy journal should decode");
|
||||
assert_eq!(decoded.version_state, rustfs_filemeta::TransitionVersionState::Unknown);
|
||||
assert!(!decoded.version_id_exact);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -317,6 +641,67 @@ mod tests {
|
||||
assert!(!first.contains("remote/object"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_delete_journal_paths_separate_legacy_and_backend_identities() {
|
||||
let mut legacy = journal_entry();
|
||||
legacy.backend_identity = None;
|
||||
legacy.version_id_exact = false;
|
||||
legacy.version_state = rustfs_filemeta::TransitionVersionState::Unknown;
|
||||
let mut backend_a = journal_entry();
|
||||
backend_a.backend_identity = Some([1; 32]);
|
||||
let mut backend_b = journal_entry();
|
||||
backend_b.backend_identity = Some([2; 32]);
|
||||
|
||||
assert_eq!(
|
||||
tier_delete_journal_object_name(&legacy),
|
||||
"ilm/tier-delete-journal/5ba6a7eb6338412b771613a6845a42ae5b8e26b5d201323eb01b38c5b42ff300.json"
|
||||
);
|
||||
assert_ne!(tier_delete_journal_object_name(&legacy), tier_delete_journal_object_name(&backend_a));
|
||||
assert_ne!(tier_delete_journal_object_name(&backend_a), tier_delete_journal_object_name(&backend_b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_delete_journal_v2_requires_backend_identity() {
|
||||
let payload = br#"{"version":2,"obj_name":"remote/object","version_id":"v1","tier_name":"WARM"}"#;
|
||||
|
||||
let err = decode_tier_delete_journal_entry(payload).expect_err("v2 entry without identity must fail closed");
|
||||
|
||||
assert!(err.to_string().contains("backend identity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_delete_journal_uses_persisted_transition_destination_identity() {
|
||||
let mut je = journal_entry();
|
||||
je.backend_identity = None;
|
||||
let identity = [9_u8; 32];
|
||||
let mut metadata = std::collections::HashMap::new();
|
||||
rustfs_utils::http::metadata_compat::insert_str(
|
||||
&mut metadata,
|
||||
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
|
||||
rustfs_utils::crypto::hex(identity),
|
||||
);
|
||||
|
||||
record_tier_delete_journal_backend_identity(&mut je, &metadata).expect("persisted transition identity should decode");
|
||||
let encoded = encode_tier_delete_journal_entry(&je).expect("identity-bound journal should encode");
|
||||
let decoded = decode_tier_delete_journal_entry(&encoded).expect("identity-bound journal should decode");
|
||||
|
||||
assert_eq!(decoded.backend_identity, Some(identity));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_delete_journal_without_transition_identity_stays_legacy() {
|
||||
let mut je = journal_entry();
|
||||
je.backend_identity = None;
|
||||
je.version_id_exact = false;
|
||||
je.version_state = rustfs_filemeta::TransitionVersionState::Unknown;
|
||||
|
||||
let encoded = encode_tier_delete_journal_entry(&je).expect("legacy journal should remain encodable");
|
||||
let persisted: serde_json::Value = serde_json::from_slice(&encoded).expect("journal JSON should decode");
|
||||
|
||||
assert_eq!(persisted["version"], 1);
|
||||
assert!(persisted["backend_identity"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_delete_journal_rejects_incomplete_entry() {
|
||||
let payload = br#"{"version":1,"obj_name":"","version_id":"v1","tier_name":"WARM"}"#;
|
||||
@@ -339,6 +724,7 @@ mod tests {
|
||||
assert_eq!(decoded.obj_name, "remote/object");
|
||||
assert!(decoded.version_id.is_empty());
|
||||
assert_eq!(decoded.tier_name, "WARM");
|
||||
assert_eq!(decoded.backend_identity, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -360,4 +746,29 @@ mod tests {
|
||||
|
||||
assert!(err.to_string().contains("decode tier delete journal failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tier_delete_journal_recovery_has_a_hard_outer_timeout() {
|
||||
let result = await_tier_delete_journal_recovery(
|
||||
&CancellationToken::new(),
|
||||
Duration::from_millis(10),
|
||||
std::future::pending::<Result<()>>(),
|
||||
)
|
||||
.await
|
||||
.expect("an elapsed timeout should return a recovery error")
|
||||
.expect_err("a permanently pending recovery must time out");
|
||||
|
||||
assert!(result.to_string().contains("recovery timed out"), "{result}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tier_delete_journal_recovery_drops_in_flight_work_on_shutdown() {
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
|
||||
let result =
|
||||
await_tier_delete_journal_recovery(&cancel, Duration::from_secs(30), std::future::pending::<Result<()>>()).await;
|
||||
|
||||
assert!(result.is_none(), "shutdown must cancel the in-flight recovery future");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,14 @@
|
||||
// limitations under the License.
|
||||
|
||||
use std::sync::Arc;
|
||||
#[cfg(test)]
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::bucket::lifecycle::bucket_lifecycle_ops::enqueue_recovered_free_version;
|
||||
use crate::disk::RUSTFS_META_BUCKET;
|
||||
use crate::error::Result;
|
||||
use crate::object_api::ObjectInfo;
|
||||
@@ -30,11 +33,93 @@ use rustfs_filemeta::FileInfo;
|
||||
|
||||
pub const DEFAULT_FREE_VERSION_RECOVERY_LIMIT: usize = 1_000;
|
||||
const DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT: usize = 10_000;
|
||||
const BACKGROUND_WALKDIR_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
#[cfg(not(test))]
|
||||
const BACKGROUND_WALK_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
#[cfg(test)]
|
||||
const BACKGROUND_WALK_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
|
||||
type ObjectInfoOrErr = StorageObjectInfoOrErr<ObjectInfo, crate::error::Error>;
|
||||
type WalkOptions = StorageWalkOptions<fn(&FileInfo) -> bool>;
|
||||
|
||||
fn recovery_walk_options(limit: usize, marker: Option<String>) -> WalkOptions {
|
||||
WalkOptions {
|
||||
include_free_versions: true,
|
||||
limit,
|
||||
marker,
|
||||
// Total walk time scales with bucket size, so it is left unbounded
|
||||
// (Duration::ZERO disables the wall-clock budget). Per-call progress
|
||||
// stalls stay bounded by the drive-level stall budget inherited from
|
||||
// `RUSTFS_DRIVE_WALKDIR_STALL_TIMEOUT_SECS`.
|
||||
walkdir_timeout: Some(Duration::ZERO),
|
||||
walkdir_stall_timeout: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) enum RecoveryWalkTestAction {
|
||||
SendItemsThenError(Vec<ObjectInfo>, crate::error::Error),
|
||||
SendItemsThenHang(Vec<ObjectInfo>, Arc<tokio::sync::Notify>),
|
||||
SendItemsUntilReceiverCloses(Arc<tokio::sync::Notify>),
|
||||
ReturnError(crate::error::Error),
|
||||
WaitForCancellation(Arc<tokio::sync::Notify>),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
type RecoveryWalkTestHook = Box<dyn Fn(&str) -> Option<RecoveryWalkTestAction> + Send + Sync>;
|
||||
|
||||
#[cfg(test)]
|
||||
static RECOVERY_WALK_TEST_HOOK: Mutex<Option<RecoveryWalkTestHook>> = Mutex::new(None);
|
||||
|
||||
#[cfg(test)]
|
||||
static RECOVERY_BUCKET_LIST_WAIT_HOOK: Mutex<Option<Arc<tokio::sync::Notify>>> = Mutex::new(None);
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) struct RecoveryWalkHookGuard;
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for RecoveryWalkHookGuard {
|
||||
fn drop(&mut self) {
|
||||
let mut hook = RECOVERY_WALK_TEST_HOOK
|
||||
.lock()
|
||||
.expect("recovery walk test hook lock should not poison");
|
||||
*hook = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn set_recovery_walk_test_hook(
|
||||
hook_fn: impl Fn(&str) -> Option<RecoveryWalkTestAction> + Send + Sync + 'static,
|
||||
) -> RecoveryWalkHookGuard {
|
||||
let mut hook = RECOVERY_WALK_TEST_HOOK
|
||||
.lock()
|
||||
.expect("recovery walk test hook lock should not poison");
|
||||
*hook = Some(Box::new(hook_fn));
|
||||
RecoveryWalkHookGuard
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) struct RecoveryBucketListWaitHookGuard;
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for RecoveryBucketListWaitHookGuard {
|
||||
fn drop(&mut self) {
|
||||
let mut hook = RECOVERY_BUCKET_LIST_WAIT_HOOK
|
||||
.lock()
|
||||
.expect("recovery bucket-list test hook lock should not poison");
|
||||
*hook = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn set_recovery_bucket_list_wait_hook(started: Arc<tokio::sync::Notify>) -> RecoveryBucketListWaitHookGuard {
|
||||
let mut hook = RECOVERY_BUCKET_LIST_WAIT_HOOK
|
||||
.lock()
|
||||
.expect("recovery bucket-list test hook lock should not poison");
|
||||
*hook = Some(started);
|
||||
RecoveryBucketListWaitHookGuard
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FreeVersionRecoveryStats {
|
||||
pub scanned: usize,
|
||||
@@ -68,12 +153,22 @@ pub async fn recover_tier_free_versions(
|
||||
limit: usize,
|
||||
bucket_marker: Option<String>,
|
||||
object_marker: Option<String>,
|
||||
) -> Result<FreeVersionRecoveryStats> {
|
||||
recover_tier_free_versions_with_cancel(api, limit, bucket_marker, object_marker, CancellationToken::new()).await
|
||||
}
|
||||
|
||||
pub(super) async fn recover_tier_free_versions_with_cancel(
|
||||
api: Arc<ECStore>,
|
||||
limit: usize,
|
||||
bucket_marker: Option<String>,
|
||||
object_marker: Option<String>,
|
||||
cancel_token: CancellationToken,
|
||||
) -> Result<FreeVersionRecoveryStats> {
|
||||
if limit == 0 {
|
||||
return Err(std::io::Error::other("free-version recovery limit must be greater than zero").into());
|
||||
}
|
||||
|
||||
let page = list_tier_free_versions(api, limit, bucket_marker.clone(), object_marker.clone()).await?;
|
||||
let page = list_tier_free_versions(api, limit, bucket_marker.clone(), object_marker.clone(), cancel_token.clone()).await?;
|
||||
let mut stats = FreeVersionRecoveryStats {
|
||||
scanned: 0,
|
||||
enqueued: 0,
|
||||
@@ -87,8 +182,11 @@ pub async fn recover_tier_free_versions(
|
||||
|
||||
let mut retry_cursor = RetryCursor::new(bucket_marker, object_marker);
|
||||
for oi in page.items {
|
||||
if cancel_token.is_cancelled() {
|
||||
return Err(tier_free_version_recovery_cancelled());
|
||||
}
|
||||
retry_cursor.visit(&oi);
|
||||
if !record_recovered_free_version_enqueue(&mut stats, queue_recovered_free_version(oi).await) {
|
||||
if !record_recovered_free_version_enqueue(&mut stats, enqueue_recovered_free_version(oi).await) {
|
||||
let (bucket_marker, object_marker) = retry_cursor.retry_markers();
|
||||
stats.truncated = true;
|
||||
stats.next_bucket_marker = bucket_marker;
|
||||
@@ -100,6 +198,18 @@ pub async fn recover_tier_free_versions(
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
fn tier_free_version_recovery_cancelled() -> crate::error::Error {
|
||||
std::io::Error::new(std::io::ErrorKind::Interrupted, "tier free-version recovery cancelled").into()
|
||||
}
|
||||
|
||||
fn tier_free_version_recovery_walk_shutdown_timed_out() -> crate::error::Error {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
"tier free-version recovery walk did not stop after cancellation",
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn record_recovered_free_version_enqueue(stats: &mut FreeVersionRecoveryStats, queued: bool) -> bool {
|
||||
stats.scanned += 1;
|
||||
if queued {
|
||||
@@ -111,10 +221,6 @@ fn record_recovered_free_version_enqueue(stats: &mut FreeVersionRecoveryStats, q
|
||||
}
|
||||
}
|
||||
|
||||
async fn queue_recovered_free_version(oi: ObjectInfo) -> bool {
|
||||
crate::bucket::lifecycle::bucket_lifecycle_ops::enqueue_recovered_free_version(oi).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct RetryCursor {
|
||||
input_bucket_marker: Option<String>,
|
||||
@@ -160,11 +266,12 @@ impl RetryCursor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_tier_free_versions(
|
||||
pub(super) async fn list_tier_free_versions(
|
||||
api: Arc<ECStore>,
|
||||
limit: usize,
|
||||
bucket_marker: Option<String>,
|
||||
object_marker: Option<String>,
|
||||
cancel_token: CancellationToken,
|
||||
) -> Result<FreeVersionRecoveryPage> {
|
||||
let mut page = FreeVersionRecoveryPage {
|
||||
items: Vec::new(),
|
||||
@@ -179,21 +286,42 @@ async fn list_tier_free_versions(
|
||||
return Ok(page);
|
||||
}
|
||||
|
||||
let buckets = api.list_bucket(&BucketOptions::default()).await?;
|
||||
let bucket_options = BucketOptions::default();
|
||||
let list_buckets = async {
|
||||
#[cfg(test)]
|
||||
let wait_hook = RECOVERY_BUCKET_LIST_WAIT_HOOK
|
||||
.lock()
|
||||
.expect("recovery bucket-list test hook lock should not poison")
|
||||
.clone();
|
||||
#[cfg(test)]
|
||||
if let Some(started) = wait_hook {
|
||||
started.notify_one();
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
api.list_bucket(&bucket_options).await
|
||||
};
|
||||
tokio::pin!(list_buckets);
|
||||
let buckets = tokio::select! {
|
||||
biased;
|
||||
_ = cancel_token.cancelled() => return Err(tier_free_version_recovery_cancelled()),
|
||||
result = &mut list_buckets => result?,
|
||||
};
|
||||
let mut bucket_seen = bucket_marker.is_none();
|
||||
let mut truncated_after: Option<RecoveryCursor> = None;
|
||||
let walk_scan_limit = recovery_walk_scan_limit(limit);
|
||||
|
||||
for bucket in buckets {
|
||||
if cancel_token.is_cancelled() {
|
||||
return Err(tier_free_version_recovery_cancelled());
|
||||
}
|
||||
if bucket.name == RUSTFS_META_BUCKET {
|
||||
continue;
|
||||
}
|
||||
if !bucket_seen {
|
||||
if bucket_marker.as_deref() == Some(bucket.name.as_str()) {
|
||||
bucket_seen = true;
|
||||
} else {
|
||||
if bucket_marker.as_deref().is_some_and(|marker| bucket.name.as_str() < marker) {
|
||||
continue;
|
||||
}
|
||||
bucket_seen = true;
|
||||
}
|
||||
|
||||
page.buckets_scanned += 1;
|
||||
@@ -204,74 +332,181 @@ async fn list_tier_free_versions(
|
||||
};
|
||||
|
||||
let (tx, mut rx) = mpsc::channel::<ObjectInfoOrErr>(100);
|
||||
let cancel = CancellationToken::new();
|
||||
let cancel = cancel_token.child_token();
|
||||
let mut draining_after_truncation = false;
|
||||
let mut drain_deadline = None;
|
||||
let mut last_seen_object: Option<String> = None;
|
||||
let mut scanned_objects = 0usize;
|
||||
let walk = tokio::spawn({
|
||||
let mut walk = tokio::spawn({
|
||||
let api = api.clone();
|
||||
let bucket_name = bucket.name.clone();
|
||||
let object_marker = bucket_object_marker.clone();
|
||||
let cancel = cancel.clone();
|
||||
async move {
|
||||
api.walk(
|
||||
cancel,
|
||||
&bucket_name,
|
||||
"",
|
||||
tx,
|
||||
WalkOptions {
|
||||
include_free_versions: true,
|
||||
limit: walk_scan_limit,
|
||||
marker: object_marker,
|
||||
..Default::default()
|
||||
#[cfg(test)]
|
||||
let test_action = {
|
||||
let hook = RECOVERY_WALK_TEST_HOOK
|
||||
.lock()
|
||||
.expect("recovery walk test hook lock should not poison");
|
||||
hook.as_ref().and_then(|hook| hook(&bucket_name))
|
||||
};
|
||||
#[cfg(test)]
|
||||
if let Some(action) = test_action {
|
||||
match action {
|
||||
RecoveryWalkTestAction::SendItemsThenError(items, err) => {
|
||||
for item in items {
|
||||
if tx
|
||||
.send(ObjectInfoOrErr {
|
||||
item: Some(item),
|
||||
err: None,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let _ = tx
|
||||
.send(ObjectInfoOrErr {
|
||||
item: None,
|
||||
err: Some(err),
|
||||
})
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
RecoveryWalkTestAction::SendItemsThenHang(items, started) => {
|
||||
for item in items {
|
||||
if tx
|
||||
.send(ObjectInfoOrErr {
|
||||
item: Some(item),
|
||||
err: None,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
started.notify_one();
|
||||
return std::future::pending().await;
|
||||
}
|
||||
RecoveryWalkTestAction::SendItemsUntilReceiverCloses(started) => {
|
||||
started.notify_one();
|
||||
let mut index = 0usize;
|
||||
loop {
|
||||
if tx
|
||||
.send(ObjectInfoOrErr {
|
||||
item: Some(ObjectInfo {
|
||||
bucket: bucket_name.clone(),
|
||||
name: format!("nonrecoverable-{index:08}"),
|
||||
..Default::default()
|
||||
}),
|
||||
err: None,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
index = index.saturating_add(1);
|
||||
}
|
||||
}
|
||||
RecoveryWalkTestAction::ReturnError(err) => return Err(err),
|
||||
RecoveryWalkTestAction::WaitForCancellation(started) => {
|
||||
started.notify_one();
|
||||
cancel.cancelled().await;
|
||||
return Err(tier_free_version_recovery_cancelled());
|
||||
}
|
||||
}
|
||||
.with_walkdir_timeouts(BACKGROUND_WALKDIR_TIMEOUT),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
api.walk(cancel, &bucket_name, "", tx, recovery_walk_options(walk_scan_limit, object_marker))
|
||||
.await
|
||||
}
|
||||
});
|
||||
|
||||
while let Some(item) = rx.recv().await {
|
||||
let mut receive_error = None;
|
||||
loop {
|
||||
let item = tokio::select! {
|
||||
biased;
|
||||
_ = cancel_token.cancelled() => {
|
||||
cancel.cancel();
|
||||
receive_error = Some(tier_free_version_recovery_cancelled());
|
||||
break;
|
||||
}
|
||||
_ = async {
|
||||
if let Some(deadline) = drain_deadline {
|
||||
tokio::time::sleep_until(deadline).await;
|
||||
} else {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
}, if drain_deadline.is_some() => {
|
||||
receive_error = Some(tier_free_version_recovery_walk_shutdown_timed_out());
|
||||
break;
|
||||
}
|
||||
item = rx.recv() => match item {
|
||||
Some(item) => item,
|
||||
None => break,
|
||||
},
|
||||
};
|
||||
page.scanned_entries += 1;
|
||||
if draining_after_truncation {
|
||||
continue;
|
||||
}
|
||||
if let Some(err) = item.err {
|
||||
cancel.cancel();
|
||||
walk.await.map_err(|err| std::io::Error::other(err.to_string()))??;
|
||||
return Err(err);
|
||||
receive_error = Some(err);
|
||||
break;
|
||||
}
|
||||
if draining_after_truncation {
|
||||
continue;
|
||||
}
|
||||
let Some(oi) = item.item else {
|
||||
continue;
|
||||
};
|
||||
record_scanned_object(&mut last_seen_object, &mut scanned_objects, &oi.name);
|
||||
if let Some(cursor) = &truncated_after
|
||||
&& cursor.object != oi.name
|
||||
&& (cursor.bucket.as_str() != bucket.name.as_str() || cursor.object.as_str() != oi.name.as_str())
|
||||
{
|
||||
page.truncated = true;
|
||||
cancel.cancel();
|
||||
draining_after_truncation = true;
|
||||
drain_deadline = Some(tokio::time::Instant::now() + BACKGROUND_WALK_SHUTDOWN_TIMEOUT);
|
||||
continue;
|
||||
}
|
||||
if is_recoverable_tier_free_version(&oi) {
|
||||
let cursor = RecoveryCursor {
|
||||
let current_cursor = RecoveryCursor {
|
||||
bucket: bucket.name.clone(),
|
||||
object: oi.name.clone(),
|
||||
};
|
||||
page.items.push(oi);
|
||||
page.next_bucket_marker = Some(cursor.bucket.clone());
|
||||
page.next_object_marker = Some(cursor.object.clone());
|
||||
page.next_bucket_marker = Some(current_cursor.bucket.clone());
|
||||
page.next_object_marker = Some(current_cursor.object.clone());
|
||||
if page.items.len() >= limit && truncated_after.is_none() {
|
||||
truncated_after = Some(cursor);
|
||||
truncated_after = Some(current_cursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk.await.map_err(|err| std::io::Error::other(err.to_string()))??;
|
||||
drop(rx);
|
||||
let walk_shutdown_timeout = drain_deadline
|
||||
.map(|deadline| deadline.saturating_duration_since(tokio::time::Instant::now()))
|
||||
.unwrap_or(BACKGROUND_WALK_SHUTDOWN_TIMEOUT);
|
||||
let walk_result = match tokio::time::timeout(walk_shutdown_timeout, &mut walk).await {
|
||||
Ok(result) => result.map_err(|err| std::io::Error::other(err.to_string()))?,
|
||||
Err(_) => {
|
||||
walk.abort();
|
||||
let _ = walk.await;
|
||||
if let Some(err) = receive_error {
|
||||
return Err(err);
|
||||
}
|
||||
return Err(tier_free_version_recovery_walk_shutdown_timed_out());
|
||||
}
|
||||
};
|
||||
if let Some(err) = receive_error {
|
||||
return Err(err);
|
||||
}
|
||||
walk_result?;
|
||||
mark_scan_truncated_if_needed(&mut page, scanned_objects, walk_scan_limit, &bucket.name, last_seen_object.as_deref());
|
||||
|
||||
if page.truncated {
|
||||
page.next_bucket_marker = Some(bucket.name.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -462,6 +697,16 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_walk_disables_total_timeout_and_inherits_stall_timeout() {
|
||||
let opts = recovery_walk_options(123, Some("marker".to_string()));
|
||||
|
||||
assert_eq!(opts.limit, 123);
|
||||
assert_eq!(opts.marker.as_deref(), Some("marker"));
|
||||
assert_eq!(opts.walkdir_timeout, Some(Duration::ZERO));
|
||||
assert_eq!(opts.walkdir_stall_timeout, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_truncation_keeps_marker_after_nonrecoverable_window() {
|
||||
let mut page = FreeVersionRecoveryPage {
|
||||
|
||||
@@ -23,6 +23,7 @@ use crate::bucket::lifecycle::bucket_lifecycle_ops::ExpiryOp;
|
||||
use crate::bucket::lifecycle::lifecycle::{self, ObjectOpts};
|
||||
use crate::bucket::lifecycle::tier_delete_journal::persist_tier_delete_journal_entry;
|
||||
use crate::client::signer_error::error_chain_contains_signer_header_marker;
|
||||
use crate::services::tier::tier::{TierConfigMgr, TierDestinationId, TierOperationLease};
|
||||
use crate::storage_api_contracts::lifecycle::TransitionedObject;
|
||||
use crate::store::ECStore;
|
||||
use rustfs_utils::get_env_usize;
|
||||
@@ -184,6 +185,7 @@ struct ObjSweeper {
|
||||
transition_status: String,
|
||||
transition_tier: String,
|
||||
transition_version_id: String,
|
||||
transition_version_state: rustfs_filemeta::TransitionVersionState,
|
||||
remote_object: String,
|
||||
}
|
||||
|
||||
@@ -230,7 +232,9 @@ impl ObjSweeper {
|
||||
}
|
||||
|
||||
pub fn should_remove_remote_object(&self) -> Option<Jentry> {
|
||||
if self.transition_status != lifecycle::TRANSITION_COMPLETE {
|
||||
if self.transition_status != lifecycle::TRANSITION_COMPLETE
|
||||
|| self.transition_version_state == rustfs_filemeta::TransitionVersionState::Unknown
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -247,6 +251,12 @@ impl ObjSweeper {
|
||||
obj_name: self.remote_object.clone(),
|
||||
version_id: self.transition_version_id.clone(),
|
||||
tier_name: self.transition_tier.clone(),
|
||||
backend_identity: None,
|
||||
version_id_exact: matches!(
|
||||
self.transition_version_state,
|
||||
rustfs_filemeta::TransitionVersionState::SuspendedNull | rustfs_filemeta::TransitionVersionState::Exact
|
||||
),
|
||||
version_state: self.transition_version_state,
|
||||
});
|
||||
}
|
||||
None
|
||||
@@ -281,6 +291,9 @@ pub struct Jentry {
|
||||
pub(crate) obj_name: String,
|
||||
pub(crate) version_id: String,
|
||||
pub(crate) tier_name: String,
|
||||
pub(crate) backend_identity: Option<TierDestinationId>,
|
||||
pub(crate) version_id_exact: bool,
|
||||
pub(crate) version_state: rustfs_filemeta::TransitionVersionState,
|
||||
}
|
||||
|
||||
impl ExpiryOp for Jentry {
|
||||
@@ -312,6 +325,33 @@ async fn delete_object_from_remote_tier_raw(obj_name: &str, rv_id: &str, tier_na
|
||||
return result;
|
||||
}
|
||||
|
||||
let tier_config_mgr = runtime_sources::tier_config_mgr_handle();
|
||||
delete_object_from_remote_tier_raw_with_manager(obj_name, rv_id, tier_name, &tier_config_mgr).await
|
||||
}
|
||||
|
||||
async fn delete_object_from_remote_tier_raw_with_manager(
|
||||
obj_name: &str,
|
||||
rv_id: &str,
|
||||
tier_name: &str,
|
||||
tier_config_mgr: &Arc<tokio::sync::RwLock<TierConfigMgr>>,
|
||||
) -> Result<(), std::io::Error> {
|
||||
let lease = TierConfigMgr::acquire_operation_lease(&tier_config_mgr, tier_name)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
delete_object_from_remote_tier_raw_with_lease(obj_name, rv_id, &lease, false, true).await
|
||||
}
|
||||
|
||||
async fn delete_object_from_remote_tier_raw_with_lease(
|
||||
obj_name: &str,
|
||||
rv_id: &str,
|
||||
lease: &TierOperationLease,
|
||||
version_id_exact: bool,
|
||||
validate_remote_version_id: bool,
|
||||
) -> Result<(), std::io::Error> {
|
||||
if validate_remote_version_id {
|
||||
lease.validate_remote_version_id(rv_id)?;
|
||||
}
|
||||
|
||||
if remote_delete_breaker_is_open(Instant::now()).await {
|
||||
metrics::counter!(METRIC_DELETE_REMOTE_BREAKER_TOTAL).increment(1);
|
||||
return Err(std::io::Error::other(ERR_REMOTE_DELETE_BREAKER_OPEN));
|
||||
@@ -323,13 +363,11 @@ async fn delete_object_from_remote_tier_raw(obj_name: &str, rv_id: &str, tier_na
|
||||
.map_err(|_| std::io::Error::other(ERR_REMOTE_DELETE_LIMITER_CLOSED))?;
|
||||
let _inflight = RemoteDeleteInflightGuard::new();
|
||||
|
||||
let tier_config_mgr = runtime_sources::tier_config_mgr_handle();
|
||||
let mut config_mgr = tier_config_mgr.write().await;
|
||||
let w = match config_mgr.get_driver(tier_name).await {
|
||||
Ok(w) => w,
|
||||
Err(e) => return Err(std::io::Error::other(e)),
|
||||
};
|
||||
w.remove(obj_name, rv_id).await
|
||||
if version_id_exact {
|
||||
lease.remove_exact(obj_name, rv_id).await
|
||||
} else {
|
||||
lease.remove(obj_name, rv_id).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -341,6 +379,30 @@ fn run_remote_tier_delete_test_hook(obj_name: &str, rv_id: &str, tier_name: &str
|
||||
.map(|hook| hook(obj_name, rv_id, tier_name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) struct RemoteTierDeleteHookGuard;
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for RemoteTierDeleteHookGuard {
|
||||
fn drop(&mut self) {
|
||||
let mut hook = REMOTE_TIER_DELETE_TEST_HOOK
|
||||
.lock()
|
||||
.expect("remote tier delete test hook lock should not poison");
|
||||
*hook = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn set_remote_tier_delete_test_hook(
|
||||
hook_fn: impl Fn(&str, &str, &str) -> std::io::Result<()> + Send + Sync + 'static,
|
||||
) -> RemoteTierDeleteHookGuard {
|
||||
let mut hook = REMOTE_TIER_DELETE_TEST_HOOK
|
||||
.lock()
|
||||
.expect("remote tier delete test hook lock should not poison");
|
||||
*hook = Some(Box::new(hook_fn));
|
||||
RemoteTierDeleteHookGuard
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RemoteTierDeleteOutcome {
|
||||
Deleted,
|
||||
@@ -364,6 +426,84 @@ pub async fn delete_object_from_remote_tier_idempotent(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_object_from_remote_tier_idempotent_with_manager_and_identity(
|
||||
obj_name: &str,
|
||||
rv_id: &str,
|
||||
tier_name: &str,
|
||||
backend_identity: TierDestinationId,
|
||||
tier_config_mgr: &Arc<tokio::sync::RwLock<TierConfigMgr>>,
|
||||
version_id_exact: bool,
|
||||
) -> Result<RemoteTierDeleteOutcome, std::io::Error> {
|
||||
let lease = TierConfigMgr::acquire_operation_lease_for_backend_identity(tier_config_mgr, tier_name, backend_identity)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
delete_object_from_remote_tier_with_lease_idempotent(obj_name, rv_id, &lease, version_id_exact).await
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_object_from_remote_tier_with_lease_idempotent(
|
||||
obj_name: &str,
|
||||
rv_id: &str,
|
||||
lease: &TierOperationLease,
|
||||
version_id_exact: bool,
|
||||
) -> Result<RemoteTierDeleteOutcome, std::io::Error> {
|
||||
delete_object_from_remote_tier_with_lease_idempotent_inner(obj_name, rv_id, lease, version_id_exact, true).await
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_confirmed_transition_candidate_exact_with_lease_idempotent(
|
||||
obj_name: &str,
|
||||
rv_id: &str,
|
||||
lease: &TierOperationLease,
|
||||
) -> Result<RemoteTierDeleteOutcome, std::io::Error> {
|
||||
if rv_id.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"confirmed versioned transition candidate requires a non-empty remote version",
|
||||
));
|
||||
}
|
||||
#[cfg(test)]
|
||||
if obj_name == "remote/empty-guard-probe" {
|
||||
CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
delete_object_from_remote_tier_with_lease_idempotent_inner(obj_name, rv_id, lease, true, false).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
pub(crate) async fn delete_confirmed_transition_candidate_exact_with_manager_and_identity(
|
||||
obj_name: &str,
|
||||
rv_id: &str,
|
||||
tier_name: &str,
|
||||
backend_identity: TierDestinationId,
|
||||
tier_config_mgr: &Arc<tokio::sync::RwLock<TierConfigMgr>>,
|
||||
) -> Result<RemoteTierDeleteOutcome, std::io::Error> {
|
||||
let lease = TierConfigMgr::acquire_operation_lease_for_backend_identity(tier_config_mgr, tier_name, backend_identity)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
delete_confirmed_transition_candidate_exact_with_lease_idempotent(obj_name, rv_id, &lease).await
|
||||
}
|
||||
|
||||
async fn delete_object_from_remote_tier_with_lease_idempotent_inner(
|
||||
obj_name: &str,
|
||||
rv_id: &str,
|
||||
lease: &TierOperationLease,
|
||||
version_id_exact: bool,
|
||||
validate_remote_version_id: bool,
|
||||
) -> Result<RemoteTierDeleteOutcome, std::io::Error> {
|
||||
match delete_object_from_remote_tier_raw_with_lease(obj_name, rv_id, lease, version_id_exact, validate_remote_version_id)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(RemoteTierDeleteOutcome::Deleted),
|
||||
Err(err) if is_remote_tier_not_found_error(&err) => Ok(RemoteTierDeleteOutcome::AlreadyRemoved),
|
||||
Err(err) => {
|
||||
if should_record_remote_delete_failure(&err) {
|
||||
record_remote_delete_failure(&err, Instant::now()).await;
|
||||
}
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_remote_tier_not_found_error(err: &std::io::Error) -> bool {
|
||||
let message = err.to_string();
|
||||
message.contains("NoSuchKey")
|
||||
@@ -377,6 +517,7 @@ pub fn transitioned_delete_journal_entry(
|
||||
versioned: bool,
|
||||
suspended: bool,
|
||||
transitioned: &TransitionedObject,
|
||||
transition_version_state: rustfs_filemeta::TransitionVersionState,
|
||||
) -> Option<Jentry> {
|
||||
let sweeper = ObjSweeper {
|
||||
version_id,
|
||||
@@ -385,6 +526,7 @@ pub fn transitioned_delete_journal_entry(
|
||||
transition_status: transitioned.status.clone(),
|
||||
transition_tier: transitioned.tier.clone(),
|
||||
transition_version_id: transitioned.version_id.clone(),
|
||||
transition_version_state,
|
||||
remote_object: transitioned.name.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -392,8 +534,13 @@ pub fn transitioned_delete_journal_entry(
|
||||
sweeper.should_remove_remote_object()
|
||||
}
|
||||
|
||||
pub fn transitioned_force_delete_journal_entry(transitioned: &TransitionedObject) -> Option<Jentry> {
|
||||
if transitioned.status != lifecycle::TRANSITION_COMPLETE {
|
||||
pub fn transitioned_force_delete_journal_entry(
|
||||
transitioned: &TransitionedObject,
|
||||
transition_version_state: rustfs_filemeta::TransitionVersionState,
|
||||
) -> Option<Jentry> {
|
||||
if transitioned.status != lifecycle::TRANSITION_COMPLETE
|
||||
|| transition_version_state == rustfs_filemeta::TransitionVersionState::Unknown
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -401,6 +548,12 @@ pub fn transitioned_force_delete_journal_entry(transitioned: &TransitionedObject
|
||||
obj_name: transitioned.name.clone(),
|
||||
version_id: transitioned.version_id.clone(),
|
||||
tier_name: transitioned.tier.clone(),
|
||||
backend_identity: None,
|
||||
version_id_exact: matches!(
|
||||
transition_version_state,
|
||||
rustfs_filemeta::TransitionVersionState::SuspendedNull | rustfs_filemeta::TransitionVersionState::Exact
|
||||
),
|
||||
version_state: transition_version_state,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -409,34 +562,17 @@ mod test {
|
||||
use crate::client::signer_error::invalid_utf8_header_error;
|
||||
|
||||
use super::{
|
||||
ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED, REMOTE_TIER_DELETE_TEST_HOOK, RemoteDeleteBreaker,
|
||||
RemoteTierDeleteOutcome, delete_object_from_remote_tier_idempotent, is_remote_tier_not_found_error,
|
||||
is_signer_header_error, should_record_remote_delete_failure,
|
||||
CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES, ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED,
|
||||
RemoteDeleteBreaker, RemoteTierDeleteOutcome, delete_confirmed_transition_candidate_exact_with_manager_and_identity,
|
||||
delete_object_from_remote_tier_idempotent, delete_object_from_remote_tier_idempotent_with_manager_and_identity,
|
||||
is_remote_tier_not_found_error, is_signer_header_error, lifecycle, set_remote_tier_delete_test_hook,
|
||||
should_record_remote_delete_failure, transitioned_delete_journal_entry, transitioned_force_delete_journal_entry,
|
||||
};
|
||||
use crate::storage_api_contracts::lifecycle::TransitionedObject;
|
||||
use rustfs_filemeta::TransitionVersionState;
|
||||
use std::io::{Error, ErrorKind};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
struct RemoteTierDeleteHookGuard;
|
||||
|
||||
impl Drop for RemoteTierDeleteHookGuard {
|
||||
fn drop(&mut self) {
|
||||
let mut hook = REMOTE_TIER_DELETE_TEST_HOOK
|
||||
.lock()
|
||||
.expect("remote tier delete test hook lock should not poison");
|
||||
*hook = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn set_remote_tier_delete_test_hook(
|
||||
hook_fn: impl Fn(&str, &str, &str) -> std::io::Result<()> + Send + Sync + 'static,
|
||||
) -> RemoteTierDeleteHookGuard {
|
||||
let mut hook = REMOTE_TIER_DELETE_TEST_HOOK
|
||||
.lock()
|
||||
.expect("remote tier delete test hook lock should not poison");
|
||||
*hook = Some(Box::new(hook_fn));
|
||||
RemoteTierDeleteHookGuard
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signer_header_error_detection_matches_utf8_failures() {
|
||||
let err = Error::new(
|
||||
@@ -477,6 +613,43 @@ mod test {
|
||||
assert!(should_record_remote_delete_failure(&Error::other("NoSuchVersion")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transitioned_delete_journal_preserves_remote_version_state() {
|
||||
let cases = [
|
||||
(TransitionVersionState::Unknown, "legacy-version", None),
|
||||
(TransitionVersionState::KnownDisabled, "", Some(false)),
|
||||
(TransitionVersionState::SuspendedNull, "null", Some(true)),
|
||||
(TransitionVersionState::Exact, "opaque-version", Some(true)),
|
||||
];
|
||||
|
||||
for (state, version_id, expected_exact) in cases {
|
||||
let transitioned = TransitionedObject {
|
||||
name: "remote/object".to_string(),
|
||||
version_id: version_id.to_string(),
|
||||
tier: "WARM".to_string(),
|
||||
status: lifecycle::TRANSITION_COMPLETE.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let regular = transitioned_delete_journal_entry(None, false, false, &transitioned, state);
|
||||
let forced = transitioned_force_delete_journal_entry(&transitioned, state);
|
||||
|
||||
match expected_exact {
|
||||
Some(expected_exact) => {
|
||||
let regular = regular.expect("known version state should produce a regular delete journal entry");
|
||||
assert_eq!(regular.version_state, state);
|
||||
assert_eq!(regular.version_id_exact, expected_exact);
|
||||
let forced = forced.expect("known version state should produce a forced delete journal entry");
|
||||
assert_eq!(forced.version_state, state);
|
||||
assert_eq!(forced.version_id_exact, expected_exact);
|
||||
}
|
||||
None => {
|
||||
assert!(regular.is_none());
|
||||
assert!(forced.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn idempotent_remote_delete_treats_hooked_nosuchversion_as_already_removed() {
|
||||
@@ -506,6 +679,148 @@ mod test {
|
||||
assert!(err.to_string().contains("driver not found"));
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test]
|
||||
async fn journal_delete_rejects_backend_identity_mismatch() {
|
||||
let manager = crate::services::tier::tier::TierConfigMgr::new();
|
||||
crate::services::tier::test_util::register_mock_tier(&manager, "WARM").await;
|
||||
let lease = crate::services::tier::tier::TierConfigMgr::acquire_operation_lease(&manager, "WARM")
|
||||
.await
|
||||
.expect("test tier lease should be available");
|
||||
let mut mismatched = lease.backend_identity();
|
||||
mismatched[0] ^= 1;
|
||||
drop(lease);
|
||||
|
||||
let err = delete_object_from_remote_tier_idempotent_with_manager_and_identity(
|
||||
"remote/object",
|
||||
"remote-version",
|
||||
"WARM",
|
||||
mismatched,
|
||||
&manager,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect_err("journal recovery must fail closed when the tier name was rebound");
|
||||
|
||||
assert!(err.to_string().contains("identity no longer matches"));
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test]
|
||||
async fn journal_delete_dispatches_an_exact_version_constraint() {
|
||||
let manager = crate::services::tier::tier::TierConfigMgr::new();
|
||||
let backend = crate::services::tier::test_util::register_mock_tier(&manager, "WARM").await;
|
||||
let lease = crate::services::tier::tier::TierConfigMgr::acquire_operation_lease(&manager, "WARM")
|
||||
.await
|
||||
.expect("test tier lease should be available");
|
||||
let identity = lease.backend_identity();
|
||||
drop(lease);
|
||||
|
||||
let outcome = delete_object_from_remote_tier_idempotent_with_manager_and_identity(
|
||||
"remote/object",
|
||||
"exact-version",
|
||||
"WARM",
|
||||
identity,
|
||||
&manager,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect("an exact journal delete should reach the backend");
|
||||
|
||||
assert_eq!(outcome, RemoteTierDeleteOutcome::Deleted);
|
||||
assert_eq!(backend.exact_remove_count(), 1);
|
||||
assert_eq!(backend.remove_count().await, 1);
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test]
|
||||
async fn journal_delete_rejects_nonempty_remote_version_before_backend_io() {
|
||||
let manager = crate::services::tier::tier::TierConfigMgr::new();
|
||||
let backend = crate::services::tier::test_util::register_mock_tier(&manager, "WARM").await;
|
||||
let lease = crate::services::tier::tier::TierConfigMgr::acquire_operation_lease(&manager, "WARM")
|
||||
.await
|
||||
.expect("test tier lease should be available");
|
||||
let identity = lease.backend_identity();
|
||||
drop(lease);
|
||||
backend.set_reject_non_empty_remote_versions(true);
|
||||
|
||||
let err = delete_object_from_remote_tier_idempotent_with_manager_and_identity(
|
||||
"remote/object",
|
||||
"remote-version",
|
||||
"WARM",
|
||||
identity,
|
||||
&manager,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect_err("a provider that rejects a versioned delete must fail before remote IO");
|
||||
|
||||
assert!(err.to_string().contains("requires an unversioned remote object"));
|
||||
assert_eq!(backend.remove_count().await, 0);
|
||||
|
||||
delete_object_from_remote_tier_idempotent_with_manager_and_identity(
|
||||
"remote/object",
|
||||
"",
|
||||
"WARM",
|
||||
identity,
|
||||
&manager,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect("unversioned remote delete should continue without a version ID");
|
||||
|
||||
assert_eq!(backend.remove_versions().await, vec![("remote/object".to_string(), String::new())]);
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn confirmed_transition_cleanup_deletes_exact_provider_token() {
|
||||
CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
let manager = crate::services::tier::tier::TierConfigMgr::new();
|
||||
let backend = crate::services::tier::test_util::register_mock_tier(&manager, "WARM").await;
|
||||
let lease = crate::services::tier::tier::TierConfigMgr::acquire_operation_lease(&manager, "WARM")
|
||||
.await
|
||||
.expect("test tier lease should be available");
|
||||
let identity = lease.backend_identity();
|
||||
drop(lease);
|
||||
backend.set_reject_non_empty_remote_versions(true);
|
||||
|
||||
let outcome = delete_confirmed_transition_candidate_exact_with_manager_and_identity(
|
||||
"remote/object",
|
||||
"provider-version-token",
|
||||
"WARM",
|
||||
identity,
|
||||
&manager,
|
||||
)
|
||||
.await
|
||||
.expect("confirmed upload compensation should delete the exact provider token");
|
||||
|
||||
assert_eq!(outcome, RemoteTierDeleteOutcome::Deleted);
|
||||
assert_eq!(backend.exact_remove_count(), 1);
|
||||
assert_eq!(
|
||||
backend.remove_versions().await,
|
||||
vec![("remote/object".to_string(), "provider-version-token".to_string())]
|
||||
);
|
||||
|
||||
let err = delete_confirmed_transition_candidate_exact_with_manager_and_identity(
|
||||
"remote/empty-guard-probe",
|
||||
"",
|
||||
"WARM",
|
||||
identity,
|
||||
&manager,
|
||||
)
|
||||
.await
|
||||
.expect_err("confirmed versioned cleanup must reject an empty token");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert_eq!(backend.remove_count().await, 1);
|
||||
assert_eq!(
|
||||
CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES.load(std::sync::atomic::Ordering::Relaxed),
|
||||
0,
|
||||
"empty remote versions must be rejected before exact cleanup dispatch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breaker_opens_at_threshold_and_recovers_after_window() {
|
||||
let mut breaker = RemoteDeleteBreaker::new(3, Duration::from_secs(30));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -649,7 +649,7 @@ impl BucketMetadata {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn default_timestamps(&mut self) {
|
||||
pub(crate) fn default_timestamps(&mut self) {
|
||||
if self.policy_config_updated_at == OffsetDateTime::UNIX_EPOCH {
|
||||
self.policy_config_updated_at = self.created
|
||||
}
|
||||
@@ -1093,16 +1093,25 @@ pub async fn load_bucket_metadata(api: Arc<ECStore>, bucket: &str) -> Result<Buc
|
||||
}
|
||||
|
||||
pub async fn load_bucket_metadata_parse(api: Arc<ECStore>, bucket: &str, parse: bool) -> Result<BucketMetadata> {
|
||||
let mut bm = match read_bucket_metadata(api.clone(), bucket).await {
|
||||
Ok(res) => res,
|
||||
Ok(load_bucket_metadata_parse_with_presence(api, bucket, parse).await?.0)
|
||||
}
|
||||
|
||||
/// The returned `bool` reports whether the metadata was actually read from
|
||||
/// persisted storage; `false` means no metadata exists for this bucket on this
|
||||
/// store and the returned value is a fabricated in-memory default.
|
||||
pub(crate) async fn load_bucket_metadata_parse_with_presence(
|
||||
api: Arc<ECStore>,
|
||||
bucket: &str,
|
||||
parse: bool,
|
||||
) -> Result<(BucketMetadata, bool)> {
|
||||
let (mut bm, persisted) = match read_bucket_metadata(api.clone(), bucket).await {
|
||||
Ok(res) => (res, true),
|
||||
Err(err) => {
|
||||
if err != Error::ConfigNotFound {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// info!("bucketmeta {} not found with err {:?}, start to init ", bucket, &err);
|
||||
|
||||
BucketMetadata::new(bucket)
|
||||
(BucketMetadata::new(bucket), false)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1112,7 +1121,7 @@ pub async fn load_bucket_metadata_parse(api: Arc<ECStore>, bucket: &str, parse:
|
||||
bm.parse_all_configs()?;
|
||||
}
|
||||
|
||||
Ok(bm)
|
||||
Ok((bm, persisted))
|
||||
}
|
||||
|
||||
async fn read_bucket_metadata(api: Arc<ECStore>, bucket: &str) -> Result<BucketMetadata> {
|
||||
|
||||
@@ -12,15 +12,17 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::metadata::{BucketMetadata, load_bucket_metadata};
|
||||
use super::metadata::{BUCKET_TARGETS_FILE, BucketMetadata, load_bucket_metadata};
|
||||
use super::quota::BucketQuota;
|
||||
use super::target::BucketTargets;
|
||||
use crate::bucket::bucket_target_sys::BucketTargetSys;
|
||||
use crate::bucket::metadata::load_bucket_metadata_parse;
|
||||
use crate::bucket::metadata::{load_bucket_metadata_parse, load_bucket_metadata_parse_with_presence};
|
||||
use crate::bucket::utils::is_meta_bucketname;
|
||||
use crate::disk::RUSTFS_META_BUCKET;
|
||||
use crate::error::{Error, Result, is_err_bucket_not_found};
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
use crate::storage_api_contracts::heal::HealOperations as _;
|
||||
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||
use crate::store::ECStore;
|
||||
use futures::future::join_all;
|
||||
use rustfs_common::heal_channel::HealOpts;
|
||||
@@ -188,7 +190,7 @@ pub async fn get(bucket: &str) -> Result<Arc<BucketMetadata>> {
|
||||
// instance cell is not initialized yet (early startup) they fall back to the
|
||||
// ambient default — the single-instance legacy behavior.
|
||||
|
||||
fn bucket_metadata_sys_of(ctx: &crate::runtime::instance::InstanceContext) -> Result<Arc<RwLock<BucketMetadataSys>>> {
|
||||
pub(crate) fn bucket_metadata_sys_of(ctx: &crate::runtime::instance::InstanceContext) -> Result<Arc<RwLock<BucketMetadataSys>>> {
|
||||
if let Some(sys) = ctx.bucket_metadata_sys() {
|
||||
return Ok(sys);
|
||||
}
|
||||
@@ -221,11 +223,35 @@ pub(crate) async fn remove_bucket_metadata_in(ctx: &crate::runtime::instance::In
|
||||
|
||||
pub async fn update(bucket: &str, config_file: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let _targets_guard = if config_file == BUCKET_TARGETS_FILE {
|
||||
Some(acquire_bucket_targets_transaction_lock(bucket).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut bucket_meta_sys = bucket_meta_sys_lock.write().await;
|
||||
|
||||
bucket_meta_sys.update(bucket, config_file, data).await
|
||||
}
|
||||
|
||||
pub async fn update_bucket_targets_under_transaction_lock(bucket: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let mut bucket_meta_sys = bucket_meta_sys_lock.write().await;
|
||||
bucket_meta_sys.update(bucket, BUCKET_TARGETS_FILE, data).await
|
||||
}
|
||||
|
||||
pub async fn acquire_bucket_targets_transaction_lock(bucket: &str) -> Result<rustfs_lock::NamespaceLockGuard> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let api = bucket_meta_sys_lock.read().await.object_store();
|
||||
let lock = api
|
||||
.new_ns_lock(RUSTFS_META_BUCKET, &bucket_targets_transaction_lock_key(bucket))
|
||||
.await?;
|
||||
Ok(lock.get_write_lock(crate::set_disk::get_lock_acquire_timeout()).await?)
|
||||
}
|
||||
|
||||
fn bucket_targets_transaction_lock_key(bucket: &str) -> String {
|
||||
format!("bucket-targets/{bucket}/transaction.lock")
|
||||
}
|
||||
|
||||
pub async fn delete(bucket: &str, config_file: &str) -> Result<OffsetDateTime> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let mut bucket_meta_sys = bucket_meta_sys_lock.write().await;
|
||||
@@ -396,9 +422,23 @@ pub async fn list_bucket_targets(bucket: &str) -> Result<BucketTargets> {
|
||||
bucket_meta_sys.get_bucket_targets_config(bucket).await
|
||||
}
|
||||
|
||||
/// Bound and lifetime of the negative cache for buckets with no persisted
|
||||
/// metadata. Entries are invalidated the moment real metadata is cached, so
|
||||
/// the TTL only bounds staleness for out-of-band creations whose reload
|
||||
/// notification was lost; the capacity bounds memory under bogus-name floods.
|
||||
const ABSENT_BUCKET_METADATA_TTL: Duration = Duration::from_secs(30);
|
||||
const ABSENT_BUCKET_METADATA_MAX_ENTRIES: u64 = 10_000;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct BucketMetadataSys {
|
||||
metadata_map: RwLock<HashMap<String, Arc<BucketMetadata>>>,
|
||||
/// Buckets recently observed to have no persisted metadata. Serving the
|
||||
/// fabricated default from here (instead of re-reading disk) keeps the
|
||||
/// per-request cost of repeated lookups for such names bounded — without
|
||||
/// this, every request naming a nonexistent bucket pays a namespace-lock
|
||||
/// acquisition plus a full erasure-set metadata fanout (reachable
|
||||
/// pre-auth via CORS preflight, and per-key in DeleteObjects).
|
||||
absent_metadata: moka::future::Cache<String, ()>,
|
||||
api: Arc<ECStore>,
|
||||
initialized: RwLock<bool>,
|
||||
}
|
||||
@@ -407,11 +447,19 @@ impl BucketMetadataSys {
|
||||
pub fn new(api: Arc<ECStore>) -> Self {
|
||||
Self {
|
||||
metadata_map: RwLock::new(HashMap::new()),
|
||||
absent_metadata: moka::future::Cache::builder()
|
||||
.max_capacity(ABSENT_BUCKET_METADATA_MAX_ENTRIES)
|
||||
.time_to_live(ABSENT_BUCKET_METADATA_TTL)
|
||||
.build(),
|
||||
api,
|
||||
initialized: RwLock::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn object_store(&self) -> Arc<ECStore> {
|
||||
self.api.clone()
|
||||
}
|
||||
|
||||
pub async fn init(&mut self, buckets: Vec<String>) {
|
||||
let _ = self.init_internal(buckets).await;
|
||||
}
|
||||
@@ -457,7 +505,7 @@ impl BucketMetadataSys {
|
||||
},
|
||||
)
|
||||
.await;
|
||||
load_bucket_metadata(self.api.clone(), bucket.as_str()).await
|
||||
load_bucket_metadata_parse_with_presence(self.api.clone(), bucket.as_str(), true).await
|
||||
});
|
||||
}
|
||||
|
||||
@@ -465,9 +513,24 @@ impl BucketMetadataSys {
|
||||
|
||||
for (idx, res) in results.into_iter().enumerate() {
|
||||
match res {
|
||||
Ok(res) => {
|
||||
Ok((bm, persisted)) => {
|
||||
if let Some(bucket) = buckets.get(idx) {
|
||||
self.set(bucket.clone(), Arc::new(res)).await;
|
||||
if persisted {
|
||||
self.set(bucket.clone(), Arc::new(bm)).await;
|
||||
} else {
|
||||
// A fabricated default (no persisted metadata
|
||||
// readable right now) must never REPLACE an
|
||||
// existing entry: the periodic refresh would
|
||||
// otherwise downgrade a lock-enabled bucket to an
|
||||
// authoritative "no lock" default on a transient
|
||||
// ConfigNotFound, disabling the object-lock
|
||||
// delete gate and wiping its target/durability
|
||||
// sync state. Insert-if-vacant keeps the startup
|
||||
// behavior for legacy buckets without a metadata
|
||||
// file, atomically under the map write lock.
|
||||
let mut map = self.metadata_map.write().await;
|
||||
map.entry(bucket.clone()).or_insert_with(|| Arc::new(bm));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -498,6 +561,8 @@ impl BucketMetadataSys {
|
||||
let mut map = self.metadata_map.write().await;
|
||||
map.insert(bucket.clone(), bm.clone());
|
||||
drop(map);
|
||||
// Real metadata supersedes any recorded absence immediately.
|
||||
self.absent_metadata.invalidate(&bucket).await;
|
||||
sync_bucket_target_sys(&bucket, &bm).await;
|
||||
sync_bucket_durability(&bucket, &bm);
|
||||
}
|
||||
@@ -600,13 +665,22 @@ impl BucketMetadataSys {
|
||||
pub async fn get_config(&self, bucket: &str) -> Result<(Arc<BucketMetadata>, bool)> {
|
||||
let has_bm = {
|
||||
let map = self.metadata_map.read().await;
|
||||
map.get(&bucket.to_string()).cloned()
|
||||
map.get(bucket).cloned()
|
||||
};
|
||||
|
||||
if let Some(bm) = has_bm {
|
||||
Ok((bm, false))
|
||||
} else {
|
||||
let bm = match load_bucket_metadata(self.api.clone(), bucket).await {
|
||||
// A recent lookup already established there is no persisted
|
||||
// metadata: serve the fabricated default without another
|
||||
// namespace-lock + erasure-set fanout.
|
||||
if self.absent_metadata.get(bucket).await.is_some() {
|
||||
let mut bm = BucketMetadata::new(bucket);
|
||||
bm.default_timestamps();
|
||||
return Ok((Arc::new(bm), true));
|
||||
}
|
||||
|
||||
let (bm, persisted) = match load_bucket_metadata_parse_with_presence(self.api.clone(), bucket, true).await {
|
||||
Ok(res) => res,
|
||||
Err(err) => {
|
||||
return if *self.initialized.read().await {
|
||||
@@ -617,13 +691,27 @@ impl BucketMetadataSys {
|
||||
}
|
||||
};
|
||||
|
||||
let mut map = self.metadata_map.write().await;
|
||||
|
||||
let bm = Arc::new(bm);
|
||||
map.insert(bucket.to_string(), bm.clone());
|
||||
drop(map);
|
||||
sync_bucket_target_sys(bucket, &bm).await;
|
||||
sync_bucket_durability(bucket, &bm);
|
||||
|
||||
// This lazy path caches only metadata that actually exists on
|
||||
// this store. A fabricated default must not enter the map:
|
||||
// `get()` is map-only and fail-closed — the object-lock delete
|
||||
// gate (`object_lock_delete_check_required`) skips its per-object
|
||||
// protection stat exactly when the map serves metadata saying the
|
||||
// bucket has no Object Lock, so caching a fabricated default here
|
||||
// would turn a metadata miss into an authoritative "no lock"
|
||||
// answer. (Startup `concurrent_load` still caches fabricated
|
||||
// defaults for buckets listed on disk — legacy buckets without a
|
||||
// metadata file — but never lets one replace an existing entry.)
|
||||
if persisted {
|
||||
let mut map = self.metadata_map.write().await;
|
||||
map.insert(bucket.to_string(), bm.clone());
|
||||
drop(map);
|
||||
sync_bucket_target_sys(bucket, &bm).await;
|
||||
sync_bucket_durability(bucket, &bm);
|
||||
} else {
|
||||
self.absent_metadata.insert(bucket.to_string(), ()).await;
|
||||
}
|
||||
|
||||
Ok((bm, true))
|
||||
}
|
||||
@@ -653,6 +741,8 @@ impl BucketMetadataSys {
|
||||
|
||||
if let Some(config) = &bm.policy_config {
|
||||
Ok((config.clone(), bm.policy_config_updated_at))
|
||||
} else if !bm.policy_config_json.is_empty() {
|
||||
Ok((serde_json::from_slice(&bm.policy_config_json)?, bm.policy_config_updated_at))
|
||||
} else {
|
||||
Err(Error::ConfigNotFound)
|
||||
}
|
||||
@@ -843,13 +933,142 @@ impl BucketMetadataSys {
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only fixture shared with sibling modules (e.g. the quota checker
|
||||
/// tests): a 4-disk `ECStore` on an isolated instance context, so tests
|
||||
/// exercising the metadata system never touch ambient process state.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support {
|
||||
use super::*;
|
||||
use crate::disk::endpoint::Endpoint;
|
||||
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
use crate::store::init_local_disks_with_instance_ctx;
|
||||
|
||||
pub(crate) async fn isolated_store_over_temp_disks() -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
|
||||
let mut dirs = Vec::with_capacity(4);
|
||||
let mut endpoints = Vec::with_capacity(4);
|
||||
for disk_idx in 0..4 {
|
||||
let dir = tempfile::tempdir().expect("tempdir should be created");
|
||||
let mut endpoint =
|
||||
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(disk_idx);
|
||||
dirs.push(dir);
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
let endpoint_pools = EndpointServerPools(vec![PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 1,
|
||||
drives_per_set: 4,
|
||||
endpoints: Endpoints::from(endpoints),
|
||||
cmd_line: "metadata-sys-cache-test".to_string(),
|
||||
platform: "test".to_string(),
|
||||
}]);
|
||||
let instance_ctx = Arc::new(InstanceContext::new());
|
||||
init_local_disks_with_instance_ctx(&instance_ctx, endpoint_pools.clone())
|
||||
.await
|
||||
.expect("local disks should initialize");
|
||||
let ecstore = ECStore::new_with_instance_ctx(
|
||||
"127.0.0.1:0".parse().expect("test address"),
|
||||
endpoint_pools,
|
||||
CancellationToken::new(),
|
||||
instance_ctx,
|
||||
)
|
||||
.await
|
||||
.expect("ECStore should initialize");
|
||||
(dirs, ecstore)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::test_support::isolated_store_over_temp_disks;
|
||||
use super::*;
|
||||
use crate::bucket::target::{BucketTarget, BucketTargetType, Credentials};
|
||||
use serial_test::serial;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// Pins the fail-closed caching contract of the lazy `get_config` path
|
||||
/// and the refresh no-replace rule: fabricated defaults are returned but
|
||||
/// never served by the map-only `get()`, persisted metadata is cached on
|
||||
/// lazy load (superseding a recorded absence), and a refresh-load miss
|
||||
/// never replaces an existing entry.
|
||||
#[tokio::test]
|
||||
async fn get_config_never_caches_fabricated_defaults_as_authoritative() {
|
||||
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||
let sys = BucketMetadataSys::new(ecstore);
|
||||
|
||||
// (a) Miss: the fabricated default is returned but not cached.
|
||||
let (bm, _) = sys
|
||||
.get_config("absent-bucket")
|
||||
.await
|
||||
.expect("fabricated default should be returned");
|
||||
assert!(bm.object_lock_config_xml.is_empty());
|
||||
assert!(
|
||||
sys.get("absent-bucket").await.is_err(),
|
||||
"a fabricated default must never be served by the map-only get()"
|
||||
);
|
||||
|
||||
// The repeat lookup is served from the negative cache, same answer.
|
||||
let (bm, _) = sys
|
||||
.get_config("absent-bucket")
|
||||
.await
|
||||
.expect("negative-cached default should be returned");
|
||||
assert!(bm.object_lock_config_xml.is_empty());
|
||||
assert!(sys.get("absent-bucket").await.is_err());
|
||||
|
||||
// (b) Persisting real metadata supersedes the recorded absence, and a
|
||||
// lazy reload after a map wipe re-caches it.
|
||||
let mut persisted = BucketMetadata::new("absent-bucket");
|
||||
persisted.policy_config_json = b"persisted-marker".to_vec();
|
||||
sys.persist_and_set(persisted).await.expect("metadata should persist");
|
||||
sys.metadata_map.write().await.clear();
|
||||
let _ = sys
|
||||
.get_config("absent-bucket")
|
||||
.await
|
||||
.expect("persisted metadata should lazily reload");
|
||||
let cached = sys
|
||||
.get("absent-bucket")
|
||||
.await
|
||||
.expect("lazily loaded persisted metadata must be cached");
|
||||
assert_eq!(cached.policy_config_json, b"persisted-marker".to_vec());
|
||||
|
||||
// (c) A refresh-load miss (no persisted metadata readable) must not
|
||||
// replace an existing entry.
|
||||
let mut kept = BucketMetadata::new("kept-bucket");
|
||||
kept.policy_config_json = b"kept-marker".to_vec();
|
||||
sys.set("kept-bucket".to_string(), Arc::new(kept)).await;
|
||||
let mut failed = HashSet::new();
|
||||
let refresh_targets = vec!["kept-bucket".to_string()];
|
||||
sys.concurrent_load(&refresh_targets, &mut failed).await;
|
||||
let kept = sys
|
||||
.get("kept-bucket")
|
||||
.await
|
||||
.expect("existing entry must survive a refresh miss");
|
||||
assert_eq!(
|
||||
kept.policy_config_json,
|
||||
b"kept-marker".to_vec(),
|
||||
"a fabricated refresh default must not replace real metadata"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_bucket_policy_rejects_malformed_cached_policy() {
|
||||
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||
let sys = BucketMetadataSys::new(ecstore);
|
||||
let mut metadata = BucketMetadata::new("malformed-policy");
|
||||
metadata.policy_config_json = b"{".to_vec();
|
||||
sys.set("malformed-policy".to_string(), Arc::new(metadata)).await;
|
||||
|
||||
let err = sys
|
||||
.get_bucket_policy("malformed-policy")
|
||||
.await
|
||||
.expect_err("malformed persisted policy must not be treated as missing");
|
||||
|
||||
assert!(matches!(err, Error::Io(_)), "malformed persisted policy must surface its parse failure");
|
||||
}
|
||||
|
||||
fn target(bucket: &str, id: &str) -> BucketTarget {
|
||||
BucketTarget {
|
||||
source_bucket: bucket.to_string(),
|
||||
|
||||
@@ -538,10 +538,12 @@ mod tests {
|
||||
use crate::disk::{BUCKET_META_PREFIX, MIGRATING_META_BUCKET, RUSTFS_META_BUCKET};
|
||||
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
|
||||
use crate::object_api::{ObjectOptions, PutObjReader};
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations, BucketOptions, MakeBucketOptions};
|
||||
use crate::storage_api_contracts::object::{ObjectIO, ObjectOperations};
|
||||
use crate::store::{ECStore, init_local_disks};
|
||||
use crate::store::{ECStore, init_local_disks_with_instance_ctx};
|
||||
use rustfs_utils::path::SLASH_SEPARATOR;
|
||||
use std::sync::Arc;
|
||||
use tokio::fs;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
@@ -570,10 +572,20 @@ mod tests {
|
||||
cmd_line: "minio-migrate-test".to_string(),
|
||||
platform: format!("OS: {} | Arch: {}", std::env::consts::OS, std::env::consts::ARCH),
|
||||
}]);
|
||||
init_local_disks(endpoint_pools.clone()).await.unwrap();
|
||||
let ecstore = ECStore::new("127.0.0.1:0".parse().unwrap(), endpoint_pools, CancellationToken::new())
|
||||
// Isolated instance context: this test deletes its disks at the end,
|
||||
// and dead entries in the shared registry break other cached envs.
|
||||
let instance_ctx = Arc::new(InstanceContext::new());
|
||||
init_local_disks_with_instance_ctx(&instance_ctx, endpoint_pools.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let ecstore = ECStore::new_with_instance_ctx(
|
||||
"127.0.0.1:0".parse().unwrap(),
|
||||
endpoint_pools,
|
||||
CancellationToken::new(),
|
||||
instance_ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let existing: Vec<String> = ecstore
|
||||
.list_bucket(&BucketOptions {
|
||||
no_metadata: true,
|
||||
|
||||
@@ -15,23 +15,26 @@
|
||||
use super::metadata_sys::get_bucket_metadata_sys;
|
||||
use crate::error::{Result, StorageError};
|
||||
use rustfs_policy::policy::{BucketPolicy, BucketPolicyArgs};
|
||||
use tracing::info;
|
||||
|
||||
pub struct PolicySys {}
|
||||
|
||||
impl PolicySys {
|
||||
pub async fn is_allowed(args: &BucketPolicyArgs<'_>) -> bool {
|
||||
match Self::get(args.bucket).await {
|
||||
Ok(cfg) => return cfg.is_allowed(args).await,
|
||||
Err(err) => {
|
||||
if err != StorageError::ConfigNotFound {
|
||||
info!("config get err {:?}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
args.is_owner
|
||||
matches!(Self::try_is_allowed(args).await, Ok(true))
|
||||
}
|
||||
|
||||
pub async fn try_is_allowed(args: &BucketPolicyArgs<'_>) -> Result<bool> {
|
||||
Self::is_allowed_with_policy(args, Self::get(args.bucket).await).await
|
||||
}
|
||||
|
||||
async fn is_allowed_with_policy(args: &BucketPolicyArgs<'_>, policy: Result<BucketPolicy>) -> Result<bool> {
|
||||
match policy {
|
||||
Ok(policy) => Ok(policy.is_allowed(args).await),
|
||||
Err(StorageError::ConfigNotFound) => Ok(args.is_owner),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get(bucket: &str) -> Result<BucketPolicy> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||
@@ -41,3 +44,91 @@ impl PolicySys {
|
||||
Ok(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{PolicySys, StorageError};
|
||||
use rustfs_policy::policy::action::{Action, S3Action};
|
||||
use rustfs_policy::policy::{BucketPolicy, BucketPolicyArgs};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn args<'a>(
|
||||
is_owner: bool,
|
||||
groups: &'a Option<Vec<String>>,
|
||||
conditions: &'a HashMap<String, Vec<String>>,
|
||||
) -> BucketPolicyArgs<'a> {
|
||||
BucketPolicyArgs {
|
||||
bucket: "bucket",
|
||||
action: Action::S3Action(S3Action::GetObjectAction),
|
||||
is_owner,
|
||||
account: "account",
|
||||
groups,
|
||||
conditions,
|
||||
object: "object",
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_policy_preserves_owner_and_iam_fallback_semantics() {
|
||||
let groups = None;
|
||||
let conditions = HashMap::new();
|
||||
assert!(
|
||||
PolicySys::is_allowed_with_policy(&args(true, &groups, &conditions), Err(StorageError::ConfigNotFound),)
|
||||
.await
|
||||
.expect("missing policy should preserve owner access")
|
||||
);
|
||||
assert!(
|
||||
!PolicySys::is_allowed_with_policy(&args(false, &groups, &conditions), Err(StorageError::ConfigNotFound),)
|
||||
.await
|
||||
.expect("missing policy should defer non-owner access to IAM")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn policy_load_failures_propagate() {
|
||||
let groups = None;
|
||||
let conditions = HashMap::new();
|
||||
for (failure, expected_message) in [
|
||||
(StorageError::Io(std::io::Error::other("policy read failed")), "policy read failed"),
|
||||
(
|
||||
StorageError::other("bucket metadata sys not initialized for this instance"),
|
||||
"bucket metadata sys not initialized for this instance",
|
||||
),
|
||||
] {
|
||||
let result = PolicySys::is_allowed_with_policy(&args(true, &groups, &conditions), Err(failure)).await;
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(StorageError::Io(ref err)) if err.to_string().contains(expected_message)),
|
||||
"policy I/O and uninitialized metadata failures must propagate instead of granting owner access"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_bucket_deny_precedes_iam_allow() {
|
||||
let groups = None;
|
||||
let conditions = HashMap::new();
|
||||
let policy: BucketPolicy = serde_json::from_str(
|
||||
r#"{
|
||||
"Version":"2012-10-17",
|
||||
"Statement":[{
|
||||
"Effect":"Deny",
|
||||
"Principal":{"AWS":"*"},
|
||||
"Action":["s3:GetObject"],
|
||||
"Resource":["arn:aws:s3:::bucket/*"]
|
||||
}]
|
||||
}"#,
|
||||
)
|
||||
.expect("deny policy should parse");
|
||||
|
||||
let bucket_allowed = PolicySys::is_allowed_with_policy(&args(true, &groups, &conditions), Ok(policy))
|
||||
.await
|
||||
.expect("loaded bucket policy should evaluate");
|
||||
let iam_allowed = true;
|
||||
let request_allowed = bucket_allowed && iam_allowed;
|
||||
|
||||
assert!(iam_allowed, "test precondition: IAM grants the action");
|
||||
assert!(!bucket_allowed, "test precondition: bucket policy explicitly denies the action");
|
||||
assert!(!request_allowed, "explicit bucket Deny must reject before IAM Allow fallback");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,11 +118,17 @@ impl QuotaChecker {
|
||||
}
|
||||
|
||||
pub async fn get_quota_config(&self, bucket: &str) -> Result<BucketQuota, QuotaError> {
|
||||
let meta = self
|
||||
// `get_config`, not the map-only `get()`: a bucket with no persisted
|
||||
// metadata must resolve to the fabricated default (no quota
|
||||
// configured) so the admission check passes and the request reaches
|
||||
// the NoSuchBucket answer — a map-only miss would fail every such
|
||||
// PUT closed with 503 before the 404 could be produced. Real read
|
||||
// faults still surface as errors and keep the fail-closed behavior.
|
||||
let (meta, _) = self
|
||||
.metadata_sys
|
||||
.read()
|
||||
.await
|
||||
.get(bucket)
|
||||
.get_config(bucket)
|
||||
.await
|
||||
.map_err(QuotaError::StorageError)?;
|
||||
|
||||
@@ -161,7 +167,7 @@ impl QuotaChecker {
|
||||
}
|
||||
|
||||
let quota = self.get_quota_config(bucket).await?;
|
||||
let current_usage = self.get_real_time_usage(bucket).await.unwrap_or(0);
|
||||
let current_usage = self.get_real_time_usage(bucket).await?;
|
||||
|
||||
Ok((quota, Some(current_usage)))
|
||||
}
|
||||
@@ -171,13 +177,59 @@ impl QuotaChecker {
|
||||
}
|
||||
|
||||
pub async fn get_real_time_usage(&self, bucket: &str) -> Result<u64, QuotaError> {
|
||||
Ok(get_bucket_usage_memory(bucket).await.unwrap_or(0))
|
||||
get_bucket_usage_memory(bucket)
|
||||
.await
|
||||
.ok_or_else(|| QuotaError::UsageUnavailable {
|
||||
bucket: bucket.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
|
||||
use serial_test::serial;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Regression (PR #5307 / s3-tests `test_100_continue_error_retry`): a
|
||||
/// bucket with no persisted metadata has no quota, so the admission check
|
||||
/// must pass and let the request reach its NoSuchBucket answer. With the
|
||||
/// map-only `get()` this failed closed as a retryable 503 on every PUT to
|
||||
/// a nonexistent bucket.
|
||||
#[tokio::test]
|
||||
async fn quota_check_allows_bucket_without_persisted_metadata() {
|
||||
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||
let sys = Arc::new(RwLock::new(BucketMetadataSys::new(ecstore)));
|
||||
let checker = QuotaChecker::new(sys);
|
||||
|
||||
let result = checker
|
||||
.check_quota("no-such-bucket", QuotaOperation::PutObject, 1024)
|
||||
.await
|
||||
.expect("a bucket with no persisted metadata has no quota and must not fail the check");
|
||||
assert!(result.allowed);
|
||||
assert_eq!(result.quota_limit, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn quota_usage_rejects_an_unknown_mutation_baseline() {
|
||||
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||
let sys = Arc::new(RwLock::new(BucketMetadataSys::new(ecstore)));
|
||||
let checker = QuotaChecker::new(sys);
|
||||
let bucket = format!("quota-unknown-{}", Uuid::new_v4().simple());
|
||||
|
||||
crate::data_usage::record_bucket_object_write_memory(&bucket, None, 42).await;
|
||||
let result = checker.get_real_time_usage(&bucket).await;
|
||||
crate::data_usage::prepare_bucket_usage_for_namespace_change(&bucket, None)
|
||||
.await
|
||||
.expect("test usage cache cleanup should succeed");
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(QuotaError::UsageUnavailable { bucket: failed_bucket }) if failed_bucket == bucket),
|
||||
"quota decisions must fail closed without an authoritative usage baseline"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_quota_check_no_limit() {
|
||||
|
||||
@@ -110,6 +110,8 @@ pub enum QuotaError {
|
||||
QuotaExceeded { current: u64, limit: u64, operation: u64 },
|
||||
#[error("Quota configuration not found for bucket: {bucket}")]
|
||||
ConfigNotFound { bucket: String },
|
||||
#[error("Authoritative data usage is unavailable for bucket: {bucket}")]
|
||||
UsageUnavailable { bucket: String },
|
||||
#[error("Invalid quota configuration: {reason}")]
|
||||
InvalidConfig { reason: String },
|
||||
#[error("Storage error: {0}")]
|
||||
@@ -155,7 +157,7 @@ impl QuotaErrorResponse {
|
||||
request_id: request_id.to_string(),
|
||||
host_id: host_id.to_string(),
|
||||
},
|
||||
QuotaError::StorageError(_) => Self {
|
||||
QuotaError::UsageUnavailable { .. } | QuotaError::StorageError(_) => Self {
|
||||
code: QUOTA_INTERNAL_ERROR_CODE.to_string(),
|
||||
message: quota_error.to_string(),
|
||||
resource: QUOTA_API_PATH.to_string(),
|
||||
|
||||
@@ -52,9 +52,9 @@ pub use replication_config_boundary::{
|
||||
pub(crate) use replication_filemeta_boundary::ReplicateTargetDecision;
|
||||
pub(crate) use replication_filemeta_boundary::version_purge_statuses_map;
|
||||
pub use replication_filemeta_boundary::{
|
||||
REPLICATE_INCOMING_DELETE, ReplicateDecision, ReplicateObjectInfo, ReplicationState, ReplicationStatusType, ReplicationType,
|
||||
VersionPurgeStatusType, replication_state_to_filemeta, replication_status_to_filemeta, replication_statuses_map,
|
||||
version_purge_status_to_filemeta,
|
||||
MrfOpKind, MrfReplicateEntry, REPLICATE_INCOMING_DELETE, ReplicateDecision, ReplicateObjectInfo, ReplicationState,
|
||||
ReplicationStatusType, ReplicationType, VersionPurgeStatusType, replication_state_to_filemeta,
|
||||
replication_status_to_filemeta, replication_statuses_map, version_purge_status_to_filemeta,
|
||||
};
|
||||
pub(crate) use replication_filemeta_boundary::{
|
||||
replication_state_from_filemeta, replication_status_from_filemeta, version_purge_status_from_filemeta,
|
||||
@@ -69,8 +69,8 @@ pub use replication_object_decision_boundary::{
|
||||
should_use_existing_delete_replication_source,
|
||||
};
|
||||
pub use replication_pool::{
|
||||
DynReplicationPool, ReplicationPoolTrait, get_global_replication_pool, get_global_replication_stats,
|
||||
init_background_replication,
|
||||
DurableMrfBacklog, DynReplicationPool, ReplicationPoolTrait, get_global_replication_pool, get_global_replication_stats,
|
||||
init_background_replication, read_durable_mrf_backlog, resync_start_conflict_id,
|
||||
};
|
||||
pub use replication_queue_boundary::{
|
||||
DeletedObjectReplicationInfo, ReplicationHealQueueResult, ReplicationOperation, ReplicationPriority,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
pub(crate) use rustfs_replication::{MrfOpKind, MrfReplicateEntry};
|
||||
pub use rustfs_replication::{MrfOpKind, MrfReplicateEntry};
|
||||
pub(crate) use rustfs_replication::{
|
||||
REPLICATE_EXISTING, REPLICATE_EXISTING_DELETE, REPLICATE_HEAL_DELETE, ReplicateTargetDecision, ReplicatedInfos,
|
||||
ReplicatedTargetInfo, ReplicationAction, ReplicationWorkerOperation, ResyncDecision, get_replication_state,
|
||||
|
||||
@@ -53,6 +53,10 @@ impl ReplicationMetadataStore {
|
||||
format!("{REPLICATION_DIR}/{bucket}/{arn}")
|
||||
}
|
||||
|
||||
pub(crate) fn resync_admission_lock_key(bucket: &str) -> String {
|
||||
format!("{REPLICATION_DIR}/{bucket}/admission.lock")
|
||||
}
|
||||
|
||||
pub(crate) fn bucket_resync_dir_path(bucket: &str) -> String {
|
||||
path_join_buf(&[BUCKET_META_PREFIX, bucket, REPLICATION_DIR])
|
||||
}
|
||||
@@ -73,6 +77,10 @@ mod tests {
|
||||
ReplicationMetadataStore::resync_lock_key("bucket-a", "arn-a"),
|
||||
".replication/bucket-a/arn-a"
|
||||
);
|
||||
assert_eq!(
|
||||
ReplicationMetadataStore::resync_admission_lock_key("bucket-a"),
|
||||
".replication/bucket-a/admission.lock"
|
||||
);
|
||||
assert_eq!(
|
||||
ReplicationMetadataStore::bucket_resync_dir_path("bucket-a"),
|
||||
"buckets/bucket-a/.replication"
|
||||
|
||||
@@ -68,6 +68,51 @@ const EVENT_REPLICATION_RESYNC_LOAD_SKIPPED: &str = "replication_resync_load_ski
|
||||
const EVENT_REPLICATION_RESYNC_RECOVERED: &str = "replication_resync_recovered";
|
||||
const EVENT_REPLICATION_MRF_QUEUE_UNAVAILABLE: &str = "replication_mrf_queue_unavailable";
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DurableMrfBacklog {
|
||||
pub available: bool,
|
||||
pub entries: Vec<MrfReplicateEntry>,
|
||||
}
|
||||
|
||||
fn durable_mrf_backlog_from_read(result: Result<Vec<u8>, EcstoreError>) -> DurableMrfBacklog {
|
||||
match result {
|
||||
Ok(data) => match decode_mrf_file(&data) {
|
||||
Ok(entries) if entries.iter().all(|entry| entry.size >= 0) => DurableMrfBacklog {
|
||||
available: true,
|
||||
entries,
|
||||
},
|
||||
Ok(_) | Err(_) => DurableMrfBacklog::default(),
|
||||
},
|
||||
Err(EcstoreError::ConfigNotFound) => DurableMrfBacklog {
|
||||
available: true,
|
||||
entries: Vec::new(),
|
||||
},
|
||||
Err(_) => DurableMrfBacklog::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn read_durable_mrf_backlog<S: ReplicationObjectIO>(storage: Arc<S>) -> DurableMrfBacklog {
|
||||
durable_mrf_backlog_from_read(ReplicationConfigStore::read(storage, ReplicationMetadataStore::MRF_REPLICATION_FILE).await)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("replication resync {active_resync_id} is already active for {bucket}/{arn}")]
|
||||
struct ResyncActiveConflictError {
|
||||
bucket: String,
|
||||
arn: String,
|
||||
active_resync_id: String,
|
||||
}
|
||||
|
||||
pub fn resync_start_conflict_id(error: &EcstoreError) -> Option<&str> {
|
||||
match error {
|
||||
EcstoreError::Io(io_error) => io_error
|
||||
.get_ref()?
|
||||
.downcast_ref::<ResyncActiveConflictError>()
|
||||
.map(|conflict| conflict.active_resync_id.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Main replication pool structure
|
||||
#[derive(Debug)]
|
||||
pub struct ReplicationPool<S: ReplicationStorage> {
|
||||
@@ -948,47 +993,123 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
}
|
||||
|
||||
pub async fn start_bucket_resync(self: Arc<Self>, opts: ResyncOpts) -> Result<(), EcstoreError> {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let bucket_status = {
|
||||
let mut status_map = self.resyncer.status_map.write().await;
|
||||
let bucket_status = status_map.entry(opts.bucket.clone()).or_insert_with(|| {
|
||||
let mut status = BucketReplicationResyncStatus::new();
|
||||
status.id = 0;
|
||||
status
|
||||
});
|
||||
let new_run = self.clone().admit_bucket_resync(opts.clone()).await?;
|
||||
self.activate_bucket_resync(opts, !new_run).await
|
||||
}
|
||||
|
||||
bucket_status.last_update = Some(now);
|
||||
bucket_status.targets_map.insert(
|
||||
opts.arn.clone(),
|
||||
TargetReplicationResyncStatus {
|
||||
start_time: Some(now),
|
||||
last_update: Some(now),
|
||||
resync_id: opts.resync_id.clone(),
|
||||
resync_before_date: opts.resync_before,
|
||||
resync_status: ResyncStatusType::ResyncPending,
|
||||
failed_size: 0,
|
||||
failed_count: 0,
|
||||
replicated_size: 0,
|
||||
replicated_count: 0,
|
||||
bucket: opts.bucket.clone(),
|
||||
object: String::new(),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
pub async fn admit_bucket_resync(self: Arc<Self>, opts: ResyncOpts) -> Result<bool, EcstoreError> {
|
||||
tokio::spawn(async move { self.admit_bucket_resync_transaction(opts).await })
|
||||
.await
|
||||
.map_err(|error| EcstoreError::other(format!("replication resync admission task failed: {error}")))?
|
||||
}
|
||||
|
||||
bucket_status.clone()
|
||||
async fn admit_bucket_resync_transaction(self: Arc<Self>, opts: ResyncOpts) -> Result<bool, EcstoreError> {
|
||||
let admission_lock_key = ReplicationMetadataStore::resync_admission_lock_key(&opts.bucket);
|
||||
let admission_lock = self
|
||||
.storage
|
||||
.new_ns_lock(ReplicationMetadataStore::rustfs_meta_bucket(), &admission_lock_key)
|
||||
.await?;
|
||||
// Lock order: bucket resync admission lock -> resync status config-object lock.
|
||||
let _admission_guard = match admission_lock.get_write_lock(ReplicationLockTiming::acquire_timeout()).await {
|
||||
Ok(guard) => guard,
|
||||
Err(lock_error) => {
|
||||
if let Ok(status) = load_bucket_resync_metadata(&opts.bucket, self.storage.clone()).await {
|
||||
self.resyncer.status_map.write().await.insert(opts.bucket.clone(), status);
|
||||
}
|
||||
return Err(EcstoreError::from(lock_error));
|
||||
}
|
||||
};
|
||||
|
||||
let mut bucket_status = load_bucket_resync_metadata(&opts.bucket, self.storage.clone()).await?;
|
||||
if let Some(active) = bucket_status.targets_map.get(&opts.arn) {
|
||||
if active.resync_id == opts.resync_id {
|
||||
self.resyncer
|
||||
.status_map
|
||||
.write()
|
||||
.await
|
||||
.insert(opts.bucket.clone(), bucket_status);
|
||||
return Ok(false);
|
||||
}
|
||||
if should_auto_resume_resync(active.resync_status) {
|
||||
let active_resync_id = active.resync_id.clone();
|
||||
self.resyncer
|
||||
.status_map
|
||||
.write()
|
||||
.await
|
||||
.insert(opts.bucket.clone(), bucket_status);
|
||||
return Err(EcstoreError::other(ResyncActiveConflictError {
|
||||
bucket: opts.bucket.clone(),
|
||||
arn: opts.arn.clone(),
|
||||
active_resync_id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let now = OffsetDateTime::now_utc();
|
||||
bucket_status.last_update = Some(now);
|
||||
bucket_status.targets_map.insert(
|
||||
opts.arn.clone(),
|
||||
TargetReplicationResyncStatus {
|
||||
start_time: Some(now),
|
||||
last_update: Some(now),
|
||||
resync_id: opts.resync_id.clone(),
|
||||
resync_before_date: opts.resync_before,
|
||||
resync_status: ResyncStatusType::ResyncPending,
|
||||
failed_size: 0,
|
||||
failed_count: 0,
|
||||
replicated_size: 0,
|
||||
replicated_count: 0,
|
||||
bucket: opts.bucket.clone(),
|
||||
object: String::new(),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
|
||||
save_resync_status(&opts.bucket, &bucket_status, self.storage.clone()).await?;
|
||||
self.resyncer
|
||||
.status_map
|
||||
.write()
|
||||
.await
|
||||
.insert(opts.bucket.clone(), bucket_status);
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn activate_bucket_resync(self: Arc<Self>, opts: ResyncOpts, recovering: bool) -> Result<(), EcstoreError> {
|
||||
let bucket_status = load_bucket_resync_metadata(&opts.bucket, self.storage.clone()).await?;
|
||||
let Some(target_status) = bucket_status.targets_map.get(&opts.arn) else {
|
||||
return Err(EcstoreError::other("replication resync admission is missing"));
|
||||
};
|
||||
if target_status.resync_id != opts.resync_id {
|
||||
return Err(EcstoreError::other(ResyncActiveConflictError {
|
||||
bucket: opts.bucket.clone(),
|
||||
arn: opts.arn.clone(),
|
||||
active_resync_id: target_status.resync_id.clone(),
|
||||
}));
|
||||
}
|
||||
if !should_auto_resume_resync(target_status.resync_status) {
|
||||
return Ok(());
|
||||
}
|
||||
self.resyncer
|
||||
.status_map
|
||||
.write()
|
||||
.await
|
||||
.insert(opts.bucket.clone(), bucket_status);
|
||||
|
||||
let resyncer = self.resyncer.clone();
|
||||
let storage = self.storage.clone();
|
||||
let cancel_token = CancellationToken::new();
|
||||
resyncer.register_cancel_token(&opts, cancel_token.clone()).await;
|
||||
tokio::spawn(async move {
|
||||
Box::pin(resyncer.clone().resync_bucket(cancel_token, storage, false, opts.clone())).await;
|
||||
resyncer.clear_cancel_token(&opts).await;
|
||||
});
|
||||
if resyncer.register_cancel_token(&opts, cancel_token.clone()).await {
|
||||
tokio::spawn(async move {
|
||||
Box::pin(
|
||||
resyncer
|
||||
.clone()
|
||||
.resync_bucket(cancel_token, storage, recovering, opts.clone()),
|
||||
)
|
||||
.await;
|
||||
resyncer.clear_cancel_token(&opts).await;
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1141,9 +1262,10 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
let resync = self.resyncer.clone();
|
||||
let storage = self.storage.clone();
|
||||
tokio::spawn(async move {
|
||||
resync.register_cancel_token(&opts, ctx.clone()).await;
|
||||
Box::pin(resync.clone().resync_bucket(ctx, storage, true, opts.clone())).await;
|
||||
resync.clear_cancel_token(&opts).await;
|
||||
if resync.register_cancel_token(&opts, ctx.clone()).await {
|
||||
Box::pin(resync.clone().resync_bucket(ctx, storage, true, opts.clone())).await;
|
||||
resync.clear_cancel_token(&opts).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1247,6 +1369,8 @@ pub trait ReplicationPoolTrait: std::fmt::Debug {
|
||||
async fn resize(&self, priority: ReplicationPriority, max_workers: usize, max_l_workers: usize);
|
||||
async fn get_bucket_resync_status(&self, bucket: &str) -> Result<BucketReplicationResyncStatus, EcstoreError>;
|
||||
async fn cancel_bucket_resync(&self, opts: ResyncOpts) -> Result<(), EcstoreError>;
|
||||
async fn admit_bucket_resync(self: Arc<Self>, opts: ResyncOpts) -> Result<bool, EcstoreError>;
|
||||
async fn activate_bucket_resync(self: Arc<Self>, opts: ResyncOpts, recovering: bool) -> Result<(), EcstoreError>;
|
||||
async fn start_bucket_resync(self: Arc<Self>, opts: ResyncOpts) -> Result<(), EcstoreError>;
|
||||
async fn init_resync(
|
||||
self: Arc<Self>,
|
||||
@@ -1290,6 +1414,14 @@ impl<S: ReplicationStorage> ReplicationPoolTrait for ReplicationPool<S> {
|
||||
self.cancel_bucket_resync(opts).await
|
||||
}
|
||||
|
||||
async fn admit_bucket_resync(self: Arc<Self>, opts: ResyncOpts) -> Result<bool, EcstoreError> {
|
||||
self.admit_bucket_resync(opts).await
|
||||
}
|
||||
|
||||
async fn activate_bucket_resync(self: Arc<Self>, opts: ResyncOpts, recovering: bool) -> Result<(), EcstoreError> {
|
||||
self.activate_bucket_resync(opts, recovering).await
|
||||
}
|
||||
|
||||
async fn start_bucket_resync(self: Arc<Self>, opts: ResyncOpts) -> Result<(), EcstoreError> {
|
||||
self.start_bucket_resync(opts).await
|
||||
}
|
||||
@@ -1553,7 +1685,9 @@ mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::{Debug, Formatter};
|
||||
use std::io::Cursor;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize};
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::Notify;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -1562,10 +1696,16 @@ mod tests {
|
||||
type TestObjectInfoOrErr = StorageObjectInfoOrErr<ObjectInfo, EcstoreError>;
|
||||
|
||||
struct LoadResyncSharedState {
|
||||
data: Vec<u8>,
|
||||
data: StdMutex<Vec<u8>>,
|
||||
lock_manager: Arc<rustfs_lock::GlobalLockManager>,
|
||||
first_read_started: Notify,
|
||||
delay_first_read: AtomicBool,
|
||||
read_count: AtomicUsize,
|
||||
write_count: AtomicUsize,
|
||||
fail_next_write: AtomicBool,
|
||||
block_next_write: AtomicBool,
|
||||
write_started: Notify,
|
||||
allow_write: Notify,
|
||||
}
|
||||
|
||||
struct LoadResyncNodeStore {
|
||||
@@ -1606,22 +1746,31 @@ mod tests {
|
||||
_h: Self::HeaderMap,
|
||||
_opts: &Self::ObjectOptions,
|
||||
) -> Result<Self::GetObjectReader, Self::Error> {
|
||||
if object != ReplicationMetadataStore::bucket_resync_file_path("load-resync-lock") {
|
||||
if !object.ends_with("/.replication/resync.bin") {
|
||||
return Err(EcstoreError::FileNotFound);
|
||||
}
|
||||
|
||||
let read_index = self.shared.read_count.fetch_add(1, Ordering::SeqCst);
|
||||
if read_index == 0 {
|
||||
if read_index == 0 && self.shared.delay_first_read.load(Ordering::SeqCst) {
|
||||
self.shared.first_read_started.notify_waiters();
|
||||
tokio::time::sleep(Duration::from_millis(1_500)).await;
|
||||
}
|
||||
|
||||
let data = self.shared.data.clone();
|
||||
let data = self
|
||||
.shared
|
||||
.data
|
||||
.lock()
|
||||
.expect("test data lock should not be poisoned")
|
||||
.clone();
|
||||
if data.is_empty() {
|
||||
return Err(EcstoreError::FileNotFound);
|
||||
}
|
||||
let size = i64::try_from(data.len()).expect("test metadata length should fit i64");
|
||||
Ok(Self::GetObjectReader {
|
||||
stream: Box::new(Cursor::new(data.clone())),
|
||||
stream: Box::new(Cursor::new(data)),
|
||||
object_info: ObjectInfo {
|
||||
size: data.len() as i64,
|
||||
actual_size: data.len() as i64,
|
||||
size,
|
||||
actual_size: size,
|
||||
..Default::default()
|
||||
},
|
||||
buffered_body: None,
|
||||
@@ -1633,9 +1782,20 @@ mod tests {
|
||||
&self,
|
||||
_bucket: &str,
|
||||
_object: &str,
|
||||
_data: &mut Self::PutObjectReader,
|
||||
data: &mut Self::PutObjectReader,
|
||||
_opts: &Self::ObjectOptions,
|
||||
) -> Result<Self::ObjectInfo, Self::Error> {
|
||||
if self.shared.fail_next_write.swap(false, Ordering::SeqCst) {
|
||||
return Err(EcstoreError::Unexpected);
|
||||
}
|
||||
if self.shared.block_next_write.swap(false, Ordering::SeqCst) {
|
||||
self.shared.write_started.notify_one();
|
||||
self.shared.allow_write.notified().await;
|
||||
}
|
||||
let mut encoded = Vec::new();
|
||||
data.stream.read_to_end(&mut encoded).await.map_err(EcstoreError::from)?;
|
||||
*self.shared.data.lock().expect("test data lock should not be poisoned") = encoded;
|
||||
self.shared.write_count.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(ObjectInfo::default())
|
||||
}
|
||||
}
|
||||
@@ -1869,6 +2029,267 @@ mod tests {
|
||||
encode_resync_file(&status).expect("test resync metadata should encode")
|
||||
}
|
||||
|
||||
fn empty_resync_shared_state() -> Arc<LoadResyncSharedState> {
|
||||
Arc::new(LoadResyncSharedState {
|
||||
data: StdMutex::new(Vec::new()),
|
||||
lock_manager: Arc::new(rustfs_lock::GlobalLockManager::new()),
|
||||
first_read_started: Notify::new(),
|
||||
delay_first_read: AtomicBool::new(false),
|
||||
read_count: AtomicUsize::new(0),
|
||||
write_count: AtomicUsize::new(0),
|
||||
fail_next_write: AtomicBool::new(false),
|
||||
block_next_write: AtomicBool::new(false),
|
||||
write_started: Notify::new(),
|
||||
allow_write: Notify::new(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn hold_resync_runtime_lock(
|
||||
shared: &Arc<LoadResyncSharedState>,
|
||||
bucket: &str,
|
||||
arn: &str,
|
||||
) -> rustfs_lock::NamespaceLockGuard {
|
||||
let lock =
|
||||
rustfs_lock::NamespaceLock::with_local_manager("resync-start-blocker".to_string(), shared.lock_manager.clone());
|
||||
let lock = rustfs_lock::NamespaceLockWrapper::new(
|
||||
lock,
|
||||
rustfs_lock::ObjectKey::new(
|
||||
ReplicationMetadataStore::rustfs_meta_bucket().to_string(),
|
||||
ReplicationMetadataStore::resync_lock_key(bucket, arn),
|
||||
),
|
||||
"blocker".to_string(),
|
||||
);
|
||||
lock.get_write_lock(Duration::from_secs(1))
|
||||
.await
|
||||
.expect("test should hold the runtime resync lock")
|
||||
}
|
||||
|
||||
fn test_resync_opts(bucket: &str, arn: &str, id: &str) -> ResyncOpts {
|
||||
ResyncOpts {
|
||||
bucket: bucket.to_string(),
|
||||
arn: arn.to_string(),
|
||||
resync_id: id.to_string(),
|
||||
resync_before: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_resync_starts_accept_one_id_and_reject_the_other() {
|
||||
let shared = empty_resync_shared_state();
|
||||
let first_pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await;
|
||||
let second_pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-b", shared.clone()))).await;
|
||||
let _runtime_guard = hold_resync_runtime_lock(&shared, "atomic-start", "arn:test").await;
|
||||
|
||||
let first = first_pool
|
||||
.clone()
|
||||
.start_bucket_resync(test_resync_opts("atomic-start", "arn:test", "run-a"));
|
||||
let second = second_pool
|
||||
.clone()
|
||||
.start_bucket_resync(test_resync_opts("atomic-start", "arn:test", "run-b"));
|
||||
let (first, second) = tokio::join!(first, second);
|
||||
|
||||
let (accepted_id, conflict) = match (first, second) {
|
||||
(Ok(()), Err(conflict)) => ("run-a", conflict),
|
||||
(Err(conflict), Ok(())) => ("run-b", conflict),
|
||||
outcome => panic!("exactly one concurrent start should be accepted: {outcome:?}"),
|
||||
};
|
||||
assert_eq!(resync_start_conflict_id(&conflict), Some(accepted_id));
|
||||
|
||||
let persisted = decode_resync_file(&shared.data.lock().expect("test data lock should not be poisoned"))
|
||||
.expect("accepted status should be persisted");
|
||||
assert_eq!(persisted.targets_map["arn:test"].resync_id, accepted_id);
|
||||
assert_eq!(persisted.targets_map["arn:test"].resync_status, ResyncStatusType::ResyncPending);
|
||||
assert_eq!(
|
||||
first_pool.resyncer.status_map.read().await["atomic-start"].targets_map["arn:test"].resync_id,
|
||||
accepted_id
|
||||
);
|
||||
assert_eq!(
|
||||
second_pool.resyncer.status_map.read().await["atomic-start"].targets_map["arn:test"].resync_id,
|
||||
accepted_id
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_resync_id_retry_is_idempotent_without_rewriting_status() {
|
||||
let shared = empty_resync_shared_state();
|
||||
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await;
|
||||
let _runtime_guard = hold_resync_runtime_lock(&shared, "same-id", "arn:test").await;
|
||||
let opts = test_resync_opts("same-id", "arn:test", "run-a");
|
||||
|
||||
pool.clone()
|
||||
.start_bucket_resync(opts.clone())
|
||||
.await
|
||||
.expect("first start should be accepted");
|
||||
let first_status = pool
|
||||
.resyncer
|
||||
.status_map
|
||||
.read()
|
||||
.await
|
||||
.get("same-id")
|
||||
.expect("accepted status should be published")
|
||||
.targets_map["arn:test"]
|
||||
.clone();
|
||||
|
||||
pool.clone()
|
||||
.start_bucket_resync(opts)
|
||||
.await
|
||||
.expect("same ID retry should be accepted idempotently");
|
||||
let retried_status = pool
|
||||
.resyncer
|
||||
.status_map
|
||||
.read()
|
||||
.await
|
||||
.get("same-id")
|
||||
.expect("retried status should remain published")
|
||||
.targets_map["arn:test"]
|
||||
.clone();
|
||||
|
||||
assert_eq!(shared.write_count.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(retried_status.resync_id, first_status.resync_id);
|
||||
assert_eq!(retried_status.start_time, first_status.start_time);
|
||||
assert_eq!(retried_status.resync_status, ResyncStatusType::ResyncPending);
|
||||
assert_eq!(pool.resyncer.cancel_tokens.read().await.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admitted_resync_waits_for_target_metadata_commit_before_activation() {
|
||||
let shared = empty_resync_shared_state();
|
||||
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await;
|
||||
let _runtime_guard = hold_resync_runtime_lock(&shared, "two-phase-start", "arn:test").await;
|
||||
let opts = test_resync_opts("two-phase-start", "arn:test", "run-a");
|
||||
|
||||
let new_run = pool
|
||||
.clone()
|
||||
.admit_bucket_resync(opts.clone())
|
||||
.await
|
||||
.expect("admission should persist the intent");
|
||||
assert!(new_run);
|
||||
assert!(pool.resyncer.cancel_tokens.read().await.is_empty());
|
||||
assert_eq!(shared.write_count.load(Ordering::SeqCst), 1);
|
||||
|
||||
pool.clone()
|
||||
.activate_bucket_resync(opts, false)
|
||||
.await
|
||||
.expect("activation should start the admitted run");
|
||||
assert_eq!(pool.resyncer.cancel_tokens.read().await.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_id_retry_after_restart_recreates_missing_runtime_task() {
|
||||
let shared = empty_resync_shared_state();
|
||||
let mut persisted = BucketReplicationResyncStatus::new();
|
||||
persisted.targets_map.insert(
|
||||
"arn:test".to_string(),
|
||||
TargetReplicationResyncStatus {
|
||||
bucket: "restart-retry".to_string(),
|
||||
resync_id: "run-a".to_string(),
|
||||
resync_status: ResyncStatusType::ResyncPending,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
*shared.data.lock().expect("test data lock should not be poisoned") =
|
||||
encode_resync_file(&persisted).expect("restart status should encode");
|
||||
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await;
|
||||
let _runtime_guard = hold_resync_runtime_lock(&shared, "restart-retry", "arn:test").await;
|
||||
|
||||
pool.clone()
|
||||
.start_bucket_resync(test_resync_opts("restart-retry", "arn:test", "run-a"))
|
||||
.await
|
||||
.expect("same ID retry should recover an accepted run");
|
||||
|
||||
assert_eq!(shared.write_count.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(pool.resyncer.cancel_tokens.read().await.len(), 1);
|
||||
assert_eq!(
|
||||
pool.resyncer.status_map.read().await["restart-retry"].targets_map["arn:test"].resync_id,
|
||||
"run-a"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_completed_resync_id_retry_does_not_restart_work() {
|
||||
let shared = empty_resync_shared_state();
|
||||
let mut persisted = BucketReplicationResyncStatus::new();
|
||||
persisted.targets_map.insert(
|
||||
"arn:test".to_string(),
|
||||
TargetReplicationResyncStatus {
|
||||
bucket: "completed-retry".to_string(),
|
||||
resync_id: "run-a".to_string(),
|
||||
resync_status: ResyncStatusType::ResyncCompleted,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
*shared.data.lock().expect("test data lock should not be poisoned") =
|
||||
encode_resync_file(&persisted).expect("completed status should encode");
|
||||
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await;
|
||||
|
||||
pool.clone()
|
||||
.start_bucket_resync(test_resync_opts("completed-retry", "arn:test", "run-a"))
|
||||
.await
|
||||
.expect("completed same ID retry should remain idempotent");
|
||||
|
||||
assert_eq!(shared.write_count.load(Ordering::SeqCst), 0);
|
||||
assert!(pool.resyncer.cancel_tokens.read().await.is_empty());
|
||||
assert_eq!(
|
||||
pool.resyncer.status_map.read().await["completed-retry"].targets_map["arn:test"].resync_status,
|
||||
ResyncStatusType::ResyncCompleted
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_failure_does_not_publish_or_persist_requested_id() {
|
||||
let shared = empty_resync_shared_state();
|
||||
shared.fail_next_write.store(true, Ordering::SeqCst);
|
||||
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await;
|
||||
|
||||
let error = pool
|
||||
.clone()
|
||||
.start_bucket_resync(test_resync_opts("failed-start", "arn:test", "run-a"))
|
||||
.await
|
||||
.expect_err("metadata save failure should reject the start");
|
||||
|
||||
assert!(matches!(error, EcstoreError::Unexpected));
|
||||
assert!(shared.data.lock().expect("test data lock should not be poisoned").is_empty());
|
||||
assert!(!pool.resyncer.status_map.read().await.contains_key("failed-start"));
|
||||
assert_eq!(shared.write_count.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn canceled_start_request_finishes_accepted_transaction() {
|
||||
let shared = empty_resync_shared_state();
|
||||
shared.block_next_write.store(true, Ordering::SeqCst);
|
||||
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await;
|
||||
let _runtime_guard = hold_resync_runtime_lock(&shared, "canceled-start", "arn:test").await;
|
||||
|
||||
let start_pool = pool.clone();
|
||||
let start = tokio::spawn(async move {
|
||||
start_pool
|
||||
.start_bucket_resync(test_resync_opts("canceled-start", "arn:test", "run-a"))
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(10), shared.write_started.notified())
|
||||
.await
|
||||
.expect("start transaction should reach the durable write");
|
||||
start.abort();
|
||||
assert!(start.await.expect_err("caller task should be canceled").is_cancelled());
|
||||
shared.allow_write.notify_one();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
if pool.resyncer.status_map.read().await.contains_key("canceled-start") {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("detached admission transaction should finish after caller cancellation");
|
||||
assert_eq!(shared.write_count.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(
|
||||
pool.resyncer.status_map.read().await["canceled-start"].targets_map["arn:test"].resync_id,
|
||||
"run-a"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replication_queue_admission_combines_target_results() {
|
||||
let mut admission = ReplicationQueueAdmission::Skipped;
|
||||
@@ -1958,10 +2379,16 @@ mod tests {
|
||||
async fn load_resync_leader_lock_allows_only_one_startup_recovery() {
|
||||
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_LOCK_ACQUIRE_TIMEOUT, Some("1"))], async {
|
||||
let shared = Arc::new(LoadResyncSharedState {
|
||||
data: load_resync_test_metadata(),
|
||||
data: StdMutex::new(load_resync_test_metadata()),
|
||||
lock_manager: Arc::new(rustfs_lock::GlobalLockManager::new()),
|
||||
first_read_started: Notify::new(),
|
||||
delay_first_read: AtomicBool::new(true),
|
||||
read_count: AtomicUsize::new(0),
|
||||
write_count: AtomicUsize::new(0),
|
||||
fail_next_write: AtomicBool::new(false),
|
||||
block_next_write: AtomicBool::new(false),
|
||||
write_started: Notify::new(),
|
||||
allow_write: Notify::new(),
|
||||
});
|
||||
let leader_pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await;
|
||||
let skipped_pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-b", shared.clone()))).await;
|
||||
@@ -2233,4 +2660,53 @@ mod tests {
|
||||
// None so replay falls back to the current time (backlog#867 backward compatibility).
|
||||
assert_eq!(entry.delete_marker_mtime, None, "missing deleteMarkerMtime key must default to None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_mrf_snapshot_reads_restart_backlog_and_valid_empty_state() {
|
||||
let entries = vec![MrfReplicateEntry {
|
||||
bucket: "restart-bucket".to_string(),
|
||||
object: "object".to_string(),
|
||||
version_id: None,
|
||||
retry_count: 1,
|
||||
size: 512,
|
||||
op: MrfOpKind::Object,
|
||||
delete_marker_version_id: None,
|
||||
delete_marker: false,
|
||||
delete_marker_mtime: None,
|
||||
}];
|
||||
let encoded = encode_mrf_file(&entries).expect("durable MRF backlog should encode");
|
||||
|
||||
let recovered = durable_mrf_backlog_from_read(Ok(encoded));
|
||||
assert!(recovered.available);
|
||||
assert_eq!(recovered.entries.len(), 1);
|
||||
assert_eq!(recovered.entries[0].bucket, "restart-bucket");
|
||||
assert_eq!(recovered.entries[0].size, 512);
|
||||
|
||||
let missing_file = durable_mrf_backlog_from_read(Err(EcstoreError::ConfigNotFound));
|
||||
assert!(missing_file.available);
|
||||
assert!(missing_file.entries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_mrf_snapshot_marks_corrupt_or_invalid_data_unavailable() {
|
||||
let corrupt = durable_mrf_backlog_from_read(Ok(vec![0, 1, 2]));
|
||||
assert!(!corrupt.available);
|
||||
assert!(corrupt.entries.is_empty());
|
||||
|
||||
let negative = encode_mrf_file(&[MrfReplicateEntry {
|
||||
bucket: "bucket".to_string(),
|
||||
object: "object".to_string(),
|
||||
version_id: None,
|
||||
retry_count: 0,
|
||||
size: -1,
|
||||
op: MrfOpKind::Object,
|
||||
delete_marker_version_id: None,
|
||||
delete_marker: false,
|
||||
delete_marker_mtime: None,
|
||||
}])
|
||||
.expect("invalid persisted entry should still encode for boundary testing");
|
||||
let invalid = durable_mrf_backlog_from_read(Ok(negative));
|
||||
assert!(!invalid.available);
|
||||
assert!(invalid.entries.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ use super::replication_filemeta_boundary::MrfReplicateEntry;
|
||||
|
||||
pub use rustfs_replication::{BucketReplicationResyncStatus, ResyncOpts, ResyncStatusType, TargetReplicationResyncStatus};
|
||||
pub(crate) use rustfs_replication::{
|
||||
is_version_id_mismatch, resync_state_accepts_update, should_auto_resume_resync, should_count_head_proxy_failure,
|
||||
is_version_id_mismatch, resync_state_accepts_update, sanitize_resync_error_detail, should_auto_resume_resync,
|
||||
should_count_head_proxy_failure,
|
||||
};
|
||||
|
||||
pub(crate) const RESYNC_META_FORMAT: u16 = rustfs_replication::resync::RESYNC_META_FORMAT;
|
||||
|
||||
@@ -37,7 +37,7 @@ use super::replication_queue_boundary::DeletedObjectReplicationInfo;
|
||||
use super::replication_resync_boundary::ResyncStatusType;
|
||||
use super::replication_resync_boundary::{
|
||||
BucketReplicationResyncStatus, ResyncOpts, TargetReplicationResyncStatus, encode_resync_file, is_version_id_mismatch,
|
||||
resync_state_accepts_update, should_count_head_proxy_failure,
|
||||
resync_state_accepts_update, sanitize_resync_error_detail, should_count_head_proxy_failure,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use super::replication_resync_boundary::{RESYNC_META_FORMAT, RESYNC_META_VERSION, WIRE_ZERO_TIME_UNIX, decode_resync_file};
|
||||
@@ -117,6 +117,20 @@ const RESYNC_TIME_INTERVAL: TokioDuration = TokioDuration::from_secs(60);
|
||||
|
||||
static WARNED_MONITOR_UNINIT: std::sync::Once = std::sync::Once::new();
|
||||
|
||||
fn resync_target_error_detail<E, R>(error: &SdkError<E, R>) -> Option<String>
|
||||
where
|
||||
E: ProvideErrorMetadata,
|
||||
{
|
||||
sanitize_resync_error_detail(error.code().unwrap_or(match error {
|
||||
SdkError::ConstructionFailure(_) => "failed to construct target request",
|
||||
SdkError::TimeoutError(_) => "target request timed out",
|
||||
SdkError::DispatchFailure(_) => "target dispatch failed",
|
||||
SdkError::ResponseError(_) => "invalid target response",
|
||||
SdkError::ServiceError(_) => "target service error",
|
||||
_ => "target request failed",
|
||||
}))
|
||||
}
|
||||
|
||||
async fn finish_resync_workers(
|
||||
worker_txs: Vec<tokio::sync::mpsc::Sender<ReplicateObjectInfo>>,
|
||||
results_tx: tokio::sync::mpsc::Sender<TargetReplicationResyncStatus>,
|
||||
@@ -241,11 +255,13 @@ fn resync_status_duration(
|
||||
Some(std::time::Duration::from_millis(millis))
|
||||
}
|
||||
|
||||
type ResyncCancelKey = (String, String, String);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ReplicationResyncer {
|
||||
pub status_map: Arc<RwLock<HashMap<String, BucketReplicationResyncStatus>>>,
|
||||
pub worker_size: usize,
|
||||
pub cancel_tokens: Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||
pub(crate) cancel_tokens: Arc<RwLock<HashMap<ResyncCancelKey, CancellationToken>>>,
|
||||
}
|
||||
|
||||
impl ReplicationResyncer {
|
||||
@@ -257,12 +273,19 @@ impl ReplicationResyncer {
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_key(opts: &ResyncOpts) -> String {
|
||||
format!("{}:{}", opts.bucket, opts.arn)
|
||||
fn cancel_key(opts: &ResyncOpts) -> ResyncCancelKey {
|
||||
(opts.bucket.clone(), opts.arn.clone(), opts.resync_id.clone())
|
||||
}
|
||||
|
||||
pub async fn register_cancel_token(&self, opts: &ResyncOpts, token: CancellationToken) {
|
||||
self.cancel_tokens.write().await.insert(Self::cancel_key(opts), token);
|
||||
pub async fn register_cancel_token(&self, opts: &ResyncOpts, token: CancellationToken) -> bool {
|
||||
let mut cancel_tokens = self.cancel_tokens.write().await;
|
||||
match cancel_tokens.entry(Self::cancel_key(opts)) {
|
||||
std::collections::hash_map::Entry::Vacant(entry) => {
|
||||
entry.insert(token);
|
||||
true
|
||||
}
|
||||
std::collections::hash_map::Entry::Occupied(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn clear_cancel_token(&self, opts: &ResyncOpts) {
|
||||
@@ -428,6 +451,9 @@ impl ReplicationResyncer {
|
||||
state.replicated_size += status.replicated_size;
|
||||
state.failed_count += status.failed_count;
|
||||
state.failed_size += status.failed_size;
|
||||
if state.error.is_none() && status.failed_count > 0 {
|
||||
state.error = status.error.as_deref().and_then(sanitize_resync_error_detail);
|
||||
}
|
||||
state.last_update = Some(now);
|
||||
bucket_status.last_update = Some(now);
|
||||
}
|
||||
@@ -885,6 +911,7 @@ impl ReplicationResyncer {
|
||||
"Processed resync object"
|
||||
);
|
||||
}
|
||||
st.error = err.as_ref().and_then(resync_target_error_detail);
|
||||
|
||||
if cancel_token.is_cancelled() {
|
||||
return;
|
||||
@@ -2071,14 +2098,20 @@ pub async fn replicate_object<S: ReplicationStorage>(roi: ReplicateObjectInfo, s
|
||||
|
||||
for arn in tgt_arns {
|
||||
let Some(tgt_client) = ReplicationTargetStore::remote_target_client(&bucket, &arn).await else {
|
||||
// Deliberately debug: this fires once per object per ARN, so a target that
|
||||
// stays unreachable would flood the log from the replication hot path. The
|
||||
// condition is reported once per pass by the site-replication reconciler and
|
||||
// once per rebuild by `update_all_targets`, which is where an operator can act
|
||||
// on it; the per-object event below still records each dropped object.
|
||||
debug!(
|
||||
event = EVENT_RESYNC_RUNTIME_SKIPPED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
||||
bucket = %bucket,
|
||||
object = %object,
|
||||
arn = %arn,
|
||||
reason = "target_client_missing",
|
||||
"Skipping replication object target"
|
||||
"Replication rule has no bucket target for its destination ARN; object not replicated"
|
||||
);
|
||||
send_local_event(EventArgs {
|
||||
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
||||
@@ -3227,6 +3260,7 @@ mod tests {
|
||||
assert_eq!(tgt.start_time, Some(start));
|
||||
assert_eq!(tgt.last_update, Some(last));
|
||||
assert_eq!(tgt.resync_before_date, Some(before));
|
||||
assert_eq!(tgt.error, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3681,6 +3715,59 @@ mod tests {
|
||||
assert!(resyncer.target_has_resync_failures(&opts).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_inc_stats_retains_first_sanitized_error_across_success() {
|
||||
let resyncer = ReplicationResyncer::new().await;
|
||||
let opts = ResyncOpts {
|
||||
bucket: "bucket".to_string(),
|
||||
arn: "arn:replication::dest".to_string(),
|
||||
resync_id: "run-new".to_string(),
|
||||
resync_before: None,
|
||||
};
|
||||
let failed = TargetReplicationResyncStatus {
|
||||
failed_count: 1,
|
||||
object: "failed-object".to_string(),
|
||||
error: Some("Authorization: Bearer status-secret".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let later_failure = TargetReplicationResyncStatus {
|
||||
failed_count: 1,
|
||||
object: "later-failed-object".to_string(),
|
||||
error: Some("AccessDenied".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let succeeded = TargetReplicationResyncStatus {
|
||||
replicated_count: 1,
|
||||
object: "successful-object".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
resyncer.inc_stats(&failed, opts.clone()).await;
|
||||
resyncer.inc_stats(&later_failure, opts.clone()).await;
|
||||
resyncer.inc_stats(&succeeded, opts.clone()).await;
|
||||
|
||||
let status_map = resyncer.status_map.read().await;
|
||||
let target = &status_map["bucket"].targets_map["arn:replication::dest"];
|
||||
assert_eq!(target.failed_count, 2);
|
||||
assert_eq!(target.replicated_count, 1);
|
||||
assert_eq!(target.object, "successful-object");
|
||||
assert_eq!(target.error.as_deref(), Some("[redacted sensitive resync error detail]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resync_target_error_detail_uses_safe_service_code_and_fallback() {
|
||||
let metadata = aws_smithy_types::error::ErrorMetadata::builder()
|
||||
.code("AccessDenied")
|
||||
.message("Authorization: Bearer status-secret")
|
||||
.build();
|
||||
let service_error = SdkError::service_error(HeadObjectError::generic(metadata), ());
|
||||
let timeout_error =
|
||||
SdkError::<HeadObjectError, ()>::timeout_error(std::io::Error::new(std::io::ErrorKind::TimedOut, "status-secret"));
|
||||
|
||||
assert_eq!(resync_target_error_detail(&service_error).as_deref(), Some("AccessDenied"));
|
||||
assert_eq!(resync_target_error_detail(&timeout_error).as_deref(), Some("target request timed out"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resync_state_accepts_update_only_for_matching_run() {
|
||||
let current = TargetReplicationResyncStatus {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user