Skip to main content

fakecloud_cloudfront/
policies.rs

1//! Origin Access Control + the four policy resources (cache, origin
2//! request, response headers, continuous deployment). Models are defined
3//! here and the [`CloudFrontService`] handlers live in this module too —
4//! `service.rs` only dispatches by action name.
5//!
6//! AWS-managed policies for `CachePolicy`, `OriginRequestPolicy`, and
7//! `ResponseHeadersPolicy` are pre-seeded by [`seed_managed`] so
8//! Terraform / CDK code that looks them up by their well-known IDs
9//! resolves them without the caller having to create them first.
10
11use chrono::{DateTime, Utc};
12use http::header::{ETAG, IF_MATCH, LOCATION};
13use http::{HeaderMap, HeaderValue, StatusCode};
14use serde::{Deserialize, Serialize};
15
16use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError, ResponseBody};
17
18use crate::router::Route;
19use crate::service::{aws_error, esc, invalid_argument, xml_response};
20use crate::state::AccountState;
21
22const XML_DECL: &str = r#"<?xml version="1.0" encoding="UTF-8"?>"#;
23const NS: &str = crate::NAMESPACE;
24
25fn skip_if_none<T>(x: &Option<T>) -> bool {
26    x.is_none()
27}
28
29// ─── Origin Access Control ────────────────────────────────────────────
30
31#[derive(Debug, Clone, Serialize, Deserialize, Default)]
32#[serde(rename_all = "PascalCase")]
33pub struct OriginAccessControlConfig {
34    pub name: String,
35    #[serde(default, skip_serializing_if = "skip_if_none")]
36    pub description: Option<String>,
37    pub signing_protocol: String,
38    pub signing_behavior: String,
39    pub origin_access_control_origin_type: String,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct StoredOriginAccessControl {
44    pub id: String,
45    pub etag: String,
46    pub config: OriginAccessControlConfig,
47}
48
49// ─── Cache Policy ─────────────────────────────────────────────────────
50
51#[derive(Debug, Clone, Serialize, Deserialize, Default)]
52#[serde(rename_all = "PascalCase")]
53pub struct CachePolicyConfig {
54    #[serde(default, skip_serializing_if = "skip_if_none")]
55    pub comment: Option<String>,
56    pub name: String,
57    #[serde(rename = "DefaultTTL", default, skip_serializing_if = "skip_if_none")]
58    pub default_ttl: Option<i64>,
59    #[serde(rename = "MaxTTL", default, skip_serializing_if = "skip_if_none")]
60    pub max_ttl: Option<i64>,
61    #[serde(rename = "MinTTL")]
62    pub min_ttl: i64,
63    #[serde(default, skip_serializing_if = "skip_if_none")]
64    pub parameters_in_cache_key_and_forwarded_to_origin:
65        Option<ParametersInCacheKeyAndForwardedToOrigin>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, Default)]
69#[serde(rename_all = "PascalCase")]
70pub struct ParametersInCacheKeyAndForwardedToOrigin {
71    pub enable_accept_encoding_gzip: bool,
72    #[serde(default, skip_serializing_if = "skip_if_none")]
73    pub enable_accept_encoding_brotli: Option<bool>,
74    pub headers_config: CachePolicyHeadersConfig,
75    pub cookies_config: CachePolicyCookiesConfig,
76    pub query_strings_config: CachePolicyQueryStringsConfig,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, Default)]
80#[serde(rename_all = "PascalCase")]
81pub struct CachePolicyHeadersConfig {
82    pub header_behavior: String,
83    #[serde(default, skip_serializing_if = "skip_if_none")]
84    pub headers: Option<NameWrapper>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88#[serde(rename_all = "PascalCase")]
89pub struct NameWrapper {
90    pub quantity: i32,
91    #[serde(default, skip_serializing_if = "skip_if_none")]
92    pub items: Option<NameItems>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, Default)]
96#[serde(rename_all = "PascalCase")]
97pub struct NameItems {
98    #[serde(default, rename = "Name")]
99    pub name: Vec<String>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, Default)]
103#[serde(rename_all = "PascalCase")]
104pub struct CachePolicyCookiesConfig {
105    pub cookie_behavior: String,
106    #[serde(default, skip_serializing_if = "skip_if_none")]
107    pub cookies: Option<NameWrapper>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, Default)]
111#[serde(rename_all = "PascalCase")]
112pub struct CachePolicyQueryStringsConfig {
113    pub query_string_behavior: String,
114    #[serde(default, skip_serializing_if = "skip_if_none")]
115    pub query_strings: Option<NameWrapper>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct StoredCachePolicy {
120    pub id: String,
121    pub etag: String,
122    pub last_modified_time: DateTime<Utc>,
123    pub config: CachePolicyConfig,
124    /// "managed" or "custom".
125    pub policy_type: String,
126}
127
128// ─── Origin Request Policy ────────────────────────────────────────────
129
130#[derive(Debug, Clone, Serialize, Deserialize, Default)]
131#[serde(rename_all = "PascalCase")]
132pub struct OriginRequestPolicyConfig {
133    #[serde(default, skip_serializing_if = "skip_if_none")]
134    pub comment: Option<String>,
135    pub name: String,
136    pub headers_config: OriginRequestPolicyHeadersConfig,
137    pub cookies_config: OriginRequestPolicyCookiesConfig,
138    pub query_strings_config: OriginRequestPolicyQueryStringsConfig,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, Default)]
142#[serde(rename_all = "PascalCase")]
143pub struct OriginRequestPolicyHeadersConfig {
144    pub header_behavior: String,
145    #[serde(default, skip_serializing_if = "skip_if_none")]
146    pub headers: Option<NameWrapper>,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize, Default)]
150#[serde(rename_all = "PascalCase")]
151pub struct OriginRequestPolicyCookiesConfig {
152    pub cookie_behavior: String,
153    #[serde(default, skip_serializing_if = "skip_if_none")]
154    pub cookies: Option<NameWrapper>,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, Default)]
158#[serde(rename_all = "PascalCase")]
159pub struct OriginRequestPolicyQueryStringsConfig {
160    pub query_string_behavior: String,
161    #[serde(default, skip_serializing_if = "skip_if_none")]
162    pub query_strings: Option<NameWrapper>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct StoredOriginRequestPolicy {
167    pub id: String,
168    pub etag: String,
169    pub last_modified_time: DateTime<Utc>,
170    pub config: OriginRequestPolicyConfig,
171    pub policy_type: String,
172}
173
174// ─── Response Headers Policy ──────────────────────────────────────────
175
176#[derive(Debug, Clone, Serialize, Deserialize, Default)]
177#[serde(rename_all = "PascalCase")]
178pub struct ResponseHeadersPolicyConfig {
179    #[serde(default, skip_serializing_if = "skip_if_none")]
180    pub comment: Option<String>,
181    pub name: String,
182    #[serde(default, skip_serializing_if = "skip_if_none")]
183    pub cors_config: Option<ResponseHeadersPolicyCorsConfig>,
184    #[serde(default, skip_serializing_if = "skip_if_none")]
185    pub security_headers_config: Option<ResponseHeadersPolicySecurityHeadersConfig>,
186    #[serde(default, skip_serializing_if = "skip_if_none")]
187    pub server_timing_headers_config: Option<ResponseHeadersPolicyServerTimingHeadersConfig>,
188    #[serde(default, skip_serializing_if = "skip_if_none")]
189    pub custom_headers_config: Option<ResponseHeadersPolicyCustomHeadersConfig>,
190    #[serde(default, skip_serializing_if = "skip_if_none")]
191    pub remove_headers_config: Option<ResponseHeadersPolicyRemoveHeadersConfig>,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize, Default)]
195#[serde(rename_all = "PascalCase")]
196pub struct ResponseHeadersPolicyCorsConfig {
197    pub access_control_allow_origins: NameWrapper,
198    pub access_control_allow_headers: NameWrapper,
199    pub access_control_allow_methods: ResponseHeadersPolicyAccessControlAllowMethods,
200    pub access_control_allow_credentials: bool,
201    #[serde(default, skip_serializing_if = "skip_if_none")]
202    pub access_control_expose_headers: Option<NameWrapper>,
203    #[serde(default, skip_serializing_if = "skip_if_none")]
204    pub access_control_max_age_sec: Option<i32>,
205    pub origin_override: bool,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize, Default)]
209#[serde(rename_all = "PascalCase")]
210pub struct ResponseHeadersPolicyAccessControlAllowMethods {
211    pub quantity: i32,
212    pub items: ResponseHeadersPolicyMethodItems,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize, Default)]
216#[serde(rename_all = "PascalCase")]
217pub struct ResponseHeadersPolicyMethodItems {
218    #[serde(default, rename = "Method")]
219    pub method: Vec<String>,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize, Default)]
223#[serde(rename_all = "PascalCase")]
224pub struct ResponseHeadersPolicySecurityHeadersConfig {
225    #[serde(default, skip_serializing_if = "skip_if_none")]
226    pub xss_protection: Option<XssProtection>,
227    #[serde(default, skip_serializing_if = "skip_if_none")]
228    pub frame_options: Option<FrameOptions>,
229    #[serde(default, skip_serializing_if = "skip_if_none")]
230    pub referrer_policy: Option<ReferrerPolicy>,
231    #[serde(default, skip_serializing_if = "skip_if_none")]
232    pub content_security_policy: Option<ContentSecurityPolicy>,
233    #[serde(default, skip_serializing_if = "skip_if_none")]
234    pub content_type_options: Option<ContentTypeOptions>,
235    #[serde(default, skip_serializing_if = "skip_if_none")]
236    pub strict_transport_security: Option<StrictTransportSecurity>,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize, Default)]
240#[serde(rename_all = "PascalCase")]
241pub struct XssProtection {
242    #[serde(rename = "Override")]
243    pub override_: bool,
244    pub protection: bool,
245    #[serde(default, skip_serializing_if = "skip_if_none")]
246    pub mode_block: Option<bool>,
247    #[serde(default, skip_serializing_if = "skip_if_none")]
248    pub report_uri: Option<String>,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize, Default)]
252#[serde(rename_all = "PascalCase")]
253pub struct FrameOptions {
254    #[serde(rename = "Override")]
255    pub override_: bool,
256    pub frame_option: String,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize, Default)]
260#[serde(rename_all = "PascalCase")]
261pub struct ReferrerPolicy {
262    #[serde(rename = "Override")]
263    pub override_: bool,
264    pub referrer_policy: String,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize, Default)]
268#[serde(rename_all = "PascalCase")]
269pub struct ContentSecurityPolicy {
270    #[serde(rename = "Override")]
271    pub override_: bool,
272    pub content_security_policy: String,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize, Default)]
276#[serde(rename_all = "PascalCase")]
277pub struct ContentTypeOptions {
278    #[serde(rename = "Override")]
279    pub override_: bool,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize, Default)]
283#[serde(rename_all = "PascalCase")]
284pub struct StrictTransportSecurity {
285    #[serde(rename = "Override")]
286    pub override_: bool,
287    pub access_control_max_age_sec: i32,
288    #[serde(default, skip_serializing_if = "skip_if_none")]
289    pub include_subdomains: Option<bool>,
290    #[serde(default, skip_serializing_if = "skip_if_none")]
291    pub preload: Option<bool>,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, Default)]
295#[serde(rename_all = "PascalCase")]
296pub struct ResponseHeadersPolicyServerTimingHeadersConfig {
297    pub enabled: bool,
298    #[serde(default, skip_serializing_if = "skip_if_none")]
299    pub sampling_rate: Option<f64>,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize, Default)]
303#[serde(rename_all = "PascalCase")]
304pub struct ResponseHeadersPolicyCustomHeadersConfig {
305    pub quantity: i32,
306    #[serde(default, skip_serializing_if = "skip_if_none")]
307    pub items: Option<ResponseHeadersPolicyCustomHeaderItems>,
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize, Default)]
311#[serde(rename_all = "PascalCase")]
312pub struct ResponseHeadersPolicyCustomHeaderItems {
313    #[serde(default, rename = "ResponseHeadersPolicyCustomHeader")]
314    pub response_headers_policy_custom_header: Vec<ResponseHeadersPolicyCustomHeader>,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize, Default)]
318#[serde(rename_all = "PascalCase")]
319pub struct ResponseHeadersPolicyCustomHeader {
320    pub header: String,
321    pub value: String,
322    #[serde(rename = "Override")]
323    pub override_: bool,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize, Default)]
327#[serde(rename_all = "PascalCase")]
328pub struct ResponseHeadersPolicyRemoveHeadersConfig {
329    pub quantity: i32,
330    #[serde(default, skip_serializing_if = "skip_if_none")]
331    pub items: Option<ResponseHeadersPolicyRemoveHeaderItems>,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize, Default)]
335#[serde(rename_all = "PascalCase")]
336pub struct ResponseHeadersPolicyRemoveHeaderItems {
337    #[serde(default, rename = "ResponseHeadersPolicyRemoveHeader")]
338    pub response_headers_policy_remove_header: Vec<ResponseHeadersPolicyRemoveHeader>,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize, Default)]
342#[serde(rename_all = "PascalCase")]
343pub struct ResponseHeadersPolicyRemoveHeader {
344    pub header: String,
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct StoredResponseHeadersPolicy {
349    pub id: String,
350    pub etag: String,
351    pub last_modified_time: DateTime<Utc>,
352    pub config: ResponseHeadersPolicyConfig,
353    pub policy_type: String,
354}
355
356// ─── Continuous Deployment Policy ─────────────────────────────────────
357
358#[derive(Debug, Clone, Serialize, Deserialize, Default)]
359#[serde(rename_all = "PascalCase")]
360pub struct ContinuousDeploymentPolicyConfig {
361    pub staging_distribution_dns_names: StagingDistributionDnsNames,
362    pub enabled: bool,
363    #[serde(default, skip_serializing_if = "skip_if_none")]
364    pub traffic_config: Option<TrafficConfig>,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize, Default)]
368#[serde(rename_all = "PascalCase")]
369pub struct StagingDistributionDnsNames {
370    pub quantity: i32,
371    #[serde(default, skip_serializing_if = "skip_if_none")]
372    pub items: Option<StagingDistributionDnsNameItems>,
373}
374
375#[derive(Debug, Clone, Serialize, Deserialize, Default)]
376#[serde(rename_all = "PascalCase")]
377pub struct StagingDistributionDnsNameItems {
378    #[serde(default, rename = "DnsName")]
379    pub dns_name: Vec<String>,
380}
381
382#[derive(Debug, Clone, Serialize, Deserialize, Default)]
383#[serde(rename_all = "PascalCase")]
384pub struct TrafficConfig {
385    #[serde(default, skip_serializing_if = "skip_if_none")]
386    pub single_weight_config: Option<ContinuousDeploymentSingleWeightConfig>,
387    #[serde(default, skip_serializing_if = "skip_if_none")]
388    pub single_header_config: Option<ContinuousDeploymentSingleHeaderConfig>,
389    #[serde(rename = "Type")]
390    pub traffic_type: String,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize, Default)]
394#[serde(rename_all = "PascalCase")]
395pub struct ContinuousDeploymentSingleWeightConfig {
396    pub weight: f32,
397    #[serde(default, skip_serializing_if = "skip_if_none")]
398    pub session_stickiness_config: Option<SessionStickinessConfig>,
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize, Default)]
402#[serde(rename_all = "PascalCase")]
403pub struct SessionStickinessConfig {
404    pub idle_ttl: i32,
405    pub maximum_ttl: i32,
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize, Default)]
409#[serde(rename_all = "PascalCase")]
410pub struct ContinuousDeploymentSingleHeaderConfig {
411    pub header: String,
412    pub value: String,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct StoredContinuousDeploymentPolicy {
417    pub id: String,
418    pub etag: String,
419    pub last_modified_time: DateTime<Utc>,
420    pub config: ContinuousDeploymentPolicyConfig,
421}
422
423// ─── Managed-policy seeding ───────────────────────────────────────────
424
425/// Pre-populate AWS-managed policies. Called once per account on first
426/// touch from `CloudFrontService`. Mirrors the IDs / names AWS returns
427/// from `aws cloudfront list-cache-policies --type managed` so
428/// Terraform / CDK lookups by well-known ID resolve.
429pub fn seed_managed(account: &mut AccountState) {
430    if !account.cache_policies.is_empty() {
431        return;
432    }
433    let now = Utc::now();
434    for (id, name, default_ttl, max_ttl, gzip, brotli) in MANAGED_CACHE_POLICIES {
435        account.cache_policies.insert(
436            (*id).to_string(),
437            StoredCachePolicy {
438                id: (*id).to_string(),
439                etag: format!("MANAGED-{id}"),
440                last_modified_time: now,
441                policy_type: "managed".to_string(),
442                config: CachePolicyConfig {
443                    name: (*name).to_string(),
444                    comment: Some(format!("AWS managed cache policy {name}")),
445                    default_ttl: Some(*default_ttl),
446                    max_ttl: Some(*max_ttl),
447                    min_ttl: 1,
448                    parameters_in_cache_key_and_forwarded_to_origin: Some(
449                        ParametersInCacheKeyAndForwardedToOrigin {
450                            enable_accept_encoding_gzip: *gzip,
451                            enable_accept_encoding_brotli: Some(*brotli),
452                            headers_config: CachePolicyHeadersConfig {
453                                header_behavior: "none".to_string(),
454                                headers: None,
455                            },
456                            cookies_config: CachePolicyCookiesConfig {
457                                cookie_behavior: "none".to_string(),
458                                cookies: None,
459                            },
460                            query_strings_config: CachePolicyQueryStringsConfig {
461                                query_string_behavior: "none".to_string(),
462                                query_strings: None,
463                            },
464                        },
465                    ),
466                },
467            },
468        );
469    }
470    for (id, name) in MANAGED_ORIGIN_REQUEST_POLICIES {
471        account.origin_request_policies.insert(
472            (*id).to_string(),
473            StoredOriginRequestPolicy {
474                id: (*id).to_string(),
475                etag: format!("MANAGED-{id}"),
476                last_modified_time: now,
477                policy_type: "managed".to_string(),
478                config: OriginRequestPolicyConfig {
479                    name: (*name).to_string(),
480                    comment: Some(format!("AWS managed origin request policy {name}")),
481                    headers_config: OriginRequestPolicyHeadersConfig {
482                        header_behavior: "allViewer".to_string(),
483                        headers: None,
484                    },
485                    cookies_config: OriginRequestPolicyCookiesConfig {
486                        cookie_behavior: "all".to_string(),
487                        cookies: None,
488                    },
489                    query_strings_config: OriginRequestPolicyQueryStringsConfig {
490                        query_string_behavior: "all".to_string(),
491                        query_strings: None,
492                    },
493                },
494            },
495        );
496    }
497    for (id, name) in MANAGED_RESPONSE_HEADERS_POLICIES {
498        account.response_headers_policies.insert(
499            (*id).to_string(),
500            StoredResponseHeadersPolicy {
501                id: (*id).to_string(),
502                etag: format!("MANAGED-{id}"),
503                last_modified_time: now,
504                policy_type: "managed".to_string(),
505                config: ResponseHeadersPolicyConfig {
506                    name: (*name).to_string(),
507                    comment: Some(format!("AWS managed response headers policy {name}")),
508                    cors_config: None,
509                    security_headers_config: None,
510                    server_timing_headers_config: None,
511                    custom_headers_config: None,
512                    remove_headers_config: None,
513                },
514            },
515        );
516    }
517}
518
519const MANAGED_CACHE_POLICIES: &[(&str, &str, i64, i64, bool, bool)] = &[
520    (
521        "658327ea-f89d-4fab-a63d-7e88639e58f6",
522        "Managed-CachingOptimized",
523        86400,
524        31536000,
525        true,
526        true,
527    ),
528    (
529        "4135ea2d-6df8-44a3-9df3-4b5a84be39ad",
530        "Managed-CachingDisabled",
531        0,
532        0,
533        false,
534        false,
535    ),
536    (
537        "b2884449-e4de-46a7-ac36-70bc7f1ddd6d",
538        "Managed-CachingOptimizedForUncompressedObjects",
539        86400,
540        31536000,
541        false,
542        false,
543    ),
544    (
545        "08627262-05a9-4f76-9ded-b50ca2e3a84f",
546        "Managed-Elemental-MediaPackage",
547        86400,
548        31536000,
549        true,
550        true,
551    ),
552    (
553        "83da9c7e-98b4-4e11-a168-04f0df8e2c65",
554        "Managed-AmplifyDefault",
555        2,
556        600,
557        true,
558        true,
559    ),
560];
561
562const MANAGED_ORIGIN_REQUEST_POLICIES: &[(&str, &str)] = &[
563    (
564        "88a5eaf4-2fd4-4709-b370-b4c650ea3fcf",
565        "Managed-CORS-S3Origin",
566    ),
567    (
568        "59781a5b-3903-41f3-afcb-af62929ccde1",
569        "Managed-CORS-CustomOrigin",
570    ),
571    ("b689b0a8-53d0-40ab-baf2-68738e2966ac", "Managed-AllViewer"),
572    (
573        "33f36d7e-f396-46d9-90e0-52428a34d9dc",
574        "Managed-UserAgentRefererHeaders",
575    ),
576    (
577        "775133bc-15f2-49f9-abea-afb2e0bf67d2",
578        "Managed-AllViewerExceptHostHeader",
579    ),
580    (
581        "acba4595-bd28-49b8-b9fe-13317c0390fa",
582        "Managed-AllViewerAndCloudFrontHeaders-2022-06",
583    ),
584];
585
586const MANAGED_RESPONSE_HEADERS_POLICIES: &[(&str, &str)] = &[
587    (
588        "5cc3b908-e619-4b99-88e5-2cf7f45965bd",
589        "Managed-CORS-with-preflight",
590    ),
591    (
592        "e61eb60c-9c35-4d20-a928-2b84e02af89c",
593        "Managed-CORS-and-SecurityHeadersPolicy",
594    ),
595    (
596        "67f7725c-6f97-4210-82d7-5512b31e9d03",
597        "Managed-SecurityHeadersPolicy",
598    ),
599    (
600        "eaab4381-ed33-4a86-88ca-d9558dc6cd63",
601        "Managed-CORS-with-preflight-and-SecurityHeadersPolicy",
602    ),
603    ("60669652-455b-4ae9-85a4-c4c02393f86c", "Managed-SimpleCORS"),
604];
605
606// ─── Handlers (impl on CloudFrontService is in service.rs via these
607// free functions, which take `&self_state` rather than `&self` so this
608// module doesn't have to know about the service type) ────────────────
609
610use crate::state::SharedCloudFrontState;
611
612pub(crate) fn touch_account(state: &SharedCloudFrontState, account_id: &str) {
613    let mut s = state.write();
614    let needs_seed = s
615        .accounts
616        .get(account_id)
617        .is_none_or(|a| a.cache_policies.is_empty());
618    if needs_seed {
619        let account = s.entry(account_id);
620        seed_managed(account);
621    }
622}
623
624#[derive(Clone)]
625pub(crate) struct PolicyView {
626    pub id: String,
627    pub last_modified_time: DateTime<Utc>,
628    pub config_xml: String,
629}
630
631impl From<StoredCachePolicy> for PolicyView {
632    fn from(p: StoredCachePolicy) -> Self {
633        let config_xml =
634            quick_xml::se::to_string_with_root("CachePolicyConfig", &p.config).unwrap_or_default();
635        Self {
636            id: p.id,
637            last_modified_time: p.last_modified_time,
638            config_xml,
639        }
640    }
641}
642
643impl From<StoredOriginRequestPolicy> for PolicyView {
644    fn from(p: StoredOriginRequestPolicy) -> Self {
645        let config_xml = quick_xml::se::to_string_with_root("OriginRequestPolicyConfig", &p.config)
646            .unwrap_or_default();
647        Self {
648            id: p.id,
649            last_modified_time: p.last_modified_time,
650            config_xml,
651        }
652    }
653}
654
655impl From<StoredResponseHeadersPolicy> for PolicyView {
656    fn from(p: StoredResponseHeadersPolicy) -> Self {
657        let config_xml =
658            quick_xml::se::to_string_with_root("ResponseHeadersPolicyConfig", &p.config)
659                .unwrap_or_default();
660        Self {
661            id: p.id,
662            last_modified_time: p.last_modified_time,
663            config_xml,
664        }
665    }
666}
667
668impl From<StoredContinuousDeploymentPolicy> for PolicyView {
669    fn from(p: StoredContinuousDeploymentPolicy) -> Self {
670        let config_xml =
671            quick_xml::se::to_string_with_root("ContinuousDeploymentPolicyConfig", &p.config)
672                .unwrap_or_default();
673        Self {
674            id: p.id,
675            last_modified_time: p.last_modified_time,
676            config_xml,
677        }
678    }
679}
680
681pub(crate) fn render_simple_policy(p: PolicyView, root: &str) -> String {
682    let mut out = String::with_capacity(512);
683    out.push_str(XML_DECL);
684    out.push_str(&format!("<{root} xmlns=\"{NS}\">"));
685    out.push_str(&format!("<Id>{}</Id>", esc(&p.id)));
686    out.push_str(&format!(
687        "<LastModifiedTime>{}</LastModifiedTime>",
688        rfc3339(&p.last_modified_time)
689    ));
690    out.push_str(&p.config_xml);
691    out.push_str(&format!("</{root}>"));
692    out
693}
694
695pub(crate) fn render_oac(oac: &StoredOriginAccessControl) -> String {
696    let mut out = String::with_capacity(384);
697    out.push_str(XML_DECL);
698    out.push_str(&format!("<OriginAccessControl xmlns=\"{NS}\">"));
699    out.push_str(&format!("<Id>{}</Id>", esc(&oac.id)));
700    out.push_str(
701        &quick_xml::se::to_string_with_root("OriginAccessControlConfig", &oac.config)
702            .unwrap_or_default(),
703    );
704    out.push_str("</OriginAccessControl>");
705    out
706}
707
708pub(crate) fn xml_with_etag(
709    status: StatusCode,
710    body: String,
711    etag: &str,
712    location_id: Option<&str>,
713) -> AwsResponse {
714    let mut headers = HeaderMap::new();
715    if let Ok(v) = HeaderValue::from_str(etag) {
716        headers.insert(ETAG, v);
717    }
718    if let Some(id) = location_id {
719        if let Ok(v) = HeaderValue::from_str(id) {
720            headers.insert(LOCATION, v);
721        }
722    }
723    xml_response(status, body, headers)
724}
725
726pub(crate) fn empty(status: StatusCode) -> AwsResponse {
727    AwsResponse {
728        status,
729        content_type: "text/xml".to_string(),
730        body: ResponseBody::Bytes(bytes::Bytes::new()),
731        headers: HeaderMap::new(),
732    }
733}
734
735pub(crate) fn route_id(route: &Route, what: &str) -> Result<String, AwsServiceError> {
736    route
737        .id
738        .clone()
739        .ok_or_else(|| invalid_argument(format!("missing {what} id")))
740}
741
742pub(crate) fn require_if_match(req: &AwsRequest) -> Result<String, AwsServiceError> {
743    req.headers
744        .get(IF_MATCH)
745        .and_then(|v| v.to_str().ok())
746        .map(|s| s.to_string())
747        .ok_or_else(|| {
748            aws_error(
749                StatusCode::BAD_REQUEST,
750                "InvalidIfMatchVersion",
751                "Missing If-Match header",
752            )
753        })
754}
755
756/// Map a logical resource "kind" to the Smithy-declared error shape name used
757/// on the CloudFront wire. Most resources use the `NoSuch{Kind}` pattern, but
758/// several do not:
759///
760/// - `Function` -> `NoSuchFunctionExists` (the actual Smithy shape name)
761/// - `KeyGroup` -> `NoSuchResource` (per Get/Update/DeleteKeyGroup errors)
762/// - `FieldLevelEncryption` -> `NoSuchFieldLevelEncryptionConfig`
763/// - Newer "tenant-era" resources (DistributionTenant, ConnectionGroup,
764///   ConnectionFunction, VpcOrigin, AnycastIpList, KeyValueStore, TrustStore,
765///   ResourcePolicy) all share the unified `EntityNotFound` error shape.
766fn not_found_code(kind: &str) -> &'static str {
767    match kind {
768        "Function" => "NoSuchFunctionExists",
769        "KeyGroup" => "NoSuchResource",
770        "FieldLevelEncryption" => "NoSuchFieldLevelEncryptionConfig",
771        "AnycastIpList" | "ConnectionFunction" | "ConnectionGroup" | "DistributionTenant"
772        | "KeyValueStore" | "ResourcePolicy" | "TrustStore" | "VpcOrigin" => "EntityNotFound",
773        _ => "",
774    }
775}
776
777pub(crate) fn not_found(kind: &str, id: &str) -> AwsServiceError {
778    let code = not_found_code(kind);
779    let code = if code.is_empty() {
780        // Default to `NoSuch{Kind}` for the long tail of resources whose
781        // Smithy shape follows that convention (Distribution, CachePolicy,
782        // PublicKey, OriginAccessControl, OriginRequestPolicy,
783        // ResponseHeadersPolicy, ContinuousDeploymentPolicy,
784        // StreamingDistribution, FieldLevelEncryptionProfile,
785        // CloudFrontOriginAccessIdentity, RealtimeLogConfig, Invalidation).
786        format!("NoSuch{kind}")
787    } else {
788        code.to_string()
789    };
790    aws_error(
791        StatusCode::NOT_FOUND,
792        code,
793        format!("The specified {kind} does not exist: {id}"),
794    )
795}
796
797pub(crate) fn precondition_failed() -> AwsServiceError {
798    aws_error(
799        StatusCode::PRECONDITION_FAILED,
800        "PreconditionFailed",
801        "If-Match header does not match the current ETag",
802    )
803}
804
805pub(crate) fn rfc3339(t: &DateTime<Utc>) -> String {
806    t.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
807}
808
809#[cfg(test)]
810mod tests {
811    use super::*;
812    use std::collections::HashSet;
813
814    #[test]
815    fn managed_cache_policy_ids_are_unique() {
816        let mut seen = HashSet::new();
817        for entry in MANAGED_CACHE_POLICIES {
818            let id = entry.0;
819            assert!(seen.insert(id), "duplicate cache policy id: {id}");
820        }
821    }
822
823    #[test]
824    fn managed_origin_request_policy_ids_are_unique() {
825        let mut seen = HashSet::new();
826        for (id, _) in MANAGED_ORIGIN_REQUEST_POLICIES {
827            assert!(seen.insert(*id), "duplicate origin request policy id: {id}");
828        }
829    }
830
831    #[test]
832    fn managed_response_headers_policy_ids_are_unique() {
833        let mut seen = HashSet::new();
834        for (id, _) in MANAGED_RESPONSE_HEADERS_POLICIES {
835            assert!(
836                seen.insert(*id),
837                "duplicate response headers policy id: {id}"
838            );
839        }
840    }
841
842    #[test]
843    fn seeded_response_headers_count_matches_unique_ids() {
844        let mut acc = AccountState::default();
845        seed_managed(&mut acc);
846        assert_eq!(
847            acc.response_headers_policies.len(),
848            MANAGED_RESPONSE_HEADERS_POLICIES.len(),
849            "duplicate IDs would reduce the seeded count"
850        );
851    }
852}