use std::collections::HashMap;
use std::sync::Arc;
use std::{env, fmt, io};
use azure_core::credentials::{AccessToken, TokenCredential, TokenRequestOptions};
use azure_core::error::ErrorKind;
use azure_core::http::{
ClientOptions, HttpClient, HttpClientOptions, RequestContent, StatusCode, Transport, Url,
new_http_client,
};
use azure_core::time::{Duration, OffsetDateTime};
use azure_identity::DeveloperToolsCredential;
use azure_storage_blob::models::{
BlobClientUploadOptions, BlobContainerClientListBlobsOptions, StorageErrorCode,
};
use azure_storage_blob::{
BlobClient, BlobClientOptions, BlobContainerClient, BlobContainerClientOptions,
};
use cbh_diag::{Reporter, ReporterExt};
use futures::TryStreamExt as _;
use jiff::Timestamp;
use super::{
PendingInvalidation, Storage, StorageError, cache_epoch_key, github_oidc, validate_key,
};
const GZIP_CONTENT_ENCODING: &str = "gzip";
#[derive(Clone)]
pub struct AzureBlobStorage {
container_endpoint: Url,
credential: Arc<dyn TokenCredential>,
http_client: Arc<dyn HttpClient>,
invalidation: Arc<PendingInvalidation>,
}
impl fmt::Debug for AzureBlobStorage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AzureBlobStorage")
.field("endpoint", &self.container_endpoint.as_str())
.finish_non_exhaustive()
}
}
impl AzureBlobStorage {
pub(crate) fn from_config(
account: &str,
container: &str,
endpoint: Option<String>,
) -> Result<Self, StorageError> {
let container_endpoint = container_endpoint_url(account, container, endpoint)?;
let http_client = new_http_client(Some(HttpClientOptions {
automatic_decompression: false,
}));
let credential = entra_credential(&http_client)?;
let credential: Arc<dyn TokenCredential> = Arc::new(CachingCredential::new(credential));
Ok(Self {
container_endpoint,
credential,
http_client,
invalidation: Arc::new(PendingInvalidation::default()),
})
}
pub(crate) fn from_parts(
account: &str,
container: &str,
endpoint: Option<String>,
credential: Arc<dyn TokenCredential>,
http_client: Arc<dyn HttpClient>,
) -> Result<Self, StorageError> {
let container_endpoint = container_endpoint_url(account, container, endpoint)?;
Ok(Self {
container_endpoint,
credential,
http_client,
invalidation: Arc::new(PendingInvalidation::default()),
})
}
#[cfg_attr(test, mutants::skip)]
fn shared_client_options(&self) -> ClientOptions {
ClientOptions {
transport: Some(Transport::new(Arc::clone(&self.http_client))),
..Default::default()
}
}
#[cfg_attr(test, mutants::skip)]
fn blob_client(&self, key: &str) -> Result<BlobClient, StorageError> {
let mut url = self.container_endpoint.clone();
url.path_segments_mut()
.map_err(|()| io_error("Azure endpoint cannot be a base URL"))?
.extend(key.split('/'));
let options = BlobClientOptions {
client_options: self.shared_client_options(),
..Default::default()
};
BlobClient::new(url, Some(Arc::clone(&self.credential)), Some(options))
.map_err(|error| azure_io(&error))
}
#[cfg_attr(test, mutants::skip)]
fn container_client(&self) -> Result<BlobContainerClient, StorageError> {
let options = BlobContainerClientOptions {
client_options: self.shared_client_options(),
..Default::default()
};
BlobContainerClient::new(
self.container_endpoint.clone(),
Some(Arc::clone(&self.credential)),
Some(options),
)
.map_err(|error| azure_io(&error))
}
#[cfg_attr(test, mutants::skip)] async fn ensure_container(&self) -> Result<(), StorageError> {
let client = self.container_client()?;
match client.create(None).await {
Ok(_) => Ok(()),
Err(error) if matches!(classify(&error), Fault::AlreadyExists) => Ok(()),
Err(error) => Err(azure_io(&error)),
}
}
#[cfg_attr(test, mutants::skip)] async fn upload_with_retry(
&self,
client: &BlobClient,
bytes: &[u8],
key: &str,
if_not_exists: bool,
) -> Result<(), StorageError> {
match upload(client, bytes, if_not_exists).await {
Ok(()) => Ok(()),
Err(error) if matches!(classify(&error), Fault::ContainerMissing) => {
self.ensure_container().await?;
upload(client, bytes, if_not_exists)
.await
.map_err(|error| map_error(&error, key))
}
Err(error) => Err(map_error(&error, key)),
}
}
#[cfg_attr(test, mutants::skip)] pub(crate) async fn flush_pending_invalidation(
&self,
project: &str,
reporter: &dyn Reporter,
) -> Result<(), StorageError> {
if !self.invalidation.take() {
return Ok(());
}
let key = cache_epoch_key(project);
let token = Timestamp::now().to_string();
reporter.note_with(|| {
format!(
"cache: a delete or overwrite reached the cloud this command, so caches keyed on \
older data are now stale; bumping the invalidation marker {key} to epoch {token}"
)
});
let client = self.blob_client(&key)?;
let compressed = cbh_codec::compress(token.as_bytes());
self.upload_with_retry(&client, &compressed, &key, false)
.await
}
}
impl Storage for AzureBlobStorage {
#[cfg_attr(test, mutants::skip)] async fn put(&self, key: &str, bytes: &[u8]) -> Result<(), StorageError> {
validate_key(key)?;
let client = self.blob_client(key)?;
let compressed = cbh_codec::compress(bytes);
self.upload_with_retry(&client, &compressed, key, true)
.await
}
#[cfg_attr(test, mutants::skip)] async fn put_overwrite(&self, key: &str, bytes: &[u8]) -> Result<(), StorageError> {
validate_key(key)?;
let client = self.blob_client(key)?;
let compressed = cbh_codec::compress(bytes);
match self
.upload_with_retry(&client, &compressed, key, true)
.await
{
Ok(()) => Ok(()),
Err(StorageError::AlreadyExists { .. }) => {
self.upload_with_retry(&client, &compressed, key, false)
.await?;
self.invalidation.arm();
Ok(())
}
Err(other) => Err(other),
}
}
#[cfg_attr(test, mutants::skip)] async fn get(&self, key: &str) -> Result<Vec<u8>, StorageError> {
validate_key(key)?;
let client = self.blob_client(key)?;
match client.download(None).await {
Ok(response) => {
let bytes = response
.body
.collect()
.await
.map_err(|error| azure_io(&error))?;
cbh_codec::decompress(&bytes).map_err(StorageError::Io)
}
Err(error) if matches!(classify(&error), Fault::NotFound | Fault::ContainerMissing) => {
Err(StorageError::NotFound {
key: key.to_owned(),
})
}
Err(error) => Err(azure_io(&error)),
}
}
#[cfg_attr(test, mutants::skip)] async fn list(&self, prefix: &str) -> Result<Vec<String>, StorageError> {
let client = self.container_client()?;
let mut pager = client
.list_blobs(Some(BlobContainerClientListBlobsOptions {
prefix: Some(prefix.to_owned()),
..Default::default()
}))
.map_err(|error| azure_io(&error))?;
let mut keys = Vec::new();
loop {
match pager.try_next().await {
Ok(Some(item)) => {
if let Some(name) = item.name {
keys.push(name);
}
}
Ok(None) => break,
Err(error) if matches!(classify(&error), Fault::ContainerMissing) => {
return Ok(Vec::new());
}
Err(error) => return Err(azure_io(&error)),
}
}
keys.sort();
Ok(keys)
}
#[cfg_attr(test, mutants::skip)] async fn delete(&self, key: &str) -> Result<(), StorageError> {
validate_key(key)?;
let client = self.blob_client(key)?;
match client.delete(None).await {
Ok(_) => {
self.invalidation.arm();
Ok(())
}
Err(error) if matches!(classify(&error), Fault::NotFound | Fault::ContainerMissing) => {
Err(StorageError::NotFound {
key: key.to_owned(),
})
}
Err(error) => Err(azure_io(&error)),
}
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg_attr(test, mutants::skip)]
fn entra_credential(
http_client: &Arc<dyn HttpClient>,
) -> Result<Arc<dyn TokenCredential>, StorageError> {
entra_credential_from(|key| env::var(key).ok(), http_client)
}
fn entra_credential_from(
get: impl Fn(&str) -> Option<String>,
http_client: &Arc<dyn HttpClient>,
) -> Result<Arc<dyn TokenCredential>, StorageError> {
if let Some(result) = github_oidc::credential_from(get, http_client) {
return result;
}
developer_tools_credential()
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn developer_tools_credential() -> Result<Arc<dyn TokenCredential>, StorageError> {
let credential: Arc<dyn TokenCredential> =
DeveloperToolsCredential::new(None).map_err(|error| {
config_error(format!("could not initialize Entra ID credential: {error}"))
})?;
Ok(credential)
}
const TOKEN_REFRESH_MARGIN: Duration = Duration::minutes(5);
struct CachingCredential {
inner: Arc<dyn TokenCredential>,
cache: futures::lock::Mutex<HashMap<Vec<String>, AccessToken>>,
now: fn() -> OffsetDateTime,
}
impl fmt::Debug for CachingCredential {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CachingCredential")
.field("inner", &self.inner)
.finish_non_exhaustive()
}
}
impl CachingCredential {
fn new(inner: Arc<dyn TokenCredential>) -> Self {
Self::with_clock(inner, OffsetDateTime::now_utc)
}
fn with_clock(inner: Arc<dyn TokenCredential>, now: fn() -> OffsetDateTime) -> Self {
Self {
inner,
cache: futures::lock::Mutex::new(HashMap::new()),
now,
}
}
}
#[async_trait::async_trait]
impl TokenCredential for CachingCredential {
async fn get_token(
&self,
scopes: &[&str],
options: Option<TokenRequestOptions<'_>>,
) -> azure_core::Result<AccessToken> {
let cache_key: Vec<String> = scopes.iter().map(|scope| (*scope).to_owned()).collect();
let mut cache = self.cache.lock().await;
if let Some(token) = cache.get(&cache_key)
&& token_is_fresh(token, (self.now)())
{
return Ok(token.clone());
}
let token = self.inner.get_token(scopes, options).await?;
cache.insert(cache_key, token.clone());
Ok(token)
}
}
fn token_is_fresh(token: &AccessToken, now: OffsetDateTime) -> bool {
now.checked_add(TOKEN_REFRESH_MARGIN)
.is_some_and(|deadline| token.expires_on > deadline)
}
#[cfg_attr(test, mutants::skip)] async fn upload(client: &BlobClient, bytes: &[u8], if_not_exists: bool) -> azure_core::Result<()> {
let mut options = BlobClientUploadOptions {
blob_content_encoding: Some(GZIP_CONTENT_ENCODING.to_owned()),
..Default::default()
};
if if_not_exists {
options = options.if_not_exists();
}
client
.upload(RequestContent::from(bytes.to_vec()), Some(options))
.await?;
Ok(())
}
fn container_endpoint_url(
account: &str,
container: &str,
endpoint: Option<String>,
) -> Result<Url, StorageError> {
let endpoint = endpoint.unwrap_or_else(|| format!("https://{account}.blob.core.windows.net"));
let mut container_endpoint = Url::parse(&endpoint)
.map_err(|error| config_error(format!("invalid Azure endpoint {endpoint:?}: {error}")))?;
container_endpoint
.path_segments_mut()
.map_err(|()| config_error(format!("Azure endpoint {endpoint:?} cannot be a base URL")))?
.pop_if_empty()
.push(container);
if container_endpoint.scheme() != "https" {
return Err(config_error(
"Azure Entra ID authentication requires an https endpoint",
));
}
Ok(container_endpoint)
}
#[derive(Clone, Copy)]
enum Fault {
NotFound,
ContainerMissing,
AlreadyExists,
Other,
}
fn classify(error: &azure_core::Error) -> Fault {
let code = match error.kind() {
ErrorKind::HttpResponse { error_code, .. } => error_code.as_deref(),
_ => None,
};
if code == Some(StorageErrorCode::ContainerNotFound.as_ref()) {
return Fault::ContainerMissing;
}
match error.http_status() {
Some(StatusCode::NotFound) => Fault::NotFound,
Some(StatusCode::Conflict | StatusCode::PreconditionFailed) => Fault::AlreadyExists,
_ => Fault::Other,
}
}
fn map_error(error: &azure_core::Error, key: &str) -> StorageError {
match classify(error) {
Fault::NotFound | Fault::ContainerMissing => StorageError::NotFound {
key: key.to_owned(),
},
Fault::AlreadyExists => StorageError::AlreadyExists {
key: key.to_owned(),
},
Fault::Other => azure_io(error),
}
}
fn azure_io(error: &azure_core::Error) -> StorageError {
StorageError::Io(io::Error::other(format!("Azure Blob error: {error:?}")))
}
fn io_error(message: &'static str) -> StorageError {
StorageError::Io(io::Error::other(message))
}
fn config_error(message: impl Into<String>) -> StorageError {
StorageError::Config {
message: message.into(),
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use azure_core::http::headers::Headers;
use futures::executor::block_on;
use super::*;
fn http_error(status: StatusCode, error_code: Option<&str>) -> azure_core::Error {
ErrorKind::HttpResponse {
status,
error_code: error_code.map(str::to_owned),
raw_response: None,
}
.into_error()
}
#[test]
fn classify_maps_container_not_found_code_to_container_missing() {
let error = http_error(
StatusCode::NotFound,
Some(StorageErrorCode::ContainerNotFound.as_ref()),
);
assert!(matches!(classify(&error), Fault::ContainerMissing));
}
#[test]
fn classify_maps_not_found_status_without_container_code_to_not_found() {
let error = http_error(StatusCode::NotFound, None);
assert!(matches!(classify(&error), Fault::NotFound));
}
#[test]
fn classify_maps_conflict_status_to_already_exists() {
let error = http_error(StatusCode::Conflict, None);
assert!(matches!(classify(&error), Fault::AlreadyExists));
}
#[test]
fn classify_maps_precondition_failed_status_to_already_exists() {
let error = http_error(StatusCode::PreconditionFailed, None);
assert!(matches!(classify(&error), Fault::AlreadyExists));
}
#[test]
fn classify_maps_other_status_to_other() {
let error = http_error(StatusCode::InternalServerError, None);
assert!(matches!(classify(&error), Fault::Other));
}
#[test]
fn classify_maps_non_http_error_to_other() {
let error = ErrorKind::Io.into_error();
assert!(matches!(classify(&error), Fault::Other));
}
#[test]
fn map_error_distinguishes_not_found_already_exists_and_io() {
let key = "object";
assert!(matches!(
map_error(&http_error(StatusCode::NotFound, None), key),
StorageError::NotFound { .. }
));
assert!(matches!(
map_error(&http_error(StatusCode::Conflict, None), key),
StorageError::AlreadyExists { .. }
));
assert!(matches!(
map_error(&http_error(StatusCode::InternalServerError, None), key),
StorageError::Io(_)
));
}
const FAR_FUTURE_UNIX: i64 = 4_102_444_800;
fn fake_credential() -> Arc<dyn TokenCredential> {
Arc::new(CountingCredential::new(FAR_FUTURE_UNIX, false))
}
#[derive(Debug)]
struct UnusedHttpClient;
#[async_trait::async_trait]
impl HttpClient for UnusedHttpClient {
async fn execute_request(
&self,
_request: &azure_core::http::Request,
) -> azure_core::Result<azure_core::http::AsyncRawResponse> {
panic!("the stub HTTP client must not be used")
}
}
#[derive(Debug)]
struct ForbiddenHttpClient;
#[async_trait::async_trait]
impl HttpClient for ForbiddenHttpClient {
async fn execute_request(
&self,
_request: &azure_core::http::Request,
) -> azure_core::Result<azure_core::http::AsyncRawResponse> {
Ok(azure_core::http::AsyncRawResponse::from_bytes(
StatusCode::Forbidden,
Headers::new(),
azure_core::Bytes::new(),
))
}
}
fn storage_from_fake_parts(
account: &str,
container: &str,
endpoint: Option<String>,
) -> AzureBlobStorage {
AzureBlobStorage::from_parts(
account,
container,
endpoint,
fake_credential(),
Arc::new(UnusedHttpClient),
)
.unwrap()
}
#[test]
#[cfg_attr(
miri,
ignore = "builds a real HTTP client (reqwest) that Miri cannot run"
)]
fn from_config_defaults_to_the_public_https_endpoint() {
let storage = AzureBlobStorage::from_config("prod", "history", None).unwrap();
assert_eq!(storage.container_endpoint.query(), None);
assert_eq!(
storage.container_endpoint.as_str(),
"https://prod.blob.core.windows.net/history"
);
}
#[test]
fn blob_client_keeps_slash_separators_literal() {
let storage = storage_from_fake_parts(
"devstoreaccount1",
"bench-history",
Some("https://127.0.0.1:10000/devstoreaccount1".to_owned()),
);
let client = storage
.blob_client("v1/proj/objects/callgrind/run.json")
.unwrap();
assert_eq!(
client.url().path(),
"/devstoreaccount1/bench-history/v1/proj/objects/callgrind/run.json"
);
assert_eq!(client.url().query(), None);
}
#[test]
fn entra_over_http_is_a_config_error() {
let error = AzureBlobStorage::from_config(
"prod",
"history",
Some("http://insecure.example/account".to_owned()),
)
.unwrap_err();
match error {
StorageError::Config { message } => {
assert!(message.contains("https"), "{message}");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn from_parts_rejects_a_non_https_endpoint() {
let error = AzureBlobStorage::from_parts(
"devstoreaccount1",
"history",
Some("http://insecure.example/account".to_owned()),
fake_credential(),
Arc::new(UnusedHttpClient),
)
.unwrap_err();
match error {
StorageError::Config { message } => {
assert!(message.contains("https"), "{message}");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn azure_backend_from_parts_wraps_a_valid_endpoint_as_an_azure_override() {
let injected = crate::azure_backend_from_parts(
"devstoreaccount1",
"history",
Some("https://127.0.0.1:10000/devstoreaccount1".to_owned()),
fake_credential(),
Arc::new(UnusedHttpClient),
)
.unwrap();
assert!(matches!(injected.0, crate::StorageFacade::Azure(_)));
}
#[test]
fn invalid_endpoint_is_a_config_error() {
let error = AzureBlobStorage::from_config("prod", "history", Some("not a url".to_owned()))
.unwrap_err();
assert!(matches!(error, StorageError::Config { .. }), "{error:?}");
}
#[test]
fn container_endpoint_url_trailing_slash_does_not_double_up_segments() {
let url = container_endpoint_url(
"devstoreaccount1",
"bench-history",
Some("https://127.0.0.1:10000/devstoreaccount1/".to_owned()),
)
.unwrap();
assert_eq!(url.path(), "/devstoreaccount1/bench-history");
}
#[test]
fn put_and_get_reject_keys_that_escape_the_prefix() {
let storage = storage_from_fake_parts(
"prod",
"history",
Some("https://prod.blob.core.windows.net".to_owned()),
);
let put = block_on(storage.put("../bad", b"x")).unwrap_err();
assert!(matches!(put, StorageError::InvalidKey { .. }), "{put:?}");
let get = block_on(storage.get("../bad")).unwrap_err();
assert!(matches!(get, StorageError::InvalidKey { .. }), "{get:?}");
}
use std::pin::Pin;
use std::task::{Context, Poll};
use futures::future::join_all;
const BASE_UNIX: i64 = 1_000_000_000;
fn fixed_now() -> OffsetDateTime {
OffsetDateTime::from_unix_timestamp(BASE_UNIX).unwrap()
}
fn at_offset(offset: i64) -> OffsetDateTime {
OffsetDateTime::from_unix_timestamp(BASE_UNIX.saturating_add(offset)).unwrap()
}
struct YieldOnce(bool);
impl Future for YieldOnce {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
if self.0 {
Poll::Ready(())
} else {
self.0 = true;
cx.waker().wake_by_ref();
Poll::Pending
}
}
}
#[derive(Debug)]
struct CountingCredential {
calls: AtomicU64,
in_flight: AtomicU64,
max_in_flight: AtomicU64,
yield_during_acquire: bool,
expires_at_unix: i64,
}
impl CountingCredential {
fn new(expires_at_unix: i64, yield_during_acquire: bool) -> Self {
Self {
calls: AtomicU64::new(0),
in_flight: AtomicU64::new(0),
max_in_flight: AtomicU64::new(0),
yield_during_acquire,
expires_at_unix,
}
}
fn calls(&self) -> u64 {
self.calls.load(Ordering::Relaxed)
}
fn max_in_flight(&self) -> u64 {
self.max_in_flight.load(Ordering::Relaxed)
}
}
#[async_trait::async_trait]
impl TokenCredential for CountingCredential {
async fn get_token(
&self,
_scopes: &[&str],
_options: Option<TokenRequestOptions<'_>>,
) -> azure_core::Result<AccessToken> {
self.calls.fetch_add(1, Ordering::Relaxed);
let now = self.in_flight.fetch_add(1, Ordering::Relaxed) + 1;
self.max_in_flight.fetch_max(now, Ordering::Relaxed);
if self.yield_during_acquire {
YieldOnce(false).await;
}
self.in_flight.fetch_sub(1, Ordering::Relaxed);
Ok(AccessToken::new(
"fake-token",
OffsetDateTime::from_unix_timestamp(self.expires_at_unix).unwrap(),
))
}
}
#[test]
fn token_is_fresh_accepts_a_token_beyond_the_refresh_margin() {
let margin = TOKEN_REFRESH_MARGIN.whole_seconds();
let token = AccessToken::new("t", at_offset(margin + 1));
assert!(token_is_fresh(&token, fixed_now()));
}
#[test]
fn token_is_fresh_rejects_a_token_within_the_refresh_margin() {
let margin = TOKEN_REFRESH_MARGIN.whole_seconds();
let token = AccessToken::new("t", at_offset(margin - 1));
assert!(!token_is_fresh(&token, fixed_now()));
}
#[test]
fn token_is_fresh_rejects_a_token_exactly_at_the_refresh_margin() {
let margin = TOKEN_REFRESH_MARGIN.whole_seconds();
let token = AccessToken::new("t", at_offset(margin));
assert!(!token_is_fresh(&token, fixed_now()));
}
#[test]
fn token_is_fresh_rejects_an_expired_token() {
let token = AccessToken::new("t", at_offset(-1));
assert!(!token_is_fresh(&token, fixed_now()));
}
#[test]
fn counting_credential_observes_concurrency_when_unserialized() {
let inner = Arc::new(CountingCredential::new(
at_offset(3600).unix_timestamp(),
true,
));
let scopes = ["scope"];
let calls = std::iter::repeat_with(|| inner.get_token(&scopes, None)).take(8);
for result in block_on(join_all(calls)) {
result.unwrap();
}
assert_eq!(inner.calls(), 8);
assert!(inner.max_in_flight() > 1, "{}", inner.max_in_flight());
}
#[test]
fn caching_credential_collapses_a_concurrent_burst_into_one_acquisition() {
let inner = Arc::new(CountingCredential::new(
at_offset(3600).unix_timestamp(),
true,
));
let cred =
CachingCredential::with_clock(Arc::<CountingCredential>::clone(&inner), fixed_now);
let scopes = ["scope"];
let calls = std::iter::repeat_with(|| cred.get_token(&scopes, None)).take(8);
for result in block_on(join_all(calls)) {
result.unwrap();
}
assert_eq!(inner.calls(), 1);
assert_eq!(inner.max_in_flight(), 1);
}
#[test]
fn caching_credential_reuses_a_fresh_token() {
let inner = Arc::new(CountingCredential::new(
at_offset(3600).unix_timestamp(),
false,
));
let cred =
CachingCredential::with_clock(Arc::<CountingCredential>::clone(&inner), fixed_now);
let scopes = ["scope"];
block_on(cred.get_token(&scopes, None)).unwrap();
block_on(cred.get_token(&scopes, None)).unwrap();
assert_eq!(inner.calls(), 1);
}
#[test]
fn caching_credential_reacquires_an_expired_token() {
let inner = Arc::new(CountingCredential::new(
at_offset(-10).unix_timestamp(),
false,
));
let cred =
CachingCredential::with_clock(Arc::<CountingCredential>::clone(&inner), fixed_now);
let scopes = ["scope"];
block_on(cred.get_token(&scopes, None)).unwrap();
block_on(cred.get_token(&scopes, None)).unwrap();
assert_eq!(inner.calls(), 2);
}
#[test]
fn caching_credential_caches_each_scope_independently() {
let inner = Arc::new(CountingCredential::new(
at_offset(3600).unix_timestamp(),
false,
));
let cred =
CachingCredential::with_clock(Arc::<CountingCredential>::clone(&inner), fixed_now);
block_on(cred.get_token(&["scope-a"], None)).unwrap();
block_on(cred.get_token(&["scope-b"], None)).unwrap();
assert_eq!(inner.calls(), 2);
block_on(cred.get_token(&["scope-a"], None)).unwrap();
assert_eq!(inner.calls(), 2);
}
#[test]
fn caching_credential_debug_hides_the_cache_and_clock() {
let inner: Arc<dyn TokenCredential> = Arc::new(CountingCredential::new(
at_offset(3600).unix_timestamp(),
false,
));
let cred = CachingCredential::with_clock(inner, fixed_now);
let rendered = format!("{cred:?}");
assert!(rendered.contains("CachingCredential"), "{rendered}");
}
use std::net::{TcpStream, ToSocketAddrs as _};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration as StdDuration;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use jiff::Timestamp;
use serial_test::serial;
fn azurite_endpoint() -> String {
env::var("AZURITE_BLOB_ENDPOINT")
.unwrap_or_else(|_| "https://127.0.0.1:10000/devstoreaccount1".to_owned())
}
fn unique_container() -> String {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = Timestamp::now().as_nanosecond();
format!("bh-test-{nanos}-{n}")
}
fn azurite_reachable() -> bool {
let endpoint = azurite_endpoint();
let Ok(url) = Url::parse(&endpoint) else {
return false;
};
let host = url.host_str().unwrap_or("127.0.0.1").to_owned();
let port = url.port().unwrap_or(10000);
let Ok(addrs) = (host.as_str(), port).to_socket_addrs() else {
return false;
};
addrs
.into_iter()
.any(|addr| TcpStream::connect_timeout(&addr, StdDuration::from_secs(2)).is_ok())
}
#[derive(Debug)]
struct FakeEntraCredential;
#[async_trait::async_trait]
impl TokenCredential for FakeEntraCredential {
async fn get_token(
&self,
_scopes: &[&str],
_options: Option<TokenRequestOptions<'_>>,
) -> azure_core::Result<AccessToken> {
let now = OffsetDateTime::now_utc();
let expires = now
.checked_add(Duration::hours(1))
.expect("one hour past the current time is representable");
Ok(AccessToken::new(fake_entra_jwt(now, expires), expires))
}
}
fn fake_entra_jwt(now: OffsetDateTime, expires: OffsetDateTime) -> String {
let encode = |bytes: &[u8]| URL_SAFE_NO_PAD.encode(bytes);
let header = encode(br#"{"alg":"HS256","typ":"JWT"}"#);
let issued = now
.checked_sub(Duration::seconds(60))
.expect("60 seconds before the current time is representable")
.unix_timestamp();
let expiry = expires.unix_timestamp();
let payload = encode(
format!(
concat!(
"{{\"iss\":\"https://sts.windows.net/",
"00000000-0000-0000-0000-000000000000/\",",
"\"aud\":\"https://storage.azure.com\",",
"\"iat\":{issued},\"nbf\":{issued},\"exp\":{expiry}}}"
),
issued = issued,
expiry = expiry,
)
.as_bytes(),
);
let signature = encode(b"signature");
format!("{header}.{payload}.{signature}")
}
fn azurite_http_client() -> Arc<dyn HttpClient> {
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.no_gzip()
.build()
.expect("building the Azurite HTTP client");
Arc::new(client)
}
fn azurite_storage_or_skip() -> Option<AzureBlobStorage> {
if !azurite_reachable() {
assert!(
env::var_os("BENCH_HISTORY_REQUIRE_AZURITE").is_none(),
"BENCH_HISTORY_REQUIRE_AZURITE is set but no Azurite emulator is reachable at {}",
azurite_endpoint()
);
eprintln!(
"skipping Azurite network test: no emulator reachable at {}",
azurite_endpoint()
);
return None;
}
let storage = AzureBlobStorage::from_parts(
"devstoreaccount1",
&unique_container(),
Some(azurite_endpoint()),
Arc::new(FakeEntraCredential),
azurite_http_client(),
)
.unwrap();
Some(storage)
}
#[tokio::test]
#[cfg_attr(miri, ignore)]
#[cfg_attr(
mutants,
ignore = "Azurite network test: self-skips without an emulator (as under mutation), and the IO it exercises is already mutants::skip"
)]
#[serial]
async fn put_creates_the_container_then_get_and_list_round_trip() {
let Some(storage) = azurite_storage_or_skip() else {
return;
};
storage.put("v1/proj/a/1.json", b"first").await.unwrap();
storage.put("v1/proj/a/2.json", b"second").await.unwrap();
storage.put("v1/proj/b/3.json", b"third").await.unwrap();
assert_eq!(storage.get("v1/proj/a/1.json").await.unwrap(), b"first");
let listed = storage.list("v1/proj/a/").await.unwrap();
assert_eq!(
listed,
vec!["v1/proj/a/1.json".to_owned(), "v1/proj/a/2.json".to_owned()]
);
let all = storage.list("v1/proj/").await.unwrap();
assert_eq!(all.len(), 3, "{all:?}");
}
#[tokio::test]
#[cfg_attr(miri, ignore)]
#[cfg_attr(
mutants,
ignore = "Azurite network test: self-skips without an emulator (as under mutation), and the IO it exercises is already mutants::skip"
)]
#[serial]
async fn get_missing_blob_in_existing_container_reports_not_found() {
let Some(storage) = azurite_storage_or_skip() else {
return;
};
storage.put("v1/present.json", b"x").await.unwrap();
let error = storage.get("v1/absent.json").await.unwrap_err();
assert!(matches!(error, StorageError::NotFound { .. }), "{error:?}");
}
#[tokio::test]
#[cfg_attr(miri, ignore)]
#[cfg_attr(
mutants,
ignore = "Azurite network test: self-skips without an emulator (as under mutation), and the IO it exercises is already mutants::skip"
)]
#[serial]
async fn get_in_missing_container_reports_not_found() {
let Some(storage) = azurite_storage_or_skip() else {
return;
};
let error = storage.get("v1/absent.json").await.unwrap_err();
assert!(matches!(error, StorageError::NotFound { .. }), "{error:?}");
}
#[tokio::test]
#[cfg_attr(miri, ignore)]
#[cfg_attr(
mutants,
ignore = "Azurite network test: self-skips without an emulator (as under mutation), and the IO it exercises is already mutants::skip"
)]
#[serial]
async fn delete_removes_a_blob_and_leaves_siblings() {
let Some(storage) = azurite_storage_or_skip() else {
return;
};
storage.put("v1/proj/clean.json", b"c").await.unwrap();
storage.put("v1/proj/dirty.json", b"d").await.unwrap();
storage.delete("v1/proj/dirty.json").await.unwrap();
assert_eq!(
storage.list("v1/proj/").await.unwrap(),
vec!["v1/proj/clean.json".to_owned()]
);
let error = storage.get("v1/proj/dirty.json").await.unwrap_err();
assert!(matches!(error, StorageError::NotFound { .. }), "{error:?}");
}
#[tokio::test]
#[cfg_attr(miri, ignore)]
#[cfg_attr(
mutants,
ignore = "Azurite network test: self-skips without an emulator (as under mutation), and the IO it exercises is already mutants::skip"
)]
#[serial]
async fn delete_missing_blob_reports_not_found() {
let Some(storage) = azurite_storage_or_skip() else {
return;
};
storage.put("v1/present.json", b"x").await.unwrap();
let error = storage.delete("v1/absent.json").await.unwrap_err();
assert!(matches!(error, StorageError::NotFound { .. }), "{error:?}");
}
#[tokio::test]
#[cfg_attr(miri, ignore)]
#[cfg_attr(
mutants,
ignore = "Azurite network test: self-skips without an emulator (as under mutation), and the IO it exercises is already mutants::skip"
)]
#[serial]
async fn delete_in_missing_container_reports_not_found() {
let Some(storage) = azurite_storage_or_skip() else {
return;
};
let error = storage.delete("v1/absent.json").await.unwrap_err();
assert!(matches!(error, StorageError::NotFound { .. }), "{error:?}");
}
#[tokio::test]
#[cfg_attr(miri, ignore)]
#[cfg_attr(
mutants,
ignore = "Azurite network test: self-skips without an emulator (as under mutation), and the IO it exercises is already mutants::skip"
)]
#[serial]
async fn put_is_write_once() {
let Some(storage) = azurite_storage_or_skip() else {
return;
};
storage.put("v1/once.json", b"original").await.unwrap();
let error = storage
.put("v1/once.json", b"replacement")
.await
.unwrap_err();
assert!(
matches!(error, StorageError::AlreadyExists { .. }),
"{error:?}"
);
assert_eq!(storage.get("v1/once.json").await.unwrap(), b"original");
}
#[tokio::test]
#[cfg_attr(miri, ignore)]
#[cfg_attr(
mutants,
ignore = "Azurite network test: self-skips without an emulator (as under mutation), and the IO it exercises is already mutants::skip"
)]
#[serial]
async fn put_overwrite_replaces_an_existing_blob() {
let Some(storage) = azurite_storage_or_skip() else {
return;
};
storage.put("v1/clobber.json", b"original").await.unwrap();
storage
.put_overwrite("v1/clobber.json", b"replacement")
.await
.unwrap();
assert_eq!(
storage.get("v1/clobber.json").await.unwrap(),
b"replacement"
);
}
#[tokio::test]
#[cfg_attr(miri, ignore)]
#[cfg_attr(
mutants,
ignore = "Azurite network test: self-skips without an emulator (as under mutation), and the IO it exercises is already mutants::skip"
)]
#[serial]
async fn put_overwrite_creates_when_absent() {
let Some(storage) = azurite_storage_or_skip() else {
return;
};
storage
.put_overwrite("v1/fresh.json", b"only")
.await
.unwrap();
assert_eq!(storage.get("v1/fresh.json").await.unwrap(), b"only");
}
#[tokio::test]
#[cfg_attr(miri, ignore)]
#[cfg_attr(
mutants,
ignore = "Azurite network test: self-skips without an emulator (as under mutation), and the IO it exercises is already mutants::skip"
)]
#[serial]
async fn put_overwrite_creating_a_new_key_does_not_arm_invalidation() {
let Some(storage) = azurite_storage_or_skip() else {
return;
};
storage
.put_overwrite("v1/added.json", b"body")
.await
.unwrap();
assert!(
!storage.invalidation.take(),
"creating a new key must not arm invalidation"
);
}
#[tokio::test]
#[cfg_attr(miri, ignore)]
#[cfg_attr(
mutants,
ignore = "Azurite network test: self-skips without an emulator (as under mutation), and the IO it exercises is already mutants::skip"
)]
#[serial]
async fn put_overwrite_replacing_an_existing_key_arms_invalidation() {
let Some(storage) = azurite_storage_or_skip() else {
return;
};
storage.put("v1/replaced.json", b"original").await.unwrap();
assert!(
!storage.invalidation.take(),
"a write-once put must not arm invalidation"
);
storage
.put_overwrite("v1/replaced.json", b"replacement")
.await
.unwrap();
assert!(
storage.invalidation.take(),
"replacing an existing key must arm invalidation"
);
}
#[tokio::test]
#[cfg_attr(
miri,
ignore = "drives the Azure SDK request pipeline, which Miri cannot run"
)]
async fn put_overwrite_forwards_a_non_conflict_upload_error() {
let storage = AzureBlobStorage::from_parts(
"acct",
"history",
None,
fake_credential(),
Arc::new(ForbiddenHttpClient),
)
.unwrap();
let error = storage
.put_overwrite("v1/proj/object.json", b"body")
.await
.expect_err("a forbidden upload must surface as an error");
assert!(matches!(error, StorageError::Io(_)));
assert!(
!storage.invalidation.take(),
"a failed write-once probe must not arm invalidation"
);
}
#[tokio::test]
#[cfg_attr(miri, ignore)]
#[cfg_attr(
mutants,
ignore = "Azurite network test: self-skips without an emulator (as under mutation), and the IO it exercises is already mutants::skip"
)]
#[serial]
async fn list_on_missing_container_is_empty() {
let Some(storage) = azurite_storage_or_skip() else {
return;
};
assert!(storage.list("v1/").await.unwrap().is_empty());
}
#[tokio::test]
#[cfg_attr(miri, ignore)]
#[cfg_attr(
mutants,
ignore = "Azurite network test: self-skips without an emulator (as under mutation), and the IO it exercises is already mutants::skip"
)]
#[serial]
async fn list_with_a_non_matching_prefix_is_empty() {
let Some(storage) = azurite_storage_or_skip() else {
return;
};
storage.put("v1/proj/a.json", b"x").await.unwrap();
assert!(storage.list("v1/other/").await.unwrap().is_empty());
}
#[test]
#[cfg_attr(
miri,
ignore = "builds a real HTTP pipeline (reqwest) that Miri cannot run"
)]
fn entra_credential_uses_the_self_minting_oidc_path_when_all_vars_present() {
let http_client = new_http_client(None);
let get = |key: &str| match key {
"ACTIONS_ID_TOKEN_REQUEST_URL" => Some("https://example.test/token".to_owned()),
"ACTIONS_ID_TOKEN_REQUEST_TOKEN" => Some("request-token".to_owned()),
"AZURE_CLIENT_ID" => Some("11111111-1111-1111-1111-111111111111".to_owned()),
"AZURE_TENANT_ID" => Some("22222222-2222-2222-2222-222222222222".to_owned()),
_ => None,
};
entra_credential_from(get, &http_client).expect("self-minting OIDC credential builds");
}
#[test]
#[cfg_attr(
miri,
ignore = "constructs the developer credential chain (reqwest) that Miri cannot run"
)]
fn entra_credential_falls_back_to_the_developer_credential_without_oidc_vars() {
let http_client = new_http_client(None);
entra_credential_from(|_| None, &http_client)
.expect("developer credential chain constructs");
}
}