Skip to main content

fakecloud_amplify/
shared.rs

1//! Primitives shared across the AWS Amplify (`amplify`) handlers: ARN
2//! synthesis, deterministic id derivation, timestamps, and default-domain /
3//! webhook-URL construction. Kept in one place so the create / get paths cannot
4//! diverge on wire format.
5
6use base64::Engine as _;
7use uuid::Uuid;
8
9/// The AWS Amplify app ARN, `arn:aws:amplify:{region}:{account}:apps/{app_id}`.
10pub fn app_arn(region: &str, account: &str, app_id: &str) -> String {
11    format!("arn:aws:amplify:{region}:{account}:apps/{app_id}")
12}
13
14/// The branch ARN, `.../apps/{app_id}/branches/{branch}`.
15pub fn branch_arn(region: &str, account: &str, app_id: &str, branch: &str) -> String {
16    format!("arn:aws:amplify:{region}:{account}:apps/{app_id}/branches/{branch}")
17}
18
19/// The domain-association ARN, `.../apps/{app_id}/domains/{domain}`.
20pub fn domain_arn(region: &str, account: &str, app_id: &str, domain: &str) -> String {
21    format!("arn:aws:amplify:{region}:{account}:apps/{app_id}/domains/{domain}")
22}
23
24/// The webhook ARN, `.../apps/{app_id}/webhooks/{webhook_id}`.
25pub fn webhook_arn(region: &str, account: &str, app_id: &str, webhook_id: &str) -> String {
26    format!("arn:aws:amplify:{region}:{account}:apps/{app_id}/webhooks/{webhook_id}")
27}
28
29/// The backend-environment ARN, `.../apps/{app_id}/backendenvironments/{name}`.
30pub fn backend_environment_arn(region: &str, account: &str, app_id: &str, name: &str) -> String {
31    format!("arn:aws:amplify:{region}:{account}:apps/{app_id}/backendenvironments/{name}")
32}
33
34/// The job ARN, `.../apps/{app_id}/branches/{branch}/jobs/{job_id}`.
35pub fn job_arn(region: &str, account: &str, app_id: &str, branch: &str, job_id: &str) -> String {
36    format!("arn:aws:amplify:{region}:{account}:apps/{app_id}/branches/{branch}/jobs/{job_id}")
37}
38
39/// FNV-1a hash for deterministic synthesis of ids / hosts from a seed so a
40/// given resource's derived value is stable across reads and restarts.
41pub fn hash_str(s: &str) -> u64 {
42    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
43    for b in s.as_bytes() {
44        h ^= u64::from(*b);
45        h = h.wrapping_mul(0x0000_0100_0000_01b3);
46    }
47    h
48}
49
50/// Generate a fresh Amplify app id of AWS's `^d[a-z0-9]+$` form: a `d` prefix
51/// followed by 24 lowercase base-36 characters (well within the 1..=20... —
52/// AWS app ids are 14 chars, so we emit `d` + 13 chars = 14 total).
53pub fn new_app_id() -> String {
54    let raw = Uuid::new_v4().simple().to_string();
55    // uuid simple is 32 lowercase hex chars (`[0-9a-f]`), matching `[a-z0-9]`.
56    format!("d{}", &raw[..13])
57}
58
59/// Generate a fresh webhook id (a UUID; Amplify's `WebhookId` pattern accepts
60/// any string).
61pub fn new_webhook_id() -> String {
62    Uuid::new_v4().to_string()
63}
64
65/// The default `*.amplifyapp.com` domain AWS assigns every app.
66pub fn default_domain(app_id: &str) -> String {
67    format!("{app_id}.amplifyapp.com")
68}
69
70/// The webhook trigger URL AWS provisions, of the form
71/// `https://webhooks.amplify.{region}.amazonaws.com/prod/webhooks?id={id}&token={token}&operation=startbuild`.
72pub fn webhook_url(region: &str, webhook_id: &str) -> String {
73    let token = Uuid::new_v4().simple().to_string();
74    format!(
75        "https://webhooks.amplify.{region}.amazonaws.com/prod/webhooks?id={webhook_id}&token={token}&operation=startbuild"
76    )
77}
78
79/// A branch thumbnail URL (a presigned-style URL over the Amplify CDN).
80pub fn thumbnail_url(region: &str, app_id: &str, branch: &str) -> String {
81    let h = hash_str(&format!("{app_id}/{branch}"));
82    format!("https://aws-amplify-prod-{region}-artifacts.s3.{region}.amazonaws.com/{app_id}/{branch}/thumbnail-{h:016x}.png")
83}
84
85/// The DNS record a subdomain resolves to (a CNAME onto the app's default
86/// domain).
87pub fn subdomain_dns_record(app_id: &str) -> String {
88    format!("CNAME {}", default_domain(app_id))
89}
90
91/// The ACM certificate-verification DNS record AWS surfaces while a domain is
92/// being verified.
93pub fn certificate_verification_dns_record(domain: &str) -> String {
94    let h = hash_str(domain);
95    format!("_{h:016x}.{domain}. CNAME _{h:016x}.acm-validations.aws.")
96}
97
98/// A presigned S3 upload URL for a manual deployment file.
99pub fn upload_url(region: &str, app_id: &str, branch: &str, key: &str) -> String {
100    let h = hash_str(&format!("{app_id}/{branch}/{key}"));
101    format!("https://aws-amplify-prod-{region}-deployments.s3.{region}.amazonaws.com/{app_id}/{branch}/{key}?X-Amz-Signature={h:016x}")
102}
103
104/// A presigned access-log download URL.
105pub fn access_log_url(region: &str, app_id: &str, domain: &str) -> String {
106    let h = hash_str(&format!("{app_id}/{domain}"));
107    format!("https://aws-amplify-prod-{region}-accesslogs.s3.{region}.amazonaws.com/{app_id}/{domain}/access-logs-{h:016x}.csv?X-Amz-Signature={h:016x}")
108}
109
110/// Encode an artifact id so `GetArtifactUrl` can resolve it back to its
111/// `(app_id, branch, job_id, file_name)` without a lookup table. AWS artifact
112/// ids are opaque; ours are a reversible base64url blob.
113pub fn encode_artifact_id(app_id: &str, branch: &str, job_id: &str, file_name: &str) -> String {
114    let raw = format!("{app_id}\x1f{branch}\x1f{job_id}\x1f{file_name}");
115    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes())
116}
117
118/// Reverse [`encode_artifact_id`]. Returns `None` for an id we did not mint.
119pub fn decode_artifact_id(id: &str) -> Option<(String, String, String, String)> {
120    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
121        .decode(id.as_bytes())
122        .ok()?;
123    let s = String::from_utf8(bytes).ok()?;
124    let parts: Vec<&str> = s.split('\x1f').collect();
125    if parts.len() != 4 {
126        return None;
127    }
128    Some((
129        parts[0].to_string(),
130        parts[1].to_string(),
131        parts[2].to_string(),
132        parts[3].to_string(),
133    ))
134}
135
136/// The artifact download URL for a build artifact.
137pub fn artifact_url(region: &str, app_id: &str, branch: &str, job_id: &str, file: &str) -> String {
138    let h = hash_str(&format!("{app_id}/{branch}/{job_id}/{file}"));
139    format!("https://aws-amplify-prod-{region}-artifacts.s3.{region}.amazonaws.com/{app_id}/{branch}/{job_id}/{file}?X-Amz-Signature={h:016x}")
140}
141
142/// Current time as restJson1 epoch-seconds (a floating-point number). Amplify's
143/// timestamp members carry no `@timestampFormat`, so restJson1's default
144/// epoch-seconds applies -- the AWS SDK parses the numeric value.
145pub fn now_epoch() -> f64 {
146    chrono::Utc::now().timestamp_millis() as f64 / 1000.0
147}
148
149/// Parse an Amplify `ResourceArn` into `(app_id, Option<branch>)` for the
150/// tagging operations. Only apps and branches carry tags in the model.
151///
152/// - `arn:aws:amplify:{region}:{account}:apps/{app_id}` -> `(app_id, None)`
153/// - `.../apps/{app_id}/branches/{branch}` -> `(app_id, Some(branch))`
154pub fn parse_resource_arn(arn: &str) -> Option<(String, Option<String>)> {
155    let (_, rest) = arn.split_once(":apps/")?;
156    if rest.is_empty() {
157        return None;
158    }
159    match rest.split_once("/branches/") {
160        Some((app_id, branch)) if !app_id.is_empty() && !branch.is_empty() => {
161            Some((app_id.to_string(), Some(branch.to_string())))
162        }
163        Some(_) => None,
164        None => {
165            // Bare `apps/{app_id}` (no trailing segments).
166            if rest.contains('/') {
167                None
168            } else {
169                Some((rest.to_string(), None))
170            }
171        }
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn app_id_matches_aws_pattern() {
181        let id = new_app_id();
182        assert!(id.starts_with('d'));
183        assert!(!id.is_empty() && id.len() <= 20);
184        assert!(id
185            .chars()
186            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()));
187    }
188
189    #[test]
190    fn artifact_id_round_trips() {
191        let id = encode_artifact_id("d123", "main", "5", "buildLogs.txt");
192        assert_eq!(
193            decode_artifact_id(&id),
194            Some((
195                "d123".to_string(),
196                "main".to_string(),
197                "5".to_string(),
198                "buildLogs.txt".to_string()
199            ))
200        );
201        assert_eq!(decode_artifact_id("not-a-real-id!!!"), None);
202    }
203
204    #[test]
205    fn parse_resource_arn_app_and_branch() {
206        assert_eq!(
207            parse_resource_arn("arn:aws:amplify:us-east-1:000000000000:apps/d123"),
208            Some(("d123".to_string(), None))
209        );
210        assert_eq!(
211            parse_resource_arn("arn:aws:amplify:us-east-1:000000000000:apps/d123/branches/main"),
212            Some(("d123".to_string(), Some("main".to_string())))
213        );
214        assert_eq!(parse_resource_arn("arn:aws:s3:::bucket"), None);
215    }
216}