1use std::borrow::Cow;
29use std::collections::BTreeMap;
30use std::env;
31use std::sync::Arc;
32
33use async_trait::async_trait;
34use azure_core::credentials::TokenCredential;
35use azure_core::http::Method;
36use azure_core::http::headers::{HeaderName, Headers};
37use azure_core::http::policies::{Policy, PolicyResult};
38use azure_core::http::{Context, Request};
39use azure_identity::DeveloperToolsCredential;
40use base64::Engine;
41use base64::engine::general_purpose::STANDARD as BASE64;
42use hmac::{Hmac, Mac};
43use sha2::Sha256;
44use time::OffsetDateTime;
45use time::format_description::BorrowedFormatItem;
46use time::macros::format_description;
47use url::Url;
48
49use crate::object_store::ObjectStoreError;
50use crate::object_store::error::other_boxed;
51use crate::url::RemoteFlags;
52
53const X_MS_DATE_FORMAT: &[BorrowedFormatItem<'_>] = format_description!(
63 "[weekday repr:short], [day padding:zero] [month repr:short] [year] \
64 [hour padding:zero]:[minute padding:zero]:[second padding:zero] GMT"
65);
66
67pub(crate) struct ResolvedCredentials {
75 pub token_credential: Option<Arc<dyn TokenCredential>>,
77 pub per_try_policy: Option<Arc<dyn Policy>>,
80 pub sas_signing_key: Option<SasSigningKey>,
87}
88
89#[derive(Clone, Debug)]
97pub(crate) struct SasSigningKey {
98 pub account: String,
99 pub key: HmacKey,
100}
101
102#[derive(Clone)]
115pub struct HmacKey {
116 bytes: Vec<u8>,
117}
118
119impl HmacKey {
120 pub fn from_base64(key_b64: &str) -> Result<Self, ObjectStoreError> {
129 let bytes = BASE64.decode(key_b64.as_bytes()).map_err(|e| {
130 ObjectStoreError::Other(format!("AccountKey is not valid base64: {e}").into())
131 })?;
132 Ok(Self { bytes })
133 }
134
135 fn as_bytes(&self) -> &[u8] {
137 &self.bytes
138 }
139}
140
141impl std::fmt::Debug for HmacKey {
142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 f.debug_struct("HmacKey")
144 .field("bytes", &"<redacted>")
145 .finish()
146 }
147}
148
149pub(crate) fn resolve(
151 account: &str,
152 flags: &RemoteFlags,
153) -> Result<ResolvedCredentials, ObjectStoreError> {
154 if let Some(alias) = flags.credential.as_deref() {
155 return resolve_alias(account, alias);
156 }
157 let cred = DeveloperToolsCredential::new(None).map_err(other_boxed)?;
158 Ok(ResolvedCredentials {
159 token_credential: Some(cred),
160 per_try_policy: None,
161 sas_signing_key: None,
162 })
163}
164
165fn resolve_alias(account: &str, alias: &str) -> Result<ResolvedCredentials, ObjectStoreError> {
166 if !is_valid_alias(alias) {
167 return Err(ObjectStoreError::Other(
168 format!(
169 "invalid credential alias `{alias}`: \
170 must match [A-Za-z0-9_]+ (used to build env var names)"
171 )
172 .into(),
173 ));
174 }
175 let upper = alias.to_ascii_uppercase();
176 let key_var = format!("AZSTORE_{upper}_KEY");
177 let conn_var = format!("AZSTORE_{upper}_CONNECTION_STRING");
178 let sas_var = format!("AZSTORE_{upper}_SAS");
179
180 if let Some(key_b64) = lookup_env(&key_var)? {
181 let policy = SharedKeySigningPolicy::new(account, &key_b64)?;
182 let key = HmacKey::from_base64(&key_b64)?;
183 return Ok(resolved(
184 Arc::new(policy),
185 Some(SasSigningKey {
186 account: account.to_owned(),
187 key,
188 }),
189 ));
190 }
191 if let Some(conn) = lookup_env(&conn_var)? {
192 let parsed = parse_connection_string(&conn)?;
193 let policy = SharedKeySigningPolicy::new(&parsed.account, &parsed.key_b64)?;
194 let key = HmacKey::from_base64(&parsed.key_b64)?;
195 return Ok(resolved(
196 Arc::new(policy),
197 Some(SasSigningKey {
198 account: parsed.account,
199 key,
200 }),
201 ));
202 }
203 if let Some(sas) = lookup_env(&sas_var)? {
204 let policy = SasSigningPolicy::new(&sas)?;
205 return Ok(resolved(Arc::new(policy), None));
209 }
210
211 Err(ObjectStoreError::Other(
212 format!(
213 "credential alias `{alias}` has no env var set: \
214 expected {key_var}, {conn_var}, or {sas_var}"
215 )
216 .into(),
217 ))
218}
219
220fn lookup_env(name: &str) -> Result<Option<String>, ObjectStoreError> {
227 match env::var(name) {
228 Ok(v) => Ok(Some(v)),
229 Err(env::VarError::NotPresent) => Ok(None),
230 Err(env::VarError::NotUnicode(_)) => Err(ObjectStoreError::Other(
231 format!("env var `{name}` is set but its value is not valid UTF-8").into(),
232 )),
233 }
234}
235
236fn resolved(
243 policy: Arc<dyn Policy>,
244 sas_signing_key: Option<SasSigningKey>,
245) -> ResolvedCredentials {
246 ResolvedCredentials {
247 token_credential: None,
248 per_try_policy: Some(policy),
249 sas_signing_key,
250 }
251}
252
253fn is_valid_alias(s: &str) -> bool {
254 !s.is_empty() && s.len() <= 64 && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
255}
256
257#[derive(Debug)]
259pub(crate) struct ConnectionStringParts {
260 pub account: String,
261 pub key_b64: String,
262}
263
264pub(crate) fn parse_connection_string(
273 input: &str,
274) -> Result<ConnectionStringParts, ObjectStoreError> {
275 let mut account = None;
276 let mut key_b64 = None;
277 for segment in input.split(';') {
278 let segment = segment.trim();
279 if segment.is_empty() {
280 continue;
281 }
282 let Some((k, v)) = segment.split_once('=') else {
287 return Err(ObjectStoreError::Other(
288 format!("connection string segment `{segment}` is missing `=`").into(),
289 ));
290 };
291 match k {
292 "AccountName" => account = Some(v.to_owned()),
293 "AccountKey" => key_b64 = Some(v.to_owned()),
294 _ => {}
299 }
300 }
301 let account = account
302 .ok_or_else(|| ObjectStoreError::Other("connection string missing AccountName".into()))?;
303 let key_b64 = key_b64
304 .ok_or_else(|| ObjectStoreError::Other("connection string missing AccountKey".into()))?;
305 Ok(ConnectionStringParts { account, key_b64 })
306}
307
308#[derive(Debug)]
315pub(crate) struct SharedKeySigningPolicy {
316 account: String,
317 key: HmacKey,
318}
319
320impl SharedKeySigningPolicy {
321 pub(crate) fn new(account: &str, key_b64: &str) -> Result<Self, ObjectStoreError> {
322 let key = HmacKey::from_base64(key_b64)?;
327 Ok(Self {
328 account: account.to_owned(),
329 key,
330 })
331 }
332}
333
334#[async_trait]
335impl Policy for SharedKeySigningPolicy {
336 async fn send(
337 &self,
338 ctx: &Context,
339 request: &mut Request,
340 next: &[Arc<dyn Policy>],
341 ) -> PolicyResult {
342 let now = OffsetDateTime::now_utc();
347 let date = now.format(&X_MS_DATE_FORMAT).map_err(|e| {
348 azure_core::Error::with_message(
349 azure_core::error::ErrorKind::Other,
350 format!("failed to format x-ms-date: {e}"),
351 )
352 })?;
353 request.insert_header(HeaderName::from_static("x-ms-date"), date);
354
355 let method = request.method();
356 let url = request.url().clone();
357 let content_length = request_content_length(request);
358 let auth = compute_authorization(
359 &self.account,
360 &self.key,
361 method,
362 &url,
363 request.headers(),
364 content_length,
365 )
366 .map_err(|e| {
367 azure_core::Error::with_message(
368 azure_core::error::ErrorKind::Other,
369 format!("shared-key signing failed: {e}"),
370 )
371 })?;
372 request.insert_header(HeaderName::from_static("authorization"), auth);
373
374 forward_to_next(ctx, request, next, "shared-key").await
375 }
376}
377
378async fn forward_to_next(
383 ctx: &Context<'_>,
384 request: &mut Request,
385 next: &[Arc<dyn Policy>],
386 policy_name: &'static str,
387) -> PolicyResult {
388 match next.first() {
389 Some(p) => p.send(ctx, request, &next[1..]).await,
390 None => Err(azure_core::Error::with_message(
391 azure_core::error::ErrorKind::Other,
392 format!("{policy_name} policy installed without a downstream policy"),
393 )),
394 }
395}
396
397fn request_content_length(request: &Request) -> Option<u64> {
401 if let Some(s) = request
402 .headers()
403 .get_optional_str(&HeaderName::from_static("content-length"))
404 && let Ok(n) = s.parse::<u64>()
405 {
406 return if n == 0 { None } else { Some(n) };
407 }
408 match request.body().len() {
409 Some(0) | None => None,
410 Some(n) => Some(n),
411 }
412}
413
414pub fn compute_authorization(
430 account: &str,
431 key: &HmacKey,
432 method: Method,
433 url: &Url,
434 headers: &Headers,
435 content_length: Option<u64>,
436) -> Result<String, String> {
437 let canon_resource = canonicalized_resource(account, url);
438 let canon_headers = canonicalized_headers(headers);
439 let string_to_sign = string_to_sign(
440 method,
441 headers,
442 content_length,
443 &canon_headers,
444 &canon_resource,
445 );
446 let sig = hmac_sha256_base64(&string_to_sign, key)?;
447 Ok(format!("SharedKey {account}:{sig}"))
448}
449
450fn header_str<'a>(headers: &'a Headers, name: &'static str) -> Cow<'a, str> {
460 let raw = headers
461 .get_optional_str(&HeaderName::from_static(name))
462 .unwrap_or("");
463 let trimmed = raw.trim();
464 if trimmed.contains('\n') {
465 Cow::Owned(trimmed.replace('\n', " "))
466 } else {
467 Cow::Borrowed(trimmed)
468 }
469}
470
471fn string_to_sign(
473 method: Method,
474 headers: &Headers,
475 content_length: Option<u64>,
476 canon_headers: &str,
477 canon_resource: &str,
478) -> String {
479 let cl = content_length.map(|n| n.to_string()).unwrap_or_default();
480 format!(
481 "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}{}",
482 method.as_ref(),
483 header_str(headers, "content-encoding"),
484 header_str(headers, "content-language"),
485 cl,
486 header_str(headers, "content-md5"),
487 header_str(headers, "content-type"),
488 "",
491 header_str(headers, "if-modified-since"),
492 header_str(headers, "if-match"),
493 header_str(headers, "if-none-match"),
494 header_str(headers, "if-unmodified-since"),
495 header_str(headers, "range"),
496 canon_headers,
497 canon_resource,
498 )
499}
500
501fn canonicalized_headers(headers: &Headers) -> String {
503 let mut sorted: BTreeMap<String, String> = BTreeMap::new();
504 for (name, value) in headers.iter() {
505 let name = name.as_str().to_ascii_lowercase();
506 if !name.starts_with("x-ms-") {
507 continue;
508 }
509 let trimmed = value.as_str().trim();
513 let value: Cow<'_, str> = if trimmed.contains('\n') {
514 Cow::Owned(trimmed.replace('\n', " "))
515 } else {
516 Cow::Borrowed(trimmed)
517 };
518 sorted
519 .entry(name)
520 .and_modify(|existing| {
521 existing.push(',');
522 existing.push_str(&value);
523 })
524 .or_insert_with(|| value.into_owned());
525 }
526 let mut out = String::new();
527 for (name, value) in sorted {
528 out.push_str(&name);
529 out.push(':');
530 out.push_str(&value);
531 out.push('\n');
532 }
533 out
534}
535
536fn canonicalized_resource(account: &str, url: &Url) -> String {
538 let mut out = format!("/{account}");
539 let path = url.path();
540 if !path.starts_with('/') {
541 out.push('/');
542 }
543 out.push_str(path);
544
545 let mut grouped: BTreeMap<String, Vec<String>> = BTreeMap::new();
546 for (k, v) in url.query_pairs() {
547 let key = k.to_ascii_lowercase();
548 grouped.entry(key).or_default().push(v.into_owned());
549 }
550 for (name, mut values) in grouped {
551 values.sort_unstable();
552 out.push('\n');
553 out.push_str(&name);
554 out.push(':');
555 for (i, v) in values.iter().enumerate() {
556 if i > 0 {
557 out.push(',');
558 }
559 out.push_str(v);
560 }
561 }
562 out
563}
564
565pub(super) fn hmac_sha256_base64(data: &str, key: &HmacKey) -> Result<String, String> {
573 let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key.as_bytes())
574 .map_err(|e| format!("HMAC init: {e}"))?;
575 mac.update(data.as_bytes());
576 Ok(BASE64.encode(mac.finalize().into_bytes()))
577}
578
579#[derive(Debug)]
586pub(crate) struct SasSigningPolicy {
587 pairs: Vec<(String, String)>,
588}
589
590impl SasSigningPolicy {
591 pub(crate) fn new(sas: &str) -> Result<Self, ObjectStoreError> {
592 let trimmed = sas.trim().trim_start_matches('?');
593 if trimmed.is_empty() {
594 return Err(ObjectStoreError::Other("SAS token is empty".into()));
595 }
596 let parsed = Url::parse(&format!("https://example.invalid/?{trimmed}"))
597 .map_err(|e| ObjectStoreError::Other(format!("malformed SAS token: {e}").into()))?;
598 let pairs: Vec<(String, String)> = parsed
599 .query_pairs()
600 .map(|(k, v)| (k.into_owned(), v.into_owned()))
601 .collect();
602 if pairs.is_empty() {
603 return Err(ObjectStoreError::Other(
604 "SAS token has no query parameters".into(),
605 ));
606 }
607 Ok(Self { pairs })
608 }
609}
610
611#[async_trait]
612impl Policy for SasSigningPolicy {
613 async fn send(
614 &self,
615 ctx: &Context,
616 request: &mut Request,
617 next: &[Arc<dyn Policy>],
618 ) -> PolicyResult {
619 let url = request.url_mut();
620 let sas_keys: std::collections::HashSet<&str> =
621 self.pairs.iter().map(|(k, _)| k.as_str()).collect();
622 let preserved: Vec<(String, String)> = url
623 .query_pairs()
624 .filter_map(|(k, v)| {
625 if sas_keys.contains(k.as_ref()) {
626 None
627 } else {
628 Some((k.into_owned(), v.into_owned()))
629 }
630 })
631 .collect();
632 url.set_query(None);
633 {
634 let mut q = url.query_pairs_mut();
635 for (k, v) in &preserved {
636 q.append_pair(k, v);
637 }
638 for (k, v) in &self.pairs {
639 q.append_pair(k, v);
640 }
641 }
642
643 forward_to_next(ctx, request, next, "SAS").await
644 }
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650
651 #[test]
654 fn alias_charset() {
655 assert!(is_valid_alias("PROD"));
656 assert!(is_valid_alias("dev_1"));
657 assert!(!is_valid_alias(""));
658 assert!(!is_valid_alias("has-dash"));
659 assert!(!is_valid_alias("has space"));
660 assert!(!is_valid_alias(&"a".repeat(65)));
661 }
662
663 #[test]
664 fn parse_connection_string_extracts_account_and_key() {
665 let s = "DefaultEndpointsProtocol=http;\
666 AccountName=devstoreaccount1;\
667 AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;\
668 BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;";
669 let parts = parse_connection_string(s).expect("parses");
670 assert_eq!(parts.account, "devstoreaccount1");
671 assert!(parts.key_b64.starts_with("Eby8vdM"));
672 }
673
674 #[test]
675 fn parse_connection_string_requires_account_name() {
676 let s = "AccountKey=abc==;BlobEndpoint=http://x/";
677 let err = parse_connection_string(s).unwrap_err();
678 assert!(err.to_string().contains("AccountName"), "{err}");
679 }
680
681 #[test]
682 fn parse_connection_string_requires_account_key() {
683 let s = "AccountName=acct;BlobEndpoint=http://x/";
684 let err = parse_connection_string(s).unwrap_err();
685 assert!(err.to_string().contains("AccountKey"), "{err}");
686 }
687
688 #[test]
689 fn parse_connection_string_ignores_blank_segments() {
690 let s = ";;AccountName=acct;;AccountKey=YWJj;;";
691 let parts = parse_connection_string(s).expect("parses");
692 assert_eq!(parts.account, "acct");
693 assert_eq!(parts.key_b64, "YWJj");
694 }
695
696 #[test]
697 fn parse_connection_string_rejects_segment_without_equals() {
698 let s = "AccountName=acct;malformed;AccountKey=YWJj";
699 let err = parse_connection_string(s).unwrap_err();
700 assert!(
701 err.to_string().contains("malformed"),
702 "error names the bad segment: {err}"
703 );
704 }
705
706 #[test]
709 fn canon_resource_path_only() {
710 let url = Url::parse("https://acct.blob.core.windows.net/container/blob").unwrap();
711 let out = canonicalized_resource("acct", &url);
712 assert_eq!(out, "/acct/container/blob");
713 }
714
715 #[test]
716 fn canon_resource_with_query_params_sorts_and_lowercases() {
717 let url = Url::parse(
718 "https://acct.blob.core.windows.net/c/b?Restype=container&comp=list&PREFIX=p",
719 )
720 .unwrap();
721 let out = canonicalized_resource("acct", &url);
722 assert_eq!(out, "/acct/c/b\ncomp:list\nprefix:p\nrestype:container");
723 }
724
725 #[test]
726 fn canon_resource_groups_duplicate_keys() {
727 let url = Url::parse("https://x.blob.core.windows.net/c?inc=a&inc=b").unwrap();
728 let out = canonicalized_resource("x", &url);
729 assert_eq!(out, "/x/c\ninc:a,b");
730 }
731
732 #[test]
735 fn canon_headers_filters_x_ms_only_and_sorts() {
736 let mut headers = Headers::new();
737 headers.insert(HeaderName::from_static("x-ms-version"), "2025-11-05");
738 headers.insert(
739 HeaderName::from_static("x-ms-date"),
740 "Wed, 01 Jan 2025 00:00:00 GMT",
741 );
742 headers.insert(HeaderName::from_static("authorization"), "ignored");
743 headers.insert(
744 HeaderName::from_static("content-type"),
745 "application/octet-stream",
746 );
747 let out = canonicalized_headers(&headers);
748 assert_eq!(
749 out,
750 "x-ms-date:Wed, 01 Jan 2025 00:00:00 GMT\nx-ms-version:2025-11-05\n"
751 );
752 }
753
754 #[test]
755 fn canon_headers_handles_no_x_ms_headers() {
756 let mut headers = Headers::new();
757 headers.insert(HeaderName::from_static("content-type"), "x");
758 assert_eq!(canonicalized_headers(&headers), "");
759 }
760
761 #[test]
764 fn compute_authorization_matches_known_vector() {
765 let key_b64 = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==";
773 let key = HmacKey::from_base64(key_b64).expect("valid base64");
774 let url =
775 Url::parse("http://127.0.0.1:10000/devstoreaccount1/c?restype=container&comp=list")
776 .unwrap();
777 let mut headers = Headers::new();
778 headers.insert(
779 HeaderName::from_static("x-ms-date"),
780 "Wed, 01 Jan 2025 00:00:00 GMT",
781 );
782 headers.insert(HeaderName::from_static("x-ms-version"), "2025-11-05");
783
784 let auth =
785 compute_authorization("devstoreaccount1", &key, Method::Get, &url, &headers, None)
786 .expect("signs");
787 assert!(auth.starts_with("SharedKey devstoreaccount1:"));
788 let sig = auth.strip_prefix("SharedKey devstoreaccount1:").unwrap();
789 assert_eq!(sig.len(), 44, "unexpected sig length: `{sig}`");
791 }
792
793 #[test]
803 fn hmac_key_signs_canonical_shared_key_v2_signature() {
804 let key_b64 = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==";
805 let url =
806 Url::parse("http://127.0.0.1:10000/devstoreaccount1/c?restype=container&comp=list")
807 .unwrap();
808 let mut headers = Headers::new();
809 headers.insert(
810 HeaderName::from_static("x-ms-date"),
811 "Wed, 01 Jan 2025 00:00:00 GMT",
812 );
813 headers.insert(HeaderName::from_static("x-ms-version"), "2025-11-05");
814
815 let key = HmacKey::from_base64(key_b64).expect("valid base64");
816 let auth =
817 compute_authorization("devstoreaccount1", &key, Method::Get, &url, &headers, None)
818 .expect("signs");
819 assert_eq!(
825 auth, "SharedKey devstoreaccount1:VgcoAvg+vqaLJ76WpTkj7NrIj4dwCiYGPiMhJ7Q/2zI=",
826 "signature must match the pinned wire-format vector",
827 );
828 }
829
830 #[test]
835 fn sas_signing_key_debug_does_not_leak_inner_bytes() {
836 let key_b64 = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==";
837 let signing = SasSigningKey {
838 account: "devstoreaccount1".to_owned(),
839 key: HmacKey::from_base64(key_b64).expect("valid base64"),
840 };
841 let rendered = format!("{signing:?}");
842 assert!(
843 rendered.contains("redacted"),
844 "Debug must redact via inner HmacKey: {rendered}"
845 );
846 assert!(
847 !rendered.contains("bytes: ["),
848 "Debug must not leak raw key bytes: {rendered}"
849 );
850 }
851
852 #[test]
856 fn hmac_key_debug_does_not_leak_bytes() {
857 let key_b64 = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==";
858 let key = HmacKey::from_base64(key_b64).expect("valid base64");
859 let rendered = format!("{key:?}");
860 assert!(
861 rendered.contains("redacted"),
862 "Debug must redact: {rendered}"
863 );
864 assert!(
868 !rendered.contains("bytes: ["),
869 "Debug output must not include raw key bytes: {rendered}"
870 );
871 }
872
873 #[test]
874 fn x_ms_date_format_matches_rfc1123_literal() {
875 let when = OffsetDateTime::from_unix_timestamp(784_111_777).expect("valid timestamp");
879 let formatted = when.format(&X_MS_DATE_FORMAT).expect("formats");
880 assert_eq!(formatted, "Sun, 06 Nov 1994 08:49:37 GMT");
881 }
882
883 #[test]
884 fn x_ms_date_format_zero_pads_single_digit_fields() {
885 let when = OffsetDateTime::from_unix_timestamp(1_735_787_045).expect("valid timestamp");
889 let formatted = when.format(&X_MS_DATE_FORMAT).expect("formats");
890 assert_eq!(formatted, "Thu, 02 Jan 2025 03:04:05 GMT");
891 }
892
893 #[test]
896 fn sas_policy_rejects_empty() {
897 assert!(SasSigningPolicy::new("").is_err());
898 assert!(SasSigningPolicy::new("?").is_err());
899 assert!(SasSigningPolicy::new(" ").is_err());
900 }
901
902 #[test]
905 fn lookup_env_returns_none_when_unset() {
906 let name = "AZSTORE_AUTH_TEST_DEFINITELY_UNSET_VAR";
910 let _env = crate::test_util::EnvGuard::unset(name);
911 assert!(matches!(lookup_env(name), Ok(None)));
912 }
913
914 #[test]
915 fn lookup_env_returns_value_when_valid_utf8() {
916 let name = "AZSTORE_AUTH_TEST_VALID_UTF8";
917 let _env = crate::test_util::EnvGuard::set(name, "hello");
918 let value = lookup_env(name).expect("UTF-8 value must read");
919 assert_eq!(value.as_deref(), Some("hello"));
920 }
921
922 #[cfg(unix)]
927 #[test]
928 fn lookup_env_surfaces_not_unicode_error_naming_var() {
929 use std::ffi::OsString;
930 use std::os::unix::ffi::OsStringExt;
931
932 let name = "AZSTORE_AUTH_TEST_NOT_UNICODE";
933 let bad = OsString::from_vec(vec![0xFF, 0xFE, 0xFD]);
936 let _env = crate::test_util::EnvGuard::set(name, &bad);
937 let err = lookup_env(name).expect_err("non-UTF-8 env value must error, not be ignored");
938 let msg = err.to_string();
939 assert!(
940 msg.contains(name),
941 "error must name the offending var (`{name}`): {msg}"
942 );
943 assert!(
944 msg.contains("not valid UTF-8") || msg.contains("UTF-8"),
945 "error must mention UTF-8: {msg}"
946 );
947 }
948
949 #[test]
950 fn sas_policy_parses_with_or_without_leading_question() {
951 let a = SasSigningPolicy::new("sv=2025&sig=abc").expect("parses");
952 let b = SasSigningPolicy::new("?sv=2025&sig=abc").expect("parses");
953 assert_eq!(a.pairs, b.pairs);
954 assert!(a.pairs.iter().any(|(k, v)| k == "sv" && v == "2025"));
955 assert!(a.pairs.iter().any(|(k, v)| k == "sig" && v == "abc"));
956 }
957}