use std::time::Duration;
use time::OffsetDateTime;
use time::format_description::well_known::Iso8601;
use url::Url;
use crate::object_store::ObjectStoreError;
use crate::object_store::azure::auth::SasSigningKey;
pub(crate) const SAS_SIGNED_VERSION: &str = "2022-11-02";
const SAS_EXPIRY_FORMAT: time::format_description::well_known::iso8601::EncodedConfig = {
use time::format_description::well_known::iso8601::{Config, TimePrecision};
Config::DEFAULT
.set_time_precision(TimePrecision::Second {
decimal_digits: None,
})
.encode()
};
pub(crate) fn build_blob_sas_url(
base_url: &Url,
container: &str,
blob_path: &str,
signing: &SasSigningKey,
ttl: Duration,
) -> Result<String, ObjectStoreError> {
reject_control_chars("container", container)?;
reject_control_chars("blob_path", blob_path)?;
let ttl_secs = i64::try_from(ttl.as_secs()).map_err(|_| {
ObjectStoreError::Other(
format!("SAS ttl too large: {}s exceeds i64::MAX", ttl.as_secs()).into(),
)
})?;
let expiry = OffsetDateTime::now_utc()
.checked_add(time::Duration::seconds(ttl_secs))
.ok_or_else(|| {
ObjectStoreError::Other(format!("SAS expiry overflow: ttl={}s", ttl.as_secs()).into())
})?;
let signed_expiry = format_iso8601_utc(expiry);
let canonical_resource = format!("/blob/{}/{container}/{blob_path}", signing.account);
let signed_protocol = if base_url.scheme() == "https" {
"https"
} else {
"https,http"
};
let string_to_sign = format!(
"r\n\
\n\
{signed_expiry}\n\
{canonical_resource}\n\
\n\
\n\
{signed_protocol}\n\
{SAS_SIGNED_VERSION}\n\
b\n\
\n\
\n\
\n\
\n\
\n\
\n\
"
);
let signature_b64 = super::auth::hmac_sha256_base64(&string_to_sign, &signing.key)
.map_err(|e| ObjectStoreError::Other(e.into()))?;
let mut out = base_url.clone();
out.query_pairs_mut()
.append_pair("sv", SAS_SIGNED_VERSION)
.append_pair("sr", "b")
.append_pair("sp", "r")
.append_pair("se", &signed_expiry)
.append_pair("spr", signed_protocol)
.append_pair("sig", &signature_b64);
Ok(out.into())
}
fn format_iso8601_utc(t: OffsetDateTime) -> String {
t.format(&Iso8601::<SAS_EXPIRY_FORMAT>)
.expect("ISO-8601 second-precision format is infallible for OffsetDateTime + String")
}
fn reject_control_chars(field: &'static str, value: &str) -> Result<(), ObjectStoreError> {
if let Some(byte) = value.bytes().find(u8::is_ascii_control) {
return Err(ObjectStoreError::Other(
format!("SAS {field} contains forbidden control byte 0x{byte:02x}").into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::object_store::azure::auth::HmacKey;
const AZURITE_KEY: &str =
"Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==";
fn azurite_signing() -> SasSigningKey {
SasSigningKey {
account: "devstoreaccount1".to_owned(),
key: HmacKey::from_base64(AZURITE_KEY).expect("valid base64"),
}
}
fn query_pairs_btree(url: &Url) -> std::collections::BTreeMap<String, String> {
url.query_pairs()
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect()
}
#[test]
fn iso8601_formatter_drops_sub_second_precision_and_uses_z_suffix() {
let t = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp");
let s = format_iso8601_utc(t);
assert_eq!(s, "2023-11-14T22:13:20Z");
}
#[test]
fn build_blob_sas_url_appends_required_query_params() {
let base = Url::parse(
"https://devstoreaccount1.blob.core.windows.net/repo/refs/heads/main/0123abcd.bundle",
)
.expect("base URL parses");
let url = build_blob_sas_url(
&base,
"repo",
"refs/heads/main/0123abcd.bundle",
&azurite_signing(),
Duration::from_hours(1),
)
.expect("SAS URL builds");
let parsed = Url::parse(&url).expect("emitted URL parses");
let pairs = query_pairs_btree(&parsed);
assert_eq!(
pairs.get("sv").map(String::as_str),
Some(SAS_SIGNED_VERSION)
);
assert_eq!(pairs.get("sr").map(String::as_str), Some("b"));
assert_eq!(pairs.get("sp").map(String::as_str), Some("r"));
assert_eq!(pairs.get("spr").map(String::as_str), Some("https"));
assert!(
pairs.get("se").is_some_and(|s| s.ends_with('Z')),
"se must be ISO-8601 with Z suffix, got {:?}",
pairs.get("se"),
);
assert!(
pairs.get("sig").is_some_and(|s| !s.is_empty()),
"sig must be present and non-empty",
);
assert!(parsed.path().ends_with("0123abcd.bundle"), "{parsed}");
}
#[test]
#[allow(clippy::similar_names)]
fn build_blob_sas_url_with_http_base_signs_combined_protocol() {
let signing = azurite_signing();
let ttl = Duration::from_hours(1);
let http_base =
Url::parse("http://127.0.0.1:10000/devstoreaccount1/repo/refs/heads/main/aa.bundle")
.expect("http base parses");
let https_base = Url::parse(
"https://devstoreaccount1.blob.core.windows.net/repo/refs/heads/main/aa.bundle",
)
.expect("https base parses");
let http_url = build_blob_sas_url(
&http_base,
"repo",
"refs/heads/main/aa.bundle",
&signing,
ttl,
)
.expect("http SAS URL builds");
let https_url = build_blob_sas_url(
&https_base,
"repo",
"refs/heads/main/aa.bundle",
&signing,
ttl,
)
.expect("https SAS URL builds");
let http_parsed = Url::parse(&http_url).expect("http URL parses");
let http_pairs = query_pairs_btree(&http_parsed);
assert_eq!(
http_pairs.get("spr").map(String::as_str),
Some("https,http"),
"HTTP base URL → spr must be `https,http` (combined): {http_pairs:?}",
);
assert_ne!(
sig_param(&http_url),
sig_param(&https_url),
"signature must encode signed_protocol: HTTP and HTTPS bases must produce \
distinct signatures even with the same TTL/key/blob",
);
}
#[test]
fn build_blob_sas_url_signature_changes_with_blob_path() {
let base_a = Url::parse(
"https://devstoreaccount1.blob.core.windows.net/repo/refs/heads/main/aa.bundle",
)
.expect("a parses");
let base_b = Url::parse(
"https://devstoreaccount1.blob.core.windows.net/repo/refs/heads/main/bb.bundle",
)
.expect("b parses");
let signing = azurite_signing();
let ttl = Duration::from_hours(1);
let a = build_blob_sas_url(&base_a, "repo", "refs/heads/main/aa.bundle", &signing, ttl)
.expect("a signs");
let b = build_blob_sas_url(&base_b, "repo", "refs/heads/main/bb.bundle", &signing, ttl)
.expect("b signs");
let sig_a = sig_param(&a);
let sig_b = sig_param(&b);
assert_ne!(
sig_a, sig_b,
"signatures must differ for different blob paths",
);
}
#[test]
fn build_blob_sas_url_signature_changes_with_container() {
let base = Url::parse(
"https://devstoreaccount1.blob.core.windows.net/c1/refs/heads/main/aa.bundle",
)
.expect("base parses");
let signing = azurite_signing();
let ttl = Duration::from_hours(1);
let a = build_blob_sas_url(&base, "c1", "refs/heads/main/aa.bundle", &signing, ttl)
.expect("c1 signs");
let b = build_blob_sas_url(&base, "c2", "refs/heads/main/aa.bundle", &signing, ttl)
.expect("c2 signs");
assert_ne!(
sig_param(&a),
sig_param(&b),
"signatures must differ across containers (canonical-resource diverges)",
);
}
fn sig_param(url: &str) -> String {
Url::parse(url)
.expect("parses")
.query_pairs()
.find(|(k, _)| k == "sig")
.map(|(_, v)| v.into_owned())
.expect("sig present")
}
#[test]
fn build_blob_sas_url_rejects_newline_in_container() {
let base = Url::parse(
"https://devstoreaccount1.blob.core.windows.net/repo/refs/heads/main/aa.bundle",
)
.expect("base parses");
let err = build_blob_sas_url(
&base,
"repo\nspoofed",
"refs/heads/main/aa.bundle",
&azurite_signing(),
Duration::from_hours(1),
)
.expect_err("newline in container must be rejected");
assert!(
err.to_string().contains("container"),
"error message must name the rejecting field: {err}"
);
}
#[test]
fn build_blob_sas_url_rejects_cr_in_blob_path() {
let base = Url::parse(
"https://devstoreaccount1.blob.core.windows.net/repo/refs/heads/main/aa.bundle",
)
.expect("base parses");
let err = build_blob_sas_url(
&base,
"repo",
"refs/heads/main/aa\rbundle",
&azurite_signing(),
Duration::from_hours(1),
)
.expect_err("\\r in blob_path must be rejected");
assert!(
err.to_string().contains("blob_path"),
"error message must name the rejecting field: {err}"
);
}
#[test]
fn build_blob_sas_url_huge_ttl_returns_error_not_panic() {
let base = Url::parse(
"https://devstoreaccount1.blob.core.windows.net/repo/refs/heads/main/aa.bundle",
)
.expect("base parses");
let err = build_blob_sas_url(
&base,
"repo",
"refs/heads/main/aa.bundle",
&azurite_signing(),
Duration::MAX,
)
.expect_err("u64::MAX-class TTL must surface as an error, not panic");
let ObjectStoreError::Other(msg) = &err else {
panic!("expected ObjectStoreError::Other, got {err:?}");
};
let text = msg.to_string();
assert!(
text.contains("ttl too large"),
"error must name the path-A overflow wording, got {text:?}",
);
assert!(
text.contains(&u64::MAX.to_string()),
"error must name the offending TTL seconds, got {text:?}",
);
}
#[test]
fn build_blob_sas_url_expiry_overflow_returns_error_not_panic() {
use time::{Date, Time};
let base = Url::parse(
"https://devstoreaccount1.blob.core.windows.net/repo/refs/heads/main/aa.bundle",
)
.expect("base parses");
let max_utc = Date::MAX.with_time(Time::MAX).assume_utc();
let until_max_secs = (max_utc - OffsetDateTime::now_utc()).whole_seconds();
let ttl = Duration::from_secs(
u64::try_from(until_max_secs + 1).expect("OffsetDateTime::MAX is well after now_utc()"),
);
let err = build_blob_sas_url(
&base,
"repo",
"refs/heads/main/aa.bundle",
&azurite_signing(),
ttl,
)
.expect_err("path-B overflow must surface as an error, not panic");
let ObjectStoreError::Other(msg) = &err else {
panic!("expected ObjectStoreError::Other, got {err:?}");
};
let text = msg.to_string();
assert!(
text.contains("expiry overflow"),
"error must name the path-B overflow wording, got {text:?}",
);
assert!(
!text.contains("ttl too large"),
"path-B must not surface with the path-A wording, got {text:?}",
);
assert!(
text.contains(&ttl.as_secs().to_string()),
"error must name the offending TTL seconds, got {text:?}",
);
}
#[test]
fn build_blob_sas_url_accepts_normal_ascii_paths() {
let base = Url::parse(
"https://devstoreaccount1.blob.core.windows.net/repo/refs/heads/main/aa.bundle",
)
.expect("base parses");
build_blob_sas_url(
&base,
"repo",
"refs/heads/main/aa.bundle",
&azurite_signing(),
Duration::from_hours(1),
)
.expect("plain ASCII path must build");
}
}