1use std::collections::HashMap;
45
46#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct SasPermissions {
65 pub read: bool,
67 pub write: bool,
69 pub delete: bool,
71 pub list: bool,
73 pub add: bool,
75 pub create: bool,
77}
78
79impl SasPermissions {
80 pub fn read_only() -> Self {
82 Self {
83 read: true,
84 write: false,
85 delete: false,
86 list: false,
87 add: false,
88 create: false,
89 }
90 }
91
92 pub fn read_write() -> Self {
94 Self {
95 read: true,
96 write: true,
97 delete: false,
98 list: false,
99 add: false,
100 create: false,
101 }
102 }
103
104 pub fn full() -> Self {
106 Self {
107 read: true,
108 write: true,
109 delete: true,
110 list: true,
111 add: true,
112 create: true,
113 }
114 }
115
116 pub fn as_permission_string(&self) -> String {
120 let mut s = String::with_capacity(6);
121 if self.read {
122 s.push('r');
123 }
124 if self.write {
125 s.push('w');
126 }
127 if self.delete {
128 s.push('d');
129 }
130 if self.list {
131 s.push('l');
132 }
133 if self.add {
134 s.push('a');
135 }
136 if self.create {
137 s.push('c');
138 }
139 s
140 }
141
142 pub fn from_permission_string(s: &str) -> Self {
144 Self {
145 read: s.contains('r'),
146 write: s.contains('w'),
147 delete: s.contains('d'),
148 list: s.contains('l'),
149 add: s.contains('a'),
150 create: s.contains('c'),
151 }
152 }
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum SasResource {
162 Blob,
164 Container,
166 Queue,
168 Table,
170}
171
172impl SasResource {
173 pub fn as_code(&self) -> &'static str {
175 match self {
176 SasResource::Blob => "b",
177 SasResource::Container => "c",
178 SasResource::Queue => "q",
179 SasResource::Table => "t",
180 }
181 }
182}
183
184#[derive(Debug, Clone)]
190pub struct AzureSasParams {
191 pub account_name: String,
193 pub container: String,
195 pub blob: Option<String>,
197 pub permissions: SasPermissions,
199 pub expiry: u64,
201 pub start: Option<u64>,
203 pub resource: SasResource,
205}
206
207#[derive(Debug, thiserror::Error)]
213pub enum AzureError {
214 #[error("parse error: {0}")]
216 Parse(String),
217 #[error("missing field: {0}")]
219 MissingField(String),
220 #[error("expired token")]
222 Expired,
223}
224
225const SAS_VERSION: &str = "2021-06-08";
231
232pub fn generate_sas_token(params: &AzureSasParams, account_key: &[u8]) -> String {
251 let expiry_str = unix_to_iso8601(params.expiry);
252 let start_str = params.start.map(unix_to_iso8601);
253 let perm_str = params.permissions.as_permission_string();
254 let resource_code = params.resource.as_code();
255
256 let signed_resource = match ¶ms.blob {
259 Some(blob) => format!("{}/{}/{}", params.account_name, params.container, blob),
260 None => format!("{}/{}", params.account_name, params.container),
261 };
262
263 let string_to_sign = format!(
264 "{account}\n{permissions}\n{expiry}\n{resource}\n{version}\n{resource_code}",
265 account = params.account_name,
266 permissions = perm_str,
267 expiry = expiry_str,
268 resource = signed_resource,
269 version = SAS_VERSION,
270 resource_code = resource_code,
271 );
272
273 let sig_bytes = mock_sign(string_to_sign.as_bytes(), account_key);
274 let sig_hex = crate::encoding_utils::hex_encode(sig_bytes);
275
276 let mut parts: Vec<String> = Vec::new();
278 parts.push(format!(
279 "sv={}",
280 crate::encoding_utils::percent_encode(SAS_VERSION)
281 ));
282 parts.push(format!("ss={}", resource_code));
283 parts.push("srt=o".to_owned()); parts.push(format!(
285 "sp={}",
286 crate::encoding_utils::percent_encode(&perm_str)
287 ));
288 if let Some(ref s) = start_str {
289 parts.push(format!("st={}", crate::encoding_utils::percent_encode(s)));
290 }
291 parts.push(format!(
292 "se={}",
293 crate::encoding_utils::percent_encode(&expiry_str)
294 ));
295 parts.push(format!(
296 "sig={}",
297 crate::encoding_utils::percent_encode(&sig_hex)
298 ));
299
300 parts.join("&")
301}
302
303pub fn build_sas_url(params: &AzureSasParams, account_key: &[u8]) -> String {
309 let token = generate_sas_token(params, account_key);
310 let blob_path = match ¶ms.blob {
311 Some(b) => format!("/{}", crate::encoding_utils::percent_encode(b)),
312 None => String::new(),
313 };
314 format!(
315 "https://{}.blob.core.windows.net/{}{blob_path}?{token}",
316 params.account_name,
317 crate::encoding_utils::percent_encode(¶ms.container),
318 )
319}
320
321pub fn parse_sas_token(token: &str) -> Result<HashMap<String, String>, AzureError> {
329 let mut map = HashMap::new();
330 for part in token.split('&') {
331 if part.is_empty() {
332 continue;
333 }
334 let eq_pos = part
335 .find('=')
336 .ok_or_else(|| AzureError::Parse(format!("missing '=' in token segment: {part}")))?;
337 let key = &part[..eq_pos];
338 let raw_value = &part[eq_pos + 1..];
339 let value = crate::encoding_utils::percent_decode(raw_value)
340 .unwrap_or_else(|_| raw_value.to_owned());
341 map.insert(key.to_owned(), value);
342 }
343 Ok(map)
344}
345
346pub fn is_sas_valid(token_params: &HashMap<String, String>, current_time: u64) -> bool {
353 match token_params.get("se") {
354 Some(se) => {
355 let expiry = iso8601_to_unix(se).unwrap_or(0);
356 current_time < expiry
357 }
358 None => false,
359 }
360}
361
362fn unix_to_iso8601(ts: u64) -> String {
370 let secs = ts;
373 let days = secs / 86400;
374 let time_of_day = secs % 86400;
375 let hh = time_of_day / 3600;
376 let mm = (time_of_day % 3600) / 60;
377 let ss = time_of_day % 60;
378
379 let (year, month, day) = days_to_ymd(days);
381 format!("{year:04}-{month:02}-{day:02}T{hh:02}:{mm:02}:{ss:02}Z")
382}
383
384fn iso8601_to_unix(s: &str) -> Option<u64> {
388 if s.len() < 19 {
390 return None;
391 }
392 let year: u64 = s[0..4].parse().ok()?;
393 let month: u64 = s[5..7].parse().ok()?;
394 let day: u64 = s[8..10].parse().ok()?;
395 let hh: u64 = s[11..13].parse().ok()?;
396 let mm: u64 = s[14..16].parse().ok()?;
397 let ss: u64 = s[17..19].parse().ok()?;
398
399 let days = ymd_to_days(year, month, day);
400 Some(days * 86400 + hh * 3600 + mm * 60 + ss)
401}
402
403fn days_to_ymd(days: u64) -> (u64, u64, u64) {
405 let z = days + 719468;
408 let era = z / 146097;
409 let doe = z % 146097;
410 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
411 let y = yoe + era * 400;
412 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
413 let mp = (5 * doy + 2) / 153;
414 let d = doy - (153 * mp + 2) / 5 + 1;
415 let m = if mp < 10 { mp + 3 } else { mp - 9 };
416 let y = if m <= 2 { y + 1 } else { y };
417 (y, m, d)
418}
419
420fn ymd_to_days(y: u64, m: u64, d: u64) -> u64 {
422 let y = if m <= 2 { y - 1 } else { y };
423 let era = y / 400;
424 let yoe = y % 400;
425 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
426 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
427 era * 146097 + doe - 719468
428}
429
430fn mock_sign(data: &[u8], key: &[u8]) -> [u8; 32] {
440 let mut out = [0u8; 32];
441 if key.is_empty() {
442 for (i, &b) in data.iter().enumerate() {
444 out[i % 32] ^= b;
445 }
446 return out;
447 }
448 for (i, &b) in data.iter().enumerate() {
449 let k = key[i % key.len()];
450 out[i % 32] ^= b ^ k;
451 }
452 out
453}
454
455#[cfg(test)]
460mod tests {
461 use super::*;
462
463 #[test]
465 fn test_read_only_permission_string() {
466 let perm = SasPermissions::read_only();
467 let s = perm.as_permission_string();
468 assert!(s.contains('r'), "read_only must contain 'r'");
469 assert!(!s.contains('w'), "read_only must not contain 'w'");
470 assert!(!s.contains('d'), "read_only must not contain 'd'");
471 assert!(!s.contains('l'), "read_only must not contain 'l'");
472 }
473
474 #[test]
476 fn test_full_permission_string() {
477 let s = SasPermissions::full().as_permission_string();
478 for ch in ['r', 'w', 'd', 'l', 'a', 'c'] {
479 assert!(s.contains(ch), "full must contain '{ch}'");
480 }
481 }
482
483 #[test]
485 fn test_generate_sas_token_contains_required_fields() {
486 let params = AzureSasParams {
487 account_name: "account".into(),
488 container: "container".into(),
489 blob: Some("file.bin".into()),
490 permissions: SasPermissions::read_write(),
491 expiry: 9_999_999_999,
492 start: None,
493 resource: SasResource::Blob,
494 };
495 let token = generate_sas_token(¶ms, b"fake-key");
496 assert!(token.contains("sv="), "token must contain sv=");
497 assert!(token.contains("sp="), "token must contain sp=");
498 assert!(token.contains("se="), "token must contain se=");
499 assert!(token.contains("sig="), "token must contain sig=");
500 }
501
502 #[test]
504 fn test_parse_sas_token_round_trip() {
505 let params = AzureSasParams {
506 account_name: "roundtrip".into(),
507 container: "c".into(),
508 blob: None,
509 permissions: SasPermissions::full(),
510 expiry: 9_000_000_000,
511 start: None,
512 resource: SasResource::Container,
513 };
514 let token = generate_sas_token(¶ms, b"key");
515 let map = parse_sas_token(&token).expect("parse");
516
517 assert_eq!(map.get("sv").map(|s| s.as_str()), Some(SAS_VERSION));
519
520 let perm_encoded = SasPermissions::full().as_permission_string();
522 assert_eq!(
523 map.get("sp").map(|s| s.as_str()),
524 Some(perm_encoded.as_str())
525 );
526 }
527
528 #[test]
530 fn test_is_sas_valid_expiry() {
531 let future_params = AzureSasParams {
532 account_name: "a".into(),
533 container: "c".into(),
534 blob: None,
535 permissions: SasPermissions::read_only(),
536 expiry: 9_999_999_999,
537 start: None,
538 resource: SasResource::Blob,
539 };
540 let future_token = generate_sas_token(&future_params, b"k");
541 let future_map = parse_sas_token(&future_token).expect("parse future");
542
543 let past_params = AzureSasParams {
544 account_name: "a".into(),
545 container: "c".into(),
546 blob: None,
547 permissions: SasPermissions::read_only(),
548 expiry: 1_000_000, start: None,
550 resource: SasResource::Blob,
551 };
552 let past_token = generate_sas_token(&past_params, b"k");
553 let past_map = parse_sas_token(&past_token).expect("parse past");
554
555 let now: u64 = 1_744_000_000;
557
558 assert!(
559 is_sas_valid(&future_map, now),
560 "future token should be valid"
561 );
562 assert!(
563 !is_sas_valid(&past_map, now),
564 "past token should be expired"
565 );
566 }
567
568 #[test]
570 fn test_is_sas_valid_missing_se() {
571 let map: HashMap<String, String> = [("sv".into(), SAS_VERSION.into())].into();
572 assert!(!is_sas_valid(&map, 1_000_000));
573 }
574
575 #[test]
577 fn test_permission_round_trip() {
578 for perm in [
579 SasPermissions::read_only(),
580 SasPermissions::read_write(),
581 SasPermissions::full(),
582 ] {
583 let s = perm.as_permission_string();
584 let decoded = SasPermissions::from_permission_string(&s);
585 assert_eq!(decoded, perm, "round-trip failed for '{s}'");
586 }
587 }
588
589 #[test]
591 fn test_build_sas_url_format() {
592 let params = AzureSasParams {
593 account_name: "myaccount".into(),
594 container: "mycontainer".into(),
595 blob: Some("blob.bin".into()),
596 permissions: SasPermissions::read_only(),
597 expiry: 9_999_999_999,
598 start: None,
599 resource: SasResource::Blob,
600 };
601 let url = build_sas_url(¶ms, b"key");
602 assert!(url.starts_with("https://myaccount.blob.core.windows.net/"));
603 assert!(url.contains('?'), "URL must contain a query string");
604 assert!(url.contains("sig="), "URL query must contain sig=");
605 }
606
607 #[test]
609 fn test_different_keys_produce_different_signatures() {
610 let params = AzureSasParams {
611 account_name: "a".into(),
612 container: "c".into(),
613 blob: None,
614 permissions: SasPermissions::read_only(),
615 expiry: 9_000_000_000,
616 start: None,
617 resource: SasResource::Blob,
618 };
619 let t1 = generate_sas_token(¶ms, b"key-one");
620 let t2 = generate_sas_token(¶ms, b"key-two");
621 let m1 = parse_sas_token(&t1).expect("parse 1");
623 let m2 = parse_sas_token(&t2).expect("parse 2");
624 assert_ne!(
625 m1.get("sig"),
626 m2.get("sig"),
627 "different keys must produce different signatures"
628 );
629 }
630}