mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-07 20:46:11 +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",
|
"path-absolutize",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"proptest",
|
"proptest",
|
||||||
|
"quick-xml",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"ratelimit",
|
"ratelimit",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ serde_urlencoded.workspace = true
|
|||||||
google-cloud-storage = { workspace = true }
|
google-cloud-storage = { workspace = true }
|
||||||
google-cloud-auth = { workspace = true }
|
google-cloud-auth = { workspace = true }
|
||||||
faster-hex = { workspace = true }
|
faster-hex = { workspace = true }
|
||||||
|
quick-xml = { workspace = true }
|
||||||
ratelimit = { workspace = true }
|
ratelimit = { workspace = true }
|
||||||
aws-smithy-http-client = { workspace = true, default-features = false, features = ["rustls-aws-lc"] }
|
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 REDACTED: &str = "REDACTED";
|
||||||
const AUTO_REGION: &str = "auto";
|
const AUTO_REGION: &str = "auto";
|
||||||
const AUTO_REGION_FALLBACK: &str = "us-east-1";
|
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 KIB: u64 = 1024;
|
||||||
const MIB: u64 = 1024 * KIB;
|
const MIB: u64 = 1024 * KIB;
|
||||||
@@ -75,14 +79,25 @@ pub struct SourceConfig {
|
|||||||
pub bucket: String,
|
pub bucket: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub path_style: PathStyle,
|
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)]
|
#[serde(default)]
|
||||||
pub credentials: Option<SourceCredentials>,
|
pub credentials: Option<SourceCredentials>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tls: TlsConfig,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum Provider {
|
pub enum Provider {
|
||||||
@@ -94,6 +109,12 @@ pub enum Provider {
|
|||||||
R2,
|
R2,
|
||||||
/// GCS XML interoperability API with HMAC keys.
|
/// GCS XML interoperability API with HMAC keys.
|
||||||
Gcs,
|
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 {
|
impl Provider {
|
||||||
@@ -105,13 +126,22 @@ impl Provider {
|
|||||||
Provider::Rustfs => "rustfs",
|
Provider::Rustfs => "rustfs",
|
||||||
Provider::R2 => "r2",
|
Provider::R2 => "r2",
|
||||||
Provider::Gcs => "gcs",
|
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
|
/// 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 {
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct TlsConfig {
|
pub struct TlsConfig {
|
||||||
@@ -354,6 +451,14 @@ pub enum OnDemandMigrationConfigError {
|
|||||||
InvalidBucket(&'static str),
|
InvalidBucket(&'static str),
|
||||||
#[error("source credentials field {0} must not be empty")]
|
#[error("source credentials field {0} must not be empty")]
|
||||||
EmptyCredential(&'static str),
|
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")]
|
#[error("source tls.ca_cert_pem is not a PEM certificate")]
|
||||||
InvalidCaCert,
|
InvalidCaCert,
|
||||||
#[error("filter.{0} must be null or a non-empty string")]
|
#[error("filter.{0} must be null or a non-empty string")]
|
||||||
@@ -388,6 +493,8 @@ impl OnDemandMigrationConfig {
|
|||||||
pub fn redacted(&self) -> Self {
|
pub fn redacted(&self) -> Self {
|
||||||
let mut copy = self.clone();
|
let mut copy = self.clone();
|
||||||
copy.source.credentials = self.source.credentials.as_ref().map(SourceCredentials::redacted);
|
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
|
copy
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,6 +540,12 @@ impl SourceConfig {
|
|||||||
match (&self.endpoint, self.provider) {
|
match (&self.endpoint, self.provider) {
|
||||||
(Some(endpoint), _) => endpoint.clone(),
|
(Some(endpoint), _) => endpoint.clone(),
|
||||||
(None, Provider::Aws) => format!("https://s3.{}.amazonaws.com", self.region),
|
(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(),
|
(None, _) => String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -448,6 +561,8 @@ impl SourceConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate(&self) -> Result<(), OnDemandMigrationConfigError> {
|
fn validate(&self) -> Result<(), OnDemandMigrationConfigError> {
|
||||||
|
self.validate_provider_block()?;
|
||||||
|
|
||||||
if self.region.is_empty() {
|
if self.region.is_empty() {
|
||||||
return Err(OnDemandMigrationConfigError::EmptyRegion);
|
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)),
|
None => return Err(OnDemandMigrationConfigError::MissingEndpoint(self.provider)),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,6 +614,84 @@ impl SourceConfig {
|
|||||||
|
|
||||||
Ok(())
|
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> {
|
fn validate_endpoint(endpoint: &str) -> Result<(), OnDemandMigrationConfigError> {
|
||||||
@@ -699,7 +895,15 @@ mod tests {
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"provider enum",
|
"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);
|
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
|
//! client, and the per-node runtime (`sys`) that turns configs into live
|
||||||
//! clients guarded by a breaker, a negative cache, singleflight and a pull
|
//! clients guarded by a breaker, a negative cache, singleflight and a pull
|
||||||
//! concurrency limit (rustfs/backlog#2147).
|
//! 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 backfill;
|
||||||
pub mod breaker;
|
pub mod breaker;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod list_through;
|
pub mod list_through;
|
||||||
|
mod native_http;
|
||||||
pub mod negative_cache;
|
pub mod negative_cache;
|
||||||
pub mod pull;
|
pub mod pull;
|
||||||
pub mod source_client;
|
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,
|
session_token: None,
|
||||||
}),
|
}),
|
||||||
tls: TlsConfig::default(),
|
tls: TlsConfig::default(),
|
||||||
|
azure: None,
|
||||||
|
gcs: None,
|
||||||
},
|
},
|
||||||
filter: FilterConfig::default(),
|
filter: FilterConfig::default(),
|
||||||
policy: PolicyConfig::default(),
|
policy: PolicyConfig::default(),
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
//! Client-supplied `If-*`, `Authorization`, `Host` and SSE-C headers are never
|
//! Client-supplied `If-*`, `Authorization`, `Host` and SSE-C headers are never
|
||||||
//! forwarded: v1 rejects SSE-C source objects outright.
|
//! forwarded: v1 rejects SSE-C source objects outright.
|
||||||
|
|
||||||
|
use super::azure::AzureSourceBackend;
|
||||||
use crate::bucket::remote_s3_client::{
|
use crate::bucket::remote_s3_client::{
|
||||||
PathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3EndpointSpec, RemoteS3RetryPolicy, build_remote_s3_config,
|
PathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3EndpointSpec, RemoteS3RetryPolicy, build_remote_s3_config,
|
||||||
};
|
};
|
||||||
@@ -64,6 +65,10 @@ pub enum SourceProvider {
|
|||||||
/// Generic S3-compatible service.
|
/// Generic S3-compatible service.
|
||||||
#[default]
|
#[default]
|
||||||
S3,
|
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 {
|
impl SourceProvider {
|
||||||
@@ -75,6 +80,8 @@ impl SourceProvider {
|
|||||||
"minio" => Some(Self::Minio),
|
"minio" => Some(Self::Minio),
|
||||||
"rustfs" => Some(Self::Rustfs),
|
"rustfs" => Some(Self::Rustfs),
|
||||||
"s3" => Some(Self::S3),
|
"s3" => Some(Self::S3),
|
||||||
|
"azure" => Some(Self::Azure),
|
||||||
|
"gcs_native" => Some(Self::GcsNative),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,6 +94,8 @@ impl SourceProvider {
|
|||||||
Self::Minio => "minio",
|
Self::Minio => "minio",
|
||||||
Self::Rustfs => "rustfs",
|
Self::Rustfs => "rustfs",
|
||||||
Self::S3 => "s3",
|
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;
|
/// Bytes per second the pull pipeline may consume from this source;
|
||||||
/// `None` means unlimited. Enforced by the consumer, not by this client.
|
/// `None` means unlimited. Enforced by the consumer, not by this client.
|
||||||
pub bandwidth_limit: Option<NonZeroU64>,
|
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 {
|
impl SourceClientSpec {
|
||||||
@@ -268,7 +340,7 @@ const ACCESS_DENIED_CODES: &[&str] = &[
|
|||||||
"InvalidToken",
|
"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 let Some(code) = code {
|
||||||
if THROTTLE_CODES.contains(&code) {
|
if THROTTLE_CODES.contains(&code) {
|
||||||
return SourceError::Throttled;
|
return SourceError::Throttled;
|
||||||
@@ -341,6 +413,11 @@ pub struct SourceHead {
|
|||||||
pub storage_class: Option<String>,
|
pub storage_class: Option<String>,
|
||||||
pub sse: Option<SourceSse>,
|
pub sse: Option<SourceSse>,
|
||||||
pub is_multipart_etag: bool,
|
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.
|
/// Per-operation fields shared by HEAD and GET outputs.
|
||||||
@@ -362,7 +439,7 @@ struct HeadParts {
|
|||||||
sse_customer_algorithm: Option<String>,
|
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())
|
etag.map(|etag| etag.trim().trim_matches('"').to_string())
|
||||||
.filter(|etag| !etag.is_empty())
|
.filter(|etag| !etag.is_empty())
|
||||||
}
|
}
|
||||||
@@ -411,6 +488,7 @@ fn source_head(parts: HeadParts) -> Result<SourceHead, SourceError> {
|
|||||||
storage_class: parts.storage_class,
|
storage_class: parts.storage_class,
|
||||||
sse,
|
sse,
|
||||||
is_multipart_etag,
|
is_multipart_etag,
|
||||||
|
etag_is_opaque: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,9 +699,40 @@ impl fmt::Debug for SourceClient {
|
|||||||
|
|
||||||
impl SourceClient {
|
impl SourceClient {
|
||||||
pub async fn new(spec: &SourceClientSpec) -> Result<Self, RemoteS3ClientError> {
|
pub async fn new(spec: &SourceClientSpec) -> Result<Self, RemoteS3ClientError> {
|
||||||
let endpoint = spec.endpoint_spec()?;
|
match &spec.backend {
|
||||||
let config = build_remote_s3_config(&endpoint).await?;
|
SourceBackendSpec::S3 => {
|
||||||
Ok(Self::from_config_builder(config, endpoint.endpoint_url(), spec))
|
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
|
/// `config` must come from [`SourceClientSpec::endpoint_spec`], which is
|
||||||
@@ -983,6 +1092,7 @@ mod tests {
|
|||||||
retry: RemoteS3RetryPolicy::Disabled,
|
retry: RemoteS3RetryPolicy::Disabled,
|
||||||
timeouts: SourceTimeouts::default(),
|
timeouts: SourceTimeouts::default(),
|
||||||
bandwidth_limit: NonZeroU64::new(1_000_000),
|
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::VirtualHost, Minio, "10.0.0.1"), PathStyle::VirtualHost);
|
||||||
assert_eq!(resolve_path_style(PathStyle::Path, Aws, "s3.amazonaws.com"), PathStyle::Path);
|
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(" 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 {
|
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::list_through::{SOURCE_LIST_RATE_PER_SEC, SourceListRateLimiter};
|
||||||
use super::negative_cache::NegativeCache;
|
use super::negative_cache::NegativeCache;
|
||||||
use super::pull::{OdmWriteBack, PullQueue};
|
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 super::stats::{GaugeGuard, OdmStats, OdmStatsSnapshot, PullFailureReason};
|
||||||
use crate::bucket::remote_s3_client::{
|
use crate::bucket::remote_s3_client::{
|
||||||
PathStyle as ClientPathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3RetryPolicy,
|
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.
|
// load on a source that is already failing.
|
||||||
retry: RemoteS3RetryPolicy::Disabled,
|
retry: RemoteS3RetryPolicy::Disabled,
|
||||||
bandwidth_limit: policy.bandwidth_limit_bytes_per_sec.and_then(NonZeroU64::new),
|
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::Rustfs => SourceProvider::Rustfs,
|
||||||
Provider::R2 => SourceProvider::R2,
|
Provider::R2 => SourceProvider::R2,
|
||||||
Provider::Gcs => SourceProvider::Gcs,
|
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,
|
session_token: None,
|
||||||
}),
|
}),
|
||||||
tls: TlsConfig::default(),
|
tls: TlsConfig::default(),
|
||||||
|
azure: None,
|
||||||
|
gcs: None,
|
||||||
},
|
},
|
||||||
filter: FilterConfig {
|
filter: FilterConfig {
|
||||||
prefix: prefix.map(str::to_string),
|
prefix: prefix.map(str::to_string),
|
||||||
|
|||||||
Reference in New Issue
Block a user