mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-30 18:12:14 +00:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f07029373 | |||
| 4b36667ba1 | |||
| 41ba34a145 | |||
| 3898d524fe | |||
| 718bec7722 | |||
| 50ddec3ffc | |||
| 7692c0c3bd | |||
| 3dd0692917 | |||
| 090d60e00a | |||
| c228fabfdb | |||
| bde569d055 | |||
| e19b0dd997 | |||
| 26b3aad069 | |||
| bf893bcb55 | |||
| 60d4598562 | |||
| 565cbdffed | |||
| 49b2782d51 | |||
| 743b87014b | |||
| 3208f930c5 | |||
| 36b3d21c44 | |||
| 935bb8a8e2 | |||
| 9364ecba67 | |||
| 0153710791 | |||
| 995e26f5ee | |||
| e6fdcd1ad6 | |||
| f016673416 | |||
| 42ff6b4d80 | |||
| 3ced40f221 | |||
| a830ab2a5d | |||
| 52f121ab2a | |||
| eb8868397e | |||
| 66c38b629d | |||
| eb23710d2e | |||
| 4b66155f26 | |||
| 4978f60254 | |||
| fe59eb4952 | |||
| a455b4377c | |||
| 08fab34804 | |||
| f07fed0c49 | |||
| dcfbc2612a | |||
| 87e1c7aeb6 | |||
| 0d7e0a814f | |||
| ef5ccc232a | |||
| c29c8a5a1e | |||
| 403997f2f8 | |||
| e34d75dfdf | |||
| 132e72ef39 | |||
| 4f4c759b67 | |||
| e331a26262 | |||
| 61d2e9fbc3 | |||
| 946755aa89 | |||
| c9622611ed | |||
| 7041e628b7 |
@@ -0,0 +1,113 @@
|
||||
---
|
||||
name: security-advisory-lessons
|
||||
description: Apply RustFS security lessons distilled from repository GitHub Security Advisories. Use when making or reviewing RustFS code changes, doing security checks, handling PR review for auth/authz, IAM, storage, RPC, logging, CORS, console/browser, encryption, policy, or endpoint changes, and when deciding which security regression tests are required.
|
||||
---
|
||||
|
||||
# RustFS Security Advisory Lessons
|
||||
|
||||
Use this skill as a RustFS-specific security lens before changing or approving code. For the current advisory snapshot and full pattern map, read [advisory-patterns.md](references/advisory-patterns.md).
|
||||
|
||||
When currentness matters, refresh the advisory inventory first:
|
||||
|
||||
```bash
|
||||
gh api repos/rustfs/rustfs/security-advisories --paginate \
|
||||
--jq '.[] | {ghsa_id,state,severity,summary,updated_at,html_url}'
|
||||
```
|
||||
|
||||
For the full pattern map, read [advisory-patterns.md](references/advisory-patterns.md).
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Scope the change
|
||||
- Identify touched routes, handlers, storage paths, credentials, logs, browser surfaces, CI/release code, and policy checks.
|
||||
- Treat these paths as security-sensitive by default: `rustfs/src/admin/`, `rustfs/src/storage/`, `rustfs/src/auth.rs`, `rustfs/src/server/layer.rs`, `crates/iam/`, `crates/policy/`, `crates/credentials/`, `crates/ecstore/src/rpc/`, `crates/rio/`, and console preview/auth code.
|
||||
|
||||
### 2. Map to advisory classes
|
||||
- Read [advisory-patterns.md](references/advisory-patterns.md) for matching GHSA lessons.
|
||||
- Do not rely on advisory titles alone. Confirm whether the issue is authentication, authorization, input validation, storage invariant, browser isolation, logging, or operational hardening.
|
||||
|
||||
### 3. Verify fail-closed behavior
|
||||
- Check that unauthenticated, wrong-permission, cross-user, cross-bucket, malformed-input, and default-config cases fail explicitly.
|
||||
- Prefer exact action/permission checks over broad helper calls or inferred ownership.
|
||||
- Confirm lower storage/RPC layers do not bypass checks done in upper layers.
|
||||
|
||||
### 4. Require regression evidence
|
||||
- For behavior changes, add focused negative tests that reproduce the advisory class.
|
||||
- For sensitive fixes, include tests for the bypass form, not only the happy path.
|
||||
- If a test is impractical, explain the residual risk and provide a manual verification command.
|
||||
|
||||
### 5. Report clearly
|
||||
- Lead with concrete findings and file/line evidence.
|
||||
- Separate proven vulnerabilities from hardening risks.
|
||||
- Avoid exaggerating unauthenticated impact when the code actually rejects unauthenticated requests but allows a low-privileged authenticated bypass.
|
||||
|
||||
## Advisory-Derived Guardrails
|
||||
|
||||
### Auth and admin authorization
|
||||
- Every admin or diagnostic route needs an explicit authn and authz story. Route registration, router whitelist, and handler-level authorization must agree.
|
||||
- Match the admin action to the operation exactly. Copy-paste action constants are a known RustFS vulnerability class.
|
||||
- Avoid authentication-only helpers for state-changing admin APIs; use `validate_admin_request` or the established equivalent with the right `AdminAction`.
|
||||
- Do not assume admin-action `Resource` scoping constrains blast radius unless the policy engine actually enforces resources for that action.
|
||||
|
||||
### IAM and service accounts
|
||||
- Treat imported IAM payload fields as attacker-controlled: `parent`, `claims`, `accessKey`, `secretKey`, status, policy names, and groups.
|
||||
- For service account create/update/import, prove parent ownership or root/admin authority before writing credentials or claims.
|
||||
- Do not let `deny_only` or "no explicit deny" become an allow decision that skips required allow checks.
|
||||
- Test cross-user list/update/import flows with wrong, correct, self, parent, and root identities.
|
||||
|
||||
### S3 copy, multipart, and presigned POST
|
||||
- Multipart copy must enforce source `GetObject` and destination `PutObject` semantics equivalent to `CopyObject`, including copy-source and policy conditions.
|
||||
- Do not let `CreateMultipartUpload`, `UploadPartCopy`, `CompleteMultipartUpload`, or `AbortMultipartUpload` return success without authorization.
|
||||
- Presigned POST policies are server-side contracts. Enforce `content-length-range`, key prefix, exact metadata/content-type, and all signed policy conditions.
|
||||
|
||||
### Paths, object keys, and filesystem access
|
||||
- Never join untrusted bucket/object/RPC path strings onto filesystem roots without normalization and boundary checks.
|
||||
- Reject or safely handle `..`, absolute paths, URL-encoded traversal, platform separators, empty components, and paths that canonicalize outside the intended root.
|
||||
- Validate both S3 object-key paths and internode/RPC disk paths; storage helpers can bypass S3 authorization if they trust already-parsed paths.
|
||||
|
||||
### Secrets, default credentials, and crypto
|
||||
- Do not ship hard-coded shared tokens, HMAC secrets, private keys, or production test keys.
|
||||
- Defaults for internode/RPC auth must fail closed for network-reachable deployments or require explicit opt-in with loud warnings.
|
||||
- License or token validation must use signatures with embedded public/verifying keys only; do not use private-key decryption as authenticity.
|
||||
- Plan key rotation and key IDs when removing exposed keys.
|
||||
|
||||
### Logging and debug output
|
||||
- Logs must never include access keys beyond safe identifiers, secret keys, session tokens, JWT claims, HMAC secrets, expected signatures, license secrets, or raw response bodies containing credentials.
|
||||
- Treat `Debug` implementations, `?value` tracing, merged config dumps, and dependency-level HTTP body logging as leak surfaces.
|
||||
- Add log-capture tests or targeted unit tests for redaction wrappers when changing credential structs or response bodies.
|
||||
|
||||
### RPC, parsing, and panic safety
|
||||
- Treat all RPC payload bytes as attacker-controlled. Replace `unwrap`, `expect`, and panic-prone deserialization with typed errors.
|
||||
- Malformed request tests should cover empty bytes, truncated MessagePack/protobuf, invalid enum values, stale timestamps, and invalid signatures.
|
||||
- RPC authentication must be independently strong; do not depend on S3 admin credentials unless the fallback is explicit and safe.
|
||||
|
||||
### Browser, CORS, and console surfaces
|
||||
- Do not reflect arbitrary `Origin` while also allowing credentials. Default CORS should be no CORS unless explicitly configured.
|
||||
- Do not render user-controlled object content in a same-origin iframe with console credentials available to JavaScript.
|
||||
- Prefer origin separation for object preview/download, `nosniff`, CSP, strict content-type handling, and avoiding durable credentials in `localStorage`.
|
||||
- License/version-like metadata endpoints should expose only coarse public data unless authenticated.
|
||||
|
||||
### Profiling, debug, and health endpoints
|
||||
- Profiling and debug endpoints are not health checks. They require admin auth, opt-in enablement, rate limiting, and safe responses.
|
||||
- Do not return absolute filesystem paths or other deployment layout in unauthenticated or low-privilege responses.
|
||||
- Ensure health endpoint allowlists cannot accidentally include expensive diagnostics.
|
||||
|
||||
### Trusted proxy and network identity
|
||||
- Only honor `X-Forwarded-For` or `X-Real-IP` when the request came from a configured trusted proxy.
|
||||
- Direct clients must use the socket peer address for `aws:SourceIp` and policy condition evaluation.
|
||||
- Add tests for direct spoofed headers and trusted-proxy headers.
|
||||
|
||||
### SSE and storage invariants
|
||||
- Encryption metadata is not proof that bytes were encrypted on disk.
|
||||
- When touching reader/writer wrappers such as hashing, encryption, compression, or warp readers, verify wrapper order and inspect stored bytes in regression tests.
|
||||
- Avoid helper shortcuts that unwrap nested readers and accidentally bypass encryption or integrity layers.
|
||||
|
||||
## Review Prompts
|
||||
|
||||
Use these prompts while reviewing a diff:
|
||||
|
||||
- Could a low-privileged authenticated user reach this path with the wrong action, parent, bucket, or source object?
|
||||
- Does a public/default/empty config change security behavior from fail-closed to fail-open?
|
||||
- Is any attacker-controlled value later used as a path, policy condition, credential identity, log field, URL, Origin, or response body?
|
||||
- Is the same operation implemented in multiple paths, such as `CopyObject` vs `UploadPartCopy`, and do all paths enforce the same security contract?
|
||||
- Does the test prove the exploit form is denied, or only that the intended form still works?
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Security Advisory Lessons"
|
||||
short_description: "Apply advisory lessons in reviews."
|
||||
default_prompt: "Review code changes against past RustFS security advisory lessons and report concrete risks, missing tests, and recommended fixes."
|
||||
@@ -0,0 +1,85 @@
|
||||
# RustFS Advisory Pattern Map
|
||||
|
||||
Snapshot source: `gh api repos/rustfs/rustfs/security-advisories --paginate` on 2026-05-05. It included 23 advisories: 8 triage, 13 published, and 2 closed.
|
||||
|
||||
Refresh this file when new advisories appear or when an advisory changes state materially.
|
||||
|
||||
## Pattern Index
|
||||
|
||||
### Admin authorization and route exposure
|
||||
|
||||
- `GHSA-pfcq-4gjr-6gjm` published: notification target endpoints accepted authenticated users but skipped admin authorization. Lesson: distinguish authn from authz; admin target CRUD must call the operation-specific admin authorization path.
|
||||
- `GHSA-mm2q-qcmx-gw4w` published: `ListServiceAccount` used `UpdateServiceAccountAdminAction`, while update lacked target ownership checks. Lesson: exact action constants and ownership checks are both required; information disclosure can chain into secret rotation and takeover.
|
||||
- `GHSA-vcwh-pff9-64cc` published: `ImportIam` checked `ExportIAMAction` for an import/write operation. Lesson: every admin handler must authorize the action it actually performs.
|
||||
- `GHSA-jqmc-mg33-v45g` triage and `GHSA-8784-9m7f-c6p6` triage: `/profile/cpu` and `/profile/memory` were whitelisted from auth and allowed expensive diagnostics plus path disclosure. Lesson: profiling/debug endpoints need admin auth, opt-in, rate limits, and non-sensitive responses.
|
||||
- `GHSA-x5xv-223c-8vm7` triage: console license metadata endpoint was public. Lesson: public metadata endpoints should be coarse or authenticated.
|
||||
|
||||
### IAM import, service accounts, and privilege boundaries
|
||||
|
||||
- `GHSA-566f-q62r-wcr8` triage: `ImportIam` accepted attacker-controlled service account `parent`, `claims`, `accessKey`, and `secretKey`, enabling persistent backdoor accounts under root. Lesson: imported IAM payloads are untrusted data and must be validated against privilege boundaries.
|
||||
- `GHSA-xgr5-qc6w-vcg9` published: `deny_only=true` skipped allow checks and let restricted service accounts mint unrestricted children. Lesson: deny-only logic must never become implicit allow for privilege creation.
|
||||
- `GHSA-mm2q-qcmx-gw4w` published: leaked service account access keys plus update-without-ownership formed an escalation chain. Lesson: service-account identifiers are security-sensitive because update APIs consume them.
|
||||
|
||||
### S3 copy, multipart, and upload policy validation
|
||||
|
||||
- `GHSA-mx42-j6wv-px98` published: `UploadPartCopy` missed source authorization and allowed cross-bucket object exfiltration. Lesson: multipart copy must enforce the same source and destination contract as `CopyObject`.
|
||||
- `GHSA-wfxj-ph3v-7mjf` triage: `UploadPartCopy` checked source and destination independently but missed destination copy-source policy constraints. Lesson: source read and destination write checks are not sufficient when policy constrains allowed copy sources.
|
||||
- `GHSA-w5fh-f8xh-5x3p` published: presigned POST accepted uploads without enforcing signed policy conditions. Lesson: parse and enforce all POST policy constraints server-side, including size, key prefix, and content type.
|
||||
|
||||
### Filesystem paths and object key traversal
|
||||
|
||||
- `GHSA-pq29-69jg-9mxc` published: RPC `read_file_stream` joined untrusted paths under a volume directory without canonical boundary checks. Lesson: `PathBuf::join` plus length checks are not path security.
|
||||
- `GHSA-8r6f-hmq2-28rg` closed: object keys containing traversal sequences bypassed bucket/object authorization when mapped to filesystem paths. Lesson: reject traversal at object-key parsing and verify final storage paths remain under the expected bucket/key root.
|
||||
|
||||
### Secrets, defaults, and cryptographic misuse
|
||||
|
||||
- `GHSA-h956-rh7x-ppgj` published: gRPC used the hard-coded token `rustfs rpc` on both client and server. Lesson: source-visible shared tokens are authentication bypasses.
|
||||
- `GHSA-r5qv-rc46-hv8q` triage: internode RPC HMAC secret fell back to the public default `rustfsadmin`. Lesson: RPC/internode auth must fail closed instead of silently using public defaults.
|
||||
- `GHSA-923g-jp7v-f97f` triage: license verification embedded a production RSA private key and used private-key decryption as authenticity. Lesson: ship verifying/public keys only and use real signature verification.
|
||||
|
||||
### Sensitive logging and debug output
|
||||
|
||||
- `GHSA-r54g-49rx-98cr` published: STS credentials were logged at info level. Lesson: generated credentials must never be logged in plaintext.
|
||||
- `GHSA-8cm2-h255-v749` triage: debug logs leaked session tokens, secret keys, JWT claims, and raw STS response bodies. Lesson: redaction must cover custom `Debug` implementations and dependency response-body logging.
|
||||
- `GHSA-333v-68xh-8mmq` published: invalid RPC signature logging included the shared HMAC secret and expected signature. Lesson: error paths often leak secrets; never log raw secrets or derived authenticators.
|
||||
|
||||
### RPC input validation and panic safety
|
||||
|
||||
- `GHSA-gw2x-q739-qhcr` published: malformed gRPC `GetMetrics` payloads reached `unwrap()` on deserialization and caused remote DoS. Lesson: every network/RPC deserialization failure returns an error, not a panic.
|
||||
- `GHSA-h956-rh7x-ppgj` published and `GHSA-r5qv-rc46-hv8q` triage: weak RPC auth increased reachability of otherwise internal handlers. Lesson: panic bugs become more severe when internode auth is weak or defaulted.
|
||||
|
||||
### Browser, CORS, and console isolation
|
||||
|
||||
- `GHSA-v9fg-3cr2-277j` published: object preview rendered attacker-controlled HTML in a same-origin iframe, exposing console credentials stored in `localStorage`. Lesson: user content must be origin-isolated from the console and protected with `nosniff`, CSP, and strict content-type handling.
|
||||
- `GHSA-x5xv-223c-8vm7` triage: default CORS reflected arbitrary origins with credentials. Lesson: never combine reflected origins with `Access-Control-Allow-Credentials: true`; default should be fail-closed.
|
||||
|
||||
### Trusted proxy and source IP conditions
|
||||
|
||||
- `GHSA-fc6g-2gcp-2qrq` published: `aws:SourceIp` trusted client-supplied `X-Forwarded-For` or `X-Real-IP`. Lesson: forwarded IP headers are valid only behind configured trusted proxies; direct clients use socket peer IP.
|
||||
|
||||
### SSE and on-disk storage invariants
|
||||
|
||||
- `GHSA-xrrf-67jm-3c2r` closed: SSE metadata reported encryption while reader composition bypassed `EncryptReader` and stored plaintext. Lesson: test actual bytes on disk and wrapper order, not only API metadata.
|
||||
|
||||
## Useful Search Seeds
|
||||
|
||||
Use these targeted searches when a diff touches security-sensitive code:
|
||||
|
||||
```bash
|
||||
rg -n "validate_admin_request|check_permissions|AdminAction::|deny_only|is_allowed" rustfs crates
|
||||
rg -n "UploadPartCopy|upload_part_copy|CompleteMultipart|PostObject|content-length-range|starts-with" rustfs crates
|
||||
rg -n "PathBuf::join|canonicalize|\\.\\.|x-forwarded-for|x-real-ip|SourceIp" rustfs crates
|
||||
rg -n "DEFAULT_SECRET|DEFAULT_ACCESS|TEST_PRIVATE_KEY|rustfs rpc|RUSTFS_RPC_SECRET" rustfs crates
|
||||
rg -n "debug!|trace!|info!|error!|\\?resp|\\?merged_config|session_token|secret_key" rustfs crates
|
||||
rg -n "HashReader|EncryptReader|SSE|server-side encryption|Access-Control-Allow-Credentials|Origin" rustfs crates
|
||||
```
|
||||
|
||||
## Minimum Regression Test Expectations
|
||||
|
||||
- Authz fixes: include unauthenticated, valid low-privilege, wrong-action, correct-action, owner, non-owner, and root/admin cases as applicable.
|
||||
- IAM fixes: include import/update/list service-account cases with attacker-controlled parent, claims, access key, secret key, and policy.
|
||||
- Copy/upload fixes: include cross-bucket, cross-user, source-denied, destination-denied, copy-source-condition, and multipart completion cases.
|
||||
- Path fixes: include encoded traversal, absolute path, nested traversal, valid object keys that resemble traversal text but should be rejected, and canonical boundary checks.
|
||||
- Logging fixes: assert redacted output for structs and response bodies that may contain credentials.
|
||||
- Browser/CORS fixes: assert no credentials on reflected/default origins, correct behavior for explicit allowlists, and no same-origin script execution for previewed object content.
|
||||
- SSE fixes: inspect stored bytes and verify API metadata, read-back behavior, and on-disk ciphertext together.
|
||||
@@ -74,6 +74,17 @@ http {
|
||||
# ssl_certificate /etc/nginx/ssl/server.crt;
|
||||
# ssl_certificate_key /etc/nginx/ssl/server.key;
|
||||
#
|
||||
# # Restrict to modern TLS versions and ciphers. Operators copying this
|
||||
# # example must keep at least these directives — without them, nginx
|
||||
# # may negotiate older protocol versions that have known weaknesses.
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# ssl_prefer_server_ciphers on;
|
||||
# ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305';
|
||||
# ssl_session_timeout 1d;
|
||||
# ssl_session_cache shared:SSL:10m;
|
||||
# ssl_session_tickets off;
|
||||
# # add_header Strict-Transport-Security "max-age=63072000" always;
|
||||
#
|
||||
# location / {
|
||||
# proxy_pass http://rustfs:9000;
|
||||
# ...
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
services:
|
||||
rustfs:
|
||||
image: rustfs/rustfs:1.0.0-alpha.99-glibc
|
||||
container_name: rustfs-issue-2715-test
|
||||
security_opt:
|
||||
- "no-new-privileges:true"
|
||||
ports:
|
||||
- "19000:9000"
|
||||
- "19001:9001"
|
||||
environment:
|
||||
- RUSTFS_VOLUMES=/data/rustfs{0...8}
|
||||
- RUSTFS_ADDRESS=0.0.0.0:9000
|
||||
- RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001
|
||||
- RUSTFS_CONSOLE_ENABLE=true
|
||||
- RUSTFS_CORS_ALLOWED_ORIGINS=*
|
||||
- RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS=*
|
||||
- RUSTFS_ACCESS_KEY=admin
|
||||
- RUSTFS_SECRET_KEY=admin
|
||||
- RUSTFS_OBS_LOGGER_LEVEL=info
|
||||
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
||||
- RUSTFS_OBS_PROFILING_ENDPOINT=http://pyroscope:4040
|
||||
- RUSTFS_STORAGE_CLASS_STANDARD=EC:2
|
||||
- RUSTFS_STORAGE_CLASS_RRS=EC:1
|
||||
- RUSTFS_UNSAFE_BYPASS_DISK_CHECK=true
|
||||
- RUSTFS_OBS_LOG_DIRECTORY=/opt/rustfs/logs
|
||||
extra_hosts:
|
||||
- "otel-collector:host-gateway"
|
||||
- "pyroscope:host-gateway"
|
||||
volumes:
|
||||
- ./deploy/data/issue-2715/rustfs0:/data/rustfs0
|
||||
- ./deploy/data/issue-2715/rustfs1:/data/rustfs1
|
||||
- ./deploy/data/issue-2715/rustfs2:/data/rustfs2
|
||||
- ./deploy/data/issue-2715/rustfs3:/data/rustfs3
|
||||
- ./deploy/data/issue-2715/rustfs4:/data/rustfs4
|
||||
- ./deploy/data/issue-2715/rustfs5:/data/rustfs5
|
||||
- ./deploy/data/issue-2715/rustfs6:/data/rustfs6
|
||||
- ./deploy/data/issue-2715/rustfs7:/data/rustfs7
|
||||
- ./deploy/data/issue-2715/rustfs8:/data/rustfs8
|
||||
- ./deploy/logs/issue-2715:/opt/rustfs/logs
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"sh",
|
||||
"-c",
|
||||
"curl -f http://127.0.0.1:9000/health && curl -f http://127.0.0.1:9001/rustfs/console/health"
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
@@ -0,0 +1 @@
|
||||
data/
|
||||
@@ -0,0 +1,106 @@
|
||||
# Issue 2815 Local Docker Verification
|
||||
|
||||
## Purpose
|
||||
|
||||
This directory contains the local distributed Docker verification assets used to validate issue `#2815` against the current source build.
|
||||
|
||||
The target behavior is:
|
||||
|
||||
- 4-node distributed cluster starts successfully
|
||||
- `/health/ready` becomes reachable on each node
|
||||
- logs no longer contain `storage_info failed: Io error: wrong msgpack marker FixArray(1)`
|
||||
- internode RPC authentication succeeds with an explicit non-default RPC secret
|
||||
|
||||
## Files
|
||||
|
||||
- `docker-compose.yml`: 4-node distributed cluster using a locally built image
|
||||
|
||||
## Data Directories
|
||||
|
||||
Create the bind-mount directories before `docker compose up`:
|
||||
|
||||
```bash
|
||||
mkdir -p .docker/test/issues-2815/data/rustfs{1..4}-disk{0..3}
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
Apple Silicon / arm64 host:
|
||||
|
||||
```bash
|
||||
docker build --platform linux/arm64 -f Dockerfile.source -t rustfs-issue-2815-local .
|
||||
```
|
||||
|
||||
If you intentionally want amd64 emulation:
|
||||
|
||||
```bash
|
||||
docker build --platform linux/amd64 -f Dockerfile.source -t rustfs-issue-2815-local .
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
docker compose -f .docker/test/issues-2815/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
If the image platform is not `linux/arm64`, align compose explicitly:
|
||||
|
||||
```bash
|
||||
RUSTFS_DOCKER_PLATFORM=linux/amd64 docker compose -f .docker/test/issues-2815/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
## Health Checks
|
||||
|
||||
Container-level healthcheck is now included and probes:
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:9000/health
|
||||
```
|
||||
|
||||
Manual checks:
|
||||
|
||||
```bash
|
||||
curl -i http://127.0.0.1:9101/health/ready
|
||||
curl -i http://127.0.0.1:9102/health/ready
|
||||
curl -i http://127.0.0.1:9103/health/ready
|
||||
curl -i http://127.0.0.1:9104/health/ready
|
||||
```
|
||||
|
||||
## RPC Secret Requirement
|
||||
|
||||
The current source build no longer reproduces the original `FixArray(1)` decode error from issue `#2815`.
|
||||
|
||||
Earlier local Docker attempts failed during erasure bootstrap with:
|
||||
|
||||
```text
|
||||
No valid auth token
|
||||
store init failed to load formats after 10 retries: erasure read quorum
|
||||
```
|
||||
|
||||
Root cause:
|
||||
|
||||
- RPC authentication rejects the default secret `rustfsadmin`
|
||||
- distributed local Docker validation therefore needs an explicit non-default secret
|
||||
|
||||
This compose now sets both:
|
||||
|
||||
- `RUSTFS_SECRET_KEY=issue-2815-secret`
|
||||
- `RUSTFS_RPC_SECRET=issue-2815-rpc-secret`
|
||||
|
||||
With those values in place, the current 4-node local Docker cluster reaches healthy state and `/health/ready` returns `200`.
|
||||
|
||||
In other words:
|
||||
|
||||
- `RUSTFS_ACCESS_KEY` may still be `rustfsadmin` for local service credentials if desired
|
||||
- `RUSTFS_SECRET_KEY` can still be used for service credentials
|
||||
- but RPC authentication must not resolve to the default secret value `rustfsadmin`
|
||||
- if `RUSTFS_RPC_SECRET` is unset, the code falls back to `RUSTFS_SECRET_KEY`
|
||||
- so at least one of them must provide a non-default shared secret for internode RPC signing
|
||||
|
||||
## Suggested Debug Commands
|
||||
|
||||
```bash
|
||||
docker compose -f .docker/test/issues-2815/docker-compose.yml ps
|
||||
docker compose -f .docker/test/issues-2815/docker-compose.yml logs --no-color --tail=200
|
||||
docker compose -f .docker/test/issues-2815/docker-compose.yml down -v
|
||||
```
|
||||
@@ -0,0 +1,120 @@
|
||||
services:
|
||||
rustfs1:
|
||||
image: rustfs-issue-2815-local
|
||||
platform: ${RUSTFS_DOCKER_PLATFORM:-linux/arm64}
|
||||
hostname: rustfs1
|
||||
container_name: rustfs-issue-2815-rustfs1
|
||||
environment:
|
||||
RUSTFS_ADDRESS: "0.0.0.0:9000"
|
||||
RUSTFS_ACCESS_KEY: "rustfsadmin"
|
||||
RUSTFS_SECRET_KEY: "issue-2815-secret"
|
||||
RUSTFS_RPC_SECRET: "issue-2815-rpc-secret"
|
||||
RUSTFS_CONSOLE_ENABLE: "false"
|
||||
RUST_LOG: "info"
|
||||
RUSTFS_UNSAFE_BYPASS_DISK_CHECK: "true"
|
||||
RUSTFS_VOLUMES: "http://rustfs{1...4}:9000/data/rustfs{0...3}"
|
||||
volumes:
|
||||
- ./data/rustfs1-disk0:/data/rustfs0
|
||||
- ./data/rustfs1-disk1:/data/rustfs1
|
||||
- ./data/rustfs1-disk2:/data/rustfs2
|
||||
- ./data/rustfs1-disk3:/data/rustfs3
|
||||
networks: [rustfs-issue-2815-net]
|
||||
ports:
|
||||
- "9101:9000"
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-c", "curl -fsS http://127.0.0.1:9000/health || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 8
|
||||
start_period: 30s
|
||||
|
||||
rustfs2:
|
||||
image: rustfs-issue-2815-local
|
||||
platform: ${RUSTFS_DOCKER_PLATFORM:-linux/arm64}
|
||||
hostname: rustfs2
|
||||
container_name: rustfs-issue-2815-rustfs2
|
||||
environment:
|
||||
RUSTFS_ADDRESS: "0.0.0.0:9000"
|
||||
RUSTFS_ACCESS_KEY: "rustfsadmin"
|
||||
RUSTFS_SECRET_KEY: "issue-2815-secret"
|
||||
RUSTFS_RPC_SECRET: "issue-2815-rpc-secret"
|
||||
RUSTFS_CONSOLE_ENABLE: "false"
|
||||
RUST_LOG: "info"
|
||||
RUSTFS_UNSAFE_BYPASS_DISK_CHECK: "true"
|
||||
RUSTFS_VOLUMES: "http://rustfs{1...4}:9000/data/rustfs{0...3}"
|
||||
volumes:
|
||||
- ./data/rustfs2-disk0:/data/rustfs0
|
||||
- ./data/rustfs2-disk1:/data/rustfs1
|
||||
- ./data/rustfs2-disk2:/data/rustfs2
|
||||
- ./data/rustfs2-disk3:/data/rustfs3
|
||||
networks: [rustfs-issue-2815-net]
|
||||
ports:
|
||||
- "9102:9000"
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-c", "curl -fsS http://127.0.0.1:9000/health || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 8
|
||||
start_period: 30s
|
||||
|
||||
rustfs3:
|
||||
image: rustfs-issue-2815-local
|
||||
platform: ${RUSTFS_DOCKER_PLATFORM:-linux/arm64}
|
||||
hostname: rustfs3
|
||||
container_name: rustfs-issue-2815-rustfs3
|
||||
environment:
|
||||
RUSTFS_ADDRESS: "0.0.0.0:9000"
|
||||
RUSTFS_ACCESS_KEY: "rustfsadmin"
|
||||
RUSTFS_SECRET_KEY: "issue-2815-secret"
|
||||
RUSTFS_RPC_SECRET: "issue-2815-rpc-secret"
|
||||
RUSTFS_CONSOLE_ENABLE: "false"
|
||||
RUST_LOG: "info"
|
||||
RUSTFS_UNSAFE_BYPASS_DISK_CHECK: "true"
|
||||
RUSTFS_VOLUMES: "http://rustfs{1...4}:9000/data/rustfs{0...3}"
|
||||
volumes:
|
||||
- ./data/rustfs3-disk0:/data/rustfs0
|
||||
- ./data/rustfs3-disk1:/data/rustfs1
|
||||
- ./data/rustfs3-disk2:/data/rustfs2
|
||||
- ./data/rustfs3-disk3:/data/rustfs3
|
||||
networks: [rustfs-issue-2815-net]
|
||||
ports:
|
||||
- "9103:9000"
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-c", "curl -fsS http://127.0.0.1:9000/health || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 8
|
||||
start_period: 30s
|
||||
|
||||
rustfs4:
|
||||
image: rustfs-issue-2815-local
|
||||
platform: ${RUSTFS_DOCKER_PLATFORM:-linux/arm64}
|
||||
hostname: rustfs4
|
||||
container_name: rustfs-issue-2815-rustfs4
|
||||
environment:
|
||||
RUSTFS_ADDRESS: "0.0.0.0:9000"
|
||||
RUSTFS_ACCESS_KEY: "rustfsadmin"
|
||||
RUSTFS_SECRET_KEY: "issue-2815-secret"
|
||||
RUSTFS_RPC_SECRET: "issue-2815-rpc-secret"
|
||||
RUSTFS_CONSOLE_ENABLE: "false"
|
||||
RUST_LOG: "info"
|
||||
RUSTFS_UNSAFE_BYPASS_DISK_CHECK: "true"
|
||||
RUSTFS_VOLUMES: "http://rustfs{1...4}:9000/data/rustfs{0...3}"
|
||||
volumes:
|
||||
- ./data/rustfs4-disk0:/data/rustfs0
|
||||
- ./data/rustfs4-disk1:/data/rustfs1
|
||||
- ./data/rustfs4-disk2:/data/rustfs2
|
||||
- ./data/rustfs4-disk3:/data/rustfs3
|
||||
networks: [rustfs-issue-2815-net]
|
||||
ports:
|
||||
- "9104:9000"
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-c", "curl -fsS http://127.0.0.1:9000/health || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 8
|
||||
start_period: 30s
|
||||
|
||||
networks:
|
||||
rustfs-issue-2815-net:
|
||||
name: rustfs-issue-2815-net
|
||||
@@ -2,35 +2,34 @@
|
||||
Pull Request Template for RustFS
|
||||
-->
|
||||
|
||||
## Type of Change
|
||||
- [ ] New Feature
|
||||
- [ ] Bug Fix
|
||||
- [ ] Documentation
|
||||
- [ ] Performance Improvement
|
||||
- [ ] Test/CI
|
||||
- [ ] Refactor
|
||||
- [ ] Other:
|
||||
|
||||
## Related Issues
|
||||
<!-- List related Issue numbers, e.g. #123 -->
|
||||
<!--
|
||||
List related issues, e.g. Fixes #123.
|
||||
Use N/A when there is no related issue.
|
||||
-->
|
||||
|
||||
## Summary of Changes
|
||||
<!-- Briefly describe the main changes and motivation for this PR -->
|
||||
<!--
|
||||
Briefly explain what changed and why reviewers should accept it.
|
||||
Focus on behavior, compatibility, and review-relevant context.
|
||||
-->
|
||||
|
||||
## Checklist
|
||||
- [ ] I have read and followed the [CONTRIBUTING.md](CONTRIBUTING.md) guidelines
|
||||
- [ ] Passed `make pre-commit`
|
||||
- [ ] Added/updated necessary tests
|
||||
- [ ] Documentation updated (if needed)
|
||||
- [ ] CI/CD passed (if applicable)
|
||||
## Verification
|
||||
<!--
|
||||
List the commands or checks you ran, for example:
|
||||
- `make pre-commit`
|
||||
|
||||
Use N/A only when verification is not applicable.
|
||||
-->
|
||||
|
||||
## Impact
|
||||
- [ ] Breaking change (compatibility)
|
||||
- [ ] Requires doc/config/deployment update
|
||||
- [ ] Other impact:
|
||||
<!--
|
||||
Describe user-facing, compatibility, API, deployment, configuration, or
|
||||
documentation impact. Use N/A when there is no expected impact.
|
||||
-->
|
||||
|
||||
## Additional Notes
|
||||
<!-- Any extra information for reviewers -->
|
||||
<!-- Any extra information for reviewers, or N/A. -->
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -163,11 +163,10 @@ jobs:
|
||||
if [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]] || [[ "$version" == *"rc"* ]]; then
|
||||
build_type="prerelease"
|
||||
is_prerelease=true
|
||||
# TODO: Temporary change - currently allows alpha versions to also create latest tags
|
||||
# After the version is stable, you need to remove the following line and restore the original logic (latest is created only for stable versions)
|
||||
if [[ "$version" == *"alpha"* ]]; then
|
||||
# Current policy: create latest tags for stable releases and selected prereleases (alpha/beta).
|
||||
if [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]]; then
|
||||
create_latest=true
|
||||
echo "🧪 Building Docker image for prerelease: $version (temporarily allowing creation of latest tag)"
|
||||
echo "🧪 Building Docker image for prerelease: $version (creating latest tag)"
|
||||
else
|
||||
echo "🧪 Building Docker image for prerelease: $version"
|
||||
fi
|
||||
@@ -216,11 +215,10 @@ jobs:
|
||||
v*alpha*|v*beta*|v*rc*|*alpha*|*beta*|*rc*)
|
||||
build_type="prerelease"
|
||||
is_prerelease=true
|
||||
# TODO: Temporary change - currently allows alpha versions to also create latest tags
|
||||
# After the version is stable, you need to remove the if block below and restore the original logic.
|
||||
if [[ "$input_version" == *"alpha"* ]]; then
|
||||
# Current policy: create latest tags for stable releases and selected prereleases (alpha/beta).
|
||||
if [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]]; then
|
||||
create_latest=true
|
||||
echo "🧪 Building with prerelease version: $input_version (temporarily allowing creation of latest tag)"
|
||||
echo "🧪 Building with prerelease version: $input_version (creating latest tag)"
|
||||
else
|
||||
echo "🧪 Building with prerelease version: $input_version"
|
||||
fi
|
||||
@@ -351,9 +349,7 @@ jobs:
|
||||
|
||||
# Add channel tags for prereleases and latest for stable
|
||||
if [[ "$CREATE_LATEST" == "true" ]]; then
|
||||
# TODO: Temporary change - the current alpha version will also create the latest tag
|
||||
# After the version is stabilized, the logic here remains unchanged, but the upstream CREATE_LATEST setting needs to be restored.
|
||||
# Stable release (and temporary alpha versions)
|
||||
# Create latest tags for stable releases and selected prereleases when CREATE_LATEST=true.
|
||||
TAGS="$TAGS,${{ env.REGISTRY_DOCKERHUB }}:latest${VARIANT_SUFFIX},${{ env.REGISTRY_GHCR }}:latest${VARIANT_SUFFIX},${{ env.REGISTRY_QUAY }}:latest${VARIANT_SUFFIX}"
|
||||
elif [[ "$BUILD_TYPE" == "prerelease" ]]; then
|
||||
# Prerelease channel tags (alpha, beta, rc)
|
||||
@@ -450,10 +446,9 @@ jobs:
|
||||
"prerelease")
|
||||
echo "🧪 Prerelease Docker image has been built with ${VERSION} tags"
|
||||
echo "⚠️ This is a prerelease image - use with caution"
|
||||
# TODO: Temporary change - alpha versions currently create the latest tag
|
||||
# After the version is stable, you need to restore the following prompt information
|
||||
if [[ "$VERSION" == *"alpha"* ]] && [[ "$CREATE_LATEST" == "true" ]]; then
|
||||
echo "🏷️ Latest tag has been created for alpha version (temporary measures)"
|
||||
# Create latest tags for stable releases and selected prereleases when CREATE_LATEST=true.
|
||||
if [[ "$CREATE_LATEST" == "true" ]]; then
|
||||
echo "🏷️ Latest tag has been created for prerelease: $VERSION"
|
||||
else
|
||||
echo "🚫 Latest tag NOT created for prerelease"
|
||||
fi
|
||||
|
||||
@@ -18,41 +18,78 @@ on:
|
||||
workflow_run:
|
||||
workflows: [ "Build and Release" ]
|
||||
types: [ completed ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version to publish, e.g. 1.0.0-beta.1 or v1.0.0-beta.1"
|
||||
required: true
|
||||
default: "1.0.0-beta.1"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
new_version: ${{ github.event.workflow_run.head_branch }}
|
||||
|
||||
jobs:
|
||||
build-helm-package:
|
||||
runs-on: ubicloud-standard-2
|
||||
# Only run on successful builds triggered by tag pushes (version format: x.y.z or x.y.z-suffix)
|
||||
if: |
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
contains(github.event.workflow_run.head_branch, '.')
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
contains(github.event.workflow_run.head_branch, '.')
|
||||
)
|
||||
|
||||
outputs:
|
||||
raw_tag: ${{ steps.version.outputs.raw_tag }}
|
||||
app_version: ${{ steps.version.outputs.app_version }}
|
||||
chart_version: ${{ steps.version.outputs.chart_version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout helm chart repo
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Replace chart app version
|
||||
- name: Normalize release version
|
||||
id: version
|
||||
run: |
|
||||
set -e
|
||||
set -x
|
||||
old_version=$(grep "^appVersion:" helm/rustfs/Chart.yaml | awk '{print $2}')
|
||||
sed -i "s/$old_version/$new_version/g" helm/rustfs/Chart.yaml
|
||||
set -eux
|
||||
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
RAW="${{ github.event.inputs.version }}"
|
||||
else
|
||||
RAW="${{ github.event.workflow_run.head_branch }}"
|
||||
fi
|
||||
|
||||
case "$RAW" in
|
||||
refs/tags/*)
|
||||
RAW_TAG="${RAW#refs/tags/}"
|
||||
;;
|
||||
*)
|
||||
RAW_TAG="$RAW"
|
||||
;;
|
||||
esac
|
||||
|
||||
./scripts/helm_chart_version.sh "$RAW_TAG"
|
||||
|
||||
- name: Replace chart version and app version
|
||||
run: |
|
||||
set -eux
|
||||
sed -i -E 's/^version:.*/version: "${{ steps.version.outputs.chart_version }}"/' helm/rustfs/Chart.yaml
|
||||
sed -i -E 's/^appVersion:.*/appVersion: "${{ steps.version.outputs.app_version }}"/' helm/rustfs/Chart.yaml
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@v4.3.0
|
||||
|
||||
- name: Test Helm Chart Templates
|
||||
run: ./scripts/test_helm_templates.sh
|
||||
|
||||
- name: Package Helm Chart
|
||||
run: |
|
||||
set -eux
|
||||
cp helm/README.md helm/rustfs/
|
||||
package_version=$(echo $new_version | awk -F '-' '{print $2}' | awk -F '.' '{print $NF}')
|
||||
helm package ./helm/rustfs --destination helm/rustfs/ --version "0.0.$package_version"
|
||||
helm package ./helm/rustfs \
|
||||
--destination helm/rustfs/ \
|
||||
--version "${{ steps.version.outputs.chart_version }}"
|
||||
|
||||
- name: Upload helm package as artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -64,6 +101,7 @@ jobs:
|
||||
publish-helm-package:
|
||||
runs-on: ubicloud-standard-2
|
||||
needs: [ build-helm-package ]
|
||||
if: needs.build-helm-package.result == 'success'
|
||||
|
||||
steps:
|
||||
- name: Checkout helm package repo
|
||||
@@ -86,9 +124,9 @@ jobs:
|
||||
|
||||
- name: Push helm package and index file
|
||||
run: |
|
||||
set -eux
|
||||
git config --global user.name "${{ secrets.USERNAME }}"
|
||||
git config --global user.email "${{ secrets.EMAIL_ADDRESS }}"
|
||||
git status .
|
||||
git add .
|
||||
git commit -m "Update rustfs helm package with $new_version."
|
||||
git commit -m "Update rustfs helm package with ${{ needs.build-helm-package.outputs.app_version }}." || echo "No changes to commit"
|
||||
git push origin main
|
||||
|
||||
+7
-7
@@ -326,10 +326,10 @@ The binary (`main.rs`) boots in this order:
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
│ │ │
|
||||
┌────▼────┐ ┌────▼────┐ ┌─────▼─────┐
|
||||
│ server │ │ admin │ │ app │
|
||||
│ (HTTP) │ │(console)│ │(use-cases) │
|
||||
└────┬────┘ └────┬────┘ └─────┬─────┘
|
||||
┌────▼────┐ ┌────▼────┐ ┌──────▼─────┐
|
||||
│ server │ │ admin │ │ app │
|
||||
│ (HTTP) │ │(console)│ │(use-cases) │
|
||||
└────┬────┘ └────┬────┘ └──────┬─────┘
|
||||
│ │ │
|
||||
└───────────────┼───────────────┘
|
||||
│
|
||||
@@ -341,13 +341,13 @@ The binary (`main.rs`) boots in this order:
|
||||
│
|
||||
┌──────────────────┼──────────────────┐
|
||||
│ │ │
|
||||
┌─────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐
|
||||
┌─────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
|
||||
│ ecstore │ │ rio │ │ io-core │
|
||||
│ (87K,core) │ │ (readers) │ │ (zero-copy) │
|
||||
└─────┬──────┘ └─────────────┘ └─────────────┘
|
||||
│
|
||||
┌─────┬──┼──┬─────┬──────┐
|
||||
│ │ │ │ │ │
|
||||
┌─────┬──┼──┬─────┬──────┐
|
||||
│ │ │ │ │ │
|
||||
common utils config policy filemeta ...
|
||||
```
|
||||
|
||||
|
||||
Generated
+153
-213
@@ -256,9 +256,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "arrow"
|
||||
version = "58.1.0"
|
||||
version = "58.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d441fdda254b65f3e9025910eb2c2066b6295d9c8ed409522b8d2ace1ff8574c"
|
||||
checksum = "607e64bb911ee4f90483e044fe78f175989148c2892e659a2cd25429e782ec54"
|
||||
dependencies = [
|
||||
"arrow-arith",
|
||||
"arrow-array",
|
||||
@@ -277,9 +277,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-arith"
|
||||
version = "58.1.0"
|
||||
version = "58.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ced5406f8b720cc0bc3aa9cf5758f93e8593cda5490677aa194e4b4b383f9a59"
|
||||
checksum = "e754319ed8a85d817fe7adf183227e0b5308b82790a737b426c1124626b48118"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -291,9 +291,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-array"
|
||||
version = "58.1.0"
|
||||
version = "58.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "772bd34cacdda8baec9418d80d23d0fb4d50ef0735685bd45158b83dfeb6e62d"
|
||||
checksum = "841321891f247aa86c6112c80d83d89cb36e0addd020fa2425085b8eb6c3f579"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"arrow-buffer",
|
||||
@@ -302,7 +302,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"half",
|
||||
"hashbrown 0.16.1",
|
||||
"hashbrown 0.17.0",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
@@ -310,9 +310,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-buffer"
|
||||
version = "58.1.0"
|
||||
version = "58.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "898f4cf1e9598fdb77f356fdf2134feedfd0ee8d5a4e0a5f573e7d0aec16baa4"
|
||||
checksum = "f955dfb73fae000425f49c8226d2044dab60fb7ad4af1e24f961756354d996c9"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"half",
|
||||
@@ -322,9 +322,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-cast"
|
||||
version = "58.1.0"
|
||||
version = "58.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0127816c96533d20fc938729f48c52d3e48f99717e7a0b5ade77d742510736d"
|
||||
checksum = "ca5e686972523798f76bef355145bc1ae25a84c731e650268d31ab763c701663"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -344,9 +344,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-csv"
|
||||
version = "58.1.0"
|
||||
version = "58.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca025bd0f38eeecb57c2153c0123b960494138e6a957bbda10da2b25415209fe"
|
||||
checksum = "86c276756867fc8186ec380c72c290e6e3b23a1d4fb05df6b1d62d2e62666d48"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-cast",
|
||||
@@ -359,9 +359,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-data"
|
||||
version = "58.1.0"
|
||||
version = "58.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42d10beeab2b1c3bb0b53a00f7c944a178b622173a5c7bcabc3cb45d90238df4"
|
||||
checksum = "db3b5846209775b6dc8056d77ff9a032b27043383dd5488abd0b663e265b9373"
|
||||
dependencies = [
|
||||
"arrow-buffer",
|
||||
"arrow-schema",
|
||||
@@ -372,9 +372,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-ipc"
|
||||
version = "58.1.0"
|
||||
version = "58.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "609a441080e338147a84e8e6904b6da482cefb957c5cdc0f3398872f69a315d0"
|
||||
checksum = "fd8907ddd8f9fbabf91ec2c85c1d81fe2874e336d2443eb36373595e28b98dd5"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -388,15 +388,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-json"
|
||||
version = "58.1.0"
|
||||
version = "58.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ead0914e4861a531be48fe05858265cf854a4880b9ed12618b1d08cba9bebc8"
|
||||
checksum = "f4518c59acc501f10d7dcae397fe12b8db3d81bc7de94456f8a58f9165d6f502"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
"arrow-cast",
|
||||
"arrow-data",
|
||||
"arrow-ord",
|
||||
"arrow-schema",
|
||||
"arrow-select",
|
||||
"chrono",
|
||||
"half",
|
||||
"indexmap 2.14.0",
|
||||
@@ -412,9 +413,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-ord"
|
||||
version = "58.1.0"
|
||||
version = "58.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "763a7ba279b20b52dad300e68cfc37c17efa65e68623169076855b3a9e941ca5"
|
||||
checksum = "efa70d9d6b1356f1fb9f1f651b84a725b7e0abb93f188cf7d31f14abfa2f2e6f"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -425,9 +426,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-row"
|
||||
version = "58.1.0"
|
||||
version = "58.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e14fe367802f16d7668163ff647830258e6e0aeea9a4d79aaedf273af3bdcd3e"
|
||||
checksum = "faec88a945338192beffbbd4be0def70135422930caa244ac3cec0cd213b26b4"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -438,9 +439,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-schema"
|
||||
version = "58.1.0"
|
||||
version = "58.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c30a1365d7a7dc50cc847e54154e6af49e4c4b0fddc9f607b687f29212082743"
|
||||
checksum = "18aa020f6bc8e5201dcd2d4b7f98c68f8a410ef37128263243e6ff2a47a67d4f"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
@@ -448,9 +449,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-select"
|
||||
version = "58.1.0"
|
||||
version = "58.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78694888660a9e8ac949853db393af2a8b8fc82c19ce333132dfa2e72cc1a7fe"
|
||||
checksum = "a657ab5132e9c8ca3b24eb15a823d0ced38017fe3930ff50167466b02e2d592c"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"arrow-array",
|
||||
@@ -462,9 +463,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-string"
|
||||
version = "58.1.0"
|
||||
version = "58.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61e04a01f8bb73ce54437514c5fd3ee2aa3e8abe4c777ee5cc55853b1652f79e"
|
||||
checksum = "f6de2efbbd1a9f9780ceb8d1ff5d20421b35863b361e3386b4f571f1fc69fcb8"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -627,9 +628,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-tungstenite"
|
||||
version = "0.34.0"
|
||||
version = "0.34.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb26fbd2a93308b1c1b74ac4e494a11ac10db57c476882240573bdf961463520"
|
||||
checksum = "8447f02eaa65412035e2d3eeaa3fc82bbb8d7137c84c5976b4af685136012ee9"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"futures-core",
|
||||
@@ -1279,7 +1280,7 @@ version = "0.11.0-rc.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690"
|
||||
dependencies = [
|
||||
"digest 0.11.2",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1406,9 +1407,9 @@ checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3"
|
||||
|
||||
[[package]]
|
||||
name = "bytestring"
|
||||
version = "1.5.0"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289"
|
||||
checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
]
|
||||
@@ -1828,9 +1829,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cpubits"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ef0c543070d296ea414df2dd7625d1b24866ce206709d8a4a424f28377f5861"
|
||||
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
@@ -2019,9 +2020,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.6"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
@@ -3084,9 +3085,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dial9-macro"
|
||||
version = "0.3.5"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e920f265b9cf1170b472f1ee1165ec80554b306e1e7aee36ce99913585159e1"
|
||||
checksum = "03f80ae5390c164835234fa3a74a4518b301958d9ca281b360227b19cc915058"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3095,9 +3096,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dial9-tokio-telemetry"
|
||||
version = "0.3.5"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d401f6c67fdd6e05bed120acf7f59960e5b7a3ada0768897f7847f2fb4304b0"
|
||||
checksum = "9a0780962019500d5ebabf6d75d0becd3494833a3a6c44d5c68697d80401b1d7"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"bon",
|
||||
@@ -3122,9 +3123,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dial9-trace-format"
|
||||
version = "0.3.5"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ebf6e8b29800b2e9aab3012cc042a1fefffb3a2a0093ae7a027e03e332c0323"
|
||||
checksum = "f47f47cc9b5b010283f6d040b003cdd544cd7de65a919e0895853c4ab6e72434"
|
||||
dependencies = [
|
||||
"dial9-trace-format-derive",
|
||||
"serde",
|
||||
@@ -3155,15 +3156,15 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"const-oid 0.9.6",
|
||||
"crypto-common 0.1.6",
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.2"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.0",
|
||||
"const-oid 0.10.2",
|
||||
@@ -3190,7 +3191,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3469,7 +3470,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3758,9 +3759,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.9"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
@@ -4099,9 +4100,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -4281,7 +4282,7 @@ version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||
dependencies = [
|
||||
"digest 0.11.2",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4667,7 +4668,7 @@ dependencies = [
|
||||
"log",
|
||||
"num-format",
|
||||
"once_cell",
|
||||
"quick-xml 0.39.2",
|
||||
"quick-xml 0.39.3",
|
||||
"rgb",
|
||||
"str_stack",
|
||||
]
|
||||
@@ -4740,7 +4741,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4817,7 +4818,7 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde_core",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4907,9 +4908,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.95"
|
||||
version = "0.3.97"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
|
||||
checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -5150,7 +5151,7 @@ dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"libc",
|
||||
"plain",
|
||||
"redox_syscall 0.7.4",
|
||||
"redox_syscall 0.7.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5370,7 +5371,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest 0.11.2",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5414,19 +5415,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "metrics"
|
||||
version = "0.24.3"
|
||||
version = "0.24.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d5312e9ba3771cfa961b585728215e3d972c950a3eed9252aa093d6301277e8"
|
||||
checksum = "ff56c2e7dce6bd462e3b8919986a617027481b1dcc703175b58cf9dd98a2f071"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"portable-atomic",
|
||||
"rapidhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "metrics-util"
|
||||
version = "0.20.1"
|
||||
version = "0.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdfb1365fea27e6dd9dc1dbc19f570198bc86914533ad639dae939635f096be4"
|
||||
checksum = "9e56997f084e57b045edf17c3ed8ba7f9f779c670df8206dfd1c736f4c02dc4a"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"crossbeam-epoch",
|
||||
@@ -5439,6 +5440,7 @@ dependencies = [
|
||||
"radix_trie",
|
||||
"rand 0.9.4",
|
||||
"rand_xoshiro",
|
||||
"rapidhash",
|
||||
"sketches-ddsketch",
|
||||
]
|
||||
|
||||
@@ -5621,9 +5623,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mqttbytes-core-next"
|
||||
version = "0.30.0"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78a866db83825732641da480c56d8a01ea7f853a639c7cbf5cc82a265ef0ba34"
|
||||
checksum = "8bc8e9c3495ed805d2049920a74944a79533868159b0bcfdf585b961a155e46c"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"thiserror 2.0.18",
|
||||
@@ -5751,9 +5753,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "no_std_io2"
|
||||
version = "0.9.3"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b51ed7824b6e07d354605f4abb3d9d300350701299da96642ee084f5ce631550"
|
||||
checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -5792,7 +5794,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6342,9 +6344,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "parquet"
|
||||
version = "58.1.0"
|
||||
version = "58.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d3f9f2205199603564127932b89695f52b62322f541d0fc7179d57c2e1c9877"
|
||||
checksum = "43d7efd3052f7d6ef601085559a246bc991e9a8cc77e02753737df6322ce35f1"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"arrow-array",
|
||||
@@ -6360,7 +6362,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"futures",
|
||||
"half",
|
||||
"hashbrown 0.16.1",
|
||||
"hashbrown 0.17.0",
|
||||
"lz4_flex",
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
@@ -6422,7 +6424,7 @@ version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
|
||||
dependencies = [
|
||||
"digest 0.11.2",
|
||||
"digest 0.11.3",
|
||||
"hmac 0.13.0",
|
||||
]
|
||||
|
||||
@@ -6526,18 +6528,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.11"
|
||||
version = "1.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
|
||||
checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.11"
|
||||
version = "1.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
|
||||
checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -7117,9 +7119,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.2"
|
||||
version = "0.39.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
|
||||
checksum = "721da970c312655cde9b4ffe0547f20a8494866a4af5ff51f18b7c633d0c870b"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"memchr",
|
||||
@@ -7180,7 +7182,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7300,6 +7302,15 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rapidhash"
|
||||
version = "4.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratelimit"
|
||||
version = "0.10.1"
|
||||
@@ -7400,9 +7411,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.7.4"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
|
||||
checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
@@ -7696,20 +7707,21 @@ dependencies = [
|
||||
"const-oid 0.10.2",
|
||||
"crypto-bigint 0.7.3",
|
||||
"crypto-primes",
|
||||
"digest 0.11.2",
|
||||
"digest 0.11.3",
|
||||
"pkcs1 0.8.0-rc.4",
|
||||
"pkcs8 0.11.0",
|
||||
"rand_core 0.10.1",
|
||||
"signature 3.0.0-rc.10",
|
||||
"sha2 0.11.0",
|
||||
"signature 3.0.0",
|
||||
"spki 0.8.0",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rumqttc-core-next"
|
||||
version = "0.30.0"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce991585c5d7af7690647aa87820543b67be40ed88f79addcf36e86a45f911e9"
|
||||
checksum = "d8ba2cd7b3c1fece002d43a8a6f6d0374c2399c21e5c0ea54bf7f9e3e88c040a"
|
||||
dependencies = [
|
||||
"async-tungstenite",
|
||||
"futures-io",
|
||||
@@ -7725,18 +7737,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rumqttc-next"
|
||||
version = "0.30.0"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0413b02f3961d1059081360990fa5544a88f28884a6fb297ba3eea36a361bfc2"
|
||||
checksum = "a8215de28413982ffd4c7a2beface3750426aaa4895016e3556a3279a762f9a7"
|
||||
dependencies = [
|
||||
"rumqttc-v5-next",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rumqttc-v5-next"
|
||||
version = "0.30.0"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76c0d2a9ac828bcc8ad70d45bed14170d883523cb972cb4064050491f4bd2fe1"
|
||||
checksum = "3c337cef1596844047b3e85db4fc3d0a5d6ab00f891e83abf32d0765b74ddd98"
|
||||
dependencies = [
|
||||
"async-tungstenite",
|
||||
"bytes",
|
||||
@@ -7861,6 +7873,7 @@ dependencies = [
|
||||
"rand 0.10.1",
|
||||
"reqwest 0.13.3",
|
||||
"rmp-serde",
|
||||
"rsa 0.10.0-rc.18",
|
||||
"rust-embed",
|
||||
"rustfs-appauth",
|
||||
"rustfs-audit",
|
||||
@@ -8090,7 +8103,7 @@ dependencies = [
|
||||
"parking_lot 0.12.5",
|
||||
"path-absolutize",
|
||||
"pin-project-lite",
|
||||
"quick-xml 0.39.2",
|
||||
"quick-xml 0.39.3",
|
||||
"rand 0.10.1",
|
||||
"ratelimit",
|
||||
"reed-solomon-erasure",
|
||||
@@ -8146,6 +8159,7 @@ dependencies = [
|
||||
name = "rustfs-filemeta"
|
||||
version = "1.0.0-beta.1"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"crc-fast",
|
||||
@@ -8398,7 +8412,7 @@ dependencies = [
|
||||
"form_urlencoded",
|
||||
"futures",
|
||||
"hashbrown 0.17.0",
|
||||
"quick-xml 0.39.2",
|
||||
"quick-xml 0.39.3",
|
||||
"rayon",
|
||||
"rustc-hash",
|
||||
"rustfs-config",
|
||||
@@ -8533,7 +8547,7 @@ dependencies = [
|
||||
"libunftp",
|
||||
"md5",
|
||||
"percent-encoding",
|
||||
"quick-xml 0.39.2",
|
||||
"quick-xml 0.39.3",
|
||||
"regex",
|
||||
"rustfs-config",
|
||||
"rustfs-credentials",
|
||||
@@ -8890,14 +8904,14 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.39"
|
||||
version = "0.23.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
@@ -8949,7 +8963,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9270,9 +9284,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.18.0"
|
||||
version = "3.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f"
|
||||
checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -9289,9 +9303,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "3.18.0"
|
||||
version = "3.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65"
|
||||
checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334"
|
||||
dependencies = [
|
||||
"darling 0.23.0",
|
||||
"proc-macro2",
|
||||
@@ -9301,9 +9315,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serdect"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9af4a3e75ebd5599b30d4de5768e00b5095d518a79fefc3ecbaf77e665d1ec06"
|
||||
checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e"
|
||||
dependencies = [
|
||||
"base16ct 1.0.0",
|
||||
"serde",
|
||||
@@ -9354,7 +9368,7 @@ checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"digest 0.11.2",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9376,7 +9390,7 @@ checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"digest 0.11.2",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9460,11 +9474,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "3.0.0-rc.10"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f1880df446116126965eeec169136b2e0251dba37c6223bcc819569550edea3"
|
||||
checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
|
||||
dependencies = [
|
||||
"digest 0.11.2",
|
||||
"digest 0.11.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
@@ -9504,9 +9518,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
|
||||
[[package]]
|
||||
name = "sketches-ddsketch"
|
||||
@@ -9966,7 +9980,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10173,9 +10187,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.1"
|
||||
version = "1.52.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
|
||||
checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"io-uring",
|
||||
@@ -10766,9 +10780,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.118"
|
||||
version = "0.2.120"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
|
||||
checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -10779,9 +10793,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.68"
|
||||
version = "0.4.70"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8"
|
||||
checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -10789,9 +10803,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.118"
|
||||
version = "0.2.120"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
|
||||
checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -10799,9 +10813,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.118"
|
||||
version = "0.2.120"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
|
||||
checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -10812,9 +10826,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.118"
|
||||
version = "0.2.120"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
|
||||
checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -10868,9 +10882,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.95"
|
||||
version = "0.3.97"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d"
|
||||
checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -10956,7 +10970,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11083,7 +11097,7 @@ version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11092,16 +11106,7 @@ version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.5",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11119,31 +11124,14 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm 0.53.1",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11161,96 +11149,48 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
|
||||
+6
-6
@@ -134,7 +134,7 @@ http-body-util = "0.1.3"
|
||||
reqwest = { version = "0.13.3", default-features = false, features = ["rustls", "charset", "http2", "system-proxy", "stream", "json", "blocking", "query", "form"] }
|
||||
rustfs-kafka-async = { version = "1.2.0" }
|
||||
socket2 = { version = "0.6.3", features = ["all"] }
|
||||
tokio = { version = "1.52.1", features = ["fs", "rt-multi-thread"] }
|
||||
tokio = { version = "1.52.2", features = ["fs", "rt-multi-thread"] }
|
||||
tokio-rustls = { version = "0.26.4", default-features = false, features = ["logging", "tls12", "aws-lc-rs"] }
|
||||
tokio-stream = { version = "0.1.18" }
|
||||
tokio-test = "0.4.5"
|
||||
@@ -152,7 +152,7 @@ byteorder = "1.5.0"
|
||||
flatbuffers = "25.12.19"
|
||||
form_urlencoded = "1.2.2"
|
||||
prost = "0.14.3"
|
||||
quick-xml = "0.39.2"
|
||||
quick-xml = "0.39.3"
|
||||
rmp = { version = "0.8.15" }
|
||||
rmp-serde = { version = "1.3.1" }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
@@ -170,7 +170,7 @@ jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"] }
|
||||
openidconnect = { version = "4.0", default-features = false }
|
||||
pbkdf2 = "0.13.0"
|
||||
rsa = { version = "0.10.0-rc.18" }
|
||||
rustls = { version = "0.23.39", default-features = false, features = ["aws-lc-rs", "logging", "tls12", "prefer-post-quantum", "std"] }
|
||||
rustls = { version = "0.23.40", default-features = false, features = ["aws-lc-rs", "logging", "tls12", "prefer-post-quantum", "std"] }
|
||||
rustls-pki-types = "1.14.1"
|
||||
sha1 = "0.11.0"
|
||||
sha2 = "0.11.0"
|
||||
@@ -247,14 +247,14 @@ rayon = "1.12.0"
|
||||
reed-solomon-erasure = { version = "6.0", default-features = false, features = ["std", "simd-accel"] }
|
||||
reed-solomon-simd = "3.1.0"
|
||||
regex = { version = "1.12.3" }
|
||||
rumqttc = { package = "rumqttc-next", version = "0.30.0", features = ["websocket"] }
|
||||
rumqttc = { package = "rumqttc-next", version = "0.30.1", features = ["websocket"] }
|
||||
rustix = { version = "1.1.4", features = ["fs"] }
|
||||
rust-embed = { version = "8.11.0" }
|
||||
rustc-hash = { version = "2.1.2" }
|
||||
s3s = { git = "https://github.com/rustfs/s3s", rev = "a3b16608df35aaeed8fff08b4988d03f4ca9445b", features = ["minio"] }
|
||||
serial_test = "3.4.0"
|
||||
shadow-rs = { version = "2.0.0", default-features = false }
|
||||
siphasher = "1.0.2"
|
||||
siphasher = "1.0.3"
|
||||
smallvec = { version = "1.15.1", features = ["serde"] }
|
||||
smartstring = "1.0.1"
|
||||
snafu = "0.9.0"
|
||||
@@ -284,7 +284,7 @@ zip = "8.6.0"
|
||||
zstd = "0.13.3"
|
||||
|
||||
# Observability and Metrics
|
||||
metrics = "0.24.3"
|
||||
metrics = "0.24.5"
|
||||
dial9-tokio-telemetry = "0.3"
|
||||
opentelemetry = { version = "0.31.0" }
|
||||
opentelemetry-appender-tracing = { version = "0.31.1", features = ["experimental_use_tracing_span_context", "experimental_metadata_attributes", "spec_unstable_logs_enabled"] }
|
||||
|
||||
+13
-2
@@ -29,8 +29,20 @@ RUN set -eux; \
|
||||
if [ "$RELEASE" = "latest" ]; then \
|
||||
TAG="$(curl -fsSL https://api.github.com/repos/rustfs/rustfs/releases \
|
||||
| grep -o '"tag_name": "[^"]*"' | cut -d'"' -f4 | head -n 1)"; \
|
||||
RELEASE_JSON="$(curl -fsSL "https://api.github.com/repos/rustfs/rustfs/releases/tags/$TAG")"; \
|
||||
else \
|
||||
TAG="$RELEASE"; \
|
||||
RELEASE_JSON="$(curl -fsSL "https://api.github.com/repos/rustfs/rustfs/releases/tags/$TAG" 2>/dev/null || true)"; \
|
||||
if [ -z "$RELEASE_JSON" ]; then \
|
||||
if [ "${TAG#v}" = "$TAG" ]; then \
|
||||
ALT_TAG="v$TAG"; \
|
||||
else \
|
||||
ALT_TAG="${TAG#v}"; \
|
||||
fi; \
|
||||
echo "Primary tag lookup failed, retrying with alternate tag: $ALT_TAG"; \
|
||||
RELEASE_JSON="$(curl -fsSL "https://api.github.com/repos/rustfs/rustfs/releases/tags/$ALT_TAG")"; \
|
||||
TAG="$ALT_TAG"; \
|
||||
fi; \
|
||||
fi; \
|
||||
echo "Using tag: $TAG (arch pattern: $ARCH_SUBSTR)"; \
|
||||
# Find download URL in assets list for this tag that contains arch substring and ends with .zip
|
||||
@@ -87,8 +99,7 @@ RUN addgroup -g 10001 -S rustfs && \
|
||||
chown -R rustfs:rustfs /data /logs && \
|
||||
chmod 0750 /data /logs
|
||||
|
||||
ENV RUSTFS_CORS_ALLOWED_ORIGINS="*" \
|
||||
RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS="*" \
|
||||
ENV RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS="*" \
|
||||
RUSTFS_VOLUMES="/data" \
|
||||
RUSTFS_OBS_LOGGER_LEVEL=warn \
|
||||
RUSTFS_OBS_LOG_DIRECTORY=/logs \
|
||||
|
||||
+13
-3
@@ -31,14 +31,25 @@ RUN set -eux; \
|
||||
arm64) ARCH_SUBSTR="aarch64-gnu" ;; \
|
||||
*) echo "Unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
\
|
||||
if [ "$RELEASE" = "latest" ]; then \
|
||||
TAG="$(curl -fsSL https://api.github.com/repos/rustfs/rustfs/releases \
|
||||
| grep -o '"tag_name": "[^"]*"' | cut -d'"' -f4 | head -n 1)"; \
|
||||
RELEASE_JSON="$(curl -fsSL "https://api.github.com/repos/rustfs/rustfs/releases/tags/$TAG")"; \
|
||||
else \
|
||||
TAG="$RELEASE"; \
|
||||
RELEASE_JSON="$(curl -fsSL "https://api.github.com/repos/rustfs/rustfs/releases/tags/$TAG" 2>/dev/null || true)"; \
|
||||
if [ -z "$RELEASE_JSON" ]; then \
|
||||
if [ "${TAG#v}" = "$TAG" ]; then \
|
||||
ALT_TAG="v$TAG"; \
|
||||
else \
|
||||
ALT_TAG="${TAG#v}"; \
|
||||
fi; \
|
||||
echo "Primary tag lookup failed, retrying with alternate tag: $ALT_TAG"; \
|
||||
RELEASE_JSON="$(curl -fsSL "https://api.github.com/repos/rustfs/rustfs/releases/tags/$ALT_TAG")"; \
|
||||
TAG="$ALT_TAG"; \
|
||||
fi; \
|
||||
fi; \
|
||||
\
|
||||
echo "Using tag: $TAG (arch pattern: $ARCH_SUBSTR)"; \
|
||||
URL="$(curl -fsSL "https://api.github.com/repos/rustfs/rustfs/releases/tags/$TAG" \
|
||||
| grep -o "\"browser_download_url\": \"[^\"]*${ARCH_SUBSTR}[^\"]*\\.zip\"" \
|
||||
| cut -d'"' -f4 | head -n 1)"; \
|
||||
@@ -97,7 +108,6 @@ ENV RUSTFS_ADDRESS=":9000" \
|
||||
RUSTFS_ACCESS_KEY="rustfsadmin" \
|
||||
RUSTFS_SECRET_KEY="rustfsadmin" \
|
||||
RUSTFS_CONSOLE_ENABLE="true" \
|
||||
RUSTFS_CORS_ALLOWED_ORIGINS="*" \
|
||||
RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS="*" \
|
||||
RUSTFS_VOLUMES="/data" \
|
||||
RUSTFS_OBS_LOGGER_LEVEL=warn \
|
||||
|
||||
+12
-8
@@ -26,6 +26,7 @@
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
ARG BUILDPLATFORM
|
||||
ARG TARGETARCH
|
||||
|
||||
# -----------------------------
|
||||
# Build stage
|
||||
@@ -35,6 +36,7 @@ FROM rust:1.93-trixie AS builder
|
||||
# Re-declare args after FROM
|
||||
ARG TARGETPLATFORM
|
||||
ARG BUILDPLATFORM
|
||||
ARG TARGETARCH
|
||||
|
||||
# Debug: print platforms
|
||||
RUN echo "Build info -> BUILDPLATFORM=${BUILDPLATFORM}, TARGETPLATFORM=${TARGETPLATFORM}"
|
||||
@@ -87,6 +89,7 @@ ENV CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++
|
||||
ENV CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc
|
||||
ENV CC_x86_64_unknown_linux_gnu=x86_64-linux-gnu-gcc
|
||||
ENV CXX_x86_64_unknown_linux_gnu=x86_64-linux-gnu-g++
|
||||
ENV CARGO_TARGET_DIR=/usr/src/rustfs/target/docker-build
|
||||
|
||||
WORKDIR /usr/src/rustfs
|
||||
|
||||
@@ -120,33 +123,33 @@ ENV CARGO_NET_GIT_FETCH_WITH_CLI=true \
|
||||
# Generate protobuf/flatbuffers code (uses protoc/flatc from distro)
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/usr/src/rustfs/target \
|
||||
--mount=type=cache,target=/usr/src/rustfs/target/docker-build \
|
||||
cargo run --bin gproto
|
||||
|
||||
# Build RustFS (target depends on TARGETPLATFORM)
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/usr/src/rustfs/target \
|
||||
--mount=type=cache,target=/usr/src/rustfs/target/docker-build \
|
||||
set -eux; \
|
||||
rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; \
|
||||
target_platform="${TARGETPLATFORM:-}"; \
|
||||
if [ -z "${target_platform}" ]; then \
|
||||
case "$(uname -m)" in \
|
||||
x86_64) target_platform="linux/amd64" ;; \
|
||||
aarch64|arm64) target_platform="linux/arm64" ;; \
|
||||
*) target_platform="linux/amd64" ;; \
|
||||
case "${TARGETARCH:-$(uname -m)}" in \
|
||||
amd64|x86_64) target_platform="linux/amd64" ;; \
|
||||
arm64|aarch64) target_platform="linux/arm64" ;; \
|
||||
*) echo "Unsupported target architecture: ${TARGETARCH:-$(uname -m)}" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
fi; \
|
||||
case "${target_platform}" in \
|
||||
linux/amd64) \
|
||||
echo "Building for x86_64-unknown-linux-gnu"; \
|
||||
cargo build --release --locked --target x86_64-unknown-linux-gnu --bin rustfs -j "$(nproc)"; \
|
||||
install -m 0755 target/x86_64-unknown-linux-gnu/release/rustfs /usr/local/bin/rustfs \
|
||||
install -m 0755 "${CARGO_TARGET_DIR}/x86_64-unknown-linux-gnu/release/rustfs" /usr/local/bin/rustfs \
|
||||
;; \
|
||||
linux/arm64) \
|
||||
echo "Building for aarch64-unknown-linux-gnu"; \
|
||||
cargo build --release --locked --target aarch64-unknown-linux-gnu --bin rustfs -j "$(nproc)"; \
|
||||
install -m 0755 target/aarch64-unknown-linux-gnu/release/rustfs /usr/local/bin/rustfs \
|
||||
install -m 0755 "${CARGO_TARGET_DIR}/aarch64-unknown-linux-gnu/release/rustfs" /usr/local/bin/rustfs \
|
||||
;; \
|
||||
*) \
|
||||
echo "Unsupported target platform=${target_platform}" >&2; exit 1 \
|
||||
@@ -212,6 +215,7 @@ RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
tzdata \
|
||||
coreutils; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -136,6 +136,22 @@ Similarly, you can run the command with podman
|
||||
podman compose --profile observability up -d
|
||||
```
|
||||
|
||||
Webhook notification quick start (Docker):
|
||||
|
||||
```bash
|
||||
docker run -d --name rustfs -p 9000:9000 \
|
||||
-e RUSTFS_NOTIFY_ENABLE=true \
|
||||
-e RUSTFS_NOTIFY_WEBHOOK_ENABLE_PRIMARY=on \
|
||||
-e RUSTFS_NOTIFY_WEBHOOK_ENDPOINT_PRIMARY=http://<host-ip>:3020/webhook \
|
||||
-e RUSTFS_NOTIFY_WEBHOOK_QUEUE_DIR_PRIMARY=/tmp/rustfs-events \
|
||||
rustfs/rustfs:latest
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `RUSTFS_NOTIFY_ENABLE=true` enables the global notify module switch.
|
||||
- For ARN `arn:rustfs:sqs::primary:webhook`, use instance-scoped env vars with `_PRIMARY`.
|
||||
- If queue dir is omitted, default is `/opt/rustfs/events`; ensure it is writable by the container runtime user.
|
||||
|
||||
**NOTE**: We recommend reviewing the `docker-compose.yml` file before running. It defines several services including Grafana, Prometheus, and Jaeger, which are helpful for RustFS observability. If you wish to start Redis or Nginx containers, you can specify the corresponding profiles.
|
||||
|
||||
### 3\. Build from Source (Option 3) - Advanced Users
|
||||
|
||||
@@ -26,7 +26,7 @@ categories = ["web-programming", "development-tools", "authentication"]
|
||||
|
||||
[dependencies]
|
||||
base64-simd = { workspace = true }
|
||||
rsa = { workspace = true }
|
||||
rsa = { workspace = true, features = ["sha2"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
rand.workspace = true
|
||||
|
||||
+123
-36
@@ -15,9 +15,13 @@
|
||||
use rsa::{
|
||||
Pkcs1v15Encrypt, RsaPrivateKey, RsaPublicKey,
|
||||
pkcs8::{DecodePrivateKey, DecodePublicKey},
|
||||
pss::{BlindedSigningKey, Signature, VerifyingKey},
|
||||
sha2::Sha256,
|
||||
signature::{RandomizedSigner, Verifier},
|
||||
traits::PublicKeyParts,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{Error, Result};
|
||||
use std::io::{Error, ErrorKind, Result};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
|
||||
pub struct Token {
|
||||
@@ -25,10 +29,11 @@ pub struct Token {
|
||||
pub expired: u64, // Expiry time (UNIX timestamp)
|
||||
}
|
||||
|
||||
/// Public key generation Token
|
||||
/// [token] Token object
|
||||
/// [key] Public key string
|
||||
/// Returns the encrypted string processed by base64
|
||||
/// Legacy public-key encryption Token encoder.
|
||||
///
|
||||
/// Use `sign_license_token` for license issuance so verifiers only need a
|
||||
/// public key.
|
||||
#[deprecated(note = "use sign_license_token for signed license issuance")]
|
||||
pub fn gencode(token: &Token, key: &str) -> Result<String> {
|
||||
let data = serde_json::to_vec(token)?;
|
||||
let mut rng = rand::rng();
|
||||
@@ -37,35 +42,62 @@ pub fn gencode(token: &Token, key: &str) -> Result<String> {
|
||||
Ok(base64_simd::URL_SAFE_NO_PAD.encode_to_string(&encrypted_data))
|
||||
}
|
||||
|
||||
/// Private key resolution Token
|
||||
/// [token] Encrypted string processed by base64
|
||||
/// [key] Private key string
|
||||
/// Return to the Token object
|
||||
/// Legacy private-key Token decoder.
|
||||
///
|
||||
/// Use `parse_signed_license_token` or `parse_license_with_public_key` for
|
||||
/// license verification so runtime services never need private key material.
|
||||
#[deprecated(note = "use parse_signed_license_token or parse_license_with_public_key for signed license verification")]
|
||||
pub fn parse(token: &str, key: &str) -> Result<Token> {
|
||||
let encrypted_data = base64_simd::URL_SAFE_NO_PAD
|
||||
.decode_to_vec(token.as_bytes())
|
||||
.map_err(Error::other)?;
|
||||
let private_key = RsaPrivateKey::from_pkcs8_pem(key).map_err(Error::other)?;
|
||||
let decrypted_data = private_key.decrypt(Pkcs1v15Encrypt, &encrypted_data).map_err(Error::other)?;
|
||||
let res: Token = serde_json::from_slice(&decrypted_data)?;
|
||||
Ok(res)
|
||||
serde_json::from_slice(&decrypted_data).map_err(Error::other)
|
||||
}
|
||||
|
||||
pub fn parse_license(license: &str) -> Result<Token> {
|
||||
parse(license, TEST_PRIVATE_KEY)
|
||||
// match parse(license, TEST_PRIVATE_KEY) {
|
||||
// Ok(token) => {
|
||||
// if token.expired > SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() {
|
||||
// Ok(token)
|
||||
// } else {
|
||||
// Err("Token expired".into())
|
||||
// }
|
||||
// }
|
||||
// Err(e) => Err(e),
|
||||
// }
|
||||
/// Signs a license token with an RSA private key.
|
||||
///
|
||||
/// The returned token is base64url(signature || payload), where the signature is
|
||||
/// RSASSA-PSS over the JSON payload using SHA-256.
|
||||
pub fn sign_license_token(token: &Token, private_key_pem: &str) -> Result<String> {
|
||||
let payload = serde_json::to_vec(token)?;
|
||||
let mut rng = rand::rng();
|
||||
let private_key = RsaPrivateKey::from_pkcs8_pem(private_key_pem).map_err(Error::other)?;
|
||||
let signing_key = BlindedSigningKey::<Sha256>::new(private_key);
|
||||
let signature: Signature = signing_key.try_sign_with_rng(&mut rng, &payload).map_err(Error::other)?;
|
||||
let signature: Box<[u8]> = signature.into();
|
||||
|
||||
let mut signed_payload = Vec::with_capacity(signature.as_ref().len() + payload.len());
|
||||
signed_payload.extend_from_slice(signature.as_ref());
|
||||
signed_payload.extend_from_slice(&payload);
|
||||
|
||||
Ok(base64_simd::URL_SAFE_NO_PAD.encode_to_string(&signed_payload))
|
||||
}
|
||||
|
||||
static TEST_PRIVATE_KEY: &str = "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCj86SrJIuxSxR6\nBJ/dlJEUIj6NeBRnhLQlCDdovuz61+7kJXVcxaR66w4m8W7SLEUP+IlPtnn6vmiG\n7XMhGNHIr7r1JsEVVLhZmL3tKI66DEZl786ZhG81BWqUlmcooIPS8UEPZNqJXLuz\nVGhxNyVGbj/tV7QC2pSISnKaixc+nrhxvo7w56p5qrm9tik0PjTgfZsUePkoBsSN\npoRkAauS14MAzK6HGB75CzG3dZqXUNWSWVocoWtQbZUwFGXyzU01ammsHQDvc2xu\nK1RQpd1qYH5bOWZ0N0aPFwT0r59HztFXg9sbjsnuhO1A7OiUOkc6iGVuJ0wm/9nA\nwZIBqzgjAgMBAAECggEAPMpeSEbotPhNw2BrllE76ec4omPfzPJbiU+em+wPGoNu\nRJHPDnMKJbl6Kd5jZPKdOOrCnxfd6qcnQsBQa/kz7+GYxMV12l7ra+1Cnujm4v0i\nLTHZvPpp8ZLsjeOmpF3AAzsJEJgon74OqtOlVjVIUPEYKvzV9ijt4gsYq0zfdYv0\nhrTMzyrGM4/UvKLsFIBROAfCeWfA7sXLGH8JhrRAyDrtCPzGtyyAmzoHKHtHafcB\nuyPFw/IP8otAgpDk5iiQPNkH0WwzAQIm12oHuNUa66NwUK4WEjXTnDg8KeWLHHNv\nIfN8vdbZchMUpMIvvkr7is315d8f2cHCB5gEO+GWAQKBgQDR/0xNll+FYaiUKCPZ\nvkOCAd3l5mRhsqnjPQ/6Ul1lAyYWpoJSFMrGGn/WKTa/FVFJRTGbBjwP+Mx10bfb\ngUg2GILDTISUh54fp4zngvTi9w4MWGKXrb7I1jPkM3vbJfC/v2fraQ/r7qHPpO2L\nf6ZbGxasIlSvr37KeGoelwcAQQKBgQDH3hmOTS2Hl6D4EXdq5meHKrfeoicGN7m8\noQK7u8iwn1R9zK5nh6IXxBhKYNXNwdCQtBZVRvFjjZ56SZJb7lKqa1BcTsgJfZCy\nnI3Uu4UykrECAH8AVCVqBXUDJmeA2yE+gDAtYEjvhSDHpUfWxoGHr0B/Oqk2Lxc/\npRy1qV5fYwKBgBWSL/hYVf+RhIuTg/s9/BlCr9SJ0g3nGGRrRVTlWQqjRCpXeFOO\nJzYqSq9pFGKUggEQxoOyJEFPwVDo9gXqRcyov+Xn2kaXl7qQr3yoixc1YZALFDWY\nd1ySBEqQr0xXnV9U/gvEgwotPRnjSzNlLWV2ZuHPtPtG/7M0o1H5GZMBAoGAKr3N\nW0gX53o+my4pCnxRQW+aOIsWq1a5aqRIEFudFGBOUkS2Oz+fI1P1GdrRfhnnfzpz\n2DK+plp/vIkFOpGhrf4bBlJ2psjqa7fdANRFLMaAAfyXLDvScHTQTCcnVUAHQPVq\n2BlSH56pnugyj7SNuLV6pnql+wdhAmRN2m9o1h8CgYAbX2juSr4ioXwnYjOUdrIY\n4+ERvHcXdjoJmmPcAm4y5NbSqLXyU0FQmplNMt2A5LlniWVJ9KNdjAQUt60FZw/+\nr76LdxXaHNZghyx0BOs7mtq5unSQXamZ8KixasfhE9uz3ij1jXjG6hafWkS8/68I\nuWbaZqgvy7a9oPHYlKH7Jg==\n-----END PRIVATE KEY-----\n";
|
||||
/// Verifies and parses a signed license token with an RSA public key.
|
||||
pub fn parse_signed_license_token(token: &str, public_key_pem: &str) -> Result<Token> {
|
||||
let signed_payload = base64_simd::URL_SAFE_NO_PAD
|
||||
.decode_to_vec(token.as_bytes())
|
||||
.map_err(Error::other)?;
|
||||
let public_key = RsaPublicKey::from_public_key_pem(public_key_pem).map_err(Error::other)?;
|
||||
let signature_len = public_key.size();
|
||||
|
||||
if signed_payload.len() <= signature_len {
|
||||
return Err(Error::new(ErrorKind::InvalidData, "license token is missing signed payload"));
|
||||
}
|
||||
|
||||
let (signature, payload) = signed_payload.split_at(signature_len);
|
||||
let signature = Signature::try_from(signature).map_err(Error::other)?;
|
||||
let verifying_key = VerifyingKey::<Sha256>::new(public_key);
|
||||
verifying_key.verify(payload, &signature).map_err(Error::other)?;
|
||||
|
||||
serde_json::from_slice(payload).map_err(Error::other)
|
||||
}
|
||||
|
||||
pub fn parse_license_with_public_key(license: &str, public_key: &str) -> Result<Token> {
|
||||
parse_signed_license_token(license, public_key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -77,7 +109,7 @@ mod tests {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[test]
|
||||
fn test_gencode_and_parse() {
|
||||
fn test_sign_license_token_and_parse_signed_license_token() {
|
||||
let mut rng = rand::rng();
|
||||
let bits = 2048;
|
||||
let private_key = RsaPrivateKey::new(&mut rng, bits).expect("Failed to generate private key");
|
||||
@@ -91,8 +123,31 @@ mod tests {
|
||||
expired: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600, // 1 hour from now
|
||||
};
|
||||
|
||||
let encoded = gencode(&token, &public_key_pem).expect("Failed to encode token");
|
||||
let encoded = sign_license_token(&token, &private_key_pem).expect("Failed to encode token");
|
||||
|
||||
let decoded = parse_signed_license_token(&encoded, &public_key_pem).expect("Failed to decode token");
|
||||
|
||||
assert_eq!(token.name, decoded.name);
|
||||
assert_eq!(token.expired, decoded.expired);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(deprecated)]
|
||||
fn test_legacy_gencode_and_parse_roundtrip() {
|
||||
let mut rng = rand::rng();
|
||||
let bits = 2048;
|
||||
let private_key = RsaPrivateKey::new(&mut rng, bits).expect("Failed to generate private key");
|
||||
let public_key = RsaPublicKey::from(&private_key);
|
||||
|
||||
let private_key_pem = private_key.to_pkcs8_pem(LineEnding::LF).unwrap();
|
||||
let public_key_pem = public_key.to_public_key_pem(LineEnding::LF).unwrap();
|
||||
|
||||
let token = Token {
|
||||
name: "test_app".to_string(),
|
||||
expired: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600,
|
||||
};
|
||||
|
||||
let encoded = gencode(&token, &public_key_pem).expect("Failed to encode token");
|
||||
let decoded = parse(&encoded, &private_key_pem).expect("Failed to decode token");
|
||||
|
||||
assert_eq!(token.name, decoded.name);
|
||||
@@ -100,28 +155,60 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_token() {
|
||||
fn test_parse_signed_license_token_rejects_tampered_payload() {
|
||||
let mut rng = rand::rng();
|
||||
let private_key_pem = RsaPrivateKey::new(&mut rng, 2048)
|
||||
.expect("Failed to generate private key")
|
||||
.to_pkcs8_pem(LineEnding::LF)
|
||||
.unwrap();
|
||||
let private_key = RsaPrivateKey::new(&mut rng, 2048).expect("Failed to generate private key");
|
||||
let public_key = RsaPublicKey::from(&private_key);
|
||||
let private_key_pem = private_key.to_pkcs8_pem(LineEnding::LF).unwrap();
|
||||
let public_key_pem = public_key.to_public_key_pem(LineEnding::LF).unwrap();
|
||||
let token = Token {
|
||||
name: "test_app".to_string(),
|
||||
expired: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600,
|
||||
};
|
||||
|
||||
let invalid_token = "invalid_base64_token";
|
||||
let result = parse(invalid_token, &private_key_pem);
|
||||
let encoded = sign_license_token(&token, &private_key_pem).expect("Failed to encode token");
|
||||
let mut signed_payload = base64_simd::URL_SAFE_NO_PAD
|
||||
.decode_to_vec(encoded.as_bytes())
|
||||
.expect("Failed to decode signed payload");
|
||||
let last_byte = signed_payload.last_mut().expect("Signed payload should not be empty");
|
||||
*last_byte ^= 0x01;
|
||||
let tampered = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&signed_payload);
|
||||
|
||||
let result = parse_signed_license_token(&tampered, &public_key_pem);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gencode_with_invalid_key() {
|
||||
fn test_source_does_not_embed_private_key() {
|
||||
let source = include_str!("token.rs");
|
||||
let forbidden = ["BEGIN", "PRIVATE KEY"].join(" ");
|
||||
|
||||
assert!(!source.contains(&forbidden));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_signed_license_token_rejects_invalid_token() {
|
||||
let mut rng = rand::rng();
|
||||
let private_key = RsaPrivateKey::new(&mut rng, 2048).expect("Failed to generate private key");
|
||||
let public_key = RsaPublicKey::from(&private_key);
|
||||
let public_key_pem = public_key.to_public_key_pem(LineEnding::LF).unwrap();
|
||||
|
||||
let invalid_token = "invalid_base64_token";
|
||||
let result = parse_signed_license_token(invalid_token, &public_key_pem);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign_license_token_with_invalid_signing_key() {
|
||||
let token = Token {
|
||||
name: "test_app".to_string(),
|
||||
expired: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600, // 1 hour from now
|
||||
};
|
||||
|
||||
let invalid_key = "invalid_public_key";
|
||||
let result = gencode(&token, invalid_key);
|
||||
let invalid_key = "invalid_private_key";
|
||||
let result = sign_license_token(&token, invalid_key);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ Examples:
|
||||
- `RUSTFS_ADDRESS`
|
||||
- `RUSTFS_VOLUMES`
|
||||
- `RUSTFS_LICENSE`
|
||||
- `RUSTFS_LICENSE_PUBLIC_KEY`
|
||||
|
||||
Current guidance:
|
||||
- Prefer module-specific names only when they are not top-level product configuration.
|
||||
@@ -51,6 +52,16 @@ Current guidance:
|
||||
- `RUSTFS_ENABLE_HEAL` -> `RUSTFS_HEAL_ENABLED`
|
||||
- `RUSTFS_DATA_SCANNER_START_DELAY_SECS` -> `RUSTFS_SCANNER_START_DELAY_SECS`
|
||||
|
||||
## License environment variables
|
||||
|
||||
- `RUSTFS_LICENSE` contains the signed license token.
|
||||
- `RUSTFS_LICENSE_PUBLIC_KEY` contains the RSA public key used to verify signed license tokens.
|
||||
|
||||
## CORS environment variables
|
||||
|
||||
- `RUSTFS_CORS_ALLOWED_ORIGINS` defaults to empty, so the S3 endpoint emits no generic CORS headers unless configured. Set `*` for wildcard origins without credentials, or a comma-separated allow-list for credentialed explicit origins.
|
||||
- `RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS` defaults to `*` for the console service.
|
||||
|
||||
## Scanner environment aliases
|
||||
|
||||
- `RUSTFS_SCANNER_SPEED` (canonical, also accepts `MINIO_SCANNER_SPEED`)
|
||||
|
||||
@@ -149,18 +149,12 @@ pub const ENV_RUSTFS_ACCESS_KEY: &str = "RUSTFS_ACCESS_KEY";
|
||||
/// Environment variable for server access key file.
|
||||
pub const ENV_RUSTFS_ACCESS_KEY_FILE: &str = "RUSTFS_ACCESS_KEY_FILE";
|
||||
|
||||
/// Environment variable for server root user.
|
||||
pub const ENV_RUSTFS_ROOT_USER: &str = "RUSTFS_ROOT_USER";
|
||||
|
||||
/// Environment variable for server secret key.
|
||||
pub const ENV_RUSTFS_SECRET_KEY: &str = "RUSTFS_SECRET_KEY";
|
||||
|
||||
/// Environment variable for server secret key file.
|
||||
pub const ENV_RUSTFS_SECRET_KEY_FILE: &str = "RUSTFS_SECRET_KEY_FILE";
|
||||
|
||||
/// Environment variable for server root password.
|
||||
pub const ENV_RUSTFS_ROOT_PASSWORD: &str = "RUSTFS_ROOT_PASSWORD";
|
||||
|
||||
/// Environment variable for server OBS endpoint.
|
||||
pub const ENV_RUSTFS_OBS_ENDPOINT: &str = "RUSTFS_OBS_ENDPOINT";
|
||||
|
||||
@@ -235,6 +229,9 @@ pub const ENV_RUSTFS_REGION: &str = "RUSTFS_REGION";
|
||||
/// Environment variable for server license.
|
||||
pub const ENV_RUSTFS_LICENSE: &str = "RUSTFS_LICENSE";
|
||||
|
||||
/// Environment variable for the RSA public key used to verify server licenses.
|
||||
pub const ENV_RUSTFS_LICENSE_PUBLIC_KEY: &str = "RUSTFS_LICENSE_PUBLIC_KEY";
|
||||
|
||||
/// Default log filename for rustfs
|
||||
/// This is the default log filename for rustfs.
|
||||
/// It is used to store the logs of the application.
|
||||
@@ -348,6 +345,7 @@ mod tests {
|
||||
fn test_environment_constants() {
|
||||
// Test environment related constants
|
||||
assert_eq!(ENVIRONMENT, "production");
|
||||
assert_eq!(ENV_RUSTFS_LICENSE_PUBLIC_KEY, "RUSTFS_LICENSE_PUBLIC_KEY");
|
||||
assert!(
|
||||
["development", "staging", "production", "test"].contains(&ENVIRONMENT),
|
||||
"Environment should be a standard environment name"
|
||||
|
||||
@@ -17,16 +17,21 @@
|
||||
pub const ENV_CORS_ALLOWED_ORIGINS: &str = "RUSTFS_CORS_ALLOWED_ORIGINS";
|
||||
|
||||
/// Default CORS allowed origins for the endpoint service
|
||||
/// Comes from the console service default
|
||||
/// See DEFAULT_CONSOLE_CORS_ALLOWED_ORIGINS
|
||||
pub const DEFAULT_CORS_ALLOWED_ORIGINS: &str = DEFAULT_CONSOLE_CORS_ALLOWED_ORIGINS;
|
||||
/// Empty means the S3 endpoint emits no generic CORS headers unless configured.
|
||||
pub const DEFAULT_CORS_ALLOWED_ORIGINS: &str = "";
|
||||
|
||||
/// CORS allowed origins for the console service
|
||||
/// Comma-separated list of origins or "*" for all origins
|
||||
pub const ENV_CONSOLE_CORS_ALLOWED_ORIGINS: &str = "RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS";
|
||||
|
||||
/// Default CORS allowed origins for the console service
|
||||
pub const DEFAULT_CONSOLE_CORS_ALLOWED_ORIGINS: &str = "*";
|
||||
/// Default CORS allowed origins for the console service.
|
||||
///
|
||||
/// Empty string means same-origin only — no `Access-Control-Allow-Origin`
|
||||
/// header is emitted, so browsers will not allow cross-origin reads of
|
||||
/// console responses by default. Operators that need cross-origin access set
|
||||
/// `RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS` to a comma-separated allow-list, or
|
||||
/// to `*` to keep the previous permissive behavior.
|
||||
pub const DEFAULT_CONSOLE_CORS_ALLOWED_ORIGINS: &str = "";
|
||||
|
||||
/// Enable or disable the console service
|
||||
pub const ENV_CONSOLE_ENABLE: &str = "RUSTFS_CONSOLE_ENABLE";
|
||||
@@ -89,3 +94,20 @@ pub const ENV_UPDATE_CHECK: &str = "RUSTFS_CHECK_UPDATE";
|
||||
|
||||
/// Default value for update toggle
|
||||
pub const DEFAULT_UPDATE_CHECK: bool = true;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn endpoint_cors_default_is_restrictive() {
|
||||
assert_eq!(ENV_CORS_ALLOWED_ORIGINS, "RUSTFS_CORS_ALLOWED_ORIGINS");
|
||||
assert_eq!(DEFAULT_CORS_ALLOWED_ORIGINS, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn console_cors_default_is_same_origin_only() {
|
||||
assert_eq!(ENV_CONSOLE_CORS_ALLOWED_ORIGINS, "RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS");
|
||||
assert_eq!(DEFAULT_CONSOLE_CORS_ALLOWED_ORIGINS, "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,10 +77,13 @@ pub const DEFAULT_OBJECT_FILE_CACHE_RECLAIM_WRITE_ENABLE: bool = false;
|
||||
pub const DEFAULT_OBJECT_FILE_CACHE_RECLAIM_READ_ENABLE: bool = false;
|
||||
pub const DEFAULT_OBJECT_FILE_CACHE_RECLAIM_THRESHOLD: usize = 4 * 1024 * 1024;
|
||||
|
||||
/// Threshold for small object seek support in megabytes.
|
||||
/// Threshold for small object seek support in bytes.
|
||||
///
|
||||
/// When an object is smaller than this size, rustfs will provide seek support.
|
||||
/// When an object response is smaller than this size, rustfs may provide
|
||||
/// in-memory seek support. Runtime GET logic also enforces a hard safety cap
|
||||
/// (`64 MiB`) to prevent large-download memory spikes even if this threshold
|
||||
/// is configured higher.
|
||||
///
|
||||
/// Default is set to 10MB.
|
||||
/// Default is set to 10 MiB.
|
||||
pub const ENV_OBJECT_SEEK_SUPPORT_THRESHOLD: &str = "RUSTFS_OBJECT_SEEK_SUPPORT_THRESHOLD";
|
||||
pub const DEFAULT_OBJECT_SEEK_SUPPORT_THRESHOLD: usize = 10 * 1024 * 1024;
|
||||
|
||||
@@ -66,7 +66,7 @@ mod tests {
|
||||
|
||||
// In production environment, access key and secret key should be different
|
||||
// These are default values, so being the same is acceptable, but should be warned in documentation
|
||||
println!("Warning: Default access key and secret key are the same. Change them in production!");
|
||||
assert_eq!(DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -74,10 +74,8 @@ mod tests {
|
||||
// Test security best practices
|
||||
|
||||
// These are default values, should be changed in production environments
|
||||
println!("Security Warning: Default credentials detected!");
|
||||
println!("Access Key: {DEFAULT_ACCESS_KEY}");
|
||||
println!("Secret Key: {DEFAULT_SECRET_KEY}");
|
||||
println!("These should be changed in production environments!");
|
||||
assert_eq!(DEFAULT_ACCESS_KEY, "rustfsadmin");
|
||||
assert_eq!(DEFAULT_SECRET_KEY, "rustfsadmin");
|
||||
|
||||
// Verify that key lengths meet minimum security requirements
|
||||
assert!(DEFAULT_ACCESS_KEY.len() >= 8, "Access key should be at least 8 characters");
|
||||
|
||||
@@ -29,6 +29,12 @@ static GLOBAL_ACTIVE_CRED: OnceLock<Credentials> = OnceLock::new();
|
||||
/// Global RPC authentication token
|
||||
pub static GLOBAL_RUSTFS_RPC_SECRET: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Public error returned when RPC authentication is not safely configured.
|
||||
pub const RPC_SECRET_REQUIRED_MESSAGE: &str = "RPC authentication secret is not configured";
|
||||
|
||||
/// Operator-facing guidance for configuring RPC authentication safely.
|
||||
pub const RPC_SECRET_REQUIRED_OPERATOR_MESSAGE: &str = "RUSTFS_RPC_SECRET must be set to a non-default value or RUSTFS_SECRET_KEY must be changed from the default for RPC authentication";
|
||||
|
||||
/// Error type for credentials operations
|
||||
#[derive(Debug)]
|
||||
pub enum CredentialsError {
|
||||
@@ -216,13 +222,39 @@ pub fn gen_secret_key(length: usize) -> std::io::Result<String> {
|
||||
/// # Returns
|
||||
/// * `String` - The RPC authentication token
|
||||
///
|
||||
fn resolve_rpc_secret(env_secret: Option<&str>, global_secret: Option<&str>) -> Option<String> {
|
||||
if let Some(secret) = env_secret.map(str::trim).filter(|secret| !secret.is_empty()) {
|
||||
return (secret != DEFAULT_SECRET_KEY).then(|| secret.to_string());
|
||||
}
|
||||
|
||||
global_secret
|
||||
.map(str::trim)
|
||||
.filter(|secret| !secret.is_empty() && *secret != DEFAULT_SECRET_KEY)
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub fn try_get_rpc_token() -> std::io::Result<String> {
|
||||
if let Some(secret) = GLOBAL_RUSTFS_RPC_SECRET.get() {
|
||||
return resolve_rpc_secret(None, Some(secret)).ok_or_else(|| Error::other(RPC_SECRET_REQUIRED_MESSAGE));
|
||||
}
|
||||
|
||||
let env_secret = env::var(ENV_RPC_SECRET).ok();
|
||||
let global_secret = get_global_secret_key_opt();
|
||||
let secret = resolve_rpc_secret(env_secret.as_deref(), global_secret.as_deref())
|
||||
.ok_or_else(|| Error::other(RPC_SECRET_REQUIRED_MESSAGE))?;
|
||||
|
||||
match GLOBAL_RUSTFS_RPC_SECRET.set(secret.clone()) {
|
||||
Ok(()) => Ok(secret),
|
||||
Err(_) => GLOBAL_RUSTFS_RPC_SECRET
|
||||
.get()
|
||||
.and_then(|stored| resolve_rpc_secret(None, Some(stored)))
|
||||
.ok_or_else(|| Error::other(RPC_SECRET_REQUIRED_MESSAGE)),
|
||||
}
|
||||
}
|
||||
|
||||
#[deprecated(note = "use try_get_rpc_token to handle missing RPC secrets explicitly")]
|
||||
pub fn get_rpc_token() -> String {
|
||||
GLOBAL_RUSTFS_RPC_SECRET
|
||||
.get_or_init(|| {
|
||||
env::var(ENV_RPC_SECRET)
|
||||
.unwrap_or_else(|_| get_global_secret_key_opt().unwrap_or_else(|| DEFAULT_SECRET_KEY.to_string()))
|
||||
})
|
||||
.clone()
|
||||
try_get_rpc_token().expect(RPC_SECRET_REQUIRED_MESSAGE)
|
||||
}
|
||||
|
||||
/// A wrapper struct for masking sensitive strings in Debug implementations.
|
||||
@@ -301,7 +333,7 @@ impl fmt::Debug for Credentials {
|
||||
f.debug_struct("Credentials")
|
||||
.field("access_key", &self.access_key)
|
||||
.field("secret_key", &Masked(Some(&self.secret_key)))
|
||||
.field("session_token", &self.session_token)
|
||||
.field("session_token", &Masked(Some(&self.session_token)))
|
||||
.field("expiration", &self.expiration)
|
||||
.field("status", &self.status)
|
||||
.field("parent_user", &self.parent_user)
|
||||
@@ -495,6 +527,63 @@ mod tests {
|
||||
assert!(!key.contains('='));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_rpc_secret_rejects_default_fallback() {
|
||||
assert!(resolve_rpc_secret(None, None).is_none());
|
||||
assert!(resolve_rpc_secret(None, Some(DEFAULT_SECRET_KEY)).is_none());
|
||||
assert!(resolve_rpc_secret(Some(DEFAULT_SECRET_KEY), Some("custom-global-secret")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rpc_secret_public_error_omits_configuration_details() {
|
||||
assert!(!RPC_SECRET_REQUIRED_MESSAGE.contains("RUSTFS_"));
|
||||
assert!(!RPC_SECRET_REQUIRED_MESSAGE.contains(DEFAULT_SECRET_KEY));
|
||||
assert!(RPC_SECRET_REQUIRED_OPERATOR_MESSAGE.contains("RUSTFS_RPC_SECRET"));
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
#[test]
|
||||
fn test_get_rpc_token_preserves_string_return_type() {
|
||||
fn assert_string_return(_: fn() -> String) {}
|
||||
|
||||
assert_string_return(get_rpc_token);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_rpc_secret_accepts_non_default_secret() {
|
||||
assert_eq!(resolve_rpc_secret(Some("custom-rpc-secret"), None).as_deref(), Some("custom-rpc-secret"));
|
||||
assert_eq!(
|
||||
resolve_rpc_secret(None, Some("custom-global-secret")).as_deref(),
|
||||
Some("custom-global-secret")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_rpc_secret_trims_and_falls_back_from_blank_env() {
|
||||
assert_eq!(
|
||||
resolve_rpc_secret(Some(" custom-rpc-secret "), None).as_deref(),
|
||||
Some("custom-rpc-secret")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_rpc_secret(Some(""), Some("custom-global-secret")).as_deref(),
|
||||
Some("custom-global-secret")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_rpc_secret(Some(" "), Some(" custom-global-secret ")).as_deref(),
|
||||
Some("custom-global-secret")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_rpc_secret(Some(" "), Some("custom-global-secret")).as_deref(),
|
||||
Some("custom-global-secret")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_rpc_secret_returns_none_for_trimmed_default_secret() {
|
||||
let padded_default_secret = format!(" {} ", DEFAULT_SECRET_KEY);
|
||||
assert!(resolve_rpc_secret(Some(padded_default_secret.as_str()), Some("custom-global-secret")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_masked_debug() {
|
||||
// Test None
|
||||
@@ -524,6 +613,24 @@ mod tests {
|
||||
assert_eq!(format!("{:?}", Masked(Some("中文测试"))), "中***试|4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credentials_debug_masks_sensitive_fields() {
|
||||
let cred = Credentials {
|
||||
access_key: "debug-access-key".to_string(),
|
||||
secret_key: "debug-secret-key".to_string(),
|
||||
session_token: "debug-session-token".to_string(),
|
||||
parent_user: "parent-user".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let output = format!("{cred:?}");
|
||||
|
||||
assert!(output.contains("debug-access-key"));
|
||||
assert!(output.contains("parent-user"));
|
||||
assert!(!output.contains("debug-secret-key"));
|
||||
assert!(!output.contains("debug-session-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credentials_expiration_serialize_as_rfc3339() {
|
||||
use time::OffsetDateTime;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! CopyObject metadata replacement regression tests.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::MetadataDirective;
|
||||
use serial_test::serial;
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_self_copy_replace_metadata_preserves_readable_object() {
|
||||
init_logging();
|
||||
info!("Issue #2789: self-copy metadata replacement must preserve object data");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "self-copy-metadata-replace-test";
|
||||
let key = "assets/chunk-2F3R7JUG.js";
|
||||
let content = b"console.log('metadata replacement should keep object data readable');";
|
||||
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create bucket");
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.content_type("text/javascript; charset=utf-8")
|
||||
.metadata("mtime", "1777992333")
|
||||
.metadata("stale", "must-be-removed")
|
||||
.body(ByteStream::from_static(content))
|
||||
.send()
|
||||
.await
|
||||
.expect("PUT failed");
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.metadata_directive(MetadataDirective::Replace)
|
||||
.content_type("text/javascript; charset=utf-8")
|
||||
.metadata("mtime", "1777992348")
|
||||
.send()
|
||||
.await
|
||||
.expect("self CopyObject with metadata replacement failed");
|
||||
|
||||
let head_resp = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD failed after self-copy");
|
||||
assert_eq!(head_resp.content_length(), Some(content.len() as i64));
|
||||
assert_eq!(
|
||||
head_resp.metadata().and_then(|metadata| metadata.get("mtime")),
|
||||
Some(&"1777992348".to_string()),
|
||||
"HEAD should return replaced metadata"
|
||||
);
|
||||
assert_eq!(
|
||||
head_resp.metadata().and_then(|metadata| metadata.get("stale")),
|
||||
None,
|
||||
"HEAD should not return metadata omitted by REPLACE"
|
||||
);
|
||||
|
||||
let get_resp = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect("GET failed after self-copy");
|
||||
let body = get_resp
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.expect("Failed to collect GET body")
|
||||
.into_bytes();
|
||||
assert_eq!(body.as_ref(), content, "self-copy metadata replacement must not drop object data");
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.metadata_directive(MetadataDirective::Replace)
|
||||
.send()
|
||||
.await
|
||||
.expect("self CopyObject with empty metadata replacement failed");
|
||||
|
||||
let empty_head_resp = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD failed after empty metadata replacement");
|
||||
assert_eq!(
|
||||
empty_head_resp.metadata().and_then(|metadata| metadata.get("mtime")),
|
||||
None,
|
||||
"HEAD should not return metadata omitted by empty REPLACE"
|
||||
);
|
||||
|
||||
let empty_get_resp = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect("GET failed after empty metadata replacement");
|
||||
let empty_body = empty_get_resp
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.expect("Failed to collect GET body after empty metadata replacement")
|
||||
.into_bytes();
|
||||
assert_eq!(empty_body.as_ref(), content, "empty metadata replacement must not drop object data");
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ use crate::common::{
|
||||
};
|
||||
use aws_sdk_s3::config::{Credentials, Region};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{Tag, Tagging};
|
||||
use aws_sdk_s3::types::{Delete, ObjectIdentifier, Tag, Tagging};
|
||||
use aws_sdk_s3::{Client, Config};
|
||||
use serial_test::serial;
|
||||
use tracing::info;
|
||||
@@ -318,11 +318,18 @@ async fn test_e2e_sts_assume_role_session_policy_existing_object_tag() -> Result
|
||||
|
||||
let rw = serde_json::to_string(&serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:*"],
|
||||
"Resource": ["arn:aws:s3:::*"]
|
||||
}]
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:*"],
|
||||
"Resource": ["arn:aws:s3:::*"]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["sts:AssumeRole"],
|
||||
"Resource": ["arn:aws:s3:::*"]
|
||||
}
|
||||
]
|
||||
}))?;
|
||||
admin_add_canned_policy(&env, &policy_readwrite, &rw).await?;
|
||||
admin_attach_policy_to_user(&env, &policy_readwrite, &parent).await?;
|
||||
@@ -362,3 +369,114 @@ async fn test_e2e_sts_assume_role_session_policy_existing_object_tag() -> Result
|
||||
info!("test_e2e_sts_assume_role_session_policy_existing_object_tag passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// STS inline session policy: DeleteObjects must evaluate `s3:DeleteObject` per requested object key.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_e2e_sts_session_policy_delete_objects_object_prefix_only() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
if !awscurl_available() {
|
||||
info!("Skipping test_e2e_sts_session_policy_delete_objects_object_prefix_only: awscurl not available");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let suffix = Uuid::new_v4();
|
||||
let parent = format!("e2e-sts-del-par-{suffix}");
|
||||
let parent_secret = "longSecretKeyForParentDelete99!";
|
||||
let policy_readwrite = format!("e2e-sts-del-rw-{suffix}");
|
||||
let bucket = format!("e2e-sts-del-bkt-{suffix}");
|
||||
let allowed_key = "allowed/table/data.parquet";
|
||||
let denied_key = "denied/table/data.parquet";
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let admin = env.create_s3_client();
|
||||
admin_create_user(&env, &parent, parent_secret).await?;
|
||||
|
||||
let rw = serde_json::to_string(&serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:*"],
|
||||
"Resource": ["arn:aws:s3:::*"]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["sts:AssumeRole"],
|
||||
"Resource": ["arn:aws:s3:::*"]
|
||||
}
|
||||
]
|
||||
}))?;
|
||||
admin_add_canned_policy(&env, &policy_readwrite, &rw).await?;
|
||||
admin_attach_policy_to_user(&env, &policy_readwrite, &parent).await?;
|
||||
|
||||
let parent_client = user_client(&env, &parent, parent_secret);
|
||||
parent_client.create_bucket().bucket(&bucket).send().await?;
|
||||
parent_client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(allowed_key)
|
||||
.body(ByteStream::from_static(b"allowed-delete-data"))
|
||||
.send()
|
||||
.await?;
|
||||
parent_client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(denied_key)
|
||||
.body(ByteStream::from_static(b"denied-delete-data"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let session_policy = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:DeleteObject"],
|
||||
"Resource": [format!("arn:aws:s3:::{}/allowed/*", bucket)]
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let (ak, sk, token) = assume_role_with_session_policy(&env, &parent, parent_secret, &session_policy).await?;
|
||||
let session_client = sts_session_client(&env, &ak, &sk, &token);
|
||||
|
||||
let delete = Delete::builder()
|
||||
.objects(ObjectIdentifier::builder().key(allowed_key).build()?)
|
||||
.objects(ObjectIdentifier::builder().key(denied_key).build()?)
|
||||
.build()?;
|
||||
|
||||
let result = session_client.delete_objects().bucket(&bucket).delete(delete).send().await?;
|
||||
|
||||
assert_eq!(result.deleted().len(), 1, "only the allowed-prefix object should be deleted");
|
||||
assert!(
|
||||
result.deleted().iter().any(|deleted| deleted.key() == Some(allowed_key)),
|
||||
"DeleteObjects response should report the allowed-prefix object as deleted"
|
||||
);
|
||||
|
||||
assert_eq!(result.errors().len(), 1, "the out-of-prefix object should return one per-key error");
|
||||
let error = &result.errors()[0];
|
||||
assert_eq!(error.key(), Some(denied_key));
|
||||
assert_eq!(error.code(), Some("AccessDenied"));
|
||||
|
||||
let allowed_head = parent_client.head_object().bucket(&bucket).key(allowed_key).send().await;
|
||||
assert!(allowed_head.is_err(), "allowed-prefix object should have been deleted");
|
||||
|
||||
parent_client
|
||||
.head_object()
|
||||
.bucket(&bucket)
|
||||
.key(denied_key)
|
||||
.send()
|
||||
.await
|
||||
.expect("out-of-prefix object should remain after per-key AccessDenied");
|
||||
|
||||
let _ = admin.delete_object().bucket(&bucket).key(allowed_key).send().await;
|
||||
let _ = admin.delete_object().bucket(&bucket).key(denied_key).send().await;
|
||||
let _ = admin.delete_bucket().bucket(&bucket).send().await;
|
||||
admin_remove_user(&env, &parent).await;
|
||||
admin_remove_policy(&env, &policy_readwrite).await;
|
||||
|
||||
info!("test_e2e_sts_session_policy_delete_objects_object_prefix_only passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use serial_test::serial;
|
||||
use tracing::info;
|
||||
|
||||
const RANGE_HEAD_BUCKET: &str = "range-head-test-bucket";
|
||||
const RANGE_HEAD_KEY: &str = "range-head-object.bin";
|
||||
const ACCEPT_RANGES_BYTES: &str = "bytes";
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn head_object_advertises_accept_ranges() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
info!("Starting HeadObject Accept-Ranges regression test");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(Vec::new()).await?;
|
||||
|
||||
let client = env.create_s3_client();
|
||||
env.create_test_bucket(RANGE_HEAD_BUCKET).await?;
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(RANGE_HEAD_BUCKET)
|
||||
.key(RANGE_HEAD_KEY)
|
||||
.body(ByteStream::from_static(b"0123456789abcdef"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(RANGE_HEAD_BUCKET)
|
||||
.key(RANGE_HEAD_KEY)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
head.accept_ranges(),
|
||||
Some(ACCEPT_RANGES_BYTES),
|
||||
"HeadObject should advertise byte range support"
|
||||
);
|
||||
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(RANGE_HEAD_BUCKET)
|
||||
.key(RANGE_HEAD_KEY)
|
||||
.send()
|
||||
.await?;
|
||||
env.delete_test_bucket(RANGE_HEAD_BUCKET).await?;
|
||||
env.stop_server();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -104,6 +104,12 @@ mod checksum_upload_test;
|
||||
#[cfg(test)]
|
||||
mod group_delete_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod head_object_range_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod copy_object_metadata_test;
|
||||
|
||||
// S3 dummy-compat bucket API tests
|
||||
#[cfg(test)]
|
||||
mod bucket_logging_test;
|
||||
|
||||
@@ -183,11 +183,8 @@ pub async fn get_local_server_property() -> ServerProperties {
|
||||
};
|
||||
|
||||
// let mut sensitive = HashSet::new();
|
||||
// sensitive.insert(ENV_ACCESS_KEY.to_string());
|
||||
// sensitive.insert(ENV_SECRET_KEY.to_string());
|
||||
// sensitive.insert(ENV_ROOT_USER.to_string());
|
||||
// sensitive.insert(ENV_ROOT_PASSWORD.to_string());
|
||||
|
||||
// sensitive.insert(rustfs_config::ENV_RUSTFS_ACCESS_KEY.to_string());
|
||||
// sensitive.insert(rustfs_config::ENV_RUSTFS_SECRET_KEY.to_string());
|
||||
if let Some(store) = new_object_layer_fn() {
|
||||
let storage_info = store.local_storage_info().await;
|
||||
props.state = ITEM_ONLINE.to_string();
|
||||
|
||||
@@ -42,10 +42,6 @@ pub static DEFAULT_KVS: LazyLock<OnceLock<HashMap<String, KVS>>> = LazyLock::new
|
||||
pub static GLOBAL_SERVER_CONFIG: LazyLock<OnceLock<Config>> = LazyLock::new(OnceLock::new);
|
||||
pub static GLOBAL_CONFIG_SYS: LazyLock<ConfigSys> = LazyLock::new(ConfigSys::new);
|
||||
|
||||
pub const ENV_ACCESS_KEY: &str = "RUSTFS_ACCESS_KEY";
|
||||
pub const ENV_SECRET_KEY: &str = "RUSTFS_SECRET_KEY";
|
||||
pub const ENV_ROOT_USER: &str = "RUSTFS_ROOT_USER";
|
||||
pub const ENV_ROOT_PASSWORD: &str = "RUSTFS_ROOT_PASSWORD";
|
||||
pub static RUSTFS_CONFIG_PREFIX: &str = "config";
|
||||
|
||||
pub struct ConfigSys {}
|
||||
|
||||
@@ -2665,7 +2665,7 @@ impl DiskAPI for LocalDisk {
|
||||
|
||||
let mut xl_meta = FileMeta::load(buf.as_ref())?;
|
||||
|
||||
xl_meta.update_object_version(fi)?;
|
||||
xl_meta.update_object_version_with_opts(fi, opts.replace_user_metadata)?;
|
||||
|
||||
let wbuf = xl_meta.marshal_msg()?;
|
||||
|
||||
|
||||
@@ -559,6 +559,7 @@ pub struct CheckPartsResp {
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct UpdateMetadataOpts {
|
||||
pub no_persistence: bool,
|
||||
pub replace_user_metadata: bool,
|
||||
}
|
||||
|
||||
pub struct DiskLocation {
|
||||
@@ -914,9 +915,13 @@ mod tests {
|
||||
/// Test UpdateMetadataOpts structure
|
||||
#[test]
|
||||
fn test_update_metadata_opts() {
|
||||
let opts = UpdateMetadataOpts { no_persistence: true };
|
||||
let opts = UpdateMetadataOpts {
|
||||
no_persistence: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(opts.no_persistence);
|
||||
assert!(!opts.replace_user_metadata);
|
||||
}
|
||||
|
||||
/// Test DiskOption structure
|
||||
|
||||
@@ -166,8 +166,19 @@ async fn write_data_blocks<W>(
|
||||
where
|
||||
W: tokio::io::AsyncWrite + Send + Sync + Unpin,
|
||||
{
|
||||
if get_data_block_len(en_blocks, data_blocks) < length {
|
||||
error!("write_data_blocks get_data_block_len < length");
|
||||
if en_blocks.len() < data_blocks {
|
||||
return Err(io::Error::new(ErrorKind::InvalidInput, "data block count exceeds available shards"));
|
||||
}
|
||||
|
||||
if length == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let Some(required_len) = offset.checked_add(length) else {
|
||||
return Err(io::Error::new(ErrorKind::InvalidInput, "offset + length overflows"));
|
||||
};
|
||||
if get_data_block_len(en_blocks, data_blocks) < required_len {
|
||||
error!("write_data_blocks not enough data after offset");
|
||||
return Err(io::Error::new(ErrorKind::UnexpectedEof, "Not enough data blocks to write"));
|
||||
}
|
||||
|
||||
@@ -188,29 +199,22 @@ where
|
||||
let block_slice = &block[offset..];
|
||||
offset = 0;
|
||||
|
||||
if write_left < block_slice.len() {
|
||||
writer.write_all(&block_slice[..write_left]).await.map_err(|e| {
|
||||
error!("write_data_blocks write_all err: {}", e);
|
||||
e
|
||||
})?;
|
||||
|
||||
total_written += write_left;
|
||||
break;
|
||||
}
|
||||
|
||||
let n = block_slice.len();
|
||||
|
||||
writer.write_all(block_slice).await.map_err(|e| {
|
||||
error!("write_data_blocks write_all2 err: {}", e);
|
||||
let write_len = write_left.min(block_slice.len());
|
||||
writer.write_all(&block_slice[..write_len]).await.map_err(|e| {
|
||||
error!("write_data_blocks write_all err: {}", e);
|
||||
e
|
||||
})?;
|
||||
|
||||
write_left -= n;
|
||||
total_written += write_len;
|
||||
write_left -= write_len;
|
||||
|
||||
total_written += n;
|
||||
if write_left == 0 {
|
||||
return Ok(total_written);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(total_written)
|
||||
error!("write_data_blocks loop exhausted with write_left>0");
|
||||
Err(io::Error::new(ErrorKind::UnexpectedEof, "Not enough data blocks to write"))
|
||||
}
|
||||
|
||||
impl Erasure {
|
||||
@@ -323,6 +327,112 @@ mod tests {
|
||||
use rustfs_utils::HashAlgorithm;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_write_data_blocks_writes_range_across_blocks() {
|
||||
let blocks = vec![Some(vec![1, 2, 3, 4]), Some(vec![5, 6, 7]), Some(vec![8, 9])];
|
||||
let mut out = Vec::new();
|
||||
|
||||
let written = write_data_blocks(&mut out, &blocks, 3, 2, 5).await.unwrap();
|
||||
|
||||
assert_eq!(written, 5);
|
||||
assert_eq!(out, vec![3, 4, 5, 6, 7]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_write_data_blocks_rejects_short_data_after_offset() {
|
||||
let blocks = vec![Some(vec![1, 2, 3, 4]), Some(vec![5, 6, 7])];
|
||||
let mut out = Vec::new();
|
||||
|
||||
let err = write_data_blocks(&mut out, &blocks, 2, 3, 5).await.unwrap_err();
|
||||
|
||||
assert_eq!(err.kind(), ErrorKind::UnexpectedEof);
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_write_data_blocks_rejects_invalid_data_block_count() {
|
||||
let blocks = vec![Some(vec![1, 2, 3, 4])];
|
||||
let mut out = Vec::new();
|
||||
|
||||
let err = write_data_blocks(&mut out, &blocks, 2, 0, 1).await.unwrap_err();
|
||||
|
||||
assert_eq!(err.kind(), ErrorKind::InvalidInput);
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
/// Regression for upstream issue #2716: ranged GETs going through
|
||||
/// `Erasure::decode` must return the requested byte range without
|
||||
/// panicking or truncating, including when the range starts at a
|
||||
/// non-zero offset and crosses EC block boundaries.
|
||||
#[tokio::test]
|
||||
async fn test_erasure_decode_ranged_read_returns_correct_bytes() {
|
||||
const DATA_SHARDS: usize = 4;
|
||||
const PARITY_SHARDS: usize = 2;
|
||||
const BLOCK_SIZE: usize = 64;
|
||||
|
||||
// 200 bytes spans 3 full blocks + 1 partial block, exercising
|
||||
// the start/middle/end branches in `Erasure::decode`.
|
||||
let total_data: Vec<u8> = (0..200u32).map(|i| i as u8).collect();
|
||||
let total_len = total_data.len();
|
||||
|
||||
let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
|
||||
let total_shards = DATA_SHARDS + PARITY_SHARDS;
|
||||
let shard_size = erasure.shard_size();
|
||||
let hash_algo = HashAlgorithm::HighwayHash256;
|
||||
|
||||
let mut shard_writers: Vec<BitrotWriter<Cursor<Vec<u8>>>> = (0..total_shards)
|
||||
.map(|_| BitrotWriter::new(Cursor::new(Vec::new()), shard_size, hash_algo.clone()))
|
||||
.collect();
|
||||
|
||||
let mut offset = 0;
|
||||
while offset < total_len {
|
||||
let end = (offset + BLOCK_SIZE).min(total_len);
|
||||
let shards = erasure.encode_data(&total_data[offset..end]).unwrap();
|
||||
for (i, shard) in shards.iter().enumerate() {
|
||||
shard_writers[i].write(shard).await.unwrap();
|
||||
}
|
||||
offset = end;
|
||||
}
|
||||
|
||||
let shard_bufs: Vec<Vec<u8>> = shard_writers.into_iter().map(|w| w.into_inner().into_inner()).collect();
|
||||
|
||||
// `Erasure::decode` does not seek the readers; the production caller
|
||||
// (`create_bitrot_reader`) positions each reader at the shard byte
|
||||
// offset corresponding to the request's start block. Mirror that here.
|
||||
let hash_size = hash_algo.size();
|
||||
let make_readers = |off: usize| -> Vec<Option<BitrotReader<Cursor<Vec<u8>>>>> {
|
||||
let start_block = off / BLOCK_SIZE;
|
||||
let cursor_pos = start_block * (shard_size + hash_size);
|
||||
shard_bufs
|
||||
.iter()
|
||||
.map(|buf| {
|
||||
let mut cursor = Cursor::new(buf.clone());
|
||||
cursor.set_position(cursor_pos as u64);
|
||||
Some(BitrotReader::new(cursor, shard_size, hash_algo.clone(), false))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// (offset, length, description)
|
||||
let cases: &[(usize, usize, &str)] = &[
|
||||
(0, total_len, "full read"),
|
||||
(0, 50, "head from start, partial block"),
|
||||
(10, 30, "small range within first block"),
|
||||
(60, 80, "range crossing two block boundaries"),
|
||||
(128, 50, "range starting at block boundary"),
|
||||
(130, 10, "small range deep in middle"),
|
||||
(192, 8, "tail covering last partial block"),
|
||||
];
|
||||
|
||||
for &(off, len, desc) in cases {
|
||||
let mut output = Vec::new();
|
||||
let (written, err) = erasure.decode(&mut output, make_readers(off), off, len, total_len).await;
|
||||
assert!(err.is_none(), "{}: unexpected error: {:?}", desc, err);
|
||||
assert_eq!(written, len, "{}: written != length", desc);
|
||||
assert_eq!(output, total_data[off..off + len], "{}: bytes mismatch", desc);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parallel_reader_normal() {
|
||||
const BLOCK_SIZE: usize = 64;
|
||||
|
||||
@@ -22,7 +22,7 @@ use crate::rpc::PeerRestClient;
|
||||
use crate::{endpoints::EndpointServerPools, new_object_layer_fn};
|
||||
use futures::future::join_all;
|
||||
use lazy_static::lazy_static;
|
||||
use rustfs_madmin::health::{Cpus, MemInfo, OsInfo, Partitions, ProcInfo, SysConfig, SysErrors, SysService};
|
||||
use rustfs_madmin::health::{Cpus, MemInfo, OsInfo, Partitions, ProcInfo, SysConfig, SysErrors, SysServices};
|
||||
use rustfs_madmin::metrics::RealtimeMetrics;
|
||||
use rustfs_madmin::net::NetInfo;
|
||||
use rustfs_madmin::{ItemState, ServerProperties};
|
||||
@@ -485,31 +485,29 @@ impl NotificationSys {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_bucket_metadata(&self, bucket: &str) -> Vec<NotificationPeerErr> {
|
||||
pub async fn load_bucket_metadata(&self, bucket: &str) -> Result<()> {
|
||||
let operation = format!("load_bucket_metadata({bucket})");
|
||||
let mut failures = Vec::new();
|
||||
let mut futures = Vec::with_capacity(self.peer_clients.len());
|
||||
for client in self.peer_clients.iter() {
|
||||
let b = bucket.to_string();
|
||||
futures.push(async move {
|
||||
if let Some(client) = client {
|
||||
match client.load_bucket_metadata(&b).await {
|
||||
Ok(_) => NotificationPeerErr {
|
||||
host: client.host.to_string(),
|
||||
err: None,
|
||||
},
|
||||
Err(e) => NotificationPeerErr {
|
||||
host: client.host.to_string(),
|
||||
err: Some(e),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
NotificationPeerErr {
|
||||
host: "".to_string(),
|
||||
err: Some(Error::other("peer is not reachable")),
|
||||
}
|
||||
}
|
||||
});
|
||||
for (idx, client) in self.peer_clients.iter().enumerate() {
|
||||
if let Some(client) = client {
|
||||
let host = client.host.to_string();
|
||||
let b = bucket.to_string();
|
||||
futures.push(async move { client.load_bucket_metadata(&b).await.map_err(|err| (host, err)) });
|
||||
} else {
|
||||
failures.push(format!("peer[{idx}] {operation} failed: peer is not reachable"));
|
||||
}
|
||||
}
|
||||
join_all(futures).await
|
||||
|
||||
for result in join_all(futures).await {
|
||||
if let Err((host, err)) = result {
|
||||
let failure = format!("peer {host} {operation} failed: {err}");
|
||||
error!("notification {operation} err {failure}");
|
||||
failures.push(failure);
|
||||
}
|
||||
}
|
||||
|
||||
aggregate_notification_failures(&operation, failures)
|
||||
}
|
||||
|
||||
pub async fn delete_bucket_metadata(&self, bucket: &str) -> Vec<NotificationPeerErr> {
|
||||
@@ -622,14 +620,14 @@ impl NotificationSys {
|
||||
join_all(futures).await
|
||||
}
|
||||
|
||||
pub async fn get_sys_services(&self) -> Vec<SysService> {
|
||||
pub async fn get_sys_services(&self) -> Vec<SysServices> {
|
||||
let mut futures = Vec::with_capacity(self.peer_clients.len());
|
||||
for client in self.peer_clients.iter().cloned() {
|
||||
futures.push(async move {
|
||||
if let Some(client) = client {
|
||||
client.get_se_linux_info().await.unwrap_or_default()
|
||||
} else {
|
||||
SysService::default()
|
||||
SysServices::default()
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -904,4 +902,22 @@ mod tests {
|
||||
assert!(msg.contains("peer-1 failed"));
|
||||
assert!(msg.contains("local save failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_bucket_metadata_reports_unreachable_peers() {
|
||||
let sys = NotificationSys {
|
||||
peer_clients: vec![None],
|
||||
all_peer_clients: Vec::new(),
|
||||
};
|
||||
|
||||
let err = sys
|
||||
.load_bucket_metadata("bucket-a")
|
||||
.await
|
||||
.expect_err("unreachable peers should fail bucket metadata reload");
|
||||
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("load_bucket_metadata(bucket-a)"));
|
||||
assert!(msg.contains("1 failure(s)"));
|
||||
assert!(msg.contains("peer[0]"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,8 @@ pub struct TonicSignatureInterceptor;
|
||||
|
||||
impl tonic::service::Interceptor for TonicSignatureInterceptor {
|
||||
fn call(&mut self, mut req: tonic::Request<()>) -> Result<tonic::Request<()>, tonic::Status> {
|
||||
let headers = gen_signature_headers(TONIC_RPC_PREFIX, &Method::GET);
|
||||
let headers = gen_signature_headers(TONIC_RPC_PREFIX, &Method::GET)
|
||||
.map_err(|_| tonic::Status::unauthenticated("No valid auth token"))?;
|
||||
req.metadata_mut().as_mut().extend(headers);
|
||||
inject_trace_context_into_metadata(req.metadata_mut());
|
||||
inject_request_id_into_metadata(req.metadata_mut());
|
||||
@@ -135,8 +136,13 @@ mod tests {
|
||||
use super::*;
|
||||
use tonic::service::Interceptor;
|
||||
|
||||
fn ensure_test_rpc_secret() {
|
||||
let _ = rustfs_credentials::GLOBAL_RUSTFS_RPC_SECRET.set("test-rpc-secret".to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signature_interceptor_keeps_auth_headers() {
|
||||
ensure_test_rpc_secret();
|
||||
let mut interceptor = TonicSignatureInterceptor;
|
||||
let req = tonic::Request::new(());
|
||||
|
||||
@@ -148,6 +154,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_signature_interceptor_may_inject_request_id() {
|
||||
ensure_test_rpc_secret();
|
||||
let mut interceptor = TonicSignatureInterceptor;
|
||||
let req = tonic::Request::new(());
|
||||
|
||||
|
||||
@@ -17,8 +17,11 @@ use base64::Engine as _;
|
||||
use base64::engine::general_purpose;
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use http::{HeaderMap, HeaderValue, Method, Uri};
|
||||
use rustfs_credentials::{DEFAULT_SECRET_KEY, ENV_RPC_SECRET, get_global_secret_key_opt};
|
||||
#[cfg(test)]
|
||||
use rustfs_credentials::{DEFAULT_SECRET_KEY, RPC_SECRET_REQUIRED_MESSAGE};
|
||||
use rustfs_credentials::{RPC_SECRET_REQUIRED_OPERATOR_MESSAGE, try_get_rpc_token};
|
||||
use sha2::Sha256;
|
||||
use std::sync::Once;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::error;
|
||||
|
||||
@@ -28,47 +31,76 @@ const SIGNATURE_HEADER: &str = "x-rustfs-signature";
|
||||
const TIMESTAMP_HEADER: &str = "x-rustfs-timestamp";
|
||||
const SIGNATURE_VALID_DURATION: i64 = 300; // 5 minutes
|
||||
pub const TONIC_RPC_PREFIX: &str = "/node_service.NodeService";
|
||||
static RPC_SECRET_RESOLUTION_LOG_ONCE: Once = Once::new();
|
||||
|
||||
/// Get the shared secret for HMAC signing
|
||||
fn get_shared_secret() -> String {
|
||||
rustfs_credentials::GLOBAL_RUSTFS_RPC_SECRET
|
||||
.get_or_init(|| {
|
||||
rustfs_utils::get_env_str(
|
||||
ENV_RPC_SECRET,
|
||||
get_global_secret_key_opt()
|
||||
.unwrap_or_else(|| DEFAULT_SECRET_KEY.to_string())
|
||||
.as_str(),
|
||||
)
|
||||
})
|
||||
.clone()
|
||||
#[cfg(test)]
|
||||
fn resolve_shared_secret(env_secret: Option<&str>, global_secret: Option<&str>) -> std::io::Result<String> {
|
||||
if let Some(secret) = env_secret.map(str::trim).filter(|secret| !secret.is_empty()) {
|
||||
return (secret != DEFAULT_SECRET_KEY)
|
||||
.then(|| secret.to_string())
|
||||
.ok_or_else(|| std::io::Error::other(RPC_SECRET_REQUIRED_MESSAGE));
|
||||
}
|
||||
|
||||
global_secret
|
||||
.map(str::trim)
|
||||
.filter(|secret| !secret.is_empty() && *secret != DEFAULT_SECRET_KEY)
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| std::io::Error::other(RPC_SECRET_REQUIRED_MESSAGE))
|
||||
}
|
||||
|
||||
/// Generate HMAC-SHA256 signature for the given data
|
||||
fn generate_signature(secret: &str, url: &str, method: &Method, timestamp: i64) -> String {
|
||||
fn get_shared_secret() -> std::io::Result<String> {
|
||||
try_get_rpc_token().map_err(|err| {
|
||||
RPC_SECRET_RESOLUTION_LOG_ONCE.call_once(|| {
|
||||
error!("RPC auth secret resolution failed: {}; {}", err, RPC_SECRET_REQUIRED_OPERATOR_MESSAGE);
|
||||
});
|
||||
err
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the canonical payload covered by the RPC HMAC.
|
||||
fn signature_payload(url: &str, method: &Method, timestamp: i64) -> String {
|
||||
let uri: Uri = url.parse().expect("Invalid URL");
|
||||
|
||||
let path_and_query = uri.path_and_query().unwrap();
|
||||
|
||||
let url = path_and_query.to_string();
|
||||
|
||||
let data = format!("{url}|{method}|{timestamp}");
|
||||
format!("{url}|{method}|{timestamp}")
|
||||
}
|
||||
|
||||
/// Generate HMAC-SHA256 signature for the given data
|
||||
fn generate_signature(secret: &str, url: &str, method: &Method, timestamp: i64) -> String {
|
||||
let data = signature_payload(url, method, timestamp);
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size");
|
||||
mac.update(data.as_bytes());
|
||||
let result = mac.finalize();
|
||||
general_purpose::STANDARD.encode(result.into_bytes())
|
||||
}
|
||||
|
||||
fn verify_signature(secret: &str, url: &str, method: &Method, timestamp: i64, signature: &str) -> bool {
|
||||
let Ok(signature) = general_purpose::STANDARD.decode(signature) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let data = signature_payload(url, method, timestamp);
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size");
|
||||
mac.update(data.as_bytes());
|
||||
mac.verify_slice(&signature).is_ok()
|
||||
}
|
||||
|
||||
/// Build headers with authentication signature
|
||||
pub fn build_auth_headers(url: &str, method: &Method, headers: &mut HeaderMap) {
|
||||
let auth_headers = gen_signature_headers(url, method);
|
||||
pub fn build_auth_headers(url: &str, method: &Method, headers: &mut HeaderMap) -> std::io::Result<()> {
|
||||
let auth_headers = gen_signature_headers(url, method)?;
|
||||
|
||||
headers.extend(auth_headers);
|
||||
inject_trace_context_into_http_headers(headers);
|
||||
inject_request_id_into_http_headers(headers);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn gen_signature_headers(url: &str, method: &Method) -> HeaderMap {
|
||||
let secret = get_shared_secret();
|
||||
pub fn gen_signature_headers(url: &str, method: &Method) -> std::io::Result<HeaderMap> {
|
||||
let secret = get_shared_secret()?;
|
||||
let timestamp = OffsetDateTime::now_utc().unix_timestamp();
|
||||
|
||||
let signature = generate_signature(&secret, url, method, timestamp);
|
||||
@@ -80,13 +112,11 @@ pub fn gen_signature_headers(url: &str, method: &Method) -> HeaderMap {
|
||||
HeaderValue::from_str(×tamp.to_string()).expect("Invalid header value"),
|
||||
);
|
||||
|
||||
headers
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
/// Verify the request signature for RPC requests
|
||||
pub fn verify_rpc_signature(url: &str, method: &Method, headers: &HeaderMap) -> std::io::Result<()> {
|
||||
let secret = get_shared_secret();
|
||||
|
||||
// Get signature from header
|
||||
let signature = headers
|
||||
.get(SIGNATURE_HEADER)
|
||||
@@ -106,24 +136,22 @@ pub fn verify_rpc_signature(url: &str, method: &Method, headers: &HeaderMap) ->
|
||||
// Check timestamp validity (prevent replay attacks)
|
||||
let current_time = OffsetDateTime::now_utc().unix_timestamp();
|
||||
|
||||
if current_time.saturating_sub(timestamp) > SIGNATURE_VALID_DURATION {
|
||||
if current_time.saturating_sub(timestamp) > SIGNATURE_VALID_DURATION
|
||||
|| timestamp.saturating_sub(current_time) > SIGNATURE_VALID_DURATION
|
||||
{
|
||||
return Err(std::io::Error::other("Request timestamp expired"));
|
||||
}
|
||||
|
||||
// Generate expected signature
|
||||
let expected_signature = generate_signature(&secret, url, method, timestamp);
|
||||
// Verify signature with constant-time HMAC comparison.
|
||||
let secret = get_shared_secret()?;
|
||||
|
||||
// Compare signatures
|
||||
if signature != expected_signature {
|
||||
if !verify_signature(&secret, url, method, timestamp, signature) {
|
||||
error!(
|
||||
"verify_rpc_signature: Invalid signature: url {}, method {}, timestamp {}, signature {}, expected_signature: {}***{}|{}",
|
||||
"verify_rpc_signature: Invalid signature: url {}, method {}, timestamp {}, signature_len {}",
|
||||
url,
|
||||
method,
|
||||
timestamp,
|
||||
signature,
|
||||
expected_signature.chars().next().unwrap_or('*'),
|
||||
expected_signature.chars().last().unwrap_or('*'),
|
||||
expected_signature.len()
|
||||
signature.len()
|
||||
);
|
||||
|
||||
return Err(std::io::Error::other("Invalid signature"));
|
||||
@@ -137,18 +165,79 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::rpc::context_propagation::REQUEST_ID_HEADER;
|
||||
use http::{HeaderMap, Method};
|
||||
use std::io::{self, Write};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use time::OffsetDateTime;
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct CapturedLogs {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
struct CapturedLogWriter {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl CapturedLogs {
|
||||
fn contents(&self) -> String {
|
||||
let buffer = self
|
||||
.buffer
|
||||
.lock()
|
||||
.expect("captured logs mutex should not be poisoned")
|
||||
.clone();
|
||||
String::from_utf8(buffer).expect("captured logs should be valid UTF-8")
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for CapturedLogWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
self.buffer
|
||||
.lock()
|
||||
.expect("captured logs mutex should not be poisoned")
|
||||
.extend_from_slice(buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for CapturedLogs {
|
||||
type Writer = CapturedLogWriter;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
CapturedLogWriter {
|
||||
buffer: Arc::clone(&self.buffer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_test_rpc_secret() {
|
||||
let _ = rustfs_credentials::GLOBAL_RUSTFS_RPC_SECRET.set("test-rpc-secret".to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_shared_secret_rejects_default_fallback() {
|
||||
let err = resolve_shared_secret(None, None).expect_err("default fallback must be rejected");
|
||||
assert_eq!(err.to_string(), RPC_SECRET_REQUIRED_MESSAGE);
|
||||
|
||||
let err = resolve_shared_secret(None, Some(DEFAULT_SECRET_KEY)).expect_err("default global secret must be rejected");
|
||||
assert_eq!(err.to_string(), RPC_SECRET_REQUIRED_MESSAGE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_shared_secret() {
|
||||
let secret = get_shared_secret();
|
||||
ensure_test_rpc_secret();
|
||||
let secret = get_shared_secret().expect("test RPC secret should resolve");
|
||||
assert!(!secret.is_empty(), "Secret should not be empty");
|
||||
|
||||
let url = "http://node1:7000/rustfs/rpc/read_file_stream?disk=http%3A%2F%2Fnode1%3A7000%2Fdata%2Frustfs3&volume=.rustfs.sys&path=pool.bin%2Fdd0fd773-a962-4265-b543-783ce83953e9%2Fpart.1&offset=0&length=44";
|
||||
let method = Method::GET;
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
build_auth_headers(url, &method, &mut headers);
|
||||
build_auth_headers(url, &method, &mut headers).expect("auth headers should build");
|
||||
|
||||
let url = "/rustfs/rpc/read_file_stream?disk=http%3A%2F%2Fnode1%3A7000%2Fdata%2Frustfs3&volume=.rustfs.sys&path=pool.bin%2Fdd0fd773-a962-4265-b543-783ce83953e9%2Fpart.1&offset=0&length=44";
|
||||
|
||||
@@ -189,11 +278,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_build_auth_headers() {
|
||||
ensure_test_rpc_secret();
|
||||
let url = "http://example.com/api/test";
|
||||
let method = Method::POST;
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
build_auth_headers(url, &method, &mut headers);
|
||||
build_auth_headers(url, &method, &mut headers).expect("auth headers should build");
|
||||
|
||||
// Verify headers are present
|
||||
assert!(headers.contains_key(SIGNATURE_HEADER), "Should contain signature header");
|
||||
@@ -216,25 +306,27 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_build_auth_headers_preserves_existing_request_id() {
|
||||
ensure_test_rpc_secret();
|
||||
let url = "http://example.com/api/test";
|
||||
let method = Method::GET;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(REQUEST_ID_HEADER, HeaderValue::from_static("req-upstream-123"));
|
||||
|
||||
build_auth_headers(url, &method, &mut headers);
|
||||
build_auth_headers(url, &method, &mut headers).expect("auth headers should build");
|
||||
|
||||
assert_eq!(headers.get(REQUEST_ID_HEADER).and_then(|v| v.to_str().ok()), Some("req-upstream-123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_auth_headers_may_set_request_id_from_trace_id() {
|
||||
ensure_test_rpc_secret();
|
||||
let url = "http://example.com/api/test";
|
||||
let method = Method::GET;
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
let span = tracing::info_span!("rpc-test-span");
|
||||
let _guard = span.enter();
|
||||
build_auth_headers(url, &method, &mut headers);
|
||||
build_auth_headers(url, &method, &mut headers).expect("auth headers should build");
|
||||
|
||||
if let Some(value) = headers.get(REQUEST_ID_HEADER).and_then(|v| v.to_str().ok()) {
|
||||
assert!(!value.is_empty(), "request id should not be empty");
|
||||
@@ -243,12 +335,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_verify_rpc_signature_success() {
|
||||
ensure_test_rpc_secret();
|
||||
let url = "http://example.com/api/test";
|
||||
let method = Method::GET;
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
// Build headers with valid signature
|
||||
build_auth_headers(url, &method, &mut headers);
|
||||
build_auth_headers(url, &method, &mut headers).expect("auth headers should build");
|
||||
|
||||
// Verify should succeed
|
||||
let result = verify_rpc_signature(url, &method, &headers);
|
||||
@@ -257,12 +350,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_verify_rpc_signature_invalid_signature() {
|
||||
ensure_test_rpc_secret();
|
||||
let url = "http://example.com/api/test";
|
||||
let method = Method::GET;
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
// Build headers with valid signature first
|
||||
build_auth_headers(url, &method, &mut headers);
|
||||
build_auth_headers(url, &method, &mut headers).expect("auth headers should build");
|
||||
|
||||
// Tamper with the signature
|
||||
headers.insert(SIGNATURE_HEADER, HeaderValue::from_str("invalid-signature").unwrap());
|
||||
@@ -275,15 +369,65 @@ mod tests {
|
||||
assert_eq!(error.to_string(), "Invalid signature");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_signature_uses_hmac_verification() {
|
||||
let secret = "test-secret";
|
||||
let url = "http://example.com/api/test";
|
||||
let method = Method::GET;
|
||||
let timestamp = 1640995200;
|
||||
let signature = generate_signature(secret, url, &method, timestamp);
|
||||
let mut tampered = general_purpose::STANDARD.decode(&signature).unwrap();
|
||||
tampered[0] ^= 1;
|
||||
let tampered_signature = general_purpose::STANDARD.encode(tampered);
|
||||
|
||||
assert!(verify_signature(secret, url, &method, timestamp, &signature));
|
||||
assert!(!verify_signature(secret, url, &method, timestamp, &tampered_signature));
|
||||
assert!(!verify_signature(secret, url, &method, timestamp, "invalid-signature"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_signature_log_contract_excludes_secrets() {
|
||||
ensure_test_rpc_secret();
|
||||
let url = "http://example.com/api/test";
|
||||
let method = Method::GET;
|
||||
let timestamp = OffsetDateTime::now_utc().unix_timestamp();
|
||||
let secret = get_shared_secret().expect("test RPC secret should resolve");
|
||||
let expected_signature = generate_signature(&secret, url, &method, timestamp);
|
||||
let invalid_signature = "invalid-signature";
|
||||
let logs = CapturedLogs::default();
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::ERROR)
|
||||
.with_writer(logs.clone())
|
||||
.with_ansi(false)
|
||||
.without_time()
|
||||
.finish();
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(SIGNATURE_HEADER, HeaderValue::from_str(invalid_signature).unwrap());
|
||||
headers.insert(TIMESTAMP_HEADER, HeaderValue::from_str(×tamp.to_string()).unwrap());
|
||||
|
||||
tracing::subscriber::with_default(subscriber, || {
|
||||
let result = verify_rpc_signature(url, &method, &headers);
|
||||
assert!(result.is_err(), "Invalid signature should fail verification");
|
||||
});
|
||||
|
||||
let captured = logs.contents();
|
||||
assert!(captured.contains("Invalid signature"));
|
||||
assert!(!captured.contains(&secret));
|
||||
assert!(!captured.contains(&expected_signature));
|
||||
assert!(!captured.contains(invalid_signature));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_rpc_signature_expired_timestamp() {
|
||||
ensure_test_rpc_secret();
|
||||
let url = "http://example.com/api/test";
|
||||
let method = Method::GET;
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
// Set expired timestamp (older than SIGNATURE_VALID_DURATION)
|
||||
let expired_timestamp = OffsetDateTime::now_utc().unix_timestamp() - SIGNATURE_VALID_DURATION - 10;
|
||||
let secret = get_shared_secret();
|
||||
let secret = get_shared_secret().expect("test RPC secret should resolve");
|
||||
let signature = generate_signature(&secret, url, &method, expired_timestamp);
|
||||
|
||||
headers.insert(SIGNATURE_HEADER, HeaderValue::from_str(&signature).unwrap());
|
||||
@@ -297,6 +441,27 @@ mod tests {
|
||||
assert_eq!(error.to_string(), "Request timestamp expired");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_rpc_signature_future_timestamp_outside_window() {
|
||||
ensure_test_rpc_secret();
|
||||
let url = "http://example.com/api/test";
|
||||
let method = Method::GET;
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
let future_timestamp = OffsetDateTime::now_utc().unix_timestamp() + SIGNATURE_VALID_DURATION + 10;
|
||||
let secret = get_shared_secret().expect("test RPC secret should resolve");
|
||||
let signature = generate_signature(&secret, url, &method, future_timestamp);
|
||||
|
||||
headers.insert(SIGNATURE_HEADER, HeaderValue::from_str(&signature).unwrap());
|
||||
headers.insert(TIMESTAMP_HEADER, HeaderValue::from_str(&future_timestamp.to_string()).unwrap());
|
||||
|
||||
let result = verify_rpc_signature(url, &method, &headers);
|
||||
assert!(result.is_err(), "Future timestamp outside valid window should fail verification");
|
||||
|
||||
let error = result.unwrap_err();
|
||||
assert_eq!(error.to_string(), "Request timestamp expired");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_rpc_signature_missing_signature_header() {
|
||||
let url = "http://example.com/api/test";
|
||||
@@ -351,13 +516,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_verify_rpc_signature_url_mismatch() {
|
||||
ensure_test_rpc_secret();
|
||||
let original_url = "http://example.com/api/test";
|
||||
let different_url = "http://example.com/api/different";
|
||||
let method = Method::GET;
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
// Build headers for one URL
|
||||
build_auth_headers(original_url, &method, &mut headers);
|
||||
build_auth_headers(original_url, &method, &mut headers).expect("auth headers should build");
|
||||
|
||||
// Try to verify with a different URL
|
||||
let result = verify_rpc_signature(different_url, &method, &headers);
|
||||
@@ -369,13 +535,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_verify_rpc_signature_method_mismatch() {
|
||||
ensure_test_rpc_secret();
|
||||
let url = "http://example.com/api/test";
|
||||
let original_method = Method::GET;
|
||||
let different_method = Method::POST;
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
// Build headers for one method
|
||||
build_auth_headers(url, &original_method, &mut headers);
|
||||
build_auth_headers(url, &original_method, &mut headers).expect("auth headers should build");
|
||||
|
||||
// Try to verify with a different method
|
||||
let result = verify_rpc_signature(url, &different_method, &headers);
|
||||
@@ -387,9 +554,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_signature_valid_duration_boundary() {
|
||||
ensure_test_rpc_secret();
|
||||
let url = "http://example.com/api/test";
|
||||
let method = Method::GET;
|
||||
let secret = get_shared_secret();
|
||||
let secret = get_shared_secret().expect("test RPC secret should resolve");
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
let current_time = OffsetDateTime::now_utc().unix_timestamp();
|
||||
@@ -418,6 +586,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_round_trip_authentication() {
|
||||
ensure_test_rpc_secret();
|
||||
let test_cases = vec![
|
||||
("http://example.com/api/test", Method::GET),
|
||||
("https://api.rustfs.com/v1/bucket", Method::POST),
|
||||
@@ -429,7 +598,7 @@ mod tests {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
// Build authentication headers
|
||||
build_auth_headers(url, &method, &mut headers);
|
||||
build_auth_headers(url, &method, &mut headers).expect("auth headers should build");
|
||||
|
||||
// Verify the signature should succeed
|
||||
let result = verify_rpc_signature(url, &method, &headers);
|
||||
|
||||
@@ -23,7 +23,7 @@ use crate::{
|
||||
use rmp_serde::{Deserializer, Serializer};
|
||||
use rustfs_madmin::{
|
||||
ServerProperties,
|
||||
health::{Cpus, MemInfo, OsInfo, Partitions, ProcInfo, SysConfig, SysErrors, SysService},
|
||||
health::{Cpus, MemInfo, OsInfo, Partitions, ProcInfo, SysConfig, SysErrors, SysServices},
|
||||
metrics::RealtimeMetrics,
|
||||
net::NetInfo,
|
||||
};
|
||||
@@ -361,7 +361,7 @@ impl PeerRestClient {
|
||||
Ok(os_info)
|
||||
}
|
||||
|
||||
pub async fn get_se_linux_info(&self) -> Result<SysService> {
|
||||
pub async fn get_se_linux_info(&self) -> Result<SysServices> {
|
||||
self.finalize_result(
|
||||
async {
|
||||
let mut client = self.get_client().await?;
|
||||
@@ -377,7 +377,7 @@ impl PeerRestClient {
|
||||
let data = response.sys_services;
|
||||
|
||||
let mut buf = Deserializer::new(Cursor::new(data));
|
||||
let sys_services: SysService = Deserialize::deserialize(&mut buf)?;
|
||||
let sys_services: SysServices = Deserialize::deserialize(&mut buf)?;
|
||||
|
||||
Ok(sys_services)
|
||||
}
|
||||
|
||||
@@ -1143,7 +1143,7 @@ impl DiskAPI for RemoteDisk {
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
build_auth_headers(&url, &Method::GET, &mut headers);
|
||||
build_auth_headers(&url, &Method::GET, &mut headers)?;
|
||||
|
||||
let mut reader = HttpReader::new_with_stall_timeout(
|
||||
url,
|
||||
@@ -1196,7 +1196,7 @@ impl DiskAPI for RemoteDisk {
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
build_auth_headers(&url, &Method::GET, &mut headers);
|
||||
build_auth_headers(&url, &Method::GET, &mut headers)?;
|
||||
Ok(Box::new(HttpReader::new(url, Method::GET, headers, None).await?))
|
||||
}
|
||||
|
||||
@@ -1239,7 +1239,7 @@ impl DiskAPI for RemoteDisk {
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
build_auth_headers(&url, &Method::PUT, &mut headers);
|
||||
build_auth_headers(&url, &Method::PUT, &mut headers)?;
|
||||
Ok(Box::new(HttpWriter::new(url, Method::PUT, headers).await?))
|
||||
}
|
||||
|
||||
@@ -1270,7 +1270,7 @@ impl DiskAPI for RemoteDisk {
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
build_auth_headers(&url, &Method::PUT, &mut headers);
|
||||
build_auth_headers(&url, &Method::PUT, &mut headers)?;
|
||||
Ok(Box::new(HttpWriter::new(url, Method::PUT, headers).await?))
|
||||
}
|
||||
|
||||
|
||||
@@ -483,6 +483,10 @@ mod tests {
|
||||
GLOBAL_CONN_MAP.write().await.insert(addr.to_string(), channel);
|
||||
}
|
||||
|
||||
fn ensure_test_rpc_secret() {
|
||||
let _ = rustfs_credentials::GLOBAL_RUSTFS_RPC_SECRET.set("test-rpc-secret".to_string());
|
||||
}
|
||||
|
||||
fn test_lock_request(timeout_duration: Duration) -> LockRequest {
|
||||
LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner-a")
|
||||
.with_acquire_timeout(timeout_duration)
|
||||
@@ -491,6 +495,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remote_client_acquire_lock_respects_request_timeout_and_evicts_connection() {
|
||||
ensure_test_rpc_secret();
|
||||
let (addr, accept_task) = spawn_hanging_listener().await;
|
||||
cache_lazy_channel(&addr).await;
|
||||
assert!(GLOBAL_CONN_MAP.read().await.contains_key(&addr));
|
||||
@@ -517,6 +522,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remote_client_acquire_locks_batch_respects_request_timeout_and_evicts_connection() {
|
||||
ensure_test_rpc_secret();
|
||||
let (addr, accept_task) = spawn_hanging_listener().await;
|
||||
cache_lazy_channel(&addr).await;
|
||||
assert!(GLOBAL_CONN_MAP.read().await.contains_key(&addr));
|
||||
|
||||
@@ -1446,7 +1446,6 @@ impl ObjectOperations for SetDisks {
|
||||
}
|
||||
};
|
||||
|
||||
let inline_data = fi.inline_data();
|
||||
fi.metadata = src_info.user_defined.clone();
|
||||
|
||||
if let Some(etag) = &src_info.etag {
|
||||
@@ -1454,27 +1453,50 @@ impl ObjectOperations for SetDisks {
|
||||
}
|
||||
|
||||
let mod_time = OffsetDateTime::now_utc();
|
||||
fi.mod_time = Some(mod_time);
|
||||
fi.version_id = version_id;
|
||||
fi.versioned = src_opts.versioned || src_opts.version_suspended;
|
||||
|
||||
for fi in metas.iter_mut() {
|
||||
if fi.is_valid() {
|
||||
fi.metadata = src_info.user_defined.clone();
|
||||
fi.mod_time = Some(mod_time);
|
||||
fi.version_id = version_id;
|
||||
fi.versioned = src_opts.versioned || src_opts.version_suspended;
|
||||
if src_info.version_only {
|
||||
let inline_data = fi.inline_data();
|
||||
|
||||
if !fi.inline_data() {
|
||||
fi.data = None;
|
||||
}
|
||||
for fi in metas.iter_mut() {
|
||||
if fi.is_valid() {
|
||||
fi.metadata = src_info.user_defined.clone();
|
||||
if let Some(etag) = &src_info.etag {
|
||||
fi.metadata.insert("etag".to_owned(), etag.clone());
|
||||
}
|
||||
fi.mod_time = Some(mod_time);
|
||||
fi.version_id = version_id;
|
||||
fi.versioned = src_opts.versioned || src_opts.version_suspended;
|
||||
|
||||
if inline_data {
|
||||
fi.set_inline_data();
|
||||
if !fi.inline_data() {
|
||||
fi.data = None;
|
||||
}
|
||||
|
||||
if inline_data {
|
||||
fi.set_inline_data();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self::write_unique_file_info(&online_disks, "", src_bucket, src_object, &metas, write_quorum)
|
||||
Self::write_unique_file_info(&online_disks, "", src_bucket, src_object, &metas, write_quorum)
|
||||
.await
|
||||
.map_err(|e| to_object_err(e.into(), vec![src_bucket, src_object]))?;
|
||||
} else {
|
||||
self.update_object_meta_with_opts(
|
||||
src_bucket,
|
||||
src_object,
|
||||
fi.clone(),
|
||||
&online_disks,
|
||||
&UpdateMetadataOpts {
|
||||
replace_user_metadata: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| to_object_err(e.into(), vec![src_bucket, src_object]))?;
|
||||
}
|
||||
|
||||
Ok(ObjectInfo::from_file_info(
|
||||
&fi,
|
||||
|
||||
@@ -17,6 +17,16 @@ use std::future::Future;
|
||||
use std::time::Duration;
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
fn empty_upload_fallback_possible(successful_responses: usize, errs: &[Option<DiskError>]) -> bool {
|
||||
successful_responses == 0
|
||||
&& errs.iter().any(|err| matches!(err, Some(DiskError::FileNotFound)))
|
||||
&& errs.iter().all(|err| match err {
|
||||
Some(DiskError::FileNotFound) => true,
|
||||
Some(err) => OBJECT_OP_IGNORED_ERRS.contains(err),
|
||||
None => false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn collect_list_parts_results<F>(
|
||||
tasks: Vec<F>,
|
||||
read_quorum: usize,
|
||||
@@ -49,11 +59,19 @@ where
|
||||
Err(_) => {}
|
||||
}
|
||||
|
||||
if successful_responses + pending < read_quorum {
|
||||
if successful_responses + pending < read_quorum && !empty_upload_fallback_possible(successful_responses, &errs) {
|
||||
return Err(DiskError::ErasureReadQuorum);
|
||||
}
|
||||
}
|
||||
|
||||
if successful_responses < read_quorum {
|
||||
if empty_upload_fallback_possible(successful_responses, &errs) {
|
||||
return Err(DiskError::FileNotFound);
|
||||
}
|
||||
|
||||
return Err(DiskError::ErasureReadQuorum);
|
||||
}
|
||||
|
||||
Ok((errs, object_parts))
|
||||
}
|
||||
|
||||
@@ -232,6 +250,50 @@ mod tests {
|
||||
assert_eq!(object_parts.iter().filter(|parts| !parts.is_empty()).count(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collect_list_parts_results_returns_file_not_found_for_empty_upload_dirs() {
|
||||
let tasks: Vec<_> = vec![
|
||||
(5_u64, Err(DiskError::FileNotFound)),
|
||||
(10, Err(DiskError::DiskNotFound)),
|
||||
(12, Err(DiskError::DiskNotFound)),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(delay_ms, outcome)| async move {
|
||||
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||
outcome
|
||||
})
|
||||
.collect();
|
||||
|
||||
let err = collect_list_parts_results(tasks, 2)
|
||||
.await
|
||||
.expect_err("missing multipart directories should be treated as empty uploads");
|
||||
|
||||
assert_eq!(err, DiskError::FileNotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collect_list_parts_results_fails_early_when_file_not_found_fallback_is_impossible() {
|
||||
let started = std::time::Instant::now();
|
||||
let tasks: Vec<_> = vec![
|
||||
(5_u64, Err(DiskError::FileNotFound)),
|
||||
(10, Err(DiskError::FileCorrupt)),
|
||||
(250, Err(DiskError::DiskNotFound)),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(delay_ms, outcome)| async move {
|
||||
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||
outcome
|
||||
})
|
||||
.collect();
|
||||
|
||||
let err = collect_list_parts_results(tasks, 2)
|
||||
.await
|
||||
.expect_err("non-ignored errors should preserve early quorum failure");
|
||||
|
||||
assert_eq!(err, DiskError::ErasureReadQuorum);
|
||||
assert!(started.elapsed() < Duration::from_millis(120));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduce_quorum_part_numbers_only_keeps_parts_present_on_quorum_of_drives() {
|
||||
let object_parts = vec![
|
||||
|
||||
@@ -402,7 +402,7 @@ impl SetDisks {
|
||||
disks: &[Option<DiskStore>],
|
||||
opts: &UpdateMetadataOpts,
|
||||
) -> disk::error::Result<()> {
|
||||
if fi.metadata.is_empty() {
|
||||
if fi.metadata.is_empty() && !opts.replace_user_metadata {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
s3s.workspace = true
|
||||
regex.workspace = true
|
||||
arc-swap.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { workspace = true }
|
||||
|
||||
@@ -201,6 +201,10 @@ impl FileMeta {
|
||||
}
|
||||
|
||||
pub fn update_object_version(&mut self, fi: FileInfo) -> Result<()> {
|
||||
self.update_object_version_with_opts(fi, false)
|
||||
}
|
||||
|
||||
pub fn update_object_version_with_opts(&mut self, fi: FileInfo, replace_user_metadata: bool) -> Result<()> {
|
||||
for version in self.versions.iter_mut() {
|
||||
match version.header.version_type {
|
||||
VersionType::Invalid | VersionType::Legacy => (),
|
||||
@@ -213,6 +217,10 @@ impl FileMeta {
|
||||
let mut ver = FileMetaVersion::try_from(version.meta.as_slice())?;
|
||||
|
||||
if let Some(ref mut obj) = ver.object {
|
||||
if replace_user_metadata {
|
||||
obj.meta_user.clear();
|
||||
}
|
||||
|
||||
for (k, v) in fi.metadata.iter() {
|
||||
// Split metadata into meta_user and meta_sys based on prefix
|
||||
// This logic must match From<FileInfo> for MetaObject
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::{
|
||||
Error, FileInfo, FileInfoOpts, FileInfoVersions, FileMeta, FileMetaShallowVersion, Result, VersionType, get_file_info,
|
||||
merge_file_meta_versions,
|
||||
};
|
||||
use arc_swap::ArcSwapOption;
|
||||
use rmp::Marker;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Ordering;
|
||||
@@ -24,10 +25,9 @@ use std::{
|
||||
fmt::Debug,
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
ptr,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicPtr, AtomicU64, Ordering as AtomicOrdering},
|
||||
atomic::{AtomicU64, Ordering as AtomicOrdering},
|
||||
},
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
@@ -767,100 +767,74 @@ pub struct Cache<T: Clone + Debug + Send> {
|
||||
update_fn: UpdateFn<T>,
|
||||
ttl: Duration,
|
||||
opts: Opts,
|
||||
val: AtomicPtr<T>,
|
||||
last_update_ms: AtomicU64,
|
||||
updating: Arc<Mutex<bool>>,
|
||||
val: ArcSwapOption<T>,
|
||||
last_update_secs: AtomicU64,
|
||||
updating: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
impl<T: Clone + Debug + Send + 'static> Cache<T> {
|
||||
impl<T: Clone + Debug + Send + Sync + 'static> Cache<T> {
|
||||
pub fn new(update_fn: UpdateFn<T>, ttl: Duration, opts: Opts) -> Self {
|
||||
let val = AtomicPtr::new(ptr::null_mut());
|
||||
Self {
|
||||
update_fn,
|
||||
ttl,
|
||||
opts,
|
||||
val,
|
||||
last_update_ms: AtomicU64::new(0),
|
||||
updating: Arc::new(Mutex::new(false)),
|
||||
val: ArcSwapOption::from(None),
|
||||
last_update_secs: AtomicU64::new(0),
|
||||
updating: Arc::new(Mutex::new(())),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unsafe_code)]
|
||||
pub async fn get(self: Arc<Self>) -> std::io::Result<T> {
|
||||
let v_ptr = self.val.load(AtomicOrdering::SeqCst);
|
||||
let v = if v_ptr.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(unsafe { (*v_ptr).clone() })
|
||||
};
|
||||
let value = self.get_shared().await?;
|
||||
Ok(value.as_ref().clone())
|
||||
}
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_secs();
|
||||
if now - self.last_update_ms.load(AtomicOrdering::SeqCst) < self.ttl.as_secs()
|
||||
&& let Some(v) = v
|
||||
pub async fn get_shared(self: Arc<Self>) -> std::io::Result<Arc<T>> {
|
||||
let now = Self::current_unix_secs();
|
||||
let current = self.cached_value();
|
||||
if self.age_since_last_update(now) < self.ttl.as_secs()
|
||||
&& let Some(value) = current.clone()
|
||||
{
|
||||
return Ok(v);
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
if self.opts.no_wait
|
||||
&& now - self.last_update_ms.load(AtomicOrdering::SeqCst) < self.ttl.as_secs() * 2
|
||||
&& let Some(value) = v
|
||||
&& self.age_since_last_update(now) < self.ttl.as_secs().saturating_mul(2)
|
||||
&& let Some(value) = current
|
||||
{
|
||||
if self.updating.try_lock().is_ok() {
|
||||
if let Ok(update_guard) = Arc::clone(&self.updating).try_lock_owned() {
|
||||
let this = Arc::clone(&self);
|
||||
spawn(async move {
|
||||
let _guard = update_guard;
|
||||
let _ = this.update().await;
|
||||
});
|
||||
}
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
let _ = self.updating.lock().await;
|
||||
let _guard = self.updating.lock().await;
|
||||
|
||||
if let (Ok(duration), Some(value)) = (
|
||||
SystemTime::now().duration_since(UNIX_EPOCH + Duration::from_secs(self.last_update_ms.load(AtomicOrdering::SeqCst))),
|
||||
v,
|
||||
) && duration < self.ttl
|
||||
let now = Self::current_unix_secs();
|
||||
if self.age_since_last_update(now) < self.ttl.as_secs()
|
||||
&& let Some(value) = self.cached_value()
|
||||
{
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
match self.update().await {
|
||||
Ok(_) => {
|
||||
let v_ptr = self.val.load(AtomicOrdering::SeqCst);
|
||||
let v = if v_ptr.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(unsafe { (*v_ptr).clone() })
|
||||
};
|
||||
Ok(v.unwrap())
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
self.update().await?;
|
||||
self.cached_value()
|
||||
.ok_or_else(|| std::io::Error::other("cache update completed without a value"))
|
||||
}
|
||||
|
||||
#[allow(unsafe_code)]
|
||||
async fn update(&self) -> std::io::Result<()> {
|
||||
match (self.update_fn)().await {
|
||||
Ok(val) => {
|
||||
let old = self.val.swap(Box::into_raw(Box::new(val)), AtomicOrdering::SeqCst);
|
||||
if !old.is_null() {
|
||||
unsafe {
|
||||
drop(Box::from_raw(old));
|
||||
}
|
||||
}
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_secs();
|
||||
self.last_update_ms.store(now, AtomicOrdering::SeqCst);
|
||||
self.val.store(Some(Arc::new(val)));
|
||||
self.last_update_secs.store(Self::current_unix_secs(), AtomicOrdering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
let v_ptr = self.val.load(AtomicOrdering::SeqCst);
|
||||
if self.opts.return_last_good && !v_ptr.is_null() {
|
||||
if self.opts.return_last_good && self.cached_value().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -868,6 +842,23 @@ impl<T: Clone + Debug + Send + 'static> Cache<T> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn current_unix_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn age_since_last_update(&self, now_secs: u64) -> u64 {
|
||||
now_secs
|
||||
.checked_sub(self.last_update_secs.load(AtomicOrdering::SeqCst))
|
||||
.unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
fn cached_value(&self) -> Option<Arc<T>> {
|
||||
self.val.load_full()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -877,6 +868,11 @@ mod tests {
|
||||
use crate::{FileMetaVersion, MetaDeleteMarker};
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
use std::sync::{
|
||||
Arc, Mutex as StdMutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use tokio::sync::{Notify, oneshot};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -964,4 +960,316 @@ mod tests {
|
||||
assert_eq!(decoded.versions, cached.versions);
|
||||
assert_ne!(extended_versions, cached.versions.len());
|
||||
}
|
||||
|
||||
fn build_hashmap_cache(update_size: usize) -> Arc<Cache<HashMap<usize, usize>>> {
|
||||
let generation = Arc::new(AtomicUsize::new(0));
|
||||
Arc::new(Cache::new(
|
||||
Box::new(move || {
|
||||
let generation = Arc::clone(&generation);
|
||||
Box::pin(async move {
|
||||
let v = generation.fetch_add(1, Ordering::SeqCst);
|
||||
let mut m = HashMap::with_capacity(update_size);
|
||||
for i in 0..update_size {
|
||||
m.insert(i, i ^ v);
|
||||
}
|
||||
Ok(m)
|
||||
})
|
||||
}),
|
||||
Duration::ZERO,
|
||||
Opts::default(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn run_cache_workload(cache: Arc<Cache<HashMap<usize, usize>>>, workers: usize, rounds: usize, probe_mod: usize) {
|
||||
let mut tasks = Vec::with_capacity(workers);
|
||||
for worker in 0..workers {
|
||||
let cache = Arc::clone(&cache);
|
||||
tasks.push(tokio::spawn(async move {
|
||||
for round in 0..rounds {
|
||||
let m = Arc::clone(&cache).get().await.expect("cache get should succeed");
|
||||
let key = (worker.wrapping_mul(17).wrapping_add(round)) % probe_mod;
|
||||
assert!(m.contains_key(&key), "expected key {key} to exist");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
task.await.expect("worker task should not panic");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
|
||||
async fn test_cache_concurrency_smoke() {
|
||||
let cache = build_hashmap_cache(2048);
|
||||
run_cache_workload(cache, 32, 120, 2048).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_get_shared_reuses_fresh_value() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let cache = Arc::new(Cache::new(
|
||||
Box::new({
|
||||
let calls = Arc::clone(&calls);
|
||||
move || {
|
||||
let calls = Arc::clone(&calls);
|
||||
Box::pin(async move { Ok(calls.fetch_add(1, Ordering::SeqCst)) })
|
||||
}
|
||||
}),
|
||||
Duration::from_secs(60),
|
||||
Opts::default(),
|
||||
));
|
||||
|
||||
let first = Arc::clone(&cache).get_shared().await.expect("prime cache should succeed");
|
||||
let second = Arc::clone(&cache).get_shared().await.expect("fresh cache hit should succeed");
|
||||
|
||||
assert!(Arc::ptr_eq(&first, &second));
|
||||
assert_eq!(*first, 0);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_future_last_update_refreshes_instead_of_underflowing() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let cache = Arc::new(Cache::new(
|
||||
Box::new({
|
||||
let calls = Arc::clone(&calls);
|
||||
move || {
|
||||
let calls = Arc::clone(&calls);
|
||||
Box::pin(async move { Ok(calls.fetch_add(1, Ordering::SeqCst)) })
|
||||
}
|
||||
}),
|
||||
Duration::from_secs(60),
|
||||
Opts::default(),
|
||||
));
|
||||
|
||||
let prime = Arc::clone(&cache).get().await.expect("prime cache should succeed");
|
||||
assert_eq!(prime, 0);
|
||||
|
||||
let now = Cache::<usize>::current_unix_secs();
|
||||
cache.last_update_secs.store(now.saturating_add(60), AtomicOrdering::SeqCst);
|
||||
|
||||
let refreshed = Arc::clone(&cache)
|
||||
.get()
|
||||
.await
|
||||
.expect("future timestamp should force refresh instead of underflowing");
|
||||
assert_eq!(refreshed, 1);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn test_cache_no_wait_returns_stale_and_refreshes_in_background() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let (bg_started_tx, bg_started_rx) = oneshot::channel::<()>();
|
||||
let (release_bg_tx, release_bg_rx) = oneshot::channel::<()>();
|
||||
let bg_started_tx = Arc::new(StdMutex::new(Some(bg_started_tx)));
|
||||
let release_bg_rx = Arc::new(StdMutex::new(Some(release_bg_rx)));
|
||||
|
||||
let cache = Arc::new(Cache::new(
|
||||
Box::new({
|
||||
let calls = Arc::clone(&calls);
|
||||
let bg_started_tx = Arc::clone(&bg_started_tx);
|
||||
let release_bg_rx = Arc::clone(&release_bg_rx);
|
||||
move || {
|
||||
let calls = Arc::clone(&calls);
|
||||
let bg_started_tx = Arc::clone(&bg_started_tx);
|
||||
let release_bg_rx = Arc::clone(&release_bg_rx);
|
||||
Box::pin(async move {
|
||||
let call = calls.fetch_add(1, Ordering::SeqCst);
|
||||
if call == 1 {
|
||||
let tx = { bg_started_tx.lock().expect("start sender lock should not poison").take() };
|
||||
if let Some(tx) = tx {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
let rx = { release_bg_rx.lock().expect("release receiver lock should not poison").take() };
|
||||
if let Some(rx) = rx {
|
||||
let _ = rx.await;
|
||||
}
|
||||
}
|
||||
Ok(call)
|
||||
})
|
||||
}
|
||||
}),
|
||||
Duration::from_secs(1),
|
||||
Opts {
|
||||
return_last_good: true,
|
||||
no_wait: true,
|
||||
},
|
||||
));
|
||||
|
||||
let prime = Arc::clone(&cache).get().await.expect("prime cache should succeed");
|
||||
assert_eq!(prime, 0);
|
||||
|
||||
let now = Cache::<usize>::current_unix_secs();
|
||||
cache.last_update_secs.store(now.saturating_sub(1), AtomicOrdering::SeqCst);
|
||||
|
||||
let stale = tokio::time::timeout(Duration::from_millis(200), Arc::clone(&cache).get())
|
||||
.await
|
||||
.expect("no_wait path should return without waiting for refresh")
|
||||
.expect("stale get should succeed");
|
||||
assert_eq!(stale, 0);
|
||||
|
||||
tokio::time::timeout(Duration::from_millis(200), bg_started_rx)
|
||||
.await
|
||||
.expect("background refresh should start")
|
||||
.expect("background start signal should be delivered");
|
||||
|
||||
release_bg_tx.send(()).expect("release signal should be delivered");
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
if cache.cached_value().as_deref() == Some(&1) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("background refresh should complete");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn test_cache_no_wait_coalesces_background_refreshes() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let release_refresh = Arc::new(Notify::new());
|
||||
let ttl = Duration::from_secs(60);
|
||||
let cache = Arc::new(Cache::new(
|
||||
Box::new({
|
||||
let calls = Arc::clone(&calls);
|
||||
let release_refresh = Arc::clone(&release_refresh);
|
||||
move || {
|
||||
let calls = Arc::clone(&calls);
|
||||
let release_refresh = Arc::clone(&release_refresh);
|
||||
Box::pin(async move {
|
||||
let call = calls.fetch_add(1, Ordering::SeqCst);
|
||||
if call > 0 {
|
||||
release_refresh.notified().await;
|
||||
}
|
||||
Ok(call)
|
||||
})
|
||||
}
|
||||
}),
|
||||
ttl,
|
||||
Opts {
|
||||
return_last_good: true,
|
||||
no_wait: true,
|
||||
},
|
||||
));
|
||||
|
||||
let prime = Arc::clone(&cache).get().await.expect("prime cache should succeed");
|
||||
assert_eq!(prime, 0);
|
||||
|
||||
let now = Cache::<usize>::current_unix_secs();
|
||||
cache
|
||||
.last_update_secs
|
||||
.store(now.saturating_sub(ttl.as_secs()), AtomicOrdering::SeqCst);
|
||||
|
||||
let stale = Arc::clone(&cache).get().await.expect("stale get should succeed");
|
||||
assert_eq!(stale, 0);
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
if calls.load(Ordering::SeqCst) == 2 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("background refresh should start");
|
||||
|
||||
let mut readers = Vec::new();
|
||||
for _ in 0..8 {
|
||||
let cache = Arc::clone(&cache);
|
||||
readers.push(tokio::spawn(
|
||||
async move { Arc::clone(&cache).get().await.expect("stale get should succeed") },
|
||||
));
|
||||
}
|
||||
|
||||
for reader in readers {
|
||||
assert_eq!(reader.await.expect("reader task should not panic"), 0);
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
|
||||
release_refresh.notify_waiters();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_return_last_good_on_refresh_error() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let cache = Arc::new(Cache::new(
|
||||
Box::new({
|
||||
let calls = Arc::clone(&calls);
|
||||
move || {
|
||||
let calls = Arc::clone(&calls);
|
||||
Box::pin(async move {
|
||||
let call = calls.fetch_add(1, Ordering::SeqCst);
|
||||
if call == 0 {
|
||||
Ok(42usize)
|
||||
} else {
|
||||
Err(std::io::Error::other("refresh failed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}),
|
||||
Duration::from_secs(1),
|
||||
Opts {
|
||||
return_last_good: true,
|
||||
no_wait: false,
|
||||
},
|
||||
));
|
||||
|
||||
let prime = Arc::clone(&cache).get().await.expect("prime cache should succeed");
|
||||
assert_eq!(prime, 42);
|
||||
|
||||
let now = Cache::<usize>::current_unix_secs();
|
||||
cache.last_update_secs.store(now.saturating_sub(2), AtomicOrdering::SeqCst);
|
||||
|
||||
let stale = Arc::clone(&cache)
|
||||
.get()
|
||||
.await
|
||||
.expect("return_last_good should keep stale value");
|
||||
assert_eq!(stale, 42);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_refresh_error_without_return_last_good() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let cache = Arc::new(Cache::new(
|
||||
Box::new({
|
||||
let calls = Arc::clone(&calls);
|
||||
move || {
|
||||
let calls = Arc::clone(&calls);
|
||||
Box::pin(async move {
|
||||
let call = calls.fetch_add(1, Ordering::SeqCst);
|
||||
if call == 0 {
|
||||
Ok(7usize)
|
||||
} else {
|
||||
Err(std::io::Error::other("refresh failed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}),
|
||||
Duration::from_secs(1),
|
||||
Opts {
|
||||
return_last_good: false,
|
||||
no_wait: false,
|
||||
},
|
||||
));
|
||||
|
||||
let prime = Arc::clone(&cache).get().await.expect("prime cache should succeed");
|
||||
assert_eq!(prime, 7);
|
||||
|
||||
let now = Cache::<usize>::current_unix_secs();
|
||||
cache.last_update_secs.store(now.saturating_sub(2), AtomicOrdering::SeqCst);
|
||||
|
||||
let err = Arc::clone(&cache)
|
||||
.get()
|
||||
.await
|
||||
.expect_err("refresh error should be propagated when return_last_good is false");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::Other);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,42 @@ pub struct LockShard {
|
||||
active_guards: parking_lot::Mutex<HashSet<u64>>,
|
||||
}
|
||||
|
||||
/// Cancellation-safe waiter counter ticket.
|
||||
///
|
||||
/// Ensures waiting counters are decremented even if the waiting future
|
||||
/// is cancelled/dropped before the normal post-await path runs.
|
||||
struct WaiterCounterGuard {
|
||||
state: Arc<ObjectLockState>,
|
||||
mode: LockMode,
|
||||
incremented: bool,
|
||||
}
|
||||
|
||||
impl WaiterCounterGuard {
|
||||
fn new(state: Arc<ObjectLockState>, mode: LockMode) -> Self {
|
||||
let incremented = match mode {
|
||||
LockMode::Shared => state.atomic_state.inc_readers_waiting(),
|
||||
LockMode::Exclusive => state.atomic_state.inc_writers_waiting(),
|
||||
};
|
||||
Self {
|
||||
state,
|
||||
mode,
|
||||
incremented,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WaiterCounterGuard {
|
||||
fn drop(&mut self) {
|
||||
if !self.incremented {
|
||||
return;
|
||||
}
|
||||
match self.mode {
|
||||
LockMode::Shared => self.state.atomic_state.dec_readers_waiting(),
|
||||
LockMode::Exclusive => self.state.atomic_state.dec_writers_waiting(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LockShard {
|
||||
pub fn new(shard_id: usize) -> Self {
|
||||
Self {
|
||||
@@ -184,16 +220,12 @@ impl LockShard {
|
||||
// If we've exhausted quick retries or have little time left, use notification wait
|
||||
let wait_result = match request.mode {
|
||||
LockMode::Shared => {
|
||||
state.atomic_state.inc_readers_waiting();
|
||||
let result = timeout(remaining, state.optimized_notify.wait_for_read()).await;
|
||||
state.atomic_state.dec_readers_waiting();
|
||||
result
|
||||
let _waiter_guard = WaiterCounterGuard::new(state.clone(), LockMode::Shared);
|
||||
timeout(remaining, state.optimized_notify.wait_for_read()).await
|
||||
}
|
||||
LockMode::Exclusive => {
|
||||
state.atomic_state.inc_writers_waiting();
|
||||
let result = timeout(remaining, state.optimized_notify.wait_for_write()).await;
|
||||
state.atomic_state.dec_writers_waiting();
|
||||
result
|
||||
let _waiter_guard = WaiterCounterGuard::new(state.clone(), LockMode::Exclusive);
|
||||
timeout(remaining, state.optimized_notify.wait_for_write()).await
|
||||
}
|
||||
};
|
||||
|
||||
@@ -784,4 +816,135 @@ mod tests {
|
||||
let lock_info = shard.get_lock_info(&obj1_key);
|
||||
assert!(lock_info.is_some(), "obj1 should still be locked by blocking_owner");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_exclusive_waiter_abort_does_not_block_following_shared_lock() {
|
||||
let shard = Arc::new(LockShard::new(0));
|
||||
let key = ObjectKey::new("bucket", "abort-waiter-key");
|
||||
|
||||
let owner1: Arc<str> = Arc::from("writer-owner-1");
|
||||
let owner2: Arc<str> = Arc::from("writer-owner-2");
|
||||
let reader_owner: Arc<str> = Arc::from("reader-owner");
|
||||
|
||||
let hold_writer = ObjectLockRequest {
|
||||
key: key.clone(),
|
||||
mode: LockMode::Exclusive,
|
||||
owner: owner1.clone(),
|
||||
acquire_timeout: Duration::from_secs(1),
|
||||
lock_timeout: Duration::from_secs(30),
|
||||
priority: LockPriority::Normal,
|
||||
};
|
||||
|
||||
assert!(shard.acquire_lock(&hold_writer).await.is_ok());
|
||||
|
||||
let contended_writer = ObjectLockRequest {
|
||||
key: key.clone(),
|
||||
mode: LockMode::Exclusive,
|
||||
owner: owner2.clone(),
|
||||
acquire_timeout: Duration::from_secs(5),
|
||||
lock_timeout: Duration::from_secs(30),
|
||||
priority: LockPriority::Normal,
|
||||
};
|
||||
|
||||
let shard_for_waiter = shard.clone();
|
||||
let waiter_handle = tokio::spawn(async move { shard_for_waiter.acquire_lock(&contended_writer).await });
|
||||
|
||||
// Ensure we actually enter slow-path wait registration before aborting.
|
||||
tokio::time::timeout(Duration::from_secs(3), async {
|
||||
loop {
|
||||
if let Some(state) = shard.objects.read().get(&key).cloned()
|
||||
&& state.atomic_state.writers_waiting_count() > 0
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("timed out waiting for contended writer to register as waiting");
|
||||
waiter_handle.abort();
|
||||
let _ = waiter_handle.await;
|
||||
|
||||
assert!(shard.release_lock(&key, &owner1, LockMode::Exclusive));
|
||||
|
||||
let followup_reader = ObjectLockRequest {
|
||||
key: key.clone(),
|
||||
mode: LockMode::Shared,
|
||||
owner: reader_owner.clone(),
|
||||
acquire_timeout: Duration::from_millis(200),
|
||||
lock_timeout: Duration::from_secs(30),
|
||||
priority: LockPriority::Normal,
|
||||
};
|
||||
|
||||
assert!(
|
||||
shard.acquire_lock(&followup_reader).await.is_ok(),
|
||||
"shared lock should succeed after writer waiter task is aborted"
|
||||
);
|
||||
assert!(shard.release_lock(&key, &reader_owner, LockMode::Shared));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_shared_waiter_abort_does_not_block_following_exclusive_lock() {
|
||||
let shard = Arc::new(LockShard::new(0));
|
||||
let key = ObjectKey::new("bucket", "abort-reader-waiter-key");
|
||||
|
||||
let writer_owner: Arc<str> = Arc::from("writer-owner");
|
||||
let reader_owner: Arc<str> = Arc::from("reader-owner");
|
||||
let followup_owner: Arc<str> = Arc::from("followup-writer-owner");
|
||||
|
||||
let hold_writer = ObjectLockRequest {
|
||||
key: key.clone(),
|
||||
mode: LockMode::Exclusive,
|
||||
owner: writer_owner.clone(),
|
||||
acquire_timeout: Duration::from_secs(1),
|
||||
lock_timeout: Duration::from_secs(30),
|
||||
priority: LockPriority::Normal,
|
||||
};
|
||||
|
||||
assert!(shard.acquire_lock(&hold_writer).await.is_ok());
|
||||
|
||||
let contended_reader = ObjectLockRequest {
|
||||
key: key.clone(),
|
||||
mode: LockMode::Shared,
|
||||
owner: reader_owner.clone(),
|
||||
acquire_timeout: Duration::from_secs(5),
|
||||
lock_timeout: Duration::from_secs(30),
|
||||
priority: LockPriority::Normal,
|
||||
};
|
||||
|
||||
let shard_for_waiter = shard.clone();
|
||||
let waiter_handle = tokio::spawn(async move { shard_for_waiter.acquire_lock(&contended_reader).await });
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(3), async {
|
||||
loop {
|
||||
if let Some(state) = shard.objects.read().get(&key).cloned()
|
||||
&& state.atomic_state.readers_waiting_count() > 0
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("timed out waiting for contended reader to register as waiting");
|
||||
waiter_handle.abort();
|
||||
let _ = waiter_handle.await;
|
||||
|
||||
assert!(shard.release_lock(&key, &writer_owner, LockMode::Exclusive));
|
||||
|
||||
let followup_writer = ObjectLockRequest {
|
||||
key: key.clone(),
|
||||
mode: LockMode::Exclusive,
|
||||
owner: followup_owner.clone(),
|
||||
acquire_timeout: Duration::from_millis(200),
|
||||
lock_timeout: Duration::from_secs(30),
|
||||
priority: LockPriority::Normal,
|
||||
};
|
||||
|
||||
assert!(
|
||||
shard.acquire_lock(&followup_writer).await.is_ok(),
|
||||
"exclusive lock should succeed after reader waiter task is aborted"
|
||||
);
|
||||
assert!(shard.release_lock(&key, &followup_owner, LockMode::Exclusive));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,13 +165,13 @@ impl AtomicLockState {
|
||||
}
|
||||
|
||||
/// Increment waiting readers count
|
||||
pub fn inc_readers_waiting(&self) {
|
||||
pub fn inc_readers_waiting(&self) -> bool {
|
||||
loop {
|
||||
let current = self.state.load(Ordering::Acquire);
|
||||
let waiting = self.readers_waiting(current);
|
||||
|
||||
if waiting == 0xFFFF {
|
||||
break; // Max waiting readers
|
||||
return false; // Max waiting readers
|
||||
}
|
||||
|
||||
let new_state = current + (1 << READERS_WAITING_SHIFT);
|
||||
@@ -181,7 +181,7 @@ impl AtomicLockState {
|
||||
.compare_exchange_weak(current, new_state, Ordering::AcqRel, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
break;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,13 +209,13 @@ impl AtomicLockState {
|
||||
}
|
||||
|
||||
/// Increment waiting writers count
|
||||
pub fn inc_writers_waiting(&self) {
|
||||
pub fn inc_writers_waiting(&self) -> bool {
|
||||
loop {
|
||||
let current = self.state.load(Ordering::Acquire);
|
||||
let waiting = self.writers_waiting(current);
|
||||
|
||||
if waiting == 0xFFFF {
|
||||
break; // Max waiting writers
|
||||
return false; // Max waiting writers
|
||||
}
|
||||
|
||||
let new_state = current + (1 << WRITERS_WAITING_SHIFT);
|
||||
@@ -225,7 +225,7 @@ impl AtomicLockState {
|
||||
.compare_exchange_weak(current, new_state, Ordering::AcqRel, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
break;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -288,6 +288,18 @@ impl AtomicLockState {
|
||||
fn writers_waiting(&self, state: u64) -> u16 {
|
||||
((state & WRITERS_WAITING_MASK) >> WRITERS_WAITING_SHIFT) as u16
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn readers_waiting_count(&self) -> u16 {
|
||||
let state = self.state.load(Ordering::Acquire);
|
||||
self.readers_waiting(state)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn writers_waiting_count(&self) -> u16 {
|
||||
let state = self.state.load(Ordering::Acquire);
|
||||
self.writers_waiting(state)
|
||||
}
|
||||
}
|
||||
|
||||
/// Object lock state with version support - optimized memory layout
|
||||
|
||||
@@ -885,8 +885,8 @@ mod tests {
|
||||
network.insert("ip".to_string(), "192.168.1.100".to_string());
|
||||
|
||||
let mut env_vars = HashMap::new();
|
||||
env_vars.insert("RUSTFS_ROOT_USER".to_string(), "admin".to_string());
|
||||
env_vars.insert("RUSTFS_ROOT_PASSWORD".to_string(), "password".to_string());
|
||||
env_vars.insert("RUSTFS_ACCESS_KEY".to_string(), "admin".to_string());
|
||||
env_vars.insert("RUSTFS_SECRET_KEY".to_string(), "password".to_string());
|
||||
|
||||
let server_props = ServerProperties {
|
||||
state: "online".to_string(),
|
||||
|
||||
@@ -24,9 +24,11 @@ use crate::{
|
||||
};
|
||||
use hashbrown::HashMap;
|
||||
use rustfs_config::notify::{
|
||||
DEFAULT_NOTIFY_TARGET_STREAM_CONCURRENCY, ENV_NOTIFY_TARGET_STREAM_CONCURRENCY, NOTIFY_KAFKA_SUB_SYS, NOTIFY_MQTT_SUB_SYS,
|
||||
NOTIFY_NATS_SUB_SYS, NOTIFY_PULSAR_SUB_SYS, NOTIFY_WEBHOOK_SUB_SYS,
|
||||
DEFAULT_NOTIFY_TARGET_STREAM_CONCURRENCY, ENV_NOTIFY_TARGET_STREAM_CONCURRENCY, ENV_NOTIFY_WEBHOOK_ENABLE,
|
||||
ENV_NOTIFY_WEBHOOK_ENDPOINT, NOTIFY_KAFKA_SUB_SYS, NOTIFY_MQTT_SUB_SYS, NOTIFY_NATS_SUB_SYS, NOTIFY_PULSAR_SUB_SYS,
|
||||
NOTIFY_WEBHOOK_SUB_SYS,
|
||||
};
|
||||
use rustfs_config::{ENV_NOTIFY_ENABLE, EVENT_DEFAULT_DIR};
|
||||
use rustfs_ecstore::config::{Config, KVS};
|
||||
use rustfs_s3_common::EventName;
|
||||
use rustfs_targets::arn::TargetID;
|
||||
@@ -42,6 +44,14 @@ use tracing::{debug, info, warn};
|
||||
|
||||
const MAX_RECENT_LIVE_EVENTS: usize = 1024;
|
||||
|
||||
fn notify_configuration_hint() -> String {
|
||||
let webhook_enable_primary = format!("{ENV_NOTIFY_WEBHOOK_ENABLE}_PRIMARY");
|
||||
let webhook_endpoint_primary = format!("{ENV_NOTIFY_WEBHOOK_ENDPOINT}_PRIMARY");
|
||||
format!(
|
||||
"No notify targets configured. Check {ENV_NOTIFY_ENABLE}=true and instance-scoped target env vars (for example {webhook_enable_primary} + {webhook_endpoint_primary} for arn:rustfs:sqs::primary:webhook). If using default queue_dir, ensure {EVENT_DEFAULT_DIR} is writable."
|
||||
)
|
||||
}
|
||||
|
||||
fn subsystem_target_type(target_type: &str) -> &str {
|
||||
match target_type {
|
||||
NOTIFY_WEBHOOK_SUB_SYS => "webhook",
|
||||
@@ -325,6 +335,9 @@ impl NotificationSystem {
|
||||
let targets: Vec<Box<dyn Target<Event> + Send + Sync>> = self.registry.create_targets_from_config(&config).await?;
|
||||
|
||||
info!("{} notification targets were created", targets.len());
|
||||
if targets.is_empty() {
|
||||
warn!("{}", notify_configuration_hint());
|
||||
}
|
||||
|
||||
// Initialize targets and start event streams
|
||||
let cancellers = self.init_targets_and_start_streams(&targets).await;
|
||||
@@ -586,6 +599,9 @@ impl NotificationSystem {
|
||||
.map_err(NotificationError::Target)?;
|
||||
|
||||
info!("{} notification targets were created from the new configuration", targets.len());
|
||||
if targets.is_empty() {
|
||||
warn!("{}", notify_configuration_hint());
|
||||
}
|
||||
|
||||
// Initialize targets and start event streams using shared helper
|
||||
let new_cancellers = self.init_targets_and_start_streams(&targets).await;
|
||||
@@ -607,7 +623,7 @@ impl NotificationSystem {
|
||||
) -> Result<(), NotificationError> {
|
||||
let arn_list = self.notifier.get_arn_list(&cfg.region).await;
|
||||
if arn_list.is_empty() {
|
||||
return Err(NotificationError::Configuration("No targets configured".to_string()));
|
||||
return Err(NotificationError::Configuration(notify_configuration_hint()));
|
||||
}
|
||||
info!("Available ARNs: {:?}", arn_list);
|
||||
// Validate the configuration against the available ARNs
|
||||
|
||||
@@ -21,7 +21,7 @@ use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::convert::TryFrom;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::warn;
|
||||
use tracing::debug;
|
||||
|
||||
const ACCESS_KEY_MIN_LEN: usize = 3;
|
||||
const ACCESS_KEY_MAX_LEN: usize = 128;
|
||||
@@ -144,7 +144,7 @@ pub fn create_new_credentials_with_metadata(
|
||||
}
|
||||
};
|
||||
|
||||
warn!("create_new_credentials_with_metadata expiration {expiration:?}, access_key: {ak}");
|
||||
debug!("create_new_credentials_with_metadata expiration {expiration:?}");
|
||||
|
||||
let token = utils::generate_jwt(&claims, token_secret)?;
|
||||
|
||||
|
||||
@@ -1084,3 +1084,161 @@ where
|
||||
async move { Err(FsError::NotImplemented) }.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::WebDavDriver;
|
||||
use crate::common::client::s3::StorageBackend as S3StorageBackend;
|
||||
use crate::common::session::{Protocol, ProtocolPrincipal, SessionContext};
|
||||
use async_trait::async_trait;
|
||||
use dav_server::davpath::DavPath;
|
||||
use dav_server::fs::FsError;
|
||||
use rustfs_credentials::Credentials;
|
||||
use rustfs_policy::auth::UserIdentity;
|
||||
use s3s::dto::*;
|
||||
use std::fmt::{Debug, Formatter};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DummyStorage;
|
||||
|
||||
impl Debug for DummyStorage {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("DummyStorage")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl S3StorageBackend for DummyStorage {
|
||||
type Error = std::io::Error;
|
||||
|
||||
async fn get_object(
|
||||
&self,
|
||||
_bucket: &str,
|
||||
_key: &str,
|
||||
_access_key: &str,
|
||||
_secret_key: &str,
|
||||
_start_pos: Option<u64>,
|
||||
) -> Result<GetObjectOutput, Self::Error> {
|
||||
unreachable!("parse_path tests should not hit storage")
|
||||
}
|
||||
|
||||
async fn get_object_range(
|
||||
&self,
|
||||
_bucket: &str,
|
||||
_key: &str,
|
||||
_access_key: &str,
|
||||
_secret_key: &str,
|
||||
_start_pos: u64,
|
||||
_length: u64,
|
||||
) -> Result<GetObjectOutput, Self::Error> {
|
||||
unreachable!("parse_path tests should not hit storage")
|
||||
}
|
||||
|
||||
async fn put_object(
|
||||
&self,
|
||||
_input: PutObjectInput,
|
||||
_access_key: &str,
|
||||
_secret_key: &str,
|
||||
) -> Result<PutObjectOutput, Self::Error> {
|
||||
unreachable!("parse_path tests should not hit storage")
|
||||
}
|
||||
|
||||
async fn delete_object(
|
||||
&self,
|
||||
_bucket: &str,
|
||||
_key: &str,
|
||||
_access_key: &str,
|
||||
_secret_key: &str,
|
||||
) -> Result<DeleteObjectOutput, Self::Error> {
|
||||
unreachable!("parse_path tests should not hit storage")
|
||||
}
|
||||
|
||||
async fn head_object(
|
||||
&self,
|
||||
_bucket: &str,
|
||||
_key: &str,
|
||||
_access_key: &str,
|
||||
_secret_key: &str,
|
||||
) -> Result<HeadObjectOutput, Self::Error> {
|
||||
unreachable!("parse_path tests should not hit storage")
|
||||
}
|
||||
|
||||
async fn head_bucket(
|
||||
&self,
|
||||
_bucket: &str,
|
||||
_access_key: &str,
|
||||
_secret_key: &str,
|
||||
) -> Result<HeadBucketOutput, Self::Error> {
|
||||
unreachable!("parse_path tests should not hit storage")
|
||||
}
|
||||
|
||||
async fn list_objects_v2(
|
||||
&self,
|
||||
_input: ListObjectsV2Input,
|
||||
_access_key: &str,
|
||||
_secret_key: &str,
|
||||
) -> Result<ListObjectsV2Output, Self::Error> {
|
||||
unreachable!("parse_path tests should not hit storage")
|
||||
}
|
||||
|
||||
async fn list_buckets(&self, _access_key: &str, _secret_key: &str) -> Result<ListBucketsOutput, Self::Error> {
|
||||
unreachable!("parse_path tests should not hit storage")
|
||||
}
|
||||
|
||||
async fn create_bucket(
|
||||
&self,
|
||||
_bucket: &str,
|
||||
_access_key: &str,
|
||||
_secret_key: &str,
|
||||
) -> Result<CreateBucketOutput, Self::Error> {
|
||||
unreachable!("parse_path tests should not hit storage")
|
||||
}
|
||||
|
||||
async fn delete_bucket(
|
||||
&self,
|
||||
_bucket: &str,
|
||||
_access_key: &str,
|
||||
_secret_key: &str,
|
||||
) -> Result<DeleteBucketOutput, Self::Error> {
|
||||
unreachable!("parse_path tests should not hit storage")
|
||||
}
|
||||
}
|
||||
|
||||
fn driver() -> WebDavDriver<DummyStorage> {
|
||||
let identity = UserIdentity::new(Credentials {
|
||||
access_key: "ak".to_string(),
|
||||
secret_key: "sk".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
let session_context = SessionContext::new(
|
||||
ProtocolPrincipal::new(Arc::new(identity)),
|
||||
Protocol::WebDav,
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
);
|
||||
|
||||
WebDavDriver::new(DummyStorage, Arc::new(session_context))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_path_decodes_url_encoded_object_names() {
|
||||
let driver = driver();
|
||||
let path = DavPath::new("/bucket/%E6%96%87%E4%BB%B6%20name.txt").expect("path should parse");
|
||||
|
||||
let (bucket, key) = driver.parse_path(&path).expect("path should decode");
|
||||
|
||||
assert_eq!(bucket, "bucket");
|
||||
assert_eq!(key.as_deref(), Some("文件 name.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_path_rejects_invalid_utf8_percent_encoding() {
|
||||
let driver = driver();
|
||||
let path = DavPath::new("/bucket/%FFreport.txt").expect("path should parse");
|
||||
|
||||
let err = driver.parse_path(&path).expect_err("invalid utf8 should be rejected");
|
||||
|
||||
assert_eq!(err, FsError::GeneralFailure);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# cargo-deny configuration
|
||||
#
|
||||
# Run with `cargo deny check` (advisories, sources, bans, licenses).
|
||||
# Schema: https://embarkstudios.github.io/cargo-deny/checks/cfg.html
|
||||
#
|
||||
# This file codifies what was previously implicit policy:
|
||||
# - which RustSec advisories we knowingly accept and why,
|
||||
# - which non-crates.io sources we trust,
|
||||
# - which duplicate crate versions we tolerate vs. flag.
|
||||
#
|
||||
# When adding an exception, include an `# owner: <github-handle> review: <yyyy-mm>`
|
||||
# comment so future audits know who signed off and when to revisit.
|
||||
|
||||
[graph]
|
||||
all-features = true
|
||||
no-default-features = false
|
||||
|
||||
[advisories]
|
||||
version = 2
|
||||
yanked = "deny"
|
||||
ignore = [
|
||||
# `instant 0.1.13` — unmaintained. No direct dependency; pulled in
|
||||
# transitively. Tracked for upgrade as part of broader dep refresh.
|
||||
# owner: rustfs-maintainers review: 2026-07
|
||||
{ id = "RUSTSEC-2024-0384", reason = "instant unmaintained; transitive only; tracked for upgrade" },
|
||||
|
||||
# `paste 1.0.15` — unmaintained. No direct dependency.
|
||||
# owner: rustfs-maintainers review: 2026-07
|
||||
{ id = "RUSTSEC-2024-0436", reason = "paste unmaintained; transitive only; tracked for upgrade" },
|
||||
|
||||
# `rsa` Marvin timing sidechannel (RUSTSEC-2023-0071). Pulled in via
|
||||
# `openidconnect` (transitive) and historically used directly. No upstream
|
||||
# fix is available yet. Tracked separately for follow-up; remove this
|
||||
# entry once a patched `rsa` lands in the dependency graph and any
|
||||
# in-process RSA decryption oracles are removed.
|
||||
# owner: rustfs-maintainers review: 2026-07
|
||||
{ id = "RUSTSEC-2023-0071", reason = "rsa Marvin timing sidechannel; no fixed upstream version; tracked separately" },
|
||||
]
|
||||
|
||||
[sources]
|
||||
unknown-registry = "deny"
|
||||
unknown-git = "deny"
|
||||
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
allow-git = [
|
||||
# Custom S3 server library with minio compatibility patches not yet upstreamed.
|
||||
# Pinned to a specific commit in workspace Cargo.toml.
|
||||
"https://github.com/rustfs/s3s",
|
||||
]
|
||||
|
||||
[bans]
|
||||
# Multiple-versions of the same crate are permitted with a warning so the
|
||||
# graph remains buildable while we work the chains down. Crypto- and
|
||||
# transport-sensitive crates are tracked separately below.
|
||||
multiple-versions = "warn"
|
||||
wildcards = "warn"
|
||||
highlight = "all"
|
||||
|
||||
# Any future crate we want to forbid outright belongs here.
|
||||
deny = []
|
||||
|
||||
# Crates whose duplicate versions are most worth eliminating, because they
|
||||
# touch crypto, parsing, or networking trust boundaries. Not currently a
|
||||
# build error — the graph still has duplicates — but tracking the list keeps
|
||||
# them visible.
|
||||
[[bans.skip-tree]]
|
||||
# `windows-sys` notoriously has many old versions in dependency closures;
|
||||
# don't flood the report with it.
|
||||
name = "windows-sys"
|
||||
|
||||
[licenses]
|
||||
version = 2
|
||||
allow = [
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"BSL-1.0", # boost; tracing-related crates
|
||||
"CC0-1.0",
|
||||
"CDLA-Permissive-2.0",# webpki / linux-raw-sys metadata
|
||||
"ISC",
|
||||
"MIT",
|
||||
"MIT-0",
|
||||
"MPL-2.0",
|
||||
"Unicode-3.0",
|
||||
"Zlib",
|
||||
]
|
||||
confidence-threshold = 0.93
|
||||
exceptions = [
|
||||
# `ring` ships a custom license combining ISC, MIT, and an OpenSSL-style
|
||||
# notice that does not parse cleanly as SPDX OpenSSL.
|
||||
{ allow = ["ISC", "MIT"], crate = "ring" },
|
||||
|
||||
# `inferno` is CDDL-1.0 (copyleft). Used only by profiling tooling
|
||||
# (pyroscope / jemalloc_pprof) which is opt-in and never linked into the
|
||||
# default S3 path. Tracked as an exception rather than a blanket allow.
|
||||
# owner: rustfs-maintainers review: 2026-07
|
||||
{ allow = ["CDDL-1.0"], crate = "inferno" },
|
||||
|
||||
# `libbz2-rs-sys` carries the upstream bzip2-1.0.6 license. It's used
|
||||
# transitively via `bzip2`. Not on a hot path.
|
||||
# owner: rustfs-maintainers review: 2026-07
|
||||
{ allow = ["bzip2-1.0.6"], crate = "libbz2-rs-sys" },
|
||||
]
|
||||
@@ -17,8 +17,7 @@ Group=rustfs
|
||||
WorkingDirectory=/opt/rustfs
|
||||
|
||||
# environment variable configuration and main program (Option 1: Directly specify arguments)
|
||||
Environment=RUSTFS_ACCESS_KEY=rustfsadmin
|
||||
Environment=RUSTFS_SECRET_KEY=rustfsadmin
|
||||
# Credentials are loaded from /etc/default/rustfs below. Replace the sample values before deployment.
|
||||
ExecStart=/usr/local/bin/rustfs \
|
||||
--address 0.0.0.0:9000 \
|
||||
--volumes /data/rustfs/vol1,/data/rustfs/vol2 \
|
||||
@@ -26,8 +25,8 @@ ExecStart=/usr/local/bin/rustfs \
|
||||
|
||||
# environment variable configuration (Option 2: Use environment variables)
|
||||
# rustfs example file see: `../config/rustfs.env`
|
||||
EnvironmentFile=-/etc/default/rustfs
|
||||
ExecStart=/usr/local/bin/rustfs $RUSTFS_VOLUMES $RUSTFS_OPTS
|
||||
EnvironmentFile=/etc/default/rustfs
|
||||
ExecStart=/usr/local/bin/rustfs $RUSTFS_VOLUMES
|
||||
|
||||
# service log configuration
|
||||
LogsDirectory=rustfs
|
||||
|
||||
+14
-14
@@ -1,23 +1,23 @@
|
||||
# RustFS administrator username
|
||||
RUSTFS_ROOT_USER=rustfsadmin
|
||||
# RustFS administrator password
|
||||
RUSTFS_ROOT_PASSWORD=rustfsadmin
|
||||
# RustFS administrator access key. Replace before deployment; do not use public defaults.
|
||||
RUSTFS_ACCESS_KEY=REPLACE_WITH_UNIQUE_ACCESS_KEY
|
||||
# RustFS administrator secret key. Replace before deployment; do not use public defaults.
|
||||
RUSTFS_SECRET_KEY=REPLACE_WITH_UNIQUE_SECRET_KEY
|
||||
|
||||
# RustFS data volume storage paths.
|
||||
# Data volume configuration example path: deploy/data/rustfs.env
|
||||
# RustFS data volume storage paths, supports multiple volumes from vol1 to vol4
|
||||
RUSTFS_VOLUMES="./deploy/deploy/vol{1...4}"
|
||||
# RustFS service startup parameters, specifying listen address and port
|
||||
RUSTFS_OPTS="--address :9000"
|
||||
RUSTFS_VOLUMES="./deploy/data/vol{1...4}"
|
||||
# RustFS service listen address and port
|
||||
RUSTFS_ADDRESS=":9000"
|
||||
RUSTFS_ADDRESS=0.0.0.0:9000
|
||||
# Enable RustFS console functionality
|
||||
RUSTFS_CONSOLE_ENABLE=true
|
||||
# RustFS service domain configuration
|
||||
RUSTFS_SERVER_DOMAINS=127.0.0.1:9000
|
||||
# RustFS license content
|
||||
RUSTFS_LICENSE="license content"
|
||||
# RustFS console listen address and port
|
||||
RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001
|
||||
# Optional service domain configuration for virtual-hosted-style requests (comma-separated).
|
||||
# RUSTFS_SERVER_DOMAINS=s3.example.com
|
||||
# Optional RustFS license content
|
||||
# RUSTFS_LICENSE=REPLACE_WITH_LICENSE_CONTENT
|
||||
# Observability configuration endpoint: RUSTFS_OBS_ENDPOINT
|
||||
RUSTFS_OBS_ENDPOINT=http://localhost:4318
|
||||
# TLS certificates directory path: deploy/certs
|
||||
# Optional TLS certificates directory path: deploy/certs
|
||||
RUSTFS_TLS_PATH=/etc/default/tls
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ services:
|
||||
- RUSTFS_ADDRESS=0.0.0.0:9000
|
||||
- RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001
|
||||
- RUSTFS_CONSOLE_ENABLE=true
|
||||
- RUSTFS_CORS_ALLOWED_ORIGINS=*
|
||||
- RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS=*
|
||||
- RUSTFS_ACCESS_KEY=rustfsadmin # CHANGEME
|
||||
- RUSTFS_SECRET_KEY=rustfsadmin # CHANGEME
|
||||
@@ -49,6 +48,9 @@ services:
|
||||
- rustfs-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
# Production strict TLS example (SAN/FQDN aligned, no `-k`):
|
||||
# curl -f --cacert /opt/tls/ca.crt --resolve rustfs-a.example.com:9000:127.0.0.1 https://rustfs-a.example.com:9000/health
|
||||
# curl -f --cacert /opt/tls/ca.crt --resolve rustfs-a.example.com:9001:127.0.0.1 https://rustfs-a.example.com:9001/rustfs/console/health
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
@@ -75,6 +77,9 @@ services:
|
||||
echo 'Volume Permissions fixed' &&
|
||||
exit 0
|
||||
"
|
||||
# Permission baseline:
|
||||
# - default RustFS runtime user is 10001:10001
|
||||
# - alternatively, run rustfs service with host-matched `user: \"<uid>:<gid>\"`
|
||||
restart: "no"
|
||||
|
||||
networks:
|
||||
|
||||
@@ -19,7 +19,6 @@ services:
|
||||
RUSTFS_ADDRESS: "0.0.0.0:9000"
|
||||
RUSTFS_CONSOLE_ADDRESS: "0.0.0.0:9001"
|
||||
RUSTFS_CONSOLE_ENABLE: "true"
|
||||
RUSTFS_CORS_ALLOWED_ORIGINS: "*"
|
||||
RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS: "*"
|
||||
RUSTFS_ACCESS_KEY: "rustfsadmin"
|
||||
RUSTFS_SECRET_KEY: "rustfsadmin"
|
||||
|
||||
+14
-3
@@ -32,8 +32,9 @@ services:
|
||||
- RUSTFS_ADDRESS=0.0.0.0:9000
|
||||
- RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001
|
||||
- RUSTFS_CONSOLE_ENABLE=true
|
||||
- RUSTFS_CORS_ALLOWED_ORIGINS=*
|
||||
- RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS=*
|
||||
# SECURITY: these defaults are public and well-known. Override before
|
||||
# exposing the listener beyond localhost.
|
||||
- RUSTFS_ACCESS_KEY=rustfsadmin
|
||||
- RUSTFS_SECRET_KEY=rustfsadmin
|
||||
- RUSTFS_OBS_LOGGER_LEVEL=info
|
||||
@@ -42,11 +43,22 @@ services:
|
||||
volumes:
|
||||
- ./deploy/data/pro:/data
|
||||
- ./deploy/logs:/app/logs
|
||||
- ./deploy/data/certs/:/opt/tls # TLS configuration, you should create tls directory and put your tls files in it and then specify the path here
|
||||
# TLS configuration directory.
|
||||
# Place at least:
|
||||
# - /opt/tls/ca.crt
|
||||
# - /opt/tls/rustfs_cert.pem
|
||||
# - /opt/tls/rustfs_key.pem
|
||||
- ./deploy/data/certs/:/opt/tls
|
||||
# Permission baseline:
|
||||
# - default RustFS runtime user is 10001:10001
|
||||
# - ensure host mounts are writable by that user, or run with host-matched user
|
||||
networks:
|
||||
- rustfs-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
# Production strict TLS example (SAN/FQDN aligned, no `-k`):
|
||||
# curl -f --cacert /opt/tls/ca.crt --resolve rustfs-a.example.com:9000:127.0.0.1 https://rustfs-a.example.com:9000/health
|
||||
# curl -f --cacert /opt/tls/ca.crt --resolve rustfs-a.example.com:9001:127.0.0.1 https://rustfs-a.example.com:9001/rustfs/console/health
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
@@ -79,7 +91,6 @@ services:
|
||||
- RUSTFS_ADDRESS=0.0.0.0:9000
|
||||
- RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001
|
||||
- RUSTFS_CONSOLE_ENABLE=true
|
||||
- RUSTFS_CORS_ALLOWED_ORIGINS=*
|
||||
- RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS=*
|
||||
- RUSTFS_ACCESS_KEY=devadmin
|
||||
- RUSTFS_SECRET_KEY=devadmin
|
||||
|
||||
Generated
+6
-6
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1776949667,
|
||||
"narHash": "sha256-GMSVw35Q+294GlrTUKlx087E31z7KurReQ1YHSKp5iw=",
|
||||
"lastModified": 1777641297,
|
||||
"narHash": "sha256-WNGcmeOZ8Tr9dq6ztCspYbzWFswr2mPebM9LpsfGxPk=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "01fbdeef22b76df85ea168fbfe1bfd9e63681b30",
|
||||
"rev": "c6d65881c5624c9cae5ea6cedef24699b0c0a4c0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -29,11 +29,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1777086717,
|
||||
"narHash": "sha256-vEl3cGHRxEFdVNuP9PbrhAWnmU98aPOLGy9/1JXzSuM=",
|
||||
"lastModified": 1777691680,
|
||||
"narHash": "sha256-sdCAzrPAaKu+yo7L2pWddy5PN6U9bO++WEWc1zcr7aQ=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "3be56bd430bfd65d3c468a50626c3a601c7dee03",
|
||||
"rev": "4757db4358c77c1cbe878fa5990e6ea88d82f6b5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: rustfs
|
||||
description: RustFS helm chart to deploy RustFS on kubernetes cluster.
|
||||
type: application
|
||||
version: "v1.0.0-beta.1"
|
||||
appVersion: "v1.0.0-beta.1"
|
||||
version: "0.1.0"
|
||||
appVersion: "1.0.0-beta.1"
|
||||
home: https://rustfs.com
|
||||
icon: https://media.sys.truenas.net/apps/rustfs/icons/icon.svg
|
||||
maintainers:
|
||||
|
||||
@@ -12,7 +12,7 @@ metadata:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
replicas: 1
|
||||
replicas: {{ min 1 .Values.replicaCount }}
|
||||
{{- with .Values.mode.standalone.strategy }}
|
||||
{{- $type := default "RollingUpdate" .type }}
|
||||
strategy:
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
{{- if not .Values.secret.existingSecret }}
|
||||
{{- $accessKey := .Values.secret.rustfs.access_key | default "" }}
|
||||
{{- $secretKey := .Values.secret.rustfs.secret_key | default "" }}
|
||||
{{- $allowInsecure := .Values.secret.allowInsecureDefaults | default false }}
|
||||
{{/* Either key set to the well-known default counts as insecure. */}}
|
||||
{{- $hasDefaultKey := or (eq $accessKey "rustfsadmin") (eq $secretKey "rustfsadmin") }}
|
||||
{{- $bothEmpty := and (eq $accessKey "") (eq $secretKey "") }}
|
||||
{{- $oneEmpty := and (not $bothEmpty) (or (eq $accessKey "") (eq $secretKey "")) }}
|
||||
{{/* Always fail when only one of the two keys is supplied — never silently
|
||||
auto-fill a single missing key with the well-known default. */}}
|
||||
{{- if $oneEmpty }}
|
||||
{{- fail (printf "secret.rustfs.access_key and secret.rustfs.secret_key must both be set, or both be left empty. Setting only one of the two is ambiguous and is rejected to avoid silently using the well-known default for the missing key.") }}
|
||||
{{- end }}
|
||||
{{- if and (not $allowInsecure) (or $bothEmpty $hasDefaultKey) }}
|
||||
{{- fail (printf "secret.rustfs.access_key and secret.rustfs.secret_key must be set to non-default, non-empty values, or set secret.existingSecret to a Secret you control. To opt into the well-known default credentials for local development only, set secret.allowInsecureDefaults=true.") }}
|
||||
{{- end }}
|
||||
{{- if and $allowInsecure $bothEmpty }}
|
||||
{{- $accessKey = "rustfsadmin" }}
|
||||
{{- $secretKey = "rustfsadmin" }}
|
||||
{{- end }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
@@ -8,8 +27,8 @@ metadata:
|
||||
{{- toYaml .Values.commonLabels | nindent 4 }}
|
||||
type: Opaque
|
||||
data:
|
||||
RUSTFS_ACCESS_KEY: {{ .Values.secret.rustfs.access_key | b64enc | quote }}
|
||||
RUSTFS_SECRET_KEY: {{ .Values.secret.rustfs.secret_key | b64enc | quote }}
|
||||
RUSTFS_ACCESS_KEY: {{ $accessKey | b64enc | quote }}
|
||||
RUSTFS_SECRET_KEY: {{ $secretKey | b64enc | quote }}
|
||||
{{- end }}
|
||||
|
||||
---
|
||||
|
||||
+10
-2
@@ -48,9 +48,17 @@ mode:
|
||||
|
||||
secret:
|
||||
existingSecret: ""
|
||||
# SECURITY: rendering fails by default unless one of the following is true:
|
||||
# 1. `secret.existingSecret` names a Kubernetes Secret you control, or
|
||||
# 2. `secret.rustfs.access_key` and `secret.rustfs.secret_key` are both
|
||||
# set to non-empty, non-default values, or
|
||||
# 3. `secret.allowInsecureDefaults: true` is set (only for local dev).
|
||||
# This prevents accidental deployment with the well-known default
|
||||
# `rustfsadmin/rustfsadmin` credentials.
|
||||
allowInsecureDefaults: false
|
||||
rustfs:
|
||||
access_key: rustfsadmin
|
||||
secret_key: rustfsadmin
|
||||
access_key: ""
|
||||
secret_key: ""
|
||||
|
||||
config:
|
||||
rustfs:
|
||||
|
||||
@@ -198,6 +198,7 @@ tokio = { workspace = true, features = ["test-util"] }
|
||||
temp-env = { workspace = true, features = ["async_closure"] }
|
||||
tracing-subscriber = { workspace = true }
|
||||
opentelemetry_sdk = { workspace = true }
|
||||
rsa = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
http.workspace = true
|
||||
|
||||
+156
-46
@@ -13,11 +13,11 @@
|
||||
// limitations under the License.
|
||||
|
||||
use crate::admin::handlers::health::{HealthProbe, build_health_payload, collect_dependency_readiness, health_check_state};
|
||||
use crate::license::get_license;
|
||||
use crate::license::has_valid_license;
|
||||
use crate::server::{CONSOLE_PREFIX, FAVICON_PATH, HEALTH_PREFIX, HEALTH_READY_PATH, LICENSE, RUSTFS_ADMIN_PREFIX, VERSION};
|
||||
use crate::version::build;
|
||||
use axum::{
|
||||
Router,
|
||||
Json, Router,
|
||||
body::Body,
|
||||
extract::Request,
|
||||
middleware,
|
||||
@@ -241,20 +241,17 @@ pub(crate) fn init_console_cfg(local_ip: IpAddr, port: u16) {
|
||||
});
|
||||
}
|
||||
|
||||
/// License handler
|
||||
/// Returns the current license information of the console.
|
||||
///
|
||||
/// # Returns:
|
||||
/// - 200 OK with JSON body containing license details.
|
||||
#[derive(Serialize)]
|
||||
struct LicensePublicStatus {
|
||||
licensed: bool,
|
||||
}
|
||||
|
||||
/// Returns coarse public license status without exposing license metadata.
|
||||
#[instrument]
|
||||
async fn license_handler() -> impl IntoResponse {
|
||||
let license = get_license().unwrap_or_default();
|
||||
|
||||
Response::builder()
|
||||
.header("content-type", "application/json")
|
||||
.status(StatusCode::OK)
|
||||
.body(Body::from(serde_json::to_string(&license).unwrap_or_default()))
|
||||
.unwrap()
|
||||
Json(LicensePublicStatus {
|
||||
licensed: has_valid_license(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if the given IP address is a private IP
|
||||
@@ -605,12 +602,18 @@ async fn health_route_disabled() -> StatusCode {
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
|
||||
/// Parse CORS allowed origins from configuration
|
||||
/// Parse CORS allowed origins from configuration.
|
||||
///
|
||||
/// # Arguments:
|
||||
/// When no origins are configured (None or an empty string), the layer is
|
||||
/// left without `Access-Control-Allow-Origin` so browsers treat responses
|
||||
/// as same-origin only. Operators that need cross-origin access set
|
||||
/// `RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS` to a comma-separated allow-list,
|
||||
/// or to `*` to allow any origin.
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `origins`: An optional reference to a string containing allowed origins.
|
||||
///
|
||||
/// # Returns:
|
||||
/// # Returns
|
||||
/// - A `CorsLayer` configured with the specified origins.
|
||||
pub fn parse_cors_origins(origins: Option<&String>) -> CorsLayer {
|
||||
let cors_layer = CorsLayer::new()
|
||||
@@ -618,38 +621,28 @@ pub fn parse_cors_origins(origins: Option<&String>) -> CorsLayer {
|
||||
.allow_headers(Any);
|
||||
|
||||
match origins {
|
||||
Some(origins_str) if origins_str == "*" => cors_layer.allow_origin(Any).expose_headers(Any),
|
||||
Some(origins_str) => {
|
||||
let origins: Vec<&str> = origins_str.split(',').map(|s| s.trim()).collect();
|
||||
if origins.is_empty() {
|
||||
warn!("Empty CORS origins provided, using permissive CORS");
|
||||
cors_layer.allow_origin(Any).expose_headers(Any)
|
||||
} else {
|
||||
// Parse origins with proper error handling
|
||||
let mut valid_origins = Vec::new();
|
||||
for origin in origins {
|
||||
match origin.parse::<HeaderValue>() {
|
||||
Ok(header_value) => {
|
||||
valid_origins.push(header_value);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Invalid CORS origin '{}': {}", origin, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if valid_origins.is_empty() {
|
||||
warn!("No valid CORS origins found, using permissive CORS");
|
||||
cors_layer.allow_origin(Any).expose_headers(Any)
|
||||
} else {
|
||||
info!("Console CORS origins configured: {:?}", valid_origins);
|
||||
cors_layer.allow_origin(AllowOrigin::list(valid_origins)).expose_headers(Any)
|
||||
Some(origins_str) if origins_str.trim() == "*" => cors_layer.allow_origin(Any).expose_headers(Any),
|
||||
Some(origins_str) if !origins_str.trim().is_empty() => {
|
||||
let origins: Vec<&str> = origins_str.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
|
||||
let mut valid_origins = Vec::new();
|
||||
for origin in origins {
|
||||
match origin.parse::<HeaderValue>() {
|
||||
Ok(header_value) => valid_origins.push(header_value),
|
||||
Err(e) => warn!("Invalid CORS origin '{}': {}", origin, e),
|
||||
}
|
||||
}
|
||||
|
||||
if valid_origins.is_empty() {
|
||||
warn!("No valid CORS origins parsed from configuration; defaulting to same-origin only");
|
||||
cors_layer
|
||||
} else {
|
||||
info!("Console CORS origins configured: {:?}", valid_origins);
|
||||
cors_layer.allow_origin(AllowOrigin::list(valid_origins)).expose_headers(Any)
|
||||
}
|
||||
}
|
||||
None => {
|
||||
debug!("No CORS origins configured for console, using permissive CORS");
|
||||
cors_layer.allow_origin(Any)
|
||||
_ => {
|
||||
debug!("No CORS origins configured for console; same-origin only");
|
||||
cors_layer
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -678,6 +671,8 @@ mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use http::{Request, StatusCode};
|
||||
use http_body_util::BodyExt;
|
||||
use serial_test::serial;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use temp_env::async_with_vars;
|
||||
use tower::ServiceExt;
|
||||
@@ -711,7 +706,10 @@ mod tests {
|
||||
assert!(!is_console_path("/rustfs/admin/v3/info"));
|
||||
}
|
||||
|
||||
// setup_console_middleware_stack reads ENV_HEALTH_ENDPOINT_ENABLE; serialise
|
||||
// with other tests that override that env var to avoid cross-task leakage.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn console_middleware_stack_propagates_request_id_header() {
|
||||
let app = setup_console_middleware_stack(parse_cors_origins(None), false, 0, 30);
|
||||
let request = Request::builder()
|
||||
@@ -727,7 +725,95 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: when no console CORS origins are configured (the new
|
||||
/// default), the layer must NOT emit `Access-Control-Allow-Origin`, so
|
||||
/// browsers treat responses as same-origin only.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn default_console_cors_is_same_origin_only() {
|
||||
let app = setup_console_middleware_stack(parse_cors_origins(None), false, 0, 30);
|
||||
|
||||
let request = Request::builder()
|
||||
.method("OPTIONS")
|
||||
.uri(format!("{CONSOLE_PREFIX}/license"))
|
||||
.header("origin", "https://example.com")
|
||||
.header("access-control-request-method", "GET")
|
||||
.body(Body::empty())
|
||||
.expect("build preflight");
|
||||
|
||||
let response = app.oneshot(request).await.expect("preflight should complete");
|
||||
|
||||
assert!(
|
||||
response.headers().get("access-control-allow-origin").is_none(),
|
||||
"default console CORS must not emit Access-Control-Allow-Origin"
|
||||
);
|
||||
assert!(
|
||||
response.headers().get("access-control-allow-credentials").is_none(),
|
||||
"default console CORS must not emit Access-Control-Allow-Credentials"
|
||||
);
|
||||
}
|
||||
|
||||
/// Operators that opt in to wildcard origins (via `*`) keep the previous
|
||||
/// permissive behavior.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn explicit_wildcard_console_cors_allows_any_origin() {
|
||||
let star = "*".to_string();
|
||||
let app = setup_console_middleware_stack(parse_cors_origins(Some(&star)), false, 0, 30);
|
||||
|
||||
let request = Request::builder()
|
||||
.method("OPTIONS")
|
||||
.uri(format!("{CONSOLE_PREFIX}/license"))
|
||||
.header("origin", "https://example.com")
|
||||
.header("access-control-request-method", "GET")
|
||||
.body(Body::empty())
|
||||
.expect("build preflight");
|
||||
|
||||
let response = app.oneshot(request).await.expect("preflight should complete");
|
||||
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("access-control-allow-origin")
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some("*"),
|
||||
"explicit `*` origin must produce wildcard Allow-Origin"
|
||||
);
|
||||
}
|
||||
|
||||
/// Whitespace-padded wildcard ("` * `") must still be treated as wildcard
|
||||
/// rather than falling into the comma-separated parser. Common when the
|
||||
/// origin string is templated through env vars.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn whitespace_padded_wildcard_console_cors_allows_any_origin() {
|
||||
let star = " * ".to_string();
|
||||
let app = setup_console_middleware_stack(parse_cors_origins(Some(&star)), false, 0, 30);
|
||||
|
||||
let request = Request::builder()
|
||||
.method("OPTIONS")
|
||||
.uri(format!("{CONSOLE_PREFIX}/license"))
|
||||
.header("origin", "https://example.com")
|
||||
.header("access-control-request-method", "GET")
|
||||
.body(Body::empty())
|
||||
.expect("build preflight");
|
||||
|
||||
let response = app.oneshot(request).await.expect("preflight should complete");
|
||||
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("access-control-allow-origin")
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some("*"),
|
||||
"whitespace-padded `*` origin must produce wildcard Allow-Origin"
|
||||
);
|
||||
}
|
||||
|
||||
// Mutates the global ENV_HEALTH_ENDPOINT_ENABLE env var; serialise to
|
||||
// avoid leaking the override into other async tests in the same module.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn console_middleware_stack_hides_health_routes_when_disabled() {
|
||||
async_with_vars([(rustfs_config::ENV_HEALTH_ENDPOINT_ENABLE, Some("false"))], async {
|
||||
let app = setup_console_middleware_stack(parse_cors_origins(None), false, 0, 30);
|
||||
@@ -757,4 +843,28 @@ mod tests {
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn console_license_route_returns_public_status_only() {
|
||||
let app = setup_console_middleware_stack(parse_cors_origins(None), false, 0, 30);
|
||||
let request = Request::builder()
|
||||
.uri(format!("{CONSOLE_PREFIX}{LICENSE}"))
|
||||
.body(Body::empty())
|
||||
.expect("failed to build license request");
|
||||
|
||||
let response = app.oneshot(request).await.expect("license request should complete");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("license body should collect")
|
||||
.to_bytes();
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("license response should be valid JSON");
|
||||
|
||||
assert_eq!(value, serde_json::json!({ "licensed": false }));
|
||||
assert!(value.get("name").is_none());
|
||||
assert!(value.get("expired").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,17 +12,41 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::admin::router::Operation;
|
||||
use crate::admin::{auth::validate_admin_request, router::Operation};
|
||||
use crate::auth::{check_key_valid, get_session_token};
|
||||
use crate::server::RemoteAddr;
|
||||
use http::header::CONTENT_TYPE;
|
||||
use http::{HeaderMap, StatusCode};
|
||||
use matchit::Params;
|
||||
use s3s::{Body, S3Request, S3Response, S3Result};
|
||||
use rustfs_policy::policy::action::{Action, AdminAction};
|
||||
use s3s::{Body, S3Request, S3Response, S3Result, s3_error};
|
||||
use tracing::info;
|
||||
|
||||
pub(super) async fn authorize_profile_request(req: &S3Request<Body>) -> S3Result<()> {
|
||||
let Some(input_cred) = req.credentials.as_ref() else {
|
||||
return Err(s3_error!(AccessDenied, "Signature is required"));
|
||||
};
|
||||
|
||||
let (cred, owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?;
|
||||
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
|
||||
|
||||
validate_admin_request(
|
||||
&req.headers,
|
||||
&cred,
|
||||
owner,
|
||||
false,
|
||||
vec![Action::AdminAction(AdminAction::ProfilingAdminAction)],
|
||||
remote_addr,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub struct TriggerProfileCPU {}
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for TriggerProfileCPU {
|
||||
async fn call(&self, _req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
authorize_profile_request(&req).await?;
|
||||
info!("Triggering CPU profile dump via S3 request...");
|
||||
|
||||
let dur = std::time::Duration::from_secs(60);
|
||||
@@ -40,7 +64,8 @@ impl Operation for TriggerProfileCPU {
|
||||
pub struct TriggerProfileMemory {}
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for TriggerProfileMemory {
|
||||
async fn call(&self, _req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
authorize_profile_request(&req).await?;
|
||||
info!("Triggering Memory profile dump via S3 request...");
|
||||
|
||||
match crate::profiling::dump_memory_pprof_now().await {
|
||||
@@ -53,3 +78,50 @@ impl Operation for TriggerProfileMemory {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{TriggerProfileCPU, TriggerProfileMemory};
|
||||
use crate::admin::router::Operation;
|
||||
use crate::server::{PROFILE_CPU_PATH, PROFILE_MEMORY_PATH};
|
||||
use http::{Extensions, HeaderMap, Uri};
|
||||
use hyper::Method;
|
||||
use matchit::Params;
|
||||
use s3s::{Body, S3ErrorCode, S3Request};
|
||||
|
||||
fn build_profile_request(uri: &'static str) -> S3Request<Body> {
|
||||
S3Request {
|
||||
input: Body::empty(),
|
||||
method: Method::GET,
|
||||
uri: Uri::from_static(uri),
|
||||
headers: HeaderMap::new(),
|
||||
extensions: Extensions::new(),
|
||||
credentials: None,
|
||||
region: None,
|
||||
service: None,
|
||||
trailing_headers: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trigger_profile_cpu_rejects_missing_credentials() {
|
||||
let result = TriggerProfileCPU {}
|
||||
.call(build_profile_request(PROFILE_CPU_PATH), Params::new())
|
||||
.await;
|
||||
let err = result.expect_err("legacy CPU profile handler must reject anonymous requests");
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
|
||||
assert_eq!(err.message(), Some("Signature is required"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trigger_profile_memory_rejects_missing_credentials() {
|
||||
let result = TriggerProfileMemory {}
|
||||
.call(build_profile_request(PROFILE_MEMORY_PATH), Params::new())
|
||||
.await;
|
||||
let err = result.expect_err("legacy memory profile handler must reject anonymous requests");
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
|
||||
assert_eq!(err.message(), Some("Signature is required"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::profile::authorize_profile_request;
|
||||
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
||||
use crate::server::ADMIN_PREFIX;
|
||||
use http::{HeaderMap, HeaderValue, Uri};
|
||||
@@ -56,6 +57,8 @@ pub struct ProfileHandler {}
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for ProfileHandler {
|
||||
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
authorize_profile_request(&req).await?;
|
||||
|
||||
#[cfg(not(all(target_os = "linux", target_env = "gnu", target_arch = "x86_64")))]
|
||||
{
|
||||
let requested_url = req.uri.to_string();
|
||||
@@ -151,7 +154,9 @@ pub struct ProfileStatusHandler {}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for ProfileStatusHandler {
|
||||
async fn call(&self, _req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
authorize_profile_request(&req).await?;
|
||||
|
||||
#[cfg(not(all(target_os = "linux", target_env = "gnu", target_arch = "x86_64")))]
|
||||
let message = format!("CPU profiling is not supported on {} platform", std::env::consts::OS);
|
||||
#[cfg(not(all(target_os = "linux", target_env = "gnu", target_arch = "x86_64")))]
|
||||
@@ -204,8 +209,26 @@ impl Operation for ProfileStatusHandler {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::extract_query_params;
|
||||
use http::Uri;
|
||||
use super::{ProfileHandler, ProfileStatusHandler, extract_query_params};
|
||||
use crate::admin::router::Operation;
|
||||
use http::{Extensions, HeaderMap, Uri};
|
||||
use hyper::Method;
|
||||
use matchit::Params;
|
||||
use s3s::{Body, S3ErrorCode, S3Request};
|
||||
|
||||
fn build_profile_request(uri: &'static str) -> S3Request<Body> {
|
||||
S3Request {
|
||||
input: Body::empty(),
|
||||
method: Method::GET,
|
||||
uri: Uri::from_static(uri),
|
||||
headers: HeaderMap::new(),
|
||||
extensions: Extensions::new(),
|
||||
credentials: None,
|
||||
region: None,
|
||||
service: None,
|
||||
trailing_headers: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_query_params_decodes_percent_encoded_values() {
|
||||
@@ -217,4 +240,32 @@ mod tests {
|
||||
assert_eq!(params.get("format"), Some(&"flamegraph".to_string()));
|
||||
assert_eq!(params.get("note"), Some(&"a+b value".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_handler_rejects_missing_credentials() {
|
||||
let result = ProfileHandler {}
|
||||
.call(build_profile_request("/rustfs/admin/debug/pprof/profile?format=protobuf"), Params::new())
|
||||
.await;
|
||||
let err = match result {
|
||||
Ok(_) => panic!("profile handler must reject unauthenticated requests"),
|
||||
Err(err) => err,
|
||||
};
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
|
||||
assert_eq!(err.message(), Some("Signature is required"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_status_handler_rejects_missing_credentials() {
|
||||
let result = ProfileStatusHandler {}
|
||||
.call(build_profile_request("/rustfs/admin/debug/pprof/status"), Params::new())
|
||||
.await;
|
||||
let err = match result {
|
||||
Ok(_) => panic!("profile status handler must reject unauthenticated requests"),
|
||||
Err(err) => err,
|
||||
};
|
||||
|
||||
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
|
||||
assert_eq!(err.message(), Some("Signature is required"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
|
||||
use crate::admin::auth::validate_admin_request;
|
||||
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
||||
use crate::admin::site_replication_identity::{
|
||||
canonical_endpoint, deployment_id_for_endpoint, normalize_peer_map_by_identity_with, same_identity_endpoint,
|
||||
site_identity_key,
|
||||
};
|
||||
use crate::admin::utils::{encode_compatible_admin_payload, read_compatible_admin_body};
|
||||
use crate::auth::{check_key_valid, get_session_token};
|
||||
use crate::error::ApiError;
|
||||
@@ -25,7 +29,10 @@ use http::header::{CONTENT_TYPE, HOST};
|
||||
use http::{HeaderMap, HeaderValue, Uri};
|
||||
use hyper::{Method, StatusCode};
|
||||
use matchit::Params;
|
||||
use rustfs_config::{DEFAULT_DELIMITER, MAX_ADMIN_REQUEST_BODY_SIZE};
|
||||
use rustfs_config::{
|
||||
DEFAULT_DELIMITER, DEFAULT_RUSTFS_TLS_PATH, DEFAULT_TRUST_LEAF_CERT_AS_CA, ENV_RUSTFS_TLS_PATH, ENV_TRUST_LEAF_CERT_AS_CA,
|
||||
MAX_ADMIN_REQUEST_BODY_SIZE, RUSTFS_CA_CERT, RUSTFS_TLS_CERT,
|
||||
};
|
||||
use rustfs_ecstore::bucket::bucket_target_sys::BucketTargetSys;
|
||||
use rustfs_ecstore::bucket::metadata::{
|
||||
BUCKET_CORS_CONFIG, BUCKET_LIFECYCLE_CONFIG, BUCKET_POLICY_CONFIG, BUCKET_QUOTA_CONFIG_FILE, BUCKET_REPLICATION_CONFIG,
|
||||
@@ -60,6 +67,7 @@ use rustfs_policy::policy::{
|
||||
};
|
||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
use rustfs_signer::sign_v4;
|
||||
use rustfs_utils::http::get_source_scheme;
|
||||
use s3s::dto::{
|
||||
BucketVersioningStatus, DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication, DeleteReplicationStatus,
|
||||
Destination, ExistingObjectReplication, ExistingObjectReplicationStatus, ReplicationConfiguration, ReplicationRule,
|
||||
@@ -71,11 +79,11 @@ use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet, hash_map::DefaultHasher};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
use time::OffsetDateTime;
|
||||
use tracing::warn;
|
||||
use url::{Url, form_urlencoded};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -88,13 +96,14 @@ const SITE_REPL_RESYNC_CANCEL: &str = "cancel";
|
||||
const SITE_REPL_MIN_NETPERF_DURATION: Duration = Duration::from_secs(1);
|
||||
const SITE_REPLICATION_PEER_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const SITE_REPLICATION_PEER_CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
const SITE_REPLICATION_PEER_ERROR_DETAIL_LIMIT: usize = 256;
|
||||
const IDENTITY_LDAP_SUB_SYS: &str = "identity_ldap";
|
||||
const LEGACY_LDAP_SUB_SYS: &str = "ldapserverconfig";
|
||||
const SITE_REPLICATOR_SERVICE_ACCOUNT: &str = "site-replicator-0";
|
||||
const SITE_REPLICATION_PEER_JOIN_PATH: &str = "/rustfs/admin/v3/site-replication/peer/join";
|
||||
const SITE_REPLICATION_PEER_EDIT_PATH: &str = "/rustfs/admin/v3/site-replication/peer/edit";
|
||||
const SITE_REPLICATION_PEER_REMOVE_PATH: &str = "/rustfs/admin/v3/site-replication/peer/remove";
|
||||
static SITE_REPLICATION_PEER_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
||||
static SITE_REPLICATION_PEER_CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
struct SiteReplicationState {
|
||||
@@ -378,9 +387,22 @@ async fn load_site_replication_state() -> S3Result<SiteReplicationState> {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
match read_config(store, SITE_REPLICATION_STATE_PATH).await {
|
||||
Ok(data) => serde_json::from_slice(&data)
|
||||
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("invalid site replication state: {e}"))),
|
||||
match read_config(store.clone(), SITE_REPLICATION_STATE_PATH).await {
|
||||
Ok(data) => {
|
||||
let mut state: SiteReplicationState = serde_json::from_slice(&data)
|
||||
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("invalid site replication state: {e}")))?;
|
||||
let original_state = serde_json::to_vec(&state).ok();
|
||||
state.peers = normalize_peer_map_by_identity(state.peers);
|
||||
let normalized_state = serde_json::to_vec(&state).ok();
|
||||
if original_state != normalized_state
|
||||
&& let Some(data) = normalized_state
|
||||
{
|
||||
save_config(store, SITE_REPLICATION_STATE_PATH, data).await.map_err(|e| {
|
||||
S3Error::with_message(S3ErrorCode::InternalError, format!("normalize site replication state failed: {e}"))
|
||||
})?;
|
||||
}
|
||||
Ok(state)
|
||||
}
|
||||
Err(StorageError::ConfigNotFound) => Ok(SiteReplicationState::default()),
|
||||
Err(err) => Err(S3Error::with_message(
|
||||
S3ErrorCode::InternalError,
|
||||
@@ -394,7 +416,10 @@ async fn save_site_replication_state(state: &SiteReplicationState) -> S3Result<(
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
let data = serde_json::to_vec(state)
|
||||
let mut normalized = state.clone();
|
||||
normalized.peers = normalize_peer_map_by_identity(normalized.peers);
|
||||
|
||||
let data = serde_json::to_vec(&normalized)
|
||||
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize state failed: {e}")))?;
|
||||
save_config(store, SITE_REPLICATION_STATE_PATH, data)
|
||||
.await
|
||||
@@ -414,24 +439,98 @@ async fn clear_site_replication_state() -> S3Result<()> {
|
||||
}
|
||||
|
||||
async fn persist_site_replication_state(state: &SiteReplicationState) -> S3Result<()> {
|
||||
if state.peers.len() <= 1 {
|
||||
let mut normalized = state.clone();
|
||||
normalized.peers = normalize_peer_map_by_identity(normalized.peers);
|
||||
if normalized.peers.len() <= 1 {
|
||||
clear_site_replication_state().await
|
||||
} else {
|
||||
save_site_replication_state(state).await
|
||||
save_site_replication_state(&normalized).await
|
||||
}
|
||||
}
|
||||
|
||||
fn site_replication_peer_client() -> &'static reqwest::Client {
|
||||
SITE_REPLICATION_PEER_CLIENT.get_or_init(|| {
|
||||
reqwest::Client::builder()
|
||||
.timeout(SITE_REPLICATION_PEER_REQUEST_TIMEOUT)
|
||||
.connect_timeout(SITE_REPLICATION_PEER_CONNECT_TIMEOUT)
|
||||
.pool_idle_timeout(Some(Duration::from_secs(60)))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new())
|
||||
fn add_root_certificates_from_file(
|
||||
mut builder: reqwest::ClientBuilder,
|
||||
cert_path: &std::path::Path,
|
||||
description: &str,
|
||||
) -> S3Result<reqwest::ClientBuilder> {
|
||||
if !cert_path.exists() {
|
||||
return Ok(builder);
|
||||
}
|
||||
|
||||
std::fs::read(cert_path).map_err(|e| {
|
||||
S3Error::with_message(
|
||||
S3ErrorCode::InternalError,
|
||||
format!("failed to read {description} {}: {e}", cert_path.display()),
|
||||
)
|
||||
})?;
|
||||
|
||||
let certs_der = rustfs_utils::load_cert_bundle_der_bytes(cert_path.to_string_lossy().as_ref()).map_err(|e| {
|
||||
S3Error::with_message(
|
||||
S3ErrorCode::InternalError,
|
||||
format!("failed to parse {description} {}: {e}", cert_path.display()),
|
||||
)
|
||||
})?;
|
||||
|
||||
for cert_der in certs_der {
|
||||
let cert = reqwest::Certificate::from_der(&cert_der).map_err(|e| {
|
||||
S3Error::with_message(
|
||||
S3ErrorCode::InternalError,
|
||||
format!("failed to load {description} {}: {e}", cert_path.display()),
|
||||
)
|
||||
})?;
|
||||
builder = builder.add_root_certificate(cert);
|
||||
}
|
||||
|
||||
Ok(builder)
|
||||
}
|
||||
|
||||
fn build_site_replication_peer_client() -> S3Result<reqwest::Client> {
|
||||
let mut builder = reqwest::Client::builder()
|
||||
.timeout(SITE_REPLICATION_PEER_REQUEST_TIMEOUT)
|
||||
.connect_timeout(SITE_REPLICATION_PEER_CONNECT_TIMEOUT)
|
||||
.pool_idle_timeout(Some(Duration::from_secs(60)));
|
||||
|
||||
let tls_path = rustfs_utils::get_env_str(ENV_RUSTFS_TLS_PATH, DEFAULT_RUSTFS_TLS_PATH);
|
||||
if !tls_path.is_empty() {
|
||||
let tls_dir = std::path::Path::new(&tls_path);
|
||||
builder = add_root_certificates_from_file(builder, &tls_dir.join(RUSTFS_CA_CERT), "site-replication CA cert")?;
|
||||
|
||||
if rustfs_utils::get_env_bool(ENV_TRUST_LEAF_CERT_AS_CA, DEFAULT_TRUST_LEAF_CERT_AS_CA) {
|
||||
builder =
|
||||
add_root_certificates_from_file(builder, &tls_dir.join(RUSTFS_TLS_CERT), "site-replication leaf cert as CA")?;
|
||||
}
|
||||
}
|
||||
|
||||
builder
|
||||
.build()
|
||||
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("build site replication peer client failed: {e}")))
|
||||
}
|
||||
|
||||
fn site_replication_peer_client() -> S3Result<&'static reqwest::Client> {
|
||||
let result = SITE_REPLICATION_PEER_CLIENT.get_or_init(|| build_site_replication_peer_client().map_err(|e| e.to_string()));
|
||||
result.as_ref().map_err(|err| {
|
||||
S3Error::with_message(
|
||||
S3ErrorCode::InternalError,
|
||||
format!("initialize site replication peer client failed: {err}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn runtime_tls_enabled() -> bool {
|
||||
if let Some(tls_enabled) = get_global_endpoints_opt().and_then(|endpoints| {
|
||||
endpoints
|
||||
.as_ref()
|
||||
.iter()
|
||||
.flat_map(|pool| pool.endpoints.as_ref().iter())
|
||||
.find(|endpoint| endpoint.is_local)
|
||||
.map(|endpoint| endpoint.url.scheme().eq_ignore_ascii_case("https"))
|
||||
}) {
|
||||
return tls_enabled;
|
||||
}
|
||||
|
||||
!rustfs_utils::get_env_str(ENV_RUSTFS_TLS_PATH, DEFAULT_RUSTFS_TLS_PATH).is_empty()
|
||||
}
|
||||
|
||||
fn query_pairs(uri: &Uri) -> HashMap<String, String> {
|
||||
uri.query()
|
||||
.map(|query| {
|
||||
@@ -554,17 +653,30 @@ fn load_ldap_idp_settings() -> (LDAPSettings, LDAPConfigSettings) {
|
||||
}
|
||||
|
||||
fn request_endpoint(uri: &Uri, headers: &HeaderMap) -> String {
|
||||
let scheme = headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("http");
|
||||
let scheme = get_source_scheme(headers)
|
||||
.and_then(|value| {
|
||||
value
|
||||
.split(',')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_ascii_lowercase)
|
||||
})
|
||||
.or_else(|| uri.scheme_str().map(str::to_ascii_lowercase))
|
||||
.unwrap_or_else(|| {
|
||||
if runtime_tls_enabled() {
|
||||
"https".to_string()
|
||||
} else {
|
||||
"http".to_string()
|
||||
}
|
||||
});
|
||||
|
||||
let host = headers
|
||||
.get(http::header::HOST)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| uri.authority().map(|value| value.as_str().to_string()))
|
||||
.or_else(|| {
|
||||
get_global_endpoints_opt().and_then(|endpoints| {
|
||||
endpoints
|
||||
@@ -577,10 +689,6 @@ fn request_endpoint(uri: &Uri, headers: &HeaderMap) -> String {
|
||||
})
|
||||
.unwrap_or_else(|| format!("127.0.0.1:{}", global_rustfs_port()));
|
||||
|
||||
if uri.scheme_str().is_some() {
|
||||
return format!("{scheme}://{host}");
|
||||
}
|
||||
|
||||
format!("{scheme}://{host}")
|
||||
}
|
||||
|
||||
@@ -601,12 +709,6 @@ fn infer_site_name(endpoint: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn deployment_id_for_endpoint(endpoint: &str) -> String {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
endpoint.hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
}
|
||||
|
||||
fn qstat(count: i64, bytes: i64) -> QStat {
|
||||
QStat {
|
||||
count: count as f64,
|
||||
@@ -666,37 +768,15 @@ fn current_local_runtime_peer(state: &SiteReplicationState) -> PeerInfo {
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_endpoint(endpoint: &str) -> String {
|
||||
let trimmed = endpoint.trim().trim_end_matches('/');
|
||||
let candidate = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("http://{trimmed}")
|
||||
};
|
||||
|
||||
Url::parse(&candidate)
|
||||
.ok()
|
||||
.map(|url| {
|
||||
let scheme = url.scheme().to_ascii_lowercase();
|
||||
let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
|
||||
let port = url.port_or_known_default();
|
||||
match port {
|
||||
Some(port) => format!("{scheme}://{host}:{port}"),
|
||||
None => format!("{scheme}://{host}"),
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| trimmed.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn same_endpoint(left: &str, right: &str) -> bool {
|
||||
canonical_endpoint(left) == canonical_endpoint(right)
|
||||
fn normalize_peer_map_by_identity(peers: BTreeMap<String, PeerInfo>) -> BTreeMap<String, PeerInfo> {
|
||||
normalize_peer_map_by_identity_with(peers, normalize_peer_info)
|
||||
}
|
||||
|
||||
fn existing_peer_for_endpoint(state: &SiteReplicationState, endpoint: &str) -> Option<PeerInfo> {
|
||||
state
|
||||
.peers
|
||||
.values()
|
||||
.find(|peer| same_endpoint(&peer.endpoint, endpoint))
|
||||
.find(|peer| same_identity_endpoint(&peer.endpoint, endpoint))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
@@ -744,11 +824,11 @@ fn build_join_peers(
|
||||
let mut normalized_local = local_peer.clone();
|
||||
normalized_local.replicate_ilm_expiry = replicate_ilm_expiry;
|
||||
normalized_local = normalize_peer_info(normalized_local);
|
||||
seen_endpoints.insert(canonical_endpoint(&normalized_local.endpoint));
|
||||
seen_endpoints.insert(site_identity_key(&normalized_local.endpoint));
|
||||
peers.insert(normalized_local.deployment_id.clone(), normalized_local);
|
||||
|
||||
for site in sites {
|
||||
let endpoint_key = canonical_endpoint(&site.endpoint);
|
||||
let endpoint_key = site_identity_key(&site.endpoint);
|
||||
if !seen_endpoints.insert(endpoint_key) {
|
||||
continue;
|
||||
}
|
||||
@@ -764,7 +844,7 @@ fn build_join_peers(
|
||||
peers.insert(peer.deployment_id.clone(), peer);
|
||||
}
|
||||
|
||||
peers
|
||||
normalize_peer_map_by_identity(peers)
|
||||
}
|
||||
|
||||
fn normalize_join_peers_for_local(local_peer: &PeerInfo, peers: BTreeMap<String, PeerInfo>) -> BTreeMap<String, PeerInfo> {
|
||||
@@ -772,7 +852,7 @@ fn normalize_join_peers_for_local(local_peer: &PeerInfo, peers: BTreeMap<String,
|
||||
|
||||
for (_, incoming_peer) in peers {
|
||||
let mut peer = normalize_peer_info(incoming_peer);
|
||||
if same_endpoint(&peer.endpoint, &local_peer.endpoint) {
|
||||
if same_identity_endpoint(&peer.endpoint, &local_peer.endpoint) {
|
||||
peer.deployment_id = local_peer.deployment_id.clone();
|
||||
if peer.name.is_empty() {
|
||||
peer.name = local_peer.name.clone();
|
||||
@@ -785,15 +865,16 @@ fn normalize_join_peers_for_local(local_peer: &PeerInfo, peers: BTreeMap<String,
|
||||
normalized.insert(local_peer.deployment_id.clone(), local_peer.clone());
|
||||
}
|
||||
|
||||
normalized
|
||||
normalize_peer_map_by_identity(normalized)
|
||||
}
|
||||
|
||||
fn reconcile_peer_with_actual_identity(mut state: SiteReplicationState, actual_peer: PeerInfo) -> SiteReplicationState {
|
||||
let actual_peer = normalize_peer_info(actual_peer);
|
||||
state
|
||||
.peers
|
||||
.retain(|_, peer| !same_endpoint(&peer.endpoint, &actual_peer.endpoint));
|
||||
.retain(|_, peer| !same_identity_endpoint(&peer.endpoint, &actual_peer.endpoint));
|
||||
state.peers.insert(actual_peer.deployment_id.clone(), actual_peer);
|
||||
state.peers = normalize_peer_map_by_identity(state.peers);
|
||||
state
|
||||
}
|
||||
|
||||
@@ -888,16 +969,26 @@ async fn send_peer_admin_request<T: Serialize>(
|
||||
.unwrap_or("us-east-1"),
|
||||
);
|
||||
|
||||
let mut req = site_replication_peer_client().request(reqwest::Method::PUT, &url);
|
||||
let mut req = site_replication_peer_client()?.request(reqwest::Method::PUT, &url);
|
||||
for (name, value) in signed.headers() {
|
||||
req = req.header(name, value);
|
||||
}
|
||||
|
||||
let response = req
|
||||
.body(payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("peer request failed: {e}")))?;
|
||||
let response = req.body(payload).send().await.map_err(|e| {
|
||||
let classify = if e.is_timeout() {
|
||||
"timeout"
|
||||
} else if e.is_connect() && e.to_string().to_ascii_lowercase().contains("dns") {
|
||||
"dns resolution"
|
||||
} else if e.to_string().to_ascii_lowercase().contains("certificate") || e.to_string().to_ascii_lowercase().contains("tls")
|
||||
{
|
||||
"tls handshake"
|
||||
} else if e.is_connect() {
|
||||
"connect"
|
||||
} else {
|
||||
"request"
|
||||
};
|
||||
S3Error::with_message(S3ErrorCode::InternalError, format!("peer request to {url} failed ({classify}): {e}"))
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response
|
||||
@@ -931,7 +1022,7 @@ async fn broadcast_site_replication_json<T: Serialize>(path: &str, body: &T) ->
|
||||
};
|
||||
|
||||
for peer in state.peers.values() {
|
||||
if peer.deployment_id == local_peer.deployment_id || same_endpoint(&peer.endpoint, &local_peer.endpoint) {
|
||||
if peer.deployment_id == local_peer.deployment_id || same_identity_endpoint(&peer.endpoint, &local_peer.endpoint) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1473,7 +1564,7 @@ fn sync_state_name_for_local_peer(
|
||||
local_peer: &PeerInfo,
|
||||
incoming: &PeerInfo,
|
||||
) -> SiteReplicationState {
|
||||
if same_endpoint(&incoming.endpoint, &local_peer.endpoint) && !incoming.name.is_empty() {
|
||||
if same_identity_endpoint(&incoming.endpoint, &local_peer.endpoint) && !incoming.name.is_empty() {
|
||||
state.name = incoming.name.clone();
|
||||
}
|
||||
state
|
||||
@@ -1503,12 +1594,58 @@ fn remove_sites(mut state: SiteReplicationState, req: SRRemoveReq) -> SiteReplic
|
||||
return state;
|
||||
}
|
||||
|
||||
let names: Vec<String> = req.site_names.into_iter().collect();
|
||||
state.peers.retain(|_, peer| !names.iter().any(|name| name == &peer.name));
|
||||
let names: HashSet<String> = req.site_names.into_iter().collect();
|
||||
if names.contains(&state.name) {
|
||||
state.peers.clear();
|
||||
state.resync_status.clear();
|
||||
state.updated_at = Some(OffsetDateTime::now_utc());
|
||||
return state;
|
||||
}
|
||||
|
||||
let removed_deployment_ids: Vec<String> = state
|
||||
.peers
|
||||
.iter()
|
||||
.filter(|(_, peer)| names.contains(&peer.name))
|
||||
.map(|(deployment_id, _)| deployment_id.clone())
|
||||
.collect();
|
||||
for deployment_id in removed_deployment_ids {
|
||||
state.peers.remove(&deployment_id);
|
||||
state.resync_status.remove(&deployment_id);
|
||||
}
|
||||
state
|
||||
.resync_status
|
||||
.retain(|deployment_id, _| state.peers.contains_key(deployment_id));
|
||||
state.updated_at = Some(OffsetDateTime::now_utc());
|
||||
state
|
||||
}
|
||||
|
||||
fn summarize_peer_error_detail(detail: &str) -> String {
|
||||
let detail = detail.trim();
|
||||
let detail_chars = detail.chars().count();
|
||||
if detail_chars <= SITE_REPLICATION_PEER_ERROR_DETAIL_LIMIT {
|
||||
return detail.to_string();
|
||||
}
|
||||
|
||||
let suffix = "... (truncated)";
|
||||
let take_chars = SITE_REPLICATION_PEER_ERROR_DETAIL_LIMIT.saturating_sub(suffix.chars().count());
|
||||
let mut summary: String = detail.chars().take(take_chars).collect();
|
||||
summary.push_str(suffix);
|
||||
summary
|
||||
}
|
||||
|
||||
fn site_replication_remove_status(peer_errors: &[String]) -> ReplicateRemoveStatus {
|
||||
ReplicateRemoveStatus {
|
||||
status: SITE_REPL_REMOVE_SUCCESS.to_string(),
|
||||
err_detail: if peer_errors.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let summaries: Vec<String> = peer_errors.iter().map(|error| summarize_peer_error_detail(error)).collect();
|
||||
summarize_peer_error_detail(&format!("failed to notify {} peer(s): {}", summaries.len(), summaries.join("; ")))
|
||||
},
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn resync_status_for_state(
|
||||
state: &mut SiteReplicationState,
|
||||
op_type: &str,
|
||||
@@ -1634,7 +1771,7 @@ fn reconcile_site_replication_bucket_targets(
|
||||
let mut targets = existing.targets;
|
||||
|
||||
for peer in state.peers.values() {
|
||||
if peer.deployment_id == local_peer.deployment_id || same_endpoint(&peer.endpoint, &local_peer.endpoint) {
|
||||
if peer.deployment_id == local_peer.deployment_id || same_identity_endpoint(&peer.endpoint, &local_peer.endpoint) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1705,7 +1842,7 @@ fn build_site_replication_config(
|
||||
) -> Option<ReplicationConfiguration> {
|
||||
let mut rules = Vec::new();
|
||||
for peer in state.peers.values() {
|
||||
if peer.deployment_id == local_peer.deployment_id || same_endpoint(&peer.endpoint, &local_peer.endpoint) {
|
||||
if peer.deployment_id == local_peer.deployment_id || same_identity_endpoint(&peer.endpoint, &local_peer.endpoint) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2283,8 +2420,9 @@ impl Operation for SiteReplicationAddHandler {
|
||||
|
||||
let mut joined_endpoints = HashSet::new();
|
||||
for site in &sites {
|
||||
let endpoint_key = canonical_endpoint(&site.endpoint);
|
||||
if same_endpoint(&site.endpoint, &local_peer.endpoint) || !joined_endpoints.insert(endpoint_key) {
|
||||
if same_identity_endpoint(&site.endpoint, &local_peer.endpoint)
|
||||
|| !joined_endpoints.insert(site_identity_key(&site.endpoint))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2327,13 +2465,17 @@ impl Operation for SiteReplicationRemoveHandler {
|
||||
let current_state = load_site_replication_state().await?;
|
||||
let local_peer = current_local_peer(&req, ¤t_state);
|
||||
let remove_req: SRRemoveReq = read_site_replication_json(req, "", false).await?;
|
||||
let state = remove_sites(current_state.clone(), remove_req.clone());
|
||||
persist_site_replication_state(&state).await?;
|
||||
let mut status = site_replication_remove_status(&[]);
|
||||
|
||||
let mut peer_errors = Vec::new();
|
||||
if !current_state.service_account_access_key.is_empty() && !current_state.service_account_secret_key.is_empty() {
|
||||
for peer in current_state.peers.values() {
|
||||
if same_endpoint(&peer.endpoint, &local_peer.endpoint) {
|
||||
if same_identity_endpoint(&peer.endpoint, &local_peer.endpoint) {
|
||||
continue;
|
||||
}
|
||||
send_peer_admin_request(
|
||||
if let Err(err) = send_peer_admin_request(
|
||||
&peer.endpoint,
|
||||
SITE_REPLICATION_PEER_REMOVE_PATH,
|
||||
¤t_state.service_account_access_key,
|
||||
@@ -2344,17 +2486,20 @@ impl Operation for SiteReplicationRemoveHandler {
|
||||
remove_all: remove_req.remove_all,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
let err_detail = summarize_peer_error_detail(&format!("{}: {err}", peer.endpoint));
|
||||
warn!(peer = %peer.endpoint, error = %err_detail, "site replication peer remove notification failed");
|
||||
peer_errors.push(err_detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let state = remove_sites(current_state, remove_req);
|
||||
persist_site_replication_state(&state).await?;
|
||||
json_response(&ReplicateRemoveStatus {
|
||||
status: SITE_REPL_REMOVE_SUCCESS.to_string(),
|
||||
api_version: Some(SITE_REPL_API_VERSION.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
if !peer_errors.is_empty() {
|
||||
status = site_replication_remove_status(&peer_errors);
|
||||
}
|
||||
|
||||
json_response(&status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2745,7 +2890,7 @@ impl Operation for SRPeerEditHandler {
|
||||
let state = load_site_replication_state().await?;
|
||||
let local_peer = current_local_peer(&req, &state);
|
||||
let mut incoming: PeerInfo = read_site_replication_json(req, "", false).await?;
|
||||
if same_endpoint(&incoming.endpoint, &local_peer.endpoint) {
|
||||
if same_identity_endpoint(&incoming.endpoint, &local_peer.endpoint) {
|
||||
incoming.deployment_id = local_peer.deployment_id.clone();
|
||||
if incoming.name.is_empty() {
|
||||
incoming.name = local_peer.name.clone();
|
||||
@@ -2863,7 +3008,8 @@ impl Operation for SRStateEditHandler {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use http::Uri;
|
||||
use http::{HeaderMap, HeaderValue, Uri};
|
||||
use temp_env::with_var;
|
||||
|
||||
fn peer(name: &str, endpoint: &str) -> PeerInfo {
|
||||
PeerInfo {
|
||||
@@ -2991,6 +3137,76 @@ mod tests {
|
||||
assert!(normalized.contains_key("hash-remote"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_site_identity_key_deduplicates_scheme_drift_on_same_host_port() {
|
||||
assert_eq!(
|
||||
site_identity_key("https://node-a.example.com:9000"),
|
||||
site_identity_key("http://NODE-A.example.com:9000/"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_peer_map_by_identity_prefers_https_endpoint() {
|
||||
let peers = BTreeMap::from([
|
||||
(
|
||||
"peer-http".to_string(),
|
||||
PeerInfo {
|
||||
deployment_id: "peer-http".to_string(),
|
||||
..peer("peer", "http://node-a.example.com:9000")
|
||||
},
|
||||
),
|
||||
(
|
||||
"peer-https".to_string(),
|
||||
PeerInfo {
|
||||
deployment_id: "peer-https".to_string(),
|
||||
..peer("peer", "https://node-a.example.com:9000")
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
let normalized = normalize_peer_map_by_identity(peers);
|
||||
assert_eq!(normalized.len(), 1);
|
||||
let normalized_peer = normalized.values().next().expect("normalized peer");
|
||||
assert!(normalized_peer.endpoint.starts_with("https://"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_request_endpoint_prefers_forwarded_proto() {
|
||||
let uri: Uri = "/rustfs/admin/v3/site-replication/status".parse().unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-scheme", HeaderValue::from_static("http"));
|
||||
headers.insert("x-forwarded-proto", HeaderValue::from_static("https"));
|
||||
headers.insert("host", HeaderValue::from_static("node-a.example.com:9000"));
|
||||
|
||||
let endpoint = request_endpoint(&uri, &headers);
|
||||
|
||||
assert_eq!(endpoint, "https://node-a.example.com:9000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_request_endpoint_uses_absolute_uri_without_host_header() {
|
||||
let uri: Uri = "https://node-a.example.com:9443/rustfs/admin/v3/site-replication/status"
|
||||
.parse()
|
||||
.unwrap();
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
let endpoint = request_endpoint(&uri, &headers);
|
||||
|
||||
assert_eq!(endpoint, "https://node-a.example.com:9443");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_request_endpoint_falls_back_to_https_when_tls_path_is_configured() {
|
||||
with_var(ENV_RUSTFS_TLS_PATH, Some("/tmp/tls"), || {
|
||||
let uri: Uri = "/rustfs/admin/v3/site-replication/status".parse().unwrap();
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
let endpoint = request_endpoint(&uri, &headers);
|
||||
|
||||
assert!(endpoint.starts_with("https://"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reconcile_peer_with_actual_identity_replaces_endpoint_hash_key() {
|
||||
let mut state = SiteReplicationState::default();
|
||||
@@ -3064,6 +3280,230 @@ mod tests {
|
||||
assert!(req.site_names.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_sites_keeps_local_success_with_peer_errors() {
|
||||
let mut state = SiteReplicationState::default();
|
||||
state.peers.insert(
|
||||
"local".to_string(),
|
||||
PeerInfo {
|
||||
deployment_id: "local".to_string(),
|
||||
..peer("local", "https://local.example.com")
|
||||
},
|
||||
);
|
||||
state.peers.insert(
|
||||
"remote".to_string(),
|
||||
PeerInfo {
|
||||
deployment_id: "remote".to_string(),
|
||||
..peer("remote", "https://remote.example.com")
|
||||
},
|
||||
);
|
||||
|
||||
let state = remove_sites(
|
||||
state,
|
||||
SRRemoveReq {
|
||||
remove_all: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let status =
|
||||
site_replication_remove_status(&["peer request to https://remote.example.com failed with 403 Forbidden".to_string()]);
|
||||
|
||||
assert!(state.peers.is_empty());
|
||||
assert_eq!(status.status, SITE_REPL_REMOVE_SUCCESS);
|
||||
assert!(status.err_detail.contains("failed to notify 1 peer"));
|
||||
assert!(status.err_detail.contains("403 Forbidden"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_sites_drops_resync_status_for_removed_peer() {
|
||||
let mut state = SiteReplicationState {
|
||||
name: "local".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
state.peers.insert(
|
||||
"local-deployment".to_string(),
|
||||
PeerInfo {
|
||||
deployment_id: "local-deployment".to_string(),
|
||||
..peer("local", "https://local.example.com")
|
||||
},
|
||||
);
|
||||
state.peers.insert(
|
||||
"remote-a-deployment".to_string(),
|
||||
PeerInfo {
|
||||
deployment_id: "remote-a-deployment".to_string(),
|
||||
..peer("remote-a", "https://remote-a.example.com")
|
||||
},
|
||||
);
|
||||
state.peers.insert(
|
||||
"remote-b-deployment".to_string(),
|
||||
PeerInfo {
|
||||
deployment_id: "remote-b-deployment".to_string(),
|
||||
..peer("remote-b", "https://remote-b.example.com")
|
||||
},
|
||||
);
|
||||
state.resync_status.insert(
|
||||
"remote-a-deployment".to_string(),
|
||||
SRResyncOpStatus {
|
||||
resync_id: "stale-a".to_string(),
|
||||
status: "success".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
state.resync_status.insert(
|
||||
"remote-a-legacy-key".to_string(),
|
||||
SRResyncOpStatus {
|
||||
resync_id: "stale-a-legacy".to_string(),
|
||||
status: "success".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
state.resync_status.insert(
|
||||
"remote-b-deployment".to_string(),
|
||||
SRResyncOpStatus {
|
||||
resync_id: "active-b".to_string(),
|
||||
status: "success".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let state = remove_sites(
|
||||
state,
|
||||
SRRemoveReq {
|
||||
site_names: vec!["remote-a".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert!(state.peers.contains_key("local-deployment"));
|
||||
assert!(!state.peers.contains_key("remote-a-deployment"));
|
||||
assert!(state.peers.contains_key("remote-b-deployment"));
|
||||
assert!(!state.resync_status.contains_key("remote-a-deployment"));
|
||||
assert!(!state.resync_status.contains_key("remote-a-legacy-key"));
|
||||
assert!(state.resync_status.contains_key("remote-b-deployment"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_sites_prunes_orphan_resync_status_without_matching_site() {
|
||||
let mut state = SiteReplicationState {
|
||||
name: "local".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
state.peers.insert(
|
||||
"remote-a-deployment".to_string(),
|
||||
PeerInfo {
|
||||
deployment_id: "remote-a-deployment".to_string(),
|
||||
..peer("remote-a", "https://remote-a.example.com")
|
||||
},
|
||||
);
|
||||
state.peers.insert(
|
||||
"remote-b-deployment".to_string(),
|
||||
PeerInfo {
|
||||
deployment_id: "remote-b-deployment".to_string(),
|
||||
..peer("remote-b", "https://remote-b.example.com")
|
||||
},
|
||||
);
|
||||
state.resync_status.insert(
|
||||
"remote-a-deployment".to_string(),
|
||||
SRResyncOpStatus {
|
||||
resync_id: "active-a".to_string(),
|
||||
status: "success".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
state.resync_status.insert(
|
||||
"removed-deployment".to_string(),
|
||||
SRResyncOpStatus {
|
||||
resync_id: "orphaned".to_string(),
|
||||
status: "success".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let state = remove_sites(
|
||||
state,
|
||||
SRRemoveReq {
|
||||
site_names: vec!["missing-site".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert!(state.peers.contains_key("remote-a-deployment"));
|
||||
assert!(state.peers.contains_key("remote-b-deployment"));
|
||||
assert!(state.resync_status.contains_key("remote-a-deployment"));
|
||||
assert!(!state.resync_status.contains_key("removed-deployment"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_sites_clears_state_when_local_site_is_removed() {
|
||||
let mut state = SiteReplicationState {
|
||||
name: "local".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
state.peers.insert(
|
||||
"local-deployment".to_string(),
|
||||
PeerInfo {
|
||||
deployment_id: "local-deployment".to_string(),
|
||||
..peer("local", "https://local.example.com")
|
||||
},
|
||||
);
|
||||
state.peers.insert(
|
||||
"remote-a-deployment".to_string(),
|
||||
PeerInfo {
|
||||
deployment_id: "remote-a-deployment".to_string(),
|
||||
..peer("remote-a", "https://remote-a.example.com")
|
||||
},
|
||||
);
|
||||
state.peers.insert(
|
||||
"remote-b-deployment".to_string(),
|
||||
PeerInfo {
|
||||
deployment_id: "remote-b-deployment".to_string(),
|
||||
..peer("remote-b", "https://remote-b.example.com")
|
||||
},
|
||||
);
|
||||
state.resync_status.insert(
|
||||
"remote-a-deployment".to_string(),
|
||||
SRResyncOpStatus {
|
||||
resync_id: "active-a".to_string(),
|
||||
status: "success".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let state = remove_sites(
|
||||
state,
|
||||
SRRemoveReq {
|
||||
site_names: vec!["local".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert!(state.peers.is_empty());
|
||||
assert!(state.resync_status.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_site_replication_remove_status_truncates_peer_error_detail() {
|
||||
let long_peer_body = "peer response body ".repeat(40);
|
||||
let status = site_replication_remove_status(&[format!(
|
||||
"https://remote.example.com: peer request failed with 403 Forbidden: {long_peer_body}"
|
||||
)]);
|
||||
|
||||
assert!(status.err_detail.contains("403 Forbidden"));
|
||||
assert!(status.err_detail.contains("truncated"));
|
||||
assert!(!status.err_detail.contains(&long_peer_body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_site_replication_remove_status_caps_final_error_detail() {
|
||||
let peer_errors: Vec<String> = (0..8)
|
||||
.map(|idx| format!("https://remote-{idx}.example.com: {}", "peer response body ".repeat(40)))
|
||||
.collect();
|
||||
let status = site_replication_remove_status(&peer_errors);
|
||||
|
||||
assert!(status.err_detail.chars().count() <= SITE_REPLICATION_PEER_ERROR_DETAIL_LIMIT);
|
||||
assert!(status.err_detail.contains("truncated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_peer_respects_ilm_expiry_override() {
|
||||
let peer = peer("remote", "https://remote.example.com");
|
||||
|
||||
@@ -250,7 +250,12 @@ async fn handle_assume_role(
|
||||
|
||||
new_cred.parent_user = cred.access_key.clone();
|
||||
|
||||
debug!("AssumeRole get new_cred {:?}", &new_cred);
|
||||
debug!(
|
||||
access_key = %new_cred.access_key,
|
||||
parent_user = %new_cred.parent_user,
|
||||
expiration = ?new_cred.expiration,
|
||||
"AssumeRole generated temporary credentials"
|
||||
);
|
||||
|
||||
let updated_at = iam_store
|
||||
.set_temp_user(&new_cred.access_key, &new_cred, None)
|
||||
|
||||
@@ -139,6 +139,44 @@ fn imported_service_account_status(status: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
const SERVICE_ACCOUNT_PARENT_SCOPE_ERROR: &str = "service account parent is outside requester scope";
|
||||
const SERVICE_ACCOUNT_ACCESS_KEY_MISMATCH_ERROR: &str = "service account access key does not match import entry";
|
||||
|
||||
fn imported_service_account_parent_allowed(parent: &str, requester: &Credentials, owner: bool) -> bool {
|
||||
if parent.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if owner {
|
||||
return true;
|
||||
}
|
||||
|
||||
if requester.is_temp() || requester.is_service_account() {
|
||||
return temp_identity_parent(requester).is_some_and(|requester_parent| requester_parent == parent);
|
||||
}
|
||||
|
||||
requester.parent_user.is_empty() && requester.access_key == parent
|
||||
}
|
||||
|
||||
fn imported_service_account_parent_scope_failure(
|
||||
access_key: &str,
|
||||
parent: &str,
|
||||
requester: &Credentials,
|
||||
owner: bool,
|
||||
) -> Option<IAMErrEntity> {
|
||||
(!imported_service_account_parent_allowed(parent, requester, owner)).then(|| IAMErrEntity {
|
||||
name: access_key.to_string(),
|
||||
error: SERVICE_ACCOUNT_PARENT_SCOPE_ERROR.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn imported_service_account_access_key_failure(entry_access_key: &str, payload_access_key: &str) -> Option<IAMErrEntity> {
|
||||
(entry_access_key != payload_access_key).then(|| IAMErrEntity {
|
||||
name: entry_access_key.to_string(),
|
||||
error: SERVICE_ACCOUNT_ACCESS_KEY_MISMATCH_ERROR.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub struct AddUser {}
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for AddUser {
|
||||
@@ -984,9 +1022,19 @@ impl Operation for ImportIam {
|
||||
return Err(s3_error!(InvalidArgument, "has space be {ak}"));
|
||||
}
|
||||
|
||||
if let Some(err) = imported_service_account_access_key_failure(&ak, &req.access_key) {
|
||||
failed.service_accounts.push(err);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(err) = imported_service_account_parent_scope_failure(&ak, &req.parent, &cred, owner) {
|
||||
failed.service_accounts.push(err);
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut update = true;
|
||||
|
||||
if let Err(e) = iam_store.get_service_account(&req.access_key).await {
|
||||
if let Err(e) = iam_store.get_service_account(&ak).await {
|
||||
if !matches!(e, rustfs_iam::error::Error::NoSuchServiceAccount(_)) {
|
||||
return Err(s3_error!(InvalidArgument, "failed to get service account {ak} {e}"));
|
||||
}
|
||||
@@ -994,7 +1042,7 @@ impl Operation for ImportIam {
|
||||
}
|
||||
|
||||
if update {
|
||||
iam_store.delete_service_account(&req.access_key, true).await.map_err(|e| {
|
||||
iam_store.delete_service_account(&ak, true).await.map_err(|e| {
|
||||
S3Error::with_message(
|
||||
S3ErrorCode::InternalError,
|
||||
format!("failed to delete service account {ak} {e}"),
|
||||
@@ -1216,8 +1264,10 @@ impl Operation for ImportIam {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
GROUP_POLICY_MAPPING_USER_TYPE, imported_service_account_status, should_check_deny_only, should_reject_group_import_name,
|
||||
should_restore_group_as_disabled,
|
||||
GROUP_POLICY_MAPPING_USER_TYPE, SERVICE_ACCOUNT_ACCESS_KEY_MISMATCH_ERROR, SERVICE_ACCOUNT_PARENT_SCOPE_ERROR,
|
||||
imported_service_account_access_key_failure, imported_service_account_parent_allowed,
|
||||
imported_service_account_parent_scope_failure, imported_service_account_status, should_check_deny_only,
|
||||
should_reject_group_import_name, should_restore_group_as_disabled,
|
||||
};
|
||||
use rustfs_credentials::{Credentials, IAM_POLICY_CLAIM_NAME_SA};
|
||||
use rustfs_iam::error::Error as IamError;
|
||||
@@ -1337,6 +1387,92 @@ mod tests {
|
||||
assert!(imported_service_account_status("unknown").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_service_account_parent_rejects_other_parent_for_non_owner() {
|
||||
let requester = Credentials {
|
||||
access_key: "delegated-importer".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!imported_service_account_parent_allowed("root-access-key", &requester, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_service_account_parent_scope_failure_records_import_error() {
|
||||
let requester = Credentials {
|
||||
access_key: "delegated-importer".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let err = imported_service_account_parent_scope_failure("svc-access-key", "root-access-key", &requester, false)
|
||||
.expect("non-owner must not import a service account for another parent");
|
||||
|
||||
assert_eq!(err.name, "svc-access-key");
|
||||
assert_eq!(err.error, SERVICE_ACCOUNT_PARENT_SCOPE_ERROR);
|
||||
assert!(
|
||||
imported_service_account_parent_scope_failure("svc-access-key", "delegated-importer", &requester, false).is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_service_account_import_rejects_payload_access_key_mismatch() {
|
||||
let payload = r#"{
|
||||
"svcalpha": {
|
||||
"parent": "useralpha",
|
||||
"accessKey": "svcbeta",
|
||||
"secretKey": "svcAlphaSecret123",
|
||||
"groups": [],
|
||||
"claims": {},
|
||||
"sessionPolicy": null,
|
||||
"status": "on",
|
||||
"name": "uploaderKey",
|
||||
"description": "alpha upload key",
|
||||
"expiration": "1970-01-01T00:00:00Z"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let svc_accts: HashMap<String, SRSvcAccCreate> = serde_json::from_str(payload).unwrap();
|
||||
let req = svc_accts.get("svcalpha").unwrap();
|
||||
let err = imported_service_account_access_key_failure("svcalpha", &req.access_key)
|
||||
.expect("mismatched service account access keys must be rejected");
|
||||
|
||||
assert_eq!(err.name, "svcalpha");
|
||||
assert_eq!(err.error, SERVICE_ACCOUNT_ACCESS_KEY_MISMATCH_ERROR);
|
||||
assert!(imported_service_account_access_key_failure("svcalpha", "svcalpha").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_service_account_parent_allows_owner_restore() {
|
||||
let requester = Credentials {
|
||||
access_key: "root-access-key".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(imported_service_account_parent_allowed("any-imported-parent", &requester, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_service_account_parent_allows_requester_self_parent() {
|
||||
let requester = Credentials {
|
||||
access_key: "delegated-importer".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(imported_service_account_parent_allowed("delegated-importer", &requester, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_service_account_parent_allows_derived_requester_parent() {
|
||||
let requester = Credentials {
|
||||
access_key: "derived-access-key".to_string(),
|
||||
parent_user: "parent-user".to_string(),
|
||||
session_token: "session-token".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(imported_service_account_parent_allowed("parent-user", &requester, false));
|
||||
assert!(!imported_service_account_parent_allowed("other-parent", &requester, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_service_account_import_accepts_null_groups_and_epoch_expiration() {
|
||||
let payload = r#"{
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod console;
|
||||
pub mod handlers;
|
||||
pub mod router;
|
||||
pub mod service;
|
||||
pub mod site_replication_identity;
|
||||
pub mod utils;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::admin::{
|
||||
};
|
||||
use crate::server::{ADMIN_PREFIX, HEALTH_PREFIX, HEALTH_READY_PATH, MINIO_ADMIN_PREFIX, PROFILE_CPU_PATH, PROFILE_MEMORY_PATH};
|
||||
use hyper::Method;
|
||||
use serial_test::serial;
|
||||
use temp_env::with_var;
|
||||
|
||||
fn admin_path(path: &str) -> String {
|
||||
@@ -60,7 +61,11 @@ fn register_admin_routes(router: &mut S3Router<AdminOperation>) {
|
||||
oidc::register_oidc_route(router).expect("register oidc route");
|
||||
}
|
||||
|
||||
// register_admin_routes reads ENV_HEALTH_ENDPOINT_ENABLE to decide whether
|
||||
// to register /health; serialise with the env-mutating test below to avoid
|
||||
// cross-thread leakage of that override.
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_register_routes_cover_representative_admin_paths() {
|
||||
let mut router: S3Router<AdminOperation> = S3Router::new(false);
|
||||
register_admin_routes(&mut router);
|
||||
@@ -171,6 +176,7 @@ fn test_register_routes_cover_representative_admin_paths() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_admin_alias_paths_match_existing_admin_routes() {
|
||||
let mut router: S3Router<AdminOperation> = S3Router::new(false);
|
||||
register_admin_routes(&mut router);
|
||||
@@ -233,6 +239,7 @@ fn test_admin_alias_paths_match_existing_admin_routes() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_health_routes_not_registered_when_disabled_by_env() {
|
||||
with_var(rustfs_config::ENV_HEALTH_ENDPOINT_ENABLE, Some("false"), || {
|
||||
let mut router: S3Router<AdminOperation> = S3Router::new(false);
|
||||
|
||||
@@ -2331,11 +2331,6 @@ where
|
||||
// Allow unauthenticated access to health check
|
||||
let path = req.uri.path();
|
||||
|
||||
// Profiling endpoints
|
||||
if req.method == Method::GET && (path == PROFILE_CPU_PATH || path == PROFILE_MEMORY_PATH) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Health check
|
||||
if (req.method == Method::HEAD || req.method == Method::GET) && is_public_health_path(path) {
|
||||
return Ok(());
|
||||
@@ -3747,6 +3742,28 @@ mod tests {
|
||||
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_access_rejects_anonymous_profile_request() {
|
||||
let router: S3Router<AdminOperation> = S3Router::new(false);
|
||||
let mut req = S3Request {
|
||||
input: Body::from(String::new()),
|
||||
method: Method::GET,
|
||||
uri: PROFILE_CPU_PATH.parse().expect("uri should parse"),
|
||||
headers: HeaderMap::new(),
|
||||
extensions: http::Extensions::new(),
|
||||
credentials: None,
|
||||
region: None,
|
||||
service: None,
|
||||
trailing_headers: None,
|
||||
};
|
||||
|
||||
let err = router
|
||||
.check_access(&mut req)
|
||||
.await
|
||||
.expect_err("anonymous profile request must be denied");
|
||||
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listen_notification_keepalive_plan_defaults_to_space_keepalive() {
|
||||
let uri: Uri = "/demo-bucket?events=s3:ObjectCreated:Put".parse().expect("uri should parse");
|
||||
|
||||
@@ -12,13 +12,80 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use rustfs_ecstore::config::com::read_config;
|
||||
use crate::admin::site_replication_identity::{deployment_id_for_endpoint, normalize_peer_map_by_identity_with};
|
||||
use rustfs_ecstore::config::com::{read_config, save_config};
|
||||
use rustfs_ecstore::error::Error as StorageError;
|
||||
use rustfs_ecstore::new_object_layer_fn;
|
||||
use rustfs_madmin::PeerInfo;
|
||||
use s3s::{S3Error, S3ErrorCode, S3Result};
|
||||
use serde_json::{Map, Value};
|
||||
use tracing::info;
|
||||
|
||||
const SITE_REPLICATION_STATE_PATH: &str = "config/site-replication/state.json";
|
||||
|
||||
fn normalize_peers_map(peers: &Map<String, Value>) -> Map<String, Value> {
|
||||
let mut valid_peers = std::collections::BTreeMap::<String, PeerInfo>::new();
|
||||
let mut passthrough_invalid = Vec::<(String, Value)>::new();
|
||||
|
||||
for (key, value) in peers {
|
||||
match serde_json::from_value::<PeerInfo>(value.clone()) {
|
||||
Ok(mut peer) => {
|
||||
if peer.endpoint.is_empty() {
|
||||
passthrough_invalid.push((key.clone(), value.clone()));
|
||||
continue;
|
||||
}
|
||||
if peer.deployment_id.is_empty() {
|
||||
peer.deployment_id = deployment_id_for_endpoint(&peer.endpoint);
|
||||
}
|
||||
// Keep all parsed entries for identity-level normalization. Using the
|
||||
// original JSON key avoids dropping records early on temporary
|
||||
// deployment_id collisions.
|
||||
valid_peers.insert(key.clone(), peer);
|
||||
}
|
||||
Err(_) => passthrough_invalid.push((key.clone(), value.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
let deduped_by_deployment = normalize_peer_map_by_identity_with(valid_peers, |peer| peer);
|
||||
|
||||
let mut normalized = Map::new();
|
||||
for (_, peer) in deduped_by_deployment {
|
||||
let key = peer.deployment_id.clone();
|
||||
if let Ok(value) = serde_json::to_value(peer) {
|
||||
normalized.insert(key, value);
|
||||
}
|
||||
}
|
||||
for (key, value) in passthrough_invalid {
|
||||
normalized.entry(key).or_insert(value);
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn normalize_site_replication_state_json(data: &[u8]) -> Result<Option<Vec<u8>>, String> {
|
||||
let mut state: Value = serde_json::from_slice(data).map_err(|e| format!("invalid site replication state: {e}"))?;
|
||||
let Some(obj) = state.as_object_mut() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let before = obj.get("peers").and_then(|v| v.as_object()).map(|v| v.len()).unwrap_or(0);
|
||||
|
||||
let Some(peers_obj) = obj.get("peers").and_then(|v| v.as_object()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let normalized_peers = normalize_peers_map(peers_obj);
|
||||
if normalized_peers == *peers_obj {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let after = normalized_peers.len();
|
||||
obj.insert("peers".to_string(), Value::Object(normalized_peers));
|
||||
let normalized =
|
||||
serde_json::to_vec(&state).map_err(|e| format!("serialize normalized site replication state failed: {e}"))?;
|
||||
info!("normalized site-replication peers during reload: {before} -> {after}");
|
||||
Ok(Some(normalized))
|
||||
}
|
||||
|
||||
/// Reload persisted site-replication state.
|
||||
///
|
||||
/// RustFS does not currently keep a separate in-memory cache for this state,
|
||||
@@ -28,10 +95,17 @@ pub async fn reload_site_replication_runtime_state() -> S3Result<()> {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
|
||||
match read_config(store, SITE_REPLICATION_STATE_PATH).await {
|
||||
match read_config(store.clone(), SITE_REPLICATION_STATE_PATH).await {
|
||||
Ok(data) => {
|
||||
let _: serde_json::Value = serde_json::from_slice(&data)
|
||||
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("invalid site replication state: {e}")))?;
|
||||
if let Some(normalized) =
|
||||
normalize_site_replication_state_json(&data).map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, e))?
|
||||
{
|
||||
save_config(store, SITE_REPLICATION_STATE_PATH, normalized)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
S3Error::with_message(S3ErrorCode::InternalError, format!("normalize site replication state failed: {e}"))
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(StorageError::ConfigNotFound) => Ok(()),
|
||||
@@ -41,3 +115,104 @@ pub async fn reload_site_replication_runtime_state() -> S3Result<()> {
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn peer_value(name: &str, endpoint: &str, deployment_id: &str) -> Value {
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"endpoint": endpoint,
|
||||
"deployment_id": deployment_id,
|
||||
"sync_state": "",
|
||||
"default_bandwidth": {},
|
||||
"replicate_ilm_expiry": false,
|
||||
"object_naming_mode": "",
|
||||
"api_version": "1"
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_state_json_deduplicates_http_https_peer() {
|
||||
let data = serde_json::to_vec(&serde_json::json!({
|
||||
"name": "local",
|
||||
"peers": {
|
||||
"remote-http": peer_value("remote", "http://node-a.example.com:9000", "remote-http"),
|
||||
"remote-https": peer_value("remote", "https://node-a.example.com:9000/", "remote-https")
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let normalized = normalize_site_replication_state_json(&data)
|
||||
.unwrap()
|
||||
.expect("state should be normalized");
|
||||
let value: Value = serde_json::from_slice(&normalized).unwrap();
|
||||
let peers = value.get("peers").and_then(Value::as_object).unwrap();
|
||||
|
||||
assert_eq!(peers.len(), 1);
|
||||
let endpoint = peers
|
||||
.values()
|
||||
.next()
|
||||
.and_then(|peer| peer.get("endpoint"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap();
|
||||
assert!(endpoint.starts_with("https://"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_state_json_is_idempotent() {
|
||||
let data = serde_json::to_vec(&serde_json::json!({
|
||||
"name": "local",
|
||||
"peers": {
|
||||
"remote": peer_value("remote", "https://node-a.example.com:9000", "remote")
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let first = normalize_site_replication_state_json(&data).unwrap();
|
||||
let normalized_once = first.unwrap_or(data);
|
||||
let second = normalize_site_replication_state_json(&normalized_once).unwrap();
|
||||
assert!(second.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_state_json_tolerates_malformed_peer_entries() {
|
||||
let data = serde_json::to_vec(&serde_json::json!({
|
||||
"name": "local",
|
||||
"peers": {
|
||||
"broken": {"endpoint": 123},
|
||||
"remote": peer_value("remote", "https://node-a.example.com:9000", "remote")
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let normalized = normalize_site_replication_state_json(&data).unwrap();
|
||||
let out = normalized.unwrap_or(data);
|
||||
let value: Value = serde_json::from_slice(&out).unwrap();
|
||||
let peers = value.get("peers").and_then(Value::as_object).unwrap();
|
||||
|
||||
assert!(peers.contains_key("broken"));
|
||||
assert!(!peers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_state_json_preserves_entries_before_identity_dedupe() {
|
||||
let data = serde_json::to_vec(&serde_json::json!({
|
||||
"name": "local",
|
||||
"peers": {
|
||||
"peer-a": peer_value("remote-a", "https://node-a.example.com:9000", "dup"),
|
||||
"peer-b": peer_value("remote-b", "https://node-b.example.com:9000", "dup")
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let normalized = normalize_site_replication_state_json(&data)
|
||||
.unwrap()
|
||||
.expect("state should be normalized");
|
||||
let value: Value = serde_json::from_slice(&normalized).unwrap();
|
||||
let peers = value.get("peers").and_then(Value::as_object).unwrap();
|
||||
|
||||
assert_eq!(peers.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
// 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 rustfs_madmin::PeerInfo;
|
||||
use std::collections::{BTreeMap, hash_map::DefaultHasher};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use url::Url;
|
||||
|
||||
fn has_http_scheme(endpoint: &str) -> bool {
|
||||
endpoint.get(..7).is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://"))
|
||||
|| endpoint
|
||||
.get(..8)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://"))
|
||||
}
|
||||
|
||||
pub fn canonical_endpoint(endpoint: &str) -> String {
|
||||
let trimmed = endpoint.trim().trim_end_matches('/');
|
||||
let candidate = if has_http_scheme(trimmed) {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("http://{trimmed}")
|
||||
};
|
||||
|
||||
Url::parse(&candidate)
|
||||
.ok()
|
||||
.map(|url| {
|
||||
let scheme = url.scheme().to_ascii_lowercase();
|
||||
let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
|
||||
let port = url.port_or_known_default();
|
||||
match port {
|
||||
Some(port) => format!("{scheme}://{host}:{port}"),
|
||||
None => format!("{scheme}://{host}"),
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| trimmed.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
pub fn site_identity_key(endpoint: &str) -> String {
|
||||
let trimmed = endpoint.trim().trim_end_matches('/');
|
||||
let candidate = if has_http_scheme(trimmed) {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("http://{trimmed}")
|
||||
};
|
||||
|
||||
Url::parse(&candidate)
|
||||
.ok()
|
||||
.map(|url| {
|
||||
let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
|
||||
match url.port_or_known_default() {
|
||||
Some(port) => format!("{host}:{port}"),
|
||||
None => host,
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| trimmed.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
pub fn deployment_id_for_endpoint(endpoint: &str) -> String {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
endpoint.hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
}
|
||||
|
||||
pub fn same_identity_endpoint(left: &str, right: &str) -> bool {
|
||||
site_identity_key(left) == site_identity_key(right)
|
||||
}
|
||||
|
||||
fn is_https_endpoint(endpoint: &str) -> bool {
|
||||
canonical_endpoint(endpoint).starts_with("https://")
|
||||
}
|
||||
|
||||
fn merge_identity_peer(existing: PeerInfo, incoming: PeerInfo) -> PeerInfo {
|
||||
let existing_https = is_https_endpoint(&existing.endpoint);
|
||||
let incoming_https = is_https_endpoint(&incoming.endpoint);
|
||||
let mut merged = if incoming_https && !existing_https {
|
||||
incoming.clone()
|
||||
} else {
|
||||
existing.clone()
|
||||
};
|
||||
let fallback = if merged.deployment_id == incoming.deployment_id {
|
||||
existing
|
||||
} else {
|
||||
incoming
|
||||
};
|
||||
|
||||
if merged.deployment_id.is_empty() {
|
||||
merged.deployment_id = fallback.deployment_id;
|
||||
}
|
||||
if merged.name.is_empty() {
|
||||
merged.name = fallback.name;
|
||||
}
|
||||
if merged.api_version.is_none() {
|
||||
merged.api_version = fallback.api_version;
|
||||
}
|
||||
merged.replicate_ilm_expiry |= fallback.replicate_ilm_expiry;
|
||||
merged
|
||||
}
|
||||
|
||||
pub fn normalize_peer_map_by_identity_with<F>(peers: BTreeMap<String, PeerInfo>, mut normalize: F) -> BTreeMap<String, PeerInfo>
|
||||
where
|
||||
F: FnMut(PeerInfo) -> PeerInfo,
|
||||
{
|
||||
let mut peers_by_identity = BTreeMap::<String, PeerInfo>::new();
|
||||
for (_, peer) in peers {
|
||||
let normalized_peer = normalize(peer);
|
||||
let identity = site_identity_key(&normalized_peer.endpoint);
|
||||
if let Some(existing) = peers_by_identity.remove(&identity) {
|
||||
peers_by_identity.insert(identity, normalize(merge_identity_peer(existing, normalized_peer)));
|
||||
} else {
|
||||
peers_by_identity.insert(identity, normalized_peer);
|
||||
}
|
||||
}
|
||||
|
||||
let mut normalized = BTreeMap::<String, PeerInfo>::new();
|
||||
for (_, mut peer) in peers_by_identity {
|
||||
if peer.deployment_id.is_empty() {
|
||||
peer.deployment_id = deployment_id_for_endpoint(&peer.endpoint);
|
||||
}
|
||||
|
||||
let mut deployment_id = peer.deployment_id.clone();
|
||||
if let Some(existing) = normalized.get(&deployment_id)
|
||||
&& site_identity_key(&existing.endpoint) != site_identity_key(&peer.endpoint)
|
||||
{
|
||||
deployment_id = format!("{deployment_id}-{}", deployment_id_for_endpoint(&peer.endpoint));
|
||||
peer.deployment_id = deployment_id.clone();
|
||||
}
|
||||
|
||||
if let Some(existing) = normalized.get(&deployment_id).cloned() {
|
||||
normalized.insert(deployment_id, normalize(merge_identity_peer(existing, peer)));
|
||||
} else {
|
||||
normalized.insert(deployment_id, peer);
|
||||
}
|
||||
}
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rustfs_madmin::{BucketBandwidth, SyncStatus};
|
||||
|
||||
fn peer(name: &str, endpoint: &str) -> PeerInfo {
|
||||
PeerInfo {
|
||||
name: name.to_string(),
|
||||
endpoint: endpoint.to_string(),
|
||||
deployment_id: name.to_string(),
|
||||
sync_state: SyncStatus::Unknown,
|
||||
default_bandwidth: BucketBandwidth::default(),
|
||||
replicate_ilm_expiry: false,
|
||||
object_naming_mode: String::new(),
|
||||
api_version: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_endpoint_accepts_case_insensitive_scheme() {
|
||||
assert_eq!(
|
||||
canonical_endpoint(" HTTPS://Node-A.Example.Com:9000/ "),
|
||||
"https://node-a.example.com:9000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_identity_key_accepts_case_insensitive_scheme() {
|
||||
assert_eq!(site_identity_key("HTTPS://Node-A.Example.Com:9000/"), "node-a.example.com:9000");
|
||||
assert!(same_identity_endpoint(
|
||||
"HTTPS://Node-A.Example.Com:9000/",
|
||||
"http://node-a.example.com:9000"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_peer_map_deduplicates_case_insensitive_scheme() {
|
||||
let peers = BTreeMap::from([
|
||||
("remote-http".to_string(), peer("remote-http", "http://node-a.example.com:9000")),
|
||||
("remote-https".to_string(), peer("remote-https", "HTTPS://Node-A.Example.Com:9000/")),
|
||||
]);
|
||||
|
||||
let normalized = normalize_peer_map_by_identity_with(peers, |peer| peer);
|
||||
|
||||
assert_eq!(normalized.len(), 1);
|
||||
let peer = normalized.values().next().expect("normalized peer should exist");
|
||||
assert_eq!(peer.endpoint, "HTTPS://Node-A.Example.Com:9000/");
|
||||
assert_eq!(peer.deployment_id, "remote-https");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_peer_map_backfills_metadata_when_https_peer_wins() {
|
||||
let peers = BTreeMap::from([
|
||||
(
|
||||
"remote-http".to_string(),
|
||||
PeerInfo {
|
||||
api_version: Some("v1".to_string()),
|
||||
replicate_ilm_expiry: true,
|
||||
..peer("remote-http", "http://node-a.example.com:9000")
|
||||
},
|
||||
),
|
||||
(
|
||||
"remote-https".to_string(),
|
||||
PeerInfo {
|
||||
name: String::new(),
|
||||
deployment_id: String::new(),
|
||||
..peer("remote-https", "https://node-a.example.com:9000")
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
let normalized = normalize_peer_map_by_identity_with(peers, |peer| peer);
|
||||
|
||||
assert_eq!(normalized.len(), 1);
|
||||
let peer = normalized.values().next().expect("normalized peer should exist");
|
||||
assert_eq!(peer.endpoint, "https://node-a.example.com:9000");
|
||||
assert_eq!(peer.name, "remote-http");
|
||||
assert_eq!(peer.deployment_id, "remote-http");
|
||||
assert_eq!(peer.api_version.as_deref(), Some("v1"));
|
||||
assert!(peer.replicate_ilm_expiry);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_peer_map_generates_missing_deployment_id() {
|
||||
let endpoint = "https://node-a.example.com:9000";
|
||||
let peers = BTreeMap::from([(
|
||||
"remote".to_string(),
|
||||
PeerInfo {
|
||||
deployment_id: String::new(),
|
||||
..peer("remote", endpoint)
|
||||
},
|
||||
)]);
|
||||
|
||||
let normalized = normalize_peer_map_by_identity_with(peers, |peer| peer);
|
||||
|
||||
let expected_deployment_id = deployment_id_for_endpoint(endpoint);
|
||||
assert!(normalized.contains_key(&expected_deployment_id));
|
||||
assert_eq!(normalized[&expected_deployment_id].deployment_id, expected_deployment_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_peer_map_suffixes_colliding_deployment_id_for_distinct_identity() {
|
||||
let first = peer("shared", "https://node-a.example.com:9000");
|
||||
let second_endpoint = "https://node-b.example.com:9000";
|
||||
let second = PeerInfo {
|
||||
deployment_id: "shared".to_string(),
|
||||
..peer("remote-b", second_endpoint)
|
||||
};
|
||||
let peers = BTreeMap::from([("first".to_string(), first), ("second".to_string(), second)]);
|
||||
|
||||
let normalized = normalize_peer_map_by_identity_with(peers, |peer| peer);
|
||||
|
||||
let expected_second_id = format!("shared-{}", deployment_id_for_endpoint(second_endpoint));
|
||||
assert_eq!(normalized.len(), 2);
|
||||
assert!(normalized.contains_key("shared"));
|
||||
assert!(normalized.contains_key(&expected_second_id));
|
||||
assert_eq!(normalized[&expected_second_id].deployment_id, expected_second_id);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ use rustfs_ecstore::bucket::{
|
||||
use rustfs_ecstore::client::object_api_utils::to_s3s_etag;
|
||||
use rustfs_ecstore::error::StorageError;
|
||||
use rustfs_ecstore::new_object_layer_fn;
|
||||
use rustfs_ecstore::notification_sys::get_global_notification_sys;
|
||||
use rustfs_ecstore::store_api::{
|
||||
BucketOperations, BucketOptions, DeleteBucketOptions, ListObjectVersionsInfo, ListObjectsV2Info, ListOperations,
|
||||
MakeBucketOptions, ObjectInfo,
|
||||
@@ -91,6 +92,99 @@ fn to_internal_error(err: impl Display) -> S3Error {
|
||||
S3Error::with_message(S3ErrorCode::InternalError, format!("{err}"))
|
||||
}
|
||||
|
||||
fn is_valid_notification_filter_value(value: &str) -> bool {
|
||||
if value.len() > 1024 || value.contains('\\') {
|
||||
return false;
|
||||
}
|
||||
!value.split('/').any(|segment| segment == "." || segment == "..")
|
||||
}
|
||||
|
||||
fn invalid_filter_value_message(cfg_scope: &str, value: &str) -> String {
|
||||
format!("invalid notification filter value (len={}) ({cfg_scope})", value.len())
|
||||
}
|
||||
|
||||
fn invalid_filter_name_message(cfg_scope: &str, name: &str) -> String {
|
||||
format!(
|
||||
"invalid notification filter name (len={}) (only 'prefix'/'suffix' are supported) ({cfg_scope})",
|
||||
name.len()
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_notification_filter_rules(
|
||||
filter: Option<&NotificationConfigurationFilter>,
|
||||
cfg_kind: &str,
|
||||
cfg_id: Option<&str>,
|
||||
) -> S3Result<()> {
|
||||
let Some(filter) = filter else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(s3key_filter) = filter.key.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(rules) = s3key_filter.filter_rules.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut has_prefix = false;
|
||||
let mut has_suffix = false;
|
||||
let cfg_scope = cfg_id.map_or_else(|| cfg_kind.to_string(), |id| format!("{cfg_kind} id={id}"));
|
||||
|
||||
for rule in rules {
|
||||
let Some(name) = rule.name.as_ref() else {
|
||||
return Err(s3_error!(InvalidArgument, "invalid notification filter rule: missing Name ({cfg_scope})"));
|
||||
};
|
||||
let Some(value) = rule.value.as_ref() else {
|
||||
return Err(s3_error!(
|
||||
InvalidArgument,
|
||||
"invalid notification filter rule: missing Value ({cfg_scope})"
|
||||
));
|
||||
};
|
||||
|
||||
if !is_valid_notification_filter_value(value) {
|
||||
return Err(s3_error!(InvalidArgument, "{}", invalid_filter_value_message(&cfg_scope, value)));
|
||||
}
|
||||
|
||||
match name.as_str() {
|
||||
"prefix" => {
|
||||
if has_prefix {
|
||||
return Err(s3_error!(InvalidArgument, "duplicate notification filter name 'prefix' ({cfg_scope})"));
|
||||
}
|
||||
has_prefix = true;
|
||||
}
|
||||
"suffix" => {
|
||||
if has_suffix {
|
||||
return Err(s3_error!(InvalidArgument, "duplicate notification filter name 'suffix' ({cfg_scope})"));
|
||||
}
|
||||
has_suffix = true;
|
||||
}
|
||||
other => {
|
||||
return Err(s3_error!(InvalidArgument, "{}", invalid_filter_name_message(&cfg_scope, other)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_notification_configuration_filters(notification_configuration: &NotificationConfiguration) -> S3Result<()> {
|
||||
if let Some(queue_configs) = notification_configuration.queue_configurations.as_ref() {
|
||||
for cfg in queue_configs {
|
||||
validate_notification_filter_rules(cfg.filter.as_ref(), "QueueConfiguration", cfg.id.as_deref())?;
|
||||
}
|
||||
}
|
||||
if let Some(topic_configs) = notification_configuration.topic_configurations.as_ref() {
|
||||
for cfg in topic_configs {
|
||||
validate_notification_filter_rules(cfg.filter.as_ref(), "TopicConfiguration", cfg.id.as_deref())?;
|
||||
}
|
||||
}
|
||||
if let Some(lambda_configs) = notification_configuration.lambda_function_configurations.as_ref() {
|
||||
for cfg in lambda_configs {
|
||||
validate_notification_filter_rules(cfg.filter.as_ref(), "LambdaFunctionConfiguration", cfg.id.as_deref())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sr_bucket_meta_item(bucket: String, item_type: &str) -> SRBucketMeta {
|
||||
SRBucketMeta {
|
||||
bucket,
|
||||
@@ -101,6 +195,20 @@ fn sr_bucket_meta_item(bucket: String, item_type: &str) -> SRBucketMeta {
|
||||
}
|
||||
}
|
||||
|
||||
fn notify_bucket_metadata_reload(
|
||||
bucket: String,
|
||||
operation: &'static str,
|
||||
request_context: Option<request_context::RequestContext>,
|
||||
) {
|
||||
spawn_background_with_context(request_context, async move {
|
||||
if let Some(notification_sys) = get_global_notification_sys()
|
||||
&& let Err(err) = notification_sys.load_bucket_metadata(&bucket).await
|
||||
{
|
||||
warn!(bucket = %bucket, error = %err, "failed to notify peers after {operation}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn replication_target_arns(config: &ReplicationConfiguration) -> HashSet<String> {
|
||||
let mut arns = HashSet::new();
|
||||
|
||||
@@ -884,6 +992,7 @@ impl DefaultBucketUsecase {
|
||||
&self,
|
||||
req: S3Request<DeleteBucketLifecycleInput>,
|
||||
) -> S3Result<S3Response<DeleteBucketLifecycleOutput>> {
|
||||
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
||||
let DeleteBucketLifecycleInput { bucket, .. } = req.input;
|
||||
|
||||
let Some(store) = new_object_layer_fn() else {
|
||||
@@ -899,6 +1008,8 @@ impl DefaultBucketUsecase {
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "delete bucket lifecycle", request_context);
|
||||
|
||||
let item = sr_bucket_meta_item(bucket.clone(), "lc-config");
|
||||
if let Err(err) = site_replication_bucket_meta_hook(item).await {
|
||||
warn!(bucket = %bucket, error = ?err, "site replication bucket lifecycle delete hook failed");
|
||||
@@ -1415,6 +1526,7 @@ impl DefaultBucketUsecase {
|
||||
&self,
|
||||
req: S3Request<PutBucketLifecycleConfigurationInput>,
|
||||
) -> S3Result<S3Response<PutBucketLifecycleConfigurationOutput>> {
|
||||
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
||||
let PutBucketLifecycleConfigurationInput {
|
||||
bucket,
|
||||
lifecycle_configuration,
|
||||
@@ -1450,6 +1562,8 @@ impl DefaultBucketUsecase {
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
notify_bucket_metadata_reload(bucket.clone(), "put bucket lifecycle", request_context);
|
||||
|
||||
let mut item = sr_bucket_meta_item(bucket.clone(), "lc-config");
|
||||
item.expiry_lc_config =
|
||||
Some(serialize_config(&input_cfg).and_then(|bytes| String::from_utf8(bytes).map_err(to_internal_error))?);
|
||||
@@ -1497,6 +1611,8 @@ impl DefaultBucketUsecase {
|
||||
..
|
||||
} = req.input;
|
||||
|
||||
validate_notification_configuration_filters(¬ification_configuration)?;
|
||||
|
||||
let Some(store) = new_object_layer_fn() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
@@ -2879,6 +2995,163 @@ mod tests {
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_notification_configuration_filters_rejects_invalid_filter_name() {
|
||||
let raw_name = "Prefix".repeat(100);
|
||||
let cfg = NotificationConfiguration {
|
||||
queue_configurations: Some(vec![QueueConfiguration {
|
||||
id: Some("q1".to_string()),
|
||||
queue_arn: "arn:rustfs:sqs:us-east-1:1:webhook".to_string(),
|
||||
events: vec!["s3:ObjectCreated:*".to_string().into()],
|
||||
filter: Some(NotificationConfigurationFilter {
|
||||
key: Some(S3KeyFilter {
|
||||
filter_rules: Some(vec![FilterRule {
|
||||
name: Some(FilterRuleName::from(raw_name.clone())),
|
||||
value: Some("uploads/".to_string()),
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = validate_notification_configuration_filters(&cfg).unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
let msg = err.message().unwrap_or_default();
|
||||
assert!(msg.contains("len="), "error message should include summarized length");
|
||||
assert!(!msg.contains(&raw_name), "error message should not echo full raw filter name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_notification_configuration_filters_rejects_duplicate_prefix_rules() {
|
||||
let cfg = NotificationConfiguration {
|
||||
queue_configurations: Some(vec![QueueConfiguration {
|
||||
id: Some("q1".to_string()),
|
||||
queue_arn: "arn:rustfs:sqs:us-east-1:1:webhook".to_string(),
|
||||
events: vec!["s3:ObjectCreated:*".to_string().into()],
|
||||
filter: Some(NotificationConfigurationFilter {
|
||||
key: Some(S3KeyFilter {
|
||||
filter_rules: Some(vec![
|
||||
FilterRule {
|
||||
name: Some(FilterRuleName::from_static(FilterRuleName::PREFIX)),
|
||||
value: Some("uploads/".to_string()),
|
||||
},
|
||||
FilterRule {
|
||||
name: Some(FilterRuleName::from_static(FilterRuleName::PREFIX)),
|
||||
value: Some("images/".to_string()),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = validate_notification_configuration_filters(&cfg).unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_notification_configuration_filters_rejects_invalid_filter_value() {
|
||||
let cfg = NotificationConfiguration {
|
||||
queue_configurations: Some(vec![QueueConfiguration {
|
||||
id: Some("q1".to_string()),
|
||||
queue_arn: "arn:rustfs:sqs:us-east-1:1:webhook".to_string(),
|
||||
events: vec!["s3:ObjectCreated:*".to_string().into()],
|
||||
filter: Some(NotificationConfigurationFilter {
|
||||
key: Some(S3KeyFilter {
|
||||
filter_rules: Some(vec![FilterRule {
|
||||
name: Some(FilterRuleName::from_static(FilterRuleName::SUFFIX)),
|
||||
value: Some("../secret".to_string()),
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = validate_notification_configuration_filters(&cfg).unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
let msg = err.message().unwrap_or_default();
|
||||
assert!(msg.contains("len="), "error message should include summarized length");
|
||||
assert!(!msg.contains("../secret"), "error message should not echo full raw filter value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_notification_configuration_filters_rejects_missing_filter_name() {
|
||||
let cfg = NotificationConfiguration {
|
||||
queue_configurations: Some(vec![QueueConfiguration {
|
||||
id: Some("q1".to_string()),
|
||||
queue_arn: "arn:rustfs:sqs:us-east-1:1:webhook".to_string(),
|
||||
events: vec!["s3:ObjectCreated:*".to_string().into()],
|
||||
filter: Some(NotificationConfigurationFilter {
|
||||
key: Some(S3KeyFilter {
|
||||
filter_rules: Some(vec![FilterRule {
|
||||
name: None,
|
||||
value: Some("uploads/".to_string()),
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = validate_notification_configuration_filters(&cfg).unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_notification_configuration_filters_rejects_missing_filter_value() {
|
||||
let cfg = NotificationConfiguration {
|
||||
queue_configurations: Some(vec![QueueConfiguration {
|
||||
id: Some("q1".to_string()),
|
||||
queue_arn: "arn:rustfs:sqs:us-east-1:1:webhook".to_string(),
|
||||
events: vec!["s3:ObjectCreated:*".to_string().into()],
|
||||
filter: Some(NotificationConfigurationFilter {
|
||||
key: Some(S3KeyFilter {
|
||||
filter_rules: Some(vec![FilterRule {
|
||||
name: Some(FilterRuleName::from_static(FilterRuleName::PREFIX)),
|
||||
value: None,
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = validate_notification_configuration_filters(&cfg).unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_notification_configuration_filters_rejects_duplicate_suffix_rules() {
|
||||
let cfg = NotificationConfiguration {
|
||||
queue_configurations: Some(vec![QueueConfiguration {
|
||||
id: Some("q1".to_string()),
|
||||
queue_arn: "arn:rustfs:sqs:us-east-1:1:webhook".to_string(),
|
||||
events: vec!["s3:ObjectCreated:*".to_string().into()],
|
||||
filter: Some(NotificationConfigurationFilter {
|
||||
key: Some(S3KeyFilter {
|
||||
filter_rules: Some(vec![
|
||||
FilterRule {
|
||||
name: Some(FilterRuleName::from_static(FilterRuleName::SUFFIX)),
|
||||
value: Some(".csv".to_string()),
|
||||
},
|
||||
FilterRule {
|
||||
name: Some(FilterRuleName::from_static(FilterRuleName::SUFFIX)),
|
||||
value: Some(".log".to_string()),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = validate_notification_configuration_filters(&cfg).unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_put_bucket_policy_returns_internal_error_when_store_uninitialized() {
|
||||
let input = PutBucketPolicyInput::builder()
|
||||
@@ -2894,6 +3167,36 @@ mod tests {
|
||||
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_put_bucket_notification_configuration_rejects_invalid_filter_before_store_lookup() {
|
||||
let input = PutBucketNotificationConfigurationInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.notification_configuration(NotificationConfiguration {
|
||||
queue_configurations: Some(vec![QueueConfiguration {
|
||||
id: Some("q1".to_string()),
|
||||
queue_arn: "arn:rustfs:sqs:us-east-1:1:webhook".to_string(),
|
||||
events: vec!["s3:ObjectCreated:*".to_string().into()],
|
||||
filter: Some(NotificationConfigurationFilter {
|
||||
key: Some(S3KeyFilter {
|
||||
filter_rules: Some(vec![FilterRule {
|
||||
name: Some(FilterRuleName::from("Prefix".to_string())),
|
||||
value: Some("uploads/".to_string()),
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
}]),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let req = build_request(input, Method::PUT);
|
||||
let usecase = DefaultBucketUsecase::without_context();
|
||||
|
||||
let err = usecase.execute_put_bucket_notification_configuration(req).await.unwrap_err();
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_put_bucket_cors_returns_internal_error_when_store_uninitialized() {
|
||||
let input = PutBucketCorsInput::builder()
|
||||
|
||||
@@ -119,6 +119,7 @@ use std::collections::HashMap;
|
||||
use std::ops::Add;
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
@@ -131,6 +132,10 @@ use tokio_util::io::{ReaderStream, StreamReader};
|
||||
use tracing::{debug, error, info, instrument, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
const ACCEPT_RANGES_BYTES: &str = "bytes";
|
||||
const MAX_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 64 * 1024 * 1024;
|
||||
static GET_OBJECT_BUFFER_THRESHOLD_WARNED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
struct DeadlockRequestGuard {
|
||||
deadlock_detector: Arc<deadlock_detector::DeadlockDetector>,
|
||||
request_id: String,
|
||||
@@ -392,6 +397,47 @@ fn object_seek_support_threshold() -> usize {
|
||||
})
|
||||
}
|
||||
|
||||
fn should_buffer_get_object_in_memory(
|
||||
info: &ObjectInfo,
|
||||
response_content_length: i64,
|
||||
part_number: Option<usize>,
|
||||
has_range: bool,
|
||||
) -> bool {
|
||||
let configured_threshold = object_seek_support_threshold() as i64;
|
||||
should_buffer_get_object_in_memory_with_threshold(info, response_content_length, part_number, has_range, configured_threshold)
|
||||
}
|
||||
|
||||
fn should_buffer_get_object_in_memory_with_threshold(
|
||||
_info: &ObjectInfo,
|
||||
response_content_length: i64,
|
||||
part_number: Option<usize>,
|
||||
has_range: bool,
|
||||
configured_threshold: i64,
|
||||
) -> bool {
|
||||
if part_number.is_some() || has_range || response_content_length <= 0 || configured_threshold <= 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let effective_threshold = configured_threshold.min(MAX_GET_OBJECT_MEMORY_BUFFER_BYTES);
|
||||
if configured_threshold > MAX_GET_OBJECT_MEMORY_BUFFER_BYTES
|
||||
&& GET_OBJECT_BUFFER_THRESHOLD_WARNED
|
||||
.compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
warn!(
|
||||
configured_threshold_bytes = configured_threshold,
|
||||
hard_limit_bytes = MAX_GET_OBJECT_MEMORY_BUFFER_BYTES,
|
||||
"RUSTFS_OBJECT_SEEK_SUPPORT_THRESHOLD exceeds safety cap; using capped in-memory buffer threshold"
|
||||
);
|
||||
}
|
||||
|
||||
if response_content_length > effective_threshold {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod deadlock_request_guard_tests {
|
||||
use super::DeadlockRequestGuard;
|
||||
@@ -1516,7 +1562,7 @@ impl DefaultObjectUsecase {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn build_get_object_body<R>(
|
||||
mut final_stream: R,
|
||||
_info: &ObjectInfo,
|
||||
info: &ObjectInfo,
|
||||
response_content_length: i64,
|
||||
optimal_buffer_size: usize,
|
||||
part_number: Option<usize>,
|
||||
@@ -1527,11 +1573,8 @@ impl DefaultObjectUsecase {
|
||||
R: AsyncRead + Send + Sync + Unpin + 'static,
|
||||
{
|
||||
if encryption_applied {
|
||||
let seekable_object_size_threshold = object_seek_support_threshold();
|
||||
let should_buffer_encrypted_object = response_content_length > 0
|
||||
&& response_content_length <= seekable_object_size_threshold as i64
|
||||
&& part_number.is_none()
|
||||
&& !has_range;
|
||||
let should_buffer_encrypted_object =
|
||||
should_buffer_get_object_in_memory(info, response_content_length, part_number, has_range);
|
||||
|
||||
if should_buffer_encrypted_object {
|
||||
let mut buf = Vec::with_capacity(response_content_length as usize);
|
||||
@@ -1558,11 +1601,8 @@ impl DefaultObjectUsecase {
|
||||
return Ok(Self::build_reader_blob(final_stream, response_content_length, optimal_buffer_size));
|
||||
}
|
||||
|
||||
let seekable_object_size_threshold = object_seek_support_threshold();
|
||||
let should_provide_seek_support = response_content_length > 0
|
||||
&& response_content_length <= seekable_object_size_threshold as i64
|
||||
&& part_number.is_none()
|
||||
&& !has_range;
|
||||
let should_provide_seek_support =
|
||||
should_buffer_get_object_in_memory(info, response_content_length, part_number, has_range);
|
||||
|
||||
if should_provide_seek_support {
|
||||
let mut buf = Vec::with_capacity(response_content_length as usize);
|
||||
@@ -2117,7 +2157,7 @@ impl DefaultObjectUsecase {
|
||||
last_modified,
|
||||
content_type,
|
||||
content_encoding: info.content_encoding.clone(),
|
||||
accept_ranges: Some("bytes".to_string()),
|
||||
accept_ranges: Some(ACCEPT_RANGES_BYTES.to_string()),
|
||||
content_range,
|
||||
e_tag: info.etag.map(|etag| to_s3s_etag(&etag)),
|
||||
metadata: filter_object_metadata(&info.user_defined),
|
||||
@@ -2963,6 +3003,19 @@ impl DefaultObjectUsecase {
|
||||
continue;
|
||||
}
|
||||
|
||||
if bypass_governance {
|
||||
let auth_res = authorize_request(&mut req, Action::S3Action(S3Action::BypassGovernanceRetentionAction)).await;
|
||||
if let Err(e) = auth_res {
|
||||
delete_results[idx].error = Some(Error {
|
||||
code: Some("AccessDenied".to_string()),
|
||||
key: Some(obj_id.key.clone()),
|
||||
message: Some(e.to_string()),
|
||||
version_id: version_id.clone(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let mut object = ObjectToDelete {
|
||||
object_name: obj_id.key.clone(),
|
||||
version_id: version_uuid,
|
||||
@@ -3625,6 +3678,7 @@ impl DefaultObjectUsecase {
|
||||
cache_control,
|
||||
content_disposition,
|
||||
content_language,
|
||||
accept_ranges: Some(ACCEPT_RANGES_BYTES.to_string()),
|
||||
website_redirect_location,
|
||||
expires,
|
||||
last_modified,
|
||||
@@ -4436,6 +4490,11 @@ mod tests {
|
||||
DeleteMarkerReplication, DeleteMarkerReplicationStatus, Destination, ExistingObjectReplication,
|
||||
ExistingObjectReplicationStatus, ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus,
|
||||
};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::{AsyncRead, ReadBuf};
|
||||
|
||||
fn build_request<T>(input: T, method: Method) -> S3Request<T> {
|
||||
S3Request {
|
||||
@@ -4521,6 +4580,164 @@ mod tests {
|
||||
assert!(!should_use_zero_copy(2 * 1024 * 1024, &headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_buffer_get_object_in_memory_respects_hard_safety_cap() {
|
||||
let info = ObjectInfo::default();
|
||||
let configured_threshold = 20_i64 * 1024 * 1024 * 1024;
|
||||
let response_len = 80_i64 * 1024 * 1024;
|
||||
let should_buffer =
|
||||
should_buffer_get_object_in_memory_with_threshold(&info, response_len, None, false, configured_threshold);
|
||||
|
||||
assert!(
|
||||
!should_buffer,
|
||||
"64MiB hard cap must force streaming when response exceeds cap even if configured threshold is much higher"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_buffer_get_object_in_memory_allows_small_non_range_requests() {
|
||||
let info = ObjectInfo::default();
|
||||
let configured_threshold = 10_i64 * 1024 * 1024;
|
||||
|
||||
assert!(should_buffer_get_object_in_memory_with_threshold(
|
||||
&info,
|
||||
1024 * 1024,
|
||||
None,
|
||||
false,
|
||||
configured_threshold
|
||||
));
|
||||
assert!(!should_buffer_get_object_in_memory_with_threshold(
|
||||
&info,
|
||||
1024 * 1024,
|
||||
Some(1),
|
||||
false,
|
||||
configured_threshold
|
||||
));
|
||||
assert!(!should_buffer_get_object_in_memory_with_threshold(
|
||||
&info,
|
||||
1024 * 1024,
|
||||
None,
|
||||
true,
|
||||
configured_threshold
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_buffer_get_object_in_memory_respects_configured_threshold_below_cap() {
|
||||
let info = ObjectInfo::default();
|
||||
let configured_threshold = 10_i64 * 1024 * 1024;
|
||||
|
||||
assert!(should_buffer_get_object_in_memory_with_threshold(
|
||||
&info,
|
||||
configured_threshold,
|
||||
None,
|
||||
false,
|
||||
configured_threshold
|
||||
));
|
||||
assert!(!should_buffer_get_object_in_memory_with_threshold(
|
||||
&info,
|
||||
configured_threshold + 1,
|
||||
None,
|
||||
false,
|
||||
configured_threshold
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_buffer_get_object_in_memory_rejects_unknown_lengths_and_disabled_thresholds() {
|
||||
let info = ObjectInfo::default();
|
||||
let configured_threshold = 10_i64 * 1024 * 1024;
|
||||
|
||||
assert!(!should_buffer_get_object_in_memory_with_threshold(
|
||||
&info,
|
||||
0,
|
||||
None,
|
||||
false,
|
||||
configured_threshold
|
||||
));
|
||||
assert!(!should_buffer_get_object_in_memory_with_threshold(
|
||||
&info,
|
||||
-1,
|
||||
None,
|
||||
false,
|
||||
configured_threshold
|
||||
));
|
||||
assert!(!should_buffer_get_object_in_memory_with_threshold(&info, 1024, None, false, 0));
|
||||
}
|
||||
|
||||
struct ReadProbeReader {
|
||||
reads: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl AsyncRead for ReadProbeReader {
|
||||
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
||||
self.reads.fetch_add(1, AtomicOrdering::Relaxed);
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_get_object_body_keeps_large_objects_on_streaming_path_without_preread() {
|
||||
let reads = Arc::new(AtomicUsize::new(0));
|
||||
let reader = ReadProbeReader {
|
||||
reads: Arc::clone(&reads),
|
||||
};
|
||||
let info = ObjectInfo {
|
||||
size: 18_i64 * 1024 * 1024 * 1024,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let body = DefaultObjectUsecase::build_get_object_body(
|
||||
reader,
|
||||
&info,
|
||||
18_i64 * 1024 * 1024 * 1024,
|
||||
128 * 1024,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect("build_get_object_body should succeed for streaming path");
|
||||
|
||||
assert!(body.is_some());
|
||||
assert_eq!(
|
||||
reads.load(AtomicOrdering::Relaxed),
|
||||
0,
|
||||
"large-object response construction should not pre-read object data"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_get_object_body_keeps_large_encrypted_objects_on_streaming_path_without_preread() {
|
||||
let reads = Arc::new(AtomicUsize::new(0));
|
||||
let reader = ReadProbeReader {
|
||||
reads: Arc::clone(&reads),
|
||||
};
|
||||
let info = ObjectInfo {
|
||||
size: 18_i64 * 1024 * 1024 * 1024,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let body = DefaultObjectUsecase::build_get_object_body(
|
||||
reader,
|
||||
&info,
|
||||
18_i64 * 1024 * 1024 * 1024,
|
||||
128 * 1024,
|
||||
None,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect("build_get_object_body should succeed for encrypted streaming path");
|
||||
|
||||
assert!(body.is_some());
|
||||
assert_eq!(
|
||||
reads.load(AtomicOrdering::Relaxed),
|
||||
0,
|
||||
"large encrypted object response construction should not pre-read object data"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_use_zero_copy_rejects_encrypted_requests_with_sse_customer_algorithm() {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
@@ -20,27 +20,59 @@
|
||||
use super::Opt;
|
||||
use crate::apply_external_env_compat;
|
||||
use rustfs_config::{
|
||||
DEFAULT_CONSOLE_ADDRESS, DEFAULT_CONSOLE_ENABLE, ENV_RUSTFS_ROOT_PASSWORD, ENV_RUSTFS_ROOT_USER, RUSTFS_REGION,
|
||||
DEFAULT_CONSOLE_ADDRESS, DEFAULT_CONSOLE_ENABLE, ENV_RUSTFS_ACCESS_KEY, ENV_RUSTFS_SECRET_KEY, RUSTFS_REGION,
|
||||
};
|
||||
use rustfs_credentials::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY, Masked};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
pub(crate) const LEGACY_ENV_RUSTFS_ROOT_USER: &str = "RUSTFS_ROOT_USER";
|
||||
pub(crate) const LEGACY_ENV_RUSTFS_ROOT_PASSWORD: &str = "RUSTFS_ROOT_PASSWORD";
|
||||
static LEGACY_CREDENTIAL_WARNED_KEYS: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
|
||||
|
||||
fn warn_legacy_credential_env_once(legacy_key: &str, canonical_key: &str) {
|
||||
let warned = LEGACY_CREDENTIAL_WARNED_KEYS.get_or_init(|| Mutex::new(HashSet::new()));
|
||||
let mut warned = match warned.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
if warned.insert(legacy_key.to_string()) {
|
||||
tracing::warn!(
|
||||
"Environment variable {} is deprecated and will be removed at GA; use {} instead",
|
||||
legacy_key,
|
||||
canonical_key
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to resolve credentials from multiple sources with precedence:
|
||||
/// 1. Inline value (if provided)
|
||||
/// 2. File value (if provided, read the content of the file)
|
||||
/// 3. Environment variable (if set)
|
||||
/// 4. Default value (if none of the above are provided)
|
||||
/// 3. Canonical environment variable (if set)
|
||||
/// 4. Legacy environment aliases (if set)
|
||||
/// 5. Default value (if none of the above are provided)
|
||||
pub(crate) fn resolve_credential<T: AsRef<std::path::Path>>(
|
||||
inline_value: Option<String>,
|
||||
file_value: Option<T>,
|
||||
env_key: &str,
|
||||
legacy_env_keys: &[&str],
|
||||
default_value: &str,
|
||||
) -> std::io::Result<String> {
|
||||
let value = inline_value
|
||||
.map(Ok)
|
||||
.or_else(|| file_value.map(std::fs::read_to_string))
|
||||
.or_else(|| rustfs_utils::get_env_opt_str(env_key).map(Ok))
|
||||
.transpose()?
|
||||
.unwrap_or_else(|| default_value.to_string());
|
||||
let value = if let Some(value) = inline_value {
|
||||
value
|
||||
} else if let Some(path) = file_value {
|
||||
std::fs::read_to_string(path)?
|
||||
} else if let Some(value) = rustfs_utils::get_env_opt_str(env_key) {
|
||||
value
|
||||
} else if let Some((legacy_key, value)) = legacy_env_keys
|
||||
.iter()
|
||||
.find_map(|legacy_key| rustfs_utils::get_env_opt_str(legacy_key).map(|value| (*legacy_key, value)))
|
||||
{
|
||||
warn_legacy_credential_env_once(legacy_key, env_key);
|
||||
value
|
||||
} else {
|
||||
default_value.to_string()
|
||||
};
|
||||
|
||||
Ok(value.trim().to_string())
|
||||
}
|
||||
@@ -169,8 +201,20 @@ impl Config {
|
||||
buffer_profile,
|
||||
} = opt;
|
||||
|
||||
let access_key = resolve_credential(access_key, access_key_file.as_ref(), ENV_RUSTFS_ROOT_USER, DEFAULT_ACCESS_KEY)?;
|
||||
let secret_key = resolve_credential(secret_key, secret_key_file.as_ref(), ENV_RUSTFS_ROOT_PASSWORD, DEFAULT_SECRET_KEY)?;
|
||||
let access_key = resolve_credential(
|
||||
access_key,
|
||||
access_key_file.as_ref(),
|
||||
ENV_RUSTFS_ACCESS_KEY,
|
||||
&[LEGACY_ENV_RUSTFS_ROOT_USER],
|
||||
DEFAULT_ACCESS_KEY,
|
||||
)?;
|
||||
let secret_key = resolve_credential(
|
||||
secret_key,
|
||||
secret_key_file.as_ref(),
|
||||
ENV_RUSTFS_SECRET_KEY,
|
||||
&[LEGACY_ENV_RUSTFS_ROOT_PASSWORD],
|
||||
DEFAULT_SECRET_KEY,
|
||||
)?;
|
||||
|
||||
// Region is optional, but if not set, we should default to "us-east-1" for signing compatibility with AWS S3 clients
|
||||
let region = region.or_else(|| Some(RUSTFS_REGION.to_string()));
|
||||
|
||||
@@ -174,7 +174,24 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_root_envs_are_used_for_bootstrap_credentials() {
|
||||
fn test_access_key_envs_are_used_for_bootstrap_credentials() {
|
||||
temp_env::with_vars(
|
||||
[
|
||||
("RUSTFS_VOLUMES", Some("/compat/vol1")),
|
||||
("RUSTFS_ACCESS_KEY", Some("canonical-access")),
|
||||
("RUSTFS_SECRET_KEY", Some("canonical-secret")),
|
||||
],
|
||||
|| {
|
||||
let config = Config::from_opt(Opt::parse_from(["rustfs"])).expect("config should parse");
|
||||
assert_eq!(config.access_key, "canonical-access");
|
||||
assert_eq!(config.secret_key, "canonical-secret");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_root_envs_fallback_for_bootstrap_credentials() {
|
||||
temp_env::with_vars(
|
||||
[
|
||||
("RUSTFS_VOLUMES", Some("/compat/vol1")),
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
//! that can be accessed globally without needing the full Config struct.
|
||||
|
||||
use super::Config;
|
||||
use crate::config::config_struct::resolve_credential;
|
||||
use crate::config::config_struct::{LEGACY_ENV_RUSTFS_ROOT_USER, resolve_credential};
|
||||
use rustfs_config::{
|
||||
DEFAULT_ADDRESS, DEFAULT_BUFFER_PROFILE, DEFAULT_CONSOLE_ADDRESS, DEFAULT_CONSOLE_ENABLE, DEFAULT_KMS_BACKEND,
|
||||
DEFAULT_KMS_ENABLE, DEFAULT_OBS_ENDPOINT, ENV_RUSTFS_ACCESS_KEY, ENV_RUSTFS_ACCESS_KEY_FILE, ENV_RUSTFS_ADDRESS,
|
||||
ENV_RUSTFS_BUFFER_PROFILE, ENV_RUSTFS_CONSOLE_ADDRESS, ENV_RUSTFS_CONSOLE_ENABLE, ENV_RUSTFS_KMS_BACKEND,
|
||||
ENV_RUSTFS_KMS_ENABLE, ENV_RUSTFS_OBS_ENDPOINT, ENV_RUSTFS_REGION, ENV_RUSTFS_ROOT_USER, ENV_RUSTFS_TLS_PATH, RUSTFS_REGION,
|
||||
ENV_RUSTFS_KMS_ENABLE, ENV_RUSTFS_OBS_ENDPOINT, ENV_RUSTFS_REGION, ENV_RUSTFS_TLS_PATH, RUSTFS_REGION,
|
||||
};
|
||||
use rustfs_credentials::DEFAULT_ACCESS_KEY;
|
||||
use rustfs_utils::{get_env_bool, get_env_opt_str, get_env_str};
|
||||
@@ -83,7 +83,8 @@ impl ConfigSnapshot {
|
||||
let access_key = resolve_credential(
|
||||
get_env_opt_str(ENV_RUSTFS_ACCESS_KEY),
|
||||
get_env_opt_str(ENV_RUSTFS_ACCESS_KEY_FILE),
|
||||
ENV_RUSTFS_ROOT_USER,
|
||||
ENV_RUSTFS_ACCESS_KEY,
|
||||
&[LEGACY_ENV_RUSTFS_ROOT_USER],
|
||||
DEFAULT_ACCESS_KEY,
|
||||
)
|
||||
.unwrap_or_else(|_| DEFAULT_ACCESS_KEY.to_string());
|
||||
|
||||
+115
-3
@@ -12,8 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use rustfs_appauth::token::Token;
|
||||
use rustfs_appauth::token::parse_license;
|
||||
use rustfs_appauth::token::{Token, parse_license_with_public_key};
|
||||
use std::fmt;
|
||||
use std::io::{Error, ErrorKind, Result};
|
||||
use std::sync::Arc;
|
||||
@@ -108,7 +107,9 @@ struct AppAuthLicenseVerifier;
|
||||
|
||||
impl LicenseVerifier for AppAuthLicenseVerifier {
|
||||
fn validate(&self, raw_license: &str, _now: u64) -> LicenseResult<Token> {
|
||||
let token = parse_license(raw_license).map_err(|err| LicenseError::Invalid(err.to_string()))?;
|
||||
let public_key = license_public_key()?;
|
||||
let token =
|
||||
parse_license_with_public_key(raw_license, &public_key).map_err(|err| LicenseError::Invalid(err.to_string()))?;
|
||||
|
||||
#[cfg(feature = "license")]
|
||||
if token.expired <= _now {
|
||||
@@ -148,6 +149,30 @@ fn normalized_license(raw_license: Option<String>) -> Option<String> {
|
||||
raw_license.map(|raw| raw.trim().to_string()).filter(|raw| !raw.is_empty())
|
||||
}
|
||||
|
||||
fn license_public_key() -> LicenseResult<String> {
|
||||
let public_key = std::env::var(rustfs_config::ENV_RUSTFS_LICENSE_PUBLIC_KEY)
|
||||
.map(|raw| raw.trim().to_string())
|
||||
.map_err(|_| {
|
||||
LicenseError::Invalid(format!(
|
||||
"{} must contain the RSA public key used to verify licenses",
|
||||
rustfs_config::ENV_RUSTFS_LICENSE_PUBLIC_KEY
|
||||
))
|
||||
})?;
|
||||
|
||||
if public_key.is_empty() {
|
||||
return Err(LicenseError::Invalid(format!(
|
||||
"{} must contain the RSA public key used to verify licenses",
|
||||
rustfs_config::ENV_RUSTFS_LICENSE_PUBLIC_KEY
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(public_key)
|
||||
}
|
||||
|
||||
fn is_license_token_current(token: &Token, now: u64) -> bool {
|
||||
token.expired > now
|
||||
}
|
||||
|
||||
fn strict_build_missing_status() -> LicenseStatus {
|
||||
if cfg!(feature = "license") {
|
||||
LicenseStatus::Missing
|
||||
@@ -243,6 +268,18 @@ pub fn current_license() -> Option<Token> {
|
||||
get_license()
|
||||
}
|
||||
|
||||
/// Return whether the loaded license token is present and not expired.
|
||||
pub fn has_valid_license() -> bool {
|
||||
let Some(token) = get_license() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(now) = now_epoch_secs() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
is_license_token_current(&token, now)
|
||||
}
|
||||
|
||||
/// Observe the current license status for observability.
|
||||
pub fn license_status() -> String {
|
||||
license_state()
|
||||
@@ -283,3 +320,78 @@ pub fn ensure_license() -> LicenseResult<()> {
|
||||
pub fn license_check() -> Result<()> {
|
||||
ensure_license().map_err(LicenseError::into_io)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rsa::{
|
||||
RsaPrivateKey, RsaPublicKey,
|
||||
pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding},
|
||||
};
|
||||
use rustfs_appauth::token::sign_license_token;
|
||||
use serial_test::serial;
|
||||
|
||||
#[test]
|
||||
fn license_token_current_requires_future_expiration() {
|
||||
let token = Token {
|
||||
name: "test_app".to_string(),
|
||||
expired: 100,
|
||||
};
|
||||
|
||||
assert!(is_license_token_current(&token, 99));
|
||||
assert!(!is_license_token_current(&token, 100));
|
||||
assert!(!is_license_token_current(&token, 101));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn appauth_verifier_rejects_missing_public_key() {
|
||||
temp_env::with_var(rustfs_config::ENV_RUSTFS_LICENSE_PUBLIC_KEY, None::<&str>, || {
|
||||
assert_license_public_key_error(AppAuthLicenseVerifier.validate("signed-license", 0));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn appauth_verifier_rejects_blank_public_key() {
|
||||
temp_env::with_var(rustfs_config::ENV_RUSTFS_LICENSE_PUBLIC_KEY, Some(" \t\n "), || {
|
||||
assert_license_public_key_error(AppAuthLicenseVerifier.validate("signed-license", 0));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn appauth_verifier_accepts_signed_license_with_trimmed_public_key() {
|
||||
let mut rng = rand::rng();
|
||||
let private_key = RsaPrivateKey::new(&mut rng, 2048).expect("private key should be generated");
|
||||
let public_key = RsaPublicKey::from(&private_key);
|
||||
let private_key_pem = private_key.to_pkcs8_pem(LineEnding::LF).expect("private key should encode");
|
||||
let public_key_pem = public_key
|
||||
.to_public_key_pem(LineEnding::LF)
|
||||
.expect("public key should encode");
|
||||
let expected = Token {
|
||||
name: "test_app".to_string(),
|
||||
expired: 100,
|
||||
};
|
||||
let signed_license = sign_license_token(&expected, &private_key_pem).expect("license should sign");
|
||||
let public_key_env = format!(" \n{public_key_pem}\t ");
|
||||
|
||||
let actual = temp_env::with_var(rustfs_config::ENV_RUSTFS_LICENSE_PUBLIC_KEY, Some(public_key_env), || {
|
||||
AppAuthLicenseVerifier.validate(&signed_license, 0)
|
||||
})
|
||||
.expect("signed license should validate with env public key");
|
||||
|
||||
assert_eq!(expected.name, actual.name);
|
||||
assert_eq!(expected.expired, actual.expired);
|
||||
}
|
||||
|
||||
fn assert_license_public_key_error(result: LicenseResult<Token>) {
|
||||
let err = result.expect_err("license verification should fail without a public key");
|
||||
let LicenseError::Invalid(message) = err else {
|
||||
panic!("expected invalid license error, got {err:?}");
|
||||
};
|
||||
|
||||
assert!(message.contains(rustfs_config::ENV_RUSTFS_LICENSE_PUBLIC_KEY));
|
||||
assert!(message.contains("RSA public key"));
|
||||
}
|
||||
}
|
||||
|
||||
+14
-5
@@ -138,6 +138,9 @@ fn is_using_default_credentials(config: &rustfs::config::Config) -> bool {
|
||||
rustfs_credentials::DEFAULT_ACCESS_KEY.eq(&config.access_key) && rustfs_credentials::DEFAULT_SECRET_KEY.eq(&config.secret_key)
|
||||
}
|
||||
|
||||
const DEFAULT_CREDENTIALS_WARNING_MESSAGE: &str =
|
||||
"Detected default root credentials; change them with the RUSTFS_ACCESS_KEY and RUSTFS_SECRET_KEY environment variables";
|
||||
|
||||
async fn async_main() -> Result<()> {
|
||||
// Parse command line arguments
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
@@ -355,11 +358,7 @@ async fn run(config: rustfs::config::Config) -> Result<()> {
|
||||
};
|
||||
|
||||
if is_using_default_credentials(&config) {
|
||||
warn!(
|
||||
"Detected default credentials '{}:{}', we recommend that you change these values with 'RUSTFS_ACCESS_KEY' and 'RUSTFS_SECRET_KEY' environment variables",
|
||||
rustfs_credentials::DEFAULT_ACCESS_KEY,
|
||||
rustfs_credentials::DEFAULT_SECRET_KEY
|
||||
);
|
||||
warn!("{}", DEFAULT_CREDENTIALS_WARNING_MESSAGE);
|
||||
}
|
||||
|
||||
let ctx = CancellationToken::new();
|
||||
@@ -780,4 +779,14 @@ mod tests {
|
||||
|
||||
assert!(!is_using_default_credentials(&config));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_credentials_warning_message_does_not_expose_values() {
|
||||
let message = DEFAULT_CREDENTIALS_WARNING_MESSAGE;
|
||||
|
||||
assert!(message.contains(rustfs_config::ENV_RUSTFS_ACCESS_KEY));
|
||||
assert!(message.contains(rustfs_config::ENV_RUSTFS_SECRET_KEY));
|
||||
assert!(!message.contains(rustfs_credentials::DEFAULT_ACCESS_KEY));
|
||||
assert!(!message.contains(rustfs_credentials::DEFAULT_SECRET_KEY));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,8 @@ pub async fn init_event_notifier() {
|
||||
if !enabled {
|
||||
info!(
|
||||
target: "rustfs::main::init_event_notifier",
|
||||
"Notify module is disabled, event notifier initialization is skipped. Enable the notify module first."
|
||||
"Notify module is disabled, event notifier initialization is skipped. Set {}=true to enable notify initialization.",
|
||||
rustfs_config::ENV_NOTIFY_ENABLE
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
+46
-35
@@ -670,7 +670,7 @@ pub struct ConditionalCorsLayer {
|
||||
|
||||
impl ConditionalCorsLayer {
|
||||
pub fn new() -> Self {
|
||||
let cors_origins = get_env_opt_str("RUSTFS_CORS_ALLOWED_ORIGINS").filter(|s| !s.is_empty());
|
||||
let cors_origins = get_env_opt_str(rustfs_config::ENV_CORS_ALLOWED_ORIGINS).filter(|s| !s.is_empty());
|
||||
Self { cors_origins }
|
||||
}
|
||||
|
||||
@@ -687,36 +687,31 @@ impl ConditionalCorsLayer {
|
||||
}
|
||||
|
||||
fn apply_cors_headers(&self, request_headers: &HeaderMap, response_headers: &mut HeaderMap) {
|
||||
let origin = request_headers
|
||||
.get(cors::standard::ORIGIN)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let allowed_origin = match (origin, &self.cors_origins) {
|
||||
(Some(orig), Some(config)) if config == "*" => Some(orig),
|
||||
(Some(orig), Some(config)) => {
|
||||
if config.split(',').map(|s| s.trim()).any(|x| x == orig.as_str()) {
|
||||
Some(orig)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
(Some(orig), None) => Some(orig), // Default: allow all if not configured
|
||||
_ => None,
|
||||
let Some(origin) = request_headers.get(cors::standard::ORIGIN).and_then(|v| v.to_str().ok()) else {
|
||||
return;
|
||||
};
|
||||
let Some(config) = self
|
||||
.cors_origins
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|config| !config.is_empty())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Track whether we're using a specific origin (not wildcard)
|
||||
let using_specific_origin = if let Some(origin) = &allowed_origin {
|
||||
if let Ok(header_value) = HeaderValue::from_str(origin) {
|
||||
response_headers.insert(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN, header_value);
|
||||
true // Using specific origin, credentials allowed
|
||||
} else {
|
||||
false
|
||||
}
|
||||
let (allow_origin, allow_credentials) = if config == "*" {
|
||||
(HeaderValue::from_static("*"), false)
|
||||
} else if config.split(',').map(str::trim).any(|allowed| allowed == origin) {
|
||||
let Ok(origin) = HeaderValue::from_str(origin) else {
|
||||
return;
|
||||
};
|
||||
(origin, true)
|
||||
} else {
|
||||
false
|
||||
return;
|
||||
};
|
||||
|
||||
response_headers.insert(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN, allow_origin);
|
||||
|
||||
// Allow all methods by default (S3-compatible set)
|
||||
response_headers.insert(
|
||||
cors::response::ACCESS_CONTROL_ALLOW_METHODS,
|
||||
@@ -732,9 +727,8 @@ impl ConditionalCorsLayer {
|
||||
HeaderValue::from_static("x-request-id, content-type, content-length, etag"),
|
||||
);
|
||||
|
||||
// Only set credentials when using a specific origin (not wildcard)
|
||||
// CORS spec: credentials cannot be used with wildcard origins
|
||||
if using_specific_origin {
|
||||
// Credentials are only safe for origins matched from an explicit allow-list.
|
||||
if allow_credentials {
|
||||
response_headers.insert(cors::response::ACCESS_CONTROL_ALLOW_CREDENTIALS, HeaderValue::from_static("true"));
|
||||
}
|
||||
}
|
||||
@@ -1077,7 +1071,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generic_cors_layer_echoes_allowed_origin() {
|
||||
fn test_generic_cors_layer_omits_headers_without_configured_origins() {
|
||||
let cors = ConditionalCorsLayer { cors_origins: None };
|
||||
let mut req_headers = HeaderMap::new();
|
||||
req_headers.insert("origin", "https://example.com".parse().unwrap());
|
||||
@@ -1085,10 +1079,11 @@ mod tests {
|
||||
let mut resp_headers = HeaderMap::new();
|
||||
cors.apply_cors_headers(&req_headers, &mut resp_headers);
|
||||
|
||||
assert_eq!(
|
||||
resp_headers.get(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(),
|
||||
"https://example.com"
|
||||
);
|
||||
assert!(resp_headers.get(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN).is_none());
|
||||
assert!(resp_headers.get(cors::response::ACCESS_CONTROL_ALLOW_CREDENTIALS).is_none());
|
||||
assert!(resp_headers.get(cors::response::ACCESS_CONTROL_ALLOW_METHODS).is_none());
|
||||
assert!(resp_headers.get(cors::response::ACCESS_CONTROL_ALLOW_HEADERS).is_none());
|
||||
assert!(resp_headers.get(cors::response::ACCESS_CONTROL_EXPOSE_HEADERS).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1111,11 +1106,27 @@ mod tests {
|
||||
resp_headers.get(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(),
|
||||
"https://allowed.com"
|
||||
);
|
||||
assert_eq!(resp_headers.get(cors::response::ACCESS_CONTROL_ALLOW_CREDENTIALS).unwrap(), "true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generic_cors_layer_wildcard_does_not_allow_credentials() {
|
||||
let cors = ConditionalCorsLayer {
|
||||
cors_origins: Some("*".to_string()),
|
||||
};
|
||||
|
||||
let mut req_headers = HeaderMap::new();
|
||||
req_headers.insert("origin", "https://example.com".parse().unwrap());
|
||||
let mut resp_headers = HeaderMap::new();
|
||||
cors.apply_cors_headers(&req_headers, &mut resp_headers);
|
||||
|
||||
assert_eq!(resp_headers.get(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), "*");
|
||||
assert!(resp_headers.get(cors::response::ACCESS_CONTROL_ALLOW_CREDENTIALS).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conditional_cors_layer_reads_env() {
|
||||
with_var("RUSTFS_CORS_ALLOWED_ORIGINS", Some("https://allowed.com"), || {
|
||||
with_var(rustfs_config::ENV_CORS_ALLOWED_ORIGINS, Some("https://allowed.com"), || {
|
||||
let cors = ConditionalCorsLayer::new();
|
||||
assert_eq!(cors.cors_origins.as_deref(), Some("https://allowed.com"));
|
||||
});
|
||||
|
||||
@@ -1069,13 +1069,6 @@ impl S3Access for FS {
|
||||
req_info.object = None;
|
||||
req_info.version_id = None;
|
||||
|
||||
authorize_request(req, Action::S3Action(S3Action::DeleteObjectAction)).await?;
|
||||
|
||||
// S3 Standard: When bypass_governance header is set, must have s3:BypassGovernanceRetention permission
|
||||
if has_bypass_governance_header(&req.headers) {
|
||||
authorize_request(req, Action::S3Action(S3Action::BypassGovernanceRetentionAction)).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2401,6 +2394,38 @@ mod tests {
|
||||
assert_eq!(req_info.version_id, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_objects_defers_object_authorization_to_usecase() {
|
||||
let input = DeleteObjectsInput::builder()
|
||||
.bucket("test-bucket".to_string())
|
||||
.delete(Delete {
|
||||
objects: vec![ObjectIdentifier {
|
||||
key: "prefix/test-key".to_string(),
|
||||
version_id: None,
|
||||
..Default::default()
|
||||
}],
|
||||
quiet: None,
|
||||
})
|
||||
.build()
|
||||
.expect("delete objects input should build");
|
||||
|
||||
let mut req = build_request(input, Method::POST);
|
||||
req.extensions.insert(ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
..ReqInfo::default()
|
||||
});
|
||||
|
||||
FS::new()
|
||||
.delete_objects(&mut req)
|
||||
.await
|
||||
.expect("DeleteObjects access hook should not require bucket-level DeleteObject");
|
||||
|
||||
let req_info = req.extensions.get::<ReqInfo>().expect("req info should remain available");
|
||||
assert_eq!(req_info.bucket.as_deref(), Some("test-bucket"));
|
||||
assert_eq!(req_info.object, None);
|
||||
assert_eq!(req_info.version_id, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn abort_multipart_upload_rejects_unauthorized_request() {
|
||||
let fs = FS::new();
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
use crate::storage::rpc::encode_msgpack_map;
|
||||
|
||||
impl NodeService {
|
||||
pub(super) async fn handle_get_proc_info(
|
||||
@@ -21,19 +22,18 @@ impl NodeService {
|
||||
) -> Result<Response<GetProcInfoResponse>, Status> {
|
||||
let addr = get_global_local_node_name().await;
|
||||
let info = get_proc_info(&addr);
|
||||
let mut buf = Vec::new();
|
||||
if let Err(err) = info.serialize(&mut Serializer::new(&mut buf)) {
|
||||
return Ok(Response::new(GetProcInfoResponse {
|
||||
match encode_msgpack_map(&info) {
|
||||
Ok(buf) => Ok(Response::new(GetProcInfoResponse {
|
||||
success: true,
|
||||
proc_info: buf.into(),
|
||||
error_info: None,
|
||||
})),
|
||||
Err(err) => Ok(Response::new(GetProcInfoResponse {
|
||||
success: false,
|
||||
proc_info: Bytes::new(),
|
||||
error_info: Some(err.to_string()),
|
||||
}));
|
||||
})),
|
||||
}
|
||||
Ok(Response::new(GetProcInfoResponse {
|
||||
success: true,
|
||||
proc_info: buf.into(),
|
||||
error_info: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_get_mem_info(
|
||||
@@ -42,19 +42,18 @@ impl NodeService {
|
||||
) -> Result<Response<GetMemInfoResponse>, Status> {
|
||||
let addr = get_global_local_node_name().await;
|
||||
let info = get_mem_info(&addr);
|
||||
let mut buf = Vec::new();
|
||||
if let Err(err) = info.serialize(&mut Serializer::new(&mut buf)) {
|
||||
return Ok(Response::new(GetMemInfoResponse {
|
||||
match encode_msgpack_map(&info) {
|
||||
Ok(buf) => Ok(Response::new(GetMemInfoResponse {
|
||||
success: true,
|
||||
mem_info: buf.into(),
|
||||
error_info: None,
|
||||
})),
|
||||
Err(err) => Ok(Response::new(GetMemInfoResponse {
|
||||
success: false,
|
||||
mem_info: Bytes::new(),
|
||||
error_info: Some(err.to_string()),
|
||||
}));
|
||||
})),
|
||||
}
|
||||
Ok(Response::new(GetMemInfoResponse {
|
||||
success: true,
|
||||
mem_info: buf.into(),
|
||||
error_info: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_get_sys_errors(
|
||||
@@ -63,19 +62,18 @@ impl NodeService {
|
||||
) -> Result<Response<GetSysErrorsResponse>, Status> {
|
||||
let addr = get_global_local_node_name().await;
|
||||
let info = get_sys_errors(&addr);
|
||||
let mut buf = Vec::new();
|
||||
if let Err(err) = info.serialize(&mut Serializer::new(&mut buf)) {
|
||||
return Ok(Response::new(GetSysErrorsResponse {
|
||||
match encode_msgpack_map(&info) {
|
||||
Ok(buf) => Ok(Response::new(GetSysErrorsResponse {
|
||||
success: true,
|
||||
sys_errors: buf.into(),
|
||||
error_info: None,
|
||||
})),
|
||||
Err(err) => Ok(Response::new(GetSysErrorsResponse {
|
||||
success: false,
|
||||
sys_errors: Bytes::new(),
|
||||
error_info: Some(err.to_string()),
|
||||
}));
|
||||
})),
|
||||
}
|
||||
Ok(Response::new(GetSysErrorsResponse {
|
||||
success: true,
|
||||
sys_errors: buf.into(),
|
||||
error_info: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_get_sys_config(
|
||||
@@ -84,19 +82,18 @@ impl NodeService {
|
||||
) -> Result<Response<GetSysConfigResponse>, Status> {
|
||||
let addr = get_global_local_node_name().await;
|
||||
let info = get_sys_config(&addr);
|
||||
let mut buf = Vec::new();
|
||||
if let Err(err) = info.serialize(&mut Serializer::new(&mut buf)) {
|
||||
return Ok(Response::new(GetSysConfigResponse {
|
||||
match encode_msgpack_map(&info) {
|
||||
Ok(buf) => Ok(Response::new(GetSysConfigResponse {
|
||||
success: true,
|
||||
sys_config: buf.into(),
|
||||
error_info: None,
|
||||
})),
|
||||
Err(err) => Ok(Response::new(GetSysConfigResponse {
|
||||
success: false,
|
||||
sys_config: Bytes::new(),
|
||||
error_info: Some(err.to_string()),
|
||||
}));
|
||||
})),
|
||||
}
|
||||
Ok(Response::new(GetSysConfigResponse {
|
||||
success: true,
|
||||
sys_config: buf.into(),
|
||||
error_info: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_get_se_linux_info(
|
||||
@@ -105,19 +102,18 @@ impl NodeService {
|
||||
) -> Result<Response<GetSeLinuxInfoResponse>, Status> {
|
||||
let addr = get_global_local_node_name().await;
|
||||
let info = get_sys_services(&addr);
|
||||
let mut buf = Vec::new();
|
||||
if let Err(err) = info.serialize(&mut Serializer::new(&mut buf)) {
|
||||
return Ok(Response::new(GetSeLinuxInfoResponse {
|
||||
match encode_msgpack_map(&info) {
|
||||
Ok(buf) => Ok(Response::new(GetSeLinuxInfoResponse {
|
||||
success: true,
|
||||
sys_services: buf.into(),
|
||||
error_info: None,
|
||||
})),
|
||||
Err(err) => Ok(Response::new(GetSeLinuxInfoResponse {
|
||||
success: false,
|
||||
sys_services: Bytes::new(),
|
||||
error_info: Some(err.to_string()),
|
||||
}));
|
||||
})),
|
||||
}
|
||||
Ok(Response::new(GetSeLinuxInfoResponse {
|
||||
success: true,
|
||||
sys_services: buf.into(),
|
||||
error_info: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_get_os_info(
|
||||
@@ -125,19 +121,18 @@ impl NodeService {
|
||||
_request: Request<GetOsInfoRequest>,
|
||||
) -> Result<Response<GetOsInfoResponse>, Status> {
|
||||
let os_info = get_os_info();
|
||||
let mut buf = Vec::new();
|
||||
if let Err(err) = os_info.serialize(&mut Serializer::new(&mut buf)) {
|
||||
return Ok(Response::new(GetOsInfoResponse {
|
||||
match encode_msgpack_map(&os_info) {
|
||||
Ok(buf) => Ok(Response::new(GetOsInfoResponse {
|
||||
success: true,
|
||||
os_info: buf.into(),
|
||||
error_info: None,
|
||||
})),
|
||||
Err(err) => Ok(Response::new(GetOsInfoResponse {
|
||||
success: false,
|
||||
os_info: Bytes::new(),
|
||||
error_info: Some(err.to_string()),
|
||||
}));
|
||||
})),
|
||||
}
|
||||
Ok(Response::new(GetOsInfoResponse {
|
||||
success: true,
|
||||
os_info: buf.into(),
|
||||
error_info: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_get_partitions(
|
||||
@@ -145,19 +140,18 @@ impl NodeService {
|
||||
_request: Request<GetPartitionsRequest>,
|
||||
) -> Result<Response<GetPartitionsResponse>, Status> {
|
||||
let partitions = get_partitions();
|
||||
let mut buf = Vec::new();
|
||||
if let Err(err) = partitions.serialize(&mut Serializer::new(&mut buf)) {
|
||||
return Ok(Response::new(GetPartitionsResponse {
|
||||
match encode_msgpack_map(&partitions) {
|
||||
Ok(buf) => Ok(Response::new(GetPartitionsResponse {
|
||||
success: true,
|
||||
partitions: buf.into(),
|
||||
error_info: None,
|
||||
})),
|
||||
Err(err) => Ok(Response::new(GetPartitionsResponse {
|
||||
success: false,
|
||||
partitions: Bytes::new(),
|
||||
error_info: Some(err.to_string()),
|
||||
}));
|
||||
})),
|
||||
}
|
||||
Ok(Response::new(GetPartitionsResponse {
|
||||
success: true,
|
||||
partitions: buf.into(),
|
||||
error_info: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_get_net_info(
|
||||
@@ -166,36 +160,34 @@ impl NodeService {
|
||||
) -> Result<Response<GetNetInfoResponse>, Status> {
|
||||
let addr = get_global_local_node_name().await;
|
||||
let info = get_net_info(&addr, "");
|
||||
let mut buf = Vec::new();
|
||||
if let Err(err) = info.serialize(&mut Serializer::new(&mut buf)) {
|
||||
return Ok(Response::new(GetNetInfoResponse {
|
||||
match encode_msgpack_map(&info) {
|
||||
Ok(buf) => Ok(Response::new(GetNetInfoResponse {
|
||||
success: true,
|
||||
net_info: buf.into(),
|
||||
error_info: None,
|
||||
})),
|
||||
Err(err) => Ok(Response::new(GetNetInfoResponse {
|
||||
success: false,
|
||||
net_info: Bytes::new(),
|
||||
error_info: Some(err.to_string()),
|
||||
}));
|
||||
})),
|
||||
}
|
||||
Ok(Response::new(GetNetInfoResponse {
|
||||
success: true,
|
||||
net_info: buf.into(),
|
||||
error_info: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_get_cpus(&self, _request: Request<GetCpusRequest>) -> Result<Response<GetCpusResponse>, Status> {
|
||||
let info = get_cpus();
|
||||
let mut buf = Vec::new();
|
||||
if let Err(err) = info.serialize(&mut Serializer::new(&mut buf)) {
|
||||
return Ok(Response::new(GetCpusResponse {
|
||||
match encode_msgpack_map(&info) {
|
||||
Ok(buf) => Ok(Response::new(GetCpusResponse {
|
||||
success: true,
|
||||
cpus: buf.into(),
|
||||
error_info: None,
|
||||
})),
|
||||
Err(err) => Ok(Response::new(GetCpusResponse {
|
||||
success: false,
|
||||
cpus: Bytes::new(),
|
||||
error_info: Some(err.to_string()),
|
||||
}));
|
||||
})),
|
||||
}
|
||||
Ok(Response::new(GetCpusResponse {
|
||||
success: true,
|
||||
cpus: buf.into(),
|
||||
error_info: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_server_info(
|
||||
@@ -203,29 +195,24 @@ impl NodeService {
|
||||
_request: Request<ServerInfoRequest>,
|
||||
) -> Result<Response<ServerInfoResponse>, Status> {
|
||||
let info = get_local_server_property().await;
|
||||
let mut buf = Vec::new();
|
||||
// Use map encoding for forward/backward compatibility across mixed versions:
|
||||
// unknown fields can be ignored by older nodes during deserialization.
|
||||
if let Err(err) = info.serialize(&mut Serializer::new(&mut buf).with_struct_map()) {
|
||||
return Ok(Response::new(ServerInfoResponse {
|
||||
match encode_msgpack_map(&info) {
|
||||
Ok(buf) => Ok(Response::new(ServerInfoResponse {
|
||||
success: true,
|
||||
server_properties: buf.into(),
|
||||
error_info: None,
|
||||
})),
|
||||
Err(err) => Ok(Response::new(ServerInfoResponse {
|
||||
success: false,
|
||||
server_properties: Bytes::new(),
|
||||
error_info: Some(err.to_string()),
|
||||
}));
|
||||
})),
|
||||
}
|
||||
Ok(Response::new(ServerInfoResponse {
|
||||
success: true,
|
||||
server_properties: buf.into(),
|
||||
error_info: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_local_storage_info(
|
||||
&self,
|
||||
_request: Request<LocalStorageInfoRequest>,
|
||||
) -> Result<Response<LocalStorageInfoResponse>, Status> {
|
||||
// let request = request.into_inner();
|
||||
|
||||
let Some(store) = new_object_layer_fn() else {
|
||||
return Ok(Response::new(LocalStorageInfoResponse {
|
||||
success: false,
|
||||
@@ -235,19 +222,17 @@ impl NodeService {
|
||||
};
|
||||
|
||||
let info = store.local_storage_info().await;
|
||||
let mut buf = Vec::new();
|
||||
if let Err(err) = info.serialize(&mut Serializer::new(&mut buf)) {
|
||||
return Ok(Response::new(LocalStorageInfoResponse {
|
||||
match encode_msgpack_map(&info) {
|
||||
Ok(buf) => Ok(Response::new(LocalStorageInfoResponse {
|
||||
success: true,
|
||||
storage_info: buf.into(),
|
||||
error_info: None,
|
||||
})),
|
||||
Err(err) => Ok(Response::new(LocalStorageInfoResponse {
|
||||
success: false,
|
||||
storage_info: Bytes::new(),
|
||||
error_info: Some(err.to_string()),
|
||||
}));
|
||||
})),
|
||||
}
|
||||
|
||||
Ok(Response::new(LocalStorageInfoResponse {
|
||||
success: true,
|
||||
storage_info: buf.into(),
|
||||
error_info: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
use crate::storage::rpc::encode_msgpack_map;
|
||||
|
||||
impl NodeService {
|
||||
pub(super) async fn handle_get_metrics(
|
||||
@@ -50,19 +51,17 @@ impl NodeService {
|
||||
};
|
||||
|
||||
let info = collect_local_metrics(t, &opts).await;
|
||||
|
||||
let mut buf = Vec::new();
|
||||
if let Err(err) = info.serialize(&mut Serializer::new(&mut buf)) {
|
||||
return Ok(Response::new(GetMetricsResponse {
|
||||
match encode_msgpack_map(&info) {
|
||||
Ok(buf) => Ok(Response::new(GetMetricsResponse {
|
||||
success: true,
|
||||
realtime_metrics: buf.into(),
|
||||
error_info: None,
|
||||
})),
|
||||
Err(err) => Ok(Response::new(GetMetricsResponse {
|
||||
success: false,
|
||||
realtime_metrics: Bytes::new(),
|
||||
error_info: Some(err.to_string()),
|
||||
}));
|
||||
})),
|
||||
}
|
||||
Ok(Response::new(GetMetricsResponse {
|
||||
success: true,
|
||||
realtime_metrics: buf.into(),
|
||||
error_info: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,3 +17,140 @@ pub mod node_service;
|
||||
|
||||
pub use http_service::InternodeRpcService;
|
||||
pub use node_service::{NodeService, make_server};
|
||||
|
||||
use rmp_serde::Serializer;
|
||||
use serde::Serialize;
|
||||
|
||||
/// Encode a value as map-keyed msgpack for internode RPC responses.
|
||||
///
|
||||
/// Uses `.with_struct_map()` so structs are serialized with named fields
|
||||
/// (msgpack map) instead of positional arrays. This matches what the
|
||||
/// client-side `Deserializer::new()` expects.
|
||||
pub(crate) fn encode_msgpack_map<T: Serialize>(value: &T) -> Result<Vec<u8>, rmp_serde::encode::Error> {
|
||||
let mut buf = Vec::new();
|
||||
value.serialize(&mut Serializer::new(&mut buf).with_struct_map())?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rmp_serde::Deserializer;
|
||||
use rustfs_madmin::{BackendDisks, BackendInfo, Disk, ITEM_ONLINE, StorageInfo};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
||||
struct Simple {
|
||||
name: String,
|
||||
count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
||||
struct Nested {
|
||||
label: String,
|
||||
tags: HashMap<String, String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
optional: Option<u64>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_decode_round_trip() {
|
||||
let val = Simple {
|
||||
name: "rustfs".into(),
|
||||
count: 42,
|
||||
};
|
||||
let buf = encode_msgpack_map(&val).unwrap();
|
||||
let decoded: Simple = Deserialize::deserialize(&mut Deserializer::new(Cursor::new(&buf))).unwrap();
|
||||
assert_eq!(val, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_produces_map_not_array() {
|
||||
let val = Simple {
|
||||
name: "test".into(),
|
||||
count: 1,
|
||||
};
|
||||
let buf = encode_msgpack_map(&val).unwrap();
|
||||
// Map marker for 2 fields: fixmap with N=2 is 0x82
|
||||
assert_eq!(buf[0], 0x82, "expected msgpack fixmap marker, got array");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_struct_with_optional_and_hashmap() {
|
||||
let mut tags = HashMap::new();
|
||||
tags.insert("env".into(), "production".into());
|
||||
|
||||
let val = Nested {
|
||||
label: "node1".into(),
|
||||
tags,
|
||||
optional: None,
|
||||
};
|
||||
let buf = encode_msgpack_map(&val).unwrap();
|
||||
let decoded: Nested = Deserialize::deserialize(&mut Deserializer::new(Cursor::new(&buf))).unwrap();
|
||||
assert_eq!(val, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_info_map_encoding_round_trip_matches_issue_2815_contract() {
|
||||
let mut online_disks = BackendDisks::new();
|
||||
online_disks.0.insert("node1".into(), 4);
|
||||
let mut offline_disks = BackendDisks::new();
|
||||
offline_disks.0.insert("node2".into(), 0);
|
||||
|
||||
let value = StorageInfo {
|
||||
disks: vec![Disk {
|
||||
endpoint: "node1:9000".into(),
|
||||
state: ITEM_ONLINE.into(),
|
||||
local: true,
|
||||
pool_index: 0,
|
||||
set_index: 0,
|
||||
disk_index: 0,
|
||||
..Default::default()
|
||||
}],
|
||||
backend: BackendInfo {
|
||||
online_disks,
|
||||
offline_disks,
|
||||
total_sets: vec![1],
|
||||
drives_per_set: vec![4],
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
let buf = encode_msgpack_map(&value).unwrap();
|
||||
let marker = buf[0];
|
||||
assert!(
|
||||
(0x80..=0x8f).contains(&marker) || marker == 0xde || marker == 0xdf,
|
||||
"StorageInfo map-encoded payload must start with a map marker, got 0x{marker:02x}"
|
||||
);
|
||||
let decoded: StorageInfo = Deserialize::deserialize(&mut Deserializer::new(Cursor::new(&buf))).unwrap();
|
||||
|
||||
assert_eq!(decoded.disks.len(), 1);
|
||||
assert_eq!(decoded.disks[0].endpoint, "node1:9000");
|
||||
assert_eq!(decoded.backend.online_disks.0.get("node1"), Some(&4));
|
||||
assert_eq!(decoded.backend.offline_disks.0.get("node2"), Some(&0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_info_tuple_encoding_uses_array_marker_that_issue_2815_fixed() {
|
||||
let mut online_disks = BackendDisks::new();
|
||||
online_disks.0.insert("node1".into(), 4);
|
||||
|
||||
let value = StorageInfo {
|
||||
backend: BackendInfo {
|
||||
online_disks,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut buf = Vec::new();
|
||||
value.serialize(&mut Serializer::new(&mut buf)).unwrap();
|
||||
let marker = buf[0];
|
||||
assert!(
|
||||
(0x90..=0x9f).contains(&marker) || marker == 0xdc || marker == 0xdd,
|
||||
"legacy tuple-mode StorageInfo must start with an array marker, got 0x{marker:02x}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::admin::service::site_replication::reload_site_replication_runtime_sta
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use futures_util::future::join_all;
|
||||
use rmp_serde::{Deserializer, Serializer};
|
||||
use rmp_serde::Deserializer;
|
||||
use rustfs_common::{get_global_local_node_name, heal_channel::HealOpts};
|
||||
use rustfs_ecstore::{
|
||||
admin_server_info::get_local_server_property,
|
||||
@@ -43,7 +43,7 @@ use rustfs_protos::{
|
||||
models::{PingBody, PingBodyBuilder},
|
||||
proto_gen::node_service::{node_service_server::NodeService as Node, *},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use std::{io::Cursor, pin::Pin, sync::Arc};
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc;
|
||||
@@ -1982,6 +1982,156 @@ mod tests {
|
||||
assert!(!proc_response.proc_info.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_proc_info_round_trip() {
|
||||
let service = create_test_node_service();
|
||||
let response = service
|
||||
.get_proc_info(Request::new(GetProcInfoRequest {}))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
assert!(response.success);
|
||||
let mut de = rmp_serde::Deserializer::new(std::io::Cursor::new(response.proc_info));
|
||||
let _: rustfs_madmin::health::ProcInfo = serde::Deserialize::deserialize(&mut de).expect("ProcInfo round-trip failed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_mem_info_round_trip() {
|
||||
let service = create_test_node_service();
|
||||
let response = service
|
||||
.get_mem_info(Request::new(GetMemInfoRequest {}))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
assert!(response.success);
|
||||
let mut de = rmp_serde::Deserializer::new(std::io::Cursor::new(response.mem_info));
|
||||
let _: rustfs_madmin::health::MemInfo = serde::Deserialize::deserialize(&mut de).expect("MemInfo round-trip failed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_sys_errors_round_trip() {
|
||||
let service = create_test_node_service();
|
||||
let response = service
|
||||
.get_sys_errors(Request::new(GetSysErrorsRequest {}))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
assert!(response.success);
|
||||
let mut de = rmp_serde::Deserializer::new(std::io::Cursor::new(response.sys_errors));
|
||||
let _: rustfs_madmin::health::SysErrors = serde::Deserialize::deserialize(&mut de).expect("SysErrors round-trip failed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_sys_config_round_trip() {
|
||||
let service = create_test_node_service();
|
||||
let response = service
|
||||
.get_sys_config(Request::new(GetSysConfigRequest {}))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
assert!(response.success);
|
||||
let mut de = rmp_serde::Deserializer::new(std::io::Cursor::new(response.sys_config));
|
||||
let _: rustfs_madmin::health::SysConfig = serde::Deserialize::deserialize(&mut de).expect("SysConfig round-trip failed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_se_linux_info_round_trip() {
|
||||
let service = create_test_node_service();
|
||||
let response = service
|
||||
.get_se_linux_info(Request::new(GetSeLinuxInfoRequest {}))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
assert!(response.success);
|
||||
let mut de = rmp_serde::Deserializer::new(std::io::Cursor::new(response.sys_services));
|
||||
let _: rustfs_madmin::health::SysServices =
|
||||
serde::Deserialize::deserialize(&mut de).expect("SysServices round-trip failed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_os_info_round_trip() {
|
||||
let service = create_test_node_service();
|
||||
let response = service
|
||||
.get_os_info(Request::new(GetOsInfoRequest {}))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
assert!(response.success);
|
||||
let mut de = rmp_serde::Deserializer::new(std::io::Cursor::new(response.os_info));
|
||||
let _: rustfs_madmin::health::OsInfo = serde::Deserialize::deserialize(&mut de).expect("OsInfo round-trip failed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_partitions_round_trip() {
|
||||
let service = create_test_node_service();
|
||||
let response = service
|
||||
.get_partitions(Request::new(GetPartitionsRequest {}))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
assert!(response.success);
|
||||
let mut de = rmp_serde::Deserializer::new(std::io::Cursor::new(response.partitions));
|
||||
let _: rustfs_madmin::health::Partitions =
|
||||
serde::Deserialize::deserialize(&mut de).expect("Partitions round-trip failed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_net_info_round_trip() {
|
||||
let service = create_test_node_service();
|
||||
let response = service
|
||||
.get_net_info(Request::new(GetNetInfoRequest {}))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
assert!(response.success);
|
||||
let mut de = rmp_serde::Deserializer::new(std::io::Cursor::new(response.net_info));
|
||||
let _: rustfs_madmin::net::NetInfo = serde::Deserialize::deserialize(&mut de).expect("NetInfo round-trip failed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_cpus_round_trip() {
|
||||
let service = create_test_node_service();
|
||||
let response = service.get_cpus(Request::new(GetCpusRequest {})).await.unwrap().into_inner();
|
||||
assert!(response.success);
|
||||
let mut de = rmp_serde::Deserializer::new(std::io::Cursor::new(response.cpus));
|
||||
let _: rustfs_madmin::health::Cpus = serde::Deserialize::deserialize(&mut de).expect("Cpus round-trip failed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_server_info_round_trip() {
|
||||
let service = create_test_node_service();
|
||||
let response = service
|
||||
.server_info(Request::new(ServerInfoRequest { metrics: false }))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
assert!(response.success);
|
||||
let mut de = rmp_serde::Deserializer::new(std::io::Cursor::new(response.server_properties));
|
||||
let _: rustfs_madmin::ServerProperties =
|
||||
serde::Deserialize::deserialize(&mut de).expect("ServerProperties round-trip failed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_metrics_round_trip() {
|
||||
let service = create_test_node_service();
|
||||
let metric_type = MetricType::DISK;
|
||||
let opts = CollectMetricsOpts::default();
|
||||
let metric_type_bytes = rmp_serde::to_vec(&metric_type).unwrap();
|
||||
let opts_bytes = rmp_serde::to_vec(&opts).unwrap();
|
||||
let response = service
|
||||
.get_metrics(Request::new(GetMetricsRequest {
|
||||
metric_type: Bytes::from(metric_type_bytes),
|
||||
opts: Bytes::from(opts_bytes),
|
||||
}))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
assert!(response.success);
|
||||
let mut de = rmp_serde::Deserializer::new(std::io::Cursor::new(response.realtime_metrics));
|
||||
let _: rustfs_madmin::metrics::RealtimeMetrics =
|
||||
serde::Deserialize::deserialize(&mut de).expect("RealtimeMetrics round-trip failed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires isolated global object layer state"]
|
||||
async fn test_reload_pool_meta() {
|
||||
|
||||
@@ -68,7 +68,7 @@ pub(crate) fn build_list_buckets_output(bucket_infos: &[BucketInfo]) -> ListBuck
|
||||
let buckets: Vec<Bucket> = bucket_infos
|
||||
.iter()
|
||||
.map(|bucket_info| Bucket {
|
||||
creation_date: bucket_info.created.map(Timestamp::from),
|
||||
creation_date: Some(Timestamp::from(bucket_info.created.unwrap_or(time::OffsetDateTime::UNIX_EPOCH))),
|
||||
name: Some(bucket_info.name.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
@@ -380,7 +380,7 @@ mod tests {
|
||||
assert_eq!(buckets[0].name.as_deref(), Some("bucket-a"));
|
||||
assert_eq!(buckets[0].creation_date, Some(s3s::dto::Timestamp::from(OffsetDateTime::UNIX_EPOCH)));
|
||||
assert_eq!(buckets[1].name.as_deref(), Some("bucket-b"));
|
||||
assert_eq!(buckets[1].creation_date, None);
|
||||
assert_eq!(buckets[1].creation_date, Some(s3s::dto::Timestamp::from(OffsetDateTime::UNIX_EPOCH)));
|
||||
|
||||
let expected_owner = rustfs_owner();
|
||||
assert_eq!(owner.display_name, expected_owner.display_name);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
RUSTFS_ROOT_USER=rustfsadmin
|
||||
RUSTFS_ROOT_PASSWORD=rustfsadmin
|
||||
RUSTFS_ACCESS_KEY=rustfsadmin
|
||||
RUSTFS_SECRET_KEY=rustfsadmin
|
||||
|
||||
RUSTFS_VOLUMES="http://node{1...4}:7000/data/rustfs{0...3} http://node{5...8}:7000/data/rustfs{0...3}"
|
||||
RUSTFS_ADDRESS=":7000"
|
||||
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -ne 1 ]; then
|
||||
echo "usage: $0 <tag-or-ref>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
RAW="$1"
|
||||
case "$RAW" in
|
||||
refs/tags/*)
|
||||
RAW_TAG="${RAW#refs/tags/}"
|
||||
;;
|
||||
*)
|
||||
RAW_TAG="$RAW"
|
||||
;;
|
||||
esac
|
||||
|
||||
APP_VERSION="${RAW_TAG#v}"
|
||||
BETA_NUM=$(printf '%s\n' "$APP_VERSION" | sed -n -E 's/^.*-beta\.([0-9]+)$/\1/p')
|
||||
if [ -n "$BETA_NUM" ]; then
|
||||
CHART_VERSION="0.${BETA_NUM}.0"
|
||||
else
|
||||
CHART_VERSION="$APP_VERSION"
|
||||
fi
|
||||
|
||||
if [ -n "${GITHUB_OUTPUT:-}" ]; then
|
||||
{
|
||||
echo "raw_tag=$RAW_TAG"
|
||||
echo "app_version=$APP_VERSION"
|
||||
echo "chart_version=$CHART_VERSION"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
else
|
||||
printf 'raw_tag=%s\n' "$RAW_TAG"
|
||||
printf 'app_version=%s\n' "$APP_VERSION"
|
||||
printf 'chart_version=%s\n' "$CHART_VERSION"
|
||||
fi
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
SCRIPT="$ROOT_DIR/scripts/helm_chart_version.sh"
|
||||
|
||||
assert_version() {
|
||||
local raw="$1"
|
||||
local expected_raw_tag="$2"
|
||||
local expected_app_version="$3"
|
||||
local expected_chart_version="$4"
|
||||
local output
|
||||
|
||||
output=$(mktemp)
|
||||
GITHUB_OUTPUT="$output" "$SCRIPT" "$raw"
|
||||
|
||||
grep -qx "raw_tag=$expected_raw_tag" "$output"
|
||||
grep -qx "app_version=$expected_app_version" "$output"
|
||||
grep -qx "chart_version=$expected_chart_version" "$output"
|
||||
|
||||
rm -f "$output"
|
||||
}
|
||||
|
||||
assert_version "refs/tags/v1.0.0-beta.12" "v1.0.0-beta.12" "1.0.0-beta.12" "0.12.0"
|
||||
assert_version "v1.0.0" "v1.0.0" "1.0.0" "1.0.0"
|
||||
assert_version "refs/tags/1.0.0" "1.0.0" "1.0.0" "1.0.0"
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
CHART_DIR="$ROOT_DIR/helm/rustfs"
|
||||
|
||||
render_chart() {
|
||||
helm template rustfs "$CHART_DIR" \
|
||||
--namespace rustfs \
|
||||
--set mode.distributed.enabled=false \
|
||||
--set mode.standalone.enabled=true \
|
||||
--set secret.rustfs.access_key=test-access-key \
|
||||
--set secret.rustfs.secret_key=test-secret-key \
|
||||
"$@"
|
||||
}
|
||||
|
||||
render_standalone_deployment() {
|
||||
render_chart "$@" |
|
||||
awk '
|
||||
/^# Source: rustfs\/templates\/deployment.yaml$/ { in_deployment = 1 }
|
||||
in_deployment && /^---$/ { exit }
|
||||
in_deployment { print }
|
||||
'
|
||||
}
|
||||
|
||||
recreate_output=$(render_standalone_deployment --set mode.standalone.strategy.type=Recreate)
|
||||
grep -q "type: Recreate" <<<"$recreate_output"
|
||||
if grep -q "rollingUpdate:" <<<"$recreate_output"; then
|
||||
echo "Recreate strategy must not render rollingUpdate fields" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rolling_output=$(render_standalone_deployment)
|
||||
grep -q "type: RollingUpdate" <<<"$rolling_output"
|
||||
grep -q "rollingUpdate:" <<<"$rolling_output"
|
||||
|
||||
# Fail-closed credential checks. Rendering must fail when no credentials,
|
||||
# existingSecret, or allowInsecureDefaults override is supplied.
|
||||
default_render_status=0
|
||||
helm template rustfs "$CHART_DIR" \
|
||||
--namespace rustfs \
|
||||
--set mode.distributed.enabled=false \
|
||||
--set mode.standalone.enabled=true \
|
||||
>/dev/null 2>&1 || default_render_status=$?
|
||||
if [[ $default_render_status -eq 0 ]]; then
|
||||
echo "Default credentials must fail to render without an explicit override" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Rendering must also fail if someone re-supplies the well-known defaults.
|
||||
default_creds_status=0
|
||||
helm template rustfs "$CHART_DIR" \
|
||||
--namespace rustfs \
|
||||
--set mode.distributed.enabled=false \
|
||||
--set mode.standalone.enabled=true \
|
||||
--set secret.rustfs.access_key=rustfsadmin \
|
||||
--set secret.rustfs.secret_key=rustfsadmin \
|
||||
>/dev/null 2>&1 || default_creds_status=$?
|
||||
if [[ $default_creds_status -eq 0 ]]; then
|
||||
echo "Setting the well-known defaults must fail without allowInsecureDefaults" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# allowInsecureDefaults=true must succeed and emit the dev creds.
|
||||
insecure_output=$(helm template rustfs "$CHART_DIR" \
|
||||
--namespace rustfs \
|
||||
--set mode.distributed.enabled=false \
|
||||
--set mode.standalone.enabled=true \
|
||||
--set secret.allowInsecureDefaults=true)
|
||||
expected_b64=$(printf 'rustfsadmin' | base64)
|
||||
if ! grep -q "RUSTFS_ACCESS_KEY: \"$expected_b64\"" <<<"$insecure_output"; then
|
||||
echo "allowInsecureDefaults=true must emit the well-known dev access key" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# existingSecret must skip rendering the chart-managed Secret entirely.
|
||||
existing_output=$(helm template rustfs "$CHART_DIR" \
|
||||
--namespace rustfs \
|
||||
--set mode.distributed.enabled=false \
|
||||
--set mode.standalone.enabled=true \
|
||||
--set secret.existingSecret=my-existing-secret)
|
||||
if grep -q "RUSTFS_ACCESS_KEY:" <<<"$existing_output"; then
|
||||
echo "existingSecret must suppress chart-managed Secret rendering" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Partial-default credentials (one key set to the well-known default) must
|
||||
# fail rendering even when the other key is non-default.
|
||||
partial_default_status=0
|
||||
helm template rustfs "$CHART_DIR" \
|
||||
--namespace rustfs \
|
||||
--set mode.distributed.enabled=false \
|
||||
--set mode.standalone.enabled=true \
|
||||
--set secret.rustfs.access_key=rustfsadmin \
|
||||
--set secret.rustfs.secret_key=some-other-secret \
|
||||
>/dev/null 2>&1 || partial_default_status=$?
|
||||
if [[ $partial_default_status -eq 0 ]]; then
|
||||
echo "Partial-default credentials (access_key=rustfsadmin) must fail rendering" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Partial-empty credentials (only one of the two keys set) must fail rendering
|
||||
# even when allowInsecureDefaults=true — never silently auto-fill a single
|
||||
# missing key with the well-known default.
|
||||
partial_empty_status=0
|
||||
helm template rustfs "$CHART_DIR" \
|
||||
--namespace rustfs \
|
||||
--set mode.distributed.enabled=false \
|
||||
--set mode.standalone.enabled=true \
|
||||
--set secret.allowInsecureDefaults=true \
|
||||
--set secret.rustfs.access_key=user-supplied-access-key \
|
||||
>/dev/null 2>&1 || partial_empty_status=$?
|
||||
if [[ $partial_empty_status -eq 0 ]]; then
|
||||
echo "Partial-empty credentials (only access_key set) must fail rendering" >&2
|
||||
exit 1
|
||||
fi
|
||||
+298
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Issue 2723 verification runner
|
||||
# Validates site-replication behavior against Task 07 matrix/evidence.
|
||||
|
||||
SITE_A_ENDPOINT="${SITE_A_ENDPOINT:-}"
|
||||
SITE_B_ENDPOINT="${SITE_B_ENDPOINT:-}"
|
||||
ACCESS_KEY="${ACCESS_KEY:-rustfsadmin}"
|
||||
SECRET_KEY="${SECRET_KEY:-rustfsadmin}"
|
||||
REGION="${REGION:-us-east-1}"
|
||||
CA_CERT="${CA_CERT:-}"
|
||||
OUT_DIR="${OUT_DIR:-target/verify/issue-2723-$(date +%Y%m%d-%H%M%S)}"
|
||||
SITE_A_RESTART_CMD="${SITE_A_RESTART_CMD:-}"
|
||||
SITE_B_RESTART_CMD="${SITE_B_RESTART_CMD:-}"
|
||||
BUCKET="${BUCKET:-}"
|
||||
REPL_OBJECT_KEY="${REPL_OBJECT_KEY:-issue-2723-e2e-object.txt}"
|
||||
REPL_OBJECT_BODY="${REPL_OBJECT_BODY:-issue-2723-replication-check}"
|
||||
AWS_PROFILE="${AWS_PROFILE:-}"
|
||||
|
||||
AWSCURL_BIN="${AWSCURL_BIN:-awscurl}"
|
||||
AWS_BIN="${AWS_BIN:-aws}"
|
||||
HEALTHCHECK_FORCE_LOOPBACK_RESOLVE="${HEALTHCHECK_FORCE_LOOPBACK_RESOLVE:-false}"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
scripts/validate_issue_2723_site_replication.sh [options]
|
||||
|
||||
Required:
|
||||
--site-a-endpoint <url> Site A admin endpoint, e.g. https://site-a.example.com:9000
|
||||
--site-b-endpoint <url> Site B admin endpoint, e.g. https://site-b.example.com:9000
|
||||
--access-key <ak>
|
||||
--secret-key <sk>
|
||||
|
||||
Optional:
|
||||
--region <name> AWS region for signing (default: us-east-1)
|
||||
--ca-cert <path> CA cert for strict HTTPS health checks
|
||||
--out-dir <dir> Artifact output directory
|
||||
--site-a-restart-cmd <cmd> Restart command for site A (optional)
|
||||
--site-b-restart-cmd <cmd> Restart command for site B (optional)
|
||||
--bucket <name> Replication validation bucket (optional)
|
||||
--repl-object-key <key> Replication validation object key
|
||||
--repl-object-body <text> Replication validation object body
|
||||
--awscurl-bin <path> awscurl binary (default: awscurl)
|
||||
--aws-bin <path> aws cli binary (default: aws)
|
||||
--aws-profile <profile> AWS CLI profile for object-flow checks
|
||||
--healthcheck-force-loopback-resolve
|
||||
Force HTTPS healthcheck `--resolve host:port:127.0.0.1`
|
||||
(default: false; intended for single-host local Docker)
|
||||
-h, --help Show help
|
||||
|
||||
Notes:
|
||||
1) If --bucket is provided and aws cli is available, script will run optional
|
||||
object-flow checks on both sites.
|
||||
2) Restart verification is skipped unless both restart commands are provided.
|
||||
USAGE
|
||||
}
|
||||
|
||||
log() {
|
||||
printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"
|
||||
}
|
||||
|
||||
fail() {
|
||||
log "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
fail "required command not found: $1"
|
||||
fi
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--site-a-endpoint) SITE_A_ENDPOINT="$2"; shift 2 ;;
|
||||
--site-b-endpoint) SITE_B_ENDPOINT="$2"; shift 2 ;;
|
||||
--access-key) ACCESS_KEY="$2"; shift 2 ;;
|
||||
--secret-key) SECRET_KEY="$2"; shift 2 ;;
|
||||
--region) REGION="$2"; shift 2 ;;
|
||||
--ca-cert) CA_CERT="$2"; shift 2 ;;
|
||||
--out-dir) OUT_DIR="$2"; shift 2 ;;
|
||||
--site-a-restart-cmd) SITE_A_RESTART_CMD="$2"; shift 2 ;;
|
||||
--site-b-restart-cmd) SITE_B_RESTART_CMD="$2"; shift 2 ;;
|
||||
--bucket) BUCKET="$2"; shift 2 ;;
|
||||
--repl-object-key) REPL_OBJECT_KEY="$2"; shift 2 ;;
|
||||
--repl-object-body) REPL_OBJECT_BODY="$2"; shift 2 ;;
|
||||
--awscurl-bin) AWSCURL_BIN="$2"; shift 2 ;;
|
||||
--aws-bin) AWS_BIN="$2"; shift 2 ;;
|
||||
--aws-profile) AWS_PROFILE="$2"; shift 2 ;;
|
||||
--healthcheck-force-loopback-resolve) HEALTHCHECK_FORCE_LOOPBACK_RESOLVE="true"; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*)
|
||||
fail "unknown argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
endpoint_scheme() {
|
||||
local endpoint="$1"
|
||||
if [[ "$endpoint" == https://* ]]; then
|
||||
echo "https"
|
||||
elif [[ "$endpoint" == http://* ]]; then
|
||||
echo "http"
|
||||
else
|
||||
fail "endpoint must include scheme http:// or https:// : $endpoint"
|
||||
fi
|
||||
}
|
||||
|
||||
endpoint_hostport() {
|
||||
local endpoint="$1"
|
||||
local hostport
|
||||
hostport="${endpoint#http://}"
|
||||
hostport="${hostport#https://}"
|
||||
hostport="${hostport%%/*}"
|
||||
echo "$hostport"
|
||||
}
|
||||
|
||||
admin_get() {
|
||||
local endpoint="$1"
|
||||
local path="$2"
|
||||
local out_file="$3"
|
||||
local url="${endpoint%/}${path}"
|
||||
if [[ -n "$CA_CERT" ]]; then
|
||||
REQUESTS_CA_BUNDLE="$CA_CERT" SSL_CERT_FILE="$CA_CERT" \
|
||||
"$AWSCURL_BIN" --service s3 --region "$REGION" --access_key "$ACCESS_KEY" --secret_key "$SECRET_KEY" "$url" >"$out_file"
|
||||
else
|
||||
"$AWSCURL_BIN" --service s3 --region "$REGION" --access_key "$ACCESS_KEY" --secret_key "$SECRET_KEY" "$url" >"$out_file"
|
||||
fi
|
||||
}
|
||||
|
||||
strict_healthcheck() {
|
||||
local endpoint="$1"
|
||||
local label="$2"
|
||||
local out_file="$3"
|
||||
local scheme hostport host port
|
||||
scheme="$(endpoint_scheme "$endpoint")"
|
||||
hostport="$(endpoint_hostport "$endpoint")"
|
||||
host="${hostport%:*}"
|
||||
port="${hostport##*:}"
|
||||
if [[ "$port" == "$hostport" ]]; then
|
||||
port=$([[ "$scheme" == "https" ]] && echo "443" || echo "80")
|
||||
fi
|
||||
local url="${scheme}://${host}:${port}/health"
|
||||
|
||||
if [[ "$scheme" == "https" ]]; then
|
||||
if [[ -z "$CA_CERT" ]]; then
|
||||
fail "HTTPS endpoint requires --ca-cert for strict validation: $endpoint"
|
||||
fi
|
||||
if [[ "$HEALTHCHECK_FORCE_LOOPBACK_RESOLVE" == "true" ]]; then
|
||||
curl -fsS --cacert "$CA_CERT" --resolve "${host}:${port}:127.0.0.1" "$url" >"$out_file"
|
||||
else
|
||||
curl -fsS --cacert "$CA_CERT" "$url" >"$out_file"
|
||||
fi
|
||||
else
|
||||
curl -fsS "$url" >"$out_file"
|
||||
fi
|
||||
log "health check passed for ${label}: $url"
|
||||
}
|
||||
|
||||
analyze_duplicates() {
|
||||
local status_json="$1"
|
||||
local out_file="$2"
|
||||
jq -r '
|
||||
def identity_key($e):
|
||||
($e | sub("^https?://";"") | sub("/$";"") | ascii_downcase);
|
||||
(.sites // {})
|
||||
| to_entries
|
||||
| map(.value.endpoint // "")
|
||||
| map(select(. != ""))
|
||||
| map(identity_key(.))
|
||||
| group_by(.)
|
||||
| map({identity: .[0], count: length})
|
||||
| map(select(.count > 1))
|
||||
' "$status_json" >"$out_file"
|
||||
}
|
||||
|
||||
optional_object_flow_check() {
|
||||
local endpoint="$1"
|
||||
local label="$2"
|
||||
local put_out="$3"
|
||||
local get_out="$4"
|
||||
|
||||
if [[ -z "$BUCKET" ]]; then
|
||||
log "skip object-flow check for ${label}: --bucket not provided"
|
||||
return 0
|
||||
fi
|
||||
if ! command -v "$AWS_BIN" >/dev/null 2>&1; then
|
||||
log "skip object-flow check for ${label}: aws cli not found"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local common=(
|
||||
--endpoint-url "$endpoint"
|
||||
--region "$REGION"
|
||||
--no-cli-pager
|
||||
)
|
||||
if [[ -n "$AWS_PROFILE" ]]; then
|
||||
common+=(--profile "$AWS_PROFILE")
|
||||
fi
|
||||
if [[ -n "$CA_CERT" ]]; then
|
||||
common+=(--ca-bundle "$CA_CERT")
|
||||
fi
|
||||
|
||||
AWS_ACCESS_KEY_ID="$ACCESS_KEY" AWS_SECRET_ACCESS_KEY="$SECRET_KEY" \
|
||||
"$AWS_BIN" "${common[@]}" s3api put-object \
|
||||
--bucket "$BUCKET" --key "$REPL_OBJECT_KEY" --body <(printf '%s' "$REPL_OBJECT_BODY") >"$put_out"
|
||||
|
||||
AWS_ACCESS_KEY_ID="$ACCESS_KEY" AWS_SECRET_ACCESS_KEY="$SECRET_KEY" \
|
||||
"$AWS_BIN" "${common[@]}" s3api head-object \
|
||||
--bucket "$BUCKET" --key "$REPL_OBJECT_KEY" >"$get_out"
|
||||
|
||||
log "object-flow check passed for ${label} on bucket=${BUCKET}, key=${REPL_OBJECT_KEY}"
|
||||
}
|
||||
|
||||
restart_if_configured() {
|
||||
if [[ -z "$SITE_A_RESTART_CMD" || -z "$SITE_B_RESTART_CMD" ]]; then
|
||||
log "skip restart verification: restart commands not fully provided"
|
||||
return 0
|
||||
fi
|
||||
log "running restart command for site A"
|
||||
bash -lc "$SITE_A_RESTART_CMD"
|
||||
log "running restart command for site B"
|
||||
bash -lc "$SITE_B_RESTART_CMD"
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
[[ -n "$SITE_A_ENDPOINT" ]] || fail "--site-a-endpoint is required"
|
||||
[[ -n "$SITE_B_ENDPOINT" ]] || fail "--site-b-endpoint is required"
|
||||
[[ -n "$ACCESS_KEY" ]] || fail "--access-key is required"
|
||||
[[ -n "$SECRET_KEY" ]] || fail "--secret-key is required"
|
||||
|
||||
require_cmd "$AWSCURL_BIN"
|
||||
require_cmd curl
|
||||
require_cmd jq
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
log "output directory: $OUT_DIR"
|
||||
|
||||
local a_status="$OUT_DIR/site-a.status.json"
|
||||
local a_info="$OUT_DIR/site-a.info.json"
|
||||
local b_status="$OUT_DIR/site-b.status.json"
|
||||
local b_info="$OUT_DIR/site-b.info.json"
|
||||
local a_health="$OUT_DIR/site-a.health.txt"
|
||||
local b_health="$OUT_DIR/site-b.health.txt"
|
||||
local a_dupes="$OUT_DIR/site-a.duplicates.json"
|
||||
local b_dupes="$OUT_DIR/site-b.duplicates.json"
|
||||
local summary="$OUT_DIR/summary.txt"
|
||||
|
||||
log "step 1/6: strict health checks"
|
||||
strict_healthcheck "$SITE_A_ENDPOINT" "site-a" "$a_health"
|
||||
strict_healthcheck "$SITE_B_ENDPOINT" "site-b" "$b_health"
|
||||
|
||||
log "step 2/6: collect site-replication status/info"
|
||||
admin_get "$SITE_A_ENDPOINT" "/rustfs/admin/v3/site-replication/status" "$a_status"
|
||||
admin_get "$SITE_A_ENDPOINT" "/rustfs/admin/v3/site-replication/info" "$a_info"
|
||||
admin_get "$SITE_B_ENDPOINT" "/rustfs/admin/v3/site-replication/status" "$b_status"
|
||||
admin_get "$SITE_B_ENDPOINT" "/rustfs/admin/v3/site-replication/info" "$b_info"
|
||||
|
||||
log "step 3/6: duplicate identity analysis"
|
||||
analyze_duplicates "$a_status" "$a_dupes"
|
||||
analyze_duplicates "$b_status" "$b_dupes"
|
||||
|
||||
log "step 4/6: optional object-flow checks"
|
||||
optional_object_flow_check "$SITE_A_ENDPOINT" "site-a" "$OUT_DIR/site-a.put.json" "$OUT_DIR/site-a.head.json"
|
||||
optional_object_flow_check "$SITE_B_ENDPOINT" "site-b" "$OUT_DIR/site-b.put.json" "$OUT_DIR/site-b.head.json"
|
||||
|
||||
log "step 5/6: optional restart verification"
|
||||
restart_if_configured
|
||||
if [[ -n "$SITE_A_RESTART_CMD" && -n "$SITE_B_RESTART_CMD" ]]; then
|
||||
admin_get "$SITE_A_ENDPOINT" "/rustfs/admin/v3/site-replication/status" "$OUT_DIR/site-a.status.after-restart.json"
|
||||
admin_get "$SITE_B_ENDPOINT" "/rustfs/admin/v3/site-replication/status" "$OUT_DIR/site-b.status.after-restart.json"
|
||||
fi
|
||||
|
||||
log "step 6/6: write summary"
|
||||
{
|
||||
echo "Issue 2723 verification summary"
|
||||
echo "site-a endpoint: $SITE_A_ENDPOINT"
|
||||
echo "site-b endpoint: $SITE_B_ENDPOINT"
|
||||
echo "region: $REGION"
|
||||
echo "ca-cert: ${CA_CERT:-<none>}"
|
||||
echo
|
||||
echo "Duplicate identities (site-a): $(jq 'length' "$a_dupes")"
|
||||
echo "Duplicate identities (site-b): $(jq 'length' "$b_dupes")"
|
||||
echo
|
||||
echo "Artifacts:"
|
||||
find "$OUT_DIR" -maxdepth 1 -type f | sort
|
||||
} >"$summary"
|
||||
|
||||
log "done. summary: $summary"
|
||||
cat "$summary"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user