Compare commits

..

19 Commits

Author SHA1 Message Date
overtrue 9d7a268002 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 08:55:51 +08:00
overtrue 1ce559f0bd Merge branch 'fix/ant-request-layer-batch' into fix/ant-table-catalog-batch 2026-11-05 06:36:38 +08:00
overtrue 34c6896768 test(ecstore): pass the index owner to the Linux cleanup-loop test 2026-11-05 06:36:30 +08:00
overtrue e505589fbe Merge branch 'fix/ant-request-layer-batch' into fix/ant-table-catalog-batch 2026-11-05 06:08:02 +08: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 a4d17865ee Merge branch 'fix/ant-request-layer-batch' into fix/ant-table-catalog-batch 2026-11-05 04:12:22 +08:00
overtrue 004d872a27 fix(tier): stream GET bodies and document replication budgets 2026-11-05 03:42:02 +08:00
overtrue 0bb193e87c Merge branch 'fix/ant-request-layer-batch' into fix/ant-table-catalog-batch 2026-11-05 03:24:58 +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 b315958598 Merge branch 'fix/ant-request-layer-batch' into fix/ant-table-catalog-batch 2026-11-05 02:54:15 +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
overtrue 1558c2ed3f Merge branch 'fix/ant-request-layer-batch' into fix/ant-table-catalog-batch 2026-11-05 01:17:10 +08:00
overtrue 524c774e2f 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.
2026-11-05 01:17:10 +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
90 changed files with 13624 additions and 1666 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
sha256-darwin=7c705942d3a9e583ebaa85845daa21c4f81fdbf36def24db931aaef38636c53e
sha256-linux=5c33dc395233671cb77452f8fb12900ecdbeb368632222b96835d310bce1aab8
sha256-linux=01e1f5569be60674e30d9e30a9961c5c53a6cea6ca1f7913218f2adf7291d574
+1 -1
View File
@@ -1 +1 @@
sha256=00f8a56b75990b487a594b97c36610b94476a6944ba00473e37187ea654d42bf
sha256=f794c004e5952147c79dadbe6b7c84804bc986f34a8142731fac8864004e5130
+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
+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 @@
+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?;
+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;
@@ -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>>,
@@ -232,7 +238,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 +246,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 +267,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;
};
@@ -350,17 +372,18 @@ impl IndexOwner {
}
#[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 +412,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 })
}
}
+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 -6
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 {
@@ -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");
}
+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. 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). 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, so an orphaned file (reclaimed by maintenance) is the worst outcome of an expired create. 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 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
@@ -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
+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)
}
}
+208 -37
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,29 @@ where
.map_err(catalog_store_error)
}
/// 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 +6181,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,
@@ -6055,7 +6222,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 +6645,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 +7013,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 +7023,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,
@@ -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 {}
+767 -11
View File
@@ -277,6 +277,41 @@ async fn table_catalog_authentication_and_credentials_use_the_request_context()
assert!(Arc::ptr_eq(&resolved_store, &store));
let mut request = request;
iam.set_policy(policy_name, Policy::parse_config(br#"{
"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":["admin:GetTable","admin:CreatePluginDelegation"]},
{"Effect":"Deny","Action":["admin:GetTable","admin:CreatePluginDelegation"],"Condition":{"NotIpAddress":{"aws:SourceIp":"10.0.0.0/8"}}}
]
}"#).unwrap()).await.unwrap();
request
.extensions
.insert(Some(crate::server::RemoteAddr("10.1.2.3:4567".parse().unwrap())));
request.headers.insert("x-forwarded-for", "10.1.2.3".parse().unwrap());
for (client, allowed) in [
(None, true),
(Some("203.0.113.17:7890"), false),
(Some("10.8.7.6:7890"), true),
] {
request.extensions.remove::<rustfs_trusted_proxies::ClientInfo>();
if let Some(client) = client {
request
.extensions
.insert(rustfs_trusted_proxies::ClientInfo::direct(client.parse().unwrap()));
}
let unscoped = authorize_table_catalog_request(&request, AdminAction::GetTableAction).await;
let scoped = authorize_table_catalog_resource_request(&request, &resource, AdminAction::GetTableAction).await;
let delegated =
crate::admin::handlers::plugin_delegation::owner(&request, AdminAction::CreatePluginDelegationAction).await;
if allowed {
unscoped.expect("verified private address authorizes table catalog access");
scoped.expect("verified private address authorizes scoped table access");
delegated.expect("verified private address authorizes delegation");
} else {
assert_eq!(unscoped.expect_err("external client is denied").code(), &S3ErrorCode::AccessDenied);
assert_eq!(scoped.err().expect("external client is denied").code(), &S3ErrorCode::AccessDenied);
assert_eq!(delegated.err().expect("external client is denied").code(), &S3ErrorCode::AccessDenied);
}
}
request.method = Method::POST;
request.uri = "/iceberg/v1/request-warehouse/namespaces/analytics/tables/events/maintenance/worker/run"
.parse()
@@ -1473,6 +1508,7 @@ fn table_catalog_handlers_require_table_admin_actions() {
2
);
assert_eq!(rename_block.matches("AdminAction::SetTableAction").count(), 2);
assert!(rename_block.contains("rename_table_response("));
let migration_block = operation_block(&src, "GetTableCatalogMigrationHandler");
assert!(
@@ -1731,7 +1767,7 @@ fn table_bucket_toggle_handlers_reload_peer_metadata_after_publication() {
#[test]
fn table_commit_publication_acquires_migration_permits_before_publication_lock() {
let src = table_catalog_handler_source();
let block = function_block(&src, "async fn begin_bucket_publication");
let block = function_block(&src, "async fn begin_bucket_publication_inner");
let migration_permits = block
.find("acquire_catalog_migration_read_guards(table_bucket)")
.expect("publication should acquire catalog migration permits");
@@ -7128,6 +7164,371 @@ async fn commit_publication_acquires_discovered_object_locks_in_key_order() {
crate::table_catalog::TableCatalogObjectBackend::complete_table_commit_publication(&commit_backend);
}
#[tokio::test]
async fn catalog_publication_reads_graph_once_for_registration_and_relocation() {
for relocate in [false, true] {
let backend = TestTableCatalogObjectBackend::content_addressed();
let store = crate::table_catalog::ObjectTableCatalogStore::new(backend.clone());
let namespace = crate::table_catalog::Namespace::parse("analytics").expect("namespace");
let (metadata_location, mut metadata, version_token) = if relocate {
let created = create_standard_events_table(&store, &backend, &namespace).await;
let current = store
.load_table("warehouse", "analytics", "events")
.await
.expect("load table")
.expect("table exists");
let table = crate::table_catalog::IdentifierSegment::parse("events").expect("table");
(
crate::table_catalog::default_table_metadata_file_path(&namespace, &table, "00002-relocation.metadata.json"),
created.metadata,
Some(current.version_token),
)
} else {
let (_, location) = seed_events_registration_target(&store, &backend).await;
(
location,
test_table_metadata_json("metadata-table-uuid", "s3://warehouse/tables/table-id"),
None,
)
};
let warehouse = "s3://warehouse/tables/revision-graph";
let manifest_list = format!("{warehouse}/metadata/snap-10.avro");
let manifest = format!("{warehouse}/metadata/manifest-snap-10.avro");
let data_file = format!("{warehouse}/data/part-10.parquet");
metadata["location"] = serde_json::json!(warehouse);
metadata["last-sequence-number"] = serde_json::json!(1);
metadata["snapshots"] = serde_json::json!([{
"snapshot-id": 10, "sequence-number": 1, "timestamp-ms": 10,
"manifest-list": manifest_list, "summary": {"operation": "append"}
}]);
metadata["current-snapshot-id"] = serde_json::json!(10);
metadata["refs"] = serde_json::json!({"main": {"type": "branch", "snapshot-id": 10}});
backend.put_json("warehouse", &metadata_location, metadata).await;
seed_test_snapshot_manifest(&backend, "warehouse", &manifest_list, 10, 1, &[(&data_file, 0, 1, 10, 1)]).await;
let commit = TableCommitObjectBackend::trusted(backend.clone());
if let Some(version_token) = version_token {
update_table_metadata_location_response(
&store,
&commit,
"warehouse",
&namespace,
"events",
UpdateTableMetadataLocationRequest {
metadata_location: table_metadata_location_for_client("warehouse", &metadata_location),
version_token,
commit_id: Some("revision-relocation".to_string()),
idempotency_key: None,
},
)
.await
.expect("relocation publishes the validated graph");
} else {
register_table_response(
&store,
&commit,
"warehouse",
&namespace,
RegisterTableRequest {
name: "events".to_string(),
metadata_location: metadata_location.clone(),
overwrite: false,
},
true,
)
.await
.expect("registration publishes the validated graph");
}
let published = store
.load_table("warehouse", "analytics", "events")
.await
.expect("load published table")
.expect("table exists");
assert_eq!(published.warehouse_location, warehouse);
assert_eq!(published.metadata_location, metadata_location);
let state = backend.state.lock().await;
for location in [&manifest_list, &manifest] {
let key = ("warehouse".to_string(), test_snapshot_object_key("warehouse", location));
assert_eq!(
state.read_attempts.get(&key),
Some(&1),
"manifest bodies must be read once, before the bucket fence: {location}"
);
assert_eq!(
state.read_lock_acquisitions.get(&key),
Some(&1),
"publication must retain the manifest object read lock"
);
}
}
}
#[tokio::test]
async fn catalog_create_binds_generated_metadata_before_publication() {
let backend = TestTableCatalogObjectBackend::content_addressed();
let store = crate::table_catalog::ObjectTableCatalogStore::new(backend.clone());
let namespace = crate::table_catalog::Namespace::parse("analytics").expect("namespace");
let created = create_standard_events_table(&store, &backend, &namespace).await;
let object = table_metadata_location_for_catalog("warehouse", &created.metadata_location).expect("metadata key");
let state = backend.state.lock().await;
let key = ("warehouse".to_string(), object);
assert_eq!(
state.read_attempts.get(&key),
Some(&1),
"initial create keeps its generated metadata SHA read bounded"
);
assert!(
state
.objects
.get(&key)
.expect("created metadata exists")
.body_revision
.is_some()
);
assert_eq!(
state.read_lock_acquisitions.get(&key),
Some(&1),
"create keeps its metadata body stable until publication"
);
}
#[tokio::test]
async fn commit_publication_revalidates_legacy_bytes_with_a_total_budget() {
use crate::table_catalog::{TableCatalogObjectBackend as _, TableCatalogStoreError};
let backend = TestTableCatalogObjectBackend::content_addressed();
let first = "metadata/legacy-first.avro";
let second = "metadata/legacy-second.avro";
let payload = vec![b'x'; TABLE_COMMIT_LEGACY_REVALIDATION_MAX_BYTES / 2 + 1];
for object in [first, second] {
backend.put_bytes("warehouse", object, payload.clone()).await;
backend
.state
.lock()
.await
.objects
.get_mut(&("warehouse".to_string(), object.to_string()))
.expect("legacy fixture exists")
.body_revision = None;
}
let commit = TableCommitObjectBackend::trusted(backend.clone());
commit
.read_object("warehouse", first)
.await
.expect("first legacy object fits");
commit
.read_object("warehouse", first)
.await
.expect("repeated observations do not consume the budget twice");
let error = commit
.read_object("warehouse", second)
.await
.expect_err("combined legacy payload exceeds the budget");
assert!(matches!(error, TableCatalogStoreError::Unavailable(_)));
assert!(
backend.lock_attempts.lock().await.is_empty(),
"oversized legacy validation fails before publication locks"
);
// A supported small legacy object still uses the original body fingerprint,
// even when metadata and the absent revision cannot distinguish an overwrite.
let legacy = "metadata/legacy-small.json";
backend.put_bytes("warehouse", legacy, b"before".to_vec()).await;
backend
.state
.lock()
.await
.objects
.get_mut(&("warehouse".to_string(), legacy.to_string()))
.expect("small legacy fixture exists")
.body_revision = None;
let commit = TableCommitObjectBackend::trusted(backend.clone());
commit.read_object("warehouse", legacy).await.expect("read legacy object");
backend
.state
.lock()
.await
.objects
.get_mut(&("warehouse".to_string(), legacy.to_string()))
.expect("legacy object remains")
.data = b"after!".to_vec();
let error = commit
.prepare_table_commit_publication("warehouse", "analytics", "events")
.await
.expect_err("legacy SHA validation detects an equal-length overwrite with unchanged metadata");
assert!(matches!(error, TableCatalogStoreError::Conflict(_)));
// Rewriting old objects supplies fresh server revisions and removes the
// legacy-only size restriction without removing publication validation.
for object in [first, second] {
backend.put_bytes("warehouse", object, payload.clone()).await;
}
let commit = TableCommitObjectBackend::trusted(backend.clone());
for object in [first, second] {
commit
.read_object("warehouse", object)
.await
.expect("rewritten object can be validated");
}
let reads = backend.read_call_count().await;
commit
.begin_table_bucket_commit_publication("warehouse")
.await
.expect("take bucket fence");
commit
.prepare_table_commit_publication("warehouse", "analytics", "events")
.await
.expect("publish rewritten objects");
assert_eq!(
backend.read_call_count().await,
reads,
"revision validation must not reread bodies under the bucket fence"
);
commit.complete_table_commit_publication();
}
#[tokio::test]
async fn commit_publication_bounds_unread_generated_metadata() {
use crate::table_catalog::{TableCatalogObjectBackend as _, TableCatalogPutPrecondition, TableCatalogStoreError};
let backend = TestTableCatalogObjectBackend::content_addressed();
let payload = vec![b'x'; TABLE_COMMIT_LEGACY_REVALIDATION_MAX_BYTES + 1];
let commit = TableCommitObjectBackend::trusted(backend.clone());
commit
.put_object("warehouse", "metadata/generated.json", payload, TableCatalogPutPrecondition::IfAbsent)
.await
.expect("write generated metadata before its normal readback");
let error = commit
.prepare_table_commit_publication("warehouse", "analytics", "events")
.await
.expect_err("publication cannot read oversized unbound generated metadata under its locks");
assert!(matches!(error, TableCatalogStoreError::Unavailable(_)));
assert!(backend.lock_attempts.lock().await.is_empty());
assert_eq!(backend.read_call_count().await, 0);
commit
.read_object("warehouse", "metadata/generated.json")
.await
.expect("the existing generated metadata readback binds the revision");
let reads = backend.read_call_count().await;
commit
.prepare_table_commit_publication("warehouse", "analytics", "events")
.await
.expect("bound generated metadata can publish");
assert_eq!(backend.read_call_count().await, reads);
commit.complete_table_commit_publication();
}
#[tokio::test]
async fn commit_publication_accepts_metadata_only_changes_with_same_body_revision() {
use crate::table_catalog::TableCatalogObjectBackend as _;
let backend = TestTableCatalogObjectBackend::content_addressed();
backend
.put_bytes("warehouse", "metadata/current.json", b"unchanged body".to_vec())
.await;
let commit = TableCommitObjectBackend::trusted(backend.clone());
commit
.read_object("warehouse", "metadata/current.json")
.await
.expect("observe body");
{
let mut state = backend.state.lock().await;
let object = state
.objects
.get_mut(&("warehouse".to_string(), "metadata/current.json".to_string()))
.expect("object exists");
object.etag = "metadata-only-update".to_string();
object.mod_time = Some(time::OffsetDateTime::now_utc());
}
commit
.prepare_table_commit_publication("warehouse", "analytics", "events")
.await
.expect("unchanged body remains valid after a metadata-only update");
assert_eq!(backend.read_call_count().await, 1);
commit.complete_table_commit_publication();
}
#[tokio::test]
async fn commit_publication_accepts_unchanged_legacy_bytes() {
use crate::table_catalog::TableCatalogObjectBackend as _;
let backend = TestTableCatalogObjectBackend::content_addressed();
backend
.put_bytes("warehouse", "metadata/legacy.json", b"unchanged".to_vec())
.await;
backend
.state
.lock()
.await
.objects
.get_mut(&("warehouse".to_string(), "metadata/legacy.json".to_string()))
.expect("legacy fixture exists")
.body_revision = None;
let commit = TableCommitObjectBackend::trusted(backend.clone());
commit
.read_object("warehouse", "metadata/legacy.json")
.await
.expect("observe legacy body");
commit
.prepare_table_commit_publication("warehouse", "analytics", "events")
.await
.expect("small legacy object remains supported");
assert_eq!(
backend.read_call_count().await,
2,
"legacy body must still be verified under its object lock"
);
commit.complete_table_commit_publication();
}
#[tokio::test]
async fn catalog_body_revision_changes_on_real_identical_body_overwrite() {
use crate::table_catalog::{
EcStoreTableCatalogObjectBackend, StrongTableCatalogRuntime, TableCatalogObjectBackend as _, TableCatalogPutPrecondition,
};
let (_temp_dir, _disk_paths, store) = crate::app::gating_test_env::isolated_multi_pool_ecstore().await;
let bucket = "catalog-body-revision";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create bucket");
let backend = EcStoreTableCatalogObjectBackend::new_with_strong_runtime(store, StrongTableCatalogRuntime::default());
backend
.put_object(bucket, "manifest.avro", b"same bytes".to_vec(), TableCatalogPutPrecondition::IfAbsent)
.await
.expect("write object");
let before = backend
.read_object(bucket, "manifest.avro")
.await
.expect("read body")
.expect("object exists");
let before_stat = backend
.object_metadata(bucket, "manifest.avro")
.await
.expect("stat body")
.expect("object exists");
assert!(before.body_revision.is_some());
assert_eq!(before.body_revision, before_stat.body_revision);
backend
.put_object(
bucket,
"manifest.avro",
b"same bytes".to_vec(),
TableCatalogPutPrecondition::IfMatch(before.etag.clone().expect("etag exists")),
)
.await
.expect("overwrite same bytes");
let after = backend
.read_object(bucket, "manifest.avro")
.await
.expect("read overwritten body")
.expect("object exists");
let after_stat = backend
.object_metadata(bucket, "manifest.avro")
.await
.expect("stat overwritten body")
.expect("object exists");
assert_eq!(before.etag, after.etag);
assert_ne!(before.body_revision, after.body_revision);
assert_eq!(after.body_revision, after_stat.body_revision);
}
#[tokio::test]
async fn commit_publication_revalidates_objects_after_ordered_lock_acquisition() {
let metadata_backend = TestTableCatalogObjectBackend::content_addressed();
@@ -7160,6 +7561,7 @@ async fn commit_publication_binds_fingerprint_to_returned_bytes() {
metadata_backend.state.lock().await.objects.insert(
("warehouse".to_string(), object.to_string()),
TestCatalogObjectRecord {
body_revision: Some(Uuid::new_v4()),
data: replacement,
etag: original_etag.clone(),
mod_time: None,
@@ -7173,6 +7575,7 @@ async fn commit_publication_binds_fingerprint_to_returned_bytes() {
metadata_backend.state.lock().await.objects.insert(
("warehouse".to_string(), object.to_string()),
TestCatalogObjectRecord {
body_revision: Some(Uuid::new_v4()),
data: original,
etag: original_etag,
mod_time: None,
@@ -7226,6 +7629,7 @@ async fn standard_commit_publishes_more_than_ten_thousand_live_files() {
state.objects.insert(
("warehouse".to_string(), test_snapshot_object_key("warehouse", file)),
TestCatalogObjectRecord {
body_revision: Some(Uuid::new_v4()),
data: data.clone(),
etag: etag.clone(),
mod_time: None,
@@ -7256,7 +7660,7 @@ async fn standard_commit_publishes_more_than_ten_thousand_live_files() {
]
}))
.expect("boundary commit request should parse");
let commit_backend = TableCommitObjectBackend::trusted(metadata_backend);
let commit_backend = TableCommitObjectBackend::trusted(metadata_backend.clone());
let result = standard_commit_table_response(&store, &commit_backend, "warehouse", &namespace, "events", request).await;
let response = commit_backend
@@ -7265,6 +7669,14 @@ async fn standard_commit_publishes_more_than_ten_thousand_live_files() {
.expect("a valid snapshot with more than ten thousand live files should publish");
assert_eq!(response.metadata["current-snapshot-id"], 20);
for location in [&manifest_list, &manifest] {
let key = ("warehouse".to_string(), test_snapshot_object_key("warehouse", location));
assert_eq!(
metadata_backend.state.lock().await.read_attempts.get(&key),
Some(&2),
"graph and conflict validation each read manifests once; publication must not read them again"
);
}
assert_eq!(
store
.load_table("warehouse", "analytics", "events")
@@ -7565,6 +7977,78 @@ async fn metadata_location_api_rejects_unbound_legacy_catalog_identity() {
assert_events_table_entry_unchanged(&store, &legacy_entry).await;
}
#[tokio::test]
async fn rename_request_cancellation_finishes_the_persisted_bucket_fence() {
let backend = TestTableCatalogObjectBackend::content_addressed();
let store = crate::table_catalog::ObjectTableCatalogStore::new(backend.clone());
let namespace = crate::table_catalog::Namespace::parse("analytics").unwrap();
let source = crate::table_catalog::IdentifierSegment::parse("events").unwrap();
let destination = crate::table_catalog::IdentifierSegment::parse("renamed").unwrap();
create_standard_events_table(&store, &backend, &namespace).await;
let original = store.load_table("warehouse", "analytics", "events").await.unwrap().unwrap();
let paths = crate::table_catalog::TableCatalogObjectPaths::default();
let source_path = paths.table_entry_path("warehouse", &namespace, &source);
let attempt = backend
.put_attempt_count(crate::admin::storage_api::RUSTFS_META_BUCKET, &source_path)
.await
+ 2;
let pause = backend
.pause_put_attempt(crate::admin::storage_api::RUSTFS_META_BUCKET, &source_path, attempt)
.await;
let request = tokio::spawn(table::rename_table_response(
store.clone(),
"warehouse".to_string(),
namespace.clone(),
source.clone(),
namespace.clone(),
destination.clone(),
));
tokio::time::timeout(std::time::Duration::from_secs(10), pause.wait_started())
.await
.expect("rename reaches persisted fence");
assert!(
store
.get_table_bucket("warehouse")
.await
.unwrap()
.unwrap()
.active_rename_id
.is_some()
);
request.abort();
assert!(request.await.unwrap_err().is_cancelled());
pause.release();
tokio::time::timeout(std::time::Duration::from_secs(10), async {
while store
.get_table_bucket("warehouse")
.await
.unwrap()
.unwrap()
.active_rename_id
.is_some()
{
tokio::task::yield_now().await;
}
})
.await
.expect("detached mutation clears the durable fence");
let renamed = store.load_table("warehouse", "analytics", "renamed").await.unwrap().unwrap();
assert_eq!(renamed.table_id, original.table_id);
assert_eq!(renamed.metadata_location, original.metadata_location);
assert!(store.load_table("warehouse", "analytics", "events").await.unwrap().is_none());
assert!(
store
.resolve_table_data_plane_resource("warehouse", "ordinary.txt")
.await
.unwrap()
.is_none()
);
table::rename_table_response(store.clone(), "warehouse".to_string(), namespace.clone(), destination, namespace, source)
.await
.expect("subsequent ordinary rename succeeds");
assert!(store.load_table("warehouse", "analytics", "events").await.unwrap().is_some());
}
#[tokio::test]
async fn table_metadata_maintenance_helper_runs_dry_run_and_delete() {
let backend = TestTableCatalogObjectBackend::content_addressed();
@@ -15963,6 +16447,7 @@ struct ColdStrongRegistrationBackend<B> {
armed: Arc<std::sync::atomic::AtomicBool>,
reader_ready: Arc<tokio::sync::Notify>,
resume: Arc<tokio::sync::Notify>,
backup_writer_waiting: Arc<tokio::sync::Notify>,
}
impl<B> ColdStrongRegistrationBackend<B> {
@@ -16012,7 +16497,16 @@ impl<B: crate::table_catalog::TableCatalogObjectBackend> crate::table_catalog::T
bucket: &str,
object: &str,
) -> crate::table_catalog::TableCatalogStoreResult<crate::table_catalog::TableCatalogLockGuard> {
self.inner.acquire_write_lock(bucket, object).await
let mut acquire = self.inner.acquire_write_lock(bucket, object);
if bucket == crate::admin::storage_api::RUSTFS_META_BUCKET
&& object == crate::table_catalog::TableCatalogObjectPaths::default().backing_migration_global_fence_lock_path()
{
match futures::poll!(acquire.as_mut()) {
std::task::Poll::Ready(result) => return result,
std::task::Poll::Pending => self.backup_writer_waiting.notify_one(),
}
}
acquire.await
}
async fn read_object(
@@ -16116,6 +16610,7 @@ async fn cold_strong_registration_completes_while_other_bucket_backup_waits() {
armed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
reader_ready: Arc::new(tokio::sync::Notify::new()),
resume: Arc::new(tokio::sync::Notify::new()),
backup_writer_waiting: Arc::new(tokio::sync::Notify::new()),
};
let catalog = StrongTableCatalogStore::new(backend.clone());
for bucket in [&bucket, &backup_bucket] {
@@ -16176,17 +16671,18 @@ async fn cold_strong_registration_completes_while_other_bucket_backup_waits() {
.await
.expect("registration holds first real global reader");
// The backup targets another bucket, so its documented writer-drain precondition is met.
let backup_catalog = StrongTableCatalogStore::new(inner);
let mut backup = Box::pin(backup_catalog.create_durable_catalog_backup(&backup_bucket, None));
assert!(
futures::poll!(backup.as_mut()).is_pending(),
"actual backup writer must queue behind held registration reader"
);
let backup_catalog = StrongTableCatalogStore::new(backend.clone());
// Keep driving route discovery and snapshot I/O until the global write lock itself is pending.
let backup = tokio::spawn(async move { backup_catalog.create_durable_catalog_backup(&backup_bucket, None).await });
tokio::time::timeout(StdDuration::from_secs(5), backend.backup_writer_waiting.notified())
.await
.expect("actual backup writer must queue behind held registration reader");
backend.resume.notify_one();
let result = tokio::time::timeout(StdDuration::from_secs(2), &mut registration).await;
if result.is_err() {
// Prove that the queued backup, rather than ECStore I/O latency, caused the stall.
drop(backup);
backup.abort();
assert!(backup.await.unwrap_err().is_cancelled());
tokio::time::timeout(StdDuration::from_secs(5), &mut registration)
.await
.expect("registration must resume once only the queued backup is cancelled")
@@ -16201,6 +16697,7 @@ async fn cold_strong_registration_completes_while_other_bucket_backup_waits() {
tokio::time::timeout(StdDuration::from_secs(5), backup)
.await
.expect("the other bucket backup should resume when registration releases its reader")
.expect("backup task joins")
.expect("the other bucket backup succeeds");
}
@@ -16528,6 +17025,66 @@ async fn cleanup_empty_table_bucket_reclaims_twenty_dropped_tables_and_allows_bu
object_store.delete_bucket(&bucket, &Default::default()).await.unwrap();
}
#[tokio::test]
#[serial_test::serial]
async fn cleanup_empty_table_bucket_validates_directory_candidate_path_and_body_before_deletion() {
use crate::admin::storage_api::RUSTFS_META_BUCKET;
use crate::table_catalog::{TableCatalogObjectPaths, TableCatalogPutPrecondition};
let (_temp, _disks, storage) = crate::app::gating_test_env::isolated_multi_pool_ecstore().await;
let bucket = format!("cleanup-directory-{}", Uuid::new_v4().simple());
let backend = crate::table_catalog::EcStoreTableCatalogObjectBackend::new_with_strong_runtime(
storage.clone(),
crate::table_catalog::StrongTableCatalogRuntime::default(),
);
let catalog = crate::table_catalog::ConfiguredTableCatalogStore::new_for_test(
backend.clone(),
crate::table_catalog::TableCatalogBackingMode::ObjectBacked,
);
let metadata = create_dropped_empty_tables_for_cleanup(&catalog, &backend, &storage, &bucket, 1).await;
let paths = TableCatalogObjectPaths::default();
let bucket_entry = paths.table_bucket_entry_path(&bucket);
let directory = format!("{}/table-entry-directory/v1/", bucket_entry.rsplit_once('/').unwrap().0);
let real = format!("{directory}analytics/tables/events_00/table-entry.json");
// Entry writers add candidates only while the directory is active; seed the
// one a backfill would have written so cleanup has a valid candidate to keep.
let real_target = format!("{}analytics/tables/events_00/table-entry.json", paths.namespace_entries_prefix(&bucket));
backend
.put_object(RUSTFS_META_BUCKET, &real, real_target.into_bytes(), TableCatalogPutPrecondition::IfAbsent)
.await
.unwrap();
assert!(backend.object_exists(RUSTFS_META_BUCKET, &real).await.unwrap());
for (suffix, target_suffix) in [
("bad segment/tables/x/table-entry.json", "bad segment/tables/x/table-entry.json"),
("analytics/tables/forged/table-entry.json", "analytics/tables/events_00/table-entry.json"),
] {
let unknown = format!("{directory}{suffix}");
let target = format!("{}{target_suffix}", paths.namespace_entries_prefix(&bucket));
backend
.put_object(RUSTFS_META_BUCKET, &unknown, target.into_bytes(), TableCatalogPutPrecondition::IfAbsent)
.await
.unwrap();
let publication = TableCommitObjectBackend::trusted(backend.clone());
assert!(
cleanup_empty_table_bucket_response(&catalog, &publication, &storage, &bucket)
.await
.is_err()
);
assert!(backend.object_exists(RUSTFS_META_BUCKET, &unknown).await.unwrap());
assert!(backend.object_exists(RUSTFS_META_BUCKET, &real).await.unwrap());
assert!(backend.object_exists(&bucket, &metadata[0]).await.unwrap());
backend.delete_object(RUSTFS_META_BUCKET, &unknown).await.unwrap();
}
let publication = TableCommitObjectBackend::trusted(backend.clone());
assert!(
!cleanup_empty_table_bucket_response(&catalog, &publication, &storage, &bucket)
.await
.unwrap()
.enabled
);
assert!(!backend.object_exists(RUSTFS_META_BUCKET, &real).await.unwrap());
}
#[tokio::test]
#[serial_test::serial]
async fn cleanup_empty_table_bucket_validates_authorization_and_metadata_before_deleting() {
@@ -16705,7 +17262,7 @@ async fn cleanup_empty_table_bucket_handler_enforces_request_object_permissions(
assert!(!backend.object_exists(&bucket, object).await.unwrap());
}
} else {
assert_eq!(result.unwrap_err().code(), &S3ErrorCode::AccessDenied);
assert_eq!(result.expect_err("request must be denied").code(), &S3ErrorCode::AccessDenied);
for object in &metadata_objects {
assert!(backend.object_exists(&bucket, object).await.unwrap());
}
@@ -17099,3 +17656,202 @@ async fn cleanup_empty_table_bucket_stops_before_delete_when_publication_lease_i
);
object_store.delete_bucket(&bucket, &Default::default()).await.unwrap();
}
#[tokio::test(start_paused = true)]
async fn publication_deadline_first_fenced_metadata_put_finishes_after_budget() {
use crate::table_catalog::{TableCatalogObjectBackend, TableCatalogPutPrecondition, TableCommitPublication};
let backend = TestTableCatalogObjectBackend::content_addressed();
let publication = TableCommitObjectBackend::trusted(backend.clone());
publication.begin_table_bucket("warehouse").await.unwrap();
let key = "generated.metadata.json";
let pause = backend.pause_next_put("warehouse", key).await;
let worker = publication.clone();
let put = tokio::spawn(async move {
worker
.put_object("warehouse", key, b"{}".to_vec(), TableCatalogPutPrecondition::IfAbsent)
.await
});
pause.wait_started().await;
tokio::time::advance(crate::storage_api::table::get_lock_acquire_timeout() + StdDuration::from_millis(1)).await;
assert!(publication.holds_table_bucket("warehouse"));
assert!(!put.is_finished());
pause.release();
put.await.unwrap().unwrap();
publication
.preparation
.read(&publication, backend.object_metadata("warehouse", key))
.await
.unwrap()
.unwrap();
assert!(publication.holds_table_bucket("warehouse"));
publication.complete();
}
#[tokio::test(start_paused = true)]
async fn publication_deadline_slow_head_clears_partial_http_preparation() {
use crate::table_catalog::test_support::TestCatalogObjectPause;
use crate::table_catalog::{TableCatalogObjectBackend, TableCommitPublication};
let backend = TestTableCatalogObjectBackend::content_addressed();
backend.seed_object("warehouse", "data/file.parquet", b"data".to_vec()).await;
let publication = TableCommitObjectBackend::trusted(backend.clone());
publication.object_exists("warehouse", "data/file.parquet").await.unwrap();
publication.begin_table_bucket("warehouse").await.unwrap();
let pause = TestCatalogObjectPause::default();
backend
.state
.lock()
.await
.pause_metadata
.insert(("warehouse".to_string(), "data/file.parquet".to_string()), pause.clone());
let preparing = {
let publication = publication.clone();
tokio::spawn(async move { publication.prepare("warehouse", "analytics", "events").await })
};
pause.wait_started().await;
tokio::time::advance(crate::storage_api::table::get_lock_acquire_timeout() + StdDuration::from_millis(1)).await;
assert!(
matches!(preparing.await.unwrap(), Err(crate::table_catalog::TableCatalogStoreError::Unavailable(message)) if message.contains("preparation deadline"))
);
assert!(!publication.holds_table_bucket("warehouse"));
assert!(!publication.holds_table("warehouse", "analytics", "events"));
assert!(publication.publication.lock().observed_objects.is_empty());
assert!(backend.state.lock().await.put_attempts.is_empty());
pause.release();
let _writer = backend
.acquire_read_lock("warehouse", &crate::table_catalog::default_table_bucket_publication_lock_path())
.await
.unwrap();
}
#[tokio::test]
#[serial_test::serial]
async fn directory_management_handlers_enforce_migration_permission_before_mutation() {
temp_env::async_with_vars([
(crate::table_catalog::ENV_TABLE_CATALOG_BACKING, Some("object")),
("RUSTFS_TABLE_CATALOG_DIRECTORY_INDEX", Some("true")),
("RUSTFS_TABLE_CATALOG_DIRECTORY_INDEX_FLEET_CONFIRMED", Some("true")),
], async {
let (_temp_dir, _disks, object_store) = crate::app::gating_test_env::isolated_multi_pool_ecstore().await;
object_store
.make_bucket("warehouse", &MakeBucketOptions::default())
.await
.unwrap();
rustfs_iam::store::object::ObjectStore::new(object_store.clone())
.save_iam_config(
serde_json::json!({"version": 1}),
format!("{}/format.json", *rustfs_iam::store::object::IAM_CONFIG_PREFIX),
)
.await
.unwrap();
let iam = rustfs_iam::build_iam_sys(object_store.clone()).await.unwrap();
let access_key = "directory-upgrade-user";
let secret_key = "directory-upgrade-secret";
iam.create_user(
access_key,
&AddOrUpdateUserReq {
secret_key: secret_key.to_string(),
policy: None,
status: AccountStatus::Enabled,
},
)
.await
.unwrap();
let context = Arc::new(AppContext::new(
object_store.clone(),
Arc::new(RequestIam { handle: iam.clone() }),
Arc::new(RequestKms),
));
assert!(context.publish_action_credentials(rustfs_credentials::Credentials {
access_key: "directory-root".to_string(),
secret_key: "directory-root-secret".to_string(),
status: "on".to_string(),
..Default::default()
}));
let setup = table_catalog_handler_request(context.clone(), access_key, secret_key);
let backend = table_catalog_backend_from_extensions(&setup.extensions).unwrap();
let configured = table_catalog_store_from_backend(backend.clone()).unwrap();
enable_table_bucket_marker(object_store.as_ref(), "warehouse").await.unwrap();
ensure_table_bucket_entry(&configured, "warehouse", true).await.unwrap();
for (name, policy, allowed) in [
(
"directory-maintenance",
serde_json::json!({"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["admin:RunTableMaintenance"]}]}),
false,
),
(
"directory-denied",
serde_json::json!({"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["admin:*"]},{"Effect":"Deny","Action":["admin:MigrateTableCatalog"]}]}),
false,
),
(
"directory-migrator",
serde_json::json!({"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["admin:MigrateTableCatalog"]}]}),
true,
),
] {
iam.set_policy(name, Policy::parse_config(&serde_json::to_vec(&policy).unwrap()).unwrap())
.await
.unwrap();
iam.policy_db_set(access_key, UserType::Reg, false, name).await.unwrap();
for prefix in ["/iceberg/v1", "/_iceberg/v1"] {
let mut router = matchit::Router::new();
router
.insert(format!("{prefix}/{{warehouse}}/catalog/directory"), ())
.unwrap();
let uri = format!("{prefix}/warehouse/catalog/directory");
let mut request = table_catalog_handler_request(context.clone(), access_key, secret_key);
request.method = Method::DELETE;
request.uri = uri.parse().unwrap();
let matched = router.at(&uri).unwrap();
let result = DisableTableDirectoryHandler {}.call(request, matched.params).await;
if allowed {
assert_eq!(result.unwrap().output.0, StatusCode::OK);
} else {
assert_eq!(result.unwrap_err().code(), &S3ErrorCode::AccessDenied);
let mut request = table_catalog_handler_request(context.clone(), access_key, secret_key);
request.method = Method::POST;
request.uri = uri.parse().unwrap();
let matched = router.at(&uri).unwrap();
assert_eq!(
AdvanceTableDirectoryHandler {}
.call(request, matched.params)
.await
.unwrap_err()
.code(),
&S3ErrorCode::AccessDenied
);
}
if allowed {
for expected_phase in ["building", "ready"] {
let mut request = table_catalog_handler_request(context.clone(), access_key, secret_key);
request.method = Method::POST;
request.uri = uri.parse().unwrap();
let matched = router.at(&uri).unwrap();
let response = AdvanceTableDirectoryHandler {}.call(request, matched.params).await.unwrap();
assert_eq!(response.output.0, StatusCode::OK);
let bytes = http_body_util::BodyExt::collect(response.output.1).await.unwrap().to_bytes();
let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(value["phase"], expected_phase);
}
let mut request = table_catalog_handler_request(context.clone(), access_key, secret_key);
request.method = Method::DELETE;
request.uri = uri.parse().unwrap();
let matched = router.at(&uri).unwrap();
assert_eq!(DisableTableDirectoryHandler {}.call(request, matched.params).await.unwrap().output.0, StatusCode::OK);
}
let object_catalog = crate::table_catalog::ObjectTableCatalogStore::new(backend.clone());
assert!(
object_catalog
.table_directory_status("warehouse")
.await
.unwrap()
.phase
.is_none(),
"denied requests and idempotent rollback must not activate a directory"
);
}
}
}).await;
}
+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);
+749 -116
View File
File diff suppressed because it is too large Load Diff
+85 -3
View File
@@ -19,6 +19,11 @@ 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);
/// Returns `true` if `s` contains any whitespace character.
///
@@ -45,9 +50,20 @@ 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
.try_acquire()
.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 +171,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 +183,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()
}
+182 -19
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
@@ -1732,7 +1738,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 +1805,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 +1837,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 +1982,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 +2009,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 +2074,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 +2113,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 +2354,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 +2391,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 +2425,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 +2510,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 +2758,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 +2850,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 +2882,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 +2890,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 +2922,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>,
@@ -5148,7 +5155,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 +5188,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;
+573 -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,12 +1015,13 @@ 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(),
req.extensions.get::<VerifiedSigV4Request>().is_some(),
)?;
validate_new_object_key(&req.input.key, "CreateMultipartUpload")?;
reject_presigned_put_max_content_length_for_other_operation(
&req.headers,
req.uri.query(),
@@ -1085,7 +1108,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 +1275,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 +1300,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 +1341,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 +1475,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 +1500,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 +1701,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 +1818,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 +2007,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 +2073,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 +2148,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 +2751,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 +2844,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 +3039,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 +3654,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 +4264,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 +4389,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).ok_or_else(|| {
S3Error::with_message(S3ErrorCode::SlowDown, "upload buffer pool is full, please reduce your request rate")
})?;
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();
+589 -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,222 @@ 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.
const GET_DISK_READ_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
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,
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.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(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,
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) = 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.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);
let _ = std::future::poll_fn(|cx| reader.as_mut().poll_read(cx, &mut buffer)).await;
}
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 +1269,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 +1293,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 +1307,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 +2691,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 +4513,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 +5091,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 +5234,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 +5287,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 +5612,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;
@@ -9861,6 +10141,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));
+144 -2
View File
@@ -16,10 +16,36 @@
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(mut info) = req_info_ref(req).cloned() else {
return Ok(false);
};
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 +439,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 +643,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 +656,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 +678,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 +687,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 +1107,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::*;
+338 -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,9 @@ 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;
let mut buf = pool.try_acquire_buffer(size).ok_or_else(|| {
S3Error::with_message(S3ErrorCode::SlowDown, "upload buffer pool is full, please reduce your request rate")
})?;
read_small_put_body_into(&mut body, &mut *buf, size).await?;
Ok(buf)
}
@@ -1134,57 +1014,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 +1191,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 +1204,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 +1366,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 +1890,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 +2158,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 +2331,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 +2344,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 +2743,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 +2767,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 +2802,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 +3320,160 @@ 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_rejects_without_allocating_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 denied = read_small_put_body_exact_pooled(std::io::Cursor::new(vec![0; 8192]), 8192, &pool).await;
assert_eq!(denied.err().expect("full pool must reject immediately").code(), &S3ErrorCode::SlowDown);
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 +4354,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 +4540,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 +4554,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 +4568,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 +4684,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 +4747,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";
+875 -6
View File
@@ -1429,6 +1429,329 @@ 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 existing optional-MD5 contract, but
// an MD5 that is supplied (and signed by SigV2) must actually match.
if request.method() == Method::PUT
&& request.headers().contains_key("content-md5")
&& 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));
if required && !headers.contains_key("content-md5") && (sig_v2 || !has_checksum) {
return Ok(control_body_digest_error("MissingContentMD5"));
}
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 +2462,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 +3117,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 +3673,552 @@ 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));
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;
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
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;
+186 -1
View File
@@ -94,6 +94,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);
@@ -137,6 +162,84 @@ fn request_audit_builder<T>(
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 +294,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 +662,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 +706,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 +789,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() {
+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(
@@ -0,0 +1,924 @@
// 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 super::super::*;
use datafusion::arrow::array::{
Array, BinaryArray, BinaryViewArray, FixedSizeListArray, LargeBinaryArray, LargeListArray, LargeListViewArray,
LargeStringArray, ListArray, ListViewArray, MapArray, StringArray, StringViewArray, StructArray, downcast_dictionary_array,
};
use datafusion::arrow::datatypes::{DataType, Field, Schema};
use datafusion::parquet::arrow::{
add_encoded_arrow_schema_to_metadata, arrow_reader::ArrowReaderOptions, arrow_writer::ArrowWriterOptions,
};
use datafusion::parquet::file::properties::{EnabledStatistics, WriterProperties};
use std::io::Write;
use std::sync::OnceLock;
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
const COMPACTION_WRITE_BYTES: usize = 8 * 1024 * 1024;
const COMPACTION_BATCH_ROWS: usize = 256;
const COMPACTION_ROW_GROUP_ROWS: usize = 65_536;
const COMPACTION_MAX_ROW_GROUPS: usize = 256;
const COMPACTION_MAX_COLUMN_CHUNKS: usize = 16_384;
const COMPACTION_SCHEMA_DEPTH: usize = 64;
const COMPACTION_SCHEMA_FIELDS: usize = 1024;
pub(crate) fn try_admit_compaction() -> TableCatalogStoreResult<Arc<OwnedSemaphorePermit>> {
static ADMISSION: OnceLock<Arc<Semaphore>> = OnceLock::new();
ADMISSION
.get_or_init(|| Arc::new(Semaphore::new(1)))
.clone()
.try_acquire_owned()
.map(Arc::new)
.map_err(|_| TableCatalogStoreError::Unavailable("table compaction capacity is full".to_owned()))
}
fn budget_error() -> TableCatalogStoreError {
TableCatalogStoreError::Invalid("table compaction exceeds the server memory or expanded-value budget".to_owned())
}
fn add_budget(total: &mut usize, amount: usize, limit: usize) -> TableCatalogStoreResult<()> {
*total = total
.checked_add(amount)
.filter(|value| *value <= limit)
.ok_or_else(budget_error)?;
Ok(())
}
/// Sizes read from one Parquet page header (Thrift compact encoding).
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct PageHeaderSizes {
uncompressed: usize,
compressed: usize,
}
const THRIFT_COMPACT_STOP: u8 = 0;
const THRIFT_COMPACT_BOOL_TRUE: u8 = 1;
const THRIFT_COMPACT_BOOL_FALSE: u8 = 2;
const THRIFT_COMPACT_BYTE: u8 = 3;
const THRIFT_COMPACT_I16: u8 = 4;
const THRIFT_COMPACT_I32: u8 = 5;
const THRIFT_COMPACT_I64: u8 = 6;
const THRIFT_COMPACT_DOUBLE: u8 = 7;
const THRIFT_COMPACT_BINARY: u8 = 8;
const THRIFT_COMPACT_LIST: u8 = 9;
const THRIFT_COMPACT_SET: u8 = 10;
const THRIFT_COMPACT_MAP: u8 = 11;
const THRIFT_COMPACT_STRUCT: u8 = 12;
const THRIFT_MAX_NESTING: usize = 16;
const PAGE_HEADER_FIELD_UNCOMPRESSED_SIZE: i16 = 2;
const PAGE_HEADER_FIELD_COMPRESSED_SIZE: i16 = 3;
/// Minimal Thrift compact-protocol cursor: enough to read a `PageHeader`'s
/// size fields and skip everything else so the next header can be located.
struct ThriftCompactCursor<'a> {
bytes: &'a [u8],
pos: usize,
}
impl ThriftCompactCursor<'_> {
fn error() -> TableCatalogStoreError {
TableCatalogStoreError::Invalid("compaction parquet page header is malformed".to_owned())
}
fn byte(&mut self) -> TableCatalogStoreResult<u8> {
let byte = *self.bytes.get(self.pos).ok_or_else(Self::error)?;
self.pos += 1;
Ok(byte)
}
fn skip_bytes(&mut self, count: usize) -> TableCatalogStoreResult<()> {
self.pos = self
.pos
.checked_add(count)
.filter(|end| *end <= self.bytes.len())
.ok_or_else(Self::error)?;
Ok(())
}
fn varint(&mut self) -> TableCatalogStoreResult<u64> {
let mut value = 0_u64;
for shift in (0..64).step_by(7) {
let byte = self.byte()?;
value |= u64::from(byte & 0x7f) << shift;
if byte & 0x80 == 0 {
return Ok(value);
}
}
Err(Self::error())
}
fn zigzag(&mut self) -> TableCatalogStoreResult<i64> {
let raw = self.varint()?;
Ok(((raw >> 1) as i64) ^ -((raw & 1) as i64))
}
fn skip_value(&mut self, kind: u8, depth: usize) -> TableCatalogStoreResult<()> {
if depth > THRIFT_MAX_NESTING {
return Err(Self::error());
}
match kind {
THRIFT_COMPACT_BOOL_TRUE | THRIFT_COMPACT_BOOL_FALSE => Ok(()),
THRIFT_COMPACT_BYTE => self.skip_bytes(1),
THRIFT_COMPACT_I16 | THRIFT_COMPACT_I32 | THRIFT_COMPACT_I64 => self.varint().map(drop),
THRIFT_COMPACT_DOUBLE => self.skip_bytes(8),
THRIFT_COMPACT_BINARY => {
let len = usize::try_from(self.varint()?).map_err(|_| Self::error())?;
self.skip_bytes(len)
}
THRIFT_COMPACT_LIST | THRIFT_COMPACT_SET => {
let header = self.byte()?;
let element = header & 0x0f;
let count = match header >> 4 {
15 => usize::try_from(self.varint()?).map_err(|_| Self::error())?,
short => usize::from(short),
};
for _ in 0..count {
self.skip_value(element, depth + 1)?;
}
Ok(())
}
THRIFT_COMPACT_MAP => {
let count = usize::try_from(self.varint()?).map_err(|_| Self::error())?;
if count == 0 {
return Ok(());
}
let kinds = self.byte()?;
for _ in 0..count {
self.skip_value(kinds >> 4, depth + 1)?;
self.skip_value(kinds & 0x0f, depth + 1)?;
}
Ok(())
}
THRIFT_COMPACT_STRUCT => self.skip_struct(depth + 1),
_ => Err(Self::error()),
}
}
fn skip_struct(&mut self, depth: usize) -> TableCatalogStoreResult<()> {
let mut field_id = 0_i16;
loop {
let header = self.byte()?;
if header == THRIFT_COMPACT_STOP {
return Ok(());
}
let kind = header & 0x0f;
field_id = match header >> 4 {
0 => i16::try_from(self.zigzag()?).map_err(|_| Self::error())?,
delta => field_id.checked_add(i16::from(delta)).ok_or_else(Self::error)?,
};
self.skip_value(kind, depth)?;
}
}
/// Read a `PageHeader` struct, returning its sizes and leaving the cursor at
/// the first page byte.
fn page_header(&mut self) -> TableCatalogStoreResult<PageHeaderSizes> {
let mut sizes = PageHeaderSizes::default();
let mut field_id = 0_i16;
loop {
let header = self.byte()?;
if header == THRIFT_COMPACT_STOP {
return Ok(sizes);
}
let kind = header & 0x0f;
field_id = match header >> 4 {
0 => i16::try_from(self.zigzag()?).map_err(|_| Self::error())?,
delta => field_id.checked_add(i16::from(delta)).ok_or_else(Self::error)?,
};
match (field_id, kind) {
(PAGE_HEADER_FIELD_UNCOMPRESSED_SIZE, THRIFT_COMPACT_I32) => {
sizes.uncompressed = usize::try_from(self.zigzag()?).map_err(|_| Self::error())?;
}
(PAGE_HEADER_FIELD_COMPRESSED_SIZE, THRIFT_COMPACT_I32) => {
sizes.compressed = usize::try_from(self.zigzag()?).map_err(|_| Self::error())?;
}
_ => self.skip_value(kind, 1)?,
}
}
}
}
/// Sum the page headers of one column chunk (`[start, end)` in the file) and
/// hold every page and the chunk total to `limit`. Pages are never decompressed.
fn validate_page_headers_in_range(chunk: &[u8], chunk_declared: usize, limit: usize) -> TableCatalogStoreResult<usize> {
let mut cursor = ThriftCompactCursor { bytes: chunk, pos: 0 };
let mut total = 0_usize;
while cursor.pos < chunk.len() {
let sizes = cursor.page_header()?;
add_budget(&mut total, sizes.uncompressed, limit)?;
if total > chunk_declared.max(limit.min(chunk_declared.saturating_mul(2))) {
return Err(TableCatalogStoreError::Invalid(
"compaction parquet page headers exceed the footer's declared chunk size".to_owned(),
));
}
cursor.skip_bytes(sizes.compressed)?;
}
Ok(total)
}
fn validate_column_chunk_page_headers(
data: &Bytes,
column: &datafusion::parquet::file::metadata::ColumnChunkMetaData,
chunk_declared: usize,
limit: usize,
location: &str,
) -> TableCatalogStoreResult<()> {
let start = column
.dictionary_page_offset()
.map_or(column.data_page_offset(), |dictionary| dictionary.min(column.data_page_offset()));
let start = usize::try_from(start).map_err(|_| budget_error())?;
let length = usize::try_from(column.compressed_size()).map_err(|_| budget_error())?;
let end = start.checked_add(length).filter(|end| *end <= data.len()).ok_or_else(|| {
TableCatalogStoreError::Invalid(format!("compaction parquet {location} column chunk lies outside the file"))
})?;
validate_page_headers_in_range(&data[start..end], chunk_declared, limit).map_err(|err| match err {
TableCatalogStoreError::Invalid(message) => TableCatalogStoreError::Invalid(format!("{location}: {message}")),
other => other,
})?;
Ok(())
}
fn view_field(field: &Field, depth: usize, fields: &mut usize) -> TableCatalogStoreResult<Arc<Field>> {
add_budget(fields, 1, COMPACTION_SCHEMA_FIELDS)?;
Ok(Arc::new(field.clone().with_data_type(view_type(field.data_type(), depth, fields)?)))
}
fn view_type(data_type: &DataType, depth: usize, fields: &mut usize) -> TableCatalogStoreResult<DataType> {
if depth > COMPACTION_SCHEMA_DEPTH {
return Err(budget_error());
}
Ok(match data_type {
DataType::Utf8 | DataType::LargeUtf8 => DataType::Utf8View,
DataType::Binary | DataType::LargeBinary => DataType::BinaryView,
DataType::List(field) => DataType::List(view_field(field, depth + 1, fields)?),
DataType::LargeList(field) => DataType::LargeList(view_field(field, depth + 1, fields)?),
DataType::ListView(field) => DataType::ListView(view_field(field, depth + 1, fields)?),
DataType::LargeListView(field) => DataType::LargeListView(view_field(field, depth + 1, fields)?),
DataType::FixedSizeList(field, size) => DataType::FixedSizeList(view_field(field, depth + 1, fields)?, *size),
DataType::Map(field, sorted) => DataType::Map(view_field(field, depth + 1, fields)?, *sorted),
DataType::Struct(children) => DataType::Struct(
children
.iter()
.map(|field| view_field(field, depth + 1, fields))
.collect::<TableCatalogStoreResult<Vec<_>>>()?
.into(),
),
DataType::Dictionary(_, value) => {
// The pinned reader does not support Dictionary<ByteView>. Keep
// its already shared values, while still checking nested schema limits.
let _ = view_type(value, depth + 1, fields)?;
data_type.clone()
}
other => other.clone(),
})
}
fn view_schema(schema: &Schema) -> TableCatalogStoreResult<SchemaRef> {
let mut fields = 0;
let mapped = schema
.fields()
.iter()
.map(|field| view_field(field, 0, &mut fields))
.collect::<TableCatalogStoreResult<Vec<_>>>()?;
Ok(Arc::new(Schema::new_with_metadata(mapped, schema.metadata().clone())))
}
/// Charge the values the writer may expand, not the small view/dictionary
/// buffers. Even a null or empty nested cell consumes structural capacity.
fn charge_value(array: &dyn Array, row: usize, depth: usize, total: &mut usize, limit: usize) -> TableCatalogStoreResult<()> {
if depth > COMPACTION_SCHEMA_DEPTH {
return Err(budget_error());
}
add_budget(total, 8, limit)?;
if array.is_null(row) {
return Ok(());
}
macro_rules! scalar_bytes {
($ty:ty) => {
add_budget(
total,
array
.as_any()
.downcast_ref::<$ty>()
.ok_or_else(budget_error)?
.value(row)
.len(),
limit,
)
};
}
macro_rules! nested_values {
($ty:ty) => {{
let value = array.as_any().downcast_ref::<$ty>().ok_or_else(budget_error)?.value(row);
for index in 0..value.len() {
charge_value(value.as_ref(), index, depth + 1, total, limit)?;
}
Ok(())
}};
}
match array.data_type() {
DataType::Utf8 => scalar_bytes!(StringArray),
DataType::LargeUtf8 => scalar_bytes!(LargeStringArray),
DataType::Utf8View => scalar_bytes!(StringViewArray),
DataType::Binary => scalar_bytes!(BinaryArray),
DataType::LargeBinary => scalar_bytes!(LargeBinaryArray),
DataType::BinaryView => scalar_bytes!(BinaryViewArray),
DataType::Boolean | DataType::Null => Ok(()),
DataType::FixedSizeBinary(size) => add_budget(total, usize::try_from(*size).map_err(|_| budget_error())?, limit),
DataType::List(_) => nested_values!(ListArray),
DataType::LargeList(_) => nested_values!(LargeListArray),
DataType::ListView(_) => nested_values!(ListViewArray),
DataType::LargeListView(_) => nested_values!(LargeListViewArray),
DataType::FixedSizeList(_, _) => nested_values!(FixedSizeListArray),
DataType::Map(_, _) => {
let value = array.as_any().downcast_ref::<MapArray>().ok_or_else(budget_error)?.value(row);
for index in 0..value.len() {
charge_value(&value, index, depth + 1, total, limit)?;
}
Ok(())
}
DataType::Struct(_) => {
for column in array
.as_any()
.downcast_ref::<StructArray>()
.ok_or_else(budget_error)?
.columns()
{
charge_value(column.as_ref(), row, depth + 1, total, limit)?;
}
Ok(())
}
DataType::Dictionary(_, _) => downcast_dictionary_array!(array => {
if let Some(key) = array.key(row) { charge_value(array.values().as_ref(), key, depth + 1, total, limit)?; }
Ok(())
}, _ => Err(budget_error())),
data_type => add_budget(total, data_type.primitive_width().ok_or_else(budget_error)?, limit),
}
}
struct BoundedOutput {
bytes: Vec<u8>,
limit: usize,
}
impl Write for BoundedOutput {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
let Some(required) = self.bytes.len().checked_add(bytes.len()).filter(|size| *size <= self.limit) else {
return Err(std::io::Error::other("compaction output exceeds the server size limit"));
};
if required > self.bytes.capacity() {
// Parquet writes successive pages and buffered footer fragments.
// Amortize their copies without allocating past the output budget.
let capacity = required.max(self.bytes.capacity().saturating_mul(2)).min(self.limit);
self.bytes
.try_reserve_exact(capacity - self.bytes.len())
.map_err(std::io::Error::other)?;
}
self.bytes.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
pub(crate) fn compact_parquet_data_files(input_files: Vec<(String, Vec<u8>)>) -> TableCatalogStoreResult<CompactedParquetFile> {
compact_parquet_with_limits(input_files, TABLE_COMPACTION_MAX_DECODED_BYTES, COMPACTION_WRITE_BYTES)
}
fn compact_parquet_with_limits(
input_files: Vec<(String, Vec<u8>)>,
limit: usize,
write_limit: usize,
) -> TableCatalogStoreResult<CompactedParquetFile> {
compact_parquet_with_budgets(input_files, limit, write_limit, COMPACTION_MAX_ROW_GROUPS, COMPACTION_ROW_GROUP_ROWS)
}
fn compact_parquet_with_budgets(
input_files: Vec<(String, Vec<u8>)>,
limit: usize,
write_limit: usize,
group_limit: usize,
row_group_rows: usize,
) -> TableCatalogStoreResult<CompactedParquetFile> {
let mut schema: Option<SchemaRef> = None;
let mut declared = 0;
let mut stored = 0;
let mut files = Vec::new();
let mut leaf_columns = 0;
// Validate every footer before decoding any page or creating output.
for (location, data) in input_files {
add_budget(&mut stored, data.len(), TABLE_COMPACTION_MAX_STORED_BYTES)?;
let data = Bytes::from(data);
let builder = ParquetRecordBatchReaderBuilder::try_new(data.clone())
.map_err(|err| TableCatalogStoreError::Invalid(format!("failed to read compaction parquet {location}: {err}")))?;
let file_schema = builder.schema().clone();
leaf_columns = leaf_columns.max(builder.metadata().file_metadata().schema_descr().num_columns());
match &schema {
Some(expected) if expected.as_ref() != file_schema.as_ref() => {
return Err(TableCatalogStoreError::Invalid("compaction input parquet schemas must match".to_owned()));
}
None => schema = Some(file_schema),
_ => {}
}
for group in builder.metadata().row_groups() {
let mut columns = 0;
for column in group.columns() {
let chunk_declared = usize::try_from(column.uncompressed_size()).map_err(|_| budget_error())?;
add_budget(&mut columns, chunk_declared, limit)?;
// The footer is one claim; the page headers the decoder actually
// sizes its buffers from are another. Walk them without
// decompressing and hold both to the budget.
validate_column_chunk_page_headers(&data, column, chunk_declared, limit, &location)?;
}
let group_bytes = usize::try_from(group.total_byte_size()).map_err(|_| budget_error())?;
add_budget(&mut declared, group_bytes.max(columns), limit)?;
}
files.push((location, data));
}
let schema = schema
.ok_or_else(|| TableCatalogStoreError::Invalid("compaction requires at least one parquet input file".to_owned()))?;
let read_schema = view_schema(&schema)?;
let group_limit = group_limit.min(COMPACTION_MAX_COLUMN_CHUNKS / leaf_columns.max(1));
// These bounds include metadata retained until the footer is serialized.
// Page/offset indexes would add a separate, page-count-dependent inventory.
let mut properties = WriterProperties::builder()
.set_max_row_group_row_count(Some(row_group_rows))
.set_statistics_enabled(EnabledStatistics::Chunk)
.set_statistics_truncate_length(Some(64))
.set_offset_index_disabled(true)
.build();
// The writer requires nested array types to match its input schema exactly.
// Views share the same Parquet physical/logical types; retain the original
// Arrow schema in the footer so readers recover its types and field metadata.
// Do not cast sliced lists/maps: Arrow casts their entire child buffers.
add_encoded_arrow_schema_to_metadata(&schema, &mut properties);
let options = ArrowWriterOptions::new()
.with_properties(properties)
.with_skip_arrow_metadata(true);
let mut writer = ArrowWriter::try_new_with_options(
BoundedOutput {
bytes: Vec::new(),
limit,
},
read_schema.clone(),
options,
)
.map_err(|err| TableCatalogStoreError::Invalid(format!("failed to build compaction writer: {err}")))?;
let mut record_count = 0_u64;
let mut decoded = 0;
for (location, data) in files {
let options = ArrowReaderOptions::new().with_schema(read_schema.clone());
let reader = ParquetRecordBatchReaderBuilder::try_new_with_options(data, options)
.and_then(|builder| builder.with_batch_size(COMPACTION_BATCH_ROWS).build())
.map_err(|err| TableCatalogStoreError::Invalid(format!("failed to build compaction reader {location}: {err}")))?;
for batch in reader {
let batch = batch
.map_err(|err| TableCatalogStoreError::Invalid(format!("failed to decode compaction batch {location}: {err}")))?;
add_budget(&mut decoded, batch.get_array_memory_size(), limit)?;
let mut start = 0;
while start < batch.num_rows() {
let mut end = start;
let mut expanded = 0_usize;
let remaining_rows = row_group_rows.saturating_sub(writer.in_progress_rows());
while end < batch.num_rows() && end - start < remaining_rows {
let mut row_bytes = 0;
for column in batch.columns() {
charge_value(column.as_ref(), end, 0, &mut row_bytes, write_limit)?;
}
if expanded.checked_add(row_bytes).is_none_or(|size| size > write_limit) {
break;
}
expanded += row_bytes;
end += 1;
}
if end == start {
return Err(budget_error());
}
if writer
.memory_size()
.checked_add(expanded)
.is_none_or(|size| size > write_limit)
{
writer.flush().map_err(|err| {
TableCatalogStoreError::Invalid(format!("failed to flush bounded compaction output: {err}"))
})?;
}
if writer.flushed_row_groups().len() >= group_limit {
return Err(TableCatalogStoreError::Invalid(
"compaction output row-group metadata exceeds the server limit".to_owned(),
));
}
writer.write(&batch.slice(start, end - start)).map_err(|err| {
TableCatalogStoreError::Invalid(format!("failed to write bounded compaction output: {err}"))
})?;
record_count = record_count.checked_add((end - start) as u64).ok_or_else(budget_error)?;
start = end;
}
}
}
let output = writer
.into_inner()
.map_err(|err| TableCatalogStoreError::Invalid(format!("failed to finalize bounded compaction output: {err}")))?;
Ok(CompactedParquetFile {
data: output.bytes,
record_count,
})
}
pub(crate) async fn run_compaction_worker(
input_files: Vec<(String, Vec<u8>)>,
admission: Arc<OwnedSemaphorePermit>,
) -> TableCatalogStoreResult<CompactedParquetFile> {
#[cfg(test)]
let barrier = COMPACTION_WORKER_BARRIER.try_with(Arc::clone).ok();
tokio::task::spawn_blocking(move || {
let _admission = admission;
#[cfg(test)]
if let Some(barrier) = barrier {
barrier.entered.notify_one();
let mut released = barrier.released.lock().unwrap();
while !*released {
released = barrier.release.wait(released).unwrap();
}
}
compact_parquet_data_files(input_files)
})
.await
.map_err(|_| TableCatalogStoreError::Internal("compaction decoder worker failed".to_owned()))?
}
pub(crate) async fn run_compaction_output<F>(operation: F, admission: Arc<OwnedSemaphorePermit>) -> TableCatalogStoreResult<()>
where
F: std::future::Future<Output = TableCatalogStoreResult<()>> + Send + 'static,
{
let credentials = rustfs_keystone::KEYSTONE_CREDENTIALS.try_with(Clone::clone).ok().flatten();
let operation = rustfs_keystone::KEYSTONE_CREDENTIALS.scope(credentials, async move {
let _admission = admission;
operation.await
});
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,
);
crate::storage_api::table::spawn_traced_join(operation)
.await
.map_err(|_| TableCatalogStoreError::Internal("compaction output worker failed".to_owned()))?
}
#[cfg(test)]
struct WorkerBarrier {
entered: tokio::sync::Notify,
released: std::sync::Mutex<bool>,
release: std::sync::Condvar,
}
#[cfg(test)]
tokio::task_local! { static COMPACTION_WORKER_BARRIER: Arc<WorkerBarrier>; }
#[cfg(test)]
mod tests {
use super::*;
use datafusion::arrow::record_batch::RecordBatch;
use datafusion::parquet::basic::{Compression, ZstdLevel};
fn parquet_strings(value: &str, rows: usize, dictionary: bool) -> Vec<u8> {
let schema = Arc::new(Schema::new_with_metadata(
vec![Field::new("value", DataType::Utf8, false)],
[("custom-key".to_owned(), "preserved".to_owned())].into_iter().collect(),
));
let values = StringArray::from(vec![value; rows]);
let batch = RecordBatch::try_new(schema.clone(), vec![Arc::new(values)]).unwrap();
let properties = WriterProperties::builder()
.set_dictionary_enabled(dictionary)
.set_compression(Compression::ZSTD(ZstdLevel::default()))
.build();
let mut writer = ArrowWriter::try_new(Vec::new(), schema, Some(properties)).unwrap();
writer.write(&batch).unwrap();
writer.into_inner().unwrap()
}
fn compact_i32_field(field_delta: u8, value: i64) -> Vec<u8> {
let mut out = vec![(field_delta << 4) | THRIFT_COMPACT_I32];
let mut zigzag = ((value << 1) ^ (value >> 63)) as u64;
loop {
let byte = (zigzag & 0x7f) as u8;
zigzag >>= 7;
if zigzag == 0 {
out.push(byte);
break;
}
out.push(byte | 0x80);
}
out
}
#[test]
fn compaction_page_header_walker_matches_the_footer_of_an_honest_file() {
let file = Bytes::from(parquet_strings("value", 1024, true));
let builder = ParquetRecordBatchReaderBuilder::try_new(file.clone()).unwrap();
for group in builder.metadata().row_groups() {
for column in group.columns() {
let declared = usize::try_from(column.uncompressed_size()).unwrap();
validate_column_chunk_page_headers(&file, column, declared, usize::MAX, "honest.parquet")
.expect("an honest file's page headers fit its footer");
let start = usize::try_from(column.dictionary_page_offset().unwrap_or(column.data_page_offset())).unwrap();
let end = start + usize::try_from(column.compressed_size()).unwrap();
let total = validate_page_headers_in_range(&file[start..end], declared, usize::MAX).unwrap();
// The footer's uncompressed size also counts the page headers themselves.
assert!(total > 0 && total <= declared, "page payload sizes ({total}) fit the footer's {declared}");
}
}
}
#[test]
fn compaction_page_header_walker_rejects_a_page_that_lies_about_its_size() {
// PageHeader { type: 0, uncompressed_page_size: 2 GiB - 1, compressed_page_size: 4 } + 4 bytes of "page".
let mut chunk = compact_i32_field(1, 0);
chunk.extend(compact_i32_field(1, i64::from(i32::MAX)));
chunk.extend(compact_i32_field(1, 4));
// data_page_header struct (field 5, delta 2): one BOOL field, then STOP.
chunk.extend([
(2 << 4) | THRIFT_COMPACT_STRUCT,
(1 << 4) | THRIFT_COMPACT_BOOL_TRUE,
THRIFT_COMPACT_STOP,
]);
chunk.push(THRIFT_COMPACT_STOP);
chunk.extend([0_u8; 4]);
let error = validate_page_headers_in_range(&chunk, 4096, 64 * 1024 * 1024)
.expect_err("a page header above the budget must be rejected before any allocation");
assert!(matches!(error, TableCatalogStoreError::Invalid(_)), "{error:?}");
// The same header within budget but far above the footer's chunk size is also a lie.
let mut chunk = compact_i32_field(1, 0);
chunk.extend(compact_i32_field(1, 1024 * 1024));
chunk.extend(compact_i32_field(1, 4));
chunk.push(THRIFT_COMPACT_STOP);
chunk.extend([0_u8; 4]);
let error = validate_page_headers_in_range(&chunk, 4096, 64 * 1024 * 1024)
.expect_err("page headers far above the footer's chunk size are rejected");
assert!(matches!(error, TableCatalogStoreError::Invalid(message) if message.contains("footer")));
// Truncated header: the cursor must fail closed, never read past the chunk.
assert!(validate_page_headers_in_range(&compact_i32_field(1, 0), 4096, 4096).is_err());
}
#[test]
fn compaction_rejects_compressed_expansion_before_decoding_pages() {
let file = parquet_strings(&"x".repeat(64 * 1024), 32, false);
assert!(file.len() < 16 * 1024, "fixture must really be highly compressed");
let error = compact_parquet_with_limits(vec![("bomb.parquet".to_owned(), file)], 128 * 1024, 64 * 1024)
.err()
.expect("footer must reject decoded expansion before allocation");
assert!(matches!(error, TableCatalogStoreError::Invalid(_)));
}
#[test]
fn compaction_dictionary_views_bound_each_writer_input_and_preserve_schema() {
let value = "x".repeat(16 * 1024);
let file = parquet_strings(&value, 32, true);
let original_schema = ParquetRecordBatchReaderBuilder::try_new(Bytes::from(file.clone()))
.unwrap()
.schema()
.clone();
let output =
compact_parquet_with_limits(vec![("dictionary.parquet".to_owned(), file)], 2 * 1024 * 1024, 32 * 1024).unwrap();
assert_eq!(output.record_count, 32);
let builder = ParquetRecordBatchReaderBuilder::try_new(Bytes::from(output.data)).unwrap();
assert_eq!(builder.schema(), &original_schema);
assert!(builder.metadata().num_row_groups() <= 32);
for batch in builder.build().unwrap() {
let batch = batch.unwrap();
let values = batch.column(0).as_any().downcast_ref::<StringArray>().unwrap();
assert!(values.iter().all(|item| item == Some(value.as_str())));
}
}
#[test]
fn compaction_rejects_a_single_expanded_value_before_writer_allocation() {
let file = parquet_strings(&"x".repeat(4096), 16, true);
let error = compact_parquet_with_limits(vec![("single-row.parquet".to_owned(), file)], 1024 * 1024, 1024)
.err()
.expect("one expanded row cannot exceed the writer input budget");
assert!(matches!(error, TableCatalogStoreError::Invalid(_)));
}
#[test]
fn compaction_nested_views_keep_field_and_schema_metadata() {
use datafusion::arrow::array::{ListBuilder, StringBuilder};
let child = Arc::new(
Field::new("text", DataType::Utf8, false)
.with_metadata([("PARQUET:field_id".to_owned(), "2".to_owned())].into_iter().collect()),
);
let array = StructArray::from(vec![(child.clone(), Arc::new(StringArray::from(vec!["nested"; 4])) as Arc<dyn Array>)]);
let value = "nested".repeat(32);
let mut lists = ListBuilder::new(StringBuilder::new());
for _ in 0..4 {
lists.values().append_value(&value);
lists.append(true);
}
let lists = lists.finish();
let schema = Arc::new(Schema::new_with_metadata(
vec![
Field::new("outer", DataType::Struct(vec![child].into()), false),
Field::new("list", lists.data_type().clone(), false),
],
[("source".to_owned(), "nested-compaction".to_owned())].into_iter().collect(),
));
let batch = RecordBatch::try_new(schema.clone(), vec![Arc::new(array), Arc::new(lists)]).unwrap();
let mut writer = ArrowWriter::try_new(Vec::new(), schema.clone(), None).unwrap();
writer.write(&batch).unwrap();
let file = writer.into_inner().unwrap();
let rejected = compact_parquet_with_limits(vec![("nested.parquet".to_owned(), file.clone())], 1024 * 1024, 200)
.err()
.expect("a nested row must fit before reaching the writer");
assert!(matches!(rejected, TableCatalogStoreError::Invalid(message) if message.contains("expanded-value budget")));
// Force slices within the decoded nested batch. They must not require
// expanding all values in the retained child buffer before writing.
let output = compact_parquet_with_limits(vec![("nested.parquet".to_owned(), file)], 1024 * 1024, 300).unwrap();
let builder = ParquetRecordBatchReaderBuilder::try_new(Bytes::from(output.data)).unwrap();
assert_eq!(builder.schema(), &schema);
let batches = builder.build().unwrap().collect::<Result<Vec<_>, _>>().unwrap();
assert_eq!(batches.iter().map(|batch| batch.num_rows()).sum::<usize>(), 4);
for batch in batches {
let outer = batch.column(0).as_any().downcast_ref::<StructArray>().unwrap();
let texts = outer.column(0).as_any().downcast_ref::<StringArray>().unwrap();
assert!(texts.iter().all(|text| text == Some("nested")));
let lists = batch.column(1).as_any().downcast_ref::<ListArray>().unwrap();
for row in 0..batch.num_rows() {
let values = lists.value(row);
let values = values.as_any().downcast_ref::<StringArray>().unwrap();
assert_eq!(values.len(), 1);
assert_eq!(values.value(0), value);
}
}
}
#[test]
fn compaction_arrow_dictionary_schema_round_trips_through_view_reader() {
use datafusion::arrow::array::{DictionaryArray, Int8Array};
use datafusion::arrow::datatypes::Int8Type;
let values = Arc::new(StringArray::from(vec!["dictionary-value".repeat(1024)]));
let array = DictionaryArray::<Int8Type>::try_new(Int8Array::from(vec![0; 16]), values).unwrap();
let field = Arc::new(Field::new("dictionary", array.data_type().clone(), false));
let array = Arc::new(array);
let nested = StructArray::from(vec![(field.clone(), array.clone() as Arc<dyn Array>)]);
let schema = Arc::new(Schema::new(vec![
field.as_ref().clone(),
Field::new("nested", nested.data_type().clone(), false),
]));
let batch = RecordBatch::try_new(schema.clone(), vec![array, Arc::new(nested)]).unwrap();
let mut writer = ArrowWriter::try_new(Vec::new(), schema, None).unwrap();
writer.write(&batch).unwrap();
let file = writer.into_inner().unwrap();
let expected = ParquetRecordBatchReaderBuilder::try_new(Bytes::from(file.clone()))
.unwrap()
.schema()
.clone();
let output =
compact_parquet_with_limits(vec![("arrow-dictionary.parquet".to_owned(), file)], 1024 * 1024, 40 * 1024).unwrap();
assert_eq!(output.record_count, 16);
let reader = ParquetRecordBatchReaderBuilder::try_new(Bytes::from(output.data)).unwrap();
assert_eq!(reader.schema(), &expected);
let batches = reader.build().unwrap().collect::<Result<Vec<_>, _>>().unwrap();
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 16);
for batch in batches {
let nested = batch.column(1).as_any().downcast_ref::<StructArray>().unwrap();
for column in [batch.column(0), nested.column(0)] {
let dictionary = column.as_any().downcast_ref::<DictionaryArray<Int8Type>>().unwrap();
let values = dictionary.values().as_any().downcast_ref::<StringArray>().unwrap();
for row in 0..dictionary.len() {
assert_eq!(values.value(dictionary.key(row).unwrap()), "dictionary-value".repeat(1024));
}
}
}
}
#[test]
fn compaction_retained_row_group_metadata_is_bounded_before_another_group() {
let file = parquet_strings("", 1024, true);
let output =
compact_parquet_with_budgets(vec![("rle.parquet".to_owned(), file)], 1024 * 1024, 64 * 1024, 2, 512).unwrap();
let reader = ParquetRecordBatchReaderBuilder::try_new(Bytes::from(output.data)).unwrap();
assert_eq!(
reader.metadata().num_row_groups(),
2,
"256-row input batches must not force separate row groups"
);
assert_eq!(output.record_count, 1024);
let file = parquet_strings("", 1025, true);
let error = compact_parquet_with_budgets(vec![("rle.parquet".to_owned(), file)], 1024 * 1024, 64 * 1024, 2, 512)
.err()
.expect("a third row group must not allocate retained metadata");
assert!(matches!(error, TableCatalogStoreError::Invalid(message) if message.contains("row-group metadata")));
}
#[test]
fn compaction_output_sink_rejects_before_growing() {
let mut output = BoundedOutput {
bytes: Vec::new(),
limit: 4,
};
output.write_all(b"1234").unwrap();
let capacity = output.bytes.capacity();
assert!(output.write_all(b"5").is_err());
assert_eq!(&output.bytes, b"1234");
assert_eq!(output.bytes.capacity(), capacity, "rejected writes must not allocate");
}
#[test]
fn compaction_output_sink_amortizes_small_writes_within_the_capacity_limit() {
// Include a non-power-of-two cap so the last growth must stop at the
// limit instead of using Vec's unconstrained doubling policy.
for chunk_size in [1, 7, 64] {
let mut output = BoundedOutput {
bytes: Vec::new(),
limit: 4099,
};
let mut growths = 0;
let mut capacity = 0;
while output.bytes.len() < output.limit {
let count = chunk_size.min(output.limit - output.bytes.len());
output.write_all(&[42; 64][..count]).expect("bounded fragment");
if output.bytes.capacity() != capacity {
growths += 1;
capacity = output.bytes.capacity();
}
assert!(capacity >= output.bytes.len() && capacity <= output.limit);
}
assert!(growths <= 14, "small writes must require logarithmic capacity growth, got {growths}");
assert_eq!(output.bytes, vec![42; output.limit]);
assert!(output.write_all(b"x").is_err());
assert_eq!(output.bytes.capacity(), capacity);
assert_eq!(output.bytes.len(), output.limit);
}
}
#[tokio::test]
#[serial_test::serial(table_compaction)]
async fn compaction_output_worker_preserves_keystone_authorization_context() {
let credentials = rustfs_credentials::Credentials {
access_key: "keystone:compaction".to_owned(),
..Default::default()
};
let admission = try_admit_compaction().unwrap();
rustfs_keystone::KEYSTONE_CREDENTIALS
.scope(Some(credentials), async {
run_compaction_output(
async {
assert_eq!(
rustfs_keystone::KEYSTONE_CREDENTIALS
.with(|credentials| credentials.as_ref().map(|value| value.access_key.clone())),
Some("keystone:compaction".to_owned())
);
Ok(())
},
admission,
)
.await
.unwrap();
})
.await;
assert!(rustfs_keystone::KEYSTONE_CREDENTIALS.try_with(Clone::clone).is_err());
}
#[tokio::test]
#[serial_test::serial(table_compaction)]
async fn compaction_cancelled_caller_keeps_actual_decoder_worker_admitted() {
let barrier = Arc::new(WorkerBarrier {
entered: tokio::sync::Notify::new(),
released: std::sync::Mutex::new(false),
release: std::sync::Condvar::new(),
});
let admission = try_admit_compaction().unwrap();
let file = parquet_strings("valid", 4, true);
let scope_barrier = barrier.clone();
let caller = tokio::spawn(COMPACTION_WORKER_BARRIER.scope(scope_barrier, async move {
run_compaction_worker(vec![("valid.parquet".to_owned(), file)], admission).await
}));
barrier.entered.notified().await;
caller.abort();
let _ = caller.await;
assert!(matches!(try_admit_compaction(), Err(TableCatalogStoreError::Unavailable(_))));
*barrier.released.lock().unwrap() = true;
barrier.release.notify_one();
tokio::time::timeout(std::time::Duration::from_secs(30), async {
loop {
if try_admit_compaction().is_ok() {
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("worker completion must return the permit");
}
}
@@ -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());
+13
View File
@@ -110,6 +110,13 @@ 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.
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 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 +137,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 +182,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";
@@ -131,6 +131,7 @@ impl<B: TableCatalogObjectBackend> ObjectTableCatalogStore<B> {
let bucket_path = self.paths.table_bucket_entry_path(bucket);
let index_state_path = self.paths.warehouse_index_state_path(bucket);
let index_prefix = self.paths.warehouse_index_entries_prefix(bucket);
let directory_prefix = self.paths.table_directory_prefix(bucket);
let mut indexes = Vec::new();
for object in catalog_objects {
if object.delete_marker
@@ -173,6 +174,14 @@ impl<B: TableCatalogObjectBackend> ObjectTableCatalogStore<B> {
return Err(cleanup_conflict("cleanup requires the default table warehouse location"));
}
etag
} else if object.key.starts_with(&directory_prefix) {
// Registration/rename share the held bucket publication fence.
// Remove only the exact immutable candidate of a vanished entry.
let (target, etag) = self.directory_candidate_target(bucket, &object.key).await?;
if self.backend.object_exists(self.catalog_bucket(), &target).await? {
return Err(cleanup_conflict("table directory candidate references a retained table entry"));
}
etag
} else {
return Err(cleanup_conflict("table bucket contains namespaces or retained catalog resources"));
};
File diff suppressed because it is too large Load Diff
+104 -14
View File
@@ -23,6 +23,20 @@ use super::strong::{
};
use super::*;
async fn run_owned_backing_migration<T: Send + 'static>(
operation: impl std::future::Future<Output = TableCatalogStoreResult<T>> + Send + 'static,
) -> TableCatalogStoreResult<T> {
let credentials = rustfs_keystone::KEYSTONE_CREDENTIALS.try_with(Clone::clone).ok().flatten();
let operation = rustfs_keystone::KEYSTONE_CREDENTIALS.scope(credentials, operation);
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,
);
crate::storage_api::table::spawn_traced_join(operation)
.await
.map_err(|_| TableCatalogStoreError::Internal("catalog migration worker failed".to_owned()))?
}
pub(super) fn table_catalog_backing_manifest(
paths: &TableCatalogObjectPaths,
namespace: &Namespace,
@@ -267,6 +281,13 @@ where
&self,
table_bucket: &str,
) -> TableCatalogStoreResult<Option<(TableCatalogBackingMigrationFence, Option<String>)>> {
// The table-entry directory keeps its state on this path; its presence
// excludes a backing migration rather than being a malformed fence.
if self.read_table_directory_state(table_bucket).await?.is_some() {
return Err(TableCatalogStoreError::Conflict(
"table bucket has an active table-entry directory; roll it back before a backing migration".to_string(),
));
}
let fence = self
.read_entry(self.catalog_bucket(), &self.paths.backing_migration_fence_path(table_bucket))
.await?;
@@ -307,6 +328,11 @@ where
&self,
table_bucket: &str,
) -> TableCatalogStoreResult<()> {
// The directory upgrade keeps legacy entries authoritative and requires
// every compatible writer to publish its candidate before the entry.
if self.read_table_directory_state(table_bucket).await?.is_some() {
return Ok(());
}
if self.read_backing_migration_fence(table_bucket).await?.is_some() {
return Err(TableCatalogStoreError::Conflict(format!(
"object-backed catalog writes are fenced while table bucket {table_bucket} is prepared for durable strong cutover"
@@ -318,6 +344,7 @@ where
async fn ensure_global_backing_migration_fence(
&self,
fence_path: &str,
mutation_fence: &TableCatalogObjectMutationFence,
) -> TableCatalogStoreResult<TableCatalogBackingMigrationGlobalFence> {
if let Some((fence, _)) = self
.read_entry::<TableCatalogBackingMigrationGlobalFence>(self.catalog_bucket(), fence_path)
@@ -330,13 +357,17 @@ where
version: TABLE_CATALOG_MIGRATION_VERSION,
migration_id: Uuid::new_v4().to_string(),
};
self.write_entry(self.catalog_bucket(), fence_path, &fence, TableCatalogPutPrecondition::IfAbsent)
self.write_migration_entry(fence_path, &fence, TableCatalogPutPrecondition::IfAbsent, mutation_fence)
.await?;
Ok(fence)
}
async fn clear_global_backing_migration_fence_if_unused(&self, fence_path: &str) -> TableCatalogStoreResult<()> {
let Some((fence, _)) = self
async fn clear_global_backing_migration_fence_if_unused(
&self,
fence_path: &str,
mutation_fence: &TableCatalogObjectMutationFence,
) -> TableCatalogStoreResult<()> {
let Some((fence, etag)) = self
.read_entry::<TableCatalogBackingMigrationGlobalFence>(self.catalog_bucket(), fence_path)
.await?
else {
@@ -353,7 +384,24 @@ where
{
return Ok(());
}
self.backend.delete_object(self.catalog_bucket(), fence_path).await
let etag = etag.ok_or_else(|| TableCatalogStoreError::Conflict("migration fence has no ETag".to_owned()))?;
self.backend
.delete_object_if_match_fenced(self.catalog_bucket(), fence_path, &etag, mutation_fence)
.await
}
async fn write_migration_entry<T: Serialize>(
&self,
path: &str,
entry: &T,
precondition: TableCatalogPutPrecondition,
fence: &TableCatalogObjectMutationFence,
) -> TableCatalogStoreResult<()> {
let data = serde_json::to_vec(entry)
.map_err(|err| TableCatalogStoreError::Internal(format!("cannot serialize migration entry: {err}")))?;
self.backend
.put_object_fenced(self.catalog_bucket(), path, data, precondition, fence)
.await
}
async fn collect_bucket_snapshot_with_locks(
@@ -907,6 +955,16 @@ where
pub(crate) async fn materialize_durable_strong_backing_migration(
&self,
table_bucket: &str,
) -> TableCatalogStoreResult<TableCatalogBackingMigrationExecutionReport> {
let store = self.clone();
let table_bucket = table_bucket.to_owned();
run_owned_backing_migration(async move { store.materialize_durable_strong_backing_migration_owned(&table_bucket).await })
.await
}
async fn materialize_durable_strong_backing_migration_owned(
&self,
table_bucket: &str,
) -> TableCatalogStoreResult<TableCatalogBackingMigrationExecutionReport> {
let fence_path = self.paths.backing_migration_fence_path(table_bucket);
let fence_lock_path = self.paths.backing_migration_fence_lock_path(table_bucket);
@@ -929,6 +987,10 @@ where
.backend
.acquire_write_lock(self.catalog_bucket(), &fence_lock_path)
.await?;
let mut mutation_fence = TableCatalogObjectMutationFence::from_signals([
_global_fence_guard.lock_lost_signal(),
_fence_guard.lock_lost_signal(),
]);
let existing_fence = self.read_backing_migration_fence(table_bucket).await?;
let strong_store = StrongTableCatalogStore::new(self.backend.clone());
if let Some((fence, _)) = existing_fence.as_ref()
@@ -955,6 +1017,10 @@ where
let source = self
.collect_bucket_snapshot_with_locks(table_bucket, &mut source_guards)
.await?;
mutation_fence
.signals
.extend(source_guards.iter().filter_map(TableCatalogLockGuard::lock_lost_signal));
mutation_fence.ensure_held()?;
let source_fingerprint = table_catalog_bucket_snapshot_fingerprint(&source)?;
if let Some((fence, _)) = existing_fence.as_ref()
&& fence.status == TableCatalogBackingMigrationFenceStatus::Materialized
@@ -965,7 +1031,8 @@ where
)));
}
self.ensure_global_backing_migration_fence(&global_fence_path).await?;
self.ensure_global_backing_migration_fence(&global_fence_path, &mutation_fence)
.await?;
let (target_fingerprint, target_snapshot_etag) = Self::observe_durable_strong_migration_target(
&strong_store,
table_bucket,
@@ -985,12 +1052,14 @@ where
source_fingerprint: None,
target_snapshot_etag,
};
self.write_entry(self.catalog_bucket(), &fence_path, &fence, TableCatalogPutPrecondition::IfAbsent)
self.write_migration_entry(&fence_path, &fence, TableCatalogPutPrecondition::IfAbsent, &mutation_fence)
.await?;
(fence.migration_id, target_bucket_existed)
};
let (target_snapshot_etag, created) = strong_store.materialize_bucket_snapshot(source.clone()).await?;
let (target_snapshot_etag, created) = strong_store
.materialize_bucket_snapshot_with_fence(source.clone(), &mutation_fence)
.await?;
let completed_fence = TableCatalogBackingMigrationFence {
version: TABLE_CATALOG_MIGRATION_VERSION,
table_bucket: table_bucket.to_string(),
@@ -1000,7 +1069,7 @@ where
source_fingerprint: Some(source_fingerprint.clone()),
target_snapshot_etag: Some(target_snapshot_etag.clone()),
};
self.write_entry(self.catalog_bucket(), &fence_path, &completed_fence, TableCatalogPutPrecondition::Any)
self.write_migration_entry(&fence_path, &completed_fence, TableCatalogPutPrecondition::Any, &mutation_fence)
.await?;
drop(source_guards);
@@ -1030,6 +1099,15 @@ where
pub(crate) async fn cancel_durable_strong_backing_migration(
&self,
table_bucket: &str,
) -> TableCatalogStoreResult<TableCatalogBackingMigrationCancelReport> {
let store = self.clone();
let table_bucket = table_bucket.to_owned();
run_owned_backing_migration(async move { store.cancel_durable_strong_backing_migration_owned(&table_bucket).await }).await
}
async fn cancel_durable_strong_backing_migration_owned(
&self,
table_bucket: &str,
) -> TableCatalogStoreResult<TableCatalogBackingMigrationCancelReport> {
let fence_path = self.paths.backing_migration_fence_path(table_bucket);
let fence_lock_path = self.paths.backing_migration_fence_lock_path(table_bucket);
@@ -1043,8 +1121,12 @@ where
.backend
.acquire_write_lock(self.catalog_bucket(), &fence_lock_path)
.await?;
let Some((fence, _)) = self.read_backing_migration_fence(table_bucket).await? else {
self.clear_global_backing_migration_fence_if_unused(&global_fence_path)
let mut mutation_fence = TableCatalogObjectMutationFence::from_signals([
_global_fence_guard.lock_lost_signal(),
_fence_guard.lock_lost_signal(),
]);
let Some((fence, fence_etag)) = self.read_backing_migration_fence(table_bucket).await? else {
self.clear_global_backing_migration_fence_if_unused(&global_fence_path, &mutation_fence)
.await?;
return Ok(TableCatalogBackingMigrationCancelReport {
table_bucket: table_bucket.to_string(),
@@ -1052,12 +1134,17 @@ where
object_backed_writes_fenced: false,
});
};
self.ensure_global_backing_migration_fence(&global_fence_path).await?;
self.ensure_global_backing_migration_fence(&global_fence_path, &mutation_fence)
.await?;
let mut source_guards = Vec::new();
let source = self
.collect_bucket_snapshot_with_locks(table_bucket, &mut source_guards)
.await?;
mutation_fence
.signals
.extend(source_guards.iter().filter_map(TableCatalogLockGuard::lock_lost_signal));
mutation_fence.ensure_held()?;
let source_fingerprint = table_catalog_bucket_snapshot_fingerprint(&source)?;
if fence.status == TableCatalogBackingMigrationFenceStatus::Materialized
&& fence.source_fingerprint.as_deref() != Some(source_fingerprint.as_str())
@@ -1086,11 +1173,14 @@ where
}
if !fence.target_bucket_existed {
strong_store
.remove_bucket_snapshot_if_unchanged(table_bucket, &source_fingerprint)
.remove_bucket_snapshot_if_unchanged_with_fence(table_bucket, &source_fingerprint, &mutation_fence)
.await?;
}
self.backend.delete_object(self.catalog_bucket(), &fence_path).await?;
self.clear_global_backing_migration_fence_if_unused(&global_fence_path)
let fence_etag = fence_etag.ok_or_else(|| TableCatalogStoreError::Conflict("migration fence has no ETag".to_owned()))?;
self.backend
.delete_object_if_match_fenced(self.catalog_bucket(), &fence_path, &fence_etag, &mutation_fence)
.await?;
self.clear_global_backing_migration_fence_if_unused(&global_fence_path, &mutation_fence)
.await?;
Ok(TableCatalogBackingMigrationCancelReport {
table_bucket: table_bucket.to_string(),
+143 -21
View File
@@ -18,9 +18,15 @@ use crate::storage_api::table::{WriteCommitGuard, WriteCompletion};
mod backup;
mod cleanup;
mod directory_index;
mod migration;
mod object;
mod publication_deadline;
mod registration_cache;
pub(crate) use publication_deadline::TablePublicationPreparation;
use publication_deadline::{
begin_publication_mutation, publication_preparation, publication_recovery, retain_publication_preparation_fence,
};
#[cfg(test)]
mod registration_cache_tests;
pub(crate) use registration_cache::TableCatalogRegistrationCache;
@@ -464,6 +470,10 @@ pub(crate) trait TableCatalogStore: Send + Sync {
#[async_trait::async_trait]
pub(crate) trait TableCommitPublication: Send + Sync {
fn preparation_budget(&self) -> Arc<TablePublicationPreparation> {
Arc::new(TablePublicationPreparation::default())
}
async fn begin_table_bucket(&self, table_bucket: &str) -> TableCatalogStoreResult<()>;
async fn prepare(&self, table_bucket: &str, namespace: &str, table: &str) -> TableCatalogStoreResult<()>;
@@ -507,6 +517,7 @@ impl Drop for TableCommitPublicationCompletion<'_> {
struct TableCommitLockPublication<'a, B> {
backend: &'a B,
preparation: Arc<TablePublicationPreparation>,
state: parking_lot::Mutex<TableCommitLockPublicationState>,
}
@@ -522,6 +533,7 @@ impl<'a, B> TableCommitLockPublication<'a, B> {
fn new(backend: &'a B) -> Self {
Self {
backend,
preparation: Arc::new(TablePublicationPreparation::default()),
state: parking_lot::Mutex::new(TableCommitLockPublicationState::default()),
}
}
@@ -571,7 +583,13 @@ impl<'a, B> TableCommitPublication for TableCommitLockPublication<'a, B>
where
B: TableCatalogObjectBackend,
{
fn preparation_budget(&self) -> Arc<TablePublicationPreparation> {
self.preparation.clone()
}
async fn begin_table_bucket(&self, table_bucket: &str) -> TableCatalogStoreResult<()> {
// Includes an earlier acquisition even when a caller prepares before entering the store.
let _ = self.preparation.deadline();
{
let mut state = self.state.lock();
if state
@@ -677,12 +695,15 @@ where
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TableCatalogObject {
pub data: Vec<u8>,
/// Server-generated identity of the body returned by this read.
pub body_revision: Option<Uuid>,
pub etag: Option<String>,
pub mod_time: Option<OffsetDateTime>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TableCatalogObjectMetadata {
pub body_revision: Option<Uuid>,
pub etag: Option<String>,
pub mod_time: Option<OffsetDateTime>,
pub size: u64,
@@ -788,6 +809,11 @@ pub(crate) struct TableCatalogCleanupObject {
pub delete_marker: bool,
}
pub(crate) struct TableCatalogCleanupPage {
pub objects: Vec<TableCatalogCleanupObject>,
pub is_truncated: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TableCatalogPutPrecondition {
Any,
@@ -872,6 +898,7 @@ pub(crate) trait TableCatalogObjectBackend: Clone + Send + Sync + 'static {
.read_object(bucket, object)
.await?
.map(|object| TableCatalogObjectMetadata {
body_revision: object.body_revision,
etag: object.etag,
mod_time: object.mod_time,
size: u64::try_from(object.data.len()).unwrap_or(u64::MAX),
@@ -887,6 +914,7 @@ pub(crate) trait TableCatalogObjectBackend: Clone + Send + Sync + 'static {
.read_object_unlocked(bucket, object)
.await?
.map(|object| TableCatalogObjectMetadata {
body_revision: object.body_revision,
etag: object.etag,
mod_time: object.mod_time,
size: u64::try_from(object.data.len()).unwrap_or(u64::MAX),
@@ -911,6 +939,21 @@ pub(crate) trait TableCatalogObjectBackend: Clone + Send + Sync + 'static {
precondition: TableCatalogPutPrecondition,
) -> TableCatalogStoreResult<()>;
/// Write an object that precedes the catalog transaction, such as the
/// initial metadata file of a create. It is authorized and fenced like
/// `put_object`, but a publication-aware backend must not treat it as the
/// first durable catalog mutation: an orphan is reclaimed by maintenance,
/// so the preparation deadline keeps bounding the work that follows.
async fn put_object_before_publication(
&self,
bucket: &str,
object: &str,
data: Vec<u8>,
precondition: TableCatalogPutPrecondition,
) -> TableCatalogStoreResult<()> {
self.put_object(bucket, object, data, precondition).await
}
async fn put_object_unlocked(
&self,
bucket: &str,
@@ -1010,6 +1053,20 @@ pub(crate) trait TableCatalogObjectBackend: Clone + Send + Sync + 'static {
))
}
/// GET-equivalent, bounded enumeration for publishing authoritative indexes.
/// Backends without this contract must not fall back to an ordinary S3 list.
async fn list_catalog_objects_page(
&self,
_bucket: &str,
_prefix: &str,
_start_after: Option<&str>,
_limit: NonZeroUsize,
) -> TableCatalogStoreResult<TableCatalogCleanupPage> {
Err(TableCatalogStoreError::Unsupported(
"authoritative catalog pagination is not supported by this backend".to_string(),
))
}
async fn list_objects_page(
&self,
bucket: &str,
@@ -1042,6 +1099,10 @@ pub(crate) trait TableCatalogObjectBackend: Clone + Send + Sync + 'static {
Ok(())
}
fn table_publication_preparation_budget(&self) -> Arc<TablePublicationPreparation> {
Arc::new(TablePublicationPreparation::default())
}
async fn begin_table_bucket_commit_publication(&self, _table_bucket: &str) -> TableCatalogStoreResult<()> {
Ok(())
}
@@ -1178,6 +1239,10 @@ impl<B> TableCommitPublication for B
where
B: TableCatalogObjectBackend,
{
fn preparation_budget(&self) -> Arc<TablePublicationPreparation> {
self.table_publication_preparation_budget()
}
async fn begin_table_bucket(&self, table_bucket: &str) -> TableCatalogStoreResult<()> {
self.begin_table_bucket_commit_publication(table_bucket).await
}
@@ -2392,6 +2457,7 @@ where
async fn object_metadata(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<Option<TableCatalogObjectMetadata>> {
match self.store.get_object_info(bucket, object, &ObjectOptions::default()).await {
Ok(info) => Ok(Some(TableCatalogObjectMetadata {
body_revision: info.data_dir.filter(|id| !id.is_nil()),
etag: info.etag,
mod_time: info.mod_time,
size: u64::try_from(info.size.max(0)).unwrap_or(u64::MAX),
@@ -2419,6 +2485,7 @@ where
.await
{
Ok(info) => Ok(Some(TableCatalogObjectMetadata {
body_revision: info.data_dir.filter(|id| !id.is_nil()),
etag: info.etag,
mod_time: info.mod_time,
size: u64::try_from(info.size.max(0)).unwrap_or(u64::MAX),
@@ -2644,6 +2711,36 @@ where
})
}
async fn list_catalog_objects_page(
&self,
bucket: &str,
prefix: &str,
start_after: Option<&str>,
limit: NonZeroUsize,
) -> TableCatalogStoreResult<TableCatalogCleanupPage> {
let max_keys = i32::try_from(limit.get())
.map_err(|_| TableCatalogStoreError::Invalid("catalog page size exceeds storage API range".to_string()))?;
let page = self
.store
.clone()
.list_unversioned_objects_authoritative(bucket, prefix, start_after.map(str::to_string), max_keys)
.await
.map_err(|err| storage_error_to_catalog("list authoritative catalog page", err))?;
Ok(TableCatalogCleanupPage {
objects: page
.objects
.into_iter()
.map(|object| TableCatalogCleanupObject {
key: object.name,
version_id: object.version_id.filter(|id| !id.is_nil()).map(|id| id.to_string()),
etag: object.etag,
delete_marker: object.delete_marker,
})
.collect(),
is_truncated: page.is_truncated,
})
}
async fn list_objects_for_cleanup(
&self,
bucket: &str,
@@ -2783,6 +2880,7 @@ where
)));
}
}
let body_revision = reader.object_info.data_dir.filter(|id| !id.is_nil());
let etag = reader.object_info.etag.clone();
let mod_time = reader.object_info.mod_time;
let mut data = Vec::new();
@@ -2801,7 +2899,12 @@ where
TableCatalogStoreError::Internal(format!("failed to read catalog object {bucket}/{object}: {err}"))
})?;
}
Ok(Some(TableCatalogObject { data, etag, mod_time }))
Ok(Some(TableCatalogObject {
data,
body_revision,
etag,
mod_time,
}))
}
async fn put_object_with_options(
@@ -2926,33 +3029,52 @@ mod tests {
)))),
acquired: Mutex::new(None),
});
let lock = rustfs_lock::NamespaceLockWrapper::new(
rustfs_lock::NamespaceLock::with_clients_and_quorum(
"maintenance-probe".to_string(),
vec![client.clone() as Arc<dyn LockClient>],
1,
),
rustfs_lock::ObjectKey::new(
RUSTFS_META_BUCKET,
TableCatalogObjectPaths::default().backing_migration_global_fence_lock_path(),
),
"maintenance-migration-probe".to_string(),
let lock = rustfs_lock::NamespaceLock::with_clients_and_quorum(
"maintenance-probe".to_string(),
vec![client.clone() as Arc<dyn LockClient>],
1,
);
let guard = TableCatalogLockGuard::namespace(lock.get_read_lock(Duration::from_secs(5)).await.unwrap());
let publication = TableCommitLockPublication::new(&backend);
publication.attach_catalog_migration_read_guard(guard).unwrap();
publication.begin_table_bucket(bucket).await.unwrap();
publication.prepare(bucket, "sales", "orders").await.unwrap();
assert!(publication.holds_table(bucket, "sales", "orders"));
let resource = rustfs_lock::ObjectKey::new(
RUSTFS_META_BUCKET,
TableCatalogObjectPaths::default().backing_migration_global_fence_lock_path(),
);
// Observe real lease loss before the separate publication preparation
// deadline. Keep the lease TTL unchanged; only speed up this probe.
let request =
rustfs_lock::LockRequest::new(resource.clone(), rustfs_lock::LockType::Shared, "maintenance-migration-probe")
.with_acquire_timeout(Duration::from_secs(5))
.with_ttl(Duration::from_secs(30))
.with_refresh_interval(Duration::from_millis(20));
let guard = TableCatalogLockGuard::namespace(lock.acquire_guard(&request).await.unwrap().unwrap());
let id = client
.acquired
.lock()
.unwrap()
.clone()
.expect("actual migration lease acquired");
.expect("actual publication migration lease acquired");
// Completion clears the publication's guards. Keep another real lease
// to test rejection of an already-lost guard after that cleanup.
let attach_request = rustfs_lock::LockRequest::new(resource, rustfs_lock::LockType::Shared, "maintenance-attach-probe")
.with_acquire_timeout(Duration::from_secs(5))
.with_ttl(Duration::from_secs(30))
.with_refresh_interval(Duration::from_millis(20));
let lost = TableCatalogLockGuard::namespace(lock.acquire_guard(&attach_request).await.unwrap().unwrap());
let attach_id = client
.acquired
.lock()
.unwrap()
.clone()
.expect("actual attachment migration lease acquired");
assert_ne!(id, attach_id, "the attachment probe must use its own lease");
let publication = TableCommitLockPublication::new(&backend);
publication.attach_catalog_migration_read_guard(guard).unwrap();
publication.begin_table_bucket(bucket).await.unwrap();
publication.prepare(bucket, "sales", "orders").await.unwrap();
assert!(publication.holds_table(bucket, "sales", "orders"));
assert!(client.force_release(&id).await.unwrap());
tokio::time::timeout(Duration::from_secs(15), async {
while publication.catalog_migration_read_permit_status() != Some(false) {
assert!(client.force_release(&attach_id).await.unwrap());
tokio::time::timeout(Duration::from_secs(2), async {
while publication.catalog_migration_read_permit_status() != Some(false) || !lost.is_lock_lost() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,185 @@
// 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 super::*;
use std::{
future::Future,
sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
},
task::Poll,
};
/// One admission budget, retained across repeated preparation and completed recovery.
/// It never cancels a transaction after its first possible durable side effect.
#[derive(Default)]
pub(crate) struct TablePublicationPreparation {
deadline: OnceLock<tokio::time::Instant>,
mutation_started: AtomicBool,
fence: parking_lot::Mutex<Option<TableCatalogObjectMutationFence>>,
}
tokio::task_local! {
static PUBLICATION_PREPARATION: Arc<TablePublicationPreparation>;
}
/// Budget for one publication preparation: the configured interval, never
/// shorter than the lock-acquire timeout that queueing alone may consume.
pub(crate) fn publication_preparation_timeout() -> std::time::Duration {
std::time::Duration::from_secs(rustfs_utils::get_env_u64(
crate::table_catalog::ENV_TABLE_CATALOG_PUBLICATION_PREPARATION_TIMEOUT_SECS,
crate::table_catalog::DEFAULT_TABLE_CATALOG_PUBLICATION_PREPARATION_TIMEOUT_SECS,
))
.max(get_lock_acquire_timeout())
}
impl TablePublicationPreparation {
pub(super) fn deadline(&self) -> tokio::time::Instant {
*self
.deadline
.get_or_init(|| tokio::time::Instant::now() + publication_preparation_timeout())
}
fn expired() -> TableCatalogStoreError {
TableCatalogStoreError::Unavailable("table publication preparation deadline exceeded; retry the operation".to_string())
}
fn check(&self) -> TableCatalogStoreResult<()> {
if !self.mutation_started.load(Ordering::Acquire) && tokio::time::Instant::now() >= self.deadline() {
return Err(Self::expired());
}
Ok(())
}
/// Fail once the budget is spent without committing the transaction.
pub(crate) fn check_deadline(&self) -> TableCatalogStoreResult<()> {
self.check()
}
pub(crate) fn begin_mutation(&self) -> TableCatalogStoreResult<()> {
self.check()?;
if let Some(fence) = self.fence.lock().as_ref() {
fence.ensure_held()?;
}
self.mutation_started.store(true, Ordering::Release);
Ok(())
}
// Only use for pure acquisition/validation. The publication is cleared on
// timeout or caller cancellation, including partially acquired guards.
pub(crate) async fn read<T>(
&self,
publication: &(dyn TableCommitPublication + Sync),
future: impl Future<Output = TableCatalogStoreResult<T>>,
) -> TableCatalogStoreResult<T> {
let mut cleanup = PreparationReadCleanup {
publication,
armed: true,
};
let result = if self.mutation_started.load(Ordering::Acquire) {
future.await
} else {
tokio::time::timeout_at(self.deadline(), future)
.await
.map_err(|_| Self::expired())?
};
if result.is_ok() {
self.check()?;
}
cleanup.armed = false;
result
}
async fn run<T>(&self, future: impl Future<Output = TableCatalogStoreResult<T>>) -> TableCatalogStoreResult<T> {
let timer = tokio::time::sleep_until(self.deadline());
tokio::pin!(timer, future);
std::future::poll_fn(|cx| {
// Check before polling new work. A mutation boundary also checks
// synchronously, so CPU work cannot publish after an expired timer.
if !self.mutation_started.load(Ordering::Acquire) && timer.as_mut().poll(cx).is_ready() {
return Poll::Ready(Err(Self::expired()));
}
match future.as_mut().poll(cx) {
Poll::Ready(result) => Poll::Ready(self.check().and(result)),
Poll::Pending => Poll::Pending,
}
})
.await
}
}
struct PreparationReadCleanup<'a> {
publication: &'a (dyn TableCommitPublication + Sync),
armed: bool,
}
impl Drop for PreparationReadCleanup<'_> {
fn drop(&mut self) {
if self.armed {
self.publication.complete();
}
}
}
pub(super) async fn publication_preparation<T>(
publication: &(dyn TableCommitPublication + Sync),
future: impl Future<Output = TableCatalogStoreResult<T>>,
) -> TableCatalogStoreResult<T> {
let _completion = TableCommitPublicationCompletion::new(publication);
let preparation = publication.preparation_budget();
PUBLICATION_PREPARATION
.scope(preparation.clone(), preparation.run(future))
.await
}
pub(super) fn retain_publication_preparation_fence(publication: &(dyn TableCommitPublication + Sync)) {
let _ = PUBLICATION_PREPARATION.try_with(|preparation| {
*preparation.fence.lock() = publication.object_mutation_fence();
});
}
/// Call immediately before the first possible persistent side effect, after
/// read-only validation and existing lease checks. A failed/ambiguous write is
/// still a mutation: recovery must retain the original cancellation semantics.
pub(super) fn begin_publication_mutation() -> TableCatalogStoreResult<()> {
PUBLICATION_PREPARATION
.try_with(|preparation| preparation.begin_mutation())
.unwrap_or(Ok(()))
}
/// Recovery is a preceding transaction, not permission for the new transaction
/// to bypass admission. Complete its original protocol once a write starts,
/// then resume the unchanged deadline before any new preparation.
pub(super) async fn publication_recovery<T>(
future: impl Future<Output = TableCatalogStoreResult<T>>,
) -> TableCatalogStoreResult<T> {
let preparation = PUBLICATION_PREPARATION.try_with(Arc::clone).ok();
let already_mutating = preparation
.as_ref()
.is_some_and(|budget| budget.mutation_started.load(Ordering::Acquire));
let result = future.await;
if result.is_ok()
&& !already_mutating
&& let Some(preparation) = preparation
{
preparation.mutation_started.store(false, Ordering::Release);
preparation.check()?;
}
result
}
#[cfg(test)]
#[path = "publication_deadline_tests.rs"]
mod tests;
@@ -0,0 +1,331 @@
// 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 super::*;
use crate::table_catalog::store::registration_cache_tests::{cache_fixture, registration_entry};
use crate::table_catalog::test_support::TestCatalogObjectBackend;
use crate::table_catalog::tests::{test_bucket_entry, test_namespace_entry, test_table_entry};
fn is_preparation_timeout<T>(result: &TableCatalogStoreResult<T>) -> bool {
matches!(result, Err(TableCatalogStoreError::Unavailable(message)) if message.contains("preparation deadline"))
}
#[tokio::test(start_paused = true)]
async fn publication_deadline_registration_inventory_releases_fence_without_writes() {
let (backend, store, namespace) = cache_fixture().await;
backend.registration_cache.clear();
let path = store
.paths
.table_entry_path("analytics", &namespace, &IdentifierSegment::parse("orders").unwrap());
let pause = backend.pause_before_next_read(RUSTFS_META_BUCKET, &path).await;
let before = backend.state.lock().await.put_attempts.clone();
let registering = {
let store = store.clone();
let entry = registration_entry(&namespace, "candidate");
tokio::spawn(async move { store.register_table(entry).await })
};
pause.wait_started().await;
let writer = {
let backend = backend.clone();
tokio::spawn(async move {
// Data-plane mutations acquire this same read fence before writing.
let _guard = backend
.acquire_read_lock("analytics", &default_table_bucket_publication_lock_path())
.await?;
backend
.put_object(
"analytics",
"tables/other-tenant/data/new",
b"new".to_vec(),
TableCatalogPutPrecondition::IfAbsent,
)
.await
})
};
tokio::task::yield_now().await;
assert!(!writer.is_finished());
tokio::time::advance(publication_preparation_timeout() + StdDuration::from_millis(1)).await;
assert!(is_preparation_timeout(&registering.await.unwrap()));
writer.await.unwrap().unwrap();
pause.release();
assert!(store.load_table("analytics", "sales", "candidate").await.unwrap().is_none());
let after = backend.state.lock().await.put_attempts.clone();
for (key, attempts) in after {
if key.0 == RUSTFS_META_BUCKET {
assert_eq!(Some(&attempts), before.get(&key), "timed-out preparation must not stage catalog writes");
}
}
store
.create_table(registration_entry(&namespace, "after_timeout"))
.await
.unwrap();
}
async fn commit_fixture(strong: bool) -> (TestCatalogObjectBackend, Arc<dyn TableCatalogStore>, TableCommitRequest) {
let backend = TestCatalogObjectBackend::default();
let store: Arc<dyn TableCatalogStore> = if strong {
Arc::new(StrongTableCatalogStore::new(backend.clone()))
} else {
Arc::new(ObjectTableCatalogStore::new(backend.clone()))
};
let namespace = Namespace::parse("sales").unwrap();
let table = IdentifierSegment::parse("orders").unwrap();
let current = default_table_metadata_file_path(&namespace, &table, "00001.metadata.json");
let next = default_table_metadata_file_path(&namespace, &table, "00002.metadata.json");
store.put_table_bucket(test_bucket_entry("analytics")).await.unwrap();
store
.create_namespace(test_namespace_entry("analytics", &namespace))
.await
.unwrap();
store
.create_table(test_table_entry("analytics", &namespace, &table, current.clone()))
.await
.unwrap();
backend.seed_object("analytics", &next, b"{}".to_vec()).await;
(
backend,
store,
TableCommitRequest {
table_bucket: "analytics".to_string(),
namespace: "sales".to_string(),
table: "orders".to_string(),
commit_id: "deadline-commit".to_string(),
idempotency_key: Some("deadline-idempotency".to_string()),
operation: "append".to_string(),
expected_version_token: "token-v1".to_string(),
expected_metadata_location: current,
new_metadata_location: next,
requirements: Vec::new(),
writer: None,
},
)
}
// The durable-strong backing does not take part in the preparation deadline; it is retired separately,
// so only the object backing is exercised here.
#[tokio::test(start_paused = true)]
async fn publication_deadline_commit_metadata_read_does_not_publish_object_backing() {
let (backend, store, request) = commit_fixture(false).await;
let pause = backend
.pause_before_next_read("analytics", &request.new_metadata_location)
.await;
let before = backend.state.lock().await.put_attempts.clone();
let committing = {
let store = store.clone();
let request = request.clone();
tokio::spawn(async move { store.commit_table(request).await })
};
pause.wait_started().await;
tokio::time::advance(publication_preparation_timeout() + StdDuration::from_millis(1)).await;
assert!(is_preparation_timeout(&committing.await.unwrap()));
assert_eq!(backend.state.lock().await.put_attempts, before);
assert_eq!(
store
.load_table("analytics", "sales", "orders")
.await
.unwrap()
.unwrap()
.generation,
1
);
pause.release();
// Positive retry exercises parsing a nontrivial valid metadata body.
backend
.seed_object(
"analytics",
&request.new_metadata_location,
serde_json::to_vec(&serde_json::json!({"properties": {"padding": "x".repeat(1024 * 1024)}})).unwrap(),
)
.await;
tokio::time::resume();
let committed = store.commit_table(request.clone()).await.unwrap();
assert_eq!(committed.table.generation, 2);
assert_eq!(
store.commit_table(request).await.unwrap().table.generation,
2,
"retry must remain idempotent"
);
}
#[tokio::test(start_paused = true)]
async fn publication_deadline_create_finishes_after_first_write_on_either_backing() {
for strong in [false, true] {
let (backend, store, _) = commit_fixture(strong).await;
let namespace = Namespace::parse("sales").unwrap();
let candidate = registration_entry(&namespace, "candidate");
let paths = TableCatalogObjectPaths::default();
let first_write = if strong {
StrongTableCatalogStore::<TestCatalogObjectBackend>::snapshot_object_path()
} else {
paths.warehouse_index_entry_path("analytics", "tables/id-candidate/")
};
let pause = backend.pause_next_put(RUSTFS_META_BUCKET, &first_write).await;
let creating = {
let store = store.clone();
tokio::spawn(async move { store.create_table(candidate).await })
};
pause.wait_started().await;
tokio::time::advance(publication_preparation_timeout() + StdDuration::from_millis(1)).await;
tokio::task::yield_now().await;
assert!(!creating.is_finished(), "deadline must not cancel a possible durable write");
pause.release();
creating.await.unwrap().unwrap();
assert!(store.load_table("analytics", "sales", "candidate").await.unwrap().is_some());
}
}
#[tokio::test(start_paused = true)]
async fn publication_deadline_queueing_and_repeated_prepare_share_one_budget() {
let backend = TestCatalogObjectBackend::default();
let publication = TableCommitLockPublication::new(&backend);
let budget = publication.preparation_budget();
let first_deadline = budget.deadline();
tokio::time::advance(publication_preparation_timeout() - StdDuration::from_millis(1)).await;
publication.begin_table_bucket("analytics").await.unwrap();
publication.prepare("analytics", "sales", "orders").await.unwrap();
assert_eq!(budget.deadline(), first_deadline);
let result = publication_preparation(&publication, async {
publication.prepare("analytics", "sales", "orders").await?;
tokio::time::sleep(StdDuration::from_millis(2)).await;
begin_publication_mutation()
})
.await;
assert!(is_preparation_timeout(&result));
assert!(!publication.holds_table_bucket("analytics"));
assert_eq!(budget.deadline(), first_deadline, "cleanup must not renew the admission budget");
}
#[tokio::test(start_paused = true)]
async fn publication_deadline_completed_recovery_cannot_exempt_a_new_transaction() {
let backend = TestCatalogObjectBackend::default();
let publication = TableCommitLockPublication::new(&backend);
let result = publication_preparation(&publication, async {
publication.begin_table_bucket("analytics").await?;
publication_recovery(async {
begin_publication_mutation()?;
backend
.put_object("analytics", "recovery", b"recovered".to_vec(), TableCatalogPutPrecondition::IfAbsent)
.await?;
tokio::time::sleep(publication_preparation_timeout() + StdDuration::from_millis(1)).await;
Ok(())
})
.await?;
backend
.put_object(
"analytics",
"new-transaction",
b"unexpected".to_vec(),
TableCatalogPutPrecondition::IfAbsent,
)
.await
})
.await;
assert!(is_preparation_timeout(&result));
assert!(backend.object_exists("analytics", "recovery").await.unwrap());
assert!(!backend.object_exists("analytics", "new-transaction").await.unwrap());
assert!(!publication.holds_table_bucket("analytics"));
}
// The durable-strong backing does not take part in the preparation deadline; it is retired separately,
// so only the object backing is exercised here.
#[tokio::test(start_paused = true)]
async fn publication_deadline_create_queue_wait_cannot_restart_after_admission() {
let (backend, store, _) = commit_fixture(false).await;
let key = default_table_bucket_publication_lock_path();
let blocker = backend.acquire_read_lock("analytics", &key).await.unwrap();
let attempts = backend.write_lock_acquisition_count("analytics", &key).await;
let candidate = registration_entry(&Namespace::parse("sales").unwrap(), "queued");
let creating = {
let store = store.clone();
tokio::spawn(async move { store.create_table(candidate).await })
};
while backend.write_lock_acquisition_count("analytics", &key).await == attempts {
tokio::task::yield_now().await;
}
tokio::time::advance(publication_preparation_timeout() + StdDuration::from_millis(1)).await;
assert!(is_preparation_timeout(&creating.await.unwrap()));
drop(blocker);
assert!(store.load_table("analytics", "sales", "queued").await.unwrap().is_none());
store
.create_table(registration_entry(&Namespace::parse("sales").unwrap(), "fresh"))
.await
.unwrap();
}
#[tokio::test(start_paused = true)]
async fn publication_deadline_active_rename_recovery_finishes_before_rejecting_new_registration() {
let (backend, store, namespace) = cache_fixture().await;
let source = store
.paths
.table_entry_path("analytics", &namespace, &IdentifierSegment::parse("orders").unwrap());
backend.fail_next_put(RUSTFS_META_BUCKET, &source).await;
assert!(
store
.rename_table("analytics", "sales", "orders", "sales", "renamed_orders")
.await
.is_err()
);
assert!(
store
.get_table_bucket("analytics")
.await
.unwrap()
.unwrap()
.active_rename_id
.is_some()
);
let pause = backend.pause_next_put(RUSTFS_META_BUCKET, &source).await;
let registering = {
let store = store.clone();
let candidate = registration_entry(&namespace, "after_recovery");
tokio::spawn(async move { store.register_table(candidate).await })
};
pause.wait_started().await;
tokio::time::advance(publication_preparation_timeout() + StdDuration::from_millis(1)).await;
tokio::task::yield_now().await;
assert!(
!registering.is_finished(),
"in-flight recovery must not be cancelled by the preparation deadline"
);
pause.release();
assert!(is_preparation_timeout(&registering.await.unwrap()));
assert!(
store
.get_table_bucket("analytics")
.await
.unwrap()
.unwrap()
.active_rename_id
.is_none()
);
assert!(store.load_table("analytics", "sales", "orders").await.unwrap().is_none());
assert!(
store
.load_table("analytics", "sales", "renamed_orders")
.await
.unwrap()
.is_some()
);
assert!(
store
.load_table("analytics", "sales", "after_recovery")
.await
.unwrap()
.is_none()
);
store
.register_table(registration_entry(&namespace, "fresh_request"))
.await
.unwrap();
}
@@ -171,10 +171,44 @@ impl<B: TableCatalogObjectBackend> ObjectTableCatalogStore<B> {
let index_prefix = self.paths.warehouse_index_entries_prefix(&entry.table_bucket);
// The backend must preserve system-bucket committed-read semantics as
// well as completeness; ordinary S3 LIST is insufficient for cache hits.
let mut tables = self
.backend
.list_objects_for_cleanup(self.catalog_bucket(), &namespace_prefix)
.await?;
let directory = self.table_directory_objects(&entry.table_bucket, None).await?;
let directory_epoch = directory.as_ref().map(|scan| scan.activation_id);
let mut tables = match directory {
Some(scan) => {
// The directory proves which keys to inspect, but cached owners
// still require the current GET-equivalent committed ETag. Do
// not replace this exact version probe with an ordinary stat.
stream::iter(scan.objects)
.map(|key| async move {
let page = self
.backend
.list_catalog_objects_page(
self.catalog_bucket(),
&key,
None,
NonZeroUsize::new(2).ok_or_else(|| {
TableCatalogStoreError::Internal("exact inventory page size must be positive".to_string())
})?,
)
.await?;
if page.is_truncated || page.objects.len() > 1 || page.objects.iter().any(|object| object.key != key) {
return Err(TableCatalogStoreError::Invalid(
"table directory target has an ambiguous authoritative inventory".to_string(),
));
}
Ok(page.objects.into_iter().next())
})
.buffered(TABLE_REGISTRATION_READ_CONCURRENCY)
.try_filter_map(|object| async move { Ok(object) })
.try_collect::<Vec<_>>()
.await?
}
None => {
self.backend
.list_objects_for_cleanup(self.catalog_bucket(), &namespace_prefix)
.await?
}
};
let mut indexes = self
.backend
.list_objects_for_cleanup(self.catalog_bucket(), &index_prefix)
@@ -252,6 +286,9 @@ impl<B: TableCatalogObjectBackend> ObjectTableCatalogStore<B> {
})?;
table_warehouse_index_state_ready(&state, &entry.table_bucket)?;
}
if let Some(epoch) = directory_epoch {
self.ensure_table_directory_epoch(&entry.table_bucket, epoch).await?;
}
if !publication.holds_table_bucket(&entry.table_bucket) {
return Err(TableCatalogStoreError::Unavailable("registration publication fence was lost".to_string()));
}
+30 -2
View File
@@ -1532,9 +1532,19 @@ where
}
}
#[cfg(test)]
pub(in crate::table_catalog) async fn materialize_bucket_snapshot(
&self,
source: StrongTableCatalogBucketSnapshot,
) -> TableCatalogStoreResult<(String, bool)> {
self.materialize_bucket_snapshot_with_fence(source, &TableCatalogObjectMutationFence::default())
.await
}
pub(in crate::table_catalog) async fn materialize_bucket_snapshot_with_fence(
&self,
source: StrongTableCatalogBucketSnapshot,
fence: &TableCatalogObjectMutationFence,
) -> TableCatalogStoreResult<(String, bool)> {
let _write_guard = self.write_lock.lock().await;
self.hydrate_state().await?;
@@ -1562,7 +1572,8 @@ where
postcondition,
)
};
self.finalize_snapshot_write(snapshot, precondition, postcondition).await?;
self.finalize_snapshot_write_with_fence(snapshot, precondition, postcondition, Some(fence))
.await?;
self.hydrate_state().await?;
let state = self.state.lock().await;
let snapshot_etag = state
@@ -1572,11 +1583,27 @@ where
Ok((snapshot_etag, true))
}
#[cfg(test)]
pub(in crate::table_catalog) async fn remove_bucket_snapshot_if_unchanged(
&self,
table_bucket: &str,
expected_fingerprint: &str,
) -> TableCatalogStoreResult<()> {
self.remove_bucket_snapshot_if_unchanged_with_fence(
table_bucket,
expected_fingerprint,
&TableCatalogObjectMutationFence::default(),
)
.await
}
pub(in crate::table_catalog) async fn remove_bucket_snapshot_if_unchanged_with_fence(
&self,
table_bucket: &str,
expected_fingerprint: &str,
fence: &TableCatalogObjectMutationFence,
) -> TableCatalogStoreResult<()> {
fence.ensure_held()?;
let _write_guard = self.write_lock.lock().await;
self.hydrate_state().await?;
let (snapshot, precondition, postcondition) = {
@@ -1597,7 +1624,8 @@ where
StrongSnapshotWritePostcondition::TableBucketAbsent(table_bucket.to_string()),
)
};
self.finalize_snapshot_write(snapshot, precondition, postcondition).await
self.finalize_snapshot_write_with_fence(snapshot, precondition, postcondition, Some(fence))
.await
}
pub(super) async fn restore_absent_migration_snapshot_baseline(&self) -> TableCatalogStoreResult<()> {
+109 -10
View File
@@ -524,8 +524,16 @@ impl TableCommitPublication for BlockingObjectPublication {
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct TestCatalogWorkerContext {
pub(crate) access_key: Option<String>,
pub(crate) operation: Option<&'static str>,
pub(crate) span_name: Option<&'static str>,
}
#[derive(Default)]
pub(crate) struct TestCatalogObjectState {
pub(crate) observed_worker_contexts: Option<Vec<TestCatalogWorkerContext>>,
pub(crate) objects: BTreeMap<(String, String), TestCatalogObjectRecord>,
pub(crate) etagless_objects: BTreeSet<(String, String)>,
pub(crate) fail_read_attempts: BTreeMap<(String, String), BTreeSet<usize>>,
@@ -546,6 +554,10 @@ pub(crate) struct TestCatalogObjectState {
pub(crate) read_calls: usize,
pub(crate) read_bytes: usize,
pub(crate) metadata_calls: usize,
pub(crate) pause_metadata: BTreeMap<(String, String), TestCatalogObjectPause>,
pub(crate) pause_after_metadata: BTreeMap<(String, String), TestCatalogObjectPause>,
pub(crate) fail_put_prefix: Option<(String, String)>,
pub(crate) corrupt_put_prefix: Option<(String, String)>,
pub(crate) list_calls: usize,
pub(crate) failed_inventory_prefixes: BTreeSet<String>,
pub(crate) inventory_overrides: BTreeMap<String, Vec<TableCatalogCleanupObject>>,
@@ -554,6 +566,7 @@ pub(crate) struct TestCatalogObjectState {
#[derive(Clone)]
pub(crate) struct TestCatalogObjectRecord {
pub(crate) body_revision: Option<Uuid>,
pub(crate) data: Vec<u8>,
pub(crate) etag: String,
pub(crate) mod_time: Option<OffsetDateTime>,
@@ -574,9 +587,15 @@ impl TestCatalogObjectBackend {
) {
let mut state = self.state.lock().await;
let etag = state.next_etag();
state
.objects
.insert((bucket.to_string(), object.to_string()), TestCatalogObjectRecord { data, etag, mod_time });
state.objects.insert(
(bucket.to_string(), object.to_string()),
TestCatalogObjectRecord {
body_revision: Some(Uuid::new_v4()),
data,
etag,
mod_time,
},
);
}
pub(crate) async fn fail_put_attempt(&self, bucket: &str, object: &str, attempt: usize) {
@@ -781,6 +800,23 @@ impl TestCatalogObjectBackend {
if let Some(fence) = fence {
fence.ensure_held()?;
}
let (fail_prefix, corrupt_prefix) = {
let mut state = self.state.lock().await;
let matches = |key: &(String, String)| key.0 == bucket && object.starts_with(&key.1);
let fail = state.fail_put_prefix.as_ref().is_some_and(matches);
let corrupt = state.corrupt_put_prefix.as_ref().is_some_and(matches);
if fail {
state.fail_put_prefix.take();
}
if corrupt {
state.corrupt_put_prefix.take();
}
(fail, corrupt)
};
if fail_prefix {
return Err(TableCatalogStoreError::Internal("injected candidate write failure".to_owned()));
}
let data = if corrupt_prefix { b"{}".to_vec() } else { data };
let mut fail_put_object_path = self.fail_put_object_path.lock().await;
if fail_put_object_path.as_deref() == Some(object) {
fail_put_object_path.take();
@@ -854,6 +890,7 @@ impl TestCatalogObjectBackend {
state.objects.insert(
key.clone(),
TestCatalogObjectRecord {
body_revision: Some(Uuid::new_v4()),
data,
etag,
mod_time: Some(OffsetDateTime::now_utc()),
@@ -995,6 +1032,16 @@ impl TableCatalogObjectBackend for TestCatalogObjectBackend {
let (attempt, pause_before) = {
let mut state = self.state.lock().await;
state.read_calls += 1;
if let Some(observations) = state.observed_worker_contexts.as_mut() {
observations.push(TestCatalogWorkerContext {
access_key: rustfs_keystone::KEYSTONE_CREDENTIALS
.try_with(|credentials| credentials.as_ref().map(|credentials| credentials.access_key.clone()))
.ok()
.flatten(),
operation: rustfs_io_metrics::s3_http_metrics::current_s3_http_operation_name(),
span_name: tracing::Span::current().metadata().map(|metadata| metadata.name()),
});
}
let attempt = {
let attempts = state.read_attempts.entry(key.clone()).or_default();
*attempts += 1;
@@ -1023,6 +1070,7 @@ impl TableCatalogObjectBackend for TestCatalogObjectBackend {
let mut state = self.state.lock().await;
let etagless = state.etagless_objects.contains(&key);
let result = state.objects.get(&key).map(|record| TableCatalogObject {
body_revision: record.body_revision,
data: record.data.clone(),
etag: (!etagless).then(|| record.etag.clone()),
mod_time: record.mod_time,
@@ -1068,15 +1116,42 @@ impl TableCatalogObjectBackend for TestCatalogObjectBackend {
}
async fn object_metadata(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<Option<TableCatalogObjectMetadata>> {
let pause = self
.state
.lock()
.await
.pause_metadata
.remove(&(bucket.to_string(), object.to_string()));
if let Some(pause) = pause {
pause.started.notify_one();
pause.release.notified().await;
}
let mut state = self.state.lock().await;
state.metadata_calls += 1;
let key = (bucket.to_string(), object.to_string());
let etagless = state.etagless_objects.contains(&key);
Ok(state.objects.get(&key).map(|record| TableCatalogObjectMetadata {
let metadata = state.objects.get(&key).map(|record| TableCatalogObjectMetadata {
body_revision: record.body_revision,
etag: (!etagless).then(|| record.etag.clone()),
mod_time: record.mod_time,
size: record.data.len() as u64,
}))
});
let pause = state.pause_after_metadata.remove(&key);
drop(state);
if let Some(pause) = pause {
pause.started.notify_one();
pause.release.notified().await;
}
Ok(metadata)
}
async fn object_metadata_unlocked(
&self,
bucket: &str,
object: &str,
) -> TableCatalogStoreResult<Option<TableCatalogObjectMetadata>> {
// Match the ECStore stat path while publication already holds the object lock.
self.object_metadata(bucket, object).await
}
async fn object_exists(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<bool> {
@@ -1202,6 +1277,25 @@ impl TableCatalogObjectBackend for TestCatalogObjectBackend {
.collect())
}
async fn list_catalog_objects_page(
&self,
bucket: &str,
prefix: &str,
start_after: Option<&str>,
limit: NonZeroUsize,
) -> TableCatalogStoreResult<TableCatalogCleanupPage> {
// Only this in-memory fixture materializes the complete inventory.
// The production adapter uses a bounded authoritative storage page.
let mut objects = self.list_objects_for_cleanup(bucket, prefix).await?;
objects.sort_by(|left, right| left.key.cmp(&right.key));
objects.retain(|object| start_after.is_none_or(|after| object.key.as_str() > after));
// Production stops on a full page without consuming lookahead. Only
// a subsequent shorter (possibly empty) page proves EOF.
let is_truncated = objects.len() >= limit.get();
objects.truncate(limit.get());
Ok(TableCatalogCleanupPage { objects, is_truncated })
}
async fn ensure_no_multipart_uploads(&self, _bucket: &str) -> TableCatalogStoreResult<()> {
Ok(())
}
@@ -1281,6 +1375,7 @@ impl TestCatalogObjectBackend {
self.state.lock().await.objects.insert(
(bucket.to_string(), object.to_string()),
TestCatalogObjectRecord {
body_revision: Some(Uuid::new_v4()),
data,
etag,
mod_time: None,
@@ -1311,11 +1406,15 @@ impl TestCatalogObjectBackend {
) {
let data = serde_json::to_vec(&value).expect("metadata JSON should serialize");
let etag = content_etag(&data);
self.state
.lock()
.await
.objects
.insert((bucket.to_string(), object.to_string()), TestCatalogObjectRecord { data, etag, mod_time });
self.state.lock().await.objects.insert(
(bucket.to_string(), object.to_string()),
TestCatalogObjectRecord {
body_revision: Some(Uuid::new_v4()),
data,
etag,
mod_time,
},
);
}
pub(crate) async fn write_lock_is_held(&self, bucket: &str, object: &str) -> bool {
+426 -57
View File
@@ -32,16 +32,17 @@ fn table_data_plane_index_miss_scan_rejects_catalogs_above_its_object_limit() {
"catalog/table-2/table-entry.json".to_string(),
];
// Only table entries count toward the limit; the namespace object is free.
assert_eq!(
bounded_table_entry_objects_for_data_plane_scan(objects.clone(), false, 3).unwrap(),
bounded_table_entry_objects_for_data_plane_scan(objects.clone(), false, 2).unwrap(),
vec![
"catalog/table-1/table-entry.json".to_string(),
"catalog/table-2/table-entry.json".to_string(),
]
);
assert_matches!(
bounded_table_entry_objects_for_data_plane_scan(objects, false, 2),
Err(TableCatalogStoreError::Unavailable(message)) if message.contains("2-catalog-object safety limit")
bounded_table_entry_objects_for_data_plane_scan(objects, false, 1),
Err(TableCatalogStoreError::Unavailable(message)) if message.contains("1-catalog-object safety limit")
);
assert_matches!(
bounded_table_entry_objects_for_data_plane_scan(vec!["catalog/table-entry.json".to_string()], true, 1),
@@ -4874,6 +4875,7 @@ async fn maintenance_test_object_versions(backend: &TestCatalogObjectBackend) ->
(
key.clone(),
TableCatalogObject {
body_revision: object.body_revision,
data: object.data.clone(),
etag: Some(object.etag.clone()),
mod_time: object.mod_time,
@@ -7047,6 +7049,7 @@ async fn table_metadata_data_plane_resource_resolves_only_current_metadata() {
seed_table_for_metadata_maintenance(&store, bucket, &namespace, &table, current.clone()).await;
backend.reset_call_counts().await;
backend.state.lock().await.read_attempts.clear();
let resource = table_metadata_data_plane_resource_for_object(&store, bucket, &current)
.await
@@ -7055,7 +7058,20 @@ async fn table_metadata_data_plane_resource_resolves_only_current_metadata() {
assert_eq!(resource.namespace, "sales");
assert_eq!(resource.table, "orders");
assert_eq!(backend.list_call_count().await, 1);
assert_eq!(backend.read_call_count().await, 2);
let paths = TableCatalogObjectPaths::default();
// One bucket generation read, one directory mode probe, and one authoritative
// table entry. The legacy fixture must never load a candidate object.
assert_eq!(
backend.state.lock().await.read_attempts,
[
paths.table_bucket_entry_path(bucket),
paths.backing_migration_fence_path(bucket),
paths.table_entry_path(bucket, &namespace, &table),
]
.into_iter()
.map(|key| ((RUSTFS_META_BUCKET.to_owned(), key), 1))
.collect()
);
assert_eq!(backend.metadata_call_count().await, 1);
let historical = default_table_metadata_file_path(&namespace, &table, "00000.metadata.json");
@@ -8125,10 +8141,26 @@ async fn operator_backfill_reconciles_a_missing_ready_index_above_the_data_plane
.delete_object(RUSTFS_META_BUCKET, &index_path)
.await
.expect("warehouse index entry should be removed");
let catalog_prefix = store.paths.namespace_entries_prefix(bucket);
// Only table entries count toward the data-plane limit; seed enough real
// entries that, with the table under test, the catalog exceeds it.
let noise_namespace = Namespace::parse("noise").expect("namespace should parse");
for index in 0..TABLE_DATA_PLANE_INDEX_MISS_SCAN_MAX_CATALOG_OBJECTS {
let noise_table = IdentifierSegment::parse(format!("noise-{index}")).expect("table should parse");
let mut entry = test_table_entry(
bucket,
&noise_namespace,
&noise_table,
default_table_metadata_file_path(&noise_namespace, &noise_table, "00001.metadata.json"),
);
entry.table_id = format!("noise-{index}");
entry.table_uuid = format!("noise-uuid-{index}");
entry.warehouse_location = format!("s3://{bucket}/tables/noise-{index}");
backend
.seed_object(RUSTFS_META_BUCKET, &format!("{catalog_prefix}noise-{index}.json"), b"{}".to_vec())
.seed_object(
RUSTFS_META_BUCKET,
&store.paths.table_entry_path(bucket, &noise_namespace, &noise_table),
serde_json::to_vec(&entry).expect("entry should encode"),
)
.await;
}
@@ -13031,6 +13063,207 @@ async fn maintenance_reachability_fails_closed_when_retained_metadata_is_unreada
);
}
#[tokio::test]
async fn object_maintenance_refuses_roots_shared_after_cross_namespace_rename() {
let backend = TestCatalogObjectBackend::default();
let store = ObjectTableCatalogStore::new(backend.clone());
let bucket = "analytics";
let namespace = Namespace::parse("sales").unwrap();
let destination = Namespace::parse("private").unwrap();
let table = IdentifierSegment::parse("orders").unwrap();
let original_metadata = default_table_metadata_file_path(&namespace, &table, "00001.metadata.json");
let replacement_metadata = default_table_metadata_file_path(&namespace, &table, "00002.metadata.json");
seed_table_for_metadata_maintenance(&store, bucket, &namespace, &table, original_metadata.clone()).await;
backend
.seed_object(bucket, &original_metadata, br#"{"metadata-log":[]}"#.to_vec())
.await;
store
.create_namespace(test_namespace_entry(bucket, &destination))
.await
.unwrap();
store
.rename_table(bucket, "sales", "orders", "private", "orders")
.await
.unwrap();
let mut replacement = test_table_entry(bucket, &namespace, &table, replacement_metadata.clone());
replacement.table_id = "replacement-id".to_string();
replacement.table_uuid = "replacement-uuid".to_string();
replacement.warehouse_location = format!("s3://{bucket}/tables/replacement-id");
backend
.seed_object(bucket, &replacement_metadata, br#"{"metadata-log":[]}"#.to_vec())
.await;
store.create_table(replacement).await.unwrap();
assert_matches!(store.plan_table_metadata_maintenance(bucket, "sales", "orders", 0).await,
Err(TableCatalogStoreError::Conflict(message)) if message.contains("exclusive metadata"));
assert_matches!(store.delete_table_metadata_maintenance_candidates(bucket, "sales", "orders", 0).await,
Err(TableCatalogStoreError::Conflict(message)) if message.contains("exclusive metadata"));
assert!(backend.object_exists(bucket, &original_metadata).await.unwrap());
assert_eq!(
store
.load_table(bucket, "private", "orders")
.await
.unwrap()
.unwrap()
.metadata_location,
original_metadata
);
assert!(backend.state.lock().await.delete_attempts.is_empty());
}
#[tokio::test]
async fn object_maintenance_rechecks_root_ownership_under_publication_fence() {
let backend = TestCatalogObjectBackend::default();
let store = ObjectTableCatalogStore::new(backend.clone());
let bucket = "analytics";
let namespace = Namespace::parse("sales").unwrap();
let other_namespace = Namespace::parse("private").unwrap();
let table = IdentifierSegment::parse("orders").unwrap();
let current = default_table_metadata_file_path(&namespace, &table, "00002.metadata.json");
let orphan = default_table_metadata_file_path(&namespace, &table, "00001.metadata.json");
seed_table_for_metadata_maintenance(&store, bucket, &namespace, &table, current.clone()).await;
backend
.seed_object(bucket, &current, br#"{"metadata-log":[]}"#.to_vec())
.await;
backend.seed_object(bucket, &orphan, br#"{"metadata-log":[]}"#.to_vec()).await;
let report = store
.plan_table_metadata_maintenance(bucket, "sales", "orders", 0)
.await
.unwrap();
assert_eq!(report.deletable_metadata_locations, vec![orphan.clone()]);
store
.create_namespace(test_namespace_entry(bucket, &other_namespace))
.await
.unwrap();
let mut other = test_table_entry(bucket, &other_namespace, &table, orphan.clone());
other.table_id = "other-id".to_string();
other.table_uuid = "other-uuid".to_string();
other.warehouse_location = format!("s3://{bucket}/tables/other-id");
store.create_table(other.clone()).await.unwrap();
assert_matches!(store.delete_table_metadata_maintenance_report(bucket, "sales", "orders", report.clone()).await,
Err(TableCatalogStoreError::Conflict(message)) if message.contains("exclusive metadata"));
assert!(backend.object_exists(bucket, &orphan).await.unwrap());
store.drop_table(bucket, "private", "orders").await.unwrap();
let pause = backend.pause_next_delete(bucket, &orphan).await;
let deleting_store = store.clone();
let deleting = tokio::spawn(async move {
deleting_store
.delete_table_metadata_maintenance_report(bucket, "sales", "orders", report)
.await
});
tokio::time::timeout(TABLE_CATALOG_TEST_TIMEOUT, pause.wait_started())
.await
.unwrap();
let publication_lock = default_table_bucket_publication_lock_path();
let attempts = backend.write_lock_acquisition_count(bucket, &publication_lock).await;
let creating_store = store.clone();
let creating = tokio::spawn(async move { creating_store.create_table(other).await });
tokio::time::timeout(TABLE_CATALOG_TEST_TIMEOUT, async {
while backend.write_lock_acquisition_count(bucket, &publication_lock).await == attempts {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert!(!creating.is_finished(), "new owner cannot publish between root validation and deletion");
creating.abort();
let _ = creating.await;
pause.release();
let deleted = tokio::time::timeout(TABLE_CATALOG_TEST_TIMEOUT, deleting)
.await
.unwrap()
.unwrap()
.unwrap();
assert_eq!(deleted.deletable_metadata_locations, vec![orphan.clone()]);
assert!(!backend.object_exists(bucket, &orphan).await.unwrap());
assert!(backend.object_exists(bucket, &current).await.unwrap());
}
#[tokio::test]
async fn object_maintenance_rejects_a_report_from_a_replaced_table_incarnation() {
let backend = TestCatalogObjectBackend::default();
let store = ObjectTableCatalogStore::new(backend.clone());
let bucket = "analytics";
let namespace = Namespace::parse("sales").unwrap();
let table = IdentifierSegment::parse("orders").unwrap();
let current = default_table_metadata_file_path(&namespace, &table, "00002.metadata.json");
let orphan = default_table_metadata_file_path(&namespace, &table, "00001.metadata.json");
seed_table_for_metadata_maintenance(&store, bucket, &namespace, &table, current.clone()).await;
backend
.seed_object(bucket, &current, br#"{"metadata-log":[]}"#.to_vec())
.await;
backend.seed_object(bucket, &orphan, br#"{"metadata-log":[]}"#.to_vec()).await;
let report = store
.plan_table_metadata_maintenance(bucket, "sales", "orders", 0)
.await
.unwrap();
store.drop_table(bucket, "sales", "orders").await.unwrap();
let mut replacement = test_table_entry(bucket, &namespace, &table, current.clone());
replacement.table_id = "new-incarnation".to_string();
replacement.table_uuid = "new-uuid".to_string();
replacement.warehouse_location = format!("s3://{bucket}/tables/new-incarnation");
store.create_table(replacement).await.unwrap();
assert_matches!(store.delete_table_metadata_maintenance_report(bucket, "sales", "orders", report).await,
Err(TableCatalogStoreError::Invalid(message)) if message.contains("identity does not match"));
assert!(backend.object_exists(bucket, &current).await.unwrap());
assert!(backend.object_exists(bucket, &orphan).await.unwrap());
}
#[tokio::test]
async fn maintenance_orphan_planning_and_deletion_never_read_object_bodies() {
let backend = TestCatalogObjectBackend::default();
let store = ObjectTableCatalogStore::new(backend.clone());
let bucket = "analytics";
let namespace = Namespace::parse("sales").unwrap();
let table = IdentifierSegment::parse("orders").unwrap();
let current = default_table_metadata_file_path(&namespace, &table, "00002.metadata.json");
seed_table_for_metadata_maintenance(&store, bucket, &namespace, &table, current.clone()).await;
backend
.seed_object(bucket, &current, br#"{"metadata-log":[]}"#.to_vec())
.await;
let root = format!("{}{}/", default_table_root_prefix(&namespace), table.as_str());
let old = format!("{root}data/old.parquet");
let fresh = format!("{root}delete/fresh.parquet");
let unknown = format!("{root}metadata/unknown.avro");
backend.seed_object(bucket, &old, b"body must not be read".to_vec()).await;
backend
.seed_object_with_mod_time(bucket, &fresh, vec![1], Some(OffsetDateTime::now_utc()))
.await;
backend.seed_object_with_mod_time(bucket, &unknown, vec![2], None).await;
for object in [&old, &fresh, &unknown] {
backend.fail_next_read(bucket, object).await;
}
let report = store
.plan_table_metadata_maintenance(bucket, "sales", "orders", 0)
.await
.expect("stat-only dry run");
assert_eq!(report.deletable_object_locations, vec![old.clone()]);
assert_eq!(
object_cleanup_report(&report, &fresh).state,
TableMetadataMaintenanceObjectState::PendingSafetyWindow
);
assert_eq!(
object_cleanup_report(&report, &unknown).state,
TableMetadataMaintenanceObjectState::PendingSafetyWindow
);
let deleted = store
.delete_table_metadata_maintenance_candidates(bucket, "sales", "orders", 0)
.await
.expect("stat-only deletion recheck");
assert_eq!(deleted.deletable_object_locations, vec![old.clone()]);
assert!(!backend.object_exists(bucket, &old).await.unwrap());
assert!(backend.object_exists(bucket, &fresh).await.unwrap());
assert!(backend.object_exists(bucket, &unknown).await.unwrap());
let state = backend.state.lock().await;
for object in [&old, &fresh, &unknown] {
assert_eq!(
state.read_attempts.get(&(bucket.to_string(), object.to_string())),
None,
"orphan body must never be fetched"
);
}
}
#[tokio::test]
async fn maintenance_dry_run_reports_unreachable_manifest_data_and_delete_candidates() {
let backend = TestCatalogObjectBackend::default();
@@ -13570,6 +13803,7 @@ async fn compaction_plan_reports_manifest_reader_gap_without_rewrite_candidates(
}
#[tokio::test]
#[serial_test::serial(table_compaction)]
async fn compaction_plan_reports_row_level_delete_files_without_rewrite_candidates() {
let backend = TestCatalogObjectBackend::default();
let store = ObjectTableCatalogStore::new(backend.clone());
@@ -13744,6 +13978,7 @@ async fn compaction_plan_requires_current_snapshot_metadata() {
}
#[tokio::test]
#[serial_test::serial(table_compaction)]
async fn compaction_commit_rewrites_small_data_files_and_advances_pointer() {
let backend = TestCatalogObjectBackend::default();
let store = ObjectTableCatalogStore::new(backend.clone());
@@ -13918,6 +14153,7 @@ async fn compaction_commit_rewrites_small_data_files_and_advances_pointer() {
}
#[tokio::test]
#[serial_test::serial(table_compaction)]
async fn compaction_commit_keeps_partition_rewrite_groups_isolated() {
let backend = TestCatalogObjectBackend::default();
let store = ObjectTableCatalogStore::new(backend.clone());
@@ -14099,6 +14335,7 @@ async fn compaction_commit_keeps_partition_rewrite_groups_isolated() {
}
#[tokio::test]
#[serial_test::serial(table_compaction)]
async fn compaction_commit_preserves_sort_order_and_keeps_groups_isolated() {
let backend = TestCatalogObjectBackend::default();
let store = ObjectTableCatalogStore::new(backend.clone());
@@ -14238,6 +14475,7 @@ async fn compaction_commit_preserves_sort_order_and_keeps_groups_isolated() {
}
#[tokio::test]
#[serial_test::serial(table_compaction)]
async fn compaction_commit_rejects_schema_mismatch_without_advancing_pointer() {
let backend = TestCatalogObjectBackend::default();
let store = ObjectTableCatalogStore::new(backend.clone());
@@ -14313,6 +14551,7 @@ async fn compaction_commit_rejects_schema_mismatch_without_advancing_pointer() {
}
#[tokio::test]
#[serial_test::serial(table_compaction)]
async fn compaction_commit_rejects_deleted_manifest_entries_without_advancing_pointer() {
let backend = TestCatalogObjectBackend::default();
let store = ObjectTableCatalogStore::new(backend.clone());
@@ -18514,7 +18753,8 @@ async fn strong_catalog_snapshot_rejects_unknown_fields_and_versions() {
.as_object_mut()
.expect("snapshot should be an object")
.remove("future-field");
value["version"] = serde_json::Value::from(STRONG_TABLE_CATALOG_SNAPSHOT_VERSION.saturating_add(1));
// Only snapshot versions up to the current one are readable.
value["version"] = serde_json::Value::from(4);
backend
.seed_object(
RUSTFS_META_BUCKET,
@@ -22089,58 +22329,108 @@ fn table_metadata_pointer_json_round_trips() {
#[test]
fn object_mutation_entrypoints_call_reserved_prefix_guard() {
let source = [
include_str!("../app/object/mod.rs"),
include_str!("../app/object/shared.rs"),
include_str!("../app/object/extract.rs"),
include_str!("../app/object/put.rs"),
include_str!("../app/object/copy.rs"),
include_str!("../app/object/delete.rs"),
include_str!("../app/object/head.rs"),
include_str!("../app/object/restore.rs"),
]
.concat();
let delete_module = include_str!("../app/object/delete.rs");
let delete_object = delete_module
.split_once("pub async fn execute_delete_object")
.map(|(_, remainder)| {
remainder
.split_once("\nmod tests")
.map_or(remainder, |(entrypoints, _)| entrypoints)
})
.expect("delete object entrypoints should remain in app/object/delete.rs");
for expected in [
"validate_object_key(&key, request_method_name)?;\n validate_table_catalog_object_mutation(&bucket, &key).await?;",
"if let Err(err) = validate_table_catalog_object_mutation(&bucket, &obj_id.key).await",
"validate_table_catalog_object_mutation(&bucket, &object).await?;",
"validate_object_key(&key, \"PUT\")?;\n validate_table_catalog_object_mutation(&bucket, &key).await?;",
"extract_try!(validate_table_catalog_object_mutation(&bucket, &fpath).await);",
] {
assert!(source.contains(expected), "missing object mutation guard: {expected}");
// Scope every assertion to one implementation method: a guard in a sibling
// method (or its tests) must not satisfy another entrypoint's contract.
fn method<'a>(source: &'a str, signature: &str) -> &'a str {
source
.split_once(signature)
.unwrap_or_else(|| panic!("missing object mutation method: {signature}"))
.1
.split_once("\n }\n")
.unwrap_or_else(|| panic!("missing method boundary: {signature}"))
.0
}
let copy_object = include_str!("../app/object/copy.rs")
.split_once("async fn execute_copy_object_body(")
.map(|(_, body)| body.split_once("\n#[cfg(test)]").map_or(body, |(entrypoint, _)| entrypoint))
.expect("copy object entrypoint should remain in app/object/copy.rs");
let copy_key = copy_object
.find("validate_object_key(&key, \"COPY (dest)\")?;")
.expect("copy destination key validation");
let copy_guard = copy_object
.find("validate_table_catalog_object_mutation(&bucket, &key).await?;")
.expect("copy destination catalog guard");
let copy_store = copy_object
.find("let Some(store) = self.object_store()")
.expect("copy object store resolution");
assert!(
copy_key < copy_guard && copy_guard < copy_store,
"copy must guard the destination before store access"
);
assert!(
delete_object.contains("validate_object_key(&key, \"DELETE\")?;")
&& delete_object.contains("validate_table_catalog_object_mutation(&bucket, &key).await?;"),
"delete object entrypoint must validate the object key and reserved catalog prefix"
fn assert_ordered(body: &str, expected: &[&str]) {
let mut remainder = body;
for statement in expected {
remainder = remainder
.split_once(statement)
.unwrap_or_else(|| panic!("missing or out-of-order object mutation guard: {statement}"))
.1;
}
}
let put = include_str!("../app/object/put.rs");
let copy = include_str!("../app/object/copy.rs");
let delete = include_str!("../app/object/delete.rs");
let extract = include_str!("../app/object/extract.rs");
let restore = include_str!("../app/object/restore.rs");
for (source, signature, delegate) in [
(put, "fn execute_put_object(", "self.execute_put_object_boxed(_fs, req)"),
(put, "fn execute_put_object_boxed<'a>(", "self.execute_put_object_inner(_fs, req)"),
(put, "fn execute_put_object_inner(", "self.execute_put_object_extract(req)"),
(copy, "fn execute_copy_object(", "self.execute_copy_object_body_boxed(req)"),
(copy, "fn execute_copy_object_body_boxed(", "self.execute_copy_object_body(req)"),
(delete, "fn execute_delete_object(", "owner.execute_delete_object_owned(req)"),
(delete, "fn execute_delete_objects(", "owner.execute_delete_objects_owned(req)"),
(extract, "fn execute_put_object_extract(", "self.execute_put_object_extract_boxed(req)"),
(
extract,
"fn execute_put_object_extract_boxed(",
"self.execute_put_object_extract_inner(req)",
),
] {
assert!(
method(source, signature).contains(delegate),
"{signature} must call its guarded implementation"
);
}
let key_guard = "validate_table_catalog_object_mutation(&bucket, &key).await?;";
let store_resolution = "let Some(store) = self.object_store()";
for (source, signature, key_validation, mutation_boundary) in [
(
put,
"fn execute_put_object_inner(",
"validate_new_object_key(&key, request_method_name)?;",
"self.put_object_core(write, body, start_time).await?;",
),
(
copy,
"fn execute_copy_object_body(",
"validate_new_object_key(&key, \"COPY (dest)\")?;",
store_resolution,
),
(
delete,
"fn execute_delete_object_owned(",
"validate_object_key(&key, \"DELETE\")?;",
store_resolution,
),
(
extract,
"fn execute_put_object_extract_inner(",
"validate_new_object_key(&key, \"PUT\")?;",
store_resolution,
),
] {
assert_ordered(method(source, signature), &[key_validation, key_guard, mutation_boundary]);
}
assert_ordered(
method(restore, "fn execute_restore_object("),
&[
"validate_table_catalog_object_mutation(&bucket, &object).await?;",
store_resolution,
],
);
assert_ordered(
method(delete, "fn execute_delete_objects_owned("),
&[
"validate_object_key(&obj_id.key, \"DeleteObjects\")",
"if let Err(err) = validate_table_catalog_object_mutation(&bucket, &obj_id.key).await {",
"continue;\n }",
"authorized_deletes.push(AuthorizedDelete { idx, object });",
],
);
assert_ordered(
method(extract, "fn execute_put_object_extract_inner("),
&[
"validate_extract_member_key(&fpath, extract_limits)",
"extract_try!(validate_table_catalog_object_mutation(&bucket, &fpath).await);",
"batch.push(ExtractPreparedMember {",
],
);
}
@@ -25256,3 +25546,82 @@ async fn disable_empty_table_bucket_failure_keeps_active_catalog() {
restarted.disable_empty_table_bucket(test_bucket_entry(bucket)).await.unwrap();
}
}
#[tokio::test]
async fn compaction_planner_stats_data_files_without_reading_their_bodies() {
let backend = TestCatalogObjectBackend::default();
let bucket = "analytics";
let namespace = Namespace::parse("sales").unwrap();
let table = IdentifierSegment::parse("orders").unwrap();
let metadata_dir = default_table_metadata_dir_path(&namespace, &table);
let current = default_table_metadata_file_path(&namespace, &table, "00004.metadata.json");
let entry = test_table_entry(bucket, &namespace, &table, current);
let data_dir = default_table_data_dir_path(&namespace, &table);
let files = [format!("{data_dir}/left.parquet"), format!("{data_dir}/right.parquet")];
let manifest = format!("{metadata_dir}/manifest-20.avro");
let manifest_list = format!("{metadata_dir}/snap-20.avro");
let manifest_bytes = manifest_avro_bytes(&[(&files[0], 0), (&files[1], 0)]);
backend
.seed_object(bucket, &manifest_list, manifest_list_avro_bytes(&[(&manifest, manifest_bytes.len())]))
.await;
backend.seed_object(bucket, &manifest, manifest_bytes).await;
for file in &files {
backend.seed_object(bucket, file, vec![0; 8192]).await;
backend.fail_next_read(bucket, file).await;
}
let metadata = serde_json::json!({
"current-snapshot-id": 20,
"snapshots": [{"snapshot-id": 20, "manifest-list": manifest_list}],
"refs": {"main": {"snapshot-id": 20, "type": "branch"}}
});
backend.reset_call_counts().await;
let report = table_compaction_planning_report(
&backend,
bucket,
&namespace,
&table,
&entry,
&metadata,
TableCompactionPlanningConfig {
target_file_size_bytes: 65536,
small_file_threshold_bytes: 16384,
min_input_files: 2,
max_rewrite_bytes_per_job: 65536,
},
)
.await
.unwrap();
assert_eq!(report.status, TableCompactionPlanningStatus::RewriteCandidates);
assert_eq!(report.candidate_file_count, 2);
assert_eq!(backend.metadata_call_count().await, 2);
assert_eq!(backend.read_call_count().await, 2, "only the manifest list and manifest may be read");
}
#[tokio::test]
async fn compaction_backend_read_limit_is_enforced_on_real_storage() {
use crate::storage_api::table::test_support::{
BucketOperations as _, MakeBucketOptions, init_bucket_metadata_sys, isolated_store_over_temp_disks,
};
let (_disks, store) = Box::pin(isolated_store_over_temp_disks()).await;
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
store
.make_bucket("bounded-compaction", &MakeBucketOptions::default())
.await
.unwrap();
let backend = EcStoreTableCatalogObjectBackend::new_with_strong_runtime(store, StrongTableCatalogRuntime::default());
backend
.put_object("bounded-compaction", "data.parquet", vec![7; 8192], TableCatalogPutPrecondition::IfAbsent)
.await
.unwrap();
let error = backend
.read_object_limited("bounded-compaction", "data.parquet", 1024)
.await
.expect_err("oversized stored input");
assert!(matches!(error, TableCatalogStoreError::Invalid(_)));
let data = backend
.read_object_limited("bounded-compaction", "data.parquet", 8192)
.await
.unwrap()
.unwrap();
assert_eq!(data.data, vec![7; 8192]);
}
+2 -1
View File
@@ -32,6 +32,7 @@ their issue closes.
| `check_extension_schema_boundaries.sh` | ci-gate | Extension-schema crate boundary guard | ci.yml Quick Checks; `make pre-commit` |
| `check_layer_dependencies.sh` | ci-gate | Crate-layering DAG guard (reads `layer-dependency-baseline.txt`) | ci.yml Quick Checks |
| `check_logging_guardrails.sh` | ci-gate | Blocks legacy logging patterns from returning | `make pre-commit` / `pre-pr` |
| `download_rc.sh` | ci-gate | Downloads and verifies the RustFS CLI release binary for container builds | `Dockerfile`, `Dockerfile.glibc`, `Dockerfile.source`, `Dockerfile.decommission-local` |
| `check_migration_gate_count.sh` | ci-gate | Migration-critical test gate with committed count floor (`.config/migration-gate-floor.txt`) | ci.yml Test and Lint; `docs/testing/README.md` |
| `check_no_planning_docs.sh` | ci-gate | Blocks committed planning-type documents | ci.yml Quick Checks; `make pre-commit` |
| `check_no_tokio_io_uring.sh` | ci-gate | Keeps tokio's io-uring backend disabled | ci.yml Quick Checks |
@@ -96,7 +97,7 @@ their issue closes.
| `test_scanner_heal_scheduler_pressure_evidence.py` | dev-tool | Scheduler-pressure evidence fail-closed tests | `test_scanner_heal_scheduler_pressure_evidence.sh` |
| `test_scanner_heal_linux_evidence_plan.py` | dev-tool | Linux evidence plan coverage tests | `test_scanner_heal_linux_evidence_plan.sh` |
| `test_build_rustfs_options.sh` | dev-tool | Shell test for rustfs build-option wiring | `make test` (script-tests) |
| `test_entrypoint_credentials.sh` | dev-tool | Container entrypoint credential-handling test | `make test` (script-tests) |
| `test_entrypoint_credentials.sh` | dev-tool | Container entrypoint credential and bundled bootstrap-default test | `make test` (script-tests) |
| `test_helm_chart_version.sh` | dev-tool | Test for `helm_chart_version.sh` | — |
| `test_package_service_scripts.sh` | ci-gate | Verifies DEB/RPM install, upgrade, removal, and service restart scriptlets | audit.yml `workflow-pin-report` |
| `test_package_versions.sh` | ci-gate | Exact-output and fail-closed tests for DEB/RPM package version normalization | audit.yml `workflow-pin-report`; package.yml RPM build |
+47
View File
@@ -0,0 +1,47 @@
#!/bin/sh
set -eu
if [ "$#" -ne 4 ]; then
echo "usage: download_rc.sh <amd64|arm64> <musl|gnu> <release|latest> <output>" >&2
exit 2
fi
ARCH="$1"
LIBC="$2"
RELEASE="$3"
OUTPUT="$4"
case "$ARCH" in
amd64|arm64) ;;
*) echo "Unsupported rc architecture: $ARCH" >&2; exit 1 ;;
esac
case "$LIBC" in
musl) LIBC_SUFFIX="" ;;
gnu) LIBC_SUFFIX="-gnu" ;;
*) echo "Unsupported rc libc: $LIBC" >&2; exit 1 ;;
esac
if [ "$RELEASE" = "latest" ]; then
RELEASE_JSON=$(curl -fsSL https://api.github.com/repos/rustfs/cli/releases/latest)
else
TAG="$RELEASE"
case "$TAG" in v*) ;; *) TAG="v$TAG" ;; esac
RELEASE_JSON=$(curl -fsSL "https://api.github.com/repos/rustfs/cli/releases/tags/$TAG")
fi
TAG=$(printf '%s' "$RELEASE_JSON" | jq -er '.tag_name')
ASSET_NAME="rustfs-cli-linux-${ARCH}${LIBC_SUFFIX}-${TAG}.tar.gz"
ASSET_JSON=$(printf '%s' "$RELEASE_JSON" | jq -cer --arg name "$ASSET_NAME" '.assets[] | select(.name == $name)')
ASSET_URL=$(printf '%s' "$ASSET_JSON" | jq -er '.browser_download_url')
ASSET_DIGEST=$(printf '%s' "$ASSET_JSON" | jq -er '.digest | select(startswith("sha256:")) | sub("^sha256:"; "")')
TEMP_DIR=$(mktemp -d)
trap 'rm -rf "$TEMP_DIR"' EXIT HUP INT TERM
curl -fsSL "$ASSET_URL" -o "$TEMP_DIR/rc.tar.gz"
printf '%s %s\n' "$ASSET_DIGEST" "$TEMP_DIR/rc.tar.gz" | sha256sum -c -
tar -tzf "$TEMP_DIR/rc.tar.gz" | grep -Fxq rc || {
echo "The RustFS CLI archive does not contain rc" >&2
exit 1
}
tar -xOzf "$TEMP_DIR/rc.tar.gz" rc > "$OUTPUT"
chmod 0755 "$OUTPUT"
+24 -14
View File
@@ -14,16 +14,6 @@ if [ -e /usr/bin/rustfs ]; then
exit 1
fi
# Every image that ships the entrypoint must not bake root credentials into
# its ENV — with hard-fail enforcement gone, this guard is the remaining
# teeth of the "no built-in default credentials" policy.
for dockerfile in Dockerfile Dockerfile.glibc Dockerfile.source Dockerfile.decommission-local; do
if grep -Eq 'RUSTFS_(ACCESS|SECRET)_KEY=' "$ROOT_DIR/$dockerfile"; then
echo "$dockerfile must not bake root credentials into image ENV" >&2
exit 1
fi
done
assert_contains() {
if ! grep -q "$2" "$1"; then
echo "Expected ${1##*/} to contain: $2" >&2
@@ -40,6 +30,26 @@ assert_lacks() {
fi
}
# Every image that ships the entrypoint must not bake root credentials into
# its ENV — with hard-fail enforcement gone, this guard is the remaining
# teeth of the "no built-in default credentials" policy.
for dockerfile in Dockerfile Dockerfile.glibc Dockerfile.source Dockerfile.decommission-local; do
if grep -Eq 'RUSTFS_(ACCESS|SECRET)_KEY=' "$ROOT_DIR/$dockerfile"; then
echo "$dockerfile must not bake root credentials into image ENV" >&2
exit 1
fi
assert_contains "$ROOT_DIR/$dockerfile" "bootstrap/"
assert_contains "$ROOT_DIR/$dockerfile" "/etc/rustfs/entrypoint.sh"
assert_contains "$ROOT_DIR/$dockerfile" "/usr/bin/rc"
done
if [ "$(jq -c . "$ROOT_DIR/bootstrap/buckets.json")" != "[]" ]; then
echo "The bundled default buckets.json must be an empty array" >&2
exit 1
fi
jq -e 'keys == ["block_public_acls", "block_public_policy", "ignore_public_acls", "restrict_public_buckets"] and all(.[]; . == true)' \
"$ROOT_DIR/bootstrap/public-access-block.json" >/dev/null
# The test environment has no /usr/bin/rustfs, so a run that passes credential
# validation still exits non-zero at exec time. "Starting: /usr/bin/rustfs" on
# stdout is the proof that validation let startup proceed.
@@ -49,7 +59,7 @@ run_expect_start() {
shift 2
log_file="$TMP_DIR/$label.log"
env -i PATH="$PATH" RUSTFS_VOLUMES="$TMP_DIR/data" RUSTFS_OBS_LOG_DIRECTORY= "$@" sh "$ENTRYPOINT" rustfs >"$log_file" 2>&1 || true
env -i PATH="$PATH" RUSTFS_BOOTSTRAP_DIR="$ROOT_DIR/bootstrap" RUSTFS_VOLUMES="$TMP_DIR/data" RUSTFS_OBS_LOG_DIRECTORY= "$@" sh "$ENTRYPOINT" rustfs >"$log_file" 2>&1 || true
assert_contains "$log_file" "$expected"
assert_contains "$log_file" "Starting: /usr/bin/rustfs"
@@ -62,7 +72,7 @@ run_expect_failure() {
shift 2
log_file="$TMP_DIR/$label.log"
if env -i PATH="$PATH" RUSTFS_VOLUMES="$TMP_DIR/data" RUSTFS_OBS_LOG_DIRECTORY= "$@" sh "$ENTRYPOINT" rustfs >"$log_file" 2>&1; then
if env -i PATH="$PATH" RUSTFS_BOOTSTRAP_DIR="$ROOT_DIR/bootstrap" RUSTFS_VOLUMES="$TMP_DIR/data" RUSTFS_OBS_LOG_DIRECTORY= "$@" sh "$ENTRYPOINT" rustfs >"$log_file" 2>&1; then
echo "Expected $label to fail" >&2
exit 1
fi
@@ -79,7 +89,7 @@ run_cli_expect_start() {
shift 2
log_file="$TMP_DIR/$label.log"
env -i PATH="$PATH" RUSTFS_VOLUMES="$TMP_DIR/data" RUSTFS_OBS_LOG_DIRECTORY= RUSTFS_SECRET_KEY=custom-secret sh "$ENTRYPOINT" rustfs "$@" >"$log_file" 2>&1 || true
env -i PATH="$PATH" RUSTFS_BOOTSTRAP_DIR="$ROOT_DIR/bootstrap" RUSTFS_VOLUMES="$TMP_DIR/data" RUSTFS_OBS_LOG_DIRECTORY= RUSTFS_SECRET_KEY=custom-secret sh "$ENTRYPOINT" rustfs "$@" >"$log_file" 2>&1 || true
assert_contains "$log_file" "$expected"
assert_contains "$log_file" "Starting: /usr/bin/rustfs"
@@ -92,7 +102,7 @@ run_cli_expect_failure() {
shift 2
log_file="$TMP_DIR/$label.log"
if env -i PATH="$PATH" RUSTFS_VOLUMES="$TMP_DIR/data" RUSTFS_OBS_LOG_DIRECTORY= RUSTFS_SECRET_KEY=custom-secret sh "$ENTRYPOINT" rustfs "$@" >"$log_file" 2>&1; then
if env -i PATH="$PATH" RUSTFS_BOOTSTRAP_DIR="$ROOT_DIR/bootstrap" RUSTFS_VOLUMES="$TMP_DIR/data" RUSTFS_OBS_LOG_DIRECTORY= RUSTFS_SECRET_KEY=custom-secret sh "$ENTRYPOINT" rustfs "$@" >"$log_file" 2>&1; then
echo "Expected $label to fail" >&2
exit 1
fi