Compare commits

..

24 Commits

Author SHA1 Message Date
overtrue 93fc2c1440 Merge remote-tracking branch 'upstream-https/main' into fix/ant-request-layer-batch
# Conflicts:
#	crates/ecstore/src/cache_value/metacache_set.rs
2026-11-05 16:42:00 +08:00
overtrue 262e890fb2 test(admin): register the catalog directory routes in the route matrix
The table-catalog batch added GET, POST and DELETE
{warehouse}/catalog/directory on both the Iceberg and the compat admin
prefixes without listing them in the admin route matrix, so
test_admin_route_matrix_matches_registered_routes failed on CI.
2026-11-05 16:35:44 +08:00
overtrue c2958f4320 fix(table-catalog): close the round-2 adversarial review findings
Parquet page-header walker: read the size fields by field id as parquet
does, accepting I16/I32/I64 wire kinds and rejecting any other kind, so
a header encoding uncompressed_page_size as I64 can no longer bypass the
budget while parquet allocates it. Bound every list, set and map count by
the bytes remaining in the chunk, and reject bool collection elements
(parquet skips them with zero width, so either width would desynchronize
the two parsers); this removes the 2^63-iteration spin on LIST<BOOL>.
Start each column-chunk walk at the dictionary page offset whenever the
footer declares one, exactly as parquet's byte_range does, instead of
min(dictionary, data).

Footer pre-walk: before parquet decodes a footer, walk FileMetaData with
the hardened cursor and charge the declared row_groups count times the
RowGroupMetaData size to the decode budget, because parquet 59 reserves
that Vec before reading one element.

Preparation deadline: clamp the
RUSTFS_TABLE_CATALOG_PUBLICATION_PREPARATION_TIMEOUT_SECS knob to 86,400
seconds so Instant + Duration cannot overflow.

Index-miss scan: after each listing page, jump the cursor past the
maintenance subtree the page ended in, and allow one extra page per table
entry already listed, so job-report history scales with the catalog while
a report flood under one table stays bounded.

Migration fence: read the fence-path object once and decide from its
stored kind whether it is directory state or a fence.

Expired or failed create: remove the initial metadata file when the
registration fails before any catalog write may have begun; nothing else
references it. Correct the comment and support matrix that claimed
maintenance reclaims it.

Tests cover each walker divergence, the footer row-group reservation, the
deadline ceiling, the paging budget for honest catalogs and for a report
flood, and the removal of the unpublished metadata file.
2026-11-05 16:34:41 +08:00
overtrue 5ae075666f fix(lock): queue table-bucket publication writers fairly
Table commits take the bucket publication fence as a write lock; under a
stream of commits the lock's try-and-retry acquisition let a new commit
jump ahead of object writers that had been waiting longer, which is part
of what starved them (backlog#3111). Requests for the publication lock
path now take a FIFO ticket per shard: a waiter is admitted only when it
is at the head of the queue, cancelled waiters release their turn, and
batch acquisitions join the same queue. Other lock paths are unchanged.

This moved here from the request-layer batch, where no cited issue
covered it; it serves the table-catalog issue this PR closes.
2026-11-05 16:34:41 +08:00
overtrue ea8c4bbb79 fix(s3,ecstore): close the round-2 adversarial review findings
DeleteObject resolved x-rustfs-source-version-id for authorization on
every request while storage consumes it only for a REPLICA request, so a
non-replica caller with s3:DeleteObjectVersion could trigger a plain
delete of the current version. The hook now resolves the header under
the same predicate as storage, and the regression test covers the
non-replica case.

HEAD no longer skips its permission probes for the bucket owner; an
explicit bucket-policy Deny on the metadata actions binds the owner too.

Multipart index pages verify each root against the staging area and
prune markers whose staging root is gone, under the marker's own lease
with a re-check, so the per-drive index stays proportional to live
uploads instead of every key ever multipart-initiated on the drive.

SigV2 configuration PUTs that declare an empty body need no Content-MD5:
there is no body to replay. The minimum-progress floor stays independent
of RUSTFS_HTTP_REQUEST_BODY_READ_TIMEOUT; the knob's documentation now
says so instead of claiming 0 disables it. The quota bootstrap window
(503 until the background scan publishes the ledger) is kept, because
ledger admission must not come from scanner reporting; the compatibility
matrix now documents it.

The S3 compatibility matrix gains a section for the client-visible
validation bounds, and the drive-timeout tuning guide tabulates
RUSTFS_GET_DISK_READ_IDLE_TIMEOUT_SECS.
2026-11-05 16:34:25 +08:00
GatewayJ 57df9181d0 fix(audit): preserve OIDC identity in S3 request audits (#9178) 2026-11-05 06:03:33 +00:00
Hauser Zhi 371f5e4ffc fix(ecstore): streamline lock RPC and listing ownership (#9176)
* fix(ecstore): preserve lock cleanup and listing trace context

Own remote lock admission and dispatched RPC cleanup across caller cancellation. Reuse bounded late-result cleanup and retain the listing request span in metacache tasks.

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

* perf(ecstore): streamline lock RPC and listing fanout

Poll RPCs in callers and transfer pending work before channel eviction. Share walk options across producers and borrow cached peer keys. Keep deadline classification deterministic.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-11-05 05:59:05 +00:00
overtrue 5505e4f7d7 fix(select): preserve complete samples and Parquet batch limits 2026-11-05 11:29:47 +08:00
Chris An 7e045fe985 fix(table-catalog): bound maintenance work and isolate table namespaces (#9174)
* fix(table-catalog): bound maintenance work and isolate table namespaces

Scan findings in the Iceberg table catalog:

- Metadata maintenance deleted every unreferenced *.metadata.json in a
  table's directory without checking whether a renamed table in another
  namespace still owned those files (backlog#3060); it now resolves the
  owning table through the directory index before deleting.
- Manifest lists and manifests were decoded with the embedded Avro
  writer schema and no recursion depth limit; a self-referencing record
  schema overflowed the stack (backlog#3080). Schemas are validated for
  depth before decoding.
- Compaction decoded every input Parquet file into Arrow batches held in
  memory; a decompression bomb OOM-killed the server (backlog#3085). Row
  groups are streamed through a byte budget and oversized inputs are
  refused.
- An S3 request for a key outside an indexed table prefix listed the
  bucket's whole namespaces/ catalog prefix and failed once it held more
  than 4096 objects (backlog#3086); a per-bucket directory index answers
  index misses without the listing.
- The maintenance planner read whole orphan files to obtain their
  modification time (backlog#3087); it reads metadata only.
- A client disconnect between persisting active_rename_id and clearing it
  left the bucket's catalog entry locked at 503 (backlog#3088); the rename
  runs detached from the request future and clears the flag on every exit.
- Table commits held the bucket publication fence until publication,
  starving every object write (backlog#3111); commits now hold it under a
  deadline and object writes are admitted between commits.

The directory index is managed through three new admin routes under
{warehouse}/catalog/directory, registered in the admin route policy.

The durable-strong store is left as is (its snapshot partitioning for
backlog#3109 and the strong-path deadline are not ported): that backing
is being retired separately, so the new tests cover the object backing.

* fix(table-catalog): close the review gaps in the scan-fix batch

Adversarial review of the table-catalog batch found the fixes narrower
than the issues they close and two cost regressions:

- backlog#3085: the compaction footer check bounded nothing a page header
  could not override; parquet sizes its page buffers from the header.
  Walk every column chunk's page headers (Thrift compact, no decompression)
  and hold each page and the chunk total to the budget before decoding.
  The stored-input budget is per rewrite group, so a multi-group job no
  longer fails after writing earlier outputs.
- backlog#3086: the default index-miss scan still counted maintenance
  reports toward its 4096-object limit; only table entries count now, and
  the listing itself is bounded to 64 pages. The directory index remains
  an optional accelerator.
- backlog#3111: a create wrote its metadata file under the fence as the
  first durable mutation, which disarmed the deadline before the unbounded
  registration inventory. The write precedes publication now. The deadline
  gets its own knob (RUSTFS_TABLE_CATALOG_PUBLICATION_PREPARATION_TIMEOUT_SECS,
  default 30 s, never below the lock-acquire timeout) instead of reusing
  the 5 s lock timeout, and the ownership inventory reads entries
  concurrently.
- Directory candidates are written only while a directory exists; the
  backfill covers earlier entries. A directory on the migration-fence
  path is reported as a conflict instead of a parse error.
- Dead test hooks, a stale comment and the support-matrix text that did
  not match the code are removed or corrected.
2026-11-05 11:20:21 +08:00
cxymds 8a5a2c9c93 fix(s3): treat duplicate CreateBucket as owned by the requester (#9167)
* fix(s3): treat duplicate CreateBucket as owned by the requester

The duplicate-bucket response was decided from the requester's root-owner
flag, so every IAM user recreating a bucket received 409
BucketAlreadyExists, including a user recreating a bucket it created.
BucketAlreadyExists means the bucket belongs to another account; a
deployment owns a single namespace, so the answer is purely region-based:
the default region returns the legacy 200 OK, any other region returns
BucketAlreadyOwnedByYou.

Both the existing-bucket fast path and the BucketExists path now share one
helper. Authorization still runs first, so a requester without
s3:CreateBucket gets AccessDenied and cannot probe bucket existence.

Refs #8493

* test(ci): refresh e2e selections for duplicate CreateBucket

The two new create_bucket_region cases join the e2e-smoke and e2e-full
selections, which main also extended with multipart_upload_id tests.

smoke is measured from a local nextest listing: removing the two new cases
reproduces main's committed digest exactly. e2e-full is derived the same way
from main's listing artifact (run 41933823399, babfeaed0) plus the two
multipart_upload_id cases, which reproduces main's committed linux digest
before the new cases are added; CI re-verifies both platforms.

test_bucket_create_exists_nonowner asserts BucketAlreadyExists for a second
identity, which a single-owner deployment cannot produce. Move it to
excluded_tests.txt with the deployment-ownership rationale; the IAM-user
duplicate paths stay covered by crates/e2e_test/src/create_bucket_region_test.rs.
2026-11-05 03:06:50 +00:00
overtrue cf730f1691 fix(s3,ecstore): close the round-1 adversarial review findings
Adversarial review of the combined request- and storage-layer batch found
fixes that fell short of their issues, two behaviour regressions for
legitimate clients and several unrequested costs:

- backlog#3038: a bulk DeleteObjects reserved the whole per-disk
  inspection allowance up front for every key in flight and exhausted the
  16 Mi request budget on any set wider than eight drives, failing healthy
  keys with SlowDown. Calls are now admitted while budget remains and
  charged for the entries they actually visited. Object-scoped discovery
  charges plain child keys at one entry per 64 names instead of one each,
  so deleting a missing key that names a folder prefix completes instead
  of answering 503 forever while a hostile prefix stays bounded. A peer
  still on the previous binary is reported
  as an unsupported participant, the repository's rolling-upgrade
  contract, instead of as a budget verdict.
- backlog#3037/#3026: a SigV2 configuration PUT without Content-MD5 was
  still replayable with another body; it is refused with MissingContentMD5
  while SigV4 keeps the optional-MD5 contract.
- backlog#3072: DeleteObject now resolves x-rustfs-source-version-id before
  authorization like PUT and COPY, so a replica delete cannot remove a
  version the policy never saw.
- backlog#3121: the multipart index no longer fsyncs a marker and four
  directories per multipart metadata write (it is rebuilt on every start),
  and ListMultipartUploads discovers roots concurrently.
- backlog#3077: Parquet SelectObjectContent decodes budget-sized batches
  instead of one row per batch.
- backlog#3031: a full upload buffer pool hands out an unpooled buffer
  instead of refusing the upload; the progress floor already bounds stalls.
- backlog#3023: the GET disk-read idle cut-off is configurable
  (RUSTFS_GET_DISK_READ_IDLE_TIMEOUT_SECS, default 120 s) so a paused
  client is not truncated after 30 s.
- backlog#3075: JSON Lines schema inference keeps the sample collected at
  the 4 MiB bound instead of failing the query.
- backlog#3024: compat admin decryption queues briefly for a slot instead
  of refusing the third concurrent request.
- backlog#3113: HEAD skips its permission probes for the bucket owner.
- backlog#3117: control characters in a lifecycle rule ID are rejected on
  PutBucketLifecycle as well.

The lock-crate publication FIFO serves backlog#3111 and moves to the
table-catalog PR. Also fixed: a lossy admin error message, an unused
s3-client function, two unused test-util features, a duplicated key
validation, the s3s footprint baseline, and a stale log event name in the
reverse-proxy runbook. The ODM multipart write-back test runs on the large
test stack like its sibling.
2026-11-05 10:30:24 +08:00
overtrue b62c5e3654 fix(s3): release cold-fill admission after watchdog handoff 2026-11-05 07:54:36 +08:00
overtrue 34c6896768 test(ecstore): pass the index owner to the Linux cleanup-loop test 2026-11-05 06:36:30 +08:00
Hauser Zhi babfeaed00 fix(io-core): preserve pooled capacity and accounting (#9171)
* fix(io-core): preserve pooled capacity and accounting

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

* fix(io-core): preserve pool allocation admission order

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

* docs(io-core): clarify pool return locking

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

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-11-04 22:07:58 +00:00
overtrue 9026271113 fix(ecstore,s3): keep the multipart index and cold fill off disk lifetimes
Two lifecycle regressions surfaced once the storage and request batches
ran together against main:

- The multipart index owner cloned the disk's pinned publication root and
  bootstrapped on a task of its own. That kept the mount alive past the
  last LocalDisk owner (the disk lifecycle allows exactly three holders of
  the root FD) and left a stray task behind, which the local-disk and
  read-repair tests detect. The owner now keeps only the root path and
  reopens it for identity checks, and the bootstrap scan rides the disk's
  existing cleanup task, where stopping the disk abandons it and a
  reconnect resumes it.
- The disk-read admission wrapper defers releasing its permit until an
  in-flight read completes, so a cancelled client cannot widen disk
  concurrency. The cold-fill producer, however, drops a read it has
  already classified as stalled when its deadline fires, and that permit
  stayed pinned for the lifetime of the stuck read. The producer now
  abandons the admission explicitly on timeout.
2026-11-05 06:07:53 +08:00
overtrue 004d872a27 fix(tier): stream GET bodies and document replication budgets 2026-11-05 03:42:02 +08:00
overtrue 98b633d17a fix(s3): keep the SSE-C key fingerprint on keyless multipart completion
The backlog#3050 fix withheld both the plaintext checksums and the
customer-key MD5 header from a CompleteMultipartUpload that did not
present the key. The checksums are a digest of the plaintext and stay
hidden; the key MD5 identifies the key only, and the S3 conformance suite
(test_copy_part_enc) expects it on a keyless completion, so it is echoed
again.
2026-11-05 03:24:23 +08:00
overtrue d8e52174da Merge remote-tracking branch 'upstream-https/main' into fix/ant-request-layer-batch
# Conflicts:
#	rustfs/src/app/multipart_usecase.rs
2026-11-05 02:36:59 +08:00
overtrue d96bd52117 test(s3): move the KMS multipart denial test to the app layer
The test drives app-layer multipart use cases, so it belongs with them;
living in the SSE module it imported app code from the infra layer and
failed the layer dependency guard. The SSE test helpers it borrows are
exposed through the storage and app facades.
2026-11-05 02:36:21 +08:00
Jose Meira 736bf777b4 feat(container): add declarative bucket bootstrap (#9162)
* feat(container): add declarative bucket bootstrap

* fix(container): handle false bucket versioning setting

* Update Rust base image to version 1.99.1-trixie

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

* Update Rust base image version to 1.99.1

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

---------

Signed-off-by: Hauser Zhi <housemecn@gmail.com>
Co-authored-by: Hauser Zhi <housemecn@gmail.com>
2026-11-04 16:59:50 +00:00
overtrue faf636e1c7 fix(api): drop imports left unused by the base merge 2026-11-05 00:53:14 +08:00
overtrue 2401a8d8ac Merge branch 'fix/ant-storage-layer-batch' into fix/ant-request-layer-batch
# Conflicts:
#	rustfs/src/app/object/put.rs
2026-11-05 00:52:11 +08:00
KarlEmm 7babc7d621 fix(s3): report malformed multipart upload IDs as NoSuchUpload (#9147)
UploadPart, UploadPartCopy, ListParts and CompleteMultipartUpload returned 400 InvalidArgument for a malformed upload ID, while AbortMultipartUpload returned 404 NoSuchUpload. AWS S3 documents NoSuchUpload for an upload ID that might be invalid.

Report MalformedUploadID as NoSuchUpload through one helper used by all operations that address an existing upload, replacing the Abort-only special case. ListMultipartUploads keeps InvalidArgument for a malformed upload-id-marker.

Add the multipart_upload_id_test e2e module to the e2e-smoke profile. It asserts 404 NoSuchUpload from every operation that addresses an existing upload, for a malformed upload ID and for an aborted one, and updates the e2e-smoke and Linux e2e-full selection digests.

Co-authored-by: Karl Bastarache <bastarachek@google.com>
Co-authored-by: Hauser Zhi <housemecn@gmail.com>
2026-11-04 16:25:00 +00:00
overtrue d15dde7bb5 fix(s3): authorize what handlers store and bound request-driven work
Scan findings in the S3 request layer (access hooks, object use cases,
HTTP server layers), grouped because they share the authorization
condition builder, the PutObject admission path and the response
layers. Builds on the storage-layer batch for the bounded recovery and
multipart APIs it calls.

Authorization and policy conditions:
- copy source is percent-decoded and version-canonical before the
  s3:x-amz-copy-source condition (backlog#3135); CopyObject authorizes
  the tag set it copies (backlog#3034); HeadObject with the replication
  check header still authorizes s3:GetObject (backlog#3047); the
  x-rustfs-source-version-id header feeds s3:versionid (backlog#3072);
  recursive force-delete with a REPLICA header authorizes every
  descendant as a delete (backlog#3070); include-deleted listings require
  s3:ListBucketVersions (backlog#3099); the metadata listing route keeps
  table isolation and uses the same prefix it authorizes (backlog#3053,
  backlog#3054); If-Match probing needs read permission (backlog#3097);
  Object Lambda checks s3:GetObjectVersion (backlog#3058); replication
  proxy fallback keeps ExistingObjectTag denies (backlog#3089); HeadObject
  returns lock and tag headers only with permission (backlog#3113);
  IPv4 clients on the dual-stack listener match IPv4 SourceIp conditions
  (backlog#3049); bucket policies are parsed once and cached
  (backlog#3032); keystone: access keys no longer verify with an empty
  secret (backlog#3043).
Admission and resource bounds:
- GetObject releases disk-read permits when the client stops reading
  (backlog#3023); PutObject takes buffer and foreground admission only
  after body bytes arrive and bounds the wait (backlog#3031,
  backlog#3084); xl.meta size is bounded on the write path
  (backlog#3100); XML compat layers stream instead of buffering whole
  bodies (backlog#3094); tier GETs stream the remote body (backlog#3046); on-demand migration pulls release permits on
  unread GETs (backlog#3110); SSE-KMS key policy covers UploadPart and
  completion (backlog#3130); MinIO admin decryption runs Argon2id off the
  async worker (backlog#3024).
Input validation and leaks:
- replication headers are validated (source ETag shape, original size)
  (backlog#3092, backlog#3093); control characters in keys and lifecycle
  rule IDs are rejected (backlog#3101, backlog#3117); Snowball member
  mtime needs s3:ReplicateObject (backlog#3083); DeleteObjects checks
  Content-MD5 and admin/STS reject SigV2 replays with altered query or
  body (backlog#3037, backlog#3026); request logs redact presigned
  queries and handler spans skip credentials (backlog#3068,
  backlog#3107); SSE-C listings and completions withhold plaintext
  metadata and checksums (backlog#3120, backlog#3050); CORS resolves the
  bucket like s3s does (backlog#3104); PutBucketTagging rejects duplicate
  keys (backlog#3126); notifications fire after a client disconnect
  (backlog#3127); ListenNotification streams re-check access
  (backlog#3119).
2026-11-04 23:27:28 +08:00
117 changed files with 16332 additions and 1991 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=7c705942d3a9e583ebaa85845daa21c4f81fdbf36def24db931aaef38636c53e
sha256-linux=5c33dc395233671cb77452f8fb12900ecdbeb368632222b96835d310bce1aab8
sha256-darwin=a3e166a140f3150218b4a857fedb8749940fad0c8b053d0e3c1b957321bf5beb
sha256-linux=dce4a8eb634a27b4589bcf6d2ebf248ede97aa59701acc8696391d2812a3177d
+1 -1
View File
@@ -1 +1 @@
sha256=00f8a56b75990b487a594b97c36610b94476a6944ba00473e37187ea654d42bf
sha256=5bdd4c2eadbc41625ae3184fff34062e000d328108436259b2b7c4fe2531ecaf
+1 -1
View File
@@ -701,7 +701,7 @@ test-group = 'ecstore-serial-flaky'
[profile.e2e-smoke]
default-filter = """
package(e2e_test) & (
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|compression|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat|data_usage)_test::|^fake_s3_target::/)
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|compression|multipart_storage_class|multipart_upload_id|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat|data_usage)_test::|^fake_s3_target::/)
| test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/)
| test(/^reliant::lifecycle::/)
| test(/^conditional_delete_objects_test::/)
+3 -3
View File
@@ -22,9 +22,9 @@ The following files in the project root are essential for Docker operations:
| File | Description | Usage |
| :--- | :--- | :--- |
| **`docker-buildx.sh`** | **Multi-Arch Build Script**<br>Automates building and pushing Docker images for `amd64` and `arm64`. Supports release and dev channels. | `./docker-buildx.sh --push` |
| **`Dockerfile`** | **Production Image (Alpine)**<br>Lightweight image using musl libc. Downloads pre-built binaries from GitHub Releases. | `docker build -t rustfs:latest .` |
| **`Dockerfile.glibc`** | **Production Image (Ubuntu)**<br>Standard image using glibc. Useful if you need specific dynamic libraries. | `docker build -f Dockerfile.glibc .` |
| **`Dockerfile.source`** | **Development Image**<br>Builds RustFS from source code. Includes build tools. Ideal for local development and CI. | `docker build -f Dockerfile.source .` |
| **`Dockerfile`** | **Production Image (Alpine)**<br>Lightweight image using musl libc. Downloads verified RustFS and `rc` binaries from GitHub Releases and bundles `/etc/rustfs/bootstrap`. | `docker build -t rustfs:latest .` |
| **`Dockerfile.glibc`** | **Production Image (Ubuntu)**<br>Standard image using glibc. Includes the bundled bootstrap configuration. | `docker build -f Dockerfile.glibc .` |
| **`Dockerfile.source`** | **Development Image**<br>Builds RustFS from source code and bundles `rc` and the default bootstrap configuration. | `docker build -f Dockerfile.source .` |
### Docker Compose Configurations
Generated
+1
View File
@@ -10090,6 +10090,7 @@ version = "1.0.2"
dependencies = [
"bytes",
"hotpath",
"metrics",
"rustfs-io-metrics",
"thiserror 2.0.21",
"tokio",
+11 -3
View File
@@ -16,6 +16,7 @@ FROM alpine:3.24.2 AS build
ARG TARGETARCH
ARG RELEASE=latest
ARG RC_RELEASE=latest
RUN apk add --no-cache ca-certificates curl jq unzip
WORKDIR /build
@@ -69,6 +70,9 @@ RUN set -eux; \
chmod +x /build/rustfs; \
rm -rf rustfs.zip /build/.tmp || true
COPY scripts/download_rc.sh /usr/local/bin/download_rc.sh
RUN sh /usr/local/bin/download_rc.sh "$TARGETARCH" musl "$RC_RELEASE" /build/rc
FROM alpine:3.24.2
@@ -95,14 +99,18 @@ RUN apk upgrade --no-cache && \
ca-certificates \
coreutils \
curl \
jq \
tzdata \
&& test "$(TZ=Asia/Kolkata date +%z)" = "+0530"
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /build/rustfs /usr/bin/rustfs
COPY entrypoint.sh /entrypoint.sh
COPY --from=build /build/rc /usr/bin/rc
COPY entrypoint.sh /etc/rustfs/entrypoint.sh
COPY bootstrap/ /etc/rustfs/bootstrap/
RUN chmod +x /usr/bin/rustfs /entrypoint.sh
RUN chmod +x /usr/bin/rustfs /usr/bin/rc /etc/rustfs/entrypoint.sh && \
ln -s /etc/rustfs/entrypoint.sh /entrypoint.sh
RUN addgroup -g 10001 -S rustfs && \
adduser -u 10001 -G rustfs -S rustfs -D && \
@@ -124,6 +132,6 @@ VOLUME ["/data"]
USER rustfs
ENTRYPOINT ["/entrypoint.sh"]
ENTRYPOINT ["/etc/rustfs/entrypoint.sh"]
CMD ["rustfs"]
+13 -4
View File
@@ -12,7 +12,10 @@
# See the License for the specific language governing permissions and
# limitations under the License.
FROM rust:1.97.1-trixie
FROM rust:1.99.1-trixie
ARG TARGETARCH
ARG RC_RELEASE=latest
RUN set -eux; \
export DEBIAN_FRONTEND=noninteractive; \
@@ -21,6 +24,7 @@ RUN set -eux; \
build-essential \
ca-certificates \
curl \
jq \
git \
pkg-config \
libssl-dev \
@@ -36,6 +40,7 @@ COPY . .
RUN ./scripts/static.sh
RUN cargo run --bin gproto
RUN cargo build --release --locked --bin rustfs
RUN sh /usr/src/rustfs/scripts/download_rc.sh "$TARGETARCH" musl "$RC_RELEASE" /usr/local/bin/rc
RUN set -eux; \
groupadd -g 10001 rustfs; \
@@ -50,8 +55,12 @@ RUN set -eux; \
chown -R rustfs:rustfs /data /logs /app; \
chmod 0750 /data /logs
COPY entrypoint.sh /entrypoint.sh
RUN install -m 0755 /usr/src/rustfs/target/release/rustfs /usr/bin/rustfs && chmod +x /entrypoint.sh
COPY entrypoint.sh /etc/rustfs/entrypoint.sh
COPY bootstrap/ /etc/rustfs/bootstrap/
RUN install -m 0755 /usr/src/rustfs/target/release/rustfs /usr/bin/rustfs && \
install -m 0755 /usr/local/bin/rc /usr/bin/rc && \
chmod +x /etc/rustfs/entrypoint.sh && \
ln -s /etc/rustfs/entrypoint.sh /entrypoint.sh
ENV RUSTFS_VOLUMES="/data" \
RUST_LOG="warn" \
@@ -63,5 +72,5 @@ ENV RUSTFS_VOLUMES="/data" \
EXPOSE 9000 9001
ENTRYPOINT ["/entrypoint.sh"]
ENTRYPOINT ["/etc/rustfs/entrypoint.sh"]
CMD ["/usr/bin/rustfs"]
+11 -3
View File
@@ -16,6 +16,7 @@ FROM ubuntu:26.04 AS build
ARG TARGETARCH
ARG RELEASE=latest
ARG RC_RELEASE=latest
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
@@ -76,6 +77,9 @@ RUN set -eux; \
chmod +x /build/rustfs; \
rm -rf rustfs.zip /build/.tmp || true
COPY scripts/download_rc.sh /usr/local/bin/download_rc.sh
RUN sh /usr/local/bin/download_rc.sh "$TARGETARCH" gnu "$RC_RELEASE" /build/rc
FROM ubuntu:26.04
ARG RELEASE=latest
@@ -99,14 +103,18 @@ RUN apt-get update && apt-get upgrade -y \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates \
curl \
jq \
tzdata \
&& test "$(TZ=Asia/Kolkata date +%z)" = "+0530" \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /build/rustfs /usr/bin/rustfs
COPY entrypoint.sh /entrypoint.sh
COPY --from=build /build/rc /usr/bin/rc
COPY entrypoint.sh /etc/rustfs/entrypoint.sh
COPY bootstrap/ /etc/rustfs/bootstrap/
RUN chmod +x /usr/bin/rustfs /entrypoint.sh
RUN chmod +x /usr/bin/rustfs /usr/bin/rc /etc/rustfs/entrypoint.sh && \
ln -s /etc/rustfs/entrypoint.sh /entrypoint.sh
RUN groupadd -g 10001 rustfs && \
useradd -u 10001 -g rustfs -m -s /sbin/nologin rustfs && \
@@ -131,6 +139,6 @@ VOLUME ["/data"]
USER rustfs
ENTRYPOINT ["/entrypoint.sh"]
ENTRYPOINT ["/etc/rustfs/entrypoint.sh"]
CMD ["rustfs"]
+19 -7
View File
@@ -28,17 +28,19 @@ ARG TARGETPLATFORM
ARG BUILDPLATFORM
ARG TARGETARCH
ARG RUSTFS_BUILD_FEATURES=""
ARG RC_RELEASE=latest
# -----------------------------
# Build stage
# -----------------------------
FROM rust:1.97.1-trixie AS builder
FROM rust:1.99.1-trixie AS builder
# Re-declare args after FROM
ARG TARGETPLATFORM
ARG BUILDPLATFORM
ARG TARGETARCH
ARG RUSTFS_BUILD_FEATURES
ARG RC_RELEASE
# Debug: print platforms
RUN echo "Build info -> BUILDPLATFORM=${BUILDPLATFORM}, TARGETPLATFORM=${TARGETPLATFORM}"
@@ -52,6 +54,7 @@ RUN set -eux; \
build-essential \
ca-certificates \
curl \
jq \
git \
pkg-config \
libssl-dev \
@@ -166,6 +169,8 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
;; \
esac
RUN sh /usr/src/rustfs/scripts/download_rc.sh "$TARGETARCH" musl "$RC_RELEASE" /usr/local/bin/rc
# -----------------------------
# Development stage (keeps toolchain)
# -----------------------------
@@ -199,10 +204,13 @@ ENV RUSTFS_VOLUMES="/data" \
# We rely on cargo run to build and run
EXPOSE 9000 9001
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
COPY entrypoint.sh /etc/rustfs/entrypoint.sh
COPY bootstrap/ /etc/rustfs/bootstrap/
RUN install -m 0755 /usr/local/bin/rc /usr/bin/rc && \
chmod +x /etc/rustfs/entrypoint.sh && \
ln -s /etc/rustfs/entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
ENTRYPOINT ["/etc/rustfs/entrypoint.sh"]
CMD ["cargo", "run", "--bin", "rustfs", "--"]
# -----------------------------
@@ -227,6 +235,7 @@ RUN set -eux; \
apt-get install -y --no-install-recommends \
ca-certificates \
curl \
jq \
tzdata \
coreutils; \
rm -rf /var/lib/apt/lists/*
@@ -248,8 +257,11 @@ RUN set -eux; \
# Copy the freshly built binary and the entrypoint
COPY --from=builder /usr/local/bin/rustfs /usr/bin/rustfs
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /usr/bin/rustfs /entrypoint.sh
COPY --from=builder /usr/local/bin/rc /usr/bin/rc
COPY entrypoint.sh /etc/rustfs/entrypoint.sh
COPY bootstrap/ /etc/rustfs/bootstrap/
RUN chmod +x /usr/bin/rustfs /usr/bin/rc /etc/rustfs/entrypoint.sh && \
ln -s /etc/rustfs/entrypoint.sh /entrypoint.sh
# Default environment (override in docker run/compose as needed)
ENV RUSTFS_VOLUMES="/data" \
@@ -263,5 +275,5 @@ EXPOSE 9000
VOLUME ["/data"]
# Keep root here; entrypoint will drop privileges using chroot --userspec
ENTRYPOINT ["/entrypoint.sh"]
ENTRYPOINT ["/etc/rustfs/entrypoint.sh"]
CMD ["/usr/bin/rustfs"]
+21
View File
@@ -0,0 +1,21 @@
# RustFS image bootstrap
The official image bundles this directory at `/etc/rustfs/bootstrap`. The default `buckets.json` is an empty JSON array, so the image starts RustFS directly and does not require a bootstrap endpoint or bootstrap credentials.
To create buckets, replace `buckets.json` in a derived image with a JSON array of bucket names. The entrypoint creates each bucket if it does not exist and applies `public-access-block.json`, whose default blocks all four public access paths. Edit that file in the derived image only when public access is an explicit requirement.
Optional per-bucket files use the bucket name as the filename without an extension:
| Directory | File format | Applied operation |
| --- | --- | --- |
| `policies/` | S3 bucket policy JSON | `rc bucket anonymous set-json` |
| `versioning/` | JSON boolean (`true` or `false`) | Enable or suspend bucket versioning |
| `lifecycle/` | `rc bucket lifecycle rule import` JSON | Replace lifecycle rules |
| `cors/` | JSON or XML accepted by `rc bucket cors set` | Replace CORS rules |
| `notifications/` | JSON array of `{ "arn": "...", "events": ["..."] }` | Add or replace event rules by ARN (prefix/suffix filters are not currently accepted) |
Every per-bucket file must correspond to a bucket listed in `buckets.json`. Applying these files is repeatable: bucket creation ignores existing buckets, while each configuration operation replaces or reconciles that bucket's requested state.
When `buckets.json` is non-empty, set `RUSTFS_BOOTSTRAP_ENDPOINT` to the RustFS S3 endpoint reachable from the container, for example `http://rustfs:9000` for a Compose service or the internal service DNS name in Kubernetes. The entrypoint waits on that endpoint's `/health/ready`; it never assumes `localhost`. `RUSTFS_BOOTSTRAP_TIMEOUT` controls the readiness wait in seconds (default `120`, maximum `3600`). `RUSTFS_BOOTSTRAP_DIR` can point to a different in-image configuration directory; it defaults to `/etc/rustfs/bootstrap`. Set `RUSTFS_ACCESS_KEY` and `RUSTFS_SECRET_KEY`, or their `_FILE` variants, for bootstrap authentication. The endpoint and credentials are read at runtime and are not baked into the image.
RustFS built-in IAM policies remain unchanged; the default bootstrap installs no custom bucket policies, lifecycle rules, versioning, CORS rules, or notification rules. The bundled RustFS CLI is resolved from a published release and its release checksum is verified. Set the Docker build argument `RC_RELEASE` to a version to pin it. Build the RustFS image after a CLI release containing the `bucket public-access-block` commands is available. For Kubernetes, the configured service must route traffic to the starting pod before its readiness probe succeeds (for example, with `publishNotReadyAddresses: true`); otherwise the service endpoint cannot reach the pod while the entrypoint waits for readiness.
+1
View File
@@ -0,0 +1 @@
[]
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+6
View File
@@ -0,0 +1,6 @@
{
"block_public_acls": true,
"ignore_public_acls": true,
"block_public_policy": true,
"restrict_public_buckets": true
}
+1
View File
@@ -0,0 +1 @@
+6 -1
View File
@@ -175,12 +175,17 @@ pub const ENV_HTTP_SOCKET_RECV_BUFFER_BYTES: &str = "RUSTFS_HTTP_SOCKET_RECV_BUF
pub const DEFAULT_HTTP_SOCKET_RECV_BUFFER_BYTES: usize = 0;
/// Environment variable for the S3 request-body inter-chunk read timeout
/// (seconds). Default: 300. Set to 0 to disable.
/// (seconds). Default: 300. Set to 0 to disable the inactivity timeout.
///
/// This bounds how long a `PutObject`/upload may wait for the *next* body chunk
/// while more bytes are still expected. It resets on every chunk, so it does not
/// penalize slow-but-progressing uploads — it only fires when a peer sends a
/// partial body and then goes silent *without* closing the connection (no EOF).
/// Independently of this knob, `PutObject` and `UploadPart` require at least
/// 64 KiB of body progress per 60 seconds of server wait (the minimum-progress
/// floor, logged as `upload_body_progress_stalled`); it bounds trickle uploads
/// that would otherwise pin upload admission indefinitely, and it stays in
/// force when this timeout is set to 0.
/// A reverse proxy or CDN that forwards a truncated body this way would
/// otherwise make RustFS wait forever for bytes that never arrive; with this
/// timeout the stalled read is aborted and logged with the received/expected
@@ -502,10 +502,163 @@ mod tests {
assert!(response.contains(if expected == reqwest::StatusCode::FORBIDDEN {
"<Code>AccessDenied</Code>"
} else {
"<Code>BucketAlreadyExists</Code>"
"<Code>BucketAlreadyOwnedByYou</Code>"
}));
}
}
Ok(())
}
/// A deployment owns a single bucket namespace, so an IAM user that recreates
/// an existing bucket is the owner, not another account. In the default region
/// every such request returns the legacy idempotent `200 OK` (AWS CreateBucket
/// semantics), including buckets created by root and by another IAM user.
#[tokio::test]
async fn test_create_bucket_duplicate_in_default_region_is_idempotent_for_iam_user()
-> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec!["--region", "us-east-1"]).await?;
let root = env.create_s3_client();
admin_create_user(&env, "duplicate-iam", "duplicate-iam-secret").await?;
admin_attach_user_policy_via(
AdminTransport::Signed,
&env.url,
&env.access_key,
&env.secret_key,
"readwrite",
"duplicate-iam",
)
.await?;
let iam = Client::from_conf(build_test_s3_config(
&env.url,
"duplicate-iam",
"duplicate-iam-secret",
None,
"duplicate-create",
));
root.create_bucket()
.bucket("duplicate-root-owned")
.send()
.await
.expect("root creates the first bucket");
iam.create_bucket()
.bucket("duplicate-root-owned")
.send()
.await
.expect("an IAM user recreating a root-owned bucket in us-east-1 gets 200 OK");
iam.create_bucket()
.bucket("duplicate-iam-owned")
.send()
.await
.expect("the IAM user creates its own bucket");
iam.create_bucket()
.bucket("duplicate-iam-owned")
.send()
.await
.expect("the same IAM user recreating its bucket gets 200 OK");
root.create_bucket()
.bucket("duplicate-iam-owned")
.send()
.await
.expect("root recreating an IAM-created bucket gets 200 OK");
Ok(())
}
/// Outside the default region the same ownership answer becomes
/// `409 BucketAlreadyOwnedByYou` for every authorized requester. Requests that
/// lack `s3:CreateBucket` still fail authorization before the duplicate check.
#[tokio::test]
async fn test_create_bucket_duplicate_outside_default_region_reports_owned_by_you() -> Result<(), Box<dyn Error + Send + Sync>>
{
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec!["--region", "eu-west-1"]).await?;
let region = "eu-west-1";
let root = Client::from_conf(
env.create_s3_client()
.config()
.to_builder()
.region(Region::new(region))
.build(),
);
admin_create_user(&env, "duplicate-eu-iam", "duplicate-eu-iam-secret").await?;
admin_attach_user_policy_via(
AdminTransport::Signed,
&env.url,
&env.access_key,
&env.secret_key,
"readwrite",
"duplicate-eu-iam",
)
.await?;
let iam = Client::from_conf(
build_test_s3_config(&env.url, "duplicate-eu-iam", "duplicate-eu-iam-secret", None, "duplicate-create")
.to_builder()
.region(Region::new(region))
.build(),
);
admin_create_user(&env, "duplicate-eu-readonly", "duplicate-eu-readonly-secret").await?;
admin_attach_user_policy_via(
AdminTransport::Signed,
&env.url,
&env.access_key,
&env.secret_key,
"readonly",
"duplicate-eu-readonly",
)
.await?;
let readonly = Client::from_conf(
build_test_s3_config(
&env.url,
"duplicate-eu-readonly",
"duplicate-eu-readonly-secret",
None,
"duplicate-create",
)
.to_builder()
.region(Region::new(region))
.build(),
);
for (client, bucket) in [(&root, "duplicate-eu-root"), (&iam, "duplicate-eu-iam")] {
client
.create_bucket()
.bucket(bucket)
.send()
.await
.unwrap_or_else(|error| panic!("{bucket} must be created: {error:?}"));
// Both the creator and the other authenticated identity own the namespace.
for creator in [&root, &iam] {
let error = creator
.create_bucket()
.bucket(bucket)
.send()
.await
.expect_err("a duplicate create outside us-east-1 must report ownership");
assert_eq!(
error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("BucketAlreadyOwnedByYou"),
"{bucket}"
);
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(409), "{bucket}");
}
}
let denied = readonly
.create_bucket()
.bucket("duplicate-eu-root")
.send()
.await
.expect_err("a requester without s3:CreateBucket must be denied before the duplicate check");
assert_eq!(denied.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
Ok(())
}
}
+3
View File
@@ -283,6 +283,9 @@ mod ssec_copy_test;
#[cfg(test)]
mod multipart_storage_class_test;
#[cfg(test)]
mod multipart_upload_id_test;
#[cfg(test)]
mod storage_class_capability_test;
@@ -0,0 +1,175 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Multipart upload ID regression tests.
//!
//! Every operation that addresses an existing multipart upload must report an
//! upload ID that does not name a live upload as `404 NoSuchUpload`, whether
//! the ID is malformed or is a well-formed ID of an aborted upload.
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::Client;
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
const BUCKET: &str = "multipart-upload-id";
const KEY: &str = "target.bin";
const COPY_SOURCE_KEY: &str = "copy-source.bin";
// RustFS upload IDs are URL-safe base64; '.' is outside that alphabet, so
// the object layer rejects this ID as malformed instead of looking it up.
const MALFORMED_UPLOAD_ID: &str = "malformed.upload.id";
const PART_NUMBER: i32 = 1;
const PART_BODY: &[u8] = b"multipart upload id regression part";
const PART_ETAG: &str = "\"00000000000000000000000000000000\"";
const NO_SUCH_UPLOAD_STATUS: u16 = 404;
const NO_SUCH_UPLOAD_CODE: &str = "NoSuchUpload";
const OBJECT_NOT_FOUND_STATUS: u16 = 404;
/// Describes how `result` differs from `404 NoSuchUpload`, or returns `None` when it matches.
fn no_such_upload_mismatch<T, E>(operation: &str, result: Result<T, SdkError<E>>) -> Option<String>
where
E: ProvideErrorMetadata + std::fmt::Debug,
{
let Err(error) = result else {
return Some(format!("{operation}: succeeded"));
};
let status = error.raw_response().map(|response| response.status().as_u16());
let code = error.as_service_error().and_then(ProvideErrorMetadata::code);
(status != Some(NO_SUCH_UPLOAD_STATUS) || code != Some(NO_SUCH_UPLOAD_CODE))
.then(|| format!("{operation}: status {status:?}, code {code:?}: {error:?}"))
}
async fn start_server_with_copy_source() -> Result<(RustFSTestEnvironment, Client), Box<dyn std::error::Error + Send + Sync>>
{
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(Vec::new()).await?;
env.create_test_bucket(BUCKET).await?;
let client = env.create_s3_client();
client
.put_object()
.bucket(BUCKET)
.key(COPY_SOURCE_KEY)
.body(ByteStream::from_static(PART_BODY))
.send()
.await?;
Ok((env, client))
}
/// Sends every operation that addresses an existing upload and asserts that all of them report `404 NoSuchUpload`.
async fn assert_every_upload_operation_reports_no_such_upload(client: &Client, upload_id: &str) {
let upload_part = client
.upload_part()
.bucket(BUCKET)
.key(KEY)
.upload_id(upload_id)
.part_number(PART_NUMBER)
.body(ByteStream::from_static(PART_BODY))
.send()
.await;
let upload_part_copy = client
.upload_part_copy()
.bucket(BUCKET)
.key(KEY)
.upload_id(upload_id)
.part_number(PART_NUMBER)
.copy_source(format!("/{BUCKET}/{COPY_SOURCE_KEY}"))
.send()
.await;
let list_parts = client.list_parts().bucket(BUCKET).key(KEY).upload_id(upload_id).send().await;
let complete = client
.complete_multipart_upload()
.bucket(BUCKET)
.key(KEY)
.upload_id(upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(CompletedPart::builder().part_number(PART_NUMBER).e_tag(PART_ETAG).build())
.build(),
)
.send()
.await;
let abort = client
.abort_multipart_upload()
.bucket(BUCKET)
.key(KEY)
.upload_id(upload_id)
.send()
.await;
let mismatches: Vec<String> = [
no_such_upload_mismatch("UploadPart", upload_part),
no_such_upload_mismatch("UploadPartCopy", upload_part_copy),
no_such_upload_mismatch("ListParts", list_parts),
no_such_upload_mismatch("CompleteMultipartUpload", complete),
no_such_upload_mismatch("AbortMultipartUpload", abort),
]
.into_iter()
.flatten()
.collect();
assert!(
mismatches.is_empty(),
"upload ID {upload_id:?}: operations not reporting {NO_SUCH_UPLOAD_STATUS} {NO_SUCH_UPLOAD_CODE}:\n{}",
mismatches.join("\n")
);
}
/// Asserts that the rejected operations left neither a multipart upload nor an object under `KEY`.
async fn assert_no_upload_or_object_published(client: &Client) -> TestResult {
let uploads = client.list_multipart_uploads().bucket(BUCKET).prefix(KEY).send().await?;
assert!(uploads.uploads().is_empty(), "rejected operations must not leave an upload: {uploads:?}");
let head = client.head_object().bucket(BUCKET).key(KEY).send().await;
let status = head
.as_ref()
.err()
.and_then(|error| error.raw_response())
.map(|response| response.status().as_u16());
assert_eq!(
status,
Some(OBJECT_NOT_FOUND_STATUS),
"rejected operations must not publish {KEY}: {head:?}"
);
Ok(())
}
#[tokio::test]
async fn malformed_upload_id_reports_no_such_upload_on_every_upload_operation() -> TestResult {
let (_env, client) = start_server_with_copy_source().await?;
assert_every_upload_operation_reports_no_such_upload(&client, MALFORMED_UPLOAD_ID).await;
assert_no_upload_or_object_published(&client).await
}
#[tokio::test]
async fn aborted_upload_id_reports_no_such_upload_on_every_upload_operation() -> TestResult {
let (_env, client) = start_server_with_copy_source().await?;
let created = client.create_multipart_upload().bucket(BUCKET).key(KEY).send().await?;
let upload_id = created.upload_id().ok_or("CreateMultipartUpload returned no upload ID")?;
client
.abort_multipart_upload()
.bucket(BUCKET)
.key(KEY)
.upload_id(upload_id)
.send()
.await?;
assert_every_upload_operation_reports_no_such_upload(&client, upload_id).await;
assert_no_upload_or_object_published(&client).await
}
}
+86 -26
View File
@@ -360,12 +360,26 @@ impl OpaMock {
pub(crate) async fn expect_bucket_tags(&mut self, action: &str, bucket: &str, department: Option<&str>) -> TestResult {
let payload = self.next_request().await?;
Self::assert_bucket_tags(&payload, action, bucket, department);
Ok(())
}
async fn expect_versioned_tag_permission(&mut self, bucket: &str, object: &str, version: &str) -> TestResult {
let payload = self.next_request().await?;
Self::assert_bucket_tags(&payload, "s3:GetObjectVersionTagging", bucket, Some("finance"));
assert_eq!(payload["input"]["resource"]["object"], object);
assert_eq!(payload["input"]["context"]["conditions"]["versionid"], serde_json::json!([version]));
Ok(())
}
fn assert_bucket_tags(payload: &Value, action: &str, bucket: &str, department: Option<&str>) {
let input = &payload["input"];
assert_eq!(input["identity"]["account"], "opabuckettags");
assert_eq!(input["identity"]["is_owner"], false);
assert_eq!(input["action"], action);
assert_eq!(input["resource"]["bucket"], bucket);
let expected = department.map(|value| serde_json::json!([value]));
assert_eq!(input["context"]["conditions"].get("ExistingBucketTag/department"), expected.as_ref());
Ok(())
}
async fn wait_for_validation(&mut self) -> TestResult {
@@ -720,7 +734,7 @@ async fn seed_legacy_duplicate_bucket_tags(env: &mut RustFSTestEnvironment, buck
async fn test_bucket_tags_opa_contract() -> TestResult {
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, Tag, Tagging, VersioningConfiguration,
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, Tag, Tagging, TaggingDirective, VersioningConfiguration,
};
init_logging();
@@ -772,7 +786,7 @@ async fn test_bucket_tags_opa_contract() -> TestResult {
.await?;
assert_eq!(read.body.collect().await?.into_bytes().as_ref(), b"report");
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
client
let head = client
.head_object()
.bucket(source)
.key("report")
@@ -780,6 +794,8 @@ async fn test_bucket_tags_opa_contract() -> TestResult {
.send()
.await?;
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
assert_eq!(head.tag_count(), Some(1));
opa.expect_versioned_tag_permission(source, "report", version).await?;
client.list_objects_v2().bucket(source).send().await?;
opa.expect_bucket_tags("s3:ListBucket", source, Some("finance")).await?;
client.list_object_versions().bucket(source).send().await?;
@@ -789,28 +805,65 @@ async fn test_bucket_tags_opa_contract() -> TestResult {
admin.create_bucket().bucket(destination).send().await?;
set_department(&admin, destination, "engineering").await?;
let copy_source = format!("{source}/report?versionId={version}");
let error = client
.copy_object()
.bucket(destination)
.key("copied")
.copy_source(&copy_source)
.send()
.await
.expect_err("source tags must not authorize a different destination");
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
opa.expect_bucket_tags("s3:PutObject", destination, Some("engineering"))
.await?;
for directive in [TaggingDirective::Copy, TaggingDirective::Replace] {
let error = client
.copy_object()
.bucket(destination)
.key("copied")
.copy_source(&copy_source)
.set_tagging_directive((directive == TaggingDirective::Replace).then_some(TaggingDirective::Replace))
.set_tagging((directive == TaggingDirective::Replace).then(|| "department=legal".to_owned()))
.send()
.await
.expect_err("source tags must not authorize a different destination");
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
if directive == TaggingDirective::Copy {
opa.expect_versioned_tag_permission(source, "report", version).await?;
}
opa.expect_bucket_tags("s3:PutObject", destination, Some("engineering"))
.await?;
let missing = admin
.head_object()
.bucket(destination)
.key("copied")
.send()
.await
.expect_err("denied COPY and REPLACE must leave no destination object");
assert_eq!(missing.raw_response().map(|response| response.status().as_u16()), Some(404));
}
set_department(&admin, destination, "finance").await?;
client
.copy_object()
.bucket(destination)
.key("copied")
.copy_source(&copy_source)
.send()
.await?;
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
opa.expect_bucket_tags("s3:PutObject", destination, Some("finance")).await?;
for directive in [TaggingDirective::Copy, TaggingDirective::Replace] {
client
.copy_object()
.bucket(destination)
.key("copied")
.copy_source(&copy_source)
.set_tagging_directive((directive == TaggingDirective::Replace).then_some(TaggingDirective::Replace))
.set_tagging((directive == TaggingDirective::Replace).then(|| "department=legal".to_owned()))
.send()
.await?;
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
// The access hook checks preliminary tags, then the copy use case checks
// the exact source reader's tags before publishing the destination.
for _ in 0..2 {
if directive == TaggingDirective::Copy {
opa.expect_versioned_tag_permission(source, "report", version).await?;
}
opa.expect_bucket_tags("s3:PutObject", destination, Some("finance")).await?;
}
let tags = admin.get_object_tagging().bucket(destination).key("copied").send().await?;
assert_eq!(tags.tag_set().len(), 1);
assert_eq!(tags.tag_set()[0].key(), "department");
assert_eq!(
tags.tag_set()[0].value(),
if directive == TaggingDirective::Copy {
"engineering"
} else {
"legal"
}
);
}
let upload = client
.create_multipart_upload()
@@ -886,6 +939,14 @@ async fn test_bucket_tags_opa_contract() -> TestResult {
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("engineering"))
.await?;
let missing = admin
.head_object()
.bucket(destination)
.key("denied-source")
.send()
.await
.expect_err("a denied source must leave no destination object");
assert_eq!(missing.raw_response().map(|response| response.status().as_u16()), Some(404));
set_department(&client, source, "finance")
.await
.expect_err("requested tags must not authorize their own mutation");
@@ -924,8 +985,6 @@ async fn test_bucket_tags_opa_contract() -> TestResult {
assert_eq!(read.body.collect().await?.into_bytes().as_ref(), b"report");
opa.expect_bucket_tags("s3:GetObject", destination, Some("engineering"))
.await?;
// Duplicate keys are refused on write; the ambiguous record below is seeded
// offline the way an upgraded deployment may still hold it on disk.
let invalid_write = admin
.put_bucket_tagging()
.bucket(destination)
@@ -938,6 +997,7 @@ async fn test_bucket_tags_opa_contract() -> TestResult {
.send()
.await
.expect_err("new duplicate tags must be rejected before publication");
assert_eq!(invalid_write.as_service_error().and_then(ProvideErrorMetadata::code), Some("InvalidTag"));
assert_eq!(invalid_write.raw_response().map(|response| response.status().as_u16()), Some(400));
set_department(&admin, source, "finance").await?;
+186 -24
View File
@@ -34,7 +34,7 @@ use tokio::sync::Mutex as TokioMutex;
use tokio::task::JoinSet;
use tokio::time::timeout;
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, warn};
use tracing::{Instrument, debug, error, warn};
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_METACACHE: &str = "metacache";
@@ -371,10 +371,15 @@ async fn list_path_raw_inner(
let producer_errs: Arc<[OnceLock<DiskError>]> = (0..opts.disks.len()).map(|_| OnceLock::new()).collect::<Vec<_>>().into();
let cancel_rx = CancellationToken::new();
let walk_options = Arc::new(walk_dir_options(&opts));
for (disk_idx, disk) in opts.disks.iter().enumerate() {
let opdisk = disk.clone();
let opts_clone = opts.clone();
let walk_options = walk_options.clone();
#[cfg(test)]
let test_reader_behavior = opts.test_reader_behaviors.get(disk_idx).cloned();
#[cfg(test)]
let test_disk_io_context_observed = opts.test_disk_io_context_observed.clone();
let fallback_claim_tracker = fallback_claim_tracker.clone();
let fds_clone = fds.clone();
#[cfg(test)]
@@ -384,17 +389,17 @@ async fn list_path_raw_inner(
let disk_io_context_clone = disk_io_context.clone();
let (rd, wr) = tokio::io::duplex(64 * 1024);
readers.push(MetacacheReader::new(rd));
jobs.spawn(with_optional_disk_io_context(disk_io_context_clone, async move {
let producer = with_optional_disk_io_context(disk_io_context_clone, async move {
let mut wr = PublishedBytesWriter::new(wr);
#[cfg(test)]
if let Some(observer) = &opts_clone.test_disk_io_context_observed {
if let Some(observer) = &test_disk_io_context_observed {
observer.store(
crate::disk::current_disk_io_context().is_some_and(|context| context.class == DiskIoClass::Background),
std::sync::atomic::Ordering::Release,
);
}
#[cfg(test)]
let test_primary_error = if let Some(behavior) = opts_clone.test_reader_behaviors.get(disk_idx).cloned() {
let test_primary_error = if let Some(behavior) = test_reader_behavior {
match behavior {
TestReaderBehavior::Eof => return Ok(()),
TestReaderBehavior::Entries(entries) => {
@@ -451,8 +456,6 @@ async fn list_path_raw_inner(
None
};
let wakl_opts = walk_dir_options(&opts_clone);
let mut need_fallback = false;
let mut last_err = None;
#[cfg(test)]
@@ -467,7 +470,7 @@ async fn list_path_raw_inner(
if !need_fallback && let Some(disk) = opdisk {
let primary_walk_started = std::time::Instant::now();
match disk.walk_dir(wakl_opts, &mut wr).await {
match disk.walk_dir((*walk_options).clone(), &mut wr).await {
Ok(_res) => {
rustfs_io_metrics::record_stage_duration(
"metacache_walk_dir_primary",
@@ -491,8 +494,8 @@ async fn list_path_raw_inner(
event = EVENT_METACACHE_LISTING,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_METACACHE,
bucket = %opts_clone.bucket,
path = %opts_clone.path,
bucket = %walk_options.bucket,
path = %walk_options.base_dir,
disk_index = disk_idx,
state = "walk_dir_missing_path",
error = ?err,
@@ -503,8 +506,8 @@ async fn list_path_raw_inner(
event = EVENT_METACACHE_LISTING,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_METACACHE,
bucket = %opts_clone.bucket,
path = %opts_clone.path,
bucket = %walk_options.bucket,
path = %walk_options.base_dir,
disk_index = disk_idx,
state = "walk_dir_failed",
error = ?err,
@@ -606,8 +609,8 @@ async fn list_path_raw_inner(
event = EVENT_METACACHE_LISTING,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_METACACHE,
bucket = %opts_clone.bucket,
path = %opts_clone.path,
bucket = %walk_options.bucket,
path = %walk_options.base_dir,
disk_index = disk_idx,
state = "fallback_disk_missing_for_path",
error = ?err,
@@ -618,8 +621,8 @@ async fn list_path_raw_inner(
event = EVENT_METACACHE_LISTING,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_METACACHE,
bucket = %opts_clone.bucket,
path = %opts_clone.path,
bucket = %walk_options.bucket,
path = %walk_options.base_dir,
disk_index = disk_idx,
state = "fallback_disk_missing",
error = ?err,
@@ -634,7 +637,7 @@ async fn list_path_raw_inner(
}
let fallback_walk_started = std::time::Instant::now();
match disk.as_ref().walk_dir(walk_dir_options(&opts_clone), &mut wr).await {
match disk.as_ref().walk_dir((*walk_options).clone(), &mut wr).await {
Ok(_r) => {
rustfs_io_metrics::record_stage_duration(
"metacache_walk_dir_fallback",
@@ -660,8 +663,8 @@ async fn list_path_raw_inner(
event = EVENT_METACACHE_LISTING,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_METACACHE,
bucket = %opts_clone.bucket,
path = %opts_clone.path,
bucket = %walk_options.bucket,
path = %walk_options.base_dir,
disk_index = disk_idx,
state = "fallback_walk_dir_missing_path",
error = ?err,
@@ -672,8 +675,8 @@ async fn list_path_raw_inner(
event = EVENT_METACACHE_LISTING,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_METACACHE,
bucket = %opts_clone.bucket,
path = %opts_clone.path,
bucket = %walk_options.bucket,
path = %walk_options.base_dir,
disk_index = disk_idx,
state = "fallback_walk_dir_failed",
error = ?err,
@@ -695,12 +698,13 @@ async fn list_path_raw_inner(
// warn!("list_path_raw: while need_fallback done");
Ok(())
}));
});
jobs.spawn(producer.in_current_span());
}
let revjob_rx = rx.clone();
let mut revjobs = JoinSet::new();
revjobs.spawn(async move {
let merge = async move {
// Consumer-side peek timeout, and a caveat worth understanding
// (rustfs/backlog#1217).
//
@@ -1059,7 +1063,8 @@ async fn list_path_raw_inner(
}
}
Ok(ListPathRawOutcome { limit_reached, scan_cut })
});
};
revjobs.spawn(merge.in_current_span());
let merge_started = std::time::Instant::now();
let Some(revjob) = revjobs.join_next().await else {
@@ -1291,6 +1296,163 @@ mod tests {
);
}
#[tokio::test]
async fn list_path_raw_fanout_retains_only_linear_disk_references() {
let root = tempfile::tempdir().expect("create disk root");
let endpoint = crate::disk::endpoint::Endpoint::try_from(root.path().to_str().expect("UTF-8 disk path"))
.expect("parse disk endpoint");
let local = Arc::new(
crate::disk::local::LocalDisk::new(&endpoint, false)
.await
.expect("open local disk"),
);
let wrapper = crate::disk::disk_store::LocalDiskWrapper::new(local, false);
let disk = Arc::new(crate::disk::Disk::Local(Box::new(wrapper)));
let baseline = Arc::strong_count(&disk);
for primary_count in [1, 4, 16] {
for fallback_count in [0, 2] {
let entered: Vec<_> = (0..primary_count).map(|_| Arc::new(tokio::sync::Notify::new())).collect();
let stopped: Vec<_> = (0..primary_count).map(|_| Arc::new(tokio::sync::Notify::new())).collect();
let behaviors = entered
.iter()
.zip(&stopped)
.map(|(entered, stopped)| TestReaderBehavior::StallWithSignals {
entered: entered.clone(),
stopped: stopped.clone(),
})
.collect();
let cancel = CancellationToken::new();
let listing = tokio::spawn(list_path_raw(
cancel.clone(),
ListPathRawOptions {
disks: vec![Some(disk.clone()); primary_count],
fallback_disks: vec![Some(disk.clone()); fallback_count],
min_disks: primary_count,
test_reader_behaviors: behaviors,
peek_timeout: Some(Duration::from_secs(60)),
..Default::default()
},
));
for signal in &entered {
timeout(Duration::from_secs(2), signal.notified())
.await
.expect("every producer must enter the real fanout before counting references");
}
// The merge owns the configured primary/fallback slots, each producer
// owns its primary, and the shared fallback queue owns each fallback.
let expected = baseline + 2 * (primary_count + fallback_count);
let retained = Arc::strong_count(&disk);
cancel.cancel();
timeout(Duration::from_secs(2), listing)
.await
.expect("cancellation must stop the listing")
.expect("listing task must join")
.expect("external cancellation must finish cleanly");
for signal in &stopped {
timeout(Duration::from_secs(2), signal.notified())
.await
.expect("cancellation must drop every producer");
}
assert_eq!(Arc::strong_count(&disk), baseline, "cancellation must release every fanout reference");
assert_eq!(
retained, expected,
"fanout must retain linear references for {primary_count} primary and {fallback_count} fallback slots"
);
}
}
}
#[tokio::test(flavor = "current_thread")]
async fn list_path_raw_producer_and_merge_preserve_request_span() {
use tracing_subscriber::{Layer, layer::SubscriberExt};
struct ProducerSpan(Arc<AtomicBool>);
#[derive(Default)]
struct StateField(bool);
impl tracing::field::Visit for StateField {
fn record_debug(&mut self, _: &tracing::field::Field, _: &dyn std::fmt::Debug) {}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.0 |= field.name() == "state" && value == "walk_dir_missing_path";
}
}
impl<S> Layer<S> for ProducerSpan
where
S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
{
fn on_event(&self, event: &tracing::Event<'_>, context: tracing_subscriber::layer::Context<'_, S>) {
let mut state = StateField::default();
event.record(&mut state);
if state.0
&& context
.lookup_current()
.is_some_and(|span| span.name() == "flink_list_request")
{
self.0.store(true, std::sync::atomic::Ordering::Release);
}
}
}
// The subscriber is thread-local; keep the spawned tasks on this runtime thread.
let producer_observed = Arc::new(AtomicBool::new(false));
let subscriber = tracing_subscriber::registry().with(ProducerSpan(producer_observed.clone()));
let _subscriber = tracing::subscriber::set_default(subscriber);
let request = tracing::info_span!("flink_list_request", request_id = "list-span-regression");
let root = tempfile::tempdir().expect("create disk root");
let endpoint = crate::disk::endpoint::Endpoint::try_from(root.path().to_str().expect("UTF-8 disk path"))
.expect("parse disk endpoint");
let local = Arc::new(
crate::disk::local::LocalDisk::new(&endpoint, false)
.await
.expect("open local disk"),
);
let wrapper = crate::disk::disk_store::LocalDiskWrapper::new(local, false);
let disk = Arc::new(crate::disk::Disk::Local(Box::new(wrapper)));
let error = list_path_raw(
CancellationToken::new(),
ListPathRawOptions {
disks: vec![Some(disk)],
bucket: "missing-bucket".to_owned(),
min_disks: 1,
..Default::default()
},
)
.instrument(request.clone())
.await
.expect_err("missing bucket must fail the real disk producer");
assert_eq!(error, DiskError::VolumeNotFound);
assert!(
producer_observed.load(std::sync::atomic::Ordering::Acquire),
"disk error must retain the request span"
);
let merge_observed = Arc::new(AtomicBool::new(false));
let merge_sink = merge_observed.clone();
let request_id = request.id();
list_path_raw(
CancellationToken::new(),
ListPathRawOptions {
disks: vec![None],
min_disks: 1,
test_reader_behaviors: vec![TestReaderBehavior::Entries(vec![fallback_test_entry()])],
agreed: Some(Box::new(move |_| {
merge_sink.store(tracing::Span::current().id() == request_id, std::sync::atomic::Ordering::Release);
Box::pin(async { Ok(()) })
})),
..Default::default()
},
)
.instrument(request)
.await
.expect("merge should deliver the test entry");
assert!(
merge_observed.load(std::sync::atomic::Ordering::Acquire),
"merge callback must retain the request span"
);
}
#[tokio::test]
async fn scanner_joinset_walk_uses_the_local_disk_background_gate() {
let root = tempfile::tempdir().expect("local disk root should be created");
+14 -7
View File
@@ -4356,7 +4356,8 @@ impl DiskAPI for RemoteDisk {
object: &str,
max_entries: u32,
) -> Result<crate::disk::BoundedDeleteRecoveryDiscovery> {
// Older peers reject this variant. Do not retry through the unbounded protocol.
// Older peers reject this variant. Do not retry through the unbounded protocol;
// they are reported as unsupported participants (rolling-upgrade contract).
match self
.recover_delete_transaction(
volume,
@@ -4374,7 +4375,9 @@ impl DiskAPI for RemoteDisk {
|| matches!(&error, Error::Io(source) if crate::cluster::rpc::client::embedded_tonic_status(source)
.is_some_and(|status| matches!(status.code(), tonic::Code::Unimplemented | tonic::Code::InvalidArgument))) =>
{
Err(Error::DeleteRecoveryWorkLimit)
// No paginated fallback through the unbounded protocol; the peer
// is an unsupported participant until it is upgraded.
Err(Error::UnsupportedDisk)
}
Err(error) => Err(error),
}
@@ -4431,7 +4434,9 @@ impl DiskAPI for RemoteDisk {
|| matches!(&error, Error::Io(source) if crate::cluster::rpc::client::embedded_tonic_status(source)
.is_some_and(|status| matches!(status.code(), tonic::Code::Unimplemented | tonic::Code::InvalidArgument))) =>
{
Err(Error::DeleteRecoveryWorkLimit)
// No paginated fallback through the unbounded protocol; the peer
// is an unsupported participant until it is upgraded.
Err(Error::UnsupportedDisk)
}
Err(error) => Err(error),
}
@@ -4461,7 +4466,9 @@ impl DiskAPI for RemoteDisk {
|| matches!(&error, Error::Io(source) if crate::cluster::rpc::client::embedded_tonic_status(source)
.is_some_and(|status| matches!(status.code(), tonic::Code::Unimplemented | tonic::Code::InvalidArgument))) =>
{
Err(Error::DeleteRecoveryWorkLimit)
// No paginated fallback through the unbounded protocol; the peer
// is an unsupported participant until it is upgraded.
Err(Error::UnsupportedDisk)
}
Err(error) => Err(error),
}
@@ -5059,7 +5066,7 @@ mod tests {
.discover_delete_transactions_bounded("bucket", "prefix", 4096)
.await
.expect_err("old peer cannot prove a bounded inventory"),
DiskError::DeleteRecoveryWorkLimit
DiskError::UnsupportedDisk
);
let transaction = Uuid::new_v4();
assert_eq!(
@@ -5067,7 +5074,7 @@ mod tests {
.inspect_delete_transaction_bounded("bucket", "prefix", transaction, 65536)
.await
.expect_err("old peer cannot inspect with a budget"),
DiskError::DeleteRecoveryWorkLimit
DiskError::UnsupportedDisk
);
assert_eq!(
remote
@@ -5084,7 +5091,7 @@ mod tests {
)
.await
.expect_err("old peer cannot settle with a budget"),
DiskError::DeleteRecoveryWorkLimit
DiskError::UnsupportedDisk
);
assert_eq!(
*peer.peer.unsupported_methods.lock().expect("observed methods"),
+527 -56
View File
@@ -18,6 +18,7 @@ use crate::cluster::rpc::client::{
use crate::cluster::rpc::set_tonic_rolling_mutation_body_digest;
use async_trait::async_trait;
use bytes::Bytes;
use futures::{FutureExt, future::BoxFuture};
use rustfs_lock::{
LockClient, LockError, LockInfo, LockRequest, LockResponse, LockStats, LockStatus, LockType, Result,
types::{LockId, LockMetadata, LockPriority},
@@ -32,16 +33,17 @@ use rustfs_protos::{
};
use std::collections::HashMap;
use std::future::Future;
use std::panic::AssertUnwindSafe;
use std::pin::Pin;
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use tokio::task::JoinHandle;
use tokio::time::{Instant, timeout};
use tonic::service::interceptor::InterceptedService;
use tonic::{Request, Response};
use tracing::{debug, info, warn};
const EVENT_NAMESPACE_LOCK_RELEASE: &str = "namespace_lock_release";
const EVENT_REMOTE_LOCK_RPC_CLEANUP: &str = "remote_lock_rpc_cleanup";
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_REMOTE_LOCK: &str = "remote_lock";
@@ -49,18 +51,38 @@ fn attach_lock_mutation_body_digest<T: rustfs_protos::CanonicalMutationBody>(req
set_tonic_rolling_mutation_body_digest(request)
}
/// Work to run if an RPC that already timed out for its caller completes later.
/// Work to run if an RPC completes after its caller stops waiting.
type LateCompletion<T> = Option<Box<dyn FnOnce(T) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send>>;
type LateFailure = LateCompletion<()>;
type RpcFuture<T> = BoxFuture<'static, std::thread::Result<std::result::Result<T, tonic::Status>>>;
struct RpcCleanup<T> {
late: LateCompletion<T>,
late_failure: LateFailure,
/// Own admission before connecting as well as while an RPC is in flight.
struct LockRequestGuard<'a> {
client: &'a RemoteClient,
op: &'static str,
admission: Option<LockRequestAdmission>,
}
impl<T> RpcCleanup<T> {
fn new(late: LateCompletion<T>, late_failure: LateFailure, admission: Option<LockRequestAdmission>) -> Self {
impl LockRequestGuard<'_> {
fn into_admission(mut self) -> Option<LockRequestAdmission> {
self.admission.take()
}
}
impl Drop for LockRequestGuard<'_> {
fn drop(&mut self) {
self.client.release_lock_request(self.op, self.admission.take());
}
}
struct RpcCleanup<'a, T> {
late: LateCompletion<T>,
late_failure: LateFailure,
admission: Option<LockRequestGuard<'a>>,
}
impl<'a, T> RpcCleanup<'a, T> {
fn new(late: LateCompletion<T>, late_failure: LateFailure, admission: Option<LockRequestGuard<'a>>) -> Self {
Self {
late,
late_failure,
@@ -69,6 +91,25 @@ impl<T> RpcCleanup<T> {
}
}
/// A dropped caller hands its RPC and cleanup to the same bounded owner as a timeout.
struct InFlightRpc<'a, T: Send + 'static> {
client: &'a RemoteClient,
op: &'static str,
resource_summary: &'a str,
future: Option<RpcFuture<T>>,
cleanup: Option<RpcCleanup<'a, T>>,
}
impl<T: Send + 'static> Drop for InFlightRpc<'_, T> {
fn drop(&mut self) {
if let Some(future) = self.future.take()
&& let Some(cleanup) = self.cleanup.take()
{
self.client.detach_rpc(self.op, self.resource_summary, future, cleanup);
}
}
}
/// The liveness window is this many RPC deadlines: a peer that completed a
/// lock RPC within it is slow, not gone, and keeps its channel on a timeout.
const LOCK_RPC_LIVENESS_WINDOW_DEADLINES: u32 = 2;
@@ -137,6 +178,9 @@ fn with_lock_peer_health<R>(addr: &str, update: impl FnOnce(&mut LockPeerChannel
let mut peers = lock_peer_channel_health()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(health) = peers.get_mut(addr) {
return update(health);
}
update(peers.entry(addr.to_string()).or_default())
}
@@ -158,6 +202,15 @@ fn reset_lock_peer_health_for_test(addr: &str) {
.remove(addr);
}
#[cfg(test)]
type EvictionPause = (tokio::sync::oneshot::Sender<()>, tokio::sync::oneshot::Receiver<()>);
#[cfg(test)]
fn eviction_pauses_for_test() -> &'static Mutex<HashMap<String, EvictionPause>> {
static PAUSES: OnceLock<Mutex<HashMap<String, EvictionPause>>> = OnceLock::new();
PAUSES.get_or_init(Mutex::default)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EvictionTrigger {
/// The caller's deadline expired while the stream was still open.
@@ -506,6 +559,14 @@ impl RemoteClient {
admission
}
fn admit_lock_request_guard(&self, op: &'static str) -> Option<LockRequestGuard<'_>> {
self.admit_lock_request(op).map(|admission| LockRequestGuard {
client: self,
op,
admission: Some(admission),
})
}
fn release_lock_request(&self, op: &'static str, admission: Option<LockRequestAdmission>) {
if is_request_breaker_operation(op) && admission.is_some() {
with_lock_peer_health(&self.addr, |health| {
@@ -587,6 +648,17 @@ impl RemoteClient {
trigger: EvictionTrigger,
deadline: Duration,
) {
#[cfg(test)]
let pause = eviction_pauses_for_test()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&self.addr);
#[cfg(test)]
if let Some((entered, resume)) = pause {
let _ = entered.send(());
let _ = resume.await;
}
let now = Instant::now();
let cooldown = Self::eviction_cooldown();
let liveness_window = Self::liveness_window(deadline);
@@ -617,22 +689,26 @@ impl RemoteClient {
);
}
/// Keep a timed-out RPC running instead of cancelling its stream.
/// Keep an abandoned RPC running instead of cancelling its stream.
///
/// Dropping the future sends `RST_STREAM`; under load those resets pile up
/// in the server's pending-accept queue until it answers `GOAWAY
/// too_many_resets` and kills every stream on the connection. A detached
/// stream ends on its own within the internode RPC timeout, the number per
/// peer is bounded, and a lock granted after its caller gave up is released.
fn detach_timed_out_rpc<T: Send + 'static>(
fn detach_rpc<T: Send + 'static>(
&self,
op: &'static str,
resource_summary: &str,
handle: JoinHandle<std::result::Result<T, tonic::Status>>,
late: LateCompletion<T>,
late_failure: LateFailure,
admission: Option<LockRequestAdmission>,
future: RpcFuture<T>,
cleanup: RpcCleanup<'_, T>,
) {
let RpcCleanup {
late,
late_failure,
admission,
} = cleanup;
let admission = admission.and_then(LockRequestGuard::into_admission);
let limit = Self::detached_rpc_limit();
let admitted = with_lock_peer_health(&self.addr, |health| {
if health.detached_rpcs >= limit {
@@ -643,25 +719,29 @@ impl RemoteClient {
}
});
if !admitted {
handle.abort();
drop(future);
self.release_lock_request(op, admission);
if let Some(late_failure) = late_failure {
drop(tokio::spawn(late_failure(())));
}
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_detached(op, "aborted");
debug!(
event = EVENT_REMOTE_LOCK_RPC_CLEANUP,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_LOCK,
state = "detached_budget_exhausted",
addr = %self.addr,
op,
resource_summary,
limit,
"Cancelled timed-out remote lock RPC because the detached stream budget is exhausted"
"Abandoned remote lock RPC cancelled"
);
return;
}
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_detached(op, "detached");
let addr = self.addr.clone();
tokio::spawn(async move {
let outcome = handle.await;
let outcome = future.await;
with_lock_peer_health(&addr, |health| {
health.detached_rpcs = health.detached_rpcs.saturating_sub(1);
if is_request_breaker_operation(op) && admission.is_some() {
@@ -688,19 +768,31 @@ impl RemoteClient {
}
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "error");
debug!(
event = EVENT_REMOTE_LOCK_RPC_CLEANUP,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_LOCK,
state = "late_rpc_failed",
addr = %addr,
op,
tonic_code = ?status.code(),
tonic_message = status.message(),
"Detached remote lock RPC failed after its caller timed out"
"Abandoned remote lock RPC failed"
);
}
Err(join_error) => {
Err(_) => {
if let Some(late_failure) = late_failure {
late_failure(()).await;
}
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "join_error");
debug!(addr = %addr, op, error = %join_error, "Detached remote lock RPC task ended abnormally");
debug!(
event = EVENT_REMOTE_LOCK_RPC_CLEANUP,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_LOCK,
state = "late_rpc_panicked",
addr = %addr,
op,
"Abandoned remote lock RPC panicked"
);
}
}
});
@@ -788,16 +880,28 @@ impl RemoteClient {
self.release_late_release();
rustfs_io_metrics::lock_metrics::record_remote_lock_late_release(outcome);
if outcome == "released" {
debug!(addr = %self.addr, count = original_count, "Released remote locks granted after their caller timed out");
debug!(
event = EVENT_REMOTE_LOCK_RPC_CLEANUP,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_LOCK,
state = "late_grants_released",
addr = %self.addr,
count = original_count,
"Released remote locks granted after their caller stopped waiting"
);
} else {
let (log_warning, suppressed_timeouts) = self.timeout_log_decision("late_release", Self::rpc_timeout());
if log_warning {
warn!(
event = EVENT_REMOTE_LOCK_RPC_CLEANUP,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_LOCK,
state = "late_grants_unreleased",
addr = %self.addr,
count = original_count,
outcome,
suppressed_timeouts,
"Could not release every remote lock granted after its caller timed out; the server lease will expire it"
"Could not release every remote lock granted after its caller stopped waiting; the server lease will expire it"
);
} else {
debug!(addr = %self.addr, count = original_count, outcome, suppressed_timeouts, "Suppressed repeated late remote lock release warning");
@@ -811,25 +915,47 @@ impl RemoteClient {
resource_summary: &str,
deadline: Duration,
future: Fut,
cleanup: RpcCleanup<T>,
cleanup: RpcCleanup<'_, T>,
) -> std::result::Result<T, LockError>
where
Fut: Future<Output = std::result::Result<T, tonic::Status>> + Send + 'static,
T: Send + 'static,
{
let RpcCleanup {
late,
let mut rpc = InFlightRpc {
client: self,
op,
resource_summary,
future: Some(AssertUnwindSafe(future).catch_unwind().boxed()),
cleanup: Some(cleanup),
};
let outcome = match rpc.future.as_mut() {
Some(future) => tokio::select! {
// A response becoming ready on the deadline tick must not turn an
// expired caller budget into a transport error and channel eviction.
biased;
_ = tokio::time::sleep(deadline) => None,
outcome = future => Some(outcome),
},
None => return Err(LockError::internal("remote lock RPC has no in-flight future")),
};
if outcome.is_some() {
rpc.future.take();
}
let Some(RpcCleanup {
late: _,
late_failure,
admission,
} = cleanup;
let mut handle = tokio::spawn(future);
match timeout(deadline, &mut handle).await {
Ok(Ok(Ok(response))) => {
self.record_rpc_success(op, admission);
self.release_lock_request(op, admission);
}) = rpc.cleanup.as_mut()
else {
return Err(LockError::internal("remote lock RPC has no cleanup owner"));
};
let token = admission.as_ref().and_then(|guard| guard.admission);
let result = match outcome {
Some(Ok(Ok(response))) => {
self.record_rpc_success(op, token);
Ok(response)
}
Ok(Ok(Err(err))) => {
Some(Ok(Err(err))) => {
let reason = err.to_string();
if err.code() == tonic::Code::DeadlineExceeded {
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_timeout(&self.addr, op);
@@ -862,12 +988,11 @@ impl RemoteClient {
"Suppressed repeated remote lock RPC deadline"
);
}
self.maybe_evict_connection(op, &reason, resource_summary, EvictionTrigger::Timeout, deadline)
.await;
self.release_lock_request(op, admission);
if let Some(late_failure) = late_failure {
if let Some(late_failure) = late_failure.take() {
drop(tokio::spawn(late_failure(())));
}
self.maybe_evict_connection(op, &reason, resource_summary, EvictionTrigger::Timeout, deadline)
.await;
return Err(LockError::timeout(format!("remote lock RPC {op} on {}", self.addr), deadline));
}
// Only evict (and re-dial) the cached channel when the failure is a genuine
@@ -928,30 +1053,32 @@ impl RemoteClient {
);
}
if transport_failure {
// Ambiguous-grant cleanup owns its work before channel eviction can suspend.
if let Some(late_failure) = late_failure.take() {
drop(tokio::spawn(late_failure(())));
}
self.maybe_evict_connection(op, &reason, resource_summary, EvictionTrigger::Transport, deadline)
.await;
}
self.release_lock_request(op, admission);
if transport_failure && let Some(late_failure) = late_failure {
drop(tokio::spawn(late_failure(())));
}
Err(LockError::internal(format!("{op} RPC failed: {reason}")))
}
Ok(Err(join_error)) => {
Some(Err(_)) => {
warn!(
event = EVENT_REMOTE_LOCK_RPC_CLEANUP,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_LOCK,
state = "rpc_panicked",
addr = %self.addr,
op,
resource_summary,
error = %join_error,
"Remote lock RPC task ended abnormally"
"Remote lock RPC panicked"
);
self.release_lock_request(op, admission);
if let Some(late_failure) = late_failure {
if let Some(late_failure) = late_failure.take() {
drop(tokio::spawn(late_failure(())));
}
Err(LockError::internal(format!("{op} RPC task failed: {join_error}")))
Err(LockError::internal(format!("{op} RPC future panicked")))
}
Err(_) => {
None => {
let reason = format!("RPC timed out after {deadline:?}");
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_timeout(&self.addr, op);
self.record_request_timeout(op, deadline);
@@ -983,12 +1110,17 @@ impl RemoteClient {
"Suppressed repeated remote lock RPC timeout"
);
}
// Continue draining the abandoned stream while channel eviction waits.
drop(rpc);
self.maybe_evict_connection(op, &reason, resource_summary, EvictionTrigger::Timeout, deadline)
.await;
self.detach_timed_out_rpc(op, resource_summary, handle, late, late_failure, admission);
Err(LockError::timeout(format!("remote lock RPC {op} on {}", self.addr), deadline))
return Err(LockError::timeout(format!("remote lock RPC {op} on {}", self.addr), deadline));
}
};
if rpc.future.is_none() {
rpc.cleanup.take();
}
result
}
fn rpc_timeout_failure_response(request: &LockRequest, err: &LockError) -> LockResponse {
@@ -1079,7 +1211,7 @@ impl RemoteClient {
#[async_trait]
impl LockClient for RemoteClient {
async fn acquire_lock(&self, request: &LockRequest) -> Result<LockResponse> {
let admission = match self.admit_lock_request("lock") {
let admission = match self.admit_lock_request_guard("lock") {
Some(admission) => admission,
None => return Ok(Self::rpc_backoff_failure_response(request)),
};
@@ -1090,7 +1222,6 @@ impl LockClient for RemoteClient {
Ok(client) => client,
Err(err) => {
self.record_request_timeout("lock", rpc_timeout);
self.release_lock_request("lock", Some(admission));
return Err(err);
}
};
@@ -1098,14 +1229,12 @@ impl LockClient for RemoteClient {
let args = match serde_json::to_string(&bounded_request) {
Ok(args) => args,
Err(err) => {
self.release_lock_request("lock", Some(admission));
return Err(LockError::internal(format!("Failed to serialize request: {err}")));
}
};
let mut req = Request::new(GenerallyLockRequest { args });
req.set_timeout(rpc_timeout);
if let Err(err) = attach_lock_mutation_body_digest(&mut req) {
self.release_lock_request("lock", Some(admission));
return Err(err.into());
}
let late = self.late_release_hook(request.lock_id.clone());
@@ -1147,7 +1276,7 @@ impl LockClient for RemoteClient {
return Ok(Vec::new());
}
let admission = match self.admit_lock_request("lock_batch") {
let admission = match self.admit_lock_request_guard("lock_batch") {
Some(admission) => admission,
None => return Ok(Self::rpc_backoff_failure_batch(requests)),
};
@@ -1161,7 +1290,6 @@ impl LockClient for RemoteClient {
Ok(client) => client,
Err(err) => {
self.record_request_timeout("lock_batch", rpc_timeout);
self.release_lock_request("lock_batch", Some(admission));
return Err(err);
}
};
@@ -1175,14 +1303,12 @@ impl LockClient for RemoteClient {
{
Ok(args) => args,
Err(err) => {
self.release_lock_request("lock_batch", Some(admission));
return Err(err);
}
};
let mut req = Request::new(BatchGenerallyLockRequest { args });
req.set_timeout(rpc_timeout);
if let Err(err) = attach_lock_mutation_body_digest(&mut req) {
self.release_lock_request("lock_batch", Some(admission));
return Err(err.into());
}
let late = self.late_release_batch_hook(requests.iter().map(|request| request.lock_id.clone()).collect());
@@ -1710,6 +1836,351 @@ mod tests {
assert!(acquired_lock_ids(&lock_ids, &[]).is_empty());
}
#[tokio::test]
async fn test_remote_client_cancelled_connection_releases_request_admission() {
ensure_test_rpc_secret();
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind pending-handshake peer");
let addr = format!("http://{}", listener.local_addr().expect("pending-handshake peer address"));
let client = RemoteClient::new(addr.clone());
let request = test_lock_request(Duration::from_secs(30));
for batch in [false, true] {
reset_lock_peer_health_for_test(&addr);
with_lock_peer_health(&addr, |health| {
health.request_backoff_until = Some(Instant::now() - Duration::from_secs(1));
});
let mut acquisition = Box::pin(async {
if batch {
client.acquire_locks_batch(std::slice::from_ref(&request)).await.map(|_| ())
} else {
client.acquire_lock(&request).await.map(|_| ())
}
});
std::future::poll_fn(|cx| {
assert!(acquisition.as_mut().poll(cx).is_pending(), "peer must wait for its HTTP/2 handshake");
std::task::Poll::Ready(())
})
.await;
let pending = lock_peer_health_for_test(&addr);
assert_eq!(pending.request_in_flight, 1, "pending connection owns one request admission");
assert!(pending.request_probe_generation.is_some(), "the request is the half-open probe");
drop(acquisition);
let cancelled = lock_peer_health_for_test(&addr);
assert_eq!(cancelled.request_in_flight, 0, "cancellation must return request admission");
assert!(cancelled.request_probe_generation.is_none(), "cancellation must unblock the next probe");
let probe = client.admit_lock_request("lock").expect("cancelled peer can be probed again");
client.release_lock_request("lock", Some(probe));
}
reset_lock_peer_health_for_test(&addr);
}
#[tokio::test]
async fn test_remote_client_cancelled_rpc_runs_late_grant_cleanup() {
let addr = "http://cancelled-rpc-late-grant";
reset_lock_peer_health_for_test(addr);
let client = RemoteClient::new(addr.to_string());
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (grant_tx, grant_rx) = tokio::sync::oneshot::channel();
let (released_tx, released_rx) = tokio::sync::oneshot::channel();
let caller = tokio::spawn(async move {
let late: LateCompletion<bool> = Some(Box::new(move |granted| {
Box::pin(async move {
released_tx.send(granted).expect("late grant observer remains alive");
})
}));
client
.execute_rpc(
"lock",
"bucket/object",
Duration::from_secs(30),
async move {
started_tx.send(()).expect("caller observes RPC dispatch");
Ok(grant_rx.await.expect("test decides when peer grants lock"))
},
RpcCleanup::new(late, None, None),
)
.await
});
timeout(Duration::from_secs(1), started_rx)
.await
.expect("RPC must dispatch")
.expect("dispatch signal");
caller.abort();
assert!(caller.await.expect_err("caller was cancelled").is_cancelled());
assert_eq!(
lock_peer_health_for_test(addr).detached_rpcs,
1,
"cancelled RPC uses bounded detached ownership"
);
grant_tx.send(true).expect("cancellation must preserve the running RPC");
assert!(
timeout(Duration::from_secs(1), released_rx)
.await
.expect("late grant must be cleaned up")
.expect("cleanup result")
);
assert_eq!(lock_peer_health_for_test(addr).detached_rpcs, 0, "completed cleanup returns its slot");
reset_lock_peer_health_for_test(addr);
}
#[tokio::test]
async fn test_remote_client_cancelled_rpc_runs_ambiguous_failure_cleanup() {
let addr = "http://cancelled-rpc-late-failure";
reset_lock_peer_health_for_test(addr);
let client = RemoteClient::new(addr.to_string());
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (failure_tx, failure_rx) = tokio::sync::oneshot::channel();
let (released_tx, released_rx) = tokio::sync::oneshot::channel();
let caller = tokio::spawn(async move {
let late_failure: LateFailure = Some(Box::new(move |()| {
Box::pin(async move {
released_tx.send(()).expect("ambiguous cleanup observer remains alive");
})
}));
client
.execute_rpc(
"lock_batch",
"bucket/object",
Duration::from_secs(30),
async move {
started_tx.send(()).expect("caller observes RPC dispatch");
failure_rx.await.expect("test decides when transport fails");
Err::<(), _>(tonic::Status::cancelled("transport failure after caller cancellation"))
},
RpcCleanup::new(None, late_failure, None),
)
.await
});
timeout(Duration::from_secs(1), started_rx)
.await
.expect("RPC must dispatch")
.expect("dispatch signal");
caller.abort();
assert!(caller.await.expect_err("caller was cancelled").is_cancelled());
failure_tx.send(()).expect("cancellation must preserve the running RPC");
timeout(Duration::from_secs(1), released_rx)
.await
.expect("ambiguous grant must be cleaned up")
.expect("cleanup signal");
assert_eq!(lock_peer_health_for_test(addr).detached_rpcs, 0, "failed detached RPC returns its slot");
reset_lock_peer_health_for_test(addr);
}
#[tokio::test]
async fn test_remote_client_cancelled_completed_failure_runs_cleanup() {
for (label, status) in [
("transport", tonic::Status::cancelled("ambiguous transport failure")),
("deadline", tonic::Status::deadline_exceeded("ambiguous deadline failure")),
] {
let addr = format!("http://cancelled-completed-{label}");
reset_lock_peer_health_for_test(&addr);
let client = RemoteClient::new(addr.clone());
let (evicting_tx, evicting_rx) = tokio::sync::oneshot::channel();
let (_resume_tx, resume_rx) = tokio::sync::oneshot::channel();
eviction_pauses_for_test()
.lock()
.expect("eviction pause registry")
.insert(addr.clone(), (evicting_tx, resume_rx));
let (released_tx, released_rx) = tokio::sync::oneshot::channel();
let caller = tokio::spawn(async move {
let late_failure: LateFailure = Some(Box::new(move |()| {
Box::pin(async move {
released_tx
.send(())
.expect("completed-failure cleanup observer remains alive");
})
}));
client
.execute_rpc(
"lock",
"bucket/object",
Duration::from_secs(30),
async move { Err::<(), _>(status) },
RpcCleanup::new(None, late_failure, None),
)
.await
});
timeout(Duration::from_secs(1), evicting_rx)
.await
.expect("completed error must reach channel eviction")
.expect("eviction signal");
timeout(Duration::from_secs(1), released_rx)
.await
.expect("cleanup must transfer before channel eviction suspends")
.expect("cleanup signal");
caller.abort();
assert!(caller.await.expect_err("caller is cancelled during eviction").is_cancelled());
assert_eq!(
lock_peer_health_for_test(&addr).detached_rpcs,
0,
"completed RPC cannot consume a detached slot"
);
reset_lock_peer_health_for_test(&addr);
}
}
#[tokio::test(start_paused = true)]
async fn test_remote_client_deadline_wins_over_same_tick_transport_timeout() {
let addr = "http://same-tick-rpc-deadline";
reset_lock_peer_health_for_test(addr);
with_lock_peer_health(addr, |health| health.last_success = Some(Instant::now()));
let client = RemoteClient::new(addr.to_string());
let budget = Duration::from_millis(50);
let (released_tx, released_rx) = tokio::sync::oneshot::channel();
let late_failure: LateFailure = Some(Box::new(move |()| {
Box::pin(async move {
released_tx.send(()).expect("same-tick cleanup observer remains alive");
})
}));
let result = client
.execute_rpc(
"lock",
"bucket/object",
budget,
async move {
tokio::time::sleep(budget).await;
Err::<(), _>(tonic::Status::cancelled("inner transport deadline expired"))
},
RpcCleanup::new(None, late_failure, None),
)
.await;
assert!(
matches!(result, Err(LockError::Timeout { .. })),
"expired caller budget must retain timeout classification"
);
let health = lock_peer_health_for_test(addr);
assert_eq!(health.consecutive_timeouts, 1, "same-tick completion must follow timeout eviction policy");
assert!(
health.last_eviction.is_none(),
"a recently serving peer keeps its channel after a caller timeout"
);
timeout(Duration::from_secs(1), released_rx)
.await
.expect("same-tick ambiguous completion must trigger cleanup")
.expect("cleanup signal");
assert_eq!(
lock_peer_health_for_test(addr).detached_rpcs,
0,
"same-tick completion returns detached admission"
);
reset_lock_peer_health_for_test(addr);
}
#[tokio::test]
async fn test_remote_client_timeout_drains_late_grant_during_eviction() {
let addr = "http://timeout-late-grant-during-eviction";
reset_lock_peer_health_for_test(addr);
let client = RemoteClient::new(addr.to_string());
let (evicting_tx, evicting_rx) = tokio::sync::oneshot::channel();
let (resume_tx, resume_rx) = tokio::sync::oneshot::channel();
eviction_pauses_for_test()
.lock()
.expect("eviction pause registry")
.insert(addr.to_string(), (evicting_tx, resume_rx));
let (grant_tx, grant_rx) = tokio::sync::oneshot::channel();
let (released_tx, released_rx) = tokio::sync::oneshot::channel();
let caller = tokio::spawn(async move {
let late: LateCompletion<bool> = Some(Box::new(move |granted| {
Box::pin(async move {
released_tx.send(granted).expect("late grant observer remains alive");
})
}));
client
.execute_rpc(
"lock",
"bucket/object",
Duration::from_millis(25),
async move { Ok(grant_rx.await.expect("test controls the late grant")) },
RpcCleanup::new(late, None, None),
)
.await
});
timeout(Duration::from_secs(1), evicting_rx)
.await
.expect("timed-out RPC must reach channel eviction")
.expect("eviction signal");
assert_eq!(
lock_peer_health_for_test(addr).detached_rpcs,
1,
"eviction waits with a detached RPC owner"
);
grant_tx
.send(true)
.expect("timed-out stream remains available for a late grant");
assert!(
timeout(Duration::from_secs(1), released_rx)
.await
.expect("late grant must be cleaned up before eviction resumes")
.expect("late cleanup result")
);
assert!(!caller.is_finished(), "channel eviction is still paused");
assert_eq!(
lock_peer_health_for_test(addr).detached_rpcs,
0,
"late completion returns detached admission"
);
resume_tx.send(()).expect("resume channel eviction");
let result = caller.await.expect("timed-out caller completes without panicking");
assert!(
matches!(result, Err(LockError::Timeout { .. })),
"caller preserves its RPC timeout result"
);
reset_lock_peer_health_for_test(addr);
}
#[tokio::test]
async fn test_remote_client_success_polls_rpc_on_callers_task() {
tokio::spawn(async {
let client = RemoteClient::new("http://direct-poll-success".to_string());
let caller_id = tokio::task::id();
let rpc_id = client
.execute_rpc(
"ping",
"health-lock-online",
Duration::from_secs(1),
async { Ok(tokio::task::id()) },
RpcCleanup::new(None, None, None),
)
.await
.expect("successful RPC must return its result");
assert_eq!(rpc_id, caller_id, "successful RPC must not allocate and schedule a separate task");
})
.await
.expect("caller task must complete");
}
#[tokio::test]
async fn test_remote_client_rpc_panic_isolated_and_cleans_up() {
let (released_tx, released_rx) = tokio::sync::oneshot::channel();
let caller = tokio::spawn(async move {
let client = RemoteClient::new("http://direct-poll-panic".to_string());
let late_failure: LateFailure = Some(Box::new(move |()| {
Box::pin(async move {
released_tx.send(()).expect("panic cleanup observer remains alive");
})
}));
client
.execute_rpc::<(), _>(
"lock",
"bucket/object",
Duration::from_secs(1),
async { panic!("test peer RPC panic") },
RpcCleanup::new(None, late_failure, None),
)
.await
});
let result = caller.await.expect("RPC panic must not escape to its caller");
assert!(
matches!(result, Err(LockError::Internal { .. })),
"RPC panic remains an internal lock failure"
);
timeout(Duration::from_secs(1), released_rx)
.await
.expect("panic must trigger ambiguous-grant cleanup")
.expect("cleanup signal");
}
#[tokio::test]
#[serial_test::serial]
async fn test_remote_client_timeout_keeps_channel_of_recently_serving_peer() {
+13 -2
View File
@@ -8135,8 +8135,6 @@ impl LocalDisk {
Self::check_startup_writable(&disk.publication_root, &disk.root.join(directory)).await?;
}
disk.multipart_index.start();
let (exit_tx, exit_rx) = tokio::sync::broadcast::channel(1);
disk.exit_signal = Some(exit_tx);
@@ -8147,6 +8145,7 @@ impl LocalDisk {
publication_root,
#[cfg(target_os = "linux")]
disk.mount_lease.clone(),
disk.multipart_index.clone(),
exit_rx,
));
debug!(
@@ -8165,8 +8164,19 @@ impl LocalDisk {
root: PathBuf,
publication_root: os::PublicationRoot,
#[cfg(target_os = "linux")] mount_lease: Arc<std::fs::File>,
multipart_index: Arc<multipart_index::IndexOwner>,
mut exit_rx: tokio::sync::broadcast::Receiver<()>,
) {
// The multipart index bootstrap rides this task instead of its own:
// the disk lifecycle accounts for exactly this background task, and
// stopping the disk abandons the scan (a reconnect resumes it).
if let Some(bootstrap) = multipart_index.start() {
tokio::select! {
_ = bootstrap => {}
_ = exit_rx.recv() => return,
}
}
drop(multipart_index);
let start_at = Instant::now() + DELETED_OBJECTS_CLEANUP_INTERVAL;
let mut interval = interval_at(start_at, DELETED_OBJECTS_CLEANUP_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
@@ -26565,6 +26575,7 @@ mod test {
root.clone(),
disk.publication_root.clone(),
disk.mount_lease.clone(),
disk.multipart_index.clone(),
exited,
));
tokio::task::yield_now().await;
@@ -41,6 +41,11 @@ struct DiscoverySession {
touched: Instant,
}
/// Plain (non-transaction) children of an object-scoped discovery root cost one
/// budget entry per this many names: enough to bound a hostile prefix without
/// making a real folder prefix undeletable.
const PLAIN_CHILDREN_PER_BUDGET_ENTRY: u32 = 64;
impl DiscoverySession {
/// Consume one bounded step, returning ownership only when traversal remains.
fn advance(self, limit: u32) -> std::io::Result<(Vec<DeleteTransactionId>, Option<Self>)> {
@@ -61,6 +66,7 @@ impl DiscoverySession {
let mut transactions = BTreeSet::new();
let mut seen_candidates = HashSet::new();
let mut visited = 0u32;
let mut plain_children = 0u32;
while !self.frames.is_empty()
&& transactions.len() < usize::try_from(limit).map_err(|_| std::io::Error::from(ErrorKind::InvalidInput))?
&& visited < max_entries
@@ -76,7 +82,6 @@ impl DiscoverySession {
continue;
}
};
visited += 1;
let name = entry
.file_name()
.into_string()
@@ -84,6 +89,19 @@ impl DiscoverySession {
if !safe_path(&name, false) || name.contains('/') {
return Err(std::io::Error::from(ErrorKind::InvalidData));
}
// An object-scoped scan only ever descends into transaction (UUID)
// directories at the root. Plain child keys of a prefix directory are
// skipped by name without a stat; they are charged at one entry per
// readdir batch so the work stays bounded while deleting a missing key
// that names a prefix with thousands of children can still complete.
if self.object.is_some() && parent.is_empty() && !canonical_uuid(&name).is_some_and(|id| !id.is_nil()) {
plain_children += 1;
if plain_children.is_multiple_of(PLAIN_CHILDREN_PER_BUDGET_ENTRY) {
visited += 1;
}
continue;
}
visited += 1;
let relative = if parent.is_empty() {
name.clone()
} else {
@@ -6193,8 +6211,9 @@ mod tests {
let (_temp, disk, _, _) = fixture().await;
let path = disk.delete_discovery_path("recovery", Some("prefix")).expect("prefix path");
fs::create_dir_all(&path).await.expect("physical prefix");
for index in 0..64 {
fs::create_dir(path.join(format!("child-{index}")))
// Transaction-shaped children are the entries a bounded probe pays for.
for _ in 0..64 {
fs::create_dir(path.join(Uuid::new_v4().to_string()))
.await
.expect("legitimate child");
}
+169 -46
View File
@@ -12,6 +12,7 @@ use crate::disk::{
};
use rustfs_filemeta::{FileInfo, FileInfoOpts, get_file_info};
use sha2::{Digest, Sha256};
use std::future::Future;
use std::{
collections::{BTreeSet, HashMap},
io,
@@ -32,7 +33,9 @@ static OWNERS: LazyLock<parking_lot::Mutex<HashMap<PhysicalRoot, Weak<IndexOwner
LazyLock::new(|| parking_lot::Mutex::new(HashMap::new()));
pub(super) struct IndexOwner {
root: os::PublicationRoot,
// Only the path: holding the disk's pinned root handle here would keep the
// mount alive past the last disk owner, which the disk lifecycle forbids.
root_path: Arc<PathBuf>,
physical: PhysicalRoot,
#[cfg(unix)]
_identity_pin: std::fs::File,
@@ -44,6 +47,9 @@ pub(super) struct IndexOwner {
// No persisted readiness is trusted: an old binary may have written while
// this process was stopped. Same-process handles share the continuous owner.
initialized: OnceLock<Arc<IndexBootstrap>>,
// Set while some task drives the bootstrap scan; cleared by that driver's
// drop so a cancelled scan can be resumed by the next caller.
driving: AtomicBool,
disabled: AtomicBool,
#[cfg(test)]
rebuild_head_pause: parking_lot::Mutex<Option<RebuildHeadPause>>,
@@ -109,6 +115,16 @@ impl IndexDirectory {
std::fs::File::open(self.path.join(name))
}
}
fn remove_file(&self, name: &str) -> io::Result<()> {
#[cfg(unix)]
{
Ok(rustix::fs::unlinkat(&self.directory, name, rustix::fs::AtFlags::empty())?)
}
#[cfg(not(unix))]
{
std::fs::remove_file(self.path.join(name))
}
}
}
/// Resume the same pinned iterators, rather than reopening and rescanning a
@@ -232,7 +248,7 @@ impl IndexOwner {
pin
};
let owner = Arc::new(Self {
root: root.clone(),
root_path: Arc::new(root.path().to_path_buf()),
physical: key,
#[cfg(unix)]
_identity_pin: identity_pin,
@@ -240,6 +256,7 @@ impl IndexOwner {
epoch: Uuid::new_v4(),
blocking_queue: os::register_disk_blocking_queue(logical_root, base),
initialized: OnceLock::new(),
driving: AtomicBool::new(false),
disabled: AtomicBool::new(false),
#[cfg(test)]
rebuild_head_pause: parking_lot::Mutex::new(None),
@@ -260,31 +277,46 @@ impl IndexOwner {
self.blocking_queue.clone()
}
/// Schedule one owned scan without delaying disk admission. Until EOF, callers
/// retain complete legacy discovery; a failed scan disables only this optimization.
pub(super) fn start(self: &Arc<Self>) {
self.initialized.get_or_init(|| {
let bootstrap = Arc::new(IndexBootstrap {
result: AtomicU8::new(0),
completed: Notify::new(),
});
let completion = bootstrap.clone();
let owner = self.clone();
tokio::spawn(async move {
let success = owner.rebuild().await.is_ok();
// A completion waiter may drop the final disk handle and reopen it.
// Release this boot owner before publishing completion to that waiter.
drop(owner);
completion.result.store(if success { 1 } else { 2 }, Ordering::Release);
completion.completed.notify_waiters();
});
bootstrap
});
/// Hand out the one owned bootstrap scan. The caller drives the returned
/// future on a task it already owns (the disk's cleanup loop), so no task is
/// spawned per disk and dropping the future releases this owner; the next
/// `start` resumes an unfinished scan. Until EOF, callers retain complete
/// legacy discovery; a failed scan disables only this optimization.
pub(super) fn start(self: &Arc<Self>) -> Option<impl Future<Output = ()> + Send + 'static> {
let bootstrap = self
.initialized
.get_or_init(|| {
Arc::new(IndexBootstrap {
result: AtomicU8::new(0),
completed: Notify::new(),
})
})
.clone();
if bootstrap.result.load(Ordering::Acquire) != 0 || self.driving.swap(true, Ordering::AcqRel) {
return None;
}
struct Driver(Arc<IndexOwner>);
impl Drop for Driver {
fn drop(&mut self) {
self.0.driving.store(false, Ordering::Release);
}
}
let driver = Driver(self.clone());
Some(async move {
let success = driver.0.rebuild().await.is_ok();
// A completion waiter may drop the final disk handle and reopen it.
// Release this boot owner before publishing completion to that waiter.
drop(driver);
bootstrap.result.store(if success { 1 } else { 2 }, Ordering::Release);
bootstrap.completed.notify_waiters();
})
}
#[cfg(any(test, feature = "test-util"))]
pub(super) async fn initialize(self: &Arc<Self>) {
self.start();
if let Some(scan) = self.start() {
scan.await;
}
let Some(bootstrap) = self.initialized.get() else {
return;
};
@@ -334,33 +366,39 @@ impl IndexOwner {
directories.push(std::fs::File::from(directory));
}
let parent = directories.last().ok_or_else(|| io::Error::other("index parent missing"))?;
let marker = std::fs::File::from(openat(
// The index is boot-local: every start rescans the staging area
// before serving it, so a marker lost to a crash is recreated before
// it can be consulted. Creation is enough; no fsync of the marker or
// its ancestors, and an existing marker costs one failed open.
match openat(
parent,
source_root,
OFlags::CREATE | OFlags::WRONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
OFlags::CREATE | OFlags::EXCL | OFlags::WRONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::RUSR | Mode::WUSR,
)?);
if !marker.metadata()?.is_file() {
return Err(io::Error::other("index marker is not a file"));
}
marker.sync_all()?;
for directory in directories.iter().rev() {
directory.sync_all()?;
) {
Ok(marker) => {
if !std::fs::File::from(marker).metadata()?.is_file() {
return Err(io::Error::other("index marker is not a file"));
}
}
Err(rustix::io::Errno::EXIST) => {}
Err(error) => return Err(error.into()),
}
}
#[cfg(not(unix))]
{
let root = os::PublicationRoot::new(&self.root_path)?;
let directory = self.base.join(INDEX).join(sha(bucket));
let marker = directory.join(source_root);
let _parents = os::mkdir_all_below_existing_base_std(&directory, &self.base, &self.root)?;
os::replace_multipart_source_metadata(&marker, b"1", &self.base, &self.root)?;
os::sync_multipart_owned_record_parents(&self.base.join(".rustfs.sys"), &directory, true, &self.base, &self.root)?;
let _parents = os::mkdir_all_below_existing_base_std(&directory, &self.base, &root)?;
os::replace_multipart_source_metadata(&marker, b"1", &self.base, &root)?;
os::sync_multipart_owned_record_parents(&self.base.join(".rustfs.sys"), &directory, true, &self.base, &root)?;
}
self.ensure_current_root()
}
fn ensure_current_root(&self) -> io::Result<()> {
let current = os::PublicationRoot::new(self.root.path())?.physical_identity()?;
let current = os::PublicationRoot::new(&self.root_path)?.physical_identity()?;
if current != self.physical {
self.disabled.store(true, Ordering::Release);
return Err(io::Error::other("multipart index physical root changed"));
@@ -389,7 +427,8 @@ impl IndexOwner {
#[cfg(not(unix))]
{
let path = self.base.join(relative);
let guard = os::pin_multipart_source_directory(&path, &self.base, &self.root)?;
let root = os::PublicationRoot::new(&self.root_path)?;
let guard = os::pin_multipart_source_directory(&path, &self.base, &root)?;
Ok(IndexDirectory { path, _guard: guard })
}
}
@@ -501,7 +540,7 @@ impl IndexOwner {
let owner = self.clone();
let bucket = bucket.to_owned();
let marker = marker.map(str::to_owned);
crate::disk::io_admission::spawn_blocking_with_disk_io_permit(None, move || {
let (page, stale) = crate::disk::io_admission::spawn_blocking_with_disk_io_permit(None, move || {
owner.ensure_current_root()?;
let directory = match owner.directory(&Path::new(INDEX).join(sha(&bucket))) {
Ok(directory) => Some(directory),
@@ -527,17 +566,72 @@ impl IndexOwner {
if more {
roots.pop_last();
}
// The marker advances past every examined name, live or stale, so a
// pruned name never stalls pagination.
let next_marker = more.then(|| roots.last().cloned()).flatten();
Ok(MultipartIndexPage {
bucket,
disk_id,
epoch: owner.epoch,
roots: roots.into_iter().collect(),
next_marker,
})
// Completion and abort remove the staging root, not the marker, so
// markers outlive their uploads. Drop the ones whose staging root is
// gone, keeping the index proportional to live uploads rather than
// to every key ever multipart-initiated on this drive.
let mut live = Vec::with_capacity(roots.len());
let mut stale = Vec::new();
for root in roots {
match owner.directory(&Path::new(RUSTFS_META_MULTIPART_BUCKET).join(&root)) {
Ok(_) => live.push(root),
Err(error) if matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory) => {
stale.push(root)
}
Err(error) => return Err(DiskError::from(error)),
}
}
Ok((
MultipartIndexPage {
bucket,
disk_id,
epoch: owner.epoch,
roots: live,
next_marker,
},
stale,
))
})
.await
.map_err(DiskError::from)?
.map_err(DiskError::from)??;
for root in stale {
self.prune_marker(&page.bucket, &root).await?;
}
Ok(page)
}
/// Remove one marker whose staging root was gone when a page examined it.
/// The marker's own namespace lease serializes this with `record_info`, and
/// the staging root is re-checked under that lease, so an upload created in
/// between keeps its marker.
async fn prune_marker(self: &Arc<Self>, bucket: &str, root: &str) -> Result<()> {
let owner = self.clone();
let bucket_directory = Path::new(INDEX).join(sha(bucket));
let marker = owner.base.join(&bucket_directory).join(root);
let root = root.to_owned();
let lease = os::acquire_namespace_mutation_lease_with_owner(&marker, None).await;
os::run_blocking_namespace_operation(lease, move || {
owner.ensure_current_root()?;
match owner.directory(&Path::new(RUSTFS_META_MULTIPART_BUCKET).join(&root)) {
Ok(_) => return Ok(()),
Err(error) if matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory) => {}
Err(error) => return Err(error),
}
let directory = match owner.directory(&bucket_directory) {
Ok(directory) => directory,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error),
};
match directory.remove_file(&root) {
Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error),
_ => Ok(()),
}
})
.await?;
Ok(())
}
}
@@ -724,6 +818,8 @@ mod tests {
std::fs::create_dir_all(&directory).expect("seed ready candidates");
for i in 0..(MULTIPART_INDEX_PAGE_SIZE + 2) {
std::fs::write(directory.join(format!("{i:064x}")), b"1").expect("candidate marker");
std::fs::create_dir_all(root.path().join(RUSTFS_META_MULTIPART_BUCKET).join(format!("{i:064x}")))
.expect("live staging root");
}
// A foreign bucket's unreadable layout must not enter this page's walk.
std::fs::write(root.path().join(INDEX).join(sha("foreign")), b"not a directory").expect("foreign layout");
@@ -744,6 +840,33 @@ mod tests {
assert!(second.next_marker.is_none());
assert!(first.roots.last() < second.roots.first());
}
#[tokio::test]
async fn multipart_index_pages_prune_markers_whose_staging_root_is_gone() {
let root = tempfile::tempdir().expect("disk root");
let disk = open(root.path()).await;
let directory = root.path().join(INDEX).join(sha("bucket"));
std::fs::create_dir_all(&directory).expect("marker directory");
let live = format!("{:064x}", 1);
let finished = format!("{:064x}", 2);
for name in [&live, &finished] {
std::fs::write(directory.join(name), b"1").expect("marker");
}
// Completion and abort remove the staging root and leave the marker.
std::fs::create_dir_all(root.path().join(RUSTFS_META_MULTIPART_BUCKET).join(&live)).expect("live staging root");
let page = disk.multipart_index.page("bucket", None, Uuid::new_v4()).await.expect("page");
assert_eq!(page.roots, vec![live.clone()]);
assert!(page.next_marker.is_none());
assert!(directory.join(&live).exists(), "a live root keeps its marker");
assert!(!directory.join(&finished).exists(), "a finished root's marker is pruned");
// The next page no longer pays for the pruned marker.
let page = disk
.multipart_index
.page("bucket", None, Uuid::new_v4())
.await
.expect("page after pruning");
assert_eq!(page.roots, vec![live]);
}
#[cfg(unix)]
#[tokio::test]
async fn multipart_index_aliases_share_readiness_and_root_replacement_cannot_return_empty() {
+21 -5
View File
@@ -957,9 +957,9 @@ pub struct DeleteRecoveryWorkBudget {
remaining: std::sync::atomic::AtomicU32,
}
/// Entries one disk may reserve up front per recovery step (discovery plus
/// inspection). Every disk of every set a request fans out to reserves this
/// much before any refund arrives, so a budget must cover that many disks.
/// Entries one disk may visit per recovery step (discovery plus inspection).
/// Every disk of every set a request fans out to is admitted up to this much
/// and charged on completion, so a budget must cover that many disks.
const DELETE_RECOVERY_RESERVATION_PER_DISK: u32 = 4096 + 65_536;
impl Default for DeleteRecoveryWorkBudget {
@@ -999,8 +999,24 @@ impl DeleteRecoveryWorkBudget {
self.remaining.load(std::sync::atomic::Ordering::Acquire)
}
pub(crate) fn refund(&self, unused: u32) {
self.remaining.fetch_add(unused, std::sync::atomic::Ordering::Release);
/// Admit one bounded disk call: refuse once the budget is spent, otherwise
/// cap the call at `maximum` or what remains. The work actually performed is
/// charged on completion with `charge`, so concurrent calls never exhaust
/// the budget by reservation alone (a bulk delete fans out dozens of
/// inspections per key); each call is still bounded by its own cap.
pub(crate) fn admit(&self, maximum: u32) -> crate::disk::error::Result<u32> {
match self.remaining() {
0 => Err(crate::disk::error::DiskError::DeleteRecoveryWorkLimit),
remaining => Ok(remaining.min(maximum)),
}
}
/// Record the entries a bounded call visited (at least one per call).
pub(crate) fn charge(&self, visited: u32) {
use std::sync::atomic::Ordering;
let _ = self.remaining.try_update(Ordering::AcqRel, Ordering::Acquire, |remaining| {
Some(remaining.saturating_sub(visited.max(1)))
});
}
}
@@ -237,14 +237,14 @@ async fn inspect(
join_all(disks.iter().map(|disk| async move {
let disk = disk.as_ref().ok_or(DiskError::DiskNotFound)?;
let state = if let Some(budget) = budget {
let reserved = budget.reserve(crate::disk::DELETE_RECOVERY_MAX_INSPECTION_ENTRIES)?;
let admitted = budget.admit(crate::disk::DELETE_RECOVERY_MAX_INSPECTION_ENTRIES)?;
let reply = disk
.inspect_delete_transaction_bounded(bucket, object, transaction, reserved)
.inspect_delete_transaction_bounded(bucket, object, transaction, admitted)
.await?;
if reply.visited > reserved {
if reply.visited > admitted {
return Err(DiskError::FileCorrupt);
}
budget.refund(reserved - reply.visited.max(1));
budget.charge(reply.visited);
reply.inspection
} else {
disk.inspect_delete_transaction(bucket, object, transaction).await?
@@ -261,9 +261,9 @@ async fn discover_bounded(
object: &str,
budget: &crate::object_api::DeleteRecoveryWorkBudget,
) -> Result<Vec<DeleteTransactionId>> {
let reserved = budget.reserve(crate::disk::DELETE_RECOVERY_MAX_DISCOVERY_ENTRIES)?;
let page = disk.discover_delete_transactions_bounded(bucket, object, reserved).await?;
if page.visited > reserved
let admitted = budget.admit(crate::disk::DELETE_RECOVERY_MAX_DISCOVERY_ENTRIES)?;
let page = disk.discover_delete_transactions_bounded(bucket, object, admitted).await?;
if page.visited > admitted
|| page.transactions.len() > 256
|| page
.transactions
@@ -273,7 +273,7 @@ async fn discover_bounded(
{
return Err(DiskError::FileCorrupt);
}
budget.refund(reserved - page.visited.max(1));
budget.charge(page.visited);
if !page.complete {
return Err(DiskError::DeleteRecoveryWorkLimit);
}
@@ -329,7 +329,7 @@ async fn settle_outcome(
return Err(DiskError::other("delete recovery namespace fence was lost"));
}
let outcome = if let Some(budget) = scope.budget {
let reserved = budget.reserve(crate::disk::DELETE_RECOVERY_MAX_INSPECTION_ENTRIES)?;
let admitted = budget.admit(crate::disk::DELETE_RECOVERY_MAX_INSPECTION_ENTRIES)?;
let may_publish = matches!(&action, DeleteRecoveryAction::PublishCommit | DeleteRecoveryAction::AdoptCommit { .. });
let reply = disk
.settle_delete_transaction_bounded(
@@ -339,17 +339,17 @@ async fn settle_outcome(
transaction: state.id.transaction,
expected_fingerprint: state.fingerprint,
action,
max_entries: reserved,
max_entries: admitted,
},
scope.namespace_owner.cloned(),
)
.await?;
if reply.visited > reserved
if reply.visited > admitted
|| (!may_publish && matches!(&reply.outcome, DeleteRecoverySettlementOutcome::ReceiptPublishedInspectionDeferred))
{
return Err(DiskError::FileCorrupt);
}
budget.refund(reserved - reply.visited.max(1));
budget.charge(reply.visited);
reply.outcome
} else {
DeleteRecoverySettlementOutcome::Inspected(Box::new(
@@ -1629,8 +1629,8 @@ mod tests {
for dir in &dirs {
let prefix = dir.path().join(bucket).join("prefix");
std::fs::create_dir_all(&prefix).expect("physical prefix");
for index in 0..64 {
std::fs::create_dir(prefix.join(format!("child-{index}"))).expect("legitimate child");
for _ in 0..64 {
std::fs::create_dir(prefix.join(Uuid::new_v4().to_string())).expect("legitimate child");
}
}
let error = store
@@ -1693,10 +1693,11 @@ mod tests {
.any(|error| matches!(error, Some(StorageError::DeleteRecoveryWorkLimit))),
"repeated actions must not each obtain a new allowance: {distinct_versions}"
);
assert!(
dirs.iter()
.all(|dir| dir.path().join(bucket).join("prefix/child-63").is_dir())
);
assert!(dirs.iter().all(|dir| {
std::fs::read_dir(dir.path().join(bucket).join("prefix"))
.map(|entries| entries.count() == 64)
.unwrap_or(false)
}));
}
let error = store
.delete_object(bucket, "prefix", ObjectOptions::default())
@@ -1779,8 +1780,8 @@ mod tests {
for key in &keys {
let prefix = dir.path().join(bucket).join(key);
std::fs::create_dir_all(&prefix).expect("physical prefix");
for child in 0..8 {
std::fs::create_dir(prefix.join(format!("child-{child}"))).expect("child");
for _ in 0..8 {
std::fs::create_dir(prefix.join(Uuid::new_v4().to_string())).expect("child");
}
}
}
@@ -1809,10 +1810,11 @@ mod tests {
"all pools and sets must share the finite allowance"
);
assert!(budget.remaining() < 32_768, "fanout must debit the original request allowance");
assert!(
dirs.iter()
.all(|dir| dir.path().join(bucket).join(&keys[0]).join("child-7").is_dir())
);
assert!(dirs.iter().all(|dir| {
std::fs::read_dir(dir.path().join(bucket).join(&keys[0]))
.map(|entries| entries.count() == 8)
.unwrap_or(false)
}));
}
#[tokio::test]
+11 -4
View File
@@ -1989,12 +1989,19 @@ impl SetDisks {
roots.insert(Self::get_multipart_sha_dir(bucket, &encode_dir_object(prefix)));
}
let mut candidates = Vec::new();
for root in roots {
let (_, paths, _, _) = self
.discover_multipart_upload_paths_on_disks(disks.clone(), bucket, prefix, &root, false)
.await?;
let mut discoveries = futures::stream::iter(roots.into_iter().map(|root| {
let disks = disks.clone();
async move {
self.discover_multipart_upload_paths_on_disks(disks, bucket, prefix, &root, false)
.await
}
}))
.buffer_unordered(MULTIPART_LIST_IO_CONCURRENCY);
while let Some(discovered) = discoveries.next().await {
let (_, paths, _, _) = discovered?;
candidates.extend(paths);
}
drop(discoveries);
(disks, candidates, quorum, false)
} else {
self.discover_multipart_upload_paths(bucket, prefix, &root, false).await?
+1
View File
@@ -42,6 +42,7 @@ rustfs-io-metrics = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
metrics = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "fs"] }
[lib]
+92 -68
View File
@@ -83,7 +83,10 @@ struct PoolTier {
/// Queue of available buffers for reuse
available_buffers: Mutex<Vec<BytesMut>>,
/// Metrics for tracking this tier
metrics: Mutex<Option<Arc<BytesPoolMetrics>>>,
metrics: Arc<BytesPoolMetrics>,
/// Serialize acquisitions before taking buffers so a slow recorder does not
/// accumulate allocated buffers behind hit-rate publication.
acquire_metrics_lock: Mutex<()>,
/// Total acquisitions for this tier
tier_total_acquires: AtomicU64,
/// Total hits for this tier
@@ -185,16 +188,10 @@ impl BytesPool {
/// ```
pub fn with_config(config: BytesPoolConfig) -> Self {
let metrics = Arc::new(BytesPoolMetrics::default());
let small_pool = Arc::new(PoolTier::new(config.small_size, config.small_max, "small"));
let medium_pool = Arc::new(PoolTier::new(config.medium_size, config.medium_max, "medium"));
let large_pool = Arc::new(PoolTier::new(config.large_size, config.large_max, "large"));
let xlarge_pool = Arc::new(PoolTier::new(config.xlarge_size, config.xlarge_max, "xlarge"));
// Set metrics reference in all tiers
small_pool.set_metrics(Arc::clone(&metrics));
medium_pool.set_metrics(Arc::clone(&metrics));
large_pool.set_metrics(Arc::clone(&metrics));
xlarge_pool.set_metrics(Arc::clone(&metrics));
let small_pool = Arc::new(PoolTier::new(config.small_size, config.small_max, "small", Arc::clone(&metrics)));
let medium_pool = Arc::new(PoolTier::new(config.medium_size, config.medium_max, "medium", Arc::clone(&metrics)));
let large_pool = Arc::new(PoolTier::new(config.large_size, config.large_max, "large", Arc::clone(&metrics)));
let xlarge_pool = Arc::new(PoolTier::new(config.xlarge_size, config.xlarge_max, "xlarge", Arc::clone(&metrics)));
Self {
small_pool,
@@ -225,7 +222,7 @@ impl BytesPool {
/// ```
pub async fn acquire_buffer(&self, size: usize) -> PooledBuffer {
let tier = self.select_tier(size);
let mut buffer = tier.acquire_buffer(size, &self.metrics).await;
let mut buffer = tier.acquire_buffer(size).await;
if buffer._permit.is_some() {
buffer.tier = Some(Arc::clone(tier));
}
@@ -252,7 +249,7 @@ impl BytesPool {
/// ```
pub fn try_acquire_buffer(&self, size: usize) -> Option<PooledBuffer> {
let tier = self.select_tier(size);
let mut buffer = tier.try_acquire_buffer(size, &self.metrics)?;
let mut buffer = tier.try_acquire_buffer(size)?;
// Set tier reference for return on drop
buffer.tier = Some(Arc::clone(tier));
Some(buffer)
@@ -290,43 +287,38 @@ impl BytesPool {
}
impl PoolTier {
fn new(buffer_size: usize, max_buffers: usize, name: &'static str) -> Self {
fn new(buffer_size: usize, max_buffers: usize, name: &'static str, metrics: Arc<BytesPoolMetrics>) -> Self {
Self {
buffer_size,
max_buffers,
semaphore: Arc::new(Semaphore::new(max_buffers)),
name,
available_buffers: Mutex::new(Vec::new()),
metrics: Mutex::new(None),
metrics,
acquire_metrics_lock: Mutex::new(()),
tier_total_acquires: AtomicU64::new(0),
tier_pool_hits: AtomicU64::new(0),
tier_current_allocated_bytes: AtomicU64::new(0),
}
}
fn set_metrics(&self, metrics: Arc<BytesPoolMetrics>) {
*self.metrics.lock().unwrap_or_else(|e| e.into_inner()) = Some(metrics);
}
fn take_or_allocate_buffer(&self, size: usize, pool_metrics: &BytesPoolMetrics) -> (BytesMut, bool) {
fn take_or_allocate_buffer(&self, size: usize) -> (BytesMut, bool) {
let pool_metrics = &self.metrics;
let buffer_opt = {
let mut available = self.available_buffers.lock().unwrap_or_else(|e| e.into_inner());
available.pop()
let buffer = available.pop();
if buffer.is_some() {
pool_metrics.available_buffers.fetch_sub(1, Ordering::Relaxed);
}
buffer
};
let was_reused = buffer_opt.is_some();
if was_reused {
// A reused buffer leaves the available pool. `return_buffer` does a
// matching `fetch_add(1)` on push, so without this the
// `available_buffers` gauge only ever grows and never reflects the real
// pool size (backlog#806).
pool_metrics.available_buffers.fetch_sub(1, Ordering::Relaxed);
}
let buffer = if let Some(mut buf) = buffer_opt {
let previous_capacity = buf.capacity();
buf.clear();
if previous_capacity < size {
buf.reserve(size - previous_capacity);
buf.reserve(size);
}
let current_capacity = buf.capacity();
if current_capacity > previous_capacity {
@@ -353,7 +345,10 @@ impl PoolTier {
(buffer, was_reused)
}
fn record_acquire_metrics(&self, pool_metrics: &BytesPoolMetrics, buffer_capacity: usize, was_reused: bool) {
fn record_acquire_metrics(&self, buffer_capacity: usize, was_reused: bool) {
let pool_metrics = &self.metrics;
pool_metrics.total_acquires.fetch_add(1, Ordering::Relaxed);
self.tier_total_acquires.fetch_add(1, Ordering::Relaxed);
rustfs_io_metrics::record_bytes_pool_acquire(self.name, buffer_capacity, was_reused);
if was_reused {
@@ -374,7 +369,7 @@ impl PoolTier {
rustfs_io_metrics::record_bytes_pool_allocated(self.name, self.tier_current_allocated_bytes.load(Ordering::Relaxed));
}
async fn acquire_buffer(&self, size: usize, pool_metrics: &BytesPoolMetrics) -> PooledBuffer {
async fn acquire_buffer(&self, size: usize) -> PooledBuffer {
// Acquire semaphore permit (owned for storage in PooledBuffer)
let permit = match Arc::clone(&self.semaphore).acquire_owned().await {
Ok(p) => p,
@@ -388,17 +383,10 @@ impl PoolTier {
}
};
// Use the pool's shared metrics for recording
let _metrics_lock = self.metrics.lock().unwrap_or_else(|e| e.into_inner());
let _metrics = _metrics_lock.as_ref().expect("operation should succeed");
// Record acquisition
pool_metrics.total_acquires.fetch_add(1, Ordering::Relaxed);
self.tier_total_acquires.fetch_add(1, Ordering::Relaxed);
let (buffer, was_reused) = self.take_or_allocate_buffer(size, pool_metrics);
let _guard = self.acquire_metrics_lock.lock().unwrap_or_else(|e| e.into_inner());
let (buffer, was_reused) = self.take_or_allocate_buffer(size);
let buffer_capacity = buffer.capacity();
self.record_acquire_metrics(pool_metrics, buffer_capacity, was_reused);
self.record_acquire_metrics(buffer_capacity, was_reused);
PooledBuffer {
buffer: ManuallyDrop::new(buffer),
@@ -407,21 +395,14 @@ impl PoolTier {
}
}
fn try_acquire_buffer(&self, size: usize, pool_metrics: &BytesPoolMetrics) -> Option<PooledBuffer> {
fn try_acquire_buffer(&self, size: usize) -> Option<PooledBuffer> {
// Try to acquire permit without blocking
let permit = Arc::clone(&self.semaphore).try_acquire_owned().ok()?;
// Use the pool's shared metrics for recording
let _metrics_lock = self.metrics.lock().unwrap_or_else(|e| e.into_inner());
let _metrics = _metrics_lock.as_ref().expect("operation should succeed");
// Record acquisition
pool_metrics.total_acquires.fetch_add(1, Ordering::Relaxed);
self.tier_total_acquires.fetch_add(1, Ordering::Relaxed);
let (buffer, was_reused) = self.take_or_allocate_buffer(size, pool_metrics);
let _guard = self.acquire_metrics_lock.lock().unwrap_or_else(|e| e.into_inner());
let (buffer, was_reused) = self.take_or_allocate_buffer(size);
let buffer_capacity = buffer.capacity();
self.record_acquire_metrics(pool_metrics, buffer_capacity, was_reused);
self.record_acquire_metrics(buffer_capacity, was_reused);
Some(PooledBuffer {
buffer: ManuallyDrop::new(buffer),
@@ -430,7 +411,7 @@ impl PoolTier {
})
}
/// Return a buffer to the pool for reuse without ever blocking the caller.
/// Return a buffer to the pool for reuse without waiting for the queue lock.
fn return_buffer(&self, buffer: BytesMut) {
let mut buffer = Some(buffer);
@@ -438,11 +419,7 @@ impl PoolTier {
&& available.len() < self.max_buffers
{
available.push(buffer.take().expect("buffer should be present until returned"));
if let Ok(metrics) = self.metrics.try_lock()
&& let Some(metrics) = metrics.as_ref()
{
metrics.available_buffers.fetch_add(1, Ordering::Relaxed);
}
self.metrics.available_buffers.fetch_add(1, Ordering::Relaxed);
}
if let Some(buffer) = buffer {
@@ -452,21 +429,30 @@ impl PoolTier {
Some(current.saturating_sub(released_bytes))
})
.ok();
if let Ok(metrics) = self.metrics.try_lock()
&& let Some(metrics) = metrics.as_ref()
{
metrics
.current_allocated_bytes
.try_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
Some(current.saturating_sub(released_bytes))
})
.ok();
}
self.metrics
.current_allocated_bytes
.try_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
Some(current.saturating_sub(released_bytes))
})
.ok();
}
rustfs_io_metrics::record_bytes_pool_allocated(self.name, self.tier_current_allocated_bytes.load(Ordering::Relaxed));
}
}
impl PooledBuffer {
/// A buffer outside the pool: it holds no permit and is never returned to
/// a tier. Callers use it when the pool is at capacity but the request
/// must not be refused.
pub fn unpooled(size: usize) -> Self {
Self {
buffer: ManuallyDrop::new(BytesMut::with_capacity(size)),
tier: None,
_permit: None,
}
}
}
impl Drop for PooledBuffer {
// SAFETY: Drop has exclusive access to `self`; taking the `ManuallyDrop`
// buffer moves it exactly once into the pool when a tier still owns it.
@@ -551,6 +537,44 @@ mod tests {
assert!(buffer.capacity() >= 2048);
}
#[tokio::test]
async fn reused_buffer_satisfies_larger_async_request() {
let pool = BytesPool::with_config(BytesPoolConfig {
small_max: 1,
..Default::default()
});
let mut first = pool.acquire_buffer(32 * 1024).await;
first.extend_from_slice(b"previous contents");
assert!(first.capacity() < 48 * 1024, "fixture must require buffer growth");
drop(first);
let reused = pool.acquire_buffer(48 * 1024).await;
assert!(reused.is_empty());
assert!(reused.capacity() >= 48 * 1024);
assert_eq!(pool.metrics().pool_hits.load(Ordering::Relaxed), 1);
assert_eq!(
pool.metrics().current_allocated_bytes.load(Ordering::Relaxed),
u64::try_from(reused.capacity()).expect("buffer capacity fits in metrics")
);
}
#[test]
fn reused_buffer_satisfies_larger_try_request() {
let pool = BytesPool::with_config(BytesPoolConfig {
small_max: 1,
..Default::default()
});
let mut first = pool.try_acquire_buffer(32 * 1024).expect("first buffer is available");
first.extend_from_slice(b"previous contents");
assert!(first.capacity() < 48 * 1024, "fixture must require buffer growth");
drop(first);
let reused = pool.try_acquire_buffer(48 * 1024).expect("returned buffer is available");
assert!(reused.is_empty());
assert!(reused.capacity() >= 48 * 1024);
assert_eq!(pool.metrics().pool_hits.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn available_buffers_gauge_decrements_on_reuse() {
// Regression (backlog#806): acquiring a pooled (reused) buffer must
@@ -0,0 +1,121 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::sync::{Mutex, mpsc};
use std::thread;
use std::time::Duration;
use metrics::{Counter, Gauge, Histogram, Key, KeyName, Metadata, Recorder, SharedString, Unit};
use rustfs_io_core::{BytesPool, BytesPoolConfig};
const WAIT: Duration = Duration::from_secs(5);
const GATE_WAIT: Duration = Duration::from_secs(20);
struct AcquisitionGate {
entered: mpsc::Sender<()>,
release: Mutex<mpsc::Receiver<()>>,
}
impl Recorder for AcquisitionGate {
fn describe_counter(&self, _key: KeyName, _unit: Option<Unit>, _description: SharedString) {}
fn describe_gauge(&self, _key: KeyName, _unit: Option<Unit>, _description: SharedString) {}
fn describe_histogram(&self, _key: KeyName, _unit: Option<Unit>, _description: SharedString) {}
fn register_counter(&self, key: &Key, _metadata: &Metadata<'_>) -> Counter {
if key.name() == "rustfs_bytes_pool_acquisitions_total" {
self.entered
.send(())
.expect("notify test that acquisition reached the recorder");
self.release
.lock()
.expect("lock acquisition gate")
.recv_timeout(GATE_WAIT)
.expect("release the acquisition recorder gate");
}
Counter::noop()
}
fn register_gauge(&self, _key: &Key, _metadata: &Metadata<'_>) -> Gauge {
Gauge::noop()
}
fn register_histogram(&self, _key: &Key, _metadata: &Metadata<'_>) -> Histogram {
Histogram::noop()
}
}
#[test]
fn returned_buffer_is_counted_while_an_acquisition_recorder_is_busy() {
let pool = BytesPool::with_config(BytesPoolConfig {
small_max: 2,
..Default::default()
});
let held = pool.try_acquire_buffer(8 * 1024).expect("acquire the buffer to return");
assert_eq!(pool.available_buffers(), 0);
// This integration test is a dedicated test process: the recorder is local
// to the worker, while the metrics enablement flag is process-wide.
rustfs_io_metrics::set_metrics_enabled(true);
let (entered_tx, entered_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
let worker_pool = pool.clone();
let acquirer = thread::spawn(move || {
let gate = AcquisitionGate {
entered: entered_tx,
release: Mutex::new(release_rx),
};
metrics::with_local_recorder(&gate, || {
worker_pool
.try_acquire_buffer(8 * 1024)
.expect("acquire a second same-tier buffer")
})
});
let entered = entered_rx.recv_timeout(WAIT);
if entered.is_err() {
let _ = release_tx.send(());
let _ = acquirer.join();
panic!("acquisition did not reach the recorder gate: {entered:?}");
}
let (dropped_tx, dropped_rx) = mpsc::channel();
let returner = thread::spawn(move || {
drop(held);
let _ = dropped_tx.send(());
});
let returned_while_blocked = dropped_rx.recv_timeout(WAIT);
// Always release before joining so a blocking Drop cannot deadlock the test.
let _ = release_tx.send(());
let returned = returner.join();
let acquired = acquirer.join();
rustfs_io_metrics::set_metrics_enabled(false);
assert!(returned_while_blocked.is_ok(), "returning a buffer must not wait on the recorder");
returned.expect("return worker should finish");
let acquired = acquired.expect("acquisition worker should finish");
let after_return = pool.available_buffers();
let reused = pool.try_acquire_buffer(8 * 1024).expect("reuse the returned buffer");
let after_reuse = pool.available_buffers();
drop(reused);
drop(acquired);
let after_both_return = pool.available_buffers();
assert_eq!(
(after_return, after_reuse, after_both_return),
(1, 0, 2),
"returned buffers must be counted, and reuse must not underflow the available gauge"
);
}
+6
View File
@@ -58,6 +58,7 @@ const ERR_LIFECYCLE_INVALID_EXPIRED_OBJECT_ALL_VERSIONS: &str =
"Days must be a positive integer and Date must not be specified inside Expiration with ExpiredObjectAllVersions";
const ERR_LIFECYCLE_INVALID_DEL_MARKER_EXPIRATION_DAYS: &str = "Days must be a positive integer with DelMarkerExpiration";
const ERR_LIFECYCLE_INVALID_RULE_ID_TOO_LONG: &str = "Rule ID must be at most 255 characters";
const ERR_LIFECYCLE_INVALID_RULE_ID_CHARS: &str = "Lifecycle rule ID must not contain control characters";
const ERR_LIFECYCLE_INVALID_RULE_ID_EMPTY: &str = "Rule ID must not be empty";
const ERR_LIFECYCLE_INVALID_RULE_STATUS: &str = "Rule status must be either Enabled or Disabled";
const ERR_LIFECYCLE_DEL_MARKER_WITH_TAGS: &str = "Rule with DelMarkerExpiration cannot have tags based filtering";
@@ -583,6 +584,11 @@ impl Lifecycle for BucketLifecycleConfiguration {
if id.len() > 255 {
return Err(std::io::Error::other(ERR_LIFECYCLE_INVALID_RULE_ID_TOO_LONG));
}
// The ID is echoed in the x-amz-expiration header; a control
// character there would make every object GET, HEAD and PUT fail.
if id.chars().any(char::is_control) {
return Err(std::io::Error::other(ERR_LIFECYCLE_INVALID_RULE_ID_CHARS));
}
}
r.validate()?;
if let Some(object_lock_enabled) = lr.object_lock_enabled.as_ref()
+20 -11
View File
@@ -21,17 +21,17 @@
use futures_util::ready;
use http::HeaderMap;
use std::io::{Cursor, Error as IoError, ErrorKind as IoErrorKind, Read};
use std::io::{Error as IoError, ErrorKind as IoErrorKind, Read};
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::io::BufReader;
use tokio_util::io::StreamReader;
use crate::{
api_error_response::err_invalid_argument,
api_get_options::GetObjectOptions,
transition_api::{
ObjectInfo, ReadCloser, ReaderImpl, RequestMetadata, TransitionClient, collect_response_body, to_object_info_for_provider,
ObjectInfo, ReadCloser, ReaderImpl, RequestMetadata, TransitionClient, collect_response_body, response_body_reader,
to_object_info_for_provider,
},
};
use futures_util::StreamExt;
@@ -86,6 +86,11 @@ impl TransitionClient {
))
}
/// Open a remote object without collecting its response body.
///
/// Request and response-header errors are returned here. Body transport,
/// idle-timeout and range-limit errors are returned when the reader is read.
/// The reader owns the response; dropping it releases an unfinished body.
pub async fn get_object_inner(
&self,
bucket_name: &str,
@@ -120,12 +125,8 @@ impl TransitionClient {
let h = resp.headers().clone();
let body_vec = if let Some(limit) = max_response_bytes {
self.collect_response_body(resp.into_body(), limit).await?
} else {
self.collect_response_body_unbounded(resp.into_body()).await?
};
Ok((object_stat, h, BufReader::new(Cursor::new(body_vec))))
let reader = response_body_reader(resp.into_body(), max_response_bytes, self.timeouts.response_body_idle_timeout);
Ok((object_stat, h, reader))
}
}
@@ -269,9 +270,13 @@ mod bounded_response_tests {
let mut opts = GetObjectOptions::default();
opts.set_range(0, 6).expect("the probe range should be valid");
let err = client
let (_, _, mut reader) = client
.get_object_inner("bucket", "probe", &opts)
.await
.expect("the response headers should produce a reader");
let err = reader
.read_to_end(&mut Vec::new())
.await
.expect_err("an eight-byte response must exceed the seven-byte range limit");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
@@ -391,9 +396,13 @@ mod bounded_response_tests {
let mut opts = GetObjectOptions::default();
opts.set_range(0, 6).expect("the probe range should be valid");
let err = client
let (_, _, mut reader) = client
.get_object_inner("bucket", "probe", &opts)
.await
.expect("the response headers should produce a reader");
let err = reader
.read_to_end(&mut Vec::new())
.await
.expect_err("body stalls after partial progress must be bounded");
assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
+20 -14
View File
@@ -68,7 +68,6 @@ use s3s::dto::ReplicationStatus;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::error::Error as StdError;
use std::io::Cursor;
use std::pin::Pin;
use std::sync::atomic::{AtomicI32, Ordering};
use std::task::{Context, Poll};
@@ -79,7 +78,6 @@ use std::{
};
use time::Duration;
use time::OffsetDateTime;
use tokio::io::BufReader;
use tokio::io::{AsyncRead, AsyncReadExt};
use tracing::{error, trace, warn};
use url::{Url, form_urlencoded};
@@ -185,6 +183,21 @@ where
collect_response_body_inner(body, Some(limit), None).await
}
/// Keep the remote body with its reader; idle and range errors surface while reading.
pub(crate) fn response_body_reader(body: Incoming, limit: Option<usize>, idle_timeout: StdDuration) -> ReadCloser {
let stream = futures::stream::try_unfold((Box::pin(body), 0_usize), move |(mut body, received)| async move {
let Some(data) = next_response_body_data(body.as_mut(), Some(idle_timeout)).await? else {
return Ok::<_, std::io::Error>(None);
};
let received = received
.checked_add(data.len())
.filter(|size| limit.is_none_or(|limit| *size <= limit))
.ok_or_else(response_body_exceeds_limit_error)?;
Ok(Some((data, (body, received))))
});
ReadCloser::new(tokio_util::io::StreamReader::new(Box::pin(stream)))
}
const C_UNKNOWN: i32 = -1;
const C_OFFLINE: i32 = 0;
#[allow(
@@ -217,9 +230,9 @@ fn signer_error_to_io_error(scope: &str, error: rustfs_signer::SignV4Error) -> s
signer_error::signer_error_to_io_error(scope, error)
}
/// Streaming object body handed to the client by the storage engine. The
/// client only ever reads it to completion, so the engine-side reader type
/// (e.g. ecstore's `GetObjectReader`) stays behind this boxed `AsyncRead`.
/// Streaming object body shared by the client and storage engine. The concrete
/// reader (e.g. ecstore's `GetObjectReader` or a remote response body) stays
/// behind this boxed `AsyncRead`.
pub struct ObjectReader(Box<dyn AsyncRead + Send + Sync + Unpin>);
impl ObjectReader {
@@ -255,7 +268,8 @@ pub enum ReaderImpl {
ObjectBody(ObjectReader),
}
pub type ReadCloser = BufReader<Cursor<Vec<u8>>>;
/// Remote object reader. Dropping it releases the response body without collecting it.
pub type ReadCloser = ObjectReader;
#[derive(Debug)]
pub struct TransitionClient {
@@ -817,14 +831,6 @@ impl TransitionClient {
collect_response_body_inner(body, Some(limit), Some(self.timeouts.response_body_idle_timeout)).await
}
pub async fn collect_response_body_unbounded<B>(&self, body: B) -> Result<Vec<u8>, std::io::Error>
where
B: Body<Data = Bytes>,
B::Error: Into<Box<dyn StdError + Send + Sync>>,
{
collect_response_body_inner(body, None, Some(self.timeouts.response_body_idle_timeout)).await
}
async fn new_request(
&self,
method: &http::Method,
@@ -0,0 +1,169 @@
// 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_s3_client::{
api_get_options::GetObjectOptions,
credentials::{Credentials, SignatureType, Static, Value},
transition_api::{BucketLookupType, Options, TransitionClient, TransitionClientTimeouts},
};
use std::time::Duration;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
};
async fn run_case(ranged: bool) -> bool {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind fixture");
let endpoint = listener.local_addr().expect("local endpoint").to_string();
let (headers_sent, headers_ready) = tokio::sync::oneshot::channel();
let (release, released) = tokio::sync::oneshot::channel();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("GET connection");
let mut request = Vec::new();
loop {
let mut buffer = [0_u8; 1024];
let n = socket.read(&mut buffer).await.expect("GET headers");
assert_ne!(n, 0);
request.extend_from_slice(&buffer[..n]);
if request.windows(4).any(|part| part == b"\r\n\r\n") {
break;
}
}
let request = String::from_utf8(request).expect("ASCII HTTP request");
assert!(request.starts_with("GET /bucket/object "), "{request}");
assert_eq!(request.to_ascii_lowercase().contains("\r\nrange: bytes=0-7\r\n"), ranged, "{request}");
let status = if ranged { "206 Partial Content" } else { "200 OK" };
let range_header = if ranged { "Content-Range: bytes 0-7/8\r\n" } else { "" };
let response = format!(
"HTTP/1.1 {status}\r\nContent-Length: 8\r\n{range_header}Content-Type: application/octet-stream\r\nETag: \"fixture\"\r\nConnection: close\r\n\r\nRust"
);
socket.write_all(response.as_bytes()).await.expect("first body bytes");
headers_sent.send(()).expect("signal response head");
released.await.expect("release remaining body");
socket.write_all(b"FS!!").await.expect("last body bytes");
});
let options = Options {
creds: Credentials::new(Static(Value {
access_key_id: "access-key".into(),
secret_access_key: "secret-key".into(),
signer_type: SignatureType::SignatureV4,
..Default::default()
})),
region: "us-east-1".into(),
bucket_lookup: BucketLookupType::BucketLookupAuto,
max_retries: 1,
..Default::default()
};
let client = TransitionClient::new_with_timeouts(&endpoint, options, "s3", TransitionClientTimeouts::default())
.await
.expect("production client");
let client = rustfs_s3_client::transition_api::TransitionCore(std::sync::Arc::new(client));
let mut opts = GetObjectOptions::default();
if ranged {
opts.set_range(0, 7).expect("closed production range");
}
let get = client.get_object("bucket", "object", &opts);
tokio::pin!(get);
let probe = async {
tokio::select! {
result = &mut get => Some(result),
_ = headers_ready => None,
}
}
.await;
let before_eof = match probe {
Some(result) => Ok(result),
None => tokio::time::timeout(Duration::from_secs(2), &mut get).await,
};
let returned_before_body_end = before_eof.is_ok();
let mut body = Vec::new();
let mut reader = match before_eof {
Ok(result) => {
let (_, _, mut reader) = result.expect("streaming GET");
let mut prefix = [0; 4];
tokio::time::timeout(Duration::from_secs(2), reader.read_exact(&mut prefix))
.await
.expect("first bytes must arrive before the remote body ends")
.expect("first body bytes");
body.extend_from_slice(&prefix);
release.send(()).expect("release fixture");
reader
}
Err(_) => {
release.send(()).expect("release fixture");
let (_, _, reader) = get.await.expect("buffered GET after full body");
reader
}
};
reader.read_to_end(&mut body).await.expect("response bytes");
assert_eq!(body, b"RustFS!!");
server.await.expect("fixture server");
returned_before_body_end
}
#[tokio::test]
async fn get_object_returns_reader_before_remote_response_ends() {
let full = run_case(false).await;
let closed_range = run_case(true).await;
assert!(full && closed_range, "tier GET must stream: full={full}, closed_range={closed_range}");
}
#[tokio::test]
async fn dropping_get_object_reader_closes_the_unfinished_response() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind fixture");
let endpoint = listener.local_addr().expect("local endpoint").to_string();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("GET connection");
let mut request = Vec::new();
while !request.windows(4).any(|part| part == b"\r\n\r\n") {
let mut buffer = [0; 1024];
let size = socket.read(&mut buffer).await.expect("GET headers");
assert_ne!(size, 0);
request.extend_from_slice(&buffer[..size]);
}
socket
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\nRust")
.await
.expect("partial response");
let closed = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut [0; 1]))
.await
.expect("dropping the reader must release the remote connection");
match closed {
Ok(size) => assert_eq!(size, 0, "a canceled body must not send another request"),
Err(error) => assert_eq!(error.kind(), std::io::ErrorKind::ConnectionReset),
}
});
let options = Options {
creds: Credentials::new(Static(Value {
access_key_id: "access-key".into(),
secret_access_key: "secret-key".into(),
signer_type: SignatureType::SignatureV4,
..Default::default()
})),
region: "us-east-1".into(),
..Default::default()
};
let client = TransitionClient::new_with_timeouts(&endpoint, options, "s3", TransitionClientTimeouts::default())
.await
.expect("production client");
let (_, _, reader) = tokio::time::timeout(
Duration::from_secs(2),
client.get_object_inner("bucket", "object", &GetObjectOptions::default()),
)
.await
.expect("headers must return before the response completes")
.expect("GET reader");
drop(reader);
server.await.expect("fixture server");
}
+1 -1
View File
@@ -88,7 +88,7 @@ transform-stream.workspace = true
url.workspace = true
[dev-dependencies]
rustfs-test-utils = { workspace = true, features = ["put-object-commit-barrier"] }
rustfs-test-utils = { workspace = true }
serial_test.workspace = true
[lib]
+1 -1
View File
@@ -58,7 +58,7 @@ tracing = { workspace = true }
[dev-dependencies]
flate2.workspace = true
rustfs-test-utils = { workspace = true, features = ["put-object-commit-barrier"] }
rustfs-test-utils = { workspace = true }
[lib]
doctest = false
+2 -2
View File
@@ -34,9 +34,9 @@ For comprehensive documentation, examples, and usage guides, please visit the ma
## Resource limits
JSON LINES schema inference reads the first 1,000 nonblank records, preserving Arrow's type inference and source field order. It buffers at most 4 MiB of decompressed input. Reaching that byte limit before the inference sample or end of the object is an error; RustFS does not truncate a record or silently shorten the sample. CSV inference reads only the first non-comment record (the header when enabled), with the same 4 MiB prefix limit. Quoting, escaping, comments, header modes and record delimiters still apply.
JSON LINES schema inference reads the first 1,000 nonblank records, preserving Arrow's type inference and source field order. It buffers at most 4 MiB of decompressed input. Reaching that byte limit cuts the sample at the last complete record, exactly as a shorter object would; it is an error only when no complete record fits. CSV inference reads only the first non-comment record (the header when enabled), with the same 4 MiB prefix limit. Quoting, escaping, comments, header modes and record delimiters still apply.
CSV schemas have at most 1,024 columns. JSON LINES schemas have at most 1,024 distinct fields across all nested objects in the inference sample. SQL expressions have at most 256 KiB and 1,024 projection expressions; wildcard expansion must also fit 1,024 output columns. Inputs exceeding these limits now return explicit errors, including inputs older versions attempted to process without these bounds. These changes do not alter Parquet schema inference or JSON DOCUMENT input handling.
CSV schemas have at most 1,024 columns. JSON LINES schemas have at most 1,024 distinct fields across all nested objects in the inference sample. SQL expressions have at most 256 KiB and 1,024 projection expressions; wildcard expansion must also fit 1,024 output columns. Inputs exceeding these limits now return explicit errors, including inputs older versions attempted to process without these bounds. Parquet schema inference is unchanged apart from reading string and binary columns as view types; Parquet decoding and downstream execution use one-row batches on a single partition, with batch coalescing disabled to bound dictionary expansion during casts. JSON DOCUMENT input handling is unchanged.
CSV decoding uses at most 256 rows per batch, reduced further for smaller configured query memory limits. At 1,024 columns, Arrow's two-offset-per-cell reservation is at most 4 MiB on 64-bit systems and at most one sixteenth of the configured query budget (with a minimum one-row batch). Schema prefix storage and temporary inference work are reserved against the existing query memory pool. The configured memory limit can reject a sample before the 4 MiB hard prefix limit.
@@ -1898,6 +1898,60 @@ mod tests {
}
}
#[tokio::test]
async fn bounded_json_lines_prefix_accepts_complete_sample_before_byte_limit() {
for rows in [1, 1000, 600] {
let mut input = test_input();
input.request.expression = "SELECT * FROM S3Object LIMIT 1".to_owned();
input.request.input_serialization.csv = None;
input.request.input_serialization.json = Some(JSONInput {
type_: Some(JSONType::from_static(JSONType::LINES)),
});
let input = Arc::new(input);
let optimizer = Arc::new(CascadeOptimizerBuilder::default().build());
let dispatcher = SimpleQueryDispatcherBuilder::default()
.with_input(Arc::clone(&input))
.with_default_table_provider(Arc::new(BaseTableProvider::default()))
.with_session_factory(Arc::new(SessionCtxFactory::new(true)))
.with_parser(Arc::new(DefaultParser::default()))
.with_query_execution_factory(Arc::new(SqlQueryExecutionFactory::new(optimizer, Arc::new(LocalScheduler {}))))
.with_func_manager(Arc::new(SimpleFunctionMetadataManager::default()))
.with_memory_limit_bytes(128 * 1024 * 1024)
.with_query_admission(Arc::new(Semaphore::new(1)))
.with_query_timeout(Duration::from_secs(30))
.build()
.expect("supported 128 MiB query budget");
let query = Query::new(
QueryContext {
input: Arc::clone(&input),
},
input.request.expression.clone(),
);
let machine = dispatcher.build_query_state_machine(query).await.expect("query machine");
let url = ObjectStoreUrl::parse(format!("s3://{}", input.bucket)).expect("store URL");
let store = machine.session.inner().runtime_env().object_store(&url).expect("store");
let payload = "x".repeat(if rows == 1000 { 32 } else { 8192 });
let data = format!("{{\"payload\":\"{payload}\"}}\n").repeat(rows);
store
.put(&Path::from(input.key.as_str()), data.into_bytes().into())
.await
.expect("write valid JSON Lines object");
let plan = dispatcher
.build_logical_plan(Arc::clone(&machine))
.await
.unwrap_or_else(|error| panic!("{rows} valid records must plan from a complete bounded sample: {error}"))
.expect("SELECT plan");
let output = dispatcher.execute_logical_plan(plan, machine).await.expect("execute query");
let mut stream = output.into_record_batch_stream().expect("record batches");
let mut returned = 0;
while let Some(batch) = stream.next().await {
let batch = batch.expect("decode supported records");
returned += batch.num_rows();
}
assert_eq!(returned, 1, "LIMIT 1 yields one supported row for {rows} input records");
}
}
#[tokio::test]
async fn bounded_json_lines_prefix_rejects_overflow_without_truncating_records() {
let mut input = test_input();
@@ -1906,9 +1960,9 @@ mod tests {
input.request.input_serialization.json = Some(JSONInput {
type_: Some(JSONType::from_static(JSONType::LINES)),
});
// Individually valid records remain below the existing 1 MiB record
// limit. The 1000-record sample, however, cannot fit its byte budget.
let data = format!("{{\"payload\":\"{}\"}}\n", "x".repeat(8192)).repeat(600);
// No complete record fits the inference prefix. Do not manufacture a
// partial record for the JSON parser at the byte limit.
let data = format!("{{\"payload\":\"{}\"}}\n", "x".repeat(4 * 1024 * 1024));
let (dispatcher, machine) = bounded_inference_machine(input, data.into_bytes()).await;
let error = dispatcher.build_logical_plan(machine).await.err().expect("prefix limit");
assert!(error.to_string().contains("4 MiB prefix"), "{error}");
@@ -120,8 +120,9 @@ impl PrefixBoundary {
}
}
/// Reads only complete inference records. Hitting the byte bound is an error,
/// never a shorter successful sample that can silently omit later fields.
/// Reads only complete inference records. Hitting the byte bound with no
/// complete record is an error; with at least one complete record the sample
/// is cut there, exactly as a short object or the record count would cut it.
pub(super) async fn read_schema_prefix(
mut stream: BoxStream<'static, std::result::Result<Bytes, ObjectStoreError>>,
memory_pool: &Arc<dyn MemoryPool>,
@@ -135,6 +136,12 @@ pub(super) async fn read_schema_prefix(
while offset < chunk.len() {
let remaining = MAX_SCHEMA_PREFIX_BYTES - bytes.len();
if remaining == 0 {
if let PrefixBoundary::JsonLines { records, line_start } = &boundary
&& *records > 0
{
bytes.truncate(*line_start);
return Ok(SchemaPrefix { bytes, reservation });
}
return Err(prefix_limit_error());
}
let end = chunk.len().min(offset + remaining);
@@ -212,6 +219,65 @@ mod tests {
Arc::new(GreedyMemoryPool::new(64 * 1024 * 1024))
}
#[tokio::test]
async fn bounded_json_prefix_keeps_only_complete_records_at_byte_limit() {
let first = b"{\"a\":1}\n";
let mut input = first.to_vec();
input.extend_from_slice(b"{\"a\":\"");
input.extend(std::iter::repeat_n(b'x', MAX_SCHEMA_PREFIX_BYTES));
input.extend_from_slice(b"\"}\n");
for width in [8192, MAX_SCHEMA_PREFIX_BYTES + 128] {
let chunks = input
.chunks(width)
.map(|chunk| Ok(Bytes::copy_from_slice(chunk)))
.collect::<Vec<_>>();
let prefix = read_schema_prefix(stream::iter(chunks).boxed(), &pool(), PrefixBoundary::json_lines())
.await
.expect("complete first record fits the inference sample");
let schema = super::super::json_lines_schema::infer_schema(&prefix.bytes, DEFAULT_SCHEMA_INFER_MAX_RECORD)
.expect("a complete JSON Lines object must not produce a truncated schema record");
assert_eq!(prefix.bytes, first, "the incomplete second record is outside the sample");
assert_eq!(schema.fields().len(), 1);
}
}
#[tokio::test]
async fn bounded_json_prefix_empty_and_complete_record_controls() {
for input in [b"".as_slice(), b"{\"a\":1}\n".as_slice(), b"{\"a\":1}".as_slice()] {
let prefix = read_schema_prefix(
stream::iter([Ok(Bytes::copy_from_slice(input))]).boxed(),
&pool(),
PrefixBoundary::json_lines(),
)
.await
.expect("short complete input is accepted");
let schema = super::super::json_lines_schema::infer_schema(&prefix.bytes, DEFAULT_SCHEMA_INFER_MAX_RECORD)
.expect("short JSON Lines schema");
assert_eq!(schema.fields().len(), usize::from(!input.is_empty()));
}
}
#[tokio::test]
async fn bounded_schema_prefix_rejects_an_incomplete_first_record() {
for json in [true, false] {
let bytes = if json {
format!("{{\"payload\":\"{}\"}}\n", "x".repeat(MAX_SCHEMA_PREFIX_BYTES))
} else {
format!("\"{}\"\n", "x".repeat(MAX_SCHEMA_PREFIX_BYTES))
};
let boundary = if json {
PrefixBoundary::json_lines()
} else {
PrefixBoundary::csv(&CsvFormat::default())
};
let error = read_schema_prefix(stream::iter([Ok(Bytes::from(bytes))]).boxed(), &pool(), boundary)
.await
.err()
.expect("a partial first record cannot provide an inference sample");
assert!(error.to_string().contains("4 MiB prefix"), "{error}");
}
}
#[tokio::test]
async fn bounded_schema_prefix_stops_polling_after_complete_sample() {
let polls = Arc::new(AtomicUsize::new(0));
@@ -117,6 +117,22 @@ Object keys are stored as file-system paths under each drive (`{drive}/{bucket}/
| Object key with a `.` or `..` path segment, or an empty segment (`//`), such as `a//b/./c/../d` | `400 InvalidArgument` (`check_object_args` in `crates/ecstore/src/bucket/utils.rs`, mirroring MinIO `IsValidObjectPrefix`) | Accepted as an opaque key | A `..` segment would resolve to a parent directory and `.`/`//` segments would alias other keys on disk; encoding them would change the MinIO-compatible on-disk format. |
| Directory marker (key ending in `/`, with or without a body) in a versioned bucket | Stored as the null version: `PutObject`, `CopyObject`, `HeadObject`, and `ListObjectVersions` report version id `null`, and a later PUT of the same key overwrites in place (`put_opts` in `rustfs/src/storage/options.rs`, mirroring MinIO `putOpts`: "for directory objects skip creating new versions") | A real version id per PUT, with a version history | The marker only exists to make an empty prefix listable; keeping a history for it would leave hidden versions behind every prefix delete. Replication still copies the marker as its null version (`test_bucket_replication_replicates_directory_marker_in_versioned_bucket` in `crates/e2e_test/src/replication_extension_test.rs`). |
## Request Validation and Operational Bounds
These behaviors are client-visible and differ from AWS S3 or MinIO. They bound hostile or malformed input; a client that depends on the permissive behavior must adapt.
| Behavior | RustFS | AWS S3 / MinIO | Why |
|---|---|---|---|
| SigV2 configuration PUT (`?acl`, `?policy`, `?lifecycle`, `?versioning`, `?tagging`, `?retention`, `?legal-hold`, and the other bucket/object configuration subresources) without `Content-MD5` and with a non-empty body | `400 MissingContentMD5` (`control_body_digest_required` in `rustfs/src/server/http.rs`); a declared empty body (`Content-Length: 0`) needs no digest | AWS requires `Content-MD5` for most of these APIs; MinIO verifies it only when present | SigV2 signs headers, not the body, so an unsigned configuration body could be replayed with other content. SigV4 keeps the optional-MD5 contract because its payload hash binds the body. |
| `DeleteObjects` without `Content-MD5` or an `x-amz-checksum-*` header | `400 MissingContentMD5` | Same on AWS and MinIO | Parity; a supplied digest must match. |
| Object key containing a C0 control character other than tab, LF, or CR (`PutObject`, `POST`, `CopyObject` destination, `CreateMultipartUpload`, `CompleteMultipartUpload`) | `400 InvalidArgument` | Accepted as an opaque key | Such keys are unprintable in listings and logs and cannot round-trip through XML 1.0 responses. Existing stored keys still load. |
| Lifecycle rule `<ID>` containing a control character (including a tab or line break inside the element) | `400 InvalidArgument` on `PutBucketLifecycleConfiguration` | MinIO validates only the length | The ID is echoed in the `x-amz-expiration` response header; a line break there would inject a header. Stored configurations still load. |
| Bucket tag key or value outside the AWS tag character class (`[\p{L}\p{N}\p{Z}+\-=._:/@]`) | `400 InvalidTag` on `PutBucketTagging` | AWS enforces the same class; MinIO is more permissive | AWS parity. Legacy stored tags still load. |
| `ListObjectVersions` / `ListObjectsV2` with the include-deleted extension | Requires `s3:ListBucketVersions` | Vendor extension | Delete markers and non-current versions are version-listing data. |
| `DeleteObject` of a missing key that names a prefix | `204 NoContent` when the recovery probe of the prefix fits the request budget (`DELETE_RECOVERY_MAX_DISCOVERY_ENTRIES`, 4096 entries per drive, one entry per 64 plain children); `503 SlowDown` beyond that, deterministically, and for the duration of a rolling upgrade while a drive in the set still runs a release without the bounded recovery RPC | `204 NoContent` | The probe looks for interrupted delete transactions under the prefix and is bounded so a hostile prefix cannot pin the request. Prefixes with more than 262,144 direct children per drive, or roughly 1,400 to 4,096 UUID-named children, exceed the bound (rustfs/backlog#3038 residual). |
| `PutObject` / `UploadPart` body delivering less than 64 KiB per 60 seconds of server wait | `400 RequestTimeout` (the minimum-progress floor, logged as `upload_body_progress_stalled`); independent of `RUSTFS_HTTP_REQUEST_BODY_READ_TIMEOUT`, including when that timeout is 0 | No minimum upload rate | A trickle upload otherwise pins upload admission and its buffers indefinitely; roughly 1.1 KiB/s is below any practical client. |
| First write after `SetBucketQuota`, or after the quota changes, on a bucket without an authoritative usage ledger | `503 SlowDown` (retryable) until a background scan of the bucket publishes the exact ledger; a failed scan retries after 30 seconds | AWS has no bucket quota; MinIO admits with unknown usage | Quota admission uses only the authoritative ledger (scanner reporting is informational, see `reporting_retirement` tests), and the request must not hold its bucket metadata guard across a whole-bucket listing. The window is one bucket listing long. |
## Update Rule
When a feature starts passing, move its test entries from `unimplemented_tests.txt` to `implemented_tests.txt` and update the row here in the same PR. Do not change README wording beyond the supported coverage. Handler-level status (missing, stubbed, or diverging endpoints) is tracked in [minio-rustfs-router-compatibility.md](minio-rustfs-router-compatibility.md).
+17 -5
View File
@@ -26,7 +26,7 @@ RustFS S3 Tables is an Iceberg REST Catalog and table-bucket implementation on t
| `/_iceberg/v1` | Supported compatibility alias | MinIO AIStor-style alias; smoke profile defaults to signing name `s3tables`. |
| S3 object data plane | Supported | Data, metadata, manifest, and delete files are ordinary S3 objects with table-aware policy checks on warehouse paths. |
| S3 table-bucket listings | Supported | `ListObjects`, `ListObjectsV2`, `ListObjectVersions`, metadata-list extensions, and multipart-upload listings omit warehouse objects unless the caller has `admin:GetTableMetadata` for that table, and always omit internal `.rustfs-table` keys. Protected cursors are encrypted with a key derived from the cluster root credentials, so rotating those credentials invalidates cursors already issued. |
| Object-backed warehouse-index recovery | Supported with bounded request fallback | Data-plane requests repair or verify a missing warehouse-prefix index against bounded pages totaling at most 4,096 catalog objects. Larger catalogs fail closed with a retryable service error and require `POST /iceberg/v1/{warehouse}/catalog/warehouse-index/backfill` with `admin:MigrateTableCatalog` instead of allowing one S3 request to trigger an unbounded scan. |
| Object-backed warehouse-index recovery | Supported with bounded request fallback | Data-plane requests repair or verify a missing warehouse-prefix index by listing the catalog prefix. Only table entries count toward the 4,096-object limit, so maintenance reports and other per-table objects cannot exhaust it; the listing itself is bounded to 64 pages plus one page per table entry already listed, and after each page the cursor skips the rest of the maintenance subtree the page ended in, so a report flood under one table costs a bounded number of pages. Larger catalogs fail closed with a retryable service error and require operator backfill. An optional table-entry directory (see Maintenance Matrix) answers these lookups without the prefix listing. |
| Table bucket enablement | Supported | A regular bucket is enabled for catalog use and addressed as the REST catalog warehouse. |
| Catalog-vended table credentials | Automated when enabled | Disabled by default. LoadTable vends credentials only when `X-Iceberg-Access-Delegation` contains the exact `vended-credentials` token; the dedicated credentials endpoint uses the same issuer path. |
| AWS S3 Tables endpoint shape | Profile generator | Generates the AWS catalog URI and warehouse ARN shape for migration docs. API parity not claimed. |
@@ -106,12 +106,24 @@ expiration and compaction plan/commit, remains object-backed only. A configured
server has opt-in, allowlisted periodic queue admission; see the
[scheduler runbook](../operations/s3-tables-maintenance-scheduler.md).
Renamed tables retain their original metadata paths. Durable-strong scheduling
and cleanup fail closed with a conflict if another table shares a scanned
metadata or warehouse directory, including a recreated source name after rename.
Such tables require disjoint cleanup roots before maintenance can run; enabling
Renamed tables retain their original metadata paths. Scheduling, planning,
and cleanup on both backings fail closed with a conflict if another table
shares a scanned metadata or warehouse directory, including a recreated source
name after rename. The object backing rechecks this ownership inventory under
the bucket publication fence before every delete pass; the inventory reads every
table entry in the bucket, so maintenance cost grows with the table count. Such
tables require disjoint cleanup roots before maintenance can run; enabling
deletion does not override this ownership check.
Object-backed create, register, and commit publication share one preparation deadline, `RUSTFS_TABLE_CATALOG_PUBLICATION_PREPARATION_TIMEOUT_SECS` (default 30, never below the object lock-acquire timeout, capped at 86,400). Migration and bucket-lock queueing, reference HEAD validation, ownership inventories, metadata parsing, and snapshot preparation consume that same interval; repeated preparation does not renew it. A create writes its initial metadata file before registration without ending the deadline; when registration then fails before any catalog write may have begun, including on expiry, the handler removes that file, so the worst outcome of an expired create is a small orphan under a root no table owns, left by a crash or failed delete between the write and the removal. Expiry before a possible durable catalog write returns HTTP 503 and releases the preparation locks; a valid large operation that cannot finish within the interval keeps failing on retry until the interval is raised. Once a catalog write may have begun, this deadline no longer cancels that transaction. Individual PUT completion and persisted commit/rename recovery retain their existing cancellation guarantees. This is a preparation limit, not a hard bound on disk I/O or the durable tail. The durable-strong backing does not implement this deadline; it is being retired. A rename that the client abandons mid-flight completes or recovers on a detached task, so the bucket's `active_rename_id` is always cleared.
Compaction commits hold inputs to a 512 MiB stored and decoded budget per rewrite group, validate the footer (including the row-group count the decoder would reserve for) and every page header before decoding a page (a recursive or over-deep Iceberg manifest Avro schema and a Parquet page that exceeds its budget are refused with HTTP 400), and run one compaction per node at a time; a concurrent request receives HTTP 503.
### Table-entry directory
An optional per-bucket directory of table entries under `.rustfs.sys` answers data-plane index-miss lookups without listing the catalog prefix. It is off by default: `RUSTFS_TABLE_CATALOG_DIRECTORY_INDEX=true` and `RUSTFS_TABLE_CATALOG_DIRECTORY_INDEX_FLEET_CONFIRMED=true` on every node, then `POST /iceberg/v1/{warehouse}/catalog/directory` (`admin:MigrateTableCatalog`) advances a backfill of 128 catalog objects per call until the directory reports `Ready`; `GET` reports the phase and `DELETE` rolls the directory back. Entry writers add a directory candidate only while the directory exists, and the backfill covers earlier entries. The directory state occupies the backing-migration fence object: nodes without this feature answer catalog mutations on that bucket with a parse error until they are upgraded, which is what the fleet-confirmed flag attests, and a backing migration is refused with a conflict while the directory is active.
Durable-strong rename preserves maintenance configuration, job identifiers,
current/latest aliases, history, audit events, and quarantine state. A persisted
rename intent protects sidecar migration across interruption: the source remains
+1
View File
@@ -75,6 +75,7 @@ Explicit per-operation overrides always win over the profile, so you can select
| `RUSTFS_DRIVE_METADATA_TIMEOUT_SECS` | `5` | `60` | Timeout for metadata reads such as `read_metadata`. |
| `RUSTFS_DRIVE_DISK_INFO_TIMEOUT_SECS` | `5` | `60` | Timeout for `disk_info()` calls. |
| `RUSTFS_OBJECT_DISK_READ_TIMEOUT` | `10` | `60` | Per-read stall budget while streaming an object body from disk. |
| `RUSTFS_GET_DISK_READ_IDLE_TIMEOUT_SECS` | `120` | `30` floor | How long a GET body may make no downstream progress before its disk-read admission is released and the stream fails; a client that resumes within the window is unaffected. The effective value is never below 30 seconds or twice `RUSTFS_OBJECT_DISK_READ_TIMEOUT`. |
| `RUSTFS_CAPACITY_STAT_TIMEOUT` | `3` | `60` | Base cooperative budget for an object-capacity disk scan. |
| `RUSTFS_CAPACITY_MAX_TIMEOUT` | `15` | `60` | Upper bound for the dynamically adjusted object-capacity scan budget. |
| `RUSTFS_DRIVE_MAX_TIMEOUT_DURATION` | `30` | — | Deprecated global fallback for every per-operation knob without an explicit override. Prefer the per-operation knobs. |
@@ -28,8 +28,13 @@
| --- | --- | --- |
| `RUSTFS_REPLICATION_STREAMING_CHECKSUMS` | unset (plain payloads) | `true` or `1` restores SDK trailer checksums (`RequestChecksumCalculation::WhenSupported`). Every streaming upload is then `aws-chunked` with an `x-amz-trailer`, except a single-part PUT that forwards the source's `x-amz-checksum-*` header, which is sent plain so the target does not receive a second algorithm; use only when every target decodes that framing. |
| `RUSTFS_REPLICATION_REPLICA_ETAG_VERIFY` | enabled | `false` or `0` disables the post-PUT ETag comparison for targets whose 32-hex ETags are legitimately not the content MD5. |
| `RUSTFS_REPLICATION_REQUEST_TIMEOUT_SECS` | `60` | SDK operation budget for replication requests, including retries; also the response deadline after an upload finishes sending its body. Values outside `1..=86400` fall back to the default. |
| `RUSTFS_REPLICATION_COMPLETE_TIMEOUT_SECS` | `900` | Independent operation budget for multipart completion, including a target that sends keepalive whitespace without completing. Values outside `1..=86400` fall back to the default. |
| `RUSTFS_REPLICATION_MIN_TRANSFER_RATE_BYTES_PER_SEC` | `1048576` | Rate used to calculate upload transmission headroom. Zero falls back to the default; positive values are accepted. This is a timeout calculation, not a bandwidth throttle. |
Both knobs are read by the RustFS process that owns the replication target, at client build time; restart the server after changing them.
These settings are read by the RustFS process that owns the replication target, at client build time; restart the server after changing them.
PUT and UploadPart use an operation budget of the request timeout plus `ceil(body_bytes / minimum_transfer_rate) * max_attempts` seconds. The current retry policy allows three attempts; the separate response deadline still starts after each upload finishes sending. Multipart completion uses its own completion budget. These defaults apply to every replication target class, including custom S3-compatible endpoints; increase them when a supported target needs longer to acknowledge a request.
### Remote tier transport timeouts
+2 -2
View File
@@ -2,7 +2,7 @@
**Use this when:** a request succeeds against `http://<host>:9000` directly but fails, hangs, or resets through Caddy, Nginx, HAProxy, or Cloudflare.
**Source of truth:** `crates/config/src/constants/tls.rs` (`DEFAULT_HTTP1_HEADER_READ_TIMEOUT`, `DEFAULT_HTTP_REQUEST_BODY_READ_TIMEOUT`); the `put_object_body_read_stalled` log event.
**Source of truth:** `crates/config/src/constants/tls.rs` (`DEFAULT_HTTP1_HEADER_READ_TIMEOUT`, `DEFAULT_HTTP_REQUEST_BODY_READ_TIMEOUT`); the `upload_body_progress_stalled` log event.
RustFS speaks plain S3 over HTTP/1.1 and HTTP/2. Most proxy problems are not RustFS storage bugs: if the same request works directly against `:9000`, the fault is in proxy/CDN request forwarding. Use the checklist below to find which forwarding behavior broke.
@@ -108,7 +108,7 @@ Cloudflare's proxy may buffer the entire request body before forwarding and can
1. Bypass the proxy. Send the failing request to `http://<host>:9000` directly. Success confirms the fault is in the proxy/CDN path.
2. Bypass the CDN, keep the proxy. Point the proxy straight at the origin (Cloudflare grey cloud / direct DNS). If it now works, the CDN was buffering or re-chunking the body.
3. Check idle reuse. Intermittent failures that correlate with upload size are almost always the keep-alive mismatch. Lower the proxy keepalive (or disable it) and retry.
4. Check for a stalled body. A client or intermediary can stop forwarding data without closing the connection. `RUSTFS_HTTP_REQUEST_BODY_READ_TIMEOUT` defaults to 300 seconds. `PutObject` logs `put_object_body_read_stalled` when its body-read guard expires. `UploadPart` logs `upload_part_body_read_stalled` and returns `RequestTimeout` (HTTP 400); its log records `raw_bytes_received`, `expected_decoded_bytes`, `timeout_secs`, bucket, key, and request ID. The event identifies missing input progress, without attributing the cause to a particular proxy.
4. Check for a stalled body. A client or intermediary can stop forwarding data without closing the connection. `RUSTFS_HTTP_REQUEST_BODY_READ_TIMEOUT` defaults to 300 seconds. `PutObject` and `UploadPart` log `upload_body_progress_stalled` and return `RequestTimeout` (HTTP 400) when a body makes no progress for the timeout, or less than 64 KiB of progress in 60 seconds of server wait (the minimum-progress floor, which applies even when the timeout is set to 0); the log records `raw_bytes_received`, `expected_decoded_bytes`, `timeout_secs`, bucket, key, and request ID. The event identifies missing input progress, without attributing the cause to a particular proxy.
5. Compare bytes. Confirm the proxy forwards exactly `Content-Length` body bytes with no compression or transformation.
6. Confirm signed headers survive. `Host` and `x-amz-*` must reach RustFS unchanged; a `SignatureDoesNotMatch` (rather than a hang) points here.
+203
View File
@@ -322,4 +322,207 @@ else
echo "Starting: $*"
fi
BOOTSTRAP_DIR="${RUSTFS_BOOTSTRAP_DIR:-/etc/rustfs/bootstrap}"
BUCKETS_FILE="$BOOTSTRAP_DIR/buckets.json"
bootstrap_credential() {
SOURCE="$1"
LABEL="$2"
case "$SOURCE" in
value:*) printf '%s' "${SOURCE#value:}" ;;
file:*)
FILE="${SOURCE#file:}"
IFS= read -r VALUE < "$FILE" || :
trim_credential "$VALUE"
;;
*)
echo "ERROR: Bootstrap requires an explicit $LABEL or its _FILE variant." >&2
return 1
;;
esac
}
bootstrap_bucket_config() {
BUCKET="$1"
CONFIG_DIR="$2"
CONFIG_FILE="$CONFIG_DIR/$BUCKET.json"
[ -f "$CONFIG_FILE" ] || return 0
if ! jq -e --arg bucket "$BUCKET" 'type == "array" and index($bucket) != null' "$BUCKETS_FILE" >/dev/null; then
echo "ERROR: Bootstrap configuration '$CONFIG_FILE' refers to a bucket absent from buckets.json." >&2
return 1
fi
return 0
}
cleanup_bootstrap() {
if [ -n "${RUSTFS_PID:-}" ]; then
kill "$RUSTFS_PID" 2>/dev/null || true
wait "$RUSTFS_PID" 2>/dev/null || true
fi
if [ -n "${RC_CONFIG_DIR:-}" ]; then
rm -rf "$RC_CONFIG_DIR" 2>/dev/null || true
fi
}
apply_bucket_bootstrap() {
BOOTSTRAP_ENDPOINT="${RUSTFS_BOOTSTRAP_ENDPOINT:-}"
case "$BOOTSTRAP_ENDPOINT" in
http://*|https://*) ;;
*) echo "ERROR: RUSTFS_BOOTSTRAP_ENDPOINT must be an http(s) S3 endpoint when buckets are configured." >&2; return 1 ;;
esac
BOOTSTRAP_ACCESS_KEY=$(bootstrap_credential "$ACCESS_SOURCE" RUSTFS_ACCESS_KEY) || return 1
BOOTSTRAP_SECRET_KEY=$(bootstrap_credential "$SECRET_SOURCE" RUSTFS_SECRET_KEY) || return 1
ENCODED_ACCESS_KEY=$(jq -rn --arg value "$BOOTSTRAP_ACCESS_KEY" '$value | @uri') || return 1
ENCODED_SECRET_KEY=$(jq -rn --arg value "$BOOTSTRAP_SECRET_KEY" '$value | @uri') || return 1
ENDPOINT_AUTHORITY="${BOOTSTRAP_ENDPOINT#*://}"
case "$ENDPOINT_AUTHORITY" in
*/*) ENDPOINT_AUTHORITY="${ENDPOINT_AUTHORITY%%/*}" ;;
esac
case "$BOOTSTRAP_ENDPOINT" in
https://*) RC_HOST_bootstrap="https://${ENCODED_ACCESS_KEY}:${ENCODED_SECRET_KEY}@${ENDPOINT_AUTHORITY}" ;;
http://*) RC_HOST_bootstrap="http://${ENCODED_ACCESS_KEY}:${ENCODED_SECRET_KEY}@${ENDPOINT_AUTHORITY}" ;;
esac
export RC_HOST_bootstrap
RC_CONFIG_DIR=$(mktemp -d "${TMPDIR:-/tmp}/rustfs-bootstrap.XXXXXX") || return 1
export RC_CONFIG_DIR
BUCKETS=$(jq -r '.[]' "$BUCKETS_FILE")
for bucket in $BUCKETS; do
echo "Ensuring bootstrap bucket: $bucket"
/usr/bin/rc bucket create "bootstrap/$bucket" --ignore-existing || return 1
/usr/bin/rc bucket public-access-block set "bootstrap/$bucket" "$BOOTSTRAP_DIR/public-access-block.json" || return 1
done
for bucket in $BUCKETS; do
for config_kind in policies versioning lifecycle cors notifications; do
CONFIG_FILE="$BOOTSTRAP_DIR/$config_kind/$bucket.json"
[ -f "$CONFIG_FILE" ] || continue
bootstrap_bucket_config "$bucket" "$BOOTSTRAP_DIR/$config_kind" || return 1
case "$config_kind" in
policies)
/usr/bin/rc bucket anonymous set-json "$CONFIG_FILE" "bootstrap/$bucket" || return 1
;;
versioning)
VERSIONING=$(jq -r 'if type == "boolean" then . else error("expected a JSON boolean") end' "$CONFIG_FILE") || return 1
if [ "$VERSIONING" = "true" ]; then
/usr/bin/rc bucket version enable "bootstrap/$bucket" || return 1
else
/usr/bin/rc bucket version suspend "bootstrap/$bucket" || return 1
fi
;;
lifecycle)
/usr/bin/rc bucket lifecycle rule import "bootstrap/$bucket" "$CONFIG_FILE" || return 1
;;
cors)
/usr/bin/rc bucket cors set "bootstrap/$bucket" "$CONFIG_FILE" || return 1
;;
notifications)
while IFS="$(printf '\t')" read -r arn events; do
/usr/bin/rc bucket event add "bootstrap/$bucket" "$arn" --event "$events" || return 1
done <<EOF
$(jq -r '.[] | [.arn, (.events | join(","))] | @tsv' "$CONFIG_FILE")
EOF
;;
esac
done
done
return 0
}
validate_bootstrap_assets() {
for config_kind in policies versioning lifecycle cors notifications; do
CONFIG_DIR="$BOOTSTRAP_DIR/$config_kind"
for CONFIG_FILE in "$CONFIG_DIR"/*.json; do
[ -f "$CONFIG_FILE" ] || continue
CONFIG_BUCKET=${CONFIG_FILE##*/}
CONFIG_BUCKET=${CONFIG_BUCKET%.json}
bootstrap_bucket_config "$CONFIG_BUCKET" "$CONFIG_DIR" || return 1
case "$config_kind" in
versioning)
jq -e 'type == "boolean"' "$CONFIG_FILE" >/dev/null || {
echo "ERROR: Versioning configuration '$CONFIG_FILE' must contain a JSON boolean." >&2
return 1
}
;;
notifications)
jq -e 'type == "array" and all(.[]; keys == ["arn", "events"] and (.arn | type == "string" and length > 0) and (.events | type == "array" and length > 0 and all(.[]; type == "string" and length > 0))) and ((map(.arn) | unique | length) == length)' "$CONFIG_FILE" >/dev/null || {
echo "ERROR: Notification configuration '$CONFIG_FILE' must be an array of unique arn/events objects." >&2
return 1
}
;;
esac
done
done
}
if [ "$1" = "/usr/bin/rustfs" ]; then
if [ ! -r "$BUCKETS_FILE" ] || ! jq -e '. as $buckets | (type == "array" and all(.[]; type == "string" and test("^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$") and (contains("..") | not))) and ((unique | length) == ($buckets | length))' "$BUCKETS_FILE" >/dev/null; then
echo "ERROR: $BUCKETS_FILE must be a JSON array of unique, valid S3 bucket names." >&2
exit 1
fi
validate_bootstrap_assets || exit 1
BOOTSTRAP_BUCKET_COUNT=$(jq 'length' "$BUCKETS_FILE")
if [ "$BOOTSTRAP_BUCKET_COUNT" -gt 0 ]; then
[ -r "$BOOTSTRAP_DIR/public-access-block.json" ] || {
echo "ERROR: Missing $BOOTSTRAP_DIR/public-access-block.json." >&2
exit 1
}
if ! jq -e 'type == "object" and keys == ["block_public_acls", "block_public_policy", "ignore_public_acls", "restrict_public_buckets"] and all(.[]; type == "boolean")' "$BOOTSTRAP_DIR/public-access-block.json" >/dev/null; then
echo "ERROR: Public Access Block configuration must define all four boolean flags and no other fields." >&2
exit 1
fi
BOOTSTRAP_ENDPOINT="${RUSTFS_BOOTSTRAP_ENDPOINT:-}"
case "$BOOTSTRAP_ENDPOINT" in
http://*|https://*) ;;
*) echo "ERROR: RUSTFS_BOOTSTRAP_ENDPOINT is required when buckets are configured." >&2; exit 1 ;;
esac
ENDPOINT_AUTHORITY="${BOOTSTRAP_ENDPOINT#*://}"
case "$ENDPOINT_AUTHORITY" in
*/*|*\?*|*\#*|*@*) echo "ERROR: RUSTFS_BOOTSTRAP_ENDPOINT must contain only the scheme, host, and optional port." >&2; exit 1 ;;
esac
bootstrap_credential "$ACCESS_SOURCE" RUSTFS_ACCESS_KEY >/dev/null || exit 1
bootstrap_credential "$SECRET_SOURCE" RUSTFS_SECRET_KEY >/dev/null || exit 1
/usr/bin/rc bucket public-access-block --help >/dev/null 2>&1 || {
echo "ERROR: The bundled RustFS CLI does not support bucket Public Access Block; rebuild with an rc release that includes bootstrap support." >&2
exit 1
}
BOOTSTRAP_TIMEOUT="${RUSTFS_BOOTSTRAP_TIMEOUT:-120}"
case "$BOOTSTRAP_TIMEOUT" in
''|*[!0-9]*|0) echo "ERROR: RUSTFS_BOOTSTRAP_TIMEOUT must be a positive integer number of seconds." >&2; exit 1 ;;
esac
[ "$BOOTSTRAP_TIMEOUT" -le 3600 ] || { echo "ERROR: RUSTFS_BOOTSTRAP_TIMEOUT must not exceed 3600 seconds." >&2; exit 1; }
MAX_BOOTSTRAP_ATTEMPTS=$(((BOOTSTRAP_TIMEOUT + 1) / 2))
"$@" &
RUSTFS_PID=$!
trap cleanup_bootstrap EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
BOOTSTRAP_HEALTH_URL="${BOOTSTRAP_ENDPOINT%/}/health/ready"
attempt=0
while ! curl -fsS "$BOOTSTRAP_HEALTH_URL" >/dev/null 2>&1; do
kill -0 "$RUSTFS_PID" 2>/dev/null || { echo "ERROR: RustFS exited before becoming ready for bootstrap." >&2; exit 1; }
attempt=$((attempt + 1))
[ "$attempt" -lt "$MAX_BOOTSTRAP_ATTEMPTS" ] || { echo "ERROR: RustFS did not become ready for bootstrap at RUSTFS_BOOTSTRAP_ENDPOINT within RUSTFS_BOOTSTRAP_TIMEOUT." >&2; exit 1; }
sleep 2
done
set +e
apply_bucket_bootstrap
BOOTSTRAP_STATUS=$?
set -e
if [ "$BOOTSTRAP_STATUS" -ne 0 ]; then
echo "ERROR: RustFS bootstrap failed." >&2
exit "$BOOTSTRAP_STATUS"
fi
wait "$RUSTFS_PID"
exit $?
fi
fi
exec "$@"
@@ -293,3 +293,46 @@ impl Operation for SyncExternalCatalogBridgeHandler {
build_json_response(StatusCode::OK, &response)
}
}
pub struct GetTableDirectoryHandler {}
#[async_trait::async_trait]
impl Operation for GetTableDirectoryHandler {
async fn call(&self, req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
let warehouse = warehouse_from_params(&params)?;
let resource = TableCatalogResource::warehouse(&warehouse);
authorize_table_catalog_resource_request(&req, &resource, AdminAction::GetTableCatalogAction).await?;
ensure_table_bucket_enabled_from_extensions(&req.extensions, &warehouse).await?;
let store = table_catalog_object_store_from_extensions(&req.extensions)?;
let response = store.table_directory_status(&warehouse).await.map_err(catalog_store_error)?;
build_json_response(StatusCode::OK, &response)
}
}
pub struct AdvanceTableDirectoryHandler {}
#[async_trait::async_trait]
impl Operation for AdvanceTableDirectoryHandler {
async fn call(&self, req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
let warehouse = warehouse_from_params(&params)?;
authorize_table_catalog_request(&req, AdminAction::MigrateTableCatalogAction).await?;
ensure_table_bucket_enabled_from_extensions(&req.extensions, &warehouse).await?;
let store = table_catalog_object_store_from_extensions(&req.extensions)?;
let response = store.advance_table_directory(&warehouse).await.map_err(catalog_store_error)?;
build_json_response(StatusCode::OK, &response)
}
}
pub struct DisableTableDirectoryHandler {}
#[async_trait::async_trait]
impl Operation for DisableTableDirectoryHandler {
async fn call(&self, req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
let warehouse = warehouse_from_params(&params)?;
authorize_table_catalog_request(&req, AdminAction::MigrateTableCatalogAction).await?;
ensure_table_bucket_enabled_from_extensions(&req.extensions, &warehouse).await?;
let store = table_catalog_object_store_from_extensions(&req.extensions)?;
let response = store.disable_table_directory(&warehouse).await.map_err(catalog_store_error)?;
build_json_response(StatusCode::OK, &response)
}
}
+232 -45
View File
@@ -1364,8 +1364,14 @@ enum TableCommitObjectIdentity {
ContentSha256(String),
}
// Legacy objects without a write-unique body identity retain SHA validation,
// with a total byte budget independent of the manifest graph's larger limit.
const TABLE_COMMIT_LEGACY_REVALIDATION_MAX_BYTES: usize = 2 * MAX_ADMIN_REQUEST_BODY_SIZE;
#[derive(Clone, PartialEq, Eq)]
struct TableCommitObservedObject {
body_revision: Option<Uuid>,
content_size: usize,
identity: TableCommitObjectIdentity,
max_size: Option<usize>,
}
@@ -1377,6 +1383,7 @@ struct TableCommitPublicationState {
table_fence: Option<(String, String, String)>,
catalog_migration_read_permit: bool,
observed_objects: BTreeMap<(String, String), TableCommitObservedObject>,
legacy_revalidation_bytes: usize,
guards: Vec<crate::table_catalog::TableCatalogLockGuard>,
}
@@ -1387,6 +1394,7 @@ struct TableCommitObjectBackend<B> {
authorization_error: Arc<tokio::sync::Mutex<Option<S3Error>>>,
publication: Arc<parking_lot::Mutex<TableCommitPublicationState>>,
publication_fence_fleet_confirmed: bool,
preparation: Arc<crate::table_catalog::TablePublicationPreparation>,
}
impl<B> TableCommitObjectBackend<B>
@@ -1400,6 +1408,7 @@ where
authorization_error: Arc::new(tokio::sync::Mutex::new(None)),
publication: Arc::new(parking_lot::Mutex::new(TableCommitPublicationState::default())),
publication_fence_fleet_confirmed,
preparation: Arc::new(crate::table_catalog::TablePublicationPreparation::default()),
}
}
@@ -1445,6 +1454,62 @@ where
)
}
async fn prepare_put_observation(
&self,
bucket: &str,
object: &str,
data: &[u8],
) -> crate::table_catalog::TableCatalogStoreResult<TableCommitObservedObject> {
self.prepare_put_observation_with(bucket, object, data, true).await
}
/// `begins_mutation` is false for a write that precedes the catalog
/// transaction (see `put_object_before_publication`): it is still fenced
/// and authorized, but it leaves the preparation deadline armed.
async fn prepare_put_observation_with(
&self,
bucket: &str,
object: &str,
data: &[u8],
begins_mutation: bool,
) -> crate::table_catalog::TableCatalogStoreResult<TableCommitObservedObject> {
let fence = {
let publication = self.publication.lock();
publication.bucket_fence.as_ref().map(|_| {
crate::table_catalog::TableCatalogObjectMutationFence::from_signals(
publication
.guards
.iter()
.map(crate::table_catalog::TableCatalogLockGuard::lock_lost_signal),
)
})
};
let authorize = self.authorize(bucket, object, S3Action::PutObjectAction);
if fence.is_some() {
self.preparation.read(self, authorize).await?;
} else {
authorize.await?;
}
self.ensure_observation_allowed(bucket, object)?;
let observation = TableCommitObservedObject {
identity: TableCommitObjectIdentity::ContentSha256(hex_sha256(data, str::to_string)),
body_revision: None,
content_size: data.len(),
max_size: Some(data.len()),
};
if let Some(fence) = fence {
// An unfenced candidate write precedes the publication transaction. Only a PUT
// under the bucket fence commits this preparation to finishing its durable tail.
fence.ensure_held()?;
if begins_mutation {
self.preparation.begin_mutation()?;
} else {
self.preparation.check_deadline()?;
}
}
Ok(observation)
}
async fn authorize(&self, bucket: &str, object: &str, action: S3Action) -> crate::table_catalog::TableCatalogStoreResult<()> {
let result = match &self.authorization {
TableCommitObjectAuthorization::Request(req) => {
@@ -1502,31 +1567,62 @@ where
bucket: &str,
object: &str,
observation: TableCommitObservedObject,
enforce_legacy_budget: bool,
) -> crate::table_catalog::TableCatalogStoreResult<()> {
let key = (bucket.to_string(), object.to_string());
let mut publication = self.publication.lock();
if publication.phase == TableCommitPublicationPhase::Complete {
return Ok(());
}
let previous_legacy_bytes = publication.observed_objects.get(&key).map_or(0, |observed| {
if observed.body_revision.is_none() {
observed.content_size
} else {
0
}
});
let legacy_bytes = publication.legacy_revalidation_bytes - previous_legacy_bytes;
let legacy_bytes = legacy_bytes.checked_add(if observation.body_revision.is_none() {
observation.content_size
} else {
0
});
let Some(legacy_bytes) =
legacy_bytes.filter(|bytes| !enforce_legacy_budget || *bytes <= TABLE_COMMIT_LEGACY_REVALIDATION_MAX_BYTES)
else {
return Err(crate::table_catalog::TableCatalogStoreError::Unavailable(
"legacy catalog objects exceed the publication validation budget; rewrite the referenced objects and retry"
.to_string(),
));
};
let Some(existing) = publication.observed_objects.get_mut(&key) else {
if publication.phase != TableCommitPublicationPhase::Discovering {
return Err(crate::table_catalog::TableCatalogStoreError::Internal(format!(
"table commit accessed an unvalidated object after publication preparation: {bucket}/{object}"
)));
}
publication.legacy_revalidation_bytes = legacy_bytes;
publication.observed_objects.insert(key, observation);
return Ok(());
};
if existing.identity != observation.identity {
if existing.identity != observation.identity
|| existing
.body_revision
.zip(observation.body_revision)
.is_some_and(|(before, after)| before != after)
{
return Err(crate::table_catalog::TableCatalogStoreError::Conflict(format!(
"table commit object changed during validation: {bucket}/{object}"
)));
}
existing.body_revision = observation.body_revision;
existing.content_size = observation.content_size;
existing.max_size = match (existing.max_size, observation.max_size) {
(Some(expected), Some(actual)) => Some(expected.min(actual)),
(Some(expected), None) => Some(expected),
(None, actual) => actual,
};
publication.legacy_revalidation_bytes = legacy_bytes;
Ok(())
}
@@ -1538,7 +1634,12 @@ where
None => TableCommitObjectIdentity::Missing,
Some(object) => TableCommitObjectIdentity::ContentSha256(hex_sha256(&object.data, str::to_string)),
};
TableCommitObservedObject { identity, max_size }
TableCommitObservedObject {
identity,
max_size,
body_revision: object.and_then(|object| object.body_revision).filter(|id| !id.is_nil()),
content_size: object.map_or(0, |object| object.data.len()),
}
}
fn observed_metadata(
@@ -1560,10 +1661,18 @@ where
Ok(TableCommitObservedObject {
identity,
max_size: None,
body_revision: None,
content_size: 0,
})
}
async fn begin_bucket_publication(&self, table_bucket: &str) -> crate::table_catalog::TableCatalogStoreResult<()> {
self.preparation
.read(self, self.begin_bucket_publication_inner(table_bucket))
.await
}
async fn begin_bucket_publication_inner(&self, table_bucket: &str) -> crate::table_catalog::TableCatalogStoreResult<()> {
{
let mut publication = self.publication.lock();
if publication.phase != TableCommitPublicationPhase::Discovering {
@@ -1610,6 +1719,17 @@ where
table_bucket: &str,
namespace: &str,
table: &str,
) -> crate::table_catalog::TableCatalogStoreResult<()> {
self.preparation
.read(self, self.prepare_publication_inner(table_bucket, namespace, table))
.await
}
async fn prepare_publication_inner(
&self,
table_bucket: &str,
namespace: &str,
table: &str,
) -> crate::table_catalog::TableCatalogStoreResult<()> {
let namespace = crate::table_catalog::Namespace::parse(namespace)
.map_err(|err| crate::table_catalog::TableCatalogStoreError::Invalid(format!("invalid namespace: {err}")))?;
@@ -1619,6 +1739,12 @@ where
let publication_lock = crate::table_catalog::default_table_publication_lock_path(&namespace, &table);
let expected = {
let mut publication = self.publication.lock();
if publication.legacy_revalidation_bytes > TABLE_COMMIT_LEGACY_REVALIDATION_MAX_BYTES {
return Err(crate::table_catalog::TableCatalogStoreError::Unavailable(
"legacy catalog objects exceed the publication validation budget; rewrite the referenced objects and retry"
.to_string(),
));
}
match publication.phase {
TableCommitPublicationPhase::Prepared if publication.table_fence.as_ref() == Some(&table_fence) => {
return Ok(());
@@ -1668,10 +1794,19 @@ where
if !matches!(&expected_object.identity, TableCommitObjectIdentity::ContentSha256(_)) {
continue;
}
let actual_object = match expected_object.max_size {
Some(max_size) => self.backend.read_object_unlocked_limited(bucket, object, max_size).await?,
None => self.backend.read_object_unlocked(bucket, object).await?,
};
if let Some(revision) = expected_object.body_revision {
let actual = self.backend.object_metadata_unlocked(bucket, object).await?;
if actual.and_then(|metadata| metadata.body_revision) != Some(revision) {
return Err(crate::table_catalog::TableCatalogStoreError::Conflict(format!(
"table commit object changed before catalog publication: {bucket}/{object}"
)));
}
continue;
}
let max_size = expected_object
.max_size
.map_or(expected_object.content_size, |limit| limit.min(expected_object.content_size));
let actual_object = self.backend.read_object_unlocked_limited(bucket, object, max_size).await?;
let actual = Self::observed_content(actual_object.as_ref(), expected_object.max_size);
if actual.identity != expected_object.identity {
return Err(crate::table_catalog::TableCatalogStoreError::Conflict(format!(
@@ -1762,7 +1897,7 @@ where
self.authorize(bucket, object, S3Action::GetObjectAction).await?;
self.ensure_observation_allowed(bucket, object)?;
let result = self.backend.read_object(bucket, object).await?;
self.record_observation(bucket, object, Self::observed_content(result.as_ref(), None))?;
self.record_observation(bucket, object, Self::observed_content(result.as_ref(), None), true)?;
Ok(result)
}
@@ -1775,7 +1910,7 @@ where
self.authorize(bucket, object, S3Action::GetObjectAction).await?;
self.ensure_observation_allowed(bucket, object)?;
let result = self.backend.read_object_limited(bucket, object, max_size).await?;
self.record_observation(bucket, object, Self::observed_content(result.as_ref(), Some(max_size)))?;
self.record_observation(bucket, object, Self::observed_content(result.as_ref(), Some(max_size)), true)?;
Ok(result)
}
@@ -1802,7 +1937,7 @@ where
let metadata = self.backend.object_metadata(bucket, object).await?;
let observation = Self::observed_metadata(bucket, object, metadata)?;
let exists = observation.identity != TableCommitObjectIdentity::Missing;
self.record_observation(bucket, object, observation)?;
self.record_observation(bucket, object, observation, true)?;
Ok(exists)
}
@@ -1817,14 +1952,24 @@ where
data: Vec<u8>,
precondition: crate::table_catalog::TableCatalogPutPrecondition,
) -> crate::table_catalog::TableCatalogStoreResult<()> {
self.authorize(bucket, object, S3Action::PutObjectAction).await?;
self.ensure_observation_allowed(bucket, object)?;
let observation = TableCommitObservedObject {
identity: TableCommitObjectIdentity::ContentSha256(hex_sha256(&data, str::to_string)),
max_size: None,
};
let observation = self.prepare_put_observation(bucket, object, &data).await?;
self.backend.put_object(bucket, object, data, precondition).await?;
self.record_observation(bucket, object, observation)
// The existing generated-metadata readback binds the written bytes to
// their revision. Until then, keep the original SHA expectation and
// enforce the fallback budget before publication preparation.
self.record_observation(bucket, object, observation, false)
}
async fn put_object_before_publication(
&self,
bucket: &str,
object: &str,
data: Vec<u8>,
precondition: crate::table_catalog::TableCatalogPutPrecondition,
) -> crate::table_catalog::TableCatalogStoreResult<()> {
let observation = self.prepare_put_observation_with(bucket, object, &data, false).await?;
self.backend.put_object(bucket, object, data, precondition).await?;
self.record_observation(bucket, object, observation, false)
}
async fn put_object_unlocked(
@@ -1835,16 +1980,11 @@ where
precondition: crate::table_catalog::TableCatalogPutPrecondition,
write_guards: Vec<crate::admin::storage_api::object::WriteCommitGuard>,
) -> crate::table_catalog::TableCatalogStoreResult<()> {
self.authorize(bucket, object, S3Action::PutObjectAction).await?;
self.ensure_observation_allowed(bucket, object)?;
let observation = TableCommitObservedObject {
identity: TableCommitObjectIdentity::ContentSha256(hex_sha256(&data, str::to_string)),
max_size: None,
};
let observation = self.prepare_put_observation(bucket, object, &data).await?;
self.backend
.put_object_unlocked(bucket, object, data, precondition, write_guards)
.await?;
self.record_observation(bucket, object, observation)
self.record_observation(bucket, object, observation, false)
}
async fn delete_object(&self, bucket: &str, object: &str) -> crate::table_catalog::TableCatalogStoreResult<()> {
@@ -1890,6 +2030,10 @@ where
self.backend.acquire_write_lock(bucket, object).await
}
fn table_publication_preparation_budget(&self) -> Arc<crate::table_catalog::TablePublicationPreparation> {
self.preparation.clone()
}
async fn begin_table_bucket_commit_publication(
&self,
table_bucket: &str,
@@ -2522,6 +2666,45 @@ where
.map_err(catalog_store_error)
}
/// A create's initial metadata file sits under a root no table owns until its
/// entry is published, and its name embeds this request's fresh table id, so
/// nothing else can reference it. When registration fails before any catalog
/// write may have begun, remove it; the publication fence is already released,
/// and a failure here (or a crash before this point) leaves a small orphan.
async fn remove_unpublished_initial_metadata(
metadata_backend: &impl crate::table_catalog::TableCatalogObjectBackend,
bucket: &str,
metadata_location: &str,
) {
if crate::table_catalog::TableCommitPublication::preparation_budget(metadata_backend).mutation_started() {
return;
}
let _ = metadata_backend.delete_object(bucket, metadata_location).await;
}
/// Recheck the bucket registered before the publication lock. A missing entry
/// here can indicate retirement while queued; do not recreate it under the fence.
async fn require_table_bucket_for_publication<S>(
store: &S,
bucket: &str,
publication: &(dyn crate::table_catalog::TableCommitPublication + Sync),
) -> S3Result<()>
where
S: crate::table_catalog::TableCatalogStore + ?Sized,
{
let entry = publication
.preparation_budget()
.read(publication, store.get_table_bucket(bucket))
.await
.map_err(catalog_store_error)?;
if entry.is_some_and(|entry| entry.state == crate::table_catalog::TableCatalogEntryState::Active) {
return Ok(());
}
Err(catalog_store_error(crate::table_catalog::TableCatalogStoreError::NotFound(format!(
"table bucket {bucket}"
))))
}
async fn table_bucket_response<S>(store: &S, bucket: &str, enabled: bool) -> S3Result<TableBucketResponse>
where
S: crate::table_catalog::TableCatalogStore + ?Sized,
@@ -6014,11 +6197,11 @@ where
)));
}
let _publication_completion = crate::table_catalog::TableCommitPublicationCompletion::new(metadata_backend);
ensure_table_bucket_entry(store, bucket, true).await?;
require_table_bucket_for_publication(store, bucket, metadata_backend).await?;
let metadata_data = serde_json::to_vec(&metadata)
.map_err(|err| s3_error!(InternalError, "failed to serialize initial table metadata: {}", err))?;
metadata_backend
.put_object(
.put_object_before_publication(
bucket,
&entry.metadata_location,
metadata_data,
@@ -6026,10 +6209,10 @@ where
)
.await
.map_err(catalog_store_already_exists_error)?;
store
.register_table_with_publication(entry.clone(), metadata_backend)
.await
.map_err(catalog_store_already_exists_error)?;
if let Err(err) = store.register_table_with_publication(entry.clone(), metadata_backend).await {
remove_unpublished_initial_metadata(metadata_backend, bucket, &entry.metadata_location).await;
return Err(catalog_store_already_exists_error(err));
}
Ok(create_table_response_from_entry(entry, metadata, false))
}
@@ -6055,7 +6238,7 @@ where
)));
}
let _publication_completion = crate::table_catalog::TableCommitPublicationCompletion::new(metadata_backend);
ensure_table_bucket_entry(store, bucket, true).await?;
require_table_bucket_for_publication(store, bucket, metadata_backend).await?;
let metadata_data = serde_json::to_vec(&metadata)
.map_err(|err| s3_error!(InternalError, "failed to serialize initial view metadata: {}", err))?;
metadata_backend
@@ -6478,15 +6661,18 @@ async fn publish_table_commit<S>(
where
S: crate::table_catalog::TableCatalogStore + ?Sized,
{
let _bucket_publication_completion = if table_bucket_fence_required {
metadata_backend
.begin_table_bucket_commit_publication(&request.table_bucket)
.await
.map_err(catalog_store_error)?;
Some(crate::table_catalog::TableCommitPublicationCompletion::new(metadata_backend))
} else {
None
};
// Real request backends acquire the migration permit here, before the store chooses its
// inline/child route. Nested publication reuses that same permit and preparation budget.
let _bucket_publication_completion =
if table_bucket_fence_required || metadata_backend.acquires_catalog_migration_read_permit() {
metadata_backend
.begin_table_bucket_commit_publication(&request.table_bucket)
.await
.map_err(catalog_store_error)?;
Some(crate::table_catalog::TableCommitPublicationCompletion::new(metadata_backend))
} else {
None
};
store
.commit_table_with_publication(request, metadata_backend)
.await
@@ -6843,6 +7029,7 @@ where
.map_err(catalog_store_error)?;
validate_table_metadata_snapshot_graph(metadata_backend, bucket, &entry, None, &metadata).await?;
ensure_table_bucket_entry(store, bucket, true).await?;
crate::table_catalog::TableCommitPublication::begin_table_bucket(metadata_backend, bucket)
.await
.map_err(catalog_store_error)?;
@@ -6852,12 +7039,12 @@ where
)));
}
let _publication_completion = crate::table_catalog::TableCommitPublicationCompletion::new(metadata_backend);
ensure_table_bucket_entry(store, bucket, true).await?;
require_table_bucket_for_publication(store, bucket, metadata_backend).await?;
let metadata_data = serde_json::to_vec(&metadata).map_err(|err| {
S3Error::with_message(S3ErrorCode::InternalError, format!("failed to serialize initial table metadata: {err}"))
})?;
metadata_backend
.put_object(
.put_object_before_publication(
bucket,
&entry.metadata_location,
metadata_data,
@@ -6865,10 +7052,10 @@ where
)
.await
.map_err(catalog_store_already_exists_error)?;
store
.register_table_with_publication(entry.clone(), metadata_backend)
.await
.map_err(catalog_store_already_exists_error)?;
if let Err(err) = store.register_table_with_publication(entry.clone(), metadata_backend).await {
remove_unpublished_initial_metadata(metadata_backend, bucket, &entry.metadata_location).await;
return Err(catalog_store_already_exists_error(err));
}
Ok(RestCommitTableResponse {
metadata_location: table_metadata_location_for_client(bucket, &entry.metadata_location),
@@ -39,6 +39,21 @@ fn register_table_catalog_prefix_routes(r: &mut S3Router<AdminOperation>, prefix
format!("{prefix}/buckets/{{warehouse}}").as_str(),
AdminOperation(&GET_TABLE_BUCKET_HANDLER),
)?;
r.insert(
Method::GET,
format!("{prefix}/{{warehouse}}/catalog/directory").as_str(),
AdminOperation(&GetTableDirectoryHandler {}),
)?;
r.insert(
Method::POST,
format!("{prefix}/{{warehouse}}/catalog/directory").as_str(),
AdminOperation(&AdvanceTableDirectoryHandler {}),
)?;
r.insert(
Method::DELETE,
format!("{prefix}/{{warehouse}}/catalog/directory").as_str(),
AdminOperation(&DisableTableDirectoryHandler {}),
)?;
r.insert(
Method::GET,
format!("{prefix}/{{warehouse}}/catalog/migration").as_str(),
@@ -56,6 +56,25 @@ impl Operation for RestRenameTableHandler {
ensure_table_bucket_enabled_from_extensions(&req.extensions, &warehouse).await?;
let store = table_catalog_store_from_extensions(&req.extensions)?;
rename_table_response(store, warehouse, source_namespace, source_table, destination_namespace, destination_table).await?;
Ok(empty_response(StatusCode::NO_CONTENT))
}
}
// Authorization has completed before ownership moves to the mutation task. A
// disconnected request must not strand the persisted bucket-wide rename fence.
pub(super) async fn rename_table_response<S>(
store: S,
warehouse: String,
source_namespace: crate::table_catalog::Namespace,
source_table: crate::table_catalog::IdentifierSegment,
destination_namespace: crate::table_catalog::Namespace,
destination_table: crate::table_catalog::IdentifierSegment,
) -> S3Result<()>
where
S: crate::table_catalog::TableCatalogStore + Send + 'static,
{
crate::admin::handlers::supervise_admin_mutation("table rename", async move {
store
.rename_table(
&warehouse,
@@ -65,9 +84,9 @@ impl Operation for RestRenameTableHandler {
destination_table.as_str(),
)
.await
.map_err(catalog_store_error)?;
Ok(empty_response(StatusCode::NO_CONTENT))
}
.map_err(catalog_store_error)
})
.await
}
pub struct RestCreateTableHandler {}
File diff suppressed because it is too large Load Diff
+43 -1
View File
@@ -1132,6 +1132,24 @@ pub const ADMIN_ROUTE_POLICY_SPECS: &[AdminRouteSpec] = &[
GET_TABLE_BUCKET,
RouteRiskLevel::Sensitive,
),
admin(
HttpMethod::Get,
"/iceberg/v1/{warehouse}/catalog/directory",
GET_TABLE_CATALOG,
RouteRiskLevel::Sensitive,
),
admin(
HttpMethod::Post,
"/iceberg/v1/{warehouse}/catalog/directory",
MIGRATE_TABLE_CATALOG,
RouteRiskLevel::High,
),
admin(
HttpMethod::Delete,
"/iceberg/v1/{warehouse}/catalog/directory",
MIGRATE_TABLE_CATALOG,
RouteRiskLevel::High,
),
admin(
HttpMethod::Get,
"/iceberg/v1/{warehouse}/catalog/migration",
@@ -1440,6 +1458,24 @@ pub const ADMIN_ROUTE_POLICY_SPECS: &[AdminRouteSpec] = &[
GET_TABLE_BUCKET,
RouteRiskLevel::Sensitive,
),
admin(
HttpMethod::Get,
"/_iceberg/v1/{warehouse}/catalog/directory",
GET_TABLE_CATALOG,
RouteRiskLevel::Sensitive,
),
admin(
HttpMethod::Post,
"/_iceberg/v1/{warehouse}/catalog/directory",
MIGRATE_TABLE_CATALOG,
RouteRiskLevel::High,
),
admin(
HttpMethod::Delete,
"/_iceberg/v1/{warehouse}/catalog/directory",
MIGRATE_TABLE_CATALOG,
RouteRiskLevel::High,
),
admin(
HttpMethod::Get,
"/_iceberg/v1/{warehouse}/catalog/migration",
@@ -2125,7 +2161,7 @@ mod tests {
let table_specs = ADMIN_ROUTE_POLICY_SPECS
.iter()
.filter(|spec| spec.path().starts_with("/iceberg/v1") || spec.path().starts_with("/_iceberg/v1"));
assert_eq!(table_specs.count(), 106);
assert_eq!(table_specs.count(), 112);
assert_action(HttpMethod::Put, "/iceberg/v1/buckets/{warehouse}", SET_TABLE_BUCKET);
assert_action(HttpMethod::Delete, "/iceberg/v1/buckets/{warehouse}", SET_TABLE_BUCKET);
assert_action(HttpMethod::Delete, "/_iceberg/v1/buckets/{warehouse}", SET_TABLE_BUCKET);
@@ -2304,6 +2340,12 @@ mod tests {
"/_iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}/maintenance/jobs/{job}/quarantine",
RUN_TABLE_MAINTENANCE,
);
assert_action(HttpMethod::Get, "/iceberg/v1/{warehouse}/catalog/directory", GET_TABLE_CATALOG);
assert_action(HttpMethod::Post, "/iceberg/v1/{warehouse}/catalog/directory", MIGRATE_TABLE_CATALOG);
assert_action(HttpMethod::Delete, "/iceberg/v1/{warehouse}/catalog/directory", MIGRATE_TABLE_CATALOG);
assert_action(HttpMethod::Get, "/_iceberg/v1/{warehouse}/catalog/directory", GET_TABLE_CATALOG);
assert_action(HttpMethod::Post, "/_iceberg/v1/{warehouse}/catalog/directory", MIGRATE_TABLE_CATALOG);
assert_action(HttpMethod::Delete, "/_iceberg/v1/{warehouse}/catalog/directory", MIGRATE_TABLE_CATALOG);
assert_action(HttpMethod::Get, "/iceberg/v1/{warehouse}/catalog/migration", GET_TABLE_CATALOG);
assert_action(HttpMethod::Get, "/_iceberg/v1/{warehouse}/catalog/migration", GET_TABLE_CATALOG);
assert_action(HttpMethod::Post, "/iceberg/v1/{warehouse}/catalog/migration", MIGRATE_TABLE_CATALOG);
@@ -514,6 +514,9 @@ fn expected_admin_route_matrix() -> Vec<RouteMatrixEntry> {
table_route_sample(Method::GET, "/{warehouse}/catalog/migration", "/analytics/catalog/migration"),
table_route_sample(Method::POST, "/{warehouse}/catalog/migration", "/analytics/catalog/migration"),
table_route_sample(Method::DELETE, "/{warehouse}/catalog/migration", "/analytics/catalog/migration"),
table_route_sample(Method::GET, "/{warehouse}/catalog/directory", "/analytics/catalog/directory"),
table_route_sample(Method::POST, "/{warehouse}/catalog/directory", "/analytics/catalog/directory"),
table_route_sample(Method::DELETE, "/{warehouse}/catalog/directory", "/analytics/catalog/directory"),
table_route_sample(Method::POST, "/{warehouse}/catalog/backup", "/analytics/catalog/backup"),
table_route_sample(Method::POST, "/{warehouse}/catalog/restore", "/analytics/catalog/restore"),
table_route_sample(
@@ -719,6 +722,9 @@ fn expected_admin_route_matrix() -> Vec<RouteMatrixEntry> {
compat_table_route_sample(Method::GET, "/{warehouse}/catalog/migration", "/analytics/catalog/migration"),
compat_table_route_sample(Method::POST, "/{warehouse}/catalog/migration", "/analytics/catalog/migration"),
compat_table_route_sample(Method::DELETE, "/{warehouse}/catalog/migration", "/analytics/catalog/migration"),
compat_table_route_sample(Method::GET, "/{warehouse}/catalog/directory", "/analytics/catalog/directory"),
compat_table_route_sample(Method::POST, "/{warehouse}/catalog/directory", "/analytics/catalog/directory"),
compat_table_route_sample(Method::DELETE, "/{warehouse}/catalog/directory", "/analytics/catalog/directory"),
compat_table_route_sample(Method::POST, "/{warehouse}/catalog/backup", "/analytics/catalog/backup"),
compat_table_route_sample(Method::POST, "/{warehouse}/catalog/restore", "/analytics/catalog/restore"),
compat_table_route_sample(
+749 -116
View File
File diff suppressed because it is too large Load Diff
+90 -3
View File
@@ -19,6 +19,14 @@ use s3s::header::CONTENT_TYPE;
use s3s::{Body, S3Error, S3ErrorCode, S3Response, S3Result, s3_error};
use serde::Serialize;
use std::collections::HashMap;
use tokio::sync::Semaphore;
// Each compatible request can run two 64 MiB derivations. Keep both CPU and
// memory bounded without occupying runtime workers or queueing unbounded jobs.
static ADMIN_DECRYPT_PERMITS: Semaphore = Semaphore::const_new(2);
/// How long a compat admin request queues for a decryption slot before it is
/// told to retry; a burst of a few requests completes instead of failing.
const ADMIN_DECRYPT_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
/// Returns `true` if `s` contains any whitespace character.
///
@@ -45,9 +53,22 @@ pub(crate) async fn read_compatible_admin_body(
.map_err(|e| s3_error!(InvalidRequest, "failed to read request body: {}", e))?;
if is_compat_admin_request(path) {
decrypt_stream_io(secret_key.as_bytes(), body.as_ref())
.or_else(|_| decrypt_data(secret_key.as_bytes(), body.as_ref()))
.map_err(|e| s3_error!(InvalidRequest, "failed to decrypt MinIO admin payload: {}", e))
let permit = ADMIN_DECRYPT_PERMITS.acquire();
let permit = tokio::time::timeout(ADMIN_DECRYPT_WAIT, permit)
.await
.map_err(|_| s3_error!(SlowDown, "Admin payload decryption is busy"))?
.map_err(|_| s3_error!(SlowDown, "Admin payload decryption is busy"))?;
let secret_key = secret_key.to_owned();
tokio::task::spawn_blocking(move || {
// Cancellation of the request must not release admission while the
// non-cancellable derivation is still running.
let _permit = permit;
decrypt_stream_io(secret_key.as_bytes(), body.as_ref())
.or_else(|_| decrypt_data(secret_key.as_bytes(), body.as_ref()))
.map_err(|e| s3_error!(InvalidRequest, "failed to decrypt MinIO admin payload: {}", e))
})
.await
.map_err(|_| s3_error!(InternalError, "Admin payload decryption failed"))?
} else {
Ok(body.to_vec())
}
@@ -155,6 +176,7 @@ mod tests {
}
#[tokio::test]
#[serial_test::serial(admin_decrypt)]
async fn reads_legacy_compat_payload_as_fallback() {
let payload = b"{\"ok\":true}".to_vec();
let encrypted = encrypt_data(b"secret", &payload).expect("encrypt payload");
@@ -166,6 +188,71 @@ mod tests {
assert_eq!(decoded, payload);
}
#[tokio::test]
#[serial_test::serial(admin_decrypt)]
async fn compatible_admin_decryption_rejects_excess_work_without_blocking_native_requests() {
let permits = ADMIN_DECRYPT_PERMITS
.acquire_many(2)
.await
.expect("reserve decryption capacity");
let error = read_compatible_admin_body(Body::from(vec![0; 64]), 1024, "/minio/admin/v3/add-user", "secret")
.await
.expect_err("excess KDF work must not queue");
assert_eq!(error.code(), &S3ErrorCode::SlowDown);
let plain = read_compatible_admin_body(Body::from(String::from("{}")), 1024, "/rustfs/admin/v3/add-user", "secret")
.await
.expect("native admin does not use KDF capacity");
assert_eq!(plain, b"{}");
drop(permits);
let encrypted = encrypt_stream_io(b"secret", b"{}").expect("encrypt compatible request");
assert_eq!(
read_compatible_admin_body(Body::from(encrypted), 1024, "/minio/admin/v3/add-user", "secret")
.await
.expect("capacity returns after work finishes"),
b"{}"
);
}
#[test]
#[serial_test::serial(admin_decrypt)]
fn cancelled_admin_decryption_keeps_capacity_until_blocking_work_finishes() {
let runtime = tokio::runtime::Builder::new_current_thread()
.max_blocking_threads(1)
.enable_all()
.build()
.expect("build single-worker runtime");
runtime.block_on(async {
let (release, wait) = std::sync::mpsc::channel();
let blocker = tokio::task::spawn_blocking(move || wait.recv().expect("release blocker"));
let request = tokio::spawn(read_compatible_admin_body(
Body::from(vec![0; 64]),
1024,
"/minio/admin/v3/add-user",
"secret",
));
tokio::task::yield_now().await;
assert_eq!(
ADMIN_DECRYPT_PERMITS.available_permits(),
1,
"decryption was queued off the runtime worker"
);
request.abort();
assert!(request.await.expect_err("request cancelled").is_cancelled());
assert_eq!(
ADMIN_DECRYPT_PERMITS.available_permits(),
1,
"cancellation must not release queued work admission"
);
release.send(()).expect("unblock worker");
blocker.await.expect("blocking task finished");
let all = ADMIN_DECRYPT_PERMITS
.acquire_many(2)
.await
.expect("decryption finished and released capacity");
drop(all);
});
}
async fn body_bytes(mut body: Body) -> Vec<u8> {
body.store_all_limited(64 * 1024).await.expect("body should read").to_vec()
}
+214 -57
View File
@@ -54,7 +54,7 @@ use super::storage_api::bucket_usecase::contract::list::{
};
use super::storage_api::bucket_usecase::error::StorageError;
use super::storage_api::bucket_usecase::helper::{OperationHelper, spawn_background_with_context};
use super::storage_api::bucket_usecase::object_utils::to_s3s_etag;
use super::storage_api::bucket_usecase::object_utils::to_s3s_xml_etag;
use super::storage_api::bucket_usecase::s3_api::bucket::{
ListObjectVersionsParams, ListObjectsV2Params, build_list_buckets_output, build_list_object_versions_output,
build_list_objects_output, build_list_objects_v2_output, encode_list_objects_v2_continuation_token,
@@ -966,6 +966,12 @@ fn encode_list_objects_v2_value(value: &str, encoding_type: Option<&EncodingType
}
fn build_metadata_extension_user_metadata(user_defined: &HashMap<String, String>) -> Option<UserMetadata> {
// Even builds unable to decrypt MinIO SSE-C must not expose its key-protected metadata.
if super::storage_api::bucket_usecase::sse::stored_ssec_metadata(user_defined)
|| user_defined.contains_key(rustfs_utils::http::object_encryption_keys::MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER)
{
return None;
}
let mut items = extract_user_defined_metadata(user_defined)
.into_iter()
.filter(|(key, _)| !key.is_empty())
@@ -1125,7 +1131,7 @@ fn build_list_object_versions_metadata_output(
.etag
.as_ref()
.filter(|_| rustfs_utils::http::etag_can_be_listed(&object.user_defined))
.map(|etag| to_s3s_etag(etag)),
.map(|etag| to_s3s_xml_etag(etag)),
storage_class: Some(ObjectVersionStorageClass::from(
object
.storage_class
@@ -1216,7 +1222,7 @@ fn build_list_objects_v2_metadata_output(
.etag
.as_ref()
.filter(|_| rustfs_utils::http::etag_can_be_listed(&object.user_defined))
.map(|etag| to_s3s_etag(etag)),
.map(|etag| to_s3s_xml_etag(etag)),
storage_class: Some(ObjectStorageClass::from(
object
.storage_class
@@ -1267,19 +1273,22 @@ fn build_list_objects_v2_metadata_output(
}
}
fn create_bucket_exists_response(is_owner: bool, region: &str) -> S3Result<S3Response<CreateBucketOutput>> {
if is_owner && region == RUSTFS_REGION {
// AWS preserves the legacy idempotent response for buckets recreated in
// us-east-1 (the default RustFS region).
/// Answer a `CreateBucket` for a bucket that already exists.
///
/// A deployment owns one bucket namespace, so every requester that passed
/// authorization belongs to the same owner as the bucket's creator; there is no
/// second account that could trigger `BucketAlreadyExists`. That leaves the AWS
/// account-owner semantics: the default region returns the legacy idempotent
/// `200 OK`, any other region returns `BucketAlreadyOwnedByYou`.
fn create_bucket_exists_response(region: &str) -> S3Result<S3Response<CreateBucketOutput>> {
if region == RUSTFS_REGION {
return Ok(S3Response::new(CreateBucketOutput::default()));
}
let error_code = if is_owner {
S3ErrorCode::BucketAlreadyOwnedByYou
} else {
S3ErrorCode::BucketAlreadyExists
};
Err(S3Error::with_message(error_code, "The requested bucket already exists."))
Err(S3Error::with_message(
S3ErrorCode::BucketAlreadyOwnedByYou,
"The requested bucket already exists.",
))
}
fn resolve_notification_region(global_region: Option<Region>, request_region: Option<Region>) -> String {
@@ -1460,14 +1469,12 @@ impl DefaultBucketUsecase {
current_object_store_handle_for_context(self.context.as_deref())
}
fn create_bucket_exists_result(&self, requester_is_owner: bool) -> S3Result<S3Response<CreateBucketOutput>> {
// Match AWS CreateBucket semantics: an owned bucket is idempotent in
// us-east-1 and returns BucketAlreadyOwnedByYou elsewhere.
fn create_bucket_exists_result(&self) -> S3Result<S3Response<CreateBucketOutput>> {
let region = self
.global_region()
.map(|region| region.to_string())
.unwrap_or_else(|| RUSTFS_REGION.to_string());
create_bucket_exists_response(requester_is_owner, &region)
create_bucket_exists_response(&region)
}
#[instrument(
@@ -1497,7 +1504,7 @@ impl DefaultBucketUsecase {
// An existing bucket needs no mutation. Answer before the mutation queue and the
// bucket's exclusive locks, which wait for every in-flight object write.
if let Ok(req_info) = req_info_ref(&req)
if req_info_ref(&req).is_ok()
&& let Some(store) = self.object_store()
&& store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
@@ -1505,7 +1512,7 @@ impl DefaultBucketUsecase {
.is_ok()
{
let helper = OperationHelper::new(&req, EventName::BucketCreated, S3Operation::CreateBucket);
let result = self.create_bucket_exists_result(req_info.is_owner);
let result = self.create_bucket_exists_result();
let _ = helper.complete(&result);
return result;
}
@@ -1523,12 +1530,9 @@ impl DefaultBucketUsecase {
) -> S3Result<S3Response<CreateBucketOutput>> {
let helper = OperationHelper::new(&req, EventName::BucketCreated, S3Operation::CreateBucket);
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
let requester_is_owner = match req_info_ref(&req) {
Ok(r) => r.is_owner,
Err(_) => {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Missing request info".to_string()));
}
};
if req_info_ref(&req).is_err() {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Missing request info".to_string()));
}
let CreateBucketInput {
bucket,
object_lock_enabled_for_bucket,
@@ -1588,7 +1592,7 @@ impl DefaultBucketUsecase {
.await;
}
Err(StorageError::BucketExists(_)) => {
let result = self.create_bucket_exists_result(requester_is_owner);
let result = self.create_bucket_exists_result();
let _ = helper.complete(&result);
return result;
}
@@ -1732,7 +1736,7 @@ impl DefaultBucketUsecase {
Ok(S3Response::new(GetBucketLocationOutput { location_constraint }))
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_list_buckets(&self, req: S3Request<ListBucketsInput>) -> S3Result<S3Response<ListBucketsOutput>> {
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
@@ -1799,7 +1803,7 @@ impl DefaultBucketUsecase {
Ok(S3Response::with_status(DeleteBucketEncryptionOutput::default(), StatusCode::NO_CONTENT))
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_delete_bucket_cors(
&self,
req: S3Request<DeleteBucketCorsInput>,
@@ -1831,7 +1835,7 @@ impl DefaultBucketUsecase {
Ok(S3Response::new(DeleteBucketCorsOutput {}))
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_delete_bucket_lifecycle(
&self,
req: S3Request<DeleteBucketLifecycleInput>,
@@ -1976,7 +1980,7 @@ impl DefaultBucketUsecase {
Ok(S3Response::new(DeleteBucketReplicationOutput::default()))
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_delete_bucket_tagging(
&self,
req: S3Request<DeleteBucketTaggingInput>,
@@ -2003,7 +2007,7 @@ impl DefaultBucketUsecase {
Ok(S3Response::new(DeleteBucketTaggingOutput {}))
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_delete_public_access_block(
&self,
req: S3Request<DeletePublicAccessBlockInput>,
@@ -2068,7 +2072,7 @@ impl DefaultBucketUsecase {
}))
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_get_bucket_cors(&self, req: S3Request<GetBucketCorsInput>) -> S3Result<S3Response<GetBucketCorsOutput>> {
let GetBucketCorsInput { bucket, .. } = req.input;
@@ -2107,7 +2111,7 @@ impl DefaultBucketUsecase {
}))
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_get_bucket_lifecycle_configuration(
&self,
req: S3Request<GetBucketLifecycleConfigurationInput>,
@@ -2348,7 +2352,7 @@ impl DefaultBucketUsecase {
}))
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_get_bucket_tagging(
&self,
req: S3Request<GetBucketTaggingInput>,
@@ -2385,7 +2389,7 @@ impl DefaultBucketUsecase {
Ok(S3Response::new(GetBucketTaggingOutput { tag_set }))
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_get_public_access_block(
&self,
req: S3Request<GetPublicAccessBlockInput>,
@@ -2419,7 +2423,7 @@ impl DefaultBucketUsecase {
}))
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_get_bucket_versioning(
&self,
req: S3Request<GetBucketVersioningInput>,
@@ -2504,7 +2508,7 @@ impl DefaultBucketUsecase {
Ok(S3Response::new(PutBucketEncryptionOutput::default()))
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_put_bucket_lifecycle_configuration(
&self,
req: S3Request<PutBucketLifecycleConfigurationInput>,
@@ -2752,7 +2756,7 @@ impl DefaultBucketUsecase {
Ok(S3Response::new(PutBucketPolicyOutput {}))
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_put_bucket_cors(&self, req: S3Request<PutBucketCorsInput>) -> S3Result<S3Response<PutBucketCorsOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
@@ -2844,7 +2848,7 @@ impl DefaultBucketUsecase {
Ok(S3Response::new(PutBucketReplicationOutput::default()))
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_put_public_access_block(
&self,
req: S3Request<PutPublicAccessBlockInput>,
@@ -2876,7 +2880,7 @@ impl DefaultBucketUsecase {
Ok(S3Response::new(PutPublicAccessBlockOutput::default()))
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_put_bucket_tagging(
&self,
req: S3Request<PutBucketTaggingInput>,
@@ -2884,6 +2888,7 @@ impl DefaultBucketUsecase {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
let PutBucketTaggingInput { bucket, tagging, .. } = req.input;
super::storage_api::bucket_usecase::s3_api::tagging::validate_bucket_tag_set(&tagging.tag_set)?;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
@@ -2915,7 +2920,7 @@ impl DefaultBucketUsecase {
Ok(S3Response::new(PutBucketTaggingOutput::default()))
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_put_bucket_versioning(
&self,
req: S3Request<PutBucketVersioningInput>,
@@ -4477,20 +4482,14 @@ mod tests {
}
#[test]
fn create_bucket_exists_response_returns_ok_for_owner() {
let response = create_bucket_exists_response(true, RUSTFS_REGION).expect("owner recreate in us-east-1 should succeed");
fn create_bucket_exists_response_returns_ok_in_default_region() {
let response = create_bucket_exists_response(RUSTFS_REGION).expect("recreate in us-east-1 should succeed");
assert_eq!(response.output.location, None);
}
#[test]
fn create_bucket_exists_response_returns_bucket_already_exists_for_non_owner() {
let err = create_bucket_exists_response(false, RUSTFS_REGION).expect_err("non-owner recreate should fail");
assert_eq!(err.code(), &S3ErrorCode::BucketAlreadyExists);
}
#[test]
fn create_bucket_exists_response_returns_owned_by_you_outside_us_east_1() {
let err = create_bucket_exists_response(true, "eu-west-1").expect_err("owner recreate outside us-east-1 should fail");
fn create_bucket_exists_response_returns_owned_by_you_outside_default_region() {
let err = create_bucket_exists_response("eu-west-1").expect_err("recreate outside us-east-1 should report ownership");
assert_eq!(err.code(), &S3ErrorCode::BucketAlreadyOwnedByYou);
}
@@ -4553,9 +4552,11 @@ mod tests {
.expect("hold the bucket lifecycle lock");
let usecase = DefaultBucketUsecase::with_context(Some(context));
for (is_owner, location, expected_error) in [
// The requester's owner flag must not change the duplicate response:
// one deployment has a single bucket owner.
for (requester_is_owner, location, expected_error) in [
(true, None, None),
(false, None, Some(S3ErrorCode::BucketAlreadyExists)),
(false, None, None),
(true, Some("ap-south-1"), Some(S3ErrorCode::InvalidLocationConstraint)),
] {
let input = CreateBucketInput {
@@ -4570,7 +4571,7 @@ mod tests {
input,
Method::PUT,
ReqInfo {
is_owner,
is_owner: requester_is_owner,
..Default::default()
},
);
@@ -4578,9 +4579,9 @@ mod tests {
.await
.expect("CreateBucket on an existing bucket must not wait for the bucket lifecycle lock");
match expected_error {
Some(code) => assert_eq!(result.expect_err("duplicate or invalid request must fail").code(), &code),
Some(code) => assert_eq!(result.expect_err("invalid request must fail").code(), &code),
None => {
result.expect("the owner recreating a bucket in us-east-1 gets the idempotent success");
result.expect("recreating a bucket in us-east-1 returns the idempotent success");
}
}
}
@@ -5148,7 +5149,10 @@ mod tests {
assert!(listings.contents[0].object.e_tag.is_none());
assert!(listings.contents[1].object.e_tag.is_some());
assert!(listings.contents[2].object.e_tag.is_some());
assert_eq!(listings.contents[3].object.e_tag, Some(to_s3s_etag("1b8a77dff5845270a8d81d86a28f38ea-2")));
assert_eq!(
listings.contents[3].object.e_tag,
Some(to_s3s_xml_etag("1b8a77dff5845270a8d81d86a28f38ea-2"))
);
let xml = String::from_utf8(serialize_config(&listings).expect("serialize objects")).expect("UTF-8 objects");
assert!(!xml.contains(legacy_md5));
@@ -5178,6 +5182,159 @@ mod tests {
assert!(!xml.contains(legacy_md5));
}
#[test]
#[serial_test::serial]
fn put_bucket_tagging_rejects_invalid_shapes_before_storage_mutation() {
crate::app::gating_test_env::run_large_stack_test("bucket-tag-validation", || async {
use crate::app::runtime_sources::{AppContext, ServerContextSlot};
use crate::app::storage_api::test::ReqInfo;
use crate::app::storage_api::test::ecfs::FS;
use s3s::access::S3Access as _;
let ambient = crate::app::gating_test_env::shared_gating_ambient().await;
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
let context = Arc::new(AppContext::new(store.clone(), ambient.iam(), ambient.kms()));
let slot = ServerContextSlot::new();
assert!(slot.install(context.clone()));
let fs = FS::with_server_ctx(slot.clone());
let usecase = DefaultBucketUsecase::with_context(Some(context));
let bucket = format!("tag-validation-{}", uuid::Uuid::new_v4().simple());
store.make_bucket(&bucket, &MakeBucketOptions::default()).await.unwrap();
store
.update_bucket_metadata_config(
&bucket,
BUCKET_POLICY_CONFIG,
serde_json::to_vec(
&serde_json::json!({"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},
"Action":"s3:PutBucketTagging","Resource":format!("arn:aws:s3:::{bucket}")}]}),
)
.unwrap(),
)
.await
.unwrap();
let tag = s3s::dto::Tag {
key: Some("team".to_owned()),
value: Some("storage".to_owned()),
};
for (tag_set, valid) in [
(vec![tag.clone()], true),
(
vec![s3s::dto::Tag {
key: Some("team".to_owned()),
value: None,
}],
false,
),
(vec![tag.clone(), tag], false),
] {
let input = PutBucketTaggingInput::builder()
.bucket(bucket.clone())
.tagging(s3s::dto::Tagging { tag_set })
.build()
.unwrap();
let mut req = build_request(input, Method::PUT);
req.extensions.insert(ReqInfo::default());
req.extensions.insert(slot.clone());
fs.put_bucket_tagging(&mut req).await.expect("permission allows tag updates");
let result = usecase.execute_put_bucket_tagging(req).await;
if valid {
result.expect("valid tags are persisted");
} else {
assert_eq!(result.unwrap_err().code(), &S3ErrorCode::InvalidTag);
}
assert_eq!(
store.get_bucket_tags_for_policy(&bucket).await.unwrap(),
HashMap::from([("team".to_owned(), "storage".to_owned())])
);
}
});
}
#[test]
fn metadata_listings_hide_ssec_user_metadata_even_with_read_permission() {
let objects = [
("customer-key", Some(("x-amz-server-side-encryption-customer-algorithm", "AES256"))),
(
"minio-customer-key",
Some((
rustfs_utils::http::object_encryption_keys::MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER,
"sealed-key",
)),
),
("server-key", Some(("x-amz-server-side-encryption", "AES256"))),
("plain", None),
]
.into_iter()
.map(|(name, encryption)| {
let mut metadata = HashMap::from([("project".to_owned(), "private-project".to_owned())]);
if let Some((key, value)) = encryption {
metadata.insert(key.to_owned(), value.to_owned());
}
ObjectInfo {
name: name.to_owned(),
user_defined: Arc::new(metadata),
..Default::default()
}
})
.collect::<Vec<_>>();
let permissions = objects
.iter()
.map(|object| {
(
object.name.clone(),
ObjectMetadataPermissions {
metadata_allowed: true,
tags_allowed: true,
},
)
})
.collect::<HashMap<_, _>>();
let listings = build_list_objects_v2_metadata_output(
ListObjectsV2Info {
objects: objects.clone(),
..Default::default()
},
"demo-bucket",
&ListObjectsV2Params {
prefix: String::new(),
max_keys: 1000,
delimiter: None,
response_start_after: None,
start_after_for_query: None,
response_continuation_token: None,
decoded_continuation_token: None,
},
None,
false,
&permissions,
);
assert!(listings.contents[0].extension.user_metadata.is_none());
assert!(listings.contents[1].extension.user_metadata.is_none());
assert!(listings.contents[2].extension.user_metadata.is_some());
assert!(listings.contents[3].extension.user_metadata.is_some());
let versions = build_list_object_versions_metadata_output(
ListObjectVersionsInfo {
objects,
..Default::default()
},
"demo-bucket",
&ListObjectVersionsParams {
prefix: String::new(),
max_keys: 1000,
delimiter: None,
key_marker: None,
version_id_marker: None,
},
None,
&permissions,
);
for (index, entry) in versions.entries.iter().enumerate() {
let ListObjectVersionMetadataEntry::Version(_, extension) = entry else {
panic!("expected version");
};
assert_eq!(extension.user_metadata.is_some(), index >= 2);
}
}
#[test]
fn build_list_object_versions_metadata_output_maps_metadata_and_preserves_entry_order() {
use time::macros::datetime;
+3
View File
@@ -158,6 +158,9 @@ pub(crate) async fn durable_quota_test_bucket(prefix: &str, limit: u64) -> (Arc<
.set_quota_config(&bucket, BucketQuota::new(Some(limit)))
.await
.expect("configure durable quota test bucket");
super::storage_api::test::set_disk::initialize_quota_ledger_for_test(Arc::clone(&store), &bucket)
.await
.expect("bootstrap authoritative quota usage");
(store, bucket)
}
+197 -6
View File
@@ -18,13 +18,13 @@ use super::bucket_usecase::DefaultBucketUsecase;
use super::object_usecase::DefaultObjectUsecase;
use crate::app::runtime_sources::ServerContextSlot;
use crate::auth::{check_key_valid, get_session_token};
use crate::storage::access::{ReqInfo, authorize_request, req_info_mut};
use crate::storage::access::{ReqInfo, authorize_bucket_listing, req_info_mut};
use crate::storage::{get_bucket_website_config_for_store, validate_website_configuration};
use async_trait::async_trait;
use http::header::CONTENT_TYPE;
use http::header::HOST;
use http::{Extensions, HeaderMap, HeaderValue, Method, StatusCode, Uri};
use rustfs_policy::policy::action::{Action, S3Action};
use rustfs_policy::policy::action::S3Action;
use s3s::access::S3Access;
use s3s::dto::{
ETagCondition, EncodingType, GetObjectInput, GetObjectOutput, HeadObjectInput, ListObjectVersionsInput, ListObjectsV2Input,
@@ -674,14 +674,14 @@ async fn check_metadata_access(req: &mut S3Request<Body>, target: BucketTarget)
let req_info = req_info_mut(req)?;
req_info.cred = cred;
req_info.is_owner = is_owner;
req_info.bucket = Some(target.bucket);
req_info.bucket = Some(target.bucket.clone());
}
let action = match target.operation {
MetadataOperation::ListObjectVersions => Action::S3Action(S3Action::ListBucketVersionsAction),
MetadataOperation::ListObjectsV2 => Action::S3Action(S3Action::ListBucketAction),
MetadataOperation::ListObjectVersions => S3Action::ListBucketVersionsAction,
MetadataOperation::ListObjectsV2 => S3Action::ListBucketAction,
};
authorize_request(req, action).await
authorize_bucket_listing(req, &target.bucket, action).await
}
async fn call_list_object_versions(req: S3Request<Body>, bucket: String) -> S3Result<S3Response<Body>> {
@@ -838,6 +838,197 @@ fn header_value(headers: &HeaderMap, name: &str) -> S3Result<Option<String>> {
#[cfg(test)]
mod tests {
#[tokio::test]
async fn metadata_route_prepares_and_applies_table_listing_isolation() {
use crate::app::runtime_sources::{AppContext, IamInterface, KmsInterface, ServerContextSlot};
use crate::app::storage_api::bucket_usecase::contract::bucket::{BucketOperations as _, MakeBucketOptions};
use crate::app::storage_api::object_usecase::access::TableDataPlaneListAccess;
use crate::app::storage_api::object_usecase::contract::object::ObjectIO as _;
use crate::app::storage_api::object_usecase::{StorageObjectOptions, StoragePutObjReader};
use crate::app::table_list_isolation;
use crate::table_catalog::{
ConfiguredTableCatalogStore, EcStoreTableCatalogObjectBackend, NamespaceEntry, TABLE_BUCKET_CATALOG_TYPE,
TABLE_BUCKET_MARKER_CONFIG, TABLE_CATALOG_ENTRY_VERSION, TableBucketEntry, TableCatalogBackingMode,
TableCatalogEntryState, TableCatalogStore, TableEntry,
};
use std::{collections::BTreeMap, sync::Arc};
struct UnreadyIam;
impl IamInterface for UnreadyIam {
fn handle(&self) -> Arc<rustfs_iam::sys::IamSys<rustfs_iam::store::object::ObjectStore>> {
panic!("anonymous listing must not resolve IAM credentials")
}
fn is_ready(&self) -> bool {
false
}
}
struct TestKms;
impl KmsInterface for TestKms {
fn handle(&self) -> Arc<rustfs_kms::KmsServiceManager> {
Arc::new(rustfs_kms::KmsServiceManager::new())
}
}
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
let bucket = format!("metadata-isolation-{}", uuid::Uuid::new_v4());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create table bucket");
store
.update_bucket_metadata_config(
&bucket,
TABLE_BUCKET_MARKER_CONFIG,
crate::table_catalog::table_bucket_marker_json().expect("table bucket marker"),
)
.await
.expect("enable table bucket");
let policy = serde_json::json!({"Version": "2012-10-17", "Statement": [{
"Effect": "Allow", "Principal": {"AWS": "*"},
"Action": ["s3:ListBucket", "s3:ListBucketVersions"], "Resource": format!("arn:aws:s3:::{bucket}")
}]});
store
.update_bucket_metadata_config(
&bucket,
crate::storage::storage_api::ecstore_bucket::metadata::BUCKET_POLICY_CONFIG,
serde_json::to_vec(&policy).expect("listing policy"),
)
.await
.expect("allow anonymous object listings");
let context = Arc::new(AppContext::new(Arc::clone(&store), Arc::new(UnreadyIam), Arc::new(TestKms)));
assert!(context.publish_action_credentials(rustfs_credentials::Credentials {
access_key: "table-list-root".to_owned(),
secret_key: "table-list-root-secret".to_owned(),
status: "on".to_owned(),
..Default::default()
}));
let backend =
EcStoreTableCatalogObjectBackend::new_with_strong_runtime(Arc::clone(&store), context.table_catalog_strong_runtime());
let catalog =
ConfiguredTableCatalogStore::new_for_test(backend, TableCatalogBackingMode::from_env().expect("catalog mode"));
catalog
.put_table_bucket(TableBucketEntry {
version: TABLE_CATALOG_ENTRY_VERSION,
table_bucket: bucket.clone(),
catalog_type: TABLE_BUCKET_CATALOG_TYPE.to_owned(),
warehouse_root: format!("s3://{bucket}/"),
state: TableCatalogEntryState::Active,
properties: BTreeMap::new(),
active_rename_id: None,
created_at: None,
updated_at: None,
})
.await
.expect("seed catalog bucket");
catalog
.create_namespace(NamespaceEntry {
version: TABLE_CATALOG_ENTRY_VERSION,
table_bucket: bucket.clone(),
namespace: "sales".to_owned(),
namespace_id: "sales".to_owned(),
state: TableCatalogEntryState::Active,
properties: BTreeMap::new(),
created_at: None,
updated_at: None,
})
.await
.expect("seed catalog namespace");
catalog
.create_table(TableEntry {
version: TABLE_CATALOG_ENTRY_VERSION,
table_bucket: bucket.clone(),
namespace: "sales".to_owned(),
table: "orders".to_owned(),
table_id: "table-id".to_owned(),
table_uuid: uuid::Uuid::new_v4().to_string(),
format: "ICEBERG".to_owned(),
format_version: 2,
warehouse_location: format!("s3://{bucket}/tables/table-id"),
metadata_location: format!("s3://{bucket}/tables/table-id/metadata.json"),
version_token: "v1".to_owned(),
generation: 1,
state: TableCatalogEntryState::Active,
properties: BTreeMap::new(),
created_at: None,
updated_at: None,
})
.await
.expect("seed catalog table");
for key in ["ordinary.txt", "tables/table-id/data.parquet", ".rustfs-table/private.json"] {
store
.put_object(
&bucket,
key,
&mut StoragePutObjReader::from_vec(b"private metadata".to_vec()),
&StorageObjectOptions::default(),
)
.await
.expect("seed objects for listing");
}
let slot = ServerContextSlot::new();
assert!(slot.install(context));
for operation in [MetadataOperation::ListObjectsV2, MetadataOperation::ListObjectVersions] {
let mut req = website_request(&format!("/{bucket}?list-type=2&metadata=true"));
req.extensions.insert(Arc::clone(&slot));
super::check_metadata_access(
&mut req,
super::BucketTarget {
operation,
bucket: bucket.clone(),
},
)
.await
.expect("bucket listing is authorized");
let access = req
.extensions
.get::<TableDataPlaneListAccess>()
.expect("metadata-route authorization must attach table isolation");
assert!(access.allows_object("ordinary.txt"));
assert!(!access.allows_object("tables/table-id/data.parquet"));
assert!(!access.allows_object(".rustfs-table/private.json"));
let objects = match operation {
MetadataOperation::ListObjectsV2 => {
table_list_isolation::list_objects_v2(
Arc::clone(&store),
access,
table_list_isolation::ListObjectsV2Request {
bucket: &bucket,
prefix: "",
continuation_token: None,
delimiter: None,
max_keys: 1000,
start_after: None,
incl_deleted: false,
opaque_cursor_supported: true,
},
)
.await
.expect("protected object listing succeeds")
.objects
}
MetadataOperation::ListObjectVersions => {
table_list_isolation::list_object_versions(
Arc::clone(&store),
access,
table_list_isolation::ListObjectVersionsRequest {
bucket: &bucket,
prefix: "",
key_marker: None,
version_id_marker: None,
delimiter: None,
max_keys: 1000,
},
)
.await
.expect("protected version listing succeeds")
.objects
}
};
let keys: Vec<_> = objects.into_iter().map(|object| object.name).collect();
assert_eq!(keys, vec!["ordinary.txt"], "catalog and unauthorized warehouse objects must be hidden");
}
}
#[tokio::test]
async fn website_304_preserves_cache_headers_without_content() {
use http_body_util::BodyExt;
+572 -106
View File
@@ -44,11 +44,11 @@ use super::storage_api::multipart_usecase::data_usage::{
use super::storage_api::multipart_usecase::error::{
StorageError, is_err_invalid_upload_id, is_err_object_not_found, is_err_version_not_found,
};
use super::storage_api::multipart_usecase::helper::OperationHelper;
use super::storage_api::multipart_usecase::helper::{OperationHelper, run_owned_mutation};
#[cfg(test)]
use super::storage_api::multipart_usecase::io::{DecryptReader, EncryptReader, HardLimitReader, boxed_reader, wrap_reader};
use super::storage_api::multipart_usecase::io::{HashReader, WriteEncryption, WritePlan, compression_metadata_value};
use super::storage_api::multipart_usecase::object_utils::to_s3s_etag;
use super::storage_api::multipart_usecase::object_utils::{to_s3s_etag, to_s3s_xml_etag};
use super::storage_api::multipart_usecase::options::{
copy_src_opts, extract_metadata_from_mime, get_complete_multipart_upload_opts_with_replication_authorization,
get_content_sha256_with_query, get_opts, has_replication_retention_update, namespace_reserved_user_metadata,
@@ -62,20 +62,20 @@ use super::storage_api::multipart_usecase::s3_api::multipart::{
use super::storage_api::multipart_usecase::set_disk::is_valid_storage_class;
use super::storage_api::multipart_usecase::sse::{
DecryptionRequest, EncryptionKeyKind, EncryptionRequest, PrepareEncryptionRequest, SseKmsPrincipal,
apply_bucket_default_lock_retention, authorize_sse_kms_object_read, build_ssec_read_headers, encryption_material_to_metadata,
extract_server_side_encryption_from_headers, extract_ssec_params_from_headers, extract_ssekms_context_from_headers,
get_buffer_size_opt_in, load_bucket_object_lock_config_state, map_get_object_reader_error, mark_encrypted_multipart_metadata,
sse_decryption, sse_prepare_encryption,
apply_bucket_default_lock_retention, authorize_sse_kms_multipart_write, authorize_sse_kms_object_read,
build_ssec_read_headers, encryption_material_to_metadata, extract_server_side_encryption_from_headers,
extract_ssec_params_from_headers, extract_ssekms_context_from_headers, get_buffer_size_opt_in,
load_bucket_object_lock_config_state, map_get_object_reader_error, mark_encrypted_multipart_metadata,
multipart_completion_authorization, sse_decryption, sse_prepare_encryption,
};
use super::storage_api::multipart_usecase::{
StorageObjectInfo as ObjectInfo, StorageObjectOptions as ObjectOptions, StoragePutObjReader as PutObjReader,
validate_object_key,
validate_new_object_key,
};
use super::trailer_adapter::trailer_source;
use crate::app::object::{
ConcurrencyManager, ForegroundWriteAdmission, get_concurrency_manager, guard_put_object_body_read_timeout,
put_object_body_read_timeout, reject_non_wildcard_write_if_none_match, reject_oversize_single_upload,
reject_veeam_sosapi_mutation,
ConcurrencyManager, ForegroundWriteAdmission, get_concurrency_manager, put_object_body_read_timeout,
reject_non_wildcard_write_if_none_match, reject_oversize_single_upload, reject_veeam_sosapi_mutation,
};
use crate::app::object_data_cache::{
ObjectDataCacheAdapter, invalidate_object_data_cache_after_complete_multipart_success,
@@ -523,7 +523,7 @@ impl DefaultMultipartUsecase {
get_concurrency_manager()
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_abort_multipart_upload(
&self,
req: S3Request<AbortMultipartUploadInput>,
@@ -551,12 +551,9 @@ impl DefaultMultipartUsecase {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
// Special handling for abort_multipart_upload: Per AWS S3 API specification, this operation
// should return NoSuchUpload (404) when the upload_id doesn't exist, even if the format
// appears invalid. This differs from other multipart operations (upload_part, list_parts,
// complete_multipart_upload) which return InvalidArgument for malformed upload_ids.
// The lenient validation matches AWS S3 behavior where format validation is relaxed for
// abort operations to avoid leaking information about upload_id format requirements.
// Per the AWS S3 API reference, a malformed upload ID is reported as
// NoSuchUpload (404), the same as an upload that was aborted or
// completed. See multipart_upload_api_error.
match store
.abort_multipart_upload(bucket.as_str(), key.as_str(), upload_id.as_str(), &opts)
.await
@@ -569,24 +566,25 @@ impl DefaultMultipartUsecase {
);
Ok(S3Response::new(AbortMultipartUploadOutput { ..Default::default() }))
}
Err(err) => {
// Convert MalformedUploadID to NoSuchUpload for S3 API compatibility
if matches!(err, StorageError::MalformedUploadID(_)) {
return Err(S3Error::new(S3ErrorCode::NoSuchUpload));
}
Err(ApiError::from(err).into())
}
Err(err) => Err(multipart_upload_api_error(err)),
}
}
#[instrument(level = "debug", skip(self, req))]
#[hotpath::measure(impl_type = "MultipartUsecase")]
pub async fn execute_complete_multipart_upload(
&self,
req: S3Request<CompleteMultipartUploadInput>,
) -> S3Result<S3Response<CompleteMultipartUploadOutput>> {
let owner = self.clone();
run_owned_mutation(async move { Box::pin(owner.execute_complete_multipart_upload_owned(req)).await }).await
}
async fn execute_complete_multipart_upload_owned(
&self,
mut req: S3Request<CompleteMultipartUploadInput>,
) -> S3Result<S3Response<CompleteMultipartUploadOutput>> {
// Uploads created before the key was reserved must not publish it.
validate_object_key(&req.input.key, "CompleteMultipartUpload")?;
validate_new_object_key(&req.input.key, "CompleteMultipartUpload")?;
reject_presigned_multipart_max_total_object_size_for_other_operation(
&req.headers,
req.uri.query(),
@@ -617,6 +615,7 @@ impl DefaultMultipartUsecase {
..
} = input;
let customer_key_provided = sse_customer_key.is_some();
reject_veeam_sosapi_mutation(|| self.object_store(), &bucket, &key).await?;
reject_non_wildcard_write_if_none_match(if_none_match.as_ref(), &req.headers)?;
validate_table_catalog_object_mutation(&bucket, &key).await?;
@@ -700,27 +699,30 @@ impl DefaultMultipartUsecase {
let multipart_info = match store.get_multipart_info(&bucket, &key, &upload_id, &opts).await {
Ok(info) => Some(info),
Err(err) if is_err_invalid_upload_id(&err) => None,
Err(err) => return Err(ApiError::from(err).into()),
Err(err) => return Err(multipart_upload_api_error(err)),
};
// A ciphertext-passthrough session stores encrypted parts verbatim and
// completes without the customer key (the replication client has none),
// so the SSE-C completion check must be skipped for it.
let completion_principal = SseKmsPrincipal::from_request(&req);
let completion_authorization = multipart_completion_authorization(
EncryptionRequest {
bucket: &bucket,
key: &key,
server_side_encryption: None,
ssekms_key_id: None,
ssekms_context: None,
sse_customer_algorithm: sse_customer_algorithm.clone(),
sse_customer_key: sse_customer_key.clone(),
sse_customer_key_md5: sse_customer_key_md5.clone(),
content_size: 0,
principal: completion_principal.as_ref(),
},
replication_authorized,
);
opts.multipart_completion_authorization = Some(completion_authorization.clone());
if let Some(info) = multipart_info.as_ref() {
if !contains_key_str(&info.user_defined, SUFFIX_REPLICATION_PRESERVE_CIPHERTEXT) {
EncryptionRequest {
bucket: &bucket,
key: &key,
server_side_encryption: None,
ssekms_key_id: None,
ssekms_context: None,
sse_customer_algorithm: sse_customer_algorithm.clone(),
sse_customer_key: sse_customer_key.clone(),
sse_customer_key_md5: sse_customer_key_md5.clone(),
content_size: 0,
principal: None,
}
.validate_complete_multipart_ssec(&info.user_defined)?;
}
completion_authorization
.authorize(&info.user_defined)
.await
.map_err(map_get_object_reader_error)?;
validate_complete_multipart_checksum_type(&req.headers, &info.user_defined)?;
}
@@ -827,7 +829,7 @@ impl DefaultMultipartUsecase {
.clone()
.complete_multipart_upload(&bucket, &key, &upload_id, uploaded_parts, &opts)
.await
.map_err(ApiError::from)?;
.map_err(map_get_object_reader_error)?;
let _ = invalidate_object_data_cache_after_complete_multipart_success(&cache_adapter, &bucket, &key).await;
// Staging reclaim on a replay can free bytes; the dirty mark is
// not a usage counter. Quota, replication, lifecycle, and the
@@ -880,24 +882,11 @@ impl DefaultMultipartUsecase {
)
})??;
// The upload record is gone on a lost-response retry, so SSE-C is
// checked against the committed object instead of the staging metadata.
if upload_user_defined.is_none() && !contains_key_str(&obj_info.user_defined, SUFFIX_REPLICATION_PRESERVE_CIPHERTEXT) {
EncryptionRequest {
bucket: &bucket,
key: &key,
server_side_encryption: None,
ssekms_key_id: None,
ssekms_context: None,
sse_customer_algorithm,
sse_customer_key,
sse_customer_key_md5,
content_size: 0,
principal: None,
}
.validate_complete_multipart_ssec(obj_info.user_defined.as_ref())?;
}
#[cfg(test)]
super::storage_api::multipart_usecase::helper::pause_committed_mutation_for_test(&bucket).await;
// Storage has authorized the actual source or committed replay candidate
// before comparing checksums/parts, recovery publication or replay effects.
let encryption_metadata = upload_user_defined.as_ref().unwrap_or(obj_info.user_defined.as_ref());
let server_side_encryption = encryption_metadata
.get("x-amz-server-side-encryption")
@@ -925,24 +914,57 @@ impl DefaultMultipartUsecase {
// checksum: stored (decrypted) values take precedence over the request input;
// additional algorithms (XXHash3/64/128, SHA-512, MD5), which have no typed
// CompleteMultipartUploadOutput field, are echoed as raw response headers (#1261).
let (checksums, is_multipart) = obj_info
.decrypt_checksums(opts.part_number.unwrap_or(0), &req.headers)
.map_err(ApiError::from)?;
let classified = crate::app::object_usecase::classify_response_checksums(checksums, is_multipart);
let checksum_crc32 = classified.crc32.or(input.checksum_crc32);
let checksum_crc32c = classified.crc32c.or(input.checksum_crc32c);
let checksum_sha1 = classified.sha1.or(input.checksum_sha1);
let checksum_sha256 = classified.sha256.or(input.checksum_sha256);
let checksum_crc64nvme = classified.crc64nvme.or(input.checksum_crc64nvme);
let checksum_type = classified.checksum_type.or(input.checksum_type);
// Keyless completion remains supported, but plaintext checksums are
// returned only after verifying that key (backlog#3050). The customer-key
// MD5 is a fingerprint of the key, not of the plaintext, and the S3
// conformance suite expects it on a keyless completion, so it is echoed
// unconditionally. Apply this equally to a new commit and a replay.
let disclose_checksums = (ssec_algorithm.is_none() && ssec_key_md5.is_none()) || customer_key_provided;
let classified = if disclose_checksums {
let (checksums, is_multipart) = obj_info
.decrypt_checksums(opts.part_number.unwrap_or(0), &req.headers)
.map_err(ApiError::from)?;
crate::app::object_usecase::classify_response_checksums(checksums, is_multipart)
} else {
Default::default()
};
let checksum_crc32 = if disclose_checksums {
classified.crc32.or(input.checksum_crc32)
} else {
None
};
let checksum_crc32c = if disclose_checksums {
classified.crc32c.or(input.checksum_crc32c)
} else {
None
};
let checksum_sha1 = if disclose_checksums {
classified.sha1.or(input.checksum_sha1)
} else {
None
};
let checksum_sha256 = if disclose_checksums {
classified.sha256.or(input.checksum_sha256)
} else {
None
};
let checksum_crc64nvme = if disclose_checksums {
classified.crc64nvme.or(input.checksum_crc64nvme)
} else {
None
};
let checksum_type = if disclose_checksums {
classified.checksum_type.or(input.checksum_type)
} else {
None
};
let complete_extra_checksum_headers = classified.extra;
let location = build_complete_multipart_location(&req.headers, &req.uri, &bucket, &key);
let output = CompleteMultipartUploadOutput {
bucket: Some(bucket.clone()),
key: Some(key.clone()),
e_tag: obj_info.etag.clone().map(|etag| to_s3s_etag(&etag)),
e_tag: obj_info.etag.clone().map(|etag| to_s3s_xml_etag(&etag)),
location: Some(location),
server_side_encryption,
ssekms_key_id,
@@ -993,7 +1015,7 @@ impl DefaultMultipartUsecase {
&self,
req: S3Request<CreateMultipartUploadInput>,
) -> S3Result<S3Response<CreateMultipartUploadOutput>> {
validate_object_key(&req.input.key, "CreateMultipartUpload")?;
validate_new_object_key(&req.input.key, "CreateMultipartUpload")?;
let multipart_max_total_object_size = parse_presigned_multipart_max_total_object_size(
&req.headers,
req.uri.query(),
@@ -1085,7 +1107,7 @@ impl DefaultMultipartUsecase {
let sse_customer_key_md5 = sse_customer_key_md5.or(header_sse_customer_key_md5);
// The session data key is generated here, so this is where a multipart upload is held
// to the KMS key it names. Parts and the completion reuse the resulting envelope.
// to the KMS key it names. Parts and completion also authorize their own caller.
let session_principal = SseKmsPrincipal::from_request(&req);
let encryption_request = PrepareEncryptionRequest {
bucket: &bucket,
@@ -1252,6 +1274,7 @@ impl DefaultMultipartUsecase {
normalize_presigned_part_checksums(&mut req)?;
let mut opts = ObjectOptions::default();
apply_bucket_generation_guard(&req, &req.input.bucket, &mut opts)?;
let part_principal = SseKmsPrincipal::from_request(&req);
let input = req.input;
let UploadPartInput {
body,
@@ -1276,14 +1299,15 @@ impl DefaultMultipartUsecase {
if let Some(size) = size {
reject_oversize_single_upload(size)?;
}
let mut body_stream = body.ok_or_else(|| s3_error!(IncompleteBody))?;
let body_stream = body.ok_or_else(|| s3_error!(IncompleteBody))?;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
let fi = store
.get_multipart_info(&bucket, &key, &upload_id, &opts)
.await
.map_err(ApiError::from)?;
.map_err(multipart_upload_api_error)?;
authorize_sse_kms_multipart_write(part_principal.as_ref(), &fi.user_defined).await?;
let max_total_object_size = multipart_max_total_object_size(&fi.user_defined)?;
let mut size = require_upload_part_size(size, max_total_object_size.is_some())?;
if let Some(limit) = max_total_object_size
@@ -1316,23 +1340,20 @@ impl DefaultMultipartUsecase {
.map(|ctx| ctx.request_id.as_str())
.unwrap_or_default();
let timeout = upload_part_body_read_timeout(put_object_body_read_timeout(), max_total_object_size.is_some());
let raw_control = req.extensions.get::<super::object::request_body::BodyReadControl>().cloned();
let observe_read_demand = if let Some(control) = &raw_control {
control.activate(
timeout,
&bucket,
&key,
request_id,
u64::try_from(size).map_err(|_| s3_error!(UnexpectedContent))?,
)
} else {
// Direct protocol callers have no raw HTTP body. Retain their
// existing capped-session guard without inventing a client cause.
if max_total_object_size.is_some() {
body_stream = guard_put_object_body_read_timeout(body_stream, &bucket, &key, request_id, Some(size), timeout);
}
false
};
let (body_stream, progress_control) =
if let Some(control) = req.extensions.get::<super::object::request_body::BodyReadControl>() {
control.activate_upload(timeout, &bucket, &key, request_id, size.max(0) as u64);
(body_stream, control.clone())
} else {
super::object::request_body::guard_upload_progress(
body_stream,
timeout,
&bucket,
&key,
request_id,
size.max(0) as u64,
)
};
let ingress_stage_start = rustfs_io_metrics::put_stage_metrics_enabled().then(std::time::Instant::now);
@@ -1453,8 +1474,8 @@ impl DefaultMultipartUsecase {
write_plan = write_plan.with_encryption(ssec_write);
(Some(ssec_material.server_side_encryption), ssec_material.kms_key_id)
} else if let Some(server_side_encryption) = server_side_encryption {
// Reuses the envelope the create-multipart-upload call was authorized for; the
// KMS key was pinned into the session metadata then and cannot change here.
// Reuse the stored envelope after authorizing this caller against its
// pinned destination key above.
let managed_material = sse_decryption(DecryptionRequest {
bucket: &bucket,
key: &key,
@@ -1478,10 +1499,11 @@ impl DefaultMultipartUsecase {
};
reader = write_plan.apply(reader, actual_size).map_err(ApiError::from)?;
if observe_read_demand && let Some(control) = raw_control {
{
use rustfs_rio::HashReaderMut;
let inner = reader.take_inner();
reader.inner = rustfs_rio::boxed_reader(super::object::request_body::DemandReader::new(inner, control));
reader.inner = rustfs_rio::boxed_reader(super::object::request_body::DemandReader::new(inner, progress_control));
}
let mut reader = PutObjReader::new(reader);
@@ -1678,7 +1700,7 @@ impl DefaultMultipartUsecase {
let res = store
.list_object_parts(&bucket, &key, &upload_id, params.part_number_marker, params.max_parts, &opts)
.await
.map_err(ApiError::from)?;
.map_err(multipart_upload_api_error)?;
Ok(S3Response::new(build_list_parts_output(res)))
}
@@ -1795,7 +1817,8 @@ impl DefaultMultipartUsecase {
let mp_info = store
.get_multipart_info(&bucket, &key, &upload_id, &dst_opts)
.await
.map_err(ApiError::from)?;
.map_err(multipart_upload_api_error)?;
authorize_sse_kms_multipart_write(copy_principal.as_ref(), &mp_info.user_defined).await?;
let destination_size_limit = multipart_max_total_object_size(&mp_info.user_defined)?;
EncryptionRequest {
bucket: &bucket,
@@ -1983,8 +2006,8 @@ impl DefaultMultipartUsecase {
mp_info.user_defined.clone(),
)
} else if let Some(server_side_encryption) = server_side_encryption {
// Destination side of the part copy: reuses the session envelope authorized at
// create-multipart-upload time. The source side is authorized above.
// Reuse the destination envelope after authorizing this caller against
// its pinned key above. The source read is separately authorized.
let managed_material = sse_decryption(DecryptionRequest {
bucket: &bucket,
key: &key,
@@ -2049,7 +2072,7 @@ impl DefaultMultipartUsecase {
checksum_sha1: checksum_value(rustfs_rio::ChecksumType::SHA1),
checksum_sha256: checksum_value(rustfs_rio::ChecksumType::SHA256),
checksum_crc64nvme: checksum_value(rustfs_rio::ChecksumType::CRC64_NVME),
e_tag: part_info.etag.map(|etag| to_s3s_etag(&etag)),
e_tag: part_info.etag.map(|etag| to_s3s_xml_etag(&etag)),
last_modified: part_info.last_mod.map(Timestamp::from),
..Default::default()
};
@@ -2124,6 +2147,21 @@ fn normalize_presigned_part_checksums(req: &mut S3Request<UploadPartInput>) -> S
Ok(())
}
/// Maps a storage error from an operation that addresses an existing multipart
/// upload (UploadPart, UploadPartCopy, ListParts, CompleteMultipartUpload and
/// AbortMultipartUpload) to an S3 error.
///
/// A malformed upload ID cannot name an existing upload, so it is reported as
/// `NoSuchUpload` (404), which the AWS S3 error reference describes as "The
/// upload ID might be invalid, or the multipart upload might have been aborted
/// or completed".
fn multipart_upload_api_error(err: StorageError) -> S3Error {
if matches!(err, StorageError::MalformedUploadID(_)) {
return S3Error::new(S3ErrorCode::NoSuchUpload);
}
ApiError::from(err).into()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2712,7 +2750,15 @@ mod tests {
#[tokio::test]
async fn multipart_upload_rejects_reserved_directory_markers() {
for key in ["__XLDIR__", "prefix__XLDIR__/object", "object__XLDIR__"] {
for key in [
"__XLDIR__",
"prefix__XLDIR__/object",
"object__XLDIR__",
"parent/xl.meta",
"parent/uuid/xl.meta.bkp",
"parent/uuid/delete-prepared",
"parent/uuid/delete-quorum-committed",
] {
let create = CreateMultipartUploadInput::builder()
.bucket("bucket".to_owned())
.key(key.to_owned())
@@ -2797,6 +2843,22 @@ mod tests {
assert!(multipart_max_total_object_size(&metadata).is_err());
}
#[test]
fn multipart_upload_api_error_reports_malformed_upload_id_as_no_such_upload() {
let malformed = multipart_upload_api_error(StorageError::MalformedUploadID("bucket/object-not-an-id".to_string()));
assert_eq!(malformed.code(), &S3ErrorCode::NoSuchUpload);
let unknown = multipart_upload_api_error(StorageError::InvalidUploadID(
"bucket".to_string(),
"object".to_string(),
"dXBsb2FkLWlk".to_string(),
));
assert_eq!(unknown.code(), &S3ErrorCode::NoSuchUpload);
let other = multipart_upload_api_error(StorageError::ObjectNotFound("bucket".to_string(), "object".to_string()));
assert_eq!(other.code(), &S3ErrorCode::NoSuchKey);
}
#[tokio::test]
async fn execute_complete_multipart_upload_rejects_missing_parts_payload() {
let input = CompleteMultipartUploadInput::builder()
@@ -2976,6 +3038,117 @@ mod tests {
);
}
#[test]
#[serial_test::serial]
fn ssec_completion_and_replay_require_key_before_disclosing_plaintext_checksums() {
crate::app::gating_test_env::run_large_stack_test("ssec-completion-checksums", || async {
let (_store, bucket) =
crate::app::gating_test_env::durable_quota_test_bucket("ssec-completion-checksums", 16_384).await;
let usecase = DefaultMultipartUsecase::from_global();
let key = "customer-encrypted";
let customer_key = "KioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKio=";
let customer_md5 = "SaaDheEjzuynJH8eW6AEpQ==";
let plaintext_sha256 = "ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0=";
let mut create_input = CreateMultipartUploadInput::builder()
.bucket(bucket.clone())
.key(key.to_string())
.build()
.expect("create");
create_input.sse_customer_algorithm = Some("AES256".to_string());
create_input.sse_customer_key = Some(customer_key.to_string());
create_input.sse_customer_key_md5 = Some(customer_md5.to_string());
create_input.checksum_algorithm = Some(ChecksumAlgorithm::from_static("SHA256"));
let mut create = build_request(create_input, Method::POST);
create
.headers
.insert("x-amz-checksum-algorithm", HeaderValue::from_static("SHA256"));
create
.headers
.insert("x-amz-checksum-type", HeaderValue::from_static("COMPOSITE"));
let upload_id = usecase
.execute_create_multipart_upload(create)
.await
.expect("SSE-C create")
.output
.upload_id
.expect("upload id");
let mut part_input = UploadPartInput::builder()
.bucket(bucket.clone())
.key(key.to_string())
.upload_id(upload_id.clone())
.part_number(1)
.build()
.expect("part");
part_input.body = Some(StreamingBlob::from(Bytes::from_static(b"abc")));
part_input.content_length = Some(3);
part_input.sse_customer_algorithm = Some("AES256".to_string());
part_input.sse_customer_key = Some(customer_key.to_string());
part_input.sse_customer_key_md5 = Some(customer_md5.to_string());
part_input.checksum_algorithm = Some(ChecksumAlgorithm::from_static("SHA256"));
part_input.checksum_sha256 = Some(plaintext_sha256.to_string());
let mut part = build_request(part_input, Method::PUT);
part.headers
.insert("x-amz-checksum-sha256", HeaderValue::from_static(plaintext_sha256));
let part = usecase.execute_upload_part(part).await.expect("SSE-C part").output;
let complete = CompleteMultipartUploadInput::builder()
.bucket(bucket.clone())
.key(key.to_string())
.upload_id(upload_id)
.multipart_upload(Some(CompletedMultipartUpload {
parts: Some(vec![CompletedPart {
part_number: Some(1),
e_tag: part.e_tag,
checksum_sha256: Some(plaintext_sha256.to_string()),
..Default::default()
}]),
}))
.build()
.expect("complete");
// Reject keyless completion before checksum comparison, then enforce
// the same key boundary on a successful commit and its replay.
for (attempt, keyed) in [false, true, false, true].into_iter().enumerate() {
let mut input = complete.clone();
if keyed {
input.sse_customer_algorithm = Some("AES256".to_string());
input.sse_customer_key = Some(customer_key.to_string());
input.sse_customer_key_md5 = Some(customer_md5.to_string());
}
if attempt == 2 {
let part = &mut input
.multipart_upload
.as_mut()
.expect("parts")
.parts
.as_mut()
.expect("part list")[0];
part.e_tag = Some(ETag::Strong("guessed-etag".to_string()));
part.checksum_sha256 = Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_string());
}
let result = usecase
.execute_complete_multipart_upload(build_request(input, Method::POST))
.await;
if keyed {
let response = result.expect("verified key completes and replays");
assert!(response.output.checksum_sha256.is_some(), "verified key retains checksum response");
assert_eq!(
response
.headers
.get("x-amz-server-side-encryption-customer-key-md5")
.expect("key MD5"),
customer_md5
);
} else {
assert_eq!(
result
.expect_err("keyless checksum completion must fail before disclosure")
.code(),
&S3ErrorCode::InvalidRequest
);
}
}
});
}
#[test]
#[serial_test::serial]
fn conditional_multipart_upload_if_none_match_returns_precondition_failed_when_object_exists() {
@@ -3480,6 +3653,80 @@ mod tests {
assert_eq!(completed.size, 4096);
}
#[tokio::test]
#[serial_test::serial]
async fn cancelled_multipart_completion_emits_once_and_replay_does_not_emit() {
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await;
let bucket = format!("mpu-owned-event-{}", Uuid::new_v4());
let object = "object";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("bucket");
let upload = store
.new_multipart_upload(&bucket, object, &ObjectOptions::default())
.await
.expect("upload");
let mut reader = PutObjReader::from_vec(b"part".to_vec());
let part = store
.put_object_part(&bucket, object, &upload.upload_id, 1, &mut reader, &ObjectOptions::default())
.await
.expect("part");
let input = CompleteMultipartUploadInput::builder()
.bucket(bucket.clone())
.key(object.to_owned())
.upload_id(upload.upload_id)
.multipart_upload(Some(CompletedMultipartUpload {
parts: Some(vec![CompletedPart {
part_number: Some(1),
e_tag: part.etag.map(|etag| to_s3s_etag(&etag)),
..Default::default()
}]),
}))
.build()
.expect("complete input");
let probe = crate::app::storage_api::multipart_usecase::helper::MutationCompletionProbe::install(&bucket);
let (entered, resume) = probe.pause_after_commit();
let request = |input| {
let mut request = build_request(input, Method::POST);
request.uri = format!("/{bucket}/{object}").parse().expect("complete URI");
request
};
let first = request(input.clone());
let completion = tokio::spawn(async move {
DefaultMultipartUsecase::from_global()
.execute_complete_multipart_upload(first)
.await
});
tokio::time::timeout(std::time::Duration::from_secs(10), entered.wait())
.await
.expect("committed MPU");
completion.abort();
assert!(completion.await.expect_err("cancel waiter").is_cancelled());
resume.wait().await;
probe.wait_for(1, 1).await;
assert_eq!(
store
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.expect("committed object")
.size,
4
);
DefaultMultipartUsecase::from_global()
.execute_complete_multipart_upload(request(input))
.await
.expect("idempotent replay");
let audits = probe.audits.lock().expect("audits");
assert_eq!(audits.len(), 2);
assert!(audits.iter().all(|entry| entry.api.status.as_deref() == Some("success")));
let events = probe.events.lock().expect("events");
assert_eq!(events.len(), 1, "replay must not emit another creation event");
assert_eq!(events[0].event_name, EventName::ObjectCreatedCompleteMultipartUpload);
}
#[tokio::test]
#[serial_test::serial]
async fn rejected_empty_parts_preserve_existing_object_and_staging() {
@@ -4016,6 +4263,63 @@ mod tests {
drop(held);
}
#[tokio::test]
#[serial_test::serial]
async fn execute_upload_part_progress_timeout_releases_admission_and_leaves_no_part() {
use crate::app::object::request_body::{BodyReadControl, ObservedBody};
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
use http_body::Frame;
use http_body_util::StreamBody;
use tokio_stream::wrappers::UnboundedReceiverStream;
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await;
let ambient = crate::app::runtime_sources::current_app_context().expect("test app context");
let context = Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms()));
let bucket = format!("part-progress-{}", Uuid::new_v4().simple());
store.make_bucket(&bucket, &MakeBucketOptions::default()).await.unwrap();
let manager = Arc::new(ConcurrencyManager::with_put_admission_for_test(true, 1, Duration::ZERO));
let usecase = DefaultMultipartUsecase::with_context_and_concurrency_manager(Some(context), Arc::clone(&manager));
let upload = store
.new_multipart_upload(&bucket, "object", &ObjectOptions::default())
.await
.unwrap();
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<Result<Frame<Bytes>, std::io::Error>>();
let control = BodyReadControl::default();
let body = StreamingBlob::from(s3s::Body::http_body_unsync(ObservedBody::new(
StreamBody::new(UnboundedReceiverStream::new(receiver)),
control.clone(),
)));
let input = UploadPartInput::builder()
.bucket(bucket.clone())
.key("object".to_owned())
.upload_id(upload.upload_id.clone())
.part_number(1)
.content_length(Some(65536))
.body(Some(body))
.build()
.unwrap();
let mut request = build_request(input, Method::PUT);
request.extensions.insert(control.clone());
let owner = tokio::spawn(async move { usecase.execute_upload_part(request).await });
control.wait_until_pending_for_test().await;
assert!(matches!(
manager.admit_put_object(65536).await.unwrap(),
ForegroundWriteAdmission::Rejected
));
control.advance_wait_for_test(Duration::from_secs(60));
sender.send(Ok(Frame::data(Bytes::from_static(b"x")))).unwrap();
assert_eq!(owner.await.unwrap().unwrap_err().code(), &S3ErrorCode::RequestTimeout);
assert!(matches!(
manager.admit_put_object(65536).await.unwrap(),
ForegroundWriteAdmission::Admitted(_)
));
let parts = store
.list_object_parts(&bucket, "object", &upload.upload_id, None, 1000, &ObjectOptions::default())
.await
.unwrap();
assert!(parts.parts.is_empty());
}
#[tokio::test]
#[serial_test::serial]
async fn execute_upload_part_rejects_when_foreground_write_admission_is_full() {
@@ -4084,4 +4388,166 @@ mod tests {
assert_eq!(format_copy_source_version_id(Some(Uuid::nil()), false, true), Some("null".to_string()));
assert_eq!(format_copy_source_version_id(Some(valid), true, false), Some(valid.to_string()));
}
// Lives in the app layer: the handlers under test are app use cases; the SSE
// helpers it borrows are infra test support (allowed downward dependency).
#[test]
#[serial_test::serial]
fn multipart_handlers_reject_another_callers_denied_kms_upload() {
crate::app::gating_test_env::run_large_stack_test("mpu-kms-current-caller", || async {
use crate::app::storage_api::test::ReqInfo;
use crate::app::storage_api::test::sse::{
RecordingKmsKeyAuthorizer, TestAuthorizationHooks, sse_kms_object_metadata,
};
use crate::storage::storage_api::contract::multipart::MultipartOperations as _;
use rustfs_policy::policy::action::KmsAction;
use s3s::S3ErrorCode;
use s3s::dto::{
CompleteMultipartUploadInput, CompletedMultipartUpload, CompletedPart, CopySource, StreamingBlob,
UploadPartCopyInput, UploadPartInput,
};
use std::sync::Arc;
let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("mpu-kms-current-caller", 16_384).await;
let usecase = DefaultMultipartUsecase::from_global();
let key = "kms-owned-upload";
let metadata = sse_kms_object_metadata();
let upload = store
.new_multipart_upload(
&bucket,
key,
&crate::storage::ObjectOptions {
user_defined: metadata,
..Default::default()
},
)
.await
.expect("seed authorized upload envelope");
let authorizer = RecordingKmsKeyAuthorizer::new(false);
fn denied_request<T>(input: T, authorizer: Arc<RecordingKmsKeyAuthorizer>) -> s3s::S3Request<T> {
let mut req = s3s::S3Request {
input,
method: http::Method::PUT,
uri: http::Uri::from_static("/"),
headers: http::HeaderMap::new(),
extensions: http::Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
req.extensions.insert(ReqInfo {
cred: Some(rustfs_credentials::Credentials {
access_key: "denied-writer".to_string(),
..Default::default()
}),
..Default::default()
});
req.extensions.insert(TestAuthorizationHooks {
enforced: true,
authorizer,
});
req
}
let mut part = UploadPartInput::builder()
.bucket(bucket.clone())
.key(key.to_string())
.upload_id(upload.upload_id.clone())
.part_number(1)
.build()
.expect("part");
part.body = Some(StreamingBlob::from(bytes::Bytes::from_static(b"abc")));
part.content_length = Some(3);
// Keep typed request construction separate: each real handler must
// consult the destination envelope before encryption or commit.
let error = usecase
.execute_upload_part(denied_request(part, authorizer.clone()))
.await
.expect_err("denied part");
assert_eq!(error.code(), &S3ErrorCode::AccessDenied);
let copy = UploadPartCopyInput::builder()
.bucket(bucket.clone())
.key(key.to_string())
.upload_id(upload.upload_id.clone())
.part_number(1)
.copy_source(CopySource::Bucket {
bucket: bucket.clone().into(),
key: "source".into(),
version_id: None,
})
.build()
.expect("copy");
let copy_req = denied_request(copy, authorizer.clone());
let error = usecase
.execute_upload_part_copy(copy_req)
.await
.expect_err("denied destination copy");
assert_eq!(error.code(), &S3ErrorCode::AccessDenied);
let complete = CompleteMultipartUploadInput::builder()
.bucket(bucket.clone())
.key(key.to_string())
.upload_id(upload.upload_id.clone())
.multipart_upload(Some(CompletedMultipartUpload {
parts: Some(vec![CompletedPart {
part_number: Some(1),
e_tag: Some(s3s::dto::ETag::Strong("etag".to_string())),
..Default::default()
}]),
}))
.build()
.expect("complete");
let complete_req = denied_request(complete, authorizer.clone());
let error = usecase
.execute_complete_multipart_upload(complete_req)
.await
.expect_err("denied completion");
assert_eq!(error.code(), &S3ErrorCode::AccessDenied);
assert_eq!(authorizer.calls(), vec![(KmsAction::GenerateDataKeyAction, "finance-key".to_string()); 3]);
assert!(
store
.get_multipart_info(&bucket, key, &upload.upload_id, &crate::storage::ObjectOptions::default())
.await
.is_ok(),
"denials must retain the original upload"
);
let mut reader = crate::storage::PutObjReader::from_vec(b"abc".to_vec());
let uploaded_part = store
.put_object_part(&bucket, key, &upload.upload_id, 1, &mut reader, &crate::storage::ObjectOptions::default())
.await
.expect("trusted owner stages part");
store
.clone()
.complete_multipart_upload(
&bucket,
key,
&upload.upload_id,
vec![crate::storage::storage_api::contract::multipart::CompletePart {
part_num: 1,
etag: uploaded_part.etag,
..Default::default()
}],
&crate::storage::ObjectOptions::default(),
)
.await
.expect("trusted owner completes upload");
let replay = CompleteMultipartUploadInput::builder()
.bucket(bucket.clone())
.key(key.to_string())
.upload_id(upload.upload_id)
.multipart_upload(Some(CompletedMultipartUpload {
parts: Some(vec![CompletedPart {
part_number: Some(1),
e_tag: Some(s3s::dto::ETag::Strong("guessed-etag".to_string())),
..Default::default()
}]),
}))
.build()
.expect("replay");
let error = usecase
.execute_complete_multipart_upload(denied_request(replay, authorizer.clone()))
.await
.expect_err("replay must authorize before comparing guessed part metadata");
assert_eq!(error.code(), &S3ErrorCode::AccessDenied);
assert_eq!(authorizer.calls().len(), 4);
});
}
}
+76 -4
View File
@@ -15,6 +15,7 @@
//! CopyObject path.
use super::*;
use crate::app::storage_api::object_usecase::object_utils::to_s3s_xml_etag;
use futures::FutureExt;
use rustfs_utils::path::fold_object_name_path_separators;
use tracing::Instrument;
@@ -142,7 +143,7 @@ impl DefaultObjectUsecase {
Box::pin(self.execute_copy_object_body(req))
}
async fn execute_copy_object_body(&self, req: S3Request<CopyObjectInput>) -> S3Result<S3Response<CopyObjectOutput>> {
async fn execute_copy_object_body(&self, mut req: S3Request<CopyObjectInput>) -> S3Result<S3Response<CopyObjectOutput>> {
reject_presigned_put_max_content_length_for_other_operation(
&req.headers,
req.uri.query(),
@@ -238,7 +239,7 @@ impl DefaultObjectUsecase {
// Validate both source and destination keys
validate_object_key(&src_key, "COPY (source)")?;
validate_object_key(&key, "COPY (dest)")?;
validate_new_object_key(&key, "COPY (dest)")?;
reject_veeam_sosapi_mutation(|| self.object_store(), &bucket, &key).await?;
validate_table_catalog_object_mutation(&bucket, &key).await?;
let replaces_metadata = match metadata_directive.as_ref().map(|directive| directive.as_str()) {
@@ -487,6 +488,13 @@ impl DefaultObjectUsecase {
let _source_cancellation_guard = source_cancellation.clone().drop_guard();
let mut src_info = gr.object_info.clone();
let effective_tags = replacement_tags.unwrap_or_else(|| (*src_info.user_tags).clone());
crate::app::storage_api::object_usecase::access::authorize_copy_object_tags(
&mut req,
&src_info.user_tags,
&effective_tags,
)
.await?;
// A copy reads the source plaintext, so it needs the source key's decrypt permission
// as well as the destination key's generate permission below. The source read resolves
@@ -568,7 +576,6 @@ impl DefaultObjectUsecase {
// Extract user_defined from Arc for mutation; it will be re-wrapped after all edits.
let mut user_defined = (*src_info.user_defined).clone();
let effective_tags = replacement_tags.unwrap_or_else(|| (*src_info.user_tags).clone());
if !replaces_metadata {
let source_expires = src_info.expires.map(Timestamp::from);
insert_expires_metadata(&mut user_defined, source_expires.as_ref())?;
@@ -888,7 +895,7 @@ impl DefaultObjectUsecase {
}
}
let copy_object_result = CopyObjectResult {
e_tag: oi.etag.as_ref().map(|etag| to_s3s_etag(etag)),
e_tag: oi.etag.as_ref().map(|etag| to_s3s_xml_etag(etag)),
last_modified: oi.mod_time.map(Timestamp::from),
checksum_crc32: response_checksums.crc32,
checksum_crc32c: response_checksums.crc32c,
@@ -1516,6 +1523,71 @@ mod tests {
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
}
#[tokio::test]
#[serial_test::serial]
async fn metadata_only_copy_escapes_legacy_etags_in_xml() {
use crate::app::storage_api::test::ReqInfo;
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
use s3s::xml::{SerializeContent, Serializer};
let (store, context) = real_store_test_context().await;
let bucket = format!("copy-legacy-etag-{}", Uuid::new_v4());
let key = "owned.txt";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create bucket");
let etag = "</ETag><Injected>victim</Injected><ETag>&amp;";
let mut reader = PutObjReader::from_vec(b"legacy object".to_vec());
store
.put_object(
&bucket,
key,
&mut reader,
&ObjectOptions {
preserve_etag: Some(etag.to_string()),
..Default::default()
},
)
.await
.expect("seed legacy ETag directly in storage");
let before = store
.get_object_info(&bucket, key, &ObjectOptions::default())
.await
.expect("legacy object");
assert_eq!(before.etag.as_deref(), Some(etag));
let input = CopyObjectInput::builder()
.bucket(bucket.clone())
.key(key.to_string())
.copy_source(CopySource::Bucket {
bucket: bucket.clone().into(),
key: key.into(),
version_id: None,
})
.metadata_directive(Some(s3s::dto::MetadataDirective::from_static("REPLACE")))
.build()
.expect("self-copy request");
let mut request = build_request(input, Method::PUT);
request.extensions.insert(ReqInfo::default());
let result = Box::pin(DefaultObjectUsecase::with_context(Some(context)).execute_copy_object(request))
.await
.expect("metadata-only self-copy");
let mut bytes = Vec::new();
result
.output
.copy_object_result
.expect("copy result")
.serialize_content(&mut Serializer::new(&mut bytes))
.expect("serialize copy result");
let xml = String::from_utf8(bytes).expect("XML UTF-8");
assert!(!xml.contains("<Injected>"), "{xml}");
assert!(xml.contains("&lt;Injected&gt;victim&lt;/Injected&gt;"), "{xml}");
let stored = store
.get_object_info(&bucket, key, &ObjectOptions::default())
.await
.expect("copied object");
assert_eq!(stored.etag.as_deref(), Some(etag), "escaping belongs only to the wire response");
}
#[tokio::test]
#[serial_test::serial]
async fn execute_copy_object_refuses_a_bucket_whose_encryption_config_is_unreadable() {
+216 -9
View File
@@ -54,12 +54,10 @@ async fn authorize_recursive_delete<T>(
let info = req_info_mut(req)?;
info.object = Some(object.name.clone());
info.version_id = version_id.clone();
let action = if replica {
Action::S3Action(S3Action::ReplicateDeleteAction)
} else {
delete_object_authorize_action(version_id.as_deref())
};
authorize_request(req, action).await?;
authorize_request(req, delete_object_authorize_action(version_id.as_deref())).await?;
if replica {
authorize_request(req, Action::S3Action(S3Action::ReplicateDeleteAction)).await?;
}
if has_bypass_governance_header(&req.headers) {
authorize_request(req, Action::S3Action(S3Action::BypassGovernanceRetentionAction)).await?;
}
@@ -524,7 +522,12 @@ fn reduce_delete_objects_result<'a>(
impl DefaultObjectUsecase {
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_delete_objects(
pub async fn execute_delete_objects(&self, req: S3Request<DeleteObjectsInput>) -> S3Result<S3Response<DeleteObjectsOutput>> {
let owner = self.clone();
run_owned_mutation(async move { Box::pin(owner.execute_delete_objects_owned(req)).await }).await
}
async fn execute_delete_objects_owned(
&self,
mut req: S3Request<DeleteObjectsInput>,
) -> S3Result<S3Response<DeleteObjectsOutput>> {
@@ -851,6 +854,8 @@ impl DefaultObjectUsecase {
.delete_objects_with_tier_delete_journal_and_accounting(&bucket, object_to_delete.clone(), storage_delete_opts)
.await;
#[cfg(test)]
super::super::storage_api::object_usecase::helper::pause_committed_mutation_for_test(&bucket).await;
let _manager = get_concurrency_manager();
if !errs.is_empty() && errs.iter().all(|err| err.as_ref().is_some_and(is_err_bucket_not_found)) {
let result = Err(S3Error::with_message(S3ErrorCode::NoSuchBucket, "Bucket not found".to_string()));
@@ -1031,6 +1036,8 @@ impl DefaultObjectUsecase {
.user_agent(get_request_user_agent(&req_headers))
.build();
#[cfg(test)]
super::super::storage_api::object_usecase::helper::capture_mutation_event_for_test(&event_args);
notify.notify(event_args).await;
}
}
@@ -1050,7 +1057,15 @@ impl DefaultObjectUsecase {
}
#[instrument(level = "info", skip(self, req))]
pub async fn execute_delete_object(&self, mut req: S3Request<DeleteObjectInput>) -> S3Result<S3Response<DeleteObjectOutput>> {
pub async fn execute_delete_object(&self, req: S3Request<DeleteObjectInput>) -> S3Result<S3Response<DeleteObjectOutput>> {
let owner = self.clone();
run_owned_mutation(async move { Box::pin(owner.execute_delete_object_owned(req)).await }).await
}
async fn execute_delete_object_owned(
&self,
mut req: S3Request<DeleteObjectInput>,
) -> S3Result<S3Response<DeleteObjectOutput>> {
if let Some(context) = &self.context {
let _ = context.object_store();
}
@@ -1261,6 +1276,8 @@ impl DefaultObjectUsecase {
}
};
#[cfg(test)]
super::super::storage_api::object_usecase::helper::pause_committed_mutation_for_test(&bucket).await;
if force_delete {
let _ = invalidate_object_data_cache_prefix_after_delete(&cache_adapter, &bucket, &key).await;
} else {
@@ -1454,6 +1471,105 @@ impl DefaultObjectUsecase {
#[cfg(test)]
mod tests {
#[test]
fn recursive_replica_delete_checks_each_descendant_delete_permission() {
crate::app::gating_test_env::run_large_stack_test("recursive-replica-permissions", || async {
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
let ambient = crate::app::gating_test_env::shared_gating_ambient().await;
let slot = crate::app::runtime_sources::ServerContextSlot::new();
assert!(slot.install(Arc::new(AppContext::new(store.clone(), ambient.iam(), ambient.kms()))));
for versioned in [false, true] {
let bucket = format!("recursive-replica-{}", Uuid::new_v4());
store
.make_bucket(
&bucket,
&MakeBucketOptions {
versioning_enabled: versioned,
..Default::default()
},
)
.await
.expect("create bucket");
for key in ["team/public", "team/secret"] {
for _ in 0..2 {
store
.put_object(
&bucket,
key,
&mut PutObjReader::from_vec(b"protected payload".to_vec()),
&ObjectOptions {
versioned,
..Default::default()
},
)
.await
.expect("seed descendant");
}
}
let before = store
.clone()
.list_object_versions(&bucket, "team/", None, None, None, 1000)
.await
.expect("list fixture")
.objects
.len();
for (grant_delete, deny_delete) in [(false, false), (true, true), (true, false)] {
let mut statements = vec![
serde_json::json!({"Effect":"Allow","Principal":{"AWS":"*"},"Action":"s3:ReplicateDelete","Resource":format!("arn:aws:s3:::{bucket}/*")}),
];
if grant_delete {
statements.push(serde_json::json!({"Effect":"Allow","Principal":{"AWS":"*"},"Action":["s3:DeleteObject","s3:DeleteObjectVersion"],"Resource":format!("arn:aws:s3:::{bucket}/*")}));
}
if deny_delete {
statements.push(serde_json::json!({"Effect":"Deny","Principal":{"AWS":"*"},"Action":["s3:DeleteObject","s3:DeleteObjectVersion"],"Resource":format!("arn:aws:s3:::{bucket}/team/secret")}));
}
store
.update_bucket_metadata_config(
&bucket,
crate::storage::storage_api::ecstore_bucket::metadata::BUCKET_POLICY_CONFIG,
serde_json::to_vec(&serde_json::json!({"Version":"2012-10-17","Statement":statements})).unwrap(),
)
.await
.expect("publish policy");
for replica in [false, true] {
let mut req = build_request((), Method::DELETE);
req.extensions.insert(slot.clone());
req.extensions.insert(crate::storage::access::ReqInfo {
bucket: Some(bucket.clone()),
object: Some("team/".to_owned()),
..Default::default()
});
let result = authorize_recursive_delete(&mut req, &store, &bucket, "team/", versioned, replica).await;
if grant_delete && !deny_delete {
result.expect("ordinary and replication deletes allowed");
} else {
assert_eq!(
result.expect_err("descendant delete permission must be enforced").code(),
&S3ErrorCode::AccessDenied
);
}
assert_eq!(
req_info_ref(&req).unwrap().object.as_deref(),
Some("team/"),
"restore original authorization context"
);
assert_eq!(
store
.clone()
.list_object_versions(&bucket, "team/", None, None, None, 1000)
.await
.expect("versions survive preflight")
.objects
.len(),
before
);
}
}
}
});
}
use super::*;
use http::{HeaderMap, HeaderValue, Method};
use s3s::dto::{
@@ -3104,6 +3220,92 @@ mod tests {
});
}
#[test]
#[serial_test::serial]
fn cancelled_delete_requests_complete_audit_and_notifications() {
crate::app::gating_test_env::run_large_stack_test("owned-delete-events", || async {
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
let (store, context) = real_store_test_context().await;
for batch in [false, true] {
let bucket = format!("delete-owned-event-{}", Uuid::new_v4());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("bucket");
for key in ["first", "second"] {
let mut reader = PutObjReader::from_vec(b"object".to_vec());
store
.put_object(&bucket, key, &mut reader, &ObjectOptions::default())
.await
.expect("object");
}
let policy_json = format!(
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"*"}},"Action":["s3:DeleteObject","s3:DeleteObjectVersion"],"Resource":["arn:aws:s3:::{bucket}/*"]}}]}}"#
);
let mut metadata = (*crate::storage::get_bucket_metadata(&bucket).await.expect("metadata")).clone();
metadata.policy_config = Some(serde_json::from_str(&policy_json).expect("policy"));
metadata.policy_config_json = policy_json.into_bytes();
crate::storage::storage_api::set_bucket_metadata(bucket.clone(), metadata)
.await
.expect("policy metadata");
let probe = crate::app::storage_api::object_usecase::helper::MutationCompletionProbe::install(&bucket);
let (entered, resume) = probe.pause_after_commit();
let usecase = DefaultObjectUsecase::with_context(Some(context.clone()));
let request_bucket = bucket.clone();
let deletion = tokio::spawn(async move {
if batch {
let input = DeleteObjectsInput::builder()
.bucket(request_bucket.clone())
.delete(Delete {
objects: ["first", "second"]
.into_iter()
.map(|key| ObjectIdentifier {
key: key.to_owned(),
..Default::default()
})
.collect(),
quiet: Some(false),
})
.build()
.expect("batch input");
let mut request = build_request(input, Method::POST);
request.uri = format!("/{request_bucket}").parse().expect("URI");
request.extensions.insert(crate::storage::access::ReqInfo::default());
usecase.execute_delete_objects(request).await.map(|_| ())
} else {
let input = DeleteObjectInput::builder()
.bucket(request_bucket.clone())
.key("first".to_owned())
.build()
.expect("delete input");
let mut request = build_request(input, Method::DELETE);
request.uri = format!("/{request_bucket}/first").parse().expect("URI");
usecase.execute_delete_object(request).await.map(|_| ())
}
});
tokio::time::timeout(std::time::Duration::from_secs(10), entered.wait())
.await
.expect("committed delete");
deletion.abort();
assert!(deletion.await.expect_err("cancel waiter").is_cancelled());
resume.wait().await;
probe.wait_for(1, if batch { 2 } else { 1 }).await;
assert!(
store
.get_object_info(&bucket, "first", &ObjectOptions::default())
.await
.is_err()
);
let audits = probe.audits.lock().expect("audits");
assert_eq!(audits.len(), 1);
assert_eq!(audits[0].api.status.as_deref(), Some("success"));
let events = probe.events.lock().expect("events");
assert_eq!(events.len(), if batch { 2 } else { 1 });
assert!(events.iter().all(|event| event.event_name == EventName::ObjectRemovedDelete));
}
});
}
#[test]
#[serial_test::serial]
fn recursive_delete_holds_writer_exclusion_after_authorization() {
@@ -3791,11 +3993,16 @@ mod tests {
.delete_object(&bucket, "object", ObjectOptions::default())
.await
.expect("delete quota-tracked object");
// Deleted bytes are reclaimed by the owned reconciliation job; requests
// no longer scan the bucket synchronously under the ledger lock.
crate::app::storage_api::test::set_disk::retry_quota_reconciliation_for_test(Arc::clone(&store), &bucket)
.await
.expect("reconcile the deleted object's usage through the production worker");
let mut replacement = PutObjReader::from_vec(vec![0x43; 4096]);
store
.put_object(&bucket, "replacement", &mut replacement, &ObjectOptions::default())
.await
.expect("deleted bytes must be reclaimed before rejecting a replacement");
.expect("reconciled deleted bytes must admit a full-size replacement");
let mut excess = PutObjReader::from_vec(vec![0x44]);
let err = store
+198 -17
View File
@@ -457,7 +457,9 @@ where
}
let pool = get_concurrency_manager().bytes_pool();
let mut buffer = pool.acquire_buffer(size).await;
let mut buffer = pool
.try_acquire_buffer(size)
.unwrap_or_else(|| rustfs_io_core::PooledBuffer::unpooled(size));
super::put::read_small_put_body_into(body, &mut *buffer, size).await?;
Ok(ExtractStagedBody {
reader: Some(super::put::PooledBufferReader::new(buffer, size)),
@@ -1282,7 +1284,7 @@ fn is_empty_extract_entry_path(path: &str) -> bool {
fn validate_extract_member_key(path: &str, limits: ArchiveLimits) -> Result<(), ExtractEntryError> {
validate_put_object_extract_entry_path(path, limits).map_err(ExtractEntryError::Recoverable)?;
validate_object_key(path, "PUT").map_err(ExtractEntryError::Recoverable)
validate_new_object_key(path, "PUT").map_err(ExtractEntryError::Recoverable)
}
fn record_extract_pax_metadata_bytes(
@@ -1588,6 +1590,26 @@ fn extract_member_iam_requirements(
}
}
// Version and replica controls require authorization even when the archive does not set a time.
// An mtime-only member can still be uploaded with server time when the caller lacks that permission.
async fn authorized_extract_member_mod_time(
req: &mut S3Request<PutObjectInput>,
replication_required: bool,
archive_time: Option<OffsetDateTime>,
) -> S3Result<Option<OffsetDateTime>> {
if !replication_required {
if archive_time.is_none() {
return Ok(None);
}
req_info_mut(req)?.suppress_denial_log = true;
}
match authorize_request(req, Action::S3Action(S3Action::ReplicateObjectAction)).await {
Ok(()) => Ok(archive_time),
Err(err) if !replication_required && err.code() == &S3ErrorCode::AccessDenied => Ok(None),
Err(err) => Err(err),
}
}
fn apply_extract_pax_overrides(
overrides: &ExtractPaxOverrides,
bucket: &str,
@@ -2067,14 +2089,26 @@ impl DefaultObjectUsecase {
true,
)?;
let Some(body) = body else { return Err(s3_error!(IncompleteBody)) };
let body = guard_put_object_body_read_timeout(
body,
&bucket,
&key,
&request_context.request_id,
content_length,
put_object_body_read_timeout(),
);
let (body, progress_control) = if let Some(control) = req.extensions.get::<super::request_body::BodyReadControl>() {
control.activate_upload(
put_object_body_read_timeout(),
&bucket,
&key,
&request_context.request_id,
content_length.unwrap_or_default().max(0) as u64,
);
(body, control.clone())
} else {
super::request_body::guard_upload_progress(
body,
put_object_body_read_timeout(),
&bucket,
&key,
&request_context.request_id,
content_length.unwrap_or_default().max(0) as u64,
)
};
let body = super::request_body::demand_upload_body(body, progress_control.clone());
let size = match content_length {
Some(c) => c,
@@ -2092,7 +2126,7 @@ impl DefaultObjectUsecase {
if size < 0 {
return Err(s3_error!(UnexpectedContent));
}
validate_object_key(&key, "PUT")?;
validate_new_object_key(&key, "PUT")?;
validate_table_catalog_object_mutation(&bucket, &key).await?;
let _ = self
.check_bucket_quota(
@@ -2156,6 +2190,7 @@ impl DefaultObjectUsecase {
ExtractArchiveEtagReader::new(archive_reader, expected_archive_length, archive_upload_state.clone());
let decoder = build_admitted_extract_archive_decoder(get_concurrency_manager(), &key, tracked_archive).await?;
let decoder = ExtractDecodedLimitReader::new(decoder, extract_limits.max_decoded_size);
let decoder = super::request_body::DemandReader::new(decoder, progress_control);
let mut ar = build_put_object_extract_archive(decoder, extract_limits);
let mut entries = ar.entries().map_err(|e| {
@@ -2357,7 +2392,10 @@ impl DefaultObjectUsecase {
put_opts_with_replication_authorization(
&bucket,
&fpath,
outer_version_id.clone(),
// The DTO version is applied with any PAX override below, then
// authorized against this member before publication. Header
// replication controls still require the outer authorization.
None,
&member_request_headers,
metadata.clone(),
replication_authorized,
@@ -2456,15 +2494,14 @@ impl DefaultObjectUsecase {
if iam_requirements.legal_hold {
extract_try!(authorize_request(&mut auth_req, Action::S3Action(S3Action::PutObjectLegalHoldAction)).await);
}
if iam_requirements.replication {
extract_try!(authorize_request(&mut auth_req, Action::S3Action(S3Action::ReplicateObjectAction)).await);
if let Some(mod_time) = extract_try!(
authorized_extract_member_mod_time(&mut auth_req, iam_requirements.replication, archive_entry_mod_time,).await
) {
opts.mod_time = Some(mod_time);
}
let table_data_plane_publication_guards = retained_table_data_plane_publication_guards(&auth_req);
drop(auth_req);
extract_try!(reject_veeam_sosapi_mutation(|| Some(Arc::clone(&commit_context.store)), &bucket, &fpath).await);
if archive_entry_mod_time.is_some() {
opts.mod_time = archive_entry_mod_time;
}
debug!("Extracting file: {}, size: {} bytes", fpath, size);
@@ -2792,6 +2829,49 @@ mod tests {
})
}
#[tokio::test(start_paused = true)]
async fn snowball_stalled_archives_release_actual_decoder_slots() {
use futures::poll;
use tokio_stream::wrappers::UnboundedReceiverStream;
let manager = ConcurrencyManager::new();
let mut senders = Vec::new();
let mut owners = Vec::new();
for _ in 0..4 {
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<Result<Bytes, std::io::Error>>();
let (body, _control) = super::super::request_body::guard_upload_progress(
StreamingBlob::wrap(UnboundedReceiverStream::new(receiver)),
Duration::ZERO,
"bucket",
"archive.tar",
"request",
1024,
);
let body = StreamReader::new(body.map(|item| item.map_err(s3s_body_error_to_io)));
let mut owner = Box::pin(build_admitted_extract_archive_decoder(&manager, "archive.tar", body));
assert!(poll!(owner.as_mut()).is_pending());
senders.push(sender);
owners.push(owner);
}
assert!(manager.try_acquire_snowball_archive_decoder().is_none());
for _ in 0..5 {
tokio::time::advance(Duration::from_secs(10)).await;
for (sender, owner) in senders.iter().zip(&mut owners) {
sender.send(Ok(Bytes::from_static(b"x"))).unwrap();
assert!(poll!(owner.as_mut()).is_pending());
}
}
tokio::time::advance(Duration::from_secs(10)).await;
for owner in owners {
let error = owner.await.err().expect("stalled archive sniff must expire");
assert_eq!(error.code(), &S3ErrorCode::RequestTimeout);
}
let recovered: Vec<_> = (0..4)
.map(|_| manager.try_acquire_snowball_archive_decoder().expect("reusable slot"))
.collect();
assert!(manager.try_acquire_snowball_archive_decoder().is_none());
drop(recovered);
}
#[tokio::test]
async fn snowball_archive_decoder_admission_is_global_and_lifetime_bound() {
struct PanicOnRead;
@@ -3585,6 +3665,107 @@ mod tests {
}
}
#[tokio::test]
#[serial_test::serial]
async fn snowball_member_mtime_requires_member_replication_authorization() {
use crate::app::storage_api::test::ReqInfo;
use crate::app::storage_api::test::bucket::metadata::{BUCKET_POLICY_CONFIG, BUCKET_VERSIONING_CONFIG};
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
use http::Method;
let (store, context) = real_store_test_context().await;
let bucket = format!("extract-time-{}", Uuid::new_v4());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create bucket");
metadata_sys::update(
&bucket,
BUCKET_VERSIONING_CONFIG,
b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec(),
)
.await
.expect("enable versioning");
let usecase = DefaultObjectUsecase::with_context(Some(context));
for (replicate, explicit_version, mtime) in [
(false, false, 2_524_608_000_u64),
(false, false, 1_000_000_000),
(false, true, 2_524_608_000),
(true, false, 1_704_000_123),
(true, true, 2_524_608_000),
] {
let actions = if replicate {
vec!["s3:PutObject", "s3:ReplicateObject"]
} else {
vec!["s3:PutObject"]
};
let policy = serde_json::json!({"Version":"2012-10-17", "Statement":[{
"Effect":"Allow", "Principal":"*", "Action":actions,
"Resource":format!("arn:aws:s3:::{bucket}/*")
}]});
metadata_sys::update(&bucket, BUCKET_POLICY_CONFIG, serde_json::to_vec(&policy).expect("policy JSON"))
.await
.expect("install member permissions");
let member = match (replicate, explicit_version) {
(true, false) => "authorized-time-only.txt",
(true, true) => "replicated.txt",
(false, true) => "denied-version.txt",
(false, false) => "ordinary.txt",
};
let mut builder = Builder::new(Vec::new());
let mut header = Header::new_gnu();
header.set_size(1);
header.set_mode(0o644);
header.set_mtime(mtime);
header.set_cksum();
builder
.append_data(&mut header, member, &b"x"[..])
.await
.expect("append member");
let archive = builder.into_inner().await.expect("finish archive");
let before = OffsetDateTime::now_utc();
let input = PutObjectInput::builder()
.bucket(bucket.clone())
.key("archive.tar".to_string())
.version_id(explicit_version.then(|| Uuid::new_v4().to_string()))
.content_length(Some(i64::try_from(archive.len()).expect("archive size")))
.body(Some(StreamingBlob::from_bytes(Bytes::from(archive))))
.build()
.expect("extract request");
let mut request = build_request(input, Method::PUT);
request.extensions.insert(ReqInfo::default());
let result = Box::pin(usecase.execute_put_object_extract(request)).await;
if explicit_version && !replicate {
assert_eq!(
result
.expect_err("version selection requires member replication permission")
.code(),
&S3ErrorCode::AccessDenied
);
let missing = store
.get_object_info(&bucket, member, &ObjectOptions::default())
.await
.expect_err("unauthorized version cannot publish a member");
assert!(is_err_object_not_found(&missing));
continue;
}
result.expect("extract member");
let info = store
.get_object_info(&bucket, member, &ObjectOptions::default())
.await
.expect("current member");
let actual = info.mod_time.expect("stored timestamp");
if replicate {
assert_eq!(actual.unix_timestamp(), i64::try_from(mtime).expect("timestamp"));
} else {
assert!(
actual >= before && actual <= OffsetDateTime::now_utc(),
"ordinary upload used archive time: {actual}"
);
}
}
}
#[tokio::test]
async fn archive_etag_reader_validates_sha256_before_completion() {
let payload = b"archive-with-wrong-sha256".to_vec();
+753 -57
View File
@@ -16,6 +16,7 @@
use super::veeam_sosapi;
use super::*;
use crate::app::storage_api::object_usecase::access::replication_proxy_allowed;
use crate::app::storage_api::object_usecase::object_cache::{
GetObjectReadCondition, GetObjectReadMetadata, evaluate_read_preconditions, has_read_preconditions,
with_get_object_read_condition,
@@ -669,48 +670,246 @@ impl GetObjectBodyLifecycle {
}
}
pin_project! {
// Keep the disk-read admission permit tied to the response body. This is
// intentionally conservative backpressure: a streaming GET should occupy a
// read slot until the client drains or drops the body.
struct DiskReadPermitReader<R> {
#[pin]
inner: R,
disk_permit: Option<GetObjectDiskPermit>,
}
// Bound downstream inactivity without imposing a fixed limit on large downloads.
// The floor also caps the watchdog's timer arithmetic.
const GET_DISK_READ_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
/// Seconds a GET body may make no progress before its disk-read admission is
/// released and the stream fails; a paused client within this window resumes
/// normally. Never below the floor above or twice the disk read timeout.
const ENV_GET_DISK_READ_IDLE_TIMEOUT_SECS: &str = "RUSTFS_GET_DISK_READ_IDLE_TIMEOUT_SECS";
const DEFAULT_GET_DISK_READ_IDLE_TIMEOUT_SECS: u64 = 120;
fn configured_get_disk_read_idle_timeout() -> Duration {
Duration::from_secs(rustfs_utils::get_env_u64(
ENV_GET_DISK_READ_IDLE_TIMEOUT_SECS,
DEFAULT_GET_DISK_READ_IDLE_TIMEOUT_SECS,
))
.max(GET_DISK_READ_IDLE_TIMEOUT)
}
const GET_DISK_READ_MIN_WINDOW_BYTES: usize = 64 * 1024;
struct DiskReadLease<R> {
reader: Option<Pin<Box<R>>>,
permit: Option<GetObjectDiskPermit>,
pending: bool,
pending_capacity: usize,
closed: bool,
expired: bool,
abandoned: tokio_util::sync::CancellationToken,
last_progress: tokio::time::Instant,
window_start: tokio::time::Instant,
window_bytes: usize,
idle_timeout: Duration,
progress_window: Duration,
waker: Option<std::task::Waker>,
}
impl<R> DiskReadPermitReader<R> {
fn new(inner: R, disk_permit: GetObjectDiskPermit) -> Self {
Self {
inner,
disk_permit: Some(disk_permit),
impl<R> DiskReadLease<R> {
fn check_deadline(&mut self, now: tokio::time::Instant) {
if now.saturating_duration_since(self.last_progress) >= self.idle_timeout {
self.expired = true;
}
if now.saturating_duration_since(self.window_start) >= self.progress_window {
if self.window_bytes < GET_DISK_READ_MIN_WINDOW_BYTES {
self.expired = true;
} else {
self.window_start = now;
self.window_bytes = 0;
}
}
}
fn next_check_delay(&self, now: tokio::time::Instant) -> Duration {
self.idle_timeout
.saturating_sub(now.saturating_duration_since(self.last_progress))
.min(
self.progress_window
.saturating_sub(now.saturating_duration_since(self.window_start)),
)
// Keep timer arithmetic representable even for extreme operator overrides.
.min(GET_DISK_READ_IDLE_TIMEOUT)
}
}
impl<R> AsyncRead for DiskReadPermitReader<R>
where
R: AsyncRead,
{
struct DiskReadPermitReader<R> {
lease: Arc<Mutex<DiskReadLease<R>>>,
changed: Arc<tokio::sync::Notify>,
}
/// Out-of-band control over a [`DiskReadPermitReader`]'s lease, for the
/// owner that decides a submitted read is stalled rather than merely unread.
struct DiskReadLeaseControl<R> {
lease: Arc<Mutex<DiskReadLease<R>>>,
changed: Arc<tokio::sync::Notify>,
}
impl<R> DiskReadLeaseControl<R> {
/// Release the disk-read permit now, even with a read in flight. Dropping
/// the reader alone keeps the permit until that read completes (a cancelled
/// client must not widen disk concurrency); a deadline that classified the
/// read as stalled has already given up on its result, so the slot goes
/// back to the pool and the read is dropped with the reader.
fn abandon(&self) {
let waker = {
let mut state = self.lease.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
state.closed = true;
state.abandoned.cancel();
state.pending = false;
state.reader.take();
state.permit.take();
state.waker.take()
};
if let Some(waker) = waker {
waker.wake();
}
self.changed.notify_one();
}
}
impl<R: AsyncRead + Send + 'static> DiskReadPermitReader<R> {
fn new(inner: R, disk_permit: GetObjectDiskPermit) -> Self {
// Leave the configured upstream stall/reopen window ahead of this
// downstream lease, including the high-latency drive profile.
let idle_timeout = get_object_disk_read_timeout()
.saturating_mul(2)
.max(configured_get_disk_read_idle_timeout());
Self::with_idle_timeout(inner, disk_permit, idle_timeout)
}
fn lease_control(&self) -> DiskReadLeaseControl<R> {
DiskReadLeaseControl {
lease: self.lease.clone(),
changed: self.changed.clone(),
}
}
fn with_idle_timeout(inner: R, disk_permit: GetObjectDiskPermit, idle_timeout: Duration) -> Self {
let now = tokio::time::Instant::now();
let lease = Arc::new(Mutex::new(DiskReadLease {
reader: Some(Box::pin(inner)),
permit: Some(disk_permit),
pending: false,
pending_capacity: 0,
closed: false,
expired: false,
abandoned: tokio_util::sync::CancellationToken::new(),
last_progress: now,
window_start: now,
window_bytes: 0,
idle_timeout,
progress_window: idle_timeout.saturating_mul(2),
waker: None,
}));
let changed = Arc::new(tokio::sync::Notify::new());
tokio::spawn(Self::watch(lease.clone(), changed.clone()));
Self { lease, changed }
}
async fn watch(lease: Arc<Mutex<DiskReadLease<R>>>, changed: Arc<tokio::sync::Notify>) {
let (reader, permit, pending_capacity, waker, abandoned) = loop {
let next_check = {
let mut state = lease.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
if !state.closed {
state.check_deadline(tokio::time::Instant::now());
}
if state.closed || state.expired {
state.closed = true;
break (
state.reader.take(),
state.permit.take(),
state.pending.then_some(state.pending_capacity),
state.waker.take(),
state.abandoned.clone(),
);
}
state.next_check_delay(tokio::time::Instant::now())
};
tokio::select! {
_ = tokio::time::sleep(next_check) => {},
_ = changed.notified() => {},
}
};
if let Some(waker) = waker {
waker.wake();
}
if let Some(mut reader) = reader {
if let Some(capacity) = pending_capacity {
// A cancelled response must not release disk admission while a
// submitted read is still running. Complete just that pending
// read, discard its result, and never start another read.
let mut scratch = vec![0_u8; capacity.min(64 * 1024)];
let mut buffer = ReadBuf::new(&mut scratch);
// An explicit stalled-read timeout can retire admission even
// after this task has taken ownership of the pending read.
tokio::select! {
biased;
_ = abandoned.cancelled() => {},
_ = std::future::poll_fn(|cx| reader.as_mut().poll_read(cx, &mut buffer)) => {},
}
}
drop(reader);
}
drop(permit);
}
}
impl<R: AsyncRead> AsyncRead for DiskReadPermitReader<R> {
fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
let this = self.project();
let had_capacity = buf.remaining() > 0;
if buf.remaining() == 0 {
return Poll::Ready(Ok(()));
}
let mut state = self.lease.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
if !state.closed {
state.check_deadline(tokio::time::Instant::now());
}
if state.expired {
self.changed.notify_one();
return Poll::Ready(Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"GetObject disk-read admission expired",
)));
}
if state.closed {
return Poll::Ready(Ok(()));
}
state.waker = Some(cx.waker().clone());
let filled_before = buf.filled().len();
let poll = this.inner.poll_read(cx, buf);
// EOF: no more disk reads can happen through this stream, so release
// the permit instead of holding it until the client drops the body.
if had_capacity
&& matches!(poll, Poll::Ready(Ok(())))
&& buf.filled().len() == filled_before
&& let Some(mut disk_permit) = this.disk_permit.take()
{
disk_permit.release();
state.pending_capacity = buf.remaining();
let poll = match state.reader.as_mut() {
Some(reader) => reader.as_mut().poll_read(cx, buf),
None => return Poll::Ready(Ok(())),
};
state.pending = poll.is_pending();
if let Poll::Ready(result) = &poll {
let progress = buf.filled().len() - filled_before;
if result.is_err() || progress == 0 {
state.closed = true;
// The read completed, so normal EOF/error can release inline.
state.reader.take();
state.permit.take();
self.changed.notify_one();
} else {
state.last_progress = tokio::time::Instant::now();
state.window_bytes = state.window_bytes.saturating_add(progress);
}
}
poll
}
}
impl<R> Drop for DiskReadPermitReader<R> {
fn drop(&mut self) {
let mut state = self.lease.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
state.closed = true;
if !state.pending {
state.reader.take();
state.permit.take();
}
// The watchdog takes ownership of an outstanding read on cancellation.
self.changed.notify_one();
}
}
pin_project! {
struct GetObjectReaderStream<R> {
#[pin]
@@ -1094,10 +1293,20 @@ async fn start_cold_fill_producer<AcquireIo, AcquireIoFuture, OpenReader, OpenRe
}
};
producer.mark_reader_started();
let GetObjectReader {
stream, buffered_body, ..
} = reader;
// Wrap outside the materialize future so a deadline can abandon the disk
// admission of a read it has classified as stalled (see `DiskReadLeaseControl`).
let (stream, lease_control) = match producer_io.disk_permit {
Some(permit) if buffered_body.is_none() => {
let reader = DiskReadPermitReader::new(stream, permit);
let control = reader.lease_control();
(wrap_reader(reader), Some(control))
}
_ => (wrap_reader(stream), None),
};
let materialize = async move {
let GetObjectReader {
stream, buffered_body, ..
} = reader;
let body = if let Some(body) = buffered_body {
if body.len() == expected {
body
@@ -1108,11 +1317,6 @@ async fn start_cold_fill_producer<AcquireIo, AcquireIoFuture, OpenReader, OpenRe
)));
}
} else {
let stream = if let Some(permit) = producer_io.disk_permit {
wrap_reader(DiskReadPermitReader::new(stream, permit))
} else {
stream
};
Bytes::from(
strict_materialize_object_body(stream, expected, GET_OBJECT_STAGE_BODY_CACHE_MATERIALIZE_READ)
.await
@@ -1127,6 +1331,11 @@ async fn start_cold_fill_producer<AcquireIo, AcquireIoFuture, OpenReader, OpenRe
result.unwrap_or(Err(StorageError::Timeout))
}
};
if matches!(materialized, Err(StorageError::Timeout))
&& let Some(control) = &lease_control
{
control.abandon();
}
let result = match materialized {
Ok((body, reservation)) => {
if cancellation.is_cancelled() {
@@ -2506,9 +2715,9 @@ impl DefaultObjectUsecase {
) -> S3Result<Option<GetObjectDiskPermit>> {
let permit_wait_start = std::time::Instant::now();
let permit_wait_timeout = Self::disk_permit_wait_timeout();
// Permits are held for the whole body transfer, so slow clients can pin
// all of them while disks are idle. Bound the wait on the primary pool
// and, on timeout, admit from a bounded degraded overflow lane. Total
// Active readers own admission until EOF, drop, or watchdog retirement.
// Bound the wait on the primary pool and, on timeout, admit from a
// bounded degraded overflow lane. Total
// concurrent disk-active GETs are hard-capped at
// `primary_cap + degraded_cap`; once that cap is reached we reject with
// `SlowDown` instead of reading without any admission token. Never
@@ -4328,6 +4537,7 @@ impl DefaultObjectUsecase {
// their semantics) may still be served by a remote copy.
if let Err(err) = &prepared_read
&& matches!(*err.code(), S3ErrorCode::NoSuchKey | S3ErrorCode::NoSuchVersion)
&& replication_proxy_allowed(&req.extensions)
{
// Active-active replication lag window: proxy the GET to a
// replication target (backlog#1675 P1-5).
@@ -4905,20 +5115,29 @@ fn odm_get_output(head: &SourceHead, content_length: i64, content_range: Option<
}
}
/// Feeds the tee primary to the client through a channel: the primary owns a
/// `!Sync` boxed source while the response body must be `Sync`. Dropping the
/// body ends the pump, which drops the primary and hands the remaining
/// source bytes to the tee's drain task (`drain_on_primary_drop`).
fn odm_inline_client_body(primary: TeePrimary) -> StreamingBlob {
// These downstream budgets are independent of source idle progress. In
// particular, reading a little before each idle timeout cannot retain a pull
// slot indefinitely. Timeout drops the primary and lets the tee drain to disk.
const ODM_INLINE_CLIENT_IDLE_BUDGET: Duration = Duration::from_secs(30);
const ODM_INLINE_CLIENT_TOTAL_BUDGET: Duration = Duration::from_secs(120);
/// Feeds the tee primary through a channel because its source is `!Sync`.
/// Dropping the body or exhausting a downstream budget drops the primary and
/// hands remaining bytes to the tee's drain task (`drain_on_primary_drop`).
fn odm_inline_client_body(primary: TeePrimary, idle_budget: Duration, total_budget: Duration) -> StreamingBlob {
let (tx, rx) = tokio::sync::mpsc::channel::<io::Result<Bytes>>(ODM_INLINE_CLIENT_CHANNEL_CHUNKS);
spawn_traced(async move {
let mut chunks = tokio_util::io::ReaderStream::with_capacity(primary, ODM_SOURCE_BODY_CHUNK_BYTES);
while let Some(chunk) = chunks.next().await {
let failed = chunk.is_err();
if tx.send(chunk).await.is_err() || failed {
break;
let pump = async move {
let mut chunks = tokio_util::io::ReaderStream::with_capacity(primary, ODM_SOURCE_BODY_CHUNK_BYTES);
while let Some(chunk) = chunks.next().await {
let failed = chunk.is_err();
if !matches!(tokio::time::timeout(idle_budget, tx.send(chunk)).await, Ok(Ok(()))) || failed {
break;
}
}
}
};
// This task is scheduled independently of response body polling.
let _ = tokio::time::timeout(total_budget, pump).await;
});
StreamingBlob::wrap(ReceiverStream::new(rx))
}
@@ -5039,7 +5258,12 @@ async fn odm_get_inline<S: OdmGetSource>(
let (guarded, idle) = idle_guarded_body(source_body, Duration::from_millis(policy.source_timeout.idle_ms));
let (primary, secondary) =
tee_reader_with_options(Box::pin(tokio_util::io::StreamReader::new(guarded)), ODM_INLINE_TEE_BUFFER_BYTES, options);
let output = Box::new(odm_get_output(&head, content_length, content_range, odm_inline_client_body(primary)));
let output = Box::new(odm_get_output(
&head,
content_length,
content_range,
odm_inline_client_body(primary, ODM_INLINE_CLIENT_IDLE_BUDGET, ODM_INLINE_CLIENT_TOTAL_BUDGET),
));
let commit_state = Arc::clone(state);
let commit_key = key.to_string();
spawn_background_with_context(request_context, async move {
@@ -5087,11 +5311,12 @@ pub(super) async fn odm_get_from_source<S: OdmGetSource>(
if head.size > policy.inline_max_bytes {
return odm_get_passthrough(state, source, headers, key, None, Some(PullReason::LargeObject)).await;
}
let slot = match state.acquire_pull_slot(key).await {
Ok(slot) => slot,
// The bucket state was torn down under this request: serve it
// without queueing anything on the old state.
Err(_) => return odm_get_passthrough(state, source, headers, key, None, None).await,
let permit_budget = Duration::from_millis(policy.source_timeout.first_byte_ms).min(Duration::from_secs(1));
let slot = match tokio::time::timeout(permit_budget, state.acquire_pull_slot(key)).await {
Ok(Ok(slot)) => slot,
// A saturated or retired bucket state must not hold the response
// indefinitely. Dropping acquisition removes its singleflight entry.
Ok(Err(_)) | Err(_) => return odm_get_passthrough(state, source, headers, key, None, None).await,
};
match slot {
PullSlot::Leader(leader) => odm_get_inline(state, source, headers, key, leader, request_context).await,
@@ -5411,6 +5636,85 @@ mod on_demand_migration_tests {
assert!(!odm_get_may_consult_source(&proxy_marked, None));
}
#[tokio::test]
async fn odm_get_permit_wait_is_bounded_and_removes_the_abandoned_slot() {
let mut policy = PolicyConfig {
max_concurrent_pulls: 1,
..Default::default()
};
policy.source_timeout.first_byte_ms = 100;
let rt = runtime("permit-budget", policy).await;
let state = rt.state("permit-budget");
let held = state.acquire_pull_slot("held").await.expect("occupy the only permit");
let data = payload(1024);
let source = ScriptedSource::for_object(&data);
let reply = tokio::time::timeout(
Duration::from_secs(2),
odm_get_from_source(&state, &source, &HeaderMap::new(), KEY, None, None),
)
.await
.expect("saturated inline permits must fall back to passthrough");
let (mut output, backfill) = served(reply);
assert_eq!(backfill, None);
assert_eq!(collect_body(&mut output).await, data);
assert_eq!(state.inflight_keys(), 1, "timed-out waiter must not strand a singleflight entry");
assert!(rt.write_back.puts().is_empty(), "fallback must not start another inline pull");
drop(held);
assert_eq!(state.inflight_keys(), 0);
}
#[tokio::test]
async fn odm_client_backpressure_and_trickle_cannot_retain_a_pull_slot() {
for trickle in [false, true] {
let rt = runtime("client-budget", PolicyConfig::default()).await;
let state = rt.state("client-budget");
let leader = match state.acquire_pull_slot(KEY).await.expect("leader") {
PullSlot::Leader(leader) => leader,
PullSlot::Follower(_) => panic!("fresh key must lead"),
};
let data = payload(4 * 1024 * 1024);
let head = source_head(&data);
let source: SourceBody = Box::pin(futures::stream::iter([Ok(Bytes::from(data.clone()))]));
let (guarded, idle) = idle_guarded_body(source, Duration::from_secs(5));
let (primary, secondary) = tee_reader_with_options(
Box::pin(tokio_util::io::StreamReader::new(guarded)),
ODM_INLINE_TEE_BUFFER_BYTES,
TeeOptions {
drain_on_primary_drop: true,
max_drain_bytes: data.len(),
},
);
let mut client = odm_inline_client_body(primary, Duration::from_millis(100), Duration::from_millis(250));
let commit_state = Arc::clone(&state);
let commit = tokio::spawn(async move {
let result = commit_inline(&commit_state, KEY, head, None, Box::pin(secondary.into_stream()), &idle).await;
leader.complete(result.map(|outcome| PullOutcome {
etag: outcome.etag,
size: outcome.size,
}));
});
let received = if trickle {
let mut received = 0;
// Progress is faster than the idle budget but cannot reset the absolute budget.
while let Some(chunk) = client.next().await {
received += chunk.expect("client chunk").len();
tokio::time::sleep(Duration::from_millis(40)).await;
}
received
} else {
0
};
tokio::time::timeout(Duration::from_secs(3), commit)
.await
.expect("client pacing cannot stall write-back")
.expect("write-back task");
assert_eq!(state.inflight_keys(), 0, "permit released while the unread response is still alive");
assert_eq!(rt.write_back.puts()[0].1, data, "drain must preserve the entire source object");
assert!(received < data.len(), "timed-out response must not pretend to deliver the full object");
drop(client);
}
}
#[tokio::test]
async fn odm_get_inline_streams_to_client_and_commits_the_same_bytes() {
let rt = runtime("b", PolicyConfig::default()).await;
@@ -6796,6 +7100,146 @@ mod tests {
drop(successor);
}
#[tokio::test(start_paused = true)]
async fn cold_fill_watchdog_timeout_releases_pending_read_admission() {
let adapter = Arc::new(
ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig {
mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled,
max_bytes: 2 * 1024 * 1024,
max_memory_percent: 0,
max_entry_bytes: 1024 * 1024,
min_free_memory_percent: 0,
fill_concurrency_max: 1,
..rustfs_object_data_cache::ObjectDataCacheConfig::default()
})
.expect("timeout cache config must be valid"),
);
let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest {
bucket: "timeout-bucket",
object: "timeout-object",
version_id: None,
etag: "timeout-etag",
size: 768 * 1024,
data_dir_u128: Some(1),
mod_time_unix_nanos: 1,
body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
});
let key = plan.key().cloned().expect("timeout body must be cacheable");
let coordinator = adapter.cold_fill_coordinator();
let ColdFillRole::Produce(mut producer) = coordinator.join(key.clone()) else {
panic!("first timeout request must produce");
};
let leader = producer.waiter();
let reservation = adapter.reserve_body(&plan);
let disk_permits = Arc::new(tokio::sync::Semaphore::new(1));
let disk_gate = Arc::clone(&disk_permits);
let readers = Arc::new(AtomicUsize::new(0));
let reader_count = Arc::clone(&readers);
// Use the real asynchronous stream contract: the submitted read stays
// pending until the peer is explicitly closed after the assertion.
struct ObservedPendingRead {
inner: tokio::io::DuplexStream,
started: Option<tokio::sync::oneshot::Sender<()>>,
}
impl AsyncRead for ObservedPendingRead {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buffer: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
if let Some(started) = self.started.take() {
let _ = started.send(());
}
Pin::new(&mut self.inner).poll_read(cx, buffer)
}
}
let (pending_stream, pending_peer) = tokio::io::duplex(64);
let (first_poll, first_poll_ready) = tokio::sync::oneshot::channel();
let pending_stream = ObservedPendingRead {
inner: pending_stream,
started: Some(first_poll),
};
let manager = get_concurrency_manager();
let producer_task = tokio::spawn(start_cold_fill_producer(
producer,
reservation,
move || async move {
let permit = disk_gate
.acquire_owned()
.await
.map_err(|_| ColdFillError::DiskAdmissionClosed)?;
let mut io = DefaultObjectUsecase::get_object_io_planning_without_disk(manager);
io.disk_permit = Some(permit.into());
Ok(io)
},
move || async move {
reader_count.fetch_add(1, AtomicOrdering::Relaxed);
Ok(GetObjectReader {
stream: Box::new(pending_stream),
object_info: ObjectInfo {
size: 768 * 1024,
actual_size: 768 * 1024,
..Default::default()
},
buffered_body: None,
body_source: GetObjectBodySource::HookMissed,
})
},
ColdFillProducerExecution {
expected: 768 * 1024,
deadline: None,
adapter: Arc::clone(&adapter),
engine_plan: plan.clone(),
},
));
first_poll_ready.await.expect("producer must submit its first read");
let ColdFillRole::Wait(follower) = coordinator.join(key.clone()) else {
panic!("second timeout request must follow");
};
tokio::time::advance(
get_object_disk_read_timeout()
.saturating_mul(2)
.max(configured_get_disk_read_idle_timeout())
+ Duration::from_secs(1),
)
.await;
let (leader_result, follower_result) =
tokio::time::timeout(Duration::from_secs(2), async { tokio::join!(leader.wait(), follower.wait()) })
.await
.expect("typed timeout must wake all waiters");
assert!(matches!(
leader_result,
ColdFillWaitOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout)))
));
assert!(matches!(
follower_result,
ColdFillWaitOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout)))
));
assert_eq!(readers.load(AtomicOrdering::Relaxed), 1);
producer_task.await.expect("producer task must join");
let replacement = tokio::time::timeout(Duration::from_secs(1), disk_permits.clone().acquire_owned()).await;
let released_before_pending_io = matches!(&replacement, Ok(Ok(_)));
drop(replacement);
drop(pending_peer);
let replacement_after_io = tokio::time::timeout(Duration::from_secs(1), disk_permits.clone().acquire_owned())
.await
.expect("control: completing pending I/O releases admission")
.expect("gate open");
drop(replacement_after_io);
assert!(
released_before_pending_io,
"a classified stalled read must return admission before pending I/O completes"
);
assert_eq!(coordinator.global_waiter_count_for_test(), 0);
assert_eq!(coordinator.active_session_count_for_test(), 0);
assert!(matches!(
adapter.lookup_body(&plan).await,
rustfs_object_data_cache::ObjectDataCacheLookup::Miss
));
assert!(adapter.reserve_body(&plan).is_some(), "timeout must release the body reservation");
let ColdFillRole::Produce(successor) = coordinator.join(key) else {
panic!("timeout must release the session for a successor");
};
drop(successor);
}
#[tokio::test]
async fn cold_fill_survives_leader_request_cancellation_without_second_producer() {
let adapter = Arc::new(
@@ -9861,6 +10305,258 @@ mod tests {
assert_eq!(GetObjectGuard::concurrent_count(), initial);
}
struct AdmissionReadProbe {
reads: Arc<AtomicUsize>,
}
impl AsyncRead for AdmissionReadProbe {
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, buffer: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
self.reads.fetch_add(1, AtomicOrdering::SeqCst);
buffer.initialize_unfilled().fill(42);
buffer.advance(buffer.remaining());
Poll::Ready(Ok(()))
}
}
struct PendingAdmissionRead {
finish: tokio::sync::oneshot::Receiver<()>,
started: Arc<AtomicUsize>,
completed: Arc<AtomicUsize>,
submitted: bool,
}
impl AsyncRead for PendingAdmissionRead {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buffer: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
if !self.submitted {
self.submitted = true;
self.started.fetch_add(1, AtomicOrdering::SeqCst);
}
match std::future::Future::poll(Pin::new(&mut self.finish), cx) {
Poll::Ready(_) => {
self.completed.fetch_add(1, AtomicOrdering::SeqCst);
buffer.put_slice(b"x");
Poll::Ready(Ok(()))
}
Poll::Pending => Poll::Pending,
}
}
}
#[tokio::test(start_paused = true)]
async fn disk_read_admission_expires_unpolled_and_cannot_resume_without_permit() {
use tokio::io::AsyncReadExt;
let semaphore = Arc::new(tokio::sync::Semaphore::new(1));
let permit = semaphore.clone().acquire_owned().await.unwrap();
let reads = Arc::new(AtomicUsize::new(0));
let mut reader = DiskReadPermitReader::with_idle_timeout(
AdmissionReadProbe { reads: reads.clone() },
permit.into(),
GET_DISK_READ_IDLE_TIMEOUT,
);
tokio::time::advance(GET_DISK_READ_IDLE_TIMEOUT + Duration::from_secs(1)).await;
let replacement = tokio::time::timeout(Duration::from_secs(1), semaphore.clone().acquire_owned())
.await
.expect("unpolled body must release admission independently")
.unwrap();
let mut buffer = [0_u8; 1];
for _ in 0..2 {
assert_eq!(reader.read(&mut buffer).await.unwrap_err().kind(), std::io::ErrorKind::TimedOut);
}
assert_eq!(reads.load(AtomicOrdering::SeqCst), 0, "expired readers must never touch disk");
assert_eq!(semaphore.available_permits(), 0, "replacement still owns the only slot");
let reopens = Arc::new(AtomicUsize::new(0));
let count = reopens.clone();
let reopen: GetObjectReopen<DiskReadPermitReader<AdmissionReadProbe>> = Box::new(move |_| {
count.fetch_add(1, AtomicOrdering::SeqCst);
Box::pin(async { Err(GetObjectResumeFailure::Fatal) })
});
let resume = GetObjectResumeControl::new(
reopen,
RetryTimer::new(
GET_OBJECT_RESUME_MAX_ATTEMPTS,
Duration::from_millis(1),
Duration::from_millis(2),
rustfs_utils::retry::NO_JITTER,
0,
),
);
let mut streaming = GetObjectStreamingReader::new(
reader,
"bucket",
"object",
"expired-admission",
None,
1024,
Duration::from_secs(1),
GetObjectBodyLifecycle::disabled(),
Some(resume),
);
assert_eq!(streaming.read(&mut buffer).await.unwrap_err().kind(), std::io::ErrorKind::TimedOut);
assert_eq!(
reopens.load(AtomicOrdering::SeqCst),
0,
"expired admission is terminal for mid-stream resume"
);
drop(streaming);
drop(replacement);
assert_eq!(semaphore.available_permits(), 1);
}
#[tokio::test(start_paused = true)]
async fn disk_read_admission_rejects_trickle_but_allows_long_progressing_downloads() {
use tokio::io::AsyncReadExt;
let semaphore = Arc::new(tokio::sync::Semaphore::new(1));
let permit = semaphore.clone().acquire_owned().await.unwrap();
let reads = Arc::new(AtomicUsize::new(0));
let mut reader = DiskReadPermitReader::with_idle_timeout(
AdmissionReadProbe { reads: reads.clone() },
permit.into(),
GET_DISK_READ_IDLE_TIMEOUT,
);
let mut byte = [0_u8; 1];
for _ in 0..6 {
reader.read_exact(&mut byte).await.unwrap();
tokio::time::advance(Duration::from_secs(10)).await;
}
let permit = tokio::time::timeout(Duration::from_secs(1), semaphore.clone().acquire_owned())
.await
.expect("small progress cannot renew admission forever")
.unwrap();
assert_eq!(reader.read(&mut byte).await.unwrap_err().kind(), std::io::ErrorKind::TimedOut);
assert_eq!(reads.load(AtomicOrdering::SeqCst), 6);
drop(reader);
let mut reader = DiskReadPermitReader::with_idle_timeout(
AdmissionReadProbe { reads: reads.clone() },
permit.into(),
GET_DISK_READ_IDLE_TIMEOUT,
);
let mut buffer = [0_u8; 16 * 1024];
// More than 30 minutes is valid: deadlines constrain inactivity and
// rolling progress, not the total size or lifetime of a download.
for _ in 0..128 {
reader.read_exact(&mut buffer).await.unwrap();
tokio::time::advance(Duration::from_secs(15)).await;
}
assert_eq!(semaphore.available_permits(), 0);
drop(reader);
assert_eq!(semaphore.available_permits(), 1);
}
#[tokio::test(start_paused = true)]
async fn disk_read_admission_retains_pending_io_until_completion_after_expiry_or_drop() {
use tokio::io::AsyncReadExt;
for cancel in [false, true] {
let semaphore = Arc::new(tokio::sync::Semaphore::new(1));
let permit = semaphore.clone().acquire_owned().await.unwrap();
let (finish, receiver) = tokio::sync::oneshot::channel();
let started = Arc::new(AtomicUsize::new(0));
let completed = Arc::new(AtomicUsize::new(0));
let mut reader = DiskReadPermitReader::with_idle_timeout(
PendingAdmissionRead {
finish: receiver,
started: started.clone(),
completed: completed.clone(),
submitted: false,
},
permit.into(),
GET_DISK_READ_IDLE_TIMEOUT,
);
let mut buffer = [0_u8; 1];
let mut reading = Box::pin(reader.read(&mut buffer));
assert!(futures::poll!(&mut reading).is_pending());
drop(reading);
assert_eq!(started.load(AtomicOrdering::SeqCst), 1);
if cancel {
drop(reader);
} else {
tokio::time::advance(GET_DISK_READ_IDLE_TIMEOUT + Duration::from_secs(1)).await;
assert_eq!(reader.read(&mut buffer).await.unwrap_err().kind(), std::io::ErrorKind::TimedOut);
drop(reader);
}
tokio::task::yield_now().await;
assert_eq!(semaphore.available_permits(), 0, "pending disk IO still consumes admission");
assert_eq!(completed.load(AtomicOrdering::SeqCst), 0);
finish.send(()).unwrap();
let _replacement = tokio::time::timeout(Duration::from_secs(1), semaphore.clone().acquire_owned())
.await
.expect("the outstanding read finishes without the response being polled")
.unwrap();
assert_eq!(completed.load(AtomicOrdering::SeqCst), 1);
assert_eq!(started.load(AtomicOrdering::SeqCst), 1, "retirement must never submit a second read");
}
}
#[tokio::test(start_paused = true)]
async fn disk_read_admission_preserves_configured_slow_io_window() {
use tokio::io::AsyncReadExt;
let semaphore = Arc::new(tokio::sync::Semaphore::new(1));
let permit = semaphore.clone().acquire_owned().await.unwrap();
let (finish, receiver) = tokio::sync::oneshot::channel();
let completed = Arc::new(AtomicUsize::new(0));
let mut reader = DiskReadPermitReader::with_idle_timeout(
PendingAdmissionRead {
finish: receiver,
started: Arc::default(),
completed: completed.clone(),
submitted: false,
},
permit.into(),
Duration::from_secs(120),
);
let mut buffer = [0_u8; 1];
let mut reading = Box::pin(reader.read(&mut buffer));
assert!(futures::poll!(&mut reading).is_pending());
tokio::time::advance(Duration::from_secs(45)).await;
assert!(
futures::poll!(&mut reading).is_pending(),
"a high-latency read must not inherit the default 30-second cutoff"
);
assert_eq!(semaphore.available_permits(), 0);
finish.send(()).unwrap();
assert_eq!(reading.await.unwrap(), 1);
assert_eq!(completed.load(AtomicOrdering::SeqCst), 1);
drop(reader);
assert_eq!(semaphore.available_permits(), 1);
}
#[tokio::test]
async fn disk_read_admission_unread_file_bodies_cannot_exhaust_both_lanes() {
use tokio::io::AsyncReadExt;
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("download");
let payload = vec![0x5a_u8; 256 * 1024];
tokio::fs::write(&path, &payload).await.unwrap();
let manager = ConcurrencyManager::with_disk_read_caps_for_test(1, 1);
let mut bodies = Vec::new();
for _ in 0..2 {
let file = tokio::fs::File::open(&path).await.unwrap();
let permit = match manager.admit_disk_read(Duration::from_millis(1)).await.unwrap() {
DiskReadAdmission::Primary(permit) | DiskReadAdmission::Degraded(permit) => permit,
other => panic!("expected an admitted read, got {other:?}"),
};
bodies.push(DiskReadPermitReader::with_idle_timeout(file, permit.into(), GET_DISK_READ_IDLE_TIMEOUT));
}
assert!(matches!(
manager.admit_disk_read(Duration::from_millis(1)).await.unwrap(),
DiskReadAdmission::Rejected
));
tokio::time::pause();
tokio::time::advance(GET_DISK_READ_IDLE_TIMEOUT + Duration::from_secs(1)).await;
let permit = match manager.admit_disk_read(Duration::from_secs(1)).await.unwrap() {
DiskReadAdmission::Primary(permit) => permit,
other => panic!("unread bodies must return the primary slot, got {other:?}"),
};
tokio::time::resume();
for body in &mut bodies {
assert_eq!(body.read(&mut [0_u8; 1]).await.unwrap_err().kind(), std::io::ErrorKind::TimedOut);
}
let file = tokio::fs::File::open(&path).await.unwrap();
let mut reader = DiskReadPermitReader::with_idle_timeout(file, permit.into(), GET_DISK_READ_IDLE_TIMEOUT);
let mut actual = Vec::new();
reader.read_to_end(&mut actual).await.unwrap();
assert_eq!(actual, payload, "a fresh reader must read the real file after recovery");
}
#[tokio::test]
async fn disk_read_permit_reader_holds_permit_until_reader_is_dropped() {
let semaphore = Arc::new(tokio::sync::Semaphore::new(1));
+147 -2
View File
@@ -16,10 +16,39 @@
use super::veeam_sosapi;
use super::*;
use crate::app::storage_api::object_usecase::access::replication_proxy_allowed;
use crate::on_demand_migration::{
BucketOdmState, HeadPolicy, OdmLookup, OdmOp, OdmOutcome, OnDemandMigrationSys, SourceClient, SourceError, SourceHead,
};
async fn head_metadata_action_allowed(req: &S3Request<HeadObjectInput>, action: S3Action) -> S3Result<bool> {
let Ok(info) = req_info_ref(req) else {
return Ok(false);
};
// No owner shortcut: an explicit bucket-policy Deny on these metadata
// actions binds the owner too (see `owner_can_bypass_policy_deny`).
let mut info = info.clone();
info.suppress_denial_log = true;
info.suppress_denial_audit = true;
let mut probe = S3Request {
input: (),
method: req.method.clone(),
uri: req.uri.clone(),
headers: req.headers.clone(),
extensions: req.extensions.clone(),
credentials: req.credentials.clone(),
region: req.region.clone(),
service: req.service.clone(),
trailing_headers: req.trailing_headers.clone(),
};
probe.extensions.insert(info);
match authorize_request(&mut probe, Action::S3Action(action)).await {
Ok(()) => Ok(true),
Err(err) if err.code() == &S3ErrorCode::AccessDenied => Ok(false),
Err(err) => Err(err),
}
}
/// Source HEAD seam for the on-demand migration passthrough: production goes
/// through [`SourceClient`], tests script the answers.
pub(super) trait OdmHeadSource {
@@ -413,7 +442,9 @@ impl DefaultObjectUsecase {
// Active-active replication lag window: an object missing
// locally may still be served by proxying the HEAD to a
// replication target (backlog#1675 P1-5).
if let Some(output) = Self::proxy_head_object_to_replication_targets(&req, &bucket, &key, &opts).await {
if replication_proxy_allowed(&req.extensions)
&& let Some(output) = Self::proxy_head_object_to_replication_targets(&req, &bucket, &key, &opts).await
{
let response = wrap_response_with_cors(&bucket, &req.method, &req.headers, output).await;
let result = Ok(response);
let _ = helper
@@ -615,7 +646,12 @@ impl DefaultObjectUsecase {
// Add x-amz-tagging-count header if object has tags
// Per S3 API spec, this header should be present in HEAD object response when tags exist
if tag_count > 0 {
let tagging_action = if req.input.version_id.is_some() {
S3Action::GetObjectVersionTaggingAction
} else {
S3Action::GetObjectTaggingAction
};
if tag_count > 0 && (replication_check || head_metadata_action_allowed(&req, tagging_action).await?) {
let header_name = http::HeaderName::from_static(AMZ_TAG_COUNT);
if let Ok(header_value) = tag_count.to_string().parse::<HeaderValue>() {
response.headers.insert(header_name, header_value);
@@ -623,9 +659,20 @@ impl DefaultObjectUsecase {
warn!("Failed to parse x-amz-tagging-count header; skipping");
}
}
let retention_allowed = replication_check
|| ([
AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER,
AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE,
AMZ_OBJECT_LOCK_MODE_LOWER,
AMZ_OBJECT_LOCK_MODE,
]
.iter()
.any(|key| metadata_map.contains_key(*key))
&& head_metadata_action_allowed(&req, S3Action::GetObjectRetentionAction).await?);
if let Some(retain_date) = metadata_map
.get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER)
.or_else(|| metadata_map.get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE))
&& retention_allowed
&& let Ok(header_name) = http::HeaderName::from_bytes(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.as_bytes())
&& let Ok(header_value) = HeaderValue::from_str(retain_date)
{
@@ -634,6 +681,7 @@ impl DefaultObjectUsecase {
if let Some(mode) = metadata_map
.get(AMZ_OBJECT_LOCK_MODE_LOWER)
.or_else(|| metadata_map.get(AMZ_OBJECT_LOCK_MODE))
&& retention_allowed
&& let Ok(header_name) = http::HeaderName::from_bytes(AMZ_OBJECT_LOCK_MODE_LOWER.as_bytes())
&& let Ok(header_value) = HeaderValue::from_str(mode)
{
@@ -642,6 +690,7 @@ impl DefaultObjectUsecase {
if let Some(legal_hold) = metadata_map
.get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER)
.or_else(|| metadata_map.get(AMZ_OBJECT_LOCK_LEGAL_HOLD))
&& (replication_check || head_metadata_action_allowed(&req, S3Action::GetObjectLegalHoldAction).await?)
&& let Ok(header_name) = http::HeaderName::from_bytes(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER.as_bytes())
&& let Ok(header_value) = HeaderValue::from_str(legal_hold)
{
@@ -1061,6 +1110,102 @@ mod tests {
build_request(input, Method::HEAD)
}
#[test]
#[serial_test::serial]
fn head_response_filters_lock_and_tag_metadata_by_permission() {
crate::app::gating_test_env::run_large_stack_test("head-metadata-permissions", || async {
use crate::app::runtime_sources::{AppContext, ServerContextSlot};
use crate::app::storage_api::test::ReqInfo;
use crate::app::storage_api::test::S3Access as _;
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
use crate::app::storage_api::test::ecfs::FS;
let ambient = crate::app::gating_test_env::shared_gating_ambient().await;
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
let context = Arc::new(AppContext::new(store.clone(), ambient.iam(), ambient.kms()));
let slot = ServerContextSlot::new();
assert!(slot.install(context.clone()));
let fs = FS::with_server_ctx(slot.clone());
let usecase = DefaultObjectUsecase::with_context(Some(context));
let bucket = format!("head-metadata-{}", Uuid::new_v4().simple());
store
.make_bucket(
&bucket,
&MakeBucketOptions {
versioning_enabled: true,
..Default::default()
},
)
.await
.unwrap();
let object = store
.put_object(
&bucket,
"protected",
&mut PutObjReader::from_vec(b"body".to_vec()),
&ObjectOptions {
versioned: true,
user_defined: HashMap::from([
(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_owned(), "2099-01-01T00:00:00Z".to_owned()),
(AMZ_OBJECT_LOCK_MODE_LOWER.to_owned(), "GOVERNANCE".to_owned()),
(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER.to_owned(), "ON".to_owned()),
(AMZ_OBJECT_TAGGING.to_owned(), "classification=secret".to_owned()),
]),
..Default::default()
},
)
.await
.unwrap();
for (grant, explicit_deny, replica) in [
(false, false, false),
(true, true, false),
(true, false, false),
(false, false, true),
] {
let mut statements = vec![serde_json::json!({"Effect":"Allow","Principal":{"AWS":"*"},
"Action":["s3:GetObject","s3:GetObjectVersion","s3:ReplicateObject"],"Resource":format!("arn:aws:s3:::{bucket}/*")})];
for (enabled, effect) in [(grant, "Allow"), (explicit_deny, "Deny")] {
if enabled {
statements.push(serde_json::json!({"Effect":effect,"Principal":{"AWS":"*"},
"Action":["s3:GetObjectRetention","s3:GetObjectLegalHold","s3:GetObjectTagging","s3:GetObjectVersionTagging"],
"Resource":format!("arn:aws:s3:::{bucket}/*")}));
}
}
store
.update_bucket_metadata_config(
&bucket,
crate::app::storage_api::object_usecase::bucket::metadata::BUCKET_POLICY_CONFIG,
serde_json::to_vec(&serde_json::json!({"Version":"2012-10-17","Statement":statements})).unwrap(),
)
.await
.unwrap();
for version in [None, object.version_id.map(|version| version.to_string())] {
let mut req = head_input(&bucket, "protected", version);
req.extensions.insert(ReqInfo::default());
req.extensions.insert(slot.clone());
if replica {
req.headers
.insert("x-rustfs-source-replication-check", HeaderValue::from_static("true"));
}
fs.head_object(&mut req).await.expect("ordinary HEAD read is permitted");
let response = Box::pin(usecase.execute_head_object(req)).await.expect("HEAD succeeds");
assert_eq!(response.output.content_length, Some(4));
for header in [
AMZ_TAG_COUNT,
AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER,
AMZ_OBJECT_LOCK_MODE_LOWER,
AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER,
] {
assert_eq!(
response.headers.contains_key(header),
replica || (grant && !explicit_deny),
"header {header}"
);
}
}
}
});
}
/// Drives `execute_head_object` against a real store with the global
/// runtime configured as `head = local_only`: any HEAD that wrongly
/// enters the runtime shows up as a `filtered` count, so a zero counter
+1 -1
View File
@@ -129,7 +129,7 @@ fn internal_put_content_input(headers: &HeaderMap, tags: Option<String>) -> PutO
}
async fn validate_internal_write_target(key: &str, bucket: &str, headers: &HeaderMap) -> Result<(), ApiError> {
validate_object_key(key, INTERNAL_PUT_METHOD_NAME).map_err(api_error_from_s3)?;
validate_new_object_key(key, INTERNAL_PUT_METHOD_NAME).map_err(api_error_from_s3)?;
validate_table_catalog_object_mutation(bucket, key)
.await
.map_err(api_error_from_s3)?;
+8 -8
View File
@@ -81,7 +81,9 @@ use super::storage_api::object_usecase::error::{
Error as EcstoreError, StorageError, is_err_bucket_not_found, is_err_object_not_found, is_err_version_not_found,
};
use super::storage_api::object_usecase::head_prefix::{head_prefix_not_found_message, probe_prefix_has_children};
use super::storage_api::object_usecase::helper::{OperationHelper, build_event_resp_elements, spawn_background_with_context};
use super::storage_api::object_usecase::helper::{
OperationHelper, build_event_resp_elements, run_owned_mutation, spawn_background_with_context,
};
use super::storage_api::object_usecase::io::{DynReader, HashReader, WritePlan, compression_metadata_value, wrap_reader};
#[cfg(test)]
use super::storage_api::object_usecase::object_cache::GetObjectBodySource;
@@ -112,8 +114,9 @@ use super::storage_api::object_usecase::timeout_wrapper::{GetObjectTimeoutPolicy
use super::storage_api::object_usecase::{ECStore, OldCurrentSize};
use super::storage_api::object_usecase::{
RFC1123, check_preconditions, parse_object_lock_legal_hold, parse_object_lock_retention, parse_part_number_i32_to_usize,
remove_object_lock_metadata_for_copy, strip_managed_encryption_metadata, validate_bucket_exists, validate_object_key,
validate_sse_headers_for_read, validate_sse_headers_for_write, validate_ssec_for_read, wrap_response_with_cors,
remove_object_lock_metadata_for_copy, strip_managed_encryption_metadata, validate_bucket_exists, validate_new_object_key,
validate_object_key, validate_sse_headers_for_read, validate_sse_headers_for_write, validate_ssec_for_read,
wrap_response_with_cors,
};
use crate::app::runtime_sources::{
AppContext, current_app_context, current_notify_interface_for_context, current_object_data_cache_for_context,
@@ -179,7 +182,7 @@ use s3s::dto::{
WebsiteRedirectLocation,
};
use s3s::header::X_AMZ_RESTORE;
use s3s::stream::{ByteStream, DynByteStream, RemainingLength};
use s3s::stream::{ByteStream, RemainingLength};
use s3s::{S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error};
fn object_s3_error(code: S3ErrorCode, message: impl Into<std::borrow::Cow<'static, str>>) -> S3Error {
@@ -232,10 +235,7 @@ pub(crate) use self::on_demand_migration_put::*;
#[cfg(test)]
pub(crate) use self::put::PollCountingBody;
use self::put::*;
pub(crate) use self::put::{
guard_put_object_body_read_timeout, previous_current_size_from_backfill, put_object_body_read_timeout,
reject_oversize_single_upload,
};
pub(crate) use self::put::{previous_current_size_from_backfill, put_object_body_read_timeout, reject_oversize_single_upload};
#[cfg(test)]
pub(crate) use self::restore::RestoreStatusCommitBarrier;
pub(crate) use self::shared::*;
@@ -857,9 +857,18 @@ mod tests {
assert_nothing_left(&store, &bucket, "short.bin").await;
}
#[tokio::test]
#[test]
#[serial_test::serial]
async fn write_back_multipart_path_matches_the_source_digest() {
fn write_back_multipart_path_matches_the_source_digest() {
// The multipart commit path's futures exceed the 4 MiB test-thread stack
// CI configures; production worker threads are sized by the runtime.
crate::app::gating_test_env::run_large_stack_test(
"odm-write-back-multipart-digest",
write_back_multipart_path_matches_the_source_digest_inner,
);
}
async fn write_back_multipart_path_matches_the_source_digest_inner() {
const PART_SIZE: usize = 5 * 1024 * 1024;
let (store, bucket) = write_back_test_bucket("odm-wb-mpu", false).await;
let body: Vec<u8> = (0..PART_SIZE + 4096).map(|i| (i % 253) as u8).collect();
+343 -228
View File
@@ -15,10 +15,11 @@
//! PutObject write path: body admission, eager commit, zero-copy tuning.
use super::*;
use futures::FutureExt;
use crate::app::trailer_adapter::trailer_source;
use crate::auth::{RUSTFS_MAX_CONTENT_LENGTH_QUERY, VerifiedPresignedRequest, parse_presigned_put_max_content_length};
use crate::error::{ClientBodyReadTimeout, UploadLimitExceeded};
use crate::error::UploadLimitExceeded;
static PUT_FAILURE_LOGS: rustfs_utils::LogThrottle = rustfs_utils::LogThrottle::new(5_000);
#[cfg(test)]
@@ -92,11 +93,10 @@ static CACHED_SMALL_EAGER_PUT_MAX_SIZE_BYTES: std::sync::OnceLock<usize> = std::
const EVENT_PUT_OBJECT_STORE_INFLIGHT_SLOW: &str = "put_object_store_inflight_slow";
const EVENT_PUT_OBJECT_STORE_RETURNED: &str = "put_object_store_returned";
const EVENT_PUT_OBJECT_REQUEST_COMPLETED: &str = "put_object_request_completed";
const EVENT_PUT_OBJECT_COMMIT_OWNER_DEADLINE: &str = "put_object_commit_owner_deadline";
const EVENT_PUT_OBJECT_BODY_READ_STALLED: &str = "put_object_body_read_stalled";
const PUT_OBJECT_STORE_WARN_THRESHOLD: Duration = Duration::from_secs(5);
// Eager PUT bodies are fully materialized before the storage owner starts. On
@@ -148,9 +148,8 @@ pub(crate) fn reject_oversize_single_upload(size: i64) -> S3Result<()> {
/// Resolve the S3 request-body inter-chunk read timeout from the environment.
///
/// Returns `Duration::ZERO` when disabled (`RUSTFS_HTTP_REQUEST_BODY_READ_TIMEOUT=0`),
/// in which case [`guard_put_object_body_read_timeout`] passes the body through
/// untouched.
/// Zero disables inactivity detection; the independent minimum-progress
/// policy still bounds uploads that retain scarce resources.
pub(crate) fn put_object_body_read_timeout() -> Duration {
Duration::from_secs(rustfs_utils::get_env_u64(
rustfs_config::ENV_HTTP_REQUEST_BODY_READ_TIMEOUT,
@@ -158,36 +157,6 @@ pub(crate) fn put_object_body_read_timeout() -> Duration {
))
}
/// A [`ByteStream`] decorator that aborts a request body whose peer stops
/// sending bytes without closing the connection.
///
/// A well-behaved short body ends with EOF and is rejected promptly by the
/// eager/streaming readers. The failure this guards against is different: a
/// reverse proxy or CDN forwards a *partial* body and then goes silent while
/// holding the connection open, so the inner stream neither yields more bytes
/// nor reports EOF. Without a bound, RustFS would wait forever for bytes that
/// never arrive and the client eventually sees a hang/abort with no server-side
/// explanation (issue #3076).
///
/// The timer resets on every chunk, so slow-but-progressing uploads are not
/// penalized; it only fires after `timeout` of complete silence. On timeout the
/// stall is logged with the received/expected byte counts and the read fails
/// with an `ErrorKind::TimedOut` error instead of hanging.
///
/// `remaining_length` and `size_hint` are forwarded from the inner stream so
/// wrapping is transparent to length/content handling downstream.
struct RequestBodyReadTimeout {
inner: DynByteStream,
timeout: Duration,
timer: Option<Pin<Box<tokio::time::Sleep>>>,
received: u64,
expected: Option<u64>,
bucket: String,
key: String,
request_id: String,
timed_out: bool,
}
/// Enforces a maximum size on the decoded request entity while preserving the
/// streaming behavior of the underlying S3 body.
struct MaxContentLengthStream {
@@ -240,86 +209,8 @@ impl ByteStream for MaxContentLengthStream {
}
}
impl Stream for RequestBodyReadTimeout {
type Item = Result<Bytes, StdError>;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.get_mut();
// Once we have surfaced a stall error, treat the stream as terminated so
// we never poll the abandoned inner stream again.
if this.timed_out {
return Poll::Ready(None);
}
match Pin::new(&mut this.inner).poll_next(cx) {
Poll::Ready(Some(Ok(chunk))) => {
this.timer = None;
this.received = this.received.saturating_add(chunk.len() as u64);
Poll::Ready(Some(Ok(chunk)))
}
Poll::Ready(other) => {
this.timer = None;
Poll::Ready(other)
}
Poll::Pending => {
if this.timeout.is_zero() {
return Poll::Pending;
}
if this.timer.is_none() {
this.timer = Some(Box::pin(tokio::time::sleep(this.timeout)));
}
if let Some(timer) = this.timer.as_mut()
&& std::future::Future::poll(timer.as_mut(), cx).is_ready()
{
this.timer = None;
this.timed_out = true;
let expected_display = this.expected.map(|v| v.to_string()).unwrap_or_else(|| "unknown".to_string());
warn!(
target: "rustfs::app::object_usecase",
event = EVENT_PUT_OBJECT_BODY_READ_STALLED,
component = LOG_COMPONENT_APP,
subsystem = LOG_SUBSYSTEM_OBJECT,
request_id = %this.request_id,
bucket = %this.bucket,
key = %this.key,
received_bytes = this.received,
expected_bytes = %expected_display,
timeout_secs = this.timeout.as_secs(),
state = "stall_timeout",
"PutObject request body read stalled; aborting. A proxy/CDN likely forwarded a partial body without closing the connection."
);
return Poll::Ready(Some(Err(Box::new(std::io::Error::new(
std::io::ErrorKind::TimedOut,
ClientBodyReadTimeout {
timeout: this.timeout,
raw_bytes_received: this.received,
},
)) as StdError)));
}
Poll::Pending
}
}
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.inner.size_hint()
}
}
impl ByteStream for RequestBodyReadTimeout {
fn remaining_length(&self) -> RemainingLength {
self.inner.remaining_length()
}
}
/// Wrap an incoming request body with [`RequestBodyReadTimeout`] unless the
/// feature is disabled (`timeout == 0`), in which case the body is returned
/// untouched. `remaining_length` is preserved via [`StreamingBlob::new`].
pub(crate) fn guard_put_object_body_read_timeout(
#[cfg(test)]
pub(super) fn guard_put_object_body_read_timeout(
body: StreamingBlob,
bucket: &str,
key: &str,
@@ -327,21 +218,8 @@ pub(crate) fn guard_put_object_body_read_timeout(
expected: Option<i64>,
timeout: Duration,
) -> StreamingBlob {
if timeout.is_zero() {
return body;
}
StreamingBlob::new(RequestBodyReadTimeout {
inner: body.into(),
timeout,
timer: None,
received: 0,
expected: expected.and_then(|v| u64::try_from(v).ok()),
bucket: bucket.to_string(),
key: key.to_string(),
request_id: request_id.to_string(),
timed_out: false,
})
super::request_body::guard_upload_progress(body, timeout, bucket, key, request_id, expected.unwrap_or_default().max(0) as u64)
.0
}
pub(super) struct PooledBufferReader {
@@ -700,7 +578,10 @@ async fn read_small_put_body_exact_pooled<R>(mut body: R, size: usize, pool: &By
where
R: AsyncRead + Unpin,
{
let mut buf = pool.acquire_buffer(size).await;
// A stalled body cannot pin pool capacity (the progress floor expires it),
// so a full pool is a burst, not an attack: hand out an unpooled buffer
// rather than refusing the upload.
let mut buf = pool.try_acquire_buffer(size).unwrap_or_else(|| PooledBuffer::unpooled(size));
read_small_put_body_into(&mut body, &mut *buf, size).await?;
Ok(buf)
}
@@ -1134,57 +1015,53 @@ pub(super) struct PutObjectCommitted {
pub(super) sse_customer_algorithm: Option<SSECustomerAlgorithm>,
pub(super) sse_customer_key_md5: Option<SSECustomerKeyMD5>,
pub(super) put_extra_checksum_headers: Vec<(&'static str, String)>,
completion: PutObjectCompletion,
completion: Option<PutObjectCompletion>,
put_request_guard: PutObjectGuard,
bucket: String,
key: String,
concurrent_put_requests: usize,
buffer_size: usize,
start_time: Instant,
size: i64,
use_zero_copy_eager_put_path: bool,
concurrent_put_requests: usize,
buffer_size: usize,
}
impl PutObjectCommitted {
/// Publish the audit entry / creation event for `result`, record the
/// request-level PutObject metrics and release the request guard.
pub(super) fn finish<T>(self, result: &S3Result<S3Response<T>>) {
let Self {
completion,
mut put_request_guard,
bucket,
key,
start_time,
size,
use_zero_copy_eager_put_path,
concurrent_put_requests,
buffer_size,
..
} = self;
let _ = completion.complete(result);
// Record PutObject metrics via zero-copy-metrics
{
let duration_ms = start_time.elapsed().as_millis() as f64;
rustfs_io_metrics::record_put_object(
duration_ms,
size,
use_zero_copy_eager_put_path, // Track if zero-copy was enabled
);
pub(super) fn finish<T>(mut self, result: &S3Result<S3Response<T>>) {
if let Some(completion) = self.completion.take() {
let _ = completion.complete(result);
}
}
}
impl Drop for PutObjectCommitted {
fn drop(&mut self) {
// This value exists only after the owned storage operation succeeds.
// A lost HTTP receiver must not discard the committed write's event.
if let Some(completion) = self.completion.take() {
let result = Ok(S3Response::new(()));
let _ = completion.complete(&result);
}
rustfs_io_metrics::record_put_object(
self.start_time.elapsed().as_millis() as f64,
self.size,
self.use_zero_copy_eager_put_path,
);
debug!(
target: "rustfs::app::object_usecase",
component = "app",
subsystem = "object",
bucket = %bucket,
key = %key,
concurrent_put_requests,
buffer_size,
event = EVENT_PUT_OBJECT_REQUEST_COMPLETED,
component = LOG_COMPONENT_APP,
subsystem = LOG_SUBSYSTEM_OBJECT,
result = "success",
bucket = %self.bucket,
key = %self.key,
concurrent_put_requests = self.concurrent_put_requests,
buffer_size = self.buffer_size,
"PutObject request completed"
);
put_request_guard.finish_ok();
self.put_request_guard.finish_ok();
}
}
@@ -1315,7 +1192,7 @@ impl DefaultObjectUsecase {
let server_side_encryption = server_side_encryption.or(extract_server_side_encryption_from_headers(&req.headers)?);
// Validate object key
validate_object_key(&key, request_method_name)?;
validate_new_object_key(&key, request_method_name)?;
validate_table_catalog_object_mutation(&bucket, &key).await?;
// Validate archive content encoding (reject when strict mode is enabled)
@@ -1328,18 +1205,6 @@ impl DefaultObjectUsecase {
let Some(body) = body else { return Err(s3_error!(IncompleteBody)) };
// Guard against a proxy/CDN that forwards a partial body then goes silent
// without closing the connection: bound the inter-chunk wait so the read
// fails (with a diagnostic log) instead of hanging forever (issue #3076).
let body = {
let request_id = req
.extensions
.get::<request_context::RequestContext>()
.map(|ctx| ctx.request_id.clone())
.unwrap_or_default();
guard_put_object_body_read_timeout(body, &bucket, &key, &request_id, content_length, put_object_body_read_timeout())
};
let body = match max_content_length {
Some(limit) => StreamingBlob::new(MaxContentLengthStream {
inner: body,
@@ -1502,6 +1367,33 @@ impl DefaultObjectUsecase {
sse_customer_key_md5,
} = sse;
// The same network budget covers eager and streaming reads. Internal
// server-generated bodies do not represent a client upload.
let (body, progress_control) = match &origin {
PutObjectOrigin::S3 { req, .. } => {
let request_id = req
.extensions
.get::<request_context::RequestContext>()
.map(|context| context.request_id.as_str())
.unwrap_or_default();
let (body, control) = if let Some(control) = req.extensions.get::<super::request_body::BodyReadControl>() {
control.activate_upload(put_object_body_read_timeout(), &bucket, &key, request_id, size.max(0) as u64);
(body, control.clone())
} else {
super::request_body::guard_upload_progress(
body,
put_object_body_read_timeout(),
&bucket,
&key,
request_id,
size.max(0) as u64,
)
};
(super::request_body::demand_upload_body(body, control.clone()), Some(control))
}
PutObjectOrigin::Internal { .. } => (body, None),
};
reject_veeam_sosapi_mutation(|| self.object_store(), bucket.as_str(), key.as_str()).await?;
// The app check preserves the existing S3 error contract; the storage
@@ -1999,6 +1891,11 @@ impl DefaultObjectUsecase {
}
reader = write_plan.apply(reader, actual_size).map_err(ApiError::from)?;
if let Some(control) = progress_control {
use rustfs_rio::HashReaderMut;
let inner = reader.take_inner();
reader.inner = rustfs_rio::boxed_reader(super::request_body::DemandReader::new(inner, control));
}
rustfs_io_metrics::record_put_object_stage_duration_from("app_encryption_prepare", encryption_stage_start);
let reader = PutObjReader::new(reader);
@@ -2262,54 +2159,55 @@ impl DefaultObjectUsecase {
rustfs_io_metrics::s3_http_metrics::current_s3_http_operation_context(),
put_commit_future,
);
let put_commit = spawn_traced_join(put_commit_future);
let commit_stage_start = put_stage_metrics_enabled.then(Instant::now);
let put_commit_result = if let Some(cancellation) = eager_put_commit_cancellation {
let put_commit = spawn_traced_join(async move {
let commit_stage_start = put_stage_metrics_enabled.then(Instant::now);
let result = std::panic::AssertUnwindSafe(put_commit_future).catch_unwind().await;
rustfs_io_metrics::record_put_object_stage_duration_from("store_commit", commit_stage_start);
let PutObjectCommitResult { obj_info, put_versioned } = match result {
Ok(Ok(result)) => result,
Ok(Err(err)) => {
put_request_guard.finish_err();
return Err(fail_put_object(completion, err));
}
Err(_) => {
put_request_guard.finish_err();
return Err(fail_put_object(
completion,
s3_error!(InternalError, "put object commit owner task panicked"),
));
}
};
completion = completion.object(obj_info.clone());
if let Some(version_id) = obj_info.version_id {
completion = completion.version_id(version_id.to_string());
}
Ok(PutObjectCommitted {
obj_info,
put_versioned,
effective_sse,
effective_kms_key_id,
sse_customer_algorithm,
sse_customer_key_md5,
put_extra_checksum_headers,
completion: Some(completion),
put_request_guard,
bucket,
key,
concurrent_put_requests,
buffer_size,
start_time,
size,
use_zero_copy_eager_put_path,
})
});
let result = if let Some(cancellation) = eager_put_commit_cancellation {
EagerPutCommitOwner::new(put_commit, cancellation, EAGER_PUT_COMMIT_CANCELLATION_GRACE)
.join()
.await
} else {
put_commit.await
};
rustfs_io_metrics::record_put_object_stage_duration_from("store_commit", commit_stage_start);
let PutObjectCommitResult { obj_info, put_versioned } = match put_commit_result {
Ok(Ok(result)) => result,
Ok(Err(err)) => {
put_request_guard.finish_err();
return Err(fail_put_object(completion, err));
}
Err(err) => {
put_request_guard.finish_err();
return Err(fail_put_object(
completion,
S3Error::with_message(S3ErrorCode::InternalError, format!("put object commit owner task failed: {err}")),
));
}
};
completion = completion.object(obj_info.clone());
if let Some(version_id) = obj_info.version_id {
completion = completion.version_id(version_id.to_string());
}
Ok(PutObjectCommitted {
obj_info,
put_versioned,
effective_sse,
effective_kms_key_id,
sse_customer_algorithm,
sse_customer_key_md5,
put_extra_checksum_headers,
completion,
put_request_guard,
bucket,
key,
start_time,
size,
use_zero_copy_eager_put_path,
concurrent_put_requests,
buffer_size,
})
result.map_err(|_| s3_error!(InternalError, "put object commit owner task failed"))?
}
}
@@ -2434,7 +2332,9 @@ mod tests {
.content_length(Some(12))
.build()
.expect("quorum probe input must build");
let probe = crate::app::storage_api::object_usecase::helper::MutationCompletionProbe::install(&bucket);
let mut req = build_request(input, method.clone());
req.uri = format!("/{bucket}/{key}").parse().expect("PUT URI");
if method == Method::POST {
req.extensions.insert(PostObjectRequestMarker);
}
@@ -2445,6 +2345,14 @@ mod tests {
)
.await
.expect_err("read quorum failure must refuse the upload");
if stage == PutStorageTestStage::Commit {
probe.wait_for(1, 0).await;
let audits = probe.audits.lock().expect("audits");
assert_eq!(audits.len(), 1);
assert_eq!(audits[0].api.status.as_deref(), Some("failure"));
assert_eq!(audits[0].api.status_code, Some(503));
assert!(probe.events.lock().expect("events").is_empty());
}
assert_eq!(err.code(), &S3ErrorCode::ServiceUnavailable, "stage {index}, method {method}");
assert_eq!(err.status_code(), Some(http::StatusCode::SERVICE_UNAVAILABLE));
assert_eq!(err.message(), Some("The service is unavailable. Please retry."));
@@ -2836,6 +2744,7 @@ mod tests {
let (store, context) = real_cold_fill_test_context().await;
let bucket = format!("put-owner-tail-{}", Uuid::new_v4());
let object = "object.bin";
let probe = crate::app::storage_api::object_usecase::helper::MutationCompletionProbe::install(&bucket);
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
@@ -2859,11 +2768,9 @@ mod tests {
.build()
.expect("PUT input must build");
let put_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context)));
let put = tokio::spawn(async move {
put_usecase
.execute_put_object(&FS::new(), build_request(put_input, Method::PUT))
.await
});
let mut request = build_request(put_input, Method::PUT);
request.uri = format!("/{bucket}/{object}").parse().expect("PUT URI");
let put = tokio::spawn(async move { put_usecase.execute_put_object(&FS::new(), request).await });
tokio::time::timeout(Duration::from_secs(10), post_store_entered.wait())
.await
@@ -2896,6 +2803,27 @@ mod tests {
.await
.expect("cancelled request's owned commit must still publish the object");
assert_eq!(recovered.size, i64::try_from(payload.len()).expect("test payload length must fit i64"));
probe.wait_for(1, 1).await;
let normal_input = PutObjectInput::builder()
.bucket(bucket.clone())
.key("normal.bin".to_owned())
.body(Some(StreamingBlob::from(s3s::Body::from(Bytes::from_static(b"normal")))))
.content_length(Some(6))
.build()
.expect("normal PUT");
let mut normal_request = build_request(normal_input, Method::PUT);
normal_request.uri = format!("/{bucket}/normal.bin").parse().expect("URI");
DefaultObjectUsecase::with_context(Some(context))
.execute_put_object(&FS::new(), normal_request)
.await
.expect("normal response completion");
let audits = probe.audits.lock().expect("audits");
assert_eq!(audits.len(), 2, "both cancelled and returned commits audit exactly once");
assert_eq!(audits[0].api.status.as_deref(), Some("success"));
let events = probe.events.lock().expect("events");
assert_eq!(events.len(), 2, "explicit completion and Drop must not double publish");
assert_eq!(events[0].event_name, EventName::ObjectCreatedPut);
assert_eq!(events[0].object.name, object);
}
#[tokio::test]
@@ -3393,6 +3321,164 @@ mod tests {
}
}
#[tokio::test]
#[serial_test::serial]
async fn execute_put_object_expires_stalled_body_without_creating_object() {
use super::super::request_body::{BodyReadControl, ObservedBody};
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
use http_body::Frame;
use http_body_util::StreamBody;
use tokio_stream::wrappers::UnboundedReceiverStream;
let (store, context) = real_store_test_context().await;
let bucket = format!("put-progress-{}", Uuid::new_v4().simple());
store.make_bucket(&bucket, &MakeBucketOptions::default()).await.unwrap();
for size in [8192, 2 * 1024 * 1024, 32 * 1024 * 1024] {
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<Result<Frame<Bytes>, std::io::Error>>();
let control = BodyReadControl::default();
let body = StreamingBlob::from(s3s::Body::http_body_unsync(ObservedBody::new(
StreamBody::new(UnboundedReceiverStream::new(receiver)),
control.clone(),
)));
let key = format!("object-{size}.bin");
let input = PutObjectInput::builder()
.bucket(bucket.clone())
.key(key.clone())
.body(Some(body))
.content_length(Some(size))
.build()
.unwrap();
let mut request = build_request(input, Method::PUT);
request.extensions.insert(control.clone());
let usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context)));
let owner = tokio::spawn(async move { usecase.execute_put_object(&FS::new(), request).await });
control.wait_until_pending_for_test().await;
control.advance_wait_for_test(Duration::from_secs(60));
sender.send(Ok(Frame::data(Bytes::new()))).unwrap();
let error = owner.await.unwrap().expect_err("stalled eager/zero-copy/streaming body");
assert_eq!(error.code(), &S3ErrorCode::RequestTimeout);
assert!(is_err_object_not_found(
&store
.get_object_info(&bucket, &key, &ObjectOptions::default())
.await
.unwrap_err()
));
}
}
#[tokio::test]
#[serial_test::serial]
async fn pooled_upload_keeps_admission_and_buffer_during_real_commit_after_caller_cancel() {
use crate::app::storage_api::test::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause};
use rustfs_io_core::BytesPoolConfig;
let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("upload-commit-owner", 16384).await;
let manager = ConcurrencyManager::with_put_admission_for_test(true, 1, Duration::ZERO);
let admission = manager.admit_put_object(8192).await.unwrap();
let permit = match admission {
ForegroundWriteAdmission::Admitted(permit) => permit,
_ => panic!("initial permit"),
};
let pool = Arc::new(BytesPool::with_config(BytesPoolConfig {
small_max: 1,
..Default::default()
}));
let (body, control) = super::super::request_body::guard_upload_progress(
StreamingBlob::from(s3s::Body::from(Bytes::from(vec![7; 8192]))),
Duration::ZERO,
&bucket,
"object",
"request",
8192,
);
let buffer =
read_small_put_body_exact_pooled(StreamReader::new(body.map(|item| item.map_err(s3s_body_error_to_io))), 8192, &pool)
.await
.unwrap();
let reader = super::super::request_body::DemandReader::new(PooledBufferReader::new(buffer, 8192), control);
let mut reader = PutObjReader::new(HashReader::from_stream(reader, 8192, 8192, None, None, false).unwrap());
let barrier = PutObjectCommitBarrier::install(&bucket, "object", PutObjectCommitPause::BeforeQuotaRename);
let write_store = Arc::clone(&store);
let write_bucket = bucket.clone();
let (finished, completion) = tokio::sync::oneshot::channel();
let owner = tokio::spawn(async move {
let permit_guard = permit;
let result = write_store
.put_object(&write_bucket, "object", &mut reader, &ObjectOptions::default())
.await;
drop(reader);
drop(permit_guard);
finished.send(result).unwrap();
});
barrier.wait_until_paused().await;
drop(owner);
assert!(pool.try_acquire_buffer(8192).is_none());
assert!(matches!(
manager.admit_put_object(8192).await.unwrap(),
ForegroundWriteAdmission::Rejected
));
barrier.release();
completion.await.unwrap().unwrap();
assert!(pool.try_acquire_buffer(8192).is_some());
assert!(matches!(
manager.admit_put_object(8192).await.unwrap(),
ForegroundWriteAdmission::Admitted(_)
));
assert_eq!(
store
.get_object_info(&bucket, "object", &ObjectOptions::default())
.await
.unwrap()
.size,
8192
);
}
#[tokio::test(start_paused = true)]
async fn pooled_upload_exhaustion_falls_back_to_unpooled_and_recovers_after_stall() {
use futures::poll;
use rustfs_io_core::BytesPoolConfig;
use tokio_stream::wrappers::UnboundedReceiverStream;
let pool = BytesPool::with_config(BytesPoolConfig {
small_max: 1,
..Default::default()
});
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<Result<Bytes, std::io::Error>>();
let (body, _control) = super::super::request_body::guard_upload_progress(
StreamingBlob::wrap(UnboundedReceiverStream::new(receiver)),
Duration::ZERO,
"bucket",
"object",
"request",
8192,
);
let mut owner = Box::pin(read_small_put_body_exact_pooled(
StreamReader::new(body.map(|item| item.map_err(s3s_body_error_to_io))),
8192,
&pool,
));
assert!(poll!(owner.as_mut()).is_pending());
let fallback = read_small_put_body_exact_pooled(std::io::Cursor::new(vec![0; 8192]), 8192, &pool)
.await
.expect("a full pool hands out an unpooled buffer instead of refusing");
assert_eq!(&fallback[..], &[0; 8192]);
drop(fallback);
assert!(pool.try_acquire_buffer(8192).is_none(), "the unpooled buffer never touched pool capacity");
for _ in 0..5 {
tokio::time::advance(Duration::from_secs(10)).await;
sender.send(Ok(Bytes::from_static(b"x"))).unwrap();
assert!(poll!(owner.as_mut()).is_pending());
assert!(pool.try_acquire_buffer(8192).is_none());
}
tokio::time::advance(Duration::from_secs(10)).await;
assert_eq!(owner.await.err().expect("trickle must expire").code(), &S3ErrorCode::RequestTimeout);
let healthy = read_small_put_body_exact_pooled(std::io::Cursor::new(vec![1; 8192]), 8192, &pool)
.await
.expect("timed-out buffer must be reusable");
assert_eq!(&healthy[..], &[1; 8192]);
assert!(pool.try_acquire_buffer(8192).is_none(), "commit reader still owns capacity");
drop(healthy);
assert!(pool.try_acquire_buffer(8192).is_some());
}
#[tokio::test]
async fn read_small_put_body_exact_pooled_reads_exact_bytes_without_prefill() {
let pool = get_concurrency_manager().bytes_pool();
@@ -4273,6 +4359,9 @@ mod tests {
.set_quota_config(&bucket, BucketQuota::new(Some(LIMIT)))
.await
.expect("configure durable quota");
crate::app::storage_api::test::set_disk::initialize_quota_ledger_for_test(Arc::clone(&store), &bucket)
.await
.expect("bootstrap authoritative quota usage");
let payload = Bytes::from(vec![0x42; 1024]);
let input = PutObjectInput::builder()
@@ -4456,7 +4545,7 @@ mod tests {
#[tokio::test]
#[serial_test::serial]
async fn cancelled_put_after_commit_marker_is_reconciled() {
async fn cancelled_put_before_rename_releases_marked_quota_reservation() {
use crate::app::storage_api::test::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause};
let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("cancelled-spawned-put-quota", 4096).await;
@@ -4470,6 +4559,12 @@ mod tests {
.await
});
commit_barrier.wait_until_paused().await;
let mut denied = PutObjReader::from_vec(vec![0x54; 4096]);
let error = store
.put_object(&bucket, "replacement", &mut denied, &ObjectOptions::default())
.await
.expect_err("the live commit marker must still reserve the entire quota");
assert!(matches!(error, StorageError::QuotaExceeded { .. }));
first.abort();
assert!(first.await.is_err(), "the outer request task must be cancelled");
drop(commit_barrier);
@@ -4478,11 +4573,25 @@ mod tests {
.get_object_info(&bucket, "object", &ObjectOptions::default())
.await
.expect_err("cancelling before rename must not commit the object");
// The owned pre-rename cancellation path proves no publication occurred,
// releases the mutation fences, and aborts the reservation before unlock.
let mut replacement = PutObjReader::from_vec(vec![0x54; 4096]);
store
.put_object(&bucket, "replacement", &mut replacement, &ObjectOptions::default())
.await
.expect("the next admission must reap the abandoned commit marker");
.expect("safe pre-rename rollback must release the marked reservation without a scan");
let mut excess = PutObjReader::from_vec(vec![0x55]);
let error = store
.put_object(&bucket, "excess", &mut excess, &ObjectOptions::default())
.await
.expect_err("the replacement must consume the reclaimed quota exactly once");
assert!(matches!(
error,
StorageError::QuotaExceeded {
current: 4096,
limit: 4096
}
));
}
#[tokio::test]
@@ -4580,6 +4689,9 @@ mod tests {
.set_quota_config(&bucket, BucketQuota::new(Some(4096)))
.await
.expect("lower bucket quota");
crate::app::storage_api::test::set_disk::initialize_quota_ledger_for_test(Arc::clone(&store), &bucket)
.await
.expect("bootstrap authoritative quota usage");
let mut excess = PutObjReader::from_vec(vec![0x72]);
let err = store
.put_object(&bucket, "excess", &mut excess, &ObjectOptions::default())
@@ -4640,6 +4752,9 @@ mod tests {
.await
.expect("quota update task should not panic")
.expect("quota update should proceed after commit");
crate::app::storage_api::test::set_disk::initialize_quota_ledger_for_test(Arc::clone(&store), &bucket)
.await
.expect("bootstrap authoritative quota usage");
let mut excess = PutObjReader::from_vec(vec![0x74]);
let err = store
+211 -19
View File
@@ -17,8 +17,10 @@
//! after its input returned Pending, so the transport cannot infer read demand.
use super::{LOG_COMPONENT_APP, LOG_SUBSYSTEM_OBJECT};
use crate::app::storage_api::request_body::{ByteStream, DynByteStream, RemainingLength, StdError, StreamingBlob};
use crate::error::ClientBodyReadTimeout;
use bytes::Bytes;
use futures::{Stream, StreamExt};
use http_body::{Body, Frame, SizeHint};
use parking_lot::Mutex;
use rustfs_rio::{DynReader, EtagResolvable, HashReaderDetector, HashReaderMut, Index, TryGetIndex};
@@ -33,8 +35,12 @@ use std::time::Duration;
use tokio::io::{AsyncRead, ReadBuf};
use tokio::time::{Instant, Sleep};
const EVENT_UPLOAD_BODY_PROGRESS_STALLED: &str = "upload_body_progress_stalled";
const EVENT_UPLOAD_PART_BODY_READ_STALLED: &str = "upload_part_body_read_stalled";
pub(crate) const UPLOAD_PROGRESS_WINDOW: Duration = Duration::from_secs(60);
pub(crate) const UPLOAD_PROGRESS_BYTES: u64 = 64 * 1024;
struct ReadPolicy {
timeout: Duration,
bucket: String,
@@ -50,13 +56,18 @@ struct ReadBudget {
waiting_since: Option<Instant>,
waited: Duration,
raw_bytes_received: u64,
progress_floor: bool,
progress_bytes: u64,
progress_waited: Duration,
finished: bool,
}
impl ReadBudget {
fn pause(&mut self) {
if let Some(start) = self.waiting_since.take() {
self.waited = self.waited.saturating_add(start.elapsed());
let elapsed = start.elapsed();
self.waited = self.waited.saturating_add(elapsed);
self.progress_waited = self.progress_waited.saturating_add(elapsed);
}
self.demand = false;
}
@@ -73,6 +84,7 @@ struct SharedBudget {
}
impl BodyReadControl {
#[cfg(test)]
pub(crate) fn activate(
&self,
timeout: Duration,
@@ -99,6 +111,22 @@ impl BodyReadControl {
true
}
pub(crate) fn activate_upload(&self, timeout: Duration, bucket: &str, key: &str, request_id: &str, expected: u64) {
let mut state = self.0.budget.lock();
if state.finished {
return;
}
state.policy = Some(ReadPolicy {
timeout,
bucket: bucket.to_owned(),
key: key.to_owned(),
request_id: request_id.to_owned(),
expected_decoded_bytes: expected,
});
state.progress_floor = true;
self.0.active.store(true, Ordering::Release);
}
fn begin_read(&self) {
let mut state = self.0.budget.lock();
if !state.finished {
@@ -111,7 +139,7 @@ impl BodyReadControl {
}
fn progress(&self, bytes: usize) {
// Other HTTP operations never activate this UploadPart policy.
// Other HTTP operations never activate this upload policy.
if !self.0.active.load(Ordering::Acquire) {
return;
}
@@ -119,8 +147,17 @@ impl BodyReadControl {
state.raw_bytes_received = state
.raw_bytes_received
.saturating_add(u64::try_from(bytes).unwrap_or(u64::MAX));
// Charge the pending interval before resetting inactivity. Small
// frames do not reset the minimum-progress window or bank credit.
if let Some(start) = state.waiting_since.take() {
state.progress_waited = state.progress_waited.saturating_add(start.elapsed());
}
state.progress_bytes = state.progress_bytes.saturating_add(bytes as u64);
if state.progress_bytes >= UPLOAD_PROGRESS_BYTES {
state.progress_bytes = 0;
state.progress_waited = Duration::ZERO;
}
state.waited = Duration::ZERO;
state.waiting_since = None;
}
fn finish(&self) {
@@ -141,38 +178,54 @@ impl BodyReadControl {
if !state.demand || state.finished {
return None;
}
let remaining = timeout.saturating_sub(state.waited);
let inactivity_remaining = if timeout.is_zero() {
Duration::MAX
} else {
timeout.saturating_sub(state.waited)
};
let remaining = if state.progress_floor {
inactivity_remaining.min(UPLOAD_PROGRESS_WINDOW.saturating_sub(state.progress_waited))
} else {
inactivity_remaining
};
let start = *state.waiting_since.get_or_insert_with(Instant::now);
// A timeout beyond the clock's representable range cannot elapse.
start.checked_add(remaining)
}
fn expire(&self) -> Option<ClientBodyReadTimeout> {
let (policy, raw_bytes_received) = {
let (policy, raw_bytes_received, progress_floor) = {
let mut state = self.0.budget.lock();
let timeout = state.policy.as_ref()?.timeout;
if !state.demand || state.waited.saturating_add(state.waiting_since?.elapsed()) < timeout {
let elapsed = state.waiting_since?.elapsed();
let inactivity_expired = !timeout.is_zero() && state.waited.saturating_add(elapsed) >= timeout;
let progress_expired =
state.progress_floor && state.progress_waited.saturating_add(elapsed) >= UPLOAD_PROGRESS_WINDOW;
if !state.demand || (!inactivity_expired && !progress_expired) {
return None;
}
if progress_expired {
state.policy.as_mut()?.timeout = UPLOAD_PROGRESS_WINDOW;
}
state.finished = true;
self.0.active.store(false, Ordering::Release);
state.demand = false;
state.waiting_since = None;
(state.policy.take()?, state.raw_bytes_received)
(state.policy.take()?, state.raw_bytes_received, state.progress_floor)
};
tracing::error!(
event = EVENT_UPLOAD_PART_BODY_READ_STALLED,
event = if progress_floor { EVENT_UPLOAD_BODY_PROGRESS_STALLED } else { EVENT_UPLOAD_PART_BODY_READ_STALLED },
component = LOG_COMPONENT_APP,
subsystem = LOG_SUBSYSTEM_OBJECT,
state = "stall_timeout",
operation = "UploadPart",
operation = if progress_floor { "Upload" } else { "UploadPart" },
request_id = %policy.request_id,
bucket = %policy.bucket,
key = %policy.key,
raw_bytes_received,
expected_decoded_bytes = policy.expected_decoded_bytes,
timeout_secs = policy.timeout.as_secs(),
"UploadPart request body read stalled"
"Upload request body read budget exhausted"
);
Some(ClientBodyReadTimeout {
timeout: policy.timeout,
@@ -245,6 +298,11 @@ impl<B: Body<Data = Bytes> + Unpin> Body for ObservedBody<B> {
if self.ended {
return Poll::Ready(None);
}
let already_waiting = self.control.0.budget.lock().waiting_since.is_some();
if already_waiting && let Some(error) = self.poll_timeout(cx) {
self.ended = true;
return Poll::Ready(Some(Err(ObservedBodyError::Inactivity(error))));
}
// Ignore empty data without resetting the budget. Bound work per poll
// even if a body repeatedly returns immediately-ready empty frames.
for _ in 0..32 {
@@ -308,18 +366,18 @@ impl<B> Drop for ObservedBody<B> {
/// Current erasure readers are owned by their read future/producer: canceling
/// that owner drops this reader. A future retained-reader cancellation path
/// must explicitly pause its read demand before retaining the reader.
pub(crate) struct DemandReader {
inner: DynReader,
pub(crate) struct DemandReader<R = DynReader> {
inner: R,
control: BodyReadControl,
}
impl DemandReader {
pub(crate) fn new(inner: DynReader, control: BodyReadControl) -> Self {
impl<R> DemandReader<R> {
pub(crate) fn new(inner: R, control: BodyReadControl) -> Self {
Self { inner, control }
}
}
impl AsyncRead for DemandReader {
impl<R: AsyncRead + Unpin> AsyncRead for DemandReader<R> {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
self.control.begin_read();
let result = Pin::new(&mut self.inner).poll_read(cx, buf);
@@ -330,13 +388,13 @@ impl AsyncRead for DemandReader {
}
}
impl Drop for DemandReader {
impl<R> Drop for DemandReader<R> {
fn drop(&mut self) {
self.control.finish();
}
}
impl EtagResolvable for DemandReader {
impl<R: EtagResolvable> EtagResolvable for DemandReader<R> {
fn is_etag_reader(&self) -> bool {
self.inner.is_etag_reader()
}
@@ -345,7 +403,7 @@ impl EtagResolvable for DemandReader {
}
}
impl HashReaderDetector for DemandReader {
impl<R: HashReaderDetector> HashReaderDetector for DemandReader<R> {
fn is_hash_reader(&self) -> bool {
self.inner.is_hash_reader()
}
@@ -354,11 +412,145 @@ impl HashReaderDetector for DemandReader {
}
}
impl TryGetIndex for DemandReader {
impl<R: TryGetIndex> TryGetIndex for DemandReader<R> {
fn try_get_index(&self) -> Option<&Index> {
self.inner.try_get_index()
}
}
/// Establish demand for eager reads and archive sniffing before the final
/// transformed reader exists. Pending demand is subsequently paused by the
/// outer DemandReader when a transform returns buffered output.
pub(crate) fn demand_upload_body(body: StreamingBlob, control: BodyReadControl) -> StreamingBlob {
StreamingBlob::new(DemandStream {
inner: body.into(),
control,
})
}
struct DemandStream {
inner: DynByteStream,
control: BodyReadControl,
}
impl Stream for DemandStream {
type Item = Result<Bytes, StdError>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
self.control.begin_read();
let result = self.inner.poll_next_unpin(cx);
if result.is_ready() {
self.control.pause_read();
}
result
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.inner.size_hint()
}
}
impl ByteStream for DemandStream {
fn remaining_length(&self) -> RemainingLength {
self.inner.remaining_length()
}
}
/// Fallback for direct protocol callers without the raw HTTP observation.
/// HTTP uses ObservedBody because a signed chunk may not yield decoded output
/// until its full payload has arrived and its signature has been verified.
/// The final DemandReader pauses pending reads when transforms return output.
pub(crate) fn guard_upload_progress(
body: StreamingBlob,
timeout: Duration,
bucket: &str,
key: &str,
request_id: &str,
expected: u64,
) -> (StreamingBlob, BodyReadControl) {
let control = BodyReadControl::default();
control.activate_upload(timeout, bucket, key, request_id, expected);
let stream = UploadProgressStream {
inner: body.into(),
control: control.clone(),
timer: None,
ended: false,
};
(StreamingBlob::new(stream), control)
}
struct UploadProgressStream {
inner: DynByteStream,
control: BodyReadControl,
timer: Option<Pin<Box<Sleep>>>,
ended: bool,
}
impl UploadProgressStream {
fn poll_timeout(&mut self, cx: &mut Context<'_>) -> Option<ClientBodyReadTimeout> {
let deadline = self.control.waiting_deadline()?;
let timer = self.timer.get_or_insert_with(|| Box::pin(tokio::time::sleep_until(deadline)));
if timer.deadline() != deadline {
timer.as_mut().reset(deadline);
}
timer.as_mut().poll(cx).is_ready().then(|| self.control.expire()).flatten()
}
}
impl Stream for UploadProgressStream {
type Item = Result<Bytes, StdError>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
if self.ended {
return Poll::Ready(None);
}
self.control.begin_read();
// An overdue read cannot be revived by a late frame (including EOF).
// Do not start a wait until the source actually stalls.
let already_waiting = self.control.0.budget.lock().waiting_since.is_some();
if already_waiting && let Some(error) = self.poll_timeout(cx) {
self.ended = true;
return Poll::Ready(Some(Err(Box::new(std::io::Error::new(std::io::ErrorKind::TimedOut, error)))));
}
let mut empty_frames_only = true;
for _ in 0..32 {
match self.inner.poll_next_unpin(cx) {
Poll::Ready(Some(Ok(bytes))) if !bytes.is_empty() => {
self.control.progress(bytes.len());
self.control.pause_read();
return Poll::Ready(Some(Ok(bytes)));
}
Poll::Ready(Some(Ok(_))) => {}
Poll::Ready(other) => {
self.ended = true;
self.control.finish();
return Poll::Ready(other);
}
Poll::Pending => {
empty_frames_only = false;
break;
}
}
}
if let Some(error) = self.poll_timeout(cx) {
self.ended = true;
return Poll::Ready(Some(Err(Box::new(std::io::Error::new(std::io::ErrorKind::TimedOut, error)))));
}
// Also bound an always-ready source of empty frames.
if empty_frames_only {
cx.waker().wake_by_ref();
}
Poll::Pending
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.inner.size_hint()
}
}
impl ByteStream for UploadProgressStream {
fn remaining_length(&self) -> RemainingLength {
self.inner.remaining_length()
}
}
impl Drop for UploadProgressStream {
fn drop(&mut self) {
self.control.finish();
}
}
#[cfg(test)]
mod tests;
+195
View File
@@ -28,6 +28,19 @@ mod protocol;
type FrameSender = mpsc::UnboundedSender<Result<Frame<Bytes>, io::Error>>;
impl BodyReadControl {
pub(crate) async fn wait_until_pending_for_test(&self) {
tokio::time::timeout(Duration::from_secs(30), async {
loop {
if self.0.budget.lock().waiting_since.is_some() {
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("body must reach a pending network read");
}
/// Elapse this body's active wait without advancing storage timers.
/// The fixture must wake the raw body afterward so it polls the deadline.
pub(crate) fn advance_wait_for_test(&self, elapsed: Duration) {
@@ -37,6 +50,7 @@ impl BodyReadControl {
"body must already be waiting for raw input"
);
state.waited = state.waited.saturating_add(elapsed);
state.progress_waited = state.progress_waited.saturating_add(elapsed);
}
}
@@ -277,3 +291,184 @@ async fn native_body_errors_preserve_their_original_source() {
let error = reader.read_to_end(&mut Vec::new()).await.expect_err("native error");
assert_eq!(ApiError::from(error).code, S3ErrorCode::InternalError);
}
fn progress_reader() -> (mpsc::UnboundedSender<Result<Bytes, io::Error>>, impl AsyncRead + Unpin) {
let (sender, receiver) = mpsc::unbounded_channel();
let (stream, control) = guard_upload_progress(
StreamingBlob::wrap(UnboundedReceiverStream::new(receiver)),
Duration::ZERO,
"bucket",
"object",
"request",
1024 * 1024,
);
let inner = StreamReader::new(stream.map(|item| item.map_err(io::Error::other)));
(sender, DemandReader::new(inner, control))
}
#[tokio::test(start_paused = true)]
async fn upload_progress_rejects_trickle_empty_frames_and_late_revival() {
for empty in [false, true] {
let (sender, mut reader) = progress_reader();
let mut output = Vec::new();
let mut read = Box::pin(reader.read_to_end(&mut output));
assert!(poll!(read.as_mut()).is_pending());
for _ in 0..5 {
tokio::time::advance(Duration::from_secs(10)).await;
sender
.send(Ok(if empty { Bytes::new() } else { Bytes::from_static(b"x") }))
.unwrap();
assert!(poll!(read.as_mut()).is_pending());
}
tokio::time::advance(Duration::from_secs(10)).await;
// Even a full qualifying chunk arriving at the deadline is too late.
sender.send(Ok(Bytes::from(vec![0; UPLOAD_PROGRESS_BYTES as usize]))).unwrap();
let error = read.await.expect_err("small or empty frames must not renew the window");
assert_eq!(ApiError::from(error).code, S3ErrorCode::RequestTimeout);
let mut byte = [0];
assert_eq!(reader.read(&mut byte).await.unwrap(), 0, "expired reader cannot resume");
}
}
#[tokio::test(start_paused = true)]
async fn upload_progress_allows_long_uploads_without_banking_burst_credit() {
let (sender, mut reader) = progress_reader();
let mut output = Vec::new();
let mut read = Box::pin(reader.read_to_end(&mut output));
assert!(poll!(read.as_mut()).is_pending());
for _ in 0..80 {
tokio::time::advance(Duration::from_secs(59)).await;
sender.send(Ok(Bytes::from(vec![7; UPLOAD_PROGRESS_BYTES as usize]))).unwrap();
assert!(poll!(read.as_mut()).is_pending());
}
drop(sender);
assert_eq!(
read.await.unwrap(),
usize::try_from(80 * UPLOAD_PROGRESS_BYTES).expect("bounded upload fixture size")
);
let (sender, mut reader) = progress_reader();
let mut output = Vec::new();
let mut read = Box::pin(reader.read_to_end(&mut output));
sender
.send(Ok(Bytes::from(vec![7; 4 * UPLOAD_PROGRESS_BYTES as usize])))
.unwrap();
assert!(poll!(read.as_mut()).is_pending());
tokio::time::advance(UPLOAD_PROGRESS_WINDOW).await;
assert_eq!(ApiError::from(read.await.unwrap_err()).code, S3ErrorCode::RequestTimeout);
}
#[tokio::test(start_paused = true)]
async fn upload_progress_excludes_storage_and_buffered_transform_backpressure() {
// A transform may return buffered output after polling its input Pending.
struct BufferedOutput<R> {
inner: R,
first: bool,
}
impl<R: AsyncRead + Unpin> AsyncRead for BufferedOutput<R> {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
let result = Pin::new(&mut self.inner).poll_read(cx, buf);
if result.is_pending() && self.first {
self.first = false;
buf.put_slice(b"buffered");
return Poll::Ready(Ok(()));
}
result
}
}
let (sender, receiver) = mpsc::unbounded_channel::<Result<Bytes, io::Error>>();
let (stream, control) = guard_upload_progress(
StreamingBlob::wrap(UnboundedReceiverStream::new(receiver)),
Duration::from_secs(300),
"bucket",
"object",
"request",
8,
);
let inner = StreamReader::new(stream.map(|item| item.map_err(io::Error::other)));
let mut reader = DemandReader::new(BufferedOutput { inner, first: true }, control);
let mut buffer = [0; 8];
assert_eq!(reader.read(&mut buffer).await.unwrap(), 8);
tokio::time::advance(Duration::from_secs(3600)).await;
sender.send(Ok(Bytes::from_static(b"uploaded"))).unwrap();
assert_eq!(reader.read(&mut buffer).await.unwrap(), 8);
assert_eq!(&buffer, b"uploaded");
drop(sender);
assert_eq!(reader.read(&mut buffer).await.unwrap(), 0);
}
#[tokio::test(start_paused = true)]
async fn upload_progress_reclaims_foreground_and_multipart_admission_after_trickle() {
use crate::app::storage_api::object_usecase::concurrency::{ConcurrencyManager, ForegroundWriteAdmission};
let manager = ConcurrencyManager::with_put_admission_for_test(true, 1, Duration::ZERO);
for multipart in [false, true] {
let admission = if multipart {
manager.admit_multipart_part(65536).await.unwrap()
} else {
manager.admit_put_object(65536).await.unwrap()
};
let permit = match admission {
ForegroundWriteAdmission::Admitted(permit) => permit,
_ => panic!("initial admission"),
};
let (sender, mut reader) = progress_reader();
let mut owner = Box::pin(async move {
let _permit = permit;
let mut output = Vec::new();
reader.read_to_end(&mut output).await
});
assert!(poll!(owner.as_mut()).is_pending());
assert!(matches!(
manager.admit_put_object(65536).await.unwrap(),
ForegroundWriteAdmission::Rejected
));
for _ in 0..5 {
tokio::time::advance(Duration::from_secs(10)).await;
sender.send(Ok(Bytes::from_static(b"x"))).unwrap();
assert!(poll!(owner.as_mut()).is_pending());
}
tokio::time::advance(Duration::from_secs(10)).await;
assert!(owner.await.is_err());
assert!(matches!(
manager.admit_multipart_part(65536).await.unwrap(),
ForegroundWriteAdmission::Admitted(_)
));
}
}
#[tokio::test(start_paused = true)]
async fn upload_progress_does_not_release_an_owned_commit_during_storage_io_or_cancellation() {
use crate::app::storage_api::object_usecase::concurrency::{ConcurrencyManager, ForegroundWriteAdmission};
let manager = ConcurrencyManager::with_put_admission_for_test(true, 1, Duration::ZERO);
let permit = match manager.admit_put_object(65536).await.unwrap() {
ForegroundWriteAdmission::Admitted(permit) => permit,
_ => panic!("initial admission"),
};
let (sender, mut reader) = progress_reader();
sender.send(Ok(Bytes::from_static(b"x"))).unwrap();
let (entered, at_io) = tokio::sync::oneshot::channel();
let (release, released) = tokio::sync::oneshot::channel();
let owner = tokio::spawn(async move {
let _permit = permit;
let mut byte = [0];
reader.read_exact(&mut byte).await.unwrap();
entered.send(()).unwrap();
released.await.unwrap();
reader.read_exact(&mut byte).await
});
at_io.await.unwrap();
// Dropping the request's join handle must leave the commit owner intact.
drop(owner);
tokio::time::advance(Duration::from_secs(3600)).await;
assert!(matches!(
manager.admit_put_object(65536).await.unwrap(),
ForegroundWriteAdmission::Rejected
));
sender.send(Ok(Bytes::from_static(b"y"))).unwrap();
release.send(()).unwrap();
tokio::task::yield_now().await;
assert!(matches!(
manager.admit_put_object(65536).await.unwrap(),
ForegroundWriteAdmission::Admitted(_)
));
}
@@ -38,7 +38,7 @@ impl S3 for Consumer {
.get::<BodyReadControl>()
.expect("HTTP control extension")
.clone();
control.activate(Duration::from_secs(300), "test-bucket", "test-key", "request", expected as u64);
control.activate_upload(Duration::from_secs(300), "test-bucket", "test-key", "request", expected as u64);
let stream = req.input.body.expect("body");
let inner = rustfs_rio::wrap_reader(StreamReader::new(stream.map(|item| item.map_err(io::Error::other))));
let mut reader =
@@ -207,7 +207,7 @@ fn signed_request(payload: &[u8], unsigned_trailer: bool) -> SignedRequest {
#[tokio::test(start_paused = true)]
async fn signed_chunk_with_raw_progress_survives_eight_minutes_without_decoded_output() {
let payload = vec![7; 65536];
let payload = vec![7; 1024 * 1024];
let SignedRequest {
request,
sender,
@@ -220,13 +220,13 @@ async fn signed_chunk_with_raw_progress_survives_eight_minutes_without_decoded_o
sender.send(Ok(Frame::data(prefix))).expect("body receiver");
assert!(poll!(call.as_mut()).is_pending());
let start = Instant::now();
for chunk in payload.chunks(8192) {
for chunk in payload.chunks(65536) {
assert_eq!(
consumer.received.load(Ordering::Relaxed),
0,
"incomplete chunks must not escape signature validation"
);
tokio::time::advance(Duration::from_secs(60)).await;
tokio::time::advance(Duration::from_secs(30)).await;
sender
.send(Ok(Frame::data(Bytes::copy_from_slice(chunk))))
.expect("body receiver");
@@ -356,3 +356,27 @@ async fn trailer_adapter_is_pending_until_the_decoded_body_ends() {
assert_eq!(trailer.lookup("x-amz-checksum-sha256"), TrailerValue::Missing);
assert_eq!(*consumer.committed.lock(), Some(payload.to_vec()));
}
#[tokio::test(start_paused = true)]
async fn signed_chunk_trickle_cannot_renew_upload_progress_window() {
let SignedRequest {
request, sender, prefix, ..
} = signed_request(&[7; 65536], false);
let consumer = Consumer::default();
let service = service(consumer.clone());
sender.send(Ok(Frame::data(prefix))).unwrap();
let mut call = Box::pin(service.call(request));
assert!(poll!(call.as_mut()).is_pending());
for _ in 0..5 {
tokio::time::advance(Duration::from_secs(10)).await;
sender.send(Ok(Frame::data(Bytes::from_static(&[7])))).unwrap();
assert!(poll!(call.as_mut()).is_pending());
assert_eq!(consumer.received.load(Ordering::Relaxed), 0);
}
tokio::time::advance(Duration::from_secs(10)).await;
let response = call.await.expect("S3 response");
assert_eq!(response.status(), http::StatusCode::BAD_REQUEST);
let body = BodyExt::collect(response.into_body()).await.unwrap().to_bytes();
assert!(String::from_utf8_lossy(&body).contains("<Code>RequestTimeout</Code>"));
assert!(consumer.committed.lock().is_none());
}
+23 -1
View File
@@ -701,7 +701,8 @@ fn build_put_object_expiration_header(event: &lifecycle::Event) -> Option<String
// HTTP-date, not ISO-8601: the AWS SDKs hand this value to an RFC 822
// parser, which rejects `2026-10-02T00:00:00Z` and drops the metadata.
let expiry_date = format_http_date(&Timestamp::from(expire_time)).ok()?;
Some(format!("expiry-date=\"{}\", rule-id=\"{}\"", expiry_date, event.rule_id))
let value = format!("expiry-date=\"{}\", rule-id=\"{}\"", expiry_date, event.rule_id);
http::HeaderValue::from_str(&value).is_ok().then_some(value)
}
pub(super) fn internal_object_info_lookup_opts(mut opts: ObjectOptions) -> ObjectOptions {
@@ -1956,6 +1957,27 @@ mod tests {
assert!(build_put_object_expiration_header(&event).is_none());
}
#[test]
fn expiration_header_ignores_unsafe_legacy_rule_ids() {
let mut event = lifecycle::Event {
action: lifecycle::IlmAction::DeleteAction,
rule_id: String::new(),
due: Some(OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid expiry")),
noncurrent_days: 0,
newer_noncurrent_versions: 0,
storage_class: String::new(),
expiry_policy: None,
};
for id in ["rule\nname", "rule\rname", "rule\0name", "rule\u{7f}name"] {
event.rule_id = id.to_owned();
assert!(build_put_object_expiration_header(&event).is_none(), "{id:?}");
}
event.rule_id = "保留策略".to_owned();
let header = build_put_object_expiration_header(&event).expect("valid UTF-8 IDs remain supported");
assert!(http::HeaderValue::from_str(&header).is_ok());
assert!(header.contains("保留策略"));
}
#[test]
fn build_put_object_expiration_header_formats_expected_value() {
let expire_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
@@ -877,6 +877,7 @@ fn veeam_sosapi_http_contract() {
)
.await
.expect("set quota on fresh bucket");
crate::app::storage_api::test::set_disk::initialize_quota_ledger_for_test(Arc::clone(&store), &quota_bucket).await.expect("bootstrap quota usage");
snapshot.last_update = Some(std::time::SystemTime::now() + Duration::from_secs(2));
snapshot.buckets_count = 2;
let quota_incarnation = store.bucket_incarnation_id_from_disk(&quota_bucket).await.expect("quota bucket incarnation");
+2
View File
@@ -22,6 +22,8 @@ pub(crate) use crate::runtime_sources::{
current_notification_system_for_context, current_object_data_cache_handle_for_context,
current_object_store_handle_for_context,
};
#[cfg(test)]
pub(crate) use crate::runtime_sources::{IamInterface, KmsInterface};
use rustfs_s3select_api::{QueryResult, server::dbms::DatabaseManagerSystem};
use s3s::dto::SelectObjectContentInput;
use std::sync::Arc;
+75
View File
@@ -1781,6 +1781,81 @@ mod tests {
assert!(error.source().is_none());
}
#[test]
#[serial_test::serial]
fn unread_select_response_does_not_block_real_overwrite() {
crate::app::gating_test_env::run_large_stack_test("select-unread-writer", || async {
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
use crate::app::storage_api::test::contract::object::ObjectIO as _;
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await;
let bucket = format!("select-unread-{}", uuid::Uuid::new_v4().simple());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("bucket");
let payload = b"original,123456789012345678901234567890\n".repeat(100_000);
let mut initial = crate::app::storage_api::object_usecase::StoragePutObjReader::from_vec(payload);
store
.put_object(&bucket, "input.csv", &mut initial, &Default::default())
.await
.expect("CSV fixture");
let snapshot = store
.prepare_select_object_snapshot(&bucket, "input.csv", &HeaderMap::new(), &Default::default())
.await
.expect("snapshot for terminal consistency fence");
let snapshot = Arc::new(snapshot);
let mut input = base_input();
input.bucket = bucket.clone();
input.key = "input.csv".to_owned();
let request = S3Request {
input,
method: http::Method::POST,
uri: format!("/{bucket}/input.csv?select&select-type=2").parse().expect("URI"),
headers: HeaderMap::new(),
extensions: http::Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
let response = execute_select_object_content(request)
.await
.expect("start actual SELECT response");
// Keep the response alive without polling its event stream. The
// producer may fill its bounded channel, but cannot retain object locks.
let mut replacement =
crate::app::storage_api::object_usecase::StoragePutObjReader::from_vec(b"replacement,2\n".to_vec());
tokio::time::timeout(
Duration::from_secs(10),
store.put_object(&bucket, "input.csv", &mut replacement, &Default::default()),
)
.await
.expect("unread SELECT must not block writer")
.expect("normal overwrite");
drop(response);
assert!(snapshot.open_reader(None).await.is_err(), "first range must reject the real overwrite");
let schema = Arc::new(Schema::new(vec![Field::new("value", DataType::Utf8, false)]));
let output = Box::pin(RecordBatchStreamAdapter::new(
schema,
futures::stream::empty::<Result<RecordBatch, DataFusionError>>(),
));
let (mut events, mut rx) = test_event_channel(4);
let outcome = send_select_events(
output,
&mut events,
csv_validation(),
Arc::new(SelectInputMetrics::default()),
Instant::now() + Duration::from_secs(30),
30,
&snapshot,
)
.await;
assert!(matches!(outcome, SelectProducerOutcome::Terminal(Err(_))));
assert!(rx.try_recv().is_err(), "a real changed-generation fence must never emit Stats or End");
});
}
fn base_input() -> SelectObjectContentInput {
SelectObjectContentInput {
bucket: "bucket".to_string(),
+36 -16
View File
@@ -24,6 +24,13 @@ pub(crate) fn EndpointServerPools(
crate::storage::storage_api::EndpointServerPools::from(pools)
}
/// Streaming body types the request-body adapters implement against.
pub(crate) mod request_body {
pub(crate) use s3s::StdError;
pub(crate) use s3s::dto::StreamingBlob;
pub(crate) use s3s::stream::{ByteStream, DynByteStream, RemainingLength};
}
#[cfg(feature = "diagnostic-read-version-caller")]
pub(crate) mod diagnostic {
pub(crate) use crate::storage::storage_api::ecstore_diagnostic::{
@@ -303,11 +310,11 @@ pub(crate) mod access {
pub(crate) use crate::storage::storage_api::access_consumer::{
PostObjectRequestMarker, TABLE_DATA_PLANE_LIST_CURSOR_PREFIX, TableDataPlaneListAccess, TableDataPlaneListCursorPosition,
TableDataPlanePublicationGuards, apply_bucket_generation_guard, apply_copy_source_bucket_generation_guard,
authorize_request, bucket_config_mutation_incarnation, delete_object_authorize_action, has_bypass_governance_header,
load_bucket_generation_from_store, log_list_buckets_iam_implicit_deny, odm_read_generation,
authorize_copy_object_tags, authorize_request, bucket_config_mutation_incarnation, delete_object_authorize_action,
has_bypass_governance_header, load_bucket_generation_from_store, log_list_buckets_iam_implicit_deny, odm_read_generation,
prepare_list_buckets_iam_authorization, prepare_odm_read_generation, recursive_force_delete_has_authenticated_caller,
replication_request_authorized, req_info_mut, req_info_ref, retained_table_data_plane_publication_guards,
set_requested_object_lock_retain_until,
replication_proxy_allowed, replication_request_authorized, req_info_mut, req_info_ref,
retained_table_data_plane_publication_guards, set_requested_object_lock_retain_until,
};
}
@@ -1130,13 +1137,17 @@ pub(crate) mod head_prefix {
}
pub(crate) mod helper {
#[cfg(test)]
pub(crate) use crate::storage::storage_api::helper_consumer::{
OperationHelper, build_event_resp_elements, spawn_background_with_context,
MutationCompletionProbe, capture_mutation_event_for_test, pause_committed_mutation_for_test,
};
pub(crate) use crate::storage::storage_api::helper_consumer::{
OperationHelper, build_event_resp_elements, run_owned_mutation, spawn_background_with_context,
};
}
pub(crate) mod object_utils {
pub(crate) use crate::storage::storage_api::to_s3s_etag;
pub(crate) use crate::storage::storage_api::{to_s3s_etag, to_s3s_xml_etag};
}
pub(crate) mod io {
@@ -1167,14 +1178,15 @@ pub(crate) mod request_context {
pub(crate) mod sse {
pub(crate) use crate::storage::storage_api::sse_consumer::{
DecryptionRequest, EncryptionRequest, PrepareEncryptionRequest, SseKmsPrincipal, apply_bucket_default_lock_retention,
authorize_sse_kms_object_read, classify_sse_read_response, extract_server_side_encryption_from_headers,
get_buffer_size_opt_in, load_bucket_object_lock_config_state, project_sse_read_response_headers, sse_decryption,
sse_encryption, sse_prepare_encryption, validate_bucket_object_lock_enabled_state,
authorize_sse_kms_multipart_write, authorize_sse_kms_object_read, classify_sse_read_response,
extract_server_side_encryption_from_headers, get_buffer_size_opt_in, load_bucket_object_lock_config_state,
project_sse_read_response_headers, sse_decryption, sse_encryption, sse_prepare_encryption,
validate_bucket_object_lock_enabled_state,
};
pub(crate) use crate::storage::storage_api::sse_consumer::{
EncryptionKeyKind, bucket_default_write_sse, build_ssec_read_headers, encryption_material_to_metadata,
extract_ssec_params_from_headers, extract_ssekms_context_from_headers, map_get_object_reader_error,
mark_encrypted_multipart_metadata,
mark_encrypted_multipart_metadata, multipart_completion_authorization, stored_ssec_metadata,
};
}
@@ -1213,7 +1225,7 @@ pub(crate) mod s3_api {
pub(crate) mod tagging {
pub(crate) use crate::storage::storage_api::s3_api_consumer::tagging::{
parse_copy_object_tags, resolve_copy_object_tags,
parse_copy_object_tags, resolve_copy_object_tags, validate_bucket_tag_set,
};
}
}
@@ -1248,7 +1260,7 @@ pub(crate) mod bucket_usecase {
}
}
pub(crate) use super::{access, bucket, error, helper, object_utils, request_context, s3, s3_api};
pub(crate) use super::{access, bucket, error, helper, object_utils, request_context, s3, s3_api, sse};
pub(crate) use crate::storage::storage_api::{
ECStore, StorageObjectInfo, StorageObjectOptions, get_validated_store, process_lambda_configurations,
process_queue_configurations, process_topic_configurations, validate_list_object_unordered_with_delimiter,
@@ -1298,8 +1310,9 @@ pub(crate) mod object_usecase {
ECStore, GetObjectReader, OldCurrentSize, RFC1123, StorageDeletedObject, StorageObjectInfo,
StorageObjectLockDeleteOptions, StorageObjectOptions, StorageObjectToDelete, StoragePutObjReader, check_preconditions,
parse_object_lock_legal_hold, parse_object_lock_retention, parse_part_number_i32_to_usize,
remove_object_lock_metadata_for_copy, strip_managed_encryption_metadata, validate_bucket_exists, validate_object_key,
validate_sse_headers_for_read, validate_sse_headers_for_write, validate_ssec_for_read, wrap_response_with_cors,
remove_object_lock_metadata_for_copy, strip_managed_encryption_metadata, validate_bucket_exists, validate_new_object_key,
validate_object_key, validate_sse_headers_for_read, validate_sse_headers_for_write, validate_ssec_for_read,
wrap_response_with_cors,
};
}
@@ -1332,7 +1345,7 @@ pub(crate) mod multipart_usecase {
access, bucket, compression, data_usage, error, helper, io, object_utils, options, request_context, s3_api, set_disk, sse,
};
pub(crate) use crate::storage::storage_api::{
ECStore, StorageObjectInfo, StorageObjectOptions, StoragePutObjReader, validate_object_key,
ECStore, StorageObjectInfo, StorageObjectOptions, StoragePutObjReader, validate_new_object_key,
};
}
@@ -1359,6 +1372,7 @@ pub(crate) mod context {
#[cfg(test)]
pub(crate) mod test {
pub(crate) use super::EndpointServerPools;
pub(crate) use s3s::access::S3Access;
pub(crate) mod contract {
pub(crate) mod bucket {
pub(crate) use super::super::super::storage_contracts::{
@@ -1389,6 +1403,11 @@ pub(crate) mod test {
pub(crate) use super::access::ReqInfo;
pub(crate) use super::options::VERSIONING_CONFIG_LOOKUPS;
pub(crate) mod sse {
pub(crate) use crate::storage::storage_api::test_consumer::sse::{
RecordingKmsKeyAuthorizer, TestAuthorizationHooks, sse_kms_object_metadata,
};
}
pub(crate) use super::{bucket, ecfs, object_utils, runtime};
pub(crate) mod data_usage {
pub(crate) use super::super::data_usage::*;
@@ -1402,7 +1421,8 @@ pub(crate) mod test {
pub(crate) mod set_disk {
pub(crate) use crate::storage::storage_api::ecstore_set_disk::{
ConditionalReadBenchmarkMetadataGuard, MultipartCommitBarrier, MultipartCommitPause, PutObjectCommitBarrier,
PutObjectCommitPause, fail_next_quota_ledger_save_for_test,
PutObjectCommitPause, fail_next_quota_ledger_save_for_test, initialize_quota_ledger_for_test,
retry_quota_reconciliation_for_test,
};
}
+34 -13
View File
@@ -197,20 +197,8 @@ impl S3Auth for IAMAuth {
return Err(s3_error!(UnauthorizedAccess, "Your account is not signed up"));
}
// Check if this is a Keystone access key (from mixed auth scenario)
// Keystone credentials use token authentication, not signature verification
if access_key.starts_with("keystone:") {
debug!(
event = EVENT_KEYSTONE_CREDENTIALS_DETECTED,
component = LOG_COMPONENT_AUTH,
subsystem = LOG_SUBSYSTEM_KEYSTONE,
access_key = %MaskedAccessKey(access_key),
result = "token_auth",
"Keystone token-auth access key detected"
);
// Return empty secret key - Keystone uses token validation, not AWS signatures
// The actual credentials are stored in task-local storage by KeystoneAuthMiddleware
return Ok(SecretKey::from(String::new()));
return Err(s3_error!(InvalidAccessKeyId, "Keystone authentication requires X-Auth-Token header"));
}
if access_key == self.access_key {
@@ -1737,6 +1725,39 @@ mod tests {
assert!(!has_root_access(&sys_cred, &oidc_cred));
}
#[tokio::test]
async fn keystone_secret_lookup_requires_validated_token_context() {
use rustfs_keystone::KEYSTONE_CREDENTIALS;
let auth = IAMAuth::new("root", "root-secret");
for key in ["keystone:x", "keystone:admin"] {
assert_eq!(auth.get_secret_key(key).await.unwrap_err().code(), &S3ErrorCode::InvalidAccessKeyId);
KEYSTONE_CREDENTIALS
.scope(None, async {
assert_eq!(auth.get_secret_key(key).await.unwrap_err().code(), &S3ErrorCode::InvalidAccessKeyId);
})
.await;
}
let credentials = Credentials {
access_key: "keystone:validated".to_owned(),
..Default::default()
};
KEYSTONE_CREDENTIALS
.scope(Some(credentials), async {
assert_eq!(
auth.get_secret_key("keystone:validated")
.await
.expect("validated Keystone token")
.expose(),
""
);
})
.await;
assert_eq!(
auth.get_secret_key("root").await.expect("regular root credential").expose(),
"root-secret"
);
}
#[test]
fn test_iam_auth_creation() {
let access_key = "test-access-key";
+916 -6
View File
@@ -1429,6 +1429,343 @@ where
}
}
// Validate control-plane digests before s3s discards the serialized body.
// Object uploads and multipart/form-data must retain their streaming readers.
struct ControlRequestBody(s3s::Body);
impl http_body::Body for ControlRequestBody {
type Data = Bytes;
type Error = Error;
fn poll_frame(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<std::result::Result<http_body::Frame<Self::Data>, Self::Error>>> {
Pin::new(&mut self.0)
.poll_frame(cx)
.map(|frame| frame.map(|result| result.map_err(Error::other)))
}
fn is_end_stream(&self) -> bool {
http_body::Body::is_end_stream(&self.0)
}
fn size_hint(&self) -> http_body::SizeHint {
http_body::Body::size_hint(&self.0)
}
}
fn control_request_body(body: s3s::Body) -> ControlRequestBody {
ControlRequestBody(body)
}
#[derive(Clone)]
struct ControlBodyDigestService<S> {
inner: S,
idle_timeout: Duration,
total_timeout: Duration,
max_body_size: usize,
virtual_host: Option<Arc<MultiDomain>>,
}
impl<S> ControlBodyDigestService<S> {
fn new(inner: S, idle_timeout: Duration) -> Self {
Self {
inner,
idle_timeout,
total_timeout: Duration::from_secs(30),
max_body_size: rustfs_s3_config().xml_max_body_size,
virtual_host: None,
}
}
fn with_virtual_host_domains(mut self, domains: Arc<[String]>) -> Self {
if !domains.is_empty() {
// Startup already validated this same domain set for S3Service.
self.virtual_host = Some(Arc::new(MultiDomain::new(domains.iter()).expect("validated S3 host domains")));
}
self
}
}
fn control_body_digest_required<B>(request: &HttpRequest<B>, virtual_host: Option<&MultiDomain>) -> Option<bool> {
let query = url::form_urlencoded::parse(request.uri().query().unwrap_or_default().as_bytes());
// Match s3s' multipart POST precedence, including its unique-header rule.
if request.method() == Method::POST
&& request.headers().get_all("content-type").iter().count() == 1
&& request
.headers()
.get("content-type")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<mime_guess::Mime>().ok())
.is_some_and(|mime| mime.type_() == mime_guess::mime::MULTIPART && mime.subtype() == mime_guess::mime::FORM_DATA)
{
return None;
}
let mut operation_ids = query.filter(|(key, _)| key == "x-id");
let operation_id = operation_ids.next().map(|(_, value)| value);
// s3s rejects duplicate declarations and treats a unique x-id as authoritative.
if operation_ids.next().is_some() {
return None;
}
if request.method() == Method::POST {
let decoded_path = urlencoding::decode(request.uri().path()).ok()?;
use s3s::host::S3Host;
// Use s3s' host parser, including CNAME fallback, and its Host-first
// precedence. A rate-limit host hint is intentionally only best-effort.
let mut host_values = request.headers().get_all("host").iter();
let host = host_values.next().and_then(|value| value.to_str().ok()).or_else(|| {
matches!(request.version(), Version::HTTP_2 | Version::HTTP_3)
.then(|| request.uri().authority().map(|value| value.as_str()))
.flatten()
});
if host_values.next().is_some() {
return None;
}
let host = match (virtual_host, host) {
(Some(parser), Some(host)) if host.parse::<SocketAddr>().is_err() && host.parse::<IpAddr>().is_err() => {
Some(parser.parse_host_header(host).ok()?)
}
_ => None,
};
let path = s3s::path::parse_virtual_hosted_style(host.as_ref().and_then(|host| host.bucket()), &decoded_path).ok()?;
if !matches!(path, s3s::path::S3Path::Bucket { .. }) {
return None;
}
let delete_objects = match operation_id.as_deref() {
Some("DeleteObjects") => true,
Some(_) => false,
None => {
// These bucket POST routes precede DeleteObjects in s3s' router.
query.clone().any(|(key, _)| key == "delete")
&& !query
.clone()
.any(|(key, _)| matches!(key.as_ref(), "metadataConfiguration" | "metadataTable"))
}
};
return delete_objects.then_some(true);
}
if request.method() == Method::PUT
&& operation_id
.as_deref()
.is_some_and(|operation| matches!(operation, "PutObject" | "UploadPart"))
{
return None;
}
// Other configuration APIs keep their optional-MD5 contract for SigV4,
// whose payload hash binds the body. A supplied MD5 must match, and a
// SigV2 request (which signs no body) must supply one.
if request.method() == Method::PUT
&& query.clone().any(|(key, value)| {
(key == "x-id" && value.starts_with("Put") && value != "PutObject")
|| matches!(
key.as_ref(),
"acl"
| "cors"
| "lifecycle"
| "logging"
| "notification"
| "policy"
| "replication"
| "requestPayment"
| "tagging"
| "versioning"
| "website"
| "object-lock"
| "retention"
| "legal-hold"
| "ownershipControls"
| "encryption"
| "accelerate"
| "intelligent-tiering"
| "analytics"
| "metrics"
| "inventory"
| "publicAccessBlock"
)
})
{
return Some(false);
}
None
}
fn control_body_digest_error(code: &'static str) -> Response<s3s::Body> {
// Only static protocol error codes reach this function; never reflect headers or bodies.
let xml = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?><Error><Code>{code}</Code><Message>{code}</Message></Error>");
let length = xml.len();
let mut response = Response::new(s3s::Body::from(xml));
*response.status_mut() = http::StatusCode::BAD_REQUEST;
response
.headers_mut()
.insert("content-type", HeaderValue::from_static("application/xml"));
response.headers_mut().insert("content-length", HeaderValue::from(length));
response
}
const CONTROL_BODY_CHECKSUM_HEADERS: [&str; 10] = [
"x-amz-checksum-crc32",
"x-amz-checksum-crc32c",
"x-amz-checksum-crc64nvme",
"x-amz-checksum-sha1",
"x-amz-checksum-sha256",
"x-amz-checksum-sha512",
"x-amz-checksum-xxhash64",
"x-amz-checksum-xxhash3",
"x-amz-checksum-xxhash128",
"x-amz-checksum-md5",
];
fn verify_control_body_digest(headers: &HeaderMap, bytes: &[u8]) -> std::result::Result<(), &'static str> {
use md5::Digest;
if let Some(expected) = headers.get("content-md5") {
if headers.get_all("content-md5").iter().count() != 1 {
return Err("InvalidDigest");
}
let expected = base64_simd::STANDARD
.decode_to_vec(expected.as_bytes())
.map_err(|_| "InvalidDigest")?;
if expected.len() != 16 {
return Err("InvalidDigest");
}
if md5::Md5::digest(bytes).as_slice() != expected.as_slice() {
return Err("BadDigest");
}
}
let mut checksums = s3s::checksum::ChecksumHasher {
crc32: headers.contains_key("x-amz-checksum-crc32").then(Default::default),
crc32c: headers.contains_key("x-amz-checksum-crc32c").then(Default::default),
crc64nvme: headers.contains_key("x-amz-checksum-crc64nvme").then(Default::default),
sha1: headers.contains_key("x-amz-checksum-sha1").then(Default::default),
sha256: headers.contains_key("x-amz-checksum-sha256").then(Default::default),
sha512: headers.contains_key("x-amz-checksum-sha512").then(Default::default),
xxhash64: headers.contains_key("x-amz-checksum-xxhash64").then(Default::default),
xxhash3: headers.contains_key("x-amz-checksum-xxhash3").then(Default::default),
xxhash128: headers.contains_key("x-amz-checksum-xxhash128").then(Default::default),
md5: headers.contains_key("x-amz-checksum-md5").then(Default::default),
};
checksums.update(bytes);
let checksums = checksums.finalize();
let computed = [
checksums.checksum_crc32,
checksums.checksum_crc32c,
checksums.checksum_crc64nvme,
checksums.checksum_sha1,
checksums.checksum_sha256,
checksums.checksum_sha512,
checksums.checksum_xxhash64,
checksums.checksum_xxhash3,
checksums.checksum_xxhash128,
checksums.checksum_md5,
];
for (name, computed) in CONTROL_BODY_CHECKSUM_HEADERS.into_iter().zip(computed) {
if let Some(computed) = computed
&& (headers.get_all(name).iter().count() != 1
|| headers.get(name).map(HeaderValue::as_bytes) != Some(computed.as_bytes()))
{
return Err("BadDigest");
}
}
Ok(())
}
impl<S, B> Service<HttpRequest<B>> for ControlBodyDigestService<S>
where
S: Service<HttpRequest<ControlRequestBody>, Response = Response<s3s::Body>> + Clone + Send + 'static,
S::Future: Send + 'static,
S::Error: Send + 'static,
B: http_body::Body<Data = Bytes> + Send + 'static,
B::Error: Send + Sync + 'static,
Box<dyn std::error::Error + Send + Sync>: From<B::Error>,
{
type Response = Response<s3s::Body>;
type Error = S::Error;
type Future = Pin<Box<dyn Future<Output = std::result::Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<std::result::Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, request: HttpRequest<B>) -> Self::Future {
use http_body_util::BodyExt;
let clone = self.inner.clone();
let mut inner = std::mem::replace(&mut self.inner, clone);
let required = control_body_digest_required(&request, self.virtual_host.as_deref());
let idle_timeout = self.idle_timeout;
let total_timeout = self.total_timeout;
let limit = self.max_body_size;
Box::pin(async move {
let mut request = request.map(s3s::Body::http_body_unsync);
let Some(required) = required else {
return inner.call(request.map(control_request_body)).await;
};
let headers = request.headers();
let sig_v2 = headers
.get("authorization")
.is_some_and(|value| value.as_bytes().starts_with(b"AWS "))
|| url::form_urlencoded::parse(request.uri().query().unwrap_or_default().as_bytes())
.any(|(key, _)| key == "AWSAccessKeyId");
let has_checksum = CONTROL_BODY_CHECKSUM_HEADERS.iter().any(|name| headers.contains_key(*name));
let has_md5 = headers.contains_key("content-md5");
if required && !has_md5 && (sig_v2 || !has_checksum) {
return Ok(control_body_digest_error("MissingContentMD5"));
}
if !required && !has_md5 {
// SigV2 signs no body: a configuration PUT without a digest
// could be replayed with another body. A declared empty body
// (a canned-ACL PUT, for example) has nothing to replay.
let declared_empty = headers
.get("content-length")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.trim() == "0");
if sig_v2 && !declared_empty {
return Ok(control_body_digest_error("MissingContentMD5"));
}
return inner.call(request.map(control_request_body)).await;
}
if headers
.get("content-length")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u64>().ok())
.is_some_and(|length| length > limit as u64)
{
return Ok(control_body_digest_error("EntityTooLarge"));
}
let deadline = tokio::time::Instant::now() + total_timeout;
let collected = tokio::time::timeout_at(deadline, async {
let mut bytes = bytes::BytesMut::new();
while let Some(frame) = tokio::time::timeout(idle_timeout, request.body_mut().frame())
.await
.map_err(|_| "RequestTimeout")?
{
if tokio::time::Instant::now() >= deadline {
return Err("RequestTimeout");
}
let frame = frame.map_err(|_| "IncompleteBody")?;
if let Ok(data) = frame.into_data() {
if data.len() > limit.saturating_sub(bytes.len()) {
return Err("EntityTooLarge");
}
bytes.extend_from_slice(&data);
}
}
Ok(bytes.freeze())
})
.await
.unwrap_or(Err("RequestTimeout"));
let bytes = match collected {
Ok(bytes) => bytes,
Err(code) => return Ok(control_body_digest_error(code)),
};
if let Err(code) = verify_control_body_digest(request.headers(), &bytes) {
return Ok(control_body_digest_error(code));
}
*request.body_mut() = s3s::Body::from(bytes);
inner.call(request.map(control_request_body)).await
})
}
}
fn trace_on_response<ResBody>(response: &Response<ResBody>, latency: Duration, span: &Span) {
span.record("status_code", tracing::field::display(response.status()));
let _enter = span.enter();
@@ -2139,10 +2476,10 @@ async fn start_http_server_inner(
let make_service = move |peer_addr: SocketAddr| -> Http3ExternalService {
let remote_addr = RemoteAddr(peer_addr);
let service = ControlBodyDigestService::new(h3_s3_service.clone(), http_request_body_read_timeout)
.with_virtual_host_domains(h3_vh_domain_hosts.clone());
#[cfg(feature = "swift")]
let service = SwiftService::new(true, None, h3_s3_service.clone());
#[cfg(not(feature = "swift"))]
let service = h3_s3_service.clone();
let service = SwiftService::new(true, None, service);
let service = EarlyResponseBodyService::new(service, http_request_body_read_timeout);
let service = ServiceBuilder::new()
.layer(tower::util::MapResponseLayer::new(|response: Response<s3s::Body>| {
@@ -2794,10 +3131,10 @@ fn process_connection(
.prepare(),
);
let http_service = ControlBodyDigestService::new(s3_service, request_body_idle_timeout)
.with_virtual_host_domains(vh_domain_hosts.clone());
#[cfg(feature = "swift")]
let http_service = SwiftService::new(true, None, s3_service);
#[cfg(not(feature = "swift"))]
let http_service = s3_service;
let http_service = SwiftService::new(true, None, http_service);
let http_service =
EarlyResponseBodyService::new(http_service, request_body_idle_timeout).with_task_owner(task_owner.clone());
let http_service = InternodeRpcService::new(http_service);
@@ -3350,6 +3687,579 @@ mod tests {
use tokio::sync::{Notify, mpsc};
use tower::{Layer, Service, ServiceBuilder};
const DIGEST_DELETE_XML: &str = "<Delete><Object><Key>safe-object</Key></Object></Delete>";
struct DigestCheckedS3(Arc<AtomicUsize>);
#[async_trait::async_trait]
impl s3s::S3 for DigestCheckedS3 {
async fn delete_objects(
&self,
request: s3s::S3Request<s3s::dto::DeleteObjectsInput>,
) -> s3s::S3Result<s3s::S3Response<s3s::dto::DeleteObjectsOutput>> {
assert_eq!(request.credentials.expect("authenticated request").access_key, "digest-key");
assert_eq!(request.input.delete.objects[0].key, "safe-object");
self.0.fetch_add(1, Ordering::SeqCst);
Ok(s3s::S3Response::new(s3s::dto::DeleteObjectsOutput::default()))
}
}
struct DigestMultipartS3(Arc<AtomicUsize>);
#[async_trait::async_trait]
impl s3s::S3 for DigestMultipartS3 {
async fn create_multipart_upload(
&self,
request: s3s::S3Request<s3s::dto::CreateMultipartUploadInput>,
) -> s3s::S3Result<s3s::S3Response<s3s::dto::CreateMultipartUploadOutput>> {
assert_eq!(request.credentials.expect("authenticated request").access_key, "digest-key");
assert_eq!(request.input.bucket, "bucket");
assert_eq!(request.input.key, "object");
self.0.fetch_add(1, Ordering::SeqCst);
Ok(s3s::S3Response::new(s3s::dto::CreateMultipartUploadOutput::default()))
}
async fn complete_multipart_upload(
&self,
request: s3s::S3Request<s3s::dto::CompleteMultipartUploadInput>,
) -> s3s::S3Result<s3s::S3Response<s3s::dto::CompleteMultipartUploadOutput>> {
assert_eq!(request.credentials.expect("authenticated request").access_key, "digest-key");
assert_eq!(request.input.bucket, "bucket");
assert_eq!(request.input.key, "object");
self.0.fetch_add(1, Ordering::SeqCst);
Ok(s3s::S3Response::new(s3s::dto::CompleteMultipartUploadOutput::default()))
}
}
fn signed_digest_route_request(uri: &str, body: &'static str, with_md5: bool, virtual_host: bool) -> HttpRequest<s3s::Body> {
use md5::Digest;
let uri: Uri = uri.parse().unwrap();
let mut request = HttpRequest::builder()
.method("POST")
.header("date", chrono::Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string())
.header("host", uri.authority().unwrap().as_str())
.uri(uri)
.header("content-length", body.len())
.header("content-type", "application/xml")
.body(s3s::Body::from(body.to_string()))
.unwrap();
if with_md5 {
request.headers_mut().insert(
"content-md5",
base64_simd::STANDARD
.encode_to_string(md5::Md5::digest(body.as_bytes()))
.parse()
.unwrap(),
);
}
let wire_uri = request.uri().clone();
if virtual_host {
// The repository V2 signer currently ignores its virtual_host flag.
// Sign the canonical bucket-prefixed resource, then restore the wire URI.
let bucket = wire_uri.host().unwrap().split('.').next().unwrap();
let mut parts = wire_uri.clone().into_parts();
parts.path_and_query = Some(format!("/{bucket}{}", wire_uri.path_and_query().unwrap()).parse().unwrap());
*request.uri_mut() = Uri::from_parts(parts).unwrap();
}
let mut request = rustfs_signer::try_sign_v2(request, body.len() as i64, "digest-key", "digest-secret", false).unwrap();
*request.uri_mut() = wire_uri;
request
}
#[tokio::test]
async fn control_body_digest_follows_s3_multipart_routing_with_delete_query() {
use tower::ServiceExt;
let calls = Arc::new(AtomicUsize::new(0));
let mut builder = S3ServiceBuilder::new(DigestMultipartS3(calls.clone()));
builder.set_auth(s3s::auth::SimpleAuth::from_single("digest-key", "digest-secret"));
builder.set_config(Arc::new(StaticConfigProvider::new(Arc::new(rustfs_s3_config()))));
builder.set_host(MultiDomain::new(["example.com"]).unwrap());
let service = ControlBodyDigestService::new(builder.build(), Duration::from_secs(2))
.with_virtual_host_domains(vec!["example.com".to_owned()].into());
for virtual_host in [false, true] {
let base = if virtual_host {
"http://bucket.example.com/object"
} else {
"http://example.com/bucket/object"
};
for (query, body) in [
("x-id=CreateMultipartUpload&uploads&delete", ""),
("uploads&delete", ""),
(
"x-id=CompleteMultipartUpload&uploadId=id&delete",
"<CompleteMultipartUpload><Part><ETag>etag</ETag><PartNumber>1</PartNumber></Part></CompleteMultipartUpload>",
),
(
"uploadId=id&delete",
"<CompleteMultipartUpload><Part><ETag>etag</ETag><PartNumber>1</PartNumber></Part></CompleteMultipartUpload>",
),
] {
let uri = format!("{base}?{query}");
let response = service
.clone()
.oneshot(signed_digest_route_request(&uri, body, false, virtual_host))
.await
.unwrap();
let status = response.status();
let bytes = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(status, StatusCode::OK, "{uri}: {}", String::from_utf8_lossy(&bytes));
}
}
assert_eq!(calls.load(Ordering::SeqCst), 8);
}
#[tokio::test]
async fn control_body_digest_keeps_bucket_deletes_guarded_with_multipart_query() {
use tower::ServiceExt;
let calls = Arc::new(AtomicUsize::new(0));
let mut builder = S3ServiceBuilder::new(DigestCheckedS3(calls.clone()));
builder.set_auth(s3s::auth::SimpleAuth::from_single("digest-key", "digest-secret"));
builder.set_config(Arc::new(StaticConfigProvider::new(Arc::new(rustfs_s3_config()))));
builder.set_host(MultiDomain::new(["example.com"]).unwrap());
let service = ControlBodyDigestService::new(builder.build(), Duration::from_secs(2))
.with_virtual_host_domains(vec!["example.com".to_owned()].into());
for virtual_host in [false, true] {
let base = if virtual_host {
"http://bucket.example.com/"
} else {
"http://example.com/bucket"
};
for query in ["delete&uploads", "delete&uploadId=id", "x-id=DeleteObjects&uploads"] {
let uri = format!("{base}?{query}");
digest_error(
service
.clone()
.oneshot(signed_digest_route_request(&uri, DIGEST_DELETE_XML, false, virtual_host))
.await
.unwrap(),
"MissingContentMD5",
)
.await;
let response = service
.clone()
.oneshot(signed_digest_route_request(&uri, DIGEST_DELETE_XML, true, virtual_host))
.await
.unwrap();
let status = response.status();
let bytes = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(status, StatusCode::OK, "{uri}: {}", String::from_utf8_lossy(&bytes));
let mut tampered = signed_digest_route_request(&uri, DIGEST_DELETE_XML, true, virtual_host);
*tampered.body_mut() = s3s::Body::from(DIGEST_DELETE_XML.replace("safe-object", "evil-object"));
digest_error(service.clone().oneshot(tampered).await.unwrap(), "BadDigest").await;
}
}
assert_eq!(calls.load(Ordering::SeqCst), 6);
}
#[tokio::test]
async fn control_body_digest_guards_cname_and_host_header_precedence() {
use tower::ServiceExt;
let calls = Arc::new(AtomicUsize::new(0));
let mut builder = S3ServiceBuilder::new(DigestCheckedS3(calls.clone()));
builder.set_auth(s3s::auth::SimpleAuth::from_single("digest-key", "digest-secret"));
builder.set_config(Arc::new(StaticConfigProvider::new(Arc::new(rustfs_s3_config()))));
builder.set_host(MultiDomain::new(["example.com"]).unwrap());
let service = ControlBodyDigestService::new(builder.build(), Duration::from_secs(2))
.with_virtual_host_domains(vec!["example.com".to_owned()].into());
for different_authority in [false, true] {
for md5 in [false, true] {
// SigV2 canonicalizes a CNAME request as /<whole-host>/.
let mut request = signed_digest_route_request(
"http://alias.example.net/alias.example.net/?delete",
DIGEST_DELETE_XML,
md5,
false,
);
*request.uri_mut() = if different_authority {
"http://example.com/?delete"
} else {
"http://alias.example.net/?delete"
}
.parse()
.unwrap();
let response = service.clone().oneshot(request).await.unwrap();
if md5 {
let status = response.status();
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
} else {
digest_error(response, "MissingContentMD5").await;
}
}
}
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
fn digest_checked_s3(calls: Arc<AtomicUsize>) -> ControlBodyDigestService<S3Service> {
let mut builder = S3ServiceBuilder::new(DigestCheckedS3(calls));
builder.set_auth(s3s::auth::SimpleAuth::from_single("digest-key", "digest-secret"));
builder.set_config(Arc::new(StaticConfigProvider::new(Arc::new(rustfs_s3_config()))));
ControlBodyDigestService::new(builder.build(), Duration::from_secs(2))
}
fn digest_v2_request(presigned: bool, md5: bool, secret: &str) -> HttpRequest<s3s::Body> {
digest_v2_request_at(presigned, md5, secret, "http://localhost/bucket?delete")
}
fn digest_v2_request_at(presigned: bool, md5: bool, secret: &str, uri: &str) -> HttpRequest<s3s::Body> {
use md5::Digest;
let signing_uri = uri.replace("%64elete", "delete");
let mut request = HttpRequest::builder()
.method("POST")
.header("date", chrono::Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string())
.uri(signing_uri)
.header("host", "localhost")
.header("content-type", "application/xml")
.header("content-length", DIGEST_DELETE_XML.len())
.body(s3s::Body::from(DIGEST_DELETE_XML.to_string()))
.unwrap();
if md5 {
request.headers_mut().insert(
"content-md5",
base64_simd::STANDARD
.encode_to_string(md5::Md5::digest(DIGEST_DELETE_XML.as_bytes()))
.parse()
.unwrap(),
);
}
let mut request = if presigned {
let mut request = rustfs_signer::try_pre_sign_v2(request, "digest-key", secret, 60, false).unwrap();
// The signer leaves the final base64 query value unescaped.
let uri = request.uri().to_string();
let (prefix, signature) = uri.rsplit_once("&Signature=").unwrap();
let encoded = url::form_urlencoded::Serializer::new(String::new())
.append_pair("Signature", signature)
.finish();
*request.uri_mut() = format!("{prefix}&{encoded}").parse().unwrap();
request
} else {
rustfs_signer::try_sign_v2(request, DIGEST_DELETE_XML.len() as i64, "digest-key", secret, false).unwrap()
};
if uri.contains("%64elete") {
*request.uri_mut() = request
.uri()
.to_string()
.replace("?delete", "?%64elete")
.replace("&delete", "&%64elete")
.parse()
.unwrap();
}
request
}
async fn digest_error(response: Response<s3s::Body>, code: &str) {
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
assert!(String::from_utf8_lossy(&bytes).contains(&format!("<Code>{code}</Code>")));
}
#[tokio::test]
async fn delete_objects_digest_binds_sigv2_header_and_query_bodies() {
use tower::ServiceExt;
for presigned in [false, true] {
let calls = Arc::new(AtomicUsize::new(0));
let service = digest_checked_s3(calls.clone());
let response = service
.clone()
.oneshot(digest_v2_request(presigned, true, "digest-secret"))
.await
.unwrap();
let status = response.status();
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
assert_eq!(calls.load(Ordering::SeqCst), 1);
let mut tampered = digest_v2_request(presigned, true, "digest-secret");
*tampered.body_mut() = s3s::Body::from(DIGEST_DELETE_XML.replace("safe-object", "evil-object"));
digest_error(service.clone().oneshot(tampered).await.unwrap(), "BadDigest").await;
digest_error(
service
.clone()
.oneshot(digest_v2_request(presigned, false, "digest-secret"))
.await
.unwrap(),
"MissingContentMD5",
)
.await;
let mut checksum_only = digest_v2_request(presigned, false, "digest-secret");
checksum_only
.headers_mut()
.insert("x-amz-checksum-sha256", HeaderValue::from_static("placeholder"));
digest_error(service.clone().oneshot(checksum_only).await.unwrap(), "MissingContentMD5").await;
let mut malformed = digest_v2_request(presigned, true, "digest-secret");
malformed
.headers_mut()
.insert("content-md5", HeaderValue::from_static("invalid"));
digest_error(service.clone().oneshot(malformed).await.unwrap(), "InvalidDigest").await;
let response = service
.oneshot(digest_v2_request(presigned, true, "wrong-secret"))
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::FORBIDDEN,
"digest verification must not replace authentication"
);
assert_eq!(calls.load(Ordering::SeqCst), 1, "only the original authenticated XML may dispatch");
}
}
#[tokio::test]
async fn delete_objects_digest_covers_operation_id_and_encoded_query_routes() {
use tower::ServiceExt;
for uri in [
"http://localhost/bucket?x-id=DeleteObjects",
"http://localhost/bucket?%64elete",
] {
for presigned in [false, true] {
let calls = Arc::new(AtomicUsize::new(0));
let service = digest_checked_s3(calls.clone());
let request = digest_v2_request_at(presigned, true, "digest-secret", uri);
let response = service.clone().oneshot(request).await.unwrap();
let status = response.status();
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(status, StatusCode::OK, "{uri}: {}", String::from_utf8_lossy(&body));
let mut request = digest_v2_request_at(presigned, true, "digest-secret", uri);
*request.body_mut() = s3s::Body::from(DIGEST_DELETE_XML.replace("safe-object", "evil-object"));
digest_error(service.clone().oneshot(request).await.unwrap(), "BadDigest").await;
digest_error(
service
.oneshot(digest_v2_request_at(presigned, false, "digest-secret", uri))
.await
.unwrap(),
"MissingContentMD5",
)
.await;
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
}
}
fn digest_v4_request(checksum: &str) -> HttpRequest<s3s::Body> {
use s3s_sigv4::{AmzDate, Payload, calculate_signature, create_canonical_request, create_string_to_sign};
let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
let date = AmzDate::parse(&timestamp).unwrap();
let canonical = create_canonical_request(
"POST",
"/bucket",
&[("delete", "")],
[
("host", "localhost"),
("x-amz-checksum-sha256", checksum),
("x-amz-content-sha256", "UNSIGNED-PAYLOAD"),
("x-amz-date", timestamp.as_str()),
],
Payload::Unsigned,
);
let to_sign = create_string_to_sign(&canonical, &date, "us-east-1", "s3");
let signature = calculate_signature(&to_sign, "digest-secret", &date, "us-east-1", "s3");
HttpRequest::builder().method("POST").uri("http://localhost/bucket?delete")
.header("host", "localhost").header("content-length", DIGEST_DELETE_XML.len())
.header("x-amz-checksum-sha256", checksum).header("x-amz-content-sha256", "UNSIGNED-PAYLOAD")
.header("x-amz-date", &timestamp)
.header("authorization", format!("AWS4-HMAC-SHA256 Credential=digest-key/{}/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-checksum-sha256;x-amz-content-sha256;x-amz-date, Signature={signature}", date.fmt_date()))
.body(s3s::Body::from(DIGEST_DELETE_XML.to_string())).unwrap()
}
#[tokio::test]
async fn delete_objects_digest_preserves_sigv4_checksum_requests() {
use sha2::Digest;
use tower::ServiceExt;
let checksum = base64_simd::STANDARD.encode_to_string(sha2::Sha256::digest(DIGEST_DELETE_XML.as_bytes()));
let calls = Arc::new(AtomicUsize::new(0));
let service = digest_checked_s3(calls.clone());
let response = service.clone().oneshot(digest_v4_request(&checksum)).await.unwrap();
let status = response.status();
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
let mut tampered = digest_v4_request(&checksum);
*tampered.body_mut() = s3s::Body::from(DIGEST_DELETE_XML.replace("safe-object", "evil-object"));
digest_error(service.oneshot(tampered).await.unwrap(), "BadDigest").await;
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn control_body_digest_bounds_declared_and_undeclared_bodies() {
use tower::ServiceExt;
let calls = Arc::new(AtomicUsize::new(0));
let mut service = digest_checked_s3(calls.clone());
service.max_body_size = DIGEST_DELETE_XML.len();
for declared in [false, true] {
let mut request = digest_v2_request(false, true, "digest-secret");
let (sender, receiver) = mpsc::unbounded_channel();
let polled = Arc::new(AtomicUsize::new(0));
let dropped = Arc::new(AtomicBool::new(false));
sender.send(Bytes::from_static(DIGEST_DELETE_XML.as_bytes())).unwrap();
sender.send(Bytes::from_static(b" ")).unwrap();
if declared {
request
.headers_mut()
.insert(CONTENT_LENGTH, HeaderValue::from(DIGEST_DELETE_XML.len() + 1));
} else {
request.headers_mut().remove(CONTENT_LENGTH);
}
let request = request.map(|_| TrackedRequestBody {
receiver,
bytes_polled: polled.clone(),
dropped: dropped.clone(),
});
digest_error(service.clone().oneshot(request).await.unwrap(), "EntityTooLarge").await;
assert!(dropped.load(Ordering::SeqCst));
assert_eq!(polled.load(Ordering::SeqCst), if declared { 0 } else { DIGEST_DELETE_XML.len() + 1 });
}
let mut request = digest_v2_request(false, true, "digest-secret");
request.headers_mut().remove(CONTENT_LENGTH);
let (sender, receiver) = mpsc::unbounded_channel();
sender.send(Bytes::from_static(DIGEST_DELETE_XML.as_bytes())).unwrap();
drop(sender);
let response = service
.oneshot(request.map(|_| TrackedRequestBody {
receiver,
bytes_polled: Arc::default(),
dropped: Arc::default(),
}))
.await
.unwrap();
let status = response.status();
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(status, StatusCode::LENGTH_REQUIRED, "{}", String::from_utf8_lossy(&body));
assert_eq!(
calls.load(Ordering::SeqCst),
0,
"preserve s3s' missing-length rejection after bounded collection"
);
}
#[tokio::test(start_paused = true)]
async fn control_body_digest_idle_total_deadlines_and_cancellation_release_body() {
use tower::ServiceExt;
for trickle in [false, true] {
let calls = Arc::new(AtomicUsize::new(0));
let mut service = digest_checked_s3(calls.clone());
service.total_timeout = Duration::from_secs(5);
let (sender, receiver) = mpsc::unbounded_channel();
let dropped = Arc::new(AtomicBool::new(false));
let request = digest_v2_request(false, true, "digest-secret").map(|_| TrackedRequestBody {
receiver,
bytes_polled: Arc::default(),
dropped: dropped.clone(),
});
let task = tokio::spawn(service.oneshot(request));
for _ in 0..6 {
if trickle {
let _ = sender.send(Bytes::from_static(b" "));
}
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(1)).await;
}
digest_error(task.await.unwrap().unwrap(), "RequestTimeout").await;
assert!(dropped.load(Ordering::SeqCst));
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
let calls = Arc::new(AtomicUsize::new(0));
let (_sender, receiver) = mpsc::unbounded_channel();
let dropped = Arc::new(AtomicBool::new(false));
let task = tokio::spawn(
digest_checked_s3(calls.clone()).oneshot(digest_v2_request(false, true, "digest-secret").map(|_| {
TrackedRequestBody {
receiver,
bytes_polled: Arc::default(),
dropped: dropped.clone(),
}
})),
);
tokio::task::yield_now().await;
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
assert!(dropped.load(Ordering::SeqCst));
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn control_body_digest_preserves_streaming_uploads_and_checks_config_md5() {
use tower::ServiceExt;
for (method, uri) in [
("PUT", "/bucket/key"),
("PUT", "/bucket/key?partNumber=1&uploadId=id"),
("POST", "/bucket?uploads"),
("POST", "/bucket"),
("POST", "/bucket/key?uploadId=id"),
("POST", "/bucket?delete"),
("PUT", "/bucket/key?x-id=PutObject&tagging"),
] {
let (_sender, receiver) = mpsc::unbounded_channel();
let polled = Arc::new(AtomicUsize::new(0));
let inner_polled = polled.clone();
let inner = tower::service_fn(move |_: HttpRequest<ControlRequestBody>| {
assert_eq!(inner_polled.load(Ordering::SeqCst), 0, "uploads must reach dispatch without buffering");
async { Ok::<_, Infallible>(Response::new(s3s::Body::empty())) }
});
let request = HttpRequest::builder()
.method(method)
.uri(uri)
.header("content-md5", "invalid")
.header("content-type", "multipart/form-data; boundary=upload")
.body(TrackedRequestBody {
receiver,
bytes_polled: polled,
dropped: Arc::default(),
})
.unwrap();
let response = ControlBodyDigestService::new(inner, Duration::from_secs(1))
.oneshot(request)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
let calls = Arc::new(AtomicUsize::new(0));
let inner_calls = calls.clone();
let inner = tower::service_fn(move |_: HttpRequest<ControlRequestBody>| {
inner_calls.fetch_add(1, Ordering::SeqCst);
async { Ok::<_, Infallible>(Response::new(s3s::Body::empty())) }
});
let service = ControlBodyDigestService::new(inner, Duration::from_secs(1));
// SigV2 signs no body: a configuration PUT without Content-MD5 could be
// replayed with any other body, so it is refused.
let mut request = digest_v2_request(false, false, "digest-secret");
*request.method_mut() = Method::PUT;
*request.uri_mut() = "/bucket?versioning".parse().unwrap();
digest_error(service.clone().oneshot(request).await.unwrap(), "MissingContentMD5").await;
// A SigV2 configuration PUT that declares an empty body carries nothing
// to replay (a canned-ACL PUT), so it needs no digest.
let request = HttpRequest::builder()
.method("PUT")
.uri("/bucket?acl")
.header("authorization", "AWS digest-key:c2lnbmF0dXJl")
.header("x-amz-acl", "private")
.header("content-length", "0")
.body(s3s::Body::empty())
.unwrap();
assert_eq!(service.clone().oneshot(request).await.unwrap().status(), StatusCode::OK);
// A SigV4 configuration PUT keeps its optional-MD5 contract (the payload
// hash binds the body) and streams through without buffering.
let request = HttpRequest::builder()
.method("PUT")
.uri("/bucket?versioning")
.body(s3s::Body::from(String::from("<VersioningConfiguration/>")))
.unwrap();
assert_eq!(service.clone().oneshot(request).await.unwrap().status(), StatusCode::OK);
// A supplied MD5 must match on either signature version.
let mut request = digest_v2_request(false, true, "digest-secret");
*request.method_mut() = Method::PUT;
*request.uri_mut() = "/bucket?versioning".parse().unwrap();
assert_eq!(service.clone().oneshot(request).await.unwrap().status(), StatusCode::OK);
let request = HttpRequest::builder()
.method("PUT")
.uri("/bucket?versioning")
.header("content-md5", "1B2M2Y8AsgTpgAmY7PhCfg==")
.body(s3s::Body::from(String::from("<VersioningConfiguration/>")))
.unwrap();
digest_error(service.oneshot(request).await.unwrap(), "BadDigest").await;
// The declared-empty SigV2 PUT, the SigV4 PUT and the matching-MD5 PUT reached the service.
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
type RateLimitTestCore = tower::util::BoxCloneService<
HttpRequest<s3s::Body>,
Response<crate::server::hybrid::HybridBody<s3s::Body, Empty<Bytes>>>,
+195 -5
View File
@@ -90,10 +90,6 @@ const SWIFT_API_PATH_PREFIX: &str = "/v1/";
pub(crate) fn redact_sensitive_uri_query(uri: &http::Uri) -> String {
let path = uri.path();
if !is_object_zip_download_path(path) {
return uri.to_string();
}
let Some(query) = uri.query() else {
return uri.to_string();
};
@@ -101,7 +97,11 @@ pub(crate) fn redact_sensitive_uri_query(uri: &http::Uri) -> String {
let mut redacted_token = false;
let mut serializer = form_urlencoded::Serializer::new(String::new());
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
if key == "token" {
if ["X-Amz-Signature", "X-Amz-Security-Token", "Signature"]
.iter()
.any(|sensitive| key.eq_ignore_ascii_case(sensitive))
|| (key == "token" && is_object_zip_download_path(path))
{
redacted_token = true;
serializer.append_pair(&key, REDACTED_QUERY_VALUE);
} else {
@@ -1045,6 +1045,9 @@ where
let (parts, body) = response.into_parts();
let response = match body {
HybridBody::Rest { rest_body } if !is_xml_compat_body(&rest_body) => {
Response::from_parts(parts, HybridBody::Rest { rest_body })
}
HybridBody::Rest { rest_body } => {
let (rest_body, changed) = fix_s3_error_message_in_xml(rest_body).await.map_err(Into::into)?;
let mut parts = parts;
@@ -1197,6 +1200,9 @@ where
let (parts, body) = response.into_parts();
let response = match body {
HybridBody::Rest { rest_body } if !is_xml_compat_body(&rest_body) => {
Response::from_parts(parts, HybridBody::Rest { rest_body })
}
HybridBody::Rest { rest_body } => {
let (rest_body, converted_status) = convert_iceberg_error_in_xml(
rest_body,
@@ -1371,6 +1377,9 @@ where
let (parts, body) = response.into_parts();
let response = match body {
HybridBody::Rest { rest_body } if !is_xml_compat_body(&rest_body) => {
Response::from_parts(parts, HybridBody::Rest { rest_body })
}
HybridBody::Rest { rest_body } => {
let rest_body = fix_object_attributes_etag_in_xml(rest_body).await.map_err(Into::into)?;
@@ -2010,6 +2019,14 @@ fn is_bodyless_status(status: StatusCode) -> bool {
|| status == StatusCode::NOT_MODIFIED
}
const MAX_XML_COMPAT_BODY_BYTES: u64 = 1024 * 1024;
fn is_xml_compat_body<B: Body>(body: &B) -> bool {
body.size_hint()
.upper()
.is_some_and(|length| length <= MAX_XML_COMPAT_BODY_BYTES)
}
fn is_xml_response(headers: &HeaderMap) -> bool {
let is_xml = headers
.get(http::header::CONTENT_TYPE)
@@ -5483,6 +5500,77 @@ mod tests {
)
}
#[tokio::test]
async fn xml_compat_layers_do_not_poll_large_or_unbounded_bodies() {
struct UnpolledXmlBody(Option<u64>);
impl From<Bytes> for UnpolledXmlBody {
fn from(_: Bytes) -> Self {
panic!("streaming bodies must not be rewritten")
}
}
impl Body for UnpolledXmlBody {
type Data = Bytes;
type Error = Infallible;
fn poll_frame(
self: Pin<&mut Self>,
_: &mut Context<'_>,
) -> Poll<Option<Result<http_body::Frame<Bytes>, Infallible>>> {
panic!("compatibility layer must not consume the object stream")
}
fn size_hint(&self) -> http_body::SizeHint {
let mut hint = http_body::SizeHint::new();
if let Some(length) = self.0 {
hint.set_exact(length);
}
hint
}
}
#[derive(Clone)]
struct StreamingXmlResponse {
length: Option<u64>,
status: StatusCode,
}
impl Service<Request<()>> for StreamingXmlResponse {
type Response = Response<HybridBody<UnpolledXmlBody, Empty<Bytes>>>;
type Error = Infallible;
type Future = Ready<Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, _: Request<()>) -> Self::Future {
ready(Ok(Response::builder()
.status(self.status)
.header(http::header::CONTENT_TYPE, "application/xml")
.body(HybridBody::Rest {
rest_body: UnpolledXmlBody(self.length),
})
.expect("streaming response")))
}
}
for length in [None, Some(MAX_XML_COMPAT_BODY_BYTES + 1)] {
for (layer, path, status) in [
(0, "/bucket/object", StatusCode::FORBIDDEN),
(1, "/iceberg/v1/warehouse/namespaces/ns/tables/table", StatusCode::FORBIDDEN),
(2, "/bucket/object?attributes", StatusCode::OK),
] {
let inner = StreamingXmlResponse { length, status };
let request = Request::builder()
.method(Method::GET)
.uri(path)
.body(())
.expect("XML request");
let response = match layer {
0 => S3ErrorMessageCompatLayer.layer(inner).call(request).await,
1 => IcebergRestErrorCompatLayer.layer(inner).call(request).await,
_ => ObjectAttributesEtagFixLayer.layer(inner).call(request).await,
}
.expect("stream passed through");
assert_eq!(response.status(), status);
assert_eq!(response.headers()[http::header::CONTENT_TYPE], "application/xml");
}
}
}
#[tokio::test]
async fn s3_error_message_compat_fixes_regular_forbidden_xml() {
let body = Bytes::from_static(b"<Error><Code>SignatureDoesNotMatch</Code></Error>");
@@ -6132,6 +6220,78 @@ mod tests {
}
}
#[test]
#[serial_test::serial]
fn conditional_cors_uses_the_host_bucket_with_real_distinct_bucket_configs() {
crate::app::gating_test_env::run_large_stack_test("cors-bucket-authority", || async {
use crate::app::storage_api::bucket::{metadata::BUCKET_CORS_CONFIG, metadata_sys};
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
let suffix = uuid::Uuid::new_v4().simple().to_string();
let owner = format!("cors-owner-{suffix}");
let other = format!("cors-other-{suffix}");
for (bucket, origin) in [(&owner, "https://owner.example"), (&other, "https://other.example")] {
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("CORS bucket");
let xml = format!(
"<CORSConfiguration><CORSRule><AllowedOrigin>{origin}</AllowedOrigin><AllowedMethod>GET</AllowedMethod></CORSRule></CORSConfiguration>"
);
metadata_sys::update(bucket, BUCKET_CORS_CONFIG, xml.into_bytes())
.await
.expect("persist distinct bucket CORS rules");
}
let layer = ConditionalCorsLayer {
cors_origins: Some("*".to_owned()),
vh_domain_hosts: Arc::from(["s3.example".to_owned(), "website.example".to_owned()]),
};
let mut service = layer.layer(CorsOkService);
for (uri, host) in [
(format!("/{other}/q3.csv"), Some(format!("{owner}.s3.example"))),
(format!("/{other}/q3.csv"), Some(format!("{owner}.website.example"))),
(format!("http://{owner}.s3.example/{other}/q3.csv"), None),
("/".to_owned(), Some(format!("{owner}.s3.example"))),
(format!("/{owner}/{other}/q3.csv"), Some("s3.example".to_owned())),
] {
for method in [Method::GET, Method::OPTIONS] {
for (origin, allowed) in [("https://owner.example", true), ("https://other.example", false)] {
let mut request = Request::builder()
.method(method.clone())
.uri(&uri)
.header(cors::standard::ORIGIN, origin);
if let Some(host) = &host {
request = request.header(http::header::HOST, host);
}
if method == Method::OPTIONS {
request = request.header(cors::request::ACCESS_CONTROL_REQUEST_METHOD, "GET");
}
let response = service
.call(request.body(()).expect("CORS request"))
.await
.expect("CORS service");
assert_eq!(
response
.headers()
.get(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN)
.and_then(|value| value.to_str().ok()),
allowed.then_some(origin),
"{method} {uri}: the object owner's CORS rule must be authoritative"
);
assert_eq!(
response.status(),
if method == Method::OPTIONS && !allowed {
StatusCode::FORBIDDEN
} else {
StatusCode::OK
}
);
}
}
}
});
}
#[tokio::test]
async fn conditional_cors_passthrough_without_origin() {
let layer = ConditionalCorsLayer {
@@ -6729,6 +6889,36 @@ mod tests {
assert_eq!(redact_sensitive_uri_query(&uri), "/rustfs/admin/v3/users?token=not-a-download-token");
}
#[test]
fn redact_sensitive_uri_query_removes_presigned_credentials() {
for path in ["/bucket/object", "/prefix/bucket/object", "/"] {
let uri: http::Uri = format!(
"{path}?X-Amz-Signature=secret-signature&x-amz-security-token=secret-session&Signature=secret-v2&partNumber=1"
)
.parse()
.expect("presigned URI");
let logged = redact_sensitive_uri_query(&uri);
assert_eq!(
logged,
format!("{path}?X-Amz-Signature=redacted&x-amz-security-token=redacted&Signature=redacted&partNumber=1")
);
assert!(!logged.contains("secret"));
}
let uri: http::Uri = "/bucket/object?%58-Amz-Signature=secret&X-Amz-Signature=another&signature=v2"
.parse()
.expect("URI");
assert_eq!(
redact_sensitive_uri_query(&uri),
"/bucket/object?X-Amz-Signature=redacted&X-Amz-Signature=redacted&signature=redacted"
);
}
#[test]
fn redact_sensitive_uri_query_preserves_ordinary_query_encoding() {
let uri: http::Uri = "/bucket?prefix=one%20two&delimiter=%2f".parse().expect("URI");
assert_eq!(redact_sensitive_uri_query(&uri), uri.to_string());
}
#[tokio::test(start_paused = true)]
async fn request_logging_watchdog_cancels_dropped_futures() {
for poll_request in [false, true] {
File diff suppressed because it is too large Load Diff
+26 -21
View File
@@ -23,7 +23,9 @@ use super::{
};
use crate::admin::handlers::site_replication::site_replication_bucket_meta_hook;
use crate::error::{ApiError, ApiErrorDiagnostic};
use crate::storage::access::{apply_bucket_generation_guard, bucket_config_mutation_incarnation, has_bypass_governance_header};
use crate::storage::access::{
apply_bucket_generation_guard, bucket_config_mutation_incarnation, has_bypass_governance_header, replication_proxy_allowed,
};
use crate::storage::helper::{OperationHelper, audit_only_operation};
use crate::storage::options::get_opts;
use crate::storage::s3_api::{self, acl};
@@ -722,7 +724,7 @@ impl FS {
#[async_trait::async_trait]
impl S3 for FS {
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
async fn abort_multipart_upload(
&self,
req: S3Request<AbortMultipartUploadInput>,
@@ -775,7 +777,7 @@ impl S3 for FS {
usecase.execute_delete_bucket(req).await
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
async fn delete_bucket_cors(&self, req: S3Request<DeleteBucketCorsInput>) -> S3Result<S3Response<DeleteBucketCorsOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
audit_only_operation(req, S3Operation::DeleteBucketCors, |req| usecase.execute_delete_bucket_cors(req)).await
@@ -792,7 +794,7 @@ impl S3 for FS {
.await
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
async fn delete_bucket_lifecycle(
&self,
req: S3Request<DeleteBucketLifecycleInput>,
@@ -819,7 +821,7 @@ impl S3 for FS {
audit_only_operation(req, S3Operation::DeleteBucketReplication, |req| self.delete_bucket_replication_inner(req)).await
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
async fn delete_bucket_tagging(
&self,
req: S3Request<DeleteBucketTaggingInput>,
@@ -849,7 +851,7 @@ impl S3 for FS {
Ok(S3Response::new(DeleteBucketWebsiteOutput::default()))
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
async fn delete_public_access_block(
&self,
req: S3Request<DeletePublicAccessBlockInput>,
@@ -877,7 +879,7 @@ impl S3 for FS {
result
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
async fn delete_object_tagging(
&self,
req: S3Request<DeleteObjectTaggingInput>,
@@ -916,6 +918,7 @@ impl S3 for FS {
// (backlog#1675 P1-5). No local object exists, so no bucket
// notification event is emitted for the proxied write.
if (is_err_object_not_found(&e) || is_err_version_not_found(&e))
&& replication_proxy_allowed(&req.extensions)
&& Self::proxy_delete_object_tagging(&bucket, &object, version_id.clone(), &req.headers)
.await
.is_some()
@@ -1045,7 +1048,7 @@ impl S3 for FS {
}
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
async fn get_bucket_cors(&self, req: S3Request<GetBucketCorsInput>) -> S3Result<S3Response<GetBucketCorsOutput>> {
record_s3_op(S3Operation::GetBucketCors);
let usecase = s3_api::bucket_usecase_for(self);
@@ -1061,7 +1064,7 @@ impl S3 for FS {
usecase.execute_get_bucket_encryption(req).await
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
async fn get_bucket_lifecycle_configuration(
&self,
req: S3Request<GetBucketLifecycleConfigurationInput>,
@@ -1136,14 +1139,14 @@ impl S3 for FS {
}
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
async fn get_bucket_tagging(&self, req: S3Request<GetBucketTaggingInput>) -> S3Result<S3Response<GetBucketTaggingOutput>> {
record_s3_op(S3Operation::GetBucketTagging);
let usecase = s3_api::bucket_usecase_for(self);
usecase.execute_get_bucket_tagging(req).await
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
async fn get_public_access_block(
&self,
req: S3Request<GetPublicAccessBlockInput>,
@@ -1153,7 +1156,7 @@ impl S3 for FS {
usecase.execute_get_public_access_block(req).await
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
async fn get_bucket_versioning(
&self,
req: S3Request<GetBucketVersioningInput>,
@@ -1296,7 +1299,7 @@ impl S3 for FS {
result
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
async fn get_object_lock_configuration(
&self,
req: S3Request<GetObjectLockConfigurationInput>,
@@ -1388,7 +1391,7 @@ impl S3 for FS {
result
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
async fn get_object_tagging(&self, req: S3Request<GetObjectTaggingInput>) -> S3Result<S3Response<GetObjectTaggingOutput>> {
use crate::storage::storage_api::ecstore_bucket::versioning::VersioningApi as _;
@@ -1436,6 +1439,7 @@ impl S3 for FS {
// replication target even though it is missing locally —
// proxy the tagging read there (backlog#1675 P1-5).
if (is_err_object_not_found(&e) || is_err_version_not_found(&e))
&& replication_proxy_allowed(&req.extensions)
&& let Some(output) =
Self::proxy_get_object_tagging(bucket, object, req.input.version_id.clone(), &req.headers).await
{
@@ -1516,7 +1520,7 @@ impl S3 for FS {
usecase.execute_head_object(req).await
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
async fn list_buckets(&self, req: S3Request<ListBucketsInput>) -> S3Result<S3Response<ListBucketsOutput>> {
// List buckets not associated with a bucket, give it bucket label "*" to denote "all".
record_s3_op(S3Operation::ListBuckets);
@@ -1595,7 +1599,7 @@ impl S3 for FS {
Ok(S3Response::new(PutBucketAccelerateConfigurationOutput::default()))
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
async fn put_bucket_cors(&self, req: S3Request<PutBucketCorsInput>) -> S3Result<S3Response<PutBucketCorsOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
audit_only_operation(req, S3Operation::PutBucketCors, |req| usecase.execute_put_bucket_cors(req)).await
@@ -1632,7 +1636,7 @@ impl S3 for FS {
audit_only_operation(req, S3Operation::PutBucketEncryption, |req| usecase.execute_put_bucket_encryption(req)).await
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
async fn put_bucket_lifecycle_configuration(
&self,
req: S3Request<PutBucketLifecycleConfigurationInput>,
@@ -1694,7 +1698,7 @@ impl S3 for FS {
Ok(S3Response::new(PutBucketRequestPaymentOutput::default()))
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
async fn put_public_access_block(
&self,
req: S3Request<PutPublicAccessBlockInput>,
@@ -1703,13 +1707,13 @@ impl S3 for FS {
audit_only_operation(req, S3Operation::PutPublicAccessBlock, |req| usecase.execute_put_public_access_block(req)).await
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
async fn put_bucket_tagging(&self, req: S3Request<PutBucketTaggingInput>) -> S3Result<S3Response<PutBucketTaggingOutput>> {
let usecase = s3_api::bucket_usecase_for(self);
audit_only_operation(req, S3Operation::PutBucketTagging, |req| usecase.execute_put_bucket_tagging(req)).await
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
async fn put_bucket_versioning(
&self,
req: S3Request<PutBucketVersioningInput>,
@@ -1885,7 +1889,7 @@ impl S3 for FS {
result
}
#[instrument(level = "debug", skip(self))]
#[instrument(level = "debug", skip(self, req))]
async fn put_object_lock_configuration(
&self,
req: S3Request<PutObjectLockConfigurationInput>,
@@ -2052,6 +2056,7 @@ impl S3 for FS {
// (backlog#1675 P1-5). No local object exists, so no bucket
// notification event is emitted for the proxied write.
if (is_err_object_not_found(&e) || is_err_version_not_found(&e))
&& replication_proxy_allowed(&req.extensions)
&& Self::proxy_put_object_tagging(&bucket, &object, version_id.clone(), &req.headers, &tagging.tag_set)
.await
.is_some()
+60 -3
View File
@@ -322,11 +322,36 @@ pub(crate) fn get_buffer_size_opt_in(file_size: i64) -> usize {
buffer_size
}
/// Validate object key for control characters and log special characters
/// Reject XML-incompatible and storage-sidecar keys at new object boundaries.
pub(crate) fn validate_new_object_key(key: &str, operation: &str) -> S3Result<()> {
validate_object_key(key, operation)?;
// These persistent sidecar filenames share directories with client objects.
// Case-insensitive checks also protect case-insensitive disk deployments.
if key.split('/').any(|segment| {
["xl.meta", "xl.meta.bkp", "delete-prepared", "delete-quorum-committed"]
.iter()
.any(|reserved| segment.eq_ignore_ascii_case(reserved))
}) {
return Err(S3Error::with_message(
S3ErrorCode::InvalidArgument,
"Object key contains a reserved storage filename",
));
}
if crate::storage::storage_api::ecstore_bucket::utils::has_xml_invalid_control_characters(key) {
return Err(S3Error::with_message(
S3ErrorCode::InvalidArgument,
"Object key contains characters unsupported by XML listings",
));
}
Ok(())
}
/// Validate client object keys and log special characters
///
/// This function:
/// 1. Rejects keys containing control characters (null bytes, newlines, carriage returns)
/// 2. Logs debug information for keys containing spaces, plus signs, or percent signs
/// 2. Rejects reserved directory markers and invalid path segments
/// 3. Logs debug information for keys containing spaces, plus signs, or percent signs
///
/// The s3s library handles URL decoding, so keys are already decoded when they reach this function.
/// This validation ensures that invalid characters that could cause issues are rejected early.
@@ -1155,7 +1180,39 @@ pub(crate) fn parse_part_number_i32_to_usize(part_number: Option<i32>, op: &'sta
#[cfg(test)]
mod object_key_admission_tests {
use super::validate_object_key;
use super::{validate_new_object_key, validate_object_key};
#[test]
fn storage_sidecar_keys_are_rejected_only_for_new_writes() {
for reserved in ["xl.meta", "xl.meta.bkp", "delete-prepared", "delete-quorum-committed"] {
for key in [
reserved.to_string(),
format!("parent/uuid/{reserved}"),
format!("parent/{reserved}/child"),
reserved.to_uppercase(),
] {
for operation in ["PutObject", "COPY (dest)", "CreateMultipartUpload", "CompleteMultipartUpload"] {
assert!(validate_new_object_key(&key, operation).is_err(), "{operation}: {key}");
}
assert!(validate_object_key(&key, "GetObject").is_ok(), "legacy read: {key}");
assert!(validate_object_key(&key, "DeleteObject").is_ok(), "legacy cleanup: {key}");
}
assert!(validate_new_object_key(&format!("ordinary/{reserved}-report"), "PutObject").is_ok());
}
}
#[test]
fn xml_invalid_keys_are_rejected_only_for_new_writes() {
for byte in (1u8..32).filter(|byte| !matches!(byte, 9 | 10 | 13)) {
let key = format!("legacy-{}", char::from(byte));
assert!(validate_new_object_key(&key, "PutObject").is_err());
assert!(validate_object_key(&key, "GetObject").is_ok());
assert!(validate_object_key(&key, "DeleteObject").is_ok());
}
for key in ["ordinary/key", "tab\tkey", "unicode-中文", "directory/"] {
assert!(validate_new_object_key(key, "PutObject").is_ok());
}
}
#[test]
fn reserved_directory_marker_keys_are_rejected() {
+20
View File
@@ -14,6 +14,26 @@
#[cfg(test)]
mod tests {
#[test]
fn object_key_rejects_reserved_directory_markers() {
for key in [
"__XLDIR__",
"prefix/__XLDIR__",
"stash__XLDIR__/blob",
"file__XLDIR__",
"file__XLDIR__/",
"a__XLDIR__b",
] {
let error = crate::storage::ecfs_extend::validate_object_key(key, "PUT")
.expect_err("client keys must not alias internal directory markers");
assert_eq!(error.code(), &s3s::S3ErrorCode::InvalidArgument, "{key}");
}
for key in ["file", "folder/", "prefix/folder/", "__xldir__", "percent%20name"] {
crate::storage::ecfs_extend::validate_object_key(key, "PUT")
.expect("ordinary objects and directory objects remain valid");
}
}
use crate::config::WorkloadProfile;
use crate::server::cors;
use crate::storage::StorageError;
+393 -1
View File
@@ -26,6 +26,7 @@ use rustfs_audit::{
entity::{ApiDetailsBuilder, AuditEntryBuilder},
global::AuditLogger,
};
use rustfs_iam::sys::{OIDC_ISSUER_CLAIM, is_rustfs_oidc_claims};
use rustfs_io_metrics::record_s3_op;
use rustfs_notify::EventArgsBuilder;
use rustfs_policy::policy::action::Action;
@@ -94,6 +95,31 @@ where
}
}
/// Own a parsed mutation through its storage and event/audit completion.
/// Dropping the HTTP waiter does not cancel the operation. Streaming bodies
/// must use their bounded upload owner instead of this entry point.
pub(crate) async fn run_owned_mutation<T, F>(operation: F) -> S3Result<S3Response<T>>
where
T: Send + 'static,
F: Future<Output = S3Result<S3Response<T>>> + Send + 'static,
{
let credentials = rustfs_keystone::KEYSTONE_CREDENTIALS.try_with(Clone::clone).ok().flatten();
let operation = rustfs_keystone::KEYSTONE_CREDENTIALS.scope(credentials, operation);
#[cfg(feature = "diagnostic-read-version-caller")]
let operation = ecstore_diagnostic::with_captured_read_version_context(
ecstore_diagnostic::ReadVersionContextBoundary::BackgroundTask,
operation,
);
#[cfg(not(feature = "diagnostic-read-version-caller"))]
let operation = rustfs_io_metrics::s3_http_metrics::with_s3_http_operation_context(
rustfs_io_metrics::s3_http_metrics::current_s3_http_operation_context(),
operation,
);
super::request_context::spawn_traced_join(operation)
.await
.map_err(|_| s3s::s3_error!(InternalError, "object mutation owner task failed"))?
}
/// Builds S3-compatible notification response elements with the canonical request ID.
pub(crate) fn build_event_resp_elements<T>(response: &S3Response<T>, request_id: &str) -> HashMap<String, String> {
let mut resp_elements = extract_resp_elements(response);
@@ -133,10 +159,103 @@ fn request_audit_builder<T>(
.filter(|cred| !cred.access_key.is_empty())
{
builder = builder.access_key(&cred.access_key);
if !cred.parent_user.is_empty() {
builder = builder.parent_user(&cred.parent_user);
}
if let Some(claims) = cred.claims.as_ref().filter(|claims| is_rustfs_oidc_claims(claims)) {
let identity_claims = ["sub", "iss", OIDC_ISSUER_CLAIM, "oidc_provider"]
.into_iter()
.filter_map(|key| {
claims
.get(key)
.filter(|value| value.as_str().is_some_and(|value| !value.is_empty()))
.map(|value| (key.to_string(), value.clone()))
})
.collect();
builder = builder.req_claims(identity_claims);
}
}
builder
}
#[cfg(test)]
type MutationCompletionPause = (std::sync::Arc<tokio::sync::Barrier>, std::sync::Arc<tokio::sync::Barrier>);
#[cfg(test)]
type MutationCompletionProbes = HashMap<String, std::sync::Weak<MutationCompletionProbe>>;
#[cfg(test)]
#[derive(Default)]
pub(crate) struct MutationCompletionProbe {
pub(crate) audits: std::sync::Mutex<Vec<AuditEntry>>,
pub(crate) events: std::sync::Mutex<Vec<rustfs_notify::EventArgs>>,
changed: tokio::sync::Notify,
pause: std::sync::Mutex<Option<MutationCompletionPause>>,
}
#[cfg(test)]
fn completion_probes() -> &'static std::sync::Mutex<MutationCompletionProbes> {
static PROBES: std::sync::OnceLock<std::sync::Mutex<MutationCompletionProbes>> = std::sync::OnceLock::new();
PROBES.get_or_init(Default::default)
}
#[cfg(test)]
fn completion_probe(bucket: &str) -> Option<std::sync::Arc<MutationCompletionProbe>> {
completion_probes()
.lock()
.expect("completion probes")
.get(bucket)
.and_then(std::sync::Weak::upgrade)
}
#[cfg(test)]
impl MutationCompletionProbe {
pub(crate) fn install(bucket: &str) -> std::sync::Arc<Self> {
let probe = std::sync::Arc::new(Self::default());
let mut probes = completion_probes().lock().expect("completion probes");
probes.retain(|_, value| value.strong_count() > 0);
probes.insert(bucket.to_owned(), std::sync::Arc::downgrade(&probe));
probe
}
pub(crate) fn pause_after_commit(&self) -> MutationCompletionPause {
let entered = std::sync::Arc::new(tokio::sync::Barrier::new(2));
let resume = std::sync::Arc::new(tokio::sync::Barrier::new(2));
*self.pause.lock().expect("completion pause") = Some((entered.clone(), resume.clone()));
(entered, resume)
}
pub(crate) async fn wait_for(&self, audits: usize, events: usize) {
tokio::time::timeout(std::time::Duration::from_secs(10), async {
loop {
let changed = self.changed.notified();
if self.audits.lock().expect("audits").len() >= audits && self.events.lock().expect("events").len() >= events {
break;
}
changed.await;
}
})
.await
.expect("committed mutation must dispatch its audit and events");
}
}
#[cfg(test)]
pub(crate) async fn pause_committed_mutation_for_test(bucket: &str) {
let pause = completion_probe(bucket).and_then(|probe| probe.pause.lock().expect("completion pause").take());
if let Some((entered, resume)) = pause {
entered.wait().await;
resume.wait().await;
}
}
#[cfg(test)]
pub(crate) fn capture_mutation_event_for_test(event: &rustfs_notify::EventArgs) {
if let Some(probe) = completion_probe(&event.bucket_name) {
probe.events.lock().expect("events").push(event.clone());
probe.changed.notify_one();
}
}
/// A unified helper structure for building and distributing audit logs and event notifications via RAII mode at the end of an S3 operation scope.
pub enum OperationHelper {
Disabled,
@@ -191,8 +310,20 @@ impl OperationHelper {
);
}
#[cfg(test)]
let probe_enabled = completion_probe(
req.extensions
.get::<ReqInfo>()
.and_then(|info| info.bucket.as_deref())
.unwrap_or_else(|| req.uri.path().trim_start_matches('/').split('/').next().unwrap_or_default()),
)
.is_some();
let audit_enabled = is_audit_module_enabled();
let notify_enabled = !audit_only && should_build_notification_event(is_notify_module_enabled());
#[cfg(test)]
let audit_enabled = audit_enabled || probe_enabled;
#[cfg(test)]
let notify_enabled = notify_enabled || (!audit_only && probe_enabled);
if audit_only && !audit_enabled {
// Nothing to record and no metric to bump: skip the path parsing below.
return Self::Disabled;
@@ -547,13 +678,39 @@ impl Drop for OperationHelper {
return;
};
// An early error or panic must have an outcome, but is never proof
// of a successful mutation. Committed owners complete explicitly.
if state.api_builder.0.status.is_none() {
let elapsed = state.start_time.elapsed();
state.api_builder = state
.api_builder
.clone()
.status("failure".to_string())
.status_code(StatusCode::INTERNAL_SERVER_ERROR.as_u16() as i32)
.time_to_response(format!("{elapsed:.2?}"))
.time_to_response_in_ns(elapsed.as_nanos().to_string());
if let Some(builder) = state.audit_builder.take() {
state.audit_builder = Some(
builder
.api(state.api_builder.clone().build())
.error("Operation ended before reporting its outcome".to_string()),
);
}
}
// Distribute audit logs
if state.audit_enabled
&& let Some(builder) = state.audit_builder.take()
{
let entry = builder.build();
#[cfg(test)]
if let Some(probe) = entry.api.bucket.as_deref().and_then(completion_probe) {
probe.audits.lock().expect("audits").push(entry.clone());
probe.changed.notify_one();
}
let ctx = Some(state.request_context.clone());
spawn_background_with_context(ctx, async move {
AuditLogger::log(builder.build()).await;
AuditLogger::log(entry).await;
});
}
@@ -565,6 +722,8 @@ impl Drop for OperationHelper {
let event_args = builder.build();
// Avoid generating notifications for copy requests
if !event_args.is_replication_request() {
#[cfg(test)]
capture_mutation_event_for_test(&event_args);
let ctx = Some(state.request_context.clone());
spawn_background_with_context(ctx, async move {
runtime_sources::current_notify_interface().notify(event_args).await;
@@ -646,6 +805,48 @@ mod tests {
assert_eq!(rx.await.expect("unscoped task completes"), (None, None));
}
#[tokio::test]
async fn owned_mutation_keeps_keystone_context_and_audits_panics_as_failure() {
let bucket = "owned-mutation-panic";
let probe = super::MutationCompletionProbe::install(bucket);
let credentials = Credentials {
access_key: "keystone:test".to_owned(),
..Default::default()
};
let result: S3Result<S3Response<()>> = rustfs_keystone::KEYSTONE_CREDENTIALS
.scope(Some(credentials), async {
super::run_owned_mutation(async {
assert_eq!(
rustfs_keystone::KEYSTONE_CREDENTIALS
.with(|credentials| credentials.as_ref().map(|value| value.access_key.clone())),
Some("keystone:test".to_owned())
);
let req = S3Request {
input: (),
method: Method::DELETE,
uri: Uri::from_static("/owned-mutation-panic/object"),
headers: HeaderMap::new(),
extensions: Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
let _helper = OperationHelper::new(&req, EventName::ObjectRemovedDelete, S3Operation::DeleteObject);
panic!("injected mutation panic before reporting a commit");
})
.await
})
.await;
assert_eq!(result.expect_err("join failure").code(), &S3ErrorCode::InternalError);
probe.wait_for(1, 0).await;
let audits = probe.audits.lock().expect("audits");
assert_eq!(audits.len(), 1);
assert_eq!(audits[0].api.status.as_deref(), Some("failure"));
assert_eq!(audits[0].api.status_code, Some(500));
assert!(probe.events.lock().expect("events").is_empty());
}
#[cfg(feature = "diagnostic-read-version-caller")]
#[test]
fn diagnostic_background_tasks_keep_existing_entry_on_fallback_thread() {
@@ -1037,6 +1238,17 @@ mod tests {
secret_key: "audit-secret-must-not-leak".to_string(),
session_token: "audit-token-must-not-leak".to_string(),
parent_user: parent_user.to_string(),
claims: match key {
"service-account" => Some(std::collections::HashMap::from([(
rustfs_credentials::IAM_POLICY_CLAIM_NAME_SA.to_string(),
serde_json::json!(rustfs_credentials::INHERITED_POLICY_TYPE),
)])),
"sts-session" => Some(std::collections::HashMap::from([(
"sessionPolicy".to_string(),
serde_json::json!("private-session-policy"),
)])),
_ => None,
},
..Default::default()
}),
..Default::default()
@@ -1057,15 +1269,195 @@ mod tests {
assert_eq!(entry.access_key.as_deref(), expected_access_key);
let json = serde_json::to_value(&entry).expect("audit entry should serialize");
assert_eq!(json.get("accessKey").and_then(|key| key.as_str()), expected_access_key);
let expected_parent_user = (!parent_user.is_empty()).then_some(parent_user);
assert_eq!(entry.parent_user.as_deref(), expected_parent_user);
assert_eq!(json.get("parentUser").and_then(|value| value.as_str()), expected_parent_user);
assert!(!json.as_object().unwrap().contains_key("requestClaims"));
let rendered = serde_json::to_string(&entry).expect("audit entry should serialize");
assert!(!rendered.contains("audit-secret-must-not-leak"));
assert!(!rendered.contains("audit-token-must-not-leak"));
assert!(!rendered.contains("private-session-policy"));
}
}
},
);
}
fn oidc_audit_credentials(issuer: Option<&str>) -> Credentials {
let mut claims = std::collections::HashMap::from([
("sub".to_string(), serde_json::json!(" subject-9170 ")),
("iss".to_string(), serde_json::json!("rustfs-oidc")),
("oidc_provider".to_string(), serde_json::json!("default")),
("email".to_string(), serde_json::json!("private@example.test")),
("preferred_username".to_string(), serde_json::json!("private-user")),
("groups".to_string(), serde_json::json!(["private-group"])),
("roles".to_string(), serde_json::json!(["private-role"])),
("policy".to_string(), serde_json::json!("private-policy")),
("sessionPolicy".to_string(), serde_json::json!("private-encoded-policy")),
("sessionPolicy-extracted".to_string(), serde_json::json!("private-decoded-policy")),
("custom".to_string(), serde_json::json!({"token": "private-custom-token"})),
]);
if let Some(issuer) = issuer {
claims.insert(rustfs_iam::sys::OIDC_ISSUER_CLAIM.to_string(), serde_json::json!(issuer));
}
Credentials {
access_key: "oidc-session".to_string(),
parent_user: "oidc-parent".to_string(),
secret_key: "audit-secret-must-not-leak".to_string(),
session_token: "audit-token-must-not-leak".to_string(),
claims: Some(claims),
..Default::default()
}
}
#[test]
fn operation_helper_preserves_oidc_audit_identity_without_sensitive_claims() {
with_vars(
[
(rustfs_config::ENV_NOTIFY_ENABLE, Some("false")),
(rustfs_config::ENV_AUDIT_ENABLE, Some("true")),
],
|| {
refresh_notify_module_enabled();
refresh_audit_module_enabled();
for issuer in [Some("https://idp-a.example.test"), Some("https://idp-b.example.test"), None] {
for service_account in [false, true] {
let mut credentials = oidc_audit_credentials(issuer);
if service_account {
credentials.access_key = "oidc-service-account".to_string();
credentials.claims.as_mut().unwrap().insert(
rustfs_credentials::IAM_POLICY_CLAIM_NAME_SA.to_string(),
serde_json::json!(rustfs_credentials::INHERITED_POLICY_TYPE),
);
}
let before = serde_json::to_value(&credentials).expect("credentials should serialize");
let mut expected_claims = serde_json::json!({
"sub": " subject-9170 ",
"iss": "rustfs-oidc",
"oidc_provider": "default"
});
if let Some(issuer) = issuer {
expected_claims["x-rustfs-internal-oidc-issuer"] = serde_json::json!(issuer);
}
for outcome in [None, Some(true), Some(false)] {
let mut req = build_request((), Method::GET, Uri::from_static("/audit-bucket/object.txt"));
req.extensions.insert(ReqInfo {
cred: Some(credentials.clone()),
..Default::default()
});
let mut helper = OperationHelper::new(&req, EventName::ObjectAccessedGet, S3Operation::GetObject);
if let Some(success) = outcome {
let result = if success {
Ok(S3Response::new(GetObjectOutput::default()))
} else {
Err(S3Error::new(S3ErrorCode::NoSuchKey))
};
helper = helper.complete(&result);
}
let OperationHelper::Enabled(state) = &mut helper else {
panic!("audit helper should be enabled");
};
let entry = state.audit_builder.take().expect("audit builder should exist").build();
let json = serde_json::to_value(&entry).expect("audit entry should serialize");
assert_eq!(json["accessKey"], credentials.access_key);
assert_eq!(json["parentUser"], "oidc-parent");
assert_eq!(json["requestClaims"], expected_claims);
let rendered = serde_json::to_string(&entry).expect("audit entry should serialize");
assert!(!rendered.contains("private-"));
assert!(!rendered.contains("private@example.test"));
assert!(!rendered.contains("audit-secret-must-not-leak"));
assert!(!rendered.contains("audit-token-must-not-leak"));
assert_eq!(
serde_json::to_value(req.extensions.get::<ReqInfo>().unwrap().cred.as_ref().unwrap()).unwrap(),
before
);
}
}
}
},
);
}
#[test]
fn authorization_denial_preserves_oidc_audit_identity() {
use rustfs_policy::policy::action::{Action, S3Action};
let credentials = oidc_audit_credentials(Some("https://idp.example.test"));
let before = serde_json::to_value(&credentials).unwrap();
let mut req = build_request((), Method::GET, Uri::from_static("/audit-bucket/object.txt"));
req.extensions.insert(ReqInfo {
cred: Some(credentials),
..Default::default()
});
let entry = super::authorization_denial_entry(
&req,
&Action::S3Action(S3Action::GetObjectAction),
"iam_explicit_deny",
super::DenialScope::Request,
);
let json = serde_json::to_value(entry).expect("denial should serialize");
assert_eq!(json["parentUser"], "oidc-parent");
assert_eq!(
json["requestClaims"],
serde_json::json!({
"sub": " subject-9170 ",
"iss": "rustfs-oidc",
"oidc_provider": "default",
"x-rustfs-internal-oidc-issuer": "https://idp.example.test"
})
);
assert_eq!(json["api"]["status_code"], 403);
assert_eq!(json["tags"]["denialReason"], "iam_explicit_deny");
let rendered = json.to_string();
assert!(!rendered.contains("private"));
assert!(!rendered.contains("audit-secret-must-not-leak"));
assert!(!rendered.contains("audit-token-must-not-leak"));
assert_eq!(
serde_json::to_value(req.extensions.get::<ReqInfo>().unwrap().cred.as_ref().unwrap()).unwrap(),
before
);
}
#[test]
fn request_audit_identity_omits_non_oidc_and_empty_claims() {
for (key, value, expected_oidc) in [
(rustfs_iam::sys::OIDC_ISSUER_CLAIM, serde_json::json!(""), true),
(rustfs_iam::sys::OIDC_ISSUER_CLAIM, serde_json::json!(["https://idp.example.test"]), true),
("iss", serde_json::json!("other-session"), false),
("sub", serde_json::json!(""), false),
("oidc_provider", serde_json::Value::Null, false),
] {
let mut credentials = oidc_audit_credentials(None);
credentials.claims.as_mut().unwrap().insert(key.to_string(), value);
let mut req = build_request((), Method::GET, Uri::from_static("/audit-bucket/object.txt"));
req.extensions.insert(ReqInfo {
cred: Some(credentials),
..Default::default()
});
let entry = super::request_audit_builder(
&req,
req.extensions.get::<ReqInfo>(),
EventName::ObjectAccessedGet,
"GetObject",
rustfs_audit::ApiDetails::default(),
"audit-identity-boundary",
)
.build();
let json = serde_json::to_value(entry).expect("audit entry should serialize");
assert_eq!(json["parentUser"], "oidc-parent");
if expected_oidc {
assert_eq!(
json["requestClaims"],
serde_json::json!({"sub": " subject-9170 ", "iss": "rustfs-oidc", "oidc_provider": "default"})
);
} else {
assert!(!json.as_object().unwrap().contains_key("requestClaims"));
}
}
}
#[test]
fn operation_helper_audits_verified_client_addresses() {
use crate::shared_types::RemoteAddr;
+183 -24
View File
@@ -360,6 +360,21 @@ pub async fn put_opts(
put_opts_with_replication_authorization(bucket, object, vid, headers, metadata, false).await
}
/// Resolve the version addressed by a write before evaluating version-scoped policy.
/// Query parameters take precedence over compatible replication headers.
pub(crate) fn destination_version_id(vid: Option<&str>, headers: &HeaderMap<HeaderValue>) -> Option<String> {
let header = get_header(headers, SUFFIX_SOURCE_VERSION_ID);
let value = vid.or(header.as_deref())?.trim();
if value.eq_ignore_ascii_case("null") {
return Some("null".to_owned());
}
Some(match Uuid::parse_str(value) {
Ok(id) if id.is_nil() => "null".to_owned(),
Ok(id) => id.to_string(),
Err(_) => value.to_owned(),
})
}
pub async fn put_opts_with_replication_authorization(
bucket: &str,
object: &str,
@@ -379,8 +394,8 @@ pub async fn put_opts_with_replication_authorization(
let versioned = versioning_cfg.prefix_enabled(object);
let version_suspended = versioning_cfg.prefix_suspended(object);
let vid = if vid.is_none() && replication_request_authorized {
get_header(headers, SUFFIX_SOURCE_VERSION_ID).map(|s| s.into_owned())
let vid = if replication_request_authorized {
destination_version_id(vid.as_deref(), headers)
} else {
vid
};
@@ -439,7 +454,7 @@ pub fn get_complete_multipart_upload_opts_with_replication_authorization(
if replication_request_authorized && get_header(headers, SUFFIX_SOURCE_REPLICATION_REQUEST).as_deref() == Some("true") {
replication_request = true;
mod_time = replication_source_mtime(headers);
preserve_etag = replication_source_etag(headers);
preserve_etag = replication_source_etag(headers)?;
if let Some(actual_size_str) = get_header(headers, SUFFIX_REPLICATION_ACTUAL_OBJECT_SIZE) {
rustfs_utils::http::insert_str(
&mut user_defined,
@@ -518,25 +533,47 @@ pub fn put_opts_from_headers_with_replication_authorization(
if replication_request_authorized && get_header(headers, SUFFIX_SOURCE_REPLICATION_REQUEST).as_deref() == Some("true") {
opts.replication_request = true;
opts.mod_time = replication_source_mtime(headers);
opts.preserve_etag = replication_source_etag(headers);
opts.preserve_etag = replication_source_etag(headers)?;
// SSE-C ciphertext passthrough: restore the stored encryption metadata
// from the transport headers and mark the body as already encrypted so
// the write path stores it verbatim.
if let Some(restored) = rustfs_utils::http::ssec_transport_to_stored_metadata(headers) {
if restored
.get(rustfs_utils::http::object_encryption_keys::SSEC_ORIGINAL_SIZE_HEADER)
.is_some_and(|value| !value.parse::<i64>().is_ok_and(|size| size >= 0))
{
return Err(StorageError::InvalidArgument(
String::new(),
String::new(),
"Invalid replicated SSE-C original size".to_string(),
));
}
opts.user_defined.extend(restored);
opts.preserve_ciphertext = true;
// A compressed passthrough object restores its compression
// layout as well, so the replica decompresses after decrypting
// (backlog#2363). The value is validated when the object is read.
// (backlog#2363). Reject unreadable layouts before persisting them.
if let Some(scheme) = get_header(headers, rustfs_utils::http::SUFFIX_REPLICATION_COMPRESSION) {
super::storage_api::options_consumer::validate_compression_scheme(&scheme).map_err(|_| {
StorageError::InvalidArgument(
String::new(),
String::new(),
"Unsupported replicated compression scheme".to_string(),
)
})?;
rustfs_utils::http::insert_str(
&mut opts.user_defined,
rustfs_utils::http::SUFFIX_COMPRESSION,
scheme.into_owned(),
);
if let Some(actual_size) = get_header(headers, rustfs_utils::http::SUFFIX_REPLICATION_COMPRESSION_ACTUAL_SIZE)
&& actual_size.parse::<i64>().is_ok_and(|size| size >= 0)
{
if let Some(actual_size) = get_header(headers, rustfs_utils::http::SUFFIX_REPLICATION_COMPRESSION_ACTUAL_SIZE) {
if !actual_size.parse::<i64>().is_ok_and(|size| size >= 0) {
return Err(StorageError::InvalidArgument(
String::new(),
String::new(),
"Invalid replicated compression size".to_string(),
));
}
rustfs_utils::http::insert_str(
&mut opts.user_defined,
rustfs_utils::http::SUFFIX_ACTUAL_SIZE,
@@ -556,17 +593,30 @@ pub fn put_opts_from_headers_with_replication_authorization(
/// Replicas must keep the source object's ETag: managed-SSE replication
/// re-encrypts on the target, so a recomputed ETag would differ from the
/// source and every HEAD comparison would re-schedule the object forever.
fn replication_source_etag(headers: &HeaderMap<HeaderValue>) -> Option<String> {
let value = get_header(headers, SUFFIX_SOURCE_ETAG)?;
let value = value.trim().trim_matches('"');
(!value.is_empty()).then(|| value.to_string())
fn replication_source_etag(headers: &HeaderMap<HeaderValue>) -> std::io::Result<Option<String>> {
let Some(value) = get_header(headers, SUFFIX_SOURCE_ETAG) else {
return Ok(None);
};
let value = value.trim();
let value = value
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
.unwrap_or(value);
if value
.bytes()
.any(|byte| !byte.is_ascii_graphic() || matches!(byte, b'"' | b'<' | b'>' | b'&'))
{
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid replication source ETag"));
}
Ok((!value.is_empty()).then(|| value.to_string()))
}
fn replication_source_mtime(headers: &HeaderMap<HeaderValue>) -> Option<time::OffsetDateTime> {
let value = get_header(headers, SUFFIX_SOURCE_MTIME)?;
let value = value.trim();
match time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339) {
Ok(mtime) => Some(mtime),
Ok(mtime) if mtime.unix_timestamp_nanos() != 0 && i64::try_from(mtime.unix_timestamp_nanos()).is_ok() => Some(mtime),
Ok(_) => None,
Err(err) => {
tracing::warn!("Invalid source-mtime value '{}' (replication request=true): {}", value, err);
None
@@ -1209,6 +1259,45 @@ mod tests {
use std::collections::HashMap;
use uuid::Uuid;
#[test]
fn replication_etags_reject_xml_and_header_delimiters_before_persistence() {
for etag in ["</ETag><Contents><Key>victim</Key></Contents><ETag>", "a&b", "a\"b", "a\tb"] {
let mut headers = HeaderMap::new();
insert_header(&mut headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true");
insert_header(&mut headers, SUFFIX_SOURCE_ETAG, etag);
assert!(
put_opts_from_headers_with_replication_authorization(&headers, HashMap::new(), true).is_err(),
"{etag:?}"
);
assert!(
get_complete_multipart_upload_opts_with_replication_authorization(&headers, true).is_err(),
"{etag:?}"
);
assert!(
put_opts_from_headers_with_replication_authorization(&headers, HashMap::new(), false)
.expect("untrusted header is inert")
.preserve_etag
.is_none()
);
}
for etag in [
"0123456789abcdef0123456789abcdef",
"\"0123456789abcdef0123456789abcdef-3\"",
"opaque-provider-etag",
] {
let mut headers = HeaderMap::new();
insert_header(&mut headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true");
insert_header(&mut headers, SUFFIX_SOURCE_ETAG, etag);
assert_eq!(
put_opts_from_headers_with_replication_authorization(&headers, HashMap::new(), true)
.expect("valid replica ETag")
.preserve_etag
.as_deref(),
Some(etag.trim_matches('"'))
);
}
}
fn create_test_headers() -> HeaderMap<HeaderValue> {
let mut headers = HeaderMap::new();
headers.insert("content-type", HeaderValue::from_static("application/json"));
@@ -1564,6 +1653,25 @@ mod tests {
}
}
#[test]
fn destination_version_resolution_preserves_query_precedence_and_normalizes_aliases() {
for header in ["x-rustfs-source-version-id", "x-minio-source-version-id"] {
let mut headers = HeaderMap::new();
headers.insert(header, HeaderValue::from_static(" ABCDEF12-1234-1234-1234-123456789ABC "));
assert_eq!(
super::destination_version_id(None, &headers).as_deref(),
Some("abcdef12-1234-1234-1234-123456789abc")
);
for query in ["null", "NULL", " null ", "00000000-0000-0000-0000-000000000000"] {
assert_eq!(super::destination_version_id(Some(query), &headers).as_deref(), Some("null"));
}
headers.insert(header, HeaderValue::from_static("null"));
assert_eq!(super::destination_version_id(None, &headers).as_deref(), Some("null"));
assert_eq!(super::destination_version_id(Some("invalid"), &headers).as_deref(), Some("invalid"));
}
assert_eq!(super::destination_version_id(None, &HeaderMap::new()), None);
}
#[tokio::test]
async fn test_put_opts_normalizes_null_version_id() {
// MinIO-compatible replication senders (including RustFS itself since
@@ -1686,9 +1794,10 @@ mod tests {
get_str,
};
let scheme = crate::storage::storage_api::compression_metadata_value(rustfs_utils::CompressionAlgorithm::Lz4);
let mut headers = HeaderMap::new();
insert_header(&mut headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true");
insert_header(&mut headers, SUFFIX_REPLICATION_COMPRESSION, "klauspost/compress/s2");
insert_header(&mut headers, SUFFIX_REPLICATION_COMPRESSION, &scheme);
insert_header(&mut headers, SUFFIX_REPLICATION_COMPRESSION_ACTUAL_SIZE, "6295552");
// Without SSE-C transport headers the request is not a passthrough:
@@ -1716,19 +1825,49 @@ mod tests {
let trusted = put_opts_from_headers_with_replication_authorization(&headers, HashMap::new(), true)
.expect("authorized replication request should parse");
assert!(trusted.preserve_ciphertext);
assert_eq!(
get_str(&trusted.user_defined, SUFFIX_COMPRESSION).as_deref(),
Some("klauspost/compress/s2")
);
assert_eq!(get_str(&trusted.user_defined, SUFFIX_COMPRESSION).as_deref(), Some(scheme.as_str()));
assert_eq!(get_str(&trusted.user_defined, SUFFIX_ACTUAL_SIZE).as_deref(), Some("6295552"));
// A malformed plaintext size is dropped; the scheme alone still lets
// the read path derive the size from the parts.
insert_header(&mut headers, SUFFIX_REPLICATION_COMPRESSION_ACTUAL_SIZE, "-5");
let malformed = put_opts_from_headers_with_replication_authorization(&headers, HashMap::new(), true)
.expect("authorized replication request should parse");
assert!(get_str(&malformed.user_defined, SUFFIX_COMPRESSION).is_some());
assert!(get_str(&malformed.user_defined, SUFFIX_ACTUAL_SIZE).is_none());
assert!(
put_opts_from_headers_with_replication_authorization(&headers, HashMap::new(), true).is_err(),
"invalid replicated sizes must not reach persisted metadata"
);
}
#[test]
fn replicated_encryption_metadata_rejects_invalid_sizes_and_compression() {
use rustfs_utils::http::object_encryption_keys::REPLICATION_SSEC_ORIGINAL_SIZE_HEADER;
use rustfs_utils::http::{SUFFIX_REPLICATION_COMPRESSION, SUFFIX_REPLICATION_COMPRESSION_ACTUAL_SIZE};
for (header, value) in [
(REPLICATION_SSEC_ORIGINAL_SIZE_HEADER, "abc"),
(REPLICATION_SSEC_ORIGINAL_SIZE_HEADER, "-1"),
(REPLICATION_SSEC_ORIGINAL_SIZE_HEADER, "9223372036854775808"),
] {
let mut headers = HeaderMap::new();
insert_header(&mut headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true");
headers.insert(
http::HeaderName::from_bytes(header.as_bytes()).expect("header name"),
HeaderValue::from_static(value),
);
assert!(put_opts_from_headers_with_replication_authorization(&headers, HashMap::new(), true).is_err());
assert!(
put_opts_from_headers_with_replication_authorization(&headers, HashMap::new(), false).is_ok(),
"untrusted transport headers stay inert"
);
}
for size in ["0", "5", "9223372036854775807"] {
let mut headers = HeaderMap::new();
insert_header(&mut headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true");
headers.insert(
http::HeaderName::from_bytes(REPLICATION_SSEC_ORIGINAL_SIZE_HEADER.as_bytes()).expect("size header"),
HeaderValue::from_static(size),
);
assert!(put_opts_from_headers_with_replication_authorization(&headers, HashMap::new(), true).is_ok());
insert_header(&mut headers, SUFFIX_REPLICATION_COMPRESSION, "not-a-compression-format");
insert_header(&mut headers, SUFFIX_REPLICATION_COMPRESSION_ACTUAL_SIZE, "5");
assert!(put_opts_from_headers_with_replication_authorization(&headers, HashMap::new(), true).is_err());
}
}
#[test]
@@ -1810,6 +1949,26 @@ mod tests {
assert!(opts_invalid.mod_time.is_none());
}
#[test]
fn replication_source_time_must_survive_the_metadata_codec() {
for timestamp in ["1970-01-01T00:00:00Z", "9999-12-31T23:59:59Z", "0001-01-01T00:00:00Z"] {
let mut headers = HeaderMap::new();
insert_header(&mut headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true");
insert_header(&mut headers, SUFFIX_SOURCE_MTIME, timestamp);
let opts = put_opts_from_headers_with_replication_authorization(&headers, HashMap::new(), true)
.expect("invalid source time falls back to server commit time");
assert!(opts.mod_time.is_none(), "{timestamp}");
}
for timestamp in ["1969-12-31T23:59:59Z", "1970-01-01T00:00:00.000000001Z"] {
let mut headers = HeaderMap::new();
insert_header(&mut headers, SUFFIX_SOURCE_MTIME, timestamp);
assert!(
super::replication_source_mtime(&headers).is_some(),
"representable time {timestamp} remains readable"
);
}
}
/// A client PUT must not materialize the replication transport names as
/// bare stored user-metadata keys: the outbound replication header
/// builder forwards user metadata verbatim on a server-authorized
+67 -6
View File
@@ -17,7 +17,7 @@ use crate::storage::storage_api::s3_api_consumer::bucket::contract::{
bucket::BucketInfo,
list::{ListObjectVersionsInfo as StorageListObjectVersionsInfo, ListObjectsV2Info as StorageListObjectsV2Info},
};
use crate::storage::storage_api::s3_api_consumer::bucket::to_s3s_etag;
use crate::storage::storage_api::s3_api_consumer::bucket::to_s3s_xml_etag;
use percent_encoding::percent_decode_str;
use s3s::dto::{
Bucket, CommonPrefix, DeleteMarkerEntry, EncodingType, ListBucketsOutput, ListObjectVersionsOutput, ListObjectsOutput,
@@ -245,7 +245,7 @@ pub(crate) fn build_list_object_versions_output(
.etag
.as_ref()
.filter(|_| rustfs_utils::http::etag_can_be_listed(&v.user_defined))
.map(|etag| to_s3s_etag(etag)),
.map(|etag| to_s3s_xml_etag(etag)),
storage_class: v.storage_class.clone().map(ObjectVersionStorageClass::from),
..Default::default()
})
@@ -334,7 +334,7 @@ pub(crate) fn build_list_objects_v2_output(
.etag
.as_ref()
.filter(|_| rustfs_utils::http::etag_can_be_listed(&v.user_defined))
.map(|etag| to_s3s_etag(etag)),
.map(|etag| to_s3s_xml_etag(etag)),
storage_class: v.storage_class.clone().map(ObjectStorageClass::from),
..Default::default()
};
@@ -452,7 +452,8 @@ mod tests {
use super::{
ListObjectVersionsInfo, ListObjectVersionsParams, ListObjectsV2Info, build_list_buckets_output,
build_list_object_versions_output, build_list_objects_output, build_list_objects_v2_output,
encode_list_objects_v2_continuation_token, parse_list_object_versions_params, parse_list_objects_v2_params, to_s3s_etag,
encode_list_objects_v2_continuation_token, parse_list_object_versions_params, parse_list_objects_v2_params,
to_s3s_xml_etag,
};
use crate::storage::s3_api::common::rustfs_owner;
use crate::storage::storage_api::s3_api_consumer::bucket::StorageObjectInfo as ObjectInfo;
@@ -464,6 +465,66 @@ mod tests {
use time::OffsetDateTime;
use uuid::Uuid;
#[test]
fn listings_escape_legacy_etags_without_creating_xml_entries() {
use s3s::xml::{Serialize, Serializer};
fn xml<T: Serialize>(value: &T) -> String {
let mut bytes = Vec::new();
value.serialize(&mut Serializer::new(&mut bytes)).expect("serialize listing");
String::from_utf8(bytes).expect("UTF-8 XML")
}
let malicious = "</ETag><Contents><Key>victim</Key></Contents><ETag>&amp;";
let object = ObjectInfo {
name: "owned/key".into(),
etag: Some(malicious.into()),
..Default::default()
};
let v2 = build_list_objects_v2_output(
ListObjectsV2Info {
objects: vec![object.clone()],
..Default::default()
},
false,
1000,
"bucket".into(),
String::new(),
None,
None,
None,
None,
);
let v1 = build_list_objects_output(v2.clone(), None);
let versions = build_list_object_versions_output(
ListObjectVersionsInfo {
objects: vec![object],
..Default::default()
},
"bucket".into(),
&ListObjectVersionsParams {
prefix: String::new(),
delimiter: None,
key_marker: None,
version_id_marker: None,
max_keys: 1000,
},
None,
);
for output in [xml(&v1), xml(&v2), xml(&versions)] {
let mut reader = quick_xml::Reader::from_str(&output);
let mut keys = 0;
loop {
match reader.read_event().expect("legacy ETag must leave well-formed XML") {
quick_xml::events::Event::Start(event) if event.name().as_ref() == "Key" => keys += 1,
quick_xml::events::Event::Eof => break,
_ => {}
}
}
assert_eq!(keys, 1, "{output}");
assert!(output.contains("&lt;/ETag&gt;&lt;Contents&gt;"), "{output}");
assert!(output.contains("&amp;amp;"), "existing entities must remain literal: {output}");
}
}
#[test]
fn test_build_list_buckets_output_maps_bucket_infos_and_owner() {
let bucket_infos = vec![
@@ -771,7 +832,7 @@ mod tests {
let entries = listing.as_ref().expect("listed objects");
assert_eq!(entries.len(), 5);
for (index, entry) in entries.iter().enumerate() {
assert_eq!(entry.e_tag, (index >= 2).then(|| to_s3s_etag("0123456789abcdef0123456789abcdef")));
assert_eq!(entry.e_tag, (index >= 2).then(|| to_s3s_xml_etag("0123456789abcdef0123456789abcdef")));
assert_eq!(entry.key.as_deref(), Some(format!("object-{index}").as_str()));
assert_eq!(entry.size, Some(42));
}
@@ -794,7 +855,7 @@ mod tests {
let versions = versions.versions.expect("listed versions");
assert_eq!(versions.len(), 5);
for (index, entry) in versions.iter().enumerate() {
assert_eq!(entry.e_tag, (index >= 2).then(|| to_s3s_etag("0123456789abcdef0123456789abcdef")));
assert_eq!(entry.e_tag, (index >= 2).then(|| to_s3s_xml_etag("0123456789abcdef0123456789abcdef")));
assert_eq!(entry.size, Some(42));
}
}
+4 -4
View File
@@ -18,7 +18,7 @@ use crate::storage::storage_api::effective_storage_class;
use crate::storage::storage_api::s3_api_consumer::multipart::contract::multipart::{
ListMultipartsInfo, ListPartsInfo, MAX_MULTIPART_PART_NUMBER,
};
use crate::storage::storage_api::s3_api_consumer::multipart::to_s3s_etag;
use crate::storage::storage_api::s3_api_consumer::multipart::to_s3s_xml_etag;
use s3s::dto::{CommonPrefix, EncodingType, ListMultipartUploadsOutput, ListPartsOutput, MultipartUpload, Part, Timestamp};
use s3s::{S3Error, S3ErrorCode};
@@ -54,7 +54,7 @@ pub(crate) fn build_list_parts_output(res: ListPartsInfo) -> ListPartsOutput {
res.parts
.into_iter()
.map(|p| Part {
e_tag: p.etag.map(|etag| to_s3s_etag(&etag)),
e_tag: p.etag.map(|etag| to_s3s_xml_etag(&etag)),
last_modified: p.last_mod.map(Timestamp::from),
part_number: p.part_num.try_into().ok(),
// Compressed parts store fewer bytes than the client sent; S3
@@ -222,7 +222,7 @@ mod tests {
use crate::storage::storage_api::s3_api_consumer::multipart::contract::multipart::{
ListMultipartsInfo, ListPartsInfo, MultipartInfo, PartInfo,
};
use crate::storage::storage_api::s3_api_consumer::multipart::to_s3s_etag;
use crate::storage::storage_api::s3_api_consumer::multipart::to_s3s_xml_etag;
use s3s::S3ErrorCode;
use s3s::dto::Timestamp;
use time::OffsetDateTime;
@@ -261,7 +261,7 @@ mod tests {
assert_eq!(parts.len(), 1);
assert_eq!(parts[0].part_number, Some(1));
assert_eq!(parts[0].size, Some(11));
assert_eq!(parts[0].e_tag, Some(to_s3s_etag("etag-1")));
assert_eq!(parts[0].e_tag, Some(to_s3s_xml_etag("etag-1")));
assert_eq!(output.owner, Some(rustfs_owner()));
assert_eq!(output.initiator, Some(rustfs_initiator()));
}
+6 -36
View File
@@ -15,8 +15,6 @@
use percent_encoding::percent_decode_str;
use s3s::dto::{Tag, TaggingDirective};
use s3s::{S3Error, S3ErrorCode, S3Result};
use std::collections::HashSet;
use std::sync::OnceLock;
use url::form_urlencoded;
use crate::storage::storage_api::encode_tags;
@@ -25,46 +23,18 @@ fn invalid_tag_error(message: &str) -> S3Error {
S3Error::with_message(S3ErrorCode::InvalidTag, message.to_string())
}
fn is_valid_tag_text(value: &str) -> bool {
static VALID_OBJECT_TAG_TEXT: OnceLock<Result<regex::Regex, regex::Error>> = OnceLock::new();
VALID_OBJECT_TAG_TEXT
.get_or_init(|| regex::Regex::new(r"^[\p{L}\p{N}\p{Z}+\-=._:/@]*$"))
.as_ref()
.is_ok_and(|pattern| pattern.is_match(value))
}
pub(crate) fn validate_object_tag_set(tag_set: &[Tag]) -> S3Result<()> {
if tag_set.len() > 10 {
return Err(invalid_tag_error("Cannot have more than 10 tags per object"));
}
crate::storage::storage_api::ecstore_bucket::metadata::validate_tag_set_entries(tag_set).map_err(invalid_tag_error)
}
let mut tag_keys = HashSet::with_capacity(tag_set.len());
for tag in tag_set {
let key = tag
.key
.as_deref()
.filter(|key| !key.is_empty())
.ok_or_else(|| invalid_tag_error("Tag key cannot be empty"))?;
if key.encode_utf16().count() > 128 || !is_valid_tag_text(key) {
return Err(invalid_tag_error("The TagKey you have provided is invalid"));
}
let value = tag
.value
.as_deref()
.ok_or_else(|| invalid_tag_error("Tag value cannot be null"))?;
if value.encode_utf16().count() > 256 || !is_valid_tag_text(value) {
return Err(invalid_tag_error("The TagValue you have provided is invalid"));
}
if !tag_keys.insert(key) {
return Err(invalid_tag_error("Cannot provide multiple Tags with the same key"));
}
pub(crate) fn validate_bucket_tag_set(tag_set: &[Tag]) -> S3Result<()> {
if tag_set.len() > crate::storage::storage_api::ecstore_bucket::metadata::MAX_BUCKET_TAGS {
return Err(invalid_tag_error("Cannot have more than 50 tags per bucket"));
}
Ok(())
crate::storage::storage_api::ecstore_bucket::metadata::validate_tag_set_entries(tag_set).map_err(invalid_tag_error)
}
fn validate_tag_component_encoding(component: &str) -> S3Result<()> {
+199 -10
View File
@@ -476,6 +476,11 @@ impl EncryptionRequest<'_> {
.decode_to_vec(key_md5)
.is_ok_and(|decoded| decoded.len() == 16) =>
{
if rustfs_utils::http::contains_key_str(user_defined, rustfs_utils::http::SUFFIX_PLAINTEXT_CHECKSUM) {
return Err(ssec_invalid_request(
"SSE-C multipart uploads with additional checksums require the customer key for completion.",
));
}
Ok(())
}
_ => Err(ssec_invalid_request("The multipart upload contains invalid SSE-C metadata.")),
@@ -903,7 +908,7 @@ impl SseKmsPrincipal {
conditions: cred.map(|cred| resource_free_condition_values(req, cred)).unwrap_or_default(),
request_audit: request_context_from_req(req).and_then(|context| kms_request_audit(&context.request_id)),
#[cfg(test)]
test_hooks: None,
test_hooks: req.extensions.get::<TestAuthorizationHooks>().cloned(),
})
}
@@ -931,9 +936,9 @@ impl SseKmsPrincipal {
#[cfg(test)]
#[derive(Clone)]
struct TestAuthorizationHooks {
enforced: bool,
authorizer: Arc<dyn KmsKeyAuthorizer>,
pub(crate) struct TestAuthorizationHooks {
pub(crate) enforced: bool,
pub(crate) authorizer: Arc<dyn KmsKeyAuthorizer>,
}
#[cfg(test)]
@@ -1130,6 +1135,87 @@ pub async fn authorize_sse_kms_object_read(
result
}
/// Authorize the current request to write under the upload's stored KMS key.
/// Upload IDs do not confer the creator's KMS permissions on another caller.
pub async fn authorize_sse_kms_multipart_write(
principal: Option<&SseKmsPrincipal>,
metadata: &HashMap<String, String>,
) -> Result<(), ApiError> {
let Some((sse_type, key_id)) = stored_managed_encryption_key(metadata) else {
return Ok(());
};
let result = authorize_sse_kms_key(principal, sse_type, KmsAction::GenerateDataKeyAction, &key_id).await;
if let Err(error) = &result {
record_managed_kms_outcome(
principal,
sse_type,
Some(&key_id),
|| stored_envelope_master_key_version(metadata),
Err(error),
);
}
result
}
/// Capture the request's encryption gate without exposing customer keys in
/// ObjectOptions Debug output. Storage applies it to the source selected under
/// completion locks, including recovered uploads and historical replay versions.
pub(crate) fn multipart_completion_authorization(
request: EncryptionRequest<'_>,
replication_authorized: bool,
) -> Arc<dyn super::storage_api::contract::multipart::MultipartCompletionAuthorization<Error = StorageError>> {
Arc::new(SseMultipartCompletionAuthorization {
bucket: request.bucket.to_string(),
key: request.key.to_string(),
principal: request.principal.cloned(),
algorithm: request.sse_customer_algorithm,
customer_key: request.sse_customer_key,
customer_md5: request.sse_customer_key_md5,
replication_authorized,
})
}
struct SseMultipartCompletionAuthorization {
bucket: String,
key: String,
principal: Option<SseKmsPrincipal>,
algorithm: Option<String>,
customer_key: Option<String>,
customer_md5: Option<String>,
replication_authorized: bool,
}
#[async_trait::async_trait]
impl super::storage_api::contract::multipart::MultipartCompletionAuthorization for SseMultipartCompletionAuthorization {
type Error = StorageError;
async fn authorize(&self, metadata: &HashMap<String, String>) -> Result<(), StorageError> {
authorize_sse_kms_multipart_write(self.principal.as_ref(), metadata)
.await
.map_err(|error| StorageError::other(map_encryption_resolution_error(error)))?;
if self.customer_key.is_some()
|| !self.replication_authorized
|| !rustfs_utils::http::contains_key_str(metadata, rustfs_utils::http::SUFFIX_REPLICATION_PRESERVE_CIPHERTEXT)
{
EncryptionRequest {
bucket: &self.bucket,
key: &self.key,
server_side_encryption: None,
ssekms_key_id: None,
ssekms_context: None,
sse_customer_algorithm: self.algorithm.clone(),
sse_customer_key: self.customer_key.clone(),
sse_customer_key_md5: self.customer_md5.clone(),
content_size: 0,
principal: self.principal.as_ref(),
}
.validate_complete_multipart_ssec(metadata)
.map_err(|error| StorageError::other(map_encryption_resolution_error(error)))?;
}
Ok(())
}
}
// ============================================================================
// Data-plane KMS audit attachment (SSE-S3 / SSE-KMS)
// ============================================================================
@@ -4187,7 +4273,7 @@ fn is_legacy_rustfs_managed_metadata(metadata: &HashMap<String, String>) -> bool
/// detection only in builds that can actually read it — default builds keep
/// the resolver's fail-closed diagnosis instead of demanding customer keys
/// they could not use.
fn stored_ssec_metadata(metadata: &HashMap<String, String>) -> bool {
pub(crate) fn stored_ssec_metadata(metadata: &HashMap<String, String>) -> bool {
metadata.contains_key("x-amz-server-side-encryption-customer-algorithm") || minio_sealed_ssec_metadata(metadata)
}
@@ -4404,7 +4490,7 @@ fn ssec_invalid_request(message: &str) -> ApiError {
#[cfg(test)]
#[allow(unused_imports)]
mod tests {
pub(crate) mod tests {
use super::{
ApiError, DataKey, DecryptionRequest, EncryptionKeyKind, EncryptionMaterial, EncryptionRequest,
EncryptionResolutionError, EncryptionResolutionErrorKind, INTERNAL_ENCRYPTION_ALGORITHM_HEADER,
@@ -6877,6 +6963,35 @@ mod tests {
}
}
#[test]
fn ssec_checksum_completion_requires_key_for_both_metadata_aliases() {
let no_key = EncryptionRequest {
sse_customer_algorithm: None,
sse_customer_key: None,
sse_customer_key_md5: None,
..multipart_ssec_request(42)
};
for prefix in ["x-rustfs-internal-", "x-minio-internal-"] {
let mut metadata = multipart_ssec_metadata(42);
metadata.insert(format!("{prefix}plaintext-checksum"), "true".to_string());
assert_eq!(
no_key
.validate_complete_multipart_ssec(&metadata)
.expect_err("no key cannot compare or reveal plaintext checksums")
.code,
S3ErrorCode::InvalidRequest
);
multipart_ssec_request(42)
.validate_complete_multipart_ssec(&metadata)
.expect("correct key");
assert!(
multipart_ssec_request(43)
.validate_complete_multipart_ssec(&metadata)
.is_err()
);
}
}
#[test]
fn test_validate_complete_multipart_ssec_still_validates_present_parameters() {
let partial = EncryptionRequest {
@@ -7932,20 +8047,20 @@ mod tests {
// Per-key KMS authorization on the SSE-KMS data path (rustfs/backlog#1582)
// ------------------------------------------------------------------------
struct RecordingKmsKeyAuthorizer {
pub(crate) struct RecordingKmsKeyAuthorizer {
allowed: bool,
calls: std::sync::Mutex<Vec<(KmsAction, String)>>,
}
impl RecordingKmsKeyAuthorizer {
fn new(allowed: bool) -> Arc<Self> {
pub(crate) fn new(allowed: bool) -> Arc<Self> {
Arc::new(Self {
allowed,
calls: std::sync::Mutex::new(Vec::new()),
})
}
fn calls(&self) -> Vec<(KmsAction, String)> {
pub(crate) fn calls(&self) -> Vec<(KmsAction, String)> {
self.calls.lock().expect("authorizer call log").clone()
}
}
@@ -7986,7 +8101,7 @@ mod tests {
}
}
fn sse_kms_object_metadata() -> HashMap<String, String> {
pub(crate) fn sse_kms_object_metadata() -> HashMap<String, String> {
HashMap::from([
("x-amz-server-side-encryption".to_string(), ServerSideEncryption::AWS_KMS.to_string()),
("x-amz-server-side-encryption-aws-kms-key-id".to_string(), "finance-key".to_string()),
@@ -8156,6 +8271,80 @@ mod tests {
);
}
#[tokio::test]
async fn completion_authorization_preserves_typed_denials_and_redacts_customer_keys() {
let key = multipart_ssec_request(42).sse_customer_key.expect("test key");
let mut metadata = multipart_ssec_metadata(42);
rustfs_utils::http::insert_str(&mut metadata, rustfs_utils::http::SUFFIX_PLAINTEXT_CHECKSUM, "true".to_string());
let no_key = super::multipart_completion_authorization(
EncryptionRequest {
sse_customer_algorithm: None,
sse_customer_key: None,
sse_customer_key_md5: None,
..multipart_ssec_request(42)
},
false,
);
let error = no_key.authorize(&metadata).await.expect_err("customer key required");
assert_eq!(map_get_object_reader_error(error).code, S3ErrorCode::InvalidRequest);
let (denied, _) = enforcing_principal(false);
let denied = super::multipart_completion_authorization(
EncryptionRequest {
principal: Some(&denied),
..multipart_ssec_request(42)
},
false,
);
let error = denied.authorize(&sse_kms_object_metadata()).await.expect_err("KMS denied");
assert_eq!(map_get_object_reader_error(error).code, S3ErrorCode::AccessDenied);
let valid = super::multipart_completion_authorization(multipart_ssec_request(42), false);
valid.authorize(&metadata).await.expect("correct customer key");
let opts = crate::storage::ObjectOptions {
multipart_completion_authorization: Some(valid),
..Default::default()
};
assert!(
!format!("{opts:?}").contains(&key),
"ObjectOptions Debug must never expose the captured customer key"
);
}
#[tokio::test]
async fn multipart_write_authorization_uses_stored_key_and_current_principal() {
let (denied, denied_authorizer) = enforcing_principal(false);
let (allowed, allowed_authorizer) = enforcing_principal(true);
let metadata = sse_kms_object_metadata();
let error = super::authorize_sse_kms_multipart_write(Some(&denied), &metadata)
.await
.expect_err("upload ID must not grant its creator's KMS permission");
assert_eq!(error.code, S3ErrorCode::AccessDenied);
assert_eq!(
denied_authorizer.calls(),
vec![(KmsAction::GenerateDataKeyAction, "finance-key".to_string())]
);
super::authorize_sse_kms_multipart_write(Some(&allowed), &metadata)
.await
.expect("authorized writer");
assert_eq!(
allowed_authorizer.calls(),
vec![(KmsAction::GenerateDataKeyAction, "finance-key".to_string())]
);
for metadata in [HashMap::new(), sse_s3_object_metadata(), multipart_ssec_metadata(42)] {
super::authorize_sse_kms_multipart_write(Some(&denied), &metadata)
.await
.expect("non-KMS upload");
}
assert_eq!(denied_authorizer.calls().len(), 1);
let (off, off_authorizer) = permissive_principal_with_enforcement_off();
super::authorize_sse_kms_multipart_write(Some(&off), &metadata)
.await
.expect("enforcement off");
super::authorize_sse_kms_multipart_write(None, &metadata)
.await
.expect("trusted internal writer");
assert!(off_authorizer.calls().is_empty());
}
#[tokio::test]
async fn copy_source_read_authorization_targets_the_source_key() {
let (denied, denied_authorizer) = enforcing_principal(false);
+48 -25
View File
@@ -97,10 +97,11 @@ pub(crate) mod contract {
}
pub(crate) mod multipart {
pub(crate) use super::super::storage_contracts::MultipartCompletionAuthorization;
pub(crate) const MAX_MULTIPART_PART_NUMBER: i32 = 10000;
pub(crate) use super::super::storage_contracts::{ListMultipartsInfo, ListPartsInfo};
#[cfg(test)]
pub(crate) use super::super::storage_contracts::{MultipartInfo, MultipartOperations, PartInfo};
pub(crate) use super::super::storage_contracts::{CompletePart, MultipartInfo, MultipartOperations, PartInfo};
pub(crate) use super::super::storage_contracts::{ListMultipartsInfo, ListPartsInfo};
}
pub(crate) mod object {
@@ -133,26 +134,27 @@ pub(crate) use super::ecfs_extend::{
get_buffer_size_opt_in, get_validated_store, has_read_preconditions, load_bucket_object_lock_config_state,
parse_object_lock_legal_hold, parse_object_lock_retention, parse_part_number_i32_to_usize, process_lambda_configurations,
process_queue_configurations, process_topic_configurations, remove_object_lock_metadata_for_copy, validate_bucket_exists,
validate_bucket_object_lock_enabled_state, validate_list_object_unordered_with_delimiter, validate_object_key,
wrap_response_with_cors,
validate_bucket_object_lock_enabled_state, validate_list_object_unordered_with_delimiter, validate_new_object_key,
validate_object_key, wrap_response_with_cors,
};
pub(crate) use super::sse::{
DecryptionRequest, EncryptionRequest, ObjectDekRewrapOutcome, PrepareEncryptionRequest, SseKmsPrincipal,
authorize_sse_kms_object_read, classify_sse_read_response, extract_server_side_encryption_from_headers,
project_sse_read_response_headers, rewrap_object_encryption_metadata, sse_decryption, sse_encryption, sse_prepare_encryption,
strip_managed_encryption_metadata, validate_sse_headers_for_read, validate_sse_headers_for_write, validate_ssec_for_read,
authorize_sse_kms_multipart_write, authorize_sse_kms_object_read, classify_sse_read_response,
extract_server_side_encryption_from_headers, project_sse_read_response_headers, rewrap_object_encryption_metadata,
sse_decryption, sse_encryption, sse_prepare_encryption, strip_managed_encryption_metadata, validate_sse_headers_for_read,
validate_sse_headers_for_write, validate_ssec_for_read,
};
pub(crate) mod access_consumer {
pub(crate) use super::super::access::{
MultipartStagingOperation, PostObjectRequestMarker, ReqInfo, TABLE_DATA_PLANE_LIST_CURSOR_PREFIX,
TableDataPlaneListAccess, TableDataPlaneListCursorPosition, TableDataPlanePublicationGuards,
apply_bucket_generation_guard, apply_copy_source_bucket_generation_guard, authorize_internal_object_request,
authorize_request, bucket_config_mutation_incarnation, delete_object_authorize_action, has_bypass_governance_header,
load_bucket_generation_from_store, log_list_buckets_iam_implicit_deny, odm_read_generation,
apply_bucket_generation_guard, apply_copy_source_bucket_generation_guard, authorize_copy_object_tags,
authorize_internal_object_request, authorize_request, bucket_config_mutation_incarnation, delete_object_authorize_action,
has_bypass_governance_header, load_bucket_generation_from_store, log_list_buckets_iam_implicit_deny, odm_read_generation,
prepare_list_buckets_iam_authorization, prepare_odm_read_generation, recursive_force_delete_has_authenticated_caller,
replication_request_authorized, req_info_mut, req_info_ref, retained_table_data_plane_publication_guards,
set_requested_object_lock_retain_until,
replication_proxy_allowed, replication_request_authorized, req_info_mut, req_info_ref,
retained_table_data_plane_publication_guards, set_requested_object_lock_retain_until,
};
}
@@ -217,7 +219,13 @@ pub(crate) mod head_prefix_consumer {
}
pub(crate) mod helper_consumer {
pub(crate) use super::super::helper::{OperationHelper, build_event_resp_elements, spawn_background_with_context};
#[cfg(test)]
pub(crate) use super::super::helper::{
MutationCompletionProbe, capture_mutation_event_for_test, pause_committed_mutation_for_test,
};
pub(crate) use super::super::helper::{
OperationHelper, build_event_resp_elements, run_owned_mutation, spawn_background_with_context,
};
pub(crate) type StorageObjectInfo = super::StorageObjectInfo;
}
@@ -233,6 +241,7 @@ pub(crate) mod options_consumer {
parse_copy_source_range, preserve_unclassified_user_metadata, put_opts_with_replication_authorization,
validate_archive_content_encoding,
};
pub(crate) use rustfs_ecstore::api::rio::validate_compression_scheme;
pub(crate) mod contract {
pub(crate) mod object {
@@ -358,9 +367,7 @@ pub(crate) mod s3_api_consumer {
pub(crate) type StorageObjectInfo = super::super::StorageObjectInfo;
pub(crate) fn to_s3s_etag(etag: &str) -> s3s::dto::ETag {
super::super::to_s3s_etag(etag)
}
pub(crate) use super::super::to_s3s_xml_etag;
}
pub(crate) mod common {
@@ -384,13 +391,13 @@ pub(crate) mod s3_api_consumer {
}
}
pub(crate) fn to_s3s_etag(etag: &str) -> s3s::dto::ETag {
super::super::to_s3s_etag(etag)
}
pub(crate) use super::super::to_s3s_xml_etag;
}
pub(crate) mod tagging {
pub(crate) use super::super::super::s3_api::tagging::{parse_copy_object_tags, resolve_copy_object_tags};
pub(crate) use super::super::super::s3_api::tagging::{
parse_copy_object_tags, resolve_copy_object_tags, validate_bucket_tag_set,
};
}
}
@@ -398,13 +405,14 @@ pub(crate) mod sse_consumer {
pub(crate) use super::super::sse::{
EncryptionKeyKind, bucket_default_write_sse, build_ssec_read_headers, encryption_material_to_metadata,
extract_ssec_params_from_headers, extract_ssekms_context_from_headers, log_sse_kms_key_policy_mode,
map_get_object_reader_error, mark_encrypted_multipart_metadata,
map_get_object_reader_error, mark_encrypted_multipart_metadata, multipart_completion_authorization, stored_ssec_metadata,
};
pub(crate) use super::{
DecryptionRequest, EncryptionRequest, PrepareEncryptionRequest, SseKmsPrincipal, apply_bucket_default_lock_retention,
authorize_sse_kms_object_read, classify_sse_read_response, extract_server_side_encryption_from_headers,
get_buffer_size_opt_in, load_bucket_object_lock_config_state, project_sse_read_response_headers, sse_decryption,
sse_encryption, sse_prepare_encryption, validate_bucket_object_lock_enabled_state,
authorize_sse_kms_multipart_write, authorize_sse_kms_object_read, classify_sse_read_response,
extract_server_side_encryption_from_headers, get_buffer_size_opt_in, load_bucket_object_lock_config_state,
project_sse_read_response_headers, sse_decryption, sse_encryption, sse_prepare_encryption,
validate_bucket_object_lock_enabled_state,
};
}
@@ -424,6 +432,10 @@ pub(crate) mod tonic_service_consumer {
#[cfg(test)]
pub(crate) mod test_consumer {
pub(crate) mod sse {
pub(crate) use super::super::super::sse::TestAuthorizationHooks;
pub(crate) use super::super::super::sse::tests::{RecordingKmsKeyAuthorizer, sse_kms_object_metadata};
}
pub(crate) use super::super::{
apply_cors_headers, apply_default_lock_retention_metadata, check_preconditions, decode_tags_to_map,
get_adaptive_buffer_size_with_profile, get_buffer_size_opt_in, matches_origin_pattern, parse_etag,
@@ -649,7 +661,8 @@ pub(crate) mod ecstore_set_disk {
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::set_disk::test_util::{
ConditionalReadBenchmarkMetadataGuard, MultipartCommitBarrier, MultipartCommitPause, PutObjectCommitBarrier,
PutObjectCommitPause, fail_next_quota_ledger_save_for_test,
PutObjectCommitPause, fail_next_quota_ledger_save_for_test, initialize_quota_ledger_for_test,
retry_quota_reconciliation_for_test,
};
pub(crate) use rustfs_ecstore::api::set_disk::{
DEFAULT_READ_BUFFER_SIZE, file_info_quorum_hash, get_lock_acquire_timeout, is_valid_storage_class,
@@ -2447,6 +2460,16 @@ pub(crate) fn to_s3s_etag(etag: &str) -> s3s::dto::ETag {
ecstore_client::object_api_utils::to_s3s_etag(etag)
}
/// The pinned s3s XML serializer writes ETag values as raw text. Escape only
/// at XML response boundaries; HTTP headers and conditional comparisons keep
/// the original stored value. Existing unsafe ETags must remain listable.
pub(crate) fn to_s3s_xml_etag(etag: &str) -> s3s::dto::ETag {
let mut etag = to_s3s_etag(etag);
let (s3s::dto::ETag::Strong(value) | s3s::dto::ETag::Weak(value)) = &mut etag;
*value = quick_xml::escape::escape(value.as_str()).into_owned();
etag
}
pub(crate) fn table_catalog_path_hash(value: &str) -> String {
ecstore_bucket::metadata::table_catalog_path_hash(value)
}
+8
View File
@@ -483,6 +483,7 @@ pub(crate) mod workload {
pub(crate) mod table {
pub(crate) use crate::storage::storage_api::ecstore_bucket::utils::check_valid_bucket_name_strict;
pub(crate) use crate::storage::storage_api::ecstore_object::{WriteCommitGuard, WriteCompletion};
pub(crate) use crate::storage::storage_api::request_context_consumer::spawn_traced_join;
pub(crate) mod contract {
pub(crate) mod http {
pub(crate) use super::super::super::storage_contracts::HTTPPreconditions;
@@ -518,6 +519,13 @@ pub(crate) mod table {
StorageObjectInfo, StorageObjectOptions, StorageObjectToDelete, StoragePutObjReader, get_bucket_metadata,
get_lock_acquire_timeout, table_catalog_path_hash,
};
#[cfg(test)]
pub(crate) mod test_support {
pub(crate) use crate::storage::storage_api::contract::bucket::{BucketOperations, MakeBucketOptions};
pub(crate) use crate::storage::storage_api::ecstore_bucket::metadata_sys::init_bucket_metadata_sys;
pub(crate) use crate::storage::storage_api::ecstore_bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
}
}
pub(crate) mod on_demand_migration {
@@ -17,6 +17,7 @@ use std::io::Read;
use super::super::*;
const AVRO_ZSTANDARD_MAX_WINDOW_LOG: u32 = 27;
const AVRO_MANIFEST_MAX_SCHEMA_DEPTH: usize = 64;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ManifestDataFileReference {
@@ -182,6 +183,7 @@ pub(crate) fn decode_manifest_list_avro(data: &[u8]) -> TableCatalogStoreResult<
let decoded_size = validate_avro_container(data)?;
let reader = apache_avro::Reader::new(data)
.map_err(|err| TableCatalogStoreError::Invalid(format!("failed to read manifest list Avro: {err}")))?;
validate_manifest_writer_schema(reader.writer_schema())?;
let format_version =
avro_record_format_version(reader.writer_schema(), &["sequence_number", "min_sequence_number"], "manifest list")?;
if format_version == 2 {
@@ -276,6 +278,7 @@ pub(crate) fn decode_manifest_avro(data: &[u8]) -> TableCatalogStoreResult<Decod
let decoded_size = validate_avro_container(data)?;
let reader = apache_avro::Reader::new(data)
.map_err(|err| TableCatalogStoreError::Invalid(format!("failed to read manifest Avro: {err}")))?;
validate_manifest_writer_schema(reader.writer_schema())?;
let format_version =
avro_record_format_version(reader.writer_schema(), &["sequence_number", "file_sequence_number"], "manifest")?;
let partition_spec_id = reader
@@ -365,6 +368,79 @@ enum AvroContainerCodec {
Zstandard,
}
// Iceberg manifests do not use recursive types. Bound resolved depth before any
// value decoding; byte and record limits cannot bound recursive Avro values.
fn validate_manifest_writer_schema(schema: &apache_avro::Schema) -> TableCatalogStoreResult<()> {
use apache_avro::schema::{Name, NamesRef, ResolvedSchema};
use std::collections::{HashMap, HashSet};
fn depth(
schema: &apache_avro::Schema,
names: &NamesRef<'_>,
active: &mut HashSet<Name>,
heights: &mut HashMap<Name, usize>,
nesting: usize,
) -> TableCatalogStoreResult<usize> {
use apache_avro::Schema;
if nesting > AVRO_MANIFEST_MAX_SCHEMA_DEPTH {
return Err(TableCatalogStoreError::Invalid(
"manifest Avro schema exceeds the maximum nesting depth".to_string(),
));
}
if let Schema::Ref { name } = schema {
let target = names.get(name).ok_or_else(|| {
TableCatalogStoreError::Invalid("manifest Avro schema has an unresolved type reference".to_string())
})?;
return depth(target, names, active, heights, nesting);
}
if let Some(name) = schema.name() {
if let Some(height) = heights.get(name) {
if nesting + height - 1 > AVRO_MANIFEST_MAX_SCHEMA_DEPTH {
return Err(TableCatalogStoreError::Invalid(
"manifest Avro schema exceeds the maximum nesting depth".to_string(),
));
}
return Ok(*height);
}
if !active.insert(name.clone()) {
return Err(TableCatalogStoreError::Invalid(
"manifest Avro schema contains a recursive type".to_string(),
));
}
}
let mut height = 1;
let mut visit = |child| -> TableCatalogStoreResult<()> {
height = height.max(1 + depth(child, names, active, heights, nesting + 1)?);
Ok(())
};
match schema {
Schema::Record(record) => {
for field in &record.fields {
visit(&field.schema)?;
}
}
Schema::Array(array) => visit(&array.items)?,
Schema::Map(map) => visit(&map.types)?,
Schema::Union(union) => {
for variant in union.variants() {
visit(variant)?;
}
}
_ => {}
}
if let Some(name) = schema.name() {
active.remove(name);
heights.insert(name.clone(), height);
}
Ok(height)
}
let resolved = ResolvedSchema::try_from(schema)
.map_err(|err| TableCatalogStoreError::Invalid(format!("invalid manifest Avro schema: {err}")))?;
depth(schema, resolved.get_names(), &mut HashSet::new(), &mut HashMap::new(), 1)?;
Ok(())
}
fn validate_avro_container(data: &[u8]) -> TableCatalogStoreResult<usize> {
if !data.starts_with(b"Obj\x01") {
return Err(TableCatalogStoreError::Invalid("Avro container has invalid header magic".to_string()));
@@ -665,6 +741,96 @@ mod tests {
use super::*;
use std::assert_matches;
fn schema_container(schema: &serde_json::Value, record: &[u8]) -> Vec<u8> {
fn long(bytes: &mut Vec<u8>, value: usize) {
let mut encoded = u64::try_from(value).expect("fixture length") << 1;
while encoded >= 128 {
bytes.push((encoded as u8 & 0x7f) | 0x80);
encoded >>= 7;
}
bytes.push(encoded as u8);
}
let schema = serde_json::to_vec(schema).expect("schema JSON");
let mut bytes = b"Obj\x01".to_vec();
long(&mut bytes, 1);
long(&mut bytes, b"avro.schema".len());
bytes.extend_from_slice(b"avro.schema");
long(&mut bytes, schema.len());
bytes.extend_from_slice(&schema);
long(&mut bytes, 0);
bytes.extend_from_slice(&[0; 16]);
long(&mut bytes, 1);
long(&mut bytes, record.len());
bytes.extend_from_slice(record);
bytes.extend_from_slice(&[0; 16]);
bytes
}
#[test]
fn manifest_decoders_reject_recursive_writer_schemas_before_decoding_values() {
for child in [
serde_json::json!(["null", "example.manifest_file"]),
serde_json::json!({"type":"array", "items":"example.manifest_file"}),
serde_json::json!({"type":"map", "values":"example.manifest_file"}),
serde_json::json!({"type":"record", "name":"child", "fields":[{"name":"parent", "type":"example.manifest_file"}]}),
] {
let schema = serde_json::json!({
"type":"record", "name":"manifest_file", "namespace":"example",
"fields":[{"name":"manifest_path","type":"string"}, {"name":"next", "type":child}]
});
// A valid self-referential union datum can drive thousands of decoder frames.
let mut record = [0, 2].repeat(10_000);
record.extend_from_slice(&[0, 0]);
let data = schema_container(&schema, &record);
assert!(
matches!(decode_manifest_list_avro(&data), Err(TableCatalogStoreError::Invalid(message)) if message.contains("recursive type"))
);
assert!(
matches!(decode_manifest_avro(&data), Err(TableCatalogStoreError::Invalid(message)) if message.contains("recursive type"))
);
}
}
#[test]
fn manifest_decoders_bound_resolved_schema_depth() {
let mut fields = vec![serde_json::json!({"name":"manifest_path", "type":"string"})];
for index in 0..AVRO_MANIFEST_MAX_SCHEMA_DEPTH {
let children = if index == 0 {
vec![]
} else {
vec![serde_json::json!({"name":"previous", "type":format!("node{}", index - 1)})]
};
fields.push(serde_json::json!({"name":format!("field{index}"), "type":{"type":"record", "name":format!("node{index}"), "fields":children}}));
}
let data = schema_container(&serde_json::json!({"type":"record", "name":"manifest_file", "fields":fields}), &[0]);
assert!(
matches!(decode_manifest_list_avro(&data), Err(TableCatalogStoreError::Invalid(message)) if message.contains("maximum nesting depth"))
);
assert!(
matches!(decode_manifest_avro(&data), Err(TableCatalogStoreError::Invalid(message)) if message.contains("maximum nesting depth"))
);
}
#[test]
fn manifest_decoder_accepts_reused_nonrecursive_named_types() {
let schema = serde_json::json!({
"type":"record", "name":"manifest_file", "namespace":"example",
"fields":[
{"name":"manifest_path", "type":"string"},
{"name":"first", "type":{"type":"record", "name":"detail", "fields":[{"name":"value", "type":"string"}]}},
{"name":"second", "type":"detail"}
]
});
let path = "manifest.avro";
let mut record = vec![u8::try_from(path.len() * 2).expect("short path")];
record.extend_from_slice(path.as_bytes());
record.extend_from_slice(&[0, 0]);
let decoded =
decode_manifest_list_avro(&schema_container(&schema, &record)).expect("reused named types are not recursive");
assert_eq!(decoded.references.len(), 1);
assert_eq!(decoded.references[0].manifest_path, path);
}
#[test]
fn rejects_v2_manifest_lists_without_required_count_fields() {
let schema = apache_avro::Schema::parse_str(
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
mod compaction;
mod config;
mod periodic;
mod periodic_runtime;
@@ -19,6 +20,7 @@ mod planner;
mod recovery;
mod worker;
pub(crate) use compaction::{run_compaction_output, run_compaction_worker, try_admit_compaction};
pub(crate) use config::*;
pub(crate) use periodic_runtime::{PeriodicMaintenanceConfig, PeriodicMaintenanceRuntime};
pub(crate) use planner::*;
+22 -63
View File
@@ -402,10 +402,10 @@ where
"compaction data file must be inside the table data directory".to_string(),
));
}
let Some(data_object) = backend.read_object(table_bucket, &data_key).await? else {
let Some(data_metadata) = backend.object_metadata(table_bucket, &data_key).await? else {
return Err(TableCatalogStoreError::NotFound(format!("compaction data file {data_key}")));
};
let size_bytes = u64::try_from(data_object.data.len()).unwrap_or(u64::MAX);
let size_bytes = data_metadata.size;
if size_bytes <= config.small_file_threshold_bytes {
planning.candidates.push(TableCompactionDataFileCandidate {
rewrite_prefix: compaction_data_file_rewrite_prefix_for_entry(entry, warehouse_object_prefix, &data_key)
@@ -447,7 +447,7 @@ where
));
}
let object_exists = backend.read_object(table_bucket, &delete_key).await?.is_some();
let object_exists = backend.object_metadata(table_bucket, &delete_key).await?.is_some();
planning.status = TableRowLevelMaintenancePlanningStatus::ManualReviewRequired;
planning.delete_file_count = planning.delete_file_count.saturating_add(1);
planning.manual_review_count = planning.manual_review_count.saturating_add(1);
@@ -519,6 +519,7 @@ pub(crate) async fn compaction_current_data_files<B>(
table: &IdentifierSegment,
entry: &TableEntry,
current_metadata: &serde_json::Value,
admission: &Arc<tokio::sync::OwnedSemaphorePermit>,
) -> TableCatalogStoreResult<Vec<CompactedDataFile>>
where
B: TableCatalogObjectBackend,
@@ -603,7 +604,7 @@ where
"compaction data file must be inside the table data directory".to_string(),
));
}
let Some(data_object) = backend.read_object(table_bucket, &data_key).await? else {
let Some(data_metadata) = backend.object_metadata(table_bucket, &data_key).await? else {
return Err(TableCatalogStoreError::NotFound(format!("compaction data file {data_key}")));
};
let snapshot_id = reference
@@ -625,14 +626,24 @@ where
TableCatalogStoreError::Invalid("compaction manifest data file missing file sequence number".to_string())
})?;
data_files.push(CompactedDataFile {
object_key: data_key,
object_key: data_key.clone(),
file_path: reference.location,
file_size_bytes: reference
.file_size_bytes
.unwrap_or_else(|| u64::try_from(data_object.data.len()).unwrap_or(u64::MAX)),
file_size_bytes: reference.file_size_bytes.unwrap_or(data_metadata.size),
record_count: match reference.record_count {
Some(record_count) => record_count,
None => parquet_record_count(&data_object.data)?,
None => {
let object = backend
.read_object_limited(table_bucket, &data_key, TABLE_COMPACTION_MAX_STORED_BYTES)
.await?
.ok_or_else(|| TableCatalogStoreError::NotFound(format!("compaction data file {data_key}")))?;
let admission = Arc::clone(admission);
tokio::task::spawn_blocking(move || {
let _admission = admission;
parquet_record_count(object.data)
})
.await
.map_err(|_| TableCatalogStoreError::Internal("compaction footer worker failed".to_owned()))??
}
},
partition_spec_id: manifest_reference.partition_spec_id.unwrap_or(0),
partition: reference.partition,
@@ -836,58 +847,6 @@ pub(crate) fn compaction_manifest_partition_spec_id(data_files: &[CompactedDataF
Ok(partition_spec_id)
}
pub(crate) fn compact_parquet_data_files(input_files: &[(String, Vec<u8>)]) -> TableCatalogStoreResult<CompactedParquetFile> {
let mut schema: Option<SchemaRef> = None;
let mut batches = Vec::new();
let mut record_count = 0_u64;
for (location, data) in input_files {
let builder = ParquetRecordBatchReaderBuilder::try_new(Bytes::from(data.clone())).map_err(|err| {
TableCatalogStoreError::Invalid(format!("failed to read compaction input parquet {location}: {err}"))
})?;
let file_schema = builder.schema().clone();
match schema.as_ref() {
Some(expected_schema) if expected_schema.as_ref() != file_schema.as_ref() => {
return Err(TableCatalogStoreError::Invalid("compaction input parquet schemas must match".to_string()));
}
Some(_) => {}
None => schema = Some(file_schema),
}
let reader = builder.build().map_err(|err| {
TableCatalogStoreError::Invalid(format!("failed to build compaction parquet reader {location}: {err}"))
})?;
for batch in reader {
let batch = batch.map_err(|err| {
TableCatalogStoreError::Invalid(format!("failed to read compaction parquet batch {location}: {err}"))
})?;
record_count = record_count.saturating_add(u64::try_from(batch.num_rows()).unwrap_or(u64::MAX));
batches.push(batch);
}
}
let Some(schema) = schema else {
return Err(TableCatalogStoreError::Invalid(
"compaction requires at least one parquet input file".to_string(),
));
};
let mut data = Vec::new();
{
let mut writer = ArrowWriter::try_new(&mut data, schema, None)
.map_err(|err| TableCatalogStoreError::Internal(format!("failed to build compaction parquet writer: {err}")))?;
for batch in batches {
writer
.write(&batch)
.map_err(|err| TableCatalogStoreError::Internal(format!("failed to write compaction parquet batch: {err}")))?;
}
writer
.close()
.map_err(|err| TableCatalogStoreError::Internal(format!("failed to close compaction parquet writer: {err}")))?;
}
Ok(CompactedParquetFile { data, record_count })
}
fn validate_compaction_manifest_entry_status(entry_status: Option<i32>) -> TableCatalogStoreResult<()> {
match entry_status {
Some(0 | 1) => Ok(()),
@@ -901,8 +860,8 @@ fn validate_compaction_manifest_entry_status(entry_status: Option<i32>) -> Table
}
}
fn parquet_record_count(data: &[u8]) -> TableCatalogStoreResult<u64> {
let builder = ParquetRecordBatchReaderBuilder::try_new(Bytes::copy_from_slice(data))
fn parquet_record_count(data: Vec<u8>) -> TableCatalogStoreResult<u64> {
let builder = ParquetRecordBatchReaderBuilder::try_new(Bytes::from(data))
.map_err(|err| TableCatalogStoreError::Invalid(format!("failed to read compaction parquet metadata: {err}")))?;
u64::try_from(builder.metadata().file_metadata().num_rows())
.map_err(|_| TableCatalogStoreError::Invalid("compaction parquet record count must not be negative".to_string()))
@@ -72,6 +72,22 @@ fn insert_referenced_object_report(
report.reasons.insert(reason);
}
pub(crate) fn maintenance_cleanup_roots(entry: &TableEntry) -> TableCatalogStoreResult<Vec<String>> {
let metadata_dir = table_metadata_dir_path_for_entry(entry)?;
let table_root = metadata_dir
.strip_suffix(&format!("/{METADATA_DIR}"))
.ok_or_else(|| TableCatalogStoreError::Invalid("maintenance metadata directory has no table root".to_string()))?;
let warehouse = table_warehouse_object_prefix(entry)?;
Ok(vec![
format!("{metadata_dir}/"),
format!("{table_root}/{DATA_DIR}/"),
format!("{table_root}/{DELETE_DIR}/"),
format!("{warehouse}{METADATA_DIR}/"),
format!("{warehouse}{DATA_DIR}/"),
format!("{warehouse}{DELETE_DIR}/"),
])
}
#[derive(Clone, Copy)]
enum TableMaintenanceObjectScope<'a> {
Logical {
@@ -795,7 +811,7 @@ where
table_metadata_maintenance_reason_for_object_kind(&object_kind),
TableMetadataMaintenanceReason::NoCurrentReachability,
]);
let state = match backend.read_object(table_bucket, &object_location).await? {
let state = match backend.object_metadata(table_bucket, &object_location).await? {
Some(object) if metadata_candidate_is_past_safety_window(object.mod_time, now) => {
reasons.insert(TableMetadataMaintenanceReason::SafetyWindowSatisfied);
cleanup_candidate_locations.push(object_location.clone());
+15
View File
@@ -110,6 +110,15 @@ pub(crate) const TABLE_MAINTENANCE_CONFIG_VERSION: u16 = 1;
pub(crate) const TABLE_EXTERNAL_CATALOG_BRIDGE_VERSION: u16 = 1;
pub(crate) const TABLE_CATALOG_BACKING_MANIFEST_VERSION: u16 = 1;
pub(crate) const ENV_TABLE_CATALOG_BACKING: &str = "RUSTFS_TABLE_CATALOG_BACKING";
/// Seconds a create, register, or commit may spend preparing (queueing for the
/// bucket publication fence, validating references, inventories) before it is
/// refused with 503 so object writes in the bucket are admitted. Never below
/// the object lock-acquire timeout and never above one day, so the deadline
/// instant cannot overflow.
pub(crate) const ENV_TABLE_CATALOG_PUBLICATION_PREPARATION_TIMEOUT_SECS: &str =
"RUSTFS_TABLE_CATALOG_PUBLICATION_PREPARATION_TIMEOUT_SECS";
pub(crate) const DEFAULT_TABLE_CATALOG_PUBLICATION_PREPARATION_TIMEOUT_SECS: u64 = 30;
pub(crate) const MAX_TABLE_CATALOG_PUBLICATION_PREPARATION_TIMEOUT_SECS: u64 = 86_400;
pub(crate) const ENV_TABLE_CATALOG_PUBLICATION_FENCE_FLEET_CONFIRMED: &str =
"RUSTFS_TABLE_CATALOG_PUBLICATION_FENCE_FLEET_CONFIRMED";
pub(crate) const ENV_TABLE_CATALOG_STRONG_SNAPSHOT_V2: &str = "RUSTFS_TABLE_CATALOG_STRONG_SNAPSHOT_V2";
@@ -130,6 +139,8 @@ const TABLE_COMMIT_MAX_AVRO_BYTES: usize = 512 * 1024 * 1024;
const TABLE_COMMIT_MAX_FILE_REFERENCES: usize = 1_000_000;
const TABLE_COMMIT_MAX_STATISTICS_OBJECTS: usize = 1_024;
const TABLE_COMMIT_MAX_STATISTICS_BYTES: usize = 512 * 1024 * 1024;
const TABLE_COMPACTION_MAX_STORED_BYTES: usize = 512 * 1024 * 1024;
const TABLE_COMPACTION_MAX_DECODED_BYTES: usize = 512 * 1024 * 1024;
const TABLE_STATISTICS_FILE_MAX_SIZE: usize = 128 * 1024 * 1024;
pub(crate) const TABLE_COMMIT_OBJECT_VALIDATION_CONCURRENCY: usize = 16;
pub const TABLE_RESERVED_PREFIX: &str = BUCKET_TABLE_RESERVED_PREFIX;
@@ -173,6 +184,10 @@ const WAREHOUSE_INDEX_STATE_FILE: &str = "state.json";
const TABLE_RENAME_ROOT: &str = "renames";
const WAREHOUSE_INDEX_MAX_PREFIX_DEPTH: usize = 64;
const TABLE_DATA_PLANE_INDEX_MISS_SCAN_MAX_CATALOG_OBJECTS: usize = 4096;
/// Listing pages an index-miss scan may consume before failing closed. Only
/// table entries count toward the object limit, so maintenance reports and
/// other catalog objects cannot trip it; this bounds the listing work itself.
const TABLE_DATA_PLANE_INDEX_MISS_SCAN_MAX_PAGES: usize = 64;
const EXTERNAL_CATALOG_ROOT: &str = "external-catalog";
const EXTERNAL_CATALOG_BRIDGE_FILE: &str = "bridge.json";
const MAINTENANCE_ROOT: &str = "maintenance";

Some files were not shown because too many files have changed in this diff Show More