Skip to main content

rustack_cloudfront_core/
id_gen.rs

1//! Resource ID and ETag generators.
2//!
3//! CloudFront resource IDs are 14-character uppercase alphanumeric strings.
4//! Distributions, OACs, cache policies, and most other resources start with
5//! `E`; invalidations start with `I`.
6
7use std::hash::{Hash, Hasher};
8
9use rand::RngExt;
10
11const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
12
13/// Length of a CloudFront resource ID excluding the type-prefix letter.
14const ID_BODY_LEN: usize = 13;
15
16/// Generate a new 14-character distribution ID starting with `E`.
17#[must_use]
18pub fn new_distribution_id() -> String {
19    new_id_with_prefix('E')
20}
21
22/// Generate a new 14-character invalidation ID starting with `I`.
23#[must_use]
24pub fn new_invalidation_id() -> String {
25    new_id_with_prefix('I')
26}
27
28/// Generate a new 14-character ID with an arbitrary type-prefix letter.
29#[must_use]
30pub fn new_id_with_prefix(prefix: char) -> String {
31    let mut rng = rand::rng();
32    let mut buf = String::with_capacity(ID_BODY_LEN + 1);
33    buf.push(prefix);
34    for _ in 0..ID_BODY_LEN {
35        let idx = rng.random_range(0..ALPHABET.len());
36        buf.push(ALPHABET[idx] as char);
37    }
38    buf
39}
40
41/// Deterministically derive a 14-character ID from a seed string.
42///
43/// When the server is started with `CLOUDFRONT_DETERMINISTIC_IDS=true`, this
44/// is used so snapshot tests can assert stable IDs across runs.
45#[must_use]
46pub fn deterministic_id_with_prefix(prefix: char, seed: &str) -> String {
47    let mut hasher = std::collections::hash_map::DefaultHasher::new();
48    seed.hash(&mut hasher);
49    let mut value = hasher.finish();
50    let mut buf = String::with_capacity(ID_BODY_LEN + 1);
51    buf.push(prefix);
52    for _ in 0..ID_BODY_LEN {
53        let idx = (value % ALPHABET.len() as u64) as usize;
54        buf.push(ALPHABET[idx] as char);
55        value /= ALPHABET.len() as u64;
56        // Re-mix to avoid collapsing to a single character once value is small.
57        value = value
58            .wrapping_mul(6_364_136_223_846_793_005)
59            .wrapping_add(1);
60    }
61    buf
62}
63
64/// Generate an opaque ETag token.
65///
66/// We use base32-style identifiers for visual consistency with AWS. The value
67/// is never interpreted — callers treat it as an opaque string.
68#[must_use]
69pub fn new_etag() -> String {
70    new_id_with_prefix('E')
71}
72
73/// Produce the `d{lowercased-id}.{suffix}` FQDN for a distribution.
74#[must_use]
75pub fn distribution_domain_name(distribution_id: &str, domain_suffix: &str) -> String {
76    format!("{}.{}", distribution_id.to_ascii_lowercase(), domain_suffix)
77}
78
79/// Shape of a CloudFront resource ID: 14 chars, uppercase alnum, optional prefix.
80#[must_use]
81pub fn is_valid_resource_id(id: &str, expected_prefix: Option<char>) -> bool {
82    if id.len() != ID_BODY_LEN + 1 {
83        return false;
84    }
85    let mut iter = id.chars();
86    let first = match iter.next() {
87        Some(c) => c,
88        None => return false,
89    };
90    if !first.is_ascii_uppercase() {
91        return false;
92    }
93    if let Some(prefix) = expected_prefix {
94        if first != prefix {
95            return false;
96        }
97    }
98    iter.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
99}
100
101/// S3 canonical user ID used for OAI responses. Matches AWS format: 64 hex chars.
102#[must_use]
103pub fn new_s3_canonical_user_id() -> String {
104    let mut rng = rand::rng();
105    const HEX: &[u8] = b"0123456789abcdef";
106    let mut buf = String::with_capacity(64);
107    for _ in 0..64 {
108        buf.push(HEX[rng.random_range(0..HEX.len())] as char);
109    }
110    buf
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn test_should_generate_14_char_id() {
119        let id = new_distribution_id();
120        assert_eq!(id.len(), 14);
121        assert!(id.starts_with('E'));
122        assert!(is_valid_resource_id(&id, Some('E')));
123    }
124
125    #[test]
126    fn test_should_generate_deterministic_id() {
127        let a = deterministic_id_with_prefix('E', "hello");
128        let b = deterministic_id_with_prefix('E', "hello");
129        assert_eq!(a, b);
130        assert_ne!(a, deterministic_id_with_prefix('E', "world"));
131    }
132
133    #[test]
134    fn test_should_derive_domain_name() {
135        let name = distribution_domain_name("E1ABCDEF123456", "cloudfront.net");
136        assert_eq!(name, "e1abcdef123456.cloudfront.net");
137    }
138
139    #[test]
140    fn test_should_validate_resource_id_shape() {
141        assert!(is_valid_resource_id("E1ABCDEF123456", Some('E')));
142        assert!(!is_valid_resource_id("E1ABCDEF123456", Some('I')));
143        assert!(!is_valid_resource_id("e1abcdef123456", Some('E')));
144        assert!(!is_valid_resource_id("E1", Some('E')));
145    }
146}