Skip to main content

scirs2_io/cloud/
azure_sas.rs

1//! Azure Blob Storage SAS (Shared Access Signature) token generation and
2//! validation (simulation mode).
3//!
4//! In production Azure Blob Storage, a SAS token is produced by building a
5//! **string-to-sign** from the storage account name, container, blob, expiry,
6//! permissions, and other parameters, then signing it with the storage account
7//! key using HMAC-SHA256.  The resulting URL looks like:
8//!
9//! ```text
10//! https://{account}.blob.core.windows.net/{container}/{blob}?{sas_token}
11//! ```
12//!
13//! # Simulation mode
14//!
15//! This module provides a **deterministic mock** instead of performing real
16//! HMAC-SHA256 signing:
17//!
18//! - The "signature" is computed as an XOR-folded hash of the string-to-sign
19//!   and the storage account key.  This is **not cryptographically secure** and
20//!   must **never** be used in production.
21//! - For production use, replace `mock_sign` with an HMAC-SHA256 call using
22//!   the `sha2` and `digest` crates (both already present in the workspace).
23//!
24//! # Example
25//!
26//! ```rust
27//! use scirs2_io::cloud::azure_sas::{AzureSasParams, SasPermissions, SasResource,
28//!     generate_sas_token, build_sas_url, is_sas_valid, parse_sas_token};
29//!
30//! let params = AzureSasParams {
31//!     account_name: "mystorageaccount".into(),
32//!     container: "data".into(),
33//!     blob: Some("file.bin".into()),
34//!     permissions: SasPermissions::read_only(),
35//!     expiry: 9_999_999_999,
36//!     start: None,
37//!     resource: SasResource::Blob,
38//! };
39//! let key = b"not-a-real-key";
40//! let token = generate_sas_token(&params, key);
41//! assert!(token.contains("sv=") && token.contains("sp=") && token.contains("se="));
42//! ```
43
44use std::collections::HashMap;
45
46// ---------------------------------------------------------------------------
47// SasPermissions
48// ---------------------------------------------------------------------------
49
50/// Bit-flag set of Azure SAS permissions.
51///
52/// Maps to the Azure `sp` query parameter.  Each flag corresponds to one
53/// character in the Azure permission string:
54///
55/// | Character | Flag    |
56/// |-----------|---------|
57/// | `r`       | read    |
58/// | `w`       | write   |
59/// | `d`       | delete  |
60/// | `l`       | list    |
61/// | `a`       | add     |
62/// | `c`       | create  |
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct SasPermissions {
65    /// Permission to read blobs.
66    pub read: bool,
67    /// Permission to write / overwrite blobs.
68    pub write: bool,
69    /// Permission to delete blobs.
70    pub delete: bool,
71    /// Permission to list blobs within a container.
72    pub list: bool,
73    /// Permission to append data to a blob.
74    pub add: bool,
75    /// Permission to create a new blob.
76    pub create: bool,
77}
78
79impl SasPermissions {
80    /// Create a read-only permission set (`r`).
81    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    /// Create a read-write permission set (`rw`).
93    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    /// Create a full-access permission set (`rwdlac`).
105    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    /// Encode the permission set as the Azure `sp` query-parameter value.
117    ///
118    /// The characters are emitted in the canonical order `r w d l a c`.
119    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    /// Decode an Azure permission string back to a `SasPermissions` value.
143    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// ---------------------------------------------------------------------------
156// SasResource
157// ---------------------------------------------------------------------------
158
159/// The type of Azure storage resource targeted by a SAS token.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum SasResource {
162    /// A single blob (`ss=b`).
163    Blob,
164    /// A container (`ss=c`).
165    Container,
166    /// A queue (`ss=q`).
167    Queue,
168    /// A table (`ss=t`).
169    Table,
170}
171
172impl SasResource {
173    /// Return the single-character code used in the `ss` query parameter.
174    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// ---------------------------------------------------------------------------
185// AzureSasParams
186// ---------------------------------------------------------------------------
187
188/// Parameters used to generate an Azure SAS token.
189#[derive(Debug, Clone)]
190pub struct AzureSasParams {
191    /// Storage account name (e.g. `"mystorageaccount"`).
192    pub account_name: String,
193    /// Container name.
194    pub container: String,
195    /// Blob name (None for container-level SAS).
196    pub blob: Option<String>,
197    /// Permission set.
198    pub permissions: SasPermissions,
199    /// Token expiry as a Unix timestamp (seconds since epoch).
200    pub expiry: u64,
201    /// Optional token start time as a Unix timestamp.
202    pub start: Option<u64>,
203    /// The resource type targeted by this SAS.
204    pub resource: SasResource,
205}
206
207// ---------------------------------------------------------------------------
208// AzureError
209// ---------------------------------------------------------------------------
210
211/// Errors returned by Azure SAS operations.
212#[derive(Debug, thiserror::Error)]
213pub enum AzureError {
214    /// Failed to parse a SAS token query string.
215    #[error("parse error: {0}")]
216    Parse(String),
217    /// A required field is missing from the SAS token.
218    #[error("missing field: {0}")]
219    MissingField(String),
220    /// The SAS token has passed its expiry time.
221    #[error("expired token")]
222    Expired,
223}
224
225// ---------------------------------------------------------------------------
226// Token generation
227// ---------------------------------------------------------------------------
228
229/// SAS service version string embedded in every token.
230const SAS_VERSION: &str = "2021-06-08";
231
232/// Generate a SAS token query string.
233///
234/// # Parameters
235///
236/// - `params` — token parameters.
237/// - `account_key` — raw storage account key bytes.
238///
239/// # Implementation note (simulation)
240///
241/// The `sig` field is computed by `mock_sign` — an XOR-folded deterministic
242/// hash.  **Do not use this in production.**  For real signing replace the
243/// `mock_sign` call with HMAC-SHA256 over `string_to_sign` using the decoded
244/// base-64 account key (see `hmac` + `sha2` crates).
245///
246/// # Returns
247///
248/// A percent-encoded query string, e.g.:
249/// `sv=2021-06-08&ss=b&srt=o&sp=r&se=2026-12-31T00%3A00%3A00Z&sig=…`
250pub 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    // Build the canonical string-to-sign (simplified vs. real Azure spec,
257    // which includes many more newline-separated fields).
258    let signed_resource = match &params.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    // Assemble the query string.
277    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()); // resource type: object
284    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
303/// Build a complete Azure Blob Storage URL with an embedded SAS token.
304///
305/// Format: `https://{account}.blob.core.windows.net/{container}/{blob}?{token}`
306///
307/// If `params.blob` is `None` the URL omits the blob path component.
308pub fn build_sas_url(params: &AzureSasParams, account_key: &[u8]) -> String {
309    let token = generate_sas_token(params, account_key);
310    let blob_path = match &params.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(&params.container),
318    )
319}
320
321/// Parse a SAS token query string into a key-value map.
322///
323/// Handles both `+`-encoded and `%xx`-encoded values.
324///
325/// # Errors
326///
327/// Returns [`AzureError::Parse`] if the string is not valid `key=value` pairs.
328pub 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
346/// Validate that a parsed SAS token has not expired.
347///
348/// `current_time` is compared against the `se` (signed expiry) field which is
349/// stored as an ISO-8601 UTC timestamp.
350///
351/// Returns `true` if the token is valid (not yet expired), `false` otherwise.
352pub 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
362// ---------------------------------------------------------------------------
363// Internal helpers
364// ---------------------------------------------------------------------------
365
366/// Convert a Unix timestamp to a truncated ISO-8601 UTC string.
367///
368/// Produces the format `YYYY-MM-DDTHH:MM:SSZ` that Azure expects in `se`/`st`.
369fn unix_to_iso8601(ts: u64) -> String {
370    // Hand-roll conversion to avoid pulling in chrono here (it is available
371    // workspace-wide but we keep this module self-contained for clarity).
372    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    // Gregorian calendar computation.
380    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
384/// Parse an ISO-8601 UTC string back to a Unix timestamp.
385///
386/// Accepts `YYYY-MM-DDTHH:MM:SSZ` format.  Returns `None` on parse failure.
387fn iso8601_to_unix(s: &str) -> Option<u64> {
388    // Expected: "YYYY-MM-DDTHH:MM:SSZ"  (20 chars)
389    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
403/// Convert an epoch day count to `(year, month, day)`.
404fn days_to_ymd(days: u64) -> (u64, u64, u64) {
405    // Proleptic Gregorian calendar via the civil date algorithm.
406    // Reference: http://howardhinnant.github.io/date_algorithms.html
407    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
420/// Convert `(year, month, day)` to an epoch day count.
421fn 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
430/// Deterministic mock signature (XOR-based; NOT cryptographically secure).
431///
432/// Folds the `data` bytes XOR'd with cycling `key` bytes into a 32-byte array.
433/// This provides a stable, key-dependent output that is reproducible across
434/// runs — useful for testing token round-trips — but offers no security
435/// guarantees.
436///
437/// **Production replacement**: use HMAC-SHA256 from the `hmac` + `sha2` crates,
438/// signing `string_to_sign` with the base-64-decoded storage account key.
439fn mock_sign(data: &[u8], key: &[u8]) -> [u8; 32] {
440    let mut out = [0u8; 32];
441    if key.is_empty() {
442        // No key: just XOR data into the output buffer.
443        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// ---------------------------------------------------------------------------
456// Tests
457// ---------------------------------------------------------------------------
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    /// SasPermissions::read_only() encodes to 'r' but not 'w'.
464    #[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    /// SasPermissions::full() encodes all six characters.
475    #[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    /// generate_sas_token output contains "sv=", "sp=", and "se=".
484    #[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(&params, 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    /// parse_sas_token round-trips the key-value pairs.
503    #[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(&params, b"key");
515        let map = parse_sas_token(&token).expect("parse");
516
517        // sv field must be the service version constant.
518        assert_eq!(map.get("sv").map(|s| s.as_str()), Some(SAS_VERSION));
519
520        // sp field must decode to the full permission string.
521        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    /// is_sas_valid returns true for future expiry, false for past.
529    #[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, // year ~1970
549            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        // Current simulation time: somewhere in 2026 (about 1_744_000_000 s).
556        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    /// Missing 'se' field means the token is invalid.
569    #[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    /// from_permission_string round-trips through as_permission_string.
576    #[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    /// build_sas_url produces a URL that starts with the account endpoint.
590    #[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(&params, 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    /// Different account keys produce different signatures (mock_sign is key-sensitive).
608    #[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(&params, b"key-one");
620        let t2 = generate_sas_token(&params, b"key-two");
621        // The sig= fields should differ.
622        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}