mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-06 03:59:14 +00:00
8fc1c9281e
* refactor(odm): move migration orchestration into application * style(odm): format relocated listing test imports
2149 lines
90 KiB
Rust
2149 lines
90 KiB
Rust
// 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.
|
|
|
|
//! Cross-cutting helpers shared by the object use-case modules.
|
|
|
|
use super::*;
|
|
use crate::on_demand_migration::{OdmStateError, PolicyConfig, SourceErrorPolicy, SourceHead};
|
|
|
|
pub(super) const RUSTFS_EXPECTED_CURRENT_VERSION_ID: &str = "x-rustfs-expected-current-version-id";
|
|
|
|
pub(super) type S3StdError = Box<dyn std::error::Error + Send + Sync + 'static>;
|
|
|
|
pub(crate) fn s3s_body_error_to_io(err: StdError) -> io::Error {
|
|
io::Error::other(err)
|
|
}
|
|
|
|
pub(super) const ACCEPT_RANGES_BYTES: &str = "bytes";
|
|
|
|
pub(super) const LOG_COMPONENT_APP: &str = "app";
|
|
|
|
pub(super) const LOG_SUBSYSTEM_OBJECT: &str = "object";
|
|
|
|
pub(super) fn decoded_content_length_from_headers(headers: &HeaderMap) -> S3Result<Option<i64>> {
|
|
let Some(val) = headers.get(AMZ_DECODED_CONTENT_LENGTH) else {
|
|
return Ok(None);
|
|
};
|
|
|
|
match atoi::atoi::<i64>(val.as_bytes()) {
|
|
Some(x) => Ok(Some(x)),
|
|
None => Err(s3_error!(UnexpectedContent)),
|
|
}
|
|
}
|
|
|
|
/// Losslessly convert an s3s [`Range`] into the internal [`HTTPRangeSpec`].
|
|
///
|
|
/// Shared by GET and HEAD so both apply identical range semantics. s3s parses
|
|
/// `first`/`last` as `u64`, but its own parser already rejects any value greater
|
|
/// than `i64::MAX`, so the int branch is a checked cast that never truncates.
|
|
///
|
|
/// The suffix length, however, is an unchecked `u64`. A naive `length as i64`
|
|
/// truncates deterministically: `bytes=-18446744073709551615` wraps to `-1` and
|
|
/// is then read as "last 1 byte", and `bytes=-0` yields a 0-length 206 instead
|
|
/// of a 416. This function instead mirrors s3s [`Range::check`] semantics:
|
|
/// * a zero-length suffix is rejected with `InvalidRange` (416), matching AWS
|
|
/// S3 and MinIO;
|
|
/// * a suffix larger than `i64::MAX` is clamped to `i64::MAX`. Object sizes in
|
|
/// this system are bounded by `i64::MAX`, so such a suffix always covers the
|
|
/// whole object, and [`HTTPRangeSpec::get_length`] clamps it to the real
|
|
/// size once the object is known.
|
|
pub(super) fn range_to_http_range_spec(range: Range) -> S3Result<HTTPRangeSpec> {
|
|
match range {
|
|
Range::Int { first, last } => {
|
|
let start = i64::try_from(first).map_err(|_| s3_error!(InvalidRange, "The requested range is not satisfiable"))?;
|
|
let end = match last {
|
|
Some(last) => {
|
|
i64::try_from(last).map_err(|_| s3_error!(InvalidRange, "The requested range is not satisfiable"))?
|
|
}
|
|
None => -1,
|
|
};
|
|
Ok(HTTPRangeSpec {
|
|
is_suffix_length: false,
|
|
start,
|
|
end,
|
|
})
|
|
}
|
|
Range::Suffix { length } => {
|
|
if length == 0 {
|
|
return Err(s3_error!(InvalidRange, "The requested range is not satisfiable"));
|
|
}
|
|
// Clamp to i64::MAX: any suffix >= object size returns the whole
|
|
// object, and object sizes never exceed i64::MAX.
|
|
let start = i64::try_from(length).unwrap_or(i64::MAX);
|
|
Ok(HTTPRangeSpec {
|
|
is_suffix_length: true,
|
|
start,
|
|
end: -1,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
/// True when the request body actually arrived chunk-framed on the wire, i.e. the payload was
|
|
/// signed as a SigV4 streaming upload (`x-amz-content-sha256: STREAMING-*`). This is the only
|
|
/// case in which the auth layer de-frames the body; `Content-Encoding: aws-chunked` without a
|
|
/// streaming payload is just a declared encoding over an unframed body.
|
|
pub(super) fn request_body_is_aws_chunked_framed(headers: &HeaderMap) -> bool {
|
|
headers
|
|
.get(AMZ_CONTENT_SHA256)
|
|
.and_then(|value| value.to_str().ok())
|
|
.is_some_and(|value| value.len() >= 10 && value[..10].eq_ignore_ascii_case("STREAMING-"))
|
|
}
|
|
|
|
/// Map a bucket-quota checker outcome onto the S3 admission result.
|
|
///
|
|
/// Hard is the only supported quota type, so a checker fault (bucket-config read, config parse, or usage lookup) must fail closed rather than admit the write: allowing it would silently bypass a configured hard quota. The no-quota happy path never reaches the error arm — `QuotaChecker::check_quota` returns `Ok(allowed)` via the zero-extra-I/O fast path when no quota is configured, so failing closed here cannot penalise buckets without a quota. A fault surfaces as a retryable `ServiceUnavailable` and is counted; the client-facing message stays generic so internal config/usage details are not leaked.
|
|
pub(crate) fn map_quota_check_outcome(bucket: &str, outcome: Result<QuotaCheckResult, QuotaError>) -> S3Result<QuotaCheckResult> {
|
|
match outcome {
|
|
Ok(result) if !result.allowed => Err(S3Error::with_message(
|
|
S3ErrorCode::InvalidRequest,
|
|
format!(
|
|
"Bucket quota exceeded. Current usage: {} bytes, limit: {} bytes",
|
|
result.current_usage.unwrap_or(0),
|
|
result.quota_limit.unwrap_or(0)
|
|
),
|
|
)),
|
|
Err(e) => {
|
|
counter!("rustfs_bucket_quota_check_failed_total").increment(1);
|
|
if matches!(&e, QuotaError::UsageUnavailable { .. }) {
|
|
debug!(bucket, error = %e, state = "usage_pending", "Bucket quota check waiting for authoritative usage");
|
|
} else {
|
|
warn!(bucket, error = %e, state = "checker_failed", "Bucket quota check failed closed");
|
|
}
|
|
Err(S3Error::with_message(
|
|
S3ErrorCode::ServiceUnavailable,
|
|
"Bucket quota check temporarily unavailable, please retry".to_string(),
|
|
))
|
|
}
|
|
Ok(result) => Ok(result),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn apply_quota_admission(opts: &mut ObjectOptions, result: &QuotaCheckResult) -> S3Result<()> {
|
|
if result.uses_durable_reservations {
|
|
return Ok(());
|
|
}
|
|
let Some(quota_limit) = result.quota_limit else {
|
|
return Ok(());
|
|
};
|
|
let Some(current_usage) = result.current_usage else {
|
|
return Err(S3Error::with_message(
|
|
S3ErrorCode::ServiceUnavailable,
|
|
"Bucket quota check temporarily unavailable, please retry".to_string(),
|
|
));
|
|
};
|
|
if current_usage > quota_limit {
|
|
return Err(S3Error::with_message(
|
|
S3ErrorCode::InvalidRequest,
|
|
format!("Bucket quota exceeded. Current usage: {current_usage} bytes, limit: {quota_limit} bytes"),
|
|
));
|
|
}
|
|
let _ = opts.set_quota_admission(current_usage, quota_limit);
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) fn ensure_object_size_within_quota(result: &QuotaCheckResult, new_size: u64) -> S3Result<()> {
|
|
let (Some(current_usage), Some(quota_limit)) = (result.current_usage, result.quota_limit) else {
|
|
return Ok(());
|
|
};
|
|
if new_size > quota_limit {
|
|
return Err(S3Error::with_message(
|
|
S3ErrorCode::InvalidRequest,
|
|
format!("Bucket quota exceeded. Current usage: {current_usage} bytes, limit: {quota_limit} bytes"),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) fn quota_accounting_object_size(info: &ObjectInfo, fail_closed: bool) -> S3Result<u64> {
|
|
match quota_object_size(info) {
|
|
Ok(size) => Ok(size),
|
|
Err(err) if fail_closed => Err(ApiError::from(err).into()),
|
|
Err(_) => Ok(info.size.max(0) as u64),
|
|
}
|
|
}
|
|
|
|
pub(super) fn request_uses_aws_chunked(headers: &HeaderMap) -> bool {
|
|
let has_aws_chunked = |header_name: &str| {
|
|
headers
|
|
.get(header_name)
|
|
.and_then(|value| value.to_str().ok())
|
|
.is_some_and(|value| value.split(',').any(|part| part.trim().eq_ignore_ascii_case("aws-chunked")))
|
|
};
|
|
|
|
has_aws_chunked("content-encoding") || has_aws_chunked("transfer-encoding")
|
|
}
|
|
|
|
pub(super) async fn validate_table_catalog_object_mutation(bucket: &str, key: &str) -> S3Result<()> {
|
|
table_catalog::validate_bucket_object_mutation(bucket, key)
|
|
.await
|
|
.map_err(|_| s3_error!(InvalidRequest, "{}", table_catalog::RESERVED_CATALOG_OBJECT_MESSAGE))
|
|
}
|
|
|
|
pub(super) struct DeadlockRequestGuard {
|
|
deadlock_detector: Arc<deadlock_detector::DeadlockDetector>,
|
|
request_id: String,
|
|
}
|
|
|
|
impl DeadlockRequestGuard {
|
|
fn new(deadlock_detector: Arc<deadlock_detector::DeadlockDetector>, request_id: String) -> Self {
|
|
Self {
|
|
deadlock_detector,
|
|
request_id,
|
|
}
|
|
}
|
|
|
|
pub(super) fn register_if_enabled<F>(
|
|
deadlock_detector: Arc<deadlock_detector::DeadlockDetector>,
|
|
request_id: &str,
|
|
description: F,
|
|
) -> Option<Self>
|
|
where
|
|
F: FnOnce() -> String,
|
|
{
|
|
if !deadlock_detector.is_enabled() {
|
|
return None;
|
|
}
|
|
|
|
let request_id = request_id.to_string();
|
|
deadlock_detector.register_request(&request_id, description());
|
|
Some(Self::new(deadlock_detector, request_id))
|
|
}
|
|
}
|
|
|
|
impl Drop for DeadlockRequestGuard {
|
|
fn drop(&mut self) {
|
|
self.deadlock_detector.unregister_request(&self.request_id);
|
|
}
|
|
}
|
|
|
|
pub(super) fn has_put_sse_request_headers(headers: &HeaderMap) -> bool {
|
|
headers.get(AMZ_SERVER_SIDE_ENCRYPTION).is_some()
|
|
|| headers.get(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM).is_some()
|
|
|| headers.get(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID).is_some()
|
|
}
|
|
|
|
/// Resolve the effective server-side encryption for a write against the bucket's
|
|
/// default encryption configuration.
|
|
///
|
|
/// A request-level value always wins; the bucket default only fills a gap, and
|
|
/// the unknown-algorithm fallback lives once in [`bucket_default_write_sse`].
|
|
///
|
|
/// `has_explicit_ssec` suppresses the default entirely. Only COPY passes `true`
|
|
/// today: its destination may carry SSE-C, which must not also be given managed
|
|
/// encryption. PUT and extract pass `false`, matching their current behaviour —
|
|
/// see backlog#1826 for the divergence that leaves.
|
|
///
|
|
/// Callers layering further overrides (PUT's `ciphertext_passthrough`) apply
|
|
/// them to the returned pair.
|
|
pub(super) fn resolve_bucket_default_sse(
|
|
bucket_sse_config: Option<&ServerSideEncryptionConfiguration>,
|
|
requested_sse: Option<ServerSideEncryption>,
|
|
requested_kms_key_id: Option<SSEKMSKeyId>,
|
|
has_explicit_ssec: bool,
|
|
) -> (Option<ServerSideEncryption>, Option<SSEKMSKeyId>) {
|
|
let bucket_default = || {
|
|
if has_explicit_ssec {
|
|
return None;
|
|
}
|
|
bucket_sse_config
|
|
.and_then(|config| config.rules.first())
|
|
.and_then(|rule| rule.apply_server_side_encryption_by_default.as_ref())
|
|
};
|
|
|
|
let effective_sse = requested_sse.or_else(|| bucket_default().map(bucket_default_write_sse));
|
|
let effective_kms_key_id = requested_kms_key_id.or_else(|| bucket_default().and_then(|sse| sse.kms_master_key_id.clone()));
|
|
(effective_sse, effective_kms_key_id)
|
|
}
|
|
|
|
/// The bucket's default encryption configuration for a write path.
|
|
///
|
|
/// `Ok(None)` carries one meaning only — this bucket has no default encryption
|
|
/// — and the write proceeds in plaintext exactly as before. Every other
|
|
/// outcome refuses the write rather than collapsing onto that same value: an
|
|
/// encryption blob that exists but cannot be read fails closed in
|
|
/// `get_sse_config` since rustfs/rustfs#7172, and swallowing that error here
|
|
/// stores plaintext into a bucket whose operator mandated encryption, with
|
|
/// nothing returned to the client and nothing in the object to tell it apart
|
|
/// afterwards (rustfs/backlog#2287).
|
|
///
|
|
/// The states the lookup can report, and what each one does:
|
|
///
|
|
/// * configured and readable — apply the bucket default;
|
|
/// * no encryption blob at all, including a bucket that does not exist and a
|
|
/// bucket whose metadata document is absent — `ConfigNotFound`, so a cold
|
|
/// cache and a missing bucket are never turned into a refusal, and the write
|
|
/// still fails later with its own `NoSuchBucket`;
|
|
/// * blob present but unparseable — deterministic, so retrying cannot help;
|
|
/// surfaces as `InternalError` until an operator repairs or removes it;
|
|
/// * the metadata read itself failed (namespace lock, quorum, disk, an
|
|
/// uninitialized metadata system) — transient, and the typed error maps to
|
|
/// the retryable `ServiceUnavailable`.
|
|
///
|
|
/// The last two are distinguished by the typed error the accessor returns, not
|
|
/// re-derived here: [`ApiError`] already separates them. This mirrors
|
|
/// `prepare_sse_configuration` in `storage::sse`, the resolver the multipart
|
|
/// writer uses, which has always failed closed on the same lookup.
|
|
pub(super) async fn load_bucket_default_sse_config(
|
|
bucket: &str,
|
|
) -> S3Result<Option<(ServerSideEncryptionConfiguration, OffsetDateTime)>> {
|
|
classify_bucket_default_sse_lookup(bucket, metadata_sys::get_sse_config(bucket).await)
|
|
}
|
|
|
|
fn classify_bucket_default_sse_lookup(
|
|
bucket: &str,
|
|
lookup: Result<(ServerSideEncryptionConfiguration, OffsetDateTime), StorageError>,
|
|
) -> S3Result<Option<(ServerSideEncryptionConfiguration, OffsetDateTime)>> {
|
|
match lookup {
|
|
Ok(config) => Ok(Some(config)),
|
|
Err(StorageError::ConfigNotFound) => Ok(None),
|
|
Err(err) => {
|
|
let api_error = ApiError::from(err);
|
|
error!(
|
|
event = "bucket_sse_config_lookup_failed",
|
|
component = LOG_COMPONENT_APP,
|
|
subsystem = LOG_SUBSYSTEM_OBJECT,
|
|
result = "write_refused",
|
|
bucket = %bucket,
|
|
code = %api_error.code.as_str(),
|
|
error = %api_error,
|
|
"Bucket default encryption is unreadable; refusing the write instead of storing plaintext"
|
|
);
|
|
Err(api_error.into())
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod bucket_default_sse_lookup_tests {
|
|
use super::*;
|
|
use s3s::dto::{ServerSideEncryptionByDefault, ServerSideEncryptionRule};
|
|
use time::OffsetDateTime;
|
|
|
|
fn sse_config() -> ServerSideEncryptionConfiguration {
|
|
ServerSideEncryptionConfiguration {
|
|
rules: vec![ServerSideEncryptionRule {
|
|
apply_server_side_encryption_by_default: Some(ServerSideEncryptionByDefault {
|
|
sse_algorithm: ServerSideEncryption::from_static(ServerSideEncryption::AES256),
|
|
kms_master_key_id: None,
|
|
}),
|
|
blocked_encryption_types: None,
|
|
bucket_key_enabled: None,
|
|
}],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_absent_configuration_still_writes_plaintext() {
|
|
let resolved = classify_bucket_default_sse_lookup("bucket", Err(StorageError::ConfigNotFound))
|
|
.expect("a bucket without default encryption must keep writing plaintext");
|
|
|
|
assert!(resolved.is_none());
|
|
assert_eq!(resolve_bucket_default_sse(None, None, None, false), (None, None));
|
|
}
|
|
|
|
#[test]
|
|
fn a_readable_configuration_is_returned() {
|
|
let resolved = classify_bucket_default_sse_lookup("bucket", Ok((sse_config(), OffsetDateTime::UNIX_EPOCH)))
|
|
.expect("a readable configuration must not refuse the write")
|
|
.expect("a readable configuration must be applied");
|
|
|
|
assert_eq!(resolved.0.rules.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unreadable_configuration_refuses_the_write() {
|
|
let err = classify_bucket_default_sse_lookup(
|
|
"bucket",
|
|
Err(StorageError::other("persisted bucket encryption configuration is invalid")),
|
|
)
|
|
.expect_err("a corrupt encryption blob must never degrade to plaintext");
|
|
|
|
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unavailable_metadata_read_refuses_the_write_as_retryable() {
|
|
let err = classify_bucket_default_sse_lookup("bucket", Err(StorageError::ErasureReadQuorum))
|
|
.expect_err("an unreadable metadata subsystem must never degrade to plaintext");
|
|
|
|
assert_eq!(err.code(), &S3ErrorCode::ServiceUnavailable);
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_bucket_keeps_its_own_error() {
|
|
let err = classify_bucket_default_sse_lookup("bucket", Err(StorageError::BucketNotFound("bucket".to_string())))
|
|
.expect_err("a bucket-not-found lookup must not be reported as an encryption failure");
|
|
|
|
assert_eq!(err.code(), &S3ErrorCode::NoSuchBucket);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod deadlock_request_guard_tests {
|
|
use super::DeadlockRequestGuard;
|
|
use crate::app::storage_api::object_usecase::deadlock_detector::{DeadlockDetector, RequestHangDetectionPolicy};
|
|
use std::cell::Cell;
|
|
use std::rc::Rc;
|
|
use std::sync::Arc;
|
|
|
|
#[test]
|
|
fn deadlock_request_guard_unregisters_on_drop() {
|
|
let detector = Arc::new(DeadlockDetector::new(RequestHangDetectionPolicy {
|
|
enabled: true,
|
|
..RequestHangDetectionPolicy::default()
|
|
}));
|
|
let request_id = "test-request-id".to_string();
|
|
|
|
detector.register_request(&request_id, "test request");
|
|
assert_eq!(detector.tracked_count(), 1);
|
|
|
|
{
|
|
let _guard = DeadlockRequestGuard::new(Arc::clone(&detector), request_id);
|
|
// `_guard` is dropped at the end of this scope, which should unregister the request.
|
|
}
|
|
|
|
assert_eq!(detector.tracked_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn deadlock_request_guard_skips_disabled_detector() {
|
|
let detector = Arc::new(DeadlockDetector::new(RequestHangDetectionPolicy {
|
|
enabled: false,
|
|
..RequestHangDetectionPolicy::default()
|
|
}));
|
|
let description_built = Rc::new(Cell::new(false));
|
|
let description_built_for_closure = Rc::clone(&description_built);
|
|
|
|
let guard = DeadlockRequestGuard::register_if_enabled(detector, "test-request-id", || {
|
|
description_built_for_closure.set(true);
|
|
"test request".to_string()
|
|
});
|
|
|
|
assert!(guard.is_none());
|
|
assert!(!description_built.get());
|
|
}
|
|
}
|
|
|
|
pub(super) async fn maybe_enqueue_transition_immediate(obj_info: &ObjectInfo, src: LcEventSrc) {
|
|
enqueue_transition_immediate(obj_info, src).await;
|
|
}
|
|
|
|
/// Inject additional-checksum response headers (XXHash3/64/128, SHA-512) that s3s
|
|
/// cannot carry on its typed `*Output` structs. Centralized so that when s3s gains
|
|
/// typed fields for these algorithms, only this one function changes (fill the typed
|
|
/// field, drop the header insert) — and there is exactly one place that could ever
|
|
/// emit a duplicate header. Header names come from `ChecksumType::key()`, so they are
|
|
/// known-valid static strings.
|
|
pub(crate) fn inject_additional_checksum_headers(headers: &mut HeaderMap, pairs: &[(&'static str, String)]) {
|
|
for (name, value) in pairs {
|
|
match HeaderValue::from_str(value) {
|
|
Ok(header_value) => {
|
|
headers.insert(http::HeaderName::from_static(name), header_value);
|
|
}
|
|
Err(_) => warn!("Failed to parse {name} checksum header value; skipping"),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) fn inject_accept_ranges_header(headers: &mut HeaderMap) {
|
|
headers.insert(http::header::ACCEPT_RANGES, HeaderValue::from_static(ACCEPT_RANGES_BYTES));
|
|
}
|
|
|
|
/// Derive the response-header echo pairs for an additional-checksum algorithm
|
|
/// (XXHash3/64/128, SHA-512) from the server-computed content checksum, for
|
|
/// PutObject to echo back (#1256). Returns empty for the five s3s-typed algorithms
|
|
/// (they are echoed via typed fields) and when the value is not yet materialized
|
|
/// (e.g. a trailing checksum, whose value lands after the body — covered by e2e).
|
|
pub(crate) fn additional_checksum_echo_pairs(want: &Option<rustfs_rio::Checksum>) -> Vec<(&'static str, String)> {
|
|
let mut out = Vec::new();
|
|
if let Some(cs) = want
|
|
&& !cs.checksum_type.is_s3s_typed()
|
|
&& !cs.encoded.is_empty()
|
|
&& let Some(name) = cs.checksum_type.key()
|
|
{
|
|
out.push((name, cs.encoded.clone()));
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Extract trailing-header checksum values, overriding the corresponding input fields.
|
|
pub(super) fn apply_trailing_checksums(
|
|
algorithm: Option<&str>,
|
|
trailing_headers: &Option<s3s::TrailingHeaders>,
|
|
checksums: &mut PutObjectChecksums,
|
|
) {
|
|
let Some(alg) = algorithm else { return };
|
|
let Some(checksum_str) = trailing_headers.as_ref().and_then(|trailer| {
|
|
let key = match alg {
|
|
ChecksumAlgorithm::CRC32 => rustfs_rio::ChecksumType::CRC32.key(),
|
|
ChecksumAlgorithm::CRC32C => rustfs_rio::ChecksumType::CRC32C.key(),
|
|
ChecksumAlgorithm::SHA1 => rustfs_rio::ChecksumType::SHA1.key(),
|
|
ChecksumAlgorithm::SHA256 => rustfs_rio::ChecksumType::SHA256.key(),
|
|
ChecksumAlgorithm::CRC64NVME => rustfs_rio::ChecksumType::CRC64_NVME.key(),
|
|
_ => return None,
|
|
};
|
|
trailer.read(|headers| {
|
|
headers
|
|
.get(key.unwrap_or_default())
|
|
.and_then(|value| value.to_str().ok().map(|s| s.to_string()))
|
|
})
|
|
}) else {
|
|
return;
|
|
};
|
|
|
|
match alg {
|
|
ChecksumAlgorithm::CRC32 => checksums.crc32 = checksum_str,
|
|
ChecksumAlgorithm::CRC32C => checksums.crc32c = checksum_str,
|
|
ChecksumAlgorithm::SHA1 => checksums.sha1 = checksum_str,
|
|
ChecksumAlgorithm::SHA256 => checksums.sha256 = checksum_str,
|
|
ChecksumAlgorithm::CRC64NVME => checksums.crc64nvme = checksum_str,
|
|
_ => (),
|
|
}
|
|
}
|
|
|
|
/// Checksums resolved from stored (decrypted) metadata for a response. The five
|
|
/// legacy algorithms fill named fields; the additional algorithms land in `extra`
|
|
/// for raw-header response paths and DTOs that expose their newer typed fields.
|
|
#[derive(Default)]
|
|
pub(crate) struct ResponseChecksums {
|
|
pub(crate) crc32: Option<String>,
|
|
pub(crate) crc32c: Option<String>,
|
|
pub(crate) sha1: Option<String>,
|
|
pub(crate) sha256: Option<String>,
|
|
pub(crate) crc64nvme: Option<String>,
|
|
pub(crate) checksum_type: Option<ChecksumType>,
|
|
pub(crate) extra: Vec<(&'static str, String)>,
|
|
}
|
|
|
|
/// Split decrypted checksum pairs into the five legacy fields and the additional
|
|
/// algorithm values. Single source of truth for every response
|
|
/// path (GetObject / HeadObject / GetObjectAttributes / CompleteMultipartUpload),
|
|
/// replacing what used to be five copies of this match loop.
|
|
pub(crate) fn classify_response_checksums<I>(pairs: I, is_multipart: bool) -> ResponseChecksums
|
|
where
|
|
I: IntoIterator<Item = (String, String)>,
|
|
{
|
|
let mut c = ResponseChecksums::default();
|
|
for (key, checksum) in pairs {
|
|
if key == AMZ_CHECKSUM_TYPE {
|
|
c.checksum_type = Some(ChecksumType::from(checksum));
|
|
continue;
|
|
}
|
|
let ct = rustfs_rio::ChecksumType::from_string(key.as_str());
|
|
match ct.base() {
|
|
rustfs_rio::ChecksumType::CRC32 => c.crc32 = Some(checksum),
|
|
rustfs_rio::ChecksumType::CRC32C => c.crc32c = Some(checksum),
|
|
rustfs_rio::ChecksumType::SHA1 => c.sha1 = Some(checksum),
|
|
rustfs_rio::ChecksumType::SHA256 => c.sha256 = Some(checksum),
|
|
rustfs_rio::ChecksumType::CRC64_NVME => c.crc64nvme = Some(checksum),
|
|
_ => {
|
|
if let Some(name) = ct.key() {
|
|
c.extra.push((name, checksum));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if is_multipart && c.checksum_type.is_none() {
|
|
c.checksum_type = Some(ChecksumType::from("COMPOSITE".to_string()));
|
|
}
|
|
c
|
|
}
|
|
|
|
fn build_put_object_expiration_header(event: &lifecycle::Event) -> Option<String> {
|
|
if !event.action.delete() {
|
|
return None;
|
|
}
|
|
|
|
let expire_time = event.due?;
|
|
|
|
if event.rule_id.is_empty() || expire_time == OffsetDateTime::UNIX_EPOCH {
|
|
return None;
|
|
}
|
|
|
|
let expiry_date = expire_time.format(&Rfc3339).ok()?;
|
|
Some(format!("expiry-date=\"{}\", rule-id=\"{}\"", expiry_date, event.rule_id))
|
|
}
|
|
|
|
pub(super) fn internal_object_info_lookup_opts(mut opts: ObjectOptions) -> ObjectOptions {
|
|
opts.http_preconditions = None;
|
|
opts
|
|
}
|
|
|
|
pub(super) fn expected_current_version_id(headers: &HeaderMap) -> S3Result<Option<String>> {
|
|
headers
|
|
.get(RUSTFS_EXPECTED_CURRENT_VERSION_ID)
|
|
.map(|value| {
|
|
let value = value
|
|
.to_str()
|
|
.map(str::trim)
|
|
.map_err(|_| s3_error!(InvalidArgument, "Invalid expected current version ID header"))?;
|
|
if value.eq_ignore_ascii_case("null") {
|
|
return Ok(Uuid::nil().to_string());
|
|
}
|
|
Uuid::parse_str(value)
|
|
.map(|version| version.to_string())
|
|
.map_err(|_| s3_error!(InvalidArgument, "Invalid expected current version ID header"))
|
|
})
|
|
.transpose()
|
|
}
|
|
|
|
pub(super) fn parse_expires_header(expires: Option<&str>) -> S3Result<Option<Timestamp>> {
|
|
expires
|
|
.map(|expires| {
|
|
Timestamp::parse(TimestampFormat::HttpDate, expires).map_err(|_| s3_error!(InvalidArgument, "Invalid Expires header"))
|
|
})
|
|
.transpose()
|
|
}
|
|
|
|
pub(super) fn format_expires_header(expires: &Timestamp) -> S3Result<String> {
|
|
let mut formatted = Vec::new();
|
|
expires
|
|
.format(TimestampFormat::HttpDate, &mut formatted)
|
|
.map_err(|e| ApiError::from(StorageError::other(format!("Invalid expires timestamp: {e}"))))?;
|
|
Ok(String::from_utf8_lossy(&formatted).into_owned())
|
|
}
|
|
|
|
pub(super) fn insert_expires_metadata(metadata: &mut HashMap<String, String>, expires: Option<&Timestamp>) -> S3Result<()> {
|
|
if let Some(expires) = expires {
|
|
metadata.insert("expires".to_string(), format_expires_header(expires)?);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(super) fn apply_standard_object_metadata(
|
|
metadata: &mut HashMap<String, String>,
|
|
cache_control: Option<&str>,
|
|
content_disposition: Option<&str>,
|
|
content_encoding: Option<&str>,
|
|
content_language: Option<&str>,
|
|
content_type: Option<&str>,
|
|
expires: Option<&Timestamp>,
|
|
website_redirect_location: Option<&str>,
|
|
) -> S3Result<()> {
|
|
if let Some(cache_control) = cache_control {
|
|
metadata.insert("cache-control".to_string(), cache_control.to_string());
|
|
}
|
|
if let Some(content_disposition) = content_disposition {
|
|
metadata.insert("content-disposition".to_string(), content_disposition.to_string());
|
|
}
|
|
if let Some(content_encoding) = content_encoding
|
|
&& let Some(normalized_content_encoding) = normalize_content_encoding_for_storage(content_encoding)
|
|
{
|
|
metadata.insert("content-encoding".to_string(), normalized_content_encoding);
|
|
}
|
|
if let Some(content_language) = content_language {
|
|
metadata.insert("content-language".to_string(), content_language.to_string());
|
|
}
|
|
if let Some(content_type) = content_type {
|
|
metadata.insert("content-type".to_string(), content_type.to_string());
|
|
}
|
|
insert_expires_metadata(metadata, expires)?;
|
|
if let Some(website_redirect_location) = website_redirect_location {
|
|
metadata.insert(AMZ_WEBSITE_REDIRECT_LOCATION.to_string(), website_redirect_location.to_string());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) fn response_storage_class(info: &ObjectInfo, metadata: &HashMap<String, String>) -> Option<StorageClass> {
|
|
let stored_class = info
|
|
.storage_class
|
|
.as_deref()
|
|
.or_else(|| metadata.get(AMZ_STORAGE_CLASS).map(String::as_str));
|
|
let transitioned_tier = (info.transitioned_object.status == rustfs_filemeta::TRANSITION_COMPLETE
|
|
&& !info.transitioned_object.tier.is_empty())
|
|
.then_some(info.transitioned_object.tier.as_str());
|
|
let effective_class = storageclass::effective_class(stored_class, transitioned_tier);
|
|
|
|
(effective_class != storageclass::STANDARD).then(|| StorageClass::from(effective_class.to_string()))
|
|
}
|
|
|
|
pub(super) fn response_storage_class_for_object_attributes(
|
|
info: &ObjectInfo,
|
|
metadata: &HashMap<String, String>,
|
|
requested: bool,
|
|
) -> Option<StorageClass> {
|
|
if !requested {
|
|
return None;
|
|
}
|
|
|
|
let stored_class = info
|
|
.storage_class
|
|
.as_deref()
|
|
.or_else(|| metadata.get(AMZ_STORAGE_CLASS).map(String::as_str));
|
|
let transitioned_tier = (info.transitioned_object.status == rustfs_filemeta::TRANSITION_COMPLETE
|
|
&& !info.transitioned_object.tier.is_empty())
|
|
.then_some(info.transitioned_object.tier.as_str());
|
|
|
|
Some(StorageClass::from(
|
|
storageclass::effective_class(stored_class, transitioned_tier).to_string(),
|
|
))
|
|
}
|
|
|
|
// Shared across Object Lock validation paths to keep the client-facing
|
|
// InvalidRequest message consistent.
|
|
pub(crate) const ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED: &str =
|
|
"x-amz-object-lock-retain-until-date and x-amz-object-lock-mode must both be supplied";
|
|
|
|
pub(crate) fn build_put_like_object_lock_metadata(
|
|
bucket: &str,
|
|
object_lock_config_state: &metadata_sys::ObjectLockConfigState,
|
|
object_lock_legal_hold_status: Option<ObjectLockLegalHoldStatus>,
|
|
object_lock_mode: Option<ObjectLockMode>,
|
|
object_lock_retain_until_date: Option<Timestamp>,
|
|
) -> S3Result<Option<HashMap<String, String>>> {
|
|
if object_lock_legal_hold_status.is_none() && object_lock_mode.is_none() && object_lock_retain_until_date.is_none() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let retention = match (object_lock_mode, object_lock_retain_until_date) {
|
|
(Some(mode), Some(retain_until_date)) => Some(ObjectLockRetention {
|
|
mode: Some(ObjectLockRetentionMode::from(mode.as_str().to_string())),
|
|
retain_until_date: Some(retain_until_date),
|
|
}),
|
|
(Some(_), None) | (None, Some(_)) => {
|
|
return Err(S3Error::with_message(
|
|
S3ErrorCode::InvalidRequest,
|
|
ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED.to_string(),
|
|
));
|
|
}
|
|
(None, None) => None,
|
|
};
|
|
|
|
validate_bucket_object_lock_enabled_state(bucket, object_lock_config_state)?;
|
|
|
|
let mut eval_metadata = parse_object_lock_retention(retention)?;
|
|
eval_metadata.extend(parse_object_lock_legal_hold(
|
|
object_lock_legal_hold_status.map(|status| ObjectLockLegalHold { status: Some(status) }),
|
|
)?);
|
|
|
|
if eval_metadata.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
Ok(Some(eval_metadata))
|
|
}
|
|
|
|
fn put_like_write_creates_new_version(opts: &ObjectOptions) -> bool {
|
|
opts.version_id.is_none() && opts.versioned && !opts.version_suspended
|
|
}
|
|
|
|
pub(crate) fn validate_existing_object_lock_for_write(
|
|
object_lock_config_state: &metadata_sys::ObjectLockConfigState,
|
|
existing_obj_info: &ObjectInfo,
|
|
opts: &ObjectOptions,
|
|
) -> S3Result<()> {
|
|
if put_like_write_creates_new_version(opts) {
|
|
return Ok(());
|
|
}
|
|
// An authorized replication write may replace the locked version only
|
|
// when the set layer's commit-lock LWW will judge every locking category,
|
|
// judged against the bucket's authoritative lock state (default retention
|
|
// included) exactly like the set-layer gate, which re-checks the same
|
|
// rule under the lock. A non-authoritative state or malformed lock
|
|
// metadata fails closed here.
|
|
if opts.replication_request {
|
|
let may_pass = replication_write_may_pass_worm_gate(object_lock_config_state, existing_obj_info, opts).map_err(|_| {
|
|
S3Error::with_message(S3ErrorCode::AccessDenied, "Object Lock state could not be verified.".to_string())
|
|
})?;
|
|
return if may_pass {
|
|
Ok(())
|
|
} else {
|
|
Err(S3Error::with_message(
|
|
S3ErrorCode::AccessDenied,
|
|
"Object is locked and the replication write carries no source lock decision for it.".to_string(),
|
|
))
|
|
};
|
|
}
|
|
|
|
let legal_hold = get_object_legalhold_meta(&existing_obj_info.user_defined);
|
|
if legal_hold.is_on() {
|
|
return Err(S3Error::with_message(
|
|
S3ErrorCode::AccessDenied,
|
|
"Object has a legal hold and cannot be overwritten. Remove the legal hold first.".to_string(),
|
|
));
|
|
}
|
|
|
|
let retention = get_object_retention_meta(&existing_obj_info.user_defined);
|
|
if let Some(mode) = retention.mode
|
|
&& mode == RetentionMode::Compliance
|
|
&& is_retention_active(mode, retention.retain_until_date)
|
|
{
|
|
return Err(S3Error::with_message(
|
|
S3ErrorCode::AccessDenied,
|
|
"Object is under COMPLIANCE retention and cannot be overwritten.".to_string(),
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) async fn resolve_put_object_expiration(bucket: &str, obj_info: &ObjectInfo) -> Option<String> {
|
|
let Ok((lifecycle_config, _)) = metadata_sys::get_lifecycle_config(bucket).await else {
|
|
debug!(bucket, state = "config_missing", "PUT object expiration config missing");
|
|
return None;
|
|
};
|
|
|
|
let obj_opts = lifecycle::object_opts_from_object_info(obj_info);
|
|
let event = predict_lifecycle_expiration(&lifecycle_config, &obj_opts).await;
|
|
debug!(
|
|
bucket,
|
|
action = ?event.action,
|
|
rule_id = %event.rule_id,
|
|
due = ?event.due,
|
|
"PUT object expiration resolved"
|
|
);
|
|
build_put_object_expiration_header(&event)
|
|
}
|
|
|
|
/// Cadence for the "I/O queue congestion detected" WARN. Under sustained
|
|
/// overload (client concurrency at or above the disk-read permit pool) every
|
|
/// GET observes >=80% utilization, so an unthrottled WARN floods the log
|
|
/// from the already saturated hot path; congestion metrics stay per-request.
|
|
const IO_QUEUE_CONGESTION_WARN_INTERVAL_MS: u64 = 5_000;
|
|
|
|
/// At-most-one-WARN-per-interval limiter for the I/O queue congestion log.
|
|
/// Callers supply monotonic milliseconds so tests can drive the clock.
|
|
pub(super) struct IoQueueCongestionWarnThrottle {
|
|
/// Timestamp of the last emitted WARN; `u64::MAX` until the first one.
|
|
last_warn_ms: AtomicU64,
|
|
/// Congested requests left unlogged since the last emitted WARN.
|
|
suppressed: AtomicU64,
|
|
}
|
|
|
|
impl IoQueueCongestionWarnThrottle {
|
|
const fn new() -> Self {
|
|
Self {
|
|
last_warn_ms: AtomicU64::new(u64::MAX),
|
|
suppressed: AtomicU64::new(0),
|
|
}
|
|
}
|
|
|
|
/// Claim the right to emit one WARN. Returns the number of events
|
|
/// suppressed since the previous emission, or `None` while the interval
|
|
/// window is still closed (the event is counted, not logged).
|
|
pub(super) fn claim(&self, now_ms: u64) -> Option<u64> {
|
|
let last = self.last_warn_ms.load(Ordering::Relaxed);
|
|
let window_open = last == u64::MAX || now_ms.saturating_sub(last) >= IO_QUEUE_CONGESTION_WARN_INTERVAL_MS;
|
|
if window_open
|
|
&& self
|
|
.last_warn_ms
|
|
.compare_exchange(last, now_ms, Ordering::Relaxed, Ordering::Relaxed)
|
|
.is_ok()
|
|
{
|
|
Some(self.suppressed.swap(0, Ordering::Relaxed))
|
|
} else {
|
|
self.suppressed.fetch_add(1, Ordering::Relaxed);
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Monotonic milliseconds since the first call, for production callers.
|
|
pub(super) fn now_ms() -> u64 {
|
|
static ANCHOR: OnceLock<std::time::Instant> = OnceLock::new();
|
|
ANCHOR.get_or_init(std::time::Instant::now).elapsed().as_millis() as u64
|
|
}
|
|
}
|
|
|
|
pub(super) static IO_QUEUE_CONGESTION_WARN_THROTTLE: IoQueueCongestionWarnThrottle = IoQueueCongestionWarnThrottle::new();
|
|
|
|
pub(super) async fn track_object_read_setup<F>(health: Option<&ObjectTrafficHealth>, future: F) -> F::Output
|
|
where
|
|
F: std::future::Future,
|
|
{
|
|
let _progress = health.and_then(ObjectTrafficHealth::track_read_storage);
|
|
future.await
|
|
}
|
|
|
|
impl DefaultObjectUsecase {
|
|
/// Headers a proxied read forwards verbatim to the replication target:
|
|
/// only the client's SSE-C key family, so the target performs the real
|
|
/// SSE-C decryption (never the replication-check exemption). HTTP
|
|
/// conditional headers (If-Match & co.) are deliberately NOT forwarded —
|
|
/// MinIO does not forward them either, and a remote 304/412 would leak a
|
|
/// conditional evaluation against a replica the local site never saw.
|
|
/// Range and part-number travel as typed SDK parameters instead.
|
|
pub(super) fn proxy_read_passthrough_headers(headers: &HeaderMap) -> HeaderMap {
|
|
const FORWARDED: &[&str] = &[
|
|
"x-amz-server-side-encryption-customer-algorithm",
|
|
"x-amz-server-side-encryption-customer-key",
|
|
"x-amz-server-side-encryption-customer-key-md5",
|
|
];
|
|
let mut forwarded = HeaderMap::new();
|
|
for name in FORWARDED {
|
|
if let Ok(header_name) = http::HeaderName::from_str(name)
|
|
&& let Some(value) = headers.get(&header_name)
|
|
{
|
|
forwarded.insert(header_name, value.clone());
|
|
}
|
|
}
|
|
forwarded
|
|
}
|
|
|
|
/// True when a proxied SDK call failed because the target does not have
|
|
/// the object either (service-level not-found or a raw 404, which also
|
|
/// covers NoSuchVersion): the caller tries the next target silently.
|
|
pub(super) fn proxy_sdk_error_is_not_found<E>(err: &aws_sdk_s3::error::SdkError<E>) -> bool {
|
|
err.raw_response().is_some_and(|resp| resp.status().as_u16() == 404)
|
|
}
|
|
}
|
|
|
|
/// Fail closed when deciding whether an object-lock-sensitive operation may
|
|
/// skip its existing-object lookup.
|
|
pub(crate) async fn object_lock_checks_required(bucket: &str) -> bool {
|
|
get_bucket_metadata(bucket)
|
|
.await
|
|
.map_or(true, |metadata| metadata.object_locking())
|
|
}
|
|
|
|
pub(super) fn object_lock_checks_required_for_state(state: &metadata_sys::ObjectLockConfigState) -> bool {
|
|
match state {
|
|
metadata_sys::ObjectLockConfigState::Configured { .. } | metadata_sys::ObjectLockConfigState::Fabricated => true,
|
|
metadata_sys::ObjectLockConfigState::ConfirmedAbsent => false,
|
|
}
|
|
}
|
|
|
|
/// Response header marking a HEAD/GET answered by the on-demand migration
|
|
/// source instead of local storage (rustfs/backlog#2155).
|
|
pub(crate) const ON_DEMAND_MIGRATION_HEADER: http::HeaderName = http::HeaderName::from_static("x-rustfs-on-demand-migration");
|
|
pub(crate) const ON_DEMAND_MIGRATION_SOURCE: HeaderValue = HeaderValue::from_static("source");
|
|
|
|
/// Response header marking a `ListObjectsV2` that could not consult the source
|
|
/// and was answered from local state alone (rustfs/backlog#2164).
|
|
pub(crate) const ON_DEMAND_MIGRATION_LIST_HEADER: http::HeaderName =
|
|
http::HeaderName::from_static("x-rustfs-on-demand-migration-list");
|
|
pub(crate) const ON_DEMAND_MIGRATION_LIST_LOCAL_ONLY: HeaderValue = HeaderValue::from_static("local_only");
|
|
|
|
/// Custom S3 error code for a source failure surfaced under
|
|
/// `policy.source_error = propagate`; carried on HTTP 424.
|
|
pub(crate) const ODM_SOURCE_UNAVAILABLE_CODE: &str = "SourceUnavailable";
|
|
|
|
/// How the local lookup missed, as seen by the on-demand migration gate.
|
|
///
|
|
/// Only a versioned bucket can report `DeleteMarker`: an unversioned bucket
|
|
/// keeps nothing after a delete, so its lookup reports `NotFound` and
|
|
/// `policy.respect_local_delete_marker` cannot hold the source back there.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(crate) enum OdmLocalMiss {
|
|
NotFound,
|
|
DeleteMarker,
|
|
}
|
|
|
|
/// Classifies a local `get_object_info` result for the on-demand migration
|
|
/// gate. `None` means the object is present or the lookup failed for a
|
|
/// reason the source cannot answer (permissions, quorum, corruption).
|
|
pub(crate) fn odm_local_miss(lookup: Result<&ObjectInfo, &EcstoreError>) -> Option<OdmLocalMiss> {
|
|
match lookup {
|
|
Ok(info) if info.delete_marker => Some(OdmLocalMiss::DeleteMarker),
|
|
Ok(_) => None,
|
|
Err(err) if is_err_object_not_found(err) || is_err_version_not_found(err) => Some(OdmLocalMiss::NotFound),
|
|
Err(_) => None,
|
|
}
|
|
}
|
|
|
|
/// Request-level gate shared by HEAD (ODM-08) and GET (ODM-09): a read of a
|
|
/// specific version can only be answered locally, and a request carrying the
|
|
/// `source-proxy-request` anti-loop marker (whatever its value) comes from a
|
|
/// peer that must be answered locally, never bounced to the source.
|
|
pub(crate) fn odm_request_may_consult_source(opts: &ObjectOptions) -> bool {
|
|
opts.version_id.is_none() && !opts.proxy_header_set
|
|
}
|
|
|
|
/// Bucket-policy gate for a local miss: a delete marker is honored as the
|
|
/// final answer while `respect_local_delete_marker` is set.
|
|
pub(crate) fn odm_policy_admits_miss(policy: &PolicyConfig, miss: OdmLocalMiss) -> bool {
|
|
match miss {
|
|
OdmLocalMiss::NotFound => true,
|
|
OdmLocalMiss::DeleteMarker => !policy.respect_local_delete_marker,
|
|
}
|
|
}
|
|
|
|
/// HTTP 424 with the `SourceUnavailable` code. The message carries only the
|
|
/// error class: a source message may echo endpoint or key details.
|
|
pub(crate) fn odm_source_unavailable_error(class: &'static str) -> S3Error {
|
|
let mut err = S3Error::with_message(S3ErrorCode::Custom(ODM_SOURCE_UNAVAILABLE_CODE.into()), class);
|
|
err.set_status_code(StatusCode::FAILED_DEPENDENCY);
|
|
err
|
|
}
|
|
|
|
/// Client-facing error for a source failure other than not-found, under
|
|
/// `policy.source_error`.
|
|
pub(crate) fn odm_source_error_response(policy: &PolicyConfig, class: &'static str) -> S3Error {
|
|
match policy.source_error {
|
|
SourceErrorPolicy::Propagate => odm_source_unavailable_error(class),
|
|
SourceErrorPolicy::NotFound => S3Error::new(S3ErrorCode::NoSuchKey),
|
|
}
|
|
}
|
|
|
|
/// Metrics/message label for a bucket whose source client could not be built.
|
|
pub(crate) fn odm_state_error_class(error: &OdmStateError) -> &'static str {
|
|
match error {
|
|
OdmStateError::AnonymousUnsupported | OdmStateError::BackendNotCompiled(_) => "unsupported",
|
|
OdmStateError::ClientBuild(_) => "client_build",
|
|
}
|
|
}
|
|
|
|
pub(crate) fn mark_on_demand_migration_response(headers: &mut HeaderMap) {
|
|
headers.insert(ON_DEMAND_MIGRATION_HEADER, ON_DEMAND_MIGRATION_SOURCE);
|
|
}
|
|
|
|
/// Marks a merged listing that fell back to local state because the source
|
|
/// could not be consulted (rustfs/backlog#2164).
|
|
pub(crate) fn mark_on_demand_migration_list_local_only(headers: &mut HeaderMap) {
|
|
headers.insert(ON_DEMAND_MIGRATION_LIST_HEADER, ON_DEMAND_MIGRATION_LIST_LOCAL_ONLY);
|
|
}
|
|
|
|
/// Evaluates the request's conditional headers (`If-Match`, `If-None-Match`,
|
|
/// `If-Modified-Since`, `If-Unmodified-Since`) against the source's view of
|
|
/// the object, with the same S3 semantics the local path applies to
|
|
/// `ObjectInfo` (rustfs/backlog#2156). Conditional headers are never
|
|
/// forwarded to the source: a 304/412 answered by the source would be
|
|
/// indistinguishable from a source failure.
|
|
pub(crate) fn odm_check_source_preconditions(headers: &HeaderMap, head: &SourceHead) -> S3Result<()> {
|
|
let if_match = headers
|
|
.get(http::header::IF_MATCH)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(str::trim);
|
|
let if_none_match = headers
|
|
.get(http::header::IF_NONE_MATCH)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(str::trim);
|
|
let needs_etag = if_match.is_some_and(|value| value != "*") || if_none_match.is_some_and(|value| value != "*");
|
|
let needs_mtime = (!headers.contains_key(http::header::IF_MATCH) && headers.contains_key(http::header::IF_UNMODIFIED_SINCE))
|
|
|| (!headers.contains_key(http::header::IF_NONE_MATCH) && headers.contains_key(http::header::IF_MODIFIED_SINCE));
|
|
if (needs_etag && head.etag.is_none()) || (needs_mtime && head.last_modified.is_none()) {
|
|
return Err(odm_source_unavailable_error("missing_source_validator"));
|
|
}
|
|
let info = ObjectInfo {
|
|
etag: head.etag.clone(),
|
|
mod_time: head.last_modified.map(OffsetDateTime::from),
|
|
..Default::default()
|
|
};
|
|
// A successful source read establishes wildcard existence, but the
|
|
// remaining conditions must still run in their ordinary precedence.
|
|
if head.etag.is_none() && (if_match == Some("*") || if_none_match == Some("*")) {
|
|
let mut remaining = headers.clone();
|
|
if if_match == Some("*") {
|
|
remaining.remove(http::header::IF_MATCH);
|
|
remaining.remove(http::header::IF_UNMODIFIED_SINCE);
|
|
}
|
|
if if_none_match == Some("*") {
|
|
remaining.remove(http::header::IF_NONE_MATCH);
|
|
remaining.remove(http::header::IF_MODIFIED_SINCE);
|
|
}
|
|
check_preconditions(&remaining, &info)?;
|
|
return if if_none_match == Some("*") {
|
|
Err(S3Error::new(S3ErrorCode::NotModified))
|
|
} else {
|
|
Ok(())
|
|
};
|
|
}
|
|
check_preconditions(headers, &info)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use http::{HeaderMap, HeaderValue};
|
|
use s3s::dto::{
|
|
ObjectLockConfiguration, ObjectLockEnabled, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration,
|
|
ServerSideEncryptionRule,
|
|
};
|
|
use std::sync::Arc;
|
|
|
|
#[test]
|
|
fn io_queue_congestion_warn_throttle_emits_once_per_interval() {
|
|
let throttle = IoQueueCongestionWarnThrottle::new();
|
|
// The first congested request logs immediately.
|
|
assert_eq!(throttle.claim(0), Some(0));
|
|
// Requests inside the window are counted, not logged.
|
|
assert_eq!(throttle.claim(1), None);
|
|
assert_eq!(throttle.claim(IO_QUEUE_CONGESTION_WARN_INTERVAL_MS - 1), None);
|
|
// The next emission reports how many stayed silent.
|
|
assert_eq!(throttle.claim(IO_QUEUE_CONGESTION_WARN_INTERVAL_MS), Some(2));
|
|
assert_eq!(throttle.claim(IO_QUEUE_CONGESTION_WARN_INTERVAL_MS + 1), None);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_expires_header_accepts_http_date() {
|
|
let expires = parse_expires_header(Some("Wed, 21 Oct 2015 07:28:00 GMT"))
|
|
.expect("valid Expires header should parse")
|
|
.expect("header should be present");
|
|
|
|
assert_eq!(format_expires_header(&expires).unwrap(), "Wed, 21 Oct 2015 07:28:00 GMT");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_expires_header_rejects_invalid_http_date() {
|
|
let err = parse_expires_header(Some("not-a-date")).expect_err("invalid Expires header should fail");
|
|
|
|
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
|
}
|
|
|
|
// classify_response_checksums is the single point that splits decrypted checksum
|
|
// pairs into the five s3s-typed fields and the additional-algorithm `extra`
|
|
// headers, replacing five copies of the loop. Lock its behaviour (#1252).
|
|
#[test]
|
|
fn classify_response_checksums_splits_typed_and_extra() {
|
|
// Typed algorithms fill named fields; nothing spills into extra.
|
|
let c = classify_response_checksums(
|
|
vec![
|
|
("CRC32".to_string(), "AAAAAA==".to_string()),
|
|
("SHA256".to_string(), "c2hhMjU2".to_string()),
|
|
("CRC64NVME".to_string(), "Zm9vYmFyCg==".to_string()),
|
|
],
|
|
false,
|
|
);
|
|
assert_eq!(c.crc32.as_deref(), Some("AAAAAA=="));
|
|
assert_eq!(c.sha256.as_deref(), Some("c2hhMjU2"));
|
|
assert_eq!(c.crc64nvme.as_deref(), Some("Zm9vYmFyCg=="));
|
|
assert!(c.extra.is_empty(), "typed algorithms must not land in extra");
|
|
|
|
// Additional algorithms land in extra keyed by their response-header name.
|
|
let c = classify_response_checksums(
|
|
vec![
|
|
("XXHASH3".to_string(), "eHhoMw==".to_string()),
|
|
("XXHASH64".to_string(), "eHhoNjQ=".to_string()),
|
|
("XXHASH128".to_string(), "eHhoMTI4".to_string()),
|
|
("SHA512".to_string(), "c2hhNTEy".to_string()),
|
|
("MD5".to_string(), "bWQ1".to_string()),
|
|
],
|
|
false,
|
|
);
|
|
assert!(c.crc32.is_none() && c.sha256.is_none() && c.crc64nvme.is_none());
|
|
let names: Vec<&str> = c.extra.iter().map(|(n, _)| *n).collect();
|
|
for expected in [
|
|
"x-amz-checksum-xxhash3",
|
|
"x-amz-checksum-xxhash64",
|
|
"x-amz-checksum-xxhash128",
|
|
"x-amz-checksum-sha512",
|
|
"x-amz-checksum-md5",
|
|
] {
|
|
assert!(names.contains(&expected), "extra missing {expected}: {names:?}");
|
|
}
|
|
assert_eq!(c.extra.len(), 5);
|
|
|
|
// The checksum-type marker is captured as the type, not mistaken for an algorithm.
|
|
let c = classify_response_checksums(vec![(AMZ_CHECKSUM_TYPE.to_string(), "COMPOSITE".to_string())], false);
|
|
assert!(c.checksum_type.is_some());
|
|
assert!(c.extra.is_empty() && c.crc32.is_none());
|
|
|
|
let c = classify_response_checksums(vec![("CRC32".to_string(), "AAAAAA==-2".to_string())], true);
|
|
assert_eq!(c.checksum_type.as_ref().map(ChecksumType::as_str), Some("COMPOSITE"));
|
|
|
|
// Empty input yields an all-default result.
|
|
let c = classify_response_checksums(Vec::<(String, String)>::new(), false);
|
|
assert!(c.crc32.is_none() && c.extra.is_empty() && c.checksum_type.is_none());
|
|
}
|
|
|
|
// additional_checksum_echo_pairs derives the PutObject/UploadPart response echo for
|
|
// additional algorithms from the server-computed checksum, and nothing for the
|
|
// five typed ones (those go through typed output fields).
|
|
#[test]
|
|
fn additional_checksum_echo_pairs_only_for_new_algorithms() {
|
|
// Typed algorithm → no echo pair.
|
|
let sha256 = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::SHA256, b"data");
|
|
assert!(additional_checksum_echo_pairs(&sha256).is_empty());
|
|
|
|
// Additional algorithm → exactly one (header, value) pair matching the digest.
|
|
let xxh3 = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::XXHASH3, b"data");
|
|
let pairs = additional_checksum_echo_pairs(&xxh3);
|
|
assert_eq!(pairs.len(), 1);
|
|
assert_eq!(pairs[0].0, "x-amz-checksum-xxhash3");
|
|
assert_eq!(pairs[0].1, xxh3.as_ref().unwrap().encoded);
|
|
|
|
// MD5 additional checksum is echoed too.
|
|
let md5 = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::MD5, b"data");
|
|
let pairs = additional_checksum_echo_pairs(&md5);
|
|
assert_eq!(pairs.len(), 1);
|
|
assert_eq!(pairs[0].0, "x-amz-checksum-md5");
|
|
|
|
// None → empty.
|
|
assert!(additional_checksum_echo_pairs(&None).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn inject_additional_checksum_headers_writes_all_pairs() {
|
|
let mut headers = HeaderMap::new();
|
|
inject_additional_checksum_headers(
|
|
&mut headers,
|
|
&[
|
|
("x-amz-checksum-xxhash3", "eHhoMw==".to_string()),
|
|
("x-amz-checksum-md5", "bWQ1".to_string()),
|
|
],
|
|
);
|
|
assert_eq!(headers.get("x-amz-checksum-xxhash3").unwrap(), "eHhoMw==");
|
|
assert_eq!(headers.get("x-amz-checksum-md5").unwrap(), "bWQ1");
|
|
// Empty input is a no-op.
|
|
let mut empty = HeaderMap::new();
|
|
inject_additional_checksum_headers(&mut empty, &[]);
|
|
assert!(empty.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn inject_accept_ranges_header_writes_static_bytes_value() {
|
|
let mut headers = HeaderMap::new();
|
|
inject_accept_ranges_header(&mut headers);
|
|
|
|
assert_eq!(headers.get(http::header::ACCEPT_RANGES).unwrap(), ACCEPT_RANGES_BYTES);
|
|
}
|
|
|
|
#[test]
|
|
fn internal_object_info_lookup_opts_drops_http_preconditions() {
|
|
let version_id = Uuid::new_v4().to_string();
|
|
let opts = ObjectOptions {
|
|
version_id: Some(version_id.clone()),
|
|
no_lock: true,
|
|
http_preconditions: Some(HTTPPreconditions {
|
|
if_none_match: Some("\"etag\"".to_string()),
|
|
if_match: Some("\"other\"".to_string()),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
|
|
let lookup_opts = internal_object_info_lookup_opts(opts);
|
|
|
|
assert!(lookup_opts.http_preconditions.is_none());
|
|
assert_eq!(lookup_opts.version_id.as_deref(), Some(version_id.as_str()));
|
|
assert!(lookup_opts.no_lock);
|
|
}
|
|
|
|
fn bucket_sse_config_with(algorithm: &str, kms_key_id: Option<&str>) -> ServerSideEncryptionConfiguration {
|
|
ServerSideEncryptionConfiguration {
|
|
rules: vec![ServerSideEncryptionRule {
|
|
apply_server_side_encryption_by_default: Some(ServerSideEncryptionByDefault {
|
|
sse_algorithm: ServerSideEncryption::from(String::from(algorithm)),
|
|
kms_master_key_id: kms_key_id.map(|id| SSEKMSKeyId::from(id.to_string())),
|
|
}),
|
|
blocked_encryption_types: None,
|
|
bucket_key_enabled: None,
|
|
}],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_bucket_default_sse_prefers_the_request_over_the_bucket_default() {
|
|
let config = bucket_sse_config_with(ServerSideEncryption::AWS_KMS, Some("bucket-key"));
|
|
|
|
let (sse, kms_key_id) = resolve_bucket_default_sse(
|
|
Some(&config),
|
|
Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)),
|
|
Some(SSEKMSKeyId::from("request-key".to_string())),
|
|
false,
|
|
);
|
|
|
|
assert_eq!(sse.as_ref().map(|sse| sse.as_str()), Some(ServerSideEncryption::AES256));
|
|
assert_eq!(kms_key_id.as_deref(), Some("request-key"));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_bucket_default_sse_fills_gaps_from_the_bucket_default() {
|
|
let config = bucket_sse_config_with(ServerSideEncryption::AWS_KMS, Some("bucket-key"));
|
|
|
|
let (sse, kms_key_id) = resolve_bucket_default_sse(Some(&config), None, None, false);
|
|
|
|
assert_eq!(sse.as_ref().map(|sse| sse.as_str()), Some(ServerSideEncryption::AWS_KMS));
|
|
assert_eq!(kms_key_id.as_deref(), Some("bucket-key"));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_bucket_default_sse_falls_back_to_aes256_for_an_unknown_algorithm() {
|
|
// Reachable only through corrupt or hand-edited bucket metadata;
|
|
// PutBucketEncryption rejects unknown algorithms. All three call sites
|
|
// now share this single decision (backlog#1826).
|
|
let config = bucket_sse_config_with("garbage", None);
|
|
|
|
let (sse, kms_key_id) = resolve_bucket_default_sse(Some(&config), None, None, false);
|
|
|
|
assert_eq!(sse.as_ref().map(|sse| sse.as_str()), Some(ServerSideEncryption::AES256));
|
|
assert!(kms_key_id.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_bucket_default_sse_suppresses_the_default_for_explicit_ssec() {
|
|
let config = bucket_sse_config_with(ServerSideEncryption::AES256, Some("bucket-key"));
|
|
|
|
let (sse, kms_key_id) = resolve_bucket_default_sse(Some(&config), None, None, true);
|
|
|
|
assert!(sse.is_none(), "an SSE-C destination must not also get managed encryption");
|
|
assert!(kms_key_id.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_bucket_default_sse_returns_nothing_without_a_bucket_default() {
|
|
let (sse, kms_key_id) = resolve_bucket_default_sse(None, None, None, false);
|
|
|
|
assert!(sse.is_none());
|
|
assert!(kms_key_id.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn build_put_like_object_lock_metadata_rejects_mode_without_retain_until_date() {
|
|
let err = build_put_like_object_lock_metadata(
|
|
"test-bucket",
|
|
&metadata_sys::ObjectLockConfigState::ConfirmedAbsent,
|
|
None,
|
|
Some(ObjectLockMode::from_static(ObjectLockMode::GOVERNANCE)),
|
|
None,
|
|
)
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
|
|
assert_eq!(err.message(), Some(ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED));
|
|
}
|
|
|
|
#[test]
|
|
fn object_lock_checks_required_reuses_authoritative_state() {
|
|
assert!(!object_lock_checks_required_for_state(
|
|
&metadata_sys::ObjectLockConfigState::ConfirmedAbsent
|
|
));
|
|
|
|
let configured = metadata_sys::ObjectLockConfigState::Configured {
|
|
config: ObjectLockConfiguration {
|
|
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
|
rule: None,
|
|
},
|
|
updated_at: OffsetDateTime::now_utc(),
|
|
};
|
|
assert!(object_lock_checks_required_for_state(&configured));
|
|
assert!(object_lock_checks_required_for_state(&metadata_sys::ObjectLockConfigState::Fabricated));
|
|
}
|
|
|
|
#[test]
|
|
fn build_put_like_object_lock_metadata_rejects_retain_until_date_without_mode() {
|
|
let retain_until = Timestamp::from(OffsetDateTime::now_utc().add(time::Duration::days(1)));
|
|
let err = build_put_like_object_lock_metadata(
|
|
"test-bucket",
|
|
&metadata_sys::ObjectLockConfigState::ConfirmedAbsent,
|
|
None,
|
|
None,
|
|
Some(retain_until),
|
|
)
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
|
|
assert_eq!(err.message(), Some(ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED));
|
|
}
|
|
|
|
const NO_BUCKET_LOCK: metadata_sys::ObjectLockConfigState = metadata_sys::ObjectLockConfigState::ConfirmedAbsent;
|
|
|
|
fn bucket_default_retention_state(mode: &'static str) -> metadata_sys::ObjectLockConfigState {
|
|
metadata_sys::ObjectLockConfigState::Configured {
|
|
config: s3s::dto::ObjectLockConfiguration {
|
|
object_lock_enabled: Some(s3s::dto::ObjectLockEnabled::from_static(s3s::dto::ObjectLockEnabled::ENABLED)),
|
|
rule: Some(s3s::dto::ObjectLockRule {
|
|
default_retention: Some(s3s::dto::DefaultRetention {
|
|
mode: Some(ObjectLockRetentionMode::from_static(mode)),
|
|
days: Some(1),
|
|
years: None,
|
|
}),
|
|
}),
|
|
},
|
|
updated_at: OffsetDateTime::now_utc(),
|
|
}
|
|
}
|
|
|
|
fn object_info_with_lock_metadata(metadata: HashMap<String, String>) -> ObjectInfo {
|
|
ObjectInfo {
|
|
user_defined: Arc::new(metadata),
|
|
mod_time: Some(OffsetDateTime::now_utc()),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn compliance_retained_object_info() -> ObjectInfo {
|
|
let mut metadata = HashMap::new();
|
|
metadata.insert(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), ObjectLockRetentionMode::COMPLIANCE.to_string());
|
|
metadata.insert(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), "2030-01-01T00:00:00Z".to_string());
|
|
object_info_with_lock_metadata(metadata)
|
|
}
|
|
|
|
fn legal_hold_object_info() -> ObjectInfo {
|
|
let mut metadata = HashMap::new();
|
|
metadata.insert(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER.to_string(), ObjectLockLegalHoldStatus::ON.to_string());
|
|
object_info_with_lock_metadata(metadata)
|
|
}
|
|
|
|
#[test]
|
|
fn validate_existing_object_lock_allows_versioned_new_version_with_compliance_retention() {
|
|
let opts = ObjectOptions {
|
|
versioned: true,
|
|
version_id: None,
|
|
..Default::default()
|
|
};
|
|
|
|
validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts)
|
|
.expect("versioned put should create a new version");
|
|
}
|
|
|
|
#[test]
|
|
fn validate_existing_object_lock_allows_versioned_new_version_with_legal_hold() {
|
|
let opts = ObjectOptions {
|
|
versioned: true,
|
|
version_id: None,
|
|
..Default::default()
|
|
};
|
|
|
|
validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &legal_hold_object_info(), &opts)
|
|
.expect("versioned put should create a new version");
|
|
}
|
|
|
|
#[test]
|
|
fn validate_existing_object_lock_blocks_unversioned_compliance_overwrite() {
|
|
let err = validate_existing_object_lock_for_write(
|
|
&NO_BUCKET_LOCK,
|
|
&compliance_retained_object_info(),
|
|
&ObjectOptions::default(),
|
|
)
|
|
.expect_err("unversioned overwrite should still be blocked");
|
|
|
|
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_existing_object_lock_blocks_suspended_version_compliance_overwrite() {
|
|
let opts = ObjectOptions {
|
|
versioned: true,
|
|
version_suspended: true,
|
|
version_id: None,
|
|
..Default::default()
|
|
};
|
|
let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts)
|
|
.expect_err("suspended versioning overwrite should still be blocked");
|
|
|
|
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_existing_object_lock_blocks_explicit_version_compliance_overwrite() {
|
|
let opts = ObjectOptions {
|
|
versioned: true,
|
|
version_id: Some(Uuid::new_v4().to_string()),
|
|
..Default::default()
|
|
};
|
|
let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts)
|
|
.expect_err("explicit version overwrite should still be blocked");
|
|
|
|
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
|
|
}
|
|
|
|
/// The source's lock state governs the replica (rustfs/backlog#1953):
|
|
/// an authorized replication write carrying the locking category's source
|
|
/// timestamp may overwrite a locked version; the set layer's LWW then
|
|
/// decides per category.
|
|
#[test]
|
|
fn validate_existing_object_lock_allows_authorized_replication_overwrite() {
|
|
let opts = ObjectOptions {
|
|
versioned: true,
|
|
version_id: Some(Uuid::new_v4().to_string()),
|
|
replication_request: true,
|
|
replication_retention_timestamp: Some(OffsetDateTime::UNIX_EPOCH),
|
|
replication_legalhold_timestamp: Some(OffsetDateTime::UNIX_EPOCH),
|
|
..Default::default()
|
|
};
|
|
|
|
validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts)
|
|
.expect("replication write must bypass the destination COMPLIANCE lock");
|
|
validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &legal_hold_object_info(), &opts)
|
|
.expect("replication write must bypass the destination legal hold");
|
|
}
|
|
|
|
/// Without the locking category's source timestamp the LWW merge cannot
|
|
/// judge it, so the write stays rejected instead of lifting the lock.
|
|
#[test]
|
|
fn validate_existing_object_lock_rejects_replication_overwrite_without_lock_timestamp() {
|
|
let opts = ObjectOptions {
|
|
versioned: true,
|
|
version_id: Some(Uuid::new_v4().to_string()),
|
|
replication_request: true,
|
|
replication_tagging_timestamp: Some(OffsetDateTime::UNIX_EPOCH),
|
|
..Default::default()
|
|
};
|
|
|
|
let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &compliance_retained_object_info(), &opts)
|
|
.expect_err("COMPLIANCE lock must hold without a retention source timestamp");
|
|
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
|
|
let err = validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &legal_hold_object_info(), &opts)
|
|
.expect_err("legal hold must hold without a legal-hold source timestamp");
|
|
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
|
|
}
|
|
|
|
/// The bucket default retention locks a version without explicit
|
|
/// retention keys; the pre-check judges the same authoritative state as
|
|
/// the set-layer gate, so a tagging-only replication write is rejected
|
|
/// and one carrying the retention source timestamp passes to LWW.
|
|
#[test]
|
|
fn validate_existing_object_lock_judges_bucket_default_retention_for_replication_overwrite() {
|
|
let default_protected = object_info_with_lock_metadata(HashMap::new());
|
|
let tagging_only = ObjectOptions {
|
|
versioned: true,
|
|
version_id: Some(Uuid::new_v4().to_string()),
|
|
replication_request: true,
|
|
replication_tagging_timestamp: Some(OffsetDateTime::UNIX_EPOCH),
|
|
..Default::default()
|
|
};
|
|
let with_retention_decision = ObjectOptions {
|
|
replication_retention_timestamp: Some(OffsetDateTime::UNIX_EPOCH),
|
|
..tagging_only.clone()
|
|
};
|
|
|
|
for mode in [ObjectLockRetentionMode::COMPLIANCE, ObjectLockRetentionMode::GOVERNANCE] {
|
|
let state = bucket_default_retention_state(mode);
|
|
let err = validate_existing_object_lock_for_write(&state, &default_protected, &tagging_only)
|
|
.expect_err("bucket default retention must hold without a retention source timestamp");
|
|
assert_eq!(err.code(), &S3ErrorCode::AccessDenied, "{mode}");
|
|
validate_existing_object_lock_for_write(&state, &default_protected, &with_retention_decision)
|
|
.expect("the retention source timestamp hands the default retention to LWW");
|
|
}
|
|
|
|
// Without a bucket default the same version is simply unlocked.
|
|
validate_existing_object_lock_for_write(&NO_BUCKET_LOCK, &default_protected, &tagging_only)
|
|
.expect("no bucket default, no lock");
|
|
}
|
|
|
|
#[test]
|
|
fn aws_chunked_put_prefers_decoded_content_length() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("content-encoding", HeaderValue::from_static("aws-chunked"));
|
|
headers.insert(AMZ_DECODED_CONTENT_LENGTH, HeaderValue::from_static("71680"));
|
|
|
|
let decoded = decoded_content_length_from_headers(&headers).expect("decoded content length should parse");
|
|
assert!(request_uses_aws_chunked(&headers));
|
|
assert_eq!(decoded, Some(71680));
|
|
|
|
let resolved = match (request_uses_aws_chunked(&headers), decoded, Some(99999)) {
|
|
(true, Some(decoded), _) => decoded,
|
|
(_, _, Some(c)) => c,
|
|
(_, Some(decoded), None) => decoded,
|
|
_ => unreachable!("test provides a valid size source"),
|
|
};
|
|
|
|
assert_eq!(resolved, 71680);
|
|
}
|
|
|
|
#[test]
|
|
fn s3s_body_error_to_io_preserves_upload_stream_error_source() {
|
|
let error = s3s_body_error_to_io(Box::new(MockUploadStreamSha256Mismatch));
|
|
|
|
assert!(matches!(
|
|
error
|
|
.get_ref()
|
|
.and_then(|source| source.downcast_ref::<MockUploadStreamSha256Mismatch>()),
|
|
Some(MockUploadStreamSha256Mismatch)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn response_storage_class_reports_effective_layout_and_preserves_transition_tier() {
|
|
let metadata = HashMap::new();
|
|
let standard_info = ObjectInfo {
|
|
storage_class: Some(storageclass::STANDARD.to_string()),
|
|
user_defined: Arc::new(metadata.clone()),
|
|
..Default::default()
|
|
};
|
|
assert!(response_storage_class(&standard_info, &metadata).is_none());
|
|
|
|
let mut metadata = HashMap::new();
|
|
metadata.insert(AMZ_STORAGE_CLASS.to_string(), storageclass::STANDARD_IA.to_string());
|
|
let label_only_info = ObjectInfo {
|
|
storage_class: Some(storageclass::STANDARD_IA.to_string()),
|
|
user_defined: Arc::new(metadata.clone()),
|
|
..Default::default()
|
|
};
|
|
assert!(
|
|
response_storage_class(&label_only_info, &metadata).is_none(),
|
|
"historical STANDARD_IA labels must report the effective implicit STANDARD layout"
|
|
);
|
|
|
|
let rrs_info = ObjectInfo {
|
|
storage_class: Some(storageclass::RRS.to_string()),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(
|
|
response_storage_class(&rrs_info, &HashMap::new())
|
|
.as_ref()
|
|
.map(StorageClass::as_str),
|
|
Some(storageclass::RRS)
|
|
);
|
|
|
|
let mut transitioned_info = label_only_info;
|
|
transitioned_info.transitioned_object.tier = "WARM-TIER".to_string();
|
|
assert!(
|
|
response_storage_class(&transitioned_info, &metadata).is_none(),
|
|
"a tier name without a completed transition must not override the effective local class"
|
|
);
|
|
transitioned_info.transitioned_object.status = rustfs_filemeta::TRANSITION_COMPLETE.to_string();
|
|
assert_eq!(
|
|
response_storage_class(&transitioned_info, &metadata)
|
|
.as_ref()
|
|
.map(StorageClass::as_str),
|
|
Some("WARM-TIER")
|
|
);
|
|
|
|
let mut metadata = HashMap::new();
|
|
metadata.insert(AMZ_STORAGE_CLASS.to_string(), storageclass::STANDARD.to_string());
|
|
let standard_metadata_info = ObjectInfo {
|
|
storage_class: None,
|
|
user_defined: Arc::new(metadata.clone()),
|
|
..Default::default()
|
|
};
|
|
assert!(
|
|
response_storage_class(&standard_metadata_info, &metadata).is_none(),
|
|
"STANDARD must be omitted even when it only arrives via metadata fallback"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn response_storage_class_for_object_attributes_defaults_to_standard_when_requested() {
|
|
let metadata = HashMap::new();
|
|
let info = ObjectInfo {
|
|
storage_class: None,
|
|
user_defined: Arc::new(metadata.clone()),
|
|
..Default::default()
|
|
};
|
|
|
|
assert_eq!(
|
|
response_storage_class_for_object_attributes(&info, &metadata, true)
|
|
.as_ref()
|
|
.map(StorageClass::as_str),
|
|
Some(storageclass::STANDARD)
|
|
);
|
|
|
|
let legacy_info = ObjectInfo {
|
|
storage_class: Some(storageclass::STANDARD_IA.to_string()),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(
|
|
response_storage_class_for_object_attributes(&legacy_info, &HashMap::new(), true)
|
|
.as_ref()
|
|
.map(StorageClass::as_str),
|
|
Some(storageclass::STANDARD)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn response_storage_class_for_object_attributes_skips_value_when_not_requested() {
|
|
let metadata = HashMap::new();
|
|
let info = ObjectInfo {
|
|
storage_class: Some(storageclass::STANDARD_IA.to_string()),
|
|
user_defined: Arc::new(metadata.clone()),
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(
|
|
response_storage_class_for_object_attributes(&info, &metadata, false).is_none(),
|
|
"StorageClass must only be returned when explicitly requested"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn expected_current_version_header_normalizes_uuid_and_null() {
|
|
let version = Uuid::new_v4();
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
RUSTFS_EXPECTED_CURRENT_VERSION_ID,
|
|
HeaderValue::from_str(&version.to_string().to_uppercase()).unwrap(),
|
|
);
|
|
assert_eq!(expected_current_version_id(&headers).unwrap(), Some(version.to_string()));
|
|
|
|
headers.insert(RUSTFS_EXPECTED_CURRENT_VERSION_ID, HeaderValue::from_static(" null "));
|
|
assert_eq!(expected_current_version_id(&headers).unwrap(), Some(Uuid::nil().to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn expected_current_version_header_rejects_empty_and_malformed_values() {
|
|
for value in ["", "not-a-version"] {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(RUSTFS_EXPECTED_CURRENT_VERSION_ID, HeaderValue::from_str(value).unwrap());
|
|
assert_eq!(expected_current_version_id(&headers).unwrap_err().code(), &S3ErrorCode::InvalidArgument);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn build_put_object_expiration_header_returns_none_for_non_delete_events() {
|
|
let event = lifecycle::Event {
|
|
action: lifecycle::IlmAction::TransitionAction,
|
|
rule_id: "rule-1".to_string(),
|
|
due: Some(OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap()),
|
|
noncurrent_days: 0,
|
|
newer_noncurrent_versions: 0,
|
|
storage_class: String::new(),
|
|
};
|
|
|
|
assert!(build_put_object_expiration_header(&event).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn build_put_object_expiration_header_formats_expected_value() {
|
|
let expire_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
|
|
let event = lifecycle::Event {
|
|
action: lifecycle::IlmAction::DeleteAction,
|
|
rule_id: "rule-1".to_string(),
|
|
due: Some(expire_time),
|
|
noncurrent_days: 0,
|
|
newer_noncurrent_versions: 0,
|
|
storage_class: String::new(),
|
|
};
|
|
|
|
let expiry_date = expire_time.format(&Rfc3339).unwrap();
|
|
let expected = format!("expiry-date=\"{}\", rule-id=\"rule-1\"", expiry_date);
|
|
assert_eq!(build_put_object_expiration_header(&event), Some(expected));
|
|
}
|
|
|
|
#[test]
|
|
fn build_put_object_expiration_header_requires_rule_id_and_due_time() {
|
|
let event = lifecycle::Event {
|
|
action: lifecycle::IlmAction::DeleteAction,
|
|
rule_id: String::new(),
|
|
due: Some(OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap()),
|
|
noncurrent_days: 0,
|
|
newer_noncurrent_versions: 0,
|
|
storage_class: String::new(),
|
|
};
|
|
|
|
assert!(build_put_object_expiration_header(&event).is_none());
|
|
|
|
let event = lifecycle::Event {
|
|
action: lifecycle::IlmAction::DeleteAction,
|
|
rule_id: "rule-1".to_string(),
|
|
due: Some(OffsetDateTime::UNIX_EPOCH),
|
|
noncurrent_days: 0,
|
|
newer_noncurrent_versions: 0,
|
|
storage_class: String::new(),
|
|
};
|
|
|
|
assert!(build_put_object_expiration_header(&event).is_none());
|
|
}
|
|
|
|
// -- Range: u64 -> i64 lossless conversion (issue rustfs/backlog#1322) --
|
|
|
|
const I64_MAX_AS_U64: u64 = i64::MAX as u64;
|
|
|
|
/// The conversion itself: s3s `Range` (u64) -> internal `HTTPRangeSpec`
|
|
/// (i64). This directly guards the suffix truncation fix. Reverting to
|
|
/// `length as i64` regresses the zero-suffix, `i64::MAX + 1` and `u64::MAX`
|
|
/// rows below.
|
|
#[test]
|
|
fn range_to_http_range_spec_is_lossless() {
|
|
// Zero-length suffix (`bytes=-0`) is unsatisfiable -> InvalidRange (416),
|
|
// never a 0-length 206.
|
|
let zero_suffix = range_to_http_range_spec(Range::Suffix { length: 0 });
|
|
assert_eq!(
|
|
zero_suffix.as_ref().err().map(|e| e.code()),
|
|
Some(&S3ErrorCode::InvalidRange),
|
|
"bytes=-0 must map to InvalidRange (416)"
|
|
);
|
|
|
|
// Suffix conversions: positive `start` holds the suffix length; values
|
|
// above i64::MAX clamp to i64::MAX (they always cover the whole object).
|
|
let suffix_cases = [
|
|
(1_u64, 1_i64),
|
|
(I64_MAX_AS_U64, i64::MAX),
|
|
(I64_MAX_AS_U64 + 1, i64::MAX), // was i64::MIN under `as i64` -> checked_neg overflow
|
|
(u64::MAX, i64::MAX), // was -1 under `as i64` -> read as "last 1 byte"
|
|
];
|
|
for (length, expected_start) in suffix_cases {
|
|
let spec = range_to_http_range_spec(Range::Suffix { length })
|
|
.unwrap_or_else(|_| panic!("suffix {length} must convert losslessly"));
|
|
assert!(spec.is_suffix_length, "suffix {length} must stay a suffix spec");
|
|
assert_eq!(spec.start, expected_start, "suffix {length} start");
|
|
assert_eq!(spec.end, -1, "suffix {length} end");
|
|
}
|
|
|
|
// Int ranges: s3s already rejects first/last > i64::MAX, so the checked
|
|
// cast never truncates. first-last and open-ended must not regress.
|
|
let int_first_last = range_to_http_range_spec(Range::Int {
|
|
first: 10,
|
|
last: Some(20),
|
|
})
|
|
.expect("first-last converts");
|
|
assert!(!int_first_last.is_suffix_length);
|
|
assert_eq!((int_first_last.start, int_first_last.end), (10, 20));
|
|
|
|
let int_open = range_to_http_range_spec(Range::Int { first: 5, last: None }).expect("open-ended converts");
|
|
assert_eq!((int_open.start, int_open.end), (5, -1));
|
|
|
|
let int_max = range_to_http_range_spec(Range::Int {
|
|
first: I64_MAX_AS_U64,
|
|
last: Some(I64_MAX_AS_U64),
|
|
})
|
|
.expect("i64::MAX int converts");
|
|
assert_eq!((int_max.start, int_max.end), (i64::MAX, i64::MAX));
|
|
}
|
|
|
|
/// Observable end-to-end effect the GET/HEAD handlers derive from a range
|
|
/// spec: `HTTPRangeSpec::get_offset_length` yields the (offset, length)
|
|
/// that becomes `Content-Length` and `Content-Range`, or an error that
|
|
/// surfaces as 416. Covers empty / 1-byte / normal objects.
|
|
#[test]
|
|
fn range_suffix_offset_length_matches_s3_semantics() {
|
|
// Expected outcome for a satisfiable range, or `None` for 416.
|
|
#[derive(Debug, PartialEq)]
|
|
enum Outcome {
|
|
/// (offset, content_length, content_range)
|
|
Partial(usize, i64, String),
|
|
Unsatisfiable,
|
|
}
|
|
|
|
fn derive(range: Range, size: i64) -> Outcome {
|
|
let spec = match range_to_http_range_spec(range) {
|
|
Ok(spec) => spec,
|
|
Err(_) => return Outcome::Unsatisfiable,
|
|
};
|
|
match spec.get_offset_length(size) {
|
|
Ok((offset, len)) => {
|
|
let content_range = format!("bytes {}-{}/{}", offset, offset as i64 + len - 1, size);
|
|
Outcome::Partial(offset, len, content_range)
|
|
}
|
|
Err(_) => Outcome::Unsatisfiable,
|
|
}
|
|
}
|
|
|
|
let suffix = |length: u64| Range::Suffix { length };
|
|
|
|
// size, range, expected
|
|
let normal = 100_i64;
|
|
let cases = [
|
|
// Zero suffix is always 416, whatever the size.
|
|
(0_i64, suffix(0), Outcome::Unsatisfiable),
|
|
(1, suffix(0), Outcome::Unsatisfiable),
|
|
(normal, suffix(0), Outcome::Unsatisfiable),
|
|
// Suffix within the object returns the trailing bytes.
|
|
(normal, suffix(1), Outcome::Partial(99, 1, "bytes 99-99/100".into())),
|
|
(normal, suffix(normal as u64), Outcome::Partial(0, 100, "bytes 0-99/100".into())),
|
|
// Suffix >= size returns the whole object (never a truncated tail).
|
|
(normal, suffix(normal as u64 + 1), Outcome::Partial(0, 100, "bytes 0-99/100".into())),
|
|
(normal, suffix(I64_MAX_AS_U64), Outcome::Partial(0, 100, "bytes 0-99/100".into())),
|
|
(normal, suffix(I64_MAX_AS_U64 + 1), Outcome::Partial(0, 100, "bytes 0-99/100".into())),
|
|
(normal, suffix(u64::MAX), Outcome::Partial(0, 100, "bytes 0-99/100".into())),
|
|
// 1-byte object: any non-zero suffix returns that single byte.
|
|
(1, suffix(1), Outcome::Partial(0, 1, "bytes 0-0/1".into())),
|
|
(1, suffix(2), Outcome::Partial(0, 1, "bytes 0-0/1".into())),
|
|
(1, suffix(I64_MAX_AS_U64 + 1), Outcome::Partial(0, 1, "bytes 0-0/1".into())),
|
|
(1, suffix(u64::MAX), Outcome::Partial(0, 1, "bytes 0-0/1".into())),
|
|
// Normal first-last and open-ended int ranges must not regress.
|
|
(
|
|
normal,
|
|
Range::Int {
|
|
first: 10,
|
|
last: Some(19),
|
|
},
|
|
Outcome::Partial(10, 10, "bytes 10-19/100".into()),
|
|
),
|
|
(
|
|
normal,
|
|
Range::Int { first: 90, last: None },
|
|
Outcome::Partial(90, 10, "bytes 90-99/100".into()),
|
|
),
|
|
];
|
|
|
|
for (size, range, expected) in cases {
|
|
let got = derive(range, size);
|
|
assert_eq!(got, expected, "size={size} range={range:?}");
|
|
}
|
|
}
|
|
|
|
fn quota_result(allowed: bool) -> QuotaCheckResult {
|
|
QuotaCheckResult {
|
|
allowed,
|
|
current_usage: Some(1024),
|
|
quota_limit: Some(2048),
|
|
operation_size: 512,
|
|
remaining: Some(512),
|
|
uses_durable_reservations: true,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn quota_admission_allows_within_limit() {
|
|
let result = map_quota_check_outcome("bucket", Ok(quota_result(true))).expect("an allowed result admits the write");
|
|
|
|
assert_eq!(result.current_usage, Some(1024));
|
|
assert_eq!(result.quota_limit, Some(2048));
|
|
assert_eq!(result.operation_size, 512);
|
|
assert_eq!(result.remaining, Some(512));
|
|
}
|
|
|
|
#[test]
|
|
fn quota_admission_rejects_over_limit() {
|
|
let err = map_quota_check_outcome("bucket", Ok(quota_result(false))).expect_err("an over-limit result rejects the write");
|
|
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_quota_admission_rejects_already_over_limit() {
|
|
let result = QuotaCheckResult {
|
|
allowed: true,
|
|
current_usage: Some(6),
|
|
quota_limit: Some(5),
|
|
operation_size: 0,
|
|
remaining: Some(0),
|
|
uses_durable_reservations: false,
|
|
};
|
|
let mut opts = ObjectOptions::default();
|
|
let err =
|
|
apply_quota_admission(&mut opts, &result).expect_err("legacy completion must not bypass an already exceeded quota");
|
|
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
|
|
}
|
|
|
|
#[test]
|
|
fn quota_admission_fails_closed_on_checker_error() {
|
|
// A configured hard quota must never be bypassed by an internal fault: a checker error becomes a retryable ServiceUnavailable, not a silent allow.
|
|
let err = map_quota_check_outcome(
|
|
"bucket",
|
|
Err(QuotaError::InvalidConfig {
|
|
reason: "corrupt quota config".to_string(),
|
|
}),
|
|
)
|
|
.expect_err("a checker fault must fail closed");
|
|
assert_eq!(err.code(), &S3ErrorCode::ServiceUnavailable);
|
|
}
|
|
|
|
#[test]
|
|
fn early_quota_filter_rejects_only_an_individually_impossible_object() {
|
|
let stale_full_usage = QuotaCheckResult {
|
|
allowed: true,
|
|
current_usage: Some(4096),
|
|
quota_limit: Some(4096),
|
|
operation_size: 0,
|
|
remaining: Some(0),
|
|
uses_durable_reservations: true,
|
|
};
|
|
|
|
ensure_object_size_within_quota(&stale_full_usage, 4096)
|
|
.expect("commit-time ledger must decide whether stale usage was reclaimed");
|
|
let err = ensure_object_size_within_quota(&stale_full_usage, 4097)
|
|
.expect_err("an object larger than the whole quota can never fit");
|
|
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
|
|
}
|
|
|
|
#[test]
|
|
fn quota_admission_fails_closed_on_unknown_authoritative_usage() {
|
|
let err = map_quota_check_outcome(
|
|
"bucket",
|
|
Err(QuotaError::UsageUnavailable {
|
|
bucket: "bucket".to_string(),
|
|
}),
|
|
)
|
|
.expect_err("unknown authoritative usage must not admit a quota-controlled write");
|
|
assert_eq!(err.code(), &S3ErrorCode::ServiceUnavailable);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod on_demand_migration_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn odm_local_miss_classifies_lookup_results() {
|
|
let present = ObjectInfo::default();
|
|
assert_eq!(odm_local_miss(Ok(&present)), None, "a present object is never a miss");
|
|
|
|
let marker = ObjectInfo {
|
|
delete_marker: true,
|
|
..Default::default()
|
|
};
|
|
assert_eq!(odm_local_miss(Ok(&marker)), Some(OdmLocalMiss::DeleteMarker));
|
|
|
|
let not_found = EcstoreError::ObjectNotFound("b".to_string(), "k".to_string());
|
|
assert_eq!(odm_local_miss(Err(¬_found)), Some(OdmLocalMiss::NotFound));
|
|
let version_not_found = EcstoreError::VersionNotFound("b".to_string(), "k".to_string(), "v".to_string());
|
|
assert_eq!(odm_local_miss(Err(&version_not_found)), Some(OdmLocalMiss::NotFound));
|
|
|
|
let other = EcstoreError::MethodNotAllowed;
|
|
assert_eq!(odm_local_miss(Err(&other)), None, "non-404 failures are not the source's to answer");
|
|
}
|
|
|
|
#[test]
|
|
fn odm_request_gate_rejects_version_reads_and_anti_loop_marker() {
|
|
let plain = ObjectOptions::default();
|
|
assert!(odm_request_may_consult_source(&plain));
|
|
|
|
let versioned_read = ObjectOptions {
|
|
version_id: Some(Uuid::new_v4().to_string()),
|
|
..Default::default()
|
|
};
|
|
assert!(
|
|
!odm_request_may_consult_source(&versioned_read),
|
|
"HEAD ?versionId never consults the source"
|
|
);
|
|
|
|
// The marker disables the passthrough by presence alone, so a peer's
|
|
// "false" convergence probe is answered locally too.
|
|
let proxy_marked = ObjectOptions {
|
|
proxy_header_set: true,
|
|
proxy_request: false,
|
|
..Default::default()
|
|
};
|
|
assert!(!odm_request_may_consult_source(&proxy_marked));
|
|
}
|
|
|
|
#[test]
|
|
fn odm_policy_gate_honors_delete_marker_only_when_configured() {
|
|
let respecting = PolicyConfig::default();
|
|
assert!(respecting.respect_local_delete_marker, "default policy respects local delete markers");
|
|
assert!(odm_policy_admits_miss(&respecting, OdmLocalMiss::NotFound));
|
|
assert!(!odm_policy_admits_miss(&respecting, OdmLocalMiss::DeleteMarker));
|
|
|
|
let overriding = PolicyConfig {
|
|
respect_local_delete_marker: false,
|
|
..Default::default()
|
|
};
|
|
assert!(odm_policy_admits_miss(&overriding, OdmLocalMiss::DeleteMarker));
|
|
}
|
|
|
|
#[test]
|
|
fn odm_source_unavailable_error_is_424_with_class_only() {
|
|
let err = odm_source_unavailable_error("server_error");
|
|
assert_eq!(err.status_code(), Some(StatusCode::FAILED_DEPENDENCY));
|
|
assert_eq!(err.code(), &S3ErrorCode::Custom(ODM_SOURCE_UNAVAILABLE_CODE.into()));
|
|
assert_eq!(err.message(), Some("server_error"));
|
|
}
|
|
|
|
#[test]
|
|
fn odm_source_error_response_follows_policy() {
|
|
let propagate = PolicyConfig::default();
|
|
assert_eq!(propagate.source_error, SourceErrorPolicy::Propagate);
|
|
let err = odm_source_error_response(&propagate, "timeout");
|
|
assert_eq!(err.status_code(), Some(StatusCode::FAILED_DEPENDENCY));
|
|
assert_eq!(err.message(), Some("timeout"));
|
|
|
|
let hide = PolicyConfig {
|
|
source_error: SourceErrorPolicy::NotFound,
|
|
..Default::default()
|
|
};
|
|
let err = odm_source_error_response(&hide, "timeout");
|
|
assert_eq!(err.code(), &S3ErrorCode::NoSuchKey);
|
|
assert_eq!(err.status_code(), Some(StatusCode::NOT_FOUND));
|
|
}
|
|
|
|
#[test]
|
|
fn odm_state_error_class_is_stable() {
|
|
assert_eq!(odm_state_error_class(&OdmStateError::AnonymousUnsupported), "unsupported");
|
|
assert_eq!(odm_state_error_class(&OdmStateError::BackendNotCompiled("gcs_native")), "unsupported");
|
|
assert_eq!(odm_state_error_class(&OdmStateError::ClientBuild("tls".to_string())), "client_build");
|
|
}
|
|
|
|
#[test]
|
|
fn mark_on_demand_migration_response_sets_header() {
|
|
let mut headers = HeaderMap::new();
|
|
mark_on_demand_migration_response(&mut headers);
|
|
assert_eq!(headers.get("x-rustfs-on-demand-migration").and_then(|v| v.to_str().ok()), Some("source"));
|
|
}
|
|
|
|
fn conditional_source_head() -> SourceHead {
|
|
SourceHead {
|
|
etag: Some("0123456789abcdef0123456789abcdef".to_string()),
|
|
size: 7,
|
|
// Thu, 01 Jan 2026 00:00:00 GMT
|
|
last_modified: Some(std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(1_767_225_600)),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn headers_with(name: http::header::HeaderName, value: &'static str) -> HeaderMap {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(name, HeaderValue::from_static(value));
|
|
headers
|
|
}
|
|
|
|
#[test]
|
|
fn odm_source_preconditions_follow_local_semantics() {
|
|
let head = conditional_source_head();
|
|
assert!(odm_check_source_preconditions(&HeaderMap::new(), &head).is_ok());
|
|
|
|
// If-None-Match on the source ETag: 304 carrying the source ETag and Last-Modified.
|
|
let err = odm_check_source_preconditions(
|
|
&headers_with(http::header::IF_NONE_MATCH, "\"0123456789abcdef0123456789abcdef\""),
|
|
&head,
|
|
)
|
|
.expect_err("matching If-None-Match is 304");
|
|
assert_eq!(err.code(), &S3ErrorCode::NotModified);
|
|
assert_eq!(err.status_code(), Some(StatusCode::NOT_MODIFIED));
|
|
let echoed = err.headers().expect("304 echoes validators");
|
|
assert_eq!(
|
|
echoed.get("etag").and_then(|v| v.to_str().ok()),
|
|
Some("\"0123456789abcdef0123456789abcdef\"")
|
|
);
|
|
assert_eq!(
|
|
echoed.get("last-modified").and_then(|v| v.to_str().ok()),
|
|
Some("Thu, 01 Jan 2026 00:00:00 GMT")
|
|
);
|
|
assert!(odm_check_source_preconditions(&headers_with(http::header::IF_NONE_MATCH, "\"other\""), &head).is_ok());
|
|
|
|
// If-Match on another ETag: 412.
|
|
let err = odm_check_source_preconditions(&headers_with(http::header::IF_MATCH, "\"other\""), &head)
|
|
.expect_err("mismatching If-Match is 412");
|
|
assert_eq!(err.code(), &S3ErrorCode::PreconditionFailed);
|
|
assert!(
|
|
odm_check_source_preconditions(&headers_with(http::header::IF_MATCH, "\"0123456789abcdef0123456789abcdef\""), &head)
|
|
.is_ok()
|
|
);
|
|
|
|
// Date validators compare against the source Last-Modified.
|
|
let err = odm_check_source_preconditions(
|
|
&headers_with(http::header::IF_MODIFIED_SINCE, "Fri, 02 Jan 2026 00:00:00 GMT"),
|
|
&head,
|
|
)
|
|
.expect_err("not modified since a later date is 304");
|
|
assert_eq!(err.code(), &S3ErrorCode::NotModified);
|
|
let err = odm_check_source_preconditions(
|
|
&headers_with(http::header::IF_UNMODIFIED_SINCE, "Wed, 31 Dec 2025 00:00:00 GMT"),
|
|
&head,
|
|
)
|
|
.expect_err("modified since an earlier date is 412");
|
|
assert_eq!(err.code(), &S3ErrorCode::PreconditionFailed);
|
|
|
|
let bare = SourceHead::default();
|
|
let err =
|
|
odm_check_source_preconditions(&headers_with(http::header::IF_MATCH, "\"other\""), &bare).expect_err("missing ETag");
|
|
assert_eq!(err.status_code(), Some(http::StatusCode::FAILED_DEPENDENCY));
|
|
assert!(odm_check_source_preconditions(&headers_with(http::header::IF_MATCH, "*"), &bare).is_ok());
|
|
let err =
|
|
odm_check_source_preconditions(&headers_with(http::header::IF_NONE_MATCH, "*"), &bare).expect_err("source exists");
|
|
assert_eq!(err.code(), &S3ErrorCode::NotModified);
|
|
let dated = SourceHead {
|
|
last_modified: head.last_modified,
|
|
..Default::default()
|
|
};
|
|
assert!(odm_check_source_preconditions(&headers_with(http::header::IF_MATCH, "*"), &dated).is_ok());
|
|
let mut combined = headers_with(http::header::IF_NONE_MATCH, "*");
|
|
combined.insert(http::header::IF_MATCH, HeaderValue::from_static("\"other\""));
|
|
assert_eq!(
|
|
odm_check_source_preconditions(&combined, &dated)
|
|
.expect_err("specific ETag unavailable")
|
|
.status_code(),
|
|
Some(http::StatusCode::FAILED_DEPENDENCY)
|
|
);
|
|
combined.remove(http::header::IF_MATCH);
|
|
combined.insert(
|
|
http::header::IF_UNMODIFIED_SINCE,
|
|
HeaderValue::from_static("Wed, 21 Oct 2015 07:28:00 GMT"),
|
|
);
|
|
assert_eq!(
|
|
odm_check_source_preconditions(&combined, &dated)
|
|
.expect_err("unmodified-since fails before none-match")
|
|
.code(),
|
|
&S3ErrorCode::PreconditionFailed
|
|
);
|
|
for header in [http::header::IF_MODIFIED_SINCE, http::header::IF_UNMODIFIED_SINCE] {
|
|
let err = odm_check_source_preconditions(&headers_with(header, "Wed, 21 Oct 2015 07:28:00 GMT"), &bare)
|
|
.expect_err("missing timestamp");
|
|
assert_eq!(err.status_code(), Some(http::StatusCode::FAILED_DEPENDENCY));
|
|
}
|
|
}
|
|
}
|