use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use chrono::{DateTime, SecondsFormat, Utc};
use reqwest::{Method, header::AUTHORIZATION};
use serde::Deserialize;
use crate::retry::RetryExt;
use crate::service::{HttpService, make_service};
use crate::util::hmac_sha256;
use crate::{ClientOptions, Result, RetryConfig};
const SAS_VERSION: &str = "2020-12-06";
const SAS_SIGNED_RESOURCE: &str = "c";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub(crate) struct UserDelegationKey {
pub signed_oid: String,
pub signed_tid: String,
pub signed_start: String,
pub signed_expiry: String,
pub signed_service: String,
pub signed_version: String,
pub value: String,
}
pub(crate) async fn fetch_user_delegation_key(
account: &str,
bearer_token: &str,
start: DateTime<Utc>,
expiry: DateTime<Utc>,
) -> Result<UserDelegationKey> {
let url =
format!("https://{account}.blob.core.windows.net/?restype=service&comp=userdelegationkey");
let body = format!(
"<KeyInfo><Start>{}</Start><Expiry>{}</Expiry></KeyInfo>",
start.to_rfc3339_opts(SecondsFormat::Secs, true),
expiry.to_rfc3339_opts(SecondsFormat::Secs, true),
);
let client = ClientOptions::default().client()?;
let service: Arc<dyn HttpService> = make_service(client.clone(), None);
let retry = RetryConfig::default();
let text = client
.request(Method::POST, &url)
.header(AUTHORIZATION, format!("Bearer {bearer_token}"))
.header("x-ms-version", SAS_VERSION)
.header(reqwest::header::CONTENT_TYPE, "application/xml")
.body(body)
.retryable(&retry, service)
.idempotent(true)
.send()
.await
.map_err(|e| crate::Error::Generic {
source: Box::new(e),
})?
.text()
.await
.map_err(|e| crate::Error::Generic {
source: Box::new(e),
})?;
quick_xml::de::from_str::<UserDelegationKey>(&text).map_err(|e| crate::Error::Generic {
source: e.to_string().into(),
})
}
fn signed_directory_depth(prefix: &str) -> Option<usize> {
let depth = prefix.split('/').filter(|s| !s.is_empty()).count();
if depth == 0 { None } else { Some(depth) }
}
pub(crate) fn build_user_delegation_sas(
account: &str,
container: &str,
prefix: &str,
key: &UserDelegationKey,
expiry: DateTime<Utc>,
permissions: &str,
) -> Result<String> {
let start = Utc::now();
let start_str = start.to_rfc3339_opts(SecondsFormat::Secs, true);
let expiry_str = expiry.to_rfc3339_opts(SecondsFormat::Secs, true);
let depth = signed_directory_depth(prefix);
let canonicalized_resource = if depth.is_some() && !prefix.is_empty() {
let clean = prefix.trim_matches('/');
format!("/blob/{account}/{container}/{clean}")
} else {
format!("/blob/{account}/{container}")
};
let sdd_field = depth.map(|d| d.to_string()).unwrap_or_default();
let string_to_sign = format!(
"{permissions}\n{start}\n{expiry}\n{resource}\n{oid}\n{tid}\n{skt}\n{ske}\n{sks}\n{skv}\n\n\n\nhttps\n{version}\n{sr}\n\n\n\n\n\n\n\n{sdd}",
permissions = permissions,
start = start_str,
expiry = expiry_str,
resource = canonicalized_resource,
oid = key.signed_oid,
tid = key.signed_tid,
skt = key.signed_start,
ske = key.signed_expiry,
sks = key.signed_service,
skv = key.signed_version,
version = SAS_VERSION,
sr = SAS_SIGNED_RESOURCE,
sdd = sdd_field,
);
let key_bytes = BASE64
.decode(&key.value)
.map_err(|e| crate::Error::Generic { source: e.into() })?;
let signature = BASE64.encode(hmac_sha256(&key_bytes, string_to_sign.as_bytes()).as_ref());
let mut sas = format!(
"sv={version}&se={expiry}&sp={permissions}&spr=https&sr={resource}\
&skoid={skoid}&sktid={sktid}&skt={skt}&ske={ske}&sks={sks}&skv={skv}\
&sig={sig}",
version = SAS_VERSION,
expiry = url_encode(&expiry_str),
permissions = permissions,
resource = SAS_SIGNED_RESOURCE,
skoid = key.signed_oid,
sktid = key.signed_tid,
skt = url_encode(&key.signed_start),
ske = url_encode(&key.signed_expiry),
sks = key.signed_service,
skv = key.signed_version,
sig = url_encode(&signature),
);
if let Some(d) = depth {
sas.push_str(&format!("&sdd={d}"));
}
Ok(sas)
}
pub(crate) fn build_storage_key_sas(
account: &str,
container: &str,
prefix: &str,
account_key_b64: &str,
expiry: DateTime<Utc>,
permissions: &str,
emulator: bool,
) -> Result<String> {
let start = Utc::now();
let start_str = start.to_rfc3339_opts(SecondsFormat::Secs, true);
let expiry_str = expiry.to_rfc3339_opts(SecondsFormat::Secs, true);
let depth = if emulator {
None
} else {
signed_directory_depth(prefix)
};
let canonicalized_resource = if depth.is_some() && !prefix.is_empty() {
let clean = prefix.trim_matches('/');
format!("/blob/{account}/{container}/{clean}")
} else {
format!("/blob/{account}/{container}")
};
let protocol = if emulator { "https,http" } else { "https" };
let base_string_to_sign = format!(
"{permissions}\n{start}\n{expiry}\n{resource}\n\n\n{protocol}\n{version}\n{sr}\n\n\n\n\n\n\n",
permissions = permissions,
start = start_str,
expiry = expiry_str,
resource = canonicalized_resource,
protocol = protocol,
version = SAS_VERSION,
sr = SAS_SIGNED_RESOURCE,
);
let string_to_sign = match depth {
Some(d) => format!("{base_string_to_sign}\n{d}"),
None => base_string_to_sign,
};
let key_bytes = BASE64
.decode(account_key_b64)
.map_err(|e| crate::Error::Generic { source: e.into() })?;
let signature = BASE64.encode(hmac_sha256(&key_bytes, string_to_sign.as_bytes()).as_ref());
let mut sas = format!(
"sv={version}&st={start}&se={expiry}&sp={permissions}&spr={protocol}&sr={resource}&sig={sig}",
version = SAS_VERSION,
start = url_encode(&start_str),
expiry = url_encode(&expiry_str),
permissions = permissions,
protocol = protocol,
resource = SAS_SIGNED_RESOURCE,
sig = url_encode(&signature),
);
if let Some(d) = depth {
sas.push_str(&format!("&sdd={d}"));
}
Ok(sas)
}
fn url_encode(s: &str) -> String {
percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string()
}
pub(crate) const SAS_READ: &str = "rl";
pub(crate) const SAS_READ_WRITE: &str = "racwdl";
pub const DEFAULT_TTL_SECS: u64 = 3600;
pub(crate) fn sas_expiry(ttl_secs: u64) -> DateTime<Utc> {
let now = SystemTime::now();
let expiry = now + Duration::from_secs(ttl_secs);
let secs = expiry
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
DateTime::from_timestamp(secs as i64, 0).unwrap_or_else(Utc::now)
}
pub async fn generate_user_delegation_sas(
account: &str,
container: &str,
prefix: &str,
bearer_token: &str,
read_only: bool,
ttl_secs: u64,
) -> Result<String> {
let expiry = sas_expiry(ttl_secs);
let start = Utc::now();
let key = fetch_user_delegation_key(account, bearer_token, start, expiry).await?;
let permissions = if read_only { SAS_READ } else { SAS_READ_WRITE };
build_user_delegation_sas(account, container, prefix, &key, expiry, permissions)
}
pub fn generate_storage_key_sas(
account: &str,
container: &str,
prefix: &str,
account_key_b64: &str,
read_only: bool,
ttl_secs: u64,
emulator: bool,
) -> Result<String> {
let expiry = sas_expiry(ttl_secs);
let permissions = if read_only { SAS_READ } else { SAS_READ_WRITE };
build_storage_key_sas(
account,
container,
prefix,
account_key_b64,
expiry,
permissions,
emulator,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_signed_directory_depth_empty() {
assert_eq!(signed_directory_depth(""), None);
assert_eq!(signed_directory_depth("/"), None);
}
#[test]
fn test_signed_directory_depth_segments() {
assert_eq!(signed_directory_depth("data"), Some(1));
assert_eq!(signed_directory_depth("data/2024"), Some(2));
assert_eq!(signed_directory_depth("data/2024/"), Some(2));
assert_eq!(signed_directory_depth("/data/2024/events"), Some(3));
}
#[test]
fn test_sas_permissions_read() {
assert_eq!(SAS_READ, "rl");
}
#[test]
fn test_sas_permissions_read_write() {
assert_eq!(SAS_READ_WRITE, "racwdl");
}
const TEST_KEY: &str =
"Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==";
#[test]
fn test_storage_key_sas_no_prefix_no_sdd() {
let sas =
generate_storage_key_sas("devstoreaccount1", "test", "", TEST_KEY, true, 3600, false)
.expect("SAS generation failed");
assert!(sas.contains("sv="), "missing sv");
assert!(sas.contains("se="), "missing se");
assert!(sas.contains("sp=rl"), "expected read permissions");
assert!(sas.contains("sig="), "missing sig");
assert!(sas.contains("spr=https"), "non-emulator SAS is https-only");
assert!(
!sas.contains("sdd="),
"should not have sdd for empty prefix"
);
}
#[test]
fn test_storage_key_sas_with_prefix_has_sdd() {
let sas = generate_storage_key_sas(
"devstoreaccount1",
"test",
"data/events",
TEST_KEY,
true,
3600,
false,
)
.expect("SAS generation failed");
assert!(sas.contains("sdd=2"), "expected sdd=2 for data/events");
}
#[test]
fn test_storage_key_sas_read_write_permissions() {
let sas =
generate_storage_key_sas("devstoreaccount1", "test", "", TEST_KEY, false, 3600, false)
.expect("SAS generation failed");
assert!(sas.contains("sp=racwdl"), "expected read-write permissions");
}
#[test]
fn test_storage_key_sas_emulator_allows_http_and_omits_sdd() {
let sas = generate_storage_key_sas(
"devstoreaccount1",
"lakehouse",
"sales/orders",
TEST_KEY,
false,
3600,
true,
)
.expect("SAS generation failed");
assert!(
sas.contains("spr=https%2Chttp") || sas.contains("spr=https,http"),
"emulator SAS must permit http: {sas}"
);
assert!(
!sas.contains("sdd="),
"emulator SAS must omit sdd (flat namespace): {sas}"
);
assert!(sas.contains("sp=racwdl"), "expected read-write permissions");
}
}