use std::sync::Arc;
use async_trait::async_trait;
use azure_core::credentials::TokenCredential;
use azure_core::http::RequestContent;
use azure_storage_blob::models::{
BlobClientDeleteOptions, BlobClientGetPropertiesResultHeaders,
BlobContainerClientListBlobsOptions,
};
use azure_storage_blob::{BlobClient, BlobContainerClient};
use futures_util::TryStreamExt;
use url::Url;
use crate::error::{Result, VaultError};
use crate::storage::StorageBackend;
pub struct AzureBackend {
container: BlobContainerClient,
container_url: Url,
credential: Option<Arc<dyn TokenCredential>>,
prefix: String,
}
fn config_err(msg: impl Into<String>) -> VaultError {
VaultError::ConfigError(msg.into())
}
fn storage_err(op: &str, e: &impl std::fmt::Display) -> VaultError {
VaultError::StorageError(format!("Azure {op} failed: {e}"))
}
fn entra_credential() -> Result<Arc<dyn TokenCredential>> {
let tenant = std::env::var("AZURE_TENANT_ID").ok();
let client = std::env::var("AZURE_CLIENT_ID").ok();
let secret = std::env::var("AZURE_CLIENT_SECRET").ok();
if let (Some(tenant), Some(client), Some(secret)) = (tenant, client, secret) {
return azure_identity::ClientSecretCredential::new(&tenant, client, secret.into(), None)
.map(|c| c as Arc<dyn TokenCredential>)
.map_err(|e| config_err(format!("Invalid Entra ID service principal: {e}")));
}
azure_identity::DeveloperToolsCredential::new(None)
.map(|c| c as Arc<dyn TokenCredential>)
.map_err(|e| {
config_err(format!(
"No Azure credentials found. Set AZURE_STORAGE_SAS_TOKEN, or configure Entra ID \
(AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET), or sign in with \
`az login`: {e}"
))
})
}
fn is_not_found(e: &azure_core::Error) -> bool {
if let Some(status) = e.http_status() {
return status == azure_core::http::StatusCode::NotFound;
}
e.to_string().contains("BlobNotFound")
}
impl AzureBackend {
pub async fn new(account: String, container: String, prefix: Option<String>) -> Result<Self> {
if std::env::var("AZURE_STORAGE_KEY").is_ok()
&& std::env::var("AZURE_STORAGE_SAS_TOKEN").is_err()
{
return Err(config_err(
"AZURE_STORAGE_KEY (shared key) is no longer supported — the Azure SDK for \
Rust v1 provides no shared-key credential. Either set \
AZURE_STORAGE_SAS_TOKEN to a SAS generated from that key \
(`az storage container generate-sas`), or authenticate with Entra ID by \
setting AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_CLIENT_SECRET.",
));
}
let host = if account.contains("://") {
account.trim_end_matches('/').to_string()
} else {
format!("https://{account}.blob.core.windows.net")
};
let mut container_url = Url::parse(&format!("{host}/{container}"))
.map_err(|e| config_err(format!("Invalid Azure container URL: {e}")))?;
let credential: Option<Arc<dyn TokenCredential>> =
if let Ok(sas) = std::env::var("AZURE_STORAGE_SAS_TOKEN") {
container_url.set_query(Some(sas.trim_start_matches('?')));
None
} else {
Some(entra_credential()?)
};
let container_client =
BlobContainerClient::new(container_url.clone(), credential.clone(), None)
.map_err(|e| config_err(format!("Failed to create Azure client: {e}")))?;
Ok(Self {
container: container_client,
container_url,
credential,
prefix: prefix.unwrap_or_default(),
})
}
fn get_blob_name(&self, key: &str) -> String {
if self.prefix.is_empty() {
key.to_string()
} else {
format!("{}/{}", self.prefix.trim_end_matches('/'), key)
}
}
fn blob_client(&self, key: &str) -> Result<BlobClient> {
let mut url = self.container_url.clone();
{
let mut segments = url
.path_segments_mut()
.map_err(|()| config_err("Azure container URL cannot be a base"))?;
for part in self.get_blob_name(key).split('/') {
segments.push(part);
}
}
BlobClient::new(url, self.credential.clone(), None)
.map_err(|e| storage_err("client construction", &e))
}
}
#[async_trait]
impl StorageBackend for AzureBackend {
async fn upload(&self, key: &str, data: &[u8]) -> Result<()> {
let client = self.blob_client(key)?;
client
.upload(RequestContent::from(data.to_vec()), None)
.await
.map_err(|e| storage_err("upload", &e))?;
Ok(())
}
async fn download(&self, key: &str) -> Result<Vec<u8>> {
let client = self.blob_client(key)?;
let response = client.download(None).await.map_err(|e| {
if is_not_found(&e) {
VaultError::ModelNotFound(key.to_string())
} else {
storage_err("download", &e)
}
})?;
let body = response
.body
.collect()
.await
.map_err(|e| storage_err("download body read", &e))?;
Ok(body.to_vec())
}
async fn delete(&self, key: &str) -> Result<bool> {
let client = self.blob_client(key)?;
match client
.delete(Some(BlobClientDeleteOptions::default()))
.await
{
Ok(_) => Ok(true),
Err(e) if is_not_found(&e) => Ok(false),
Err(e) => Err(storage_err("delete", &e)),
}
}
async fn exists(&self, key: &str) -> Result<bool> {
let client = self.blob_client(key)?;
client
.exists()
.await
.map_err(|e| storage_err("existence check", &e))
}
async fn list(&self) -> Result<Vec<String>> {
let mut options = BlobContainerClientListBlobsOptions::default();
if !self.prefix.is_empty() {
options.prefix = Some(self.prefix.clone());
}
let mut blobs = self
.container
.list_blobs(Some(options))
.map_err(|e| storage_err("list", &e))?;
let mut keys = Vec::new();
while let Some(blob) = blobs
.try_next()
.await
.map_err(|e| storage_err("list", &e))?
{
let Some(name) = blob.name else { continue };
let clean = if self.prefix.is_empty() {
name
} else {
name.strip_prefix(&format!("{}/", self.prefix.trim_end_matches('/')))
.unwrap_or(&name)
.to_string()
};
keys.push(clean);
}
Ok(keys)
}
async fn size(&self, key: &str) -> Result<u64> {
let client = self.blob_client(key)?;
let response = client.get_properties(None).await.map_err(|e| {
if is_not_found(&e) {
VaultError::ModelNotFound(key.to_string())
} else {
storage_err("properties", &e)
}
})?;
response
.content_length()
.map_err(|e| storage_err("properties", &e))?
.ok_or_else(|| storage_err("properties", &"blob reported no Content-Length"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_credential_resolution() {
std::env::set_var("AZURE_STORAGE_KEY", "dGVzdGtleQ==");
std::env::remove_var("AZURE_STORAGE_SAS_TOKEN");
let msg = match AzureBackend::new("acct".into(), "container".into(), None).await {
Err(e) => e.to_string(),
Ok(_) => panic!("shared key must be rejected"),
};
assert!(msg.contains("AZURE_STORAGE_SAS_TOKEN"), "got: {msg}");
assert!(msg.contains("AZURE_CLIENT_ID"), "got: {msg}");
std::env::set_var("AZURE_STORAGE_SAS_TOKEN", "sv=2022-11-02&sig=deadbeef");
let backend = AzureBackend::new("acct".into(), "container".into(), Some("p".into()))
.await
.expect("a SAS should authenticate without any Entra ID setup");
assert!(backend.credential.is_none());
assert_eq!(
backend.container_url.query(),
Some("sv=2022-11-02&sig=deadbeef")
);
assert_eq!(backend.get_blob_name("m.bin"), "p/m.bin");
std::env::set_var("AZURE_STORAGE_SAS_TOKEN", "sig=abc");
let backend = AzureBackend::new("acct".into(), "cont".into(), Some("models".into()))
.await
.unwrap();
let client = backend.blob_client("a/b.bin").unwrap();
let url = client.url();
assert_eq!(url.path(), "/cont/models/a/b.bin");
assert_eq!(
url.query(),
Some("sig=abc"),
"SAS must survive on blob URLs"
);
std::env::remove_var("AZURE_STORAGE_KEY");
std::env::remove_var("AZURE_STORAGE_SAS_TOKEN");
}
#[tokio::test]
#[ignore = "requires live Azure credentials and a test container"]
async fn test_azure_backend() {
let account = std::env::var("TEST_AZURE_ACCOUNT").unwrap();
let container = std::env::var("TEST_AZURE_CONTAINER").unwrap();
let backend = AzureBackend::new(account, container, Some("test-aimodelvault".to_string()))
.await
.unwrap();
let data = b"test data";
backend.upload("test.txt", data).await.unwrap();
assert!(backend.exists("test.txt").await.unwrap());
let retrieved = backend.download("test.txt").await.unwrap();
assert_eq!(data, &retrieved[..]);
assert_eq!(backend.size("test.txt").await.unwrap(), data.len() as u64);
assert!(backend.delete("test.txt").await.unwrap());
}
}