mirror of
https://github.com/rustfs/rustfs.git
synced 2026-11-05 02:23:56 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d7a268002 | |||
| 1ce559f0bd | |||
| 34c6896768 | |||
| e505589fbe | |||
| 9026271113 | |||
| a4d17865ee | |||
| 004d872a27 | |||
| 0bb193e87c | |||
| 98b633d17a | |||
| b315958598 | |||
| d8e52174da | |||
| d96bd52117 | |||
| 1558c2ed3f | |||
| 524c774e2f | |||
| 736bf777b4 | |||
| faf636e1c7 | |||
| 2401a8d8ac | |||
| 7babc7d621 | |||
| d15dde7bb5 |
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=7c705942d3a9e583ebaa85845daa21c4f81fdbf36def24db931aaef38636c53e
|
||||
sha256-linux=5c33dc395233671cb77452f8fb12900ecdbeb368632222b96835d310bce1aab8
|
||||
sha256-linux=01e1f5569be60674e30d9e30a9961c5c53a6cea6ca1f7913218f2adf7291d574
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256=00f8a56b75990b487a594b97c36610b94476a6944ba00473e37187ea654d42bf
|
||||
sha256=f794c004e5952147c79dadbe6b7c84804bc986f34a8142731fac8864004e5130
|
||||
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"block_public_acls": true,
|
||||
"ignore_public_acls": true,
|
||||
"block_public_policy": true,
|
||||
"restrict_public_buckets": true
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(©_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(©_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(©_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(©_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?;
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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(¶ms)?;
|
||||
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(¶ms)?;
|
||||
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(¶ms)?;
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>&";
|
||||
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("<Injected>victim</Injected>"), "{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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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), "a_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("a_bucket).await.expect("quota bucket incarnation");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
@@ -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(×tamp).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", ×tamp)
|
||||
.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
@@ -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] {
|
||||
|
||||
+1277
-49
File diff suppressed because it is too large
Load Diff
+26
-21
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>&";
|
||||
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("</ETag><Contents>"), "{output}");
|
||||
assert!(output.contains("&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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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(®istering.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(®istering.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()));
|
||||
}
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ¤t)
|
||||
.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, ¤t, 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, ¤t).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, ¤t, 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, ¤t).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, ¤t, 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
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user