mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-05 11:45:39 +00:00
feat(ecstore): add a native azure blob odm source backend
This commit is contained in:
Generated
+1
@@ -9703,6 +9703,7 @@ dependencies = [
|
||||
"path-absolutize",
|
||||
"pin-project-lite",
|
||||
"proptest",
|
||||
"quick-xml",
|
||||
"rand 0.10.2",
|
||||
"ratelimit",
|
||||
"rcgen",
|
||||
|
||||
@@ -215,6 +215,7 @@ serde_urlencoded.workspace = true
|
||||
google-cloud-storage = { workspace = true }
|
||||
google-cloud-auth = { workspace = true }
|
||||
faster-hex = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
ratelimit = { workspace = true }
|
||||
aws-smithy-http-client = { workspace = true, default-features = false, features = ["rustls-aws-lc"] }
|
||||
|
||||
|
||||
@@ -0,0 +1,982 @@
|
||||
// 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.
|
||||
|
||||
//! Native Azure Blob source backend.
|
||||
//!
|
||||
//! Azure has no S3 API, so this backend speaks the Blob REST service directly:
|
||||
//! Get Blob / Get Blob Properties for the read path, List Blobs for the listing,
|
||||
//! Get Blob Tags for the tags and Get Container Properties for the probe. The
|
||||
//! local key is the blob name inside the container named by `source.bucket`.
|
||||
//!
|
||||
//! Two authorization schemes are supported, matching the two forms an operator
|
||||
//! can hold: a storage-account key signed per request with Shared Key, and a SAS
|
||||
//! token appended to the request query.
|
||||
//!
|
||||
//! Azure's ETag is a concurrency token, not a digest of the bytes, so every head
|
||||
//! this backend produces is marked [`SourceHead::etag_is_opaque`]: the write-back
|
||||
//! path records the value for provenance and refuses to check content against it.
|
||||
//! `Content-MD5` is the only Azure digest, it is optional per blob, and it is not
|
||||
//! mapped onto the ETag slot precisely so that the two never get confused.
|
||||
//!
|
||||
//! The anti-loop `source-proxy-request` markers the S3 backend sends are omitted:
|
||||
//! they mean something only to a RustFS or MinIO source, and Azure would have to
|
||||
//! carry them through Shared Key canonicalization for no gain.
|
||||
|
||||
use super::native_http::{
|
||||
NativeHeadFields, NativeHttp, header, native_source_head, parse_http_timestamp, read_text, response_body,
|
||||
};
|
||||
use super::source_client::{
|
||||
AzureAuth, AzureSourceSpec, SourceBackend, SourceError, SourceGet, SourceHead, SourceListRequest, SourceObject, SourcePage,
|
||||
SourceTimeouts, range_header_value,
|
||||
};
|
||||
use crate::bucket::remote_s3_client::RemoteS3ClientError;
|
||||
use crate::storage_api_contracts::range::HTTPRangeSpec;
|
||||
use hmac::{Hmac, Mac, digest::KeyInit};
|
||||
use http::{HeaderMap, HeaderValue, Method};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::events::Event;
|
||||
use sha2::Sha256;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use url::Url;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Blob REST version this backend pins. Every response field it reads exists
|
||||
/// from this version on, including blob tags and blob versioning.
|
||||
const API_VERSION: &str = "2021-08-06";
|
||||
const HEADER_VERSION: &str = "x-ms-version";
|
||||
const HEADER_DATE: &str = "x-ms-date";
|
||||
const HEADER_ERROR_CODE: &str = "x-ms-error-code";
|
||||
const METADATA_PREFIX: &str = "x-ms-meta-";
|
||||
/// A List Blobs or Get Blob Tags response is small; refuse a source that
|
||||
/// streams an unbounded document at us instead of buffering it.
|
||||
const MAX_XML_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// `Sun, 06 Nov 1994 08:49:37 GMT`, the only `x-ms-date` form Azure accepts.
|
||||
const HTTP_DATE: &[time::format_description::BorrowedFormatItem<'static>] =
|
||||
time::macros::format_description!("[weekday repr:short], [day] [month repr:short] [year] [hour]:[minute]:[second] GMT");
|
||||
|
||||
enum Credential {
|
||||
/// Decoded storage-account key.
|
||||
SharedKey(Vec<u8>),
|
||||
/// SAS parameters, decoded once so re-encoding cannot double-escape them.
|
||||
Sas(Vec<(String, String)>),
|
||||
}
|
||||
|
||||
pub struct AzureSourceBackend {
|
||||
http: NativeHttp,
|
||||
account: String,
|
||||
container: String,
|
||||
credential: Credential,
|
||||
}
|
||||
|
||||
impl AzureSourceBackend {
|
||||
pub fn new(
|
||||
endpoint: &str,
|
||||
container: &str,
|
||||
spec: &AzureSourceSpec,
|
||||
timeouts: SourceTimeouts,
|
||||
skip_tls_verify: bool,
|
||||
ca_cert_pem: Option<&str>,
|
||||
) -> Result<Self, RemoteS3ClientError> {
|
||||
let credential = match &spec.auth {
|
||||
AzureAuth::SharedKey(key) => Credential::SharedKey(
|
||||
base64_simd::STANDARD
|
||||
.decode_to_vec(key.as_bytes())
|
||||
.map_err(|_| RemoteS3ClientError::Credentials("azure account key is not base64"))?,
|
||||
),
|
||||
AzureAuth::Sas(sas) => {
|
||||
let pairs: Vec<(String, String)> = url::form_urlencoded::parse(sas.trim_start_matches('?').as_bytes())
|
||||
.into_owned()
|
||||
.collect();
|
||||
if pairs.is_empty() {
|
||||
return Err(RemoteS3ClientError::Credentials("azure sas token has no parameters"));
|
||||
}
|
||||
Credential::Sas(pairs)
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
http: NativeHttp::new(endpoint, timeouts, skip_tls_verify, ca_cert_pem)?,
|
||||
account: spec.account.clone(),
|
||||
container: container.to_string(),
|
||||
credential,
|
||||
})
|
||||
}
|
||||
|
||||
/// URL of one blob in the container. The key is split so its `/` stay path
|
||||
/// separators while every other character is percent-encoded.
|
||||
fn blob_url(&self, key: &str) -> Result<Url, SourceError> {
|
||||
self.http.url(std::iter::once(self.container.as_str()).chain(key.split('/')))
|
||||
}
|
||||
|
||||
fn container_url(&self) -> Result<Url, SourceError> {
|
||||
self.http.url(std::iter::once(self.container.as_str()))
|
||||
}
|
||||
|
||||
/// Builds a signed (or SAS-carrying) request. `headers` holds the
|
||||
/// operation's own headers; the service headers and authorization are
|
||||
/// added here so every request is authorized the same way.
|
||||
fn request(&self, method: Method, mut url: Url, mut headers: HeaderMap) -> Result<reqwest::Request, SourceError> {
|
||||
headers.insert(HEADER_VERSION, HeaderValue::from_static(API_VERSION));
|
||||
let now = time::OffsetDateTime::now_utc()
|
||||
.format(HTTP_DATE)
|
||||
.map_err(|err| SourceError::Other(format!("cannot render the request date: {err}")))?;
|
||||
headers.insert(
|
||||
HEADER_DATE,
|
||||
HeaderValue::from_str(&now).map_err(|_| SourceError::Other("cannot render the request date".to_string()))?,
|
||||
);
|
||||
|
||||
match &self.credential {
|
||||
Credential::SharedKey(key) => {
|
||||
let signature = shared_key_signature(key, &self.account, method.as_str(), &url, &headers)?;
|
||||
headers.insert(
|
||||
http::header::AUTHORIZATION,
|
||||
HeaderValue::from_str(&format!("SharedKey {}:{signature}", self.account))
|
||||
.map_err(|_| SourceError::Other("cannot render the authorization header".to_string()))?,
|
||||
);
|
||||
}
|
||||
Credential::Sas(pairs) => {
|
||||
url.query_pairs_mut().extend_pairs(pairs.iter().map(|(k, v)| (k, v)));
|
||||
}
|
||||
}
|
||||
|
||||
let mut request = reqwest::Request::new(method, url);
|
||||
*request.headers_mut() = headers;
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
/// Shared mapping for Get Blob and Get Blob Properties.
|
||||
fn head_from_response(headers: &HeaderMap) -> Result<SourceHead, SourceError> {
|
||||
// A customer-provided key means the service holds ciphertext it cannot
|
||||
// decrypt for us; the same rule the S3 path applies to SSE-C.
|
||||
if header(headers, "x-ms-encryption-key-sha256").is_some() {
|
||||
return Err(SourceError::Unsupported(
|
||||
"source blob uses a customer-provided encryption key; customer-key sources are not supported".to_string(),
|
||||
));
|
||||
}
|
||||
native_source_head(
|
||||
headers,
|
||||
METADATA_PREFIX,
|
||||
NativeHeadFields {
|
||||
etag: header(headers, "etag").map(str::to_string),
|
||||
etag_is_opaque: true,
|
||||
version_id: header(headers, "x-ms-version-id").map(str::to_string),
|
||||
storage_class: header(headers, "x-ms-access-tier").map(str::to_string),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SourceBackend for AzureSourceBackend {
|
||||
async fn head(&self, key: &str) -> Result<SourceHead, SourceError> {
|
||||
let request = self.request(Method::HEAD, self.blob_url(key)?, HeaderMap::new())?;
|
||||
let response = self.http.send(request, HEADER_ERROR_CODE).await?;
|
||||
Self::head_from_response(response.headers())
|
||||
}
|
||||
|
||||
async fn get(&self, key: &str, range: Option<&HTTPRangeSpec>) -> Result<SourceGet, SourceError> {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(range) = range.map(range_header_value).transpose()? {
|
||||
headers.insert(
|
||||
http::header::RANGE,
|
||||
HeaderValue::from_str(&range).map_err(|_| SourceError::Other("invalid range header".to_string()))?,
|
||||
);
|
||||
}
|
||||
let request = self.request(Method::GET, self.blob_url(key)?, headers)?;
|
||||
let response = self.http.send(request, HEADER_ERROR_CODE).await?;
|
||||
let head = Self::head_from_response(response.headers())?;
|
||||
let content_range = header(response.headers(), "content-range").map(str::to_string);
|
||||
Ok(SourceGet {
|
||||
head,
|
||||
body: response_body(response),
|
||||
content_range,
|
||||
})
|
||||
}
|
||||
|
||||
async fn list(&self, request: &SourceListRequest<'_>) -> Result<SourcePage, SourceError> {
|
||||
// Azure paginates with an opaque marker and has no "start after this
|
||||
// key" form. Refuse rather than silently listing from the beginning.
|
||||
if request.start_after.is_some() {
|
||||
return Err(SourceError::Unsupported(
|
||||
"azure sources cannot resume a listing from a key; use the continuation token".to_string(),
|
||||
));
|
||||
}
|
||||
let mut url = self.container_url()?;
|
||||
{
|
||||
let mut query = url.query_pairs_mut();
|
||||
query.append_pair("restype", "container");
|
||||
query.append_pair("comp", "list");
|
||||
if let Some(prefix) = request.prefix.filter(|prefix| !prefix.is_empty()) {
|
||||
query.append_pair("prefix", prefix);
|
||||
}
|
||||
if let Some(delimiter) = request.delimiter.filter(|delimiter| !delimiter.is_empty()) {
|
||||
query.append_pair("delimiter", delimiter);
|
||||
}
|
||||
if let Some(marker) = request.continuation_token.filter(|marker| !marker.is_empty()) {
|
||||
query.append_pair("marker", marker);
|
||||
}
|
||||
if request.max_keys > 0 {
|
||||
query.append_pair("maxresults", &request.max_keys.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let request = self.request(Method::GET, url, HeaderMap::new())?;
|
||||
let response = self.http.send(request, HEADER_ERROR_CODE).await?;
|
||||
let body = read_text(response, MAX_XML_BYTES).await?;
|
||||
let listing = parse_list_blobs(&body)?;
|
||||
|
||||
Ok(SourcePage {
|
||||
objects: listing.objects,
|
||||
common_prefixes: listing.prefixes,
|
||||
is_truncated: listing.next_marker.is_some(),
|
||||
next_continuation_token: listing.next_marker,
|
||||
})
|
||||
}
|
||||
|
||||
async fn tagging(&self, key: &str) -> Result<HashMap<String, String>, SourceError> {
|
||||
let mut url = self.blob_url(key)?;
|
||||
url.query_pairs_mut().append_pair("comp", "tags");
|
||||
let request = self.request(Method::GET, url, HeaderMap::new())?;
|
||||
let response = self.http.send(request, HEADER_ERROR_CODE).await?;
|
||||
let body = read_text(response, MAX_XML_BYTES).await?;
|
||||
parse_blob_tags(&body)
|
||||
}
|
||||
|
||||
async fn probe(&self) -> Result<(), SourceError> {
|
||||
let mut url = self.container_url()?;
|
||||
url.query_pairs_mut().append_pair("restype", "container");
|
||||
let request = self.request(Method::HEAD, url, HeaderMap::new())?;
|
||||
self.http.send(request, HEADER_ERROR_CODE).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared Key signature over the canonical request. Only the fields this
|
||||
/// backend ever sets are non-empty: `Range`, the `x-ms-*` headers and the
|
||||
/// canonicalized resource. GET and HEAD carry no body, so every `Content-*`
|
||||
/// slot stays empty.
|
||||
fn shared_key_signature(key: &[u8], account: &str, method: &str, url: &Url, headers: &HeaderMap) -> Result<String, SourceError> {
|
||||
let mut string_to_sign = String::with_capacity(256);
|
||||
string_to_sign.push_str(method);
|
||||
string_to_sign.push('\n');
|
||||
// Content-Encoding, Content-Language, Content-Length, Content-MD5,
|
||||
// Content-Type, Date, If-Modified-Since, If-Match, If-None-Match,
|
||||
// If-Unmodified-Since: all empty. `Date` stays empty because `x-ms-date`
|
||||
// carries the timestamp and Azure then ignores this slot.
|
||||
for _ in 0..10 {
|
||||
string_to_sign.push('\n');
|
||||
}
|
||||
string_to_sign.push_str(header(headers, "range").unwrap_or_default());
|
||||
string_to_sign.push('\n');
|
||||
|
||||
// Canonicalized headers: every `x-ms-*` header, lowercased and sorted.
|
||||
let mut canonical_headers = BTreeMap::new();
|
||||
for (name, value) in headers {
|
||||
let name = name.as_str();
|
||||
if let Some(rest) = name.strip_prefix("x-ms-")
|
||||
&& !rest.is_empty()
|
||||
&& let Ok(value) = value.to_str()
|
||||
{
|
||||
canonical_headers.insert(name.to_string(), value.trim().to_string());
|
||||
}
|
||||
}
|
||||
for (name, value) in &canonical_headers {
|
||||
string_to_sign.push_str(name);
|
||||
string_to_sign.push(':');
|
||||
string_to_sign.push_str(value);
|
||||
string_to_sign.push('\n');
|
||||
}
|
||||
|
||||
// Canonicalized resource: the account, the encoded path, then every query
|
||||
// parameter lowercased and sorted, with repeated values joined by commas.
|
||||
string_to_sign.push('/');
|
||||
string_to_sign.push_str(account);
|
||||
string_to_sign.push_str(url.path());
|
||||
let mut canonical_query: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
for (name, value) in url.query_pairs() {
|
||||
canonical_query
|
||||
.entry(name.to_ascii_lowercase())
|
||||
.or_default()
|
||||
.push(value.into_owned());
|
||||
}
|
||||
for (name, mut values) in canonical_query {
|
||||
values.sort();
|
||||
string_to_sign.push('\n');
|
||||
string_to_sign.push_str(&name);
|
||||
string_to_sign.push(':');
|
||||
string_to_sign.push_str(&values.join(","));
|
||||
}
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(key)
|
||||
.map_err(|_| SourceError::Other("azure account key has an unusable length".to_string()))?;
|
||||
mac.update(string_to_sign.as_bytes());
|
||||
Ok(base64_simd::STANDARD.encode_to_string(mac.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AzureListing {
|
||||
objects: Vec<SourceObject>,
|
||||
prefixes: Vec<String>,
|
||||
/// `None` when the listing is complete; Azure marks the end with an empty
|
||||
/// `NextMarker`.
|
||||
next_marker: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BlobEntry {
|
||||
name: String,
|
||||
etag: Option<String>,
|
||||
size: u64,
|
||||
last_modified: Option<std::time::SystemTime>,
|
||||
access_tier: Option<String>,
|
||||
}
|
||||
|
||||
/// Parses one `List Blobs` page.
|
||||
fn parse_list_blobs(xml: &str) -> Result<AzureListing, SourceError> {
|
||||
let mut reader = xml_reader(xml);
|
||||
let mut objects = Vec::new();
|
||||
let mut prefixes = Vec::new();
|
||||
let mut next_marker = None;
|
||||
let mut blob: Option<BlobEntry> = None;
|
||||
let mut in_blob_prefix = false;
|
||||
// Open container elements. quick-xml reports a truncated document as a
|
||||
// plain end of input, so a non-zero depth at EOF is the only signal that
|
||||
// the page was cut short and must not be read as a complete listing.
|
||||
let mut depth = 0_usize;
|
||||
|
||||
loop {
|
||||
match reader.read_event() {
|
||||
Ok(Event::Start(start)) => {
|
||||
let name = local_name(start.name().as_ref());
|
||||
match name.as_str() {
|
||||
"blob" => {
|
||||
depth += 1;
|
||||
blob = Some(BlobEntry::default());
|
||||
}
|
||||
"blobprefix" => {
|
||||
depth += 1;
|
||||
in_blob_prefix = true;
|
||||
}
|
||||
"properties" | "blobs" | "enumerationresults" => depth += 1,
|
||||
_ => {
|
||||
let end = start.to_end().into_owned();
|
||||
let text = leaf_text(&mut reader, end.name())?;
|
||||
apply_list_field(&name, text, &mut blob, &mut prefixes, &mut next_marker, in_blob_prefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::Empty(empty)) => {
|
||||
let name = local_name(empty.name().as_ref());
|
||||
apply_list_field(&name, String::new(), &mut blob, &mut prefixes, &mut next_marker, in_blob_prefix);
|
||||
}
|
||||
Ok(Event::End(end)) => match local_name(end.name().as_ref()).as_str() {
|
||||
"blob" => {
|
||||
depth = depth.saturating_sub(1);
|
||||
if let Some(entry) = blob.take() {
|
||||
objects.push(SourceObject {
|
||||
key: entry.name,
|
||||
etag: entry.etag,
|
||||
size: entry.size,
|
||||
last_modified: entry.last_modified,
|
||||
storage_class: entry.access_tier,
|
||||
// Azure ETags carry no part count; the listing
|
||||
// never describes a composed object.
|
||||
is_multipart_etag: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
"blobprefix" => {
|
||||
depth = depth.saturating_sub(1);
|
||||
in_blob_prefix = false;
|
||||
}
|
||||
"properties" | "blobs" | "enumerationresults" => depth = depth.saturating_sub(1),
|
||||
_ => {}
|
||||
},
|
||||
Ok(Event::Eof) => break,
|
||||
Ok(_) => {}
|
||||
Err(err) => return Err(SourceError::Other(format!("source listing is not valid XML: {err}"))),
|
||||
}
|
||||
}
|
||||
if depth != 0 {
|
||||
return Err(SourceError::Other("source listing ended before every element was closed".to_string()));
|
||||
}
|
||||
|
||||
Ok(AzureListing {
|
||||
objects,
|
||||
prefixes,
|
||||
next_marker: next_marker.filter(|marker| !marker.is_empty()),
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_list_field(
|
||||
name: &str,
|
||||
text: String,
|
||||
blob: &mut Option<BlobEntry>,
|
||||
prefixes: &mut Vec<String>,
|
||||
next_marker: &mut Option<String>,
|
||||
in_blob_prefix: bool,
|
||||
) {
|
||||
match name {
|
||||
"name" => {
|
||||
if in_blob_prefix {
|
||||
prefixes.push(text);
|
||||
} else if let Some(entry) = blob.as_mut() {
|
||||
entry.name = text;
|
||||
}
|
||||
}
|
||||
"nextmarker" => *next_marker = Some(text),
|
||||
"etag" => {
|
||||
if let Some(entry) = blob.as_mut() {
|
||||
entry.etag = Some(text.trim().trim_matches('"').to_string()).filter(|etag| !etag.is_empty());
|
||||
}
|
||||
}
|
||||
"content-length" => {
|
||||
if let Some(entry) = blob.as_mut() {
|
||||
entry.size = text.trim().parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
"last-modified" => {
|
||||
if let Some(entry) = blob.as_mut() {
|
||||
entry.last_modified = parse_http_timestamp(text.trim());
|
||||
}
|
||||
}
|
||||
"accesstier" => {
|
||||
if let Some(entry) = blob.as_mut() {
|
||||
entry.access_tier = Some(text).filter(|tier| !tier.is_empty());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a `Get Blob Tags` response.
|
||||
fn parse_blob_tags(xml: &str) -> Result<HashMap<String, String>, SourceError> {
|
||||
let mut reader = xml_reader(xml);
|
||||
let mut tags = HashMap::new();
|
||||
let mut key = None;
|
||||
let mut value = None;
|
||||
let mut depth = 0_usize;
|
||||
|
||||
loop {
|
||||
match reader.read_event() {
|
||||
Ok(Event::Start(start)) => {
|
||||
let name = local_name(start.name().as_ref());
|
||||
match name.as_str() {
|
||||
"tags" | "tagset" | "tag" => depth += 1,
|
||||
_ => {
|
||||
let end = start.to_end().into_owned();
|
||||
let text = leaf_text(&mut reader, end.name())?;
|
||||
match name.as_str() {
|
||||
"key" => key = Some(text),
|
||||
"value" => value = Some(text),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::Empty(empty)) => match local_name(empty.name().as_ref()).as_str() {
|
||||
"key" => key = Some(String::new()),
|
||||
"value" => value = Some(String::new()),
|
||||
_ => {}
|
||||
},
|
||||
Ok(Event::End(end)) => {
|
||||
let name = local_name(end.name().as_ref());
|
||||
if matches!(name.as_str(), "tags" | "tagset" | "tag") {
|
||||
depth = depth.saturating_sub(1);
|
||||
}
|
||||
if name == "tag"
|
||||
&& let (Some(key), Some(value)) = (key.take(), value.take())
|
||||
{
|
||||
tags.insert(key, value);
|
||||
}
|
||||
}
|
||||
Ok(Event::Eof) => break,
|
||||
Ok(_) => {}
|
||||
Err(err) => return Err(SourceError::Other(format!("source tags are not valid XML: {err}"))),
|
||||
}
|
||||
}
|
||||
if depth != 0 {
|
||||
return Err(SourceError::Other("source tags ended before every element was closed".to_string()));
|
||||
}
|
||||
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
fn xml_reader(xml: &str) -> Reader<&[u8]> {
|
||||
let mut reader = Reader::from_str(xml);
|
||||
let config = reader.config_mut();
|
||||
config.trim_text_start = true;
|
||||
config.trim_text_end = true;
|
||||
reader
|
||||
}
|
||||
|
||||
/// Lowercased element name without its namespace prefix.
|
||||
fn local_name(raw: &str) -> String {
|
||||
raw.rsplit(':').next().unwrap_or(raw).to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// Text of a leaf element, consuming through its end tag.
|
||||
fn leaf_text(reader: &mut Reader<&[u8]>, end: quick_xml::name::QName<'_>) -> Result<String, SourceError> {
|
||||
let raw = reader
|
||||
.read_text(end)
|
||||
.map_err(|err| format!("source response is not valid XML: {err}"))
|
||||
.and_then(|text| {
|
||||
quick_xml::escape::unescape(text.as_ref())
|
||||
.map(|text| text.into_owned())
|
||||
.map_err(|err| format!("source response has invalid XML escapes: {err}"))
|
||||
});
|
||||
raw.map_err(SourceError::Other)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::bucket::on_demand_migration::source_client::SourceError;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
const LIST_PAGE: &str = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<EnumerationResults ServiceEndpoint="https://acct.blob.core.windows.net/" ContainerName="legacy">
|
||||
<Prefix>photos/</Prefix>
|
||||
<Delimiter>/</Delimiter>
|
||||
<MaxResults>2</MaxResults>
|
||||
<Blobs>
|
||||
<Blob>
|
||||
<Name>photos/a & b.jpg</Name>
|
||||
<Properties>
|
||||
<Last-Modified>Wed, 21 Oct 2015 07:28:00 GMT</Last-Modified>
|
||||
<Etag>0x8D2F1B0A1B2C3D4</Etag>
|
||||
<Content-Length>42</Content-Length>
|
||||
<Content-MD5>1B2M2Y8AsgTpgAmY7PhCfg==</Content-MD5>
|
||||
<BlobType>BlockBlob</BlobType>
|
||||
<AccessTier>Hot</AccessTier>
|
||||
</Properties>
|
||||
</Blob>
|
||||
<Blob>
|
||||
<Name>photos/b.jpg</Name>
|
||||
<Properties>
|
||||
<Content-Length>7</Content-Length>
|
||||
</Properties>
|
||||
</Blob>
|
||||
<BlobPrefix>
|
||||
<Name>photos/raw/</Name>
|
||||
</BlobPrefix>
|
||||
</Blobs>
|
||||
<NextMarker>2!76!MDAwMDI0</NextMarker>
|
||||
</EnumerationResults>"#;
|
||||
|
||||
const LAST_PAGE: &str = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<EnumerationResults><Blobs><Blob><Name>only.txt</Name><Properties><Content-Length>1</Content-Length></Properties></Blob></Blobs><NextMarker /></EnumerationResults>"#;
|
||||
|
||||
const TAGS: &str = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<Tags><TagSet>
|
||||
<Tag><Key>env</Key><Value>prod</Value></Tag>
|
||||
<Tag><Key>team</Key><Value>storage & co</Value></Tag>
|
||||
</TagSet></Tags>"#;
|
||||
|
||||
#[test]
|
||||
fn list_blobs_maps_entries_prefixes_and_the_marker() {
|
||||
let listing = parse_list_blobs(LIST_PAGE).expect("page should parse");
|
||||
assert_eq!(listing.prefixes, vec!["photos/raw/"]);
|
||||
assert_eq!(listing.next_marker.as_deref(), Some("2!76!MDAwMDI0"));
|
||||
assert_eq!(listing.objects.len(), 2);
|
||||
let first = &listing.objects[0];
|
||||
assert_eq!(first.key, "photos/a & b.jpg", "XML entities in a blob name are decoded");
|
||||
assert_eq!(first.etag.as_deref(), Some("0x8D2F1B0A1B2C3D4"));
|
||||
assert_eq!(first.size, 42);
|
||||
assert_eq!(
|
||||
first.last_modified,
|
||||
Some(std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_445_412_480))
|
||||
);
|
||||
assert_eq!(first.storage_class.as_deref(), Some("Hot"));
|
||||
assert!(!first.is_multipart_etag);
|
||||
assert_eq!(listing.objects[1].key, "photos/b.jpg");
|
||||
assert_eq!(listing.objects[1].size, 7);
|
||||
assert!(listing.objects[1].etag.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_next_marker_ends_the_listing() {
|
||||
let listing = parse_list_blobs(LAST_PAGE).expect("page should parse");
|
||||
assert_eq!(listing.objects.len(), 1);
|
||||
assert!(listing.next_marker.is_none(), "an empty NextMarker is not a cursor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_listing_xml_is_an_error() {
|
||||
for bad in [
|
||||
"<EnumerationResults><Blobs>",
|
||||
"<EnumerationResults><Blobs><Blob><Name>a</Name></Blobs></EnumerationResults>",
|
||||
"not xml at <all",
|
||||
] {
|
||||
let err = parse_list_blobs(bad).expect_err("{bad} must fail");
|
||||
assert!(matches!(err, SourceError::Other(_)), "{bad}: {err:?}");
|
||||
}
|
||||
assert!(parse_blob_tags("<Tags><TagSet>").is_err(), "a truncated tag set must fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_tags_parse_into_the_shared_tag_map() {
|
||||
let tags = parse_blob_tags(TAGS).expect("tags should parse");
|
||||
assert_eq!(
|
||||
tags,
|
||||
HashMap::from([
|
||||
("env".to_string(), "prod".to_string()),
|
||||
("team".to_string(), "storage & co".to_string())
|
||||
])
|
||||
);
|
||||
assert!(parse_blob_tags("<Tags><TagSet /></Tags>").expect("empty tag set").is_empty());
|
||||
}
|
||||
|
||||
/// Signature fixture from a request this backend really builds: it pins the
|
||||
/// canonical form so a change to the header set or the query canonicalization
|
||||
/// cannot silently start producing signatures Azure rejects.
|
||||
#[test]
|
||||
fn shared_key_signs_the_canonical_request() {
|
||||
let key = base64_simd::STANDARD.decode_to_vec(b"c2VjcmV0LWtleQ==").expect("test key");
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(HEADER_VERSION, HeaderValue::from_static(API_VERSION));
|
||||
headers.insert(HEADER_DATE, HeaderValue::from_static("Sun, 06 Nov 1994 08:49:37 GMT"));
|
||||
headers.insert(http::header::RANGE, HeaderValue::from_static("bytes=10-14"));
|
||||
let url = Url::parse("https://acct.blob.core.windows.net/legacy/photos/a.jpg").expect("url");
|
||||
|
||||
let signature = shared_key_signature(&key, "acct", "GET", &url, &headers).expect("signature");
|
||||
let expected = {
|
||||
let string_to_sign = concat!(
|
||||
"GET\n\n\n\n\n\n\n\n\n\n\n",
|
||||
"bytes=10-14\n",
|
||||
"x-ms-date:Sun, 06 Nov 1994 08:49:37 GMT\n",
|
||||
"x-ms-version:2021-08-06\n",
|
||||
"/acct/legacy/photos/a.jpg"
|
||||
);
|
||||
let mut mac = HmacSha256::new_from_slice(&key).expect("hmac");
|
||||
mac.update(string_to_sign.as_bytes());
|
||||
base64_simd::STANDARD.encode_to_string(mac.finalize().into_bytes())
|
||||
};
|
||||
assert_eq!(signature, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_key_canonicalizes_query_parameters() {
|
||||
let key = vec![1_u8; 32];
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(HEADER_VERSION, HeaderValue::from_static(API_VERSION));
|
||||
// Query order must not change the signature: Azure canonicalizes by
|
||||
// lowercased parameter name.
|
||||
let a = Url::parse("https://acct.blob.core.windows.net/legacy?restype=container&comp=list&prefix=p%2F").expect("url");
|
||||
let b = Url::parse("https://acct.blob.core.windows.net/legacy?prefix=p%2F&COMP=list&restype=container").expect("url");
|
||||
assert_eq!(
|
||||
shared_key_signature(&key, "acct", "GET", &a, &headers).expect("a"),
|
||||
shared_key_signature(&key, "acct", "GET", &b, &headers).expect("b")
|
||||
);
|
||||
}
|
||||
|
||||
fn backend(endpoint: &Url, credential: Credential) -> AzureSourceBackend {
|
||||
AzureSourceBackend {
|
||||
http: NativeHttp::for_test(endpoint.clone()),
|
||||
account: "acct".to_string(),
|
||||
container: "legacy".to_string(),
|
||||
credential,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Recorded {
|
||||
method: String,
|
||||
target: String,
|
||||
headers: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Recorded {
|
||||
fn header(&self, name: &str) -> Option<&str> {
|
||||
self.headers
|
||||
.iter()
|
||||
.find(|(key, _)| key.eq_ignore_ascii_case(name))
|
||||
.map(|(_, value)| value.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// One canned HTTP/1.1 response per connection. `Connection: close` keeps
|
||||
/// every request on its own socket so the script order is deterministic.
|
||||
async fn scripted_server(responses: Vec<(u16, Vec<(&'static str, String)>, String)>) -> (Url, Arc<Mutex<Vec<Recorded>>>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("fixture listener should bind");
|
||||
let port = listener.local_addr().expect("fixture address").port();
|
||||
let recorded: Arc<Mutex<Vec<Recorded>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = Arc::clone(&recorded);
|
||||
|
||||
tokio::spawn(async move {
|
||||
for (status, headers, body) in responses {
|
||||
let Ok((mut stream, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0_u8; 2048];
|
||||
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
match stream.read(&mut buffer).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(read) => request.extend_from_slice(&buffer[..read]),
|
||||
}
|
||||
}
|
||||
let text = String::from_utf8_lossy(&request).into_owned();
|
||||
let mut lines = text.lines();
|
||||
let start = lines.next().unwrap_or_default().to_string();
|
||||
let mut parts = start.split_whitespace();
|
||||
let recorded = Recorded {
|
||||
method: parts.next().unwrap_or_default().to_string(),
|
||||
target: parts.next().unwrap_or_default().to_string(),
|
||||
headers: lines
|
||||
.take_while(|line| !line.is_empty())
|
||||
.filter_map(|line| line.split_once(':'))
|
||||
.map(|(name, value)| (name.trim().to_string(), value.trim().to_string()))
|
||||
.collect(),
|
||||
};
|
||||
sink.lock().expect("recorder lock").push(recorded);
|
||||
|
||||
let mut response = format!("HTTP/1.1 {status} X\r\nContent-Length: {}\r\nConnection: close\r\n", body.len());
|
||||
for (name, value) in headers {
|
||||
response.push_str(&format!("{name}: {value}\r\n"));
|
||||
}
|
||||
response.push_str("\r\n");
|
||||
response.push_str(&body);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.flush().await;
|
||||
}
|
||||
});
|
||||
|
||||
(Url::parse(&format!("http://127.0.0.1:{port}")).expect("fixture endpoint"), recorded)
|
||||
}
|
||||
|
||||
fn blob_headers() -> Vec<(&'static str, String)> {
|
||||
vec![
|
||||
("ETag", "\"0x8D2F1B0A1B2C3D4\"".to_string()),
|
||||
("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT".to_string()),
|
||||
("Content-Type", "image/jpeg".to_string()),
|
||||
("Content-MD5", "1B2M2Y8AsgTpgAmY7PhCfg==".to_string()),
|
||||
("x-ms-meta-owner", "alice".to_string()),
|
||||
("x-ms-access-tier", "Cool".to_string()),
|
||||
("x-ms-version-id", "2026-01-01T00:00:00.0000000Z".to_string()),
|
||||
("x-ms-blob-type", "BlockBlob".to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn head_signs_the_request_and_maps_azure_metadata() {
|
||||
let (endpoint, recorded) = scripted_server(vec![(200, blob_headers(), String::new())]).await;
|
||||
let backend = backend(&endpoint, Credential::SharedKey(b"0123456789abcdef0123456789abcdef".to_vec()));
|
||||
|
||||
let head = backend.head("photos/a b.jpg").await.expect("HEAD should map");
|
||||
|
||||
let recorded = recorded.lock().expect("recorder lock").clone();
|
||||
assert_eq!(recorded.len(), 1);
|
||||
assert_eq!(recorded[0].method, "HEAD");
|
||||
assert_eq!(recorded[0].target, "/legacy/photos/a%20b.jpg", "the blob name is path-encoded");
|
||||
assert_eq!(recorded[0].header("x-ms-version"), Some(API_VERSION));
|
||||
assert!(recorded[0].header("x-ms-date").is_some(), "a signed request must carry x-ms-date");
|
||||
assert!(
|
||||
recorded[0]
|
||||
.header("authorization")
|
||||
.is_some_and(|value| value.starts_with("SharedKey acct:")),
|
||||
"{:?}",
|
||||
recorded[0].header("authorization")
|
||||
);
|
||||
|
||||
assert_eq!(head.etag.as_deref(), Some("0x8D2F1B0A1B2C3D4"));
|
||||
assert!(head.etag_is_opaque, "an Azure ETag is never a content digest");
|
||||
assert!(!head.is_multipart_etag);
|
||||
assert_eq!(head.size, 0);
|
||||
assert_eq!(head.content_type.as_deref(), Some("image/jpeg"));
|
||||
assert_eq!(head.storage_class.as_deref(), Some("Cool"));
|
||||
assert_eq!(head.version_id.as_deref(), Some("2026-01-01T00:00:00.0000000Z"));
|
||||
assert_eq!(head.user_metadata, HashMap::from([("owner".to_string(), "alice".to_string())]));
|
||||
assert!(head.sse.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sas_credentials_travel_in_the_query_and_never_sign() {
|
||||
let (endpoint, recorded) = scripted_server(vec![(200, blob_headers(), String::new())]).await;
|
||||
let backend = backend(
|
||||
&endpoint,
|
||||
Credential::Sas(vec![
|
||||
("sv".to_string(), "2021-08-06".to_string()),
|
||||
("sig".to_string(), "a+b/c=".to_string()),
|
||||
]),
|
||||
);
|
||||
|
||||
backend.head("a.txt").await.expect("HEAD should map");
|
||||
|
||||
let recorded = recorded.lock().expect("recorder lock").clone();
|
||||
assert!(recorded[0].header("authorization").is_none(), "a SAS request must not be signed");
|
||||
assert!(recorded[0].target.contains("sv=2021-08-06"), "{}", recorded[0].target);
|
||||
assert!(
|
||||
recorded[0].target.contains("sig=a%2Bb%2Fc%3D"),
|
||||
"the SAS signature must be re-encoded exactly once: {}",
|
||||
recorded[0].target
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_passes_the_range_through_and_streams_the_body() {
|
||||
let mut headers = blob_headers();
|
||||
headers.push(("Content-Range", "bytes 10-14/100".to_string()));
|
||||
let (endpoint, recorded) = scripted_server(vec![(206, headers, "hello".to_string())]).await;
|
||||
let backend = backend(&endpoint, Credential::SharedKey(vec![7_u8; 32]));
|
||||
|
||||
let range = HTTPRangeSpec {
|
||||
is_suffix_length: false,
|
||||
start: 10,
|
||||
end: 14,
|
||||
};
|
||||
let got = backend.get("a.txt", Some(&range)).await.expect("ranged GET should succeed");
|
||||
|
||||
let recorded = recorded.lock().expect("recorder lock").clone();
|
||||
assert_eq!(recorded[0].method, "GET");
|
||||
assert_eq!(recorded[0].header("range"), Some("bytes=10-14"));
|
||||
assert_eq!(got.content_range.as_deref(), Some("bytes 10-14/100"));
|
||||
assert_eq!(got.head.size, 5);
|
||||
let body = got.body.collect().await.expect("body should stream").into_bytes();
|
||||
assert_eq!(body.as_ref(), b"hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn customer_key_blobs_are_refused() {
|
||||
let mut headers = blob_headers();
|
||||
headers.push(("x-ms-encryption-key-sha256", "abc".to_string()));
|
||||
let (endpoint, _) = scripted_server(vec![(200, headers, String::new())]).await;
|
||||
let backend = backend(&endpoint, Credential::SharedKey(vec![7_u8; 32]));
|
||||
|
||||
let err = backend.head("a.txt").await.expect_err("customer-key blobs are unsupported");
|
||||
assert!(matches!(err, SourceError::Unsupported(_)), "{err:?}");
|
||||
assert_eq!(err.class_label(), "unsupported");
|
||||
assert!(!err.is_retryable());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_requests_the_container_and_pages_with_the_marker() {
|
||||
let (endpoint, recorded) = scripted_server(vec![
|
||||
(200, Vec::new(), LIST_PAGE.to_string()),
|
||||
(200, Vec::new(), LAST_PAGE.to_string()),
|
||||
])
|
||||
.await;
|
||||
let backend = backend(&endpoint, Credential::SharedKey(vec![7_u8; 32]));
|
||||
|
||||
let page = backend
|
||||
.list(&SourceListRequest {
|
||||
prefix: Some("photos/"),
|
||||
delimiter: Some("/"),
|
||||
max_keys: 2,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect("first page should list");
|
||||
assert!(page.is_truncated);
|
||||
assert_eq!(page.next_continuation_token.as_deref(), Some("2!76!MDAwMDI0"));
|
||||
assert_eq!(page.common_prefixes, vec!["photos/raw/"]);
|
||||
|
||||
let page = backend
|
||||
.list(&SourceListRequest {
|
||||
prefix: Some("photos/"),
|
||||
continuation_token: page.next_continuation_token.as_deref(),
|
||||
max_keys: 2,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect("second page should list");
|
||||
assert!(!page.is_truncated);
|
||||
assert!(page.next_continuation_token.is_none());
|
||||
|
||||
let recorded = recorded.lock().expect("recorder lock").clone();
|
||||
for request in &recorded {
|
||||
assert!(request.target.starts_with("/legacy?"), "{}", request.target);
|
||||
assert!(request.target.contains("restype=container"), "{}", request.target);
|
||||
assert!(request.target.contains("comp=list"), "{}", request.target);
|
||||
assert!(request.target.contains("prefix=photos%2F"), "{}", request.target);
|
||||
assert!(request.target.contains("maxresults=2"), "{}", request.target);
|
||||
}
|
||||
assert!(!recorded[0].target.contains("marker="), "{}", recorded[0].target);
|
||||
assert!(recorded[1].target.contains("marker=2%2176%21MDAwMDI0"), "{}", recorded[1].target);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_refuses_a_start_after_cursor_before_sending() {
|
||||
let (endpoint, recorded) = scripted_server(Vec::new()).await;
|
||||
let backend = backend(&endpoint, Credential::SharedKey(vec![7_u8; 32]));
|
||||
|
||||
let err = backend
|
||||
.list(&SourceListRequest {
|
||||
start_after: Some("a"),
|
||||
max_keys: 1,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect_err("azure has no start-after form");
|
||||
assert!(matches!(err, SourceError::Unsupported(_)), "{err:?}");
|
||||
assert!(
|
||||
recorded.lock().expect("recorder lock").is_empty(),
|
||||
"an unsupported request must never reach the source"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tagging_and_probe_address_the_right_resources() {
|
||||
let (endpoint, recorded) =
|
||||
scripted_server(vec![(200, Vec::new(), TAGS.to_string()), (200, Vec::new(), String::new())]).await;
|
||||
let backend = backend(&endpoint, Credential::SharedKey(vec![7_u8; 32]));
|
||||
|
||||
let tags = backend.tagging("a.txt").await.expect("tags should parse");
|
||||
assert_eq!(tags.get("env").map(String::as_str), Some("prod"));
|
||||
backend.probe().await.expect("probe should succeed");
|
||||
|
||||
let recorded = recorded.lock().expect("recorder lock").clone();
|
||||
assert_eq!(recorded[0].target, "/legacy/a.txt?comp=tags");
|
||||
assert_eq!(recorded[1].method, "HEAD");
|
||||
assert_eq!(recorded[1].target, "/legacy?restype=container");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn azure_statuses_map_onto_the_shared_error_classes() {
|
||||
for (status, code, expected, retryable) in [
|
||||
(404_u16, Some("BlobNotFound"), "not_found", false),
|
||||
(403, Some("AuthorizationPermissionMismatch"), "access_denied", false),
|
||||
(401, None, "access_denied", false),
|
||||
(429, None, "throttled", true),
|
||||
(503, Some("ServerBusy"), "throttled", true),
|
||||
(500, None, "server_error", true),
|
||||
] {
|
||||
let headers = code
|
||||
.map(|code| vec![(HEADER_ERROR_CODE, code.to_string())])
|
||||
.unwrap_or_default();
|
||||
let (endpoint, _) = scripted_server(vec![(status, headers, String::new())]).await;
|
||||
let backend = backend(&endpoint, Credential::SharedKey(vec![7_u8; 32]));
|
||||
let err = backend.head("a.txt").await.expect_err("{status} must fail");
|
||||
assert_eq!(err.class_label(), expected, "status {status} -> {err:?}");
|
||||
assert_eq!(err.is_retryable(), retryable, "status {status} -> {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transport_failures_never_render_the_request_url() {
|
||||
// Nothing is listening on the reserved port, so the connect fails and
|
||||
// the error must not carry the SAS-bearing URL.
|
||||
let backend = backend(
|
||||
&Url::parse("http://127.0.0.1:1").expect("endpoint"),
|
||||
Credential::Sas(vec![("sig".to_string(), "top-secret-signature".to_string())]),
|
||||
);
|
||||
let err = backend.head("a.txt").await.expect_err("a closed port must fail");
|
||||
let rendered = err.to_string();
|
||||
assert!(!rendered.contains("top-secret-signature"), "{rendered}");
|
||||
assert!(!rendered.contains("127.0.0.1"), "{rendered}");
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,10 @@ pub const ON_DEMAND_MIGRATION_CONFIG_VERSION: u32 = 1;
|
||||
const REDACTED: &str = "REDACTED";
|
||||
const AUTO_REGION: &str = "auto";
|
||||
const AUTO_REGION_FALLBACK: &str = "us-east-1";
|
||||
/// Public Azure Blob host suffix; the account name is the first label.
|
||||
pub const AZURE_BLOB_SUFFIX: &str = "blob.core.windows.net";
|
||||
/// Public Google Cloud Storage endpoint for the native provider.
|
||||
pub const GCS_DEFAULT_ENDPOINT: &str = "https://storage.googleapis.com";
|
||||
|
||||
const KIB: u64 = 1024;
|
||||
const MIB: u64 = 1024 * KIB;
|
||||
@@ -75,14 +79,25 @@ pub struct SourceConfig {
|
||||
pub bucket: String,
|
||||
#[serde(default)]
|
||||
pub path_style: PathStyle,
|
||||
/// `None` means anonymous access to a public source bucket.
|
||||
/// `None` means anonymous access to a public source bucket. Only the
|
||||
/// SigV4 providers read it; `azure` and `gcs_native` carry their own
|
||||
/// credentials in `azure` / `gcs`.
|
||||
#[serde(default)]
|
||||
pub credentials: Option<SourceCredentials>,
|
||||
#[serde(default)]
|
||||
pub tls: TlsConfig,
|
||||
/// Required for [`Provider::Azure`] and rejected for every other
|
||||
/// provider.
|
||||
#[serde(default)]
|
||||
pub azure: Option<AzureSourceConfig>,
|
||||
/// Required for [`Provider::GcsNative`] and rejected for every other
|
||||
/// provider. [`Provider::Gcs`] keeps using `credentials` because it
|
||||
/// speaks the S3 interoperability API.
|
||||
#[serde(default)]
|
||||
pub gcs: Option<GcsSourceConfig>,
|
||||
}
|
||||
|
||||
/// Source vendor family. `azure` is deliberately absent from this version.
|
||||
/// Source vendor family.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Provider {
|
||||
@@ -94,6 +109,12 @@ pub enum Provider {
|
||||
R2,
|
||||
/// GCS XML interoperability API with HMAC keys.
|
||||
Gcs,
|
||||
/// Native Azure Blob service; parameters in `source.azure`.
|
||||
Azure,
|
||||
/// Native GCS JSON API with a service-account key; parameters in
|
||||
/// `source.gcs`.
|
||||
#[serde(rename = "gcs_native")]
|
||||
GcsNative,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
@@ -105,13 +126,22 @@ impl Provider {
|
||||
Provider::Rustfs => "rustfs",
|
||||
Provider::R2 => "r2",
|
||||
Provider::Gcs => "gcs",
|
||||
Provider::Azure => "azure",
|
||||
Provider::GcsNative => "gcs_native",
|
||||
}
|
||||
}
|
||||
|
||||
/// Providers that do not speak S3 and therefore ignore `region`,
|
||||
/// `path_style` and `credentials`.
|
||||
pub fn is_native(&self) -> bool {
|
||||
matches!(self, Provider::Azure | Provider::GcsNative)
|
||||
}
|
||||
|
||||
/// Providers whose SDKs accept `region = "auto"`; RustFS maps it to
|
||||
/// `us-east-1` for signing.
|
||||
/// `us-east-1` for signing. The native providers never sign with a
|
||||
/// region, so they accept it as well.
|
||||
fn accepts_auto_region(&self) -> bool {
|
||||
matches!(self, Provider::R2 | Provider::Minio | Provider::Rustfs)
|
||||
matches!(self, Provider::R2 | Provider::Minio | Provider::Rustfs) || self.is_native()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +194,73 @@ impl fmt::Debug for SourceCredentials {
|
||||
}
|
||||
}
|
||||
|
||||
/// Native Azure Blob source parameters. The container is `source.bucket`,
|
||||
/// so a config never carries two names for the same container. Exactly one
|
||||
/// of `account_key` and `sas_token` must be set: the account key signs with
|
||||
/// Shared Key, the SAS token is appended to every request URL.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AzureSourceConfig {
|
||||
/// Storage account name; also derives the default `blob.core.windows.net`
|
||||
/// endpoint when `source.endpoint` is absent.
|
||||
pub account: String,
|
||||
/// Base64 shared key of the storage account.
|
||||
#[serde(default)]
|
||||
pub account_key: Option<String>,
|
||||
/// SAS query string without the leading `?`.
|
||||
#[serde(default)]
|
||||
pub sas_token: Option<String>,
|
||||
}
|
||||
|
||||
impl AzureSourceConfig {
|
||||
/// A copy safe to return to admin clients or log: both secrets are
|
||||
/// replaced by `REDACTED`, and whether each is set stays visible.
|
||||
pub fn redacted(&self) -> Self {
|
||||
Self {
|
||||
account: self.account.clone(),
|
||||
account_key: self.account_key.as_ref().map(|_| REDACTED.to_string()),
|
||||
sas_token: self.sas_token.as_ref().map(|_| REDACTED.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for AzureSourceConfig {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("AzureSourceConfig")
|
||||
.field("account", &self.account)
|
||||
.field("account_key", &self.account_key.as_ref().map(|_| REDACTED))
|
||||
.field("sas_token", &self.sas_token.as_ref().map(|_| REDACTED))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Native Google Cloud Storage source parameters. The bucket is
|
||||
/// `source.bucket`; only the service-account key lives here.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GcsSourceConfig {
|
||||
/// Service-account key JSON, verbatim as downloaded from Google Cloud.
|
||||
pub service_account_json: String,
|
||||
}
|
||||
|
||||
impl GcsSourceConfig {
|
||||
/// A copy safe to return to admin clients or log: the whole key JSON is
|
||||
/// a secret (it embeds the private key), so it is replaced wholesale.
|
||||
pub fn redacted(&self) -> Self {
|
||||
Self {
|
||||
service_account_json: REDACTED.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for GcsSourceConfig {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("GcsSourceConfig")
|
||||
.field("service_account_json", &REDACTED)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TlsConfig {
|
||||
@@ -354,6 +451,14 @@ pub enum OnDemandMigrationConfigError {
|
||||
InvalidBucket(&'static str),
|
||||
#[error("source credentials field {0} must not be empty")]
|
||||
EmptyCredential(&'static str),
|
||||
#[error("source.{0} is required for provider {1}")]
|
||||
MissingProviderBlock(&'static str, Provider),
|
||||
#[error("source.{0} is not valid for provider {1}")]
|
||||
UnexpectedProviderBlock(&'static str, Provider),
|
||||
/// Carries only the reason: the block holds account keys, SAS tokens and
|
||||
/// service-account JSON, so no value of it is ever echoed.
|
||||
#[error("source.{0} is invalid: {1}")]
|
||||
InvalidProviderBlock(&'static str, &'static str),
|
||||
#[error("source tls.ca_cert_pem is not a PEM certificate")]
|
||||
InvalidCaCert,
|
||||
#[error("filter.{0} must be null or a non-empty string")]
|
||||
@@ -388,6 +493,8 @@ impl OnDemandMigrationConfig {
|
||||
pub fn redacted(&self) -> Self {
|
||||
let mut copy = self.clone();
|
||||
copy.source.credentials = self.source.credentials.as_ref().map(SourceCredentials::redacted);
|
||||
copy.source.azure = self.source.azure.as_ref().map(AzureSourceConfig::redacted);
|
||||
copy.source.gcs = self.source.gcs.as_ref().map(GcsSourceConfig::redacted);
|
||||
copy
|
||||
}
|
||||
|
||||
@@ -433,6 +540,12 @@ impl SourceConfig {
|
||||
match (&self.endpoint, self.provider) {
|
||||
(Some(endpoint), _) => endpoint.clone(),
|
||||
(None, Provider::Aws) => format!("https://s3.{}.amazonaws.com", self.region),
|
||||
(None, Provider::Azure) => self
|
||||
.azure
|
||||
.as_ref()
|
||||
.map(|azure| format!("https://{}.{AZURE_BLOB_SUFFIX}", azure.account))
|
||||
.unwrap_or_default(),
|
||||
(None, Provider::GcsNative) => GCS_DEFAULT_ENDPOINT.to_string(),
|
||||
(None, _) => String::new(),
|
||||
}
|
||||
}
|
||||
@@ -448,6 +561,8 @@ impl SourceConfig {
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), OnDemandMigrationConfigError> {
|
||||
self.validate_provider_block()?;
|
||||
|
||||
if self.region.is_empty() {
|
||||
return Err(OnDemandMigrationConfigError::EmptyRegion);
|
||||
}
|
||||
@@ -466,6 +581,9 @@ impl SourceConfig {
|
||||
));
|
||||
}
|
||||
}
|
||||
// Both native providers derive a fixed endpoint; Azure's is built
|
||||
// from the account name, already checked by `validate_provider_block`.
|
||||
None if self.provider.is_native() => {}
|
||||
None => return Err(OnDemandMigrationConfigError::MissingEndpoint(self.provider)),
|
||||
}
|
||||
|
||||
@@ -496,6 +614,84 @@ impl SourceConfig {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The provider-specific block must be present for exactly its own
|
||||
/// provider: a stray `azure` block on an `s3` source would otherwise be
|
||||
/// accepted, stored, and silently ignored by the client builder.
|
||||
fn validate_provider_block(&self) -> Result<(), OnDemandMigrationConfigError> {
|
||||
let missing = OnDemandMigrationConfigError::MissingProviderBlock;
|
||||
let unexpected = OnDemandMigrationConfigError::UnexpectedProviderBlock;
|
||||
let invalid = OnDemandMigrationConfigError::InvalidProviderBlock;
|
||||
|
||||
if self.provider != Provider::Azure && self.azure.is_some() {
|
||||
return Err(unexpected("azure", self.provider));
|
||||
}
|
||||
if self.provider != Provider::GcsNative && self.gcs.is_some() {
|
||||
return Err(unexpected("gcs", self.provider));
|
||||
}
|
||||
|
||||
match self.provider {
|
||||
Provider::Azure => {
|
||||
let azure = self.azure.as_ref().ok_or(missing("azure", self.provider))?;
|
||||
if azure.account.is_empty() {
|
||||
return Err(invalid("azure", "account must not be empty"));
|
||||
}
|
||||
// The account feeds a hostname when the endpoint is derived:
|
||||
// keep it to label characters so it cannot rewrite the host.
|
||||
if !azure.account.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
|
||||
return Err(invalid("azure", "account contains characters outside [A-Za-z0-9-]"));
|
||||
}
|
||||
match (azure.account_key.as_deref(), azure.sas_token.as_deref()) {
|
||||
(Some(_), Some(_)) => return Err(invalid("azure", "account_key and sas_token are mutually exclusive")),
|
||||
(None, None) => return Err(invalid("azure", "one of account_key and sas_token is required")),
|
||||
(Some(key), None) => {
|
||||
if key.is_empty() {
|
||||
return Err(invalid("azure", "account_key must not be empty"));
|
||||
}
|
||||
// Decoded here so a mistyped key fails at the admin
|
||||
// boundary instead of on the first source request.
|
||||
if base64_simd::STANDARD.decode_to_vec(key.as_bytes()).is_err() {
|
||||
return Err(invalid("azure", "account_key is not base64"));
|
||||
}
|
||||
}
|
||||
(None, Some(sas)) => {
|
||||
if sas.is_empty() {
|
||||
return Err(invalid("azure", "sas_token must not be empty"));
|
||||
}
|
||||
if sas.starts_with('?') {
|
||||
return Err(invalid("azure", "sas_token must not start with '?'"));
|
||||
}
|
||||
if sas.chars().any(char::is_whitespace) {
|
||||
return Err(invalid("azure", "sas_token must not contain whitespace"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Provider::GcsNative => {
|
||||
let gcs = self.gcs.as_ref().ok_or(missing("gcs", self.provider))?;
|
||||
let key: serde_json::Value = serde_json::from_str(&gcs.service_account_json)
|
||||
.map_err(|_| invalid("gcs", "service_account_json is not valid JSON"))?;
|
||||
let Some(object) = key.as_object() else {
|
||||
return Err(invalid("gcs", "service_account_json is not a JSON object"));
|
||||
};
|
||||
if object.get("type").and_then(serde_json::Value::as_str) != Some("service_account") {
|
||||
return Err(invalid("gcs", "service_account_json is not a service_account key"));
|
||||
}
|
||||
for field in ["client_email", "private_key"] {
|
||||
if object
|
||||
.get(field)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_none_or(str::is_empty)
|
||||
{
|
||||
return Err(invalid("gcs", "service_account_json is missing client_email or private_key"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Provider::S3 | Provider::Aws | Provider::Minio | Provider::Rustfs | Provider::R2 | Provider::Gcs => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_endpoint(endpoint: &str) -> Result<(), OnDemandMigrationConfigError> {
|
||||
@@ -699,7 +895,15 @@ mod tests {
|
||||
),
|
||||
(
|
||||
"provider enum",
|
||||
r#"{"source":{"provider":"azure","endpoint":"https://h","region":"r","bucket":"b"}}"#,
|
||||
r#"{"source":{"provider":"swift","endpoint":"https://h","region":"r","bucket":"b"}}"#,
|
||||
),
|
||||
(
|
||||
"azure block",
|
||||
r#"{"source":{"provider":"azure","region":"auto","bucket":"b","azure":{"account":"acct","account_key":"a2V5","extra":1}}}"#,
|
||||
),
|
||||
(
|
||||
"gcs block",
|
||||
r#"{"source":{"provider":"gcs_native","region":"auto","bucket":"b","gcs":{"service_account_json":"{}","extra":1}}}"#,
|
||||
),
|
||||
] {
|
||||
let err = OnDemandMigrationConfig::from_json(json.as_bytes()).expect_err(label);
|
||||
|
||||
@@ -19,11 +19,17 @@
|
||||
//! client, and the per-node runtime (`sys`) that turns configs into live
|
||||
//! clients guarded by a breaker, a negative cache, singleflight and a pull
|
||||
//! concurrency limit (rustfs/backlog#2147).
|
||||
//!
|
||||
//! A source is reached through one `SourceBackend`: the S3 dialect for every
|
||||
//! S3-compatible provider, and a native backend for the providers that have no
|
||||
//! S3 API (`azure`).
|
||||
|
||||
pub mod azure;
|
||||
pub mod backfill;
|
||||
pub mod breaker;
|
||||
pub mod config;
|
||||
pub mod list_through;
|
||||
mod native_http;
|
||||
pub mod negative_cache;
|
||||
pub mod pull;
|
||||
pub mod source_client;
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
// 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.
|
||||
|
||||
//! Shared HTTP transport for the on-demand migration source backends that do
|
||||
//! not speak S3 (Azure Blob, native GCS).
|
||||
//!
|
||||
//! The S3 backend rides the AWS SDK; these providers have no SigV4 dialect, so
|
||||
//! they talk plain HTTP through one `reqwest` client that carries the same
|
||||
//! connect/read timeouts and TLS policy the operator configured for the source.
|
||||
//! Redirects are refused: the endpoint passed the outbound policy gate once, and
|
||||
//! following a source-chosen `Location` would leave that gate behind.
|
||||
//!
|
||||
//! Errors never render the request URL. A SAS token lives in the query string,
|
||||
//! so a `reqwest` error rendered with its URL would print the credential into
|
||||
//! the log line and the admin response.
|
||||
|
||||
use super::source_client::{SourceError, SourceHead, SourceTimeouts, USER_AGENT_SUFFIX, classify_status, is_multipart_etag};
|
||||
use crate::bucket::remote_s3_client::{RemoteS3ClientError, validate_remote_endpoint, validate_target_ca_pem};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_smithy_types::body::SdkBody;
|
||||
use futures::StreamExt;
|
||||
use http::HeaderMap;
|
||||
use std::collections::HashMap;
|
||||
use std::time::SystemTime;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::{Rfc2822, Rfc3339};
|
||||
use url::Url;
|
||||
|
||||
/// Origin the native backends are allowed to address, plus the HTTP client
|
||||
/// that reaches it.
|
||||
pub(super) struct NativeHttp {
|
||||
client: reqwest::Client,
|
||||
endpoint: Url,
|
||||
}
|
||||
|
||||
impl NativeHttp {
|
||||
/// `endpoint` must be a bare `scheme://host[:port]` origin; it is checked
|
||||
/// against the outbound policy exactly like an S3 source endpoint.
|
||||
pub(super) fn new(
|
||||
endpoint: &str,
|
||||
timeouts: SourceTimeouts,
|
||||
skip_tls_verify: bool,
|
||||
ca_cert_pem: Option<&str>,
|
||||
) -> Result<Self, RemoteS3ClientError> {
|
||||
let endpoint = Url::parse(endpoint.trim()).map_err(|err| RemoteS3ClientError::InvalidEndpoint(err.to_string()))?;
|
||||
if !matches!(endpoint.scheme(), "http" | "https") {
|
||||
return Err(RemoteS3ClientError::InvalidEndpoint(format!(
|
||||
"unsupported scheme {}; expected http or https",
|
||||
endpoint.scheme()
|
||||
)));
|
||||
}
|
||||
if endpoint.host_str().is_none_or(str::is_empty) {
|
||||
return Err(RemoteS3ClientError::InvalidEndpoint("endpoint has no host".to_string()));
|
||||
}
|
||||
if !endpoint.username().is_empty() || endpoint.password().is_some() {
|
||||
return Err(RemoteS3ClientError::InvalidEndpoint("endpoint must not carry userinfo".to_string()));
|
||||
}
|
||||
if !matches!(endpoint.path(), "" | "/") || endpoint.query().is_some() || endpoint.fragment().is_some() {
|
||||
return Err(RemoteS3ClientError::InvalidEndpoint(
|
||||
"endpoint must be an origin without path, query or fragment".to_string(),
|
||||
));
|
||||
}
|
||||
validate_remote_endpoint(&endpoint).map_err(RemoteS3ClientError::EndpointNotAllowed)?;
|
||||
|
||||
let mut builder = reqwest::Client::builder()
|
||||
.connect_timeout(timeouts.connect)
|
||||
.read_timeout(timeouts.read)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.user_agent(USER_AGENT_SUFFIX);
|
||||
if skip_tls_verify {
|
||||
builder = builder.danger_accept_invalid_certs(true);
|
||||
} else if let Some(pem) = ca_cert_pem.map(str::trim).filter(|pem| !pem.is_empty()) {
|
||||
// Reject a malformed bundle the same way the S3 path does, so the
|
||||
// operator sees "invalid CA PEM" instead of a TLS handshake failure.
|
||||
validate_target_ca_pem(pem)?;
|
||||
let certificate = reqwest::Certificate::from_pem(pem.as_bytes())
|
||||
.map_err(|err| RemoteS3ClientError::InvalidCaPem(err.to_string()))?;
|
||||
builder = builder.add_root_certificate(certificate);
|
||||
}
|
||||
|
||||
let client = builder
|
||||
.build()
|
||||
.map_err(|err| RemoteS3ClientError::InvalidEndpoint(format!("http client cannot be built: {err}")))?;
|
||||
Ok(Self { client, endpoint })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn for_test(endpoint: Url) -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("test http client should build"),
|
||||
endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
/// A URL under the endpoint origin. `segments` are percent-encoded as
|
||||
/// path segments, so a key containing `?`, `#` or a space cannot rewrite
|
||||
/// the request target.
|
||||
pub(super) fn url<'a>(&self, segments: impl IntoIterator<Item = &'a str>) -> Result<Url, SourceError> {
|
||||
let mut url = self.endpoint.clone();
|
||||
{
|
||||
let mut path = url
|
||||
.path_segments_mut()
|
||||
.map_err(|_| SourceError::Other("source endpoint cannot carry a path".to_string()))?;
|
||||
path.clear();
|
||||
path.extend(segments);
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
/// Sends the request and returns the response only for a 2xx status.
|
||||
/// Non-2xx statuses are classified from the status and the provider's own
|
||||
/// error-code header; response bodies are not read, so no provider message
|
||||
/// can smuggle credentials or markup into a log line.
|
||||
pub(super) async fn send(
|
||||
&self,
|
||||
request: reqwest::Request,
|
||||
error_code_header: &str,
|
||||
) -> Result<reqwest::Response, SourceError> {
|
||||
let response = self.client.execute(request).await.map_err(classify_transport_error)?;
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
let code = response
|
||||
.headers()
|
||||
.get(error_code_header)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string);
|
||||
Err(classify_status(
|
||||
status.as_u16(),
|
||||
None,
|
||||
match &code {
|
||||
Some(code) => format!("source returned HTTP {status} ({code})"),
|
||||
None => format!("source returned HTTP {status}"),
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a transport failure without the request URL: a SAS token or a
|
||||
/// signed query would otherwise reach logs and admin responses.
|
||||
pub(super) fn classify_transport_error(err: reqwest::Error) -> SourceError {
|
||||
let is_timeout = err.is_timeout();
|
||||
let is_connect = err.is_connect();
|
||||
let message = err.without_url().to_string();
|
||||
if is_timeout {
|
||||
SourceError::Timeout
|
||||
} else if is_connect {
|
||||
SourceError::Connect(message)
|
||||
} else {
|
||||
SourceError::Other(message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Streams the response body without buffering it.
|
||||
pub(super) fn response_body(response: reqwest::Response) -> ByteStream {
|
||||
let stream = response.bytes_stream().map(|chunk| {
|
||||
chunk
|
||||
.map(http_body::Frame::data)
|
||||
.map_err(|err| std::io::Error::other(err.without_url().to_string()))
|
||||
});
|
||||
ByteStream::new(SdkBody::from_body_1_x(http_body_util::StreamBody::new(stream)))
|
||||
}
|
||||
|
||||
/// Reads a bounded response body as UTF-8, for the XML and JSON listings.
|
||||
pub(super) async fn read_text(response: reqwest::Response, max_bytes: usize) -> Result<String, SourceError> {
|
||||
let mut body = Vec::new();
|
||||
let mut stream = response.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(classify_transport_error)?;
|
||||
if body.len().saturating_add(chunk.len()) > max_bytes {
|
||||
return Err(SourceError::Other("source listing response exceeded the size limit".to_string()));
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
String::from_utf8(body).map_err(|_| SourceError::Other("source listing response is not valid UTF-8".to_string()))
|
||||
}
|
||||
|
||||
pub(super) fn header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||
headers.get(name).and_then(|value| value.to_str().ok()).map(str::trim)
|
||||
}
|
||||
|
||||
fn header_string(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
header(headers, name).filter(|value| !value.is_empty()).map(str::to_string)
|
||||
}
|
||||
|
||||
/// `Last-Modified` and friends arrive as an HTTP date; the JSON dialects use
|
||||
/// RFC 3339 for the same field, so both are accepted.
|
||||
pub(super) fn parse_http_timestamp(value: &str) -> Option<SystemTime> {
|
||||
OffsetDateTime::parse(value, &Rfc2822)
|
||||
.or_else(|_| OffsetDateTime::parse(value, &Rfc3339))
|
||||
.ok()
|
||||
.map(SystemTime::from)
|
||||
}
|
||||
|
||||
/// Provider-specific fields the shared header mapping cannot infer.
|
||||
pub(super) struct NativeHeadFields {
|
||||
pub(super) etag: Option<String>,
|
||||
/// The ETag is an opaque token rather than a digest of the bytes.
|
||||
pub(super) etag_is_opaque: bool,
|
||||
pub(super) version_id: Option<String>,
|
||||
pub(super) storage_class: Option<String>,
|
||||
}
|
||||
|
||||
/// Maps a HEAD or GET response onto [`SourceHead`]. `metadata_prefix` is the
|
||||
/// provider's user-metadata header prefix (`x-ms-meta-`, `x-goog-meta-`); the
|
||||
/// stored shape drops it, matching the `x-amz-meta-` handling of the S3 path.
|
||||
pub(super) fn native_source_head(
|
||||
headers: &HeaderMap,
|
||||
metadata_prefix: &str,
|
||||
fields: NativeHeadFields,
|
||||
) -> Result<SourceHead, SourceError> {
|
||||
let size = header(headers, "content-length")
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.ok_or_else(|| SourceError::Other("source response has no valid content-length".to_string()))?;
|
||||
|
||||
let mut user_metadata = HashMap::new();
|
||||
for (name, value) in headers {
|
||||
let name = name.as_str();
|
||||
if let Some(key) = name.strip_prefix(metadata_prefix)
|
||||
&& !key.is_empty()
|
||||
&& let Ok(value) = value.to_str()
|
||||
{
|
||||
user_metadata.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let etag = fields
|
||||
.etag
|
||||
.map(|etag| etag.trim().trim_matches('"').to_string())
|
||||
.filter(|etag| !etag.is_empty());
|
||||
// An opaque ETag never encodes a part count, so the multipart flag stays
|
||||
// false for it however the provider happens to spell the token.
|
||||
let is_multipart_etag = !fields.etag_is_opaque && etag.as_deref().is_some_and(is_multipart_etag);
|
||||
|
||||
Ok(SourceHead {
|
||||
etag,
|
||||
size,
|
||||
last_modified: header(headers, "last-modified").and_then(parse_http_timestamp),
|
||||
content_type: header_string(headers, "content-type"),
|
||||
content_encoding: header_string(headers, "content-encoding"),
|
||||
content_disposition: header_string(headers, "content-disposition"),
|
||||
content_language: header_string(headers, "content-language"),
|
||||
cache_control: header_string(headers, "cache-control"),
|
||||
expires: header_string(headers, "expires"),
|
||||
user_metadata,
|
||||
version_id: fields.version_id,
|
||||
storage_class: fields.storage_class,
|
||||
// Neither native provider hands back ciphertext: a customer-key object
|
||||
// is refused by the backend before it reaches this mapping, and the
|
||||
// service-managed encryption is transparent to the reader.
|
||||
sse: None,
|
||||
is_multipart_etag,
|
||||
etag_is_opaque: fields.etag_is_opaque,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use http::HeaderValue;
|
||||
|
||||
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
for (name, value) in pairs {
|
||||
headers.insert(
|
||||
http::HeaderName::from_bytes(name.as_bytes()).expect("test header name"),
|
||||
HeaderValue::from_str(value).expect("test header value"),
|
||||
);
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
fn fields() -> NativeHeadFields {
|
||||
NativeHeadFields {
|
||||
etag: None,
|
||||
etag_is_opaque: false,
|
||||
version_id: None,
|
||||
storage_class: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_source_head_maps_content_headers_and_prefixed_metadata() {
|
||||
let headers = headers(&[
|
||||
("content-length", "1234"),
|
||||
("content-type", "text/plain"),
|
||||
("content-encoding", "gzip"),
|
||||
("content-language", "en"),
|
||||
("content-disposition", "attachment"),
|
||||
("cache-control", "max-age=60"),
|
||||
("expires", "Thu, 01 Jan 2026 00:00:00 GMT"),
|
||||
("last-modified", "Wed, 21 Oct 2015 07:28:00 GMT"),
|
||||
("x-ms-meta-owner", "alice"),
|
||||
("x-goog-meta-owner", "not-mine"),
|
||||
]);
|
||||
let head = native_source_head(
|
||||
&headers,
|
||||
"x-ms-meta-",
|
||||
NativeHeadFields {
|
||||
etag: Some("\"0x8DCE1D2\"".to_string()),
|
||||
etag_is_opaque: true,
|
||||
version_id: Some("2026-01-01T00:00:00.0000000Z".to_string()),
|
||||
storage_class: Some("Hot".to_string()),
|
||||
},
|
||||
)
|
||||
.expect("head should map");
|
||||
|
||||
assert_eq!(head.size, 1234);
|
||||
assert_eq!(head.content_type.as_deref(), Some("text/plain"));
|
||||
assert_eq!(head.content_encoding.as_deref(), Some("gzip"));
|
||||
assert_eq!(head.content_language.as_deref(), Some("en"));
|
||||
assert_eq!(head.content_disposition.as_deref(), Some("attachment"));
|
||||
assert_eq!(head.cache_control.as_deref(), Some("max-age=60"));
|
||||
assert_eq!(head.expires.as_deref(), Some("Thu, 01 Jan 2026 00:00:00 GMT"));
|
||||
assert_eq!(
|
||||
head.last_modified,
|
||||
Some(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_445_412_480)),
|
||||
"HTTP-date Last-Modified must parse"
|
||||
);
|
||||
assert_eq!(
|
||||
head.user_metadata,
|
||||
HashMap::from([("owner".to_string(), "alice".to_string())]),
|
||||
"only the provider's own metadata prefix is read"
|
||||
);
|
||||
assert_eq!(head.etag.as_deref(), Some("0x8DCE1D2"), "quotes are stripped, the token is kept");
|
||||
assert!(head.etag_is_opaque);
|
||||
assert!(!head.is_multipart_etag);
|
||||
assert_eq!(head.storage_class.as_deref(), Some("Hot"));
|
||||
assert!(head.sse.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_source_head_requires_a_content_length() {
|
||||
let err = native_source_head(&headers(&[("content-type", "text/plain")]), "x-ms-meta-", fields())
|
||||
.expect_err("a response without content-length is unusable");
|
||||
assert!(matches!(err, SourceError::Other(_)), "{err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_etag_never_reads_as_a_multipart_etag() {
|
||||
// A digest-shaped ETag keeps the S3 reading; the same string marked
|
||||
// opaque must not be split into "digest-partcount".
|
||||
for (opaque, expected) in [(false, true), (true, false)] {
|
||||
let head = native_source_head(
|
||||
&headers(&[("content-length", "1")]),
|
||||
"x-ms-meta-",
|
||||
NativeHeadFields {
|
||||
etag: Some("d41d8cd98f00b204e9800998ecf8427e-3".to_string()),
|
||||
etag_is_opaque: opaque,
|
||||
..fields()
|
||||
},
|
||||
)
|
||||
.expect("head should map");
|
||||
assert_eq!(head.is_multipart_etag, expected, "opaque = {opaque}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_http_rejects_endpoints_that_are_not_bare_origins() {
|
||||
for bad in [
|
||||
"ftp://source.example.com",
|
||||
"https://user:pw@source.example.com",
|
||||
"https://source.example.com/container",
|
||||
"https://source.example.com/?x=1",
|
||||
"not a url",
|
||||
] {
|
||||
assert!(
|
||||
NativeHttp::new(bad, SourceTimeouts::default(), false, None).is_err(),
|
||||
"{bad} must be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_http_percent_encodes_every_path_segment() {
|
||||
let http = NativeHttp::for_test(Url::parse("https://acct.blob.core.windows.net").expect("origin"));
|
||||
let url = http.url(["container", "dir", "a b?c#d.txt"]).expect("url should build");
|
||||
assert_eq!(url.as_str(), "https://acct.blob.core.windows.net/container/dir/a%20b%3Fc%23d.txt");
|
||||
assert_eq!(url.query(), None, "a key with '?' must not become a query");
|
||||
}
|
||||
}
|
||||
@@ -1119,6 +1119,8 @@ mod tests {
|
||||
session_token: None,
|
||||
}),
|
||||
tls: TlsConfig::default(),
|
||||
azure: None,
|
||||
gcs: None,
|
||||
},
|
||||
filter: FilterConfig::default(),
|
||||
policy: PolicyConfig::default(),
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
//! Client-supplied `If-*`, `Authorization`, `Host` and SSE-C headers are never
|
||||
//! forwarded: v1 rejects SSE-C source objects outright.
|
||||
|
||||
use super::azure::AzureSourceBackend;
|
||||
use crate::bucket::remote_s3_client::{
|
||||
PathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3EndpointSpec, RemoteS3RetryPolicy, build_remote_s3_config,
|
||||
};
|
||||
@@ -64,6 +65,10 @@ pub enum SourceProvider {
|
||||
/// Generic S3-compatible service.
|
||||
#[default]
|
||||
S3,
|
||||
/// Native Azure Blob service; not an S3 dialect.
|
||||
Azure,
|
||||
/// Native GCS JSON API with a service-account key; not an S3 dialect.
|
||||
GcsNative,
|
||||
}
|
||||
|
||||
impl SourceProvider {
|
||||
@@ -75,6 +80,8 @@ impl SourceProvider {
|
||||
"minio" => Some(Self::Minio),
|
||||
"rustfs" => Some(Self::Rustfs),
|
||||
"s3" => Some(Self::S3),
|
||||
"azure" => Some(Self::Azure),
|
||||
"gcs_native" => Some(Self::GcsNative),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -87,6 +94,8 @@ impl SourceProvider {
|
||||
Self::Minio => "minio",
|
||||
Self::Rustfs => "rustfs",
|
||||
Self::S3 => "s3",
|
||||
Self::Azure => "azure",
|
||||
Self::GcsNative => "gcs_native",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +167,69 @@ pub struct SourceClientSpec {
|
||||
/// Bytes per second the pull pipeline may consume from this source;
|
||||
/// `None` means unlimited. Enforced by the consumer, not by this client.
|
||||
pub bandwidth_limit: Option<NonZeroU64>,
|
||||
/// Which [`SourceBackend`] to build. The S3 variant reads `region`,
|
||||
/// `path_style` and `credentials`; the native variants ignore all three
|
||||
/// and carry their own credentials.
|
||||
pub backend: SourceBackendSpec,
|
||||
}
|
||||
|
||||
/// Provider-specific half of [`SourceClientSpec`].
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub enum SourceBackendSpec {
|
||||
#[default]
|
||||
S3,
|
||||
Azure(AzureSourceSpec),
|
||||
Gcs(GcsSourceSpec),
|
||||
}
|
||||
|
||||
/// Native Azure Blob parameters. The container is [`SourceClientSpec::bucket`].
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct AzureSourceSpec {
|
||||
pub account: String,
|
||||
pub auth: AzureAuth,
|
||||
}
|
||||
|
||||
impl fmt::Debug for AzureSourceSpec {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("AzureSourceSpec")
|
||||
.field("account", &self.account)
|
||||
.field("auth", &self.auth)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// How Azure requests are authorized.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum AzureAuth {
|
||||
/// Base64 storage-account key, signed per request with Shared Key.
|
||||
SharedKey(String),
|
||||
/// SAS query string without the leading `?`, appended to every URL.
|
||||
Sas(String),
|
||||
}
|
||||
|
||||
impl fmt::Debug for AzureAuth {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
// Both variants are secrets; only the scheme may be rendered.
|
||||
f.write_str(match self {
|
||||
Self::SharedKey(_) => "SharedKey(REDACTED)",
|
||||
Self::Sas(_) => "Sas(REDACTED)",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Native GCS parameters. The bucket is [`SourceClientSpec::bucket`].
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct GcsSourceSpec {
|
||||
/// Service-account key JSON.
|
||||
pub service_account_json: String,
|
||||
}
|
||||
|
||||
impl fmt::Debug for GcsSourceSpec {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("GcsSourceSpec")
|
||||
.field("service_account_json", &"REDACTED")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SourceClientSpec {
|
||||
@@ -268,7 +340,7 @@ const ACCESS_DENIED_CODES: &[&str] = &[
|
||||
"InvalidToken",
|
||||
];
|
||||
|
||||
fn classify_status(status: u16, code: Option<&str>, message: String) -> SourceError {
|
||||
pub(super) fn classify_status(status: u16, code: Option<&str>, message: String) -> SourceError {
|
||||
if let Some(code) = code {
|
||||
if THROTTLE_CODES.contains(&code) {
|
||||
return SourceError::Throttled;
|
||||
@@ -341,6 +413,11 @@ pub struct SourceHead {
|
||||
pub storage_class: Option<String>,
|
||||
pub sse: Option<SourceSse>,
|
||||
pub is_multipart_etag: bool,
|
||||
/// The provider's ETag is not derived from the object bytes (Azure
|
||||
/// stamps an opaque concurrency token). Such an ETag is recorded for
|
||||
/// provenance but must never be read as a content digest, so the
|
||||
/// write-back path refuses to use it as the expected MD5.
|
||||
pub etag_is_opaque: bool,
|
||||
}
|
||||
|
||||
/// Per-operation fields shared by HEAD and GET outputs.
|
||||
@@ -362,7 +439,7 @@ struct HeadParts {
|
||||
sse_customer_algorithm: Option<String>,
|
||||
}
|
||||
|
||||
fn normalize_etag(etag: Option<String>) -> Option<String> {
|
||||
pub(super) fn normalize_etag(etag: Option<String>) -> Option<String> {
|
||||
etag.map(|etag| etag.trim().trim_matches('"').to_string())
|
||||
.filter(|etag| !etag.is_empty())
|
||||
}
|
||||
@@ -411,6 +488,7 @@ fn source_head(parts: HeadParts) -> Result<SourceHead, SourceError> {
|
||||
storage_class: parts.storage_class,
|
||||
sse,
|
||||
is_multipart_etag,
|
||||
etag_is_opaque: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -621,9 +699,40 @@ impl fmt::Debug for SourceClient {
|
||||
|
||||
impl SourceClient {
|
||||
pub async fn new(spec: &SourceClientSpec) -> Result<Self, RemoteS3ClientError> {
|
||||
let endpoint = spec.endpoint_spec()?;
|
||||
let config = build_remote_s3_config(&endpoint).await?;
|
||||
Ok(Self::from_config_builder(config, endpoint.endpoint_url(), spec))
|
||||
match &spec.backend {
|
||||
SourceBackendSpec::S3 => {
|
||||
let endpoint = spec.endpoint_spec()?;
|
||||
let config = build_remote_s3_config(&endpoint).await?;
|
||||
Ok(Self::from_config_builder(config, endpoint.endpoint_url(), spec))
|
||||
}
|
||||
SourceBackendSpec::Azure(azure) => {
|
||||
let backend = AzureSourceBackend::new(
|
||||
&spec.endpoint,
|
||||
&spec.bucket,
|
||||
azure,
|
||||
spec.timeouts,
|
||||
spec.skip_tls_verify,
|
||||
spec.ca_cert_pem.as_deref(),
|
||||
)?;
|
||||
Ok(Self::from_backend(Box::new(backend), spec))
|
||||
}
|
||||
SourceBackendSpec::Gcs(_) => {
|
||||
Err(RemoteS3ClientError::Credentials("the native gcs source backend is not implemented yet"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps a ready backend in the prefix-mapping client. The endpoint is
|
||||
/// kept only for `Debug` and admin status.
|
||||
fn from_backend(backend: Box<dyn SourceBackend>, spec: &SourceClientSpec) -> Self {
|
||||
Self {
|
||||
backend,
|
||||
endpoint: spec.endpoint.clone(),
|
||||
bucket: spec.bucket.clone(),
|
||||
source_prefix: spec.source_prefix.clone().filter(|prefix| !prefix.is_empty()),
|
||||
timeouts: spec.timeouts,
|
||||
bandwidth_limit: spec.bandwidth_limit,
|
||||
}
|
||||
}
|
||||
|
||||
/// `config` must come from [`SourceClientSpec::endpoint_spec`], which is
|
||||
@@ -983,6 +1092,7 @@ mod tests {
|
||||
retry: RemoteS3RetryPolicy::Disabled,
|
||||
timeouts: SourceTimeouts::default(),
|
||||
bandwidth_limit: NonZeroU64::new(1_000_000),
|
||||
backend: SourceBackendSpec::S3,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1487,7 +1597,9 @@ mod tests {
|
||||
assert_eq!(resolve_path_style(PathStyle::VirtualHost, Minio, "10.0.0.1"), PathStyle::VirtualHost);
|
||||
assert_eq!(resolve_path_style(PathStyle::Path, Aws, "s3.amazonaws.com"), PathStyle::Path);
|
||||
assert_eq!(SourceProvider::from_label(" AWS "), Some(Aws));
|
||||
assert_eq!(SourceProvider::from_label("azure"), None);
|
||||
assert_eq!(SourceProvider::from_label(" Azure "), Some(Azure));
|
||||
assert_eq!(SourceProvider::from_label("gcs_native"), Some(GcsNative));
|
||||
assert_eq!(SourceProvider::from_label("swift"), None);
|
||||
}
|
||||
|
||||
fn prefix_client(prefix: Option<String>) -> SourceClient {
|
||||
|
||||
@@ -47,7 +47,10 @@ use super::config::{
|
||||
use super::list_through::{SOURCE_LIST_RATE_PER_SEC, SourceListRateLimiter};
|
||||
use super::negative_cache::NegativeCache;
|
||||
use super::pull::{OdmWriteBack, PullQueue};
|
||||
use super::source_client::{SourceClient, SourceClientSpec, SourceError, SourceProvider, SourceTimeouts};
|
||||
use super::source_client::{
|
||||
AzureAuth, AzureSourceSpec, GcsSourceSpec, SourceBackendSpec, SourceClient, SourceClientSpec, SourceError, SourceProvider,
|
||||
SourceTimeouts,
|
||||
};
|
||||
use super::stats::{GaugeGuard, OdmStats, OdmStatsSnapshot, PullFailureReason};
|
||||
use crate::bucket::remote_s3_client::{
|
||||
PathStyle as ClientPathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3RetryPolicy,
|
||||
@@ -619,6 +622,7 @@ pub fn source_client_spec(config: &OnDemandMigrationConfig) -> SourceClientSpec
|
||||
// load on a source that is already failing.
|
||||
retry: RemoteS3RetryPolicy::Disabled,
|
||||
bandwidth_limit: policy.bandwidth_limit_bytes_per_sec.and_then(NonZeroU64::new),
|
||||
backend: source_backend_spec(source),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,6 +634,31 @@ fn source_provider(provider: Provider) -> SourceProvider {
|
||||
Provider::Rustfs => SourceProvider::Rustfs,
|
||||
Provider::R2 => SourceProvider::R2,
|
||||
Provider::Gcs => SourceProvider::Gcs,
|
||||
Provider::Azure => SourceProvider::Azure,
|
||||
Provider::GcsNative => SourceProvider::GcsNative,
|
||||
}
|
||||
}
|
||||
|
||||
/// Which backend the client builds. A native provider whose block is missing
|
||||
/// falls back to the S3 spec, where the builder reports the missing
|
||||
/// credentials: the config layer already refuses to store that shape, so this
|
||||
/// only covers a config written by an older or hand-edited build.
|
||||
pub(crate) fn source_backend_spec(source: &SourceConfig) -> SourceBackendSpec {
|
||||
match (source.provider, source.azure.as_ref(), source.gcs.as_ref()) {
|
||||
(Provider::Azure, Some(azure), _) => SourceBackendSpec::Azure(AzureSourceSpec {
|
||||
account: azure.account.clone(),
|
||||
auth: match (&azure.account_key, &azure.sas_token) {
|
||||
(Some(key), _) => AzureAuth::SharedKey(key.clone()),
|
||||
(None, Some(sas)) => AzureAuth::Sas(sas.clone()),
|
||||
// Refused by `SourceConfig::validate`; an empty shared key
|
||||
// fails closed at the builder rather than signing with none.
|
||||
(None, None) => AzureAuth::SharedKey(String::new()),
|
||||
},
|
||||
}),
|
||||
(Provider::GcsNative, _, Some(gcs)) => SourceBackendSpec::Gcs(GcsSourceSpec {
|
||||
service_account_json: gcs.service_account_json.clone(),
|
||||
}),
|
||||
_ => SourceBackendSpec::S3,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -929,6 +958,8 @@ mod tests {
|
||||
session_token: None,
|
||||
}),
|
||||
tls: TlsConfig::default(),
|
||||
azure: None,
|
||||
gcs: None,
|
||||
},
|
||||
filter: FilterConfig {
|
||||
prefix: prefix.map(str::to_string),
|
||||
|
||||
Reference in New Issue
Block a user