mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-05 19:55:37 +00:00
Compare commits
191 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44f3f0e73e | |||
| 152f110583 | |||
| f1de19fc14 | |||
| 7c4e514ec9 | |||
| 13a2ae212e | |||
| 94a6da6e83 | |||
| a199312e45 | |||
| 3420006762 | |||
| c95b4f0820 | |||
| d9080ae77f | |||
| 95c926dc79 | |||
| d902ac4f34 | |||
| 80d0c51389 | |||
| daeaf40e2c | |||
| 7b17d46ca9 | |||
| c006f84461 | |||
| 9a1a15ca58 | |||
| 4bbc1d5640 | |||
| 8ddbf05924 | |||
| 0e27f57c40 | |||
| e760d44e65 | |||
| 9e27ede8f0 | |||
| 5c40570edd | |||
| a6c5d80069 | |||
| 5f3620a00d | |||
| 5d22fe0934 | |||
| f1f8057154 | |||
| 7b7e5f38f7 | |||
| 52703e0da6 | |||
| 6ab14981ba | |||
| 12b5e69a7b | |||
| c00e5491b5 | |||
| a6ea4ac8f3 | |||
| 2739330971 | |||
| dda841d8de | |||
| 09ec797a66 | |||
| a169dd01a6 | |||
| 64739778c4 | |||
| 030a87013c | |||
| 94a61d789a | |||
| b90443f697 | |||
| ba7785d61d | |||
| 31031f2a46 | |||
| 61914ac4ad | |||
| 0e92eac2c2 | |||
| 3699b6b88d | |||
| 4154a3b7ca | |||
| a42046b79c | |||
| f4cc919401 | |||
| 3f9ec4275b | |||
| 9cf276ed24 | |||
| 8f7ccab4ed | |||
| 78935c34c9 | |||
| fda93adafe | |||
| 38e93f553d | |||
| 41d7db6d57 | |||
| 57b0a136d5 | |||
| 2ebf8bc138 | |||
| 0e56ef4f1c | |||
| efcd960b65 | |||
| 9f245e3fd4 | |||
| ba15588ce8 | |||
| 62a767a52d | |||
| 7c2361757e | |||
| a96dd7d289 | |||
| 2ada8a5cfb | |||
| f69087a457 | |||
| 6ef7bac071 | |||
| 9ea5cd59ca | |||
| 286626c1bd | |||
| ff47714363 | |||
| 5a424219d2 | |||
| 2ef9519bea | |||
| 0c85dbd8e9 | |||
| a45cf6b521 | |||
| b059569744 | |||
| 7ecb44ea60 | |||
| 08d16d067d | |||
| 766d88cc89 | |||
| 3c1e172600 | |||
| 4cd38feae7 | |||
| a4377b6351 | |||
| 51041e917e | |||
| 32346f159a | |||
| 45f706b274 | |||
| a5386093d8 | |||
| b1b3655bf8 | |||
| b49c9a07d1 | |||
| 5e05bd5485 | |||
| 2b61990ec6 | |||
| b610d5a55d | |||
| 8f0d4a20d1 | |||
| 65a7cc9cd4 | |||
| aa56d4b847 | |||
| 1590d9107b | |||
| c7f201e6cb | |||
| a1ed41e109 | |||
| 4bc9dc482a | |||
| c0c208d89a | |||
| 7cac528de3 | |||
| eaf0d4da81 | |||
| bab9049cb0 | |||
| df5ae13fd0 | |||
| 37a50f1e5d | |||
| 4f4d268155 | |||
| 9db1d6f06b | |||
| be22175035 | |||
| 45c03ca37f | |||
| 0ad6bf72cb | |||
| 5243bee746 | |||
| 42d47b5f1e | |||
| 9118a6e344 | |||
| f469869620 | |||
| 5834949c56 | |||
| f51b06f0ae | |||
| a65b306fb0 | |||
| b93e7b2355 | |||
| 8f196f2f20 | |||
| 59fd318192 | |||
| 75a71fe6d7 | |||
| ba629bdae0 | |||
| 5cce18fef2 | |||
| 54e2dce495 | |||
| 6f0a371f01 | |||
| 1bcb396752 | |||
| c0c5fc22f9 | |||
| 6886f7cac4 | |||
| 032c5f9ac6 | |||
| 4e749d7046 | |||
| 51449f0975 | |||
| 90f64c60af | |||
| 0f987714a1 | |||
| 2bd83a5276 | |||
| 6a99edab50 | |||
| be1562e089 | |||
| 5a0367969a | |||
| 0c155b1656 | |||
| 9db29c8a6f | |||
| b4a78fc907 | |||
| 02317dd36f | |||
| b4e6c1b081 | |||
| bcfed065c1 | |||
| 9a89434644 | |||
| b15928220f | |||
| cf37bc418c | |||
| 76a861f815 | |||
| 5ce884f605 | |||
| 0c4c1caef8 | |||
| d9cd04e94e | |||
| 017ffb92f7 | |||
| 40f1356831 | |||
| 619f0fd9e8 | |||
| 9d68d63802 | |||
| f41014ede7 | |||
| 68dd5bfb9f | |||
| 77d7404d77 | |||
| 97d2d344b2 | |||
| 3da319624f | |||
| bb9491f782 | |||
| 9f12f344c9 | |||
| 82df9ec4fa | |||
| 40e6decc93 | |||
| 116119d93a | |||
| 6f39765498 | |||
| d63ca1f5f5 | |||
| 4d43f1ea8a | |||
| 5c6e1abe7e | |||
| 3b0a28dd9b | |||
| 7be0d56be8 | |||
| dcdaa37b84 | |||
| ea07c781c4 | |||
| 7a7871ca67 | |||
| c80d970d58 | |||
| 1f40c3ecd0 | |||
| 102fb767ce | |||
| afb2e4f728 | |||
| 3d75e7b51f | |||
| e4dfc6f45b | |||
| c1c6a1e23f | |||
| a8be4f2695 | |||
| a1ebe9a3b3 | |||
| e2193cc42c | |||
| 6f14a79089 | |||
| de9e8faa27 | |||
| 2251f22c1a | |||
| 70a6a9e8dc | |||
| fe453b7f5b | |||
| ade7e320da | |||
| f6d69ce643 | |||
| 762431c0c7 | |||
| 1fb5d5a19d |
@@ -1,12 +1,12 @@
|
|||||||
---
|
---
|
||||||
name: rustfs-release-publish
|
name: rustfs-release-publish
|
||||||
description: "Run the end-to-end RustFS console gate, version bump, preview validation, and final-tag publication pipeline. Use only when the user explicitly asks to release or publish a RustFS version (发版/发布)."
|
description: "Run the end-to-end RustFS console gate, version bump, preview validation, human confirmation, and final-tag publication pipeline. Use only when the user explicitly asks to release or publish a RustFS version (发版/发布)."
|
||||||
---
|
---
|
||||||
# RustFS Release Publish (preview-validated pipeline)
|
# RustFS Release Publish (preview-validated pipeline)
|
||||||
|
|
||||||
This skill orchestrates a full release. It wraps `rustfs-release-version-bump` (which only edits version files and opens the PR) with a mandatory preview-tag validation loop before the final tag is published.
|
This skill orchestrates a full release. It wraps `rustfs-release-version-bump` (which only edits version files and opens the PR) with a mandatory preview-tag validation loop before the final tag is published.
|
||||||
|
|
||||||
Core design: **version files never carry a `-preview.N` suffix**. The preview suffix exists only in tag names. A preview tag creates a visible GitHub Release marked Prerelease and uploads versioned assets, but it never becomes GitHub Latest and never updates `*-latest`, `latest.json`, R2, Docker, or Helm channels. This works because the binary self-reports the git tag it was built from (`build::TAG` via shadow_rs, see `rustfs/src/config/cli.rs` `SHORT_VERSION`), and `build.yml` derives artifact names and preview classification from the tag name — Cargo.toml's version is only a no-tag fallback. Therefore the preview tag and the final tag can (and MUST) point at the exact same commit: what you validated is byte-for-byte the source that ships.
|
Core design: **version files never carry a `-preview.N` suffix**. The preview suffix exists only in tag names. A preview tag creates a visible GitHub Release marked Prerelease and uploads versioned assets, but it never becomes GitHub Latest and never updates `*-latest`, `latest.json`, R2, Docker, or Helm channels. That Release is temporary: `build.yml` deletes it automatically once the final tag's Release is published, so the Releases page ends up carrying deliverables only while the `-preview.N` tags stay behind as the traceability record. This works because the binary self-reports the git tag it was built from (`build::TAG` via shadow_rs, see `rustfs/src/config/cli.rs` `SHORT_VERSION`), and `build.yml` derives artifact names and preview classification from the tag name — Cargo.toml's version is only a no-tag fallback. Therefore the preview tag and the final tag can (and MUST) point at the exact same commit: what you validated is byte-for-byte the source that ships.
|
||||||
|
|
||||||
Pipeline shape:
|
Pipeline shape:
|
||||||
|
|
||||||
@@ -17,7 +17,9 @@ check console main against its latest Release
|
|||||||
-> tag <preview-tag> at that commit -> CI green
|
-> tag <preview-tag> at that commit -> CI green
|
||||||
-> verify preview Release assets -> run binary locally + console checks
|
-> verify preview Release assets -> run binary locally + console checks
|
||||||
-> validate with latest rc client
|
-> validate with latest rc client
|
||||||
|
-> report preview acceptance results -> STOP for explicit human confirmation
|
||||||
-> tag <target> at the SAME commit (zero delta) -> re-verify CI/release
|
-> tag <target> at the SAME commit (zero delta) -> re-verify CI/release
|
||||||
|
-> CI deletes the <target>-preview.N Releases (tags kept)
|
||||||
```
|
```
|
||||||
|
|
||||||
On validation failure: fix lands on main via normal PR (version files are already at `<target>`, no new bump PR), then tag `<preview-tag N+1>` at the new main commit and restart from Phase 2.
|
On validation failure: fix lands on main via normal PR (version files are already at `<target>`, no new bump PR), then tag `<preview-tag N+1>` at the new main commit and restart from Phase 2.
|
||||||
@@ -50,16 +52,20 @@ Rules:
|
|||||||
- Use `<target>-preview.N` for every target, e.g. `1.0.0-beta.10-preview.3` or `1.1.0-preview.1`.
|
- Use `<target>-preview.N` for every target, e.g. `1.0.0-beta.10-preview.3` or `1.1.0-preview.1`.
|
||||||
- The canonical suffix is exactly `-preview.<digits>`. `build.yml` recognizes it before alpha/beta/rc classification and routes it to the preview-only path; any other tag containing `-preview` fails closed instead of being treated as a release.
|
- The canonical suffix is exactly `-preview.<digits>`. `build.yml` recognizes it before alpha/beta/rc classification and routes it to the preview-only path; any other tag containing `-preview` fails closed instead of being treated as a release.
|
||||||
- A preview Release MUST be published with `isPrerelease=true` and `isLatest=false`. Any `*-latest` preview asset or preview-triggered `latest.json`, R2, Docker, or Helm publication is a pipeline failure.
|
- A preview Release MUST be published with `isPrerelease=true` and `isLatest=false`. Any `*-latest` preview asset or preview-triggered `latest.json`, R2, Docker, or Helm publication is a pipeline failure.
|
||||||
|
- Preview Releases are cleaned up by the `cleanup-preview-releases` job after `publish-release` succeeds for the deliverable tag. It deletes every Release whose tag is exactly `<target>-preview.<digits>` and never passes `--cleanup-tag`, so the tags survive.
|
||||||
|
|
||||||
## Hard rules
|
## Hard rules
|
||||||
|
|
||||||
- Version files (Cargo.toml, Cargo.lock, README, flake.nix, Chart.yaml, rustfs.spec) are bumped ONCE, directly to `<target>`. Never write a `-preview.N` suffix into any version file. If `rustfs-release-version-bump` is ever asked for a `-preview` version, that is a pipeline bug — stop.
|
- Version files (Cargo.toml, Cargo.lock, README, flake.nix, Chart.yaml, rustfs.spec) are bumped ONCE, directly to `<target>`. Never write a `-preview.N` suffix into any version file. If `rustfs-release-version-bump` is ever asked for a `-preview` version, that is a pipeline bug — stop.
|
||||||
- Preview Release assets are versioned and intentionally visible on the Releases page. Do not label them Latest or use them to update any latest distribution channel.
|
- Preview Release assets are versioned and intentionally visible on the Releases page for the duration of validation. Do not label them Latest or use them to update any latest distribution channel.
|
||||||
|
- Never delete a preview Release by hand before Phase 6 finishes — Phase 4 downloads its assets and the final Release notes are generated while it still exists. Cleanup is CI's job; only step in manually (`gh release delete "<preview-tag>" --yes`, never `--cleanup-tag`) if `cleanup-preview-releases` failed.
|
||||||
- Tags have no `v` prefix. Always annotated: `git tag -a <tag> -m "Release <tag>"`.
|
- Tags have no `v` prefix. Always annotated: `git tag -a <tag> -m "Release <tag>"`.
|
||||||
- The final tag MUST point at exactly `PREVIEW_HASH` — the commit the validated preview tag points at. Never tag current `main` HEAD (commits merged after validation are unvalidated), and never create an extra version-bump commit between preview and final.
|
- The final tag MUST point at exactly `PREVIEW_HASH` — the commit the validated preview tag points at. Never tag current `main` HEAD (commits merged after validation are unvalidated), and never create an extra version-bump commit between preview and final.
|
||||||
- When a previous deliverable exists, GitHub Release notes for the preview and final tags MUST use it as their shared comparison baseline: the most recently published non-preview Release before the target. Internal `-preview.N` Releases are explicitly excluded from that selection, even when they point at the same commit as the final tag. If no previous deliverable exists, omit `previous_tag_name` and record that GitHub's default baseline fallback was used.
|
- When a previous deliverable exists, GitHub Release notes for the preview and final tags MUST use it as their shared comparison baseline: the most recently published non-preview Release before the target. Internal `-preview.N` Releases are explicitly excluded from that selection, even when they point at the same commit as the final tag — cleanup runs after the notes are generated, so the preview Release is still present and would otherwise be picked as the baseline. If no previous deliverable exists, omit `previous_tag_name` and record that GitHub's default baseline fallback was used.
|
||||||
- Generated Release notes carry a workflow-management marker so retries can repair them. Before manually curating a generated body, remove that marker; unmarked non-placeholder notes are preserved by later workflow runs.
|
- Generated Release notes carry a workflow-management marker so retries can repair them. Before manually curating a generated body, remove that marker; unmarked non-placeholder notes are preserved by later workflow runs.
|
||||||
- Phases run in order; a failure in any phase blocks everything after it. After the fix lands on main, restart from Phase 2 with the next preview iteration against the new `origin/main` hash — do not resume mid-pipeline against a stale hash.
|
- Phases run in order; a failure in any phase blocks everything after it. After the fix lands on main, restart from Phase 2 with the next preview iteration against the new `origin/main` hash — do not resume mid-pipeline against a stale hash.
|
||||||
|
- Completing preview acceptance does not authorize the final tag. After Phases 3–5 pass, report the acceptance evidence and stop until the user explicitly confirms continuation. The original release request, an earlier confirmation, silence, or an automated follow-up does not satisfy this gate.
|
||||||
|
- Confirmation is scoped to the reported `<target>`, `<preview-tag>`, and `PREVIEW_HASH`. A failed or repeated acceptance cycle, including any new preview iteration, invalidates prior confirmation and requires a new one.
|
||||||
- If the release is abandoned after Phase 1 merged, main's version files claim a version that was never tagged. Either revert the bump PR or leave it to be overwritten by the next release — but tell the user explicitly and record the decision.
|
- If the release is abandoned after Phase 1 merged, main's version files claim a version that was never tagged. Either revert the bump PR or leave it to be overwritten by the next release — but tell the user explicitly and record the decision.
|
||||||
- User-facing status updates in Chinese; commits, PR titles/bodies, and tag messages in English. No hard-wrapping in commit messages, PR bodies, or documentation prose — one logical line per sentence/paragraph, let soft wrap handle display.
|
- User-facing status updates in Chinese; commits, PR titles/bodies, and tag messages in English. No hard-wrapping in commit messages, PR bodies, or documentation prose — one logical line per sentence/paragraph, let soft wrap handle display.
|
||||||
|
|
||||||
@@ -207,6 +213,12 @@ rc alias remove preview
|
|||||||
|
|
||||||
- Any FAIL blocks the release. Afterwards stop the server and delete the scratch data directory.
|
- Any FAIL blocks the release. Afterwards stop the server and delete the scratch data directory.
|
||||||
|
|
||||||
|
### Manual confirmation gate
|
||||||
|
|
||||||
|
After every Phase 3–5 check passes, report the target, preview tag, `PREVIEW_HASH`, preview Release URL, console result, and rc matrix, then explicitly ask the user whether to publish the final tag. End the turn without creating or pushing `<target>`.
|
||||||
|
|
||||||
|
Continue to Phase 6 only after a new user reply explicitly confirms the reported target, preview tag, and commit. A clear affirmative reply to that exact report, such as `确认继续`, is sufficient; if the reply is ambiguous or any reported value changed, ask again.
|
||||||
|
|
||||||
## Phase 6 — Publish the final tag on the validated commit
|
## Phase 6 — Publish the final tag on the validated commit
|
||||||
|
|
||||||
No second version bump, no release branch. The final tag goes on the exact commit the preview validated:
|
No second version bump, no release branch. The final tag goes on the exact commit the preview validated:
|
||||||
@@ -221,6 +233,7 @@ git push origin "<target>"
|
|||||||
- CI rebuilds from the same source; the only changed input is the tag name, so the binary now self-reports `<target>`.
|
- CI rebuilds from the same source; the only changed input is the tag name, so the binary now self-reports `<target>`.
|
||||||
- Verify the final tag's complete publication path: all matrix and release jobs green; `gh release view "<target>"` shows the full versioned and `-latest` asset set plus checksums, SBOM, and provenance; Docker and Helm workflows succeed; `latest.json` points to `<target>`. A stable target must have `isPrerelease=false` and `isLatest=true`. An alpha/beta/rc target must have `isPrerelease=true`; GitHub does not permit prereleases to be Latest, but the project `latest.json` still advances to the final non-preview target.
|
- Verify the final tag's complete publication path: all matrix and release jobs green; `gh release view "<target>"` shows the full versioned and `-latest` asset set plus checksums, SBOM, and provenance; Docker and Helm workflows succeed; `latest.json` points to `<target>`. A stable target must have `isPrerelease=false` and `isLatest=true`. An alpha/beta/rc target must have `isPrerelease=true`; GitHub does not permit prereleases to be Latest, but the project `latest.json` still advances to the final non-preview target.
|
||||||
- Verify the final Release body contains `## What's Changed` and a Full Changelog link. When `PREVIOUS_DELIVERABLE` exists, the link MUST be `https://github.com/rustfs/rustfs/compare/<PREVIOUS_DELIVERABLE>...<target>` and the baseline MUST equal the preview Release baseline; for example, both `1.0.0-beta.12-preview.1` and `1.0.0-beta.12` compare from `1.0.0-beta.11`.
|
- Verify the final Release body contains `## What's Changed` and a Full Changelog link. When `PREVIOUS_DELIVERABLE` exists, the link MUST be `https://github.com/rustfs/rustfs/compare/<PREVIOUS_DELIVERABLE>...<target>` and the baseline MUST equal the preview Release baseline; for example, both `1.0.0-beta.12-preview.1` and `1.0.0-beta.12` compare from `1.0.0-beta.11`.
|
||||||
|
- Verify the preview cleanup: `cleanup-preview-releases` must succeed, `gh release view "<preview-tag>"` must then report `release not found` for every preview iteration of this target, and `git rev-parse "<preview-tag>^{commit}"` must still resolve to `PREVIEW_HASH` (the tag is kept). If the job failed, delete the leftover Releases manually with `gh release delete "<preview-tag>" --yes` and report it.
|
||||||
- Optionally spot-check `./rustfs --version` from a final-tag artifact — it must report `<target>`.
|
- Optionally spot-check `./rustfs --version` from a final-tag artifact — it must report `<target>`.
|
||||||
|
|
||||||
## Output contract
|
## Output contract
|
||||||
@@ -229,5 +242,6 @@ Always report:
|
|||||||
|
|
||||||
- Console gate result: previous/latest Console tags, whether merged changes required a release, `CONSOLE_HASH`, and Console run/Release URLs when a release was published.
|
- Console gate result: previous/latest Console tags, whether merged changes required a release, `CONSOLE_HASH`, and Console run/Release URLs when a release was published.
|
||||||
- Target version, preview tag(s) used, `PREVIEW_HASH` (which both tags point at).
|
- Target version, preview tag(s) used, `PREVIEW_HASH` (which both tags point at).
|
||||||
- Per-phase result (PASS/FAIL/BLOCKED) with key evidence: preview and final Release URLs, preview `isPrerelease`/`isLatest` state, final latest-channel state, console check results, and the rc command matrix.
|
- Manual confirmation gate status (`WAITING_FOR_CONFIRMATION` or `CONFIRMED`) and its exact target, preview tag, and `PREVIEW_HASH`.
|
||||||
|
- Per-phase result (PASS/FAIL/BLOCKED) with key evidence: preview and final Release URLs, preview `isPrerelease`/`isLatest` state, final latest-channel state, console check results, the rc command matrix, and the preview-Release cleanup result (deleted Releases plus surviving tags).
|
||||||
- Any deviation from this pipeline and why the user approved it.
|
- Any deviation from this pipeline and why the user approved it.
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
sha256-darwin=88ee9684ece0e27294f2b3f0c9c8fe62890feff76aa47279d42dab0af3196fe2
|
sha256-darwin=d6aa36cfaae2c4d8590482c7e47138c5965b335b34a75f50d11ffc3366e9021e
|
||||||
sha256-linux=d13337936af6778b1d2b2b255ae7fd350fdec94034be46daf738bd577653f799
|
sha256-linux=c8315465f50c194faee36141cdbb1e15e59271e524d948564a69e2d5eb408f2a
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
sha256=ec27cde6ce6400723c4b372bfbd2ac61709c744294e4810af765e8a808d8e31d
|
sha256=294350518743cac8d7c41880a2835216e4b697908d7b0b1bc92b62816d94c59d
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ logging-guardrails-check: ## Check logging guardrails for redaction and noise re
|
|||||||
@echo "🪵 Checking logging guardrails..."
|
@echo "🪵 Checking logging guardrails..."
|
||||||
./scripts/check_logging_guardrails.sh
|
./scripts/check_logging_guardrails.sh
|
||||||
|
|
||||||
|
.PHONY: error-other-ratchet-check
|
||||||
|
error-other-ratchet-check: ## Check the ecstore ::other(format!) quorum-bucketing ratchet stays shrink-only
|
||||||
|
@echo "🪣 Checking error other(format!) ratchet..."
|
||||||
|
./scripts/check_error_other_format_ratchet.sh
|
||||||
|
|
||||||
.PHONY: tokio-io-uring-check
|
.PHONY: tokio-io-uring-check
|
||||||
tokio-io-uring-check: ## Check tokio io-uring runtime feature stays removed
|
tokio-io-uring-check: ## Check tokio io-uring runtime feature stays removed
|
||||||
@echo "🚫 Checking tokio io-uring feature guard..."
|
@echo "🚫 Checking tokio io-uring feature guard..."
|
||||||
@@ -75,6 +80,11 @@ embedded-secrets-check: ## Check no private key material or credential literal i
|
|||||||
@echo "🔑 Checking embedded secret material guard..."
|
@echo "🔑 Checking embedded secret material guard..."
|
||||||
./scripts/check_embedded_secrets.sh
|
./scripts/check_embedded_secrets.sh
|
||||||
|
|
||||||
|
.PHONY: offline-enrollment-e2e-check
|
||||||
|
offline-enrollment-e2e-check: core-deps ## Build and exercise the dedicated offline enrollment E2E root
|
||||||
|
@echo "🔐 Checking the offline enrollment E2E root boundary..."
|
||||||
|
./scripts/check_offline_enrollment_e2e.sh
|
||||||
|
|
||||||
.PHONY: test-wiring-check
|
.PHONY: test-wiring-check
|
||||||
test-wiring-check: ## Check tests stay registered and selected by their intended runners
|
test-wiring-check: ## Check tests stay registered and selected by their intended runners
|
||||||
@echo "🧪 Checking test wiring..."
|
@echo "🧪 Checking test wiring..."
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ planning-docs-check: ## Check that no planning-type documents are committed
|
|||||||
./scripts/check_no_planning_docs.sh
|
./scripts/check_no_planning_docs.sh
|
||||||
|
|
||||||
.PHONY: pre-commit
|
.PHONY: pre-commit
|
||||||
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
|
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
|
||||||
@echo "✅ All pre-commit checks passed!"
|
@echo "✅ All pre-commit checks passed!"
|
||||||
|
|
||||||
.PHONY: pre-pr
|
.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 s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check log-analyzer-rules-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 error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check log-analyzer-rules-check offline-enrollment-e2e-check clippy-check test ## Run full pre-PR checks with clippy and tests
|
||||||
@echo "✅ All pre-PR checks passed!"
|
@echo "✅ All pre-PR checks passed!"
|
||||||
|
|
||||||
.PHONY: dev-check
|
.PHONY: dev-check
|
||||||
dev-check: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast local development checks
|
dev-check: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast local development checks
|
||||||
@echo "✅ Fast development checks passed!"
|
@echo "✅ Fast development checks passed!"
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ script-tests: ## Run shell script tests
|
|||||||
./scripts/test_exact_1mib_handoff_abba.sh
|
./scripts/test_exact_1mib_handoff_abba.sh
|
||||||
./scripts/test_pinned_paired_abba_bench.sh
|
./scripts/test_pinned_paired_abba_bench.sh
|
||||||
./scripts/test_manual_transition_runbooks.sh
|
./scripts/test_manual_transition_runbooks.sh
|
||||||
|
./scripts/test_fuzz_runner.sh
|
||||||
./scripts/check_embedded_secrets.sh --self-test
|
./scripts/check_embedded_secrets.sh --self-test
|
||||||
python3 ./scripts/check_test_wiring.py --self-test
|
python3 ./scripts/check_test_wiring.py --self-test
|
||||||
python3 ./scripts/check_security_coverage.py --self-test
|
python3 ./scripts/check_security_coverage.py --self-test
|
||||||
|
|||||||
+41
-6
@@ -1,5 +1,7 @@
|
|||||||
# nextest configuration for RustFS.
|
# nextest configuration for RustFS.
|
||||||
#
|
#
|
||||||
|
experimental = ["setup-scripts"]
|
||||||
|
|
||||||
# Serialize the ecstore tests that share the process-wide disk registry or
|
# Serialize the ecstore tests that share the process-wide disk registry or
|
||||||
# exercise a multi-disk commit handoff across nextest process boundaries.
|
# exercise a multi-disk commit handoff across nextest process boundaries.
|
||||||
#
|
#
|
||||||
@@ -44,7 +46,27 @@ e2e-reliability = { max-threads = 1 }
|
|||||||
e2e-inline-boundaries = { max-threads = 1 }
|
e2e-inline-boundaries = { max-threads = 1 }
|
||||||
e2e-cluster-nightly = { max-threads = 1 }
|
e2e-cluster-nightly = { max-threads = 1 }
|
||||||
|
|
||||||
|
# These exact regression scenarios build deep async storage futures that exceed
|
||||||
|
# libtest's 2 MiB spawned-thread stack on Linux. Give only their test processes
|
||||||
|
# the same 32 MiB stack already used by the crate's dedicated large-stack tests.
|
||||||
|
[scripts.setup.ecstore-large-stack]
|
||||||
|
command = ['sh', '-c', 'echo RUST_MIN_STACK=33554432 >> "$NEXTEST_ENV"']
|
||||||
|
|
||||||
|
# The serial ILM selection builds the same deep storage futures in both the
|
||||||
|
# lifecycle transition module and scanner integration binary. Different tests
|
||||||
|
# in each have overflowed first across otherwise unrelated CI runs.
|
||||||
|
[scripts.setup.lifecycle-large-stack]
|
||||||
|
command = ['sh', '-c', 'echo RUST_MIN_STACK=33554432 >> "$NEXTEST_ENV"']
|
||||||
|
|
||||||
# --- default profile (local): serialize the flaky groups, never retry --------
|
# --- default profile (local): serialize the flaky groups, never retry --------
|
||||||
|
[[profile.default.scripts]]
|
||||||
|
filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_ops::tests::manual_transition_worker_result_recovery_marks_unknown_for_corrupt_marker|services::rebalance::entry::tests::real_rebalance_run_fence_loss_blocks_multipart_publication|store::init::tests::(decommission_entry_(allows_free_version_consumed_before_source_lock|rejects_subquorum_free_version_conflict_and_retains_source|skips_cleanup_only_marker_when_free_version_is_present)|prepared_tier_delete_recovery_(checks_later_pool_then_commits_after_source_removal|finds_directory_source_on_encoded_set|retains_journal_on_source_metadata_error)|tier_mutation_peer_handler_applies_prepare_commit_and_abort_idempotently|transition_response_loss_persists_unknown_outcome_for_provider_recovery|transition_transaction_recovery_(drops_record_after_confirmed_local_commit|keeps_cleanup_pending_local_commit)))$/)'
|
||||||
|
setup = 'ecstore-large-stack'
|
||||||
|
|
||||||
|
[[profile.default.scripts]]
|
||||||
|
filter = 'binary(lifecycle_integration_test) | (package(rustfs) & test(/^app::lifecycle_transition_api_test::/))'
|
||||||
|
setup = 'lifecycle-large-stack'
|
||||||
|
|
||||||
[[profile.default.overrides]]
|
[[profile.default.overrides]]
|
||||||
filter = 'package(rustfs-ecstore) & (test(concurrent_resend_same_part_commits_one_generation) | test(concurrent_config_writes_from_separate_nodes_do_not_lose_writes) | test(/^store::bucket::tests::bucket_delete_(mark_delete|purge_removes|default_s3_delete)/))'
|
filter = 'package(rustfs-ecstore) & (test(concurrent_resend_same_part_commits_one_generation) | test(concurrent_config_writes_from_separate_nodes_do_not_lose_writes) | test(/^store::bucket::tests::bucket_delete_(mark_delete|purge_removes|default_s3_delete)/))'
|
||||||
test-group = 'ecstore-serial-flaky'
|
test-group = 'ecstore-serial-flaky'
|
||||||
@@ -116,7 +138,7 @@ test-group = 'e2e-inline-boundaries'
|
|||||||
# does not cross nextest process boundaries, so keep every Vault-backed test in
|
# does not cross nextest process boundaries, so keep every Vault-backed test in
|
||||||
# one group.
|
# one group.
|
||||||
[[profile.default.overrides]]
|
[[profile.default.overrides]]
|
||||||
filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))'
|
filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::kms_rekey_sweep_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))'
|
||||||
test-group = 'e2e-vault'
|
test-group = 'e2e-vault'
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -134,6 +156,14 @@ fail-fast = false
|
|||||||
# marker is the observable signal the flake policy is built around.
|
# marker is the observable signal the flake policy is built around.
|
||||||
path = "junit.xml"
|
path = "junit.xml"
|
||||||
|
|
||||||
|
[[profile.ci.scripts]]
|
||||||
|
filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_ops::tests::manual_transition_worker_result_recovery_marks_unknown_for_corrupt_marker|services::rebalance::entry::tests::real_rebalance_run_fence_loss_blocks_multipart_publication|store::init::tests::(decommission_entry_(allows_free_version_consumed_before_source_lock|rejects_subquorum_free_version_conflict_and_retains_source|skips_cleanup_only_marker_when_free_version_is_present)|prepared_tier_delete_recovery_(checks_later_pool_then_commits_after_source_removal|finds_directory_source_on_encoded_set|retains_journal_on_source_metadata_error)|tier_mutation_peer_handler_applies_prepare_commit_and_abort_idempotently|transition_response_loss_persists_unknown_outcome_for_provider_recovery|transition_transaction_recovery_(drops_record_after_confirmed_local_commit|keeps_cleanup_pending_local_commit)))$/)'
|
||||||
|
setup = 'ecstore-large-stack'
|
||||||
|
|
||||||
|
[[profile.ci.scripts]]
|
||||||
|
filter = 'binary(lifecycle_integration_test) | (package(rustfs) & test(/^app::lifecycle_transition_api_test::/))'
|
||||||
|
setup = 'lifecycle-large-stack'
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# QUARANTINE — flaky tests granted retries = 2 under the ci profile ONLY.
|
# QUARANTINE — flaky tests granted retries = 2 under the ci profile ONLY.
|
||||||
#
|
#
|
||||||
@@ -166,6 +196,15 @@ test-group = 'ecstore-serial-flaky'
|
|||||||
filter = 'package(rustfs-ecstore) & test(walk_dir_does_not_charge_consumer_backpressure_to_the_stall_budget)'
|
filter = 'package(rustfs-ecstore) & test(walk_dir_does_not_charge_consumer_backpressure_to_the_stall_budget)'
|
||||||
retries = 2
|
retries = 2
|
||||||
|
|
||||||
|
# Serialize the relocated-pool GET resume regression under the ci profile too
|
||||||
|
# (see the matching default-profile override near the top). No longer a
|
||||||
|
# quarantine: the fixture race (rustfs#6701/rustfs#6703) was fixed by #6707,
|
||||||
|
# which made the staging tolerate quorum-tolerated disk gaps; only the 8-disk
|
||||||
|
# cross-disk-IO serialization remains.
|
||||||
|
[[profile.ci.overrides]]
|
||||||
|
filter = 'package(rustfs) & test(execute_get_object_resumes_from_relocated_pool_without_splicing_body)'
|
||||||
|
test-group = 'ecstore-serial-flaky'
|
||||||
|
|
||||||
# Serialize the 4-disk reliability / degraded-read e2e tests under the ci
|
# Serialize the 4-disk reliability / degraded-read e2e tests under the ci
|
||||||
# profile too (see the e2e-reliability test-group note near the top). Not a
|
# profile too (see the e2e-reliability test-group note near the top). Not a
|
||||||
# quarantine: no retries, just single-threaded so several 4-disk servers never
|
# quarantine: no retries, just single-threaded so several 4-disk servers never
|
||||||
@@ -181,10 +220,6 @@ test-group = 'e2e-reliability'
|
|||||||
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::multipart::tests::crash_consistency::/)'
|
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::multipart::tests::crash_consistency::/)'
|
||||||
test-group = 'ecstore-serial-flaky'
|
test-group = 'ecstore-serial-flaky'
|
||||||
|
|
||||||
[[profile.ci.overrides]]
|
|
||||||
filter = 'package(rustfs) & test(execute_get_object_resumes_from_relocated_pool_without_splicing_body)'
|
|
||||||
test-group = 'ecstore-serial-flaky'
|
|
||||||
|
|
||||||
# Match the default-profile embedded test isolation without quarantining or
|
# Match the default-profile embedded test isolation without quarantining or
|
||||||
# retrying failures in CI.
|
# retrying failures in CI.
|
||||||
[[profile.ci.overrides]]
|
[[profile.ci.overrides]]
|
||||||
@@ -454,5 +489,5 @@ filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
|
|||||||
test-group = 'e2e-inline-boundaries'
|
test-group = 'e2e-inline-boundaries'
|
||||||
|
|
||||||
[[profile.e2e-full.overrides]]
|
[[profile.e2e-full.overrides]]
|
||||||
filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))'
|
filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::kms_rekey_sweep_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))'
|
||||||
test-group = 'e2e-vault'
|
test-group = 'e2e-vault'
|
||||||
|
|||||||
@@ -9,4 +9,4 @@
|
|||||||
# if the selected count drops below this number, so a rename or removal that
|
# if the selected count drops below this number, so a rename or removal that
|
||||||
# thins the security smoke gate must update this file in the same PR.
|
# thins the security smoke gate must update this file in the same PR.
|
||||||
# Adding tests does not require a bump, but bumping keeps the guard tight.
|
# Adding tests does not require a bump, but bumping keeps the guard tight.
|
||||||
16
|
18
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Copyright 2024 RustFS Team
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
global:
|
||||||
|
scrape_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.
|
||||||
|
evaluation_interval: 15s
|
||||||
|
external_labels:
|
||||||
|
cluster: 'rustfs-dev' # Label to identify the cluster
|
||||||
|
replica: '1' # Replica identifier
|
||||||
|
|
||||||
|
rule_files:
|
||||||
|
- /etc/prometheus/rules/*.yml
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: 'otel-collector'
|
||||||
|
static_configs:
|
||||||
|
- targets: [ 'otel-collector:8888' ] # Scrape metrics from Collector
|
||||||
|
scrape_interval: 10s
|
||||||
|
|
||||||
|
- job_name: 'rustfs-app-metrics'
|
||||||
|
static_configs:
|
||||||
|
- targets: [ 'otel-collector:8889' ] # Application indicators
|
||||||
|
scrape_interval: 15s
|
||||||
|
metric_relabel_configs:
|
||||||
|
- source_labels: [ __name__ ]
|
||||||
|
regex: 'go_.*'
|
||||||
|
action: drop # Drop Go runtime metrics if not needed
|
||||||
|
|
||||||
|
- job_name: 'tempo'
|
||||||
|
static_configs:
|
||||||
|
- targets: [ 'tempo:3200' ] # Scrape metrics from Tempo
|
||||||
|
|
||||||
|
- job_name: 'jaeger'
|
||||||
|
static_configs:
|
||||||
|
- targets: [ 'jaeger:14269' ] # Jaeger admin port (14269 is standard for admin/metrics)
|
||||||
|
|
||||||
|
- job_name: 'loki'
|
||||||
|
static_configs:
|
||||||
|
- targets: [ 'loki:3100' ]
|
||||||
|
|
||||||
|
- job_name: 'prometheus'
|
||||||
|
static_configs:
|
||||||
|
- targets: [ 'localhost:9090' ]
|
||||||
|
|
||||||
|
- job_name: 'vulture'
|
||||||
|
static_configs:
|
||||||
|
- targets:
|
||||||
|
- 'vulture:8080'
|
||||||
|
|
||||||
|
otlp:
|
||||||
|
promote_resource_attributes:
|
||||||
|
- service.instance.id
|
||||||
|
- service.name
|
||||||
|
- service.namespace
|
||||||
|
- cloud.availability_zone
|
||||||
|
- cloud.region
|
||||||
|
- container.name
|
||||||
|
- deployment.environment.name
|
||||||
|
- k8s.cluster.name
|
||||||
|
- k8s.container.name
|
||||||
|
- k8s.cronjob.name
|
||||||
|
- k8s.daemonset.name
|
||||||
|
- k8s.deployment.name
|
||||||
|
- k8s.job.name
|
||||||
|
- k8s.namespace.name
|
||||||
|
- k8s.pod.name
|
||||||
|
- k8s.replicaset.name
|
||||||
|
- k8s.statefulset.name
|
||||||
|
translation_strategy: NoUTF8EscapingWithSuffixes
|
||||||
|
|
||||||
|
storage:
|
||||||
|
tsdb:
|
||||||
|
out_of_order_time_window: 30m
|
||||||
@@ -5,3 +5,4 @@ self-hosted-runner:
|
|||||||
- sm-standard-2
|
- sm-standard-2
|
||||||
- sm-standard-4
|
- sm-standard-4
|
||||||
- dind-sm-standard-2
|
- dind-sm-standard-2
|
||||||
|
- smoke-testing
|
||||||
|
|||||||
@@ -7,8 +7,11 @@
|
|||||||
{ "workflow": ".github/workflows/e2e-s3tests.yml", "max_age_hours": 192 },
|
{ "workflow": ".github/workflows/e2e-s3tests.yml", "max_age_hours": 192 },
|
||||||
{ "workflow": ".github/workflows/fuzz.yml", "max_age_hours": 36 },
|
{ "workflow": ".github/workflows/fuzz.yml", "max_age_hours": 36 },
|
||||||
{ "workflow": ".github/workflows/mint.yml", "max_age_hours": 192 },
|
{ "workflow": ".github/workflows/mint.yml", "max_age_hours": 192 },
|
||||||
{ "workflow": ".github/workflows/minio-interop.yml", "max_age_hours": 36 },
|
|
||||||
{ "workflow": ".github/workflows/nightly-gnu.yml", "max_age_hours": 36 },
|
{ "workflow": ".github/workflows/nightly-gnu.yml", "max_age_hours": 36 },
|
||||||
{ "workflow": ".github/workflows/performance-ab.yml", "max_age_hours": 36 },
|
{ "workflow": ".github/workflows/performance-ab.yml", "max_age_hours": 36 },
|
||||||
{ "workflow": ".github/workflows/runner-hygiene.yml", "max_age_hours": 792 }
|
{
|
||||||
|
"workflow": ".github/workflows/runner-hygiene.yml",
|
||||||
|
"max_age_hours": 792,
|
||||||
|
"never_ran_grace_until": "2026-09-02T06:37:00Z"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1033,6 +1033,55 @@ jobs:
|
|||||||
echo "🎉 Released $TAG successfully!"
|
echo "🎉 Released $TAG successfully!"
|
||||||
echo "📄 Release URL: ${{ needs.create-release.outputs.release_url }}"
|
echo "📄 Release URL: ${{ needs.create-release.outputs.release_url }}"
|
||||||
|
|
||||||
|
# Remove the internal preview releases once the deliverable release is live.
|
||||||
|
# Only the Releases are deleted; the -preview.N tags stay so the validated
|
||||||
|
# commit remains traceable.
|
||||||
|
cleanup-preview-releases:
|
||||||
|
name: Cleanup Preview Releases
|
||||||
|
needs: [ build-check, publish-release ]
|
||||||
|
if: startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Delete preview releases for this target
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TAG="${{ needs.build-check.outputs.version }}"
|
||||||
|
RELEASES_JSON="${RUNNER_TEMP}/releases.json"
|
||||||
|
|
||||||
|
# Fetch before filtering: a failed listing must abort here instead of
|
||||||
|
# looking like "nothing to clean up".
|
||||||
|
gh api --paginate "repos/${GITHUB_REPOSITORY}/releases?per_page=100" > "$RELEASES_JSON"
|
||||||
|
|
||||||
|
# Match only <target>-preview.<digits>. String operations, not a
|
||||||
|
# regex over the tag, so dots in the version cannot widen the match.
|
||||||
|
DELETED=0
|
||||||
|
while IFS= read -r preview_tag; do
|
||||||
|
[[ -n "$preview_tag" ]] || continue
|
||||||
|
echo "🧹 Deleting preview release $preview_tag (tag kept)"
|
||||||
|
gh release delete "$preview_tag" --yes
|
||||||
|
DELETED=$((DELETED + 1))
|
||||||
|
done < <(
|
||||||
|
jq -r --arg tag "$TAG" '
|
||||||
|
.[]
|
||||||
|
| select(.tag_name | startswith($tag + "-preview."))
|
||||||
|
| select(.tag_name | ltrimstr($tag + "-preview.") | test("^[0-9]+$"))
|
||||||
|
| .tag_name
|
||||||
|
' "$RELEASES_JSON"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "$DELETED" -eq 0 ]]; then
|
||||||
|
echo "ℹ️ No preview releases to clean up for $TAG"
|
||||||
|
else
|
||||||
|
echo "✅ Removed $DELETED preview release(s) for $TAG"
|
||||||
|
fi
|
||||||
|
|
||||||
alert-on-failure:
|
alert-on-failure:
|
||||||
name: Alert on scheduled failure
|
name: Alert on scheduled failure
|
||||||
needs: [build-check, prepare-platform-matrix, build-rustfs, build-summary]
|
needs: [build-check, prepare-platform-matrix, build-rustfs, build-summary]
|
||||||
|
|||||||
@@ -212,6 +212,9 @@ jobs:
|
|||||||
install-build-packaging-tools: 'false'
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
- name: Build ci-feat-rio superset
|
- name: Build ci-feat-rio superset
|
||||||
|
env:
|
||||||
|
# --all-targets links the same test binaries as the reader lane.
|
||||||
|
CARGO_BUILD_JOBS: "2"
|
||||||
run: |
|
run: |
|
||||||
cargo build -p rustfs -p rustfs-ecstore --all-targets --features rio-v2
|
cargo build -p rustfs -p rustfs-ecstore --all-targets --features rio-v2
|
||||||
cargo build -p rustfs --bins --features rio-v2,e2e-test-hooks
|
cargo build -p rustfs --bins --features rio-v2,e2e-test-hooks
|
||||||
@@ -240,6 +243,9 @@ jobs:
|
|||||||
install-build-packaging-tools: 'false'
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
- name: Build ci-feat-proto superset
|
- name: Build ci-feat-proto superset
|
||||||
|
env:
|
||||||
|
# Avoid an unbounded burst of concurrent test-binary links (#5394).
|
||||||
|
CARGO_BUILD_JOBS: "2"
|
||||||
run: |
|
run: |
|
||||||
cargo build -p rustfs -p rustfs-protocols --all-targets --features swift
|
cargo build -p rustfs -p rustfs-protocols --all-targets --features swift
|
||||||
cargo build -p rustfs -p rustfs-protocols --all-targets --features sftp
|
cargo build -p rustfs -p rustfs-protocols --all-targets --features sftp
|
||||||
|
|||||||
+149
-46
@@ -142,6 +142,9 @@ jobs:
|
|||||||
- name: Check logging guardrails
|
- name: Check logging guardrails
|
||||||
run: ./scripts/check_logging_guardrails.sh
|
run: ./scripts/check_logging_guardrails.sh
|
||||||
|
|
||||||
|
- name: Check error other(format!) ratchet
|
||||||
|
run: ./scripts/check_error_other_format_ratchet.sh
|
||||||
|
|
||||||
- name: Check tokio io-uring feature guard
|
- name: Check tokio io-uring feature guard
|
||||||
run: ./scripts/check_no_tokio_io_uring.sh
|
run: ./scripts/check_no_tokio_io_uring.sh
|
||||||
|
|
||||||
@@ -181,22 +184,14 @@ jobs:
|
|||||||
needs: [ quick-checks ]
|
needs: [ quick-checks ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
# Both lines are required. Job-level `permissions` replaces the workflow
|
|
||||||
# block rather than merging with it, so declaring only `actions: write`
|
|
||||||
# would drop `contents: read` and break this job's checkout and the
|
|
||||||
# repo-token the setup action hands to setup-protoc.
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
actions: write
|
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
# This job's token can cancel runs and delete Actions caches. Checkout
|
# Checkout otherwise writes the token into .git/config, where a PR's
|
||||||
# otherwise writes it into .git/config, where a PR's own build.rs or
|
# own build.rs or proc-macro could read it back out.
|
||||||
# proc-macro could read it back out.
|
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
@@ -212,6 +207,9 @@ jobs:
|
|||||||
cache-save-if: 'false'
|
cache-save-if: 'false'
|
||||||
install-build-packaging-tools: 'false'
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
|
- name: Protect Connect test home
|
||||||
|
run: chmod go-w "$(realpath "$HOME")"
|
||||||
|
|
||||||
- name: Prepare test evidence
|
- name: Prepare test evidence
|
||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts/test-and-lint
|
mkdir -p artifacts/test-and-lint
|
||||||
@@ -310,6 +308,9 @@ jobs:
|
|||||||
} > artifacts/test-and-lint/doctest-diagnostics.txt
|
} > artifacts/test-and-lint/doctest-diagnostics.txt
|
||||||
exit "${status}"
|
exit "${status}"
|
||||||
|
|
||||||
|
- name: Check offline enrollment E2E root boundary
|
||||||
|
run: ./scripts/check_offline_enrollment_e2e.sh
|
||||||
|
|
||||||
- name: Upload test reports and diagnostics
|
- name: Upload test reports and diagnostics
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
@@ -344,41 +345,36 @@ jobs:
|
|||||||
- name: Run rebalance/decommission migration proofs
|
- name: Run rebalance/decommission migration proofs
|
||||||
run: ./scripts/check_migration_gate_count.sh
|
run: ./scripts/check_migration_gate_count.sh
|
||||||
|
|
||||||
# Early stop. Once this job has failed the PR cannot merge, so the sibling
|
# Record the reason before this job completes as FAILURE. A separate
|
||||||
# lanes are burning runners on a result nobody can act on: on run
|
# dependent job cancels sibling lanes only after GitHub has preserved this
|
||||||
# 30674613104 three lanes had already failed while Test and Lint and the
|
# required check's failure verdict.
|
||||||
# rio-v2 variant kept going past 70 minutes.
|
|
||||||
#
|
|
||||||
# Only this job may cancel. The lanes that are NOT required checks
|
|
||||||
# (protocols, ILM, e2e, s3-tests) must never hold that power: a flake in
|
|
||||||
# one of them would turn the required "Test and Lint" into `cancelled`,
|
|
||||||
# which blocks the merge. Today a maintainer can merge with sftp red, and
|
|
||||||
# that has to stay true.
|
|
||||||
#
|
|
||||||
# These steps run last so the `if: always()` artifact upload above still
|
|
||||||
# captures logs and diagnostics before the run goes away.
|
|
||||||
- name: Annotate early-stop reason
|
- name: Annotate early-stop reason
|
||||||
if: failure() && github.event_name == 'pull_request'
|
|
||||||
run: |
|
|
||||||
{
|
|
||||||
echo "## CI early-stop"
|
|
||||||
echo "Job \`${GITHUB_JOB}\` (Test and Lint) failed; cancelling run ${GITHUB_RUN_ID} to free runners."
|
|
||||||
echo "Sibling jobs showing **cancelled** were stopped by this job, not by their own failure."
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
|
|
||||||
# curl rather than `gh`: every existing `gh` call in this repo runs on
|
|
||||||
# ubuntu-latest, and the sm-standard-* images are custom and trimmed (they
|
|
||||||
# ship no C toolchain, see the e2e job below), so `gh` is not known to
|
|
||||||
# exist here.
|
|
||||||
#
|
|
||||||
# Fork PRs are excluded explicitly instead of relying on the error path:
|
|
||||||
# their GITHUB_TOKEN is forced read-only and job-level permissions cannot
|
|
||||||
# raise it, so the call would always 403. Skipping keeps their logs clean.
|
|
||||||
- name: Cancel run on failure (same-repo PR only)
|
|
||||||
if: >-
|
if: >-
|
||||||
failure() && github.event_name == 'pull_request'
|
failure() && github.event_name == 'pull_request'
|
||||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||||
continue-on-error: true
|
run: |
|
||||||
|
{
|
||||||
|
echo "## CI early-stop"
|
||||||
|
echo "Job \`${GITHUB_JOB}\` (Test and Lint) failed; a follow-up job will cancel sibling lanes to free runners."
|
||||||
|
echo "Sibling jobs showing **cancelled** were stopped by the early-stop follow-up, not by their own failure."
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
# Preserve the required Test and Lint FAILURE verdict before stopping sibling
|
||||||
|
# lanes. Cancelling from inside test-and-lint changed its own conclusion to
|
||||||
|
# CANCELLED and hid the actionable failure in the PR checks UI.
|
||||||
|
cancel-after-test-and-lint-failure:
|
||||||
|
name: Cancel siblings after Test and Lint failure
|
||||||
|
if: >-
|
||||||
|
failure() && needs.test-and-lint.result == 'failure'
|
||||||
|
&& github.event_name == 'pull_request'
|
||||||
|
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
needs: [ test-and-lint ]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
steps:
|
||||||
|
- name: Cancel remaining jobs
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
@@ -386,7 +382,7 @@ jobs:
|
|||||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||||
-H "Accept: application/vnd.github+json" \
|
-H "Accept: application/vnd.github+json" \
|
||||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel" || true
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel"
|
||||||
|
|
||||||
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
|
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
|
||||||
# drive the object layer through process-global singletons (the GLOBAL_ENV
|
# drive the object layer through process-global singletons (the GLOBAL_ENV
|
||||||
@@ -433,10 +429,38 @@ jobs:
|
|||||||
# - test_noncurrent_{expiry,transition}_still_works_after_immediate_compensation_transition:
|
# - test_noncurrent_{expiry,transition}_still_works_after_immediate_compensation_transition:
|
||||||
# noncurrent transition/expiry after an immediate compensation transition.
|
# noncurrent transition/expiry after an immediate compensation transition.
|
||||||
- name: Run ignored ILM integration tests serially
|
- name: Run ignored ILM integration tests serially
|
||||||
|
env:
|
||||||
|
# Match the measured Test and Lint link budget. The default exposed
|
||||||
|
# all 14 pod CPUs and a cold cache spent the full 80m compiling
|
||||||
|
# without starting one ILM test (main run 32982910990).
|
||||||
|
CARGO_BUILD_JOBS: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '3' || '2' }}
|
||||||
run: |
|
run: |
|
||||||
cargo nextest run -j1 --run-ignored ignored-only \
|
mkdir -p artifacts/ilm-integration
|
||||||
|
set +e
|
||||||
|
NEXTEST_HIDE_PROGRESS_BAR=1 timeout --verbose --signal=TERM --kill-after=30s 80m \
|
||||||
|
cargo nextest run -j1 --run-ignored ignored-only \
|
||||||
-p rustfs-scanner -p rustfs \
|
-p rustfs-scanner -p rustfs \
|
||||||
-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))'
|
-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))' \
|
||||||
|
--status-level all --final-status-level all \
|
||||||
|
2>&1 | tee artifacts/ilm-integration/nextest.log
|
||||||
|
status=${PIPESTATUS[0]}
|
||||||
|
{
|
||||||
|
echo "exit_status=${status}"
|
||||||
|
echo "finished_at=$(date --utc --iso-8601=seconds)"
|
||||||
|
echo
|
||||||
|
echo "Remaining test-related processes:"
|
||||||
|
pgrep -af 'cargo|nextest|target/.*/deps/' || true
|
||||||
|
} > artifacts/ilm-integration/diagnostics.txt
|
||||||
|
exit "${status}"
|
||||||
|
|
||||||
|
- name: Upload ILM test diagnostics
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: ilm-integration-${{ github.run_number }}-${{ github.run_attempt }}
|
||||||
|
path: |
|
||||||
|
artifacts/ilm-integration
|
||||||
|
target/nextest/ci/junit.xml
|
||||||
|
|
||||||
test-and-lint-rio-v2:
|
test-and-lint-rio-v2:
|
||||||
name: Test and Lint (rio-v2)
|
name: Test and Lint (rio-v2)
|
||||||
@@ -460,14 +484,83 @@ jobs:
|
|||||||
cache-save-if: 'false'
|
cache-save-if: 'false'
|
||||||
install-build-packaging-tools: 'false'
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
|
- name: Protect Connect test home
|
||||||
|
run: chmod go-w "$(realpath "$HOME")"
|
||||||
|
|
||||||
- name: Run rio-v2 clippy lints
|
- name: Run rio-v2 clippy lints
|
||||||
run: cargo clippy -p rustfs -p rustfs-ecstore --all-targets --features rio-v2 -- -D warnings
|
run: cargo clippy -p rustfs -p rustfs-ecstore --all-targets --features rio-v2 -- -D warnings
|
||||||
|
|
||||||
- name: Run rio-v2 feature tests
|
- name: Run rio-v2 feature tests
|
||||||
|
env:
|
||||||
|
# Match the main nextest lane's #5394 link-I/O guard. A cold feature
|
||||||
|
# cache otherwise fans out enough rust-lld processes to exhaust this
|
||||||
|
# job's 90-minute budget before any test starts.
|
||||||
|
CARGO_BUILD_JOBS: "2"
|
||||||
run: |
|
run: |
|
||||||
cargo nextest run -p rustfs -p rustfs-ecstore --features rio-v2
|
# --profile ci so the quarantine list (and its junit flaky markers)
|
||||||
|
# covers this leg too; the default profile is the local no-retry
|
||||||
|
# profile and silently ignored quarantined flakes here (rustfs#6703).
|
||||||
|
cargo nextest run --profile ci -p rustfs -p rustfs-ecstore --features rio-v2
|
||||||
cargo test -p rustfs --doc --features rio-v2
|
cargo test -p rustfs --doc --features rio-v2
|
||||||
|
|
||||||
|
connect-short-credential-boundary:
|
||||||
|
name: Connect Short Credential Boundary
|
||||||
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
|
needs: [ quick-checks ]
|
||||||
|
runs-on: sm-standard-4
|
||||||
|
timeout-minutes: 60
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Rust environment
|
||||||
|
uses: ./.github/actions/setup
|
||||||
|
with:
|
||||||
|
rust-version: stable
|
||||||
|
cache-shared-key: ci-dev
|
||||||
|
cache-save-if: 'false'
|
||||||
|
install-build-packaging-tools: 'false'
|
||||||
|
install-test-tools: 'false'
|
||||||
|
|
||||||
|
- name: Run short credential behavior tests
|
||||||
|
env:
|
||||||
|
CARGO_BUILD_JOBS: "2"
|
||||||
|
run: |
|
||||||
|
cargo test -p rustfs --test connect_registration \
|
||||||
|
--features connect-e2e-short-credentials \
|
||||||
|
registration_enforces_build_profile_credential_lifetime -- --exact
|
||||||
|
cargo test -p rustfs --test connect_registration \
|
||||||
|
--features connect-e2e-short-credentials \
|
||||||
|
rotation_waits_for_threshold_and_stops_on_revocation -- --exact
|
||||||
|
|
||||||
|
- name: Reject short credentials in release builds
|
||||||
|
env:
|
||||||
|
CARGO_BUILD_JOBS: "2"
|
||||||
|
run: |
|
||||||
|
log="$(mktemp)"
|
||||||
|
set +e
|
||||||
|
CARGO_TERM_COLOR=never cargo check -p rustfs --release \
|
||||||
|
--features connect-e2e-short-credentials >"$log" 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
cat "$log"
|
||||||
|
expected='error: connect-e2e-short-credentials is restricted to debug builds'
|
||||||
|
summary="error: could not compile \`rustfs\` (lib) due to 1 previous error"
|
||||||
|
expected_count="$(grep -Fxc "$expected" "$log" || true)"
|
||||||
|
summary_count="$(grep -Fc "$summary" "$log" || true)"
|
||||||
|
error_count="$(grep -Ec '^error(:|\[)' "$log" || true)"
|
||||||
|
if [ "$status" -ne 101 ] || [ "$expected_count" -ne 1 ] \
|
||||||
|
|| [ "$summary_count" -ne 1 ] || [ "$error_count" -ne 2 ]; then
|
||||||
|
echo "release feature gate did not fail solely at the expected compile_error" >&2
|
||||||
|
rm -f "$log"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -f "$log"
|
||||||
|
|
||||||
test-and-lint-protocols:
|
test-and-lint-protocols:
|
||||||
name: "Test and Lint (${{ matrix.features.name }})"
|
name: "Test and Lint (${{ matrix.features.name }})"
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
@@ -504,13 +597,23 @@ jobs:
|
|||||||
cache-save-if: 'false'
|
cache-save-if: 'false'
|
||||||
install-build-packaging-tools: 'false'
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
|
- name: Protect Connect test home
|
||||||
|
run: chmod go-w "$(realpath "$HOME")"
|
||||||
|
|
||||||
- name: Run clippy with ${{ matrix.features.name }}
|
- name: Run clippy with ${{ matrix.features.name }}
|
||||||
run: |
|
run: |
|
||||||
cargo clippy -p rustfs -p rustfs-protocols --all-targets ${{ matrix.features.flags }} -- -D warnings
|
cargo clippy -p rustfs -p rustfs-protocols --all-targets ${{ matrix.features.flags }} -- -D warnings
|
||||||
|
|
||||||
- name: Run tests with ${{ matrix.features.name }}
|
- name: Run tests with ${{ matrix.features.name }}
|
||||||
|
env:
|
||||||
|
# Keep feature-test linking under the same bounded concurrency as the
|
||||||
|
# main nextest lane; Clippy is metadata-only and needs no such limit.
|
||||||
|
CARGO_BUILD_JOBS: "2"
|
||||||
run: |
|
run: |
|
||||||
cargo nextest run -p rustfs -p rustfs-protocols ${{ matrix.features.flags }}
|
# --profile ci so the quarantine list (and its junit flaky markers)
|
||||||
|
# covers this leg too; the default profile is the local no-retry
|
||||||
|
# profile and silently ignored quarantined flakes here (rustfs#6703).
|
||||||
|
cargo nextest run --profile ci -p rustfs -p rustfs-protocols ${{ matrix.features.flags }}
|
||||||
|
|
||||||
build-rustfs-debug-binary:
|
build-rustfs-debug-binary:
|
||||||
name: Build RustFS Debug Binary
|
name: Build RustFS Debug Binary
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Copyright 2024 RustFS Team
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
name: Upgrade Compatibility
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/e2e-upgrade.yml"
|
||||||
|
- "crates/e2e_test/src/common.rs"
|
||||||
|
- "crates/e2e_test/src/lib.rs"
|
||||||
|
- "crates/e2e_test/src/upgrade_compatibility_test.rs"
|
||||||
|
- "crates/ecstore/**"
|
||||||
|
- "crates/filemeta/**"
|
||||||
|
- "crates/kms/**"
|
||||||
|
- "crates/storage-api/**"
|
||||||
|
- "rustfs/**"
|
||||||
|
- "Cargo.lock"
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "[0-9]*.[0-9]*.[0-9]*"
|
||||||
|
schedule:
|
||||||
|
- cron: "17 3 * * 1"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUST_BACKTRACE: 1
|
||||||
|
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
|
||||||
|
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
|
||||||
|
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
direct-upgrade:
|
||||||
|
name: Direct upgrade from rc.2
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Rust environment
|
||||||
|
uses: ./.github/actions/setup
|
||||||
|
with:
|
||||||
|
cache-shared-key: e2e-direct-upgrade
|
||||||
|
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
install-build-packaging-tools: "false"
|
||||||
|
|
||||||
|
- name: Download pinned previous release
|
||||||
|
env:
|
||||||
|
SOURCE_DIR: ${{ runner.temp }}/rustfs-upgrade-source
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p "$SOURCE_DIR"
|
||||||
|
archive="$SOURCE_DIR/$UPGRADE_SOURCE_ASSET"
|
||||||
|
curl --fail --location --retry 3 --output "$archive" \
|
||||||
|
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${UPGRADE_SOURCE_VERSION}/${UPGRADE_SOURCE_ASSET}"
|
||||||
|
echo "$UPGRADE_SOURCE_SHA256 $archive" | sha256sum --check --strict
|
||||||
|
unzip -q "$archive" -d "$SOURCE_DIR"
|
||||||
|
chmod +x "$SOURCE_DIR/rustfs"
|
||||||
|
test -x "$SOURCE_DIR/rustfs"
|
||||||
|
echo "RUSTFS_UPGRADE_SOURCE_BINARY=$SOURCE_DIR/rustfs" >> "$GITHUB_ENV"
|
||||||
|
echo "RUSTFS_E2E_LOG_DIR=$RUNNER_TEMP/rustfs-upgrade-logs" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Build current RustFS binary
|
||||||
|
run: |
|
||||||
|
cargo build --locked -p rustfs --bin rustfs
|
||||||
|
: > target/debug/rustfs.features
|
||||||
|
|
||||||
|
- name: Run direct-upgrade compatibility test
|
||||||
|
run: |
|
||||||
|
cargo test --locked -p e2e_test \
|
||||||
|
upgrade_compatibility_test::direct_upgrade_from_rc2_preserves_object_contracts \
|
||||||
|
-- --ignored --exact --nocapture
|
||||||
|
|
||||||
|
- name: Upload server logs
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: direct-upgrade-server-logs-${{ github.run_number }}
|
||||||
|
path: ${{ runner.temp }}/rustfs-upgrade-logs
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 14
|
||||||
@@ -173,7 +173,7 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
fuzz/artifacts/**
|
fuzz/artifacts/**
|
||||||
fuzz/corpus/${{ matrix.target }}/**
|
fuzz/corpus/${{ matrix.target }}/**
|
||||||
if-no-files-found: ignore
|
if-no-files-found: error
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
@@ -227,7 +227,7 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
fuzz/artifacts/**
|
fuzz/artifacts/**
|
||||||
fuzz/corpus/${{ matrix.target }}/**
|
fuzz/corpus/${{ matrix.target }}/**
|
||||||
if-no-files-found: ignore
|
if-no-files-found: error
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -37,6 +37,11 @@
|
|||||||
# banner. Re-enabling is a UI action; anyone doing so should first check that the
|
# banner. Re-enabling is a UI action; anyone doing so should first check that the
|
||||||
# workflow still matches the current CI layout. See rustfs/backlog#1603.
|
# workflow still matches the current CI layout. See rustfs/backlog#1603.
|
||||||
#
|
#
|
||||||
|
# While disabled, this workflow is deliberately absent from
|
||||||
|
# .github/scheduled-validations.json — a disabled workflow can never satisfy the
|
||||||
|
# freshness check. Whoever re-enables it must re-add the entry in the same
|
||||||
|
# change so the freshness gate covers it again.
|
||||||
|
#
|
||||||
name: minio-interop
|
name: minio-interop
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build x86_64 GNU
|
name: Build x86_64 GNU
|
||||||
runs-on: sm-standard-2
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 150
|
timeout-minutes: 150
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
@@ -55,6 +55,155 @@ jobs:
|
|||||||
- name: Build RustFS
|
- name: Build RustFS
|
||||||
run: cargo build --release --locked --target x86_64-unknown-linux-gnu -p rustfs --bins
|
run: cargo build --release --locked --target x86_64-unknown-linux-gnu -p rustfs --bins
|
||||||
|
|
||||||
|
- name: Build DEB package
|
||||||
|
id: deb
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Nightly snapshot name: rustfs-nightly-<YYYY-MM-DD> (Asia/Shanghai,
|
||||||
|
# matching the schedule timezone so the file date always matches the
|
||||||
|
# cron's intended day).
|
||||||
|
DEB_DATE="$(TZ=Asia/Shanghai date +%Y-%m-%d)"
|
||||||
|
DEB_FILE="rustfs-nightly-${DEB_DATE}.deb"
|
||||||
|
PKG_DIR="rustfs-nightly-${DEB_DATE}"
|
||||||
|
|
||||||
|
command -v fakeroot >/dev/null 2>&1 || sudo apt-get install -y -qq fakeroot
|
||||||
|
|
||||||
|
BIN="target/x86_64-unknown-linux-gnu/release/rustfs"
|
||||||
|
test -x "${BIN}" || { echo "rustfs binary not found: ${BIN}"; exit 1; }
|
||||||
|
|
||||||
|
mkdir -p "${PKG_DIR}/DEBIAN"
|
||||||
|
mkdir -p "${PKG_DIR}/usr/bin"
|
||||||
|
mkdir -p "${PKG_DIR}/etc/default"
|
||||||
|
mkdir -p "${PKG_DIR}/lib/systemd/system"
|
||||||
|
mkdir -p "${PKG_DIR}/usr/share/doc/rustfs"
|
||||||
|
|
||||||
|
cp "${BIN}" "${PKG_DIR}/usr/bin/rustfs"
|
||||||
|
chmod 755 "${PKG_DIR}/usr/bin/rustfs"
|
||||||
|
cp deploy/build/rustfs.service "${PKG_DIR}/lib/systemd/system/"
|
||||||
|
|
||||||
|
cat > "${PKG_DIR}/etc/default/rustfs" << 'ENVEOF'
|
||||||
|
# RustFS Environment Configuration
|
||||||
|
# See https://rustfs.com/docs/ for more information
|
||||||
|
# RUSTFS_VOLUMES=""
|
||||||
|
# RUSTFS_ROOT_USER=""
|
||||||
|
# RUSTFS_ROOT_PASSWORD=""
|
||||||
|
ENVEOF
|
||||||
|
|
||||||
|
# dpkg versions must start with a digit and cannot contain hyphens;
|
||||||
|
# a date-based snapshot version keeps the nightly installable
|
||||||
|
# alongside release packages.
|
||||||
|
DEB_VERSION="${DEB_DATE//-/.}~nightly"
|
||||||
|
|
||||||
|
cat > "${PKG_DIR}/DEBIAN/control" << EOF
|
||||||
|
Package: rustfs
|
||||||
|
Version: ${DEB_VERSION}
|
||||||
|
Section: utils
|
||||||
|
Priority: optional
|
||||||
|
Architecture: amd64
|
||||||
|
Depends: libc6 (>= 2.31)
|
||||||
|
Maintainer: RustFS Team <support@rustfs.com>
|
||||||
|
Description: High-performance distributed object storage
|
||||||
|
RustFS is a high-performance distributed object storage software
|
||||||
|
built using Rust. It is compatible with MinIO and S3 API.
|
||||||
|
Homepage: https://rustfs.com
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "${PKG_DIR}/DEBIAN/conffiles" << 'CONFFILES'
|
||||||
|
/etc/default/rustfs
|
||||||
|
CONFFILES
|
||||||
|
|
||||||
|
cat > "${PKG_DIR}/DEBIAN/postinst" << 'POSTINST'
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
if ! getent passwd rustfs > /dev/null 2>&1; then
|
||||||
|
useradd -r -s /bin/false -d /opt/rustfs rustfs
|
||||||
|
fi
|
||||||
|
mkdir -p /opt/rustfs /data/rustfs /var/log/rustfs
|
||||||
|
chown rustfs:rustfs /opt/rustfs /data/rustfs /var/log/rustfs
|
||||||
|
if [ -d /run/systemd/system ]; then
|
||||||
|
systemctl daemon-reload
|
||||||
|
fi
|
||||||
|
echo "RustFS installed. Configure /etc/default/rustfs then: systemctl start rustfs"
|
||||||
|
POSTINST
|
||||||
|
chmod 755 "${PKG_DIR}/DEBIAN/postinst"
|
||||||
|
|
||||||
|
cat > "${PKG_DIR}/DEBIAN/prerm" << 'PRERM'
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
if [ -d /run/systemd/system ] && systemctl is-active --quiet rustfs; then
|
||||||
|
systemctl stop rustfs
|
||||||
|
fi
|
||||||
|
PRERM
|
||||||
|
chmod 755 "${PKG_DIR}/DEBIAN/prerm"
|
||||||
|
|
||||||
|
cat > "${PKG_DIR}/DEBIAN/postrm" << 'POSTRM'
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
if [ -d /run/systemd/system ]; then
|
||||||
|
systemctl daemon-reload
|
||||||
|
fi
|
||||||
|
POSTRM
|
||||||
|
chmod 755 "${PKG_DIR}/DEBIAN/postrm"
|
||||||
|
|
||||||
|
cp LICENSE "${PKG_DIR}/usr/share/doc/rustfs/"
|
||||||
|
cp README.md "${PKG_DIR}/usr/share/doc/rustfs/"
|
||||||
|
|
||||||
|
fakeroot dpkg-deb --build "${PKG_DIR}"
|
||||||
|
ls -lh "${DEB_FILE}"
|
||||||
|
echo "deb_file=${DEB_FILE}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Upload DEB artifact
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: ${{ steps.deb.outputs.deb_file }}
|
||||||
|
path: ${{ steps.deb.outputs.deb_file }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# Persist the nightly deb on Cloudflare R2 (same channel as package.yml)
|
||||||
|
# so it can be downloaded later with a stable, unauthenticated URL —
|
||||||
|
# e.g. https://dl.rustfs.com/artifacts/rustfs/packages/nightly/... .
|
||||||
|
# Skipped when the R2 secrets are not configured (artifact-only mode).
|
||||||
|
- name: Upload DEB to Cloudflare R2
|
||||||
|
if: env.R2_ACCESS_KEY_ID != ''
|
||||||
|
env:
|
||||||
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
|
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
|
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||||
|
AWS_EC2_METADATA_DISABLED: true
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ -z "$R2_ACCESS_KEY_ID" || -z "$R2_SECRET_ACCESS_KEY" || -z "$R2_ENDPOINT" || -z "$R2_BUCKET" ]]; then
|
||||||
|
echo "⚠️ R2 credentials missing, skipping upload"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v aws >/dev/null 2>&1; then
|
||||||
|
sudo apt-get update && sudo apt-get install -y -qq awscli
|
||||||
|
fi
|
||||||
|
|
||||||
|
export AWS_ACCESS_KEY_ID="$R2_ACCESS_KEY_ID"
|
||||||
|
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
|
||||||
|
export AWS_DEFAULT_REGION="auto"
|
||||||
|
|
||||||
|
DEB_FILE="${{ steps.deb.outputs.deb_file }}"
|
||||||
|
R2_PREFIX="s3://${R2_BUCKET}/artifacts/rustfs/packages/nightly/"
|
||||||
|
|
||||||
|
echo "📤 Uploading ${DEB_FILE} to ${R2_PREFIX}"
|
||||||
|
aws s3 cp "${DEB_FILE}" "${R2_PREFIX}" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||||
|
|
||||||
|
# Stable "latest" alias so tests can fetch the newest nightly
|
||||||
|
# without knowing today's date.
|
||||||
|
echo "📤 Uploading latest alias"
|
||||||
|
aws s3 cp "${DEB_FILE}" "${R2_PREFIX}rustfs-nightly-latest.deb" \
|
||||||
|
--endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||||
|
|
||||||
|
echo "✅ R2 upload complete"
|
||||||
|
|
||||||
# Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774).
|
# Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774).
|
||||||
#
|
#
|
||||||
# RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and
|
# RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Copyright 2024 RustFS Team
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
name: OIDC Keycloak Live
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/oidc-keycloak.yml"
|
||||||
|
- "crates/config/src/constants/oidc.rs"
|
||||||
|
- "crates/iam/src/federation/**"
|
||||||
|
- "crates/iam/src/oidc.rs"
|
||||||
|
- "rustfs/src/admin/handlers/oidc.rs"
|
||||||
|
- "rustfs/src/admin/handlers/sts.rs"
|
||||||
|
- "scripts/test/oidc_keycloak_live.sh"
|
||||||
|
- "scripts/test/fixtures/keycloak-rustfs-ci-realm.json"
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/oidc-keycloak.yml"
|
||||||
|
- "crates/config/src/constants/oidc.rs"
|
||||||
|
- "crates/iam/src/federation/**"
|
||||||
|
- "crates/iam/src/oidc.rs"
|
||||||
|
- "rustfs/src/admin/handlers/oidc.rs"
|
||||||
|
- "rustfs/src/admin/handlers/sts.rs"
|
||||||
|
- "scripts/test/oidc_keycloak_live.sh"
|
||||||
|
- "scripts/test/fixtures/keycloak-rustfs-ci-realm.json"
|
||||||
|
schedule:
|
||||||
|
- cron: "23 2 * * 1"
|
||||||
|
timezone: "Asia/Shanghai"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: oidc-keycloak-live-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
oidc-keycloak-live:
|
||||||
|
name: OIDC Keycloak live gate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Rust environment
|
||||||
|
uses: ./.github/actions/setup
|
||||||
|
with:
|
||||||
|
cache-shared-key: oidc-keycloak-live
|
||||||
|
cache-save-if: "true"
|
||||||
|
install-build-packaging-tools: "false"
|
||||||
|
install-test-tools: "false"
|
||||||
|
|
||||||
|
- name: Build RustFS
|
||||||
|
run: cargo build --locked -p rustfs --bin rustfs
|
||||||
|
|
||||||
|
- name: Install pinned request signer
|
||||||
|
run: |
|
||||||
|
python3 -m pip install --user --upgrade pip "awscurl==0.44"
|
||||||
|
echo "${HOME}/.local/bin" >> "${GITHUB_PATH}"
|
||||||
|
|
||||||
|
- name: Run live Keycloak discovery, JWT and STS checks
|
||||||
|
run: bash scripts/test/oidc_keycloak_live.sh ./target/debug/rustfs
|
||||||
|
|
||||||
|
- name: Upload service logs
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: oidc-keycloak-live-${{ github.run_number }}
|
||||||
|
path: ${{ runner.temp }}/rustfs-keycloak-live-*/**/*.log
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 3
|
||||||
|
|
||||||
|
alert-on-failure:
|
||||||
|
name: Alert on scheduled failure
|
||||||
|
needs: oidc-keycloak-live
|
||||||
|
if: >-
|
||||||
|
always() && github.event_name == 'schedule' &&
|
||||||
|
(needs.oidc-keycloak-live.result == 'failure' || needs.oidc-keycloak-live.result == 'cancelled')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Open or update failure-tracking issue
|
||||||
|
uses: ./.github/actions/schedule-failure-issue
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
name: RustFS Heal Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
package_url:
|
||||||
|
description: 'Direct .deb URL (nightly/R2). Defaults to the latest nightly deb.'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
stop_node_gb:
|
||||||
|
description: 'Stop the outage node when surviving nodes reach N GiB'
|
||||||
|
required: false
|
||||||
|
default: '15'
|
||||||
|
warp_stop_gb:
|
||||||
|
description: 'Stop warp when surviving nodes reach N GiB'
|
||||||
|
required: false
|
||||||
|
default: '40'
|
||||||
|
heal_target_gb:
|
||||||
|
description: 'Outage node must reach N GiB after heal to pass'
|
||||||
|
required: false
|
||||||
|
default: '40'
|
||||||
|
cleanup_before:
|
||||||
|
description: 'Reset the nodes before the test (DESTROYS existing data/config)'
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
cleanup_after:
|
||||||
|
description: 'Reset the nodes after the test (DESTROYS test data/config)'
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# Only one test at a time: both this and the pool-expansion workflow mutate
|
||||||
|
# the same test environment, so they share one concurrency group.
|
||||||
|
concurrency:
|
||||||
|
group: rustfs-pool-expansion-test
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
env:
|
||||||
|
RUSTFS_ACCESS_KEY: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||||
|
RUSTFS_SECRET_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||||
|
RUSTFS_API_ENDPOINT: ${{ secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT }}
|
||||||
|
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||||
|
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||||
|
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
heal-test:
|
||||||
|
runs-on: smoke-testing
|
||||||
|
timeout-minutes: 480
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Show environment
|
||||||
|
run: |
|
||||||
|
uname -a
|
||||||
|
jq --version
|
||||||
|
openssl version
|
||||||
|
warp --version || true
|
||||||
|
df -h /data | tail -1
|
||||||
|
|
||||||
|
- name: Reset test environment (before)
|
||||||
|
if: ${{ inputs.cleanup_before != 'false' }}
|
||||||
|
run: |
|
||||||
|
chmod +x scripts/test/rustfs_heal_test.sh
|
||||||
|
./scripts/test/rustfs_heal_test.sh --reset -y
|
||||||
|
|
||||||
|
- name: Install RustFS package & start cluster
|
||||||
|
run: |
|
||||||
|
ARGS=(--steps "1,2" -y --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
|
||||||
|
if [ -n "${{ inputs.package_url }}" ]; then
|
||||||
|
ARGS+=(--package-url "${{ inputs.package_url }}")
|
||||||
|
else
|
||||||
|
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||||
|
fi
|
||||||
|
./scripts/test/rustfs_heal_test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
|
- name: Preflight checks
|
||||||
|
run: |
|
||||||
|
ARGS=(--preflight --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
|
||||||
|
if [ -n "${{ inputs.package_url }}" ]; then
|
||||||
|
ARGS+=(--package-url "${{ inputs.package_url }}")
|
||||||
|
else
|
||||||
|
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||||
|
fi
|
||||||
|
./scripts/test/rustfs_heal_test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
|
- name: Run heal test (write -> outage -> heal -> verify)
|
||||||
|
run: |
|
||||||
|
./scripts/test/rustfs_heal_test.sh \
|
||||||
|
--steps "3,4,5,6,7" -y \
|
||||||
|
--endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
|
||||||
|
--stop-node-gb "${{ inputs.stop_node_gb }}" \
|
||||||
|
--warp-stop-gb "${{ inputs.warp_stop_gb }}" \
|
||||||
|
--heal-target-gb "${{ inputs.heal_target_gb }}" \
|
||||||
|
--log-file /tmp/rustfs-heal-test.log
|
||||||
|
|
||||||
|
- name: Upload test logs
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: rustfs-heal-test-${{ github.run_id }}
|
||||||
|
path: |
|
||||||
|
/tmp/rustfs-heal-test*.log
|
||||||
|
/tmp/rustfs-warp.*.log
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
- name: Reset test environment (after)
|
||||||
|
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
||||||
|
run: |
|
||||||
|
./scripts/test/rustfs_heal_test.sh --reset -y
|
||||||
|
|
||||||
|
- name: Notify on failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "RustFS heal test failed"
|
||||||
|
echo "Package source: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
|
||||||
|
echo "See the uploaded log artifact for details."
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
name: RustFS Pool Expansion / Decommission Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
rustfs_version:
|
||||||
|
description: 'RustFS release tag to test (e.g. 1.0.0-rc.3)'
|
||||||
|
required: false
|
||||||
|
default: '1.0.0-rc.3'
|
||||||
|
package_url:
|
||||||
|
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
pools:
|
||||||
|
description: 'Number of pools to expand to (2 = first rebalance only)'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- '2'
|
||||||
|
- '3'
|
||||||
|
default: '3'
|
||||||
|
storage_threshold:
|
||||||
|
description: 'Stop writing when storage usage reaches N%'
|
||||||
|
required: false
|
||||||
|
default: '50'
|
||||||
|
warp_duration:
|
||||||
|
description: 'warp write duration (e.g. 5m, 10m)'
|
||||||
|
required: false
|
||||||
|
default: '10m'
|
||||||
|
run_decommission:
|
||||||
|
description: 'Run the pool decommission step (3-pool topology only)'
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
stop_node_gb:
|
||||||
|
description: 'Heal: stop the outage node when surviving nodes reach N GiB'
|
||||||
|
required: false
|
||||||
|
default: '15'
|
||||||
|
warp_stop_gb:
|
||||||
|
description: 'Heal: stop warp when surviving nodes reach N GiB'
|
||||||
|
required: false
|
||||||
|
default: '40'
|
||||||
|
heal_target_gb:
|
||||||
|
description: 'Heal: outage node must reach N GiB after heal'
|
||||||
|
required: false
|
||||||
|
default: '40'
|
||||||
|
cleanup_before:
|
||||||
|
description: 'Reset the nodes before the test (DESTROYS existing data/config)'
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
cleanup_after:
|
||||||
|
description: 'Reset the nodes after the test (DESTROYS test data/config)'
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
workflow_run:
|
||||||
|
# Run after the nightly build completes: pool expansion first, then heal.
|
||||||
|
workflows: ["Nightly GNU Build"]
|
||||||
|
types: [completed]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# Only one pool-expansion test at a time: the workflow mutates a shared
|
||||||
|
# test environment, so concurrent runs must not clobber each other.
|
||||||
|
concurrency:
|
||||||
|
group: rustfs-pool-expansion-test
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
env:
|
||||||
|
RUSTFS_ACCESS_KEY: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||||
|
RUSTFS_SECRET_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||||
|
RUSTFS_API_ENDPOINT: ${{ secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT }}
|
||||||
|
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||||
|
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||||
|
# Package used by the nightly run (workflow_dispatch inputs are empty for
|
||||||
|
# workflow_run events), i.e. the latest nightly deb published by nightly-gnu.yml.
|
||||||
|
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pool-expansion-test:
|
||||||
|
runs-on: smoke-testing
|
||||||
|
timeout-minutes: 360
|
||||||
|
# Run on manual dispatch, or when the nightly build completed successfully
|
||||||
|
# (its deb is what the tests install). Skipped when nightly failed.
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
|
||||||
|
|
||||||
|
- name: Show environment
|
||||||
|
run: |
|
||||||
|
uname -a
|
||||||
|
jq --version
|
||||||
|
openssl version
|
||||||
|
warp --version || true
|
||||||
|
df -h /data | tail -1
|
||||||
|
|
||||||
|
- name: Reset test environment (before)
|
||||||
|
if: ${{ inputs.cleanup_before != 'false' }}
|
||||||
|
run: |
|
||||||
|
chmod +x scripts/test/rustfs_pool_expand.sh
|
||||||
|
./scripts/test/rustfs_pool_expand.sh --reset -y
|
||||||
|
|
||||||
|
- name: Install RustFS package & start first pool
|
||||||
|
run: |
|
||||||
|
ARGS=(--steps "1,2,3" -y --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
|
||||||
|
if [ -n "${{ inputs.package_url }}" ]; then
|
||||||
|
ARGS+=(--package-url "${{ inputs.package_url }}")
|
||||||
|
elif [ -n "${{ inputs.rustfs_version }}" ]; then
|
||||||
|
ARGS+=(--version "${{ inputs.rustfs_version }}")
|
||||||
|
else
|
||||||
|
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||||
|
fi
|
||||||
|
./scripts/test/rustfs_pool_expand.sh "${ARGS[@]}"
|
||||||
|
|
||||||
|
- name: Preflight checks
|
||||||
|
run: |
|
||||||
|
ARGS=(--preflight --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
|
||||||
|
if [ -n "${{ inputs.package_url }}" ]; then
|
||||||
|
ARGS+=(--package-url "${{ inputs.package_url }}")
|
||||||
|
elif [ -n "${{ inputs.rustfs_version }}" ]; then
|
||||||
|
ARGS+=(--version "${{ inputs.rustfs_version }}")
|
||||||
|
else
|
||||||
|
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||||
|
fi
|
||||||
|
./scripts/test/rustfs_pool_expand.sh "${ARGS[@]}"
|
||||||
|
|
||||||
|
- name: Run pool expansion & decommission test
|
||||||
|
id: pool_test
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
STEPS="4,5,6"
|
||||||
|
if [ "${{ inputs.pools || '3' }}" = "3" ]; then
|
||||||
|
STEPS="$STEPS,7,8"
|
||||||
|
if [ "${{ inputs.run_decommission != 'false' }}" = "true" ]; then
|
||||||
|
STEPS="$STEPS,9"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
./scripts/test/rustfs_pool_expand.sh \
|
||||||
|
--steps "$STEPS" --with-warp -y \
|
||||||
|
--endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
|
||||||
|
--storage-threshold "${{ inputs.storage_threshold || '50' }}" \
|
||||||
|
--warp-duration "${{ inputs.warp_duration || '10m' }}" \
|
||||||
|
--log-file /tmp/rustfs-pool-test.log
|
||||||
|
|
||||||
|
- name: Upload test logs
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: rustfs-pool-test-${{ github.run_id }}
|
||||||
|
path: |
|
||||||
|
/tmp/rustfs-pool-test*.log
|
||||||
|
/tmp/rustfs-warp.*.log
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
- name: Reset test environment (after)
|
||||||
|
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
||||||
|
run: |
|
||||||
|
./scripts/test/rustfs_pool_expand.sh --reset -y
|
||||||
|
|
||||||
|
- name: Notify on failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "RustFS pool expansion test failed"
|
||||||
|
echo "Package source: ${{ inputs.package_url || inputs.rustfs_version || 'nightly (R2 latest)' }}"
|
||||||
|
echo "See the uploaded log artifact for details."
|
||||||
|
|
||||||
|
# Heal regression runs after the pool test regardless of its outcome: a pool
|
||||||
|
# failure must be reported (it makes the run red) but must not block heal.
|
||||||
|
heal-test:
|
||||||
|
name: Heal test (after pool test)
|
||||||
|
runs-on: smoke-testing
|
||||||
|
timeout-minutes: 480
|
||||||
|
needs: pool-expansion-test
|
||||||
|
if: ${{ always() && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success') }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
|
||||||
|
|
||||||
|
- name: Reset test environment (before)
|
||||||
|
if: ${{ inputs.cleanup_before != 'false' }}
|
||||||
|
run: |
|
||||||
|
chmod +x scripts/test/rustfs_heal_test.sh
|
||||||
|
./scripts/test/rustfs_heal_test.sh --reset -y
|
||||||
|
|
||||||
|
- name: Install RustFS package & start cluster
|
||||||
|
run: |
|
||||||
|
ARGS=(--steps "1,2" -y --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
|
||||||
|
if [ -n "${{ inputs.package_url }}" ]; then
|
||||||
|
ARGS+=(--package-url "${{ inputs.package_url }}")
|
||||||
|
else
|
||||||
|
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||||
|
fi
|
||||||
|
./scripts/test/rustfs_heal_test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
|
- name: Preflight checks
|
||||||
|
run: |
|
||||||
|
ARGS=(--preflight --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
|
||||||
|
if [ -n "${{ inputs.package_url }}" ]; then
|
||||||
|
ARGS+=(--package-url "${{ inputs.package_url }}")
|
||||||
|
else
|
||||||
|
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||||
|
fi
|
||||||
|
./scripts/test/rustfs_heal_test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
|
- name: Run heal test (write -> outage -> heal -> verify)
|
||||||
|
run: |
|
||||||
|
./scripts/test/rustfs_heal_test.sh \
|
||||||
|
--steps 3,4,5,6,7 -y \
|
||||||
|
--endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
|
||||||
|
--stop-node-gb "${{ inputs.stop_node_gb || '15' }}" \
|
||||||
|
--warp-stop-gb "${{ inputs.warp_stop_gb || '40' }}" \
|
||||||
|
--heal-target-gb "${{ inputs.heal_target_gb || '40' }}" \
|
||||||
|
--log-file /tmp/rustfs-heal-test.log
|
||||||
|
|
||||||
|
- name: Upload test logs
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: rustfs-heal-test-${{ github.run_id }}
|
||||||
|
path: |
|
||||||
|
/tmp/rustfs-heal-test.log
|
||||||
|
/tmp/rustfs-warp.*.log
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
- name: Reset test environment (after)
|
||||||
|
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
||||||
|
run: |
|
||||||
|
./scripts/test/rustfs_heal_test.sh --reset -y
|
||||||
|
|
||||||
|
- name: Notify on failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "RustFS heal test failed"
|
||||||
|
echo "See the uploaded log artifact for details."
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# Copyright 2024 RustFS Team
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
name: Targets Integration
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- ".github/actions/setup/**"
|
||||||
|
- ".github/workflows/targets-integration.yml"
|
||||||
|
- "crates/targets/**"
|
||||||
|
- "Cargo.lock"
|
||||||
|
schedule:
|
||||||
|
- cron: "17 2 * * *"
|
||||||
|
timezone: "Asia/Shanghai"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: targets-integration-${{ github.ref }}-${{ github.event_name }}
|
||||||
|
cancel-in-progress: ${{ github.event_name != 'schedule' }}
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUST_BACKTRACE: 1
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
targets-live:
|
||||||
|
name: PostgreSQL, MySQL, AMQP, and NATS
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 90
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
NO_PROXY: 127.0.0.1,localhost
|
||||||
|
RUSTFS_TEST_PG_DSN: postgres://postgres:rustfs@127.0.0.1:5432/rustfs_events
|
||||||
|
RUSTFS_TEST_MYSQL_DSN: root:testpass@tcp(127.0.0.1:3306)/testdb
|
||||||
|
RUSTFS_TEST_AMQP_URL: amqp://rustfs:rustfs@127.0.0.1:5672/%2f
|
||||||
|
RUSTFS_TEST_NATS_URL: nats://127.0.0.1:4222
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Rust environment
|
||||||
|
uses: ./.github/actions/setup
|
||||||
|
with:
|
||||||
|
cache-shared-key: targets-live-lane
|
||||||
|
cache-save-if: ${{ github.ref == 'refs/heads/main' || github.event_name == 'schedule' }}
|
||||||
|
install-build-packaging-tools: 'false'
|
||||||
|
install-test-tools: 'false'
|
||||||
|
|
||||||
|
- name: Start target services
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p artifacts/targets-live/services
|
||||||
|
docker run -d --name rustfs-targets-postgres \
|
||||||
|
-e POSTGRES_PASSWORD=rustfs \
|
||||||
|
-e POSTGRES_DB=rustfs_events \
|
||||||
|
-p 5432:5432 postgres:16
|
||||||
|
docker run -d --name rustfs-targets-mysql \
|
||||||
|
-e MYSQL_ROOT_PASSWORD=testpass \
|
||||||
|
-e MYSQL_DATABASE=testdb \
|
||||||
|
-p 3306:3306 mysql:8.0.36
|
||||||
|
docker run -d --name rustfs-targets-rabbitmq \
|
||||||
|
-e RABBITMQ_DEFAULT_USER=rustfs \
|
||||||
|
-e RABBITMQ_DEFAULT_PASS=rustfs \
|
||||||
|
-p 5672:5672 rabbitmq:3
|
||||||
|
docker run -d --name rustfs-targets-nats \
|
||||||
|
-p 4222:4222 -p 8222:8222 nats:2 -js -m 8222
|
||||||
|
|
||||||
|
for _ in $(seq 1 120); do
|
||||||
|
docker exec rustfs-targets-postgres pg_isready -U postgres -d rustfs_events >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
docker exec rustfs-targets-postgres pg_isready -U postgres -d rustfs_events
|
||||||
|
|
||||||
|
for _ in $(seq 1 120); do
|
||||||
|
docker exec rustfs-targets-mysql mysqladmin ping -h 127.0.0.1 -uroot -ptestpass --silent >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
docker exec rustfs-targets-mysql mysqladmin ping -h 127.0.0.1 -uroot -ptestpass --silent
|
||||||
|
|
||||||
|
for _ in $(seq 1 120); do
|
||||||
|
docker exec rustfs-targets-rabbitmq rabbitmq-diagnostics -q ping >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
docker exec rustfs-targets-rabbitmq rabbitmq-diagnostics -q ping
|
||||||
|
|
||||||
|
for _ in $(seq 1 120); do
|
||||||
|
curl -fsS http://127.0.0.1:8222/healthz >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
curl -fsS http://127.0.0.1:8222/healthz
|
||||||
|
|
||||||
|
- name: Run live target tests
|
||||||
|
env:
|
||||||
|
CARGO_BUILD_JOBS: "2"
|
||||||
|
run: |
|
||||||
|
set +e
|
||||||
|
timeout --verbose --signal=TERM --kill-after=30s 75m bash <<'TESTS' \
|
||||||
|
2>&1 | tee artifacts/targets-live/tests.log
|
||||||
|
result=0
|
||||||
|
|
||||||
|
echo "::group::PostgreSQL"
|
||||||
|
cargo test --locked -p rustfs-targets --test postgres_integration -- --ignored --test-threads=1 || result=1
|
||||||
|
echo "::endgroup::"
|
||||||
|
|
||||||
|
echo "::group::MySQL"
|
||||||
|
cargo test --locked -p rustfs-targets --test mysql_integration -- --ignored --test-threads=1 || result=1
|
||||||
|
echo "::endgroup::"
|
||||||
|
|
||||||
|
echo "::group::AMQP"
|
||||||
|
cargo test --locked -p rustfs-targets --test amqp_integration -- --ignored --test-threads=1 || result=1
|
||||||
|
echo "::endgroup::"
|
||||||
|
|
||||||
|
echo "::group::NATS integration"
|
||||||
|
cargo test --locked -p rustfs-targets --test nats_jetstream_validation_integration -- --ignored --test-threads=1 || result=1
|
||||||
|
cargo test --locked -p rustfs-targets --test nats_jetstream_regression_guards -- --ignored --test-threads=1 || result=1
|
||||||
|
cargo test --locked -p rustfs-targets --lib target::nats::jetstream -- --ignored --test-threads=1 || result=1
|
||||||
|
echo "::endgroup::"
|
||||||
|
|
||||||
|
exit "${result}"
|
||||||
|
TESTS
|
||||||
|
status=${PIPESTATUS[0]}
|
||||||
|
{
|
||||||
|
echo "exit_status=${status}"
|
||||||
|
echo "finished_at=$(date --utc --iso-8601=seconds)"
|
||||||
|
echo
|
||||||
|
echo "Remaining test-related processes:"
|
||||||
|
pgrep -af 'cargo|target/.*/deps/' || true
|
||||||
|
} > artifacts/targets-live/diagnostics.txt
|
||||||
|
exit "${status}"
|
||||||
|
|
||||||
|
- name: Collect service logs
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts/targets-live/services
|
||||||
|
for container in postgres mysql rabbitmq nats; do
|
||||||
|
docker logs --tail 500 "rustfs-targets-${container}" \
|
||||||
|
> "artifacts/targets-live/services/${container}.log" 2>&1 || true
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Stop target services
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
docker rm -f \
|
||||||
|
rustfs-targets-postgres \
|
||||||
|
rustfs-targets-mysql \
|
||||||
|
rustfs-targets-rabbitmq \
|
||||||
|
rustfs-targets-nats >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
- name: Upload target integration diagnostics
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: targets-integration-${{ github.run_number }}-${{ github.run_attempt }}
|
||||||
|
path: artifacts/targets-live
|
||||||
|
|
||||||
|
alert-on-failure:
|
||||||
|
name: Alert on scheduled failure
|
||||||
|
needs: [targets-live]
|
||||||
|
if: >-
|
||||||
|
always() && github.event_name == 'schedule' &&
|
||||||
|
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Open or update failure-tracking issue
|
||||||
|
uses: ./.github/actions/schedule-failure-issue
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
+28
-15
@@ -73,7 +73,7 @@ The main crate is organized in layers, top to bottom:
|
|||||||
|-------|-----------|----------------|
|
|-------|-----------|----------------|
|
||||||
| **Server** | `server/` | HTTP listener, TLS, CORS, compression, middleware, graceful shutdown |
|
| **Server** | `server/` | HTTP listener, TLS, CORS, compression, middleware, graceful shutdown |
|
||||||
| **Admin** | `admin/` | Admin API routing, 30+ handler modules, web console |
|
| **Admin** | `admin/` | Admin API routing, 30+ handler modules, web console |
|
||||||
| **App** | `app/` | Use-case orchestration: object_usecase, bucket_usecase, multipart_usecase |
|
| **App** | `app/` | Use-case orchestration: object (per-operation modules under `app/object/`, re-exported as `object_usecase`), bucket_usecase, multipart_usecase |
|
||||||
| **Storage** | `storage/` | S3 API translation, erasure-coded FS, SSE encryption, RPC, concurrency |
|
| **Storage** | `storage/` | S3 API translation, erasure-coded FS, SSE encryption, RPC, concurrency |
|
||||||
| **Auth** | `auth.rs` | S3 signature verification, credential validation |
|
| **Auth** | `auth.rs` | S3 signature verification, credential validation |
|
||||||
| **Config** | `config/` | CLI parsing, config struct, workload profiles |
|
| **Config** | `config/` | CLI parsing, config struct, workload profiles |
|
||||||
@@ -92,8 +92,8 @@ refactors.
|
|||||||
|
|
||||||
| Domain | Current workspace crates | Responsibility |
|
| Domain | Current workspace crates | Responsibility |
|
||||||
|--------|--------------------------|----------------|
|
|--------|--------------------------|----------------|
|
||||||
| Foundation | `checksums`, `common`, `config`, `data-usage`, `utils` | Shared configuration, data-usage models, utilities, and checksums. |
|
| Foundation | `checksums`, `common`, `config`, `data-usage`, `heal-contracts`, `scanner-contracts`, `utils` | Shared configuration, data-usage models, heal/scanner domain contracts, 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. |
|
| I/O and storage | `concurrency`, `ecstore`, `filemeta`, `heal`, `io-core`, `io-metrics`, `lifecycle`, `lock`, `object-capacity`, `object-data-cache`, `replication`, `rio`, `rio-v2`, `s3-client`, `scanner`, `storage-api` | Erasure-coded object storage, metadata, recovery, lifecycle, replication, locking, cache, I/O pipelines, and the engine-side S3 client for remote tier/transition targets. |
|
||||||
| 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. |
|
| 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. |
|
| 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. |
|
| Operations and integration | `audit`, `notify`, `obs`, `targets`, `zip` | Auditing, observability, event delivery, notification targets, and archive support. |
|
||||||
@@ -115,8 +115,15 @@ default build (lifecycle:
|
|||||||
1. **Layers flow downward.** Server → Admin/App → Storage → ecstore → rio/io-core.
|
1. **Layers flow downward.** Server → Admin/App → Storage → ecstore → rio/io-core.
|
||||||
No upward imports.
|
No upward imports.
|
||||||
|
|
||||||
2. **Leaf crates have zero internal dependencies.** `config`, `credentials`, `crypto`,
|
2. **Leaf crates depend only on external crates, with adjudicated exceptions
|
||||||
`io-metrics`, and `madmin` should depend only on external crates.
|
pinned by a guard.** `config`, `credentials`, and `crypto` take no internal
|
||||||
|
dependency. `io-metrics` takes exactly `rustfs-s3-ops` (transitively
|
||||||
|
`rustfs-s3-types`), a pure contract crate with no I/O and no global state —
|
||||||
|
adjudicated in rustfs/backlog#1834. `madmin` left the leaf set when #6166 made
|
||||||
|
it the SigV4-signed admin SDK client; its internal dependency surface is pinned
|
||||||
|
to exactly `rustfs-signer`. Both pins live in the leaf allowlist in
|
||||||
|
`scripts/check_architecture_migration_rules.sh`; any other internal dependency
|
||||||
|
fails the guard ([crate boundaries](docs/architecture/crate-boundaries.md)).
|
||||||
- ✅ RESOLVED: the historical `utils → config` and `common → filemeta`/`madmin`
|
- ✅ RESOLVED: the historical `utils → config` and `common → filemeta`/`madmin`
|
||||||
edges were removed; do not reintroduce them (see Known Structural Issues).
|
edges were removed; do not reintroduce them (see Known Structural Issues).
|
||||||
|
|
||||||
@@ -128,7 +135,7 @@ default build (lifecycle:
|
|||||||
`crates/ecstore/src/bucket/replication/replication_state.rs`) — a naming
|
`crates/ecstore/src/bucket/replication/replication_state.rs`) — a naming
|
||||||
collision, not copies; renaming is tracked in rustfs/backlog#1847.
|
collision, not copies; renaming is tracked in rustfs/backlog#1847.
|
||||||
- `LastMinuteLatency` has two deliberately different implementations: the
|
- `LastMinuteLatency` has two deliberately different implementations: the
|
||||||
per-second bucketed accumulator in `crates/common/src/last_minute.rs` and
|
per-second bucketed accumulator in `crates/scanner-contracts/src/last_minute.rs` and
|
||||||
the in-memory endpoint-health sample tracker in
|
the in-memory endpoint-health sample tracker in
|
||||||
`crates/ecstore/src/bucket/bucket_target_sys.rs` (its doc comment explains
|
`crates/ecstore/src/bucket/bucket_target_sys.rs` (its doc comment explains
|
||||||
why it stays local).
|
why it stays local).
|
||||||
@@ -138,15 +145,19 @@ default build (lifecycle:
|
|||||||
`BackpressureSettings` copy that lingered in io-metrics was removed
|
`BackpressureSettings` copy that lingered in io-metrics was removed
|
||||||
(rustfs/backlog#1833).
|
(rustfs/backlog#1833).
|
||||||
|
|
||||||
4. **ecstore does not know about HTTP or S3 protocol details.** It operates on
|
4. **ecstore does not *serve* HTTP or the S3 wire protocol.** It operates on
|
||||||
storage-level abstractions (objects, buckets, disks, pools).
|
storage-level abstractions (objects, buckets, disks, pools) and holds no
|
||||||
- ⚠️ VIOLATED: 58 files under `crates/ecstore/src` reference `s3s`
|
wire or DTO types of the serving surface. *Consuming* remote S3-compatible
|
||||||
(`rg -l 's3s' crates/ecstore/src | wc -l`), `crates/ecstore/src/client/`
|
endpoints (ILM tier warm backends, transition targets) is a legitimate
|
||||||
is a ~9.4K-line embedded S3 HTTP client, and `crates/ecstore/Cargo.toml`
|
engine capability, but it lives in the dedicated `rustfs-s3-client` crate
|
||||||
depends on `s3s`, `http`, `hyper`/`hyper-util`/`hyper-rustls`, and
|
(`crates/s3-client`, extracted from the formerly embedded
|
||||||
`reqwest`. Target state: the engine's need to act as an S3 client
|
`crates/ecstore/src/client/` by rustfs/backlog#1842), not inside ecstore.
|
||||||
(tiering, replication targets) is served by an extracted client crate,
|
- ⚠️ PARTIALLY VIOLATED: serving-side `s3s` references remain in ecstore
|
||||||
and ecstore holds no wire or DTO types.
|
(bucket metadata/replication/lifecycle DTOs and error mapping). The
|
||||||
|
count is ratcheted shrink-only by `scripts/check_s3s_footprint.sh`
|
||||||
|
(`S3S_ECSTORE_FILES_BASELINE`; the `object_lock` module was converted to
|
||||||
|
storage-level types as the first ratchet step). Target state: the
|
||||||
|
baseline reaches zero and ecstore's `Cargo.toml` drops `s3s`.
|
||||||
|
|
||||||
5. **The `rustfs` binary crate is the only place that wires everything together.**
|
5. **The `rustfs` binary crate is the only place that wires everything together.**
|
||||||
Individual crates should be testable in isolation.
|
Individual crates should be testable in isolation.
|
||||||
@@ -321,6 +332,8 @@ The binary (`main.rs`) boots in this order:
|
|||||||
|
|
||||||
- **"Where is replication configured?"**
|
- **"Where is replication configured?"**
|
||||||
`admin/handlers/replication.rs` and `admin/handlers/site_replication.rs` for API,
|
`admin/handlers/replication.rs` and `admin/handlers/site_replication.rs` for API,
|
||||||
|
`rustfs/src/site_replication/` for the site-replication service subsystem
|
||||||
|
(state, peer transport, retry queue, repair, hooks),
|
||||||
`ecstore/src/bucket/replication/` for engine
|
`ecstore/src/bucket/replication/` for engine
|
||||||
|
|
||||||
- **"Where do I add a new admin endpoint?"**
|
- **"Where do I add a new admin endpoint?"**
|
||||||
|
|||||||
Generated
+519
-220
File diff suppressed because it is too large
Load Diff
+74
-64
@@ -26,6 +26,7 @@ members = [
|
|||||||
"crates/e2e_test", # End-to-end test suite
|
"crates/e2e_test", # End-to-end test suite
|
||||||
"crates/filemeta", # File metadata management
|
"crates/filemeta", # File metadata management
|
||||||
"crates/heal", # Erasure set and object healing
|
"crates/heal", # Erasure set and object healing
|
||||||
|
"crates/heal-contracts", # Heal request/response channel contracts
|
||||||
"crates/iam", # Identity and Access Management
|
"crates/iam", # Identity and Access Management
|
||||||
"crates/keystone", # OpenStack Keystone integration
|
"crates/keystone", # OpenStack Keystone integration
|
||||||
"crates/lifecycle", # Lifecycle rule evaluation contracts
|
"crates/lifecycle", # Lifecycle rule evaluation contracts
|
||||||
@@ -44,11 +45,13 @@ members = [
|
|||||||
"crates/rio-v2", # MinIO on-disk format compatibility I/O layer (feature-gated, ships in no default build)
|
"crates/rio-v2", # MinIO on-disk format compatibility I/O layer (feature-gated, ships in no default build)
|
||||||
"crates/replication", # Replication contracts and wire formats
|
"crates/replication", # Replication contracts and wire formats
|
||||||
"crates/concurrency", # Concurrency management for RustFS - timeout, locking, backpressure, and I/O scheduling
|
"crates/concurrency", # Concurrency management for RustFS - timeout, locking, backpressure, and I/O scheduling
|
||||||
|
"crates/s3-client", # S3 client for engine-side consumption of remote S3 endpoints (tiering, transition targets)
|
||||||
"crates/s3-types", # S3 event type definitions
|
"crates/s3-types", # S3 event type definitions
|
||||||
"crates/s3-ops", # S3 operation definitions and mapping
|
"crates/s3-ops", # S3 operation definitions and mapping
|
||||||
"crates/s3select-api", # S3 Select API interface
|
"crates/s3select-api", # S3 Select API interface
|
||||||
"crates/s3select-query", # S3 Select query engine
|
"crates/s3select-query", # S3 Select query engine
|
||||||
"crates/scanner", # Scanner for data integrity checks and health monitoring
|
"crates/scanner", # Scanner for data integrity checks and health monitoring
|
||||||
|
"crates/scanner-contracts", # Scanner metrics and cycle contracts
|
||||||
"crates/security-governance", # Security governance contracts
|
"crates/security-governance", # Security governance contracts
|
||||||
"crates/extension-schema", # Extension schema contracts
|
"crates/extension-schema", # Extension schema contracts
|
||||||
"crates/signer", # client signer
|
"crates/signer", # client signer
|
||||||
@@ -69,7 +72,7 @@ edition = "2024"
|
|||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
repository = "https://github.com/rustfs/rustfs"
|
repository = "https://github.com/rustfs/rustfs"
|
||||||
rust-version = "1.97.1"
|
rust-version = "1.97.1"
|
||||||
version = "1.0.0-rc.3"
|
version = "1.0.0-rc.4"
|
||||||
homepage = "https://rustfs.com"
|
homepage = "https://rustfs.com"
|
||||||
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
|
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"]
|
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
|
||||||
@@ -86,52 +89,55 @@ redundant_clone = "warn"
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# RustFS Internal Crates
|
# RustFS Internal Crates
|
||||||
rustfs = { path = "./rustfs", version = "1.0.0-rc.3" }
|
rustfs = { path = "./rustfs", version = "1.0.0-rc.4" }
|
||||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.3" }
|
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.4" }
|
||||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.3" }
|
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.0-rc.4" }
|
||||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.3" }
|
rustfs-scanner-contracts = { path = "crates/scanner-contracts", version = "1.0.0-rc.4" }
|
||||||
rustfs-common = { path = "crates/common", version = "1.0.0-rc.3" }
|
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.4" }
|
||||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.3" }
|
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.4" }
|
||||||
rustfs-config = { path = "./crates/config", version = "1.0.0-rc.3" }
|
rustfs-common = { path = "crates/common", version = "1.0.0-rc.4" }
|
||||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.3" }
|
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.4" }
|
||||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.3" }
|
rustfs-config = { path = "./crates/config", version = "1.0.0-rc.4" }
|
||||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.3" }
|
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.4" }
|
||||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.3" }
|
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.4" }
|
||||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.3" }
|
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.4" }
|
||||||
rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.3" }
|
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.4" }
|
||||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.3" }
|
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.4" }
|
||||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.3" }
|
rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.4" }
|
||||||
rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.3" }
|
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.4" }
|
||||||
rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.3" }
|
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.4" }
|
||||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.3" }
|
rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.4" }
|
||||||
rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.3" }
|
rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.4" }
|
||||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.3" }
|
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.4" }
|
||||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.3" }
|
rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.4" }
|
||||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.3" }
|
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.4" }
|
||||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.3", default-features = false }
|
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.4" }
|
||||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.3" }
|
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.4" }
|
||||||
rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.3" }
|
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.4", default-features = false }
|
||||||
rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.3" }
|
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.4" }
|
||||||
rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.3" }
|
rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.4" }
|
||||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.3" }
|
rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.4" }
|
||||||
rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.3" }
|
rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.4" }
|
||||||
rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.3" }
|
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.4" }
|
||||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.3" }
|
rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.4" }
|
||||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.3" }
|
rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.4" }
|
||||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.3" }
|
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.4" }
|
||||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.3" }
|
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.0-rc.4" }
|
||||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.3" }
|
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.4" }
|
||||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.3" }
|
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.4" }
|
||||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.3" }
|
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.4" }
|
||||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.3" }
|
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.4" }
|
||||||
rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.3" }
|
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.4" }
|
||||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.3" }
|
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.4" }
|
||||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.3" }
|
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.4" }
|
||||||
rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.3" }
|
rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.4" }
|
||||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.3" }
|
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.4" }
|
||||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.3" }
|
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.4" }
|
||||||
rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.3" }
|
rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.4" }
|
||||||
rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.3" }
|
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.4" }
|
||||||
|
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.4" }
|
||||||
|
rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.4" }
|
||||||
|
rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.4" }
|
||||||
|
|
||||||
# Async Runtime and Networking
|
# Async Runtime and Networking
|
||||||
async-channel = "2.5.0"
|
async-channel = "2.5.0"
|
||||||
@@ -147,7 +153,7 @@ futures-core = "0.3.34"
|
|||||||
futures-lite = "2.6.1"
|
futures-lite = "2.6.1"
|
||||||
futures-util = "0.3.34"
|
futures-util = "0.3.34"
|
||||||
pollster = "1.0.1"
|
pollster = "1.0.1"
|
||||||
pulsar = { default-features = false, version = "6.8.0" }
|
pulsar = { default-features = false, version = "6.9.0" }
|
||||||
lapin = { default-features = false, version = "4.10.0" }
|
lapin = { default-features = false, version = "4.10.0" }
|
||||||
hyper = { version = "1.11.0" }
|
hyper = { version = "1.11.0" }
|
||||||
hyper-rustls = { default-features = false, version = "0.27.9" }
|
hyper-rustls = { default-features = false, version = "0.27.9" }
|
||||||
@@ -193,7 +199,7 @@ serde_urlencoded = "0.7.1"
|
|||||||
# releases.
|
# releases.
|
||||||
aes-gcm = { version = "=0.11.1" }
|
aes-gcm = { version = "=0.11.1" }
|
||||||
argon2 = { version = "=0.6.0-rc.8" }
|
argon2 = { version = "=0.6.0-rc.8" }
|
||||||
blake2 = "=0.11.0-rc.6"
|
blake2 = "=0.11.0"
|
||||||
chacha20poly1305 = { version = "=0.11.0" }
|
chacha20poly1305 = { version = "=0.11.0" }
|
||||||
crc-fast = "1.10.0"
|
crc-fast = "1.10.0"
|
||||||
hmac = { version = "0.13.0" }
|
hmac = { version = "0.13.0" }
|
||||||
@@ -231,13 +237,12 @@ atoi = "3.1.0"
|
|||||||
atomic_enum = "0.3.0"
|
atomic_enum = "0.3.0"
|
||||||
aws-config = { version = "1.11.0" }
|
aws-config = { version = "1.11.0" }
|
||||||
aws-credential-types = { version = "1.3.0" }
|
aws-credential-types = { version = "1.3.0" }
|
||||||
aws-sdk-kms = { default-features = false, version = "1.116.0" }
|
aws-sdk-kms = { default-features = false, version = "1.117.0" }
|
||||||
aws-sdk-s3 = { default-features = false, version = "1.143.0" }
|
aws-sdk-s3 = { default-features = false, version = "1.144.0" }
|
||||||
aws-sdk-sts = { default-features = false, version = "1.112.0" }
|
aws-sdk-sts = { default-features = false, version = "1.113.0" }
|
||||||
aws-smithy-http-client = { default-features = false, version = "1.4.0" }
|
aws-smithy-http-client = { default-features = false, version = "1.4.0" }
|
||||||
aws-smithy-runtime-api = { version = "1.15.0" }
|
aws-smithy-runtime-api = { version = "1.15.0" }
|
||||||
aws-smithy-types = { version = "1.6.2" }
|
aws-smithy-types = { version = "1.6.2" }
|
||||||
base64 = "0.23.1"
|
|
||||||
base64-simd = "0.8.0"
|
base64-simd = "0.8.0"
|
||||||
brotli = "8.0.4"
|
brotli = "8.0.4"
|
||||||
clap = { version = "4.6.6" }
|
clap = { version = "4.6.6" }
|
||||||
@@ -254,10 +259,12 @@ enumset = "1.1.14"
|
|||||||
faster-hex = "0.10.0"
|
faster-hex = "0.10.0"
|
||||||
flate2 = "1.1.9"
|
flate2 = "1.1.9"
|
||||||
glob = "0.3.4"
|
glob = "0.3.4"
|
||||||
google-cloud-storage = "1.17.0"
|
google-cloud-storage = "1.18.0"
|
||||||
google-cloud-auth = "1.15.0"
|
google-cloud-auth = "1.16.0"
|
||||||
hashbrown = { version = "0.17.1" }
|
hashbrown = { version = "0.17.1" }
|
||||||
hex = "0.4.3"
|
# Base32 for RFC 6238 TOTP shared secrets (RFC 4648 unpadded, the alphabet
|
||||||
|
# every authenticator app expects). Already in the graph transitively.
|
||||||
|
data-encoding = "2.11.1"
|
||||||
hex-simd = "0.8.0"
|
hex-simd = "0.8.0"
|
||||||
highway = { version = "1.3.0" }
|
highway = { version = "1.3.0" }
|
||||||
hostname = "0.4.2"
|
hostname = "0.4.2"
|
||||||
@@ -279,6 +286,10 @@ nvml-wrapper = "0.12.1"
|
|||||||
parking_lot = "0.12.5"
|
parking_lot = "0.12.5"
|
||||||
path-absolutize = "4.0.1"
|
path-absolutize = "4.0.1"
|
||||||
percent-encoding = "2.3.2"
|
percent-encoding = "2.3.2"
|
||||||
|
# Server-side QR rendering for TOTP enrollment, so neither the console nor the
|
||||||
|
# CLI needs its own QR encoder. No default features: the image/render backends
|
||||||
|
# pull in an image stack this only needs SVG and text output from.
|
||||||
|
qrcode-rs = { version = "2.0.0", default-features = false, features = ["std", "svg"] }
|
||||||
pin-project-lite = "0.2.17"
|
pin-project-lite = "0.2.17"
|
||||||
pretty_assertions = "1.4.1"
|
pretty_assertions = "1.4.1"
|
||||||
rand = { version = "0.10.2" }
|
rand = { version = "0.10.2" }
|
||||||
@@ -293,14 +304,14 @@ rustify = { version = "0.7", default-features = false }
|
|||||||
rustix = { version = "1.1.4" }
|
rustix = { version = "1.1.4" }
|
||||||
rust-embed = { version = "8.12.0" }
|
rust-embed = { version = "8.12.0" }
|
||||||
rustc-hash = { version = "2.1.3" }
|
rustc-hash = { version = "2.1.3" }
|
||||||
s3s = { git = "https://github.com/rustfs/s3s.git", rev = "e080e38c56a3b43acbacce55710d765a5ce9003d" }
|
s3s = { git = "https://github.com/rustfs/s3s.git", rev = "0f6f83d98b37fd9edcaa3be573db4aa8f568e088", version = "0.15.0", features = ["minio"] }
|
||||||
serial_test = "4.0.1"
|
serial_test = "4.0.1"
|
||||||
shadow-rs = { default-features = false, version = "2.0.0" }
|
shadow-rs = { default-features = false, version = "2.0.0" }
|
||||||
siphasher = "1.0.3"
|
siphasher = "1.0.3"
|
||||||
smallvec = { version = "1.15.2" }
|
smallvec = { version = "1.15.2" }
|
||||||
compact_str = "0.10.0"
|
compact_str = "0.10.0"
|
||||||
snap = "1.1.2"
|
snap = "1.1.2"
|
||||||
starshard = { version = "2.2.2" }
|
starshard = { version = "2.3.0" }
|
||||||
strum = { version = "0.28.0" }
|
strum = { version = "0.28.0" }
|
||||||
sysinfo = "0.39.6"
|
sysinfo = "0.39.6"
|
||||||
temp-env = "0.3.6"
|
temp-env = "0.3.6"
|
||||||
@@ -316,7 +327,7 @@ tracing-subscriber = { version = "0.3.23" }
|
|||||||
transform-stream = "0.3.1"
|
transform-stream = "0.3.1"
|
||||||
url = "2.5.8"
|
url = "2.5.8"
|
||||||
urlencoding = "2.1.3"
|
urlencoding = "2.1.3"
|
||||||
uuid = { version = "1.25.0" }
|
uuid = { version = "1.26.0" }
|
||||||
vaultrs = { version = "0.8.0" }
|
vaultrs = { version = "0.8.0" }
|
||||||
tar = "0.4.46"
|
tar = "0.4.46"
|
||||||
walkdir = "2.5.0"
|
walkdir = "2.5.0"
|
||||||
@@ -330,7 +341,7 @@ zstd = "0.13.3"
|
|||||||
# Observability and Metrics
|
# Observability and Metrics
|
||||||
metrics = "0.24.6"
|
metrics = "0.24.6"
|
||||||
metrics-util = "0.20"
|
metrics-util = "0.20"
|
||||||
dial9-tokio-telemetry = "0.3"
|
dial9-tokio-telemetry = "0.5.0"
|
||||||
opentelemetry = { version = "0.32.0" }
|
opentelemetry = { version = "0.32.0" }
|
||||||
opentelemetry-appender-tracing = { version = "0.32.0" }
|
opentelemetry-appender-tracing = { version = "0.32.0" }
|
||||||
opentelemetry-otlp = { version = "0.32.0" }
|
opentelemetry-otlp = { version = "0.32.0" }
|
||||||
@@ -345,15 +356,14 @@ libunftp = { version = "0.23.0" }
|
|||||||
unftp-core = "0.1.0"
|
unftp-core = "0.1.0"
|
||||||
suppaftp = { version = "10.0.2" }
|
suppaftp = { version = "10.0.2" }
|
||||||
rcgen = { version = "0.14.9", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
rcgen = { version = "0.14.9", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
||||||
russh = { version = "0.63.0" }
|
russh = { version = "0.63.1" }
|
||||||
russh-sftp = "2.4.0"
|
russh-sftp = "2.4.0"
|
||||||
|
|
||||||
# WebDAV
|
# WebDAV
|
||||||
dav-server = "0.11.0"
|
dav-server = "0.11.0"
|
||||||
|
|
||||||
# Performance Analysis and Memory Profiling
|
# Performance Analysis and Memory Profiling
|
||||||
rustfs-mimalloc = { version = "0.5.0" }
|
rustfs-mimalloc = { version = "0.5.1" }
|
||||||
rustfs-mimalloc-sys = { version = "0.5.0" }
|
|
||||||
hotpath = { version = "0.24.0", default-features = false }
|
hotpath = { version = "0.24.0", default-features = false }
|
||||||
# Snapshot testing for output format regression detection
|
# Snapshot testing for output format regression detection
|
||||||
insta = { version = "1.48" }
|
insta = { version = "1.48" }
|
||||||
@@ -373,8 +383,8 @@ opt-level = 3
|
|||||||
lto = "thin"
|
lto = "thin"
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
debug = 0
|
debug = 0
|
||||||
split-debuginfo = "off"
|
|
||||||
strip = "symbols"
|
strip = "symbols"
|
||||||
|
split-debuginfo = "off"
|
||||||
|
|
||||||
[profile.production]
|
[profile.production]
|
||||||
inherits = "release"
|
inherits = "release"
|
||||||
|
|||||||
@@ -12,8 +12,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://trendshift.io/repositories/14181" target="_blank"><img src="https://trendshift.io/api/badge/repositories/14181" alt="rustfs%2Frustfs | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/14181" target="_blank"><img src="https://trendshift.io/api/badge/repositories/14181" alt="rustfs%2Frustfs | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
<a href="https://runacap.com/ross-index/q4-2025/" target="_blank" rel="noopener"><img style="width: 260px; height: 55px" src="https://runacap.com/wp-content/uploads/2026/01/ROSS_badge_white_Q4_2025.svg" alt="ROSS Index - Fastest Growing Open-Source Startups in Q4 2025 | Runa Capital" height="55" /></a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -116,7 +115,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:latest
|
||||||
|
|
||||||
# Using specific version
|
# 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-rc.3
|
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.4
|
||||||
```
|
```
|
||||||
|
|
||||||
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
|
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
|
||||||
|
|||||||
+2
-3
@@ -12,8 +12,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://trendshift.io/repositories/14181" target="_blank"><img src="https://trendshift.io/api/badge/repositories/14181" alt="rustfs%2Frustfs | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/14181" target="_blank"><img src="https://trendshift.io/api/badge/repositories/14181" alt="rustfs%2Frustfs | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
<a href="https://runacap.com/ross-index/q4-2025/" target="_blank" rel="noopener"><img style="width: 260px; height: 55px" src="https://runacap.com/wp-content/uploads/2026/01/ROSS_badge_white_Q4_2025.svg" alt="ROSS Index - Fastest Growing Open-Source Startups in Q4 2025 | Runa Capital" height="55" /></a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -113,7 +112,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:latest
|
||||||
|
|
||||||
# 使用指定版本运行
|
# 使用指定版本运行
|
||||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.3
|
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.4
|
||||||
```
|
```
|
||||||
|
|
||||||
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ tokio = { workspace = true, features = ["sync", "fs", "rt-multi-thread", "time",
|
|||||||
tracing = { workspace = true, features = ["std", "attributes"] }
|
tracing = { workspace = true, features = ["std", "attributes"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
async-trait = { workspace = true }
|
rustfs-targets = { workspace = true, features = ["test-support"] }
|
||||||
temp-env = { workspace = true }
|
temp-env = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
|
|
||||||
|
|||||||
@@ -564,88 +564,21 @@ impl AuditRuntimeFacade {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::AuditPipeline;
|
use super::AuditPipeline;
|
||||||
use crate::{AuditEntry, AuditError, AuditRegistry};
|
use crate::{AuditEntry, AuditError, AuditRegistry};
|
||||||
use async_trait::async_trait;
|
use rustfs_targets::testkit::MockTarget;
|
||||||
use rustfs_targets::arn::TargetID;
|
|
||||||
use rustfs_targets::store::{Key, Store};
|
|
||||||
use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
|
|
||||||
use rustfs_targets::{StoreError, Target, TargetError};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{Mutex, Notify};
|
use tokio::sync::{Mutex, Notify};
|
||||||
|
|
||||||
/// Mock target whose `save()` outcome is fixed at construction so tests can
|
/// Builds a mock target whose `save()` outcome is fixed at construction so tests can force
|
||||||
/// force full-success / full-failure / partial-failure fan-outs.
|
/// full-success / full-failure / partial-failure fan-outs.
|
||||||
#[derive(Clone)]
|
fn mock_target(id: &str, fail: bool) -> MockTarget {
|
||||||
struct MockTarget {
|
let target = MockTarget::new(id, "webhook");
|
||||||
id: TargetID,
|
if fail { target.with_save_failures(usize::MAX) } else { target }
|
||||||
fail: bool,
|
|
||||||
health_gate: Option<(Arc<Notify>, Arc<Notify>)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MockTarget {
|
|
||||||
fn new(id: &str, fail: bool) -> Self {
|
|
||||||
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]
|
|
||||||
impl<E> Target<E> for MockTarget
|
|
||||||
where
|
|
||||||
E: rustfs_targets::PluginEvent,
|
|
||||||
{
|
|
||||||
fn id(&self) -> TargetID {
|
|
||||||
self.id.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn is_active(&self) -> Result<bool, TargetError> {
|
|
||||||
if let Some((started, release)) = &self.health_gate {
|
|
||||||
started.notify_one();
|
|
||||||
release.notified().await;
|
|
||||||
}
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(&self, _event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
|
|
||||||
if self.fail {
|
|
||||||
Err(TargetError::Configuration("forced save failure".to_string()))
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn close(&self) -> Result<(), TargetError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
|
|
||||||
Box::new(self.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_enabled(&self) -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pipeline_with(targets: Vec<MockTarget>) -> AuditPipeline {
|
fn pipeline_with(targets: Vec<MockTarget>) -> AuditPipeline {
|
||||||
let mut registry = AuditRegistry::new();
|
let mut registry = AuditRegistry::new();
|
||||||
for target in targets {
|
for target in targets {
|
||||||
registry.add_target(target.id.to_string(), Box::new(target));
|
registry.add_target(target.target_id().to_string(), Box::new(target));
|
||||||
}
|
}
|
||||||
AuditPipeline::new(Arc::new(Mutex::new(registry)))
|
AuditPipeline::new(Arc::new(Mutex::new(registry)))
|
||||||
}
|
}
|
||||||
@@ -658,7 +591,7 @@ mod tests {
|
|||||||
// dispatch must return Err rather than swallowing the failures as Ok.
|
// dispatch must return Err rather than swallowing the failures as Ok.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn dispatch_returns_err_when_all_targets_fail() {
|
async fn dispatch_returns_err_when_all_targets_fail() {
|
||||||
let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", true), MockTarget::new("b:webhook", true)]);
|
let pipeline = pipeline_with(vec![mock_target("a:webhook", true), mock_target("b:webhook", true)]);
|
||||||
let result = pipeline.dispatch(entry()).await;
|
let result = pipeline.dispatch(entry()).await;
|
||||||
assert!(matches!(result, Err(AuditError::Target(_))), "expected Err, got {result:?}");
|
assert!(matches!(result, Err(AuditError::Target(_))), "expected Err, got {result:?}");
|
||||||
}
|
}
|
||||||
@@ -667,13 +600,13 @@ mod tests {
|
|||||||
// so dispatch reports success (degradation is logged, not propagated).
|
// so dispatch reports success (degradation is logged, not propagated).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn dispatch_returns_ok_on_partial_failure() {
|
async fn dispatch_returns_ok_on_partial_failure() {
|
||||||
let pipeline = pipeline_with(vec![MockTarget::new("ok:webhook", false), MockTarget::new("bad:webhook", true)]);
|
let pipeline = pipeline_with(vec![mock_target("ok:webhook", false), mock_target("bad:webhook", true)]);
|
||||||
pipeline.dispatch(entry()).await.expect("partial success should return Ok");
|
pipeline.dispatch(entry()).await.expect("partial success should return Ok");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn dispatch_returns_ok_when_all_targets_succeed() {
|
async fn dispatch_returns_ok_when_all_targets_succeed() {
|
||||||
let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", false), MockTarget::new("b:webhook", false)]);
|
let pipeline = pipeline_with(vec![mock_target("a:webhook", false), mock_target("b:webhook", false)]);
|
||||||
pipeline.dispatch(entry()).await.expect("all-success should return Ok");
|
pipeline.dispatch(entry()).await.expect("all-success should return Ok");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -686,9 +619,10 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn health_probe_does_not_hold_the_registry_lock() {
|
async fn health_probe_does_not_hold_the_registry_lock() {
|
||||||
let started = Arc::new(Notify::new());
|
|
||||||
let release = 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 target = mock_target("blocked", false).with_health_gate(release.clone());
|
||||||
|
let started = target.health_started();
|
||||||
|
let pipeline = pipeline_with(vec![target]);
|
||||||
let registry = Arc::clone(&pipeline.registry);
|
let registry = Arc::clone(&pipeline.registry);
|
||||||
let snapshot_task = tokio::spawn(async move { pipeline.snapshot_target_health().await });
|
let snapshot_task = tokio::spawn(async move { pipeline.snapshot_target_health().await });
|
||||||
started.notified().await;
|
started.notified().await;
|
||||||
@@ -706,14 +640,14 @@ mod tests {
|
|||||||
// whole-batch loss instead of returning Ok.
|
// whole-batch loss instead of returning Ok.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn dispatch_batch_returns_err_when_all_targets_fail() {
|
async fn dispatch_batch_returns_err_when_all_targets_fail() {
|
||||||
let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", true)]);
|
let pipeline = pipeline_with(vec![mock_target("a:webhook", true)]);
|
||||||
let result = pipeline.dispatch_batch(vec![entry(), entry()]).await;
|
let result = pipeline.dispatch_batch(vec![entry(), entry()]).await;
|
||||||
assert!(matches!(result, Err(AuditError::Target(_))), "expected Err, got {result:?}");
|
assert!(matches!(result, Err(AuditError::Target(_))), "expected Err, got {result:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn dispatch_batch_returns_ok_when_all_targets_succeed() {
|
async fn dispatch_batch_returns_ok_when_all_targets_succeed() {
|
||||||
let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", false), MockTarget::new("b:webhook", false)]);
|
let pipeline = pipeline_with(vec![mock_target("a:webhook", false), mock_target("b:webhook", false)]);
|
||||||
pipeline
|
pipeline
|
||||||
.dispatch_batch(vec![entry(), entry()])
|
.dispatch_batch(vec![entry(), entry()])
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -286,70 +286,10 @@ impl AuditRegistry {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::AuditRegistry;
|
use super::AuditRegistry;
|
||||||
use crate::{AuditEntry, AuditError};
|
use crate::AuditError;
|
||||||
use rustfs_targets::arn::TargetID;
|
use rustfs_targets::TargetError;
|
||||||
use rustfs_targets::store::{Key, Store};
|
use rustfs_targets::target::ChannelTargetType;
|
||||||
use rustfs_targets::target::{ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta};
|
use rustfs_targets::testkit::MockTarget;
|
||||||
use rustfs_targets::{StoreError, Target, TargetError};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct CloseTestTarget {
|
|
||||||
id: TargetID,
|
|
||||||
close_calls: Arc<AtomicUsize>,
|
|
||||||
fail_on_close: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CloseTestTarget {
|
|
||||||
fn new(id: TargetID, close_calls: Arc<AtomicUsize>, fail_on_close: bool) -> Self {
|
|
||||||
Self {
|
|
||||||
id,
|
|
||||||
close_calls,
|
|
||||||
fail_on_close,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl Target<AuditEntry> for CloseTestTarget {
|
|
||||||
fn id(&self) -> TargetID {
|
|
||||||
self.id.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn is_active(&self) -> Result<bool, TargetError> {
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(&self, _event: Arc<EntityTarget<AuditEntry>>) -> Result<(), TargetError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn close(&self) -> Result<(), TargetError> {
|
|
||||||
self.close_calls.fetch_add(1, Ordering::SeqCst);
|
|
||||||
if self.fail_on_close {
|
|
||||||
Err(TargetError::Unknown("close failed".to_string()))
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clone_dyn(&self) -> Box<dyn Target<AuditEntry> + Send + Sync> {
|
|
||||||
Box::new(self.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_enabled(&self) -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registry_registers_amqp_factory() {
|
fn registry_registers_amqp_factory() {
|
||||||
@@ -361,23 +301,21 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn close_all_returns_first_error_and_clears_targets() {
|
async fn close_all_returns_first_error_and_clears_targets() {
|
||||||
let mut registry = AuditRegistry::new();
|
let mut registry = AuditRegistry::new();
|
||||||
let ok_calls = Arc::new(AtomicUsize::new(0));
|
let ok = MockTarget::new("ok", "webhook");
|
||||||
let fail_calls = Arc::new(AtomicUsize::new(0));
|
let ok_observer = ok.clone();
|
||||||
|
let fail = MockTarget::new("fail", "webhook")
|
||||||
|
.with_close_failures(usize::MAX)
|
||||||
|
.with_close_failure_error(|| TargetError::Unknown("close failed".to_string()));
|
||||||
|
let fail_observer = fail.clone();
|
||||||
|
|
||||||
let ok_id = TargetID::new("ok".to_string(), "webhook".to_string());
|
registry.add_target(ok.target_id().to_string(), Box::new(ok));
|
||||||
let fail_id = TargetID::new("fail".to_string(), "webhook".to_string());
|
registry.add_target(fail.target_id().to_string(), Box::new(fail));
|
||||||
|
|
||||||
registry.add_target(ok_id.to_string(), Box::new(CloseTestTarget::new(ok_id, Arc::clone(&ok_calls), false)));
|
|
||||||
registry.add_target(
|
|
||||||
fail_id.to_string(),
|
|
||||||
Box::new(CloseTestTarget::new(fail_id, Arc::clone(&fail_calls), true)),
|
|
||||||
);
|
|
||||||
|
|
||||||
let result = registry.close_all().await;
|
let result = registry.close_all().await;
|
||||||
|
|
||||||
assert!(matches!(result, Err(AuditError::Target(TargetError::Unknown(_)))));
|
assert!(matches!(result, Err(AuditError::Target(TargetError::Unknown(_)))));
|
||||||
assert_eq!(ok_calls.load(Ordering::SeqCst), 1);
|
assert_eq!(ok_observer.close_call_count(), 1);
|
||||||
assert_eq!(fail_calls.load(Ordering::SeqCst), 1);
|
assert_eq!(fail_observer.close_call_count(), 1);
|
||||||
assert!(registry.list_targets().is_empty());
|
assert!(registry.list_targets().is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-70
@@ -577,76 +577,17 @@ fn warn_audit_state(state: &str, reason: Option<&str>) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{AuditSystem, AuditSystemState};
|
use super::{AuditSystem, AuditSystemState};
|
||||||
use crate::{AuditEntry, AuditError};
|
use crate::{AuditEntry, AuditError};
|
||||||
use async_trait::async_trait;
|
|
||||||
use rustfs_targets::ReplayWorkerManager;
|
use rustfs_targets::ReplayWorkerManager;
|
||||||
use rustfs_targets::arn::TargetID;
|
use rustfs_targets::testkit::MockTarget;
|
||||||
use rustfs_targets::store::{Key, Store};
|
|
||||||
use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
|
|
||||||
use rustfs_targets::{StoreError, Target, TargetError};
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct TestTarget {
|
|
||||||
close_calls: Arc<AtomicUsize>,
|
|
||||||
id: TargetID,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TestTarget {
|
|
||||||
fn new(id: &str, name: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
close_calls: Arc::new(AtomicUsize::new(0)),
|
|
||||||
id: TargetID::new(id.to_string(), name.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl<E> Target<E> for TestTarget
|
|
||||||
where
|
|
||||||
E: rustfs_targets::PluginEvent,
|
|
||||||
{
|
|
||||||
fn id(&self) -> TargetID {
|
|
||||||
self.id.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn is_active(&self) -> Result<bool, TargetError> {
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(&self, _event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn close(&self) -> Result<(), TargetError> {
|
|
||||||
self.close_calls.fetch_add(1, Ordering::SeqCst);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
|
|
||||||
Box::new(self.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_enabled(&self) -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn reload_with_empty_config_stops_existing_runtime() {
|
async fn reload_with_empty_config_stops_existing_runtime() {
|
||||||
let system = AuditSystem::new();
|
let system = AuditSystem::new();
|
||||||
let target = TestTarget::new("primary", "webhook");
|
let target = MockTarget::new("primary", "webhook");
|
||||||
let close_calls = Arc::clone(&target.close_calls);
|
let observer = target.clone();
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut registry = system.registry.lock().await;
|
let mut registry = system.registry.lock().await;
|
||||||
@@ -671,7 +612,7 @@ mod tests {
|
|||||||
assert_eq!(system.get_state().await, AuditSystemState::Stopped);
|
assert_eq!(system.get_state().await, AuditSystemState::Stopped);
|
||||||
assert!(system.list_targets().await.is_empty());
|
assert!(system.list_targets().await.is_empty());
|
||||||
assert_eq!(system.runtime_status_snapshot().await, ReplayWorkerManager::new().snapshot(0));
|
assert_eq!(system.runtime_status_snapshot().await, ReplayWorkerManager::new().snapshot(0));
|
||||||
assert_eq!(close_calls.load(Ordering::SeqCst), 1);
|
assert_eq!(observer.close_call_count(), 1);
|
||||||
assert_eq!(*system.config.read().await, Some(rustfs_config::server_config::Config(HashMap::new())));
|
assert_eq!(*system.config.read().await, Some(rustfs_config::server_config::Config(HashMap::new())));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,7 +634,7 @@ mod tests {
|
|||||||
// Seed a target + replay worker so both critical sections touch real state.
|
// Seed a target + replay worker so both critical sections touch real state.
|
||||||
{
|
{
|
||||||
let mut registry = system.registry.lock().await;
|
let mut registry = system.registry.lock().await;
|
||||||
registry.add_target("primary:webhook".to_string(), Box::new(TestTarget::new("primary", "webhook")));
|
registry.add_target("primary:webhook".to_string(), Box::new(MockTarget::new("primary", "webhook")));
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
let mut replay_workers = system.stream_cancellers.write().await;
|
let mut replay_workers = system.stream_cancellers.write().await;
|
||||||
@@ -793,8 +734,8 @@ mod tests {
|
|||||||
async fn commit_closes_old_targets_before_installing_new() {
|
async fn commit_closes_old_targets_before_installing_new() {
|
||||||
let system = AuditSystem::new();
|
let system = AuditSystem::new();
|
||||||
|
|
||||||
let old = TestTarget::new("old", "webhook");
|
let old = MockTarget::new("old", "webhook");
|
||||||
let old_close = Arc::clone(&old.close_calls);
|
let old_observer = old.clone();
|
||||||
{
|
{
|
||||||
let mut registry = system.registry.lock().await;
|
let mut registry = system.registry.lock().await;
|
||||||
registry.add_target("old:webhook".to_string(), Box::new(old));
|
registry.add_target("old:webhook".to_string(), Box::new(old));
|
||||||
@@ -809,17 +750,17 @@ mod tests {
|
|||||||
*state = AuditSystemState::Running;
|
*state = AuditSystemState::Running;
|
||||||
}
|
}
|
||||||
|
|
||||||
let new = TestTarget::new("new", "webhook");
|
let new = MockTarget::new("new", "webhook");
|
||||||
let new_close = Arc::clone(&new.close_calls);
|
let new_observer = new.clone();
|
||||||
system
|
system
|
||||||
.commit_runtime_targets(vec![Box::new(new)], AuditSystemState::Running)
|
.commit_runtime_targets(vec![Box::new(new)], AuditSystemState::Running)
|
||||||
.await
|
.await
|
||||||
.expect("commit should succeed");
|
.expect("commit should succeed");
|
||||||
|
|
||||||
// Old target closed exactly once during the pre-install shutdown.
|
// Old target closed exactly once during the pre-install shutdown.
|
||||||
assert_eq!(old_close.load(Ordering::SeqCst), 1);
|
assert_eq!(old_observer.close_call_count(), 1);
|
||||||
// New target installed and left open.
|
// New target installed and left open.
|
||||||
assert_eq!(new_close.load(Ordering::SeqCst), 0);
|
assert_eq!(new_observer.close_call_count(), 0);
|
||||||
assert_eq!(system.list_targets().await, vec!["new:webhook".to_string()]);
|
assert_eq!(system.list_targets().await, vec!["new:webhook".to_string()]);
|
||||||
// Old replay worker stopped; the store-less new target adds none.
|
// Old replay worker stopped; the store-less new target adds none.
|
||||||
assert_eq!(system.runtime_status_snapshot().await.replay_worker_count, 0);
|
assert_eq!(system.runtime_status_snapshot().await.replay_worker_count, 0);
|
||||||
|
|||||||
@@ -12,136 +12,16 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use rustfs_audit::{AuditEntry, AuditError, AuditPipeline, AuditRegistry, AuditRuntimeFacade, AuditRuntimeView};
|
use rustfs_audit::{AuditEntry, AuditError, AuditPipeline, AuditRegistry, AuditRuntimeFacade, AuditRuntimeView};
|
||||||
use rustfs_targets::arn::TargetID;
|
use rustfs_targets::SharedTarget;
|
||||||
use rustfs_targets::store::{Key, Store};
|
use rustfs_targets::testkit::MockTarget;
|
||||||
use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
|
|
||||||
use rustfs_targets::{SharedTarget, StoreError, Target, TargetError};
|
|
||||||
use serde::{Serialize, de::DeserializeOwned};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
||||||
use tokio::sync::{Mutex, RwLock};
|
use tokio::sync::{Mutex, RwLock};
|
||||||
|
|
||||||
#[derive(Clone)]
|
/// Builds a target whose `save()` always fails, used to exercise the dispatch
|
||||||
struct TestTarget {
|
|
||||||
close_calls: Arc<AtomicUsize>,
|
|
||||||
id: TargetID,
|
|
||||||
init_calls: Arc<AtomicUsize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TestTarget {
|
|
||||||
fn new(id: &str, name: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
close_calls: Arc::new(AtomicUsize::new(0)),
|
|
||||||
id: TargetID::new(id.to_string(), name.to_string()),
|
|
||||||
init_calls: Arc::new(AtomicUsize::new(0)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl<E> Target<E> for TestTarget
|
|
||||||
where
|
|
||||||
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
|
|
||||||
{
|
|
||||||
fn id(&self) -> TargetID {
|
|
||||||
self.id.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn is_active(&self) -> Result<bool, TargetError> {
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(&self, _event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn close(&self) -> Result<(), TargetError> {
|
|
||||||
self.close_calls.fetch_add(1, Ordering::SeqCst);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
|
|
||||||
Box::new(self.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn init(&self) -> Result<(), TargetError> {
|
|
||||||
self.init_calls.fetch_add(1, Ordering::SeqCst);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_enabled(&self) -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A target whose `save()` always fails, used to exercise the dispatch
|
|
||||||
/// failure-propagation paths.
|
/// failure-propagation paths.
|
||||||
#[derive(Clone)]
|
fn failing_target(id: &str, name: &str) -> MockTarget {
|
||||||
struct FailingTarget {
|
MockTarget::new(id, name).with_save_failures(usize::MAX)
|
||||||
id: TargetID,
|
|
||||||
save_calls: Arc<AtomicUsize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FailingTarget {
|
|
||||||
fn new(id: &str, name: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
id: TargetID::new(id.to_string(), name.to_string()),
|
|
||||||
save_calls: Arc::new(AtomicUsize::new(0)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl<E> Target<E> for FailingTarget
|
|
||||||
where
|
|
||||||
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
|
|
||||||
{
|
|
||||||
fn id(&self) -> TargetID {
|
|
||||||
self.id.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn is_active(&self) -> Result<bool, TargetError> {
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(&self, _event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
|
|
||||||
self.save_calls.fetch_add(1, Ordering::SeqCst);
|
|
||||||
Err(TargetError::Storage("disk full".to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn close(&self) -> Result<(), TargetError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
|
|
||||||
Box::new(self.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn init(&self) -> Result<(), TargetError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_enabled(&self) -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pipeline_with_targets(targets: Vec<(&str, SharedTarget<AuditEntry>)>) -> AuditPipeline {
|
fn pipeline_with_targets(targets: Vec<(&str, SharedTarget<AuditEntry>)>) -> AuditPipeline {
|
||||||
@@ -154,8 +34,8 @@ fn pipeline_with_targets(targets: Vec<(&str, SharedTarget<AuditEntry>)>) -> Audi
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn audit_pipeline_dispatch_propagates_total_failure() {
|
async fn audit_pipeline_dispatch_propagates_total_failure() {
|
||||||
let failing = FailingTarget::new("primary", "webhook");
|
let failing = failing_target("primary", "webhook");
|
||||||
let save_calls = Arc::clone(&failing.save_calls);
|
let observer = failing.clone();
|
||||||
let pipeline = pipeline_with_targets(vec![("primary:webhook", Arc::new(failing))]);
|
let pipeline = pipeline_with_targets(vec![("primary:webhook", Arc::new(failing))]);
|
||||||
|
|
||||||
let result = pipeline.dispatch(Arc::new(AuditEntry::default())).await;
|
let result = pipeline.dispatch(Arc::new(AuditEntry::default())).await;
|
||||||
@@ -164,13 +44,13 @@ async fn audit_pipeline_dispatch_propagates_total_failure() {
|
|||||||
matches!(result, Err(AuditError::Target(_))),
|
matches!(result, Err(AuditError::Target(_))),
|
||||||
"dispatch must surface an error when every target fails, got {result:?}"
|
"dispatch must surface an error when every target fails, got {result:?}"
|
||||||
);
|
);
|
||||||
assert_eq!(save_calls.load(Ordering::SeqCst), 1, "the failing target should have been invoked");
|
assert_eq!(observer.save_call_count(), 1, "the failing target should have been invoked");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn audit_pipeline_dispatch_tolerates_partial_failure() {
|
async fn audit_pipeline_dispatch_tolerates_partial_failure() {
|
||||||
let failing = FailingTarget::new("primary", "webhook");
|
let failing = failing_target("primary", "webhook");
|
||||||
let healthy = TestTarget::new("secondary", "webhook");
|
let healthy = MockTarget::new("secondary", "webhook");
|
||||||
let pipeline = pipeline_with_targets(vec![
|
let pipeline = pipeline_with_targets(vec![
|
||||||
("primary:webhook", Arc::new(failing)),
|
("primary:webhook", Arc::new(failing)),
|
||||||
("secondary:webhook", Arc::new(healthy)),
|
("secondary:webhook", Arc::new(healthy)),
|
||||||
@@ -186,7 +66,7 @@ async fn audit_pipeline_dispatch_tolerates_partial_failure() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn audit_pipeline_dispatch_batch_propagates_total_failure() {
|
async fn audit_pipeline_dispatch_batch_propagates_total_failure() {
|
||||||
let failing = FailingTarget::new("primary", "webhook");
|
let failing = failing_target("primary", "webhook");
|
||||||
let pipeline = pipeline_with_targets(vec![("primary:webhook", Arc::new(failing))]);
|
let pipeline = pipeline_with_targets(vec![("primary:webhook", Arc::new(failing))]);
|
||||||
|
|
||||||
let entries = vec![Arc::new(AuditEntry::default()), Arc::new(AuditEntry::default())];
|
let entries = vec![Arc::new(AuditEntry::default()), Arc::new(AuditEntry::default())];
|
||||||
@@ -200,8 +80,8 @@ async fn audit_pipeline_dispatch_batch_propagates_total_failure() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn audit_pipeline_dispatch_batch_tolerates_partial_failure() {
|
async fn audit_pipeline_dispatch_batch_tolerates_partial_failure() {
|
||||||
let failing = FailingTarget::new("primary", "webhook");
|
let failing = failing_target("primary", "webhook");
|
||||||
let healthy = TestTarget::new("secondary", "webhook");
|
let healthy = MockTarget::new("secondary", "webhook");
|
||||||
let pipeline = pipeline_with_targets(vec![
|
let pipeline = pipeline_with_targets(vec![
|
||||||
("primary:webhook", Arc::new(failing)),
|
("primary:webhook", Arc::new(failing)),
|
||||||
("secondary:webhook", Arc::new(healthy)),
|
("secondary:webhook", Arc::new(healthy)),
|
||||||
@@ -266,9 +146,8 @@ async fn audit_runtime_facade_activates_empty_target_list() {
|
|||||||
async fn audit_runtime_view_upsert_and_remove_target() {
|
async fn audit_runtime_view_upsert_and_remove_target() {
|
||||||
let registry = Arc::new(Mutex::new(AuditRegistry::new()));
|
let registry = Arc::new(Mutex::new(AuditRegistry::new()));
|
||||||
let runtime_view = AuditRuntimeView::new(registry.clone());
|
let runtime_view = AuditRuntimeView::new(registry.clone());
|
||||||
let target = TestTarget::new("primary", "webhook");
|
let target = MockTarget::new("primary", "webhook");
|
||||||
let init_calls = Arc::clone(&target.init_calls);
|
let observer = target.clone();
|
||||||
let close_calls = Arc::clone(&target.close_calls);
|
|
||||||
|
|
||||||
runtime_view
|
runtime_view
|
||||||
.upsert_target("primary:webhook".to_string(), Box::new(target))
|
.upsert_target("primary:webhook".to_string(), Box::new(target))
|
||||||
@@ -276,7 +155,7 @@ async fn audit_runtime_view_upsert_and_remove_target() {
|
|||||||
.expect("upsert should succeed");
|
.expect("upsert should succeed");
|
||||||
|
|
||||||
assert_eq!(runtime_view.list_targets().await, vec!["primary:webhook".to_string()]);
|
assert_eq!(runtime_view.list_targets().await, vec!["primary:webhook".to_string()]);
|
||||||
assert_eq!(init_calls.load(Ordering::SeqCst), 1);
|
assert_eq!(observer.init_call_count(), 1);
|
||||||
|
|
||||||
runtime_view
|
runtime_view
|
||||||
.remove_target("primary:webhook")
|
.remove_target("primary:webhook")
|
||||||
@@ -284,7 +163,7 @@ async fn audit_runtime_view_upsert_and_remove_target() {
|
|||||||
.expect("remove should succeed");
|
.expect("remove should succeed");
|
||||||
|
|
||||||
assert!(runtime_view.list_targets().await.is_empty());
|
assert!(runtime_view.list_targets().await.is_empty());
|
||||||
assert_eq!(close_calls.load(Ordering::SeqCst), 1);
|
assert_eq!(observer.close_call_count(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -292,7 +171,7 @@ async fn audit_runtime_facade_replace_targets_commits_runtime_state() {
|
|||||||
let registry = Arc::new(Mutex::new(AuditRegistry::new()));
|
let registry = Arc::new(Mutex::new(AuditRegistry::new()));
|
||||||
let replay_workers = Arc::new(RwLock::new(rustfs_targets::ReplayWorkerManager::new()));
|
let replay_workers = Arc::new(RwLock::new(rustfs_targets::ReplayWorkerManager::new()));
|
||||||
let facade = AuditRuntimeFacade::new(registry.clone(), replay_workers.clone());
|
let facade = AuditRuntimeFacade::new(registry.clone(), replay_workers.clone());
|
||||||
let target = TestTarget::new("primary", "webhook");
|
let target = MockTarget::new("primary", "webhook");
|
||||||
let activation = rustfs_targets::RuntimeActivation {
|
let activation = rustfs_targets::RuntimeActivation {
|
||||||
replay_workers: rustfs_targets::ReplayWorkerManager::new(),
|
replay_workers: rustfs_targets::ReplayWorkerManager::new(),
|
||||||
targets: vec![Arc::new(target) as rustfs_targets::SharedTarget<rustfs_audit::AuditEntry>],
|
targets: vec![Arc::new(target) as rustfs_targets::SharedTarget<rustfs_audit::AuditEntry>],
|
||||||
|
|||||||
+165
-8
@@ -41,14 +41,22 @@ pub const XXHASH_64_NAME: &str = "xxhash64";
|
|||||||
pub const XXHASH_128_NAME: &str = "xxhash128";
|
pub const XXHASH_128_NAME: &str = "xxhash128";
|
||||||
pub const MD5_NAME: &str = "md5";
|
pub const MD5_NAME: &str = "md5";
|
||||||
|
|
||||||
/// One of three deliberately separate checksum registries (backlog#1833):
|
/// The canonical checksum-algorithm registry (backlog#1833, backlog#1844):
|
||||||
/// this enum owns the **streaming-hash algorithm registry**, including the
|
/// this enum owns the streaming-hash implementations and, via the exhaustive
|
||||||
/// RustFS extensions (sha512, xxhash3/64/128). The on-disk xl.meta bitset
|
/// per-algorithm metadata methods below, the wire names, header names, digest
|
||||||
/// lives in `rustfs_rio::ChecksumType` (crates/rio/src/checksum.rs, varint
|
/// lengths, and checksum-type capabilities — including the RustFS extensions
|
||||||
/// bits are append-only), and the MinIO-port client keeps its own
|
/// (sha512, xxhash3/64/128). The MinIO-port client's `ChecksumMode`
|
||||||
/// `ChecksumMode` (crates/ecstore/src/client/checksum.rs). When adding an
|
/// (crates/s3-client/src/checksum.rs) delegates all per-algorithm dispatch
|
||||||
/// algorithm, extend all three (or record why not) — they do not derive from
|
/// here through its `algorithm()` bridge. The on-disk xl.meta bitset remains
|
||||||
/// each other.
|
/// deliberately separate in `rustfs_rio::ChecksumType`
|
||||||
|
/// (crates/rio/src/checksum.rs, varint bits are append-only), and rio also
|
||||||
|
/// keeps its own hot-path hasher shells — equivalence with this crate's
|
||||||
|
/// hashers is enforced by both test suites pinning the same official
|
||||||
|
/// known-answer vectors (backlog#1844 PR3 verdict, recorded on
|
||||||
|
/// `rustfs_rio::ChecksumType`). When adding an algorithm: add the variant
|
||||||
|
/// here (the exhaustive matches force every metadata decision), bridge it in
|
||||||
|
/// the client, and allocate an xl.meta bit + hasher + shared vector in rio
|
||||||
|
/// (or record why not).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
pub enum ChecksumAlgorithm {
|
pub enum ChecksumAlgorithm {
|
||||||
@@ -120,6 +128,84 @@ impl ChecksumAlgorithm {
|
|||||||
Self::Xxhash128 => XXHASH_128_NAME,
|
Self::Xxhash128 => XXHASH_128_NAME,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-algorithm wire metadata. These matches are deliberately exhaustive
|
||||||
|
// (no `_` arm): adding a ChecksumAlgorithm variant without deciding its
|
||||||
|
// name, header, digest length, and checksum-type support must fail to
|
||||||
|
// compile rather than silently inherit a default (backlog#1844).
|
||||||
|
|
||||||
|
/// The canonical `x-amz-checksum-algorithm` wire value (uppercase), as
|
||||||
|
/// carried in S3 requests/responses and stored checksum maps.
|
||||||
|
pub fn s3_algorithm_name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Crc32 => "CRC32",
|
||||||
|
Self::Crc32c => "CRC32C",
|
||||||
|
Self::Crc64Nvme => "CRC64NVME",
|
||||||
|
Self::Sha1 => "SHA1",
|
||||||
|
Self::Sha256 => "SHA256",
|
||||||
|
Self::Sha512 => "SHA512",
|
||||||
|
Self::Xxhash3 => "XXHASH3",
|
||||||
|
Self::Xxhash64 => "XXHASH64",
|
||||||
|
Self::Xxhash128 => "XXHASH128",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `x-amz-checksum-*` HTTP header that carries this algorithm's
|
||||||
|
/// base64-encoded digest.
|
||||||
|
pub fn http_header_name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Crc32 => http::CRC_32_HEADER_NAME,
|
||||||
|
Self::Crc32c => http::CRC_32_C_HEADER_NAME,
|
||||||
|
Self::Crc64Nvme => http::CRC_64_NVME_HEADER_NAME,
|
||||||
|
Self::Sha1 => http::SHA_1_HEADER_NAME,
|
||||||
|
Self::Sha256 => http::SHA_256_HEADER_NAME,
|
||||||
|
Self::Sha512 => http::SHA_512_HEADER_NAME,
|
||||||
|
Self::Xxhash3 => http::XXHASH_3_HEADER_NAME,
|
||||||
|
Self::Xxhash64 => http::XXHASH_64_HEADER_NAME,
|
||||||
|
Self::Xxhash128 => http::XXHASH_128_HEADER_NAME,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw (unencoded) digest length in bytes.
|
||||||
|
pub fn raw_len(&self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::Crc32 | Self::Crc32c => 4,
|
||||||
|
Self::Crc64Nvme => 8,
|
||||||
|
Self::Sha1 => 20,
|
||||||
|
Self::Sha256 => 32,
|
||||||
|
Self::Sha512 => 64,
|
||||||
|
Self::Xxhash3 | Self::Xxhash64 => 8,
|
||||||
|
Self::Xxhash128 => 16,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the algorithm supports the S3 COMPOSITE multipart checksum
|
||||||
|
/// type. Per the AWS registry, every algorithm does except CRC64NVME,
|
||||||
|
/// which is FULL_OBJECT-only.
|
||||||
|
pub fn supports_composite(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::Crc64Nvme => false,
|
||||||
|
Self::Crc32
|
||||||
|
| Self::Crc32c
|
||||||
|
| Self::Sha1
|
||||||
|
| Self::Sha256
|
||||||
|
| Self::Sha512
|
||||||
|
| Self::Xxhash3
|
||||||
|
| Self::Xxhash64
|
||||||
|
| Self::Xxhash128 => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the algorithm supports the S3 FULL_OBJECT checksum type, i.e.
|
||||||
|
/// part digests can be linearly combined into the whole-object digest.
|
||||||
|
/// Only the CRC family has this property; the hash algorithms are
|
||||||
|
/// COMPOSITE-only.
|
||||||
|
pub fn supports_full_object(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::Crc32 | Self::Crc32c | Self::Crc64Nvme => true,
|
||||||
|
Self::Sha1 | Self::Sha256 | Self::Sha512 | Self::Xxhash3 | Self::Xxhash64 | Self::Xxhash128 => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait Checksum: Send + Sync {
|
pub trait Checksum: Send + Sync {
|
||||||
@@ -731,6 +817,77 @@ mod tests {
|
|||||||
assert_eq!(&raw[..], reference.digest128().to_be_bytes().as_slice());
|
assert_eq!(&raw[..], reference.digest128().to_be_bytes().as_slice());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_algorithm_metadata_is_consistent_for_every_variant() {
|
||||||
|
use crate::Checksum;
|
||||||
|
|
||||||
|
// Cross-checks the per-algorithm metadata methods against the hasher
|
||||||
|
// implementations themselves, so the registry cannot drift from the
|
||||||
|
// code that computes digests (backlog#1844). The list must cover every
|
||||||
|
// variant; the metadata methods use exhaustive matches, so a new
|
||||||
|
// variant that is missing here still fails to compile there first.
|
||||||
|
let all = [
|
||||||
|
ChecksumAlgorithm::Crc32,
|
||||||
|
ChecksumAlgorithm::Crc32c,
|
||||||
|
ChecksumAlgorithm::Crc64Nvme,
|
||||||
|
ChecksumAlgorithm::Sha1,
|
||||||
|
ChecksumAlgorithm::Sha256,
|
||||||
|
ChecksumAlgorithm::Sha512,
|
||||||
|
ChecksumAlgorithm::Xxhash3,
|
||||||
|
ChecksumAlgorithm::Xxhash64,
|
||||||
|
ChecksumAlgorithm::Xxhash128,
|
||||||
|
];
|
||||||
|
|
||||||
|
for algorithm in all {
|
||||||
|
// Digest length must match what the hasher actually produces.
|
||||||
|
let mut hasher = algorithm.into_impl();
|
||||||
|
hasher.update(b"metadata consistency probe");
|
||||||
|
assert_eq!(
|
||||||
|
algorithm.raw_len(),
|
||||||
|
Checksum::size(&*algorithm.into_impl()) as usize,
|
||||||
|
"{algorithm:?} raw_len() != hasher size()"
|
||||||
|
);
|
||||||
|
assert_eq!(hasher.finalize().len(), algorithm.raw_len(), "{algorithm:?} finalize length != raw_len()");
|
||||||
|
|
||||||
|
// Header name must match the hasher's own header binding.
|
||||||
|
assert_eq!(
|
||||||
|
algorithm.http_header_name(),
|
||||||
|
algorithm.into_impl().header_name(),
|
||||||
|
"{algorithm:?} http_header_name() != HttpChecksum::header_name()"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
algorithm.http_header_name(),
|
||||||
|
format!("x-amz-checksum-{}", algorithm.as_str()),
|
||||||
|
"{algorithm:?} header must be x-amz-checksum-<name>"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The uppercase wire name and the lowercase parse name must be the
|
||||||
|
// same word, and the wire name must parse back to the variant.
|
||||||
|
assert!(
|
||||||
|
algorithm.s3_algorithm_name().eq_ignore_ascii_case(algorithm.as_str()),
|
||||||
|
"{algorithm:?} s3_algorithm_name() and as_str() diverge"
|
||||||
|
);
|
||||||
|
assert_eq!(algorithm.s3_algorithm_name().parse::<ChecksumAlgorithm>().unwrap(), algorithm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AWS checksum-type support table: CRC64NVME is FULL_OBJECT-only, the
|
||||||
|
// CRC family supports FULL_OBJECT, everything else is COMPOSITE-only.
|
||||||
|
for algorithm in all {
|
||||||
|
let composite = algorithm.supports_composite();
|
||||||
|
let full_object = algorithm.supports_full_object();
|
||||||
|
assert!(composite || full_object, "{algorithm:?} supports no checksum type at all");
|
||||||
|
match algorithm {
|
||||||
|
ChecksumAlgorithm::Crc32 | ChecksumAlgorithm::Crc32c => {
|
||||||
|
assert!(composite && full_object, "{algorithm:?} must support both checksum types")
|
||||||
|
}
|
||||||
|
ChecksumAlgorithm::Crc64Nvme => {
|
||||||
|
assert!(!composite && full_object, "CRC64NVME must be FULL_OBJECT-only")
|
||||||
|
}
|
||||||
|
_ => assert!(composite && !full_object, "{algorithm:?} must be COMPOSITE-only"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_xxhash64_matches_direct_computation_big_endian_seed0() {
|
fn test_xxhash64_matches_direct_computation_big_endian_seed0() {
|
||||||
use crate::Xxhash64;
|
use crate::Xxhash64;
|
||||||
|
|||||||
@@ -38,16 +38,9 @@ hotpath.workspace = true
|
|||||||
tokio = { workspace = true, features = ["fs", "rt-multi-thread"] }
|
tokio = { workspace = true, features = ["fs", "rt-multi-thread"] }
|
||||||
tonic = { workspace = true, features = ["gzip", "deflate"] }
|
tonic = { workspace = true, features = ["gzip", "deflate"] }
|
||||||
uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] }
|
uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] }
|
||||||
chrono = { workspace = true, features = ["serde"] }
|
|
||||||
jiff = { workspace = true, features = ["serde"] }
|
|
||||||
metrics = { workspace = true }
|
metrics = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
|
||||||
smallvec = { workspace = true }
|
smallvec = { workspace = true }
|
||||||
rmp-serde = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
doctest = false
|
doctest = false
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
@@ -27,8 +26,6 @@ pub static GLOBAL_CONN_MAP: LazyLock<RwLock<HashMap<String, Channel>>> = LazyLoc
|
|||||||
pub static GLOBAL_ROOT_CERT: LazyLock<RwLock<Option<Vec<u8>>>> = LazyLock::new(|| RwLock::new(None));
|
pub static GLOBAL_ROOT_CERT: LazyLock<RwLock<Option<Vec<u8>>>> = LazyLock::new(|| RwLock::new(None));
|
||||||
pub static GLOBAL_MTLS_IDENTITY: LazyLock<RwLock<Option<MtlsIdentityPem>>> = LazyLock::new(|| RwLock::new(None));
|
pub static GLOBAL_MTLS_IDENTITY: LazyLock<RwLock<Option<MtlsIdentityPem>>> = LazyLock::new(|| RwLock::new(None));
|
||||||
pub static GLOBAL_OUTBOUND_TLS_GENERATION: LazyLock<AtomicU64> = LazyLock::new(|| AtomicU64::new(0));
|
pub static GLOBAL_OUTBOUND_TLS_GENERATION: LazyLock<AtomicU64> = LazyLock::new(|| AtomicU64::new(0));
|
||||||
/// Global initialization time of the RustFS node.
|
|
||||||
pub static GLOBAL_INIT_TIME: LazyLock<RwLock<Option<DateTime<Utc>>>> = LazyLock::new(|| RwLock::new(None));
|
|
||||||
|
|
||||||
/// Log level to use when reporting cached gRPC connection eviction.
|
/// Log level to use when reporting cached gRPC connection eviction.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -63,20 +60,6 @@ pub fn try_get_global_local_node_name() -> Option<String> {
|
|||||||
.filter(|name| !name.is_empty())
|
.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();
|
|
||||||
*GLOBAL_INIT_TIME.write().await = Some(now);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the global RustFS initialization time.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
/// * `Option<DateTime<Utc>>` - The initialization time if set.
|
|
||||||
pub async fn get_global_init_time() -> Option<DateTime<Utc>> {
|
|
||||||
*GLOBAL_INIT_TIME.read().await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the global RustFS address used for gRPC connections.
|
/// Set the global RustFS address used for gRPC connections.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
|
|||||||
@@ -14,9 +14,6 @@
|
|||||||
|
|
||||||
// pub mod error;
|
// pub mod error;
|
||||||
pub mod globals;
|
pub mod globals;
|
||||||
pub mod heal_channel;
|
|
||||||
pub mod last_minute;
|
|
||||||
pub mod metrics;
|
|
||||||
pub mod mrf_channel;
|
pub mod mrf_channel;
|
||||||
mod readiness;
|
mod readiness;
|
||||||
pub mod table_catalog;
|
pub mod table_catalog;
|
||||||
|
|||||||
@@ -150,9 +150,10 @@ Drive timeout health-action policy:
|
|||||||
Drive timeout profile preset:
|
Drive timeout profile preset:
|
||||||
- `RUSTFS_DRIVE_TIMEOUT_PROFILE`
|
- `RUSTFS_DRIVE_TIMEOUT_PROFILE`
|
||||||
- `default` (default): keep current timeout defaults.
|
- `default` (default): keep current timeout defaults.
|
||||||
- `high_latency`: use 60s default timeout for scanner-sensitive operations when no per-operation timeout override is set (`read_metadata`, `disk_info`, `list_dir`, `walk_dir`, `walk_dir_stall`).
|
- `high_latency`: use 60s default timeout for scanner-sensitive operations when no operation-specific override is set (`read_metadata`, `disk_info`, `list_dir`, `walk_dir`, `walk_dir_stall`, and object-capacity scan base/maximum budgets).
|
||||||
- Precedence:
|
- Precedence:
|
||||||
- Explicit per-operation timeout env (`RUSTFS_DRIVE_*_TIMEOUT_SECS`) takes highest precedence.
|
- Explicit per-operation timeout env (`RUSTFS_DRIVE_*_TIMEOUT_SECS`) takes highest precedence.
|
||||||
|
- Explicit object-capacity timeout env (`RUSTFS_CAPACITY_STAT_TIMEOUT`, `RUSTFS_CAPACITY_MAX_TIMEOUT`) takes precedence for capacity scans.
|
||||||
- Then `RUSTFS_DRIVE_MAX_TIMEOUT_DURATION` legacy fallback.
|
- Then `RUSTFS_DRIVE_MAX_TIMEOUT_DURATION` legacy fallback.
|
||||||
- Then the profile-derived default (`default` or `high_latency`).
|
- Then the profile-derived default (`default` or `high_latency`).
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ pub const HEALTH_OBJECT_PROGRESS_LOCK_MARGIN_MS: u64 = 5_000;
|
|||||||
pub const ENV_HEALTH_CLUSTER_TIMEOUT_MS: &str = "RUSTFS_HEALTH_CLUSTER_TIMEOUT_MS";
|
pub const ENV_HEALTH_CLUSTER_TIMEOUT_MS: &str = "RUSTFS_HEALTH_CLUSTER_TIMEOUT_MS";
|
||||||
pub const DEFAULT_HEALTH_CLUSTER_TIMEOUT_MS: u64 = 2000;
|
pub const DEFAULT_HEALTH_CLUSTER_TIMEOUT_MS: u64 = 2000;
|
||||||
|
|
||||||
|
/// Timeout for one remote lock-client online check used by readiness (milliseconds).
|
||||||
|
///
|
||||||
|
/// This is intentionally shorter than the generic lock RPC timeout so
|
||||||
|
/// `/health/ready` can report degradation instead of riding a dead peer's
|
||||||
|
/// connect or HTTP/2 keepalive budget.
|
||||||
|
pub const ENV_HEALTH_LOCK_ONLINE_TIMEOUT_MS: &str = "RUSTFS_HEALTH_LOCK_ONLINE_TIMEOUT_MS";
|
||||||
|
pub const DEFAULT_HEALTH_LOCK_ONLINE_TIMEOUT_MS: u64 = 1000;
|
||||||
|
|
||||||
/// Maximum time to wait for local node runtime readiness (storage / IAM / lock
|
/// Maximum time to wait for local node runtime readiness (storage / IAM / lock
|
||||||
/// quorum) during startup before failing fast (seconds).
|
/// quorum) during startup before failing fast (seconds).
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -181,6 +181,22 @@ pub const DEFAULT_POOL_META_V2_FLEET_CONFIRMED: bool = false;
|
|||||||
const _: () = assert!(!DEFAULT_POOL_META_V2_WRITE);
|
const _: () = assert!(!DEFAULT_POOL_META_V2_WRITE);
|
||||||
const _: () = assert!(!DEFAULT_POOL_META_V2_FLEET_CONFIRMED);
|
const _: () = assert!(!DEFAULT_POOL_META_V2_FLEET_CONFIRMED);
|
||||||
|
|
||||||
|
/// Request writing pool metadata version 3 with durable generations.
|
||||||
|
///
|
||||||
|
/// Existing deployments remain on their observed version until
|
||||||
|
/// [`ENV_POOL_META_V3_FLEET_CONFIRMED`] is also enabled. Fresh deployments may
|
||||||
|
/// initialize directly at version 3 because they have no legacy readers.
|
||||||
|
pub const ENV_POOL_META_V3_WRITE: &str = "RUSTFS_POOL_META_V3_WRITE";
|
||||||
|
pub const DEFAULT_POOL_META_V3_WRITE: bool = false;
|
||||||
|
|
||||||
|
/// Operator-attested confirmation that every pool metadata reader and writer
|
||||||
|
/// understands the version 3 generation and recovery protocol.
|
||||||
|
pub const ENV_POOL_META_V3_FLEET_CONFIRMED: &str = "RUSTFS_POOL_META_V3_FLEET_CONFIRMED";
|
||||||
|
pub const DEFAULT_POOL_META_V3_FLEET_CONFIRMED: bool = false;
|
||||||
|
|
||||||
|
const _: () = assert!(!DEFAULT_POOL_META_V3_WRITE);
|
||||||
|
const _: () = assert!(!DEFAULT_POOL_META_V3_FLEET_CONFIRMED);
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Concurrent Request Fix - Timeout and Backpressure Configuration
|
// Concurrent Request Fix - Timeout and Backpressure Configuration
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -272,6 +288,39 @@ pub const DEFAULT_PUT_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 0;
|
|||||||
|
|
||||||
const _: () = assert!(!DEFAULT_PUT_FOREGROUND_ADMISSION_ENABLE);
|
const _: () = assert!(!DEFAULT_PUT_FOREGROUND_ADMISSION_ENABLE);
|
||||||
|
|
||||||
|
/// Enable automatic foreground admission for large or unknown-size PutObject requests.
|
||||||
|
///
|
||||||
|
/// Unlike the strict experimental gate above, this default-on path only applies
|
||||||
|
/// to requests that are large enough to create sustained erasure/RPC pressure.
|
||||||
|
/// Small PUTs continue on the legacy path unless the strict gate is explicitly
|
||||||
|
/// enabled.
|
||||||
|
pub const ENV_PUT_LARGE_FOREGROUND_ADMISSION_ENABLE: &str = "RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_ENABLE";
|
||||||
|
pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_ENABLE: bool = true;
|
||||||
|
|
||||||
|
/// Maximum large foreground PutObject requests admitted concurrently per process.
|
||||||
|
///
|
||||||
|
/// `0` derives a conservative default from the local disk-read scheduler cap,
|
||||||
|
/// currently clamped to protect the commit path without making ordinary high
|
||||||
|
/// throughput uploads single-file.
|
||||||
|
pub const ENV_PUT_LARGE_FOREGROUND_ADMISSION_LIMIT: &str = "RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_LIMIT";
|
||||||
|
pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_LIMIT: usize = 0;
|
||||||
|
|
||||||
|
/// Minimum object size that enters automatic large PutObject admission.
|
||||||
|
///
|
||||||
|
/// Requests with an unknown size are treated as large because the write pressure
|
||||||
|
/// cannot be bounded from headers.
|
||||||
|
pub const ENV_PUT_LARGE_FOREGROUND_ADMISSION_MIN_SIZE_BYTES: &str = "RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_MIN_SIZE_BYTES";
|
||||||
|
pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_MIN_SIZE_BYTES: usize = 32 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Time in milliseconds a large foreground PutObject waits for a permit.
|
||||||
|
///
|
||||||
|
/// A short wait smooths transient bursts while still returning S3
|
||||||
|
/// `SlowDown`/503 before body ingest when the node is already saturated.
|
||||||
|
pub const ENV_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: &str = "RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS";
|
||||||
|
pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 250;
|
||||||
|
|
||||||
|
const _: () = assert!(DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_ENABLE);
|
||||||
|
|
||||||
/// Environment variable for minimum GetObject timeout in seconds.
|
/// Environment variable for minimum GetObject timeout in seconds.
|
||||||
///
|
///
|
||||||
/// When dynamic timeout calculation is enabled, this is the minimum timeout
|
/// When dynamic timeout calculation is enabled, this is the minimum timeout
|
||||||
@@ -755,4 +804,10 @@ mod remote_version_state_tests {
|
|||||||
assert_eq!(super::ENV_POOL_META_V2_WRITE, "RUSTFS_POOL_META_V2_WRITE");
|
assert_eq!(super::ENV_POOL_META_V2_WRITE, "RUSTFS_POOL_META_V2_WRITE");
|
||||||
assert_eq!(super::ENV_POOL_META_V2_FLEET_CONFIRMED, "RUSTFS_POOL_META_V2_FLEET_CONFIRMED");
|
assert_eq!(super::ENV_POOL_META_V2_FLEET_CONFIRMED, "RUSTFS_POOL_META_V2_FLEET_CONFIRMED");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_meta_v3_gate_uses_stable_environment_names() {
|
||||||
|
assert_eq!(super::ENV_POOL_META_V3_WRITE, "RUSTFS_POOL_META_V3_WRITE");
|
||||||
|
assert_eq!(super::ENV_POOL_META_V3_FLEET_CONFIRMED, "RUSTFS_POOL_META_V3_FLEET_CONFIRMED");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ pub const ENV_ALLOCATOR_RECLAIM_ENABLED: &str = "RUSTFS_ALLOCATOR_RECLAIM_ENABLE
|
|||||||
pub const ENV_ALLOCATOR_RECLAIM_INTERVAL_SECS: &str = "RUSTFS_ALLOCATOR_RECLAIM_INTERVAL_SECS";
|
pub const ENV_ALLOCATOR_RECLAIM_INTERVAL_SECS: &str = "RUSTFS_ALLOCATOR_RECLAIM_INTERVAL_SECS";
|
||||||
pub const ENV_ALLOCATOR_RECLAIM_FORCE: &str = "RUSTFS_ALLOCATOR_RECLAIM_FORCE";
|
pub const ENV_ALLOCATOR_RECLAIM_FORCE: &str = "RUSTFS_ALLOCATOR_RECLAIM_FORCE";
|
||||||
pub const ENV_ALLOCATOR_RECLAIM_IDLE_INTERVALS: &str = "RUSTFS_ALLOCATOR_RECLAIM_IDLE_INTERVALS";
|
pub const ENV_ALLOCATOR_RECLAIM_IDLE_INTERVALS: &str = "RUSTFS_ALLOCATOR_RECLAIM_IDLE_INTERVALS";
|
||||||
pub const DEFAULT_ALLOCATOR_RECLAIM_ENABLED: bool = false;
|
pub const DEFAULT_ALLOCATOR_RECLAIM_ENABLED: bool = true;
|
||||||
pub const DEFAULT_ALLOCATOR_RECLAIM_INTERVAL_SECS: u64 = 30;
|
pub const DEFAULT_ALLOCATOR_RECLAIM_INTERVAL_SECS: u64 = 30;
|
||||||
pub const DEFAULT_ALLOCATOR_RECLAIM_FORCE: bool = true;
|
pub const DEFAULT_ALLOCATOR_RECLAIM_FORCE: bool = true;
|
||||||
pub const DEFAULT_ALLOCATOR_RECLAIM_IDLE_INTERVALS: u64 = 3;
|
pub const DEFAULT_ALLOCATOR_RECLAIM_IDLE_INTERVALS: u64 = 3;
|
||||||
|
|||||||
@@ -109,15 +109,22 @@ hyper = { workspace = true, features = ["http2", "http1", "server"] }
|
|||||||
hyper-util = { workspace = true, features = ["tokio", "server-auto", "server-graceful", "tracing"] }
|
hyper-util = { workspace = true, features = ["tokio", "server-auto", "server-graceful", "tracing"] }
|
||||||
reqwest = { workspace = true, features = ["json", "multipart", "stream"] }
|
reqwest = { workspace = true, features = ["json", "multipart", "stream"] }
|
||||||
rustfs-signer.workspace = true
|
rustfs-signer.workspace = true
|
||||||
|
|
||||||
|
# The MFA e2e test computes RFC 6238 codes itself rather than calling the
|
||||||
|
# server's implementation: a shared helper could agree with a bug on both sides.
|
||||||
|
data-encoding = { workspace = true }
|
||||||
|
hmac = { workspace = true }
|
||||||
|
sha1 = { workspace = true }
|
||||||
|
serde_urlencoded = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true, features = ["env-filter", "time"] }
|
tracing-subscriber = { workspace = true, features = ["env-filter", "time"] }
|
||||||
uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] }
|
uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] }
|
||||||
urlencoding.workspace = true
|
urlencoding.workspace = true
|
||||||
walkdir.workspace = true
|
walkdir.workspace = true
|
||||||
base64 = { workspace = true }
|
base64-simd = { workspace = true }
|
||||||
rand = { workspace = true, features = ["serde"] }
|
rand = { workspace = true, features = ["serde"] }
|
||||||
chrono = { workspace = true, features = ["serde"] }
|
chrono = { workspace = true, features = ["serde"] }
|
||||||
hex = { workspace = true }
|
hex-simd = { workspace = true }
|
||||||
md-5 = { workspace = true }
|
md-5 = { workspace = true }
|
||||||
opentelemetry-proto = { workspace = true }
|
opentelemetry-proto = { workspace = true }
|
||||||
prost.workspace = true
|
prost.workspace = true
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
|
|||||||
| **reliant** | [`src/reliant/`](src/reliant) | Tests that reuse an **externally started** server (SQL/select, conditional writes, lifecycle, deleted-object reads, node-interact). Run via [`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh); see [`src/reliant/README.md`](src/reliant/README.md) |
|
| **reliant** | [`src/reliant/`](src/reliant) | Tests that reuse an **externally started** server (SQL/select, conditional writes, lifecycle, deleted-object reads, node-interact). Run via [`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh); see [`src/reliant/README.md`](src/reliant/README.md) |
|
||||||
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
|
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
|
||||||
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
|
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
|
||||||
|
| **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory |
|
||||||
|
|
||||||
## How to run
|
## How to run
|
||||||
|
|
||||||
@@ -168,6 +169,7 @@ the same profile for membership and execution with one nightly worker.
|
|||||||
| `s3s-e2e` black-box | `e2e-tests` + `e2e-tests-rio-v2` jobs | **Active** (external conformance tool) |
|
| `s3s-e2e` black-box | `e2e-tests` + `e2e-tests-rio-v2` jobs | **Active** (external conformance tool) |
|
||||||
| ILM / lifecycle (ignored) | `test-ilm-integration-serial` lane, `-j1` | **Active** (backlog#1148 ilm-1) |
|
| ILM / lifecycle (ignored) | `test-ilm-integration-serial` lane, `-j1` | **Active** (backlog#1148 ilm-1) |
|
||||||
| KMS suite | `e2e-full` job, merge queue + main | **Active** |
|
| KMS suite | `e2e-full` job, merge queue + main | **Active** |
|
||||||
|
| Direct upgrade from pinned previous release | `e2e-upgrade.yml`, storage-sensitive PRs + release tags + weekly | **Active** |
|
||||||
| Cluster faults (`e2e-nightly` profile) | consolidated nightly workflow | **Active** (backlog#1149 ci-7) |
|
| Cluster faults (`e2e-nightly` profile) | consolidated nightly workflow | **Active** (backlog#1149 ci-7) |
|
||||||
| Protocols (FTPS/WebDAV/SFTP) | consolidated nightly workflow, serial | **Active** (backlog#1149 ci-7) |
|
| Protocols (FTPS/WebDAV/SFTP) | consolidated nightly workflow, serial | **Active** (backlog#1149 ci-7) |
|
||||||
| Replication (fast subset) | `e2e-smoke` profile, `e2e-tests` job, every PR | **Active** (backlog#1147 repl-1) |
|
| Replication (fast subset) | `e2e-smoke` profile, `e2e-tests` job, every PR | **Active** (backlog#1147 repl-1) |
|
||||||
|
|||||||
@@ -0,0 +1,449 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
//! End-to-end coverage for the self-service account and two-factor surface.
|
||||||
|
//!
|
||||||
|
//! The unit tests cover the state machine at its edges; what only an end-to-end
|
||||||
|
//! test can prove is that the pieces are wired together and that the *existing*
|
||||||
|
//! authentication paths still behave. Specifically:
|
||||||
|
//!
|
||||||
|
//! 1. Enrollment is refused when `RUSTFS_IAM_MASTER_KEY` is absent, so a TOTP
|
||||||
|
//! secret is never written where an attacker could read it off a disk.
|
||||||
|
//! 2. With a master key, the full flow works: enroll, activate with a real
|
||||||
|
//! RFC 6238 code, and receive single-use recovery codes.
|
||||||
|
//! 3. Once a factor is enrolled, `AssumeRole` refuses to mint a session without
|
||||||
|
//! one, and accepts a valid code — the actual login gate.
|
||||||
|
//! 4. A direct SigV4 admin request keeps working with a factor enrolled. This is
|
||||||
|
//! the regression that matters most: gating it would break every script and
|
||||||
|
//! CLI the moment somebody enabled 2FA.
|
||||||
|
//! 5. `AssumeRole` for an identity with no enrollment is byte-for-byte the old
|
||||||
|
//! behaviour, so existing deployments are untouched.
|
||||||
|
//! 6. Rotating a password through `/account/password` invalidates the sessions
|
||||||
|
//! minted under the old secret.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||||
|
use hmac::{Hmac, KeyInit as _, Mac};
|
||||||
|
use http::header::HOST;
|
||||||
|
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||||
|
use rustfs_signer::sign_v4;
|
||||||
|
use s3s::Body;
|
||||||
|
use sha1::Sha1;
|
||||||
|
use std::error::Error;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
const ACCOUNT_INFO_PATH: &str = "/rustfs/admin/v3/account/info";
|
||||||
|
const ACCOUNT_PASSWORD_PATH: &str = "/rustfs/admin/v3/account/password";
|
||||||
|
const ACCOUNT_MFA_PATH: &str = "/rustfs/admin/v3/account/mfa";
|
||||||
|
const ACCOUNT_MFA_ENROLL_PATH: &str = "/rustfs/admin/v3/account/mfa/enroll";
|
||||||
|
const ACCOUNT_MFA_ACTIVATE_PATH: &str = "/rustfs/admin/v3/account/mfa/activate";
|
||||||
|
const MFA_CHALLENGE_PATH: &str = "/rustfs/admin/v3/mfa/challenge";
|
||||||
|
const ADMIN_INFO_PATH: &str = "/rustfs/admin/v3/info";
|
||||||
|
|
||||||
|
/// A master key so the server will accept an enrollment. Test-only value.
|
||||||
|
const TEST_MASTER_KEY: &str = "e2e-mfa-master-key-do-not-reuse";
|
||||||
|
|
||||||
|
type HmacSha1 = Hmac<Sha1>;
|
||||||
|
|
||||||
|
/// One signed admin request, returning the status and the raw body.
|
||||||
|
///
|
||||||
|
/// Signs with `UNSIGNED_PAYLOAD` so the body does not participate in the
|
||||||
|
/// hash, matching how the other admin e2e tests drive these routes.
|
||||||
|
async fn signed_request(
|
||||||
|
base_url: &str,
|
||||||
|
method: http::Method,
|
||||||
|
path: &str,
|
||||||
|
body: Option<&str>,
|
||||||
|
access_key: &str,
|
||||||
|
secret_key: &str,
|
||||||
|
) -> Result<(reqwest::StatusCode, String), Box<dyn Error + Send + Sync>> {
|
||||||
|
let url = format!("{base_url}{path}");
|
||||||
|
let uri = url.parse::<http::Uri>()?;
|
||||||
|
let authority = uri.authority().ok_or("missing authority")?.to_string();
|
||||||
|
let body_bytes = body.map(|b| b.as_bytes().to_vec()).unwrap_or_default();
|
||||||
|
|
||||||
|
let request = http::Request::builder()
|
||||||
|
.method(method.clone())
|
||||||
|
.uri(uri)
|
||||||
|
.header(HOST, authority)
|
||||||
|
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
|
||||||
|
let signed = sign_v4(request.body(Body::empty())?, 0, access_key, secret_key, "", "us-east-1");
|
||||||
|
|
||||||
|
let client = local_http_client();
|
||||||
|
let mut builder = client.request(method, url.as_str());
|
||||||
|
for (name, value) in signed.headers() {
|
||||||
|
builder = builder.header(name, value);
|
||||||
|
}
|
||||||
|
if !body_bytes.is_empty() {
|
||||||
|
builder = builder.body(body_bytes);
|
||||||
|
}
|
||||||
|
let response = builder.send().await?;
|
||||||
|
let status = response.status();
|
||||||
|
let text = response.text().await?;
|
||||||
|
Ok((status, text))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A SigV4-signed `AssumeRole` form POST, optionally carrying a second factor.
|
||||||
|
///
|
||||||
|
/// Uses STS's own `SerialNumber`/`TokenCode` fields, which is the point: a
|
||||||
|
/// script or SDK can present the factor without a RustFS-specific protocol.
|
||||||
|
async fn assume_role(
|
||||||
|
base_url: &str,
|
||||||
|
access_key: &str,
|
||||||
|
secret_key: &str,
|
||||||
|
second_factor: Option<(&str, &str)>,
|
||||||
|
) -> Result<(reqwest::StatusCode, String), Box<dyn Error + Send + Sync>> {
|
||||||
|
let mut form = vec![
|
||||||
|
("Action", "AssumeRole".to_string()),
|
||||||
|
("Version", "2011-06-15".to_string()),
|
||||||
|
("RoleArn", "arn:aws:iam::*:role/Admin".to_string()),
|
||||||
|
("RoleSessionName", "e2e".to_string()),
|
||||||
|
("DurationSeconds", "3600".to_string()),
|
||||||
|
];
|
||||||
|
if let Some((challenge, code)) = second_factor {
|
||||||
|
form.push(("SerialNumber", challenge.to_string()));
|
||||||
|
form.push(("TokenCode", code.to_string()));
|
||||||
|
}
|
||||||
|
let body = serde_urlencoded::to_string(&form)?;
|
||||||
|
|
||||||
|
let uri = base_url.parse::<http::Uri>()?;
|
||||||
|
let authority = uri.authority().ok_or("missing authority")?.to_string();
|
||||||
|
let request = http::Request::builder()
|
||||||
|
.method(http::Method::POST)
|
||||||
|
.uri(format!("{base_url}/"))
|
||||||
|
.header(HOST, authority)
|
||||||
|
.header("content-type", "application/x-www-form-urlencoded")
|
||||||
|
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
|
||||||
|
let signed = sign_v4(request.body(Body::empty())?, 0, access_key, secret_key, "", "us-east-1");
|
||||||
|
|
||||||
|
let client = local_http_client();
|
||||||
|
let mut builder = client.request(http::Method::POST, format!("{base_url}/"));
|
||||||
|
for (name, value) in signed.headers() {
|
||||||
|
builder = builder.header(name, value);
|
||||||
|
}
|
||||||
|
let response = builder.body(body).send().await?;
|
||||||
|
let status = response.status();
|
||||||
|
let text = response.text().await?;
|
||||||
|
Ok((status, text))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate the current RFC 6238 code for a base32 secret.
|
||||||
|
///
|
||||||
|
/// Computed independently of the server implementation: a shared helper
|
||||||
|
/// could agree with a bug on both sides.
|
||||||
|
fn totp_now(secret_base32: &str) -> String {
|
||||||
|
let secret = data_encoding::BASE32_NOPAD
|
||||||
|
.decode(secret_base32.as_bytes())
|
||||||
|
.expect("server must return unpadded base32");
|
||||||
|
let step = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("clock after the epoch")
|
||||||
|
.as_secs()
|
||||||
|
/ 30;
|
||||||
|
|
||||||
|
let mut mac = HmacSha1::new_from_slice(&secret).expect("HMAC accepts any key length");
|
||||||
|
mac.update(&step.to_be_bytes());
|
||||||
|
let digest = mac.finalize().into_bytes();
|
||||||
|
|
||||||
|
let offset = (digest[digest.len() - 1] & 0x0f) as usize;
|
||||||
|
let binary = u32::from_be_bytes([
|
||||||
|
digest[offset] & 0x7f,
|
||||||
|
digest[offset + 1],
|
||||||
|
digest[offset + 2],
|
||||||
|
digest[offset + 3],
|
||||||
|
]);
|
||||||
|
format!("{:06}", binary % 1_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json(body: &str) -> serde_json::Value {
|
||||||
|
serde_json::from_str(body).unwrap_or_else(|error| panic!("expected JSON, got {body}: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn enrollment_is_refused_without_at_rest_protection() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
init_logging();
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
// Deliberately no RUSTFS_IAM_MASTER_KEY.
|
||||||
|
env.start_rustfs_server(vec![]).await?;
|
||||||
|
|
||||||
|
let (access_key, secret_key) = (env.access_key.clone(), env.secret_key.clone());
|
||||||
|
|
||||||
|
// The account surface itself works.
|
||||||
|
let (status, body) =
|
||||||
|
signed_request(&env.url, http::Method::GET, ACCOUNT_INFO_PATH, None, &access_key, &secret_key).await?;
|
||||||
|
assert_eq!(status, reqwest::StatusCode::OK, "account info must be reachable, body: {body}");
|
||||||
|
let info = json(&body);
|
||||||
|
assert_eq!(info["access_key"], access_key.as_str());
|
||||||
|
assert_eq!(info["identity_type"], "root");
|
||||||
|
assert_eq!(info["credentials_source"], "env");
|
||||||
|
// Root credentials come from a process-wide OnceLock that also derives
|
||||||
|
// the internode RPC secret, so they are immutable at runtime.
|
||||||
|
assert_eq!(info["mutable"]["password"], false);
|
||||||
|
|
||||||
|
// Status reports the refusal rather than pretending enrollment is possible.
|
||||||
|
let (status, body) =
|
||||||
|
signed_request(&env.url, http::Method::GET, ACCOUNT_MFA_PATH, None, &access_key, &secret_key).await?;
|
||||||
|
assert_eq!(status, reqwest::StatusCode::OK, "mfa status must be reachable, body: {body}");
|
||||||
|
let mfa = json(&body);
|
||||||
|
assert_eq!(mfa["enabled"], false);
|
||||||
|
assert_eq!(mfa["enrollment_available"], false);
|
||||||
|
assert!(
|
||||||
|
mfa["enrollment_blocked_reason"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|reason| reason.contains("RUSTFS_IAM_MASTER_KEY")),
|
||||||
|
"the refusal must name the variable an operator has to set, body: {body}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And enrolling actually fails, rather than writing a plaintext secret.
|
||||||
|
let (status, body) = signed_request(
|
||||||
|
&env.url,
|
||||||
|
http::Method::POST,
|
||||||
|
ACCOUNT_MFA_ENROLL_PATH,
|
||||||
|
Some("{}"),
|
||||||
|
&access_key,
|
||||||
|
&secret_key,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert!(
|
||||||
|
status.is_client_error() || status.is_server_error(),
|
||||||
|
"enrollment must fail without a master key, status: {status}, body: {body}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains("RUSTFS_IAM_MASTER_KEY"),
|
||||||
|
"the failure must explain the remedy, body: {body}"
|
||||||
|
);
|
||||||
|
|
||||||
|
env.stop_server();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn assume_role_is_unchanged_for_an_identity_with_no_second_factor() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
// The regression that protects every existing deployment: an identity
|
||||||
|
// with no enrollment must take no new code path.
|
||||||
|
init_logging();
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_IAM_MASTER_KEY", TEST_MASTER_KEY)])
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (access_key, secret_key) = (env.access_key.clone(), env.secret_key.clone());
|
||||||
|
|
||||||
|
let (status, body) =
|
||||||
|
signed_request(&env.url, http::Method::GET, MFA_CHALLENGE_PATH, None, &access_key, &secret_key).await?;
|
||||||
|
assert_eq!(status, reqwest::StatusCode::OK, "challenge must be reachable, body: {body}");
|
||||||
|
let challenge = json(&body);
|
||||||
|
assert_eq!(challenge["required"], false, "no enrollment means no challenge");
|
||||||
|
assert!(challenge["challenge"].is_null());
|
||||||
|
|
||||||
|
let (status, body) = assume_role(&env.url, &access_key, &secret_key, None).await?;
|
||||||
|
assert_eq!(status, reqwest::StatusCode::OK, "AssumeRole must still work, body: {body}");
|
||||||
|
assert!(body.contains("<AccessKeyId>"), "expected STS credentials, body: {body}");
|
||||||
|
|
||||||
|
env.stop_server();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_full_second_factor_lifecycle_gates_only_session_minting() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
init_logging();
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_IAM_MASTER_KEY", TEST_MASTER_KEY)])
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (access_key, secret_key) = (env.access_key.clone(), env.secret_key.clone());
|
||||||
|
|
||||||
|
// --- Enroll ---
|
||||||
|
let (status, body) = signed_request(
|
||||||
|
&env.url,
|
||||||
|
http::Method::POST,
|
||||||
|
ACCOUNT_MFA_ENROLL_PATH,
|
||||||
|
Some("{}"),
|
||||||
|
&access_key,
|
||||||
|
&secret_key,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(status, reqwest::StatusCode::OK, "enrollment must succeed, body: {body}");
|
||||||
|
let enrollment = json(&body);
|
||||||
|
let secret_base32 = enrollment["secret_base32"].as_str().expect("secret").to_string();
|
||||||
|
assert!(
|
||||||
|
enrollment["otpauth_uri"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|uri| uri.starts_with("otpauth://totp/RustFS:")),
|
||||||
|
"body: {body}"
|
||||||
|
);
|
||||||
|
assert!(!enrollment["qr_svg"].as_str().unwrap_or_default().is_empty(), "expected an SVG");
|
||||||
|
assert!(!enrollment["qr_utf8"].as_str().unwrap_or_default().is_empty(), "expected block art");
|
||||||
|
|
||||||
|
// A pending enrollment must not gate anything yet: a mis-scanned QR
|
||||||
|
// cannot be allowed to lock the operator out.
|
||||||
|
let (status, body) =
|
||||||
|
signed_request(&env.url, http::Method::GET, MFA_CHALLENGE_PATH, None, &access_key, &secret_key).await?;
|
||||||
|
assert_eq!(status, reqwest::StatusCode::OK);
|
||||||
|
assert_eq!(json(&body)["required"], false, "a pending enrollment must not gate login");
|
||||||
|
|
||||||
|
// --- Activate ---
|
||||||
|
let code = totp_now(&secret_base32);
|
||||||
|
let (status, body) = signed_request(
|
||||||
|
&env.url,
|
||||||
|
http::Method::POST,
|
||||||
|
ACCOUNT_MFA_ACTIVATE_PATH,
|
||||||
|
Some(&format!(r#"{{"code":"{code}"}}"#)),
|
||||||
|
&access_key,
|
||||||
|
&secret_key,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(status, reqwest::StatusCode::OK, "activation must succeed, body: {body}");
|
||||||
|
let activated = json(&body);
|
||||||
|
let recovery_codes = activated["recovery_codes"].as_array().expect("recovery codes").clone();
|
||||||
|
assert_eq!(recovery_codes.len(), 10, "expected a full recovery set, body: {body}");
|
||||||
|
|
||||||
|
// --- The gate is now on for session minting ---
|
||||||
|
let (status, body) =
|
||||||
|
signed_request(&env.url, http::Method::GET, MFA_CHALLENGE_PATH, None, &access_key, &secret_key).await?;
|
||||||
|
assert_eq!(status, reqwest::StatusCode::OK);
|
||||||
|
let challenge_body = json(&body);
|
||||||
|
assert_eq!(challenge_body["required"], true, "body: {body}");
|
||||||
|
let challenge = challenge_body["challenge"].as_str().expect("challenge").to_string();
|
||||||
|
|
||||||
|
let (status, body) = assume_role(&env.url, &access_key, &secret_key, None).await?;
|
||||||
|
assert!(status.is_client_error(), "AssumeRole must refuse without a factor, body: {body}");
|
||||||
|
assert!(
|
||||||
|
body.contains("MultiFactorAuthRequired"),
|
||||||
|
"clients match on this code to prompt instead of reporting a failed login, body: {body}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- ... but direct SigV4 access is untouched ---
|
||||||
|
let (status, body) = signed_request(&env.url, http::Method::GET, ADMIN_INFO_PATH, None, &access_key, &secret_key).await?;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
reqwest::StatusCode::OK,
|
||||||
|
"a direct admin request must keep working with a factor enrolled, body: {body}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- A valid factor mints the session ---
|
||||||
|
// A fresh code: activation consumed the previous time step, so reusing
|
||||||
|
// that code would be refused as a replay.
|
||||||
|
let code = wait_for_a_fresh_code(&secret_base32).await;
|
||||||
|
let (status, body) = assume_role(&env.url, &access_key, &secret_key, Some((&challenge, &code))).await?;
|
||||||
|
assert_eq!(status, reqwest::StatusCode::OK, "a valid factor must mint a session, body: {body}");
|
||||||
|
assert!(body.contains("<AccessKeyId>"), "expected STS credentials, body: {body}");
|
||||||
|
|
||||||
|
// --- A recovery code also works, once ---
|
||||||
|
let recovery_code = recovery_codes[0].as_str().expect("recovery code").to_string();
|
||||||
|
let (status, body) = assume_role(&env.url, &access_key, &secret_key, Some((&challenge, &recovery_code))).await?;
|
||||||
|
assert_eq!(status, reqwest::StatusCode::OK, "a recovery code must mint a session, body: {body}");
|
||||||
|
|
||||||
|
let (status, body) = assume_role(&env.url, &access_key, &secret_key, Some((&challenge, &recovery_code))).await?;
|
||||||
|
assert!(
|
||||||
|
status.is_client_error(),
|
||||||
|
"a spent recovery code must not work twice, status: {status}, body: {body}"
|
||||||
|
);
|
||||||
|
|
||||||
|
env.stop_server();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_iam_user_can_rotate_its_own_password_and_lose_its_sessions() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
init_logging();
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_IAM_MASTER_KEY", TEST_MASTER_KEY)])
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (root_ak, root_sk) = (env.access_key.clone(), env.secret_key.clone());
|
||||||
|
let user_ak = "mfarotationuser";
|
||||||
|
let old_sk = "mfarotationsecret";
|
||||||
|
let new_sk = "mfarotationsecret2";
|
||||||
|
|
||||||
|
// Root creates the user.
|
||||||
|
let (status, body) = signed_request(
|
||||||
|
&env.url,
|
||||||
|
http::Method::PUT,
|
||||||
|
&format!("/rustfs/admin/v3/add-user?accessKey={user_ak}"),
|
||||||
|
Some(&format!(r#"{{"secretKey":"{old_sk}","status":"enabled"}}"#)),
|
||||||
|
&root_ak,
|
||||||
|
&root_sk,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(status, reqwest::StatusCode::OK, "user creation must succeed, body: {body}");
|
||||||
|
|
||||||
|
// The user sees itself as mutable, unlike root.
|
||||||
|
let (status, body) = signed_request(&env.url, http::Method::GET, ACCOUNT_INFO_PATH, None, user_ak, old_sk).await?;
|
||||||
|
assert_eq!(status, reqwest::StatusCode::OK, "body: {body}");
|
||||||
|
let info = json(&body);
|
||||||
|
assert_eq!(info["identity_type"], "iam");
|
||||||
|
assert_eq!(info["credentials_source"], "iam");
|
||||||
|
assert_eq!(info["mutable"]["password"], true);
|
||||||
|
|
||||||
|
// The wrong current secret is refused, so a live session alone cannot
|
||||||
|
// rewrite the credential.
|
||||||
|
let (status, body) = signed_request(
|
||||||
|
&env.url,
|
||||||
|
http::Method::POST,
|
||||||
|
ACCOUNT_PASSWORD_PATH,
|
||||||
|
Some(&format!(r#"{{"current_secret_key":"wrong-secret","new_secret_key":"{new_sk}"}}"#)),
|
||||||
|
user_ak,
|
||||||
|
old_sk,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert!(status.is_client_error(), "a wrong current secret must be refused, body: {body}");
|
||||||
|
|
||||||
|
// The correct one rotates it.
|
||||||
|
let (status, body) = signed_request(
|
||||||
|
&env.url,
|
||||||
|
http::Method::POST,
|
||||||
|
ACCOUNT_PASSWORD_PATH,
|
||||||
|
Some(&format!(r#"{{"current_secret_key":"{old_sk}","new_secret_key":"{new_sk}"}}"#)),
|
||||||
|
user_ak,
|
||||||
|
old_sk,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(status, reqwest::StatusCode::OK, "rotation must succeed, body: {body}");
|
||||||
|
|
||||||
|
// The new secret works and the old one does not.
|
||||||
|
let (status, body) = signed_request(&env.url, http::Method::GET, ACCOUNT_INFO_PATH, None, user_ak, new_sk).await?;
|
||||||
|
assert_eq!(status, reqwest::StatusCode::OK, "the new secret must work, body: {body}");
|
||||||
|
|
||||||
|
let (status, _) = signed_request(&env.url, http::Method::GET, ACCOUNT_INFO_PATH, None, user_ak, old_sk).await?;
|
||||||
|
assert!(status.is_client_error(), "the old secret must stop working, status: {status}");
|
||||||
|
|
||||||
|
env.stop_server();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait until the current time step differs from the one a code was just
|
||||||
|
/// consumed in, then return a code for it.
|
||||||
|
///
|
||||||
|
/// Anti-replay burns the step, so a test that reuses a code inside its own
|
||||||
|
/// window would fail for the right reason at the wrong moment.
|
||||||
|
async fn wait_for_a_fresh_code(secret_base32: &str) -> String {
|
||||||
|
let step_at_start = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("clock after the epoch")
|
||||||
|
.as_secs()
|
||||||
|
/ 30;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("clock after the epoch")
|
||||||
|
.as_secs();
|
||||||
|
if now / 30 > step_at_start {
|
||||||
|
return totp_now(secret_base32);
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,8 +16,10 @@
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::borrow::Borrow;
|
||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging, signed_s3_request};
|
use crate::common::{RustFSTestEnvironment, init_logging, signed_s3_request};
|
||||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
|
||||||
use aws_sdk_s3::types::{
|
use aws_sdk_s3::types::{
|
||||||
AccelerateConfiguration, BucketAccelerateStatus, BucketLoggingStatus, IndexDocument, LoggingEnabled, Payer,
|
AccelerateConfiguration, BucketAccelerateStatus, BucketLoggingStatus, IndexDocument, LoggingEnabled, Payer,
|
||||||
RequestPaymentConfiguration, WebsiteConfiguration,
|
RequestPaymentConfiguration, WebsiteConfiguration,
|
||||||
@@ -26,6 +28,26 @@ mod tests {
|
|||||||
use http::header::CONTENT_TYPE;
|
use http::header::CONTENT_TYPE;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
fn assert_s3_error<T, E, R>(result: Result<T, R>, expected_status: u16, expected_code: &str, context: &str)
|
||||||
|
where
|
||||||
|
T: std::fmt::Debug,
|
||||||
|
E: ProvideErrorMetadata + std::fmt::Debug,
|
||||||
|
R: Borrow<SdkError<E>> + std::fmt::Debug,
|
||||||
|
{
|
||||||
|
let error = result.expect_err(context);
|
||||||
|
let sdk_error = error.borrow();
|
||||||
|
assert_eq!(
|
||||||
|
sdk_error.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(expected_status),
|
||||||
|
"{context}: expected HTTP {expected_status}, got: {error:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sdk_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some(expected_code),
|
||||||
|
"{context}: expected {expected_code}, got: {error:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_dummy_bucket_compatibility_endpoints() {
|
async fn test_dummy_bucket_compatibility_endpoints() {
|
||||||
init_logging();
|
init_logging();
|
||||||
@@ -217,17 +239,11 @@ mod tests {
|
|||||||
.expect("DeleteBucketWebsite should return success");
|
.expect("DeleteBucketWebsite should return success");
|
||||||
|
|
||||||
let website_after_delete = client.get_bucket_website().bucket(bucket).send().await;
|
let website_after_delete = client.get_bucket_website().bucket(bucket).send().await;
|
||||||
assert!(
|
assert_s3_error(
|
||||||
website_after_delete.is_err(),
|
website_after_delete,
|
||||||
"GetBucketWebsite should return NoSuchWebsiteConfiguration after deletion"
|
404,
|
||||||
);
|
"NoSuchWebsiteConfiguration",
|
||||||
let website_err = website_after_delete.err().unwrap();
|
"GetBucketWebsite after deleting the website configuration",
|
||||||
let website_code = website_err.as_service_error().and_then(|e| e.code());
|
|
||||||
assert!(
|
|
||||||
matches!(website_code, Some("NoSuchWebsiteConfiguration")),
|
|
||||||
"Unexpected GetBucketWebsite error code: {:?}, err: {:?}",
|
|
||||||
website_code,
|
|
||||||
website_err
|
|
||||||
);
|
);
|
||||||
|
|
||||||
env.stop_server();
|
env.stop_server();
|
||||||
@@ -245,15 +261,7 @@ mod tests {
|
|||||||
let missing_bucket = "test-dummy-bucket-missing";
|
let missing_bucket = "test-dummy-bucket-missing";
|
||||||
|
|
||||||
let get_logging = client.get_bucket_logging().bucket(missing_bucket).send().await;
|
let get_logging = client.get_bucket_logging().bucket(missing_bucket).send().await;
|
||||||
assert!(get_logging.is_err(), "GetBucketLogging should fail for missing bucket");
|
assert_s3_error(get_logging, 404, "NoSuchBucket", "GetBucketLogging for a missing bucket");
|
||||||
let get_logging_err = get_logging.err().unwrap();
|
|
||||||
let get_logging_code = get_logging_err.as_service_error().and_then(|e| e.code());
|
|
||||||
assert!(
|
|
||||||
matches!(get_logging_code, Some("NoSuchBucket")),
|
|
||||||
"Unexpected GetBucketLogging error code: {:?}, err: {:?}",
|
|
||||||
get_logging_code,
|
|
||||||
get_logging_err
|
|
||||||
);
|
|
||||||
|
|
||||||
let put_logging = client
|
let put_logging = client
|
||||||
.put_bucket_logging()
|
.put_bucket_logging()
|
||||||
@@ -261,41 +269,22 @@ mod tests {
|
|||||||
.bucket_logging_status(BucketLoggingStatus::builder().build())
|
.bucket_logging_status(BucketLoggingStatus::builder().build())
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
assert!(put_logging.is_err(), "PutBucketLogging should fail for missing bucket");
|
assert_s3_error(put_logging, 404, "NoSuchBucket", "PutBucketLogging for a missing bucket");
|
||||||
let put_logging_err = put_logging.err().unwrap();
|
|
||||||
let put_logging_code = put_logging_err.as_service_error().and_then(|e| e.code());
|
|
||||||
assert!(
|
|
||||||
matches!(put_logging_code, Some("NoSuchBucket")),
|
|
||||||
"Unexpected PutBucketLogging error code: {:?}, err: {:?}",
|
|
||||||
put_logging_code,
|
|
||||||
put_logging_err
|
|
||||||
);
|
|
||||||
|
|
||||||
let get_accelerate = client
|
let get_accelerate = client
|
||||||
.get_bucket_accelerate_configuration()
|
.get_bucket_accelerate_configuration()
|
||||||
.bucket(missing_bucket)
|
.bucket(missing_bucket)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
assert!(get_accelerate.is_err(), "GetBucketAccelerateConfiguration should fail for missing bucket");
|
assert_s3_error(
|
||||||
let get_accelerate_err = get_accelerate.err().unwrap();
|
get_accelerate,
|
||||||
let get_accelerate_code = get_accelerate_err.as_service_error().and_then(|e| e.code());
|
404,
|
||||||
assert!(
|
"NoSuchBucket",
|
||||||
matches!(get_accelerate_code, Some("NoSuchBucket")),
|
"GetBucketAccelerateConfiguration for a missing bucket",
|
||||||
"Unexpected GetBucketAccelerateConfiguration error code: {:?}, err: {:?}",
|
|
||||||
get_accelerate_code,
|
|
||||||
get_accelerate_err
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let get_request_payment = client.get_bucket_request_payment().bucket(missing_bucket).send().await;
|
let get_request_payment = client.get_bucket_request_payment().bucket(missing_bucket).send().await;
|
||||||
assert!(get_request_payment.is_err(), "GetBucketRequestPayment should fail for missing bucket");
|
assert_s3_error(get_request_payment, 404, "NoSuchBucket", "GetBucketRequestPayment for a missing bucket");
|
||||||
let get_request_payment_err = get_request_payment.err().unwrap();
|
|
||||||
let get_request_payment_code = get_request_payment_err.as_service_error().and_then(|e| e.code());
|
|
||||||
assert!(
|
|
||||||
matches!(get_request_payment_code, Some("NoSuchBucket")),
|
|
||||||
"Unexpected GetBucketRequestPayment error code: {:?}, err: {:?}",
|
|
||||||
get_request_payment_code,
|
|
||||||
get_request_payment_err
|
|
||||||
);
|
|
||||||
|
|
||||||
let put_accelerate = client
|
let put_accelerate = client
|
||||||
.put_bucket_accelerate_configuration()
|
.put_bucket_accelerate_configuration()
|
||||||
@@ -307,14 +296,11 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
assert!(put_accelerate.is_err(), "PutBucketAccelerateConfiguration should fail for missing bucket");
|
assert_s3_error(
|
||||||
let put_accelerate_err = put_accelerate.err().unwrap();
|
put_accelerate,
|
||||||
let put_accelerate_code = put_accelerate_err.as_service_error().and_then(|e| e.code());
|
404,
|
||||||
assert!(
|
"NoSuchBucket",
|
||||||
matches!(put_accelerate_code, Some("NoSuchBucket")),
|
"PutBucketAccelerateConfiguration for a missing bucket",
|
||||||
"Unexpected PutBucketAccelerateConfiguration error code: {:?}, err: {:?}",
|
|
||||||
put_accelerate_code,
|
|
||||||
put_accelerate_err
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let put_request_payment = client
|
let put_request_payment = client
|
||||||
@@ -328,15 +314,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
assert!(put_request_payment.is_err(), "PutBucketRequestPayment should fail for missing bucket");
|
assert_s3_error(put_request_payment, 404, "NoSuchBucket", "PutBucketRequestPayment for a missing bucket");
|
||||||
let put_request_payment_err = put_request_payment.err().unwrap();
|
|
||||||
let put_request_payment_code = put_request_payment_err.as_service_error().and_then(|e| e.code());
|
|
||||||
assert!(
|
|
||||||
matches!(put_request_payment_code, Some("NoSuchBucket")),
|
|
||||||
"Unexpected PutBucketRequestPayment error code: {:?}, err: {:?}",
|
|
||||||
put_request_payment_code,
|
|
||||||
put_request_payment_err
|
|
||||||
);
|
|
||||||
|
|
||||||
let put_website = client
|
let put_website = client
|
||||||
.put_bucket_website()
|
.put_bucket_website()
|
||||||
@@ -353,37 +331,13 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
assert!(put_website.is_err(), "PutBucketWebsite should fail for missing bucket");
|
assert_s3_error(put_website, 404, "NoSuchBucket", "PutBucketWebsite for a missing bucket");
|
||||||
let put_website_err = put_website.err().unwrap();
|
|
||||||
let put_website_code = put_website_err.as_service_error().and_then(|e| e.code());
|
|
||||||
assert!(
|
|
||||||
matches!(put_website_code, Some("NoSuchBucket")),
|
|
||||||
"Unexpected PutBucketWebsite error code: {:?}, err: {:?}",
|
|
||||||
put_website_code,
|
|
||||||
put_website_err
|
|
||||||
);
|
|
||||||
|
|
||||||
let get_website = client.get_bucket_website().bucket(missing_bucket).send().await;
|
let get_website = client.get_bucket_website().bucket(missing_bucket).send().await;
|
||||||
assert!(get_website.is_err(), "GetBucketWebsite should fail for missing bucket");
|
assert_s3_error(get_website, 404, "NoSuchBucket", "GetBucketWebsite for a missing bucket");
|
||||||
let get_website_err = get_website.err().unwrap();
|
|
||||||
let get_website_code = get_website_err.as_service_error().and_then(|e| e.code());
|
|
||||||
assert!(
|
|
||||||
matches!(get_website_code, Some("NoSuchBucket")),
|
|
||||||
"Unexpected GetBucketWebsite error code: {:?}, err: {:?}",
|
|
||||||
get_website_code,
|
|
||||||
get_website_err
|
|
||||||
);
|
|
||||||
|
|
||||||
let delete_website = client.delete_bucket_website().bucket(missing_bucket).send().await;
|
let delete_website = client.delete_bucket_website().bucket(missing_bucket).send().await;
|
||||||
assert!(delete_website.is_err(), "DeleteBucketWebsite should fail for missing bucket");
|
assert_s3_error(delete_website, 404, "NoSuchBucket", "DeleteBucketWebsite for a missing bucket");
|
||||||
let delete_website_err = delete_website.err().unwrap();
|
|
||||||
let delete_website_code = delete_website_err.as_service_error().and_then(|e| e.code());
|
|
||||||
assert!(
|
|
||||||
matches!(delete_website_code, Some("NoSuchBucket")),
|
|
||||||
"Unexpected DeleteBucketWebsite error code: {:?}, err: {:?}",
|
|
||||||
delete_website_code,
|
|
||||||
delete_website_err
|
|
||||||
);
|
|
||||||
|
|
||||||
env.stop_server();
|
env.stop_server();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||||
use aws_sdk_s3::config::{Credentials, Region};
|
use aws_sdk_s3::config::{Credentials, Region};
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::{Client, Config};
|
use aws_sdk_s3::{Client, Config};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
@@ -73,10 +74,14 @@ async fn test_bucket_policy_authenticated_user() -> Result<(), Box<dyn std::erro
|
|||||||
let user_client = create_user_client(&env, user_access, user_secret);
|
let user_client = create_user_client(&env, user_access, user_secret);
|
||||||
|
|
||||||
// 4. Verify Access Denied initially (No Policy)
|
// 4. Verify Access Denied initially (No Policy)
|
||||||
let result = user_client.list_objects_v2().bucket(bucket_name).send().await;
|
let denied = user_client
|
||||||
if result.is_ok() {
|
.list_objects_v2()
|
||||||
return Err("Should be Access Denied initially".into());
|
.bucket(bucket_name)
|
||||||
}
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("a user without a bucket policy must be denied");
|
||||||
|
assert_eq!(denied.raw_response().map(|response| response.status().as_u16()), Some(403));
|
||||||
|
assert_eq!(denied.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
|
||||||
|
|
||||||
// 5. Apply Bucket Policy Allowed User
|
// 5. Apply Bucket Policy Allowed User
|
||||||
let policy_json = serde_json::json!({
|
let policy_json = serde_json::json!({
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ mod tests {
|
|||||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
use aws_sdk_s3::config::{Credentials, Region, RequestChecksumCalculation};
|
use aws_sdk_s3::config::{Credentials, Region, RequestChecksumCalculation};
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::types::{ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart};
|
use aws_sdk_s3::types::{ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart};
|
||||||
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||||
use base64::Engine;
|
|
||||||
use md5::{Digest as Md5Digest, Md5};
|
use md5::{Digest as Md5Digest, Md5};
|
||||||
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
|
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
@@ -73,12 +73,12 @@ mod tests {
|
|||||||
let mut hasher = Md5::new();
|
let mut hasher = Md5::new();
|
||||||
hasher.update(body);
|
hasher.update(body);
|
||||||
let digest = hasher.finalize();
|
let digest = hasher.finalize();
|
||||||
base64::engine::general_purpose::STANDARD.encode(digest.as_slice())
|
base64_simd::STANDARD.encode_to_string(digest.as_slice())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn checksum_sha256_base64(body: &[u8]) -> String {
|
fn checksum_sha256_base64(body: &[u8]) -> String {
|
||||||
let digest = Sha256::digest(body);
|
let digest = Sha256::digest(body);
|
||||||
base64::engine::general_purpose::STANDARD.encode(digest.as_slice())
|
base64_simd::STANDARD.encode_to_string(digest.as_slice())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn checksum_crc64nvme_base64(body: &[u8]) -> String {
|
fn checksum_crc64nvme_base64(body: &[u8]) -> String {
|
||||||
@@ -186,16 +186,16 @@ mod tests {
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(
|
let error = result.expect_err("PutObject with a mismatched SHA256 must be rejected (issue #4341)");
|
||||||
result.is_err(),
|
assert_eq!(
|
||||||
"PutObject with a mismatched SHA256 must be rejected, but it succeeded (issue #4341)"
|
error.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(400),
|
||||||
|
"Mismatched SHA256 must return HTTP 400, got {error:?}"
|
||||||
);
|
);
|
||||||
let err = result.err().unwrap();
|
assert_eq!(
|
||||||
let msg = format!("{err:?}");
|
error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
info!("PutObject correctly rejected mismatched checksum: {msg}");
|
Some("BadDigest"),
|
||||||
assert!(
|
"Mismatched SHA256 must return BadDigest, got {error:?}"
|
||||||
msg.contains("BadDigest") || msg.to_lowercase().contains("digest") || msg.to_lowercase().contains("checksum"),
|
|
||||||
"Expected a BadDigest/checksum error, got: {msg}"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// And the object must not have been stored.
|
// And the object must not have been stored.
|
||||||
@@ -556,11 +556,16 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
assert!(put_bad.is_err(), "{header}: a mismatched checksum must be rejected");
|
let error = put_bad.expect_err("a mismatched checksum must be rejected");
|
||||||
let msg = format!("{:?}", put_bad.err().unwrap());
|
assert_eq!(
|
||||||
assert!(
|
error.raw_response().map(|response| response.status().as_u16()),
|
||||||
msg.contains("BadDigest") || msg.to_lowercase().contains("digest") || msg.to_lowercase().contains("checksum"),
|
Some(400),
|
||||||
"{header}: expected a BadDigest/checksum error, got: {msg}"
|
"{header}: mismatched checksum must return HTTP 400, got {error:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some("BadDigest"),
|
||||||
|
"{header}: mismatched checksum must return BadDigest, got {error:?}"
|
||||||
);
|
);
|
||||||
let error = client
|
let error = client
|
||||||
.head_object()
|
.head_object()
|
||||||
|
|||||||
@@ -638,6 +638,18 @@ impl RustFSTestEnvironment {
|
|||||||
extra_args: Vec<&str>,
|
extra_args: Vec<&str>,
|
||||||
extra_env: &[(&str, &str)],
|
extra_env: &[(&str, &str)],
|
||||||
cleanup_existing: bool,
|
cleanup_existing: bool,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let binary_path = rustfs_binary_path();
|
||||||
|
self.start_rustfs_server_inner_with_binary(&binary_path, extra_args, extra_env, cleanup_existing)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_rustfs_server_inner_with_binary(
|
||||||
|
&mut self,
|
||||||
|
binary_path: &Path,
|
||||||
|
extra_args: Vec<&str>,
|
||||||
|
extra_env: &[(&str, &str)],
|
||||||
|
cleanup_existing: bool,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
if cleanup_existing {
|
if cleanup_existing {
|
||||||
self.cleanup_existing_processes().await?;
|
self.cleanup_existing_processes().await?;
|
||||||
@@ -647,8 +659,7 @@ impl RustFSTestEnvironment {
|
|||||||
|
|
||||||
info!("Starting RustFS server with args: {:?}", args);
|
info!("Starting RustFS server with args: {:?}", args);
|
||||||
|
|
||||||
let binary_path = rustfs_binary_path();
|
let mut command = Command::new(binary_path);
|
||||||
let mut command = Command::new(&binary_path);
|
|
||||||
command.env("RUST_LOG", "rustfs=info,rustfs_notify=debug");
|
command.env("RUST_LOG", "rustfs=info,rustfs_notify=debug");
|
||||||
// The embedded console would bind the fixed default port :9001, which
|
// The embedded console would bind the fixed default port :9001, which
|
||||||
// collides with unrelated local services (e.g. Docker Desktop). Tests
|
// collides with unrelated local services (e.g. Docker Desktop). Tests
|
||||||
@@ -668,6 +679,19 @@ impl RustFSTestEnvironment {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start a specific RustFS binary against this environment's isolated
|
||||||
|
/// data directory. Upgrade tests use this to seed an old on-disk format
|
||||||
|
/// before restarting the same environment with the workspace binary.
|
||||||
|
pub async fn start_rustfs_server_from_binary(
|
||||||
|
&mut self,
|
||||||
|
binary_path: &Path,
|
||||||
|
extra_args: Vec<&str>,
|
||||||
|
extra_env: &[(&str, &str)],
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
self.start_rustfs_server_inner_with_binary(binary_path, extra_args, extra_env, true)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Start RustFS server with basic configuration
|
/// Start RustFS server with basic configuration
|
||||||
pub async fn start_rustfs_server(&mut self, extra_args: Vec<&str>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
pub async fn start_rustfs_server(&mut self, extra_args: Vec<&str>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
self.start_rustfs_server_inner(extra_args, &[], true).await
|
self.start_rustfs_server_inner(extra_args, &[], true).await
|
||||||
|
|||||||
@@ -652,11 +652,10 @@ const MPU_SSE_COMPRESSION_BUCKET: &str = "compression-mpu-sse-bucket";
|
|||||||
async fn start_rustfs_with_compression_and_sse(
|
async fn start_rustfs_with_compression_and_sse(
|
||||||
env: &mut RustFSTestEnvironment,
|
env: &mut RustFSTestEnvironment,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
use base64::Engine;
|
|
||||||
env.cleanup_existing_processes().await?;
|
env.cleanup_existing_processes().await?;
|
||||||
|
|
||||||
let binary_path = rustfs_binary_path();
|
let binary_path = rustfs_binary_path();
|
||||||
let master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
|
let master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
|
||||||
// Server output goes to a file inside the per-test temp dir so a failing
|
// Server output goes to a file inside the per-test temp dir so a failing
|
||||||
// run can be diagnosed from the child's logs.
|
// run can be diagnosed from the child's logs.
|
||||||
let server_log = std::fs::File::create(format!("{}/server.log", env.temp_dir))?;
|
let server_log = std::fs::File::create(format!("{}/server.log", env.temp_dir))?;
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ mod tests {
|
|||||||
VersioningConfiguration,
|
VersioningConfiguration,
|
||||||
};
|
};
|
||||||
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||||
use base64::Engine as _;
|
use base64_simd::STANDARD as BASE64;
|
||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
|
||||||
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
|
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
@@ -465,7 +464,7 @@ mod tests {
|
|||||||
create_versioned_bucket(&client, dst_bucket).await;
|
create_versioned_bucket(&client, dst_bucket).await;
|
||||||
|
|
||||||
let content = b"deterministic synthetic payload for copy-object checksum #4996";
|
let content = b"deterministic synthetic payload for copy-object checksum #4996";
|
||||||
let expected_sha256 = BASE64.encode(Sha256::digest(content));
|
let expected_sha256 = BASE64.encode_to_string(Sha256::digest(content));
|
||||||
|
|
||||||
client
|
client
|
||||||
.put_object()
|
.put_object()
|
||||||
@@ -534,7 +533,7 @@ mod tests {
|
|||||||
create_versioned_bucket(&client, dst_bucket).await;
|
create_versioned_bucket(&client, dst_bucket).await;
|
||||||
|
|
||||||
let content = b"another deterministic payload whose source checksum must survive the copy";
|
let content = b"another deterministic payload whose source checksum must survive the copy";
|
||||||
let expected_sha256 = BASE64.encode(Sha256::digest(content));
|
let expected_sha256 = BASE64.encode_to_string(Sha256::digest(content));
|
||||||
|
|
||||||
// Store the source WITH a SHA-256 checksum so it has one to preserve.
|
// Store the source WITH a SHA-256 checksum so it has one to preserve.
|
||||||
let put_src = client
|
let put_src = client
|
||||||
@@ -614,7 +613,7 @@ mod tests {
|
|||||||
create_versioned_bucket(&client, dst_bucket).await;
|
create_versioned_bucket(&client, dst_bucket).await;
|
||||||
|
|
||||||
let content = b"payload whose copy must be re-checksummed with a different algorithm";
|
let content = b"payload whose copy must be re-checksummed with a different algorithm";
|
||||||
let expected_sha256 = BASE64.encode(Sha256::digest(content));
|
let expected_sha256 = BASE64.encode_to_string(Sha256::digest(content));
|
||||||
|
|
||||||
// Source is stored WITH a SHA-256 checksum.
|
// Source is stored WITH a SHA-256 checksum.
|
||||||
client
|
client
|
||||||
|
|||||||
@@ -23,8 +23,8 @@
|
|||||||
//! It was fixed in three layers on `main`, each with its own *unit* regression:
|
//! It was fixed in three layers on `main`, each with its own *unit* regression:
|
||||||
//! * rustfs#4594 — `GetObjectStreamingReader::poll_read` now returns
|
//! * rustfs#4594 — `GetObjectStreamingReader::poll_read` now returns
|
||||||
//! `UnexpectedEof` on a short body instead of a clean `Ok(())`
|
//! `UnexpectedEof` on a short body instead of a clean `Ok(())`
|
||||||
//! (`rustfs/src/app/object_usecase.rs`,
|
//! (`rustfs/src/app/object/get.rs`,
|
||||||
//! `app::object_usecase::tests::get_object_streaming_reader_errors_on_short_eof`).
|
//! `app::object::get::tests::get_object_streaming_reader_errors_on_short_eof`).
|
||||||
//! * rustfs#4560 — the lazy multipart codec reader degrades a later part to
|
//! * rustfs#4560 — the lazy multipart codec reader degrades a later part to
|
||||||
//! the legacy per-part decode in place, and surfaces reconstruction errors
|
//! the legacy per-part decode in place, and surfaces reconstruction errors
|
||||||
//! instead of silently truncating
|
//! instead of silently truncating
|
||||||
|
|||||||
@@ -155,8 +155,13 @@ mod tests {
|
|||||||
.key(key)
|
.key(key)
|
||||||
.version_id(version_id)
|
.version_id(version_id)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await
|
||||||
assert!(get_deleted_version.is_err(), "explicitly deleted version should no longer be readable");
|
.expect_err("explicitly deleted version should no longer be readable");
|
||||||
|
assert_eq!(
|
||||||
|
get_deleted_version.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(404),
|
||||||
|
"explicitly deleted version absence probe must return HTTP 404, got {get_deleted_version:?}"
|
||||||
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -463,8 +463,18 @@ async fn test_e2e_sts_session_policy_delete_objects_object_prefix_only() -> Resu
|
|||||||
assert_eq!(error.key(), Some(denied_key));
|
assert_eq!(error.key(), Some(denied_key));
|
||||||
assert_eq!(error.code(), Some("AccessDenied"));
|
assert_eq!(error.code(), Some("AccessDenied"));
|
||||||
|
|
||||||
let allowed_head = parent_client.head_object().bucket(&bucket).key(allowed_key).send().await;
|
let allowed_head = parent_client
|
||||||
assert!(allowed_head.is_err(), "allowed-prefix object should have been deleted");
|
.head_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(allowed_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("allowed-prefix object should have been deleted");
|
||||||
|
assert_eq!(
|
||||||
|
allowed_head.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(404),
|
||||||
|
"allowed-prefix object absence probe must return HTTP 404, got {allowed_head:?}"
|
||||||
|
);
|
||||||
|
|
||||||
parent_client
|
parent_client
|
||||||
.head_object()
|
.head_object()
|
||||||
|
|||||||
@@ -1113,7 +1113,7 @@ fn md5_bytes(input: impl AsRef<[u8]>) -> [u8; 16] {
|
|||||||
fn md5_hex(input: impl AsRef<[u8]>) -> String {
|
fn md5_hex(input: impl AsRef<[u8]>) -> String {
|
||||||
let mut hasher = Md5::new();
|
let mut hasher = Md5::new();
|
||||||
hasher.update(input.as_ref());
|
hasher.update(input.as_ref());
|
||||||
hex::encode(hasher.finalize())
|
hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_store_budget(state: &StoreState, removed_bytes: usize, added_bytes: usize, adds_version: bool) -> S3Result {
|
fn ensure_store_budget(state: &StoreState, removed_bytes: usize, added_bytes: usize, adds_version: bool) -> S3Result {
|
||||||
@@ -1375,7 +1375,7 @@ impl S3 for FakeBackend {
|
|||||||
Some(value) => value,
|
Some(value) => value,
|
||||||
None => {
|
None => {
|
||||||
let (digest, _body_permit) = md5_digest(body.clone(), _body_permit).await?;
|
let (digest, _body_permit) = md5_digest(body.clone(), _body_permit).await?;
|
||||||
hex::encode(digest)
|
hex_simd::encode_to_string(digest, hex_simd::AsciiCase::Lower)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let version = ObjectVersion {
|
let version = ObjectVersion {
|
||||||
@@ -1660,7 +1660,7 @@ impl S3 for FakeBackend {
|
|||||||
}
|
}
|
||||||
let body = collect_stream(input.body, input.content_length, fault.as_ref(), &self.control).await?;
|
let body = collect_stream(input.body, input.content_length, fault.as_ref(), &self.control).await?;
|
||||||
let (digest, _body_permit) = md5_digest(body.clone(), _body_permit).await?;
|
let (digest, _body_permit) = md5_digest(body.clone(), _body_permit).await?;
|
||||||
let e_tag = hex::encode(digest);
|
let e_tag = hex_simd::encode_to_string(digest, hex_simd::AsciiCase::Lower);
|
||||||
let mut state = lock(&self.store);
|
let mut state = lock(&self.store);
|
||||||
let existing_bytes = state
|
let existing_bytes = state
|
||||||
.uploads
|
.uploads
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ use aws_sdk_s3::types::{
|
|||||||
BucketLifecycleConfiguration, BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, ExpirationStatus,
|
BucketLifecycleConfiguration, BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, ExpirationStatus,
|
||||||
LifecycleRule, LifecycleRuleFilter, ServerSideEncryption, Transition, TransitionStorageClass, VersioningConfiguration,
|
LifecycleRule, LifecycleRuleFilter, ServerSideEncryption, Transition, TransitionStorageClass, VersioningConfiguration,
|
||||||
};
|
};
|
||||||
use base64::Engine;
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use flate2::read::GzDecoder;
|
use flate2::read::GzDecoder;
|
||||||
use http::header::{CONTENT_ENCODING, HOST};
|
use http::header::{CONTENT_ENCODING, HOST};
|
||||||
@@ -1808,7 +1807,7 @@ async fn four_node_inline_fallback_controls() -> TestResult {
|
|||||||
let collector = OtlpMetricCollector::start().await?;
|
let collector = OtlpMetricCollector::start().await?;
|
||||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
||||||
configure_reader_metric_cluster(&mut cluster, &collector);
|
configure_reader_metric_cluster(&mut cluster, &collector);
|
||||||
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
|
let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
|
||||||
cluster.set_env("RUSTFS_SSE_S3_MASTER_KEY", &sse_master_key);
|
cluster.set_env("RUSTFS_SSE_S3_MASTER_KEY", &sse_master_key);
|
||||||
cluster.start().await?;
|
cluster.start().await?;
|
||||||
|
|
||||||
@@ -2017,7 +2016,7 @@ async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls() -> Te
|
|||||||
|
|
||||||
let collector = OtlpMetricCollector::start().await?;
|
let collector = OtlpMetricCollector::start().await?;
|
||||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
||||||
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
|
let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
|
||||||
cluster.set_env("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key);
|
cluster.set_env("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key);
|
||||||
cluster.set_env("RUSTFS_COMPRESSION_ENABLED", "true");
|
cluster.set_env("RUSTFS_COMPRESSION_ENABLED", "true");
|
||||||
cluster.set_env("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true");
|
cluster.set_env("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true");
|
||||||
@@ -2489,7 +2488,7 @@ async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls_during_
|
|||||||
hot.set_env("RUSTFS_SCANNER_CYCLE", "1");
|
hot.set_env("RUSTFS_SCANNER_CYCLE", "1");
|
||||||
hot.set_env("RUSTFS_ILM_PROCESS_TIME", "1");
|
hot.set_env("RUSTFS_ILM_PROCESS_TIME", "1");
|
||||||
|
|
||||||
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
|
let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
|
||||||
hot.set_env("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key);
|
hot.set_env("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key);
|
||||||
hot.set_env("RUSTFS_COMPRESSION_ENABLED", "true");
|
hot.set_env("RUSTFS_COMPRESSION_ENABLED", "true");
|
||||||
hot.start().await?;
|
hot.start().await?;
|
||||||
|
|||||||
@@ -24,9 +24,10 @@
|
|||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, awscurl_get, awscurl_post, init_logging as common_init_logging, local_http_client};
|
use crate::common::{RustFSTestEnvironment, awscurl_get, awscurl_post, init_logging as common_init_logging, local_http_client};
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::types::ServerSideEncryption;
|
use aws_sdk_s3::types::ServerSideEncryption;
|
||||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
use base64_simd::STANDARD as BASE64;
|
||||||
use http::header::{CONTENT_TYPE, HOST};
|
use http::header::{CONTENT_TYPE, HOST};
|
||||||
use md5::{Digest as Md5Digest, Md5};
|
use md5::{Digest as Md5Digest, Md5};
|
||||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||||
@@ -50,6 +51,9 @@ pub const VAULT_TOKEN: &str = "dev-root-token";
|
|||||||
pub const VAULT_TRANSIT_PATH: &str = "transit";
|
pub const VAULT_TRANSIT_PATH: &str = "transit";
|
||||||
pub const VAULT_KEY_NAME: &str = "rustfs-master-key";
|
pub const VAULT_KEY_NAME: &str = "rustfs-master-key";
|
||||||
pub const ENV_TEST_VAULT_BIN: &str = "RUSTFS_TEST_VAULT_BIN";
|
pub const ENV_TEST_VAULT_BIN: &str = "RUSTFS_TEST_VAULT_BIN";
|
||||||
|
pub const SSE_C_KEY_MISMATCH_MESSAGE: &str =
|
||||||
|
"The provided encryption parameters did not match the ones used originally to encrypt the object.";
|
||||||
|
pub const SSE_C_MISSING_PARAMETERS_MESSAGE: &str = "The object was stored using a form of Server Side Encryption. The correct parameters must be provided to retrieve the object.";
|
||||||
|
|
||||||
/// Initialize tracing for KMS tests with KMS-specific log levels
|
/// Initialize tracing for KMS tests with KMS-specific log levels
|
||||||
pub fn init_logging() {
|
pub fn init_logging() {
|
||||||
@@ -60,7 +64,25 @@ pub fn init_logging() {
|
|||||||
pub fn sse_customer_key_md5_base64(key: &str) -> String {
|
pub fn sse_customer_key_md5_base64(key: &str) -> String {
|
||||||
let mut hasher = Md5::new();
|
let mut hasher = Md5::new();
|
||||||
hasher.update(key.as_bytes());
|
hasher.update(key.as_bytes());
|
||||||
BASE64.encode(hasher.finalize())
|
BASE64.encode_to_string(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn assert_s3_error<T, E>(result: Result<T, SdkError<E>>, status: u16, code: &str, message: &str, context: &str)
|
||||||
|
where
|
||||||
|
T: std::fmt::Debug,
|
||||||
|
E: ProvideErrorMetadata + std::fmt::Debug,
|
||||||
|
{
|
||||||
|
let error = result.expect_err(context);
|
||||||
|
assert_eq!(
|
||||||
|
error.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(status),
|
||||||
|
"{context}: unexpected HTTP status: {error:?}"
|
||||||
|
);
|
||||||
|
let service_error = error
|
||||||
|
.as_service_error()
|
||||||
|
.expect("request failure should retain an S3 service error");
|
||||||
|
assert_eq!(service_error.code(), Some(code), "{context}: unexpected error code: {error:?}");
|
||||||
|
assert_eq!(service_error.message(), Some(message), "{context}: unexpected error message: {error:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn kms_admin_request(
|
pub async fn kms_admin_request(
|
||||||
@@ -343,7 +365,7 @@ pub async fn create_key_with_specific_id(key_dir: &str, key_id: &str) -> Result<
|
|||||||
"created_at": format!("{}[UTC]", chrono::Utc::now().to_rfc3339()),
|
"created_at": format!("{}[UTC]", chrono::Utc::now().to_rfc3339()),
|
||||||
"rotated_at": serde_json::Value::Null,
|
"rotated_at": serde_json::Value::Null,
|
||||||
"created_by": "e2e-test",
|
"created_by": "e2e-test",
|
||||||
"encrypted_key_material": BASE64.encode(key_data),
|
"encrypted_key_material": BASE64.encode_to_string(key_data),
|
||||||
"nonce": Vec::<u8>::new()
|
"nonce": Vec::<u8>::new()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -361,7 +383,7 @@ pub async fn test_sse_c_encryption(s3_client: &Client, bucket: &str) -> Result<(
|
|||||||
info!("Testing SSE-C encryption");
|
info!("Testing SSE-C encryption");
|
||||||
|
|
||||||
let test_key = "01234567890123456789012345678901"; // 32-byte key
|
let test_key = "01234567890123456789012345678901"; // 32-byte key
|
||||||
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
|
let test_key_b64 = base64_simd::STANDARD.encode_to_string(test_key);
|
||||||
let test_key_md5 = sse_customer_key_md5_base64(test_key);
|
let test_key_md5 = sse_customer_key_md5_base64(test_key);
|
||||||
let test_data = b"Hello, KMS SSE-C World!";
|
let test_data = b"Hello, KMS SSE-C World!";
|
||||||
let object_key = "test-sse-c-object";
|
let object_key = "test-sse-c-object";
|
||||||
@@ -529,8 +551,8 @@ pub async fn test_error_scenarios(s3_client: &Client, bucket: &str) -> Result<()
|
|||||||
// Test SSE-C with wrong key for download
|
// Test SSE-C with wrong key for download
|
||||||
let test_key = "01234567890123456789012345678901";
|
let test_key = "01234567890123456789012345678901";
|
||||||
let wrong_key = "98765432109876543210987654321098";
|
let wrong_key = "98765432109876543210987654321098";
|
||||||
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
|
let test_key_b64 = base64_simd::STANDARD.encode_to_string(test_key);
|
||||||
let wrong_key_b64 = base64::engine::general_purpose::STANDARD.encode(wrong_key);
|
let wrong_key_b64 = base64_simd::STANDARD.encode_to_string(wrong_key);
|
||||||
let test_key_md5 = sse_customer_key_md5_base64(test_key);
|
let test_key_md5 = sse_customer_key_md5_base64(test_key);
|
||||||
let wrong_key_md5 = sse_customer_key_md5_base64(wrong_key);
|
let wrong_key_md5 = sse_customer_key_md5_base64(wrong_key);
|
||||||
let test_data = b"Test data for error scenarios";
|
let test_data = b"Test data for error scenarios";
|
||||||
@@ -559,7 +581,13 @@ pub async fn test_error_scenarios(s3_client: &Client, bucket: &str) -> Result<()
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(wrong_key_result.is_err(), "Download with wrong SSE-C key should fail");
|
assert_s3_error(
|
||||||
|
wrong_key_result,
|
||||||
|
400,
|
||||||
|
"InvalidRequest",
|
||||||
|
SSE_C_KEY_MISMATCH_MESSAGE,
|
||||||
|
"download with a wrong SSE-C key must be rejected",
|
||||||
|
);
|
||||||
info!("✅ Correctly rejected download with wrong SSE-C key");
|
info!("✅ Correctly rejected download with wrong SSE-C key");
|
||||||
|
|
||||||
info!("Error scenario tests completed successfully");
|
info!("Error scenario tests completed successfully");
|
||||||
@@ -779,7 +807,7 @@ pub async fn test_multipart_upload_with_config(
|
|||||||
// Prepare encryption parameters
|
// Prepare encryption parameters
|
||||||
let (sse_c_key_b64, sse_c_key_md5) = match &config.encryption_type {
|
let (sse_c_key_b64, sse_c_key_md5) = match &config.encryption_type {
|
||||||
EncryptionType::SSEC { key, key_md5 } => {
|
EncryptionType::SSEC { key, key_md5 } => {
|
||||||
let key_b64 = base64::engine::general_purpose::STANDARD.encode(key);
|
let key_b64 = base64_simd::STANDARD.encode_to_string(key);
|
||||||
(Some(key_b64), Some(key_md5.clone()))
|
(Some(key_b64), Some(key_md5.clone()))
|
||||||
}
|
}
|
||||||
_ => (None, None),
|
_ => (None, None),
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
//! Ranged GETs over encrypted single-part objects.
|
||||||
|
//!
|
||||||
|
//! Byte-exactness must hold on every frame layout the server can write:
|
||||||
|
//! legacy v1 (variable frames, conservative full read) and, when
|
||||||
|
//! `RUSTFS_ENCRYPTION_FRAME_V2=true` reaches the server under test, the
|
||||||
|
//! fixed-frame v2 layout whose marker enables the closed-form frame seek.
|
||||||
|
//! The matrix crosses frame boundaries, starts mid-frame, and ends inside
|
||||||
|
//! the final short frame, so a mispositioned seek cannot pass.
|
||||||
|
|
||||||
|
use super::common::LocalKMSTestEnvironment;
|
||||||
|
use crate::common::{TEST_BUCKET, init_logging};
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use aws_sdk_s3::types::ServerSideEncryption;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
const FRAME_PLAINTEXT: usize = 8 * 1024;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sse_s3_single_part_ranged_gets_are_byte_exact() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
init_logging();
|
||||||
|
info!("Testing ranged GETs over an SSE-S3 single-part object");
|
||||||
|
|
||||||
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
|
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
|
kms_env.wait_for_kms_ready().await?;
|
||||||
|
|
||||||
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
|
|
||||||
|
let test_key = "encrypted-range-get";
|
||||||
|
let body: Vec<u8> = (0..3 * FRAME_PLAINTEXT + 500).map(|i| (i % 251) as u8).collect();
|
||||||
|
|
||||||
|
let put = s3_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(TEST_BUCKET)
|
||||||
|
.key(test_key)
|
||||||
|
.server_side_encryption(ServerSideEncryption::Aes256)
|
||||||
|
.body(ByteStream::from(body.clone()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(
|
||||||
|
put.server_side_encryption(),
|
||||||
|
Some(&ServerSideEncryption::Aes256),
|
||||||
|
"the object under test must actually be encrypted"
|
||||||
|
);
|
||||||
|
|
||||||
|
let cases: &[(usize, usize)] = &[
|
||||||
|
// Head range inside frame 0.
|
||||||
|
(0, 99),
|
||||||
|
// Crossing the first frame boundary.
|
||||||
|
(FRAME_PLAINTEXT - 1, FRAME_PLAINTEXT),
|
||||||
|
// Starting exactly on a frame boundary.
|
||||||
|
(FRAME_PLAINTEXT, FRAME_PLAINTEXT + 9),
|
||||||
|
// Mid-object, mid-frame on both ends.
|
||||||
|
(2 * FRAME_PLAINTEXT + 5, 3 * FRAME_PLAINTEXT + 100),
|
||||||
|
// Tail range ending inside the final short frame.
|
||||||
|
(3 * FRAME_PLAINTEXT + 100, 3 * FRAME_PLAINTEXT + 499),
|
||||||
|
];
|
||||||
|
|
||||||
|
for &(start, end) in cases {
|
||||||
|
let response = s3_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(TEST_BUCKET)
|
||||||
|
.key(test_key)
|
||||||
|
.range(format!("bytes={start}-{end}"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(
|
||||||
|
response.content_length(),
|
||||||
|
Some((end - start + 1) as i64),
|
||||||
|
"range {start}-{end} content length"
|
||||||
|
);
|
||||||
|
let data = response.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(data.as_ref(), &body[start..=end], "range {start}-{end} must be byte-exact");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A suffix range exercises the offset resolution path as well.
|
||||||
|
let response = s3_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(TEST_BUCKET)
|
||||||
|
.key(test_key)
|
||||||
|
.range("bytes=-123")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let data = response.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(data.as_ref(), &body[body.len() - 123..], "suffix range must be byte-exact");
|
||||||
|
|
||||||
|
// The unranged body still round-trips.
|
||||||
|
let response = s3_client.get_object().bucket(TEST_BUCKET).key(test_key).send().await?;
|
||||||
|
let data = response.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(data.as_ref(), body.as_slice(), "full body must round-trip");
|
||||||
|
|
||||||
|
// A block-aligned object ends in an empty authenticated final frame under
|
||||||
|
// the v2 layout; tail ranges touching the last plaintext byte must not be
|
||||||
|
// misread as truncation.
|
||||||
|
let aligned_key = "encrypted-range-get-aligned";
|
||||||
|
let aligned_body: Vec<u8> = (0..3 * FRAME_PLAINTEXT).map(|i| ((i + 3) % 251) as u8).collect();
|
||||||
|
s3_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(TEST_BUCKET)
|
||||||
|
.key(aligned_key)
|
||||||
|
.server_side_encryption(ServerSideEncryption::Aes256)
|
||||||
|
.body(ByteStream::from(aligned_body.clone()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
for (start, end) in [
|
||||||
|
(2 * FRAME_PLAINTEXT + 10, 3 * FRAME_PLAINTEXT - 1),
|
||||||
|
(3 * FRAME_PLAINTEXT - 1, 3 * FRAME_PLAINTEXT - 1),
|
||||||
|
] {
|
||||||
|
let response = s3_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(TEST_BUCKET)
|
||||||
|
.key(aligned_key)
|
||||||
|
.range(format!("bytes={start}-{end}"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let data = response.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(
|
||||||
|
data.as_ref(),
|
||||||
|
&aligned_body[start..=end],
|
||||||
|
"aligned range {start}-{end} must be byte-exact"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
//! Anonymous access to SSE-KMS objects under per-key authorization.
|
||||||
|
//!
|
||||||
|
//! Locks both halves of the anonymous contract decided in backlog#2028 (D4):
|
||||||
|
//!
|
||||||
|
//! - **Enforcement on**: anonymous requests hold no `kms` grants, so a public
|
||||||
|
//! bucket policy does not let them read SSE-KMS objects or write through an
|
||||||
|
//! SSE-KMS default-encryption rule. Both fail with `AccessDenied`.
|
||||||
|
//! - **Enforcement off** (the default): bucket policy alone governs anonymous
|
||||||
|
//! access, matching the pre-enforcement behavior — public SSE-KMS objects are
|
||||||
|
//! decrypted and served, and anonymous writes are encrypted under the default
|
||||||
|
//! key.
|
||||||
|
//!
|
||||||
|
//! The denial today is emergent — an empty-account principal falling through to
|
||||||
|
//! the IAM default deny — so without this file a refactor of principal
|
||||||
|
//! construction or policy evaluation could silently flip it. Each test carries a
|
||||||
|
//! plaintext-object positive control: a denial proves nothing while the bucket
|
||||||
|
//! policy has not propagated.
|
||||||
|
|
||||||
|
use super::common::{LocalKMSTestEnvironment, create_key_with_specific_id};
|
||||||
|
use crate::common::{init_logging, local_http_client};
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use aws_sdk_s3::types::{
|
||||||
|
ServerSideEncryption, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, ServerSideEncryptionRule,
|
||||||
|
};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||||
|
|
||||||
|
const DEFAULT_KEY: &str = "kms-anon-default-key";
|
||||||
|
const BUCKET: &str = "kms-anon-enforcement";
|
||||||
|
const PLAIN_OBJECT: &str = "plain.txt";
|
||||||
|
const ENCRYPTED_OBJECT: &str = "encrypted.txt";
|
||||||
|
const PAYLOAD: &[u8] = b"kms anonymous enforcement payload";
|
||||||
|
|
||||||
|
/// How long a bucket policy change may take to reach the request path.
|
||||||
|
const POLICY_PROPAGATION: Duration = Duration::from_secs(20);
|
||||||
|
|
||||||
|
/// Start a local-KMS server and build the public-bucket fixture.
|
||||||
|
///
|
||||||
|
/// The bucket holds a plaintext object (the positive control), an SSE-KMS
|
||||||
|
/// object, an SSE-KMS default-encryption rule, and a bucket policy opening
|
||||||
|
/// `GetObject`/`PutObject` to everyone. The enforcement switch defaults to off,
|
||||||
|
/// so the enforcing case has to set it explicitly.
|
||||||
|
async fn start_public_sse_kms_bucket(env: &mut LocalKMSTestEnvironment, enforce: bool) -> TestResult {
|
||||||
|
create_key_with_specific_id(&env.kms_keys_dir, DEFAULT_KEY).await?;
|
||||||
|
|
||||||
|
let key_dir = env.kms_keys_dir.clone();
|
||||||
|
let args = vec![
|
||||||
|
"--kms-enable",
|
||||||
|
"--kms-backend",
|
||||||
|
"local",
|
||||||
|
"--kms-key-dir",
|
||||||
|
key_dir.as_str(),
|
||||||
|
"--kms-default-key-id",
|
||||||
|
DEFAULT_KEY,
|
||||||
|
];
|
||||||
|
let mut envs = vec![("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true")];
|
||||||
|
if enforce {
|
||||||
|
envs.push(("RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY", "true"));
|
||||||
|
}
|
||||||
|
env.base_env.start_rustfs_server_with_env(args, &envs).await?;
|
||||||
|
env.base_env.create_test_bucket(BUCKET).await?;
|
||||||
|
|
||||||
|
let owner = env.base_env.create_s3_client();
|
||||||
|
|
||||||
|
owner
|
||||||
|
.put_object()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(PLAIN_OBJECT)
|
||||||
|
.body(ByteStream::from_static(PAYLOAD))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
owner
|
||||||
|
.put_object()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(ENCRYPTED_OBJECT)
|
||||||
|
.body(ByteStream::from_static(PAYLOAD))
|
||||||
|
.server_side_encryption(ServerSideEncryption::AwsKms)
|
||||||
|
.ssekms_key_id(DEFAULT_KEY)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let encryption_config = ServerSideEncryptionConfiguration::builder()
|
||||||
|
.rules(
|
||||||
|
ServerSideEncryptionRule::builder()
|
||||||
|
.apply_server_side_encryption_by_default(
|
||||||
|
ServerSideEncryptionByDefault::builder()
|
||||||
|
.sse_algorithm(ServerSideEncryption::AwsKms)
|
||||||
|
.kms_master_key_id(DEFAULT_KEY)
|
||||||
|
.build()?,
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build()?;
|
||||||
|
owner
|
||||||
|
.put_bucket_encryption()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.server_side_encryption_configuration(encryption_config)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let policy = serde_json::json!({
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [{
|
||||||
|
"Sid": "PublicReadWrite",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": "*",
|
||||||
|
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||||
|
"Resource": [format!("arn:aws:s3:::{BUCKET}/*")]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
owner.put_bucket_policy().bucket(BUCKET).policy(&policy).send().await?;
|
||||||
|
let _ = owner.delete_public_access_block().bucket(BUCKET).send().await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn object_url(env: &LocalKMSTestEnvironment, key: &str) -> String {
|
||||||
|
format!("{}/{BUCKET}/{key}", env.base_env.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn anonymous_get(env: &LocalKMSTestEnvironment, key: &str) -> Result<reqwest::Response, reqwest::Error> {
|
||||||
|
local_http_client().get(object_url(env, key)).send().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn anonymous_put(env: &LocalKMSTestEnvironment, key: &str) -> Result<reqwest::Response, reqwest::Error> {
|
||||||
|
local_http_client().put(object_url(env, key)).body(PAYLOAD).send().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retry the plaintext read until the public bucket policy is live.
|
||||||
|
async fn wait_for_public_read(env: &LocalKMSTestEnvironment) -> TestResult {
|
||||||
|
let deadline = tokio::time::Instant::now() + POLICY_PROPAGATION;
|
||||||
|
loop {
|
||||||
|
let status = anonymous_get(env, PLAIN_OBJECT).await?.status();
|
||||||
|
if status.as_u16() == 200 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if tokio::time::Instant::now() >= deadline {
|
||||||
|
return Err(format!("positive control never became readable: anonymous GET {PLAIN_OBJECT} -> {status}").into());
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_anonymous_denied(response: reqwest::Response, what: &str) -> TestResult {
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let body = response.text().await?;
|
||||||
|
assert_eq!(status, 403, "{what} must be denied, got {status}: {body}");
|
||||||
|
assert!(body.contains("AccessDenied"), "{what} must carry AccessDenied: {body}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enforcement on: a public bucket policy does not exempt anonymous requests
|
||||||
|
/// from per-key authorization, on either the read or the default-encryption
|
||||||
|
/// write path.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn anonymous_sse_kms_denied_under_enforcement() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
let mut env = LocalKMSTestEnvironment::new().await?;
|
||||||
|
start_public_sse_kms_bucket(&mut env, true).await?;
|
||||||
|
wait_for_public_read(&env).await?;
|
||||||
|
|
||||||
|
let read = anonymous_get(&env, ENCRYPTED_OBJECT).await?;
|
||||||
|
assert_anonymous_denied(read, "anonymous GET of an SSE-KMS object").await?;
|
||||||
|
|
||||||
|
let write = anonymous_put(&env, "anon-write.txt").await?;
|
||||||
|
assert_anonymous_denied(write, "anonymous PUT through an SSE-KMS default-encryption rule").await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enforcement off (the default): bucket policy alone governs anonymous access,
|
||||||
|
/// and the default-encryption rule still encrypts anonymous writes.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn anonymous_sse_kms_governed_by_bucket_policy_without_enforcement() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
let mut env = LocalKMSTestEnvironment::new().await?;
|
||||||
|
start_public_sse_kms_bucket(&mut env, false).await?;
|
||||||
|
wait_for_public_read(&env).await?;
|
||||||
|
|
||||||
|
let read = anonymous_get(&env, ENCRYPTED_OBJECT).await?;
|
||||||
|
assert_eq!(read.status().as_u16(), 200, "anonymous GET of a public SSE-KMS object must succeed");
|
||||||
|
assert_eq!(read.bytes().await?.as_ref(), PAYLOAD, "the object must be served decrypted");
|
||||||
|
|
||||||
|
let write = anonymous_put(&env, "anon-write.txt").await?;
|
||||||
|
assert_eq!(write.status().as_u16(), 200, "anonymous PUT to a public bucket must succeed");
|
||||||
|
|
||||||
|
let stored = env
|
||||||
|
.base_env
|
||||||
|
.create_s3_client()
|
||||||
|
.head_object()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key("anon-write.txt")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(
|
||||||
|
stored.server_side_encryption(),
|
||||||
|
Some(&ServerSideEncryption::AwsKms),
|
||||||
|
"the anonymous write must be encrypted by the bucket default rule"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -19,9 +19,9 @@
|
|||||||
//! complex workflows.
|
//! complex workflows.
|
||||||
|
|
||||||
use super::common::{
|
use super::common::{
|
||||||
EncryptionType, LocalKMSTestEnvironment, MultipartTestConfig, create_sse_c_config, sse_customer_key_md5_base64,
|
EncryptionType, LocalKMSTestEnvironment, MultipartTestConfig, SSE_C_KEY_MISMATCH_MESSAGE, assert_s3_error,
|
||||||
test_all_multipart_encryption_types, test_kms_key_management, test_multipart_upload_with_config, test_sse_c_encryption,
|
create_sse_c_config, sse_customer_key_md5_base64, test_all_multipart_encryption_types, test_kms_key_management,
|
||||||
test_sse_kms_encryption, test_sse_s3_encryption,
|
test_multipart_upload_with_config, test_sse_c_encryption, test_sse_kms_encryption, test_sse_s3_encryption,
|
||||||
};
|
};
|
||||||
use crate::common::{TEST_BUCKET, init_logging};
|
use crate::common::{TEST_BUCKET, init_logging};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
@@ -177,7 +177,7 @@ async fn test_comprehensive_key_isolation() -> Result<(), Box<dyn std::error::Er
|
|||||||
// Verify that files cannot be read with wrong keys
|
// Verify that files cannot be read with wrong keys
|
||||||
info!("🔒 Verify key isolation");
|
info!("🔒 Verify key isolation");
|
||||||
let wrong_key = "11111111111111111111111111111111";
|
let wrong_key = "11111111111111111111111111111111";
|
||||||
let wrong_key_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, wrong_key);
|
let wrong_key_b64 = base64_simd::STANDARD.encode_to_string(wrong_key);
|
||||||
let wrong_key_md5 = sse_customer_key_md5_base64(wrong_key);
|
let wrong_key_md5 = sse_customer_key_md5_base64(wrong_key);
|
||||||
|
|
||||||
// Try to read file encrypted with key1 using wrong key
|
// Try to read file encrypted with key1 using wrong key
|
||||||
@@ -191,7 +191,13 @@ async fn test_comprehensive_key_isolation() -> Result<(), Box<dyn std::error::Er
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(wrong_read_result.is_err(), "The encrypted file should not be readable with the wrong key");
|
assert_s3_error(
|
||||||
|
wrong_read_result,
|
||||||
|
400,
|
||||||
|
"InvalidRequest",
|
||||||
|
SSE_C_KEY_MISMATCH_MESSAGE,
|
||||||
|
"multipart SSE-C object GET with a wrong key must be rejected",
|
||||||
|
);
|
||||||
info!("✅ Confirm that key isolation is working correctly");
|
info!("✅ Confirm that key isolation is working correctly");
|
||||||
|
|
||||||
kms_env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
||||||
|
|||||||
@@ -21,21 +21,13 @@
|
|||||||
//! - Concurrent encryption operations
|
//! - Concurrent encryption operations
|
||||||
//! - Security validation tests
|
//! - Security validation tests
|
||||||
|
|
||||||
use super::common::{LocalKMSTestEnvironment, sse_customer_key_md5_base64};
|
use super::common::{LocalKMSTestEnvironment, SSE_C_KEY_MISMATCH_MESSAGE, assert_s3_error, sse_customer_key_md5_base64};
|
||||||
use crate::common::{TEST_BUCKET, init_logging};
|
use crate::common::{TEST_BUCKET, init_logging};
|
||||||
use aws_sdk_s3::types::ServerSideEncryption;
|
use aws_sdk_s3::types::ServerSideEncryption;
|
||||||
use base64::Engine;
|
|
||||||
use md5::{Digest as Md5Digest, Md5};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
fn md5_hex(input: impl AsRef<[u8]>) -> String {
|
|
||||||
let mut hasher = Md5::new();
|
|
||||||
hasher.update(input.as_ref());
|
|
||||||
hex::encode(hasher.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test encryption of zero-byte files (empty files)
|
/// Test encryption of zero-byte files (empty files)
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_kms_zero_byte_file_encryption() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
async fn test_kms_zero_byte_file_encryption() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
@@ -75,7 +67,7 @@ async fn test_kms_zero_byte_file_encryption() -> Result<(), Box<dyn std::error::
|
|||||||
// Test SSE-C with zero-byte file
|
// Test SSE-C with zero-byte file
|
||||||
info!("📤 Testing SSE-C with zero-byte file");
|
info!("📤 Testing SSE-C with zero-byte file");
|
||||||
let test_key = "01234567890123456789012345678901";
|
let test_key = "01234567890123456789012345678901";
|
||||||
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
|
let test_key_b64 = base64_simd::STANDARD.encode_to_string(test_key);
|
||||||
let test_key_md5 = sse_customer_key_md5_base64(test_key);
|
let test_key_md5 = sse_customer_key_md5_base64(test_key);
|
||||||
let object_key_c = "zero-byte-sse-c";
|
let object_key_c = "zero-byte-sse-c";
|
||||||
|
|
||||||
@@ -168,7 +160,7 @@ async fn test_kms_single_byte_file_encryption() -> Result<(), Box<dyn std::error
|
|||||||
// Test SSE-C with single byte
|
// Test SSE-C with single byte
|
||||||
info!("📤 Testing SSE-C with single-byte file");
|
info!("📤 Testing SSE-C with single-byte file");
|
||||||
let test_key = "01234567890123456789012345678901";
|
let test_key = "01234567890123456789012345678901";
|
||||||
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
|
let test_key_b64 = base64_simd::STANDARD.encode_to_string(test_key);
|
||||||
let test_key_md5 = sse_customer_key_md5_base64(test_key);
|
let test_key_md5 = sse_customer_key_md5_base64(test_key);
|
||||||
let object_key_c = "single-byte-sse-c";
|
let object_key_c = "single-byte-sse-c";
|
||||||
|
|
||||||
@@ -294,8 +286,8 @@ async fn test_kms_invalid_key_scenarios() -> Result<(), Box<dyn std::error::Erro
|
|||||||
// Test 1: Invalid key length for SSE-C
|
// Test 1: Invalid key length for SSE-C
|
||||||
info!("🔍 Testing invalid SSE-C key length");
|
info!("🔍 Testing invalid SSE-C key length");
|
||||||
let invalid_short_key = "short"; // Too short
|
let invalid_short_key = "short"; // Too short
|
||||||
let invalid_key_b64 = base64::engine::general_purpose::STANDARD.encode(invalid_short_key);
|
let invalid_key_b64 = base64_simd::STANDARD.encode_to_string(invalid_short_key);
|
||||||
let invalid_key_md5 = md5_hex(invalid_short_key);
|
let invalid_key_md5 = sse_customer_key_md5_base64(invalid_short_key);
|
||||||
|
|
||||||
let invalid_key_result = s3_client
|
let invalid_key_result = s3_client
|
||||||
.put_object()
|
.put_object()
|
||||||
@@ -308,14 +300,32 @@ async fn test_kms_invalid_key_scenarios() -> Result<(), Box<dyn std::error::Erro
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(invalid_key_result.is_err(), "Should reject invalid key length");
|
assert_s3_error(
|
||||||
|
invalid_key_result,
|
||||||
|
400,
|
||||||
|
"InvalidRequest",
|
||||||
|
"SSE-C key must be 32 bytes (256 bits), got 5 bytes.",
|
||||||
|
"invalid SSE-C key length must be rejected",
|
||||||
|
);
|
||||||
|
assert_s3_error(
|
||||||
|
s3_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(TEST_BUCKET)
|
||||||
|
.key("test-invalid-key-length")
|
||||||
|
.send()
|
||||||
|
.await,
|
||||||
|
404,
|
||||||
|
"NoSuchKey",
|
||||||
|
"The specified key does not exist.",
|
||||||
|
"rejected invalid-key PUT must not create an object",
|
||||||
|
);
|
||||||
info!("✅ Correctly rejected invalid key length");
|
info!("✅ Correctly rejected invalid key length");
|
||||||
|
|
||||||
// Test 2: Mismatched MD5 for SSE-C
|
// Test 2: Mismatched MD5 for SSE-C
|
||||||
info!("🔍 Testing mismatched MD5 for SSE-C key");
|
info!("🔍 Testing mismatched MD5 for SSE-C key");
|
||||||
let valid_key = "01234567890123456789012345678901";
|
let valid_key = "01234567890123456789012345678901";
|
||||||
let valid_key_b64 = base64::engine::general_purpose::STANDARD.encode(valid_key);
|
let valid_key_b64 = base64_simd::STANDARD.encode_to_string(valid_key);
|
||||||
let wrong_md5 = "wrongmd5hash12345678901234567890"; // Wrong MD5
|
let wrong_md5 = sse_customer_key_md5_base64("98765432109876543210987654321098");
|
||||||
|
|
||||||
let wrong_md5_result = s3_client
|
let wrong_md5_result = s3_client
|
||||||
.put_object()
|
.put_object()
|
||||||
@@ -324,11 +334,24 @@ async fn test_kms_invalid_key_scenarios() -> Result<(), Box<dyn std::error::Erro
|
|||||||
.body(aws_sdk_s3::primitives::ByteStream::from(test_data.to_vec()))
|
.body(aws_sdk_s3::primitives::ByteStream::from(test_data.to_vec()))
|
||||||
.sse_customer_algorithm("AES256")
|
.sse_customer_algorithm("AES256")
|
||||||
.sse_customer_key(&valid_key_b64)
|
.sse_customer_key(&valid_key_b64)
|
||||||
.sse_customer_key_md5(wrong_md5)
|
.sse_customer_key_md5(&wrong_md5)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(wrong_md5_result.is_err(), "Should reject mismatched MD5");
|
assert_s3_error(
|
||||||
|
wrong_md5_result,
|
||||||
|
400,
|
||||||
|
"InvalidRequest",
|
||||||
|
"The calculated MD5 hash of the key did not match the hash that was provided.",
|
||||||
|
"mismatched SSE-C key MD5 must be rejected",
|
||||||
|
);
|
||||||
|
assert_s3_error(
|
||||||
|
s3_client.get_object().bucket(TEST_BUCKET).key("test-wrong-md5").send().await,
|
||||||
|
404,
|
||||||
|
"NoSuchKey",
|
||||||
|
"The specified key does not exist.",
|
||||||
|
"rejected mismatched-MD5 PUT must not create an object",
|
||||||
|
);
|
||||||
info!("✅ Correctly rejected mismatched MD5");
|
info!("✅ Correctly rejected mismatched MD5");
|
||||||
|
|
||||||
// Test 3: Try to access SSE-C object without providing key
|
// Test 3: Try to access SSE-C object without providing key
|
||||||
@@ -355,7 +378,28 @@ async fn test_kms_invalid_key_scenarios() -> Result<(), Box<dyn std::error::Erro
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(no_key_result.is_err(), "Should require SSE-C key for access");
|
assert_s3_error(
|
||||||
|
no_key_result,
|
||||||
|
400,
|
||||||
|
"InvalidRequest",
|
||||||
|
"The object was stored using a form of Server Side Encryption. The correct parameters must be provided to retrieve the object.",
|
||||||
|
"SSE-C object GET without a customer key must be rejected",
|
||||||
|
);
|
||||||
|
|
||||||
|
let recovered = s3_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(TEST_BUCKET)
|
||||||
|
.key("test-sse-c-no-key-access")
|
||||||
|
.sse_customer_algorithm("AES256")
|
||||||
|
.sse_customer_key(&valid_key_b64)
|
||||||
|
.sse_customer_key_md5(&valid_key_md5)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.body
|
||||||
|
.collect()
|
||||||
|
.await?
|
||||||
|
.into_bytes();
|
||||||
|
assert_eq!(recovered.as_ref(), test_data, "failed GET must not corrupt the SSE-C object");
|
||||||
info!("✅ Correctly required SSE-C key for access");
|
info!("✅ Correctly required SSE-C key for access");
|
||||||
|
|
||||||
kms_env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -420,7 +464,7 @@ async fn test_kms_concurrent_encryption() -> Result<(), Box<dyn std::error::Erro
|
|||||||
2 => {
|
2 => {
|
||||||
// SSE-C
|
// SSE-C
|
||||||
let key = format!("testkey{i:026}"); // 32-byte key
|
let key = format!("testkey{i:026}"); // 32-byte key
|
||||||
let key_b64 = base64::engine::general_purpose::STANDARD.encode(&key);
|
let key_b64 = base64_simd::STANDARD.encode_to_string(&key);
|
||||||
let key_md5 = sse_customer_key_md5_base64(&key);
|
let key_md5 = sse_customer_key_md5_base64(&key);
|
||||||
|
|
||||||
client
|
client
|
||||||
@@ -490,8 +534,8 @@ async fn test_kms_key_validation_security() -> Result<(), Box<dyn std::error::Er
|
|||||||
let key1 = "key1key1key1key1key1key1key1key1"; // 32 bytes
|
let key1 = "key1key1key1key1key1key1key1key1"; // 32 bytes
|
||||||
let key2 = "key2key2key2key2key2key2key2key2"; // 32 bytes
|
let key2 = "key2key2key2key2key2key2key2key2"; // 32 bytes
|
||||||
|
|
||||||
let key1_b64 = base64::engine::general_purpose::STANDARD.encode(key1);
|
let key1_b64 = base64_simd::STANDARD.encode_to_string(key1);
|
||||||
let key2_b64 = base64::engine::general_purpose::STANDARD.encode(key2);
|
let key2_b64 = base64_simd::STANDARD.encode_to_string(key2);
|
||||||
let key1_md5 = sse_customer_key_md5_base64(key1);
|
let key1_md5 = sse_customer_key_md5_base64(key1);
|
||||||
let key2_md5 = sse_customer_key_md5_base64(key2);
|
let key2_md5 = sse_customer_key_md5_base64(key2);
|
||||||
|
|
||||||
@@ -563,7 +607,13 @@ async fn test_kms_key_validation_security() -> Result<(), Box<dyn std::error::Er
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(wrong_key_result.is_err(), "Should not be able to decrypt with wrong key");
|
assert_s3_error(
|
||||||
|
wrong_key_result,
|
||||||
|
400,
|
||||||
|
"InvalidRequest",
|
||||||
|
SSE_C_KEY_MISMATCH_MESSAGE,
|
||||||
|
"SSE-C object GET with the wrong customer key must be rejected",
|
||||||
|
);
|
||||||
info!("✅ Key isolation verified - wrong key cannot decrypt data");
|
info!("✅ Key isolation verified - wrong key cannot decrypt data");
|
||||||
|
|
||||||
kms_env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
use super::common::LocalKMSTestEnvironment;
|
use super::common::LocalKMSTestEnvironment;
|
||||||
use crate::common::{TEST_BUCKET, init_logging};
|
use crate::common::{TEST_BUCKET, init_logging};
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::types::ServerSideEncryption;
|
use aws_sdk_s3::types::ServerSideEncryption;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -77,12 +78,25 @@ async fn test_kms_key_directory_unavailable() -> Result<(), Box<dyn std::error::
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// This should fail, but the server should still be responsive
|
let unavailable_error = put_result2.expect_err("a missing Local KMS key directory must reject encrypted writes");
|
||||||
if put_result2.is_err() {
|
assert_eq!(unavailable_error.raw_response().map(|response| response.status().as_u16()), Some(500));
|
||||||
info!("✅ Upload correctly failed when key directory unavailable");
|
assert_eq!(
|
||||||
} else {
|
unavailable_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
warn!("⚠️ Upload succeeded despite unavailable key directory (may be using cached keys)");
|
Some("InternalError")
|
||||||
}
|
);
|
||||||
|
let unavailable_absence = s3_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(TEST_BUCKET)
|
||||||
|
.key(object_key2)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("a write rejected by unavailable KMS must not publish an object");
|
||||||
|
assert_eq!(unavailable_absence.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||||
|
assert_eq!(
|
||||||
|
unavailable_absence.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some("NoSuchKey")
|
||||||
|
);
|
||||||
|
info!("✅ Upload correctly failed when key directory unavailable");
|
||||||
|
|
||||||
// Restore the key directory
|
// Restore the key directory
|
||||||
info!("🔧 Restoring key directory");
|
info!("🔧 Restoring key directory");
|
||||||
@@ -107,6 +121,11 @@ async fn test_kms_key_directory_unavailable() -> Result<(), Box<dyn std::error::
|
|||||||
|
|
||||||
assert_eq!(put_response3.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
assert_eq!(put_response3.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
||||||
|
|
||||||
|
let get_response3 = s3_client.get_object().bucket(TEST_BUCKET).key(object_key3).send().await?;
|
||||||
|
assert_eq!(get_response3.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
||||||
|
let downloaded_data3 = get_response3.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(downloaded_data3.as_ref(), test_data3);
|
||||||
|
|
||||||
// Verify we can still access the original file
|
// Verify we can still access the original file
|
||||||
info!("📥 Verifying access to original encrypted file");
|
info!("📥 Verifying access to original encrypted file");
|
||||||
let get_response = s3_client.get_object().bucket(TEST_BUCKET).key(object_key).send().await?;
|
let get_response = s3_client.get_object().bucket(TEST_BUCKET).key(object_key).send().await?;
|
||||||
@@ -174,12 +193,22 @@ async fn test_kms_corrupted_key_files() -> Result<(), Box<dyn std::error::Error
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// This might succeed if KMS uses cached keys, but should eventually fail
|
let corrupt_error = put_result2.expect_err("corrupt Local KMS key material must reject encrypted writes");
|
||||||
if put_result2.is_err() {
|
assert_eq!(corrupt_error.raw_response().map(|response| response.status().as_u16()), Some(500));
|
||||||
info!("✅ Upload correctly failed with corrupted key");
|
assert_eq!(
|
||||||
} else {
|
corrupt_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
warn!("⚠️ Upload succeeded despite corrupted key (likely using cached key)");
|
Some("InternalError")
|
||||||
}
|
);
|
||||||
|
let corrupt_absence = s3_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(TEST_BUCKET)
|
||||||
|
.key(object_key2)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("a write rejected by corrupt KMS material must not publish an object");
|
||||||
|
assert_eq!(corrupt_absence.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||||
|
assert_eq!(corrupt_absence.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
|
||||||
|
info!("✅ Upload correctly failed with corrupted key");
|
||||||
|
|
||||||
// Restore the original key file
|
// Restore the original key file
|
||||||
info!("🔧 Restoring original key file");
|
info!("🔧 Restoring original key file");
|
||||||
@@ -205,6 +234,11 @@ async fn test_kms_corrupted_key_files() -> Result<(), Box<dyn std::error::Error
|
|||||||
|
|
||||||
assert_eq!(put_response3.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
assert_eq!(put_response3.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
||||||
|
|
||||||
|
let get_response3 = s3_client.get_object().bucket(TEST_BUCKET).key(object_key3).send().await?;
|
||||||
|
assert_eq!(get_response3.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
||||||
|
let downloaded_data3 = get_response3.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(downloaded_data3.as_ref(), test_data3);
|
||||||
|
|
||||||
kms_env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
||||||
info!("✅ Corrupted key files test completed successfully");
|
info!("✅ Corrupted key files test completed successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -280,18 +314,14 @@ async fn test_kms_multipart_upload_interruption() -> Result<(), Box<dyn std::err
|
|||||||
info!("🔧 Simulating upload interruption");
|
info!("🔧 Simulating upload interruption");
|
||||||
|
|
||||||
// Abort the multipart upload
|
// Abort the multipart upload
|
||||||
let abort_result = s3_client
|
s3_client
|
||||||
.abort_multipart_upload()
|
.abort_multipart_upload()
|
||||||
.bucket(TEST_BUCKET)
|
.bucket(TEST_BUCKET)
|
||||||
.key(object_key)
|
.key(object_key)
|
||||||
.upload_id(upload_id)
|
.upload_id(upload_id)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await?;
|
||||||
|
info!("✅ Multipart upload aborted successfully");
|
||||||
match abort_result {
|
|
||||||
Ok(_) => info!("✅ Multipart upload aborted successfully"),
|
|
||||||
Err(e) => warn!("⚠️ Failed to abort multipart upload: {}", e),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to complete the aborted upload - this should fail
|
// Try to complete the aborted upload - this should fail
|
||||||
info!("🔍 Attempting to complete aborted upload");
|
info!("🔍 Attempting to complete aborted upload");
|
||||||
@@ -310,18 +340,38 @@ async fn test_kms_multipart_upload_interruption() -> Result<(), Box<dyn std::err
|
|||||||
.set_parts(Some(completed_parts))
|
.set_parts(Some(completed_parts))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let complete_result = s3_client
|
let complete_error = s3_client
|
||||||
.complete_multipart_upload()
|
.complete_multipart_upload()
|
||||||
.bucket(TEST_BUCKET)
|
.bucket(TEST_BUCKET)
|
||||||
.key(object_key)
|
.key(object_key)
|
||||||
.upload_id(upload_id)
|
.upload_id(upload_id)
|
||||||
.multipart_upload(completed_multipart_upload)
|
.multipart_upload(completed_multipart_upload)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await
|
||||||
|
.expect_err("an aborted multipart upload must not be completable");
|
||||||
assert!(complete_result.is_err(), "Should not be able to complete aborted upload");
|
assert_eq!(complete_error.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||||
|
assert_eq!(
|
||||||
|
complete_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some("NoSuchUpload")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
complete_error.as_service_error().and_then(ProvideErrorMetadata::message),
|
||||||
|
Some(
|
||||||
|
"The specified multipart upload does not exist. The upload ID may be invalid, or the upload may have been aborted or completed."
|
||||||
|
)
|
||||||
|
);
|
||||||
info!("✅ Correctly failed to complete aborted upload");
|
info!("✅ Correctly failed to complete aborted upload");
|
||||||
|
|
||||||
|
let missing_object = s3_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(TEST_BUCKET)
|
||||||
|
.key(object_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("aborting a multipart upload must not publish an object");
|
||||||
|
assert_eq!(missing_object.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||||
|
assert_eq!(missing_object.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
|
||||||
|
|
||||||
// Start a new multipart upload and complete it successfully
|
// Start a new multipart upload and complete it successfully
|
||||||
info!("📤 Starting new multipart upload");
|
info!("📤 Starting new multipart upload");
|
||||||
let create_multipart_output2 = s3_client
|
let create_multipart_output2 = s3_client
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
//! - Complete encryption/decryption lifecycle
|
//! - Complete encryption/decryption lifecycle
|
||||||
|
|
||||||
use super::common::{
|
use super::common::{
|
||||||
LocalKMSTestEnvironment, get_kms_status, sse_customer_key_md5_base64, test_kms_key_management, test_sse_c_encryption,
|
LocalKMSTestEnvironment, SSE_C_KEY_MISMATCH_MESSAGE, assert_s3_error, get_kms_status, sse_customer_key_md5_base64,
|
||||||
|
test_kms_key_management, test_sse_c_encryption,
|
||||||
};
|
};
|
||||||
use crate::common::{TEST_BUCKET, init_logging};
|
use crate::common::{TEST_BUCKET, init_logging};
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
@@ -137,8 +138,8 @@ async fn test_local_kms_key_isolation() {
|
|||||||
// Test that different SSE-C keys create isolated encrypted objects
|
// Test that different SSE-C keys create isolated encrypted objects
|
||||||
let key1 = "01234567890123456789012345678901";
|
let key1 = "01234567890123456789012345678901";
|
||||||
let key2 = "98765432109876543210987654321098";
|
let key2 = "98765432109876543210987654321098";
|
||||||
let key1_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key1);
|
let key1_b64 = base64_simd::STANDARD.encode_to_string(key1);
|
||||||
let key2_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key2);
|
let key2_b64 = base64_simd::STANDARD.encode_to_string(key2);
|
||||||
let key1_md5 = sse_customer_key_md5_base64(key1);
|
let key1_md5 = sse_customer_key_md5_base64(key1);
|
||||||
let key2_md5 = sse_customer_key_md5_base64(key2);
|
let key2_md5 = sse_customer_key_md5_base64(key2);
|
||||||
|
|
||||||
@@ -196,7 +197,13 @@ async fn test_local_kms_key_isolation() {
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(wrong_key_result.is_err(), "Should not be able to decrypt object1 with key2");
|
assert_s3_error(
|
||||||
|
wrong_key_result,
|
||||||
|
400,
|
||||||
|
"InvalidRequest",
|
||||||
|
SSE_C_KEY_MISMATCH_MESSAGE,
|
||||||
|
"local SSE-C object GET with a wrong key must be rejected",
|
||||||
|
);
|
||||||
|
|
||||||
kms_env
|
kms_env
|
||||||
.base_env
|
.base_env
|
||||||
@@ -558,7 +565,7 @@ async fn test_multipart_upload_with_sse_c(
|
|||||||
|
|
||||||
// SSE-C encryption key
|
// SSE-C encryption key
|
||||||
let encryption_key = "01234567890123456789012345678901";
|
let encryption_key = "01234567890123456789012345678901";
|
||||||
let key_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, encryption_key);
|
let key_b64 = base64_simd::STANDARD.encode_to_string(encryption_key);
|
||||||
let key_md5 = sse_customer_key_md5_base64(encryption_key);
|
let key_md5 = sse_customer_key_md5_base64(encryption_key);
|
||||||
|
|
||||||
// Generate test data
|
// Generate test data
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
//! Bulk DEK rekey sweep over stored objects.
|
||||||
|
//!
|
||||||
|
//! The full loop — rotate the master key, sweep, prove convergence — runs
|
||||||
|
//! against Vault Transit, whose context-bound envelopes exercise the
|
||||||
|
//! decrypt + re-encrypt rewrap route end to end. The capability refusal runs
|
||||||
|
//! against the Local backend, which supports no rewrap at all.
|
||||||
|
|
||||||
|
use super::common::{
|
||||||
|
LocalKMSTestEnvironment, VAULT_KEY_NAME, VaultTestEnvironment, kms_admin_request, start_kms, wait_for_kms_ready,
|
||||||
|
};
|
||||||
|
use crate::common::{TEST_BUCKET, init_logging};
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use aws_sdk_s3::types::ServerSideEncryption;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
async fn rekey_status(
|
||||||
|
base_url: &str,
|
||||||
|
access_key: &str,
|
||||||
|
secret_key: &str,
|
||||||
|
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let body = kms_admin_request(
|
||||||
|
base_url,
|
||||||
|
http::Method::GET,
|
||||||
|
"/rustfs/admin/v3/kms/keys/rekey/status",
|
||||||
|
None,
|
||||||
|
access_key,
|
||||||
|
secret_key,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(serde_json::from_str(&body)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a sweep and poll it to a terminal state.
|
||||||
|
async fn run_rekey_to_completion(
|
||||||
|
base_url: &str,
|
||||||
|
access_key: &str,
|
||||||
|
secret_key: &str,
|
||||||
|
request_body: &str,
|
||||||
|
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
kms_admin_request(
|
||||||
|
base_url,
|
||||||
|
http::Method::POST,
|
||||||
|
"/rustfs/admin/v3/kms/keys/rekey",
|
||||||
|
Some(request_body),
|
||||||
|
access_key,
|
||||||
|
secret_key,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for _ in 0..120 {
|
||||||
|
let status = rekey_status(base_url, access_key, secret_key).await?;
|
||||||
|
if status["state"] != "running" {
|
||||||
|
return Ok(status);
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
Err("rekey sweep did not reach a terminal state in time".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn kms_rekey_sweep_rewraps_rotated_envelopes_and_converges() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
init_logging();
|
||||||
|
info!("Testing the bulk rekey sweep against Vault Transit");
|
||||||
|
|
||||||
|
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?;
|
||||||
|
wait_for_kms_ready(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await?;
|
||||||
|
|
||||||
|
let base_url = env.base_env.url.clone();
|
||||||
|
let access_key = env.base_env.access_key.clone();
|
||||||
|
let secret_key = env.base_env.secret_key.clone();
|
||||||
|
|
||||||
|
let s3_client = env.base_env.create_s3_client();
|
||||||
|
env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
|
|
||||||
|
// Three encrypted objects the sweep must rewrap, one plaintext object it
|
||||||
|
// must leave alone.
|
||||||
|
let encrypted_keys = ["rekey/alpha", "rekey/beta", "rekey/gamma"];
|
||||||
|
let mut bodies = Vec::new();
|
||||||
|
for (index, key) in encrypted_keys.iter().enumerate() {
|
||||||
|
let body: Vec<u8> = (0..2048).map(|i| ((i + index * 7) % 251) as u8).collect();
|
||||||
|
s3_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(TEST_BUCKET)
|
||||||
|
.key(*key)
|
||||||
|
.server_side_encryption(ServerSideEncryption::AwsKms)
|
||||||
|
.ssekms_key_id(VAULT_KEY_NAME)
|
||||||
|
.body(ByteStream::from(body.clone()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
bodies.push(body);
|
||||||
|
}
|
||||||
|
s3_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(TEST_BUCKET)
|
||||||
|
.key("rekey/plaintext")
|
||||||
|
.body(ByteStream::from(b"unencrypted".to_vec()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Rotate the master key so the stored envelopes fall behind Vault's
|
||||||
|
// latest version.
|
||||||
|
kms_admin_request(
|
||||||
|
&base_url,
|
||||||
|
http::Method::POST,
|
||||||
|
"/rustfs/admin/v3/kms/keys/rotate",
|
||||||
|
Some(&format!(r#"{{"key_id":"{VAULT_KEY_NAME}"}}"#)),
|
||||||
|
&access_key,
|
||||||
|
&secret_key,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status =
|
||||||
|
run_rekey_to_completion(&base_url, &access_key, &secret_key, &format!(r#"{{"buckets":["{TEST_BUCKET}"]}}"#)).await?;
|
||||||
|
assert_eq!(status["state"], "completed", "first sweep must complete: {status}");
|
||||||
|
assert_eq!(status["failed"], 0, "no object may fail: {status}");
|
||||||
|
assert_eq!(
|
||||||
|
status["rewrapped"],
|
||||||
|
encrypted_keys.len(),
|
||||||
|
"every rotated envelope must be rewrapped: {status}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
status["not_applicable"].as_u64().unwrap_or(0) >= 1,
|
||||||
|
"the plaintext object must be reported not applicable: {status}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The rewrapped objects still serve their exact bytes.
|
||||||
|
for (key, expected) in encrypted_keys.iter().zip(&bodies) {
|
||||||
|
let response = s3_client.get_object().bucket(TEST_BUCKET).key(*key).send().await?;
|
||||||
|
let data = response.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(data.as_ref(), expected.as_slice(), "object {key} must be byte-exact after the rewrap");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convergence: a second sweep finds everything current and writes nothing.
|
||||||
|
let status =
|
||||||
|
run_rekey_to_completion(&base_url, &access_key, &secret_key, &format!(r#"{{"buckets":["{TEST_BUCKET}"]}}"#)).await?;
|
||||||
|
assert_eq!(status["state"], "completed", "second sweep must complete: {status}");
|
||||||
|
assert_eq!(status["rewrapped"], 0, "a converged sweep must write nothing: {status}");
|
||||||
|
assert_eq!(status["failed"], 0, "{status}");
|
||||||
|
assert_eq!(
|
||||||
|
status["already_current"],
|
||||||
|
encrypted_keys.len(),
|
||||||
|
"every envelope must now be current: {status}"
|
||||||
|
);
|
||||||
|
|
||||||
|
env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn kms_rekey_refuses_a_backend_without_rewrap_support() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
init_logging();
|
||||||
|
info!("Testing that the rekey sweep refuses the Local backend up front");
|
||||||
|
|
||||||
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
|
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
|
kms_env.wait_for_kms_ready().await?;
|
||||||
|
|
||||||
|
let error = kms_admin_request(
|
||||||
|
&kms_env.base_env.url,
|
||||||
|
http::Method::POST,
|
||||||
|
"/rustfs/admin/v3/kms/keys/rekey",
|
||||||
|
Some("{}"),
|
||||||
|
&kms_env.base_env.access_key,
|
||||||
|
&kms_env.base_env.secret_key,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("a backend without rewrap support must be refused up front");
|
||||||
|
assert!(error.to_string().contains("501"), "the refusal must be 501 Not Implemented, got: {error}");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -22,9 +22,9 @@ use crate::common::{TEST_BUCKET, init_logging};
|
|||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
use super::common::{
|
use super::common::{
|
||||||
VAULT_KEY_NAME, VaultTestEnvironment, get_kms_status, sse_customer_key_md5_base64, start_kms,
|
SSE_C_KEY_MISMATCH_MESSAGE, VAULT_KEY_NAME, VaultTestEnvironment, assert_s3_error, get_kms_status,
|
||||||
test_all_multipart_encryption_types, test_error_scenarios, test_kms_key_management, test_sse_c_encryption,
|
sse_customer_key_md5_base64, start_kms, test_all_multipart_encryption_types, test_error_scenarios, test_kms_key_management,
|
||||||
test_sse_kms_encryption, test_sse_s3_encryption,
|
test_sse_c_encryption, test_sse_kms_encryption, test_sse_s3_encryption,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Helper that brings up Vault, configures RustFS, and starts the KMS service.
|
/// Helper that brings up Vault, configures RustFS, and starts the KMS service.
|
||||||
@@ -127,8 +127,8 @@ async fn test_vault_kms_key_isolation() -> Result<(), Box<dyn std::error::Error
|
|||||||
|
|
||||||
let key1 = "01234567890123456789012345678901";
|
let key1 = "01234567890123456789012345678901";
|
||||||
let key2 = "98765432109876543210987654321098";
|
let key2 = "98765432109876543210987654321098";
|
||||||
let key1_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key1);
|
let key1_b64 = base64_simd::STANDARD.encode_to_string(key1);
|
||||||
let key2_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key2);
|
let key2_b64 = base64_simd::STANDARD.encode_to_string(key2);
|
||||||
let key1_md5 = sse_customer_key_md5_base64(key1);
|
let key1_md5 = sse_customer_key_md5_base64(key1);
|
||||||
let key2_md5 = sse_customer_key_md5_base64(key2);
|
let key2_md5 = sse_customer_key_md5_base64(key2);
|
||||||
|
|
||||||
@@ -182,7 +182,13 @@ async fn test_vault_kms_key_isolation() -> Result<(), Box<dyn std::error::Error
|
|||||||
.sse_customer_key_md5(&key2_md5)
|
.sse_customer_key_md5(&key2_md5)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
assert!(wrong_key.is_err(), "Object1 should not decrypt with key2");
|
assert_s3_error(
|
||||||
|
wrong_key,
|
||||||
|
400,
|
||||||
|
"InvalidRequest",
|
||||||
|
SSE_C_KEY_MISMATCH_MESSAGE,
|
||||||
|
"Vault-backed SSE-C object GET with a wrong key must be rejected",
|
||||||
|
);
|
||||||
|
|
||||||
context
|
context
|
||||||
.base_env()
|
.base_env()
|
||||||
|
|||||||
@@ -48,14 +48,23 @@ mod encryption_metadata_test;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod copy_object_self_copy_sse_test;
|
mod copy_object_self_copy_sse_test;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod encrypted_range_get_test;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod copy_object_version_restore_sse_test;
|
mod copy_object_version_restore_sse_test;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod configured_roundtrip_test;
|
mod configured_roundtrip_test;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod kms_anonymous_enforcement_test;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod kms_authorization_negative_matrix_test;
|
mod kms_authorization_negative_matrix_test;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod kms_ilm_sse_kms_test;
|
mod kms_ilm_sse_kms_test;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod kms_rekey_sweep_test;
|
||||||
|
|||||||
@@ -497,7 +497,7 @@ async fn test_multipart_encryption_type(
|
|||||||
// Prepare SSE-C keys when required
|
// Prepare SSE-C keys when required
|
||||||
let (sse_c_key, sse_c_md5) = if matches!(encryption_type, EncryptionType::SSEC) {
|
let (sse_c_key, sse_c_md5) = if matches!(encryption_type, EncryptionType::SSEC) {
|
||||||
let key = "01234567890123456789012345678901";
|
let key = "01234567890123456789012345678901";
|
||||||
let key_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key);
|
let key_b64 = base64_simd::STANDARD.encode_to_string(key);
|
||||||
let key_md5 = sse_customer_key_md5_base64(key);
|
let key_md5 = sse_customer_key_md5_base64(key);
|
||||||
(Some(key_b64), Some(key_md5))
|
(Some(key_b64), Some(key_md5))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ mod get_codec_streaming_compat_test;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod version_id_regression_test;
|
mod version_id_regression_test;
|
||||||
|
|
||||||
|
// Pinned previous-release -> current-build on-disk compatibility.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod upgrade_compatibility_test;
|
||||||
|
|
||||||
// Receiver-side replication LWW (rustfs/backlog#1953): stale inbound
|
// Receiver-side replication LWW (rustfs/backlog#1953): stale inbound
|
||||||
// replication metadata must not overwrite a newer local category state.
|
// replication metadata must not overwrite a newer local category state.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -280,6 +284,7 @@ mod console_smoke_test;
|
|||||||
// plus non-admin 403 probes per endpoint (sec-4 pattern).
|
// plus non-admin 403 probes per endpoint (sec-4 pattern).
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod admin_iam_crud_test;
|
mod admin_iam_crud_test;
|
||||||
|
mod admin_mfa_test;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod admin_pools_test;
|
mod admin_pools_test;
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ use aws_sdk_s3::primitives::ByteStream;
|
|||||||
use aws_sdk_s3::types::{
|
use aws_sdk_s3::types::{
|
||||||
ServerSideEncryption, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, ServerSideEncryptionRule,
|
ServerSideEncryption, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, ServerSideEncryptionRule,
|
||||||
};
|
};
|
||||||
use base64::Engine;
|
|
||||||
use chrono::{Duration as ChronoDuration, Utc};
|
use chrono::{Duration as ChronoDuration, Utc};
|
||||||
use flate2::{Compression, write::GzEncoder};
|
use flate2::{Compression, write::GzEncoder};
|
||||||
use http::HeaderValue;
|
use http::HeaderValue;
|
||||||
@@ -47,19 +46,19 @@ fn encode_post_policy(conditions: Vec<serde_json::Value>) -> String {
|
|||||||
"conditions": conditions,
|
"conditions": conditions,
|
||||||
});
|
});
|
||||||
|
|
||||||
base64::engine::general_purpose::STANDARD.encode(policy.to_string())
|
base64_simd::STANDARD.encode_to_string(policy.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sse_customer_key_md5_base64(key: &str) -> String {
|
fn sse_customer_key_md5_base64(key: &str) -> String {
|
||||||
let mut hasher = Md5::new();
|
let mut hasher = Md5::new();
|
||||||
hasher.update(key.as_bytes());
|
hasher.update(key.as_bytes());
|
||||||
base64::engine::general_purpose::STANDARD.encode(hasher.finalize())
|
base64_simd::STANDARD.encode_to_string(hasher.finalize())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn md5_hex(input: impl AsRef<[u8]>) -> String {
|
fn md5_hex(input: impl AsRef<[u8]>) -> String {
|
||||||
let mut hasher = Md5::new();
|
let mut hasher = Md5::new();
|
||||||
hasher.update(input.as_ref());
|
hasher.update(input.as_ref());
|
||||||
hex::encode(hasher.finalize())
|
hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_restricted_user(
|
async fn create_restricted_user(
|
||||||
@@ -97,7 +96,7 @@ fn restricted_user_client(env: &RustFSTestEnvironment, username: &str, secret_ke
|
|||||||
const LOCAL_SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
|
const LOCAL_SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
|
||||||
|
|
||||||
fn local_sse_master_key_value() -> String {
|
fn local_sse_master_key_value() -> String {
|
||||||
base64::engine::general_purpose::STANDARD.encode([0x42u8; 32])
|
base64_simd::STANDARD.encode_to_string([0x42u8; 32])
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn make_tar(files: &[(&str, &[u8])], dirs: &[&str]) -> Vec<u8> {
|
async fn make_tar(files: &[(&str, &[u8])], dirs: &[&str]) -> Vec<u8> {
|
||||||
@@ -1887,7 +1886,7 @@ async fn test_anonymous_post_object_allows_sse_c_fields_outside_policy_condition
|
|||||||
let object_key = "sse-c-object.txt";
|
let object_key = "sse-c-object.txt";
|
||||||
let expected_body = b"anonymous-post-sse-c".to_vec();
|
let expected_body = b"anonymous-post-sse-c".to_vec();
|
||||||
let customer_key = "01234567890123456789012345678901";
|
let customer_key = "01234567890123456789012345678901";
|
||||||
let customer_key_b64 = base64::engine::general_purpose::STANDARD.encode(customer_key);
|
let customer_key_b64 = base64_simd::STANDARD.encode_to_string(customer_key);
|
||||||
let customer_key_md5 = sse_customer_key_md5_base64(customer_key);
|
let customer_key_md5 = sse_customer_key_md5_base64(customer_key);
|
||||||
|
|
||||||
let admin_client = env.create_s3_client();
|
let admin_client = env.create_s3_client();
|
||||||
@@ -1941,7 +1940,7 @@ async fn test_anonymous_post_object_allows_sse_c_fields_outside_policy_condition
|
|||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
.key(object_key)
|
.key(object_key)
|
||||||
.sse_customer_algorithm("AES256")
|
.sse_customer_algorithm("AES256")
|
||||||
.sse_customer_key(base64::engine::general_purpose::STANDARD.encode(customer_key))
|
.sse_customer_key(base64_simd::STANDARD.encode_to_string(customer_key))
|
||||||
.sse_customer_key_md5(customer_key_md5)
|
.sse_customer_key_md5(customer_key_md5)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
@@ -1963,8 +1962,8 @@ async fn test_anonymous_post_object_rejects_sse_c_exact_policy_mismatch() -> Res
|
|||||||
let object_key = "sse-c-mismatch-object.txt";
|
let object_key = "sse-c-mismatch-object.txt";
|
||||||
let policy_key = "01234567890123456789012345678901";
|
let policy_key = "01234567890123456789012345678901";
|
||||||
let request_key = "abcdefghijklmnopqrstuvwxyzABCDEF";
|
let request_key = "abcdefghijklmnopqrstuvwxyzABCDEF";
|
||||||
let policy_key_b64 = base64::engine::general_purpose::STANDARD.encode(policy_key);
|
let policy_key_b64 = base64_simd::STANDARD.encode_to_string(policy_key);
|
||||||
let request_key_b64 = base64::engine::general_purpose::STANDARD.encode(request_key);
|
let request_key_b64 = base64_simd::STANDARD.encode_to_string(request_key);
|
||||||
|
|
||||||
let admin_client = env.create_s3_client();
|
let admin_client = env.create_s3_client();
|
||||||
admin_client.create_bucket().bucket(bucket).send().await?;
|
admin_client.create_bucket().bucket(bucket).send().await?;
|
||||||
@@ -3526,7 +3525,7 @@ async fn test_signed_put_object_extract_preserves_sse_s3_and_redirect() -> Resul
|
|||||||
init_logging();
|
init_logging();
|
||||||
|
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
|
let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
|
||||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key.as_str())])
|
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key.as_str())])
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -3799,7 +3798,7 @@ async fn test_signed_put_object_extract_uses_bucket_default_sse_s3() -> Result<(
|
|||||||
init_logging();
|
init_logging();
|
||||||
|
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
|
let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
|
||||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key.as_str())])
|
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key.as_str())])
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -3925,7 +3924,7 @@ async fn test_signed_put_object_extract_preserves_sse_c() -> Result<(), Box<dyn
|
|||||||
let extracted_key = "nested/file.txt";
|
let extracted_key = "nested/file.txt";
|
||||||
let expected_body = b"extract-sse-c-body".to_vec();
|
let expected_body = b"extract-sse-c-body".to_vec();
|
||||||
let customer_key = "01234567890123456789012345678901";
|
let customer_key = "01234567890123456789012345678901";
|
||||||
let customer_key_b64 = base64::engine::general_purpose::STANDARD.encode(customer_key);
|
let customer_key_b64 = base64_simd::STANDARD.encode_to_string(customer_key);
|
||||||
let customer_key_md5 = sse_customer_key_md5_base64(customer_key);
|
let customer_key_md5 = sse_customer_key_md5_base64(customer_key);
|
||||||
|
|
||||||
let client = env.create_s3_client();
|
let client = env.create_s3_client();
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
//! rejected header-SigV4 requests.
|
//! rejected header-SigV4 requests.
|
||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||||
use rustfs_signer::request_signature_v4::{SIGN_V4_ALGORITHM, get_scope, get_signature, get_signing_key};
|
use rustfs_signer::request_signature_v4::{SIGN_V4_ALGORITHM, get_scope, get_signature, get_signing_key};
|
||||||
@@ -280,7 +281,8 @@ async fn tampered_payload_is_rejected() -> Result<(), Box<dyn std::error::Error
|
|||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
setup(&mut env).await?;
|
setup(&mut env).await?;
|
||||||
|
|
||||||
let path = format!("/{BUCKET}/tampered-payload.txt");
|
let key = "tampered-payload.txt";
|
||||||
|
let path = format!("/{BUCKET}/{key}");
|
||||||
let claimed_body = b"the-body-i-claim-to-send";
|
let claimed_body = b"the-body-i-claim-to-send";
|
||||||
let actual_body = b"the-body-i-really-send!!";
|
let actual_body = b"the-body-i-really-send!!";
|
||||||
assert_eq!(claimed_body.len(), actual_body.len(), "keep content-length stable for the mismatch");
|
assert_eq!(claimed_body.len(), actual_body.len(), "keep content-length stable for the mismatch");
|
||||||
@@ -295,17 +297,82 @@ async fn tampered_payload_is_rejected() -> Result<(), Box<dyn std::error::Error
|
|||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
assert_ne!(status.as_u16(), 200, "payload mismatch must not succeed, body:\n{body}");
|
|
||||||
assert!(
|
assert!(
|
||||||
status.is_client_error() || status.is_server_error(),
|
status.is_client_error(),
|
||||||
"payload mismatch must be an error status, got {status}, body:\n{body}"
|
"payload mismatch must be rejected with a client error, got {status}, body:\n{body}"
|
||||||
);
|
);
|
||||||
info!(%status, "tampered payload rejected with error status");
|
info!(%status, "tampered payload rejected with error status");
|
||||||
}
|
}
|
||||||
// A mid-stream hash-mismatch abort surfacing as a transport error is
|
// A mid-stream hash-mismatch abort surfacing as a transport error is
|
||||||
// also a valid rejection (definitely not a 200 success).
|
// also a valid rejection (definitely not a 200 success).
|
||||||
Err(err) => info!(%err, "tampered payload rejected via transport error"),
|
Err(err) => {
|
||||||
|
assert!(!err.is_connect(), "connection failure is not proof of payload rejection: {err}");
|
||||||
|
assert!(!err.is_timeout(), "request timeout is not proof of payload rejection: {err}");
|
||||||
|
info!(%err, "tampered payload rejected via mid-stream transport error");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let absent = env
|
||||||
|
.create_s3_client()
|
||||||
|
.get_object()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("a tampered payload must not publish an object");
|
||||||
|
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||||
|
assert_eq!(absent.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A signed UploadPart body must pass the same payload-hash gate as PutObject.
|
||||||
|
/// Rejection must happen before the part is published into the multipart upload.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tampered_upload_part_payload_is_rejected() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
init_logging();
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
setup(&mut env).await?;
|
||||||
|
|
||||||
|
let key = "tampered-upload-part.bin";
|
||||||
|
let client = env.create_s3_client();
|
||||||
|
let upload = client.create_multipart_upload().bucket(BUCKET).key(key).send().await?;
|
||||||
|
let upload_id = upload.upload_id().ok_or("create multipart upload omitted upload_id")?;
|
||||||
|
|
||||||
|
let path = format!("/{BUCKET}/{key}");
|
||||||
|
let canonical_query = format!("partNumber=1&uploadId={}", urlencoding::encode(upload_id));
|
||||||
|
let request_target = format!("{path}?{canonical_query}");
|
||||||
|
let claimed_body = b"the-part-i-claim-to-send";
|
||||||
|
let actual_body = b"the-part-i-really-send!!";
|
||||||
|
assert_eq!(claimed_body.len(), actual_body.len(), "keep content-length stable for the mismatch");
|
||||||
|
|
||||||
|
let signer = SigV4::new(&env);
|
||||||
|
let headers = signer.sign("PUT", &path, &canonical_query, &sha256_hex(claimed_body));
|
||||||
|
let resp = send_signed(&env, reqwest::Method::PUT, &request_target, &headers, Some(actual_body.to_vec())).await?;
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
reqwest::StatusCode::BAD_REQUEST,
|
||||||
|
"multipart payload mismatch must be rejected as BadDigest, body:\n{body}"
|
||||||
|
);
|
||||||
|
assert_error_code(&body, "BadDigest");
|
||||||
|
|
||||||
|
let parts = client
|
||||||
|
.list_parts()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(key)
|
||||||
|
.upload_id(upload_id)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert!(parts.parts().is_empty(), "a tampered UploadPart must not publish a part");
|
||||||
|
|
||||||
|
client
|
||||||
|
.abort_multipart_upload()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(key)
|
||||||
|
.upload_id(upload_id)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
//! - Bypass governance retention header handling
|
//! - Bypass governance retention header handling
|
||||||
|
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::error::SdkError;
|
||||||
|
use aws_sdk_s3::operation::delete_object::DeleteObjectError;
|
||||||
|
use aws_sdk_s3::operation::put_object_retention::PutObjectRetentionError;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::types::{
|
use aws_sdk_s3::types::{
|
||||||
DefaultRetention, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockMode,
|
DefaultRetention, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockMode,
|
||||||
@@ -180,11 +183,8 @@ pub async fn put_object_retention(
|
|||||||
mode: ObjectLockRetentionMode,
|
mode: ObjectLockRetentionMode,
|
||||||
retain_until: DateTime<Utc>,
|
retain_until: DateTime<Utc>,
|
||||||
bypass_governance: bool,
|
bypass_governance: bool,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<SdkError<PutObjectRetentionError>>> {
|
||||||
// AWS SDK requires UTC time without timezone offset (e.g., "2026-01-24T11:20:14Z")
|
let retain_until_datetime = aws_sdk_s3::primitives::DateTime::from_secs(retain_until.timestamp());
|
||||||
let retain_until_str = retain_until.format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
|
||||||
let retain_until_datetime =
|
|
||||||
aws_sdk_s3::primitives::DateTime::from_str(&retain_until_str, aws_sdk_s3::primitives::DateTimeFormat::DateTime)?;
|
|
||||||
|
|
||||||
let retention = ObjectLockRetention::builder()
|
let retention = ObjectLockRetention::builder()
|
||||||
.mode(mode.clone())
|
.mode(mode.clone())
|
||||||
@@ -202,7 +202,7 @@ pub async fn put_object_retention(
|
|||||||
request = request.version_id(vid);
|
request = request.version_id(vid);
|
||||||
}
|
}
|
||||||
|
|
||||||
request.send().await?;
|
request.send().await.map_err(Box::new)?;
|
||||||
info!("Put object retention on {} with mode {:?}", key, mode);
|
info!("Put object retention on {} with mode {:?}", key, mode);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -236,7 +236,7 @@ pub async fn delete_object_with_bypass(
|
|||||||
key: &str,
|
key: &str,
|
||||||
version_id: Option<&str>,
|
version_id: Option<&str>,
|
||||||
bypass_governance: bool,
|
bypass_governance: bool,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<SdkError<DeleteObjectError>>> {
|
||||||
let mut request = client
|
let mut request = client
|
||||||
.delete_object()
|
.delete_object()
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
@@ -247,7 +247,7 @@ pub async fn delete_object_with_bypass(
|
|||||||
request = request.version_id(vid);
|
request = request.version_id(vid);
|
||||||
}
|
}
|
||||||
|
|
||||||
request.send().await?;
|
request.send().await.map_err(Box::new)?;
|
||||||
info!("Deleted object {} (bypass: {})", key, bypass_governance);
|
info!("Deleted object {} (bypass: {})", key, bypass_governance);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,11 @@
|
|||||||
//! - PutObjectRetention modification restrictions
|
//! - PutObjectRetention modification restrictions
|
||||||
//! - Default bucket retention is applied to new objects
|
//! - Default bucket retention is applied to new objects
|
||||||
|
|
||||||
|
use std::borrow::Borrow;
|
||||||
|
|
||||||
use super::common::*;
|
use super::common::*;
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
|
||||||
use aws_sdk_s3::primitives::{ByteStream, DateTimeFormat};
|
use aws_sdk_s3::primitives::{ByteStream, DateTimeFormat};
|
||||||
use aws_sdk_s3::types::{
|
use aws_sdk_s3::types::{
|
||||||
CompletedMultipartUpload, CompletedPart, Delete, MetadataDirective, ObjectIdentifier, ObjectLockLegalHoldStatus,
|
CompletedMultipartUpload, CompletedPart, Delete, MetadataDirective, ObjectIdentifier, ObjectLockLegalHoldStatus,
|
||||||
@@ -70,25 +72,49 @@ fn retention_timestamp(days: i64) -> aws_sdk_s3::primitives::DateTime {
|
|||||||
.expect("retention timestamp should parse")
|
.expect("retention timestamp should parse")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assert_access_denied<T, E: std::fmt::Debug>(result: Result<T, E>, context: &str) {
|
fn assert_access_denied<T, E, R>(result: Result<T, R>, context: &str)
|
||||||
let err = match result {
|
where
|
||||||
Ok(_) => panic!("{context}"),
|
T: std::fmt::Debug,
|
||||||
Err(err) => format!("{err:?}"),
|
E: ProvideErrorMetadata + std::fmt::Debug,
|
||||||
};
|
R: Borrow<SdkError<E>> + std::fmt::Debug,
|
||||||
assert!(
|
{
|
||||||
err.contains("AccessDenied") || err.to_lowercase().contains("access denied"),
|
let error = result.expect_err(context);
|
||||||
"{context}: expected AccessDenied, got: {err}"
|
let sdk_error = error.borrow();
|
||||||
|
assert_eq!(
|
||||||
|
sdk_error.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(403),
|
||||||
|
"{context}: expected HTTP 403, got: {error:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sdk_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some("AccessDenied"),
|
||||||
|
"{context}: expected AccessDenied, got: {error:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assert_invalid_object_lock_retention_pair<T, E: std::fmt::Debug>(result: Result<T, E>, context: &str) {
|
fn assert_invalid_object_lock_retention_pair<T, E, R>(result: Result<T, R>, context: &str)
|
||||||
let err = match result {
|
where
|
||||||
Ok(_) => panic!("{context}"),
|
T: std::fmt::Debug,
|
||||||
Err(err) => format!("{err:?}"),
|
E: ProvideErrorMetadata + std::fmt::Debug,
|
||||||
};
|
R: Borrow<SdkError<E>> + std::fmt::Debug,
|
||||||
assert!(
|
{
|
||||||
err.contains("InvalidRequest") || err.contains("must both be supplied"),
|
let error = result.expect_err(context);
|
||||||
"{context}: expected invalid paired retention headers, got: {err}"
|
let sdk_error = error.borrow();
|
||||||
|
assert_eq!(
|
||||||
|
sdk_error.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(400),
|
||||||
|
"{context}: expected HTTP 400, got: {error:?}"
|
||||||
|
);
|
||||||
|
let service_error = sdk_error.as_service_error().expect("expected an S3 service error");
|
||||||
|
assert_eq!(
|
||||||
|
service_error.code(),
|
||||||
|
Some("InvalidRequest"),
|
||||||
|
"{context}: expected InvalidRequest, got: {error:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
service_error.message(),
|
||||||
|
Some("x-amz-object-lock-retain-until-date and x-amz-object-lock-mode must both be supplied"),
|
||||||
|
"{context}: unexpected error message: {error:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,14 +155,15 @@ async fn test_delete_object_blocked_by_compliance_retention() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Attempt to delete - should fail
|
// Attempt to delete - should fail
|
||||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await;
|
assert_access_denied(
|
||||||
assert!(delete_result.is_err(), "Delete should fail for COMPLIANCE locked object");
|
delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await,
|
||||||
|
"Delete should fail for COMPLIANCE locked object",
|
||||||
|
);
|
||||||
|
|
||||||
// Even with bypass header, COMPLIANCE should not allow deletion
|
// Even with bypass header, COMPLIANCE should not allow deletion
|
||||||
let delete_with_bypass_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), true).await;
|
assert_access_denied(
|
||||||
assert!(
|
delete_object_with_bypass(&client, bucket, key, Some(&version_id), true).await,
|
||||||
delete_with_bypass_result.is_err(),
|
"Delete with bypass should still fail for COMPLIANCE mode",
|
||||||
"Delete with bypass should still fail for COMPLIANCE mode"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
info!("✅ Test passed: COMPLIANCE retention blocks deletion");
|
info!("✅ Test passed: COMPLIANCE retention blocks deletion");
|
||||||
@@ -165,8 +192,10 @@ async fn test_delete_object_blocked_by_governance_without_bypass() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Attempt to delete without bypass - should fail
|
// Attempt to delete without bypass - should fail
|
||||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await;
|
assert_access_denied(
|
||||||
assert!(delete_result.is_err(), "Delete without bypass should fail for GOVERNANCE locked object");
|
delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await,
|
||||||
|
"Delete without bypass should fail for GOVERNANCE locked object",
|
||||||
|
);
|
||||||
|
|
||||||
info!("✅ Test passed: GOVERNANCE retention blocks deletion without bypass");
|
info!("✅ Test passed: GOVERNANCE retention blocks deletion without bypass");
|
||||||
}
|
}
|
||||||
@@ -198,14 +227,19 @@ async fn test_delete_object_allowed_by_governance_with_bypass() {
|
|||||||
assert!(delete_result.is_ok(), "Delete with bypass should succeed for GOVERNANCE mode");
|
assert!(delete_result.is_ok(), "Delete with bypass should succeed for GOVERNANCE mode");
|
||||||
|
|
||||||
// Verify object is deleted
|
// Verify object is deleted
|
||||||
let head_result = client
|
let head_error = client
|
||||||
.head_object()
|
.head_object()
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
.key(key)
|
.key(key)
|
||||||
.version_id(&version_id)
|
.version_id(&version_id)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await
|
||||||
assert!(head_result.is_err(), "Object should be deleted");
|
.expect_err("Object should be deleted");
|
||||||
|
assert_eq!(
|
||||||
|
head_error.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(404),
|
||||||
|
"deleted version should return HTTP 404: {head_error:?}"
|
||||||
|
);
|
||||||
|
|
||||||
info!("✅ Test passed: GOVERNANCE retention allows deletion with bypass");
|
info!("✅ Test passed: GOVERNANCE retention allows deletion with bypass");
|
||||||
}
|
}
|
||||||
@@ -240,17 +274,18 @@ async fn test_delete_object_creates_delete_marker_for_retained_current_version()
|
|||||||
.expect("delete marker should have a version id")
|
.expect("delete marker should have a version id")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let protected_delete = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await;
|
assert_access_denied(
|
||||||
assert!(protected_delete.is_err(), "Retained version should still reject direct deletion");
|
delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await,
|
||||||
|
"Retained version should still reject direct deletion",
|
||||||
|
);
|
||||||
|
|
||||||
delete_object_with_bypass(&client, bucket, key, Some(&delete_marker_version_id), false)
|
delete_object_with_bypass(&client, bucket, key, Some(&delete_marker_version_id), false)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let still_protected = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await;
|
assert_access_denied(
|
||||||
assert!(
|
delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await,
|
||||||
still_protected.is_err(),
|
"Retained version should remain protected after delete marker removal",
|
||||||
"Retained version should remain protected after delete marker removal"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true)
|
delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true)
|
||||||
@@ -282,12 +317,16 @@ async fn test_delete_object_blocked_by_legal_hold() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Attempt to delete - should fail (legal hold cannot be bypassed)
|
// Attempt to delete - should fail (legal hold cannot be bypassed)
|
||||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await;
|
assert_access_denied(
|
||||||
assert!(delete_result.is_err(), "Delete should fail for legal hold object");
|
delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await,
|
||||||
|
"Delete should fail for legal hold object",
|
||||||
|
);
|
||||||
|
|
||||||
// Even with bypass header, legal hold should block deletion
|
// Even with bypass header, legal hold should block deletion
|
||||||
let delete_with_bypass_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), true).await;
|
assert_access_denied(
|
||||||
assert!(delete_with_bypass_result.is_err(), "Delete with bypass should still fail for legal hold");
|
delete_object_with_bypass(&client, bucket, key, Some(&version_id), true).await,
|
||||||
|
"Delete with bypass should still fail for legal hold",
|
||||||
|
);
|
||||||
|
|
||||||
info!("✅ Test passed: Legal Hold blocks deletion");
|
info!("✅ Test passed: Legal Hold blocks deletion");
|
||||||
}
|
}
|
||||||
@@ -315,14 +354,19 @@ async fn test_delete_object_allowed_with_legal_hold_off() {
|
|||||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await;
|
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&version_id), false).await;
|
||||||
assert!(delete_result.is_ok(), "Delete should succeed when legal hold is OFF");
|
assert!(delete_result.is_ok(), "Delete should succeed when legal hold is OFF");
|
||||||
|
|
||||||
let head_result = client
|
let head_error = client
|
||||||
.head_object()
|
.head_object()
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
.key(key)
|
.key(key)
|
||||||
.version_id(&version_id)
|
.version_id(&version_id)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await
|
||||||
assert!(head_result.is_err(), "Object should be deleted when legal hold is OFF");
|
.expect_err("Object should be deleted when legal hold is OFF");
|
||||||
|
assert_eq!(
|
||||||
|
head_error.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(404),
|
||||||
|
"deleted version should return HTTP 404: {head_error:?}"
|
||||||
|
);
|
||||||
|
|
||||||
info!("✅ Test passed: Legal Hold OFF allows deletion");
|
info!("✅ Test passed: Legal Hold OFF allows deletion");
|
||||||
}
|
}
|
||||||
@@ -545,8 +589,10 @@ async fn test_put_object_overwrite_creates_new_version_under_legal_hold() {
|
|||||||
"held version must keep its legal hold after the overwrite"
|
"held version must keep its legal hold after the overwrite"
|
||||||
);
|
);
|
||||||
|
|
||||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&held_version_id), false).await;
|
assert_access_denied(
|
||||||
assert!(delete_result.is_err(), "held version must stay delete-protected after the overwrite");
|
delete_object_with_bypass(&client, bucket, key, Some(&held_version_id), false).await,
|
||||||
|
"held version must stay delete-protected after the overwrite",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -768,8 +814,10 @@ async fn test_copy_object_overwrite_creates_new_version_under_legal_hold() {
|
|||||||
"held destination version must keep its legal hold after the copy"
|
"held destination version must keep its legal hold after the copy"
|
||||||
);
|
);
|
||||||
|
|
||||||
let delete_result = delete_object_with_bypass(&client, bucket, dst_key, Some(&held_version_id), false).await;
|
assert_access_denied(
|
||||||
assert!(delete_result.is_err(), "held destination version must stay delete-protected");
|
delete_object_with_bypass(&client, bucket, dst_key, Some(&held_version_id), false).await,
|
||||||
|
"held destination version must stay delete-protected",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -909,10 +957,9 @@ async fn test_create_multipart_upload_creates_new_version_under_compliance_reten
|
|||||||
|
|
||||||
// COMPLIANCE retention on the previous version survives the overwrite and
|
// COMPLIANCE retention on the previous version survives the overwrite and
|
||||||
// cannot be bypassed.
|
// cannot be bypassed.
|
||||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true).await;
|
assert_access_denied(
|
||||||
assert!(
|
delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true).await,
|
||||||
delete_result.is_err(),
|
"retained version must stay delete-protected even with governance bypass",
|
||||||
"retained version must stay delete-protected even with governance bypass"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -971,8 +1018,10 @@ async fn test_delete_completed_multipart_object_blocked_by_legal_hold() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let version_id = complete_output.version_id().expect("multipart object should be versioned");
|
let version_id = complete_output.version_id().expect("multipart object should be versioned");
|
||||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await;
|
assert_access_denied(
|
||||||
assert!(delete_result.is_err(), "Delete should fail for multipart object protected by legal hold");
|
delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await,
|
||||||
|
"Delete should fail for multipart object protected by legal hold",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1032,8 +1081,10 @@ async fn test_delete_completed_multipart_object_blocked_by_retention() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let version_id = complete_output.version_id().expect("multipart object should be versioned");
|
let version_id = complete_output.version_id().expect("multipart object should be versioned");
|
||||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await;
|
assert_access_denied(
|
||||||
assert!(delete_result.is_err(), "Delete should fail for multipart object protected by retention");
|
delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await,
|
||||||
|
"Delete should fail for multipart object protected by retention",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1111,8 +1162,10 @@ async fn test_complete_multipart_upload_creates_new_version_under_legal_hold() {
|
|||||||
"held version must keep its legal hold after multipart completion"
|
"held version must keep its legal hold after multipart completion"
|
||||||
);
|
);
|
||||||
|
|
||||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&held_version_id), false).await;
|
assert_access_denied(
|
||||||
assert!(delete_result.is_err(), "held version must stay delete-protected");
|
delete_object_with_bypass(&client, bucket, key, Some(&held_version_id), false).await,
|
||||||
|
"held version must stay delete-protected",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1181,10 +1234,9 @@ async fn test_complete_multipart_upload_creates_new_version_under_compliance_ret
|
|||||||
|
|
||||||
// COMPLIANCE retention on the previous version survives the overwrite and
|
// COMPLIANCE retention on the previous version survives the overwrite and
|
||||||
// cannot be bypassed.
|
// cannot be bypassed.
|
||||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true).await;
|
assert_access_denied(
|
||||||
assert!(
|
delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), true).await,
|
||||||
delete_result.is_err(),
|
"retained version must stay delete-protected even with governance bypass",
|
||||||
"retained version must stay delete-protected even with governance bypass"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1438,7 +1490,7 @@ async fn test_put_retention_compliance_cannot_shorten() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(shorten_result.is_err(), "Shortening COMPLIANCE retention should fail");
|
assert_access_denied(shorten_result, "Shortening COMPLIANCE retention should fail");
|
||||||
|
|
||||||
info!("✅ Test passed: Cannot shorten COMPLIANCE retention");
|
info!("✅ Test passed: Cannot shorten COMPLIANCE retention");
|
||||||
}
|
}
|
||||||
@@ -1561,10 +1613,7 @@ async fn test_put_retention_governance_shorten_requires_bypass() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(
|
assert_access_denied(shorten_without_bypass, "Shortening GOVERNANCE retention without bypass should fail");
|
||||||
shorten_without_bypass.is_err(),
|
|
||||||
"Shortening GOVERNANCE retention without bypass should fail"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Shorten with bypass - should succeed
|
// Shorten with bypass - should succeed
|
||||||
let shorten_with_bypass = put_object_retention(
|
let shorten_with_bypass = put_object_retention(
|
||||||
@@ -1621,8 +1670,10 @@ async fn test_default_retention_applied_to_new_objects() {
|
|||||||
let version_id = response.version_id().unwrap();
|
let version_id = response.version_id().unwrap();
|
||||||
|
|
||||||
// Try to delete without bypass - should fail due to default retention
|
// Try to delete without bypass - should fail due to default retention
|
||||||
let delete_result = delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await;
|
assert_access_denied(
|
||||||
assert!(delete_result.is_err(), "Delete should fail for object with default retention applied");
|
delete_object_with_bypass(&client, bucket, key, Some(version_id), false).await,
|
||||||
|
"Delete should fail for object with default retention applied",
|
||||||
|
);
|
||||||
|
|
||||||
let retention = client
|
let retention = client
|
||||||
.get_object_retention()
|
.get_object_retention()
|
||||||
@@ -1710,8 +1761,10 @@ async fn test_delete_object_creates_delete_marker_for_default_retained_current_v
|
|||||||
.expect("delete marker should have a version id")
|
.expect("delete marker should have a version id")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let protected_delete = delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await;
|
assert_access_denied(
|
||||||
assert!(protected_delete.is_err(), "Default-retained version should still reject direct deletion");
|
delete_object_with_bypass(&client, bucket, key, Some(&retained_version_id), false).await,
|
||||||
|
"Default-retained version should still reject direct deletion",
|
||||||
|
);
|
||||||
|
|
||||||
let retention_after_delete_marker = client
|
let retention_after_delete_marker = client
|
||||||
.get_object_retention()
|
.get_object_retention()
|
||||||
@@ -2011,11 +2064,9 @@ async fn test_copy_object_retention_uses_destination_policy() {
|
|||||||
|
|
||||||
// COMPLIANCE retention on the previous destination version survives the
|
// COMPLIANCE retention on the previous destination version survives the
|
||||||
// overwrite and cannot be bypassed.
|
// overwrite and cannot be bypassed.
|
||||||
let delete_result =
|
assert_access_denied(
|
||||||
delete_object_with_bypass(&client, dst_bucket, "locked-destination", Some(&retained_version_id), true).await;
|
delete_object_with_bypass(&client, dst_bucket, "locked-destination", Some(&retained_version_id), true).await,
|
||||||
assert!(
|
"retained destination version must stay delete-protected even with governance bypass",
|
||||||
delete_result.is_err(),
|
|
||||||
"retained destination version must stay delete-protected even with governance bypass"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2268,9 +2319,9 @@ async fn test_versioning_auto_enabled_with_object_lock() {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_error_message_distinguishes_legal_hold_from_retention() {
|
async fn test_legal_hold_and_retention_delete_errors_are_exact_and_non_mutating() {
|
||||||
init_logging();
|
init_logging();
|
||||||
info!("🧪 Test: Error messages distinguish Legal Hold from Retention");
|
info!("🧪 Test: Legal Hold and Retention reject deletes without mutating objects");
|
||||||
|
|
||||||
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
let mut env = ObjectLockTestEnvironment::new().await.unwrap();
|
||||||
env.start_rustfs().await.unwrap();
|
env.start_rustfs().await.unwrap();
|
||||||
@@ -2295,7 +2346,6 @@ async fn test_error_message_distinguishes_legal_hold_from_retention() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Delete legal hold object - check error
|
|
||||||
let lh_delete_result = client
|
let lh_delete_result = client
|
||||||
.delete_object()
|
.delete_object()
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
@@ -2303,18 +2353,8 @@ async fn test_error_message_distinguishes_legal_hold_from_retention() {
|
|||||||
.version_id(&lh_version)
|
.version_id(&lh_version)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
assert_access_denied(lh_delete_result, "Legal Hold must reject deleting the protected version");
|
||||||
|
|
||||||
if let Err(e) = lh_delete_result {
|
|
||||||
let error_str = format!("{:?}", e);
|
|
||||||
info!("Legal hold delete error: {}", error_str);
|
|
||||||
// Error should mention legal hold
|
|
||||||
assert!(
|
|
||||||
error_str.to_lowercase().contains("legal") || error_str.to_lowercase().contains("hold"),
|
|
||||||
"Error should mention legal hold"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete retention object - check error
|
|
||||||
let ret_delete_result = client
|
let ret_delete_result = client
|
||||||
.delete_object()
|
.delete_object()
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
@@ -2322,16 +2362,24 @@ async fn test_error_message_distinguishes_legal_hold_from_retention() {
|
|||||||
.version_id(&ret_version)
|
.version_id(&ret_version)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
assert_access_denied(ret_delete_result, "COMPLIANCE retention must reject deleting the protected version");
|
||||||
|
|
||||||
if let Err(e) = ret_delete_result {
|
for (key, version_id) in [(legal_hold_key, &lh_version), (retention_key, &ret_version)] {
|
||||||
let error_str = format!("{:?}", e);
|
let body = client
|
||||||
info!("Retention delete error: {}", error_str);
|
.get_object()
|
||||||
// Error should mention retention
|
.bucket(bucket)
|
||||||
assert!(
|
.key(key)
|
||||||
error_str.to_lowercase().contains("retention") || error_str.to_lowercase().contains("compliance"),
|
.version_id(version_id)
|
||||||
"Error should mention retention"
|
.send()
|
||||||
);
|
.await
|
||||||
|
.expect("rejected delete must leave the protected version readable")
|
||||||
|
.body
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.expect("protected version body should remain readable")
|
||||||
|
.into_bytes();
|
||||||
|
assert_eq!(body.as_ref(), b"data", "rejected delete mutated protected object {key}");
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("✅ Test passed: Error messages distinguish lock types");
|
info!("✅ Test passed: protected deletes are exact and non-mutating");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ fn valid_config() -> PresigningConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Flip bytes inside the `X-Amz-Signature=` query value without changing its
|
/// Flip bytes inside the `X-Amz-Signature=` query value without changing its
|
||||||
/// length, producing a structurally valid but incorrect signature.
|
/// length, producing a structurally valid but incorrect signature. Every hex
|
||||||
|
/// digit is replaced by its complement (15 - v), which has no fixed point, so
|
||||||
|
/// the tamper changes the value no matter which digits the signature contains.
|
||||||
fn tamper_signature(uri: &str) -> String {
|
fn tamper_signature(uri: &str) -> String {
|
||||||
let marker = "X-Amz-Signature=";
|
let marker = "X-Amz-Signature=";
|
||||||
let idx = uri.find(marker).expect("presigned uri must carry X-Amz-Signature") + marker.len();
|
let idx = uri.find(marker).expect("presigned uri must carry X-Amz-Signature") + marker.len();
|
||||||
@@ -96,10 +98,9 @@ fn tamper_signature(uri: &str) -> String {
|
|||||||
let (sig, tail) = rest.split_at(end);
|
let (sig, tail) = rest.split_at(end);
|
||||||
let tampered: String = sig
|
let tampered: String = sig
|
||||||
.chars()
|
.chars()
|
||||||
.map(|c| match c {
|
.map(|c| {
|
||||||
'0' => 'f',
|
let v = c.to_digit(16).expect("X-Amz-Signature value must be hex");
|
||||||
'a' => '0',
|
char::from_digit(15 - v, 16).expect("complement of a hex digit is a hex digit")
|
||||||
other => other,
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
assert_ne!(sig, tampered, "tamper must actually change the signature hex");
|
assert_ne!(sig, tampered, "tamper must actually change the signature hex");
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ use crate::common::local_http_client;
|
|||||||
use crate::common::rustfs_binary_path_with_features;
|
use crate::common::rustfs_binary_path_with_features;
|
||||||
use crate::protocols::test_env::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY, ProtocolTestEnvironment};
|
use crate::protocols::test_env::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY, ProtocolTestEnvironment};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use base64::Engine;
|
|
||||||
use http::header::{CONTENT_TYPE, HOST};
|
use http::header::{CONTENT_TYPE, HOST};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||||
@@ -64,7 +63,7 @@ fn basic_auth_header() -> String {
|
|||||||
|
|
||||||
fn basic_auth_header_for(access_key: &str, secret_key: &str) -> String {
|
fn basic_auth_header_for(access_key: &str, secret_key: &str) -> String {
|
||||||
let credentials = format!("{}:{}", access_key, secret_key);
|
let credentials = format!("{}:{}", access_key, secret_key);
|
||||||
let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
|
let encoded = base64_simd::STANDARD.encode_to_string(credentials);
|
||||||
format!("Basic {}", encoded)
|
format!("Basic {}", encoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, admin_request, awscurl_delete, awscurl_get, awscurl_post, awscurl_put, init_logging};
|
use crate::common::{RustFSTestEnvironment, admin_request, awscurl_delete, awscurl_get, awscurl_post, awscurl_put, init_logging};
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use http::{Method, StatusCode};
|
use http::{Method, StatusCode};
|
||||||
use tokio::time::{Duration, sleep, timeout};
|
use tokio::time::{Duration, sleep, timeout};
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
@@ -132,19 +133,13 @@ impl QuotaTestEnv {
|
|||||||
pub async fn object_exists(&self, key: &str) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
pub async fn object_exists(&self, key: &str) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
match self.client.head_object().bucket(&self.bucket_name).key(key).send().await {
|
match self.client.head_object().bucket(&self.bucket_name).key(key).send().await {
|
||||||
Ok(_) => Ok(true),
|
Ok(_) => Ok(true),
|
||||||
Err(e) => {
|
Err(error) => {
|
||||||
// Check for any 404-related errors and return false instead of propagating
|
let status = error.raw_response().map(|response| response.status().as_u16());
|
||||||
let error_str = e.to_string();
|
let code = error.as_service_error().and_then(ProvideErrorMetadata::code);
|
||||||
if error_str.contains("404") || error_str.contains("Not Found") || error_str.contains("NotFound") {
|
if status == Some(404) && matches!(code, Some("NotFound" | "NoSuchKey")) {
|
||||||
Ok(false)
|
Ok(false)
|
||||||
} else {
|
} else {
|
||||||
// Also check the error code directly
|
Err(error.into())
|
||||||
if let Some(service_err) = e.as_service_error()
|
|
||||||
&& service_err.is_not_found()
|
|
||||||
{
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
Err(e.into())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,6 +150,22 @@ impl QuotaTestEnv {
|
|||||||
Ok(stats.get("current_usage").and_then(|v| v.as_u64()).unwrap_or(0))
|
Ok(stats.get("current_usage").and_then(|v| v.as_u64()).unwrap_or(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn wait_for_bucket_usage(&self, expected: u64) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let convergence = async {
|
||||||
|
loop {
|
||||||
|
let usage = self.get_bucket_usage().await?;
|
||||||
|
if usage == expected {
|
||||||
|
return Ok::<u64, Box<dyn std::error::Error + Send + Sync>>(usage);
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match timeout(Duration::from_secs(30), convergence).await {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_) => Err(format!("bucket usage did not converge to {expected} bytes within 30 seconds").into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn set_bucket_quota_for(
|
pub async fn set_bucket_quota_for(
|
||||||
&self,
|
&self,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
@@ -262,7 +273,46 @@ impl QuotaTestEnv {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod integration_tests {
|
mod integration_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
|
||||||
|
fn assert_error_response(status: StatusCode, body: &str, expected_status: StatusCode, expected_code: &str) {
|
||||||
|
assert_eq!(status, expected_status, "unexpected error status: {status} {body}");
|
||||||
|
assert!(
|
||||||
|
body.contains(&format!("<Code>{expected_code}</Code>")),
|
||||||
|
"expected {expected_code}, got: {body}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_quota_rejection<E>(status: Option<u16>, service_error: Option<&E>, error: &impl std::fmt::Debug)
|
||||||
|
where
|
||||||
|
E: ProvideErrorMetadata + std::fmt::Debug,
|
||||||
|
{
|
||||||
|
assert_eq!(status, Some(400), "quota rejection must return HTTP 400: {error:?}");
|
||||||
|
let service_error = service_error.expect("quota rejection must be an S3 service error");
|
||||||
|
assert_eq!(service_error.code(), Some("InvalidRequest"), "unexpected quota error: {error:?}");
|
||||||
|
assert!(
|
||||||
|
service_error
|
||||||
|
.message()
|
||||||
|
.is_some_and(|message| message.starts_with("Bucket quota exceeded")),
|
||||||
|
"operation must fail specifically at quota admission: {error:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_put_rejected_by_quota(env: &QuotaTestEnv, key: &str, size_bytes: usize) {
|
||||||
|
let error = env
|
||||||
|
.client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&env.bucket_name)
|
||||||
|
.key(key)
|
||||||
|
.body(aws_sdk_s3::primitives::ByteStream::from(vec![0u8; size_bytes]))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("PUT above quota must be rejected");
|
||||||
|
assert_quota_rejection(
|
||||||
|
error.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
error.as_service_error(),
|
||||||
|
&error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_quota_basic_operations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
async fn test_quota_basic_operations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
@@ -288,8 +338,7 @@ mod integration_tests {
|
|||||||
assert!(env.object_exists("test2.txt").await?);
|
assert!(env.object_exists("test2.txt").await?);
|
||||||
|
|
||||||
// Try to upload 1KB more (should fail due to quota)
|
// Try to upload 1KB more (should fail due to quota)
|
||||||
let upload_result = env.upload_object("test3.txt", 1024).await;
|
assert_put_rejected_by_quota(&env, "test3.txt", 1024).await;
|
||||||
assert!(upload_result.is_err());
|
|
||||||
assert!(!env.object_exists("test3.txt").await?);
|
assert!(!env.object_exists("test3.txt").await?);
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
@@ -340,10 +389,10 @@ mod integration_tests {
|
|||||||
let err = put_aws_chunked("over-quota.bin", 16 * 1024)
|
let err = put_aws_chunked("over-quota.bin", 16 * 1024)
|
||||||
.await
|
.await
|
||||||
.expect_err("declared aws-chunked PUT over quota must be rejected");
|
.expect_err("declared aws-chunked PUT over quota must be rejected");
|
||||||
let err_debug = format!("{err:?}");
|
assert_quota_rejection(
|
||||||
assert!(
|
err.raw_response().map(|response| response.status().as_u16()),
|
||||||
!err_debug.contains("UnexpectedContent"),
|
err.as_service_error(),
|
||||||
"over-quota rejection must be the quota error, not UnexpectedContent: {err_debug}"
|
&err,
|
||||||
);
|
);
|
||||||
assert!(!env.object_exists("over-quota.bin").await?);
|
assert!(!env.object_exists("over-quota.bin").await?);
|
||||||
|
|
||||||
@@ -444,8 +493,8 @@ mod integration_tests {
|
|||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Check updated usage
|
// A completed scanner generation releases the conservative quota floor after a delete.
|
||||||
let updated_usage = env.get_bucket_usage().await?;
|
let updated_usage = env.wait_for_bucket_usage(256 * 1024).await?;
|
||||||
assert_eq!(updated_usage, 256 * 1024);
|
assert_eq!(updated_usage, 256 * 1024);
|
||||||
|
|
||||||
env.cleanup_bucket().await?;
|
env.cleanup_bucket().await?;
|
||||||
@@ -565,24 +614,35 @@ mod integration_tests {
|
|||||||
env.create_bucket().await?;
|
env.create_bucket().await?;
|
||||||
|
|
||||||
// Test invalid quota type
|
// Test invalid quota type
|
||||||
let url = format!("{}/rustfs/admin/v3/quota/{}", env.env.url, env.bucket_name);
|
let quota_path = format!("/rustfs/admin/v3/quota/{}", env.bucket_name);
|
||||||
|
|
||||||
let invalid_config = serde_json::json!({
|
let invalid_config = serde_json::json!({
|
||||||
"quota": 1024,
|
"quota": 1024,
|
||||||
"quota_type": "SOFT" // Invalid type
|
"quota_type": "SOFT" // Invalid type
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = awscurl_put(&url, &invalid_config.to_string(), &env.env.access_key, &env.env.secret_key).await;
|
let (status, body) = admin_request(
|
||||||
assert!(response.is_err());
|
&env.env.url,
|
||||||
let error_msg = response.unwrap_err().to_string();
|
Method::PUT,
|
||||||
assert!(error_msg.contains("InvalidArgument"));
|
"a_path,
|
||||||
|
Some(invalid_config.to_string()),
|
||||||
|
&env.env.access_key,
|
||||||
|
&env.env.secret_key,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_error_response(status, &body, StatusCode::BAD_REQUEST, "InvalidArgument");
|
||||||
|
|
||||||
// Test operations on non-existent bucket
|
// Test operations on non-existent bucket
|
||||||
let url = format!("{}/rustfs/admin/v3/quota/non-existent-bucket", env.env.url);
|
let (status, body) = admin_request(
|
||||||
let response = awscurl_get(&url, &env.env.access_key, &env.env.secret_key).await;
|
&env.env.url,
|
||||||
assert!(response.is_err());
|
Method::GET,
|
||||||
let error_msg = response.unwrap_err().to_string();
|
"/rustfs/admin/v3/quota/non-existent-bucket",
|
||||||
assert!(error_msg.contains("NoSuchBucket"));
|
None,
|
||||||
|
&env.env.access_key,
|
||||||
|
&env.env.secret_key,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_error_response(status, &body, StatusCode::NOT_FOUND, "NoSuchBucket");
|
||||||
|
|
||||||
env.cleanup_bucket().await?;
|
env.cleanup_bucket().await?;
|
||||||
|
|
||||||
@@ -636,10 +696,16 @@ mod integration_tests {
|
|||||||
"quota": 1024,
|
"quota": 1024,
|
||||||
"quota_type": "SOFT"
|
"quota_type": "SOFT"
|
||||||
});
|
});
|
||||||
let response = awscurl_put(&url, &invalid_config.to_string(), &env.env.access_key, &env.env.secret_key).await;
|
let (status, body) = admin_request(
|
||||||
assert!(response.is_err());
|
&env.env.url,
|
||||||
let error_msg = response.unwrap_err().to_string();
|
Method::PUT,
|
||||||
assert!(error_msg.contains("InvalidArgument"));
|
&format!("/rustfs/admin/v3/quota/{}", env.bucket_name),
|
||||||
|
Some(invalid_config.to_string()),
|
||||||
|
&env.env.access_key,
|
||||||
|
&env.env.secret_key,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_error_response(status, &body, StatusCode::BAD_REQUEST, "InvalidArgument");
|
||||||
|
|
||||||
env.cleanup_bucket().await?;
|
env.cleanup_bucket().await?;
|
||||||
|
|
||||||
@@ -682,26 +748,21 @@ mod integration_tests {
|
|||||||
assert!(resp.contains("quota_limit"));
|
assert!(resp.contains("quota_limit"));
|
||||||
|
|
||||||
// Normal user sets quota — should be denied
|
// Normal user sets quota — should be denied
|
||||||
let set_error = awscurl_put(
|
let quota_path = format!("/rustfs/admin/v3/quota/{}", env.bucket_name);
|
||||||
&get_url,
|
let (status, body) = admin_request(
|
||||||
&serde_json::json!({"quota": 2048, "quota_type": "HARD"}).to_string(),
|
&env.env.url,
|
||||||
|
Method::PUT,
|
||||||
|
"a_path,
|
||||||
|
Some(serde_json::json!({"quota": 2048, "quota_type": "HARD"}).to_string()),
|
||||||
normal_ak,
|
normal_ak,
|
||||||
normal_sk,
|
normal_sk,
|
||||||
)
|
)
|
||||||
.await
|
.await?;
|
||||||
.expect_err("normal user should not be able to set quota")
|
assert_error_response(status, &body, StatusCode::FORBIDDEN, "AccessDenied");
|
||||||
.to_string();
|
|
||||||
assert!(set_error.contains("AccessDenied"), "quota denial must return AccessDenied: {set_error}");
|
|
||||||
|
|
||||||
// Normal user clears quota — should be denied
|
// Normal user clears quota — should be denied
|
||||||
let delete_error = awscurl_delete(&get_url, normal_ak, normal_sk)
|
let (status, body) = admin_request(&env.env.url, Method::DELETE, "a_path, None, normal_ak, normal_sk).await?;
|
||||||
.await
|
assert_error_response(status, &body, StatusCode::FORBIDDEN, "AccessDenied");
|
||||||
.expect_err("normal user should not be able to clear quota")
|
|
||||||
.to_string();
|
|
||||||
assert!(
|
|
||||||
delete_error.contains("AccessDenied"),
|
|
||||||
"quota deletion denial must return AccessDenied: {delete_error}"
|
|
||||||
);
|
|
||||||
|
|
||||||
env.cleanup_bucket().await?;
|
env.cleanup_bucket().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -741,7 +802,12 @@ mod integration_tests {
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(copy_result.is_err());
|
let copy_error = copy_result.expect_err("copy above quota must be rejected");
|
||||||
|
assert_quota_rejection(
|
||||||
|
copy_error.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
copy_error.as_service_error(),
|
||||||
|
©_error,
|
||||||
|
);
|
||||||
assert!(!env.object_exists("copy2.txt").await?);
|
assert!(!env.object_exists("copy2.txt").await?);
|
||||||
|
|
||||||
env.cleanup_bucket().await?;
|
env.cleanup_bucket().await?;
|
||||||
@@ -764,8 +830,7 @@ mod integration_tests {
|
|||||||
env.upload_object("file2.txt", 1024 * 1024).await?;
|
env.upload_object("file2.txt", 1024 * 1024).await?;
|
||||||
|
|
||||||
// Verify quota is full
|
// Verify quota is full
|
||||||
let upload_result = env.upload_object("file3.txt", 1024).await;
|
assert_put_rejected_by_quota(&env, "file3.txt", 1024).await;
|
||||||
assert!(upload_result.is_err());
|
|
||||||
|
|
||||||
// Delete multiple objects using batch delete
|
// Delete multiple objects using batch delete
|
||||||
let objects = vec![
|
let objects = vec![
|
||||||
@@ -865,9 +930,7 @@ mod integration_tests {
|
|||||||
|
|
||||||
// Test 2: Multipart upload exceeds quota (should fail)
|
// Test 2: Multipart upload exceeds quota (should fail)
|
||||||
// Upload 6MB filler (total now: 5MB + 6MB = 11MB > 10MB quota)
|
// Upload 6MB filler (total now: 5MB + 6MB = 11MB > 10MB quota)
|
||||||
let upload_filler = env.upload_object("filler.txt", 6 * 1024 * 1024).await;
|
assert_put_rejected_by_quota(&env, "filler.txt", 6 * 1024 * 1024).await;
|
||||||
// This should fail due to quota
|
|
||||||
assert!(upload_filler.is_err());
|
|
||||||
|
|
||||||
// Verify filler doesn't exist
|
// Verify filler doesn't exist
|
||||||
assert!(!env.object_exists("filler.txt").await?);
|
assert!(!env.object_exists("filler.txt").await?);
|
||||||
@@ -923,7 +986,11 @@ mod integration_tests {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
let complete_error = complete_result.expect_err("multipart completion above quota must be rejected");
|
let complete_error = complete_result.expect_err("multipart completion above quota must be rejected");
|
||||||
assert_eq!(complete_error.as_service_error().and_then(|error| error.code()), Some("InvalidRequest"));
|
assert_quota_rejection(
|
||||||
|
complete_error.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
complete_error.as_service_error(),
|
||||||
|
&complete_error,
|
||||||
|
);
|
||||||
assert!(!env.object_exists("over_quota.txt").await?);
|
assert!(!env.object_exists("over_quota.txt").await?);
|
||||||
|
|
||||||
let staged_parts = env
|
let staged_parts = env
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ use rustfs_protos::{
|
|||||||
proto_gen::node_service::{
|
proto_gen::node_service::{
|
||||||
BatchGenerallyLockRequest, BatchGenerallyLockResponse, BatchReadVersionRequest, BatchReadVersionResponse,
|
BatchGenerallyLockRequest, BatchGenerallyLockResponse, BatchReadVersionRequest, BatchReadVersionResponse,
|
||||||
GenerallyLockRequest, GenerallyLockResponse, GenerallyLockResult, PingRequest, PingResponse,
|
GenerallyLockRequest, GenerallyLockResponse, GenerallyLockResult, PingRequest, PingResponse,
|
||||||
SnapshotLeaseMutationResponse, SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest, SnapshotLeaseRequest,
|
ScannerPublicationLeaseReleaseRequest, ScannerPublicationLeaseReleaseResponse, ScannerPublicationLeaseRequest,
|
||||||
SnapshotLeaseResponse, node_service_server::NodeService,
|
ScannerPublicationLeaseResponse, SnapshotLeaseMutationResponse, SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest,
|
||||||
|
SnapshotLeaseRequest, SnapshotLeaseResponse, node_service_server::NodeService,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
@@ -126,6 +127,20 @@ impl NodeService for MinimalLockNodeService {
|
|||||||
Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs"))
|
Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn acquire_scanner_publication_lease(
|
||||||
|
&self,
|
||||||
|
_request: Request<ScannerPublicationLeaseRequest>,
|
||||||
|
) -> Result<Response<ScannerPublicationLeaseResponse>, Status> {
|
||||||
|
Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn release_scanner_publication_lease(
|
||||||
|
&self,
|
||||||
|
_request: Request<ScannerPublicationLeaseReleaseRequest>,
|
||||||
|
) -> Result<Response<ScannerPublicationLeaseReleaseResponse>, Status> {
|
||||||
|
Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs"))
|
||||||
|
}
|
||||||
|
|
||||||
async fn lock(&self, request: Request<GenerallyLockRequest>) -> Result<Response<GenerallyLockResponse>, Status> {
|
async fn lock(&self, request: Request<GenerallyLockRequest>) -> Result<Response<GenerallyLockResponse>, Status> {
|
||||||
let request = request.into_inner();
|
let request = request.into_inner();
|
||||||
let args: LockRequest = match serde_json::from_str(&request.args) {
|
let args: LockRequest = match serde_json::from_str(&request.args) {
|
||||||
|
|||||||
@@ -27,8 +27,8 @@
|
|||||||
//! loopback/SSRF restriction (that guard is replication-only), so `hot` can tier
|
//! loopback/SSRF restriction (that guard is replication-only), so `hot` can tier
|
||||||
//! to `cold` over `http://127.0.0.1:<port>`.
|
//! to `cold` over `http://127.0.0.1:<port>`.
|
||||||
//!
|
//!
|
||||||
//! A single test drives the full transition main path and pins the chain
|
//! The hermetic tests drive the transition and restore paths and pin the
|
||||||
//! required by ilm-7:
|
//! chains required by ilm-7 and the restore follow-up:
|
||||||
//! 1. `AddTier(RustFS)` on `hot` targeting `cold` — the real connectivity /
|
//! 1. `AddTier(RustFS)` on `hot` targeting `cold` — the real connectivity /
|
||||||
//! in-use probe runs (no `force`), so this also proves the tier is reachable.
|
//! in-use probe runs (no `force`), so this also proves the tier is reachable.
|
||||||
//! 2. A `Transition Days=0` rule installed before a multipart PUT transitions
|
//! 2. A `Transition Days=0` rule installed before a multipart PUT transitions
|
||||||
@@ -42,6 +42,9 @@
|
|||||||
//! 6. The remote object is present in the cold-tier bucket after transition.
|
//! 6. The remote object is present in the cold-tier bucket after transition.
|
||||||
//! 7. `DeleteObject` on `hot` drives free-version cleanup: the cold-tier copy
|
//! 7. `DeleteObject` on `hot` drives free-version cleanup: the cold-tier copy
|
||||||
//! eventually disappears and the hot object is gone (no local residue).
|
//! eventually disappears and the hot object is gone (no local residue).
|
||||||
|
//! 8. `RestoreObject` copy-back failures clear the in-progress marker, a
|
||||||
|
//! retry serves the object locally until expiry, and expiry leaves the
|
||||||
|
//! remote object available for a second restore.
|
||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, local_http_client};
|
use crate::common::{RustFSTestEnvironment, local_http_client};
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
@@ -49,7 +52,8 @@ use aws_sdk_s3::error::ProvideErrorMetadata;
|
|||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::types::{
|
use aws_sdk_s3::types::{
|
||||||
BucketLifecycleConfiguration, BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, ExpirationStatus,
|
BucketLifecycleConfiguration, BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, ExpirationStatus,
|
||||||
LifecycleRule, LifecycleRuleFilter, NoncurrentVersionTransition, Transition, TransitionStorageClass, VersioningConfiguration,
|
LifecycleRule, LifecycleRuleFilter, NoncurrentVersionTransition, RestoreRequest, Transition, TransitionStorageClass,
|
||||||
|
VersioningConfiguration,
|
||||||
};
|
};
|
||||||
use http::Method;
|
use http::Method;
|
||||||
use http::header::HOST;
|
use http::header::HOST;
|
||||||
@@ -739,6 +743,77 @@ async fn wait_for_transition(client: &Client, bucket: &str, key: &str, deadline:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Poll `HEAD` until the asynchronous copy-back reports a completed restore.
|
||||||
|
async fn wait_for_restore_complete(client: &Client, bucket: &str, key: &str, deadline: StdDuration) -> TestResult {
|
||||||
|
let start = Instant::now();
|
||||||
|
loop {
|
||||||
|
let head = client.head_object().bucket(bucket).key(key).send().await?;
|
||||||
|
if head
|
||||||
|
.restore()
|
||||||
|
.is_some_and(|restore| restore.contains("ongoing-request=\"false\""))
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if start.elapsed() >= deadline {
|
||||||
|
return Err(format!(
|
||||||
|
"restore for {bucket}/{key} did not complete within {}s; restore={:?}",
|
||||||
|
deadline.as_secs(),
|
||||||
|
head.restore()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
tokio::time::sleep(StdDuration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll `HEAD` until the lifecycle restore-expiry action removes restore metadata.
|
||||||
|
async fn wait_for_restore_clear(client: &Client, bucket: &str, key: &str, deadline: StdDuration) -> TestResult {
|
||||||
|
let start = Instant::now();
|
||||||
|
loop {
|
||||||
|
let head = client.head_object().bucket(bucket).key(key).send().await?;
|
||||||
|
if head.restore().is_none() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if start.elapsed() >= deadline {
|
||||||
|
return Err(format!(
|
||||||
|
"restore metadata for {bucket}/{key} was not cleared within {}s; restore={:?}",
|
||||||
|
deadline.as_secs(),
|
||||||
|
head.restore()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
tokio::time::sleep(StdDuration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll `HEAD` through the failed-copy transition, proving that the request
|
||||||
|
/// first published an in-progress marker and that the failure then removed it.
|
||||||
|
async fn wait_for_restore_failure(client: &Client, bucket: &str, key: &str, deadline: StdDuration) -> TestResult {
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut saw_ongoing = false;
|
||||||
|
loop {
|
||||||
|
let head = client.head_object().bucket(bucket).key(key).send().await?;
|
||||||
|
if head
|
||||||
|
.restore()
|
||||||
|
.is_some_and(|restore| restore.contains("ongoing-request=\"true\""))
|
||||||
|
{
|
||||||
|
saw_ongoing = true;
|
||||||
|
} else if saw_ongoing && head.restore().is_none() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if start.elapsed() >= deadline {
|
||||||
|
return Err(format!(
|
||||||
|
"failed restore for {bucket}/{key} did not publish and clear its in-progress marker within {}s; \
|
||||||
|
saw_ongoing={saw_ongoing}, restore={:?}",
|
||||||
|
deadline.as_secs(),
|
||||||
|
head.restore()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
tokio::time::sleep(StdDuration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Poll until the cold-tier bucket is empty (remote free-version cleanup done),
|
/// Poll until the cold-tier bucket is empty (remote free-version cleanup done),
|
||||||
/// or fail after `deadline`.
|
/// or fail after `deadline`.
|
||||||
async fn wait_for_cold_tier_empty(cold_client: &Client, deadline: StdDuration) -> TestResult {
|
async fn wait_for_cold_tier_empty(cold_client: &Client, deadline: StdDuration) -> TestResult {
|
||||||
@@ -894,6 +969,143 @@ async fn test_hermetic_transition_main_path() -> TestResult {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restore a transitioned object through a real RustFS remote tier.
|
||||||
|
///
|
||||||
|
/// The test covers the externally visible copy-back contract that a mock tier
|
||||||
|
/// cannot prove: a failed remote read clears `x-amz-restore`, a retry creates a
|
||||||
|
/// local copy that remains readable while the cold tier is unavailable, expiry
|
||||||
|
/// removes only that local copy, and the same remote object can be restored a
|
||||||
|
/// second time. The accelerated lifecycle clock keeps the expiry assertion
|
||||||
|
/// bounded while the scanner remains enabled.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn test_hermetic_transition_restore_failure_expiry_and_retry() -> TestResult {
|
||||||
|
let mut cold = RustFSTestEnvironment::new().await?;
|
||||||
|
cold.access_key = "restorecoldtieradmin".to_string();
|
||||||
|
cold.secret_key = "restorecoldtiersecret".to_string();
|
||||||
|
cold.start_rustfs_server_without_cleanup(vec![]).await?;
|
||||||
|
let cold_client = cold.create_s3_client();
|
||||||
|
cold_client.create_bucket().bucket(TIER_BUCKET).send().await?;
|
||||||
|
|
||||||
|
let mut hot = RustFSTestEnvironment::new().await?;
|
||||||
|
hot.start_rustfs_server_with_env(vec![], &[("RUSTFS_SCANNER_CYCLE", "1"), ("RUSTFS_ILM_DEBUG_DAY_SECS", "5")])
|
||||||
|
.await?;
|
||||||
|
let hot_client = hot.create_s3_client();
|
||||||
|
add_rustfs_tier(&hot, &cold).await?;
|
||||||
|
|
||||||
|
hot_client.create_bucket().bucket(SOURCE_BUCKET).send().await?;
|
||||||
|
hot_client
|
||||||
|
.put_bucket_lifecycle_configuration()
|
||||||
|
.bucket(SOURCE_BUCKET)
|
||||||
|
.lifecycle_configuration(BucketLifecycleConfiguration::builder().rules(transition_rule()?).build()?)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let data = payload();
|
||||||
|
put_multipart_object(&hot_client, SOURCE_BUCKET, OBJECT_KEY, &data).await?;
|
||||||
|
wait_for_transition(&hot_client, SOURCE_BUCKET, OBJECT_KEY, StdDuration::from_secs(90)).await?;
|
||||||
|
let transitioned = hot_client.head_object().bucket(SOURCE_BUCKET).key(OBJECT_KEY).send().await?;
|
||||||
|
assert_eq!(
|
||||||
|
transitioned.storage_class().map(|storage_class| storage_class.as_str()),
|
||||||
|
Some(TIER_NAME),
|
||||||
|
"restore fixture must be transitioned before the copy-back request"
|
||||||
|
);
|
||||||
|
assert!(transitioned.restore().is_none(), "transitioned object must not already be restored");
|
||||||
|
assert_eq!(
|
||||||
|
cold_tier_object_count(&cold_client).await?,
|
||||||
|
1,
|
||||||
|
"cold tier should contain one restore candidate"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A failed remote read is accepted asynchronously, but it must not leave
|
||||||
|
// an object permanently advertising an in-progress restore.
|
||||||
|
cold.stop_server();
|
||||||
|
hot_client
|
||||||
|
.restore_object()
|
||||||
|
.bucket(SOURCE_BUCKET)
|
||||||
|
.key(OBJECT_KEY)
|
||||||
|
.restore_request(RestoreRequest::builder().days(1).build())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
wait_for_restore_failure(&hot_client, SOURCE_BUCKET, OBJECT_KEY, StdDuration::from_secs(30)).await?;
|
||||||
|
|
||||||
|
// Once the tier is available again, the same object can be restored.
|
||||||
|
cold.restart_server_preserving_data(vec![], &[]).await?;
|
||||||
|
hot_client
|
||||||
|
.restore_object()
|
||||||
|
.bucket(SOURCE_BUCKET)
|
||||||
|
.key(OBJECT_KEY)
|
||||||
|
.restore_request(RestoreRequest::builder().days(1).build())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
wait_for_restore_complete(&hot_client, SOURCE_BUCKET, OBJECT_KEY, StdDuration::from_secs(30)).await?;
|
||||||
|
|
||||||
|
let restored = hot_client.head_object().bucket(SOURCE_BUCKET).key(OBJECT_KEY).send().await?;
|
||||||
|
assert_eq!(
|
||||||
|
restored.storage_class().map(|storage_class| storage_class.as_str()),
|
||||||
|
Some(TIER_NAME),
|
||||||
|
"restore must retain the transitioned storage class"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
restored
|
||||||
|
.restore()
|
||||||
|
.is_some_and(|restore| restore.contains("ongoing-request=\"false\"")),
|
||||||
|
"completed restore must advertise a finished temporary copy"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A completed restore must be served locally even if the remote tier is
|
||||||
|
// temporarily unavailable.
|
||||||
|
cold.stop_server();
|
||||||
|
let local_get = hot_client.get_object().bucket(SOURCE_BUCKET).key(OBJECT_KEY).send().await?;
|
||||||
|
let local_body = local_get.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(local_body.as_ref(), data.as_slice(), "restored local copy must be byte-identical");
|
||||||
|
|
||||||
|
cold.restart_server_preserving_data(vec![], &[]).await?;
|
||||||
|
|
||||||
|
// The test clock makes the one-day restore expire in roughly five seconds.
|
||||||
|
wait_for_restore_clear(&hot_client, SOURCE_BUCKET, OBJECT_KEY, StdDuration::from_secs(30)).await?;
|
||||||
|
let expired = hot_client.head_object().bucket(SOURCE_BUCKET).key(OBJECT_KEY).send().await?;
|
||||||
|
assert_eq!(
|
||||||
|
expired.storage_class().map(|storage_class| storage_class.as_str()),
|
||||||
|
Some(TIER_NAME),
|
||||||
|
"restore expiry must not clear the transitioned storage class"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cold_tier_object_count(&cold_client).await?,
|
||||||
|
1,
|
||||||
|
"restore expiry must retain the remote object"
|
||||||
|
);
|
||||||
|
|
||||||
|
// After expiry the local copy is gone, so an unavailable tier must make the
|
||||||
|
// read fail; with the tier back, the original bytes remain readable.
|
||||||
|
cold.stop_server();
|
||||||
|
let expired_get = hot_client.get_object().bucket(SOURCE_BUCKET).key(OBJECT_KEY).send().await;
|
||||||
|
assert!(expired_get.is_err(), "expired restore must not leave a local copy behind");
|
||||||
|
cold.restart_server_preserving_data(vec![], &[]).await?;
|
||||||
|
let remote_get = hot_client.get_object().bucket(SOURCE_BUCKET).key(OBJECT_KEY).send().await?;
|
||||||
|
let remote_body = remote_get.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(remote_body.as_ref(), data.as_slice(), "post-expiry GET must read the remote copy");
|
||||||
|
|
||||||
|
// The remote candidate survives expiry and can be restored again.
|
||||||
|
hot_client
|
||||||
|
.restore_object()
|
||||||
|
.bucket(SOURCE_BUCKET)
|
||||||
|
.key(OBJECT_KEY)
|
||||||
|
.restore_request(RestoreRequest::builder().days(1).build())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
wait_for_restore_complete(&hot_client, SOURCE_BUCKET, OBJECT_KEY, StdDuration::from_secs(30)).await?;
|
||||||
|
|
||||||
|
hot_client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(SOURCE_BUCKET)
|
||||||
|
.key(OBJECT_KEY)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
wait_for_cold_tier_empty(&cold_client, StdDuration::from_secs(90)).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
async fn test_manual_transition_run_black_box_semantics() -> TestResult {
|
async fn test_manual_transition_run_black_box_semantics() -> TestResult {
|
||||||
let mut cold = RustFSTestEnvironment::new().await?;
|
let mut cold = RustFSTestEnvironment::new().await?;
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ use crate::fake_s3_target::{
|
|||||||
FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, FaultAction as FakeTargetFault, Operation as FakeTargetOperation,
|
FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, FaultAction as FakeTargetFault, Operation as FakeTargetOperation,
|
||||||
RequestRecord,
|
RequestRecord,
|
||||||
};
|
};
|
||||||
use crate::kms::common::{create_key_with_specific_id, sse_customer_key_md5_base64};
|
use crate::kms::common::{
|
||||||
|
SSE_C_KEY_MISMATCH_MESSAGE, SSE_C_MISSING_PARAMETERS_MESSAGE, assert_s3_error, create_key_with_specific_id,
|
||||||
|
sse_customer_key_md5_base64,
|
||||||
|
};
|
||||||
use crate::storage_api::replication_extension::BucketTargetSys;
|
use crate::storage_api::replication_extension::BucketTargetSys;
|
||||||
use aws_sdk_s3::config::{Credentials, Region};
|
use aws_sdk_s3::config::{Credentials, Region};
|
||||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
@@ -31,7 +34,7 @@ use aws_sdk_s3::types::{
|
|||||||
VersioningConfiguration,
|
VersioningConfiguration,
|
||||||
};
|
};
|
||||||
use aws_sdk_s3::{Client, Config};
|
use aws_sdk_s3::{Client, Config};
|
||||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
use base64_simd::STANDARD as BASE64_STANDARD;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use flate2::read::GzDecoder;
|
use flate2::read::GzDecoder;
|
||||||
use futures::{Stream, StreamExt};
|
use futures::{Stream, StreamExt};
|
||||||
@@ -1241,7 +1244,7 @@ async fn wait_for_source_replication_pending_or_failed(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn wait_for_source_replication_status(client: &Client, bucket: &str, key: &str, expected: &str, ssec: bool) -> TestResult {
|
async fn wait_for_source_replication_status(client: &Client, bucket: &str, key: &str, expected: &str, ssec: bool) -> TestResult {
|
||||||
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
|
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
|
||||||
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
||||||
let wait = async {
|
let wait = async {
|
||||||
loop {
|
loop {
|
||||||
@@ -1336,7 +1339,7 @@ async fn assert_failed_replication_stays_absent_for(
|
|||||||
ssec: bool,
|
ssec: bool,
|
||||||
duration: Duration,
|
duration: Duration,
|
||||||
) -> TestResult {
|
) -> TestResult {
|
||||||
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
|
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
|
||||||
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
||||||
let wait = async {
|
let wait = async {
|
||||||
let deadline = tokio::time::Instant::now() + duration;
|
let deadline = tokio::time::Instant::now() + duration;
|
||||||
@@ -4329,7 +4332,7 @@ async fn test_bucket_replication_sse_c_contract() -> TestResult {
|
|||||||
let target_client = target_env.create_s3_client();
|
let target_client = target_env.create_s3_client();
|
||||||
let key = "ssec-contract.txt";
|
let key = "ssec-contract.txt";
|
||||||
let body = b"repl-17 SSE-C payload";
|
let body = b"repl-17 SSE-C payload";
|
||||||
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
|
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
|
||||||
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
||||||
|
|
||||||
source_client
|
source_client
|
||||||
@@ -4375,10 +4378,16 @@ async fn test_bucket_replication_sse_c_contract() -> TestResult {
|
|||||||
// Without the customer key the replica must not be readable — the direct
|
// Without the customer key the replica must not be readable — the direct
|
||||||
// detection point for a silent-plaintext replica (backlog#1291).
|
// detection point for a silent-plaintext replica (backlog#1291).
|
||||||
let plain_read = target_client.get_object().bucket(&target_bucket).key(key).send().await;
|
let plain_read = target_client.get_object().bucket(&target_bucket).key(key).send().await;
|
||||||
assert!(plain_read.is_err(), "SSE-C replica must not be readable without the customer key");
|
assert_s3_error(
|
||||||
|
plain_read,
|
||||||
|
400,
|
||||||
|
"InvalidRequest",
|
||||||
|
SSE_C_MISSING_PARAMETERS_MESSAGE,
|
||||||
|
"SSE-C replica must not be readable without the customer key",
|
||||||
|
);
|
||||||
|
|
||||||
// A wrong customer key must fail too.
|
// A wrong customer key must fail too.
|
||||||
let wrong_key = BASE64_STANDARD.encode("99999999999999999999999999999999");
|
let wrong_key = BASE64_STANDARD.encode_to_string("99999999999999999999999999999999");
|
||||||
let wrong_key_md5 = sse_customer_key_md5_base64("99999999999999999999999999999999");
|
let wrong_key_md5 = sse_customer_key_md5_base64("99999999999999999999999999999999");
|
||||||
let wrong_read = target_client
|
let wrong_read = target_client
|
||||||
.get_object()
|
.get_object()
|
||||||
@@ -4389,7 +4398,13 @@ async fn test_bucket_replication_sse_c_contract() -> TestResult {
|
|||||||
.sse_customer_key_md5(&wrong_key_md5)
|
.sse_customer_key_md5(&wrong_key_md5)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
assert!(wrong_read.is_err(), "SSE-C replica must reject a wrong customer key");
|
assert_s3_error(
|
||||||
|
wrong_read,
|
||||||
|
400,
|
||||||
|
"InvalidRequest",
|
||||||
|
SSE_C_KEY_MISMATCH_MESSAGE,
|
||||||
|
"SSE-C replica must reject a wrong customer key",
|
||||||
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -4408,7 +4423,7 @@ async fn test_bucket_replication_sse_c_multipart_passthrough() -> TestResult {
|
|||||||
let source_client = source_env.create_s3_client();
|
let source_client = source_env.create_s3_client();
|
||||||
let target_client = target_env.create_s3_client();
|
let target_client = target_env.create_s3_client();
|
||||||
let key = "ssec-mp-contract.bin";
|
let key = "ssec-mp-contract.bin";
|
||||||
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
|
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
|
||||||
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
||||||
|
|
||||||
let created = source_client
|
let created = source_client
|
||||||
@@ -4486,9 +4501,12 @@ async fn test_bucket_replication_sse_c_multipart_passthrough() -> TestResult {
|
|||||||
assert_eq!(replica.body.collect().await?.into_bytes().as_ref(), payload.as_slice());
|
assert_eq!(replica.body.collect().await?.into_bytes().as_ref(), payload.as_slice());
|
||||||
|
|
||||||
let plain_read = target_client.get_object().bucket(&target_bucket).key(key).send().await;
|
let plain_read = target_client.get_object().bucket(&target_bucket).key(key).send().await;
|
||||||
assert!(
|
assert_s3_error(
|
||||||
plain_read.is_err(),
|
plain_read,
|
||||||
"SSE-C multipart replica must not be readable without the customer key"
|
400,
|
||||||
|
"InvalidRequest",
|
||||||
|
SSE_C_MISSING_PARAMETERS_MESSAGE,
|
||||||
|
"SSE-C multipart replica must not be readable without the customer key",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stability across scanner cycles: convergence must hold for passthrough.
|
// Stability across scanner cycles: convergence must hold for passthrough.
|
||||||
@@ -4550,7 +4568,7 @@ async fn test_ssec_replication_fails_closed_when_target_drops_passthrough_header
|
|||||||
.await?;
|
.await?;
|
||||||
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
|
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
|
||||||
|
|
||||||
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
|
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
|
||||||
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
||||||
let put_ssec = |key: &'static str| {
|
let put_ssec = |key: &'static str| {
|
||||||
source_client
|
source_client
|
||||||
@@ -4723,7 +4741,7 @@ async fn test_bucket_replication_sse_c_heals_after_target_outage() -> TestResult
|
|||||||
let source_client = source_env.create_s3_client();
|
let source_client = source_env.create_s3_client();
|
||||||
let key = "ssec-heal-contract.txt";
|
let key = "ssec-heal-contract.txt";
|
||||||
let body = b"repl-22 ssec heal payload".to_vec();
|
let body = b"repl-22 ssec heal payload".to_vec();
|
||||||
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
|
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
|
||||||
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
||||||
|
|
||||||
// Target outage: the SSE-C write cannot replicate.
|
// Target outage: the SSE-C write cannot replicate.
|
||||||
@@ -4838,7 +4856,7 @@ async fn test_bucket_replication_sse_c_existing_object_resync() -> TestResult {
|
|||||||
// The SSE-C object exists before any replication wiring.
|
// The SSE-C object exists before any replication wiring.
|
||||||
let key = "ssec-existing-contract.txt";
|
let key = "ssec-existing-contract.txt";
|
||||||
let body = b"repl-22 ssec existing-object payload".to_vec();
|
let body = b"repl-22 ssec existing-object payload".to_vec();
|
||||||
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
|
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
|
||||||
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
||||||
source_client
|
source_client
|
||||||
.put_object()
|
.put_object()
|
||||||
@@ -4892,15 +4910,12 @@ async fn test_bucket_replication_sse_c_existing_object_resync() -> TestResult {
|
|||||||
assert_eq!(replica.body.collect().await?.into_bytes().as_ref(), body.as_slice());
|
assert_eq!(replica.body.collect().await?.into_bytes().as_ref(), body.as_slice());
|
||||||
|
|
||||||
// No plaintext leak: the replica stays unreadable without the key.
|
// No plaintext leak: the replica stays unreadable without the key.
|
||||||
assert!(
|
assert_s3_error(
|
||||||
target_client
|
target_client.get_object().bucket(target_bucket).key(key).send().await,
|
||||||
.get_object()
|
400,
|
||||||
.bucket(target_bucket)
|
"InvalidRequest",
|
||||||
.key(key)
|
SSE_C_MISSING_PARAMETERS_MESSAGE,
|
||||||
.send()
|
"SSE-C resynced replica must not be readable without the customer key",
|
||||||
.await
|
|
||||||
.is_err(),
|
|
||||||
"SSE-C replica must not be readable without the customer key"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -9137,7 +9152,7 @@ async fn test_get_and_head_proxy_unreplicated_object_to_replication_target() ->
|
|||||||
// the real SSE-C decryption; the plaintext fake simply ignores them).
|
// the real SSE-C decryption; the plaintext fake simply ignores them).
|
||||||
target.take_requests();
|
target.take_requests();
|
||||||
let ssec_key = "01234567890123456789012345678901";
|
let ssec_key = "01234567890123456789012345678901";
|
||||||
let ssec_key_b64 = BASE64_STANDARD.encode(ssec_key);
|
let ssec_key_b64 = BASE64_STANDARD.encode_to_string(ssec_key);
|
||||||
let ssec_key_md5 = sse_customer_key_md5_base64(ssec_key);
|
let ssec_key_md5 = sse_customer_key_md5_base64(ssec_key);
|
||||||
let _ = source_client
|
let _ = source_client
|
||||||
.get_object()
|
.get_object()
|
||||||
|
|||||||
@@ -21,11 +21,13 @@
|
|||||||
//! - SSRF prevention (internal/private endpoints rejected for tiering)
|
//! - SSRF prevention (internal/private endpoints rejected for tiering)
|
||||||
//! - Race condition handling (concurrent writes converge without corruption)
|
//! - Race condition handling (concurrent writes converge without corruption)
|
||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, awscurl_put, init_logging, require_awscurl};
|
use crate::common::{RustFSTestEnvironment, init_logging, signed_s3_request};
|
||||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart, Tag, Tagging};
|
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart, Tag, Tagging};
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
/// Oversized tagging payloads must be rejected by the per-object tag limit.
|
/// Oversized tagging payloads must be rejected by the per-object tag limit.
|
||||||
///
|
///
|
||||||
@@ -74,8 +76,12 @@ async fn test_large_xml_body_rejection() -> Result<(), Box<dyn Error + Send + Sy
|
|||||||
let _ = client.delete_object().bucket(&bucket_name).key(object_key).send().await;
|
let _ = client.delete_object().bucket(&bucket_name).key(object_key).send().await;
|
||||||
let _ = client.delete_bucket().bucket(&bucket_name).send().await;
|
let _ = client.delete_bucket().bucket(&bucket_name).send().await;
|
||||||
|
|
||||||
assert!(result.is_err(), "Server must reject an oversized tagging payload, but it was accepted");
|
let err = result.expect_err("Server must reject an oversized tagging payload");
|
||||||
let err = result.expect_err("checked is_err above");
|
assert_eq!(
|
||||||
|
err.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(400),
|
||||||
|
"Oversized tagging should return HTTP 400, got: {err:?}"
|
||||||
|
);
|
||||||
let code = err.as_service_error().and_then(|e| e.code());
|
let code = err.as_service_error().and_then(|e| e.code());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
code,
|
code,
|
||||||
@@ -87,7 +93,7 @@ async fn test_large_xml_body_rejection() -> Result<(), Box<dyn Error + Send + Sy
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Excessive multipart parts must be rejected.
|
/// Multipart completion must reject part numbers above the 10,000-part limit.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_excessive_multipart_parts() -> Result<(), Box<dyn Error + Send + Sync>> {
|
async fn test_excessive_multipart_parts() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
@@ -107,11 +113,24 @@ async fn test_excessive_multipart_parts() -> Result<(), Box<dyn Error + Send + S
|
|||||||
|
|
||||||
let upload_id = create_result.upload_id().expect("upload_id should be present").to_string();
|
let upload_id = create_result.upload_id().expect("upload_id should be present").to_string();
|
||||||
|
|
||||||
// Try to complete with too many parts (should be rejected).
|
// Upload one real control part so a generic missing-part rejection cannot
|
||||||
let mut parts = Vec::new();
|
// masquerade as enforcement of the 10,000-part boundary.
|
||||||
for i in 1..=10001 {
|
let uploaded = client
|
||||||
parts.push(CompletedPart::builder().part_number(i).e_tag(format!("etag-{i}")).build());
|
.upload_part()
|
||||||
}
|
.bucket(&bucket_name)
|
||||||
|
.key("test-large")
|
||||||
|
.upload_id(&upload_id)
|
||||||
|
.part_number(1)
|
||||||
|
.body(ByteStream::from_static(b"valid-control-part"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let parts = vec![
|
||||||
|
CompletedPart::builder()
|
||||||
|
.part_number(1)
|
||||||
|
.e_tag(uploaded.e_tag().expect("uploaded part should return an ETag"))
|
||||||
|
.build(),
|
||||||
|
CompletedPart::builder().part_number(10001).e_tag("out-of-range").build(),
|
||||||
|
];
|
||||||
|
|
||||||
let result = client
|
let result = client
|
||||||
.complete_multipart_upload()
|
.complete_multipart_upload()
|
||||||
@@ -132,7 +151,21 @@ async fn test_excessive_multipart_parts() -> Result<(), Box<dyn Error + Send + S
|
|||||||
.await;
|
.await;
|
||||||
let _ = client.delete_bucket().bucket(&bucket_name).send().await;
|
let _ = client.delete_bucket().bucket(&bucket_name).send().await;
|
||||||
|
|
||||||
assert!(result.is_err(), "Server should reject excessive multipart parts");
|
let error = result.expect_err("server should reject a completion part number above 10000");
|
||||||
|
assert_eq!(
|
||||||
|
error.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(400),
|
||||||
|
"part-limit rejection must return HTTP 400, got: {error:?}"
|
||||||
|
);
|
||||||
|
let service_error = error
|
||||||
|
.as_service_error()
|
||||||
|
.expect("part-limit rejection must be an S3 service error");
|
||||||
|
assert_eq!(service_error.code(), Some("InvalidPart"), "unexpected part-limit error: {error:?}");
|
||||||
|
assert_eq!(
|
||||||
|
service_error.message(),
|
||||||
|
Some("Part number 10001 must be between 1 and 10000"),
|
||||||
|
"completion must fail at the part-number boundary, not a later missing-part check"
|
||||||
|
);
|
||||||
|
|
||||||
env.stop_server();
|
env.stop_server();
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -197,15 +230,14 @@ async fn test_concurrent_object_operations() -> Result<(), Box<dyn Error + Send
|
|||||||
// After deletion, the object must be absent.
|
// After deletion, the object must be absent.
|
||||||
client.delete_object().bucket(&bucket_name).key(key).send().await?;
|
client.delete_object().bucket(&bucket_name).key(key).send().await?;
|
||||||
let get_after_delete = client.get_object().bucket(&bucket_name).key(key).send().await;
|
let get_after_delete = client.get_object().bucket(&bucket_name).key(key).send().await;
|
||||||
assert!(get_after_delete.is_err(), "Object must be absent after delete");
|
let error = get_after_delete.expect_err("Object must be absent after delete");
|
||||||
let code = get_after_delete
|
|
||||||
.err()
|
|
||||||
.and_then(|e| e.as_service_error().and_then(|se| se.code()).map(str::to_string));
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
code.as_deref(),
|
error.raw_response().map(|response| response.status().as_u16()),
|
||||||
Some("NoSuchKey"),
|
Some(404),
|
||||||
"GET after delete should return NoSuchKey, got {code:?}"
|
"GET after delete should return HTTP 404, got: {error:?}"
|
||||||
);
|
);
|
||||||
|
let code = error.as_service_error().and_then(|service_error| service_error.code());
|
||||||
|
assert_eq!(code, Some("NoSuchKey"), "GET after delete should return NoSuchKey, got: {error:?}");
|
||||||
|
|
||||||
let _ = client.delete_bucket().bucket(&bucket_name).send().await;
|
let _ = client.delete_bucket().bucket(&bucket_name).send().await;
|
||||||
|
|
||||||
@@ -213,35 +245,31 @@ async fn test_concurrent_object_operations() -> Result<(), Box<dyn Error + Send
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Internal/private endpoints must be rejected as remote tier backends (SSRF).
|
/// Internal/private endpoints must be rejected as remote tier backends (SSRF)
|
||||||
|
/// before RustFS opens a connection to them.
|
||||||
///
|
///
|
||||||
/// This issues a real admin AddTier call (`PUT /rustfs/admin/v3/tier`) for each
|
/// This issues a real admin AddTier call (`PUT /rustfs/admin/v3/tier`) for each
|
||||||
/// internal/private endpoint and asserts the server rejects it (non-2xx, so the
|
/// internal/private endpoint and requires the exact validation response. A
|
||||||
/// signed request helper returns an error). An internal endpoint must never be
|
/// loopback listener additionally proves the literal loopback case is rejected
|
||||||
/// accepted as a tier backend. The rejection may originate from explicit
|
/// without an outbound connection; a generic connectivity failure is not
|
||||||
/// SSRF/internal-address filtering or from the backend connectivity/credential
|
/// sufficient evidence of SSRF protection.
|
||||||
/// validation performed during AddTier; either way the security-relevant
|
|
||||||
/// outcome — the internal endpoint is not accepted — is asserted here.
|
|
||||||
///
|
|
||||||
/// The admin API is exercised via signed `awscurl` requests, matching the
|
|
||||||
/// pattern used by the other admin-API E2E tests in this crate. The full E2E
|
|
||||||
/// lane installs and verifies the pinned `awscurl` prerequisite.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_tiering_url_validation() -> Result<(), Box<dyn Error + Send + Sync>> {
|
async fn test_tiering_url_validation() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
require_awscurl()?;
|
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
env.start_rustfs_server(vec![]).await?;
|
env.start_rustfs_server(vec![]).await?;
|
||||||
|
|
||||||
let tier_url = format!("{}/rustfs/admin/v3/tier", env.url);
|
let tier_url = format!("{}/rustfs/admin/v3/tier", env.url);
|
||||||
|
let sentinel = TcpListener::bind("127.0.0.1:0").await?;
|
||||||
|
let sentinel_endpoint = format!("http://{}", sentinel.local_addr()?);
|
||||||
let internal_endpoints = [
|
let internal_endpoints = [
|
||||||
"http://127.0.0.1:8080",
|
(sentinel_endpoint.as_str(), true),
|
||||||
"http://localhost:8080",
|
("http://localhost:8080", false),
|
||||||
"http://169.254.169.254", // cloud instance metadata endpoint
|
("http://169.254.169.254", false), // cloud instance metadata endpoint
|
||||||
"http://[::1]:8080",
|
("http://[::1]:8080", false),
|
||||||
];
|
];
|
||||||
|
|
||||||
for endpoint in internal_endpoints {
|
for (endpoint, checks_connection_attempt) in internal_endpoints {
|
||||||
// AddTier expects an uppercase tier name and a backend configuration.
|
// AddTier expects an uppercase tier name and a backend configuration.
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"type": "s3",
|
"type": "s3",
|
||||||
@@ -258,11 +286,30 @@ async fn test_tiering_url_validation() -> Result<(), Box<dyn Error + Send + Sync
|
|||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let result = awscurl_put(&tier_url, &body, &env.access_key, &env.secret_key).await;
|
let response = signed_s3_request(
|
||||||
|
http::Method::PUT,
|
||||||
|
&tier_url,
|
||||||
|
Some(body),
|
||||||
|
Some("application/json"),
|
||||||
|
&env.access_key,
|
||||||
|
&env.secret_key,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let status = response.status();
|
||||||
|
let response_body = response.text().await?;
|
||||||
|
assert_eq!(status, 400, "AddTier must reject internal endpoint {endpoint} with HTTP 400");
|
||||||
assert!(
|
assert!(
|
||||||
result.is_err(),
|
response_body.contains("<Code>InvalidArgument</Code>") && response_body.contains("tier endpoint is not allowed"),
|
||||||
"AddTier must reject internal endpoint {endpoint}, but it was accepted: {result:?}"
|
"AddTier must reject internal endpoint {endpoint} during URL validation, got: {response_body}"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if checks_connection_attempt {
|
||||||
|
match tokio::time::timeout(Duration::from_secs(1), sentinel.accept()).await {
|
||||||
|
Err(_) => {}
|
||||||
|
Ok(Ok((_, peer))) => panic!("SSRF guard connected to loopback endpoint {endpoint} from {peer}"),
|
||||||
|
Ok(Err(error)) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
env.stop_server();
|
env.stop_server();
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
mod tests {
|
mod tests {
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use http::StatusCode;
|
use http::StatusCode;
|
||||||
use http::header::HOST;
|
use http::header::HOST;
|
||||||
@@ -731,12 +732,7 @@ mod tests {
|
|||||||
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
||||||
|
|
||||||
// Test that control characters are rejected
|
// Test that control characters are rejected
|
||||||
let invalid_keys = vec![
|
let invalid_keys = ["file\0with\0null.txt", "file\nwith\nnewline.txt", "file\rwith\rcarriage.txt"];
|
||||||
"file\0with\0null.txt",
|
|
||||||
"file\nwith\nnewline.txt",
|
|
||||||
"file\rwith\rcarriage.txt",
|
|
||||||
"file\twith\ttab.txt", // Tab might be allowed, but let's test
|
|
||||||
];
|
|
||||||
|
|
||||||
for key in invalid_keys {
|
for key in invalid_keys {
|
||||||
info!("Testing rejection of control character in key: {:?}", key);
|
info!("Testing rejection of control character in key: {:?}", key);
|
||||||
@@ -747,18 +743,28 @@ mod tests {
|
|||||||
.key(key)
|
.key(key)
|
||||||
.body(ByteStream::from_static(b"test"))
|
.body(ByteStream::from_static(b"test"))
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await
|
||||||
|
.expect_err("invalid control characters must be rejected by the server");
|
||||||
// Note: The validation happens on the server side, so we expect an error
|
assert_eq!(
|
||||||
// For null byte, newline, and carriage return
|
result.raw_response().map(|response| response.status().as_u16()),
|
||||||
if key.contains('\0') || key.contains('\n') || key.contains('\r') {
|
Some(400),
|
||||||
assert!(result.is_err(), "Control character should be rejected for key: {key:?}");
|
"control character must return HTTP 400 for key {key:?}: {result:?}"
|
||||||
if let Err(e) = result {
|
);
|
||||||
info!("✅ Control character correctly rejected: {:?}", e);
|
assert_eq!(
|
||||||
}
|
result.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
}
|
Some("InvalidArgument"),
|
||||||
|
"control character must return InvalidArgument for key {key:?}: {result:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let listed = client
|
||||||
|
.list_objects_v2()
|
||||||
|
.bucket(bucket)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("server must remain healthy after rejected requests");
|
||||||
|
assert!(listed.contents().is_empty(), "rejected requests must not create objects");
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
env.stop_server();
|
env.stop_server();
|
||||||
info!("Test completed successfully");
|
info!("Test completed successfully");
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ use aws_sdk_s3::error::BoxError;
|
|||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::types::{BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, VersioningConfiguration};
|
use aws_sdk_s3::types::{BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, VersioningConfiguration};
|
||||||
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||||
use base64::Engine;
|
|
||||||
use md5::{Digest as Md5Digest, Md5};
|
use md5::{Digest as Md5Digest, Md5};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
@@ -107,8 +106,8 @@ fn customer_key(byte: u8) -> CustomerKey {
|
|||||||
hasher.update(raw);
|
hasher.update(raw);
|
||||||
CustomerKey {
|
CustomerKey {
|
||||||
raw: String::from_utf8_lossy(&raw).into_owned(),
|
raw: String::from_utf8_lossy(&raw).into_owned(),
|
||||||
encoded: base64::engine::general_purpose::STANDARD.encode(raw),
|
encoded: base64_simd::STANDARD.encode_to_string(raw),
|
||||||
md5: base64::engine::general_purpose::STANDARD.encode(hasher.finalize()),
|
md5: base64_simd::STANDARD.encode_to_string(hasher.finalize()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,9 +275,17 @@ async fn copy_object_rotates_ssec_key_and_drops_source_encryption_metadata() ->
|
|||||||
.await
|
.await
|
||||||
.expect_err("invalid source SSE-C parameters must reject CopyObject");
|
.expect_err("invalid source SSE-C parameters must reject CopyObject");
|
||||||
assert_secret_absent(&format!("{error:?}"), &[&source_key, &wrong_key]);
|
assert_secret_absent(&format!("{error:?}"), &[&source_key, &wrong_key]);
|
||||||
assert!(
|
let absence = client
|
||||||
client.head_object().bucket(bucket).key(&failed_target).send().await.is_err(),
|
.head_object()
|
||||||
"a rejected CopyObject must not create its target"
|
.bucket(bucket)
|
||||||
|
.key(&failed_target)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("a rejected CopyObject must not create its target");
|
||||||
|
assert_eq!(
|
||||||
|
absence.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(404),
|
||||||
|
"rejected CopyObject absence probe must return HTTP 404, got {absence:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
env.stop_server();
|
env.stop_server();
|
||||||
@@ -464,9 +471,17 @@ async fn multipart_copy_requires_keys_on_every_stage_and_abort_leaves_no_object(
|
|||||||
abort_attempts_before + 1,
|
abort_attempts_before + 1,
|
||||||
"each failed multipart copy must issue exactly one wire-level abort attempt"
|
"each failed multipart copy must issue exactly one wire-level abort attempt"
|
||||||
);
|
);
|
||||||
assert!(
|
let absence = client
|
||||||
client.head_object().bucket(bucket).key(&failed_target).send().await.is_err(),
|
.head_object()
|
||||||
"an aborted failed multipart copy must leave no completed object"
|
.bucket(bucket)
|
||||||
|
.key(&failed_target)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("an aborted failed multipart copy must leave no completed object");
|
||||||
|
assert_eq!(
|
||||||
|
absence.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(404),
|
||||||
|
"aborted multipart copy absence probe must return HTTP 404, got {absence:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
env.stop_server();
|
env.stop_server();
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
// 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};
|
||||||
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use aws_sdk_s3::types::{
|
||||||
|
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, ServerSideEncryption, VersioningConfiguration,
|
||||||
|
};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||||
|
|
||||||
|
const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY";
|
||||||
|
const SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
|
||||||
|
const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
|
||||||
|
const PLAIN_BUCKET: &str = "upgrade-plain-data";
|
||||||
|
const VERSIONED_BUCKET: &str = "upgrade-versioned-data";
|
||||||
|
|
||||||
|
fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let path = std::env::var_os(SOURCE_BINARY_ENV)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.ok_or("RUSTFS_UPGRADE_SOURCE_BINARY must point to the pinned previous release binary")?;
|
||||||
|
if !path.is_file() {
|
||||||
|
return Err(format!("upgrade source binary does not exist: {}", path.display()).into());
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn enable_versioning(client: &Client, bucket: &str) -> TestResult {
|
||||||
|
let configuration = VersioningConfiguration::builder()
|
||||||
|
.status(BucketVersioningStatus::Enabled)
|
||||||
|
.build();
|
||||||
|
client
|
||||||
|
.put_bucket_versioning()
|
||||||
|
.bucket(bucket)
|
||||||
|
.versioning_configuration(configuration)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_object(
|
||||||
|
client: &Client,
|
||||||
|
bucket: &str,
|
||||||
|
key: &str,
|
||||||
|
version_id: Option<&str>,
|
||||||
|
) -> Result<(Option<ServerSideEncryption>, Vec<u8>), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let mut request = client.get_object().bucket(bucket).key(key);
|
||||||
|
if let Some(version_id) = version_id {
|
||||||
|
request = request.version_id(version_id);
|
||||||
|
}
|
||||||
|
let response = request.send().await?;
|
||||||
|
let encryption = response.server_side_encryption().cloned();
|
||||||
|
let body = response.body.collect().await?.into_bytes().to_vec();
|
||||||
|
Ok((encryption, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<u8>]) -> TestResult {
|
||||||
|
let created = client.create_multipart_upload().bucket(bucket).key(key).send().await?;
|
||||||
|
let upload_id = created.upload_id().ok_or("CreateMultipartUpload omitted upload ID")?;
|
||||||
|
let mut completed_parts = Vec::with_capacity(parts.len());
|
||||||
|
|
||||||
|
for (index, part) in parts.iter().enumerate() {
|
||||||
|
let part_number = i32::try_from(index + 1)?;
|
||||||
|
let uploaded = client
|
||||||
|
.upload_part()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.upload_id(upload_id)
|
||||||
|
.part_number(part_number)
|
||||||
|
.body(ByteStream::from(part.clone()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
completed_parts.push(
|
||||||
|
CompletedPart::builder()
|
||||||
|
.part_number(part_number)
|
||||||
|
.e_tag(uploaded.e_tag().ok_or("UploadPart omitted ETag")?)
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
client
|
||||||
|
.complete_multipart_upload()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.upload_id(upload_id)
|
||||||
|
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires a pinned previous RustFS release binary"]
|
||||||
|
async fn direct_upgrade_from_rc2_preserves_object_contracts() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let previous_binary = source_binary()?;
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
let server_env = [(SSE_MASTER_KEY_ENV, SSE_MASTER_KEY)];
|
||||||
|
env.start_rustfs_server_from_binary(&previous_binary, vec![], &server_env)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let old_client = env.create_s3_client();
|
||||||
|
env.create_test_bucket(PLAIN_BUCKET).await?;
|
||||||
|
env.create_test_bucket(VERSIONED_BUCKET).await?;
|
||||||
|
enable_versioning(&old_client, VERSIONED_BUCKET).await?;
|
||||||
|
|
||||||
|
let plain_key = "plain-object";
|
||||||
|
let plain_bytes = b"written by the previous RustFS release";
|
||||||
|
old_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(PLAIN_BUCKET)
|
||||||
|
.key(plain_key)
|
||||||
|
.body(ByteStream::from_static(plain_bytes))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let encrypted_key = "sse-s3-object";
|
||||||
|
let encrypted_bytes = b"encrypted by the previous RustFS release";
|
||||||
|
old_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(PLAIN_BUCKET)
|
||||||
|
.key(encrypted_key)
|
||||||
|
.server_side_encryption(ServerSideEncryption::Aes256)
|
||||||
|
.body(ByteStream::from_static(encrypted_bytes))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let multipart_key = "multipart-object";
|
||||||
|
let multipart_parts = vec![vec![b'a'; 5 * 1024 * 1024], b"final multipart bytes".to_vec()];
|
||||||
|
let multipart_bytes = multipart_parts.concat();
|
||||||
|
write_multipart(&old_client, PLAIN_BUCKET, multipart_key, &multipart_parts).await?;
|
||||||
|
|
||||||
|
let versioned_key = "versioned-object";
|
||||||
|
let version1_bytes = b"version one from the previous release";
|
||||||
|
let version1 = old_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(VERSIONED_BUCKET)
|
||||||
|
.key(versioned_key)
|
||||||
|
.body(ByteStream::from_static(version1_bytes))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("first versioned PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
let version2_bytes = b"version two from the previous release";
|
||||||
|
let version2 = old_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(VERSIONED_BUCKET)
|
||||||
|
.key(versioned_key)
|
||||||
|
.body(ByteStream::from_static(version2_bytes))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("second versioned PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
let deleted = old_client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(VERSIONED_BUCKET)
|
||||||
|
.key(versioned_key)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(deleted.delete_marker(), Some(true));
|
||||||
|
let delete_marker = deleted
|
||||||
|
.version_id()
|
||||||
|
.ok_or("versioned DELETE omitted delete marker version ID")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
env.restart_server_preserving_data(vec![], &server_env).await?;
|
||||||
|
let current_client = env.create_s3_client();
|
||||||
|
|
||||||
|
assert_eq!(read_object(¤t_client, PLAIN_BUCKET, plain_key, None).await?.1, plain_bytes);
|
||||||
|
|
||||||
|
let (encryption, upgraded_encrypted_bytes) = read_object(¤t_client, PLAIN_BUCKET, encrypted_key, None).await?;
|
||||||
|
assert_eq!(encryption, Some(ServerSideEncryption::Aes256));
|
||||||
|
assert_eq!(upgraded_encrypted_bytes, encrypted_bytes);
|
||||||
|
|
||||||
|
assert_eq!(read_object(¤t_client, PLAIN_BUCKET, multipart_key, None).await?.1, multipart_bytes);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
read_object(¤t_client, VERSIONED_BUCKET, versioned_key, Some(&version1))
|
||||||
|
.await?
|
||||||
|
.1,
|
||||||
|
version1_bytes
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_object(¤t_client, VERSIONED_BUCKET, versioned_key, Some(&version2))
|
||||||
|
.await?
|
||||||
|
.1,
|
||||||
|
version2_bytes
|
||||||
|
);
|
||||||
|
|
||||||
|
let current_read = current_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(VERSIONED_BUCKET)
|
||||||
|
.key(versioned_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("the previous release's delete marker must remain current after upgrade");
|
||||||
|
assert_eq!(current_read.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||||
|
assert_eq!(current_read.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
|
||||||
|
|
||||||
|
let listed = current_client
|
||||||
|
.list_object_versions()
|
||||||
|
.bucket(VERSIONED_BUCKET)
|
||||||
|
.prefix(versioned_key)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(listed.versions().len(), 2);
|
||||||
|
assert!(
|
||||||
|
listed
|
||||||
|
.versions()
|
||||||
|
.iter()
|
||||||
|
.any(|version| version.version_id() == Some(version1.as_str()))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
listed
|
||||||
|
.versions()
|
||||||
|
.iter()
|
||||||
|
.any(|version| version.version_id() == Some(version2.as_str()))
|
||||||
|
);
|
||||||
|
assert_eq!(listed.delete_markers().len(), 1);
|
||||||
|
assert_eq!(listed.delete_markers()[0].version_id(), Some(delete_marker.as_str()));
|
||||||
|
assert_eq!(listed.delete_markers()[0].is_latest(), Some(true));
|
||||||
|
|
||||||
|
let post_upgrade_key = "written-after-upgrade";
|
||||||
|
let post_upgrade_bytes = b"written by the current RustFS build";
|
||||||
|
current_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(PLAIN_BUCKET)
|
||||||
|
.key(post_upgrade_key)
|
||||||
|
.body(ByteStream::from_static(post_upgrade_bytes))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(
|
||||||
|
read_object(¤t_client, PLAIN_BUCKET, post_upgrade_key, None).await?.1,
|
||||||
|
post_upgrade_bytes
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
mod tests {
|
mod tests {
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::types::{BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, VersioningConfiguration};
|
use aws_sdk_s3::types::{BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, VersioningConfiguration};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
@@ -285,7 +286,10 @@ mod tests {
|
|||||||
let output = result.unwrap();
|
let output = result.unwrap();
|
||||||
|
|
||||||
info!("📥 PutObject response - version_id: {:?}", output.version_id);
|
info!("📥 PutObject response - version_id: {:?}", output.version_id);
|
||||||
// version_id can be None or Some("null") for non-versioned buckets
|
assert!(
|
||||||
|
output.version_id().is_none() || output.version_id() == Some("null"),
|
||||||
|
"non-versioned PUT must omit version ID or return the S3 null version"
|
||||||
|
);
|
||||||
info!("✅ PASSED: PutObject works correctly without versioning");
|
info!("✅ PASSED: PutObject works correctly without versioning");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +321,11 @@ mod tests {
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
assert!(put_result.is_ok(), "PUT operation failed");
|
assert!(put_result.is_ok(), "PUT operation failed");
|
||||||
let _version_id = put_result.unwrap().version_id;
|
let version_id = put_result
|
||||||
|
.unwrap()
|
||||||
|
.version_id()
|
||||||
|
.expect("versioned PUT should return a version ID")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
// Test GET
|
// Test GET
|
||||||
info!("📥 Testing GET operation");
|
info!("📥 Testing GET operation");
|
||||||
@@ -341,16 +349,46 @@ mod tests {
|
|||||||
|
|
||||||
// Test DELETE
|
// Test DELETE
|
||||||
info!("🗑️ Testing DELETE operation");
|
info!("🗑️ Testing DELETE operation");
|
||||||
let delete_result = client.delete_object().bucket(bucket).key(key).send().await;
|
let delete_result = client
|
||||||
assert!(delete_result.is_ok(), "DELETE operation failed");
|
.delete_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("DELETE operation failed");
|
||||||
|
assert_eq!(delete_result.delete_marker(), Some(true));
|
||||||
|
let delete_marker_version_id = delete_result
|
||||||
|
.version_id()
|
||||||
|
.expect("versioned DELETE should return a delete marker version ID")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
// Verify object is deleted (should return NoSuchKey or version marker)
|
let get_after_delete = client
|
||||||
let get_after_delete = client.get_object().bucket(bucket).key(key).send().await;
|
.get_object()
|
||||||
assert!(
|
.bucket(bucket)
|
||||||
get_after_delete.is_err() || get_after_delete.unwrap().delete_marker == Some(true),
|
.key(key)
|
||||||
"Object should be deleted or have delete marker"
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("the current delete marker must hide the object");
|
||||||
|
assert_eq!(get_after_delete.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||||
|
assert_eq!(
|
||||||
|
get_after_delete.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some("NoSuchKey")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let versions = client
|
||||||
|
.list_object_versions()
|
||||||
|
.bucket(bucket)
|
||||||
|
.prefix(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("ListObjectVersions failed after DELETE");
|
||||||
|
assert_eq!(versions.versions().len(), 1);
|
||||||
|
assert_eq!(versions.versions()[0].version_id(), Some(version_id.as_str()));
|
||||||
|
assert_eq!(versions.versions()[0].is_latest(), Some(false));
|
||||||
|
assert_eq!(versions.delete_markers().len(), 1);
|
||||||
|
assert_eq!(versions.delete_markers()[0].version_id(), Some(delete_marker_version_id.as_str()));
|
||||||
|
assert_eq!(versions.delete_markers()[0].is_latest(), Some(true));
|
||||||
|
|
||||||
info!("✅ PASSED: All basic S3 operations work correctly");
|
info!("✅ PASSED: All basic S3 operations work correctly");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,31 +455,59 @@ mod tests {
|
|||||||
|
|
||||||
let client = env.create_s3_client();
|
let client = env.create_s3_client();
|
||||||
env.create_test_bucket(bucket).await?;
|
env.create_test_bucket(bucket).await?;
|
||||||
|
enable_versioning(&client, bucket).await?;
|
||||||
|
|
||||||
let key = "terraform.tfstate";
|
let key = "terraform.tfstate";
|
||||||
let response = client
|
let first_version = client
|
||||||
.put_object()
|
.put_object()
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
.key(key)
|
.key(key)
|
||||||
.body(ByteStream::from(b"v1".to_vec()))
|
.body(ByteStream::from(b"v1".to_vec()))
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await?
|
||||||
assert!(response.is_ok());
|
.version_id()
|
||||||
|
.ok_or("first Terraform state PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
client.delete_object().bucket(bucket).key(key).send().await?;
|
let deleted = client.delete_object().bucket(bucket).key(key).send().await?;
|
||||||
|
assert_eq!(deleted.delete_marker(), Some(true));
|
||||||
|
let delete_marker = deleted
|
||||||
|
.version_id()
|
||||||
|
.ok_or("Terraform state DELETE omitted delete marker version ID")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
let response = client
|
let second_version = client
|
||||||
.put_object()
|
.put_object()
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
.key(key)
|
.key(key)
|
||||||
.body(ByteStream::from(b"v1".to_vec()))
|
.body(ByteStream::from(b"v2".to_vec()))
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("second Terraform state PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
assert!(response.is_ok());
|
let get_response = client.get_object().bucket(bucket).key(key).send().await?;
|
||||||
|
let current_body = get_response.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(current_body.as_ref(), b"v2");
|
||||||
|
|
||||||
let get_response = client.get_object().bucket(bucket).key(key).send().await;
|
let listed = client.list_object_versions().bucket(bucket).prefix(key).send().await?;
|
||||||
assert!(get_response.is_ok(), "Object should exist after PUT");
|
assert_eq!(listed.versions().len(), 2);
|
||||||
|
assert!(
|
||||||
|
listed
|
||||||
|
.versions()
|
||||||
|
.iter()
|
||||||
|
.any(|version| version.version_id() == Some(first_version.as_str()) && version.is_latest() == Some(false))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
listed
|
||||||
|
.versions()
|
||||||
|
.iter()
|
||||||
|
.any(|version| version.version_id() == Some(second_version.as_str()) && version.is_latest() == Some(true))
|
||||||
|
);
|
||||||
|
assert_eq!(listed.delete_markers().len(), 1);
|
||||||
|
assert_eq!(listed.delete_markers()[0].version_id(), Some(delete_marker.as_str()));
|
||||||
|
assert_eq!(listed.delete_markers()[0].is_latest(), Some(false));
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ keywords = ["erasure-coding", "storage", "rustfs", "Minio", "solomon"]
|
|||||||
categories = ["web-programming", "development-tools", "filesystem"]
|
categories = ["web-programming", "development-tools", "filesystem"]
|
||||||
documentation = "https://docs.rs/rustfs-ecstore/latest/rustfs_ecstore/"
|
documentation = "https://docs.rs/rustfs-ecstore/latest/rustfs_ecstore/"
|
||||||
|
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
@@ -45,7 +44,6 @@ hotpath = [
|
|||||||
"hotpath/async-channel",
|
"hotpath/async-channel",
|
||||||
"hotpath/parking_lot",
|
"hotpath/parking_lot",
|
||||||
"hotpath/reqwest-0-13",
|
"hotpath/reqwest-0-13",
|
||||||
"rustfs-checksums/hotpath",
|
|
||||||
"rustfs-common/hotpath",
|
"rustfs-common/hotpath",
|
||||||
"rustfs-concurrency/hotpath",
|
"rustfs-concurrency/hotpath",
|
||||||
"rustfs-config/hotpath",
|
"rustfs-config/hotpath",
|
||||||
@@ -63,16 +61,13 @@ hotpath = [
|
|||||||
"rustfs-rio/hotpath",
|
"rustfs-rio/hotpath",
|
||||||
"rustfs-rio-v2?/hotpath",
|
"rustfs-rio-v2?/hotpath",
|
||||||
"rustfs-s3-types/hotpath",
|
"rustfs-s3-types/hotpath",
|
||||||
"rustfs-signer/hotpath",
|
|
||||||
"rustfs-storage-api/hotpath",
|
"rustfs-storage-api/hotpath",
|
||||||
"rustfs-tls-runtime/hotpath",
|
|
||||||
"rustfs-utils/hotpath",
|
"rustfs-utils/hotpath",
|
||||||
"rustfs-crypto/hotpath",
|
"rustfs-crypto/hotpath",
|
||||||
]
|
]
|
||||||
hotpath-alloc = [
|
hotpath-alloc = [
|
||||||
"hotpath",
|
"hotpath",
|
||||||
"hotpath/hotpath-alloc",
|
"hotpath/hotpath-alloc",
|
||||||
"rustfs-checksums/hotpath-alloc",
|
|
||||||
"rustfs-common/hotpath-alloc",
|
"rustfs-common/hotpath-alloc",
|
||||||
"rustfs-concurrency/hotpath-alloc",
|
"rustfs-concurrency/hotpath-alloc",
|
||||||
"rustfs-config/hotpath-alloc",
|
"rustfs-config/hotpath-alloc",
|
||||||
@@ -90,16 +85,13 @@ hotpath-alloc = [
|
|||||||
"rustfs-rio/hotpath-alloc",
|
"rustfs-rio/hotpath-alloc",
|
||||||
"rustfs-rio-v2?/hotpath-alloc",
|
"rustfs-rio-v2?/hotpath-alloc",
|
||||||
"rustfs-s3-types/hotpath-alloc",
|
"rustfs-s3-types/hotpath-alloc",
|
||||||
"rustfs-signer/hotpath-alloc",
|
|
||||||
"rustfs-storage-api/hotpath-alloc",
|
"rustfs-storage-api/hotpath-alloc",
|
||||||
"rustfs-tls-runtime/hotpath-alloc",
|
|
||||||
"rustfs-utils/hotpath-alloc",
|
"rustfs-utils/hotpath-alloc",
|
||||||
"rustfs-crypto/hotpath-alloc",
|
"rustfs-crypto/hotpath-alloc",
|
||||||
]
|
]
|
||||||
hotpath-cpu = [
|
hotpath-cpu = [
|
||||||
"hotpath",
|
"hotpath",
|
||||||
"hotpath/hotpath-cpu",
|
"hotpath/hotpath-cpu",
|
||||||
"rustfs-checksums/hotpath-cpu",
|
|
||||||
"rustfs-common/hotpath-cpu",
|
"rustfs-common/hotpath-cpu",
|
||||||
"rustfs-concurrency/hotpath-cpu",
|
"rustfs-concurrency/hotpath-cpu",
|
||||||
"rustfs-config/hotpath-cpu",
|
"rustfs-config/hotpath-cpu",
|
||||||
@@ -117,9 +109,7 @@ hotpath-cpu = [
|
|||||||
"rustfs-rio/hotpath-cpu",
|
"rustfs-rio/hotpath-cpu",
|
||||||
"rustfs-rio-v2?/hotpath-cpu",
|
"rustfs-rio-v2?/hotpath-cpu",
|
||||||
"rustfs-s3-types/hotpath-cpu",
|
"rustfs-s3-types/hotpath-cpu",
|
||||||
"rustfs-signer/hotpath-cpu",
|
|
||||||
"rustfs-storage-api/hotpath-cpu",
|
"rustfs-storage-api/hotpath-cpu",
|
||||||
"rustfs-tls-runtime/hotpath-cpu",
|
|
||||||
"rustfs-utils/hotpath-cpu",
|
"rustfs-utils/hotpath-cpu",
|
||||||
"rustfs-crypto/hotpath-cpu",
|
"rustfs-crypto/hotpath-cpu",
|
||||||
]
|
]
|
||||||
@@ -134,18 +124,18 @@ rustfs-filemeta.workspace = true
|
|||||||
rustfs-utils = { workspace = true, features = ["full"] }
|
rustfs-utils = { workspace = true, features = ["full"] }
|
||||||
rustfs-rio.workspace = true
|
rustfs-rio.workspace = true
|
||||||
rustfs-rio-v2 = { workspace = true, optional = true }
|
rustfs-rio-v2 = { workspace = true, optional = true }
|
||||||
rustfs-signer.workspace = true
|
|
||||||
rustfs-storage-api.workspace = true
|
rustfs-storage-api.workspace = true
|
||||||
rustfs-tls-runtime.workspace = true
|
|
||||||
rustfs-checksums.workspace = true
|
|
||||||
rustfs-config = { workspace = true, features = ["notify", "audit", "server-config-model"] }
|
rustfs-config = { workspace = true, features = ["notify", "audit", "server-config-model"] }
|
||||||
rustfs-concurrency.workspace = true
|
rustfs-concurrency.workspace = true
|
||||||
rustfs-credentials = { workspace = true }
|
rustfs-credentials = { workspace = true }
|
||||||
rustfs-common.workspace = true
|
rustfs-common.workspace = true
|
||||||
|
rustfs-heal-contracts.workspace = true
|
||||||
|
rustfs-scanner-contracts.workspace = true
|
||||||
rustfs-policy.workspace = true
|
rustfs-policy.workspace = true
|
||||||
rustfs-protos.workspace = true
|
rustfs-protos.workspace = true
|
||||||
rustfs-replication.workspace = true
|
rustfs-replication.workspace = true
|
||||||
rustfs-lifecycle.workspace = true
|
rustfs-lifecycle.workspace = true
|
||||||
|
rustfs-s3-client = { workspace = true }
|
||||||
rustfs-s3-types = { workspace = true }
|
rustfs-s3-types = { workspace = true }
|
||||||
rustfs-data-usage.workspace = true
|
rustfs-data-usage.workspace = true
|
||||||
rustfs-object-capacity.workspace = true
|
rustfs-object-capacity.workspace = true
|
||||||
@@ -156,7 +146,6 @@ bytes = { workspace = true, features = ["serde"] }
|
|||||||
byteorder = { workspace = true }
|
byteorder = { workspace = true }
|
||||||
chrono = { workspace = true, features = ["serde"] }
|
chrono = { workspace = true, features = ["serde"] }
|
||||||
jiff = { workspace = true, features = ["serde"] }
|
jiff = { workspace = true, features = ["serde"] }
|
||||||
glob = { workspace = true }
|
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
flatbuffers.workspace = true
|
flatbuffers.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
@@ -167,7 +156,6 @@ serde = { workspace = true, features = ["derive"] }
|
|||||||
time = { workspace = true, features = ["parsing", "formatting", "macros", "serde"] }
|
time = { workspace = true, features = ["parsing", "formatting", "macros", "serde"] }
|
||||||
bytesize.workspace = true
|
bytesize.workspace = true
|
||||||
serde_json = { workspace = true, features = ["raw_value"] }
|
serde_json = { workspace = true, features = ["raw_value"] }
|
||||||
quick-xml = { workspace = true, features = ["serialize", "async-tokio"] }
|
|
||||||
s3s = { workspace = true, features = ["minio"] }
|
s3s = { workspace = true, features = ["minio"] }
|
||||||
http.workspace = true
|
http.workspace = true
|
||||||
opentelemetry.workspace = true
|
opentelemetry.workspace = true
|
||||||
@@ -187,9 +175,7 @@ rmp.workspace = true
|
|||||||
rmp-serde.workspace = true
|
rmp-serde.workspace = true
|
||||||
tokio-util = { workspace = true, features = ["io", "compat"] }
|
tokio-util = { workspace = true, features = ["io", "compat"] }
|
||||||
tokio-stream = { workspace = true, features = ["sync"] }
|
tokio-stream = { workspace = true, features = ["sync"] }
|
||||||
base64 = { workspace = true }
|
|
||||||
hmac = { workspace = true }
|
hmac = { workspace = true }
|
||||||
sha1 = { workspace = true }
|
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
hex-simd = { workspace = true }
|
hex-simd = { workspace = true }
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
@@ -204,7 +190,6 @@ tonic = { workspace = true, features = ["gzip", "deflate"] }
|
|||||||
xxhash-rust = { workspace = true, features = ["xxh64", "xxh3"] }
|
xxhash-rust = { workspace = true, features = ["xxh64", "xxh3"] }
|
||||||
tower = { workspace = true, features = ["timeout"] }
|
tower = { workspace = true, features = ["timeout"] }
|
||||||
async-channel.workspace = true
|
async-channel.workspace = true
|
||||||
enumset = { workspace = true }
|
|
||||||
num_cpus = { workspace = true }
|
num_cpus = { workspace = true }
|
||||||
rand = { workspace = true, features = ["serde"] }
|
rand = { workspace = true, features = ["serde"] }
|
||||||
pin-project-lite.workspace = true
|
pin-project-lite.workspace = true
|
||||||
|
|||||||
@@ -154,6 +154,12 @@ pub mod bucket {
|
|||||||
pub mod object_lock {
|
pub mod object_lock {
|
||||||
pub use crate::bucket::object_lock::{ObjectLockApi, ObjectLockStatusExt};
|
pub use crate::bucket::object_lock::{ObjectLockApi, ObjectLockStatusExt};
|
||||||
|
|
||||||
|
pub mod types {
|
||||||
|
pub use crate::bucket::object_lock::types::{
|
||||||
|
DefaultRetention, LegalHoldStatus, ObjectLegalHold, ObjectRetention, RetentionMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
pub mod objectlock {
|
pub mod objectlock {
|
||||||
pub use crate::bucket::object_lock::objectlock::{get_object_legalhold_meta, get_object_retention_meta};
|
pub use crate::bucket::object_lock::objectlock::{get_object_legalhold_meta, get_object_retention_meta};
|
||||||
}
|
}
|
||||||
@@ -249,26 +255,8 @@ pub mod capacity {
|
|||||||
pub use crate::store::utils::is_reserved_or_invalid_bucket;
|
pub use crate::store::utils::is_reserved_or_invalid_bucket;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod client {
|
pub mod object_api_utils {
|
||||||
pub mod admin_handler_utils {
|
pub use crate::object_api::object_api_utils::{ObjReaderFn, PutObjReader, get_raw_etag, new_getobjectreader, to_s3s_etag};
|
||||||
pub use crate::client::admin_handler_utils::AdminError;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod api_put_object {
|
|
||||||
pub use crate::client::api_put_object::{AdvancedPutOptions, PutObjectOptions};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod object_api_utils {
|
|
||||||
pub use crate::client::object_api_utils::{ObjReaderFn, PutObjReader, get_raw_etag, new_getobjectreader, to_s3s_etag};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod transition_api {
|
|
||||||
pub use crate::client::transition_api::{
|
|
||||||
BucketLookupType, CreateBucketConfiguration, LocationConstraint, ObjectInfo, ObjectMultipartInfo, Options,
|
|
||||||
PutObjectPartOptions, ReadCloser, ReaderImpl, RequestMetadata, RestoreInfo, SendRequest, TransitionClient,
|
|
||||||
TransitionCore, UploadInfo, to_object_info,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod cluster {
|
pub mod cluster {
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ use super::runtime_boundary as runtime_sources;
|
|||||||
use crate::bucket::lifecycle::lifecycle;
|
use crate::bucket::lifecycle::lifecycle;
|
||||||
use crate::object_api::ObjectInfo;
|
use crate::object_api::ObjectInfo;
|
||||||
use crate::services::event_notification::{EventArgs, send_event};
|
use crate::services::event_notification::{EventArgs, send_event};
|
||||||
use rustfs_common::metrics::IlmAction;
|
|
||||||
use rustfs_s3_types::EventName;
|
use rustfs_s3_types::EventName;
|
||||||
|
use rustfs_scanner_contracts::metrics::IlmAction;
|
||||||
|
|
||||||
const LIFECYCLE_EXPIRY_USER_AGENT: &str = "Internal: [ILM-Expiry]";
|
const LIFECYCLE_EXPIRY_USER_AGENT: &str = "Internal: [ILM-Expiry]";
|
||||||
const LIFECYCLE_TRANSITION_USER_AGENT: &str = "Internal: [ILM-Transition]";
|
const LIFECYCLE_TRANSITION_USER_AGENT: &str = "Internal: [ILM-Transition]";
|
||||||
|
|||||||
@@ -72,9 +72,6 @@ use crate::store::ECStore;
|
|||||||
use async_channel::{Receiver as A_Receiver, Sender as A_Sender, bounded};
|
use async_channel::{Receiver as A_Receiver, Sender as A_Sender, bounded};
|
||||||
use http::HeaderMap;
|
use http::HeaderMap;
|
||||||
use rand::RngExt as _;
|
use rand::RngExt as _;
|
||||||
use rustfs_common::metrics::{
|
|
||||||
IlmAction, Metrics, ScannerLifecycleExpiryStateUpdate, ScannerLifecycleTransitionStateUpdate, global_metrics,
|
|
||||||
};
|
|
||||||
use rustfs_config::{
|
use rustfs_config::{
|
||||||
DEFAULT_TRANSITION_QUEUE_CAPACITY, DEFAULT_TRANSITION_QUEUE_SEND_TIMEOUT_MS, DEFAULT_TRANSITION_WORKERS_ABSOLUTE_MAX,
|
DEFAULT_TRANSITION_QUEUE_CAPACITY, DEFAULT_TRANSITION_QUEUE_SEND_TIMEOUT_MS, DEFAULT_TRANSITION_WORKERS_ABSOLUTE_MAX,
|
||||||
DEFAULT_TRANSITION_WORKERS_CAP, ENV_MAX_EXPIRY_WORKERS, ENV_TRANSITION_QUEUE_CAPACITY, ENV_TRANSITION_QUEUE_SEND_TIMEOUT_MS,
|
DEFAULT_TRANSITION_WORKERS_CAP, ENV_MAX_EXPIRY_WORKERS, ENV_TRANSITION_QUEUE_CAPACITY, ENV_TRANSITION_QUEUE_SEND_TIMEOUT_MS,
|
||||||
@@ -84,6 +81,9 @@ use rustfs_data_usage::TierStats;
|
|||||||
use rustfs_filemeta::{
|
use rustfs_filemeta::{
|
||||||
FileInfo, FileInfoOpts, NULL_VERSION_ID, RestoreStatusOps, TRANSITION_COMPLETE, get_file_info, is_restored_object_on_disk,
|
FileInfo, FileInfoOpts, NULL_VERSION_ID, RestoreStatusOps, TRANSITION_COMPLETE, get_file_info, is_restored_object_on_disk,
|
||||||
};
|
};
|
||||||
|
use rustfs_scanner_contracts::metrics::{
|
||||||
|
IlmAction, Metrics, ScannerLifecycleExpiryStateUpdate, ScannerLifecycleTransitionStateUpdate, global_metrics,
|
||||||
|
};
|
||||||
use rustfs_utils::{
|
use rustfs_utils::{
|
||||||
get_env_i64, get_env_usize,
|
get_env_i64, get_env_usize,
|
||||||
path::encode_dir_object,
|
path::encode_dir_object,
|
||||||
@@ -939,7 +939,7 @@ impl ExpiryState {
|
|||||||
let version_count = u64::try_from(v.versions.len()).unwrap_or(u64::MAX);
|
let version_count = u64::try_from(v.versions.len()).unwrap_or(u64::MAX);
|
||||||
let trace = LifecycleExpiryTrace::for_batch(&v.bucket, &v.event, &v.src, version_count);
|
let trace = LifecycleExpiryTrace::for_batch(&v.bucket, &v.event, &v.src, version_count);
|
||||||
trace.emit(EVENT_LIFECYCLE_DELETE_DISPATCHED, "delete_dispatched", None);
|
trace.emit(EVENT_LIFECYCLE_DELETE_DISPATCHED, "delete_dispatched", None);
|
||||||
crate::client::object_handlers_common::delete_object_versions(
|
crate::bucket::lifecycle::object_handlers_common::delete_object_versions(
|
||||||
&api,
|
&api,
|
||||||
&v.bucket,
|
&v.bucket,
|
||||||
&v.versions,
|
&v.versions,
|
||||||
@@ -5431,8 +5431,6 @@ mod tests {
|
|||||||
use crate::bucket::lifecycle::tier_sweeper::Jentry;
|
use crate::bucket::lifecycle::tier_sweeper::Jentry;
|
||||||
use crate::bucket::metadata::{BUCKET_LIFECYCLE_CONFIG, BUCKET_VERSIONING_CONFIG};
|
use crate::bucket::metadata::{BUCKET_LIFECYCLE_CONFIG, BUCKET_VERSIONING_CONFIG};
|
||||||
use crate::bucket::metadata_sys;
|
use crate::bucket::metadata_sys;
|
||||||
#[cfg(feature = "test-util")]
|
|
||||||
use crate::client::transition_api::ReaderImpl;
|
|
||||||
use crate::disk::endpoint::Endpoint;
|
use crate::disk::endpoint::Endpoint;
|
||||||
use crate::disk::{RUSTFS_META_MULTIPART_BUCKET, STORAGE_FORMAT_FILE};
|
use crate::disk::{RUSTFS_META_MULTIPART_BUCKET, STORAGE_FORMAT_FILE};
|
||||||
use crate::error::{Error, is_err_invalid_upload_id};
|
use crate::error::{Error, is_err_invalid_upload_id};
|
||||||
@@ -5460,11 +5458,13 @@ mod tests {
|
|||||||
use futures::FutureExt;
|
use futures::FutureExt;
|
||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
use http::HeaderMap;
|
use http::HeaderMap;
|
||||||
use rustfs_common::metrics::{IlmAction, global_metrics};
|
|
||||||
use rustfs_config::ENV_MAX_EXPIRY_WORKERS;
|
use rustfs_config::ENV_MAX_EXPIRY_WORKERS;
|
||||||
use rustfs_config::ENV_TRANSITION_WORKERS_ABSOLUTE_MAX;
|
use rustfs_config::ENV_TRANSITION_WORKERS_ABSOLUTE_MAX;
|
||||||
use rustfs_data_usage::TierStats;
|
use rustfs_data_usage::TierStats;
|
||||||
use rustfs_filemeta::{FileInfo, FileMeta};
|
use rustfs_filemeta::{FileInfo, FileMeta};
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
|
use rustfs_s3_client::transition_api::ReaderImpl;
|
||||||
|
use rustfs_scanner_contracts::metrics::{IlmAction, global_metrics};
|
||||||
use s3s::dto::{
|
use s3s::dto::{
|
||||||
BucketLifecycleConfiguration, DefaultRetention, ExpirationStatus, LifecycleExpiration, LifecycleRule, MetadataEntry,
|
BucketLifecycleConfiguration, DefaultRetention, ExpirationStatus, LifecycleExpiration, LifecycleRule, MetadataEntry,
|
||||||
ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRetentionMode, ObjectLockRule, OutputLocation, RestoreRequest,
|
ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRetentionMode, ObjectLockRule, OutputLocation, RestoreRequest,
|
||||||
@@ -6352,7 +6352,7 @@ mod tests {
|
|||||||
assert_eq!(err.kind(), std::io::ErrorKind::Other);
|
assert_eq!(err.kind(), std::io::ErrorKind::Other);
|
||||||
let admin_err = err
|
let admin_err = err
|
||||||
.get_ref()
|
.get_ref()
|
||||||
.and_then(|source| source.downcast_ref::<crate::client::admin_handler_utils::AdminError>())
|
.and_then(|source| source.downcast_ref::<rustfs_s3_client::admin_handler_utils::AdminError>())
|
||||||
.expect("identity mismatch should retain the typed tier error");
|
.expect("identity mismatch should retain the typed tier error");
|
||||||
assert_eq!(admin_err.code, crate::services::tier::tier::ERR_TIER_INVALID_CONFIG.code);
|
assert_eq!(admin_err.code, crate::services::tier::tier::ERR_TIER_INVALID_CONFIG.code);
|
||||||
assert_eq!(new_backend.get_count().await, 0);
|
assert_eq!(new_backend.get_count().await, 0);
|
||||||
@@ -7002,6 +7002,15 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("free-version metadata should remain readable");
|
.expect("free-version metadata should remain readable");
|
||||||
let mut metadata = FileMeta::load(&encoded).expect("free-version metadata should decode");
|
let mut metadata = FileMeta::load(&encoded).expect("free-version metadata should decode");
|
||||||
|
let mut free_version_delete = FileInfo {
|
||||||
|
version_id: Some(stale_version_id),
|
||||||
|
deleted: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
free_version_delete.set_tier_free_version();
|
||||||
|
metadata
|
||||||
|
.delete_version(&free_version_delete)
|
||||||
|
.expect("stale free version should be consumed before its ID is reused");
|
||||||
metadata
|
metadata
|
||||||
.add_version(FileInfo {
|
.add_version(FileInfo {
|
||||||
volume: bucket.clone(),
|
volume: bucket.clone(),
|
||||||
@@ -11554,7 +11563,7 @@ mod tests {
|
|||||||
lease
|
lease
|
||||||
.put(
|
.put(
|
||||||
"remote/object",
|
"remote/object",
|
||||||
crate::client::transition_api::ReaderImpl::Body(bytes::Bytes::from_static(b"candidate")),
|
rustfs_s3_client::transition_api::ReaderImpl::Body(bytes::Bytes::from_static(b"candidate")),
|
||||||
9,
|
9,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -388,6 +388,7 @@ impl ManualTransitionJobProgressProof {
|
|||||||
queue_snapshot: &ManualTransitionQueueSnapshot,
|
queue_snapshot: &ManualTransitionQueueSnapshot,
|
||||||
cursor_revision: Option<u64>,
|
cursor_revision: Option<u64>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
|
let cursor_revision = cursor_revision.or_else(|| manual_transition_job::manual_transition_cursor_revision(report));
|
||||||
let cursor_marker = match report.continuation_token.as_deref() {
|
let cursor_marker = match report.continuation_token.as_deref() {
|
||||||
Some(token) => {
|
Some(token) => {
|
||||||
let marker = decode_manual_transition_continuation_token(token)?
|
let marker = decode_manual_transition_continuation_token(token)?
|
||||||
@@ -605,6 +606,11 @@ fn manual_job_cursor_reaches(
|
|||||||
if previous.continuation_token_sha256 == next.continuation_token_sha256 {
|
if previous.continuation_token_sha256 == next.continuation_token_sha256 {
|
||||||
return previous.cursor_marker == next.cursor_marker && previous.cursor_revision == next.cursor_revision;
|
return previous.cursor_marker == next.cursor_marker && previous.cursor_revision == next.cursor_revision;
|
||||||
}
|
}
|
||||||
|
if let (Some(previous_marker), Some(next_marker)) = (&previous.cursor_marker, &next.cursor_marker)
|
||||||
|
&& previous_marker != next_marker
|
||||||
|
{
|
||||||
|
return next.scanned > previous.scanned && next_marker > previous_marker;
|
||||||
|
}
|
||||||
match (&previous.continuation_token_sha256, &next.continuation_token_sha256) {
|
match (&previous.continuation_token_sha256, &next.continuation_token_sha256) {
|
||||||
(None, Some(_)) => {
|
(None, Some(_)) => {
|
||||||
next.scanned > previous.scanned
|
next.scanned > previous.scanned
|
||||||
@@ -911,7 +917,7 @@ mod tests {
|
|||||||
fn try_manual_job_checkpoint(job: &manual_transition_job::ManualTransitionJobRecord) -> Result<DurableIlmRecordCheckpoint> {
|
fn try_manual_job_checkpoint(job: &manual_transition_job::ManualTransitionJobRecord) -> Result<DurableIlmRecordCheckpoint> {
|
||||||
let path =
|
let path =
|
||||||
manual_transition_job::manual_transition_job_record_object_name(job.job_id).expect("manual job path should build");
|
manual_transition_job::manual_transition_job_record_object_name(job.job_id).expect("manual job path should build");
|
||||||
let encoded = job.encode().expect("manual job should encode");
|
let encoded = job.encode().map_err(|err| Error::other(err.to_string()))?;
|
||||||
Ok(validate_durable_ilm_record(&path, &encoded)?.checkpoint)
|
Ok(validate_durable_ilm_record(&path, &encoded)?.checkpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -955,10 +961,10 @@ mod tests {
|
|||||||
let options = super::super::bucket_lifecycle_ops::ManualTransitionRunOptions::default();
|
let options = super::super::bucket_lifecycle_ops::ManualTransitionRunOptions::default();
|
||||||
let mut job =
|
let mut job =
|
||||||
manual_transition_job::ManualTransitionJobRecord::new(Uuid::new_v4(), "legacy-checkpoint-bucket", &options, "owner");
|
manual_transition_job::ManualTransitionJobRecord::new(Uuid::new_v4(), "legacy-checkpoint-bucket", &options, "owner");
|
||||||
job.cursor_revision = None;
|
let mut report = job.report.clone();
|
||||||
job.updated_at_unix_nanos += 1;
|
report.scanned = 1;
|
||||||
job.report.scanned = 1;
|
report.continuation_token = Some(continuation_token("logs/a"));
|
||||||
job.report.continuation_token = Some(continuation_token("logs/a"));
|
job.update_running_progress(report, ManualTransitionQueueSnapshot::default());
|
||||||
let compact = manual_job_checkpoint(&job);
|
let compact = manual_job_checkpoint(&job);
|
||||||
let mut legacy = compact.clone();
|
let mut legacy = compact.clone();
|
||||||
let DurableIlmRecordCheckpoint::ManualTransitionJob {
|
let DurableIlmRecordCheckpoint::ManualTransitionJob {
|
||||||
@@ -1090,8 +1096,8 @@ mod tests {
|
|||||||
counter_rollback.updated_at_unix_nanos += 1;
|
counter_rollback.updated_at_unix_nanos += 1;
|
||||||
counter_rollback.report.scanned = 9;
|
counter_rollback.report.scanned = 9;
|
||||||
assert!(
|
assert!(
|
||||||
previous_checkpoint
|
try_manual_job_checkpoint(&counter_rollback)
|
||||||
.validate_successor(&manual_job_checkpoint(&counter_rollback))
|
.and_then(|checkpoint| previous_checkpoint.validate_successor(&checkpoint))
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1100,8 +1106,8 @@ mod tests {
|
|||||||
cursor_rollback.report.scanned += 1;
|
cursor_rollback.report.scanned += 1;
|
||||||
cursor_rollback.report.continuation_token = Some(continuation_token("logs/a"));
|
cursor_rollback.report.continuation_token = Some(continuation_token("logs/a"));
|
||||||
assert!(
|
assert!(
|
||||||
previous_checkpoint
|
try_manual_job_checkpoint(&cursor_rollback)
|
||||||
.validate_successor(&manual_job_checkpoint(&cursor_rollback))
|
.and_then(|checkpoint| previous_checkpoint.validate_successor(&checkpoint))
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1125,8 +1131,8 @@ mod tests {
|
|||||||
same_marker_version_rollback.report.continuation_token =
|
same_marker_version_rollback.report.continuation_token =
|
||||||
Some(continuation_token_with_version("logs/b", Some("opaque-arbitrary-version")));
|
Some(continuation_token_with_version("logs/b", Some("opaque-arbitrary-version")));
|
||||||
assert!(
|
assert!(
|
||||||
same_marker_version_previous_checkpoint
|
try_manual_job_checkpoint(&same_marker_version_rollback)
|
||||||
.validate_successor(&manual_job_checkpoint(&same_marker_version_rollback))
|
.and_then(|checkpoint| same_marker_version_previous_checkpoint.validate_successor(&checkpoint))
|
||||||
.is_err(),
|
.is_err(),
|
||||||
"a different opaque version marker without producer evidence must fail closed"
|
"a different opaque version marker without producer evidence must fail closed"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -588,6 +588,9 @@ impl ManualTransitionJobRecord {
|
|||||||
if self.state == ManualTransitionJobState::Cancelled && !self.cancel_requested {
|
if self.state == ManualTransitionJobState::Cancelled && !self.cancel_requested {
|
||||||
return Err(ManualTransitionJobError::Corrupt("cancelled job is missing cancel request"));
|
return Err(ManualTransitionJobError::Corrupt("cancelled job is missing cancel request"));
|
||||||
}
|
}
|
||||||
|
if self.cursor_revision != manual_transition_cursor_revision(&self.report) {
|
||||||
|
return Err(ManualTransitionJobError::Corrupt("cursor revision does not match report"));
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1996,7 +1999,7 @@ fn manual_transition_job_store_error(err: ManualTransitionJobError) -> Error {
|
|||||||
Error::other(err)
|
Error::other(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn manual_transition_cursor_revision(report: &ManualTransitionRunReport) -> Option<u64> {
|
pub(super) fn manual_transition_cursor_revision(report: &ManualTransitionRunReport) -> Option<u64> {
|
||||||
report.continuation_token.as_ref()?;
|
report.continuation_token.as_ref()?;
|
||||||
(report.scanned > 0).then_some(report.scanned)
|
(report.scanned > 0).then_some(report.scanned)
|
||||||
}
|
}
|
||||||
@@ -2054,6 +2057,18 @@ mod tests {
|
|||||||
assert_eq!(decoded.max_objects, Some(17));
|
assert_eq!(decoded.max_objects, Some(17));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manual_transition_job_record_rejects_untracked_cursor_revision() {
|
||||||
|
let options = ManualTransitionRunOptions::default();
|
||||||
|
let mut record = ManualTransitionJobRecord::new(Uuid::new_v4(), "bucket", &options, TEST_OWNER);
|
||||||
|
record.report.scanned = 1;
|
||||||
|
record.report.continuation_token = encode_manual_transition_continuation_token(Some("logs/page-a".to_string()), None);
|
||||||
|
|
||||||
|
let err = record.encode().expect_err("untracked cursor progress must fail closed");
|
||||||
|
|
||||||
|
assert!(matches!(err, ManualTransitionJobError::Corrupt("cursor revision does not match report")));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn manual_transition_job_record_waits_for_worker_results() {
|
fn manual_transition_job_record_waits_for_worker_results() {
|
||||||
let options = ManualTransitionRunOptions::default();
|
let options = ManualTransitionRunOptions::default();
|
||||||
@@ -2734,6 +2749,7 @@ mod tests {
|
|||||||
lease_id: Uuid,
|
lease_id: Uuid,
|
||||||
lease_expires_at_unix_nanos: i128,
|
lease_expires_at_unix_nanos: i128,
|
||||||
state: ManualTransitionJobState,
|
state: ManualTransitionJobState,
|
||||||
|
#[serde(default)]
|
||||||
scan_completed: bool,
|
scan_completed: bool,
|
||||||
cancel_requested: bool,
|
cancel_requested: bool,
|
||||||
created_at_unix_nanos: i128,
|
created_at_unix_nanos: i128,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub mod evaluator;
|
|||||||
pub mod manual_transition_job;
|
pub mod manual_transition_job;
|
||||||
mod metadata_boundary;
|
mod metadata_boundary;
|
||||||
pub(crate) use metadata_boundary::{LifecycleExpiryConfigs, get_expiry_configs};
|
pub(crate) use metadata_boundary::{LifecycleExpiryConfigs, get_expiry_configs};
|
||||||
|
mod object_handlers_common;
|
||||||
mod object_lock_boundary;
|
mod object_lock_boundary;
|
||||||
pub use self::core as lifecycle;
|
pub use self::core as lifecycle;
|
||||||
mod replication_sink;
|
mod replication_sink;
|
||||||
|
|||||||
+2
-2
@@ -21,7 +21,7 @@ const EVENT_LIFECYCLE_CLEANUP_SKIPPED: &str = "lifecycle_cleanup_skipped";
|
|||||||
const EVENT_LIFECYCLE_CLEANUP_FAILED: &str = "lifecycle_cleanup_failed";
|
const EVENT_LIFECYCLE_CLEANUP_FAILED: &str = "lifecycle_cleanup_failed";
|
||||||
|
|
||||||
use crate::bucket::lifecycle::lifecycle;
|
use crate::bucket::lifecycle::lifecycle;
|
||||||
use crate::bucket::replication::{ReplicationLifecycleBridge, ReplicationObjectBridge};
|
use crate::bucket::lifecycle::replication_sink::{self, ReplicationObjectBridge};
|
||||||
use crate::object_api::ObjectOptions;
|
use crate::object_api::ObjectOptions;
|
||||||
use crate::storage_api_contracts::object::{ObjectOperations as _, ObjectToDelete};
|
use crate::storage_api_contracts::object::{ObjectOperations as _, ObjectToDelete};
|
||||||
use crate::store::ECStore;
|
use crate::store::ECStore;
|
||||||
@@ -84,7 +84,7 @@ pub async fn delete_object_versions(
|
|||||||
if deleted_obj.replication_state.is_none() {
|
if deleted_obj.replication_state.is_none() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
ReplicationLifecycleBridge::schedule_delete(bucket.to_string(), deleted_obj.clone()).await;
|
replication_sink::schedule_delete(bucket.to_string(), deleted_obj.clone()).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (i, err) in errors.iter().enumerate() {
|
for (i, err) in errors.iter().enumerate() {
|
||||||
@@ -26,7 +26,8 @@ pub(crate) fn check_object_lock_for_deletion_with_config(
|
|||||||
obj_info: &ObjectInfo,
|
obj_info: &ObjectInfo,
|
||||||
bypass_governance: bool,
|
bypass_governance: bool,
|
||||||
) -> crate::error::Result<Option<ObjectLockBlockReason>> {
|
) -> crate::error::Result<Option<ObjectLockBlockReason>> {
|
||||||
objectlock_sys::check_object_lock_for_deletion_with_config(config, obj_info, bypass_governance)
|
let default_retention = config.and_then(crate::bucket::metadata_sys::default_retention_from_object_lock_config);
|
||||||
|
objectlock_sys::check_object_lock_for_deletion_with_default_retention(default_retention.as_ref(), obj_info, bypass_governance)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use rustfs_common::metrics::IlmAction;
|
use rustfs_scanner_contracts::metrics::IlmAction;
|
||||||
|
|
||||||
use crate::bucket::lifecycle::lifecycle::ObjectOpts;
|
use crate::bucket::lifecycle::lifecycle::ObjectOpts;
|
||||||
use crate::bucket::replication::ReplicationLifecycleBridge;
|
use crate::bucket::replication::ReplicationLifecycleBridge;
|
||||||
@@ -77,7 +77,7 @@ mod tests {
|
|||||||
use crate::bucket::replication::{DeleteReplicationConfigSnapshot, ReplicationObjectBridge};
|
use crate::bucket::replication::{DeleteReplicationConfigSnapshot, ReplicationObjectBridge};
|
||||||
use crate::object_api::{ObjectInfo, ObjectOptions};
|
use crate::object_api::{ObjectInfo, ObjectOptions};
|
||||||
use crate::storage_api_contracts::object::ObjectToDelete;
|
use crate::storage_api_contracts::object::ObjectToDelete;
|
||||||
use rustfs_common::metrics::IlmAction;
|
use rustfs_scanner_contracts::metrics::IlmAction;
|
||||||
use s3s::dto::{
|
use s3s::dto::{
|
||||||
BucketVersioningStatus, DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication,
|
BucketVersioningStatus, DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication,
|
||||||
DeleteReplicationStatus, Destination, ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus,
|
DeleteReplicationStatus, Destination, ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus,
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ use super::runtime_boundary as runtime_sources;
|
|||||||
use crate::bucket::lifecycle::bucket_lifecycle_ops::ExpiryOp;
|
use crate::bucket::lifecycle::bucket_lifecycle_ops::ExpiryOp;
|
||||||
use crate::bucket::lifecycle::lifecycle::{self, ObjectOpts};
|
use crate::bucket::lifecycle::lifecycle::{self, ObjectOpts};
|
||||||
use crate::bucket::lifecycle::tier_delete_journal::persist_tier_delete_journal_entry;
|
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::object_api::ObjectInfo;
|
use crate::object_api::ObjectInfo;
|
||||||
use crate::services::tier::tier::{TierConfigMgr, TierDestinationId, TierOperationLease};
|
use crate::services::tier::tier::{TierConfigMgr, TierDestinationId, TierOperationLease};
|
||||||
use crate::storage_api_contracts::lifecycle::TransitionedObject;
|
use crate::storage_api_contracts::lifecycle::TransitionedObject;
|
||||||
use crate::store::ECStore;
|
use crate::store::ECStore;
|
||||||
|
use rustfs_s3_client::signer_error::error_chain_contains_signer_header_marker;
|
||||||
use rustfs_utils::get_env_usize;
|
use rustfs_utils::get_env_usize;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
@@ -670,7 +670,7 @@ pub(crate) fn transitioned_delete_journal_entry_for_source(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use crate::client::signer_error::invalid_utf8_header_error;
|
use rustfs_s3_client::signer_error::invalid_utf8_header_error;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES, ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED,
|
CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES, ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED,
|
||||||
|
|||||||
@@ -586,7 +586,10 @@ pub(crate) async fn save_transition_transaction_record(
|
|||||||
transition_transaction_record_object_name(transaction.transaction_id).map_err(transition_transaction_store_error)?;
|
transition_transaction_record_object_name(transaction.transaction_id).map_err(transition_transaction_store_error)?;
|
||||||
let data = transaction.encode().map_err(transition_transaction_store_error)?;
|
let data = transaction.encode().map_err(transition_transaction_store_error)?;
|
||||||
config_boundary::save_config(api.clone(), &object, data.clone()).await?;
|
config_boundary::save_config(api.clone(), &object, data.clone()).await?;
|
||||||
api.record_durable_ilm_decommission_progress(&object, &data).await
|
// Box::pin: the durable-receipt state machine is large and sits on the
|
||||||
|
// already-deep transition worker poll chain; keeping it inline overflows
|
||||||
|
// the default 2 MiB tokio worker stack in debug builds.
|
||||||
|
Box::pin(api.record_durable_ilm_decommission_progress(&object, &data)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn load_transition_transaction_record(
|
pub(crate) async fn load_transition_transaction_record(
|
||||||
@@ -605,7 +608,8 @@ pub(crate) async fn delete_transition_transaction_record(
|
|||||||
let object =
|
let object =
|
||||||
transition_transaction_record_object_name(transaction.transaction_id).map_err(transition_transaction_store_error)?;
|
transition_transaction_record_object_name(transaction.transaction_id).map_err(transition_transaction_store_error)?;
|
||||||
let data = transaction.encode().map_err(transition_transaction_store_error)?;
|
let data = transaction.encode().map_err(transition_transaction_store_error)?;
|
||||||
api.record_durable_ilm_decommission_terminal(&object, &data).await?;
|
// Box::pin: see save_transition_transaction_record.
|
||||||
|
Box::pin(api.record_durable_ilm_decommission_terminal(&object, &data)).await?;
|
||||||
match config_boundary::delete_config(api, &object).await {
|
match config_boundary::delete_config(api, &object).await {
|
||||||
Ok(()) | Err(Error::ConfigNotFound) => Ok(()),
|
Ok(()) | Err(Error::ConfigNotFound) => Ok(()),
|
||||||
Err(err) => Err(err),
|
Err(err) => Err(err),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use super::msgp_decode::{read_msgp_ext8_time, skip_msgp_value, write_msgp_time};
|
use super::msgp_decode::{read_msgp_ext8_time, skip_msgp_value, write_msgp_time};
|
||||||
use super::object_lock::ObjectLockApi;
|
use super::object_lock::{ObjectLockApi, ObjectLockStatusExt};
|
||||||
use super::versioning::VersioningApi;
|
use super::versioning::VersioningApi;
|
||||||
use super::{quota::BucketQuota, target::BucketTargets};
|
use super::{quota::BucketQuota, target::BucketTargets};
|
||||||
use crate::bucket::replication::invalid_replication_config_status_field;
|
use crate::bucket::replication::invalid_replication_config_status_field;
|
||||||
@@ -39,6 +39,26 @@ use time::{Date, OffsetDateTime, PrimitiveDateTime, Time as CivilTime, UtcOffset
|
|||||||
use tracing::error;
|
use tracing::error;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// The serving-layer DTO impls for the storage-level Object Lock traits live
|
||||||
|
// here because this module owns the persisted `ObjectLockConfiguration`
|
||||||
|
// during the s3s ratchet migration (rustfs/backlog#1842).
|
||||||
|
impl ObjectLockApi for ObjectLockConfiguration {
|
||||||
|
fn enabled(&self) -> bool {
|
||||||
|
self.object_lock_enabled
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|v| v.as_str() == s3s::dto::ObjectLockEnabled::ENABLED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectLockStatusExt for s3s::dto::ObjectLockLegalHoldStatus {
|
||||||
|
fn valid(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self.as_str(),
|
||||||
|
s3s::dto::ObjectLockLegalHoldStatus::ON | s3s::dto::ObjectLockLegalHoldStatus::OFF
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn read_msgp_str<R: Read>(rd: &mut R) -> Result<String> {
|
fn read_msgp_str<R: Read>(rd: &mut R) -> Result<String> {
|
||||||
let len = rmp::decode::read_str_len(rd)? as usize;
|
let len = rmp::decode::read_str_len(rd)? as usize;
|
||||||
let mut buf = vec![0u8; len];
|
let mut buf = vec![0u8; len];
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ use crate::storage_api_contracts::heal::HealOperations as _;
|
|||||||
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||||
use crate::store::{ECStore, await_bucket_namespace_operation};
|
use crate::store::{ECStore, await_bucket_namespace_operation};
|
||||||
use futures::future::join_all;
|
use futures::future::join_all;
|
||||||
use rustfs_common::heal_channel::HealOpts;
|
use rustfs_heal_contracts::heal_channel::HealOpts;
|
||||||
use rustfs_policy::policy::BucketPolicy;
|
use rustfs_policy::policy::BucketPolicy;
|
||||||
use s3s::dto::ReplicationConfiguration;
|
use s3s::dto::ReplicationConfiguration;
|
||||||
use s3s::dto::{
|
use s3s::dto::{
|
||||||
@@ -167,6 +167,53 @@ pub(crate) fn object_lock_config_state_from_authoritative_metadata(bm: &BucketMe
|
|||||||
Ok(ObjectLockConfigState::ConfirmedAbsent)
|
Ok(ObjectLockConfigState::ConfirmedAbsent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert the persisted serving-layer configuration into the storage-level
|
||||||
|
/// [`DefaultRetention`](crate::bucket::object_lock::types::DefaultRetention)
|
||||||
|
/// the WORM evaluation code consumes (rustfs/backlog#1842). A rule without a
|
||||||
|
/// usable GOVERNANCE/COMPLIANCE mode converts to `None`, exactly like the
|
||||||
|
/// evaluation code has always ignored such rules; days/years are passed
|
||||||
|
/// through untouched so an invalid period still fails closed at evaluation.
|
||||||
|
pub(crate) fn default_retention_from_object_lock_config(
|
||||||
|
config: &ObjectLockConfiguration,
|
||||||
|
) -> Option<crate::bucket::object_lock::types::DefaultRetention> {
|
||||||
|
let default_retention = config.rule.as_ref()?.default_retention.as_ref()?;
|
||||||
|
let mode = crate::bucket::object_lock::types::RetentionMode::parse(default_retention.mode.as_ref()?.as_str())?;
|
||||||
|
Some(crate::bucket::object_lock::types::DefaultRetention {
|
||||||
|
mode,
|
||||||
|
days: default_retention.days,
|
||||||
|
years: default_retention.years,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test-only builder for a `Configured` Object Lock state carrying a default
|
||||||
|
/// retention, so storage-side tests do not have to name serving-layer DTOs.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn configured_object_lock_state_for_tests(
|
||||||
|
mode: crate::bucket::object_lock::types::RetentionMode,
|
||||||
|
days: i32,
|
||||||
|
) -> ObjectLockConfigState {
|
||||||
|
ObjectLockConfigState::Configured {
|
||||||
|
config: ObjectLockConfiguration {
|
||||||
|
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||||
|
rule: Some(s3s::dto::ObjectLockRule {
|
||||||
|
default_retention: Some(s3s::dto::DefaultRetention {
|
||||||
|
mode: Some(s3s::dto::ObjectLockRetentionMode::from_static(match mode {
|
||||||
|
crate::bucket::object_lock::types::RetentionMode::Governance => {
|
||||||
|
s3s::dto::ObjectLockRetentionMode::GOVERNANCE
|
||||||
|
}
|
||||||
|
crate::bucket::object_lock::types::RetentionMode::Compliance => {
|
||||||
|
s3s::dto::ObjectLockRetentionMode::COMPLIANCE
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
days: Some(days),
|
||||||
|
years: None,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
updated_at: OffsetDateTime::now_utc(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_authoritative_object_lock_config(config: &ObjectLockConfiguration) -> Result<()> {
|
fn validate_authoritative_object_lock_config(config: &ObjectLockConfiguration) -> Result<()> {
|
||||||
if config.object_lock_enabled.as_ref().map(ObjectLockEnabled::as_str) != Some(ObjectLockEnabled::ENABLED) {
|
if config.object_lock_enabled.as_ref().map(ObjectLockEnabled::as_str) != Some(ObjectLockEnabled::ENABLED) {
|
||||||
return Err(Error::other("persisted bucket Object Lock enabled state is invalid"));
|
return Err(Error::other("persisted bucket Object Lock enabled state is invalid"));
|
||||||
|
|||||||
@@ -14,27 +14,18 @@
|
|||||||
|
|
||||||
pub mod objectlock;
|
pub mod objectlock;
|
||||||
pub mod objectlock_sys;
|
pub mod objectlock_sys;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
use s3s::dto::{ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHoldStatus};
|
/// Whether a bucket Object Lock configuration has locking enabled. The
|
||||||
|
/// serving-layer `ObjectLockConfiguration` DTO implements this in
|
||||||
|
/// the bucket-metadata module, which owns the persisted configuration type
|
||||||
|
/// during the s3s ratchet migration (rustfs/backlog#1842).
|
||||||
pub trait ObjectLockApi {
|
pub trait ObjectLockApi {
|
||||||
fn enabled(&self) -> bool;
|
fn enabled(&self) -> bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ObjectLockApi for ObjectLockConfiguration {
|
/// Whether a legal-hold status value is one of the two valid wire values.
|
||||||
fn enabled(&self) -> bool {
|
/// Implemented for the serving-layer DTO in the bucket-metadata module.
|
||||||
self.object_lock_enabled
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|v| v.as_str() == ObjectLockEnabled::ENABLED)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait ObjectLockStatusExt {
|
pub trait ObjectLockStatusExt {
|
||||||
fn valid(&self) -> bool;
|
fn valid(&self) -> bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ObjectLockStatusExt for ObjectLockLegalHoldStatus {
|
|
||||||
fn valid(&self) -> bool {
|
|
||||||
matches!(self.as_str(), ObjectLockLegalHoldStatus::ON | ObjectLockLegalHoldStatus::OFF)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,8 +12,10 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use s3s::dto::{Date, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockRetention, ObjectLockRetentionMode};
|
use super::types::{LegalHoldStatus, ObjectLegalHold, ObjectRetention, RetentionMode};
|
||||||
use s3s::header::{X_AMZ_OBJECT_LOCK_LEGAL_HOLD, X_AMZ_OBJECT_LOCK_MODE, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE};
|
use rustfs_utils::http::headers::{
|
||||||
|
AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER,
|
||||||
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use time::{OffsetDateTime, format_description};
|
use time::{OffsetDateTime, format_description};
|
||||||
|
|
||||||
@@ -31,65 +33,48 @@ pub fn utc_now_ntp() -> OffsetDateTime {
|
|||||||
OffsetDateTime::now_utc()
|
OffsetDateTime::now_utc()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_object_retention_meta(meta: &HashMap<String, String>) -> ObjectLockRetention {
|
pub fn get_object_retention_meta(meta: &HashMap<String, String>) -> ObjectRetention {
|
||||||
// Note: X_AMZ_OBJECT_LOCK_MODE.as_str() is already lowercase ("x-amz-object-lock-mode")
|
// The persisted metadata keys are the lowercase wire header names.
|
||||||
let mode_str = meta.get(X_AMZ_OBJECT_LOCK_MODE.as_str());
|
let mode_str = meta.get(AMZ_OBJECT_LOCK_MODE_LOWER);
|
||||||
|
|
||||||
let Some(mode_str) = mode_str else {
|
let Some(mode_str) = mode_str else {
|
||||||
return ObjectLockRetention {
|
return ObjectRetention::default();
|
||||||
mode: None,
|
|
||||||
retain_until_date: None,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// If mode is invalid, return empty retention (don't panic)
|
// If mode is invalid, return empty retention (don't panic)
|
||||||
let Some(mode) = parse_ret_mode(mode_str.as_str()) else {
|
let Some(mode) = parse_ret_mode(mode_str.as_str()) else {
|
||||||
return ObjectLockRetention {
|
return ObjectRetention::default();
|
||||||
mode: None,
|
|
||||||
retain_until_date: None,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let till_str = meta.get(X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str());
|
let till_str = meta.get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER);
|
||||||
|
|
||||||
let retain_until_date = till_str
|
let retain_until_date =
|
||||||
.and_then(|s| OffsetDateTime::parse(s, &format_description::well_known::Iso8601::DEFAULT).ok())
|
till_str.and_then(|s| OffsetDateTime::parse(s, &format_description::well_known::Iso8601::DEFAULT).ok());
|
||||||
.map(Date::from);
|
|
||||||
|
|
||||||
ObjectLockRetention {
|
ObjectRetention {
|
||||||
mode: Some(mode),
|
mode: Some(mode),
|
||||||
retain_until_date,
|
retain_until_date,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_object_legalhold_meta(meta: &HashMap<String, String>) -> ObjectLockLegalHold {
|
pub fn get_object_legalhold_meta(meta: &HashMap<String, String>) -> ObjectLegalHold {
|
||||||
// Note: X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str() is already lowercase
|
let hold_str = meta.get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER);
|
||||||
let hold_str = meta.get(X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str());
|
|
||||||
|
|
||||||
match hold_str.and_then(|s| parse_legalhold_status(s)) {
|
ObjectLegalHold {
|
||||||
Some(status) => ObjectLockLegalHold { status: Some(status) },
|
status: hold_str.and_then(|s| parse_legalhold_status(s)),
|
||||||
None => ObjectLockLegalHold { status: None },
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse retention mode string into ObjectLockRetentionMode.
|
/// Parse retention mode string into [`RetentionMode`].
|
||||||
/// Returns None for invalid/unknown mode strings instead of panicking.
|
/// Returns None for invalid/unknown mode strings instead of panicking.
|
||||||
pub fn parse_ret_mode(mode_str: &str) -> Option<ObjectLockRetentionMode> {
|
pub fn parse_ret_mode(mode_str: &str) -> Option<RetentionMode> {
|
||||||
match mode_str.to_uppercase().as_str() {
|
RetentionMode::parse(mode_str)
|
||||||
"GOVERNANCE" => Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::GOVERNANCE)),
|
|
||||||
"COMPLIANCE" => Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse legal hold status string into ObjectLockLegalHoldStatus.
|
/// Parse legal hold status string into [`LegalHoldStatus`].
|
||||||
/// Returns None for invalid/unknown status strings instead of panicking.
|
/// Returns None for invalid/unknown status strings instead of panicking.
|
||||||
pub fn parse_legalhold_status(hold_str: &str) -> Option<ObjectLockLegalHoldStatus> {
|
pub fn parse_legalhold_status(hold_str: &str) -> Option<LegalHoldStatus> {
|
||||||
match hold_str.to_uppercase().as_str() {
|
LegalHoldStatus::parse(hold_str)
|
||||||
"ON" => Some(ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::ON)),
|
|
||||||
"OFF" => Some(ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::OFF)),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -101,25 +86,25 @@ mod tests {
|
|||||||
// Test uppercase
|
// Test uppercase
|
||||||
let mode = parse_ret_mode("GOVERNANCE");
|
let mode = parse_ret_mode("GOVERNANCE");
|
||||||
assert!(mode.is_some());
|
assert!(mode.is_some());
|
||||||
assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE);
|
assert_eq!(mode.unwrap().as_str(), RetentionMode::GOVERNANCE);
|
||||||
|
|
||||||
let mode = parse_ret_mode("COMPLIANCE");
|
let mode = parse_ret_mode("COMPLIANCE");
|
||||||
assert!(mode.is_some());
|
assert!(mode.is_some());
|
||||||
assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::COMPLIANCE);
|
assert_eq!(mode.unwrap().as_str(), RetentionMode::COMPLIANCE);
|
||||||
|
|
||||||
// Test lowercase
|
// Test lowercase
|
||||||
let mode = parse_ret_mode("governance");
|
let mode = parse_ret_mode("governance");
|
||||||
assert!(mode.is_some());
|
assert!(mode.is_some());
|
||||||
assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE);
|
assert_eq!(mode.unwrap().as_str(), RetentionMode::GOVERNANCE);
|
||||||
|
|
||||||
let mode = parse_ret_mode("compliance");
|
let mode = parse_ret_mode("compliance");
|
||||||
assert!(mode.is_some());
|
assert!(mode.is_some());
|
||||||
assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::COMPLIANCE);
|
assert_eq!(mode.unwrap().as_str(), RetentionMode::COMPLIANCE);
|
||||||
|
|
||||||
// Test mixed case
|
// Test mixed case
|
||||||
let mode = parse_ret_mode("Governance");
|
let mode = parse_ret_mode("Governance");
|
||||||
assert!(mode.is_some());
|
assert!(mode.is_some());
|
||||||
assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE);
|
assert_eq!(mode.unwrap().as_str(), RetentionMode::GOVERNANCE);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -136,20 +121,20 @@ mod tests {
|
|||||||
// Test uppercase
|
// Test uppercase
|
||||||
let status = parse_legalhold_status("ON");
|
let status = parse_legalhold_status("ON");
|
||||||
assert!(status.is_some());
|
assert!(status.is_some());
|
||||||
assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::ON);
|
assert_eq!(status.unwrap().as_str(), LegalHoldStatus::ON);
|
||||||
|
|
||||||
let status = parse_legalhold_status("OFF");
|
let status = parse_legalhold_status("OFF");
|
||||||
assert!(status.is_some());
|
assert!(status.is_some());
|
||||||
assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::OFF);
|
assert_eq!(status.unwrap().as_str(), LegalHoldStatus::OFF);
|
||||||
|
|
||||||
// Test lowercase
|
// Test lowercase
|
||||||
let status = parse_legalhold_status("on");
|
let status = parse_legalhold_status("on");
|
||||||
assert!(status.is_some());
|
assert!(status.is_some());
|
||||||
assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::ON);
|
assert_eq!(status.unwrap().as_str(), LegalHoldStatus::ON);
|
||||||
|
|
||||||
let status = parse_legalhold_status("off");
|
let status = parse_legalhold_status("off");
|
||||||
assert!(status.is_some());
|
assert!(status.is_some());
|
||||||
assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::OFF);
|
assert_eq!(status.unwrap().as_str(), LegalHoldStatus::OFF);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -175,7 +160,7 @@ mod tests {
|
|||||||
meta.insert("x-amz-object-lock-mode".to_string(), "GOVERNANCE".to_string());
|
meta.insert("x-amz-object-lock-mode".to_string(), "GOVERNANCE".to_string());
|
||||||
let retention = get_object_retention_meta(&meta);
|
let retention = get_object_retention_meta(&meta);
|
||||||
assert!(retention.mode.is_some());
|
assert!(retention.mode.is_some());
|
||||||
assert_eq!(retention.mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE);
|
assert_eq!(retention.mode.unwrap().as_str(), RetentionMode::GOVERNANCE);
|
||||||
assert!(retention.retain_until_date.is_none());
|
assert!(retention.retain_until_date.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +181,7 @@ mod tests {
|
|||||||
meta.insert("x-amz-object-lock-retain-until-date".to_string(), "2030-01-01T00:00:00Z".to_string());
|
meta.insert("x-amz-object-lock-retain-until-date".to_string(), "2030-01-01T00:00:00Z".to_string());
|
||||||
let retention = get_object_retention_meta(&meta);
|
let retention = get_object_retention_meta(&meta);
|
||||||
assert!(retention.mode.is_some());
|
assert!(retention.mode.is_some());
|
||||||
assert_eq!(retention.mode.unwrap().as_str(), ObjectLockRetentionMode::COMPLIANCE);
|
assert_eq!(retention.mode.unwrap().as_str(), RetentionMode::COMPLIANCE);
|
||||||
assert!(retention.retain_until_date.is_some());
|
assert!(retention.retain_until_date.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,17 +195,11 @@ mod tests {
|
|||||||
meta.insert("x-amz-object-lock-legal-hold".to_string(), "ON".to_string());
|
meta.insert("x-amz-object-lock-legal-hold".to_string(), "ON".to_string());
|
||||||
|
|
||||||
let retention = get_object_retention_meta(&meta);
|
let retention = get_object_retention_meta(&meta);
|
||||||
assert_eq!(
|
assert_eq!(retention.mode.as_ref().map(|mode| mode.as_str()), Some(RetentionMode::COMPLIANCE));
|
||||||
retention.mode.as_ref().map(|mode| mode.as_str()),
|
|
||||||
Some(ObjectLockRetentionMode::COMPLIANCE)
|
|
||||||
);
|
|
||||||
assert!(retention.retain_until_date.is_some(), "persisted retention date must remain readable");
|
assert!(retention.retain_until_date.is_some(), "persisted retention date must remain readable");
|
||||||
|
|
||||||
let legal_hold = get_object_legalhold_meta(&meta);
|
let legal_hold = get_object_legalhold_meta(&meta);
|
||||||
assert_eq!(
|
assert_eq!(legal_hold.status.as_ref().map(|status| status.as_str()), Some(LegalHoldStatus::ON));
|
||||||
legal_hold.status.as_ref().map(|status| status.as_str()),
|
|
||||||
Some(ObjectLockLegalHoldStatus::ON)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -236,7 +215,7 @@ mod tests {
|
|||||||
meta.insert("x-amz-object-lock-legal-hold".to_string(), "ON".to_string());
|
meta.insert("x-amz-object-lock-legal-hold".to_string(), "ON".to_string());
|
||||||
let legalhold = get_object_legalhold_meta(&meta);
|
let legalhold = get_object_legalhold_meta(&meta);
|
||||||
assert!(legalhold.status.is_some());
|
assert!(legalhold.status.is_some());
|
||||||
assert_eq!(legalhold.status.unwrap().as_str(), ObjectLockLegalHoldStatus::ON);
|
assert_eq!(legalhold.status.unwrap().as_str(), LegalHoldStatus::ON);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -245,7 +224,7 @@ mod tests {
|
|||||||
meta.insert("x-amz-object-lock-legal-hold".to_string(), "OFF".to_string());
|
meta.insert("x-amz-object-lock-legal-hold".to_string(), "OFF".to_string());
|
||||||
let legalhold = get_object_legalhold_meta(&meta);
|
let legalhold = get_object_legalhold_meta(&meta);
|
||||||
assert!(legalhold.status.is_some());
|
assert!(legalhold.status.is_some());
|
||||||
assert_eq!(legalhold.status.unwrap().as_str(), ObjectLockLegalHoldStatus::OFF);
|
assert_eq!(legalhold.status.unwrap().as_str(), LegalHoldStatus::OFF);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -12,12 +12,16 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use crate::bucket::metadata_sys::{ObjectLockConfigState, get_object_lock_config, get_object_lock_config_state};
|
use crate::bucket::metadata_sys::{
|
||||||
|
ObjectLockConfigState, default_retention_from_object_lock_config, get_object_lock_config, get_object_lock_config_state,
|
||||||
|
};
|
||||||
use crate::bucket::object_lock::objectlock;
|
use crate::bucket::object_lock::objectlock;
|
||||||
|
use crate::bucket::object_lock::types::{DefaultRetention, LegalHoldStatus, RetentionMode};
|
||||||
use crate::error::{Error, Result, StorageError};
|
use crate::error::{Error, Result, StorageError};
|
||||||
use crate::object_api::{ObjectInfo, ObjectOptions};
|
use crate::object_api::{ObjectInfo, ObjectOptions};
|
||||||
use s3s::dto::{Date, DefaultRetention, ObjectLockConfiguration, ObjectLockLegalHoldStatus, ObjectLockRetentionMode};
|
use rustfs_utils::http::headers::{
|
||||||
use s3s::header::{X_AMZ_OBJECT_LOCK_LEGAL_HOLD, X_AMZ_OBJECT_LOCK_MODE, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE};
|
AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER,
|
||||||
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
@@ -29,11 +33,12 @@ impl BucketObjectLockSys {
|
|||||||
Arc::new(Self {})
|
Arc::new(Self {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The bucket's active default retention, if the bucket has an
|
||||||
|
/// authoritative Object Lock configuration with a usable
|
||||||
|
/// GOVERNANCE/COMPLIANCE default retention rule.
|
||||||
pub async fn get(bucket: &str) -> Option<DefaultRetention> {
|
pub async fn get(bucket: &str) -> Option<DefaultRetention> {
|
||||||
if let Ok(object_lock_config) = get_object_lock_config(bucket).await
|
if let Ok(object_lock_config) = get_object_lock_config(bucket).await {
|
||||||
&& let Some(object_lock_rule) = object_lock_config.0.rule
|
return default_retention_from_object_lock_config(&object_lock_config.0);
|
||||||
{
|
|
||||||
return object_lock_rule.default_retention;
|
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -54,13 +59,10 @@ pub(crate) fn ensure_recursive_force_delete_allowed_for_state(bucket: &str, stat
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a retention period is still active based on mode and retain_until_date
|
/// Check if a retention period is still active based on mode and retain_until_date
|
||||||
pub fn is_retention_active(mode: &str, retain_until_date: Option<&s3s::dto::Date>) -> bool {
|
pub fn is_retention_active(_mode: RetentionMode, retain_until_date: Option<OffsetDateTime>) -> bool {
|
||||||
if mode != ObjectLockRetentionMode::COMPLIANCE && mode != ObjectLockRetentionMode::GOVERNANCE {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if let Some(retain_until) = retain_until_date {
|
if let Some(retain_until) = retain_until_date {
|
||||||
let now = objectlock::utc_now_ntp();
|
let now = objectlock::utc_now_ntp();
|
||||||
return OffsetDateTime::from(retain_until.clone()).unix_timestamp() > now.unix_timestamp();
|
return retain_until.unix_timestamp() > now.unix_timestamp();
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -68,23 +70,20 @@ pub fn is_retention_active(mode: &str, retain_until_date: Option<&s3s::dto::Date
|
|||||||
/// Check if retention modification is blocked for the given object.
|
/// Check if retention modification is blocked for the given object.
|
||||||
pub fn check_retention_for_modification(
|
pub fn check_retention_for_modification(
|
||||||
user_defined: &std::collections::HashMap<String, String>,
|
user_defined: &std::collections::HashMap<String, String>,
|
||||||
new_mode: Option<&str>,
|
new_mode: Option<RetentionMode>,
|
||||||
new_retain_until: Option<OffsetDateTime>,
|
new_retain_until: Option<OffsetDateTime>,
|
||||||
bypass_governance: bool,
|
bypass_governance: bool,
|
||||||
) -> Option<ObjectLockBlockReason> {
|
) -> Option<ObjectLockBlockReason> {
|
||||||
let retention = objectlock::get_object_retention_meta(user_defined);
|
let retention = objectlock::get_object_retention_meta(user_defined);
|
||||||
|
|
||||||
let Some(mode) = &retention.mode else {
|
let mode = retention.mode?;
|
||||||
return None;
|
|
||||||
};
|
|
||||||
|
|
||||||
let mode_str = mode.as_str();
|
if !is_retention_active(mode, retention.retain_until_date) {
|
||||||
if !is_retention_active(mode_str, retention.retain_until_date.as_ref()) {
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let existing_retain_until = retention.retain_until_date.as_ref().map(|d| OffsetDateTime::from(d.clone()));
|
let existing_retain_until = retention.retain_until_date;
|
||||||
let mode_changed = new_mode != Some(mode_str);
|
let mode_changed = new_mode != Some(mode);
|
||||||
|
|
||||||
// Check if new retention period is shorter than existing
|
// Check if new retention period is shorter than existing
|
||||||
let is_shortening = match (&existing_retain_until, &new_retain_until) {
|
let is_shortening = match (&existing_retain_until, &new_retain_until) {
|
||||||
@@ -93,35 +92,34 @@ pub fn check_retention_for_modification(
|
|||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// COMPLIANCE mode: cannot shorten retention at all (even with bypass)
|
match mode {
|
||||||
// Can only extend the retention period
|
// COMPLIANCE mode: cannot shorten retention at all (even with bypass)
|
||||||
if mode_str == ObjectLockRetentionMode::COMPLIANCE {
|
// Can only extend the retention period
|
||||||
if mode_changed || is_shortening {
|
RetentionMode::Compliance => {
|
||||||
return Some(ObjectLockBlockReason::Retention {
|
if mode_changed || is_shortening {
|
||||||
mode: mode_str.to_string(),
|
return Some(ObjectLockBlockReason::Retention {
|
||||||
retain_until: existing_retain_until,
|
mode,
|
||||||
});
|
retain_until: existing_retain_until,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Extending retention in COMPLIANCE mode is allowed
|
||||||
|
None
|
||||||
}
|
}
|
||||||
// Extending retention in COMPLIANCE mode is allowed
|
// GOVERNANCE mode: extending is always allowed, shortening requires bypass
|
||||||
return None;
|
// This matches AWS S3 behavior where:
|
||||||
}
|
// - Extending retention: allowed without bypass permission
|
||||||
|
// - Shortening/removing retention: requires bypass permission
|
||||||
// GOVERNANCE mode: extending is always allowed, shortening requires bypass
|
RetentionMode::Governance => {
|
||||||
// This matches AWS S3 behavior where:
|
if (mode_changed || is_shortening) && !bypass_governance {
|
||||||
// - Extending retention: allowed without bypass permission
|
return Some(ObjectLockBlockReason::Retention {
|
||||||
// - Shortening/removing retention: requires bypass permission
|
mode,
|
||||||
if mode_str == ObjectLockRetentionMode::GOVERNANCE {
|
retain_until: existing_retain_until,
|
||||||
if (mode_changed || is_shortening) && !bypass_governance {
|
});
|
||||||
return Some(ObjectLockBlockReason::Retention {
|
}
|
||||||
mode: mode_str.to_string(),
|
// Extending retention or shortening with bypass is allowed
|
||||||
retain_until: existing_retain_until,
|
None
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// Extending retention or shortening with bypass is allowed
|
|
||||||
return None;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_years(dt: OffsetDateTime, years: i32) -> OffsetDateTime {
|
pub fn add_years(dt: OffsetDateTime, years: i32) -> OffsetDateTime {
|
||||||
@@ -137,8 +135,7 @@ pub fn add_years(dt: OffsetDateTime, years: i32) -> OffsetDateTime {
|
|||||||
/// Check if an object has legal hold enabled.
|
/// Check if an object has legal hold enabled.
|
||||||
/// Returns true if legal hold is ON.
|
/// Returns true if legal hold is ON.
|
||||||
fn has_legal_hold(user_defined: &std::collections::HashMap<String, String>) -> bool {
|
fn has_legal_hold(user_defined: &std::collections::HashMap<String, String>) -> bool {
|
||||||
let lhold = objectlock::get_object_legalhold_meta(user_defined);
|
objectlock::get_object_legalhold_meta(user_defined).is_on()
|
||||||
matches!(lhold.status, Some(ref st) if st.as_str() == ObjectLockLegalHoldStatus::ON)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether an authorized replication write (`ObjectOptions::replication_request`)
|
/// Whether an authorized replication write (`ObjectOptions::replication_request`)
|
||||||
@@ -172,11 +169,11 @@ pub fn replication_write_may_pass_worm_gate(
|
|||||||
// Delete markers are never locked (same as the WORM gate).
|
// Delete markers are never locked (same as the WORM gate).
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
let config = object_lock_config_from_state(state)?;
|
let default_retention = default_retention_from_state(state)?;
|
||||||
if legal_hold_locks(obj_info)? && opts.replication_legalhold_timestamp.is_none() {
|
if legal_hold_locks(obj_info)? && opts.replication_legalhold_timestamp.is_none() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
let retention_locked = active_retention(config, obj_info)?.is_some();
|
let retention_locked = active_retention(default_retention.as_ref(), obj_info)?.is_some();
|
||||||
Ok(!(retention_locked && opts.replication_retention_timestamp.is_none()))
|
Ok(!(retention_locked && opts.replication_retention_timestamp.is_none()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,8 +201,8 @@ pub fn is_object_locked_by_metadata(user_defined: &std::collections::HashMap<Str
|
|||||||
|
|
||||||
// Check retention - reuse is_retention_active to avoid code duplication
|
// Check retention - reuse is_retention_active to avoid code duplication
|
||||||
let ret = objectlock::get_object_retention_meta(user_defined);
|
let ret = objectlock::get_object_retention_meta(user_defined);
|
||||||
if let Some(mode) = &ret.mode
|
if let Some(mode) = ret.mode
|
||||||
&& is_retention_active(mode.as_str(), ret.retain_until_date.as_ref())
|
&& is_retention_active(mode, ret.retain_until_date)
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -220,7 +217,7 @@ pub enum ObjectLockBlockReason {
|
|||||||
LegalHold,
|
LegalHold,
|
||||||
/// Object is under retention until the specified date
|
/// Object is under retention until the specified date
|
||||||
Retention {
|
Retention {
|
||||||
mode: String,
|
mode: RetentionMode,
|
||||||
retain_until: Option<OffsetDateTime>,
|
retain_until: Option<OffsetDateTime>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -246,30 +243,28 @@ impl ObjectLockBlockReason {
|
|||||||
/// Check if retention blocks deletion based on mode and bypass permission.
|
/// Check if retention blocks deletion based on mode and bypass permission.
|
||||||
/// Returns Some(ObjectLockBlockReason) if blocked, None if allowed.
|
/// Returns Some(ObjectLockBlockReason) if blocked, None if allowed.
|
||||||
fn check_retention_blocks_deletion(
|
fn check_retention_blocks_deletion(
|
||||||
mode_str: &str,
|
mode: RetentionMode,
|
||||||
retain_until: Option<OffsetDateTime>,
|
retain_until: Option<OffsetDateTime>,
|
||||||
bypass_governance: bool,
|
bypass_governance: bool,
|
||||||
) -> Option<ObjectLockBlockReason> {
|
) -> Option<ObjectLockBlockReason> {
|
||||||
// COMPLIANCE mode cannot be bypassed; GOVERNANCE can only be bypassed with permission
|
// COMPLIANCE mode cannot be bypassed; GOVERNANCE can only be bypassed with permission
|
||||||
let can_bypass = mode_str == ObjectLockRetentionMode::GOVERNANCE && bypass_governance;
|
let can_bypass = mode == RetentionMode::Governance && bypass_governance;
|
||||||
if !can_bypass {
|
if !can_bypass {
|
||||||
return Some(ObjectLockBlockReason::Retention {
|
return Some(ObjectLockBlockReason::Retention { mode, retain_until });
|
||||||
mode: mode_str.to_string(),
|
|
||||||
retain_until,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check an object's lock metadata using an already resolved bucket Object
|
/// Check an object's lock metadata using an already resolved bucket default
|
||||||
/// Lock configuration. `None` means the configuration is confirmed absent.
|
/// retention. `None` means the bucket configuration is confirmed absent or
|
||||||
|
/// carries no usable default retention rule.
|
||||||
///
|
///
|
||||||
/// # S3 Standard Behavior
|
/// # S3 Standard Behavior
|
||||||
/// - COMPLIANCE mode: Cannot be deleted even with bypass header
|
/// - COMPLIANCE mode: Cannot be deleted even with bypass header
|
||||||
/// - GOVERNANCE mode: Can be deleted if bypass_governance is true (caller must verify s3:BypassGovernanceRetention permission)
|
/// - GOVERNANCE mode: Can be deleted if bypass_governance is true (caller must verify s3:BypassGovernanceRetention permission)
|
||||||
/// - Legal Hold: Cannot be bypassed regardless of mode
|
/// - Legal Hold: Cannot be bypassed regardless of mode
|
||||||
pub(crate) fn check_object_lock_for_deletion_with_config(
|
pub(crate) fn check_object_lock_for_deletion_with_default_retention(
|
||||||
config: Option<&ObjectLockConfiguration>,
|
default_retention: Option<&DefaultRetention>,
|
||||||
obj_info: &ObjectInfo,
|
obj_info: &ObjectInfo,
|
||||||
bypass_governance: bool,
|
bypass_governance: bool,
|
||||||
) -> Result<Option<ObjectLockBlockReason>> {
|
) -> Result<Option<ObjectLockBlockReason>> {
|
||||||
@@ -281,8 +276,8 @@ pub(crate) fn check_object_lock_for_deletion_with_config(
|
|||||||
return Ok(Some(ObjectLockBlockReason::LegalHold));
|
return Ok(Some(ObjectLockBlockReason::LegalHold));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some((mode_str, retain_until)) = active_retention(config, obj_info)?
|
if let Some((mode, retain_until)) = active_retention(default_retention, obj_info)?
|
||||||
&& let Some(reason) = check_retention_blocks_deletion(mode_str, Some(retain_until), bypass_governance)
|
&& let Some(reason) = check_retention_blocks_deletion(mode, Some(retain_until), bypass_governance)
|
||||||
{
|
{
|
||||||
return Ok(Some(reason));
|
return Ok(Some(reason));
|
||||||
}
|
}
|
||||||
@@ -300,56 +295,41 @@ fn persisted_lock_value<'a>(obj_info: &'a ObjectInfo, key: &str) -> Option<&'a S
|
|||||||
/// Whether the version's persisted legal hold is ON. Any other non-empty
|
/// Whether the version's persisted legal hold is ON. Any other non-empty
|
||||||
/// value than ON/OFF is malformed metadata and fails closed.
|
/// value than ON/OFF is malformed metadata and fails closed.
|
||||||
fn legal_hold_locks(obj_info: &ObjectInfo) -> Result<bool> {
|
fn legal_hold_locks(obj_info: &ObjectInfo) -> Result<bool> {
|
||||||
let Some(status) = persisted_lock_value(obj_info, X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str()) else {
|
let Some(status) = persisted_lock_value(obj_info, AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER) else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
if status.eq_ignore_ascii_case(ObjectLockLegalHoldStatus::ON) {
|
match LegalHoldStatus::parse(status) {
|
||||||
return Ok(true);
|
Some(LegalHoldStatus::On) => Ok(true),
|
||||||
|
Some(LegalHoldStatus::Off) => Ok(false),
|
||||||
|
None => Err(Error::other("persisted object legal-hold metadata is invalid")),
|
||||||
}
|
}
|
||||||
if !status.eq_ignore_ascii_case(ObjectLockLegalHoldStatus::OFF) {
|
|
||||||
return Err(Error::other("persisted object legal-hold metadata is invalid"));
|
|
||||||
}
|
|
||||||
Ok(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The retention that currently locks the version, if any: the explicit
|
/// The retention that currently locks the version, if any: the explicit
|
||||||
/// persisted retention when the keys are present, otherwise the bucket
|
/// persisted retention when the keys are present, otherwise the bucket
|
||||||
/// default retention computed from the version's modification time. Returns
|
/// default retention computed from the version's modification time. Returns
|
||||||
/// `(mode, retain_until)` only while the retention is still active.
|
/// `(mode, retain_until)` only while the retention is still active.
|
||||||
fn active_retention<'a>(
|
fn active_retention(
|
||||||
config: Option<&'a ObjectLockConfiguration>,
|
default_retention: Option<&DefaultRetention>,
|
||||||
obj_info: &ObjectInfo,
|
obj_info: &ObjectInfo,
|
||||||
) -> Result<Option<(&'a str, OffsetDateTime)>> {
|
) -> Result<Option<(RetentionMode, OffsetDateTime)>> {
|
||||||
let mode = persisted_lock_value(obj_info, X_AMZ_OBJECT_LOCK_MODE.as_str());
|
let mode = persisted_lock_value(obj_info, AMZ_OBJECT_LOCK_MODE_LOWER);
|
||||||
let retain_until = persisted_lock_value(obj_info, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str());
|
let retain_until = persisted_lock_value(obj_info, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER);
|
||||||
match (mode, retain_until) {
|
match (mode, retain_until) {
|
||||||
(None, None) => {}
|
(None, None) => {}
|
||||||
(Some(mode), Some(retain_until)) => {
|
(Some(mode), Some(retain_until)) => {
|
||||||
let mode =
|
let mode =
|
||||||
objectlock::parse_ret_mode(mode).ok_or_else(|| Error::other("persisted object retention mode is invalid"))?;
|
objectlock::parse_ret_mode(mode).ok_or_else(|| Error::other("persisted object retention mode is invalid"))?;
|
||||||
let retain_until = OffsetDateTime::parse(retain_until, &time::format_description::well_known::Iso8601::DEFAULT)
|
let retain_until = OffsetDateTime::parse(retain_until, &time::format_description::well_known::Iso8601::DEFAULT)
|
||||||
.map(Date::from)
|
|
||||||
.map_err(|_| Error::other("persisted object retention date is invalid"))?;
|
.map_err(|_| Error::other("persisted object retention date is invalid"))?;
|
||||||
let mode_str = match mode.as_str() {
|
return Ok(is_retention_active(mode, Some(retain_until)).then_some((mode, retain_until)));
|
||||||
ObjectLockRetentionMode::COMPLIANCE => ObjectLockRetentionMode::COMPLIANCE,
|
|
||||||
ObjectLockRetentionMode::GOVERNANCE => ObjectLockRetentionMode::GOVERNANCE,
|
|
||||||
_ => return Err(Error::other("persisted object retention mode is invalid")),
|
|
||||||
};
|
|
||||||
return Ok(is_retention_active(mode_str, Some(&retain_until)).then(|| (mode_str, OffsetDateTime::from(retain_until))));
|
|
||||||
}
|
}
|
||||||
_ => return Err(Error::other("persisted object retention metadata is incomplete")),
|
_ => return Err(Error::other("persisted object retention metadata is incomplete")),
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(default_retention) = config.and_then(|config| config.rule.as_ref()?.default_retention.as_ref()) else {
|
let Some(default_retention) = default_retention else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let Some(mode) = &default_retention.mode else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let mode_str = mode.as_str();
|
|
||||||
if mode_str != ObjectLockRetentionMode::COMPLIANCE && mode_str != ObjectLockRetentionMode::GOVERNANCE {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
// Calculate retention expiration date from object modification time
|
// Calculate retention expiration date from object modification time
|
||||||
let mod_time = obj_info
|
let mod_time = obj_info
|
||||||
.mod_time
|
.mod_time
|
||||||
@@ -363,12 +343,16 @@ fn active_retention<'a>(
|
|||||||
.ok_or_else(|| Error::other("persisted bucket Object Lock retention period is invalid"))?;
|
.ok_or_else(|| Error::other("persisted bucket Object Lock retention period is invalid"))?;
|
||||||
add_years(mod_time, years)
|
add_years(mod_time, years)
|
||||||
};
|
};
|
||||||
Ok((retain_until.unix_timestamp() > now.unix_timestamp()).then_some((mode_str, retain_until)))
|
Ok((retain_until.unix_timestamp() > now.unix_timestamp()).then_some((default_retention.mode, retain_until)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn object_lock_config_from_state(state: &ObjectLockConfigState) -> Result<Option<&ObjectLockConfiguration>> {
|
/// The bucket default retention carried by an authoritative Object Lock
|
||||||
|
/// state. `ConfirmedAbsent` and a configuration without a usable default
|
||||||
|
/// retention rule are both `None`; a fabricated state is an error, never a
|
||||||
|
/// pass.
|
||||||
|
fn default_retention_from_state(state: &ObjectLockConfigState) -> Result<Option<DefaultRetention>> {
|
||||||
match state {
|
match state {
|
||||||
ObjectLockConfigState::Configured { config, .. } => Ok(Some(config)),
|
ObjectLockConfigState::Configured { config, .. } => Ok(default_retention_from_object_lock_config(config)),
|
||||||
ObjectLockConfigState::ConfirmedAbsent => Ok(None),
|
ObjectLockConfigState::ConfirmedAbsent => Ok(None),
|
||||||
ObjectLockConfigState::Fabricated => Err(Error::other("bucket Object Lock metadata is not authoritative")),
|
ObjectLockConfigState::Fabricated => Err(Error::other("bucket Object Lock metadata is not authoritative")),
|
||||||
}
|
}
|
||||||
@@ -379,7 +363,11 @@ pub(crate) fn check_object_lock_for_deletion_with_state(
|
|||||||
obj_info: &ObjectInfo,
|
obj_info: &ObjectInfo,
|
||||||
bypass_governance: bool,
|
bypass_governance: bool,
|
||||||
) -> Result<Option<ObjectLockBlockReason>> {
|
) -> Result<Option<ObjectLockBlockReason>> {
|
||||||
check_object_lock_for_deletion_with_config(object_lock_config_from_state(state)?, obj_info, bypass_governance)
|
check_object_lock_for_deletion_with_default_retention(
|
||||||
|
default_retention_from_state(state)?.as_ref(),
|
||||||
|
obj_info,
|
||||||
|
bypass_governance,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compatibility wrapper for callers that predate fallible metadata lookup.
|
/// Compatibility wrapper for callers that predate fallible metadata lookup.
|
||||||
@@ -402,7 +390,10 @@ pub async fn check_object_lock_for_deletion(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use s3s::dto::{ObjectLockEnabled, ObjectLockRule};
|
use crate::bucket::metadata_sys::configured_object_lock_state_for_tests;
|
||||||
|
use rustfs_utils::http::headers::{
|
||||||
|
AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER,
|
||||||
|
};
|
||||||
use time::{Date, Month, PrimitiveDateTime, Time};
|
use time::{Date, Month, PrimitiveDateTime, Time};
|
||||||
|
|
||||||
fn make_datetime(year: i32, month: u8, day: u8) -> OffsetDateTime {
|
fn make_datetime(year: i32, month: u8, day: u8) -> OffsetDateTime {
|
||||||
@@ -411,51 +402,46 @@ mod tests {
|
|||||||
PrimitiveDateTime::new(date, time).assume_utc()
|
PrimitiveDateTime::new(date, time).assume_utc()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_retention_config(mode: &'static str) -> ObjectLockConfiguration {
|
fn default_retention(mode: RetentionMode) -> DefaultRetention {
|
||||||
ObjectLockConfiguration {
|
DefaultRetention {
|
||||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
mode,
|
||||||
rule: Some(ObjectLockRule {
|
days: Some(30),
|
||||||
default_retention: Some(DefaultRetention {
|
years: None,
|
||||||
mode: Some(ObjectLockRetentionMode::from_static(mode)),
|
|
||||||
days: Some(30),
|
|
||||||
years: None,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deletion_with_config_blocks_active_default_compliance_even_with_bypass() {
|
fn deletion_with_config_blocks_active_default_compliance_even_with_bypass() {
|
||||||
let config = default_retention_config(ObjectLockRetentionMode::COMPLIANCE);
|
let retention = default_retention(RetentionMode::Compliance);
|
||||||
let obj_info = ObjectInfo {
|
let obj_info = ObjectInfo {
|
||||||
mod_time: Some(OffsetDateTime::now_utc()),
|
mod_time: Some(OffsetDateTime::now_utc()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = check_object_lock_for_deletion_with_config(Some(&config), &obj_info, true);
|
let result = check_object_lock_for_deletion_with_default_retention(Some(&retention), &obj_info, true);
|
||||||
|
|
||||||
assert!(matches!(result, Ok(Some(ObjectLockBlockReason::Retention { .. }))));
|
assert!(matches!(result, Ok(Some(ObjectLockBlockReason::Retention { .. }))));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deletion_with_config_allows_active_default_governance_with_bypass() {
|
fn deletion_with_config_allows_active_default_governance_with_bypass() {
|
||||||
let config = default_retention_config(ObjectLockRetentionMode::GOVERNANCE);
|
let retention = default_retention(RetentionMode::Governance);
|
||||||
let obj_info = ObjectInfo {
|
let obj_info = ObjectInfo {
|
||||||
mod_time: Some(OffsetDateTime::now_utc()),
|
mod_time: Some(OffsetDateTime::now_utc()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
check_object_lock_for_deletion_with_config(Some(&config), &obj_info, true),
|
check_object_lock_for_deletion_with_default_retention(Some(&retention), &obj_info, true),
|
||||||
Ok(None)
|
Ok(None)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deletion_with_default_retention_rejects_missing_object_mod_time() {
|
fn deletion_with_default_retention_rejects_missing_object_mod_time() {
|
||||||
let config = default_retention_config(ObjectLockRetentionMode::COMPLIANCE);
|
let retention = default_retention(RetentionMode::Compliance);
|
||||||
|
|
||||||
let err = check_object_lock_for_deletion_with_config(Some(&config), &ObjectInfo::default(), false)
|
let err = check_object_lock_for_deletion_with_default_retention(Some(&retention), &ObjectInfo::default(), false)
|
||||||
.expect_err("default retention needs an authoritative object modification time");
|
.expect_err("default retention needs an authoritative object modification time");
|
||||||
|
|
||||||
assert!(err.to_string().contains("modification time"));
|
assert!(err.to_string().contains("modification time"));
|
||||||
@@ -465,7 +451,7 @@ mod tests {
|
|||||||
fn deletion_with_confirmed_absence_still_blocks_explicit_compliance() {
|
fn deletion_with_confirmed_absence_still_blocks_explicit_compliance() {
|
||||||
let retain_until = OffsetDateTime::now_utc() + time::Duration::days(30);
|
let retain_until = OffsetDateTime::now_utc() + time::Duration::days(30);
|
||||||
let mut user_defined = std::collections::HashMap::new();
|
let mut user_defined = std::collections::HashMap::new();
|
||||||
user_defined.insert("x-amz-object-lock-mode".to_string(), ObjectLockRetentionMode::COMPLIANCE.to_string());
|
user_defined.insert("x-amz-object-lock-mode".to_string(), RetentionMode::COMPLIANCE.to_string());
|
||||||
user_defined.insert(
|
user_defined.insert(
|
||||||
"x-amz-object-lock-retain-until-date".to_string(),
|
"x-amz-object-lock-retain-until-date".to_string(),
|
||||||
retain_until
|
retain_until
|
||||||
@@ -477,7 +463,7 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = check_object_lock_for_deletion_with_config(None, &obj_info, true);
|
let result = check_object_lock_for_deletion_with_default_retention(None, &obj_info, true);
|
||||||
|
|
||||||
assert!(matches!(result, Ok(Some(ObjectLockBlockReason::Retention { .. }))));
|
assert!(matches!(result, Ok(Some(ObjectLockBlockReason::Retention { .. }))));
|
||||||
}
|
}
|
||||||
@@ -501,16 +487,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn deletion_rejects_incomplete_persisted_retention_metadata() {
|
fn deletion_rejects_incomplete_persisted_retention_metadata() {
|
||||||
let mut user_defined = std::collections::HashMap::new();
|
let mut user_defined = std::collections::HashMap::new();
|
||||||
user_defined.insert(
|
user_defined.insert(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), RetentionMode::COMPLIANCE.to_string());
|
||||||
X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(),
|
|
||||||
ObjectLockRetentionMode::COMPLIANCE.to_string(),
|
|
||||||
);
|
|
||||||
let obj_info = ObjectInfo {
|
let obj_info = ObjectInfo {
|
||||||
user_defined: Arc::new(user_defined),
|
user_defined: Arc::new(user_defined),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let err = check_object_lock_for_deletion_with_config(None, &obj_info, false)
|
let err = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false)
|
||||||
.expect_err("mode without retain-until date must fail closed");
|
.expect_err("mode without retain-until date must fail closed");
|
||||||
|
|
||||||
assert!(err.to_string().contains("incomplete"));
|
assert!(err.to_string().contains("incomplete"));
|
||||||
@@ -523,29 +506,24 @@ mod tests {
|
|||||||
.expect("retain-until date should format");
|
.expect("retain-until date should format");
|
||||||
let cases = [
|
let cases = [
|
||||||
("invalid mode", Some("INVALID"), Some(valid_date.as_str()), "retention mode"),
|
("invalid mode", Some("INVALID"), Some(valid_date.as_str()), "retention mode"),
|
||||||
(
|
("invalid date", Some(RetentionMode::COMPLIANCE), Some("not-a-date"), "retention date"),
|
||||||
"invalid date",
|
|
||||||
Some(ObjectLockRetentionMode::COMPLIANCE),
|
|
||||||
Some("not-a-date"),
|
|
||||||
"retention date",
|
|
||||||
),
|
|
||||||
("date only", None, Some(valid_date.as_str()), "incomplete"),
|
("date only", None, Some(valid_date.as_str()), "incomplete"),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (case, mode, retain_until, expected) in cases {
|
for (case, mode, retain_until, expected) in cases {
|
||||||
let mut user_defined = std::collections::HashMap::new();
|
let mut user_defined = std::collections::HashMap::new();
|
||||||
if let Some(mode) = mode {
|
if let Some(mode) = mode {
|
||||||
user_defined.insert(X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(), mode.to_string());
|
user_defined.insert(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), mode.to_string());
|
||||||
}
|
}
|
||||||
if let Some(retain_until) = retain_until {
|
if let Some(retain_until) = retain_until {
|
||||||
user_defined.insert(X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str().to_string(), retain_until.to_string());
|
user_defined.insert(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), retain_until.to_string());
|
||||||
}
|
}
|
||||||
let obj_info = ObjectInfo {
|
let obj_info = ObjectInfo {
|
||||||
user_defined: Arc::new(user_defined),
|
user_defined: Arc::new(user_defined),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let err = check_object_lock_for_deletion_with_config(None, &obj_info, false).expect_err(case);
|
let err = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false).expect_err(case);
|
||||||
assert!(err.to_string().contains(expected), "unexpected {case} error: {err}");
|
assert!(err.to_string().contains(expected), "unexpected {case} error: {err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -579,10 +557,6 @@ mod tests {
|
|||||||
/// source timestamp of every category that currently locks the version.
|
/// source timestamp of every category that currently locks the version.
|
||||||
#[test]
|
#[test]
|
||||||
fn replication_write_passes_worm_gate_only_with_every_locking_category_timestamp() {
|
fn replication_write_passes_worm_gate_only_with_every_locking_category_timestamp() {
|
||||||
use rustfs_utils::http::headers::{
|
|
||||||
AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER,
|
|
||||||
};
|
|
||||||
|
|
||||||
let hold = [(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, "ON")];
|
let hold = [(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, "ON")];
|
||||||
let retention = [
|
let retention = [
|
||||||
(AMZ_OBJECT_LOCK_MODE_LOWER, "GOVERNANCE"),
|
(AMZ_OBJECT_LOCK_MODE_LOWER, "GOVERNANCE"),
|
||||||
@@ -623,19 +597,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The bucket default retention locks a version that carries no explicit
|
/// The bucket default retention locks a version that carries no explicit
|
||||||
/// retention keys (`check_object_lock_for_deletion_with_config` judges it
|
/// retention keys (`check_object_lock_for_deletion_with_default_retention`
|
||||||
/// from the modification time), so the replication bypass must demand the
|
/// judges it from the modification time), so the replication bypass must
|
||||||
/// retention source timestamp for it too — a tagging-only replication
|
/// demand the retention source timestamp for it too — a tagging-only
|
||||||
/// write must not overwrite the default-protected version unjudged.
|
/// replication write must not overwrite the default-protected version
|
||||||
|
/// unjudged.
|
||||||
#[test]
|
#[test]
|
||||||
fn replication_write_under_bucket_default_retention_requires_retention_timestamp() {
|
fn replication_write_under_bucket_default_retention_requires_retention_timestamp() {
|
||||||
use rustfs_utils::http::headers::{AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER};
|
for mode in [RetentionMode::Compliance, RetentionMode::Governance] {
|
||||||
|
let state = configured_object_lock_state_for_tests(mode, 30);
|
||||||
for mode in [ObjectLockRetentionMode::COMPLIANCE, ObjectLockRetentionMode::GOVERNANCE] {
|
|
||||||
let state = ObjectLockConfigState::Configured {
|
|
||||||
config: default_retention_config(mode),
|
|
||||||
updated_at: OffsetDateTime::now_utc(),
|
|
||||||
};
|
|
||||||
let no_keys = lock_object_info(std::collections::HashMap::new());
|
let no_keys = lock_object_info(std::collections::HashMap::new());
|
||||||
assert!(
|
assert!(
|
||||||
check_object_lock_for_deletion_with_state(&state, &no_keys, false)
|
check_object_lock_for_deletion_with_state(&state, &no_keys, false)
|
||||||
@@ -689,8 +659,6 @@ mod tests {
|
|||||||
/// state or malformed persisted lock metadata; both are errors, not a pass.
|
/// state or malformed persisted lock metadata; both are errors, not a pass.
|
||||||
#[test]
|
#[test]
|
||||||
fn replication_write_worm_gate_fails_closed_on_unverifiable_lock_state() {
|
fn replication_write_worm_gate_fails_closed_on_unverifiable_lock_state() {
|
||||||
use rustfs_utils::http::headers::AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER;
|
|
||||||
|
|
||||||
let opts = replication_opts(true, true);
|
let opts = replication_opts(true, true);
|
||||||
let err = replication_write_may_pass_worm_gate(
|
let err = replication_write_may_pass_worm_gate(
|
||||||
&ObjectLockConfigState::Fabricated,
|
&ObjectLockConfigState::Fabricated,
|
||||||
@@ -705,10 +673,7 @@ mod tests {
|
|||||||
.expect_err("malformed legal hold must not be judged");
|
.expect_err("malformed legal hold must not be judged");
|
||||||
assert!(err.to_string().contains("legal-hold"));
|
assert!(err.to_string().contains("legal-hold"));
|
||||||
|
|
||||||
let state = ObjectLockConfigState::Configured {
|
let state = configured_object_lock_state_for_tests(RetentionMode::Compliance, 30);
|
||||||
config: default_retention_config(ObjectLockRetentionMode::COMPLIANCE),
|
|
||||||
updated_at: OffsetDateTime::now_utc(),
|
|
||||||
};
|
|
||||||
let no_mod_time = ObjectInfo::default();
|
let no_mod_time = ObjectInfo::default();
|
||||||
let err = replication_write_may_pass_worm_gate(&state, &no_mod_time, &opts)
|
let err = replication_write_may_pass_worm_gate(&state, &no_mod_time, &opts)
|
||||||
.expect_err("default retention without a modification time must not be judged");
|
.expect_err("default retention without a modification time must not be judged");
|
||||||
@@ -722,10 +687,6 @@ mod tests {
|
|||||||
/// (rustfs/backlog#1953).
|
/// (rustfs/backlog#1953).
|
||||||
#[test]
|
#[test]
|
||||||
fn deletion_treats_cleared_empty_lock_metadata_as_unlocked() {
|
fn deletion_treats_cleared_empty_lock_metadata_as_unlocked() {
|
||||||
use rustfs_utils::http::headers::{
|
|
||||||
AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER,
|
|
||||||
};
|
|
||||||
|
|
||||||
let cases: [(&str, &[&str]); 3] = [
|
let cases: [(&str, &[&str]); 3] = [
|
||||||
(
|
(
|
||||||
"cleared retention",
|
"cleared retention",
|
||||||
@@ -749,7 +710,7 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = check_object_lock_for_deletion_with_config(None, &obj_info, false);
|
let result = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false);
|
||||||
assert!(matches!(result, Ok(None)), "{case}: empty lock keys must read as unlocked: {result:?}");
|
assert!(matches!(result, Ok(None)), "{case}: empty lock keys must read as unlocked: {result:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -757,13 +718,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn deletion_rejects_invalid_persisted_legal_hold_metadata() {
|
fn deletion_rejects_invalid_persisted_legal_hold_metadata() {
|
||||||
let mut user_defined = std::collections::HashMap::new();
|
let mut user_defined = std::collections::HashMap::new();
|
||||||
user_defined.insert(X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str().to_string(), "INVALID".to_string());
|
user_defined.insert(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER.to_string(), "INVALID".to_string());
|
||||||
let obj_info = ObjectInfo {
|
let obj_info = ObjectInfo {
|
||||||
user_defined: Arc::new(user_defined),
|
user_defined: Arc::new(user_defined),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let err = check_object_lock_for_deletion_with_config(None, &obj_info, false)
|
let err = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false)
|
||||||
.expect_err("invalid legal-hold value must fail closed");
|
.expect_err("invalid legal-hold value must fail closed");
|
||||||
|
|
||||||
assert!(err.to_string().contains("legal-hold"));
|
assert!(err.to_string().contains("legal-hold"));
|
||||||
@@ -829,42 +790,29 @@ mod tests {
|
|||||||
assert_eq!(result.day(), 4);
|
assert_eq!(result.day(), 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_is_retention_active_invalid_mode() {
|
|
||||||
// Invalid mode should return false
|
|
||||||
assert!(!is_retention_active("INVALID", None));
|
|
||||||
assert!(!is_retention_active("", None));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_is_retention_active_no_date() {
|
fn test_is_retention_active_no_date() {
|
||||||
// Valid mode but no retain_until_date should return false
|
// Valid mode but no retain_until_date should return false
|
||||||
assert!(!is_retention_active(ObjectLockRetentionMode::COMPLIANCE, None));
|
assert!(!is_retention_active(RetentionMode::Compliance, None));
|
||||||
assert!(!is_retention_active(ObjectLockRetentionMode::GOVERNANCE, None));
|
assert!(!is_retention_active(RetentionMode::Governance, None));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_is_retention_active_future_date() {
|
fn test_is_retention_active_future_date() {
|
||||||
// Valid mode with future retain_until_date should return true
|
// Valid mode with future retain_until_date should return true
|
||||||
let future_date = OffsetDateTime::now_utc() + time::Duration::days(30);
|
let future_date = OffsetDateTime::now_utc() + time::Duration::days(30);
|
||||||
let s3_date = s3s::dto::Date::from(future_date);
|
|
||||||
|
|
||||||
assert!(is_retention_active(ObjectLockRetentionMode::COMPLIANCE, Some(&s3_date)));
|
assert!(is_retention_active(RetentionMode::Compliance, Some(future_date)));
|
||||||
let future_date = OffsetDateTime::now_utc() + time::Duration::days(30);
|
assert!(is_retention_active(RetentionMode::Governance, Some(future_date)));
|
||||||
let s3_date = s3s::dto::Date::from(future_date);
|
|
||||||
assert!(is_retention_active(ObjectLockRetentionMode::GOVERNANCE, Some(&s3_date)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_is_retention_active_past_date() {
|
fn test_is_retention_active_past_date() {
|
||||||
// Valid mode with past retain_until_date should return false
|
// Valid mode with past retain_until_date should return false
|
||||||
let past_date = OffsetDateTime::now_utc() - time::Duration::days(30);
|
let past_date = OffsetDateTime::now_utc() - time::Duration::days(30);
|
||||||
let s3_date = s3s::dto::Date::from(past_date);
|
|
||||||
|
|
||||||
assert!(!is_retention_active(ObjectLockRetentionMode::COMPLIANCE, Some(&s3_date)));
|
assert!(!is_retention_active(RetentionMode::Compliance, Some(past_date)));
|
||||||
let past_date = OffsetDateTime::now_utc() - time::Duration::days(30);
|
assert!(!is_retention_active(RetentionMode::Governance, Some(past_date)));
|
||||||
let s3_date = s3s::dto::Date::from(past_date);
|
|
||||||
assert!(!is_retention_active(ObjectLockRetentionMode::GOVERNANCE, Some(&s3_date)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -890,10 +838,7 @@ mod tests {
|
|||||||
|
|
||||||
// Extending by another 30 days should be allowed
|
// Extending by another 30 days should be allowed
|
||||||
let new_retain = Some(existing_retain + time::Duration::days(30));
|
let new_retain = Some(existing_retain + time::Duration::days(30));
|
||||||
assert!(
|
assert!(check_retention_for_modification(&user_defined, Some(RetentionMode::Compliance), new_retain, false).is_none());
|
||||||
check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::COMPLIANCE), new_retain, false)
|
|
||||||
.is_none()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -911,8 +856,7 @@ mod tests {
|
|||||||
|
|
||||||
// Shortening to 30 days should be blocked
|
// Shortening to 30 days should be blocked
|
||||||
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(30));
|
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(30));
|
||||||
let result =
|
let result = check_retention_for_modification(&user_defined, Some(RetentionMode::Compliance), new_retain, false);
|
||||||
check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::COMPLIANCE), new_retain, false);
|
|
||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
assert!(matches!(result, Some(ObjectLockBlockReason::Retention { .. })));
|
assert!(matches!(result, Some(ObjectLockBlockReason::Retention { .. })));
|
||||||
}
|
}
|
||||||
@@ -950,8 +894,7 @@ mod tests {
|
|||||||
|
|
||||||
// Shortening from 30 days to 15 days without bypass should be blocked
|
// Shortening from 30 days to 15 days without bypass should be blocked
|
||||||
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(15));
|
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(15));
|
||||||
let result =
|
let result = check_retention_for_modification(&user_defined, Some(RetentionMode::Governance), new_retain, false);
|
||||||
check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::GOVERNANCE), new_retain, false);
|
|
||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -971,10 +914,7 @@ mod tests {
|
|||||||
|
|
||||||
// Extending from 30 days to 60 days without bypass should be allowed
|
// Extending from 30 days to 60 days without bypass should be allowed
|
||||||
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(60));
|
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(60));
|
||||||
assert!(
|
assert!(check_retention_for_modification(&user_defined, Some(RetentionMode::Governance), new_retain, false).is_none());
|
||||||
check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::GOVERNANCE), new_retain, false)
|
|
||||||
.is_none()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -992,10 +932,7 @@ mod tests {
|
|||||||
|
|
||||||
// Shortening from 30 days to 15 days with bypass should be allowed
|
// Shortening from 30 days to 15 days with bypass should be allowed
|
||||||
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(15));
|
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(15));
|
||||||
assert!(
|
assert!(check_retention_for_modification(&user_defined, Some(RetentionMode::Governance), new_retain, true).is_none());
|
||||||
check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::GOVERNANCE), new_retain, true)
|
|
||||||
.is_none()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1010,12 +947,8 @@ mod tests {
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let result = check_retention_for_modification(
|
let result =
|
||||||
&user_defined,
|
check_retention_for_modification(&user_defined, Some(RetentionMode::Compliance), Some(existing_retain), false);
|
||||||
Some(ObjectLockRetentionMode::COMPLIANCE),
|
|
||||||
Some(existing_retain),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1032,13 +965,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
check_retention_for_modification(
|
check_retention_for_modification(&user_defined, Some(RetentionMode::Compliance), Some(existing_retain), true)
|
||||||
&user_defined,
|
.is_none()
|
||||||
Some(ObjectLockRetentionMode::COMPLIANCE),
|
|
||||||
Some(existing_retain),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.is_none()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1054,12 +982,8 @@ mod tests {
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let result = check_retention_for_modification(
|
let result =
|
||||||
&user_defined,
|
check_retention_for_modification(&user_defined, Some(RetentionMode::Governance), Some(existing_retain), true);
|
||||||
Some(ObjectLockRetentionMode::GOVERNANCE),
|
|
||||||
Some(existing_retain),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
//! Storage-level Object Lock types (rustfs/backlog#1842).
|
||||||
|
//!
|
||||||
|
//! The engine evaluates WORM state from persisted object metadata and the
|
||||||
|
//! bucket default retention; none of that needs S3 wire/DTO types. The
|
||||||
|
//! serving layer converts to/from its wire DTOs at its own boundary, and the
|
||||||
|
//! bucket-metadata module converts the persisted `ObjectLockConfiguration`
|
||||||
|
//! into [`DefaultRetention`] when handing it to the evaluation code here.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
|
/// Object Lock retention mode. Persisted metadata and the bucket default
|
||||||
|
/// retention only ever carry these two values; anything else is either
|
||||||
|
/// malformed metadata (fail-closed at the parse site) or an inactive
|
||||||
|
/// configuration (ignored at the conversion site).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RetentionMode {
|
||||||
|
Governance,
|
||||||
|
Compliance,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RetentionMode {
|
||||||
|
pub const GOVERNANCE: &'static str = "GOVERNANCE";
|
||||||
|
pub const COMPLIANCE: &'static str = "COMPLIANCE";
|
||||||
|
|
||||||
|
/// Parse the canonical S3 wire spelling, case-insensitively (matching the
|
||||||
|
/// historical `parse_ret_mode` behavior). Returns `None` for anything
|
||||||
|
/// that is not GOVERNANCE/COMPLIANCE.
|
||||||
|
pub fn parse(value: &str) -> Option<Self> {
|
||||||
|
if value.eq_ignore_ascii_case(Self::GOVERNANCE) {
|
||||||
|
Some(Self::Governance)
|
||||||
|
} else if value.eq_ignore_ascii_case(Self::COMPLIANCE) {
|
||||||
|
Some(Self::Compliance)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse only the exact canonical wire spelling. Use this for a mode a
|
||||||
|
/// caller supplies in a *request*: the retention-modification gate has
|
||||||
|
/// always compared the requested mode literally against the canonical
|
||||||
|
/// persisted mode, so a non-canonical spelling must stay "not the same
|
||||||
|
/// mode" (and therefore blocked), not be normalized into a match.
|
||||||
|
pub fn parse_exact(value: &str) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
Self::GOVERNANCE => Some(Self::Governance),
|
||||||
|
Self::COMPLIANCE => Some(Self::Compliance),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Governance => Self::GOVERNANCE,
|
||||||
|
Self::Compliance => Self::COMPLIANCE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for RetentionMode {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Object Lock legal hold status (ON/OFF).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LegalHoldStatus {
|
||||||
|
On,
|
||||||
|
Off,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LegalHoldStatus {
|
||||||
|
pub const ON: &'static str = "ON";
|
||||||
|
pub const OFF: &'static str = "OFF";
|
||||||
|
|
||||||
|
/// Parse the canonical S3 wire spelling, case-insensitively (matching the
|
||||||
|
/// historical `parse_legalhold_status` behavior).
|
||||||
|
pub fn parse(value: &str) -> Option<Self> {
|
||||||
|
if value.eq_ignore_ascii_case(Self::ON) {
|
||||||
|
Some(Self::On)
|
||||||
|
} else if value.eq_ignore_ascii_case(Self::OFF) {
|
||||||
|
Some(Self::Off)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::On => Self::ON,
|
||||||
|
Self::Off => Self::OFF,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for LegalHoldStatus {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An object version's retention as read from persisted metadata. `mode` is
|
||||||
|
/// `None` when the metadata carries no (or an unparsable) retention mode.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct ObjectRetention {
|
||||||
|
pub mode: Option<RetentionMode>,
|
||||||
|
pub retain_until_date: Option<OffsetDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An object version's legal hold as read from persisted metadata. `status`
|
||||||
|
/// is `None` when the metadata carries no (or an unparsable) legal hold.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct ObjectLegalHold {
|
||||||
|
pub status: Option<LegalHoldStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectLegalHold {
|
||||||
|
pub fn is_on(&self) -> bool {
|
||||||
|
self.status == Some(LegalHoldStatus::On)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bucket's default Object Lock retention, converted from the persisted
|
||||||
|
/// configuration. Conversion only yields a value for an active default
|
||||||
|
/// retention (a valid GOVERNANCE/COMPLIANCE mode); a rule without a usable
|
||||||
|
/// mode converts to `None`, matching how the evaluation code has always
|
||||||
|
/// ignored such rules.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct DefaultRetention {
|
||||||
|
pub mode: RetentionMode,
|
||||||
|
pub days: Option<i32>,
|
||||||
|
pub years: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The modification gate compares a *requested* mode against the canonical
|
||||||
|
/// persisted mode literally: a non-canonical spelling must not normalize
|
||||||
|
/// into a match, or a client could shorten GOVERNANCE retention without
|
||||||
|
/// bypass by spelling the mode differently. `parse_exact` is that pin.
|
||||||
|
#[test]
|
||||||
|
fn parse_exact_accepts_only_canonical_spellings() {
|
||||||
|
assert_eq!(RetentionMode::parse_exact("GOVERNANCE"), Some(RetentionMode::Governance));
|
||||||
|
assert_eq!(RetentionMode::parse_exact("COMPLIANCE"), Some(RetentionMode::Compliance));
|
||||||
|
for non_canonical in ["governance", "Governance", "compliance", "Compliance", "", "INVALID"] {
|
||||||
|
assert_eq!(RetentionMode::parse_exact(non_canonical), None, "{non_canonical:?} must not parse");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted metadata parsing stays case-insensitive (the historical
|
||||||
|
/// `parse_ret_mode` / `parse_legalhold_status` behavior): on-disk values
|
||||||
|
/// written by older builds must keep locking.
|
||||||
|
#[test]
|
||||||
|
fn parse_is_case_insensitive_for_persisted_values() {
|
||||||
|
assert_eq!(RetentionMode::parse("governance"), Some(RetentionMode::Governance));
|
||||||
|
assert_eq!(RetentionMode::parse("Compliance"), Some(RetentionMode::Compliance));
|
||||||
|
assert_eq!(LegalHoldStatus::parse("on"), Some(LegalHoldStatus::On));
|
||||||
|
assert_eq!(LegalHoldStatus::parse("Off"), Some(LegalHoldStatus::Off));
|
||||||
|
assert_eq!(RetentionMode::parse("INVALID"), None);
|
||||||
|
assert_eq!(LegalHoldStatus::parse("MAYBE"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
use super::{BucketQuota, QuotaCheckResult, QuotaError, QuotaOperation};
|
use super::{BucketQuota, QuotaCheckResult, QuotaError, QuotaOperation};
|
||||||
use crate::bucket::metadata_sys::{BucketMetadataSys, update, update_if_incarnation};
|
use crate::bucket::metadata_sys::{BucketMetadataSys, update, update_if_incarnation};
|
||||||
use crate::data_usage::get_bucket_usage_memory;
|
use crate::data_usage::get_bucket_usage_memory;
|
||||||
use rustfs_common::metrics::Metric;
|
|
||||||
use rustfs_config::QUOTA_CONFIG_FILE;
|
use rustfs_config::QUOTA_CONFIG_FILE;
|
||||||
|
use rustfs_scanner_contracts::metrics::Metric;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
@@ -120,9 +120,9 @@ impl QuotaChecker {
|
|||||||
|
|
||||||
let duration = start_time.elapsed();
|
let duration = start_time.elapsed();
|
||||||
// inc_time is now a plain fn (not async) — no .await needed.
|
// inc_time is now a plain fn (not async) — no .await needed.
|
||||||
rustfs_common::metrics::Metrics::inc_time(Metric::QuotaCheck, duration);
|
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaCheck, duration);
|
||||||
if !allowed {
|
if !allowed {
|
||||||
rustfs_common::metrics::Metrics::inc_time(Metric::QuotaViolation, duration);
|
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaViolation, duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
@@ -185,7 +185,7 @@ impl QuotaChecker {
|
|||||||
.await
|
.await
|
||||||
.map_err(QuotaError::StorageError)?;
|
.map_err(QuotaError::StorageError)?;
|
||||||
|
|
||||||
rustfs_common::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
|
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
|
||||||
Ok(updated_at)
|
Ok(updated_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +206,7 @@ impl QuotaChecker {
|
|||||||
}
|
}
|
||||||
.map_err(QuotaError::StorageError)?;
|
.map_err(QuotaError::StorageError)?;
|
||||||
|
|
||||||
rustfs_common::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
|
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
|
||||||
Ok(updated_at)
|
Ok(updated_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user