diff --git a/crates/ecstore/src/api/mod.rs b/crates/ecstore/src/api/mod.rs
index 0b902719d..31a34e37d 100644
--- a/crates/ecstore/src/api/mod.rs
+++ b/crates/ecstore/src/api/mod.rs
@@ -426,7 +426,7 @@ pub mod tier {
pub mod tier_config {
pub use crate::services::tier::tier_config::{
ServicePrincipalAuth, TierAliyun, TierAzure, TierConfig, TierGCS, TierHuaweicloud, TierMinIO, TierR2, TierRustFS,
- TierS3, TierTencent, TierType,
+ TierS3, TierTencent, TierType, TierWasabi,
};
}
diff --git a/crates/ecstore/src/client/api_error_response.rs b/crates/ecstore/src/client/api_error_response.rs
index 8cd576e90..f586113e3 100644
--- a/crates/ecstore/src/client/api_error_response.rs
+++ b/crates/ecstore/src/client/api_error_response.rs
@@ -103,7 +103,7 @@ pub fn http_resp_to_error_response(
object_name: &str,
) -> ErrorResponse {
let err_body = String::from_utf8_lossy(&b).to_string();
- if h.is_empty() || resp_status.is_client_error() || resp_status.is_server_error() {
+ if h.is_empty() || !(resp_status.is_client_error() || resp_status.is_server_error()) {
return ErrorResponse {
status_code: resp_status,
code: S3ErrorCode::ResponseInterrupted,
@@ -329,4 +329,21 @@ mod tests {
assert_eq!(value["RequestId"], Value::String("req-xml-123".to_string()));
assert!(value.get("request_id").is_none(), "external error contract must not expose request_id");
}
+
+ #[test]
+ fn parses_s3_error_code_from_client_error_response() {
+ let mut headers = HeaderMap::new();
+ headers.insert("x-amz-request-id", "request-id".parse().expect("request ID header should parse"));
+
+ let response = http_resp_to_error_response(
+ StatusCode::NOT_FOUND,
+ &headers,
+ b"NoSuchVersionremote detail".to_vec(),
+ "bucket",
+ "object",
+ );
+
+ assert_eq!(response.code, S3ErrorCode::NoSuchVersion);
+ assert_eq!(response.status_code, StatusCode::NOT_FOUND);
+ }
}
diff --git a/crates/ecstore/src/client/api_stat.rs b/crates/ecstore/src/client/api_stat.rs
index c8557b4ce..e68b21d5e 100644
--- a/crates/ecstore/src/client/api_stat.rs
+++ b/crates/ecstore/src/client/api_stat.rs
@@ -18,7 +18,7 @@
#![allow(unused_must_use)]
#![allow(clippy::all)]
-use http::{HeaderMap, HeaderValue};
+use http::{HeaderMap, HeaderValue, StatusCode};
use http_body_util::BodyExt;
use hyper::body::Body;
use hyper::body::Bytes;
@@ -31,13 +31,62 @@ use uuid::Uuid;
use crate::client::{
api_error_response::{ErrorResponse, err_invalid_argument, http_resp_to_error_response},
api_get_options::GetObjectOptions,
- transition_api::{ObjectInfo, ReadCloser, ReaderImpl, RequestMetadata, TransitionClient, to_object_info_for_provider},
+ transition_api::{
+ ObjectInfo, ReadCloser, ReaderImpl, RequestMetadata, TransitionClient, collect_response_body, to_object_info_for_provider,
+ },
};
use s3s::{
- dto::VersioningConfiguration,
+ dto::{BucketVersioningStatus, MFADelete, VersioningConfiguration},
header::{X_AMZ_DELETE_MARKER, X_AMZ_VERSION_ID},
};
+const S3_XML_NAMESPACE: &str = "http://s3.amazonaws.com/doc/2006-03-01/";
+
+#[derive(serde::Deserialize)]
+#[serde(deny_unknown_fields)]
+struct StrictVersioningConfiguration {
+ #[serde(rename = "@xmlns")]
+ namespace: Option,
+ #[serde(rename = "MfaDelete")]
+ mfa_delete: Option,
+ #[serde(rename = "Status")]
+ status: Option,
+}
+
+#[derive(serde::Deserialize)]
+enum StrictVersioningResponse {
+ VersioningConfiguration(StrictVersioningConfiguration),
+}
+
+fn parse_bucket_versioning_response(
+ status: StatusCode,
+ headers: &HeaderMap,
+ body: Vec,
+ bucket_name: &str,
+) -> Result {
+ if status != StatusCode::OK {
+ return Err(std::io::Error::other(http_resp_to_error_response(status, headers, body, bucket_name, "")));
+ }
+
+ let StrictVersioningResponse::VersioningConfiguration(parsed) =
+ quick_xml::de::from_reader(body.as_slice()).map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
+ if parsed
+ .namespace
+ .as_deref()
+ .is_some_and(|namespace| namespace != S3_XML_NAMESPACE)
+ {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ "unexpected GetBucketVersioning XML namespace",
+ ));
+ }
+ Ok(VersioningConfiguration {
+ mfa_delete: parsed.mfa_delete.map(MFADelete::from),
+ status: parsed.status.map(BucketVersioningStatus::from),
+ ..Default::default()
+ })
+}
+
impl TransitionClient {
pub async fn bucket_exists(&self, bucket_name: &str) -> Result {
let resp = self
@@ -123,19 +172,8 @@ impl TransitionClient {
let resp_status = resp.status();
let h = resp.headers().clone();
- let mut body_vec = Vec::new();
- let mut body = resp.into_body();
- while let Some(frame) = body.frame().await {
- let frame = frame.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
- if let Some(data) = frame.data_ref() {
- body_vec.extend_from_slice(data);
- }
- }
- let resperr = http_resp_to_error_response(resp_status, &h, body_vec, bucket_name, "");
-
- warn!("get bucket versioning, resperr: {:?}", resperr);
-
- Ok(VersioningConfiguration::default())
+ let body_vec = collect_response_body(resp.into_body(), rustfs_config::MAX_S3_CLIENT_RESPONSE_SIZE).await?;
+ parse_bucket_versioning_response(resp_status, &h, body_vec, bucket_name)
}
Err(err) => Err(std::io::Error::other(err)),
@@ -235,3 +273,72 @@ impl TransitionClient {
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::parse_bucket_versioning_response;
+ use http::{HeaderMap, StatusCode};
+ use s3s::dto::BucketVersioningStatus;
+
+ #[test]
+ fn parses_bucket_versioning_statuses_mfa_delete_and_unversioned_state() {
+ for (xml, expected) in [
+ (
+ br#"Enabled"#
+ .as_slice(),
+ Some(BucketVersioningStatus::ENABLED),
+ ),
+ (
+ br#"Suspended"#
+ .as_slice(),
+ Some(BucketVersioningStatus::SUSPENDED),
+ ),
+ (
+ br#""#.as_slice(),
+ None,
+ ),
+ ] {
+ let config = parse_bucket_versioning_response(StatusCode::OK, &HeaderMap::new(), xml.to_vec(), "tier-bucket")
+ .expect("valid GetBucketVersioning response should parse");
+ assert_eq!(config.status.as_ref().map(|status| status.as_str()), expected);
+ }
+
+ let config = parse_bucket_versioning_response(
+ StatusCode::OK,
+ &HeaderMap::new(),
+ br#"Enabled"#.to_vec(),
+ "tier-bucket",
+ )
+ .expect("valid MfaDelete should parse");
+ assert_eq!(config.mfa_delete.as_ref().map(|status| status.as_str()), Some("Enabled"));
+ }
+
+ #[test]
+ fn rejects_non_ok_and_malformed_bucket_versioning_responses() {
+ let valid = br#"Enabled"#.to_vec();
+ for status in [StatusCode::NO_CONTENT, StatusCode::PARTIAL_CONTENT] {
+ let status_err = parse_bucket_versioning_response(status, &HeaderMap::new(), valid.clone(), "tier-bucket")
+ .expect_err("GetBucketVersioning must return exactly HTTP 200");
+ assert_eq!(status_err.kind(), std::io::ErrorKind::Other);
+ }
+
+ let parse_err = parse_bucket_versioning_response(
+ StatusCode::OK,
+ &HeaderMap::new(),
+ b"".to_vec(),
+ "tier-bucket",
+ )
+ .expect_err("malformed GetBucketVersioning XML must fail closed");
+ assert_eq!(parse_err.kind(), std::io::ErrorKind::InvalidData);
+
+ for xml in [
+ b"Enabled".as_slice(),
+ b"Enabled".as_slice(),
+ b"".as_slice(),
+ ] {
+ let strict_err = parse_bucket_versioning_response(StatusCode::OK, &HeaderMap::new(), xml.to_vec(), "tier-bucket")
+ .expect_err("unknown GetBucketVersioning XML must fail closed");
+ assert_eq!(strict_err.kind(), std::io::ErrorKind::InvalidData);
+ }
+ }
+}
diff --git a/crates/ecstore/src/client/provider_versions.rs b/crates/ecstore/src/client/provider_versions.rs
index cad80ed4e..ddfff5910 100644
--- a/crates/ecstore/src/client/provider_versions.rs
+++ b/crates/ecstore/src/client/provider_versions.rs
@@ -60,6 +60,7 @@ impl ProviderVersionCapabilities {
|| 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")
{
Self {
raw_version_header: Some(X_AMZ_VERSION_ID),
@@ -156,6 +157,8 @@ mod tests {
("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"),
diff --git a/crates/ecstore/src/client/transition_api.rs b/crates/ecstore/src/client/transition_api.rs
index 75fbcb206..008b9769b 100644
--- a/crates/ecstore/src/client/transition_api.rs
+++ b/crates/ecstore/src/client/transition_api.rs
@@ -42,7 +42,7 @@ use http::{
request::{Builder, Request},
};
use http_body::Body;
-use http_body_util::BodyExt;
+use http_body_util::{BodyExt, LengthLimitError, Limited};
use hyper::body::Bytes;
use hyper::body::Incoming;
use hyper_rustls::{ConfigBuilderExt, HttpsConnector};
@@ -55,9 +55,7 @@ use rustfs_rio::HashReader;
use rustfs_utils::HashAlgorithm;
use rustfs_utils::{
net::get_endpoint_url,
- retry::{
- DEFAULT_RETRY_CAP, DEFAULT_RETRY_UNIT, MAX_JITTER, MAX_RETRY, RetryTimer, is_http_status_retryable, is_s3code_retryable,
- },
+ retry::{DEFAULT_RETRY_CAP, DEFAULT_RETRY_UNIT, MAX_JITTER, MAX_RETRY, RetryTimer},
};
use rustls_pki_types::PrivateKeyDer;
use rustls_pki_types::pem::PemObject;
@@ -82,9 +80,25 @@ use url::{Url, form_urlencoded};
use uuid::Uuid;
const C_USER_AGENT: &str = "RustFS (linux; x86)";
+pub(crate) const MAX_S3_ERROR_RESPONSE_SIZE: usize = 64 * 1024;
const SUCCESS_STATUS: [StatusCode; 3] = [StatusCode::OK, StatusCode::NO_CONTENT, StatusCode::PARTIAL_CONTENT];
+pub(crate) async fn collect_response_body(body: B, limit: usize) -> Result, std::io::Error>
+where
+ B: Body,
+ B::Error: Into>,
+{
+ let body = Limited::new(body, limit).collect().await.map_err(|err| {
+ if err.is::() {
+ std::io::Error::new(std::io::ErrorKind::InvalidData, "remote tier response body exceeds limit")
+ } else {
+ std::io::Error::other(err)
+ }
+ })?;
+ Ok(body.to_bytes().to_vec())
+}
+
const C_UNKNOWN: i32 = -1;
const C_OFFLINE: i32 = 0;
const C_ONLINE: i32 = 1;
@@ -397,20 +411,17 @@ impl TransitionClient {
pub async fn doit(&self, req: Request) -> Result, std::io::Error> {
let req_method;
let req_uri;
- let req_headers;
let resp;
let http_client = self.http_client.clone();
{
req_method = req.method().clone();
req_uri = req.uri().clone();
- req_headers = req.headers().clone();
debug!("endpoint_url: {}", self.endpoint_url.as_str().to_string());
resp = http_client.request(req);
}
let resp = resp.await;
debug!("http_client url: {} {}", req_method, req_uri);
- debug!("http_client headers: {:?}", req_headers);
if let Err(err) = resp {
error!("http_client call error: {:?}", err);
return Err(std::io::Error::other(err));
@@ -420,28 +431,23 @@ impl TransitionClient {
Ok(r) => r,
Err(_) => return Err(std::io::Error::other("Unexpected error in response")),
};
- debug!("http_resp: {:?}", resp);
+ debug!(status = %resp.status(), "remote tier response received");
//let b = resp.body_mut().store_all_unlimited().await.unwrap().to_vec();
//debug!("http_resp_body: {}", String::from_utf8(b).unwrap());
//if self.is_trace_enabled && !(self.trace_errors_only && resp.status() == StatusCode::OK) {
if !resp.status().is_success() {
- //self.dump_http(&cloned_req, &resp)?;
- let mut body_vec = Vec::new();
- let mut body = resp.into_body();
- while let Some(frame) = body.frame().await {
- let frame = frame.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
- if let Some(data) = frame.data_ref() {
- body_vec.extend_from_slice(data);
- }
- }
- let body_str = String::from_utf8_lossy(&body_vec);
- warn!("err_body: {}", body_str);
- Err(std::io::Error::other(format!("http_client call error: {}", body_str)))
- } else {
- Ok(resp)
+ let status = resp.status();
+ let request_id = resp
+ .headers()
+ .get("x-amz-request-id")
+ .and_then(|value| value.to_str().ok())
+ .unwrap_or_default()
+ .to_string();
+ warn!(status = %status, request_id, "remote tier request rejected");
}
+ Ok(resp)
}
pub async fn execute_method(
@@ -483,17 +489,22 @@ impl TransitionClient {
let resp_status = resp.status();
let h = resp.headers().clone();
- let mut body_vec = Vec::new();
- let mut body = resp.into_body();
- while let Some(frame) = body.frame().await {
- let frame = frame.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
- if let Some(data) = frame.data_ref() {
- body_vec.extend_from_slice(data);
- }
- }
- let mut err_response =
- http_resp_to_error_response(resp_status, &h, body_vec.clone(), &metadata.bucket_name, &metadata.object_name);
- err_response.message = format!("remote tier error: {}", err_response.message);
+ let body_vec = collect_response_body(resp.into_body(), MAX_S3_ERROR_RESPONSE_SIZE).await?;
+ let parsed_error =
+ http_resp_to_error_response(resp_status, &h, body_vec, &metadata.bucket_name, &metadata.object_name);
+ let routing_region = parsed_error.region;
+ let code = match parsed_error.code {
+ S3ErrorCode::Custom(_) => S3ErrorCode::ResponseInterrupted,
+ code => code,
+ };
+ let err_response = ErrorResponse {
+ status_code: resp_status,
+ message: format!("remote tier request failed with status {resp_status}: {}", code.as_str()),
+ code,
+ bucket_name: metadata.bucket_name.clone(),
+ key: metadata.object_name.clone(),
+ ..Default::default()
+ };
if self.region == "" {
return match err_response.code {
@@ -502,20 +513,20 @@ impl TransitionClient {
Err(std::io::Error::other(err_response))
}
S3ErrorCode::AccessDenied => {
- if err_response.region == "" {
+ if routing_region.is_empty() {
return Err(std::io::Error::other(err_response));
}
if metadata.bucket_name != "" {
if let Ok(mut bucket_loc_cache) = self.bucket_loc_cache.lock() {
if let Some(location) = bucket_loc_cache.get(&metadata.bucket_name) {
- if location != err_response.region {
- bucket_loc_cache.set(&metadata.bucket_name, &err_response.region);
+ if location != routing_region {
+ bucket_loc_cache.set(&metadata.bucket_name, &routing_region);
//continue;
}
}
}
- } else if err_response.region != metadata.bucket_location {
- metadata.bucket_location = err_response.region.clone();
+ } else if routing_region != metadata.bucket_location {
+ metadata.bucket_location = routing_region;
//continue;
}
Err(std::io::Error::other(err_response))
@@ -526,18 +537,10 @@ impl TransitionClient {
};
}
- if is_s3code_retryable(err_response.code.as_str()) {
- continue;
- }
-
- if is_http_status_retryable(&resp_status) {
- continue;
- }
-
- break;
+ return Err(std::io::Error::other(err_response));
}
- Err(std::io::Error::other("resp err"))
+ Err(std::io::Error::other("remote tier request did not produce a response"))
}
async fn new_request(
@@ -1408,13 +1411,43 @@ pub struct CreateBucketConfiguration {
#[cfg(test)]
mod tests {
use super::{
- SignatureType, build_tls_config, signer_error_to_io_error, to_object_info_for_provider, validate_header_values,
- with_rustls_init_guard,
+ MAX_S3_CLIENT_RESPONSE_SIZE, MAX_S3_ERROR_RESPONSE_SIZE, SignatureType, build_tls_config, collect_response_body,
+ signer_error_to_io_error, to_object_info_for_provider, validate_header_values, with_rustls_init_guard,
};
use crate::client::provider_versions::ProviderVersionCapabilities;
use http::{HeaderMap, HeaderValue};
+ use http_body_util::Full;
+ use hyper::body::Bytes;
use uuid::Uuid;
+ #[tokio::test]
+ async fn response_body_limit_rejects_the_first_byte_past_the_boundary() {
+ let exact = collect_response_body(
+ Full::new(Bytes::from(vec![0_u8; MAX_S3_CLIENT_RESPONSE_SIZE])),
+ MAX_S3_CLIENT_RESPONSE_SIZE,
+ )
+ .await
+ .expect("a response exactly at the limit should be accepted");
+ assert_eq!(exact.len(), MAX_S3_CLIENT_RESPONSE_SIZE);
+
+ let err = collect_response_body(
+ Full::new(Bytes::from(vec![0_u8; MAX_S3_CLIENT_RESPONSE_SIZE + 1])),
+ MAX_S3_CLIENT_RESPONSE_SIZE,
+ )
+ .await
+ .expect_err("oversized remote response must fail closed");
+
+ assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
+
+ let err = collect_response_body(
+ Full::new(Bytes::from(vec![0_u8; MAX_S3_ERROR_RESPONSE_SIZE + 1])),
+ MAX_S3_ERROR_RESPONSE_SIZE,
+ )
+ .await
+ .expect_err("oversized S3 error response must use the smaller error limit");
+ assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
+ }
+
#[test]
fn rustls_guard_converts_panics_to_io_errors() {
let err = with_rustls_init_guard(|| -> Result<(), std::io::Error> { panic!("missing provider") })
diff --git a/crates/ecstore/src/services/tier/mod.rs b/crates/ecstore/src/services/tier/mod.rs
index 15f975f99..3b501eb15 100644
--- a/crates/ecstore/src/services/tier/mod.rs
+++ b/crates/ecstore/src/services/tier/mod.rs
@@ -30,3 +30,4 @@ pub mod warm_backend_rustfs;
pub mod warm_backend_s3;
pub mod warm_backend_s3sdk;
pub mod warm_backend_tencent;
+pub mod warm_backend_wasabi;
diff --git a/crates/ecstore/src/services/tier/tier.rs b/crates/ecstore/src/services/tier/tier.rs
index 134e5b1d1..26bb97e85 100644
--- a/crates/ecstore/src/services/tier/tier.rs
+++ b/crates/ecstore/src/services/tier/tier.rs
@@ -51,7 +51,7 @@ use crate::client::admin_handler_utils::AdminError;
use crate::error::{Error, Result, StorageError};
use crate::services::tier::{
tier_admin::TierCreds,
- tier_config::{TierConfig, TierType},
+ tier_config::{TierConfig, TierType, TierWasabi},
tier_handlers::{ERR_TIER_ALREADY_EXISTS, ERR_TIER_NAME_NOT_UPPERCASE, ERR_TIER_NOT_FOUND, ERR_TIER_RESERVED_NAME},
warm_backend::{WarmBackend, check_warm_backend, new_warm_backend},
};
@@ -146,6 +146,8 @@ const EXTERNAL_TIER_TYPE_S3: i32 = 1;
const EXTERNAL_TIER_TYPE_AZURE: i32 = 2;
const EXTERNAL_TIER_TYPE_GCS: i32 = 3;
const EXTERNAL_TIER_TYPE_MINIO: i32 = 4;
+const EXTERNAL_TIER_VERSION_WASABI_V1: &str = "rustfs-wasabi-v1";
+const EXTERNAL_TIER_WASABI_ENDPOINT_SENTINEL: &str = "rustfs-wasabi-v1:";
const TIER_BACKEND_IDENTITY_VERSION: u8 = 2;
const _TIER_CFG_REFRESH_AT_HDR: &str = "X-RustFS-TierCfg-RefreshedAt";
@@ -484,6 +486,14 @@ fn replaced_tier_destinations(
for (tier_name, current_config) in ¤t.tiers {
let changed = match replacement.tiers.get(tier_name) {
Some(replacement_config) => {
+ if matches!(current_config.tier_type, TierType::Wasabi)
+ != matches!(replacement_config.tier_type, TierType::Wasabi)
+ {
+ let mut admin_err = ERR_TIER_INVALID_CONFIG.clone();
+ admin_err.message =
+ "Changing an existing remote tier to or from Wasabi is not supported; add a new tier instead".to_string();
+ return Err(admin_err);
+ }
let current_identity = tier_backend_identity(current_config).map_err(|err| {
let mut admin_err = ERR_TIER_INVALID_CONFIG.clone();
admin_err.message = err.to_string();
@@ -505,7 +515,43 @@ fn replaced_tier_destinations(
Ok(replaced)
}
+fn encode_tier_backend_identity(
+ tier_type: &str,
+ endpoint: &str,
+ bucket: &str,
+ prefix: &str,
+ region: &str,
+ routing_account: &str,
+) -> io::Result {
+ let encoded = rmp_serde::to_vec(&(
+ TIER_BACKEND_IDENTITY_VERSION,
+ tier_type,
+ endpoint,
+ bucket,
+ prefix,
+ region,
+ routing_account,
+ ))
+ .map_err(|err| io::Error::other(format!("serialize tier backend identity failed: {err}")))?;
+ Ok(Sha256::digest(encoded).into())
+}
+
fn tier_backend_identity(config: &TierConfig) -> io::Result {
+ if matches!(config.tier_type, TierType::Wasabi) {
+ let wasabi = config
+ .wasabi
+ .as_ref()
+ .ok_or_else(|| io::Error::other("tier backend identity payload is missing"))?;
+ return encode_tier_backend_identity(
+ "s3",
+ &wasabi.canonical_endpoint()?,
+ &wasabi.bucket,
+ normalized_tier_prefix(&config.tier_type, &wasabi.prefix),
+ &wasabi.region,
+ "",
+ );
+ }
+
let (tier_type, endpoint, bucket, prefix, region, routing_account) = match config.tier_type {
TierType::S3 => config.s3.as_ref().map(|value| {
(
@@ -517,6 +563,7 @@ fn tier_backend_identity(config: &TierConfig) -> io::Result {
"",
)
}),
+ TierType::Wasabi => None,
TierType::RustFS => config.rustfs.as_ref().map(|value| {
(
"rustfs",
@@ -601,17 +648,7 @@ fn tier_backend_identity(config: &TierConfig) -> io::Result {
}
.ok_or_else(|| io::Error::other("tier backend identity payload is missing"))?;
let prefix = normalized_tier_prefix(&config.tier_type, prefix);
- let encoded = rmp_serde::to_vec(&(
- TIER_BACKEND_IDENTITY_VERSION,
- tier_type,
- endpoint,
- bucket,
- prefix,
- region,
- routing_account,
- ))
- .map_err(|err| io::Error::other(format!("serialize tier backend identity failed: {err}")))?;
- Ok(Sha256::digest(encoded).into())
+ encode_tier_backend_identity(tier_type, endpoint, bucket, prefix, region, routing_account)
}
pub(crate) fn tier_destination_id_from_metadata(metadata: &HashMap) -> io::Result