mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-10 15:16:56 +00:00
ae11bcf2be
* feat(tiering): model provider version capabilities * feat(tiering): persist opaque remote versions * fix(tiering): use exact GCS generations * fix(tiering): gate remote version state safely * fix(tiering): gate remote version state writes * fix(tiering): preserve remote version state on delete * fix(tiering): accept unversioned transition responses * fix(tiering): replay exact cleanup journals * test(tiering): pin empty exact cleanup guard * test(tiering): accept strict missing journal errors * test(tiering): exercise free-version identity guard * test(tiering): reach destination identity guard * test(tiering): persist version identity drift * fix(tier): reconcile paginated remote versions * style(tier): format candidate validation test * test(tiering): bind version drift fixture --------- Co-authored-by: overtrue <anzhengchao@gmail.com>
355 lines
14 KiB
Rust
355 lines
14 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.
|
|
|
|
use std::io::{Error, ErrorKind};
|
|
|
|
use http::HeaderMap;
|
|
|
|
const X_AMZ_VERSION_ID: &str = "x-amz-version-id";
|
|
const X_OSS_VERSION_ID: &str = "x-oss-version-id";
|
|
const X_COS_VERSION_ID: &str = "x-cos-version-id";
|
|
const X_OBS_VERSION_ID: &str = "x-obs-version-id";
|
|
const MAX_REMOTE_VERSION_ID_LEN: usize = 1024;
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) enum BucketVersioningState {
|
|
Unknown,
|
|
Disabled,
|
|
Suspended,
|
|
Enabled,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub(crate) enum RemoteVersion {
|
|
Unknown,
|
|
Disabled,
|
|
SuspendedNull,
|
|
Exact(String),
|
|
}
|
|
|
|
impl RemoteVersion {
|
|
pub(crate) fn exact_id(&self) -> Option<&str> {
|
|
match self {
|
|
Self::SuspendedNull => Some("null"),
|
|
Self::Exact(version_id) => Some(version_id),
|
|
Self::Unknown | Self::Disabled => None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn exact_request_id(&self) -> Result<Option<&str>, Error> {
|
|
match self {
|
|
Self::Unknown => Err(Error::new(
|
|
ErrorKind::InvalidData,
|
|
"remote object version is unknown; exact version routing is unsafe",
|
|
)),
|
|
Self::Disabled => Ok(None),
|
|
Self::SuspendedNull => Ok(Some("null")),
|
|
Self::Exact(version_id) => Ok(Some(version_id)),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) enum ConditionalCreateCapability {
|
|
Unsupported,
|
|
IfNoneMatchStar,
|
|
GenerationMatchZero,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) struct ProviderVersionCapabilities {
|
|
raw_version_header: Option<&'static str>,
|
|
pub(crate) bucket_versioning_state: bool,
|
|
pub(crate) list_object_versions: bool,
|
|
pub(crate) conditional_create: ConditionalCreateCapability,
|
|
pub(crate) exact_get_delete: bool,
|
|
}
|
|
|
|
impl ProviderVersionCapabilities {
|
|
pub(crate) fn for_tier_type(tier_type: &str) -> Self {
|
|
if tier_type.eq_ignore_ascii_case("s3")
|
|
|| tier_type.eq_ignore_ascii_case("rustfs")
|
|
|| tier_type.eq_ignore_ascii_case("minio")
|
|
|| tier_type.eq_ignore_ascii_case("r2")
|
|
|| tier_type.eq_ignore_ascii_case("wasabi")
|
|
{
|
|
let list_object_versions = tier_type.eq_ignore_ascii_case("s3")
|
|
|| tier_type.eq_ignore_ascii_case("rustfs")
|
|
|| tier_type.eq_ignore_ascii_case("minio")
|
|
|| tier_type.eq_ignore_ascii_case("r2");
|
|
Self {
|
|
raw_version_header: Some(X_AMZ_VERSION_ID),
|
|
bucket_versioning_state: list_object_versions,
|
|
list_object_versions,
|
|
conditional_create: if tier_type.eq_ignore_ascii_case("s3") || tier_type.eq_ignore_ascii_case("r2") {
|
|
ConditionalCreateCapability::IfNoneMatchStar
|
|
} else {
|
|
ConditionalCreateCapability::Unsupported
|
|
},
|
|
exact_get_delete: true,
|
|
}
|
|
} else if tier_type.eq_ignore_ascii_case("aliyun") {
|
|
Self {
|
|
raw_version_header: Some(X_OSS_VERSION_ID),
|
|
bucket_versioning_state: false,
|
|
list_object_versions: false,
|
|
conditional_create: ConditionalCreateCapability::Unsupported,
|
|
exact_get_delete: true,
|
|
}
|
|
} else if tier_type.eq_ignore_ascii_case("tencent") {
|
|
Self {
|
|
raw_version_header: Some(X_COS_VERSION_ID),
|
|
bucket_versioning_state: false,
|
|
list_object_versions: false,
|
|
conditional_create: ConditionalCreateCapability::Unsupported,
|
|
exact_get_delete: true,
|
|
}
|
|
} else if tier_type.eq_ignore_ascii_case("huaweicloud") {
|
|
Self {
|
|
raw_version_header: Some(X_OBS_VERSION_ID),
|
|
bucket_versioning_state: false,
|
|
list_object_versions: false,
|
|
conditional_create: ConditionalCreateCapability::Unsupported,
|
|
exact_get_delete: true,
|
|
}
|
|
} else if tier_type.eq_ignore_ascii_case("gcs") {
|
|
Self {
|
|
raw_version_header: None,
|
|
bucket_versioning_state: false,
|
|
list_object_versions: false,
|
|
conditional_create: ConditionalCreateCapability::GenerationMatchZero,
|
|
exact_get_delete: false,
|
|
}
|
|
} else {
|
|
Self {
|
|
raw_version_header: None,
|
|
bucket_versioning_state: false,
|
|
list_object_versions: false,
|
|
conditional_create: ConditionalCreateCapability::Unsupported,
|
|
exact_get_delete: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn raw_version_id(self, headers: &HeaderMap) -> Result<Option<&str>, Error> {
|
|
let Some(header_name) = self.raw_version_header else {
|
|
return Ok(None);
|
|
};
|
|
let Some(value) = headers.get(header_name) else {
|
|
return Ok(None);
|
|
};
|
|
let value = value
|
|
.to_str()
|
|
.map_err(|_| Error::new(ErrorKind::InvalidData, "remote object version id is not valid ASCII"))?;
|
|
validate_remote_version_id(value)?;
|
|
Ok(Some(value))
|
|
}
|
|
|
|
pub(crate) fn remote_version(self, headers: &HeaderMap, versioning: BucketVersioningState) -> Result<RemoteVersion, Error> {
|
|
let Some(value) = self.raw_version_id(headers)? else {
|
|
return Ok(match versioning {
|
|
BucketVersioningState::Disabled => RemoteVersion::Disabled,
|
|
BucketVersioningState::Unknown | BucketVersioningState::Suspended | BucketVersioningState::Enabled => {
|
|
RemoteVersion::Unknown
|
|
}
|
|
});
|
|
};
|
|
if value == "null" {
|
|
return Ok(RemoteVersion::SuspendedNull);
|
|
}
|
|
Ok(RemoteVersion::Exact(value.to_string()))
|
|
}
|
|
}
|
|
|
|
pub(crate) fn validate_remote_version_id(version_id: &str) -> Result<(), Error> {
|
|
if version_id.is_empty() {
|
|
return Err(Error::new(
|
|
ErrorKind::InvalidData,
|
|
"remote tier returned an empty object version id header",
|
|
));
|
|
}
|
|
if version_id.len() > MAX_REMOTE_VERSION_ID_LEN {
|
|
return Err(Error::new(
|
|
ErrorKind::InvalidData,
|
|
"remote tier returned an oversized object version id header",
|
|
));
|
|
}
|
|
if version_id.chars().any(char::is_control) {
|
|
return Err(Error::new(
|
|
ErrorKind::InvalidData,
|
|
"remote tier returned an object version id containing control characters",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{BucketVersioningState, ConditionalCreateCapability, ProviderVersionCapabilities, RemoteVersion};
|
|
use http::{HeaderMap, HeaderValue};
|
|
|
|
#[test]
|
|
fn provider_version_header_matrix_preserves_opaque_versions() {
|
|
for (tier_type, header_name) in [
|
|
("s3", "x-amz-version-id"),
|
|
("S3", "x-amz-version-id"),
|
|
("rustfs", "x-amz-version-id"),
|
|
("RustFS", "x-amz-version-id"),
|
|
("minio", "x-amz-version-id"),
|
|
("MinIO", "x-amz-version-id"),
|
|
("r2", "x-amz-version-id"),
|
|
("R2", "x-amz-version-id"),
|
|
("wasabi", "x-amz-version-id"),
|
|
("Wasabi", "x-amz-version-id"),
|
|
("aliyun", "x-oss-version-id"),
|
|
("Aliyun", "x-oss-version-id"),
|
|
("tencent", "x-cos-version-id"),
|
|
("Tencent", "x-cos-version-id"),
|
|
("huaweicloud", "x-obs-version-id"),
|
|
("Huaweicloud", "x-obs-version-id"),
|
|
] {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(header_name, HeaderValue::from_static("opaque.version_01"));
|
|
let capabilities = ProviderVersionCapabilities::for_tier_type(tier_type);
|
|
|
|
assert_eq!(capabilities.raw_version_id(&headers).expect("raw version"), Some("opaque.version_01"));
|
|
assert_eq!(
|
|
capabilities
|
|
.remote_version(&headers, BucketVersioningState::Enabled)
|
|
.expect("remote version"),
|
|
RemoteVersion::Exact("opaque.version_01".to_string())
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn provider_version_header_matrix_does_not_cross_read_sibling_headers() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("x-amz-version-id", HeaderValue::from_static("aws-version"));
|
|
headers.insert("x-cos-version-id", HeaderValue::from_static("cos-version"));
|
|
|
|
assert_eq!(
|
|
ProviderVersionCapabilities::for_tier_type("s3")
|
|
.raw_version_id(&headers)
|
|
.expect("aws raw version"),
|
|
Some("aws-version")
|
|
);
|
|
assert_eq!(
|
|
ProviderVersionCapabilities::for_tier_type("tencent")
|
|
.raw_version_id(&headers)
|
|
.expect("cos raw version"),
|
|
Some("cos-version")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_version_missing_header_is_unknown_until_bucket_state_is_known() {
|
|
let headers = HeaderMap::new();
|
|
let capabilities = ProviderVersionCapabilities::for_tier_type("aliyun");
|
|
|
|
assert_eq!(
|
|
capabilities
|
|
.remote_version(&headers, BucketVersioningState::Unknown)
|
|
.expect("unknown versioning"),
|
|
RemoteVersion::Unknown
|
|
);
|
|
assert_eq!(
|
|
capabilities
|
|
.remote_version(&headers, BucketVersioningState::Disabled)
|
|
.expect("disabled versioning"),
|
|
RemoteVersion::Disabled
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_capability_matrix_is_conservative_and_provider_specific() {
|
|
for (tier_type, state, list, conditional_create, exact_get_delete) in [
|
|
("s3", true, true, ConditionalCreateCapability::IfNoneMatchStar, true),
|
|
("rustfs", true, true, ConditionalCreateCapability::Unsupported, true),
|
|
("minio", true, true, ConditionalCreateCapability::Unsupported, true),
|
|
("r2", true, true, ConditionalCreateCapability::IfNoneMatchStar, true),
|
|
("wasabi", false, false, ConditionalCreateCapability::Unsupported, true),
|
|
("aliyun", false, false, ConditionalCreateCapability::Unsupported, true),
|
|
("tencent", false, false, ConditionalCreateCapability::Unsupported, true),
|
|
("huaweicloud", false, false, ConditionalCreateCapability::Unsupported, true),
|
|
("gcs", false, false, ConditionalCreateCapability::GenerationMatchZero, false),
|
|
("azure", false, false, ConditionalCreateCapability::Unsupported, false),
|
|
("unsupported", false, false, ConditionalCreateCapability::Unsupported, false),
|
|
] {
|
|
let capabilities = ProviderVersionCapabilities::for_tier_type(tier_type);
|
|
assert_eq!(capabilities.bucket_versioning_state, state, "{tier_type} versioning state");
|
|
assert_eq!(capabilities.list_object_versions, list, "{tier_type} version listing");
|
|
assert_eq!(capabilities.conditional_create, conditional_create, "{tier_type} conditional create");
|
|
assert_eq!(capabilities.exact_get_delete, exact_get_delete, "{tier_type} exact routing");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn remote_version_states_preserve_unknown_disabled_suspended_and_exact() {
|
|
let capabilities = ProviderVersionCapabilities::for_tier_type("s3");
|
|
let empty = HeaderMap::new();
|
|
let mut null = HeaderMap::new();
|
|
null.insert("x-amz-version-id", HeaderValue::from_static("null"));
|
|
let mut exact = HeaderMap::new();
|
|
exact.insert("x-amz-version-id", HeaderValue::from_static("opaque.generation-7"));
|
|
|
|
for (headers, state, expected) in [
|
|
(&empty, BucketVersioningState::Unknown, RemoteVersion::Unknown),
|
|
(&empty, BucketVersioningState::Disabled, RemoteVersion::Disabled),
|
|
(&empty, BucketVersioningState::Suspended, RemoteVersion::Unknown),
|
|
(&empty, BucketVersioningState::Enabled, RemoteVersion::Unknown),
|
|
(&null, BucketVersioningState::Suspended, RemoteVersion::SuspendedNull),
|
|
(
|
|
&exact,
|
|
BucketVersioningState::Enabled,
|
|
RemoteVersion::Exact("opaque.generation-7".to_string()),
|
|
),
|
|
] {
|
|
assert_eq!(
|
|
capabilities
|
|
.remote_version(headers, state)
|
|
.expect("version state should normalize"),
|
|
expected
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn exact_request_routing_fails_closed_for_unknown_versions() {
|
|
for (version, expected) in [
|
|
(RemoteVersion::Disabled, None),
|
|
(RemoteVersion::SuspendedNull, Some("null")),
|
|
(RemoteVersion::Exact("opaque-v1".to_string()), Some("opaque-v1")),
|
|
] {
|
|
assert_eq!(version.exact_request_id().expect("known version state"), expected);
|
|
}
|
|
assert!(RemoteVersion::Unknown.exact_request_id().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn provider_version_rejects_empty_or_oversized_headers() {
|
|
let oversized = "v".repeat(1025);
|
|
for bad in ["", oversized.as_str()] {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("x-oss-version-id", HeaderValue::from_str(bad).expect("test header value"));
|
|
|
|
assert!(
|
|
ProviderVersionCapabilities::for_tier_type("aliyun")
|
|
.raw_version_id(&headers)
|
|
.is_err()
|
|
);
|
|
}
|
|
}
|
|
}
|