mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-29 16:37:07 +00:00
1005 lines
40 KiB
Rust
1005 lines
40 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::{
|
|
collections::HashMap,
|
|
io,
|
|
sync::atomic::{AtomicBool, Ordering},
|
|
};
|
|
|
|
use s3s::header::{X_AMZ_DELETE_MARKER, X_AMZ_VERSION_ID};
|
|
use uuid::Uuid;
|
|
|
|
use crate::{
|
|
client::transition_api::{BucketLookupType, ReadCloser, ReaderImpl},
|
|
services::tier::{
|
|
tier_config::{TierS3, TierWasabi},
|
|
warm_backend::{WarmBackend, WarmBackendGetOpts},
|
|
warm_backend_s3::WarmBackendS3,
|
|
},
|
|
};
|
|
|
|
const WASABI_VERSIONING_DRIFT_ERROR: &str = "Wasabi tier bucket versioning changed after configuration";
|
|
|
|
pub struct WarmBackendWasabi {
|
|
s3: WarmBackendS3,
|
|
versioning_checked: AtomicBool,
|
|
versioning_drifted: AtomicBool,
|
|
}
|
|
|
|
impl WarmBackendWasabi {
|
|
pub async fn new(conf: &TierWasabi, _tier: &str) -> io::Result<Self> {
|
|
let endpoint = conf.canonical_endpoint()?;
|
|
let s3 = TierS3 {
|
|
name: conf.name.clone(),
|
|
endpoint,
|
|
access_key: conf.access_key.clone(),
|
|
secret_key: conf.secret_key.clone(),
|
|
bucket: conf.bucket.clone(),
|
|
prefix: conf.prefix.clone(),
|
|
region: conf.region.clone(),
|
|
..Default::default()
|
|
};
|
|
let backend = WarmBackendS3::new_with_bucket_lookup(&s3, BucketLookupType::BucketLookupPath, "wasabi").await?;
|
|
Ok(Self {
|
|
s3: backend,
|
|
versioning_checked: AtomicBool::new(false),
|
|
versioning_drifted: AtomicBool::new(false),
|
|
})
|
|
}
|
|
|
|
fn drift_error() -> io::Error {
|
|
io::Error::other(WASABI_VERSIONING_DRIFT_ERROR)
|
|
}
|
|
|
|
fn normalized_remote_version(remote_version: &str) -> &str {
|
|
if remote_version.is_empty() || Uuid::parse_str(remote_version).is_ok_and(|version_id| version_id.is_nil()) {
|
|
""
|
|
} else {
|
|
remote_version
|
|
}
|
|
}
|
|
|
|
async fn check_remote_bucket_unversioned(&self) -> io::Result<()> {
|
|
if self.versioning_drifted.load(Ordering::Acquire) {
|
|
return Err(Self::drift_error());
|
|
}
|
|
let config = self.s3.client.get_bucket_versioning(&self.s3.bucket).await?;
|
|
if config.status.is_some() || config.mfa_delete.is_some() {
|
|
self.versioning_drifted.store(true, Ordering::Release);
|
|
return Err(io::Error::other("Wasabi tier bucket must be unversioned"));
|
|
}
|
|
self.versioning_checked.store(true, Ordering::Release);
|
|
if self.versioning_drifted.load(Ordering::Acquire) {
|
|
return Err(Self::drift_error());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn ensure_unversioned(&self) -> io::Result<()> {
|
|
if self.versioning_drifted.load(Ordering::Acquire) {
|
|
return Err(Self::drift_error());
|
|
}
|
|
if self.versioning_checked.load(Ordering::Acquire) {
|
|
return Ok(());
|
|
}
|
|
self.check_remote_bucket_unversioned().await
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl WarmBackend for WarmBackendWasabi {
|
|
async fn validate(&self) -> io::Result<()> {
|
|
self.check_remote_bucket_unversioned().await
|
|
}
|
|
|
|
fn validate_remote_version_id(&self, remote_version_id: &str) -> io::Result<()> {
|
|
if remote_version_id.is_empty() {
|
|
return Ok(());
|
|
}
|
|
self.versioning_drifted.store(true, Ordering::Release);
|
|
Err(io::Error::other(
|
|
"Wasabi returned an object version ID for a tier that requires an unversioned bucket",
|
|
))
|
|
}
|
|
|
|
async fn put(&self, object: &str, r: ReaderImpl, length: i64) -> io::Result<String> {
|
|
self.ensure_unversioned().await?;
|
|
self.s3.put(object, r, length).await
|
|
}
|
|
|
|
async fn put_with_meta(&self, object: &str, r: ReaderImpl, length: i64, meta: HashMap<String, String>) -> io::Result<String> {
|
|
self.ensure_unversioned().await?;
|
|
self.s3.put_with_meta(object, r, length, meta).await
|
|
}
|
|
|
|
async fn get(&self, object: &str, rv: &str, opts: WarmBackendGetOpts) -> io::Result<ReadCloser> {
|
|
let rv = Self::normalized_remote_version(rv);
|
|
if rv.is_empty() {
|
|
self.ensure_unversioned().await?;
|
|
}
|
|
let (headers, reader) = self.s3.get_with_headers(object, rv, opts).await?;
|
|
if rv.is_empty() && (headers.contains_key(X_AMZ_VERSION_ID) || headers.contains_key(X_AMZ_DELETE_MARKER)) {
|
|
self.versioning_drifted.store(true, Ordering::Release);
|
|
return Err(io::Error::other(
|
|
"Wasabi bucket versioning changed while reading an unversioned tier object",
|
|
));
|
|
}
|
|
Ok(reader)
|
|
}
|
|
|
|
async fn remove(&self, object: &str, rv: &str) -> io::Result<()> {
|
|
let rv = Self::normalized_remote_version(rv);
|
|
if !rv.is_empty() {
|
|
return self.s3.remove(object, rv).await;
|
|
}
|
|
self.ensure_unversioned().await?;
|
|
|
|
let result = self.s3.remove_with_result(object, rv).await?;
|
|
if result.delete_marker || !result.delete_marker_version_id.is_empty() {
|
|
self.versioning_drifted.store(true, Ordering::Release);
|
|
if !result.delete_marker_version_id.is_empty()
|
|
&& let Err(rollback_error) = self.s3.remove(object, &result.delete_marker_version_id).await
|
|
{
|
|
return Err(io::Error::other(format!(
|
|
"{WASABI_VERSIONING_DRIFT_ERROR}; failed to remove the unexpected delete marker: {rollback_error}"
|
|
)));
|
|
}
|
|
return Err(io::Error::other(WASABI_VERSIONING_DRIFT_ERROR));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn remove_exact(&self, object: &str, rv: &str) -> io::Result<()> {
|
|
if rv.is_empty() {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
"an exact Wasabi tier delete requires a remote version ID",
|
|
));
|
|
}
|
|
self.s3.remove(object, rv).await
|
|
}
|
|
|
|
async fn in_use(&self) -> io::Result<bool> {
|
|
self.check_remote_bucket_unversioned().await?;
|
|
let in_use = self.s3.in_use().await?;
|
|
if self.versioning_drifted.load(Ordering::Acquire) {
|
|
return Err(Self::drift_error());
|
|
}
|
|
Ok(in_use)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::client::{
|
|
credentials::{Credentials, SignatureType, Static, Value},
|
|
transition_api::{Options, TransitionCore},
|
|
};
|
|
use std::sync::{Arc, atomic::AtomicUsize};
|
|
use tokio::{
|
|
io::{AsyncReadExt, AsyncWriteExt},
|
|
net::TcpListener,
|
|
};
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct TestResponse {
|
|
status: &'static str,
|
|
headers: &'static str,
|
|
body: &'static str,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct CapturedRequest {
|
|
head: String,
|
|
body: Vec<u8>,
|
|
}
|
|
|
|
fn test_config() -> TierWasabi {
|
|
TierWasabi {
|
|
name: "WASABI".to_string(),
|
|
endpoint: String::new(),
|
|
access_key: "access-key".to_string(),
|
|
secret_key: "secret-key".to_string(),
|
|
bucket: "tier-bucket".to_string(),
|
|
prefix: "/archive/".to_string(),
|
|
region: "ap-northeast-1".to_string(),
|
|
}
|
|
}
|
|
|
|
async fn backend_for_endpoint(endpoint: &str, max_retries: i64) -> WarmBackendWasabi {
|
|
let client = Arc::new(
|
|
crate::client::transition_api::TransitionClient::new(
|
|
endpoint,
|
|
Options {
|
|
creds: Credentials::new(Static(Value {
|
|
access_key_id: "access-key".to_string(),
|
|
secret_access_key: "secret-key".to_string(),
|
|
signer_type: SignatureType::SignatureV4,
|
|
..Default::default()
|
|
})),
|
|
region: "us-east-1".to_string(),
|
|
bucket_lookup: BucketLookupType::BucketLookupPath,
|
|
max_retries,
|
|
..Default::default()
|
|
},
|
|
"wasabi",
|
|
)
|
|
.await
|
|
.expect("test transition client should initialize"),
|
|
);
|
|
WarmBackendWasabi {
|
|
s3: WarmBackendS3 {
|
|
core: TransitionCore(client.clone()),
|
|
client,
|
|
bucket: "tier-bucket".to_string(),
|
|
prefix: "archive".to_string(),
|
|
storage_class: String::new(),
|
|
},
|
|
versioning_checked: AtomicBool::new(true),
|
|
versioning_drifted: AtomicBool::new(false),
|
|
}
|
|
}
|
|
|
|
async fn backend_with_responses(
|
|
responses: Vec<TestResponse>,
|
|
) -> Option<(WarmBackendWasabi, tokio::task::JoinHandle<Vec<CapturedRequest>>)> {
|
|
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
|
Ok(listener) => listener,
|
|
Err(err) if err.kind() == io::ErrorKind::PermissionDenied => return None,
|
|
Err(err) => panic!("test listener should bind: {err}"),
|
|
};
|
|
let endpoint = listener
|
|
.local_addr()
|
|
.expect("test listener address should resolve")
|
|
.to_string();
|
|
let request_task = tokio::spawn(async move {
|
|
let mut requests = Vec::with_capacity(responses.len());
|
|
for response in responses {
|
|
let (mut stream, _) = listener.accept().await.expect("S3 request should connect");
|
|
let mut request = Vec::new();
|
|
let mut buffer = [0_u8; 1024];
|
|
loop {
|
|
let read = stream.read(&mut buffer).await.expect("S3 request should be readable");
|
|
assert_ne!(read, 0, "connection closed before request headers were received");
|
|
request.extend_from_slice(&buffer[..read]);
|
|
if request.windows(4).any(|window| window == b"\r\n\r\n") {
|
|
break;
|
|
}
|
|
}
|
|
let header_end = request
|
|
.windows(4)
|
|
.position(|window| window == b"\r\n\r\n")
|
|
.expect("request should contain a complete header")
|
|
+ 4;
|
|
let head = String::from_utf8(request[..header_end].to_vec()).expect("S3 request header should be UTF-8");
|
|
let content_length = head
|
|
.lines()
|
|
.filter_map(|line| line.split_once(':'))
|
|
.find_map(|(name, value)| {
|
|
name.eq_ignore_ascii_case("content-length")
|
|
.then(|| value.trim().parse::<usize>().expect("Content-Length should be numeric"))
|
|
})
|
|
.unwrap_or_default();
|
|
let request_end = header_end
|
|
.checked_add(content_length)
|
|
.expect("request length should not overflow");
|
|
while request.len() < request_end {
|
|
let remaining = request_end - request.len();
|
|
let read_len = remaining.min(buffer.len());
|
|
let read = stream
|
|
.read(&mut buffer[..read_len])
|
|
.await
|
|
.expect("S3 request body should be readable");
|
|
assert_ne!(read, 0, "connection closed before the complete request body was received");
|
|
request.extend_from_slice(&buffer[..read]);
|
|
}
|
|
assert_eq!(request.len(), request_end, "fixture must capture exactly one request body");
|
|
let wire_response = format!(
|
|
"HTTP/1.1 {}\r\nContent-Length: {}\r\n{}Connection: close\r\n\r\n{}",
|
|
response.status,
|
|
response.body.len(),
|
|
response.headers,
|
|
response.body
|
|
);
|
|
stream
|
|
.write_all(wire_response.as_bytes())
|
|
.await
|
|
.expect("S3 response should be writable");
|
|
requests.push(CapturedRequest {
|
|
head,
|
|
body: request[header_end..].to_vec(),
|
|
});
|
|
}
|
|
requests
|
|
});
|
|
let backend = backend_for_endpoint(&endpoint, 1).await;
|
|
Some((backend, request_task))
|
|
}
|
|
|
|
fn request_target(request: &CapturedRequest) -> &str {
|
|
request
|
|
.head
|
|
.lines()
|
|
.next()
|
|
.and_then(|line| line.split_whitespace().nth(1))
|
|
.expect("request should contain a target")
|
|
}
|
|
|
|
fn request_header<'a>(request: &'a CapturedRequest, name: &str) -> Option<&'a str> {
|
|
request
|
|
.head
|
|
.lines()
|
|
.filter_map(|line| line.split_once(':'))
|
|
.find_map(|(header, value)| header.eq_ignore_ascii_case(name).then(|| value.trim()))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn new_uses_canonical_path_style_sigv4_client() {
|
|
let backend = WarmBackendWasabi::new(&test_config(), "WASABI")
|
|
.await
|
|
.expect("Wasabi backend should initialize without network I/O");
|
|
let mut credentials = backend
|
|
.s3
|
|
.client
|
|
.creds_provider
|
|
.lock()
|
|
.expect("test credentials lock should not be poisoned");
|
|
let credentials = credentials.get().expect("static Wasabi credentials should resolve");
|
|
|
|
assert_eq!(backend.s3.client.lookup, BucketLookupType::BucketLookupPath);
|
|
assert_eq!(backend.s3.client.endpoint_url.scheme(), "https");
|
|
assert_eq!(backend.s3.client.endpoint_url.host_str(), Some("s3.ap-northeast-1.wasabisys.com"));
|
|
assert_eq!(backend.s3.client.tier_type, "wasabi");
|
|
assert_eq!(credentials.signer_type, SignatureType::SignatureV4);
|
|
assert_eq!(backend.s3.prefix, "archive");
|
|
assert!(!backend.versioning_checked.load(Ordering::Acquire));
|
|
assert!(!backend.versioning_drifted.load(Ordering::Acquire));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn validate_reads_remote_bucket_versioning_and_fails_closed() {
|
|
for (body, should_succeed) in [
|
|
(r#"<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"/>"#, true),
|
|
(
|
|
r#"<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>Enabled</Status></VersioningConfiguration>"#,
|
|
false,
|
|
),
|
|
(
|
|
r#"<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>Suspended</Status></VersioningConfiguration>"#,
|
|
false,
|
|
),
|
|
(
|
|
r#"<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>FutureStatus</Status></VersioningConfiguration>"#,
|
|
false,
|
|
),
|
|
(
|
|
r#"<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><MfaDelete>Enabled</MfaDelete></VersioningConfiguration>"#,
|
|
false,
|
|
),
|
|
] {
|
|
let Some((backend, request_task)) = backend_with_responses(vec![TestResponse {
|
|
status: "200 OK",
|
|
headers: "",
|
|
body,
|
|
}])
|
|
.await
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let result = backend.validate().await;
|
|
let requests = request_task.await.expect("versioning request task should finish");
|
|
|
|
assert_eq!(result.is_ok(), should_succeed, "unexpected validation result for {body}");
|
|
assert_eq!(requests.len(), 1);
|
|
let target = request_target(&requests[0]);
|
|
assert!(target.starts_with("/tier-bucket"), "{target}");
|
|
assert!(target.contains("versioning"), "{target}");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_remote_version_get_and_delete_omit_version_id() {
|
|
for remote_version in [String::new(), Uuid::nil().to_string()] {
|
|
let Some((backend, request_task)) = backend_with_responses(vec![
|
|
TestResponse {
|
|
status: "200 OK",
|
|
headers: "ETag: \"etag\"\r\n",
|
|
body: "remote body",
|
|
},
|
|
TestResponse {
|
|
status: "204 No Content",
|
|
headers: "",
|
|
body: "",
|
|
},
|
|
])
|
|
.await
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let mut reader = backend
|
|
.get("remote-object", &remote_version, WarmBackendGetOpts::default())
|
|
.await
|
|
.expect("unversioned Wasabi object should be readable");
|
|
let mut body = String::new();
|
|
reader
|
|
.read_to_string(&mut body)
|
|
.await
|
|
.expect("remote object body should be readable");
|
|
backend
|
|
.remove("remote-object", &remote_version)
|
|
.await
|
|
.expect("unversioned Wasabi object should be removable");
|
|
let requests = request_task.await.expect("GET and DELETE request task should finish");
|
|
|
|
assert_eq!(body, "remote body");
|
|
assert_eq!(requests.len(), 2);
|
|
assert_eq!(request_target(&requests[0]), "/tier-bucket/archive/remote-object");
|
|
assert_eq!(request_target(&requests[1]), "/tier-bucket/archive/remote-object");
|
|
assert!(requests[0].head.starts_with("GET "));
|
|
assert!(requests[1].head.starts_with("DELETE "));
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exact_nil_put_response_delete_preserves_version_id() {
|
|
let Some((backend, request_task)) = backend_with_responses(vec![TestResponse {
|
|
status: "204 No Content",
|
|
headers: "",
|
|
body: "",
|
|
}])
|
|
.await
|
|
else {
|
|
return;
|
|
};
|
|
let remote_version = Uuid::nil().to_string();
|
|
|
|
backend
|
|
.remove_exact("remote-object", &remote_version)
|
|
.await
|
|
.expect("an exact rejected PUT response should be removable");
|
|
let requests = request_task.await.expect("DELETE request task should finish");
|
|
|
|
assert_eq!(requests.len(), 1);
|
|
assert!(requests[0].head.starts_with("DELETE "));
|
|
assert_eq!(
|
|
request_target(&requests[0]),
|
|
format!("/tier-bucket/archive/remote-object?versionId={remote_version}")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_remote_version_get_rejects_versioned_response() {
|
|
// An empty `x-amz-version-id` is rejected one layer down by the shared remote-version
|
|
// validator, so it fails closed with that validator's message instead of the drift message.
|
|
for (response_headers, expected_error) in [
|
|
("ETag: \"etag\"\r\nx-amz-version-id: unexpected-version\r\n", "bucket versioning changed"),
|
|
("ETag: \"etag\"\r\nx-amz-version-id:\r\n", "empty object version id header"),
|
|
("ETag: \"etag\"\r\nx-amz-delete-marker: true\r\n", "bucket versioning changed"),
|
|
] {
|
|
let Some((backend, request_task)) = backend_with_responses(vec![TestResponse {
|
|
status: "200 OK",
|
|
headers: response_headers,
|
|
body: "newer body",
|
|
}])
|
|
.await
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let err = backend
|
|
.get("remote-object", "", WarmBackendGetOpts::default())
|
|
.await
|
|
.expect_err("a versioned GET response must not return a possibly newer object");
|
|
let requests = request_task.await.expect("GET request task should finish");
|
|
|
|
assert!(err.to_string().contains(expected_error), "{err}");
|
|
assert_eq!(requests.len(), 1);
|
|
assert!(requests[0].head.starts_with("GET "));
|
|
assert_eq!(request_target(&requests[0]), "/tier-bucket/archive/remote-object");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn nonempty_remote_version_get_and_delete_send_exact_version_id() {
|
|
let Some((backend, request_task)) = backend_with_responses(vec![
|
|
TestResponse {
|
|
status: "200 OK",
|
|
headers: "ETag: \"etag\"\r\n",
|
|
body: "legacy version body",
|
|
},
|
|
TestResponse {
|
|
status: "204 No Content",
|
|
headers: "",
|
|
body: "",
|
|
},
|
|
])
|
|
.await
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let mut reader = backend
|
|
.get("remote-object", "remote-version-1", WarmBackendGetOpts::default())
|
|
.await
|
|
.expect("legacy exact-version GET should succeed");
|
|
let mut body = String::new();
|
|
reader
|
|
.read_to_string(&mut body)
|
|
.await
|
|
.expect("legacy exact-version body should be readable");
|
|
backend
|
|
.remove("remote-object", "remote-version-1")
|
|
.await
|
|
.expect("versioned Wasabi object should use exact-version deletion");
|
|
let requests = request_task.await.expect("GET and DELETE request task should finish");
|
|
|
|
assert_eq!(body, "legacy version body");
|
|
assert_eq!(requests.len(), 2);
|
|
assert_eq!(
|
|
request_target(&requests[0]),
|
|
"/tier-bucket/archive/remote-object?versionId=remote-version-1"
|
|
);
|
|
assert_eq!(request_target(&requests[1]), request_target(&requests[0]));
|
|
assert!(requests[0].head.starts_with("GET "));
|
|
assert!(requests[1].head.starts_with("DELETE "));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exact_delete_preserves_remote_no_such_version_code() {
|
|
let Some((backend, request_task)) = backend_with_responses(vec![TestResponse {
|
|
status: "404 Not Found",
|
|
headers: "x-amz-request-id: request-id\r\n",
|
|
body: "<Error><Code>NoSuchVersion</Code><Message>The specified version does not exist.</Message></Error>",
|
|
}])
|
|
.await
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let err = backend
|
|
.remove("remote-object", "missing-version")
|
|
.await
|
|
.expect_err("a missing exact version should retain its S3 error code");
|
|
let requests = request_task.await.expect("DELETE request task should finish");
|
|
|
|
assert!(err.to_string().contains("NoSuchVersion"), "{err}");
|
|
assert_eq!(requests.len(), 1);
|
|
assert_eq!(
|
|
request_target(&requests[0]),
|
|
"/tier-bucket/archive/remote-object?versionId=missing-version"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn non_success_response_is_not_retried() {
|
|
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
|
Ok(listener) => listener,
|
|
Err(err) if err.kind() == io::ErrorKind::PermissionDenied => return,
|
|
Err(err) => panic!("test listener should bind: {err}"),
|
|
};
|
|
let endpoint = listener
|
|
.local_addr()
|
|
.expect("test listener address should resolve")
|
|
.to_string();
|
|
let request_count = Arc::new(AtomicUsize::new(0));
|
|
let server_count = request_count.clone();
|
|
let server = tokio::spawn(async move {
|
|
loop {
|
|
let Ok((mut stream, _)) = listener.accept().await else {
|
|
return;
|
|
};
|
|
server_count.fetch_add(1, Ordering::AcqRel);
|
|
let mut request = Vec::new();
|
|
let mut buffer = [0_u8; 1024];
|
|
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
|
|
let read = stream.read(&mut buffer).await.expect("request should be readable");
|
|
if read == 0 {
|
|
return;
|
|
}
|
|
request.extend_from_slice(&buffer[..read]);
|
|
}
|
|
stream
|
|
.write_all(
|
|
b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 41\r\nConnection: close\r\n\r\n<Error><Code>InternalError</Code></Error>",
|
|
)
|
|
.await
|
|
.expect("error response should be writable");
|
|
}
|
|
});
|
|
let backend = backend_for_endpoint(&endpoint, 4).await;
|
|
|
|
backend
|
|
.remove("remote-object", "exact-version")
|
|
.await
|
|
.expect_err("the first non-success response must be returned");
|
|
tokio::task::yield_now().await;
|
|
assert_eq!(request_count.load(Ordering::Acquire), 1);
|
|
server.abort();
|
|
let _ = server.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn production_error_response_enforces_the_small_body_limit() {
|
|
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
|
Ok(listener) => listener,
|
|
Err(err) if err.kind() == io::ErrorKind::PermissionDenied => return,
|
|
Err(err) => panic!("test listener should bind: {err}"),
|
|
};
|
|
let endpoint = listener
|
|
.local_addr()
|
|
.expect("test listener address should resolve")
|
|
.to_string();
|
|
let server = tokio::spawn(async move {
|
|
let (mut stream, _) = listener.accept().await.expect("S3 request should connect");
|
|
let mut request = Vec::new();
|
|
let mut buffer = [0_u8; 1024];
|
|
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
|
|
let read = stream.read(&mut buffer).await.expect("request should be readable");
|
|
assert_ne!(read, 0, "connection closed before request headers were received");
|
|
request.extend_from_slice(&buffer[..read]);
|
|
}
|
|
let body = vec![b'x'; crate::client::transition_api::MAX_S3_ERROR_RESPONSE_SIZE + 1];
|
|
let head = format!(
|
|
"HTTP/1.1 500 Internal Server Error\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
|
body.len()
|
|
);
|
|
stream
|
|
.write_all(head.as_bytes())
|
|
.await
|
|
.expect("response headers should be writable");
|
|
stream.write_all(&body).await.expect("response body should be writable");
|
|
});
|
|
let backend = backend_for_endpoint(&endpoint, 1).await;
|
|
|
|
let err = backend
|
|
.remove("remote-object", "exact-version")
|
|
.await
|
|
.expect_err("an oversized error response must fail closed");
|
|
server.await.expect("error response task should finish");
|
|
|
|
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remote_error_fields_are_sanitized() {
|
|
let Some((backend, request_task)) = backend_with_responses(vec![TestResponse {
|
|
status: "404 Not Found",
|
|
headers: "x-amz-request-id: request-id\r\nx-minio-error-code: secret-header-value\r\n",
|
|
body: "<Error><Code>NoSuchVersion</Code><Message>secret-body-message</Message><RequestId>secret-request</RequestId><HostId>secret-host</HostId></Error>",
|
|
}])
|
|
.await
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let err = backend
|
|
.remove("remote-object", "missing-version")
|
|
.await
|
|
.expect_err("the malicious remote error should remain an error");
|
|
let requests = request_task.await.expect("DELETE request task should finish");
|
|
let rendered = format!("{err} {err:?}");
|
|
|
|
assert!(rendered.contains("ResponseInterrupted"), "{rendered}");
|
|
for secret in ["secret-header-value", "secret-body-message", "secret-request", "secret-host"] {
|
|
assert!(!rendered.contains(secret), "remote error field leaked: {rendered}");
|
|
}
|
|
assert_eq!(requests.len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_version_delete_checks_the_real_s3_response_for_markers() {
|
|
for (response_headers, marker_version) in [
|
|
("x-amz-delete-marker: true\r\n", None),
|
|
("x-amz-version-id: marker-version\r\n", Some("marker-version")),
|
|
] {
|
|
let mut responses = vec![TestResponse {
|
|
status: "204 No Content",
|
|
headers: response_headers,
|
|
body: "",
|
|
}];
|
|
if marker_version.is_some() {
|
|
responses.push(TestResponse {
|
|
status: "204 No Content",
|
|
headers: "",
|
|
body: "",
|
|
});
|
|
}
|
|
responses.push(TestResponse {
|
|
status: "204 No Content",
|
|
headers: "",
|
|
body: "",
|
|
});
|
|
let Some((backend, request_task)) = backend_with_responses(responses).await else {
|
|
return;
|
|
};
|
|
let err = backend
|
|
.remove("remote-object", "")
|
|
.await
|
|
.expect_err("a delete-marker response must retain the local cleanup record");
|
|
backend
|
|
.remove("remote-object", "")
|
|
.await
|
|
.expect_err("a detected versioning drift must stop subsequent versionless deletes");
|
|
backend
|
|
.put("new-object", ReaderImpl::Body(hyper::body::Bytes::new()), 0)
|
|
.await
|
|
.expect_err("a detected versioning drift must stop subsequent uploads");
|
|
backend
|
|
.in_use()
|
|
.await
|
|
.expect_err("a detected versioning drift must make usage checks fail closed");
|
|
backend
|
|
.remove("remote-object", "legacy-version")
|
|
.await
|
|
.expect("exact cleanup must remain available after versioning drift");
|
|
let requests = request_task.await.expect("delete request task should finish");
|
|
|
|
assert!(err.to_string().contains("bucket versioning changed"), "{err}");
|
|
assert_eq!(requests.len(), 2 + usize::from(marker_version.is_some()));
|
|
assert!(requests[0].head.starts_with("DELETE "));
|
|
assert_eq!(request_target(&requests[0]), "/tier-bucket/archive/remote-object");
|
|
if let Some(marker_version) = marker_version {
|
|
assert_eq!(
|
|
request_target(&requests[1]),
|
|
format!("/tier-bucket/archive/remote-object?versionId={marker_version}")
|
|
);
|
|
}
|
|
assert_eq!(
|
|
request_target(requests.last().expect("exact cleanup request should exist")),
|
|
"/tier-bucket/archive/remote-object?versionId=legacy-version"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn in_use_delegates_to_the_path_style_s3_listing() {
|
|
let Some((backend, request_task)) = backend_with_responses(vec![
|
|
TestResponse {
|
|
status: "200 OK",
|
|
headers: "",
|
|
body: r#"<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"/>"#,
|
|
},
|
|
TestResponse {
|
|
status: "200 OK",
|
|
headers: "",
|
|
body: r#"<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"/>"#,
|
|
},
|
|
])
|
|
.await
|
|
else {
|
|
return;
|
|
};
|
|
|
|
assert!(!backend.in_use().await.expect("an empty Wasabi prefix should not be in use"));
|
|
let requests = request_task.await.expect("LIST request task should finish");
|
|
|
|
assert_eq!(requests.len(), 2);
|
|
assert!(request_target(&requests[0]).contains("versioning"));
|
|
assert!(requests[1].head.starts_with("GET "));
|
|
let target = request_target(&requests[1]);
|
|
// Path-style bucket-level listing addresses the bucket root, so the target keeps the
|
|
// trailing slash before the query string.
|
|
assert!(target.starts_with("/tier-bucket/?"), "{target}");
|
|
assert!(target.contains("list-type=2"), "{target}");
|
|
assert!(target.contains("prefix=archive"), "{target}");
|
|
assert!(target.contains("max-keys=1"), "{target}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remote_version_validation_rejects_every_nonempty_put_version() {
|
|
let backend = WarmBackendWasabi::new(&test_config(), "WASABI")
|
|
.await
|
|
.expect("Wasabi backend should initialize without network I/O");
|
|
|
|
assert!(backend.validate_remote_version_id("").is_ok());
|
|
assert!(backend.validate_remote_version_id(&Uuid::nil().to_string()).is_err());
|
|
assert!(backend.validate_remote_version_id("opaque-version").is_err());
|
|
assert!(
|
|
backend
|
|
.validate_remote_version_id("550e8400-e29b-41d4-a716-446655440000")
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rejected_put_version_latches_nonexact_operations() {
|
|
let Some((backend, request_task)) = backend_with_responses(vec![TestResponse {
|
|
status: "204 No Content",
|
|
headers: "",
|
|
body: "",
|
|
}])
|
|
.await
|
|
else {
|
|
return;
|
|
};
|
|
|
|
backend
|
|
.validate_remote_version_id("unexpected-version")
|
|
.expect_err("a real version ID must detect versioning drift");
|
|
backend
|
|
.put("new-object", ReaderImpl::Body(hyper::body::Bytes::new()), 0)
|
|
.await
|
|
.expect_err("drift must stop subsequent uploads");
|
|
backend
|
|
.get("remote-object", "", WarmBackendGetOpts::default())
|
|
.await
|
|
.expect_err("drift must stop versionless reads");
|
|
backend
|
|
.remove("remote-object", "")
|
|
.await
|
|
.expect_err("drift must stop versionless deletes");
|
|
backend.in_use().await.expect_err("drift must make usage checks fail closed");
|
|
backend
|
|
.remove("remote-object", "unexpected-version")
|
|
.await
|
|
.expect("exact cleanup must remain available after rejecting a PUT version");
|
|
let requests = request_task.await.expect("exact cleanup request should finish");
|
|
|
|
assert_eq!(requests.len(), 1);
|
|
assert_eq!(
|
|
request_target(&requests[0]),
|
|
"/tier-bucket/archive/remote-object?versionId=unexpected-version"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_fresh_backend_rechecks_versioning_before_an_empty_delete() {
|
|
let Some((backend, request_task)) = backend_with_responses(vec![TestResponse {
|
|
status: "200 OK",
|
|
headers: "",
|
|
body: r#"<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>Enabled</Status></VersioningConfiguration>"#,
|
|
}])
|
|
.await
|
|
else {
|
|
return;
|
|
};
|
|
backend.versioning_checked.store(false, Ordering::Release);
|
|
|
|
let err = backend
|
|
.remove("remote-object", "")
|
|
.await
|
|
.expect_err("a restarted backend must reject versionless deletion after versioning drift");
|
|
assert!(err.to_string().contains("must be unversioned"), "{err}");
|
|
let requests = request_task.await.expect("versioning request task should finish");
|
|
|
|
assert_eq!(requests.len(), 1);
|
|
assert!(requests[0].head.starts_with("GET "));
|
|
assert!(request_target(&requests[0]).contains("versioning"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_fresh_backend_rechecks_versioning_before_put_with_meta() {
|
|
let Some((backend, request_task)) = backend_with_responses(vec![TestResponse {
|
|
status: "200 OK",
|
|
headers: "",
|
|
body: r#"<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>Enabled</Status></VersioningConfiguration>"#,
|
|
}])
|
|
.await
|
|
else {
|
|
return;
|
|
};
|
|
backend.versioning_checked.store(false, Ordering::Release);
|
|
|
|
let err = backend
|
|
.put_with_meta("remote-object", ReaderImpl::Body(hyper::body::Bytes::new()), 0, HashMap::new())
|
|
.await
|
|
.expect_err("lifecycle PUT must reject a remotely versioned Wasabi bucket before upload");
|
|
assert!(err.to_string().contains("must be unversioned"), "{err}");
|
|
let requests = request_task.await.expect("versioning request task should finish");
|
|
|
|
assert_eq!(requests.len(), 1);
|
|
assert!(requests[0].head.starts_with("GET "));
|
|
assert!(request_target(&requests[0]).contains("versioning"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_marker_rollback_failure_latches_versioning_drift() {
|
|
let Some((backend, request_task)) = backend_with_responses(vec![
|
|
TestResponse {
|
|
status: "204 No Content",
|
|
headers: "x-amz-delete-marker: true\r\nx-amz-version-id: marker-version\r\n",
|
|
body: "",
|
|
},
|
|
TestResponse {
|
|
status: "500 Internal Server Error",
|
|
headers: "",
|
|
body: "<Error><Code>InternalError</Code></Error>",
|
|
},
|
|
])
|
|
.await
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let err = backend
|
|
.remove("remote-object", "")
|
|
.await
|
|
.expect_err("marker rollback failure must retain the cleanup record");
|
|
backend
|
|
.remove("remote-object", "")
|
|
.await
|
|
.expect_err("the drift latch must stop another versionless delete");
|
|
let requests = request_task.await.expect("delete requests should finish");
|
|
|
|
assert!(err.to_string().contains("failed to remove the unexpected delete marker"), "{err}");
|
|
assert_eq!(requests.len(), 2);
|
|
assert_eq!(request_target(&requests[0]), "/tier-bucket/archive/remote-object");
|
|
assert_eq!(
|
|
request_target(&requests[1]),
|
|
"/tier-bucket/archive/remote-object?versionId=marker-version"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn put_paths_send_complete_bodies_metadata_and_preserve_version_header() {
|
|
let Some((backend, request_task)) = backend_with_responses(vec![
|
|
TestResponse {
|
|
status: "200 OK",
|
|
headers: "ETag: \"etag-1\"\r\nx-amz-version-id: opaque.wasabi_01\r\n",
|
|
body: "",
|
|
},
|
|
TestResponse {
|
|
status: "200 OK",
|
|
headers: "ETag: \"etag-2\"\r\n",
|
|
body: "",
|
|
},
|
|
])
|
|
.await
|
|
else {
|
|
return;
|
|
};
|
|
let with_meta = vec![0x5a; 4096];
|
|
let plain = vec![0x3c; 3072];
|
|
let mut metadata = HashMap::new();
|
|
metadata.insert("source-etag".to_string(), "etag-value".to_string());
|
|
|
|
let version = backend
|
|
.put_with_meta(
|
|
"with-meta",
|
|
ReaderImpl::Body(hyper::body::Bytes::from(with_meta.clone())),
|
|
i64::try_from(with_meta.len()).expect("test body length should fit i64"),
|
|
metadata,
|
|
)
|
|
.await
|
|
.expect("Wasabi PUT with metadata should succeed");
|
|
let unversioned = backend
|
|
.put(
|
|
"plain",
|
|
ReaderImpl::Body(hyper::body::Bytes::from(plain.clone())),
|
|
i64::try_from(plain.len()).expect("test body length should fit i64"),
|
|
)
|
|
.await
|
|
.expect("unversioned Wasabi PUT should succeed");
|
|
let requests = request_task.await.expect("PUT request task should finish");
|
|
|
|
assert_eq!(version, "opaque.wasabi_01");
|
|
assert!(unversioned.is_empty());
|
|
assert_eq!(requests.len(), 2);
|
|
assert!(requests[0].head.starts_with("PUT "));
|
|
assert_eq!(request_target(&requests[0]), "/tier-bucket/archive/with-meta");
|
|
assert_eq!(requests[0].body, with_meta);
|
|
assert_eq!(request_header(&requests[0], "content-length"), Some("4096"));
|
|
assert_eq!(request_header(&requests[0], "x-amz-meta-source-etag"), Some("etag-value"));
|
|
assert!(requests[1].head.starts_with("PUT "));
|
|
assert_eq!(request_target(&requests[1]), "/tier-bucket/archive/plain");
|
|
assert_eq!(requests[1].body, plain);
|
|
assert_eq!(request_header(&requests[1], "content-length"), Some("3072"));
|
|
assert_eq!(request_header(&requests[1], "x-amz-meta-source-etag"), None);
|
|
}
|
|
}
|